mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge remote-tracking branch 'origin/work/side-effect-backup' into work/sharing-write-integration
This commit is contained in:
commit
7eed26eb01
10 files changed
+297
-12
No files matched your search
@@ -284,6 +284,19 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
},
|
||||
include: { schedules: true },
|
||||
});
|
||||
const sideEffect = await prisma.medicationSideEffect.create({
|
||||
data: {
|
||||
id: "side-effect-dr",
|
||||
userId: ownerId,
|
||||
medicationId: medication.id,
|
||||
occurredAt: new Date("2026-06-27T21:00:00.000Z"),
|
||||
category: "INJECTION_SITE",
|
||||
entry: "INJECTION_SWELLING",
|
||||
severity: 4,
|
||||
notesEncrypted: encryptToBytes("swollen for a day after the injection"),
|
||||
createdAt: new Date("2026-06-27T21:10:00.000Z"),
|
||||
},
|
||||
});
|
||||
const intake = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
id: "intake-dr",
|
||||
@@ -583,6 +596,15 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
where: { id: intake.id },
|
||||
}),
|
||||
).toEqual(intake);
|
||||
// Byte-for-byte, ciphertext included: a canonical payload carries the
|
||||
// encrypted note verbatim, so the same instance's key reads back exactly
|
||||
// what it wrote. Re-encrypting here would still decrypt to the same words
|
||||
// and would still be a different row than the one the file described.
|
||||
expect(
|
||||
await prisma.medicationSideEffect.findUniqueOrThrow({
|
||||
where: { id: sideEffect.id },
|
||||
}),
|
||||
).toEqual(sideEffect);
|
||||
expect(
|
||||
await prisma.cycleProfile.findUniqueOrThrow({
|
||||
where: { id: cycleProfile.id },
|
||||
|
||||
@@ -26,11 +26,18 @@
|
||||
* The registry below is keyed by `TwoEndedModel`, so a name added to the plan's
|
||||
* two-ended list without a row seeded and counted here does not compile.
|
||||
*
|
||||
* What this still does not prove: that a restored row carries the right VALUES.
|
||||
* It asks whether the rows came back, not whether they came back intact —
|
||||
* `admin-backups-canonical-roundtrip.test.ts` is where field-level fidelity is
|
||||
* asserted. A restore that wrote one row per model with every column defaulted
|
||||
* would satisfy this file and fail that one.
|
||||
* What this still does not prove, with one exception: that a restored row
|
||||
* carries the right VALUES. It asks whether the rows came back, not whether
|
||||
* they came back intact — `admin-backups-canonical-roundtrip.test.ts` is where
|
||||
* field-level fidelity is asserted. A restore that wrote one row per model with
|
||||
* every column defaulted would satisfy this file and fail that one.
|
||||
*
|
||||
* The exception is the medication side effect. Its note lives in an encrypted
|
||||
* column beside a legacy plaintext one, so "the row came back" and "the note
|
||||
* came back" are genuinely different answers here: a restore that dropped the
|
||||
* ciphertext would still be counted as recovered by everything above. The
|
||||
* severity, the category and the entry are asserted alongside it, because a
|
||||
* side effect without them says something happened and not what.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -40,6 +47,7 @@ process.env.ENCRYPTION_KEY ??=
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { encrypt, encryptBytes } from "@/lib/crypto";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
|
||||
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
||||
@@ -74,6 +82,7 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
|
||||
const OWNER_ID = "round-trip-owner";
|
||||
const AT = (iso: string) => new Date(iso);
|
||||
const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
@@ -94,6 +103,8 @@ const COUNT_BACK: Record<
|
||||
p.medicationSchedule.count({ where: { medication: { userId } } }),
|
||||
MedicationIntakeEvent: (p, userId) =>
|
||||
p.medicationIntakeEvent.count({ where: { userId } }),
|
||||
MedicationSideEffect: (p, userId) =>
|
||||
p.medicationSideEffect.count({ where: { userId } }),
|
||||
MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }),
|
||||
MoodEntryTagLink: (p, userId) =>
|
||||
p.moodEntryTagLink.count({ where: { moodEntry: { userId } } }),
|
||||
@@ -183,6 +194,19 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
takenAt: AT("2026-07-01T08:04:00.000Z"),
|
||||
},
|
||||
});
|
||||
// The one row in this fixture that is also checked field by field after the
|
||||
// restore — see the assertion at the end of the test for why.
|
||||
await prisma.medicationSideEffect.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
medicationId: medication.id,
|
||||
occurredAt: AT("2026-07-01T21:00:00.000Z"),
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 3,
|
||||
notesEncrypted: encryptToBytes(SIDE_EFFECT_NOTE),
|
||||
},
|
||||
});
|
||||
|
||||
// A RATED tag the account defined itself — the export carries only rated
|
||||
// links, so a BINARY tag would leave `MoodEntryTagLink` empty.
|
||||
@@ -477,5 +501,34 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
"without them, which is a backup that reports success and hands back " +
|
||||
"less than it was given",
|
||||
).toEqual([]);
|
||||
|
||||
const sideEffect = await prisma.medicationSideEffect.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
include: { medication: { select: { name: true, userId: true } } },
|
||||
});
|
||||
expect({
|
||||
category: sideEffect.category,
|
||||
entry: sideEffect.entry,
|
||||
severity: sideEffect.severity,
|
||||
occurredAt: sideEffect.occurredAt.toISOString(),
|
||||
medication: sideEffect.medication.name,
|
||||
medicationOwner: sideEffect.medication.userId,
|
||||
note: readNote(sideEffect.notesEncrypted, sideEffect.notes),
|
||||
}).toEqual({
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 3,
|
||||
occurredAt: "2026-07-01T21:00:00.000Z",
|
||||
medication: "Round-trip tablet",
|
||||
medicationOwner: OWNER_ID,
|
||||
note: SIDE_EFFECT_NOTE,
|
||||
});
|
||||
// The note must be BACK IN THE COLUMN it came from, not carried as
|
||||
// plaintext: a restore that writes a portable export's decrypted note into
|
||||
// `notes` reads identically through `readNote` above and has quietly
|
||||
// un-encrypted the account's free text.
|
||||
expect(sideEffect.notes, "plaintext must not come back in the column").toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user