mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
1 parent
b01822be48
commit
62eb431d66
372 files changed
+25178
-8561
No files matched your search
@@ -147,6 +147,125 @@ describe("ArrivalReaction — the day claim", () => {
|
||||
expect(row?.occurredAt.toISOString()).toBe(late.toISOString());
|
||||
});
|
||||
|
||||
it("replaces a generated marker and rejects the stale generation owner", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await createUser("arrival-replacement");
|
||||
const early = new Date("2026-07-14T06:00:00Z");
|
||||
const late = new Date("2026-07-14T18:00:00Z");
|
||||
const providerInvokedAt = new Date("2026-07-14T06:01:00Z");
|
||||
const existing = await prisma.arrivalReaction.create({
|
||||
data: {
|
||||
...marker(user.id, "weight", early),
|
||||
refId: "old-ref",
|
||||
lineEncrypted: Buffer.from("old-ciphertext"),
|
||||
generatedAt: new Date("2026-07-14T06:02:00Z"),
|
||||
generationClaimId: "old-claim",
|
||||
generationClaimedAt: providerInvokedAt,
|
||||
generationReservedTokens: 220,
|
||||
generationBudgetDateKey: "2026-07-14",
|
||||
generationProviderInvokedAt: providerInvokedAt,
|
||||
},
|
||||
});
|
||||
await prisma.coachUsage.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
dateKey: "2026-07-14",
|
||||
totalTokens: 220,
|
||||
messageCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { runDataArrival } = await import("@/lib/jobs/data-arrival");
|
||||
const outcome = await runDataArrival(prisma as never, {
|
||||
userId: user.id,
|
||||
kind: "weight",
|
||||
salience: "salient",
|
||||
localDate: LOCAL_DATE,
|
||||
occurredAt: late.toISOString(),
|
||||
count: 1,
|
||||
source: "withings",
|
||||
refId: "new-ref",
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({ status: "processed", dedup: false });
|
||||
const replaced = await prisma.arrivalReaction.findUniqueOrThrow({
|
||||
where: { id: existing.id },
|
||||
});
|
||||
expect(replaced).toMatchObject({
|
||||
occurredAt: late,
|
||||
refId: "new-ref",
|
||||
lineEncrypted: null,
|
||||
generatedAt: null,
|
||||
generationClaimId: null,
|
||||
generationClaimedAt: null,
|
||||
generationReservedTokens: null,
|
||||
generationBudgetDateKey: null,
|
||||
generationProviderInvokedAt: null,
|
||||
});
|
||||
|
||||
const staleCommit = await prisma.arrivalReaction.updateMany({
|
||||
where: {
|
||||
id: existing.id,
|
||||
generationClaimId: "old-claim",
|
||||
generationProviderInvokedAt: providerInvokedAt,
|
||||
},
|
||||
data: {
|
||||
lineEncrypted: Buffer.from("stale-ciphertext"),
|
||||
generatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
expect(staleCommit.count).toBe(0);
|
||||
|
||||
const usage = await prisma.coachUsage.findUniqueOrThrow({
|
||||
where: {
|
||||
userId_dateKey: { userId: user.id, dateKey: "2026-07-14" },
|
||||
},
|
||||
});
|
||||
expect(usage).toMatchObject({ totalTokens: 220, messageCount: 1 });
|
||||
});
|
||||
|
||||
it("refunds a superseded pre-provider reservation", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await createUser("arrival-reservation-refund");
|
||||
const early = new Date("2026-07-14T06:00:00Z");
|
||||
const late = new Date("2026-07-14T18:00:00Z");
|
||||
await prisma.arrivalReaction.create({
|
||||
data: {
|
||||
...marker(user.id, "weight", early),
|
||||
generationClaimId: "pre-provider-claim",
|
||||
generationClaimedAt: new Date("2026-07-14T06:01:00Z"),
|
||||
generationReservedTokens: 220,
|
||||
generationBudgetDateKey: "2026-07-14",
|
||||
},
|
||||
});
|
||||
await prisma.coachUsage.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
dateKey: "2026-07-14",
|
||||
totalTokens: 500,
|
||||
messageCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const { runDataArrival } = await import("@/lib/jobs/data-arrival");
|
||||
await runDataArrival(prisma as never, {
|
||||
userId: user.id,
|
||||
kind: "weight",
|
||||
salience: "salient",
|
||||
localDate: LOCAL_DATE,
|
||||
occurredAt: late.toISOString(),
|
||||
count: 1,
|
||||
source: "withings",
|
||||
});
|
||||
|
||||
const usage = await prisma.coachUsage.findUniqueOrThrow({
|
||||
where: {
|
||||
userId_dateKey: { userId: user.id, dateKey: "2026-07-14" },
|
||||
},
|
||||
});
|
||||
expect(usage).toMatchObject({ totalTokens: 280, messageCount: 1 });
|
||||
});
|
||||
|
||||
it("stores the line as encrypted bytes, and tolerates its absence", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await createUser("arrival-crypto");
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const RANGE = {
|
||||
start: new Date("2026-01-01T00:00:00.000Z"),
|
||||
end: new Date("2026-05-01T00:00:00.000Z"),
|
||||
days: 120,
|
||||
};
|
||||
|
||||
const MODULES = {
|
||||
cycle: false,
|
||||
mood: false,
|
||||
sleep: false,
|
||||
glucose: true,
|
||||
workouts: false,
|
||||
recovery: false,
|
||||
labs: false,
|
||||
illness: false,
|
||||
achievements: true,
|
||||
coach: true,
|
||||
insights: true,
|
||||
medications: true,
|
||||
doctorReport: true,
|
||||
environment: true,
|
||||
mcp: true,
|
||||
inboundDocuments: true,
|
||||
mentalHealth: true,
|
||||
nutrients: true,
|
||||
} as const;
|
||||
|
||||
const SECTIONS = {
|
||||
bp: false,
|
||||
weight: true,
|
||||
pulse: true,
|
||||
bmi: false,
|
||||
mood: false,
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
glucose: true,
|
||||
cycle: false,
|
||||
labs: false,
|
||||
allergies: false,
|
||||
familyHistory: false,
|
||||
} as const;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("doctor report — bounded dense measurement reads", () => {
|
||||
it("keeps dense transfer and points bounded by local days while sparse rows remain raw", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "doctor-report-dense",
|
||||
email: "doctor-report-dense@example.test",
|
||||
timezone: "America/New_York",
|
||||
sourcePriorityJson: {
|
||||
metricPriority: {
|
||||
pulse: ["WITHINGS", "MANUAL"],
|
||||
},
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const sparseRows = [
|
||||
{
|
||||
userId: user.id,
|
||||
type: "WEIGHT" as const,
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date("2026-01-02T04:15:00.000Z"),
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "WEIGHT" as const,
|
||||
value: 81,
|
||||
unit: "kg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date("2026-01-02T06:15:00.000Z"),
|
||||
},
|
||||
];
|
||||
await prisma.measurement.createMany({ data: sparseRows });
|
||||
|
||||
const seedDenseBatch = async (
|
||||
offset: number,
|
||||
samplesPerLocalDay: number,
|
||||
) => {
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
||||
const localDayInstants = [
|
||||
Date.parse("2026-01-02T04:30:00.000Z"), // Jan 1, 23:30 in New York
|
||||
Date.parse("2026-01-02T05:30:00.000Z"), // Jan 2, 00:30 in New York
|
||||
];
|
||||
for (const [dayIndex, baseMs] of localDayInstants.entries()) {
|
||||
for (let i = 0; i < samplesPerLocalDay; i += 1) {
|
||||
const measuredAt = new Date(baseMs + (offset + i) * 1_000);
|
||||
rows.push(
|
||||
{
|
||||
userId: user.id,
|
||||
type: "PULSE",
|
||||
value: 60 + dayIndex,
|
||||
unit: "bpm",
|
||||
source: "WITHINGS",
|
||||
measuredAt,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "PULSE",
|
||||
value: 160 + dayIndex,
|
||||
unit: "bpm",
|
||||
source: "MANUAL",
|
||||
measuredAt,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 90 + dayIndex,
|
||||
unit: "mg/dL",
|
||||
source: "MANUAL",
|
||||
glucoseContext: "FASTING",
|
||||
measuredAt,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await prisma.measurement.createMany({ data: rows });
|
||||
};
|
||||
|
||||
await seedDenseBatch(0, 4);
|
||||
const findManySpy = vi.spyOn(prisma.measurement, "findMany");
|
||||
|
||||
const initial = await collectDoctorReportData(user.id, RANGE, {
|
||||
sections: { ...SECTIONS },
|
||||
moduleMap: { ...MODULES },
|
||||
});
|
||||
|
||||
expect(initial.measurements.PULSE).toHaveLength(2);
|
||||
expect(initial.measurements.BLOOD_GLUCOSE).toHaveLength(2);
|
||||
expect(initial.measurements.PULSE.map((point) => point.measuredAt)).toEqual(
|
||||
["2026-01-01T05:00:00.000Z", "2026-01-02T05:00:00.000Z"],
|
||||
);
|
||||
expect(initial.stats.PULSE).toMatchObject({
|
||||
count: 8,
|
||||
avg: 60.5,
|
||||
min: 60,
|
||||
max: 61,
|
||||
});
|
||||
expect(initial.measurements.WEIGHT).toEqual(
|
||||
sparseRows.map((row) => ({
|
||||
value: row.value,
|
||||
measuredAt: row.measuredAt.toISOString(),
|
||||
})),
|
||||
);
|
||||
|
||||
const rawRows = (await findManySpy.mock.results[0]?.value) as Array<{
|
||||
type: string;
|
||||
}>;
|
||||
expect(rawRows).toHaveLength(sparseRows.length);
|
||||
expect(rawRows.map((row) => row.type)).toEqual(["WEIGHT", "WEIGHT"]);
|
||||
|
||||
await seedDenseBatch(10, 300);
|
||||
const grown = await collectDoctorReportData(user.id, RANGE, {
|
||||
sections: { ...SECTIONS },
|
||||
moduleMap: { ...MODULES },
|
||||
});
|
||||
|
||||
expect(grown.measurements.PULSE).toHaveLength(2);
|
||||
expect(grown.measurements.BLOOD_GLUCOSE).toHaveLength(2);
|
||||
expect(grown.stats.PULSE.count).toBe(608);
|
||||
expect(grown.stats.BLOOD_GLUCOSE.count).toBe(608);
|
||||
expect(grown.measurements.WEIGHT).toEqual(initial.measurements.WEIGHT);
|
||||
});
|
||||
});
|
||||
@@ -269,10 +269,10 @@ describe("document vault — policy error contract", () => {
|
||||
});
|
||||
|
||||
it("cannot be overshot by concurrent uploads racing the quota gate", async () => {
|
||||
// Four DISTINCT same-sized files fired in parallel against a quota that
|
||||
// fits exactly two. Without the per-user advisory lock in the upload
|
||||
// transaction every request reads the same pre-insert SUM and all four
|
||||
// land — a burst could overshoot the quota by rate-limit × cap.
|
||||
// The production admission gate allows two same-user uploads in flight,
|
||||
// so seed one file, then race two DISTINCT files for the one remaining
|
||||
// quota slot. Without the per-user advisory lock both contenders read the
|
||||
// same pre-insert SUM and land, overshooting the quota.
|
||||
const user = await seedVaultUser("vault-quota-race");
|
||||
const files = Array.from({ length: 4 }, (_, i) => {
|
||||
const filler = Buffer.alloc(2048, i + 1);
|
||||
@@ -285,11 +285,22 @@ describe("document vault — policy error contract", () => {
|
||||
});
|
||||
const { post } = await routes();
|
||||
|
||||
const results = await Promise.all(
|
||||
files.map((bytes, i) => post(uploadRequest(bytes, `race-${i}.pdf`))),
|
||||
const first = await post(uploadRequest(files[0], "race-0.pdf"));
|
||||
const contenders = await Promise.all(
|
||||
files
|
||||
.slice(1, 3)
|
||||
.map((bytes, i) => post(uploadRequest(bytes, `race-${i + 1}.pdf`))),
|
||||
);
|
||||
const last = await post(uploadRequest(files[3], "race-3.pdf"));
|
||||
const results = [first, ...contenders, last];
|
||||
const statuses = results.map((r) => r.status).sort();
|
||||
expect(statuses).toEqual([201, 201, 413, 413]);
|
||||
const bodies = await Promise.all(results.map((result) => result.json()));
|
||||
expect(
|
||||
bodies
|
||||
.filter((_, index) => results[index].status === 413)
|
||||
.map((body) => body.meta.reason),
|
||||
).toEqual(["quotaExceeded", "quotaExceeded"]);
|
||||
|
||||
const rows = await getPrismaClient().inboundDocument.findMany({
|
||||
where: { userId: user.id },
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import {
|
||||
_setMedicationIntakeImportPrismaForTests,
|
||||
processMedicationIntakeImportJob,
|
||||
type MedicationImportPayload,
|
||||
type MedicationImportProgress,
|
||||
} from "@/lib/jobs/medication-intake-import";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { toJson } from "@/lib/db";
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
|
||||
queueMedicationIntakeSync: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
_setMedicationIntakeImportPrismaForTests(prisma);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
_setMedicationIntakeImportPrismaForTests(null);
|
||||
});
|
||||
|
||||
describe("medication intake import job — concurrent retry", () => {
|
||||
it("produces one event and one inventory consumption per unique import key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "med-import-owner",
|
||||
email: "med-import-owner@example.test",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const medication = await prisma.medication.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Test medication",
|
||||
dose: "1 tablet",
|
||||
unitsPerDose: 1,
|
||||
},
|
||||
});
|
||||
const inventory = await prisma.medicationInventoryItem.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
unitsTotal: 10,
|
||||
unitsRemaining: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const payload: MedicationImportPayload = {
|
||||
entries: [
|
||||
{
|
||||
takenAt: "2026-07-20T05:00:00.000Z",
|
||||
idempotencyKey: `import-${medication.id}-1`,
|
||||
},
|
||||
{
|
||||
takenAt: "2026-07-20T05:00:00.000Z",
|
||||
idempotencyKey: `import-${medication.id}-1`,
|
||||
},
|
||||
{
|
||||
takenAt: "2026-07-20T17:00:00.000Z",
|
||||
idempotencyKey: `import-${medication.id}-2`,
|
||||
},
|
||||
{
|
||||
takenAt: "2026-07-21T05:00:00.000Z",
|
||||
idempotencyKey: `import-${medication.id}-3`,
|
||||
},
|
||||
],
|
||||
};
|
||||
const progress: MedicationImportProgress = {
|
||||
processed: 0,
|
||||
total: payload.entries.length,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
const job = await prisma.medicationIntakeImportJob.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
payload: toJson(payload),
|
||||
progress: toJson(progress),
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
processMedicationIntakeImportJob(job.id),
|
||||
processMedicationIntakeImportJob(job.id),
|
||||
]);
|
||||
await processMedicationIntakeImportJob(job.id);
|
||||
|
||||
const [events, refreshedInventory, rollups, refreshedJob, audits] =
|
||||
await Promise.all([
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: user.id, medicationId: medication.id },
|
||||
orderBy: { idempotencyKey: "asc" },
|
||||
}),
|
||||
prisma.medicationInventoryItem.findUniqueOrThrow({
|
||||
where: { id: inventory.id },
|
||||
}),
|
||||
prisma.medicationComplianceRollup.findMany({
|
||||
where: { userId: user.id, medicationId: medication.id },
|
||||
orderBy: { day: "asc" },
|
||||
}),
|
||||
prisma.medicationIntakeImportJob.findUniqueOrThrow({
|
||||
where: { id: job.id },
|
||||
}),
|
||||
prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
action: "medication.intake.import",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(events).toHaveLength(3);
|
||||
expect(events.map((event) => event.idempotencyKey)).toEqual([
|
||||
`import-${medication.id}-1`,
|
||||
`import-${medication.id}-2`,
|
||||
`import-${medication.id}-3`,
|
||||
]);
|
||||
expect(
|
||||
events.every(
|
||||
(event) =>
|
||||
Array.isArray(event.inventoryConsumption) &&
|
||||
event.inventoryConsumption.length === 1,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(Number(refreshedInventory.unitsRemaining)).toBe(7);
|
||||
expect(
|
||||
rollups.map(({ day, scheduled, taken }) => ({
|
||||
day,
|
||||
scheduled,
|
||||
taken,
|
||||
})),
|
||||
).toEqual([
|
||||
{ day: "2026-07-20", scheduled: 2, taken: 2 },
|
||||
{ day: "2026-07-21", scheduled: 1, taken: 1 },
|
||||
]);
|
||||
expect(refreshedJob.status).toBe("done");
|
||||
expect(refreshedJob.result).toEqual({
|
||||
imported: 3,
|
||||
skippedDuplicates: 1,
|
||||
skippedInvalid: 0,
|
||||
});
|
||||
expect(refreshedJob.progress).toEqual({
|
||||
processed: 4,
|
||||
total: 4,
|
||||
imported: 3,
|
||||
skippedDuplicates: 1,
|
||||
touchedDays: ["2026-07-20", "2026-07-21"],
|
||||
rollupProcessed: 2,
|
||||
});
|
||||
expect(audits).toHaveLength(1);
|
||||
expect(invalidateUserMedications).toHaveBeenCalledTimes(1);
|
||||
expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rolls final effects back with a failed terminal marker and retries once", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "med-import-finalize",
|
||||
email: "med-import-finalize@example.test",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const medication = await prisma.medication.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Finalize medication",
|
||||
dose: "1 tablet",
|
||||
unitsPerDose: 1,
|
||||
},
|
||||
});
|
||||
const payload: MedicationImportPayload = {
|
||||
entries: [
|
||||
{
|
||||
takenAt: "2026-07-20T05:00:00.000Z",
|
||||
idempotencyKey: `import-${medication.id}-final`,
|
||||
},
|
||||
],
|
||||
};
|
||||
const progress: MedicationImportProgress = {
|
||||
processed: 0,
|
||||
total: 1,
|
||||
imported: 0,
|
||||
skippedDuplicates: 0,
|
||||
touchedDays: [],
|
||||
rollupProcessed: 0,
|
||||
};
|
||||
const job = await prisma.medicationIntakeImportJob.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: medication.id,
|
||||
payload: toJson(payload),
|
||||
progress: toJson(progress),
|
||||
},
|
||||
});
|
||||
|
||||
let rejectTerminalMarker = true;
|
||||
const clientWithTerminalFailure = {
|
||||
$transaction: (
|
||||
operation: (tx: Prisma.TransactionClient) => Promise<unknown>,
|
||||
) =>
|
||||
prisma.$transaction(async (tx) => {
|
||||
const jobDelegate = new Proxy(tx.medicationIntakeImportJob, {
|
||||
get(target, property, receiver) {
|
||||
if (property !== "update") {
|
||||
return Reflect.get(target, property, receiver);
|
||||
}
|
||||
return async (
|
||||
args: Prisma.MedicationIntakeImportJobUpdateArgs,
|
||||
) => {
|
||||
if (rejectTerminalMarker && args.data.status === "done") {
|
||||
rejectTerminalMarker = false;
|
||||
throw new Error("terminal marker failed");
|
||||
}
|
||||
return tx.medicationIntakeImportJob.update(args);
|
||||
};
|
||||
},
|
||||
});
|
||||
const transaction = new Proxy(tx, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "medicationIntakeImportJob") {
|
||||
return jobDelegate;
|
||||
}
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
return operation(transaction);
|
||||
}),
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
_setMedicationIntakeImportPrismaForTests(clientWithTerminalFailure);
|
||||
await expect(processMedicationIntakeImportJob(job.id)).rejects.toThrow(
|
||||
"terminal marker failed",
|
||||
);
|
||||
|
||||
const afterFailedFinalization = await Promise.all([
|
||||
prisma.medicationIntakeEvent.count({
|
||||
where: { medicationId: medication.id },
|
||||
}),
|
||||
prisma.medicationComplianceRollup.count({
|
||||
where: { medicationId: medication.id },
|
||||
}),
|
||||
prisma.auditLog.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
action: "medication.intake.import",
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeImportJob.findUniqueOrThrow({
|
||||
where: { id: job.id },
|
||||
}),
|
||||
]);
|
||||
expect(afterFailedFinalization[0]).toBe(1);
|
||||
expect(afterFailedFinalization[1]).toBe(0);
|
||||
expect(afterFailedFinalization[2]).toBe(0);
|
||||
expect(afterFailedFinalization[3].status).toBe("running");
|
||||
|
||||
_setMedicationIntakeImportPrismaForTests(prisma);
|
||||
await processMedicationIntakeImportJob(job.id);
|
||||
await processMedicationIntakeImportJob(job.id);
|
||||
|
||||
const [events, rollups, audits, completedJob] = await Promise.all([
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { medicationId: medication.id },
|
||||
}),
|
||||
prisma.medicationComplianceRollup.findMany({
|
||||
where: { medicationId: medication.id },
|
||||
}),
|
||||
prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
action: "medication.intake.import",
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeImportJob.findUniqueOrThrow({
|
||||
where: { id: job.id },
|
||||
}),
|
||||
]);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(rollups).toHaveLength(1);
|
||||
expect(audits).toHaveLength(1);
|
||||
expect(completedJob.status).toBe("done");
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ vi.mock("@/lib/withings/client", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { getValidToken as getValidWhoopToken } from "@/lib/whoop/sync";
|
||||
import { getValidToken as getValidWhoopToken } from "@/lib/whoop/sync-core";
|
||||
import { syncUserRecovery } from "@/lib/whoop/sync-recovery";
|
||||
import { getValidToken as getValidWithingsToken } from "@/lib/withings/sync";
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
|
||||
"measurement_rollups",
|
||||
"measurements",
|
||||
"medication_intake_events",
|
||||
"medication_intake_import_jobs",
|
||||
"medication_schedules",
|
||||
"medications",
|
||||
"mood_entries",
|
||||
|
||||
@@ -459,4 +459,83 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => {
|
||||
expect(recent.some((r) => r.date === aucklandDay)).toBe(true);
|
||||
expect(recent.some((r) => r.date === berlinDay)).toBe(false);
|
||||
});
|
||||
it("buckets long dense measurement series by the user's local day", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const me = await prisma.user.create({
|
||||
data: {
|
||||
username: "la-series-user",
|
||||
email: "la-series@example.test",
|
||||
role: "USER",
|
||||
timezone: "America/Los_Angeles",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: me.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
const base = new Date();
|
||||
base.setUTCDate(base.getUTCDate() - 2);
|
||||
base.setUTCHours(0, 30, 0, 0);
|
||||
const samples = [
|
||||
{ measuredAt: base, value: 10 },
|
||||
{
|
||||
measuredAt: new Date(base.getTime() + 8 * 60 * 60_000),
|
||||
value: 30,
|
||||
},
|
||||
{
|
||||
measuredAt: new Date(base.getTime() + 24 * 60 * 60_000),
|
||||
value: 50,
|
||||
},
|
||||
{
|
||||
measuredAt: new Date(base.getTime() + 32 * 60 * 60_000),
|
||||
value: 70,
|
||||
},
|
||||
];
|
||||
await prisma.measurement.createMany({
|
||||
data: samples.map((sample) => ({
|
||||
userId: me.id,
|
||||
type: "PULSE" as const,
|
||||
value: sample.value,
|
||||
unit: "bpm",
|
||||
measuredAt: sample.measuredAt,
|
||||
source: "MANUAL" as const,
|
||||
})),
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/measurements/series/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/measurements/series?kind=pulse&days=91",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
points: Array<{ id: string; at: string; value: number }>;
|
||||
stats: { count: number };
|
||||
};
|
||||
};
|
||||
const { userDayKey } = await import("@/lib/tz/resolver");
|
||||
const expected = [
|
||||
{ day: userDayKey(samples[0].measuredAt, me.timezone), value: 10 },
|
||||
{ day: userDayKey(samples[1].measuredAt, me.timezone), value: 40 },
|
||||
{ day: userDayKey(samples[3].measuredAt, me.timezone), value: 70 },
|
||||
];
|
||||
|
||||
expect(body.data.points).toHaveLength(3);
|
||||
expect(
|
||||
body.data.points.map((point) => ({
|
||||
day: point.id.replace("day:", ""),
|
||||
value: point.value,
|
||||
})),
|
||||
).toEqual(expected);
|
||||
expect(
|
||||
body.data.points.map((point) =>
|
||||
userDayKey(new Date(point.at), me.timezone),
|
||||
),
|
||||
).toEqual(expected.map(({ day }) => day));
|
||||
expect(body.data.stats.count).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
const WHOOP_OWNER_MIGRATION_SQL = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"prisma/migrations/0264_whoop_owner_identity/migration.sql",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
async function createUser(tag: string) {
|
||||
const prisma = getPrismaClient();
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
username: `whoop-owner-${tag}`,
|
||||
email: `whoop-owner-${tag}@example.test`,
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("WHOOP provider identity ownership — integration", () => {
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
it("prevents one WHOOP user id from belonging to two local users", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const firstUser = await createUser("first");
|
||||
const secondUser = await createUser("second");
|
||||
const tokenExpiresAt = new Date("2026-08-01T00:00:00.000Z");
|
||||
|
||||
await prisma.whoopConnection.create({
|
||||
data: {
|
||||
userId: firstUser.id,
|
||||
whoopUserId: "shared-provider-user",
|
||||
accessToken: "encrypted-access-1",
|
||||
refreshToken: "encrypted-refresh-1",
|
||||
tokenExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
prisma.whoopConnection.create({
|
||||
data: {
|
||||
userId: secondUser.id,
|
||||
whoopUserId: "shared-provider-user",
|
||||
accessToken: "encrypted-access-2",
|
||||
refreshToken: "encrypted-refresh-2",
|
||||
tokenExpiresAt,
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "P2002" });
|
||||
});
|
||||
|
||||
it("keeps the earliest duplicate deterministically when the migration is applied", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const winnerUser = await createUser("migration-winner");
|
||||
const tiedLoserUser = await createUser("migration-tied-loser");
|
||||
const laterLoserUser = await createUser("migration-later-loser");
|
||||
const earliest = new Date("2026-01-01T00:00:00.000Z");
|
||||
const later = new Date("2026-01-02T00:00:00.000Z");
|
||||
const tokenExpiresAt = new Date("2026-08-01T00:00:00.000Z");
|
||||
|
||||
await prisma.$executeRawUnsafe(
|
||||
'DROP INDEX "whoop_connections_whoop_user_id_key"',
|
||||
);
|
||||
await prisma.whoopConnection.createMany({
|
||||
data: [
|
||||
{
|
||||
id: "a-earliest",
|
||||
userId: winnerUser.id,
|
||||
whoopUserId: "duplicate-provider-user",
|
||||
accessToken: "encrypted-access-1",
|
||||
refreshToken: "encrypted-refresh-1",
|
||||
tokenExpiresAt,
|
||||
createdAt: earliest,
|
||||
updatedAt: earliest,
|
||||
},
|
||||
{
|
||||
id: "z-earliest",
|
||||
userId: tiedLoserUser.id,
|
||||
whoopUserId: "duplicate-provider-user",
|
||||
accessToken: "encrypted-access-2",
|
||||
refreshToken: "encrypted-refresh-2",
|
||||
tokenExpiresAt,
|
||||
createdAt: earliest,
|
||||
updatedAt: earliest,
|
||||
},
|
||||
{
|
||||
id: "0-later",
|
||||
userId: laterLoserUser.id,
|
||||
whoopUserId: "duplicate-provider-user",
|
||||
accessToken: "encrypted-access-3",
|
||||
refreshToken: "encrypted-refresh-3",
|
||||
tokenExpiresAt,
|
||||
createdAt: later,
|
||||
updatedAt: later,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.$executeRawUnsafe(WHOOP_OWNER_MIGRATION_SQL);
|
||||
|
||||
const connections = await prisma.whoopConnection.findMany({
|
||||
where: { id: { in: ["a-earliest", "z-earliest", "0-later"] } },
|
||||
orderBy: { id: "asc" },
|
||||
select: { id: true, whoopUserId: true },
|
||||
});
|
||||
expect(connections).toEqual([
|
||||
{ id: "0-later", whoopUserId: null },
|
||||
{ id: "a-earliest", whoopUserId: "duplicate-provider-user" },
|
||||
{ id: "z-earliest", whoopUserId: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -336,15 +336,9 @@ describe("POST /api/workouts/batch (real Postgres)", () => {
|
||||
expect(stored).toBe(0);
|
||||
});
|
||||
|
||||
it("collapses same-instant cross-source twins via write-time dedup, then surfaces re-posts as duplicates", async () => {
|
||||
it("preserves same-source workouts and surfaces exact re-posts as duplicates", async () => {
|
||||
const { POST } = await import("@/app/api/workouts/batch/route");
|
||||
|
||||
// Two entries share `sportType`, `startedAt`, and `source`. The
|
||||
// v1.4.42 write-time cross-source picker (`dedupeWorkoutBatch`)
|
||||
// groups by `(userId, activityType, startedAt ± 90 s)` and keeps
|
||||
// ONE canonical row — the other lands as `duplicate` BEFORE
|
||||
// reaching the DB. This is the Apple Watch + Withings ScanWatch
|
||||
// case the picker exists to handle.
|
||||
const body = {
|
||||
workouts: [baseWorkout("hk-uuid-dup-1"), baseWorkout("hk-uuid-dup-2")],
|
||||
};
|
||||
@@ -358,8 +352,8 @@ describe("POST /api/workouts/batch (real Postgres)", () => {
|
||||
entries: Array<{ status: string }>;
|
||||
};
|
||||
};
|
||||
expect(firstJson.data.inserted).toBe(1);
|
||||
expect(firstJson.data.duplicates).toBe(1);
|
||||
expect(firstJson.data.inserted).toBe(2);
|
||||
expect(firstJson.data.duplicates).toBe(0);
|
||||
|
||||
const second = await POST(makeRequest(body));
|
||||
expect(second.status).toBe(200);
|
||||
@@ -370,9 +364,6 @@ describe("POST /api/workouts/batch (real Postgres)", () => {
|
||||
entries: Array<{ status: string }>;
|
||||
};
|
||||
};
|
||||
// The write-time dedup still fires on the second post (one twin
|
||||
// collapses), and the surviving twin now collides with the row
|
||||
// persisted by the first post — so both end up as `duplicate`.
|
||||
expect(secondJson.data.inserted).toBe(0);
|
||||
expect(secondJson.data.duplicates).toBe(2);
|
||||
expect(secondJson.data.entries.every((e) => e.status === "duplicate")).toBe(
|
||||
@@ -382,9 +373,7 @@ describe("POST /api/workouts/batch (real Postgres)", () => {
|
||||
const stored = await getPrismaClient().workout.count({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
// Only the first-post survivor lives in the DB — write-time dedup
|
||||
// dropped the other twin before any DB write.
|
||||
expect(stored).toBe(1);
|
||||
expect(stored).toBe(2);
|
||||
});
|
||||
|
||||
it("replays a cached response when the same Idempotency-Key is reused", async () => {
|
||||
|
||||
@@ -85,12 +85,16 @@ function makeRequest(body: { workouts: WorkoutFixture[] }): NextRequest {
|
||||
});
|
||||
}
|
||||
|
||||
function workout(externalId: string, minuteOffset: number): WorkoutFixture {
|
||||
function workout(
|
||||
externalId: string,
|
||||
minuteOffset: number,
|
||||
source = "APPLE_HEALTH",
|
||||
): WorkoutFixture {
|
||||
return {
|
||||
sportType: "running",
|
||||
startedAt: new Date(2026, 4, 14, 6, minuteOffset).toISOString(),
|
||||
endedAt: new Date(2026, 4, 14, 7, minuteOffset).toISOString(),
|
||||
source: "APPLE_HEALTH",
|
||||
source,
|
||||
externalId,
|
||||
};
|
||||
}
|
||||
@@ -177,20 +181,20 @@ describe("POST /api/workouts/batch — concurrent-write race", () => {
|
||||
it("two batches with one overlapping externalId resolve to a single DB row each", async () => {
|
||||
const { POST } = await import("@/app/api/workouts/batch/route");
|
||||
|
||||
// Batch A — two entries one minute apart plus one shared key. The
|
||||
// v1.4.42 `dedupeWorkoutBatch` picker collapses the minute-1 +
|
||||
// minute-2 pair (same activity, within the 90 s window) to one
|
||||
// Batch A — a cross-source pair one minute apart plus one shared key.
|
||||
// The v1.4.42 `dedupeWorkoutBatch` picker collapses the Apple Health +
|
||||
// Manual pair (same activity, within the 90 s window) to one
|
||||
// surviving row, leaving 2 effective rows per batch.
|
||||
const batchA: WorkoutFixture[] = [
|
||||
workout("hk-uuid-only-a-0", 1),
|
||||
workout("hk-uuid-only-a-1", 2),
|
||||
workout("hk-uuid-only-a-1", 2, "MANUAL"),
|
||||
workout("hk-uuid-shared", 10),
|
||||
];
|
||||
// Batch B — same shape, different externalIds for the unique pair
|
||||
// but the SAME shared key.
|
||||
// Batch B — same cross-source shape, different externalIds for the
|
||||
// unique pair but the SAME shared key.
|
||||
const batchB: WorkoutFixture[] = [
|
||||
workout("hk-uuid-only-b-0", 21),
|
||||
workout("hk-uuid-only-b-1", 22),
|
||||
workout("hk-uuid-only-b-1", 22, "MANUAL"),
|
||||
workout("hk-uuid-shared", 10),
|
||||
];
|
||||
|
||||
@@ -202,16 +206,24 @@ describe("POST /api/workouts/batch — concurrent-write race", () => {
|
||||
expect(resB.status).toBe(200);
|
||||
|
||||
const aJson = (await resA.json()) as {
|
||||
data: { inserted: number; duplicates: number };
|
||||
data: { processed: number; inserted: number; duplicates: number };
|
||||
};
|
||||
const bJson = (await resB.json()) as typeof aJson;
|
||||
|
||||
// Three unique tuples land in the DB after write-time dedup + the
|
||||
// composite unique index: one survivor from A's minute-1/2 pair,
|
||||
// one survivor from B's minute-21/22 pair, and one shared-key row
|
||||
// Three unique tuples land in the DB after write-time cross-source
|
||||
// dedup + the composite unique index: one survivor from A's minute-1/2
|
||||
// pair, one survivor from B's minute-21/22 pair, and one shared-key row
|
||||
// (either as A's "inserted" or B's "inserted" — never both). The
|
||||
// sum of the two batches' inserted counts equals the row count.
|
||||
expect(aJson.data.inserted + bJson.data.inserted).toBe(3);
|
||||
// The two canonical drops plus the losing shared-key insert must be
|
||||
// observable as duplicates rather than being counted as inserts.
|
||||
expect(aJson.data.duplicates + bJson.data.duplicates).toBe(3);
|
||||
for (const json of [aJson, bJson]) {
|
||||
expect(json.data.inserted + json.data.duplicates).toBe(
|
||||
json.data.processed,
|
||||
);
|
||||
}
|
||||
|
||||
const stored = await getPrismaClient().workout.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
|
||||
Reference in new issue
Block a user