mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
A full backup carries the account's own mood tags, rated factors and tag groups, and the JSON import left them out: a mood entry imported into another account landed without every link to a tag the person had made. Before the entries are written, every key they or the file name is resolved once. A catalogue tag or one the account owns is used as it is; an own tag whose label the file states differently is kept, never overwritten, and named in keptMoodTagKeys (keptMoodCategoryKeys for a group). A tag the file defines and the account lacks is created in its group, the group created the same way, within the account's caps of 50 tags and 12 groups. A key another account of the server holds comes over under a key derived from the account and the file's key, so a second import of the same file finds the copy instead of minting another. The links are written through that resolution, own rated factors included, inside the entry's transaction; anything unresolved is still dropped and named. The import card names kept keys in all nine locales. The field-coverage guard now holds the backup's customMoodTags and customMoodTagCategories to the import schema. The round trip moves a backup to another server (the same seeded catalogue, none of the account's rows), imports it into a fresh account and compares the re-export, own tags, group and rated factor included; a second case imports into another account of the same server, and a third keeps a renamed own tag.
468 lines
17 KiB
TypeScript
468 lines
17 KiB
TypeScript
/**
|
|
* The JSON import takes the app's own files back, whole.
|
|
*
|
|
* An account with every column filled in (`json-import-seed.ts`) is written
|
|
* out by the full backup (the portable export, `/api/export/full-backup`) and
|
|
* by the JSON export (`/api/export?format=json`), each file is imported into
|
|
* a fresh account, and the fresh account is written out again by the same
|
|
* exporter. The two files must agree on everything the import carries; what
|
|
* it deliberately leaves out is named in `json-import-field-coverage.test.ts`
|
|
* and is the only thing stripped before the comparison.
|
|
*
|
|
* Reading the result back through the exporter rather than through a
|
|
* hand-written projection of the tables is the point: a field the import
|
|
* drops shows up as a difference in the file a person would get back, which
|
|
* is the loss that matters, and a field an exporter learns to write is
|
|
* compared from the release it starts.
|
|
*
|
|
* Both files spell an empty field their own way, `null` in the backup and an
|
|
* empty string in the export. Before the import read files in the exporter's
|
|
* terms, the first empty note, tag list or glucose context refused the whole
|
|
* file with a 422.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import { GET as exportJson } from "@/app/api/export/route";
|
|
import { POST as importCsv } from "@/app/api/import/csv/route";
|
|
import { GET as exportMeasurementsCsv } from "@/app/api/export/measurements/route";
|
|
import { POST as importJson } from "@/app/api/import/route";
|
|
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
|
|
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
|
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
|
import { derivedVocabularyKey } from "@/lib/import/json/vocabulary";
|
|
import { decryptCustomLabel, encryptCustomLabel } from "@/lib/mood/custom-tags";
|
|
|
|
import { callRoute } from "../kit/call-route";
|
|
import { makeSession, makeUser } from "../kit/factories";
|
|
import {
|
|
JI_OWN_TAG_KEY,
|
|
JI_OWN_TAG_LABEL,
|
|
seedJsonImportCatalogue,
|
|
seedJsonImportOwner,
|
|
} from "./json-import-seed";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const prisma = getPrismaClient();
|
|
|
|
type Row = Record<string, unknown>;
|
|
|
|
interface ImportStats {
|
|
measurements: number;
|
|
moodEntries: number;
|
|
skipped: number;
|
|
droppedTagKeys?: string[];
|
|
droppedFactorKeys?: string[];
|
|
keptMoodTagKeys?: string[];
|
|
}
|
|
|
|
let ownerId: string;
|
|
|
|
interface FullBackup {
|
|
measurements: Row[];
|
|
moodEntries: Row[];
|
|
customMoodTags: Row[];
|
|
customMoodTagCategories: Row[];
|
|
}
|
|
|
|
async function fullBackupOf(userId: string): Promise<FullBackup> {
|
|
let text = "";
|
|
await streamFullBackupJson(prisma, userId, (chunk) => {
|
|
text += chunk;
|
|
});
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
async function jsonExportOf(userId: string): Promise<{
|
|
data: { measurements: Row[]; moodEntries: Row[] };
|
|
}> {
|
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
|
const res = await callRoute<unknown>(exportJson, {
|
|
method: "GET",
|
|
route: "/api/export",
|
|
query: { format: "json", type: "all" },
|
|
auth: await makeSession(user),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
return res.body as { data: { measurements: Row[]; moodEntries: Row[] } };
|
|
}
|
|
|
|
async function importInto(file: unknown) {
|
|
const user = await makeUser({ timezone: "Europe/Berlin" });
|
|
const res = await callRoute<ImportStats>(importJson, {
|
|
method: "POST",
|
|
route: "/api/import",
|
|
body: file,
|
|
auth: await makeSession(user),
|
|
});
|
|
return { userId: user.id, res };
|
|
}
|
|
|
|
/**
|
|
* A file's own vocabulary, comparable across accounts and servers: each tag
|
|
* with its group named by key rather than by a row id.
|
|
*/
|
|
function vocabularyOf(file: FullBackup) {
|
|
const groupKey = new Map(
|
|
file.customMoodTagCategories.map((c) => [c.id as string, c.key as string]),
|
|
);
|
|
const groups = new Map(
|
|
file.customMoodTagCategories.map((c) => {
|
|
const { id: _id, ...rest } = c;
|
|
return [c.key as string, rest];
|
|
}),
|
|
);
|
|
return file.customMoodTags
|
|
.map((tag) => {
|
|
const { categoryId, ...rest } = tag;
|
|
const categoryKey =
|
|
groupKey.get(categoryId as string) ?? (categoryId as string);
|
|
return {
|
|
...rest,
|
|
key: tag.key as string,
|
|
categoryKey,
|
|
group: groups.get(categoryKey) ?? null,
|
|
};
|
|
})
|
|
.sort((a, b) => String(a.key).localeCompare(String(b.key)));
|
|
}
|
|
|
|
/** A row without the fields the import deliberately does not carry. */
|
|
function comparable(row: Row): Row {
|
|
const { id: _id, source: _source, ...rest } = row;
|
|
return rest;
|
|
}
|
|
|
|
function byInstant(at: string) {
|
|
return (a: Row, b: Row) =>
|
|
String(a[at]).localeCompare(String(b[at])) ||
|
|
String(a.type ?? "").localeCompare(String(b.type ?? ""));
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
await seedJsonImportCatalogue(prisma);
|
|
({ ownerId } = await seedJsonImportOwner(prisma));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
});
|
|
|
|
describe("the JSON import takes the app's own files back", () => {
|
|
it("takes a full backup into another account of the same server, every link included", async () => {
|
|
const original = await fullBackupOf(ownerId);
|
|
// The file carries the shapes the import used to refuse or drop.
|
|
expect(original.measurements.some((m) => m.notes === null)).toBe(true);
|
|
expect(original.measurements.some((m) => m.sleepStage === "DEEP")).toBe(
|
|
true,
|
|
);
|
|
expect(original.moodEntries.some((e) => e.context !== null)).toBe(true);
|
|
|
|
const { userId, res } = await importInto(original);
|
|
expect(res.status).toBe(200);
|
|
// Every link lands: the owner's own tags come over as copies, under keys
|
|
// of the importing account, because this server's keys are the owner's.
|
|
expect(res.data).toEqual({
|
|
measurements: original.measurements.length,
|
|
moodEntries: original.moodEntries.length,
|
|
skipped: 0,
|
|
});
|
|
const copyOf = (key: string) =>
|
|
key.startsWith("custom:")
|
|
? derivedVocabularyKey("custom:", userId, key)
|
|
: key.startsWith("customcat:")
|
|
? derivedVocabularyKey("customcat:", userId, key)
|
|
: key;
|
|
|
|
const back = await fullBackupOf(userId);
|
|
expect(
|
|
back.measurements.map(comparable).sort(byInstant("measuredAt")),
|
|
).toEqual(
|
|
original.measurements.map(comparable).sort(byInstant("measuredAt")),
|
|
);
|
|
const expectedMood = original.moodEntries
|
|
.map((entry) => ({
|
|
...comparable(entry),
|
|
structuredTags: (entry.structuredTags as string[]).map(copyOf),
|
|
factors: (entry.factors as Array<{ key: string; rating: number }>).map(
|
|
(factor) => ({ ...factor, key: copyOf(factor.key) }),
|
|
),
|
|
}))
|
|
.sort(byInstant("loggedAt"));
|
|
expect(
|
|
back.moodEntries.map(comparable).sort(byInstant("loggedAt")),
|
|
).toEqual(expectedMood);
|
|
expect(vocabularyOf(back)).toEqual(
|
|
vocabularyOf(original)
|
|
.map((tag) => ({
|
|
...tag,
|
|
key: copyOf(tag.key),
|
|
categoryKey: copyOf(tag.categoryKey),
|
|
group: tag.group && {
|
|
...tag.group,
|
|
key: copyOf(String(tag.group.key)),
|
|
},
|
|
}))
|
|
.sort((a, b) => a.key.localeCompare(b.key)),
|
|
);
|
|
|
|
// A second import of the same file finds the copies.
|
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
|
const again = await callRoute<ImportStats>(importJson, {
|
|
method: "POST",
|
|
route: "/api/import",
|
|
body: original,
|
|
auth: await makeSession(user),
|
|
});
|
|
expect(again.status).toBe(200);
|
|
expect(await prisma.moodTag.count({ where: { userId } })).toBe(
|
|
original.customMoodTags.length,
|
|
);
|
|
expect(await prisma.moodTagCategory.count({ where: { userId } })).toBe(
|
|
original.customMoodTagCategories.length,
|
|
);
|
|
});
|
|
|
|
it("keeps the account's own name for a tag the file names differently, and says so", async () => {
|
|
const original = await fullBackupOf(ownerId);
|
|
// The owner renames the tag after the backup, then imports the backup.
|
|
await prisma.moodTag.update({
|
|
where: { key: JI_OWN_TAG_KEY },
|
|
data: { labelEncrypted: encryptCustomLabel("Evening walk") },
|
|
});
|
|
const owner = await prisma.user.findUniqueOrThrow({
|
|
where: { id: ownerId },
|
|
});
|
|
// The vocabulary alone: the readings of a portable file carry no source
|
|
// id, and would be written a second time into the account they came from.
|
|
const res = await callRoute<ImportStats>(importJson, {
|
|
method: "POST",
|
|
route: "/api/import",
|
|
body: {
|
|
customMoodTags: original.customMoodTags,
|
|
customMoodTagCategories: original.customMoodTagCategories,
|
|
},
|
|
auth: await makeSession(owner),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.data.keptMoodTagKeys).toEqual([JI_OWN_TAG_KEY]);
|
|
const tag = await prisma.moodTag.findUniqueOrThrow({
|
|
where: { key: JI_OWN_TAG_KEY },
|
|
});
|
|
expect(decryptCustomLabel(tag.labelEncrypted)).toBe("Evening walk");
|
|
await prisma.moodTag.update({
|
|
where: { key: JI_OWN_TAG_KEY },
|
|
data: { labelEncrypted: encryptCustomLabel(JI_OWN_TAG_LABEL) },
|
|
});
|
|
});
|
|
|
|
it("takes the JSON export back, empty strings and its envelope included", async () => {
|
|
const original = await jsonExportOf(ownerId);
|
|
expect(original.data.measurements.some((m) => m.notes === "")).toBe(true);
|
|
expect(original.data.moodEntries.some((e) => e.note === "")).toBe(true);
|
|
|
|
const { userId, res } = await importInto(original);
|
|
expect(res.status).toBe(200);
|
|
expect(res.data).toMatchObject({
|
|
measurements: original.data.measurements.length,
|
|
moodEntries: original.data.moodEntries.length,
|
|
skipped: 0,
|
|
});
|
|
|
|
// A sleep night is one summary record in this file, and its `notes` is
|
|
// the stage breakdown the exporter writes from the stored stages. The
|
|
// import stores the night and that text as its note; a re-export
|
|
// rebuilds the column from stages the summary does not have. So the
|
|
// night's note is compared against what was stored, and the rest of the
|
|
// record through the file.
|
|
const nights = original.data.measurements.filter(
|
|
(m) => m.type === "SLEEP_DURATION",
|
|
);
|
|
expect(nights).toHaveLength(1);
|
|
const storedNight = await prisma.measurement.findFirstOrThrow({
|
|
where: { userId, type: "SLEEP_DURATION" },
|
|
});
|
|
expect(readNote(storedNight.notesEncrypted, storedNight.notes)).toBe(
|
|
nights[0]!.notes,
|
|
);
|
|
const withoutNightNote = (m: Row) =>
|
|
m.type === "SLEEP_DURATION"
|
|
? { ...comparable(m), notes: null }
|
|
: comparable(m);
|
|
|
|
const back = await jsonExportOf(userId);
|
|
expect(
|
|
back.data.measurements
|
|
.map(withoutNightNote)
|
|
.sort(byInstant("measuredAt")),
|
|
).toEqual(
|
|
original.data.measurements
|
|
.map(withoutNightNote)
|
|
.sort(byInstant("measuredAt")),
|
|
);
|
|
expect(
|
|
back.data.moodEntries.map(comparable).sort(byInstant("loggedAt")),
|
|
).toEqual(
|
|
original.data.moodEntries.map(comparable).sort(byInstant("loggedAt")),
|
|
);
|
|
});
|
|
|
|
it("leaves out a disaster-recovery file's deletion records, and counts them", async () => {
|
|
const { payload } = await buildFullBackupPayload(prisma, ownerId, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
const rows = (payload as { measurements: Row[] }).measurements;
|
|
const deleted = rows.filter((m) => typeof m.deletedAt === "string");
|
|
expect(deleted).toHaveLength(1);
|
|
|
|
const { userId, res } = await importInto(payload);
|
|
expect(res.status).toBe(200);
|
|
expect(res.data).toMatchObject({
|
|
measurements: rows.length - deleted.length,
|
|
skipped: deleted.length,
|
|
});
|
|
expect(
|
|
await prisma.measurement.count({ where: { userId, value: 90 } }),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("takes the same file twice without changing what the first import wrote", async () => {
|
|
const original = await fullBackupOf(ownerId);
|
|
const { userId } = await importInto(original);
|
|
const first = await fullBackupOf(userId);
|
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
|
const again = await callRoute<ImportStats>(importJson, {
|
|
method: "POST",
|
|
route: "/api/import",
|
|
body: original,
|
|
auth: await makeSession(user),
|
|
});
|
|
expect(again.status).toBe(200);
|
|
// The mood entries carry their instant, so the second pass finds them;
|
|
// the readings carry no source id in a portable file and are written
|
|
// again, as the import has always documented for a file without one.
|
|
expect(again.data.moodEntries).toBe(0);
|
|
const second = await fullBackupOf(userId);
|
|
expect(
|
|
second.moodEntries.map(comparable).sort(byInstant("loggedAt")),
|
|
).toEqual(first.moodEntries.map(comparable).sort(byInstant("loggedAt")));
|
|
});
|
|
});
|
|
|
|
describe("a full backup moved to another server", () => {
|
|
it("comes back equal, the account's own tags, groups and rated factor included", async () => {
|
|
const original = await fullBackupOf(ownerId);
|
|
expect(original.customMoodTags.length).toBeGreaterThanOrEqual(2);
|
|
expect(original.customMoodTags.some((tag) => tag.kind === "RATED")).toBe(
|
|
true,
|
|
);
|
|
expect(original.customMoodTagCategories).toHaveLength(1);
|
|
|
|
// Another server: the same seeded catalogue, none of this account's rows.
|
|
await truncateAllTables(prisma);
|
|
await seedJsonImportCatalogue(prisma);
|
|
|
|
const { userId, res } = await importInto(original);
|
|
expect(res.status).toBe(200);
|
|
expect(res.data).toEqual({
|
|
measurements: original.measurements.length,
|
|
moodEntries: original.moodEntries.length,
|
|
skipped: 0,
|
|
});
|
|
|
|
const back = await fullBackupOf(userId);
|
|
expect(
|
|
back.measurements.map(comparable).sort(byInstant("measuredAt")),
|
|
).toEqual(
|
|
original.measurements.map(comparable).sort(byInstant("measuredAt")),
|
|
);
|
|
expect(
|
|
back.moodEntries.map(comparable).sort(byInstant("loggedAt")),
|
|
).toEqual(original.moodEntries.map(comparable).sort(byInstant("loggedAt")));
|
|
expect(vocabularyOf(back)).toEqual(vocabularyOf(original));
|
|
});
|
|
});
|
|
|
|
describe("the CSV import reads the measurement CSV export", () => {
|
|
const READINGS = [
|
|
{ type: "WEIGHT", value: 81.4, unit: "kg", at: "2026-09-10T06:30:00.000Z" },
|
|
{ type: "PULSE", value: 62, unit: "bpm", at: "2026-09-11T07:00:00.000Z" },
|
|
{
|
|
type: "BLOOD_GLUCOSE",
|
|
value: 104,
|
|
unit: "mg/dL",
|
|
at: "2026-09-12T08:00:00.000Z",
|
|
glucoseContext: "FASTING" as const,
|
|
},
|
|
] as const;
|
|
|
|
it("takes it back, in display units too", async () => {
|
|
const owner = await makeUser({
|
|
timezone: "Europe/Berlin",
|
|
glucoseUnit: "mmol/L",
|
|
});
|
|
for (const m of READINGS) {
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: owner.id,
|
|
type: m.type,
|
|
value: m.value,
|
|
unit: m.unit,
|
|
measuredAt: new Date(m.at),
|
|
source: "MANUAL",
|
|
notesEncrypted: encryptNote(null),
|
|
glucoseContext: "glucoseContext" in m ? m.glucoseContext : null,
|
|
},
|
|
});
|
|
}
|
|
const exported = await callRoute(exportMeasurementsCsv, {
|
|
method: "GET",
|
|
route: "/api/export/measurements",
|
|
auth: await makeSession(owner),
|
|
});
|
|
expect(exported.status).toBe(200);
|
|
const csv = String(exported.body);
|
|
// The account reads glucose in mmol/L, so that is what the file says.
|
|
expect(csv).toMatch(/BLOOD_GLUCOSE,\d+(\.\d)?,mmol\/L/);
|
|
|
|
const user = await makeUser({ timezone: "Europe/Berlin" });
|
|
const res = await callRoute<{ inserted: number; skipped: number }>(
|
|
importCsv,
|
|
{
|
|
method: "POST",
|
|
route: "/api/import/csv",
|
|
text: csv,
|
|
headers: { "content-type": "text/csv" },
|
|
auth: await makeSession(user),
|
|
},
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(res.data).toMatchObject({ inserted: READINGS.length, skipped: 0 });
|
|
|
|
const read = async (userId: string) =>
|
|
(
|
|
await prisma.measurement.findMany({
|
|
where: { userId, deletedAt: null },
|
|
orderBy: [{ measuredAt: "asc" }, { type: "asc" }],
|
|
})
|
|
).map((m) => ({
|
|
type: m.type,
|
|
value: m.value,
|
|
unit: m.unit,
|
|
measuredAt: m.measuredAt.toISOString(),
|
|
glucoseContext: m.glucoseContext,
|
|
note: readNote(m.notesEncrypted, m.notes),
|
|
}));
|
|
const back = await read(user.id);
|
|
const original = await read(owner.id);
|
|
expect(back).toHaveLength(original.length);
|
|
back.forEach((m, i) => {
|
|
const o = original[i]!;
|
|
// mmol/L at one decimal: the stored mg/dL value comes back within it.
|
|
const tolerance = m.type === "BLOOD_GLUCOSE" ? 0.05 * 18.0156 : 1e-9;
|
|
expect({ ...m, value: 0 }).toEqual({ ...o, value: 0 });
|
|
expect(Math.abs(m.value - o.value)).toBeLessThanOrEqual(tolerance);
|
|
});
|
|
});
|
|
});
|