From 2716f302c763ba62488d730be5945866097fe1e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 12:24:15 +0200 Subject: [PATCH] chore: align hardening tests and formatting --- .../restore/__tests__/owner-mismatch.test.ts | 1 + .../api/admin/backups/[id]/restore/route.ts | 1059 +++++++++-------- src/app/api/devices/__tests__/route.test.ts | 4 +- src/app/api/devices/route.ts | 6 +- .../__tests__/aggregated-hr-wire.test.ts | 4 +- .../insert-returning-arrivals.test.ts | 4 +- .../batch/__tests__/pr-detection-hook.test.ts | 4 +- src/app/api/measurements/batch/route.ts | 187 ++- .../mood-entries/[id]/__tests__/route.test.ts | 35 +- .../__tests__/full-backup-payload.test.ts | 16 +- src/lib/export/records-backup.ts | 7 +- .../__tests__/withings-ecg-worker.test.ts | 18 +- .../__tests__/backup-handlers.test.ts | 6 +- src/lib/jobs/reminder/backup-handlers.ts | 8 +- src/lib/jobs/reminder/withings-sync.ts | 1 - src/lib/jobs/withings-ecg-queue.ts | 4 +- .../reconcile-external-measurement.test.ts | 4 +- .../reconcile-external-measurement.ts | 10 +- src/lib/mood/__tests__/tag-links.test.ts | 1 - src/lib/oura/__tests__/sync.test.ts | 27 +- src/lib/oura/sync.ts | 49 +- src/lib/polar/__tests__/sync.test.ts | 27 +- src/lib/polar/sync.ts | 49 +- src/lib/strava/sync.ts | 5 +- src/lib/validations/backup.ts | 4 +- src/lib/whoop/__tests__/sync.test.ts | 11 +- src/lib/withings/__tests__/sync-sleep.test.ts | 14 +- src/lib/withings/sync-ecg.ts | 1 - src/lib/withings/sync-sleep.ts | 138 ++- .../admin-backups-canonical-roundtrip.test.ts | 138 ++- .../integration/admin-backups-upload.test.ts | 61 +- tests/integration/device-register.test.ts | 10 +- .../measurement-identity-reconcile.test.ts | 6 +- .../partial-unique-live-rows.test.ts | 4 +- ...ovider-token-refresh-serialization.test.ts | 120 +- 35 files changed, 1044 insertions(+), 999 deletions(-) diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts index 48ee3186d..e26b605ab 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts @@ -39,6 +39,7 @@ vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "{}") })); vi.mock("@/lib/crypto/note-cipher", () => ({ encryptNote: vi.fn() })); vi.mock("@/lib/validations/backup", () => ({ parseBackupPayload: vi.fn(), + isCompatibleSchemaVersion: vi.fn(() => true), summarizeBackup: vi.fn(() => ({})), })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 86876f8e5..d92116869 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -73,7 +73,6 @@ function decodeEncryptedBytes(encoded: string): Uint8Array { return bytes; } - const handler = apiHandler( async ( request: NextRequest, @@ -231,7 +230,6 @@ const handler = apiHandler( }, }); - // Portable exports intentionally omit document ciphertext. They remain // valid upload/download artifacts, but cannot be used to manufacture an // InboundDocument row. A DR restore must fail before any delete rather @@ -270,553 +268,560 @@ const handler = apiHandler( let cleared: RestoreResponse["cleared"]; try { - cleared = await prisma.$transaction(async (tx) => { - // Delete every serialized owner-scoped partition before rebuilding it - // in the same transaction. Child rows either go first for counts or - // cascade from their serialized parent. - const intake = await tx.medicationIntakeEvent.deleteMany({ - where: { userId: ownerId }, - }); - const meds = await tx.medication.deleteMany({ - where: { userId: ownerId }, - }); - const measurements = await tx.measurement.deleteMany({ - where: { userId: ownerId }, - }); - const moods = await tx.moodEntry.deleteMany({ - where: { userId: ownerId }, - }); - // v1.4.39 W-MOOD — wipe the persisted mood rollup partition - // for this owner so the next analytics read doesn't surface - // pre-restore daily means. The fold below mints fresh rows - // from the restored mood entries. - await tx.moodEntryRollup.deleteMany({ where: { userId: ownerId } }); - // v1.4.39.1 — wipe the persisted measurement rollup partition - // for the same reason. Pre-fix the partition kept the previous - // owner's daily means even after the underlying measurements - // were replaced — the chart's `source=rollup` fast-path could - // surface a stale 30-day mean built from rows that no longer - // existed. The fold below mints fresh rows from the restored - // measurement set. - await tx.measurementRollup.deleteMany({ where: { userId: ownerId } }); - const channels = await tx.notificationChannel.deleteMany({ - where: { userId: ownerId }, - }); - const subs = await tx.pushSubscription.deleteMany({ - where: { userId: ownerId }, - }); - const tgDel = await tx.telegramScheduledDeletion.deleteMany({ - where: { userId: ownerId }, - }); - const documents = await tx.inboundDocument.deleteMany({ - where: { userId: ownerId }, - }); - const workouts = await tx.workout.deleteMany({ - where: { userId: ownerId }, - }); - const familyHistory = await tx.familyHistoryEntry.deleteMany({ - where: { userId: ownerId }, - }); - const allergies = await tx.allergy.deleteMany({ - where: { userId: ownerId }, - }); - const illnessEpisodes = await tx.illnessEpisode.deleteMany({ - where: { userId: ownerId }, - }); - const labResults = await tx.labResult.deleteMany({ - where: { userId: ownerId }, - }); - const biomarkers = await tx.biomarker.deleteMany({ - where: { userId: ownerId }, - }); + cleared = await prisma.$transaction( + async (tx) => { + // Delete every serialized owner-scoped partition before rebuilding it + // in the same transaction. Child rows either go first for counts or + // cascade from their serialized parent. + const intake = await tx.medicationIntakeEvent.deleteMany({ + where: { userId: ownerId }, + }); + const meds = await tx.medication.deleteMany({ + where: { userId: ownerId }, + }); + const measurements = await tx.measurement.deleteMany({ + where: { userId: ownerId }, + }); + const moods = await tx.moodEntry.deleteMany({ + where: { userId: ownerId }, + }); + // v1.4.39 W-MOOD — wipe the persisted mood rollup partition + // for this owner so the next analytics read doesn't surface + // pre-restore daily means. The fold below mints fresh rows + // from the restored mood entries. + await tx.moodEntryRollup.deleteMany({ where: { userId: ownerId } }); + // v1.4.39.1 — wipe the persisted measurement rollup partition + // for the same reason. Pre-fix the partition kept the previous + // owner's daily means even after the underlying measurements + // were replaced — the chart's `source=rollup` fast-path could + // surface a stale 30-day mean built from rows that no longer + // existed. The fold below mints fresh rows from the restored + // measurement set. + await tx.measurementRollup.deleteMany({ where: { userId: ownerId } }); + const channels = await tx.notificationChannel.deleteMany({ + where: { userId: ownerId }, + }); + const subs = await tx.pushSubscription.deleteMany({ + where: { userId: ownerId }, + }); + const tgDel = await tx.telegramScheduledDeletion.deleteMany({ + where: { userId: ownerId }, + }); + const documents = await tx.inboundDocument.deleteMany({ + where: { userId: ownerId }, + }); + const workouts = await tx.workout.deleteMany({ + where: { userId: ownerId }, + }); + const familyHistory = await tx.familyHistoryEntry.deleteMany({ + where: { userId: ownerId }, + }); + const allergies = await tx.allergy.deleteMany({ + where: { userId: ownerId }, + }); + const illnessEpisodes = await tx.illnessEpisode.deleteMany({ + where: { userId: ownerId }, + }); + const labResults = await tx.labResult.deleteMany({ + where: { userId: ownerId }, + }); + const biomarkers = await tx.biomarker.deleteMany({ + where: { userId: ownerId }, + }); - // Stable ids are authoritative for canonical snapshots. Legacy payloads - // without ids fall back to the historic natural measurement key. - for (const measurement of payload.measurements) { - const measuredAt = new Date(measurement.measuredAt); - const source = measurement.source ?? "MANUAL"; - const restoredData = { - type: measurement.type, - value: measurement.value, - unit: measurement.unit, - source, - measuredAt, - notes: null, - notesEncrypted: encryptNote(measurement.notes ?? null), - deletedAt: measurement.deletedAt - ? new Date(measurement.deletedAt) - : null, - }; - - if (measurement.id) { - await tx.measurement.upsert({ - where: { id: measurement.id, userId: ownerId }, - create: { - id: measurement.id, - userId: ownerId, - ...restoredData, - }, - update: restoredData, - }); - continue; - } - - const existing = await tx.measurement.findFirst({ - where: { - userId: ownerId, + // Stable ids are authoritative for canonical snapshots. Legacy payloads + // without ids fall back to the historic natural measurement key. + for (const measurement of payload.measurements) { + const measuredAt = new Date(measurement.measuredAt); + const source = measurement.source ?? "MANUAL"; + const restoredData = { type: measurement.type, + value: measurement.value, + unit: measurement.unit, source, measuredAt, - }, - select: { id: true }, - }); - if (existing) { - await tx.measurement.update({ - where: { id: existing.id, userId: ownerId }, - data: restoredData, - }); - } else { - await tx.measurement.create({ - data: { userId: ownerId, ...restoredData }, - }); - } - } - - // Medications + schedules — restore one at a time so we can - // wire schedules to their parent's freshly-generated id, AND - // build a name → id map for the intake events that follow. - const medByName = new Map(); - for (const m of payload.medications) { - const created = await tx.medication.create({ - data: { - userId: ownerId, - name: m.name, - dose: m.dose, - active: m.active ?? true, - schedules: { - create: m.schedules.map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label ?? null, - dose: s.dose ?? null, - })), - }, - }, - }); - // First write wins on duplicate names — keeps the round-trip - // deterministic against the restore-from-our-own-download case. - if (!medByName.has(m.name)) medByName.set(m.name, created.id); - } - - // Intake events — only restore the ones whose `medication` name - // resolved against a row we just created. Orphans (medications - // that were deleted before the snapshot but still referenced by - // intake events) are dropped silently — the alternative would - // be an FK-violation crash, which is worse than a slightly - // smaller history. - if (payload.intakeEvents.length > 0) { - const rows = payload.intakeEvents - .map((e) => { - const medId = medByName.get(e.medication); - if (!medId) return null; - return { - userId: ownerId, - medicationId: medId, - scheduledFor: new Date(e.scheduledFor), - takenAt: e.takenAt ? new Date(e.takenAt) : null, - skipped: e.skipped ?? false, - source: (e.source ?? "WEB") as never, - }; - }) - .filter((r): r is NonNullable => r !== null); - if (rows.length > 0) { - await tx.medicationIntakeEvent.createMany({ data: rows }); - } - } - - if (payload.moodEntries.length > 0) { - const factorKeys = [ - ...new Set( - payload.moodEntries.flatMap((entry) => - entry.factors.map((factor) => factor.key), - ), - ), - ]; - const factorRows = - factorKeys.length === 0 - ? [] - : await tx.moodTag.findMany({ - where: { - key: { in: factorKeys }, - kind: "RATED", - userId: null, - }, - select: { id: true, key: true }, - }); - const factorByKey = new Map( - factorRows.map((factor) => [factor.key, factor.id]), - ); - if (factorByKey.size !== factorKeys.length) { - const missing = factorKeys.filter((key) => !factorByKey.has(key)); - throw new Error(`Unknown mood factor keys: ${missing.join(", ")}`); - } - - for (const entry of payload.moodEntries) { - const moodLoggedAt = new Date(entry.loggedAt); - const restoredData = { - date: entry.date, - mood: entry.mood, - score: entry.score, - tags: entry.tags ?? null, - source: entry.source ?? "MOODLOG", - externalId: entry.externalId ?? null, - moodLoggedAt, - deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + notes: null, + notesEncrypted: encryptNote(measurement.notes ?? null), + deletedAt: measurement.deletedAt + ? new Date(measurement.deletedAt) + : null, }; - const createData = { userId: ownerId, ...restoredData }; - const restored = entry.id - ? await tx.moodEntry.upsert({ - where: { id: entry.id, userId: ownerId }, - create: { id: entry.id, ...createData }, - update: restoredData, - }) - : entry.externalId - ? await tx.moodEntry.upsert({ - where: { - userId_source_externalId: { - userId: ownerId, - source: restoredData.source, - externalId: entry.externalId, - }, - }, - create: createData, - update: restoredData, - }) - : await tx.moodEntry.upsert({ - where: { - userId_date_moodLoggedAt: { - userId: ownerId, - date: entry.date, - moodLoggedAt, - }, - }, - create: createData, - update: restoredData, - }); - await tx.moodEntryTagLink.deleteMany({ - where: { moodEntryId: restored.id }, + if (measurement.id) { + await tx.measurement.upsert({ + where: { id: measurement.id, userId: ownerId }, + create: { + id: measurement.id, + userId: ownerId, + ...restoredData, + }, + update: restoredData, + }); + continue; + } + + const existing = await tx.measurement.findFirst({ + where: { + userId: ownerId, + type: measurement.type, + source, + measuredAt, + }, + select: { id: true }, }); - if (entry.factors.length > 0) { - await tx.moodEntryTagLink.createMany({ - data: entry.factors.map((factor) => ({ - moodEntryId: restored.id, - moodTagId: factorByKey.get(factor.key)!, - rating: factor.rating, - })), + if (existing) { + await tx.measurement.update({ + where: { id: existing.id, userId: ownerId }, + data: restoredData, + }); + } else { + await tx.measurement.create({ + data: { userId: ownerId, ...restoredData }, }); } } - } - // v1.15.0 — cycle tables (profile + observed spans + day-logs + - // symptom links). Delete-then-recreate, mirroring the contract - // above. `notesEncrypted` is restored as ciphertext verbatim. - const cycleCleared = await restoreCycleData(tx, ownerId, payload); - - const biomarkerByName = new Map(); - for (const biomarker of payload.biomarkers) { - const created = await tx.biomarker.create({ - data: { - ...(biomarker.id ? { id: biomarker.id } : {}), - userId: ownerId, - name: biomarker.name, - unit: biomarker.unit, - lowerBound: biomarker.lowerBound ?? null, - upperBound: biomarker.upperBound ?? null, - panel: biomarker.panel ?? null, - hidden: biomarker.hidden ?? false, - contextEncrypted: - biomarker.context == null - ? null - : encryptContextToBytes(biomarker.context), - ...(biomarker.createdAt - ? { createdAt: new Date(biomarker.createdAt) } - : {}), - ...(biomarker.updatedAt - ? { updatedAt: new Date(biomarker.updatedAt) } - : {}), - }, - }); - biomarkerByName.set(biomarker.name, created.id); - } - - for (const lab of payload.labResults) { - const biomarkerId = lab.biomarkerName - ? biomarkerByName.get(lab.biomarkerName) - : undefined; - if (lab.biomarkerName && !biomarkerId) { - throw new Error( - `Unknown biomarker reference: ${lab.biomarkerName}`, - ); - } - await tx.labResult.create({ - data: { - ...(lab.id ? { id: lab.id } : {}), - userId: ownerId, - biomarkerId: biomarkerId ?? null, - panel: lab.panel ?? null, - analyte: lab.analyte, - value: lab.value ?? null, - valueText: lab.valueText ?? null, - unit: lab.unit, - referenceLow: lab.referenceLow ?? null, - referenceHigh: lab.referenceHigh ?? null, - takenAt: new Date(lab.takenAt), - source: lab.source, - noteEncrypted: - lab.note == null ? null : encryptNoteToBytes(lab.note), - ...(lab.createdAt - ? { createdAt: new Date(lab.createdAt) } - : {}), - ...(lab.updatedAt - ? { updatedAt: new Date(lab.updatedAt) } - : {}), - }, - }); - } - - const episodeIds = new Set( - payload.illnessEpisodes.map((episode) => episode.id), - ); - for (const episode of payload.illnessEpisodes) { - if ( - episode.parentConditionId && - !episodeIds.has(episode.parentConditionId) - ) { - throw new Error( - `Unknown illness parent: ${episode.parentConditionId}`, - ); - } - await tx.illnessEpisode.create({ - data: { - id: episode.id, - userId: ownerId, - label: episode.label, - type: episode.type as never, - lifecycle: episode.lifecycle as never, - onsetAt: new Date(episode.onsetAt), - resolvedAt: episode.resolvedAt - ? new Date(episode.resolvedAt) - : null, - parentConditionId: null, - noteEncrypted: - episode.note == null ? null : encryptToBytes(episode.note), - ...(episode.createdAt - ? { createdAt: new Date(episode.createdAt) } - : {}), - ...(episode.updatedAt - ? { updatedAt: new Date(episode.updatedAt) } - : {}), - }, - }); - } - for (const episode of payload.illnessEpisodes) { - if (episode.parentConditionId) { - await tx.illnessEpisode.update({ - where: { id: episode.id }, - data: { parentConditionId: episode.parentConditionId }, - }); - } - } - - const symptomKeys = [ - ...new Set( - payload.illnessEpisodes.flatMap((episode) => - episode.dayLogs.flatMap((dayLog) => - dayLog.symptoms.map((symptom) => symptom.key), - ), - ), - ), - ]; - const symptomRows = - symptomKeys.length === 0 - ? [] - : await tx.illnessSymptom.findMany({ - where: { key: { in: symptomKeys } }, - select: { id: true, key: true }, - }); - const symptomByKey = new Map( - symptomRows.map((symptom) => [symptom.key, symptom.id]), - ); - if (symptomByKey.size !== symptomKeys.length) { - const missing = symptomKeys.filter((key) => !symptomByKey.has(key)); - throw new Error(`Unknown illness symptom keys: ${missing.join(", ")}`); - } - for (const episode of payload.illnessEpisodes) { - for (const dayLog of episode.dayLogs) { - await tx.illnessDayLog.create({ + // Medications + schedules — restore one at a time so we can + // wire schedules to their parent's freshly-generated id, AND + // build a name → id map for the intake events that follow. + const medByName = new Map(); + for (const m of payload.medications) { + const created = await tx.medication.create({ data: { - ...(dayLog.id ? { id: dayLog.id } : {}), userId: ownerId, - episodeId: episode.id, - date: dayLog.date, - functionalImpact: dayLog.functionalImpact ?? null, - feverC: dayLog.feverC ?? null, - noteEncrypted: - dayLog.note == null ? null : encryptToBytes(dayLog.note), - ...(dayLog.updatedAt - ? { updatedAt: new Date(dayLog.updatedAt) } - : {}), - symptomLinks: { - create: dayLog.symptoms.map((symptom) => ({ - symptomId: symptomByKey.get(symptom.key)!, - severity: symptom.severity ?? null, + name: m.name, + dose: m.dose, + active: m.active ?? true, + schedules: { + create: m.schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label ?? null, + dose: s.dose ?? null, })), }, }, }); + // First write wins on duplicate names — keeps the round-trip + // deterministic against the restore-from-our-own-download case. + if (!medByName.has(m.name)) medByName.set(m.name, created.id); } - } - for (const allergy of payload.allergies) { - await tx.allergy.create({ - data: { - id: allergy.id, - userId: ownerId, - substance: allergy.substance, - category: allergy.category as never, - type: allergy.type as never, - severity: (allergy.severity ?? null) as never, - status: allergy.status as never, - onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null, - reactionEncrypted: - allergy.reaction == null - ? null - : encryptToBytes(allergy.reaction), - notesEncrypted: - allergy.note == null ? null : encryptToBytes(allergy.note), - ...(allergy.createdAt - ? { createdAt: new Date(allergy.createdAt) } - : {}), - ...(allergy.updatedAt - ? { updatedAt: new Date(allergy.updatedAt) } - : {}), - }, - }); - } + // Intake events — only restore the ones whose `medication` name + // resolved against a row we just created. Orphans (medications + // that were deleted before the snapshot but still referenced by + // intake events) are dropped silently — the alternative would + // be an FK-violation crash, which is worse than a slightly + // smaller history. + if (payload.intakeEvents.length > 0) { + const rows = payload.intakeEvents + .map((e) => { + const medId = medByName.get(e.medication); + if (!medId) return null; + return { + userId: ownerId, + medicationId: medId, + scheduledFor: new Date(e.scheduledFor), + takenAt: e.takenAt ? new Date(e.takenAt) : null, + skipped: e.skipped ?? false, + source: (e.source ?? "WEB") as never, + }; + }) + .filter((r): r is NonNullable => r !== null); + if (rows.length > 0) { + await tx.medicationIntakeEvent.createMany({ data: rows }); + } + } - for (const familyEntry of payload.familyHistory) { - await tx.familyHistoryEntry.create({ - data: { - id: familyEntry.id, - userId: ownerId, - relationship: familyEntry.relationship as never, - condition: familyEntry.condition, - ageAtOnset: familyEntry.ageAtOnset ?? null, - notesEncrypted: - familyEntry.note == null - ? null - : encryptToBytes(familyEntry.note), - ...(familyEntry.createdAt - ? { createdAt: new Date(familyEntry.createdAt) } - : {}), - ...(familyEntry.updatedAt - ? { updatedAt: new Date(familyEntry.updatedAt) } - : {}), - }, - }); - } - - if (payload.workouts.length > 0) { - await tx.workout.createMany({ - data: payload.workouts.map((workout) => ({ - ...(workout.id ? { id: workout.id } : {}), - userId: ownerId, - sportType: workout.sportType, - startedAt: new Date(workout.startedAt), - endedAt: new Date(workout.endedAt), - durationSec: workout.durationSec, - totalEnergyKcal: workout.totalEnergyKcal ?? null, - totalDistanceM: workout.totalDistanceM ?? null, - avgHeartRate: workout.avgHeartRate ?? null, - maxHeartRate: workout.maxHeartRate ?? null, - minHeartRate: workout.minHeartRate ?? null, - stepCount: workout.stepCount ?? null, - elevationM: workout.elevationM ?? null, - pauseDurationSec: workout.pauseDurationSec ?? null, - source: workout.source as never, - externalId: workout.externalId ?? null, - ...(workout.createdAt - ? { createdAt: new Date(workout.createdAt) } - : {}), - ...(workout.updatedAt - ? { updatedAt: new Date(workout.updatedAt) } - : {}), - })), - }); - } - - if (payload.documents.length > 0) { - await tx.inboundDocument.createMany({ - data: payload.documents.map((document) => ({ - id: document.id, - userId: ownerId, - kind: document.kind as never, - title: document.title ?? null, - filename: document.filename ?? null, - mimeType: document.mimeType, - byteSize: document.byteSize, - contentEncrypted: decodeEncryptedBytes( - document.contentEncrypted!, + if (payload.moodEntries.length > 0) { + const factorKeys = [ + ...new Set( + payload.moodEntries.flatMap((entry) => + entry.factors.map((factor) => factor.key), + ), ), - contentSha256: document.contentSha256 ?? null, - contentCodec: document.contentCodec!, - status: document.status as never, - providerType: document.providerType ?? null, - reportDate: document.reportDate - ? new Date(document.reportDate) - : null, - documentDate: document.documentDate - ? new Date(document.documentDate) - : null, - errorReason: document.errorReason ?? null, - summaryEncrypted: document.summaryEncrypted - ? decodeEncryptedBytes(document.summaryEncrypted) - : null, - summaryGeneratedAt: document.summaryGeneratedAt - ? new Date(document.summaryGeneratedAt) - : null, - summaryState: document.summaryState as never, - createdAt: new Date(document.createdAt!), - updatedAt: new Date(document.updatedAt!), - })), - }); - } + ]; + const factorRows = + factorKeys.length === 0 + ? [] + : await tx.moodTag.findMany({ + where: { + key: { in: factorKeys }, + kind: "RATED", + userId: null, + }, + select: { id: true, key: true }, + }); + const factorByKey = new Map( + factorRows.map((factor) => [factor.key, factor.id]), + ); + if (factorByKey.size !== factorKeys.length) { + const missing = factorKeys.filter((key) => !factorByKey.has(key)); + throw new Error( + `Unknown mood factor keys: ${missing.join(", ")}`, + ); + } - return { - measurements: measurements.count, - medications: meds.count, - intakeEvents: intake.count, - moodEntries: moods.count, - notificationChannels: channels.count, - pushSubscriptions: subs.count, - telegramScheduledDeletions: tgDel.count, - cycles: cycleCleared.cycles, - cycleDayLogs: cycleCleared.cycleDayLogs, - cycleProfile: cycleCleared.cycleProfile, - labResults: labResults.count, - biomarkers: biomarkers.count, - illnessEpisodes: illnessEpisodes.count, - allergies: allergies.count, - familyHistory: familyHistory.count, - workouts: workouts.count, - documents: documents.count, - }; - }, { - maxWait: 10_000, - timeout: 120_000, - }); + for (const entry of payload.moodEntries) { + const moodLoggedAt = new Date(entry.loggedAt); + const restoredData = { + date: entry.date, + mood: entry.mood, + score: entry.score, + tags: entry.tags ?? null, + source: entry.source ?? "MOODLOG", + externalId: entry.externalId ?? null, + moodLoggedAt, + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + }; + const createData = { userId: ownerId, ...restoredData }; + const restored = entry.id + ? await tx.moodEntry.upsert({ + where: { id: entry.id, userId: ownerId }, + create: { id: entry.id, ...createData }, + update: restoredData, + }) + : entry.externalId + ? await tx.moodEntry.upsert({ + where: { + userId_source_externalId: { + userId: ownerId, + source: restoredData.source, + externalId: entry.externalId, + }, + }, + create: createData, + update: restoredData, + }) + : await tx.moodEntry.upsert({ + where: { + userId_date_moodLoggedAt: { + userId: ownerId, + date: entry.date, + moodLoggedAt, + }, + }, + create: createData, + update: restoredData, + }); + + await tx.moodEntryTagLink.deleteMany({ + where: { moodEntryId: restored.id }, + }); + if (entry.factors.length > 0) { + await tx.moodEntryTagLink.createMany({ + data: entry.factors.map((factor) => ({ + moodEntryId: restored.id, + moodTagId: factorByKey.get(factor.key)!, + rating: factor.rating, + })), + }); + } + } + } + + // v1.15.0 — cycle tables (profile + observed spans + day-logs + + // symptom links). Delete-then-recreate, mirroring the contract + // above. `notesEncrypted` is restored as ciphertext verbatim. + const cycleCleared = await restoreCycleData(tx, ownerId, payload); + + const biomarkerByName = new Map(); + for (const biomarker of payload.biomarkers) { + const created = await tx.biomarker.create({ + data: { + ...(biomarker.id ? { id: biomarker.id } : {}), + userId: ownerId, + name: biomarker.name, + unit: biomarker.unit, + lowerBound: biomarker.lowerBound ?? null, + upperBound: biomarker.upperBound ?? null, + panel: biomarker.panel ?? null, + hidden: biomarker.hidden ?? false, + contextEncrypted: + biomarker.context == null + ? null + : encryptContextToBytes(biomarker.context), + ...(biomarker.createdAt + ? { createdAt: new Date(biomarker.createdAt) } + : {}), + ...(biomarker.updatedAt + ? { updatedAt: new Date(biomarker.updatedAt) } + : {}), + }, + }); + biomarkerByName.set(biomarker.name, created.id); + } + + for (const lab of payload.labResults) { + const biomarkerId = lab.biomarkerName + ? biomarkerByName.get(lab.biomarkerName) + : undefined; + if (lab.biomarkerName && !biomarkerId) { + throw new Error( + `Unknown biomarker reference: ${lab.biomarkerName}`, + ); + } + await tx.labResult.create({ + data: { + ...(lab.id ? { id: lab.id } : {}), + userId: ownerId, + biomarkerId: biomarkerId ?? null, + panel: lab.panel ?? null, + analyte: lab.analyte, + value: lab.value ?? null, + valueText: lab.valueText ?? null, + unit: lab.unit, + referenceLow: lab.referenceLow ?? null, + referenceHigh: lab.referenceHigh ?? null, + takenAt: new Date(lab.takenAt), + source: lab.source, + noteEncrypted: + lab.note == null ? null : encryptNoteToBytes(lab.note), + ...(lab.createdAt + ? { createdAt: new Date(lab.createdAt) } + : {}), + ...(lab.updatedAt + ? { updatedAt: new Date(lab.updatedAt) } + : {}), + }, + }); + } + + const episodeIds = new Set( + payload.illnessEpisodes.map((episode) => episode.id), + ); + for (const episode of payload.illnessEpisodes) { + if ( + episode.parentConditionId && + !episodeIds.has(episode.parentConditionId) + ) { + throw new Error( + `Unknown illness parent: ${episode.parentConditionId}`, + ); + } + await tx.illnessEpisode.create({ + data: { + id: episode.id, + userId: ownerId, + label: episode.label, + type: episode.type as never, + lifecycle: episode.lifecycle as never, + onsetAt: new Date(episode.onsetAt), + resolvedAt: episode.resolvedAt + ? new Date(episode.resolvedAt) + : null, + parentConditionId: null, + noteEncrypted: + episode.note == null ? null : encryptToBytes(episode.note), + ...(episode.createdAt + ? { createdAt: new Date(episode.createdAt) } + : {}), + ...(episode.updatedAt + ? { updatedAt: new Date(episode.updatedAt) } + : {}), + }, + }); + } + for (const episode of payload.illnessEpisodes) { + if (episode.parentConditionId) { + await tx.illnessEpisode.update({ + where: { id: episode.id }, + data: { parentConditionId: episode.parentConditionId }, + }); + } + } + + const symptomKeys = [ + ...new Set( + payload.illnessEpisodes.flatMap((episode) => + episode.dayLogs.flatMap((dayLog) => + dayLog.symptoms.map((symptom) => symptom.key), + ), + ), + ), + ]; + const symptomRows = + symptomKeys.length === 0 + ? [] + : await tx.illnessSymptom.findMany({ + where: { key: { in: symptomKeys } }, + select: { id: true, key: true }, + }); + const symptomByKey = new Map( + symptomRows.map((symptom) => [symptom.key, symptom.id]), + ); + if (symptomByKey.size !== symptomKeys.length) { + const missing = symptomKeys.filter((key) => !symptomByKey.has(key)); + throw new Error( + `Unknown illness symptom keys: ${missing.join(", ")}`, + ); + } + for (const episode of payload.illnessEpisodes) { + for (const dayLog of episode.dayLogs) { + await tx.illnessDayLog.create({ + data: { + ...(dayLog.id ? { id: dayLog.id } : {}), + userId: ownerId, + episodeId: episode.id, + date: dayLog.date, + functionalImpact: dayLog.functionalImpact ?? null, + feverC: dayLog.feverC ?? null, + noteEncrypted: + dayLog.note == null ? null : encryptToBytes(dayLog.note), + ...(dayLog.updatedAt + ? { updatedAt: new Date(dayLog.updatedAt) } + : {}), + symptomLinks: { + create: dayLog.symptoms.map((symptom) => ({ + symptomId: symptomByKey.get(symptom.key)!, + severity: symptom.severity ?? null, + })), + }, + }, + }); + } + } + + for (const allergy of payload.allergies) { + await tx.allergy.create({ + data: { + id: allergy.id, + userId: ownerId, + substance: allergy.substance, + category: allergy.category as never, + type: allergy.type as never, + severity: (allergy.severity ?? null) as never, + status: allergy.status as never, + onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null, + reactionEncrypted: + allergy.reaction == null + ? null + : encryptToBytes(allergy.reaction), + notesEncrypted: + allergy.note == null ? null : encryptToBytes(allergy.note), + ...(allergy.createdAt + ? { createdAt: new Date(allergy.createdAt) } + : {}), + ...(allergy.updatedAt + ? { updatedAt: new Date(allergy.updatedAt) } + : {}), + }, + }); + } + + for (const familyEntry of payload.familyHistory) { + await tx.familyHistoryEntry.create({ + data: { + id: familyEntry.id, + userId: ownerId, + relationship: familyEntry.relationship as never, + condition: familyEntry.condition, + ageAtOnset: familyEntry.ageAtOnset ?? null, + notesEncrypted: + familyEntry.note == null + ? null + : encryptToBytes(familyEntry.note), + ...(familyEntry.createdAt + ? { createdAt: new Date(familyEntry.createdAt) } + : {}), + ...(familyEntry.updatedAt + ? { updatedAt: new Date(familyEntry.updatedAt) } + : {}), + }, + }); + } + + if (payload.workouts.length > 0) { + await tx.workout.createMany({ + data: payload.workouts.map((workout) => ({ + ...(workout.id ? { id: workout.id } : {}), + userId: ownerId, + sportType: workout.sportType, + startedAt: new Date(workout.startedAt), + endedAt: new Date(workout.endedAt), + durationSec: workout.durationSec, + totalEnergyKcal: workout.totalEnergyKcal ?? null, + totalDistanceM: workout.totalDistanceM ?? null, + avgHeartRate: workout.avgHeartRate ?? null, + maxHeartRate: workout.maxHeartRate ?? null, + minHeartRate: workout.minHeartRate ?? null, + stepCount: workout.stepCount ?? null, + elevationM: workout.elevationM ?? null, + pauseDurationSec: workout.pauseDurationSec ?? null, + source: workout.source as never, + externalId: workout.externalId ?? null, + ...(workout.createdAt + ? { createdAt: new Date(workout.createdAt) } + : {}), + ...(workout.updatedAt + ? { updatedAt: new Date(workout.updatedAt) } + : {}), + })), + }); + } + + if (payload.documents.length > 0) { + await tx.inboundDocument.createMany({ + data: payload.documents.map((document) => ({ + id: document.id, + userId: ownerId, + kind: document.kind as never, + title: document.title ?? null, + filename: document.filename ?? null, + mimeType: document.mimeType, + byteSize: document.byteSize, + contentEncrypted: decodeEncryptedBytes( + document.contentEncrypted!, + ), + contentSha256: document.contentSha256 ?? null, + contentCodec: document.contentCodec!, + status: document.status as never, + providerType: document.providerType ?? null, + reportDate: document.reportDate + ? new Date(document.reportDate) + : null, + documentDate: document.documentDate + ? new Date(document.documentDate) + : null, + errorReason: document.errorReason ?? null, + summaryEncrypted: document.summaryEncrypted + ? decodeEncryptedBytes(document.summaryEncrypted) + : null, + summaryGeneratedAt: document.summaryGeneratedAt + ? new Date(document.summaryGeneratedAt) + : null, + summaryState: document.summaryState as never, + createdAt: new Date(document.createdAt!), + updatedAt: new Date(document.updatedAt!), + })), + }); + } + + return { + measurements: measurements.count, + medications: meds.count, + intakeEvents: intake.count, + moodEntries: moods.count, + notificationChannels: channels.count, + pushSubscriptions: subs.count, + telegramScheduledDeletions: tgDel.count, + cycles: cycleCleared.cycles, + cycleDayLogs: cycleCleared.cycleDayLogs, + cycleProfile: cycleCleared.cycleProfile, + labResults: labResults.count, + biomarkers: biomarkers.count, + illnessEpisodes: illnessEpisodes.count, + allergies: allergies.count, + familyHistory: familyHistory.count, + workouts: workouts.count, + documents: documents.count, + }; + }, + { + maxWait: 10_000, + timeout: 120_000, + }, + ); } catch (err) { // Scrub the raw Prisma / driver error from the wire response — // even on an admin endpoint, leaking column names, constraint diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index c16b20a0d..88c52f32d 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -85,8 +85,8 @@ beforeEach(() => { vi.mocked(prisma.device.deleteMany).mockResolvedValue({ count: 0 }); vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 }); vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); - vi.mocked(prisma.$transaction).mockImplementation( - async (callback) => callback(prisma as never), + vi.mocked(prisma.$transaction).mockImplementation(async (callback) => + callback(prisma as never), ); vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({ id: "ch-1", diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index cbbabdaef..f9fb33118 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -234,11 +234,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // the APNs row with the newly presented metadata. let canonicalDevice = apnsDevice ?? legacyDevice; let duplicateDevice: typeof legacyDevice = null; - if ( - apnsDevice && - legacyDevice && - apnsDevice.id !== legacyDevice.id - ) { + if (apnsDevice && legacyDevice && apnsDevice.id !== legacyDevice.id) { duplicateDevice = legacyDevice; await tx.device.deleteMany({ where: { id: legacyDevice.id, userId: user.id }, diff --git a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts index 51de39107..352a6aff6 100644 --- a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts @@ -77,7 +77,9 @@ vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ }); return { status: "updated", row: { ...exact, ...input } }; } - const [inserted] = await tx.measurement.createManyAndReturn({ data: input }); + const [inserted] = await tx.measurement.createManyAndReturn({ + data: input, + }); if (inserted) return { status: "inserted", row: inserted }; if (options.exactExternalMatch === "update") { await tx.measurement.updateMany({ where: {}, data: input }); diff --git a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts index d376658b3..504bf1926 100644 --- a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts +++ b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts @@ -58,7 +58,9 @@ vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ await tx.measurement.updateMany({ where: { id: exact.id }, data: input }); return { status: "updated", row: { ...exact, ...input } }; } - const [inserted] = await tx.measurement.createManyAndReturn({ data: input }); + const [inserted] = await tx.measurement.createManyAndReturn({ + data: input, + }); if (inserted) return { status: "inserted", row: inserted }; if (options.exactExternalMatch === "update") { await tx.measurement.updateMany({ where: {}, data: input }); diff --git a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts index ab9ff6113..ab8f5b478 100644 --- a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts +++ b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts @@ -38,7 +38,9 @@ vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ ) => { const override = await reconcileOverrideMock(tx, input); if (override !== undefined) return override; - const [inserted] = await tx.measurement.createManyAndReturn({ data: input }); + const [inserted] = await tx.measurement.createManyAndReturn({ + data: input, + }); return inserted ? { status: "inserted", row: inserted } : { status: "duplicate" }; diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 25013650d..1001392ce 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -177,12 +177,7 @@ type BatchEntry = z.infer; * sample is a canonical, immutable reading. `skipped` is a validation no-op; * `failed` is a hard database verdict and must not be checkpointed as landed. */ -type EntryStatus = - | "inserted" - | "updated" - | "duplicate" - | "skipped" - | "failed"; +type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped" | "failed"; // v1.5.0 issue #213 — `stats:*` externalIds carry per-day cumulative // totals that the iOS HealthKit observer re-posts as the day progresses. @@ -475,103 +470,101 @@ async function postBatch(request: NextRequest): Promise { } for (const p of prepared) { - const externalId = p.row.externalId as string; - const statsRow = isStatsExternalId(externalId); - const key = `${p.row.type}::${p.row.source}::${externalId}`; - if (statsRow && lastStatsIndexByKey.get(key) !== p.index) { - results[p.index] = { - index: p.index, - status: "duplicate", - reason: "superseded_in_batch", - }; - duplicateCount++; - continue; - } + const externalId = p.row.externalId as string; + const statsRow = isStatsExternalId(externalId); + const key = `${p.row.type}::${p.row.source}::${externalId}`; + if (statsRow && lastStatsIndexByKey.get(key) !== p.index) { + results[p.index] = { + index: p.index, + status: "duplicate", + reason: "superseded_in_batch", + }; + duplicateCount++; + continue; + } - if (!statsRow && hasSameReadingSibling(p)) { - results[p.index] = { - index: p.index, - status: "duplicate", - reason: "cross_source_merge", - }; - duplicateCount++; - crossSourceMergedCount++; - continue; - } + if (!statsRow && hasSameReadingSibling(p)) { + results[p.index] = { + index: p.index, + status: "duplicate", + reason: "cross_source_merge", + }; + duplicateCount++; + crossSourceMergedCount++; + continue; + } - const verdict = await prisma.$transaction((tx) => - reconcileExternalMeasurement( - tx, - { - userId: user.id, - type: p.row.type as MeasurementType, - value: p.row.value as number, - valueMin: p.row.valueMin as number | null, - valueMax: p.row.valueMax as number | null, - unit: p.row.unit as string, - source: - p.row.source as Prisma.MeasurementUncheckedCreateInput["source"], - measuredAt: p.row.measuredAt as Date, - externalId, - externalSourceVersion: p.row - .externalSourceVersion as string | null, - glucoseContext: - p.row.glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"], - sleepStage: - p.row.sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"], - rhythmClassification: - p.row - .rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"], - deviceType: p.row.deviceType as string | null, - }, - { exactExternalMatch: statsRow ? "update" : "duplicate" }, - ), - ); - - switch (verdict.status) { - case "inserted": - results[p.index] = { index: p.index, status: "inserted" }; - insertedCount++; - insertedPrepared.push(p); - writtenIdentities.push({ - type: p.row.type as MeasurementType, - measuredAt: p.row.measuredAt as Date, - }); - break; - case "updated": - case "resurrected": - results[p.index] = { index: p.index, status: "updated" }; - updatedCount++; - writtenIdentities.push( - { - type: p.row.type as MeasurementType, - measuredAt: p.row.measuredAt as Date, - }, - ...(verdict.dirtyIdentities ?? []), - ); - break; - case "duplicate": - results[p.index] = { index: p.index, status: "duplicate" }; - duplicateCount++; - break; - case "failed": - results[p.index] = { - index: p.index, - status: "failed", - reason: verdict.error.code ?? "write_failed", - }; - failedCount++; - continue; - } - - if (isMergeableSource(p.row.source as string)) { - inBatchCandidates.push({ + const verdict = await prisma.$transaction((tx) => + reconcileExternalMeasurement( + tx, + { + userId: user.id, type: p.row.type as MeasurementType, - source: p.row.source as string, value: p.row.value as number, + valueMin: p.row.valueMin as number | null, + valueMax: p.row.valueMax as number | null, + unit: p.row.unit as string, + source: p.row + .source as Prisma.MeasurementUncheckedCreateInput["source"], + measuredAt: p.row.measuredAt as Date, + externalId, + externalSourceVersion: p.row.externalSourceVersion as string | null, + glucoseContext: p.row + .glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"], + sleepStage: p.row + .sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"], + rhythmClassification: p.row + .rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"], + deviceType: p.row.deviceType as string | null, + }, + { exactExternalMatch: statsRow ? "update" : "duplicate" }, + ), + ); + + switch (verdict.status) { + case "inserted": + results[p.index] = { index: p.index, status: "inserted" }; + insertedCount++; + insertedPrepared.push(p); + writtenIdentities.push({ + type: p.row.type as MeasurementType, measuredAt: p.row.measuredAt as Date, }); - } + break; + case "updated": + case "resurrected": + results[p.index] = { index: p.index, status: "updated" }; + updatedCount++; + writtenIdentities.push( + { + type: p.row.type as MeasurementType, + measuredAt: p.row.measuredAt as Date, + }, + ...(verdict.dirtyIdentities ?? []), + ); + break; + case "duplicate": + results[p.index] = { index: p.index, status: "duplicate" }; + duplicateCount++; + break; + case "failed": + results[p.index] = { + index: p.index, + status: "failed", + reason: verdict.error.code ?? "write_failed", + }; + failedCount++; + continue; + } + + if (isMergeableSource(p.row.source as string)) { + inBatchCandidates.push({ + type: p.row.type as MeasurementType, + source: p.row.source as string, + value: p.row.value as number, + measuredAt: p.row.measuredAt as Date, + }); + } } } diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index c036c0268..308b67d60 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -13,7 +13,6 @@ const txClient = vi.hoisted(() => ({ }, })); - vi.mock("@/lib/db", () => ({ prisma: { moodEntry: { @@ -222,9 +221,7 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => { error: null; }; expect(body.data.tagKeys).toEqual(["happy"]); - expect(body.data.ratedFactors).toEqual([ - { key: "factor_work", rating: 4 }, - ]); + expect(body.data.ratedFactors).toEqual([{ key: "factor_work", rating: 4 }]); expect(body.error).toBeNull(); }); @@ -254,9 +251,7 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => { }; }; expect(body.data.tagKeys).toEqual(["happy"]); - expect(body.data.ratedFactors).toEqual([ - { key: "factor_work", rating: 5 }, - ]); + expect(body.data.ratedFactors).toEqual([{ key: "factor_work", rating: 5 }]); }); it("returns 422 when a factor score is outside its catalog scale", async () => { @@ -333,18 +328,18 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => { }); }); - it.each([ - { ratedFactors: null }, - { ratedFactors: [] }, - ])("clears RATED links for an explicit $ratedFactors", async (payload) => { - const res = await PUT(putReq(payload), ROUTE_CTX); + it.each([{ ratedFactors: null }, { ratedFactors: [] }])( + "clears RATED links for an explicit $ratedFactors", + async (payload) => { + const res = await PUT(putReq(payload), ROUTE_CTX); - expect(res.status).toBe(200); - expect(replaceRatedFactorLinks).toHaveBeenCalledWith( - "me1", - "user-1", - [], - txClient, - ); - }); + expect(res.status).toBe(200); + expect(replaceRatedFactorLinks).toHaveBeenCalledWith( + "me1", + "user-1", + [], + txClient, + ); + }, + ); }); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 29b327804..3586b1a6d 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -46,9 +46,7 @@ function makePrisma() { externalId: "moodlog-42", moodLoggedAt: new Date("2026-07-19T08:00:00.000Z"), deletedAt, - tagLinks: [ - { rating: 4, moodTag: { key: "sleep_quality" } }, - ], + tagLinks: [{ rating: 4, moodTag: { key: "sleep_quality" } }], }, ]), }, @@ -94,10 +92,14 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { installSectionMocks(); const prisma = makePrisma(); - const { payload } = await buildFullBackupPayload(prisma as never, "user-1", { - purpose: "disaster-recovery", - exportedAt: new Date("2026-07-20T00:00:00.000Z"), - }); + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); expect(mocks.findMeasurementsPaged).toHaveBeenCalledWith( prisma, diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 1637560a7..a534de18a 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -368,9 +368,7 @@ export async function buildRecordsBackupSection( mimeType: d.mimeType, byteSize: d.byteSize, status: d.status, - reportDate: d.reportDate - ? d.reportDate.toISOString().slice(0, 10) - : null, + reportDate: d.reportDate ? d.reportDate.toISOString().slice(0, 10) : null, documentDate: d.documentDate ? d.documentDate.toISOString().slice(0, 10) : null, @@ -415,8 +413,7 @@ export async function buildRecordsBackupSection( documents: disasterRecovery ? { included: "encrypted-content", - note: - "Document metadata and encrypted stored bytes are included for disaster recovery.", + note: "Document metadata and encrypted stored bytes are included for disaster recovery.", } : { included: "metadata-only", note: DOCUMENTS_MANIFEST_NOTE }, workouts: { included: "summary-only", note: WORKOUTS_MANIFEST_NOTE }, diff --git a/src/lib/jobs/__tests__/withings-ecg-worker.test.ts b/src/lib/jobs/__tests__/withings-ecg-worker.test.ts index c873d070d..05de54a0a 100644 --- a/src/lib/jobs/__tests__/withings-ecg-worker.test.ts +++ b/src/lib/jobs/__tests__/withings-ecg-worker.test.ts @@ -3,12 +3,13 @@ import type { Job } from "pg-boss"; import { readFileSync } from "node:fs"; import { join } from "node:path"; const { syncUserEcg } = vi.hoisted(() => ({ - syncUserEcg: vi.fn< - ( - userId: string, - options?: { startdate?: number; enddate?: number }, - ) => Promise - >(), + syncUserEcg: + vi.fn< + ( + userId: string, + options?: { startdate?: number; enddate?: number }, + ) => Promise + >(), })); vi.mock("@/lib/withings/sync-ecg", () => ({ @@ -70,10 +71,7 @@ beforeEach(() => { vi.clearAllMocks(); }); const registrarSource = readFileSync( - join( - process.cwd(), - "src/lib/jobs/reminder/register-integration-sync.ts", - ), + join(process.cwd(), "src/lib/jobs/reminder/register-integration-sync.ts"), "utf8", ); diff --git a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts index 5ee8ff29d..30cdc3b14 100644 --- a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts +++ b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts @@ -38,9 +38,9 @@ const documentCiphertext = Buffer.from([1, 2, 3, 4]).toString("base64"); function buildPrismaMock() { return { user: { - findMany: vi.fn().mockResolvedValue([ - { id: "user-dr", username: "backup-owner" }, - ]), + findMany: vi + .fn() + .mockResolvedValue([{ id: "user-dr", username: "backup-owner" }]), }, dataBackup: { upsert: mocks.upsert }, }; diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index 8b419ae2e..1106159ed 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -58,11 +58,9 @@ export async function handleDataBackup(jobs: Job[]) { let backed = 0; for (const user of users) { try { - const { payload } = await buildFullBackupPayload( - prisma, - user.id, - { purpose: "disaster-recovery" }, - ); + const { payload } = await buildFullBackupPayload(prisma, user.id, { + purpose: "disaster-recovery", + }); const backupJson = JSON.stringify(payload); // Encrypt the backup data (contains sensitive health information) diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts index f42305b0b..6b3909996 100644 --- a/src/lib/jobs/reminder/withings-sync.ts +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -17,7 +17,6 @@ import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { getWorkerPrisma } from "./shared"; export type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue"; - export interface WithingsSyncPayload { triggeredAt: string; } diff --git a/src/lib/jobs/withings-ecg-queue.ts b/src/lib/jobs/withings-ecg-queue.ts index 9026d1b20..34a0e0ad6 100644 --- a/src/lib/jobs/withings-ecg-queue.ts +++ b/src/lib/jobs/withings-ecg-queue.ts @@ -49,9 +49,7 @@ export async function enqueueWithingsEcgSync( userId: identity.userId, eventId, triggeredAt: new Date().toISOString(), - ...(parsedStartdate?.success - ? { startdate: parsedStartdate.data } - : {}), + ...(parsedStartdate?.success ? { startdate: parsedStartdate.data } : {}), ...(parsedEnddate?.success ? { enddate: parsedEnddate.data } : {}), } satisfies WithingsEcgSyncPayload, { diff --git a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts index 41ca58bb2..4034767ec 100644 --- a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts +++ b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts @@ -24,9 +24,7 @@ function desired( }; } -function row( - overrides: Record = {}, -): Record { +function row(overrides: Record = {}): Record { return { id: "row-1", userId: "user-1", diff --git a/src/lib/measurements/reconcile-external-measurement.ts b/src/lib/measurements/reconcile-external-measurement.ts index 95ed27a51..d45d998c9 100644 --- a/src/lib/measurements/reconcile-external-measurement.ts +++ b/src/lib/measurements/reconcile-external-measurement.ts @@ -36,10 +36,7 @@ export type ReconciledMeasurementRow = Pick< "id" | "type" | "measuredAt" | "externalId" >; -export type DirtyMeasurementIdentity = Pick< - Measurement, - "type" | "measuredAt" ->; +export type DirtyMeasurementIdentity = Pick; export type MeasurementReconciliationVerdict = | { @@ -126,7 +123,9 @@ async function lockUser( async function lockAndReloadCandidates( tx: PrismaTypes.TransactionClient, desired: ExternalMeasurementWrite, -): Promise>> { +): Promise< + Array> +> { const where = { userId: desired.userId, OR: [ @@ -262,6 +261,7 @@ export async function reconcileExternalMeasurement( } const { userId: _ownerId, ...mutable } = desired; + void _ownerId; const updated = await tx.measurement.update({ where: { id: canonical.id }, data: { diff --git a/src/lib/mood/__tests__/tag-links.test.ts b/src/lib/mood/__tests__/tag-links.test.ts index e1abeb0ab..c14e7c7e6 100644 --- a/src/lib/mood/__tests__/tag-links.test.ts +++ b/src/lib/mood/__tests__/tag-links.test.ts @@ -272,7 +272,6 @@ describe("replaceTagLinks — archived-tag links survive an entry edit", () => { skipDuplicates: true, }); }); - }); describe("entry-ownership guard", () => { diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index af6578fee..d59602a77 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -142,17 +142,22 @@ beforeEach(() => { transactionMock .mockReset() .mockImplementation(async (run: (tx: unknown) => unknown) => run({})); - reconcileMock.mockReset().mockImplementation( - async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ - status: "inserted", - row: { - id: `inserted:${input.externalId}`, - type: input.type, - measuredAt: input.measuredAt, - externalId: input.externalId, - }, - }), - ); + reconcileMock + .mockReset() + .mockImplementation( + async ( + _tx: unknown, + input: { type: string; measuredAt: Date; externalId: string }, + ) => ({ + status: "inserted", + row: { + id: `inserted:${input.externalId}`, + type: input.type, + measuredAt: input.measuredAt, + externalId: input.externalId, + }, + }), + ); createManyAndReturnMock.mockReset().mockImplementation( async ({ data, diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index c534fabc1..779cf1bdf 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -461,30 +461,33 @@ export async function upsertOuraMeasurements( InsertedMeasurementArrivalRow & { externalId: string | null } > = []; - const verdicts = await prisma.$transaction(async (tx) => { - const outcomes = []; - for (const r of readings) { - const verdict = await reconcileExternalMeasurement( - tx, - { - userId, - type: r.type as MeasurementType, - source: "OURA", - value: r.value, - unit: r.unit, - measuredAt: r.measuredAt, - externalId: r.externalId, - sleepStage: r.sleepStage ?? null, - }, - { exactExternalMatch: "update" }, - ); - if (verdict.status === "failed") { - throw new MeasurementReconciliationError(verdict); + const verdicts = await prisma.$transaction( + async (tx) => { + const outcomes = []; + for (const r of readings) { + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: r.type as MeasurementType, + source: "OURA", + value: r.value, + unit: r.unit, + measuredAt: r.measuredAt, + externalId: r.externalId, + sleepStage: r.sleepStage ?? null, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + outcomes.push(verdict); } - outcomes.push(verdict); - } - return outcomes; - }, { timeout: 60_000 }); + return outcomes; + }, + { timeout: 60_000 }, + ); for (let index = 0; index < readings.length; index++) { const reading = readings[index]!; diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index da00e02cd..b80055b53 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -106,17 +106,22 @@ beforeEach(() => { transactionMock .mockReset() .mockImplementation(async (run: (tx: unknown) => unknown) => run({})); - reconcileMock.mockReset().mockImplementation( - async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ - status: "inserted", - row: { - id: `inserted:${input.externalId}`, - type: input.type, - measuredAt: input.measuredAt, - externalId: input.externalId, - }, - }), - ); + reconcileMock + .mockReset() + .mockImplementation( + async ( + _tx: unknown, + input: { type: string; measuredAt: Date; externalId: string }, + ) => ({ + status: "inserted", + row: { + id: `inserted:${input.externalId}`, + type: input.type, + measuredAt: input.measuredAt, + externalId: input.externalId, + }, + }), + ); createManyAndReturnMock.mockReset().mockImplementation( async ({ data, diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index f321239a5..e3a4c5283 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -210,30 +210,33 @@ export async function upsertPolarMeasurements( InsertedMeasurementArrivalRow & { externalId: string | null } > = []; - const verdicts = await prisma.$transaction(async (tx) => { - const outcomes = []; - for (const r of readings) { - const verdict = await reconcileExternalMeasurement( - tx, - { - userId, - type: r.type as MeasurementType, - source: "POLAR", - value: r.value, - unit: r.unit, - measuredAt: r.measuredAt, - externalId: r.externalId, - sleepStage: r.sleepStage ?? null, - }, - { exactExternalMatch: "update" }, - ); - if (verdict.status === "failed") { - throw new MeasurementReconciliationError(verdict); + const verdicts = await prisma.$transaction( + async (tx) => { + const outcomes = []; + for (const r of readings) { + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: r.type as MeasurementType, + source: "POLAR", + value: r.value, + unit: r.unit, + measuredAt: r.measuredAt, + externalId: r.externalId, + sleepStage: r.sleepStage ?? null, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + outcomes.push(verdict); } - outcomes.push(verdict); - } - return outcomes; - }, { timeout: 60_000 }); + return outcomes; + }, + { timeout: 60_000 }, + ); for (let index = 0; index < readings.length; index++) { const reading = readings[index]!; diff --git a/src/lib/strava/sync.ts b/src/lib/strava/sync.ts index 800316eaf..e416407c4 100644 --- a/src/lib/strava/sync.ts +++ b/src/lib/strava/sync.ts @@ -68,7 +68,10 @@ export function classifyStravaFailure(err: unknown): FailureKind { return toFailureKind(classifyStravaError(err)); } -async function recordStravaFailure(userId: string, err: unknown): Promise { +async function recordStravaFailure( + userId: string, + err: unknown, +): Promise { await recordSyncFailure({ userId, integration: "strava", diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index fb50d852b..ca68e8f64 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -46,7 +46,6 @@ import { SecondarySymptom, } from "@/generated/prisma/enums"; - export const BACKUP_SCHEMA_VERSION = "1" as const; const isoDateTime = z @@ -351,8 +350,7 @@ const base64BytesSchema = z .string() .min(1) .refine( - (value) => - value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), + (value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), { message: "Expected base64-encoded encrypted bytes" }, ); diff --git a/src/lib/whoop/__tests__/sync.test.ts b/src/lib/whoop/__tests__/sync.test.ts index c8e56648d..6416e109c 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -118,7 +118,10 @@ beforeEach(() => { async (run: (tx: unknown) => unknown) => run(prismaMock), ); reconcileMock.mockImplementation( - async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ + async ( + _tx: unknown, + input: { type: string; measuredAt: Date; externalId: string }, + ) => ({ status: "inserted", row: { id: `inserted:${input.externalId}`, @@ -167,7 +170,6 @@ describe("getValidToken — rotating refresh", () => { expect(prismaMock.$queryRaw).toHaveBeenCalledTimes(1); }); - it("re-reads token state under an advisory lock and reuses a peer winner", async () => { prismaMock.whoopConnection.findUnique .mockResolvedValueOnce({ @@ -549,7 +551,10 @@ describe("syncUserRecovery — durable cursor ordering", () => { }); fetchRecoveriesMock.mockResolvedValue([recovery]); reconcileMock.mockImplementation( - async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ + async ( + _tx: unknown, + input: { type: string; measuredAt: Date; externalId: string }, + ) => ({ status: "updated", row: { id: `existing:${input.externalId}`, diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts index dd5271afd..565455c02 100644 --- a/src/lib/withings/__tests__/sync-sleep.test.ts +++ b/src/lib/withings/__tests__/sync-sleep.test.ts @@ -107,11 +107,14 @@ function installFetchMock(segments: FakeSegment[]) { beforeEach(() => { vi.resetAllMocks(); - transactionMock.mockImplementation( - async (run: (tx: unknown) => unknown) => run({}), + transactionMock.mockImplementation(async (run: (tx: unknown) => unknown) => + run({}), ); reconcileMock.mockImplementation( - async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ + async ( + _tx: unknown, + input: { type: string; measuredAt: Date; externalId: string }, + ) => ({ status: "inserted", row: { id: `inserted:${input.externalId}`, @@ -502,10 +505,7 @@ describe("syncUserSleep — segment writes + idempotency", () => { externalId: string; value: number; }; - return [ - row.type, - { externalId: row.externalId, value: row.value }, - ]; + return [row.type, { externalId: row.externalId, value: row.value }]; }), ); expect(byType.get("RESTING_HEART_RATE")).toEqual({ diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts index 61cfb92ec..04c99bb1a 100644 --- a/src/lib/withings/sync-ecg.ts +++ b/src/lib/withings/sync-ecg.ts @@ -496,7 +496,6 @@ export async function syncUserEcg( } } - annotate({ action: { name: "withings.ecg.sync" }, meta: { diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 6ba1cb710..f878d39a1 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -412,48 +412,51 @@ export async function syncUserSleep( // and are excluded (their `no-id` prefix would span every id-less night in // history, not just this fetch). const freshBySession = new Map(); - await prisma.$transaction(async (tx) => { - for (const segment of segments) { - const stage = mapWithingsSleepState(segment.state); - if (!stage) continue; + await prisma.$transaction( + async (tx) => { + for (const segment of segments) { + const stage = mapWithingsSleepState(segment.state); + if (!stage) continue; - const measuredAt = new Date(segment.enddate * 1000); - const durationSec = Math.max(0, segment.enddate - segment.startdate); - const minutes = Math.round(durationSec / 60); - const externalId = `withings:sleep:${userId}:${segment.id ?? "no-id"}:${segment.startdate}`; - if (typeof segment.id === "number") { - const fresh = freshBySession.get(segment.id) ?? []; - fresh.push(externalId); - freshBySession.set(segment.id, fresh); - } + const measuredAt = new Date(segment.enddate * 1000); + const durationSec = Math.max(0, segment.enddate - segment.startdate); + const minutes = Math.round(durationSec / 60); + const externalId = `withings:sleep:${userId}:${segment.id ?? "no-id"}:${segment.startdate}`; + if (typeof segment.id === "number") { + const fresh = freshBySession.get(segment.id) ?? []; + fresh.push(externalId); + freshBySession.set(segment.id, fresh); + } - const verdict = await reconcileExternalMeasurement( - tx, - { - userId, - type: SLEEP_TYPE, - value: minutes, - unit: getUnitForType(SLEEP_TYPE), - measuredAt, - source: "WITHINGS", - sleepStage: stage, - externalId, - }, - { exactExternalMatch: "update" }, - ); - if (verdict.status === "failed") { - throw new MeasurementReconciliationError(verdict); + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: SLEEP_TYPE, + value: minutes, + unit: getUnitForType(SLEEP_TYPE), + measuredAt, + source: "WITHINGS", + sleepStage: stage, + externalId, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + for (const dirty of verdict.dirtyIdentities ?? []) { + touched.push(dirty); + } + if (verdict.status === "inserted") { + insertedSleepMeasuredAts.push(measuredAt); + } + touched.push({ type: SLEEP_TYPE, measuredAt }); + imported++; } - for (const dirty of verdict.dirtyIdentities ?? []) { - touched.push(dirty); - } - if (verdict.status === "inserted") { - insertedSleepMeasuredAts.push(measuredAt); - } - touched.push({ type: SLEEP_TYPE, measuredAt }); - imported++; - } - }, { timeout: 60_000 }); + }, + { timeout: 60_000 }, + ); // Session-scoped sweep (mirrors Google Health's replace-by-window): for each // session this fetch re-produced, soft-delete any live WITHINGS @@ -486,35 +489,38 @@ export async function syncUserSleep( ); } - await prisma.$transaction(async (tx) => { - for (const summary of summaries) { - const measuredAt = new Date(summary.enddate * 1000); - const sessionId = summary.id ?? "no-id"; - for (const vital of mapWithingsSleepSummary(summary.data)) { - const verdict = await reconcileExternalMeasurement( - tx, - { - userId, - type: vital.type, - value: vital.value, - unit: getUnitForType(vital.type), - measuredAt, - source: "WITHINGS", - externalId: `withings:sleep:${userId}:${sessionId}:${vital.fieldTag}`, - }, - { exactExternalMatch: "update" }, - ); - if (verdict.status === "failed") { - throw new MeasurementReconciliationError(verdict); + await prisma.$transaction( + async (tx) => { + for (const summary of summaries) { + const measuredAt = new Date(summary.enddate * 1000); + const sessionId = summary.id ?? "no-id"; + for (const vital of mapWithingsSleepSummary(summary.data)) { + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: vital.type, + value: vital.value, + unit: getUnitForType(vital.type), + measuredAt, + source: "WITHINGS", + externalId: `withings:sleep:${userId}:${sessionId}:${vital.fieldTag}`, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + for (const dirty of verdict.dirtyIdentities ?? []) { + touched.push(dirty); + } + touched.push({ type: vital.type, measuredAt }); + imported++; } - for (const dirty of verdict.dirtyIdentities ?? []) { - touched.push(dirty); - } - touched.push({ type: vital.type, measuredAt }); - imported++; } - } - }, { timeout: 60_000 }); + }, + { timeout: 60_000 }, + ); // v1.4.39.1 — refresh the persistent rollup table for every distinct // (type, day) the sync touched. Sleep segments collapse heavily — diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index ee2b12f1a..e61f05ff9 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -12,7 +12,6 @@ process.env.BACKUP_S3_ACCESS_KEY = "test-access"; process.env.BACKUP_S3_SECRET_KEY = "test-secret"; process.env.BACKUP_ENCRYPTION_KEY = OFFHOST_ENCRYPTION_KEY; - import { encrypt, encryptBytes } from "@/lib/crypto"; import { encryptToBytes, decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -337,15 +336,10 @@ describe("canonical disaster-recovery backup round-trip", () => { failed: 0, totalUsers: 1, }); - const offhostObject = s3.objects.get( - `2026-07-02/user-${ownerId}.json.enc`, - ); + const offhostObject = s3.objects.get(`2026-07-02/user-${ownerId}.json.enc`); expect(offhostObject).toBeDefined(); const payload = parseBackupPayload( - decryptBackup( - offhostObject!, - Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex"), - ), + decryptBackup(offhostObject!, Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex")), ); expect(payload).toMatchObject({ schemaVersion: "1", @@ -379,14 +373,26 @@ describe("canonical disaster-recovery backup round-trip", () => { expect((await restore()).status).toBe(200); - expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe(1); - expect(await prisma.biomarker.count({ where: { userId: ownerId } })).toBe(1); - expect(await prisma.illnessEpisode.count({ where: { userId: ownerId } })).toBe(1); - expect(await prisma.illnessDayLog.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe( + 1, + ); + expect(await prisma.biomarker.count({ where: { userId: ownerId } })).toBe( + 1, + ); + expect( + await prisma.illnessEpisode.count({ where: { userId: ownerId } }), + ).toBe(1); + expect( + await prisma.illnessDayLog.count({ where: { userId: ownerId } }), + ).toBe(1); expect(await prisma.allergy.count({ where: { userId: ownerId } })).toBe(1); - expect(await prisma.familyHistoryEntry.count({ where: { userId: ownerId } })).toBe(1); + expect( + await prisma.familyHistoryEntry.count({ where: { userId: ownerId } }), + ).toBe(1); expect(await prisma.workout.count({ where: { userId: ownerId } })).toBe(1); - expect(await prisma.inboundDocument.count({ where: { userId: ownerId } })).toBe(1); + expect( + await prisma.inboundDocument.count({ where: { userId: ownerId } }), + ).toBe(1); expect( await prisma.measurement.findUniqueOrThrow({ where: { id: measurement.id }, @@ -466,7 +472,9 @@ describe("canonical disaster-recovery backup round-trip", () => { where: { id: illnessDay.id }, include: { symptomLinks: true }, }); - expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe("Rest day"); + expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe( + "Rest day", + ); expect(restoredIllnessDay.symptomLinks).toEqual([ expect.objectContaining({ symptomId: symptom.id, severity: 2 }), ]); @@ -625,55 +633,57 @@ describe("canonical disaster-recovery backup round-trip", () => { payload.schemaVersion = "999"; }, }, - ])("rejects $label with 422 before writes or invalidation", async ({ mutate }) => { - const prisma = getPrismaClient(); - const admin = await seedAdminSession(); - const existing = await prisma.measurement.create({ - data: { - id: "pre-validation-measurement", + ])( + "rejects $label with 422 before writes or invalidation", + async ({ mutate }) => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const existing = await prisma.measurement.create({ + data: { + id: "pre-validation-measurement", + userId: admin.id, + type: "WEIGHT", + value: 91, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-03T06:00:00.000Z"), + }, + }); + const payload: Record = { + schemaVersion: "1", + exportedAt: "2026-07-03T08:00:00.000Z", userId: admin.id, - type: "WEIGHT", - value: 91, - unit: "kg", - source: "MANUAL", - measuredAt: new Date("2026-07-03T06:00:00.000Z"), - }, - }); - const payload: Record = { - schemaVersion: "1", - exportedAt: "2026-07-03T08:00:00.000Z", - userId: admin.id, - measurements: [], - moodEntries: [], - }; - mutate(payload); - const backup = await prisma.dataBackup.create({ - data: { - userId: admin.id, - type: `INVALID_BOUNDARY_${String(payload.schemaVersion)}`, - data: encrypt(JSON.stringify(payload)), - }, - }); + measurements: [], + moodEntries: [], + }; + mutate(payload); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: `INVALID_BOUNDARY_${String(payload.schemaVersion)}`, + data: encrypt(JSON.stringify(payload)), + }, + }); - const response = await POST( - makeRequest(backup.id) as unknown as Parameters[0], - { params: Promise.resolve({ id: backup.id }) }, - ); - - expect(response.status).toBe(422); - expect(await response.json()).toMatchObject({ - data: null, - error: expect.any(String), - }); - expect( - await prisma.measurement.findMany({ - where: { userId: admin.id }, - select: { id: true }, - }), - ).toEqual([{ id: existing.id }]); - expect(invalidateUserData).not.toHaveBeenCalled(); - }); + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(422); + expect(await response.json()).toMatchObject({ + data: null, + error: expect.any(String), + }); + expect( + await prisma.measurement.findMany({ + where: { userId: admin.id }, + select: { id: true }, + }), + ).toEqual([{ id: existing.id }]); + expect(invalidateUserData).not.toHaveBeenCalled(); + }, + ); it("rejects metadata-only documents before mutation or invalidation", async () => { const prisma = getPrismaClient(); @@ -784,9 +794,9 @@ describe("canonical disaster-recovery backup round-trip", () => { select: { id: true }, }), ).toEqual([{ id: measurement.id }]); - expect( - await prisma.moodEntry.count({ where: { userId: admin.id } }), - ).toBe(0); + expect(await prisma.moodEntry.count({ where: { userId: admin.id } })).toBe( + 0, + ); expect(invalidateUserData).not.toHaveBeenCalled(); }); }); diff --git a/tests/integration/admin-backups-upload.test.ts b/tests/integration/admin-backups-upload.test.ts index 39c822251..d6866f0ff 100644 --- a/tests/integration/admin-backups-upload.test.ts +++ b/tests/integration/admin-backups-upload.test.ts @@ -239,34 +239,43 @@ describe("POST /api/admin/backups/upload", () => { }); it.each([ - ["measurement type", (payload: UploadPayloadFixture) => { - payload.measurements[0]!.type = "NOT_A_MEASUREMENT"; - }], - ["measurement source", (payload: UploadPayloadFixture) => { - payload.measurements[0]!.source = "NOT_A_SOURCE"; - }], - ])("rejects an invalid %s with 422 and zero backup writes", async (_label, mutate) => { - const prisma = getPrismaClient(); - const admin = await seedAdminSession(); - const payload = buildPayload(admin.id); - mutate(payload); - const file = new File([JSON.stringify(payload)], "invalid-enum.json", { - type: "application/json", - }); + [ + "measurement type", + (payload: UploadPayloadFixture) => { + payload.measurements[0]!.type = "NOT_A_MEASUREMENT"; + }, + ], + [ + "measurement source", + (payload: UploadPayloadFixture) => { + payload.measurements[0]!.source = "NOT_A_SOURCE"; + }, + ], + ])( + "rejects an invalid %s with 422 and zero backup writes", + async (_label, mutate) => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const payload = buildPayload(admin.id); + mutate(payload); + const file = new File([JSON.stringify(payload)], "invalid-enum.json", { + type: "application/json", + }); - const { POST } = await import("@/app/api/admin/backups/upload/route"); - const res = await POST( - buildMultipart(file) as unknown as Parameters[0], - ); + const { POST } = await import("@/app/api/admin/backups/upload/route"); + const res = await POST( + buildMultipart(file) as unknown as Parameters[0], + ); - expect(res.status).toBe(422); - expect(await res.json()).toMatchObject({ - data: null, - error: "Backup payload failed schema validation", - meta: { issues: expect.any(Array) }, - }); - expect(await prisma.dataBackup.count()).toBe(0); - }); + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ + data: null, + error: "Backup payload failed schema validation", + meta: { issues: expect.any(Array) }, + }); + expect(await prisma.dataBackup.count()).toBe(0); + }, + ); it("rejects an incompatible schemaVersion with 422", async () => { const prisma = getPrismaClient(); diff --git a/tests/integration/device-register.test.ts b/tests/integration/device-register.test.ts index 94381cf5a..c4ffd314d 100644 --- a/tests/integration/device-register.test.ts +++ b/tests/integration/device-register.test.ts @@ -175,7 +175,9 @@ describe("POST /api/devices", () => { const secondBody = (await second.json()) as { data: { id: string } }; expect(secondBody.data.id).toBe(firstBody.data.id); - const devices = await prisma.device.findMany({ where: { userId: user.id } }); + const devices = await prisma.device.findMany({ + where: { userId: user.id }, + }); expect(devices).toHaveLength(1); expect(devices[0]).toMatchObject({ id: firstBody.data.id, @@ -214,7 +216,7 @@ describe("POST /api/devices", () => { liveActivityPushToken: "feedface".repeat(8), }, }); - const legacyDevice = await prisma.device.create({ + await prisma.device.create({ data: { userId: user.id, platform: "ios", @@ -252,7 +254,9 @@ describe("POST /api/devices", () => { const body = (await response.json()) as { data: { id: string } }; expect(body.data.id).toBe(apnsDevice.id); - const devices = await prisma.device.findMany({ where: { userId: user.id } }); + const devices = await prisma.device.findMany({ + where: { userId: user.id }, + }); expect(devices).toHaveLength(1); expect(devices[0]).toMatchObject({ id: apnsDevice.id, diff --git a/tests/integration/measurement-identity-reconcile.test.ts b/tests/integration/measurement-identity-reconcile.test.ts index 451c4b0f6..4835692cf 100644 --- a/tests/integration/measurement-identity-reconcile.test.ts +++ b/tests/integration/measurement-identity-reconcile.test.ts @@ -110,9 +110,9 @@ describe("external measurement identity reconciliation (real Postgres)", () => { status: "updated", row: { id: occupied.id, externalId: "sleep:new" }, }); - expect( - await prisma.measurement.count({ where: { userId: user.id } }), - ).toBe(1); + expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe( + 1, + ); }); it("resurrects and re-keys a tombstone occupying the natural key", async () => { diff --git a/tests/integration/partial-unique-live-rows.test.ts b/tests/integration/partial-unique-live-rows.test.ts index 145c95052..c73ae9870 100644 --- a/tests/integration/partial-unique-live-rows.test.ts +++ b/tests/integration/partial-unique-live-rows.test.ts @@ -347,7 +347,9 @@ describe("MoodEntry — external-id re-import idempotency (v1.12.1 / 0122)", () mood: "OKAY", score: 3, }); - expect(await prisma.moodEntry.count({ where: { userId: user.id } })).toBe(1); + expect(await prisma.moodEntry.count({ where: { userId: user.id } })).toBe( + 1, + ); }); it("entries without an id still dedup on the legacy wall-clock key", async () => { diff --git a/tests/integration/provider-token-refresh-serialization.test.ts b/tests/integration/provider-token-refresh-serialization.test.ts index cc8afa424..88a38b2f1 100644 --- a/tests/integration/provider-token-refresh-serialization.test.ts +++ b/tests/integration/provider-token-refresh-serialization.test.ts @@ -79,10 +79,7 @@ async function seedProviderUser(provider: "whoop" | "withings") { return userId; } -async function runContenders( - provider: "whoop" | "withings", - userId: string, -) { +async function runContenders(provider: "whoop" | "withings", userId: string) { const getToken = provider === "whoop" ? getValidWhoopToken : getValidWithingsToken; return Promise.all( @@ -98,62 +95,73 @@ beforeEach(async () => { describe.each([ ["whoop", whoopRefresh], ["withings", withingsRefresh], -] as const)("%s rotating token refresh serialization (real Postgres)", (provider, refresh) => { - it("lets exactly one contender refresh a stale token and shares the winner", async () => { - const userId = await seedProviderUser(provider); - refresh.mockImplementation(async (refreshToken: string) => { - expect(refreshToken).toBe("stale-refresh"); - return { - access_token: "winner-access", - refresh_token: "winner-refresh", - expires_in: 3600, - }; - }); - - const results = await runContenders(provider, userId); - - expect(refresh).toHaveBeenCalledTimes(1); - expect(results).toHaveLength(CONTENDERS); - expect(results.every((result) => result?.accessToken === "winner-access")).toBe( - true, - ); - - const prisma = getPrismaClient(); - const row = - provider === "whoop" - ? await prisma.whoopConnection.findUniqueOrThrow({ where: { userId } }) - : await prisma.withingsConnection.findUniqueOrThrow({ where: { userId } }); - expect(decrypt(row.accessToken)).toBe("winner-access"); - expect(decrypt(row.refreshToken)).toBe("winner-refresh"); - }); - - it("rolls back a failed winner so a later contender can retry", async () => { - const userId = await seedProviderUser(provider); - refresh - .mockRejectedValueOnce(new Error("injected refresh failure")) - .mockResolvedValue({ - access_token: "retry-access", - refresh_token: "retry-refresh", - expires_in: 3600, +] as const)( + "%s rotating token refresh serialization (real Postgres)", + (provider, refresh) => { + it("lets exactly one contender refresh a stale token and shares the winner", async () => { + const userId = await seedProviderUser(provider); + refresh.mockImplementation(async (refreshToken: string) => { + expect(refreshToken).toBe("stale-refresh"); + return { + access_token: "winner-access", + refresh_token: "winner-refresh", + expires_in: 3600, + }; }); - const results = await runContenders(provider, userId); + const results = await runContenders(provider, userId); - expect(refresh).toHaveBeenCalledTimes(2); - expect(results.filter((result) => result === null)).toHaveLength(1); - expect( - results.filter((result) => result?.accessToken === "retry-access"), - ).toHaveLength(CONTENDERS - 1); + expect(refresh).toHaveBeenCalledTimes(1); + expect(results).toHaveLength(CONTENDERS); + expect( + results.every((result) => result?.accessToken === "winner-access"), + ).toBe(true); - const prisma = getPrismaClient(); - const row = - provider === "whoop" - ? await prisma.whoopConnection.findUniqueOrThrow({ where: { userId } }) - : await prisma.withingsConnection.findUniqueOrThrow({ where: { userId } }); - expect(decrypt(row.accessToken)).toBe("retry-access"); - expect(decrypt(row.refreshToken)).toBe("retry-refresh"); - }); -}); + const prisma = getPrismaClient(); + const row = + provider === "whoop" + ? await prisma.whoopConnection.findUniqueOrThrow({ + where: { userId }, + }) + : await prisma.withingsConnection.findUniqueOrThrow({ + where: { userId }, + }); + expect(decrypt(row.accessToken)).toBe("winner-access"); + expect(decrypt(row.refreshToken)).toBe("winner-refresh"); + }); + + it("rolls back a failed winner so a later contender can retry", async () => { + const userId = await seedProviderUser(provider); + refresh + .mockRejectedValueOnce(new Error("injected refresh failure")) + .mockResolvedValue({ + access_token: "retry-access", + refresh_token: "retry-refresh", + expires_in: 3600, + }); + + const results = await runContenders(provider, userId); + + expect(refresh).toHaveBeenCalledTimes(2); + expect(results.filter((result) => result === null)).toHaveLength(1); + expect( + results.filter((result) => result?.accessToken === "retry-access"), + ).toHaveLength(CONTENDERS - 1); + + const prisma = getPrismaClient(); + const row = + provider === "whoop" + ? await prisma.whoopConnection.findUniqueOrThrow({ + where: { userId }, + }) + : await prisma.withingsConnection.findUniqueOrThrow({ + where: { userId }, + }); + expect(decrypt(row.accessToken)).toBe("retry-access"); + expect(decrypt(row.refreshToken)).toBe("retry-refresh"); + }); + }, +); describe("WHOOP recovery cursor durability (real Postgres)", () => { const oldCursor = "2026-06-01T00:00:00.000Z";