mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(backup): carry the mood day context through export and restore
- the completeness guard now binds a third model, MoodContext, to a named select; the four omitted columns each carry the reason the backup is right to drop them - the disaster-recovery arm carries the ciphertext, the portable arm the plain text, and every other column rides in both - the restore rebuilds the context after its entry, inside the same transaction, and a file written before contexts existed restores without one - the round trip asserts the restored row by value, zero included
This commit is contained in:
1 parent
a25ec211cd
commit
8eb36da228
5 files changed
+420
-3
No files matched your search
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Pair guard binding `prisma/schema.prisma`'s `MoodEntry` and
|
||||
* `MoodEntryTagLink` models to the disaster-recovery backup selects.
|
||||
* Pair guard binding `prisma/schema.prisma`'s `MoodEntry`,
|
||||
* `MoodEntryTagLink` and `MoodContext` models to the disaster-recovery backup
|
||||
* selects.
|
||||
*
|
||||
* The sibling of `measurement-backup-completeness.test.ts`, written as its own
|
||||
* file rather than as a second describe inside it, because that guard's own
|
||||
@@ -27,7 +28,8 @@
|
||||
* this goes red naming that column; add a scalar to the model without adding
|
||||
* it to the select and it goes red naming the new one; rename the mapper's
|
||||
* `moodEntry` parameter and the serialization assertion goes red on all of
|
||||
* them at once.
|
||||
* them at once. The same three hold for `MOOD_CONTEXT_BACKUP_SELECT` and the
|
||||
* `moodContext` mapper parameter beside it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -52,6 +54,16 @@ const EXCLUDED_MOOD_ENTRY_COLUMNS: Record<string, string> = {
|
||||
userId: "user-scoped backup; owner is re-derived from the restoring account",
|
||||
};
|
||||
|
||||
const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record<string, string> = {
|
||||
moodEntryId:
|
||||
"re-bound to the restored parent row's id; the context rides inside its entry",
|
||||
userId: "user-scoped backup; owner is re-derived from the restoring account",
|
||||
createdAt:
|
||||
"the sub-row's own bookkeeping stamp; the entry carries the moment the person logged the day, and the context has nothing separate to say about when it was typed",
|
||||
updatedAt:
|
||||
"the sub-row's own bookkeeping stamp, moved by every write; a restore re-stamps it and nothing reads the old value",
|
||||
};
|
||||
|
||||
const EXCLUDED_TAG_LINK_COLUMNS: Record<string, string> = {
|
||||
moodEntryId: "re-bound to the restored parent row's id, never carried",
|
||||
moodTagId:
|
||||
@@ -173,6 +185,11 @@ describe("mood backup completeness", () => {
|
||||
models,
|
||||
);
|
||||
const linkSelected = selectFields(source, "MOOD_ENTRY_TAG_LINK_SELECT");
|
||||
const contextColumns = scalarFields(
|
||||
modelBlock(schema, "MoodContext"),
|
||||
models,
|
||||
);
|
||||
const contextSelected = selectFields(source, "MOOD_CONTEXT_BACKUP_SELECT");
|
||||
|
||||
it("parses both ends into a plausible shape", () => {
|
||||
expect(models.size).toBeGreaterThan(50);
|
||||
@@ -194,6 +211,47 @@ describe("mood backup completeness", () => {
|
||||
expect(linkColumns).not.toContain("moodEntry");
|
||||
expect(linkColumns).not.toContain("moodTag");
|
||||
expect(linkSelected.length).toBeGreaterThan(0);
|
||||
|
||||
expect(contextColumns.length).toBeGreaterThan(15);
|
||||
expect(contextColumns).toContain("notesEncrypted");
|
||||
expect(contextColumns).toContain("eventAt");
|
||||
expect(contextColumns).not.toContain("moodEntry");
|
||||
expect(contextColumns).not.toContain("user");
|
||||
expect(contextSelected.length).toBeGreaterThan(15);
|
||||
});
|
||||
|
||||
it("the backup select covers every MoodContext column", () => {
|
||||
const missing = contextColumns.filter(
|
||||
(c) =>
|
||||
!contextSelected.includes(c) && !(c in EXCLUDED_MOOD_CONTEXT_COLUMNS),
|
||||
);
|
||||
|
||||
if (missing.length > 0) {
|
||||
const report = missing.map((c) => ` ❌ ${c}`).join("\n");
|
||||
throw new Error(
|
||||
`MOOD_CONTEXT_BACKUP_SELECT omits ${missing.length} column(s) of the MoodContext model:\n${report}\n\n` +
|
||||
"A disaster-recovery restore brings these back as NULL, which reads " +
|
||||
"as an answer nobody gave rather than as a lost one. Add each to " +
|
||||
"the select and to both serialization arms, extend " +
|
||||
"`moodContextSchema` in the backup validations and the restore " +
|
||||
"write — or add it to EXCLUDED_MOOD_CONTEXT_COLUMNS with the reason " +
|
||||
"the backup is correct to drop it.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("every selected MoodContext column reaches a serialized payload", () => {
|
||||
const emitted = serialisedFields(source, "moodContext");
|
||||
const dropped = contextSelected.filter((c) => !emitted.has(c));
|
||||
|
||||
if (dropped.length > 0) {
|
||||
const report = dropped.map((c) => ` ❌ ${c}`).join("\n");
|
||||
throw new Error(
|
||||
`${dropped.length} selected MoodContext column(s) reach neither serialization arm:\n${report}\n\n` +
|
||||
"The query pays for them and no payload carries them, so a restore " +
|
||||
"cannot put them back.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("the backup select covers every MoodEntry column", () => {
|
||||
@@ -290,5 +348,14 @@ describe("mood backup completeness", () => {
|
||||
).toContain(column);
|
||||
expect(reason.length).toBeGreaterThan(20);
|
||||
}
|
||||
for (const [column, reason] of Object.entries(
|
||||
EXCLUDED_MOOD_CONTEXT_COLUMNS,
|
||||
)) {
|
||||
expect(
|
||||
contextColumns,
|
||||
`${column} is excluded but is no longer a MoodContext column`,
|
||||
).toContain(column);
|
||||
expect(reason.length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -858,6 +858,50 @@ const handler = apiHandler(
|
||||
update: restoredData,
|
||||
});
|
||||
|
||||
// The day context, rebuilt after the entry so it can bind to
|
||||
// whatever id that entry actually got. Delete-then-write rather
|
||||
// than an upsert with a preserve-when-absent arm: the file
|
||||
// states the whole context or states that there was none, and a
|
||||
// restored entry carrying half a file's context and half a live
|
||||
// row's would be a state neither of them describes.
|
||||
await tx.moodContext.deleteMany({
|
||||
where: { moodEntryId: restored.id },
|
||||
});
|
||||
if (entry.context) {
|
||||
const context = entry.context;
|
||||
await tx.moodContext.create({
|
||||
data: {
|
||||
moodEntryId: restored.id,
|
||||
userId: ownerId,
|
||||
workStatus: context.workStatus ?? null,
|
||||
workMinutes: context.workMinutes ?? null,
|
||||
overtimeMinutes: context.overtimeMinutes ?? null,
|
||||
workLoad: context.workLoad ?? null,
|
||||
workSatisfaction: context.workSatisfaction ?? null,
|
||||
contactCircles: context.contactCircles ?? null,
|
||||
contactForm: context.contactForm ?? null,
|
||||
contactExtent: context.contactExtent ?? null,
|
||||
contactQuality: context.contactQuality ?? null,
|
||||
contactSupport: context.contactSupport ?? null,
|
||||
leisureCategories: context.leisureCategories ?? null,
|
||||
leisureMinutes: context.leisureMinutes ?? null,
|
||||
leisureJoy: context.leisureJoy ?? null,
|
||||
leisureRecovery: context.leisureRecovery ?? null,
|
||||
eventType: context.eventType ?? null,
|
||||
eventValence: context.eventValence ?? null,
|
||||
eventAt: context.eventAt ? new Date(context.eventAt) : null,
|
||||
// The note comes back the way every other dual-column
|
||||
// note does: ciphertext verbatim when the file carries
|
||||
// it, a portable file's plain text re-encrypted on the
|
||||
// way in.
|
||||
notesEncrypted:
|
||||
context.notesEncrypted == null
|
||||
? encryptNote(context.note ?? null)
|
||||
: decodeEncryptedBytes(context.notesEncrypted),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.moodEntryTagLink.deleteMany({
|
||||
where: { moodEntryId: restored.id },
|
||||
});
|
||||
|
||||
@@ -148,6 +148,96 @@ const MOOD_ENTRY_TAG_LINK_SELECT = {
|
||||
moodTag: { select: { key: true, kind: true } },
|
||||
} as const satisfies Prisma.MoodEntryTagLinkSelect;
|
||||
|
||||
/**
|
||||
* Every restorable `MoodContext` scalar.
|
||||
*
|
||||
* `moodEntryId` and `userId` are absent for the reasons the exclusion list in
|
||||
* `mood-backup-completeness.test.ts` states: the context rides inside its
|
||||
* entry and is re-bound to whatever id that entry got, and the owner is
|
||||
* re-derived from the restoring account. The two row stamps are absent for the
|
||||
* third reason there — they describe when the sub-row was written, not when
|
||||
* the day happened, and the entry already carries the moment that matters.
|
||||
*
|
||||
* Named rather than inlined so the guard can compare it field-by-field against
|
||||
* the model, exactly as it does for the two selects above. This is the whole
|
||||
* point of the pair: a column added to `MoodContext` without a line here
|
||||
* fails, and it fails in the release that added it.
|
||||
*/
|
||||
const MOOD_CONTEXT_BACKUP_SELECT = {
|
||||
workStatus: true,
|
||||
workMinutes: true,
|
||||
overtimeMinutes: true,
|
||||
workLoad: true,
|
||||
workSatisfaction: true,
|
||||
contactCircles: true,
|
||||
contactForm: true,
|
||||
contactExtent: true,
|
||||
contactQuality: true,
|
||||
contactSupport: true,
|
||||
leisureCategories: true,
|
||||
leisureMinutes: true,
|
||||
leisureJoy: true,
|
||||
leisureRecovery: true,
|
||||
eventType: true,
|
||||
eventValence: true,
|
||||
eventAt: true,
|
||||
notesEncrypted: true,
|
||||
} as const satisfies Prisma.MoodContextSelect;
|
||||
|
||||
type BackupMoodContext = Prisma.MoodContextGetPayload<{
|
||||
select: typeof MOOD_CONTEXT_BACKUP_SELECT;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* One context row, in whichever of the two wire shapes the caller asked for.
|
||||
*
|
||||
* The split is the same one `Measurement` makes: a disaster-recovery file
|
||||
* carries the ciphertext verbatim, because the instance that reads it back
|
||||
* holds the key; a portable export carries the plain text, because the point
|
||||
* of a portable file is that somebody can read it. Every other column rides in
|
||||
* both, since none of them is a storage detail — they are all answers somebody
|
||||
* gave.
|
||||
*
|
||||
* The parameter is spelled `moodContext` rather than `c`, deliberately: the
|
||||
* completeness guard proves a selected column reaches a payload by matching
|
||||
* `moodContext.<column>`, and a one-letter parameter shared with another
|
||||
* mapper in this file would make that matcher unable to fail.
|
||||
*/
|
||||
function serialiseMoodContext(
|
||||
moodContext: BackupMoodContext,
|
||||
disasterRecovery: boolean,
|
||||
) {
|
||||
return {
|
||||
...(disasterRecovery
|
||||
? {
|
||||
notesEncrypted: moodContext.notesEncrypted
|
||||
? Buffer.from(moodContext.notesEncrypted).toString("base64")
|
||||
: null,
|
||||
}
|
||||
: { note: readNote(moodContext.notesEncrypted, null) }),
|
||||
workStatus: moodContext.workStatus,
|
||||
workMinutes: moodContext.workMinutes,
|
||||
overtimeMinutes: moodContext.overtimeMinutes,
|
||||
workLoad: moodContext.workLoad,
|
||||
workSatisfaction: moodContext.workSatisfaction,
|
||||
// The two multi-selects ride as the stored JSON string, unparsed. The
|
||||
// restore writes the column back verbatim, so a file cannot arrive with a
|
||||
// list the parser and the column disagree about.
|
||||
contactCircles: moodContext.contactCircles,
|
||||
contactForm: moodContext.contactForm,
|
||||
contactExtent: moodContext.contactExtent,
|
||||
contactQuality: moodContext.contactQuality,
|
||||
contactSupport: moodContext.contactSupport,
|
||||
leisureCategories: moodContext.leisureCategories,
|
||||
leisureMinutes: moodContext.leisureMinutes,
|
||||
leisureJoy: moodContext.leisureJoy,
|
||||
leisureRecovery: moodContext.leisureRecovery,
|
||||
eventType: moodContext.eventType,
|
||||
eventValence: moodContext.eventValence,
|
||||
eventAt: moodContext.eventAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical full-backup payload for `userId`. Portable exports omit
|
||||
* tombstones and document ciphertext; disaster-recovery payloads preserve
|
||||
@@ -259,6 +349,9 @@ export async function buildFullBackupPayload(
|
||||
// definition — so the present/absent tags, the kind most people pick,
|
||||
// were absent from the file and could not come back from it.
|
||||
tagLinks: { select: MOOD_ENTRY_TAG_LINK_SELECT },
|
||||
// The day context, when the entry has one. Sparse by design — most
|
||||
// entries carry none, and the join answers null on those.
|
||||
context: { select: MOOD_CONTEXT_BACKUP_SELECT },
|
||||
},
|
||||
}),
|
||||
// The account's OWN mood tags. The seeded catalogue is reference data every
|
||||
@@ -505,6 +598,12 @@ export async function buildFullBackupPayload(
|
||||
deletedAt: moodEntry.deletedAt?.toISOString() ?? null,
|
||||
factors,
|
||||
structuredTags,
|
||||
// The day context, or null. Null rather than an empty object, because
|
||||
// "no context" and "a context whose every field is empty" are
|
||||
// different facts and the restore has to keep them apart.
|
||||
context: moodEntry.context
|
||||
? serialiseMoodContext(moodEntry.context, disasterRecovery)
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
customMoodTags: customMoodTags.map((tag) => ({
|
||||
|
||||
@@ -287,6 +287,45 @@ const moodEntrySchema = z
|
||||
// RATED-only meaning, so every file written before this one parses
|
||||
// unchanged and restores identically: absent reads as none.
|
||||
structuredTags: z.array(z.string().min(1)).default([]),
|
||||
// The day context, when the entry had one. Optional and nullable at the
|
||||
// top: a file written before this existed carries no `context` key at all
|
||||
// and must parse and restore exactly as it did. Every field inside is
|
||||
// optional for the same reason a context row's columns are nullable —
|
||||
// only the sections somebody opened say anything.
|
||||
//
|
||||
// The two multi-selects ride as the stored JSON string rather than as
|
||||
// arrays, so the restore writes the column back verbatim and a file cannot
|
||||
// arrive with a list the parser and the column disagree about. Nothing is
|
||||
// re-validated against the vocabulary here: a backup restores what the
|
||||
// account had, and a key retired between the export and the restore is
|
||||
// still that person's answer.
|
||||
context: z
|
||||
.object({
|
||||
workStatus: z.string().nullable().optional(),
|
||||
workMinutes: z.number().int().nullable().optional(),
|
||||
overtimeMinutes: z.number().int().nullable().optional(),
|
||||
workLoad: z.number().int().nullable().optional(),
|
||||
workSatisfaction: z.number().int().nullable().optional(),
|
||||
contactCircles: z.string().nullable().optional(),
|
||||
contactForm: z.string().nullable().optional(),
|
||||
contactExtent: z.string().nullable().optional(),
|
||||
contactQuality: z.number().int().nullable().optional(),
|
||||
contactSupport: z.number().int().nullable().optional(),
|
||||
leisureCategories: z.string().nullable().optional(),
|
||||
leisureMinutes: z.number().int().nullable().optional(),
|
||||
leisureJoy: z.number().int().nullable().optional(),
|
||||
leisureRecovery: z.number().int().nullable().optional(),
|
||||
eventType: z.string().nullable().optional(),
|
||||
eventValence: z.number().int().nullable().optional(),
|
||||
eventAt: isoDateTime.nullable().optional(),
|
||||
// Same pair as the entry's own note: a portable file carries the
|
||||
// plain text, a disaster-recovery file the ciphertext.
|
||||
note: z.string().nullable().optional(),
|
||||
notesEncrypted: base64BytesSchema.nullable().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ const LOGGED_AT = new Date("2026-07-19T23:40:00.000Z");
|
||||
const CREATED_AT = new Date("2026-07-19T23:41:00.000Z");
|
||||
const UPDATED_AT = new Date("2026-07-20T06:05:00.000Z");
|
||||
const DELETED_AT = new Date("2026-07-20T07:00:00.000Z");
|
||||
const CONTEXT_NOTE = "the meeting overran and the evening went with it";
|
||||
const CONTEXT_EVENT_AT = new Date("2026-07-19T16:30:00.000Z");
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
@@ -214,6 +216,29 @@ describe("mood backup round trip", () => {
|
||||
{ moodTagId: ownTag.id, rating: null },
|
||||
],
|
||||
},
|
||||
// The day context, with one value from each of the four sections plus
|
||||
// its own encrypted note. A zero is in there for the same reason it is
|
||||
// among the level-A values above: zero is a real answer and a carrier
|
||||
// that read it as absence would round-trip it to NULL and pass every
|
||||
// truthiness check on the way.
|
||||
context: {
|
||||
create: {
|
||||
userId: OWNER_ID,
|
||||
workStatus: "overtime",
|
||||
workMinutes: 600,
|
||||
workLoad: 8,
|
||||
workSatisfaction: 0,
|
||||
contactCircles: JSON.stringify(["partner", "friends"]),
|
||||
contactForm: "phone",
|
||||
contactQuality: 7,
|
||||
leisureCategories: JSON.stringify(["reading"]),
|
||||
leisureMinutes: 30,
|
||||
eventType: "conflict",
|
||||
eventValence: -4,
|
||||
eventAt: CONTEXT_EVENT_AT,
|
||||
notesEncrypted: encryptNote(CONTEXT_NOTE),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.moodEntry.create({
|
||||
@@ -316,6 +341,149 @@ describe("mood backup round trip", () => {
|
||||
expect(byKey.get("rt_headache")!.rating).toBeNull();
|
||||
expect(byKey.get("custom:rt_long_walk")!.rating).toBeNull();
|
||||
expect(byKey.get("rt_sleep_quality")!.rating).toBe(4);
|
||||
|
||||
// The day context comes back as its own row, bound to the restored entry.
|
||||
// Asserted as "the row is there" before any column is read, so a restore
|
||||
// that stopped writing it fails by name instead of as a Prisma throw three
|
||||
// frames down.
|
||||
expect(
|
||||
await prisma.moodContext.count({
|
||||
where: { moodEntryId: "mood-rt-entry" },
|
||||
}),
|
||||
"the restored entry has no day context — the backup or the restore dropped it",
|
||||
).toBe(1);
|
||||
const context = await prisma.moodContext.findUniqueOrThrow({
|
||||
where: { moodEntryId: "mood-rt-entry" },
|
||||
});
|
||||
expect(context.userId).toBe(OWNER_ID);
|
||||
expect(context.workStatus).toBe("overtime");
|
||||
expect(context.workMinutes).toBe(600);
|
||||
expect(context.workLoad).toBe(8);
|
||||
// Zero, not NULL.
|
||||
expect(context.workSatisfaction).toBe(0);
|
||||
expect(context.contactCircles).toBe(JSON.stringify(["partner", "friends"]));
|
||||
expect(context.contactForm).toBe("phone");
|
||||
expect(context.contactQuality).toBe(7);
|
||||
// Untouched sections stay empty rather than arriving as a defaulted middle.
|
||||
expect(context.contactSupport).toBeNull();
|
||||
expect(context.leisureCategories).toBe(JSON.stringify(["reading"]));
|
||||
expect(context.leisureMinutes).toBe(30);
|
||||
expect(context.leisureJoy).toBeNull();
|
||||
expect(context.eventType).toBe("conflict");
|
||||
expect(context.eventValence).toBe(-4);
|
||||
expect(context.eventAt).toEqual(CONTEXT_EVENT_AT);
|
||||
expect(
|
||||
context.notesEncrypted,
|
||||
"the restored context has no note ciphertext — the backup dropped it",
|
||||
).not.toBeNull();
|
||||
expect(decryptFromBytes(context.notesEncrypted!)).toBe(CONTEXT_NOTE);
|
||||
|
||||
// The entry that never had a context still has none. A restore that minted
|
||||
// an empty row for every entry would turn "never asked" into "asked and
|
||||
// answered nothing" across the whole account.
|
||||
expect(
|
||||
await prisma.moodContext.count({
|
||||
where: { moodEntryId: "mood-rt-tombstone" },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("a file written before contexts existed restores without one", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await seedOwner(prisma);
|
||||
|
||||
// The compatibility floor for this change: no `context` key at all, and a
|
||||
// live row that DOES have one. The restore wipes and rebuilds, so the row
|
||||
// comes back exactly as the file describes it — which means without a
|
||||
// context, not with the one that happened to be on disk.
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
id: "mood-rt-legacy-context",
|
||||
userId: OWNER_ID,
|
||||
date: "2026-07-19",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
source: "MANUAL",
|
||||
moodLoggedAt: LOGGED_AT,
|
||||
context: { create: { userId: OWNER_ID, workStatus: "regular" } },
|
||||
},
|
||||
});
|
||||
expect(await prisma.moodContext.count()).toBe(1);
|
||||
|
||||
const legacyPayload = {
|
||||
schemaVersion: "2",
|
||||
exportedAt: "2026-07-20T00:00:00.000Z",
|
||||
userId: OWNER_ID,
|
||||
appSettings: null,
|
||||
measurements: [],
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [
|
||||
{
|
||||
id: "mood-rt-legacy-context",
|
||||
date: "2026-07-19",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
tags: null,
|
||||
source: "MANUAL",
|
||||
loggedAt: LOGGED_AT.toISOString(),
|
||||
externalId: null,
|
||||
deletedAt: null,
|
||||
factors: [],
|
||||
},
|
||||
],
|
||||
customMoodTags: [],
|
||||
nutrientDays: [],
|
||||
};
|
||||
|
||||
const response = await restoreFromPayload(
|
||||
prisma,
|
||||
parseBackupPayload(legacyPayload),
|
||||
"MOOD_CONTEXT_LEGACY",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
await prisma.moodEntry.findUniqueOrThrow({
|
||||
where: { id: "mood-rt-legacy-context" },
|
||||
});
|
||||
expect(await prisma.moodContext.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("carries the context through a portable export, in plain text", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedOwner(prisma);
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
id: "mood-rt-portable",
|
||||
userId: OWNER_ID,
|
||||
date: "2026-07-19",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
source: "MANUAL",
|
||||
moodLoggedAt: LOGGED_AT,
|
||||
context: {
|
||||
create: {
|
||||
userId: OWNER_ID,
|
||||
leisureCategories: JSON.stringify(["music"]),
|
||||
leisureJoy: 9,
|
||||
notesEncrypted: encryptNote(CONTEXT_NOTE),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
||||
purpose: "portable-export",
|
||||
});
|
||||
const entries = (payload as { moodEntries: Array<Record<string, unknown>> })
|
||||
.moodEntries;
|
||||
const context = entries[0].context as Record<string, unknown>;
|
||||
// A portable file is for a person to read: the note is plain text and no
|
||||
// ciphertext rides along.
|
||||
expect(context.note).toBe(CONTEXT_NOTE);
|
||||
expect(context.notesEncrypted).toBeUndefined();
|
||||
expect(context.leisureJoy).toBe(9);
|
||||
expect(context.leisureCategories).toBe(JSON.stringify(["music"]));
|
||||
});
|
||||
|
||||
it("wipes before it rebuilds, so a live row comes back exactly as the file describes it", async () => {
|
||||
|
||||
Reference in new issue
Block a user