mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
- 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
779 lines
26 KiB
TypeScript
779 lines
26 KiB
TypeScript
/**
|
|
* A mood entry, out through the real backup and back in through the real
|
|
* restore, asserted on the rows.
|
|
*
|
|
* Deliberately not a classification test. Asserting that the payload has a
|
|
* `moodEntries` key proves nothing this repository has not already been burned
|
|
* by: excise the restore call entirely and a shape check still reports green.
|
|
* So this seeds an account, exports through `buildFullBackupPayload`, parses
|
|
* through `parseBackupPayload`, restores through the real
|
|
* `POST /api/admin/backups/[id]/restore`, and then reads the row back out of
|
|
* Postgres and compares values.
|
|
*
|
|
* The fixture is the entry that used to come back wrong in four ways at once:
|
|
* a ticked BINARY tag and a rated factor on the same entry, a note in the
|
|
* encrypted column, a timezone that is not the legacy Europe/Berlin default,
|
|
* and a sync counter above zero. The old payload carried none of them.
|
|
*
|
|
* The second case is the compatibility floor: a hand-written payload in the
|
|
* shape files were written in BEFORE this change, restored through the same
|
|
* route. Every field this adds is optional or defaulted precisely so that file
|
|
* keeps working, and the only way to know is to send one.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import { encrypt } from "@/lib/crypto";
|
|
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import { encryptNote } from "@/lib/crypto/note-cipher";
|
|
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
|
import { parseBackupPayload } from "@/lib/validations/backup";
|
|
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => cookieJar.set(name, value),
|
|
delete: (name: string) => cookieJar.delete(name),
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/cache/invalidate", () => ({
|
|
invalidateUserData: vi.fn(),
|
|
}));
|
|
|
|
const OWNER_ID = "mood-round-trip-owner";
|
|
const NOTE = "slept badly, long walk in the afternoon";
|
|
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());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
async function seedAdminSession(prisma: PrismaClient) {
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
username: "mood-round-trip-admin",
|
|
email: "mood-round-trip-admin@example.test",
|
|
role: "ADMIN",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return admin;
|
|
}
|
|
|
|
async function seedOwner(prisma: PrismaClient) {
|
|
await prisma.user.create({
|
|
data: {
|
|
id: OWNER_ID,
|
|
username: "mood-round-trip-owner",
|
|
email: "mood-round-trip-owner@example.test",
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The taxonomy the entry links to: one seeded BINARY tag (`userId: null`, the
|
|
* reference data every instance has), one BINARY tag the account made itself,
|
|
* and one RATED factor. The account's own tag matters — it travels in the file
|
|
* as a `customMoodTags` row, and a restore that could not re-create it would
|
|
* lose the link even with everything else correct.
|
|
*/
|
|
async function seedTaxonomy(prisma: PrismaClient) {
|
|
const category = await prisma.moodTagCategory.create({
|
|
data: {
|
|
id: "mood-rt-category",
|
|
key: "mood_round_trip",
|
|
labelKey: "mood.tagCategory.roundTrip",
|
|
},
|
|
});
|
|
const seededTag = await prisma.moodTag.create({
|
|
data: {
|
|
id: "mood-rt-seeded-tag",
|
|
categoryId: category.id,
|
|
key: "rt_headache",
|
|
labelKey: "mood.tag.rtHeadache",
|
|
kind: "BINARY",
|
|
},
|
|
});
|
|
const ownTag = await prisma.moodTag.create({
|
|
data: {
|
|
id: "mood-rt-own-tag",
|
|
userId: OWNER_ID,
|
|
categoryId: category.id,
|
|
key: "custom:rt_long_walk",
|
|
labelKey: "mood.tag.custom",
|
|
kind: "BINARY",
|
|
},
|
|
});
|
|
const factor = await prisma.moodTag.create({
|
|
data: {
|
|
id: "mood-rt-factor",
|
|
categoryId: category.id,
|
|
key: "rt_sleep_quality",
|
|
labelKey: "mood.tag.rtSleepQuality",
|
|
kind: "RATED",
|
|
scaleMin: 1,
|
|
scaleMax: 5,
|
|
},
|
|
});
|
|
return { category, seededTag, ownTag, factor };
|
|
}
|
|
|
|
function makeRestoreRequest(id: string) {
|
|
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
});
|
|
}
|
|
|
|
async function restoreFromPayload(
|
|
prisma: PrismaClient,
|
|
payload: unknown,
|
|
type: string,
|
|
) {
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type,
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
return POST(makeRestoreRequest(backup.id) as never, {
|
|
params: Promise.resolve({ id: backup.id }),
|
|
});
|
|
}
|
|
|
|
describe("mood backup round trip", () => {
|
|
it("brings the note, the timezone, the sync counter and both tag arms back", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await seedOwner(prisma);
|
|
const { seededTag, ownTag, factor } = await seedTaxonomy(prisma);
|
|
|
|
await prisma.moodEntry.create({
|
|
data: {
|
|
id: "mood-rt-entry",
|
|
userId: OWNER_ID,
|
|
date: "2026-07-19",
|
|
mood: "OKAY",
|
|
score: 3,
|
|
// All five level-A values, and deliberately not the ones the label
|
|
// would derive: a restore that re-derived them instead of carrying
|
|
// them would look right on this row and be wrong on every row where
|
|
// the person disagreed with their own five-point pick.
|
|
moodA1: 4,
|
|
stressA2: 9,
|
|
energyA3: 2,
|
|
connectionA4: 6,
|
|
stabilityA5: 0,
|
|
tags: JSON.stringify(["free-text-tag"]),
|
|
note: null,
|
|
noteEncrypted: encryptNote(NOTE),
|
|
source: "MANUAL",
|
|
externalId: null,
|
|
moodLoggedAt: LOGGED_AT,
|
|
// Not Europe/Berlin: a restore that drops `tz` moves this entry's day
|
|
// boundary by six hours and nothing else in the row shows it.
|
|
tz: "America/New_York",
|
|
syncVersion: 3,
|
|
createdAt: CREATED_AT,
|
|
updatedAt: UPDATED_AT,
|
|
tagLinks: {
|
|
create: [
|
|
{ moodTagId: factor.id, rating: 4 },
|
|
{ moodTagId: seededTag.id, rating: null },
|
|
{ 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({
|
|
data: {
|
|
id: "mood-rt-tombstone",
|
|
userId: OWNER_ID,
|
|
date: "2026-07-18",
|
|
mood: "SCHLECHT",
|
|
score: 2,
|
|
source: "MANUAL",
|
|
moodLoggedAt: new Date("2026-07-18T22:00:00.000Z"),
|
|
tz: "America/New_York",
|
|
syncVersion: 5,
|
|
deletedAt: DELETED_AT,
|
|
},
|
|
});
|
|
|
|
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
const parsed = parseBackupPayload(payload);
|
|
|
|
// Wipe what the restore is supposed to bring back. The tag rows stay: the
|
|
// instance's catalogue is reference data, and the account's own tag rides
|
|
// in the file and is re-created by the restore either way.
|
|
await prisma.moodEntry.deleteMany({ where: { userId: OWNER_ID } });
|
|
await prisma.moodTag.deleteMany({ where: { userId: OWNER_ID } });
|
|
expect(await prisma.moodEntry.count({ where: { userId: OWNER_ID } })).toBe(
|
|
0,
|
|
);
|
|
|
|
const response = await restoreFromPayload(
|
|
prisma,
|
|
parsed,
|
|
"MOOD_ROUND_TRIP",
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as {
|
|
data: { skipped: { links: number } };
|
|
};
|
|
// Nothing was dropped. A restore that lost a link and still answered 200
|
|
// is exactly what the skip report exists to make visible.
|
|
expect(body.data.skipped.links).toBe(0);
|
|
|
|
const restored = await prisma.moodEntry.findUniqueOrThrow({
|
|
where: { id: "mood-rt-entry" },
|
|
});
|
|
// Asserted as "there is a note" before it is decrypted, so a payload that
|
|
// stopped carrying the ciphertext fails by name rather than as a TypeError
|
|
// three frames inside the cipher.
|
|
expect(
|
|
restored.noteEncrypted,
|
|
"the restored entry has no note ciphertext — the backup dropped it",
|
|
).not.toBeNull();
|
|
expect(decryptFromBytes(restored.noteEncrypted!)).toBe(NOTE);
|
|
expect(restored.note).toBeNull();
|
|
expect(restored.tz).toBe("America/New_York");
|
|
expect(restored.syncVersion).toBe(3);
|
|
expect(restored.moodLoggedAt).toEqual(LOGGED_AT);
|
|
expect(restored.createdAt).toEqual(CREATED_AT);
|
|
expect(restored.updatedAt).toEqual(UPDATED_AT);
|
|
expect(restored.date).toBe("2026-07-19");
|
|
expect(restored.score).toBe(3);
|
|
// Each of the five asserted by value. Zero is included on purpose: it is a
|
|
// real answer on this scale, and a carrier that treated it as absence
|
|
// would round-trip it as NULL and pass a truthiness check.
|
|
expect(restored.moodA1).toBe(4);
|
|
expect(restored.stressA2).toBe(9);
|
|
expect(restored.energyA3).toBe(2);
|
|
expect(restored.connectionA4).toBe(6);
|
|
expect(restored.stabilityA5).toBe(0);
|
|
expect(restored.tags).toBe(JSON.stringify(["free-text-tag"]));
|
|
expect(restored.deletedAt).toBeNull();
|
|
|
|
const tombstone = await prisma.moodEntry.findUniqueOrThrow({
|
|
where: { id: "mood-rt-tombstone" },
|
|
});
|
|
expect(tombstone.deletedAt).toEqual(DELETED_AT);
|
|
expect(tombstone.syncVersion).toBe(5);
|
|
// The tombstone was written without level-A values and comes back without
|
|
// them. A restore filling these in would put an answer nobody gave into a
|
|
// row the person had already deleted.
|
|
expect(tombstone.moodA1).toBeNull();
|
|
expect(tombstone.stressA2).toBeNull();
|
|
|
|
const links = await prisma.moodEntryTagLink.findMany({
|
|
where: { moodEntryId: "mood-rt-entry" },
|
|
select: { rating: true, moodTag: { select: { key: true, kind: true } } },
|
|
orderBy: { moodTagId: "asc" },
|
|
});
|
|
const byKey = new Map(links.map((l) => [l.moodTag.key, l]));
|
|
expect([...byKey.keys()].sort()).toEqual([
|
|
"custom:rt_long_walk",
|
|
"rt_headache",
|
|
"rt_sleep_quality",
|
|
]);
|
|
// The rating is what tells the two arms apart in the row: a ticked tag
|
|
// carries NULL, and that NULL is what the old backup filter read as
|
|
// "nothing to carry".
|
|
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 () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await seedOwner(prisma);
|
|
|
|
// A row as it stands today, with answered sliders.
|
|
await prisma.moodEntry.create({
|
|
data: {
|
|
id: "mood-rt-live",
|
|
userId: OWNER_ID,
|
|
date: "2026-07-19",
|
|
mood: "OKAY",
|
|
score: 3,
|
|
moodA1: 4,
|
|
stressA2: 9,
|
|
source: "MANUAL",
|
|
moodLoggedAt: LOGGED_AT,
|
|
},
|
|
});
|
|
|
|
// Restoring a file written before these columns existed. The restore
|
|
// deletes the owner's mood partition and rebuilds it from the file, so
|
|
// the row that comes back is the file's row and carries nothing the file
|
|
// did not hold. This is the disaster-recovery contract and it is asserted
|
|
// here so nobody reads the omit-on-update below as protecting a live row:
|
|
// by the time that arm runs there are no live rows left.
|
|
const legacyPayload = {
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-07-20T00:00:00.000Z",
|
|
userId: OWNER_ID,
|
|
appSettings: null,
|
|
measurements: [],
|
|
medications: [],
|
|
intakeEvents: [],
|
|
moodEntries: [
|
|
{
|
|
id: "mood-rt-live",
|
|
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_LEVEL_A_WIPE",
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
const after = await prisma.moodEntry.findUniqueOrThrow({
|
|
where: { id: "mood-rt-live" },
|
|
});
|
|
// Not 4 and not 9: the file said nothing about them and the file is what
|
|
// was restored. Nothing was derived to fill the gap either.
|
|
expect(after.moodA1).toBeNull();
|
|
expect(after.stressA2).toBeNull();
|
|
});
|
|
|
|
it("lets a second entry for the same row add without erasing", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await seedOwner(prisma);
|
|
|
|
// Two entries in ONE file addressing the same row. The first creates it,
|
|
// the second lands on the upsert's update arm — the only way that arm is
|
|
// reachable, since the partition was wiped a moment earlier. A second
|
|
// entry that mentions nothing about a dimension must not blank what the
|
|
// first one set, and one that mentions it wins, including a null.
|
|
const payload = {
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-08-08T00:00:00.000Z",
|
|
userId: OWNER_ID,
|
|
appSettings: null,
|
|
measurements: [],
|
|
medications: [],
|
|
intakeEvents: [],
|
|
moodEntries: [
|
|
{
|
|
id: "mood-rt-collide",
|
|
date: "2026-07-19",
|
|
mood: "OKAY",
|
|
score: 3,
|
|
tags: null,
|
|
source: "MANUAL",
|
|
loggedAt: LOGGED_AT.toISOString(),
|
|
externalId: null,
|
|
deletedAt: null,
|
|
a1: 4,
|
|
a2: 9,
|
|
a3: 6,
|
|
factors: [],
|
|
},
|
|
{
|
|
id: "mood-rt-collide",
|
|
date: "2026-07-19",
|
|
mood: "GUT",
|
|
score: 4,
|
|
tags: null,
|
|
source: "MANUAL",
|
|
loggedAt: LOGGED_AT.toISOString(),
|
|
externalId: null,
|
|
deletedAt: null,
|
|
a1: 7,
|
|
a3: null,
|
|
factors: [],
|
|
},
|
|
],
|
|
customMoodTags: [],
|
|
nutrientDays: [],
|
|
};
|
|
|
|
const response = await restoreFromPayload(
|
|
prisma,
|
|
parseBackupPayload(payload),
|
|
"MOOD_LEVEL_A_COLLIDE",
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
const after = await prisma.moodEntry.findUniqueOrThrow({
|
|
where: { id: "mood-rt-collide" },
|
|
});
|
|
expect(after.mood).toBe("GUT");
|
|
// Stated by the second entry.
|
|
expect(after.moodA1).toBe(7);
|
|
// Not mentioned by the second entry: the first entry's answer stands.
|
|
expect(after.stressA2).toBe(9);
|
|
// Stated as null by the second entry: cleared.
|
|
expect(after.energyA3).toBeNull();
|
|
});
|
|
|
|
it("restores a file written in the shape that predates these fields", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await seedOwner(prisma);
|
|
const { factor } = await seedTaxonomy(prisma);
|
|
|
|
// Verbatim the shape the builder emitted before this change: no `note`, no
|
|
// `noteEncrypted`, no `tz`, no `syncedAt`, no `syncVersion`, no
|
|
// `createdAt`/`updatedAt`, no `structuredTags`. Hand-written rather than
|
|
// generated, because a fixture built from today's builder cannot describe
|
|
// yesterday's file.
|
|
const legacyPayload = {
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-07-20T00:00:00.000Z",
|
|
userId: OWNER_ID,
|
|
appSettings: null,
|
|
measurements: [],
|
|
medications: [],
|
|
intakeEvents: [],
|
|
moodEntries: [
|
|
{
|
|
id: "mood-rt-legacy",
|
|
date: "2026-07-19",
|
|
mood: "GUT",
|
|
score: 4,
|
|
tags: null,
|
|
source: "MOODLOG",
|
|
loggedAt: LOGGED_AT.toISOString(),
|
|
externalId: "moodlog-legacy",
|
|
deletedAt: null,
|
|
factors: [{ key: factor.key, rating: 5 }],
|
|
},
|
|
],
|
|
customMoodTags: [],
|
|
nutrientDays: [],
|
|
};
|
|
|
|
const parsed = parseBackupPayload(legacyPayload);
|
|
// Defaults, not values: absence has to stay absence through the parse.
|
|
expect(parsed.moodEntries[0].structuredTags).toEqual([]);
|
|
expect(parsed.moodEntries[0].tz).toBeUndefined();
|
|
expect(parsed.moodEntries[0].syncVersion).toBeUndefined();
|
|
expect(parsed.moodEntries[0].a1).toBeUndefined();
|
|
|
|
const response = await restoreFromPayload(
|
|
prisma,
|
|
parsed,
|
|
"MOOD_LEGACY_SHAPE",
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
const restored = await prisma.moodEntry.findUniqueOrThrow({
|
|
where: { id: "mood-rt-legacy" },
|
|
});
|
|
// Identical to what the old restore wrote for the same file: the note
|
|
// columns empty, `tz` NULL — which is what such a row meant, the legacy
|
|
// Europe/Berlin reading — and the counter at the schema default.
|
|
expect(restored.note).toBeNull();
|
|
expect(restored.noteEncrypted).toBeNull();
|
|
expect(restored.tz).toBeNull();
|
|
expect(restored.syncVersion).toBe(0);
|
|
expect(restored.mood).toBe("GUT");
|
|
expect(restored.score).toBe(4);
|
|
expect(restored.externalId).toBe("moodlog-legacy");
|
|
expect(restored.deletedAt).toBeNull();
|
|
// A file from before these columns carries no answer for them, and the
|
|
// restore does not invent one. `GUT` would derive to 7; a restore that
|
|
// derived instead of carrying would write a number this file never held.
|
|
expect(restored.moodA1).toBeNull();
|
|
expect(restored.stressA2).toBeNull();
|
|
expect(restored.energyA3).toBeNull();
|
|
expect(restored.connectionA4).toBeNull();
|
|
expect(restored.stabilityA5).toBeNull();
|
|
|
|
const links = await prisma.moodEntryTagLink.findMany({
|
|
where: { moodEntryId: "mood-rt-legacy" },
|
|
select: { rating: true, moodTagId: true },
|
|
});
|
|
expect(links).toEqual([{ moodTagId: factor.id, rating: 5 }]);
|
|
});
|
|
|
|
it("names a ticked tag this instance cannot resolve instead of losing it quietly", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedAdminSession(prisma);
|
|
await seedOwner(prisma);
|
|
await seedTaxonomy(prisma);
|
|
|
|
const payload = {
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-07-20T00:00:00.000Z",
|
|
userId: OWNER_ID,
|
|
appSettings: null,
|
|
measurements: [],
|
|
medications: [],
|
|
intakeEvents: [],
|
|
moodEntries: [
|
|
{
|
|
id: "mood-rt-unknown-tag",
|
|
date: "2026-07-19",
|
|
mood: "OKAY",
|
|
score: 3,
|
|
tags: null,
|
|
source: "MANUAL",
|
|
loggedAt: LOGGED_AT.toISOString(),
|
|
externalId: null,
|
|
deletedAt: null,
|
|
factors: [],
|
|
structuredTags: ["rt_headache", "rt_retired_tag"],
|
|
},
|
|
],
|
|
customMoodTags: [],
|
|
nutrientDays: [],
|
|
};
|
|
|
|
const response = await restoreFromPayload(
|
|
prisma,
|
|
parseBackupPayload(payload),
|
|
"MOOD_UNKNOWN_TAG",
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as {
|
|
data: {
|
|
skipped: {
|
|
links: number;
|
|
catalogueKeys: Array<{
|
|
catalogue: string;
|
|
key: string;
|
|
links: number;
|
|
}>;
|
|
};
|
|
};
|
|
};
|
|
expect(body.data.skipped.catalogueKeys).toEqual([
|
|
{ catalogue: "moodTag", key: "rt_retired_tag", links: 1 },
|
|
]);
|
|
expect(body.data.skipped.links).toBe(1);
|
|
|
|
// The entry itself came back whole. One key that has nowhere to attach
|
|
// costs the link, never the record.
|
|
const restored = await prisma.moodEntry.findUniqueOrThrow({
|
|
where: { id: "mood-rt-unknown-tag" },
|
|
});
|
|
expect(restored.score).toBe(3);
|
|
const links = await prisma.moodEntryTagLink.findMany({
|
|
where: { moodEntryId: restored.id },
|
|
select: { moodTag: { select: { key: true } } },
|
|
});
|
|
expect(links).toEqual([{ moodTag: { key: "rt_headache" } }]);
|
|
});
|
|
});
|