mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(import): bring the account's own mood vocabulary along
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.
This commit is contained in:
1 parent
d1545cf554
commit
2357807706
25 files changed
+995
-67
No files matched your search
@@ -28,6 +28,8 @@ import {
|
||||
jsonImportMeasurementSchema,
|
||||
jsonImportMoodContextSchema,
|
||||
jsonImportMoodEntrySchema,
|
||||
jsonImportMoodTagCategorySchema,
|
||||
jsonImportMoodTagSchema,
|
||||
} from "@/lib/import/json/schema";
|
||||
|
||||
import { callRoute } from "../kit/call-route";
|
||||
@@ -86,20 +88,26 @@ const CONTEXT_NOT_IMPORTED: Record<string, string> = {
|
||||
updatedAt: ROW_STAMP,
|
||||
};
|
||||
|
||||
/** A tag or group of the file is matched and created by key, not by id. */
|
||||
const MOOD_TAG_NOT_IMPORTED: Record<string, string> = {
|
||||
id: "A tag is matched and created by its key; its id in the file names a row of the account it came from (disaster-recovery file only).",
|
||||
};
|
||||
const MOOD_CATEGORY_NOT_IMPORTED: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* The files' other sections. The JSON import carries readings and mood
|
||||
* entries into an account that has its own record already; an account's
|
||||
* whole record (medications, labs, documents, settings, …) comes back through
|
||||
* the backup restore, which replaces it. The mood vocabulary is named apart
|
||||
* because the entries' links point into it: a link to a tag the importing
|
||||
* account does not have is dropped and named in the response.
|
||||
* The files' other sections. The JSON import carries readings, mood entries
|
||||
* and the account's own mood vocabulary (which the entries' links point into)
|
||||
* into an account that has its own record already; an account's whole record
|
||||
* (medications, labs, documents, settings, …) comes back through the backup
|
||||
* restore, which replaces it.
|
||||
*/
|
||||
const IMPORTED_SECTIONS = new Set(["measurements", "moodEntries"]);
|
||||
const VOCABULARY_SECTIONS: Record<string, string> = {
|
||||
customMoodTags:
|
||||
"Account vocabulary, restored by the backup restore. An imported link to one of these tags resolves only when the importing account has a tag of that key, and is otherwise dropped and named (`droppedTagKeys`).",
|
||||
customMoodTagCategories:
|
||||
"Account vocabulary, restored by the backup restore with the tags it groups.",
|
||||
const IMPORTED_SECTIONS = new Set([
|
||||
"measurements",
|
||||
"moodEntries",
|
||||
"customMoodTags",
|
||||
"customMoodTagCategories",
|
||||
]);
|
||||
const NAMED_MOOD_SECTIONS: Record<string, string> = {
|
||||
hiddenMoodTags:
|
||||
"An account setting (which tags its editor hides), restored by the backup restore.",
|
||||
};
|
||||
@@ -166,6 +174,18 @@ const MEASUREMENT_READ = new Set(
|
||||
);
|
||||
const MOOD_READ = new Set(Object.keys(jsonImportMoodEntrySchema.shape));
|
||||
const CONTEXT_READ = new Set(Object.keys(jsonImportMoodContextSchema.shape));
|
||||
const MOOD_TAG_READ = new Set(Object.keys(jsonImportMoodTagSchema.shape));
|
||||
const MOOD_CATEGORY_READ = new Set(
|
||||
Object.keys(jsonImportMoodTagCategorySchema.shape),
|
||||
);
|
||||
const BACKUP_NAMES = ["portable", "disasterRecovery"] as const;
|
||||
|
||||
function sectionOf(
|
||||
name: (typeof BACKUP_NAMES)[number],
|
||||
section: "customMoodTags" | "customMoodTagCategories",
|
||||
): Row[] {
|
||||
return files[name][section] as Row[];
|
||||
}
|
||||
|
||||
const FILE_NAMES = ["jsonExport", "portable", "disasterRecovery"] as const;
|
||||
|
||||
@@ -207,13 +227,37 @@ describe("the JSON import reads what the exporters write", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("names the mood vocabulary the entries' links point into", () => {
|
||||
for (const name of ["portable", "disasterRecovery"] as const) {
|
||||
it.each(BACKUP_NAMES)("every field of the %s's own mood tags", (name) => {
|
||||
const rows = sectionOf(name, "customMoodTags");
|
||||
// The seed's own tag and its own rated factor.
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2);
|
||||
expect(
|
||||
unaccounted(keysOf(rows), MOOD_TAG_READ, MOOD_TAG_NOT_IMPORTED),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(BACKUP_NAMES)("every field of the %s's own mood groups", (name) => {
|
||||
const rows = sectionOf(name, "customMoodTagCategories");
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
unaccounted(keysOf(rows), MOOD_CATEGORY_READ, MOOD_CATEGORY_NOT_IMPORTED),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads or names every mood section the backups carry", () => {
|
||||
for (const name of BACKUP_NAMES) {
|
||||
const sections = Object.keys(files[name]);
|
||||
expect(sections).toEqual(expect.arrayContaining([...IMPORTED_SECTIONS]));
|
||||
for (const section of Object.keys(VOCABULARY_SECTIONS)) {
|
||||
for (const section of Object.keys(NAMED_MOOD_SECTIONS)) {
|
||||
expect(sections, `${name} carries ${section}`).toContain(section);
|
||||
}
|
||||
// Every mood section of the file is one of the two lists.
|
||||
const mood = sections.filter((s) => /mood/i.test(s));
|
||||
expect(
|
||||
mood.filter(
|
||||
(s) => !IMPORTED_SECTIONS.has(s) && !(s in NAMED_MOOD_SECTIONS),
|
||||
),
|
||||
).toEqual([]);
|
||||
}
|
||||
// The JSON export's other two sections are medications, restored the
|
||||
// same way as every other section of an account's record.
|
||||
@@ -245,6 +289,14 @@ describe("the JSON import reads what the exporters write", () => {
|
||||
...Object.keys(CONTEXT_NOT_IMPORTED)
|
||||
.filter((key) => !writtenContext.has(key))
|
||||
.map((key) => `context.${key}`),
|
||||
...Object.keys(MOOD_TAG_NOT_IMPORTED)
|
||||
.filter(
|
||||
(key) =>
|
||||
!BACKUP_NAMES.some((name) =>
|
||||
keysOf(sectionOf(name, "customMoodTags")).has(key),
|
||||
),
|
||||
)
|
||||
.map((key) => `customMoodTag.${key}`),
|
||||
];
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
@@ -256,6 +308,7 @@ describe("the JSON import reads what the exporters write", () => {
|
||||
),
|
||||
...Object.keys(MOOD_NOT_IMPORTED).filter((k) => MOOD_READ.has(k)),
|
||||
...Object.keys(CONTEXT_NOT_IMPORTED).filter((k) => CONTEXT_READ.has(k)),
|
||||
...Object.keys(MOOD_TAG_NOT_IMPORTED).filter((k) => MOOD_TAG_READ.has(k)),
|
||||
];
|
||||
expect(both).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -29,11 +29,14 @@ 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";
|
||||
@@ -49,14 +52,19 @@ interface ImportStats {
|
||||
skipped: number;
|
||||
droppedTagKeys?: string[];
|
||||
droppedFactorKeys?: string[];
|
||||
keptMoodTagKeys?: string[];
|
||||
}
|
||||
|
||||
let ownerId: string;
|
||||
|
||||
async function fullBackupOf(userId: string): Promise<{
|
||||
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;
|
||||
@@ -89,6 +97,35 @@ async function importInto(file: unknown) {
|
||||
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;
|
||||
@@ -112,7 +149,7 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe("the JSON import takes the app's own files back", () => {
|
||||
it("takes a full backup back field for field", async () => {
|
||||
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);
|
||||
@@ -123,33 +160,100 @@ describe("the JSON import takes the app's own files back", () => {
|
||||
|
||||
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,
|
||||
// The account's own tag is not this account's: the entry lands without
|
||||
// that one link, and the response says which.
|
||||
droppedTagKeys: [JI_OWN_TAG_KEY],
|
||||
});
|
||||
const copyOf = (key: string) =>
|
||||
key.startsWith("custom:")
|
||||
? derivedVocabularyKey("custom:", userId, key)
|
||||
: key.startsWith("customcat:")
|
||||
? derivedVocabularyKey("customcat:", userId, key)
|
||||
: key;
|
||||
|
||||
const back = await fullBackupOf(userId);
|
||||
const expectedMeasurements = original.measurements
|
||||
.map(comparable)
|
||||
.sort(byInstant("measuredAt"));
|
||||
expect(
|
||||
back.measurements.map(comparable).sort(byInstant("measuredAt")),
|
||||
).toEqual(expectedMeasurements);
|
||||
).toEqual(
|
||||
original.measurements.map(comparable).sort(byInstant("measuredAt")),
|
||||
);
|
||||
const expectedMood = original.moodEntries
|
||||
.map((entry) => ({
|
||||
...comparable(entry),
|
||||
structuredTags: (entry.structuredTags as string[]).filter(
|
||||
(key) => key !== JI_OWN_TAG_KEY,
|
||||
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 () => {
|
||||
@@ -245,6 +349,40 @@ describe("the JSON import takes the app's own files back", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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" },
|
||||
|
||||
@@ -5,21 +5,27 @@
|
||||
* Every reading carries what makes it that reading: a range keeps its bounds,
|
||||
* a sleep night its stages and its writer, a rhythm alert its verdict, a
|
||||
* glucose value its meal-time context, a note its text. The mood entry
|
||||
* carries a note, all five level-A values, its zone, a rated factor, a
|
||||
* catalogue tag, a tag of the account's own and a full day context. A field
|
||||
* carries a note, all five level-A values, its zone, a catalogue factor and
|
||||
* tag, the account's own tag and its own rated factor (in a group of its own)
|
||||
* and a full day context. A field
|
||||
* the seed leaves empty is a field neither test can see arrive, so nothing
|
||||
* here is left at its default.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { encodeKeyList } from "@/lib/mood/context-vocabulary";
|
||||
import { encryptCustomLabel } from "@/lib/mood/custom-tags";
|
||||
|
||||
import { makeUser } from "../kit/factories";
|
||||
|
||||
export const JI_FACTOR_KEY = "ji_sleep_quality";
|
||||
export const JI_TAG_KEY = "ji_headache";
|
||||
/** The account's own tag: another account importing the file cannot resolve it. */
|
||||
/** The account's own tag, in a seeded group. */
|
||||
export const JI_OWN_TAG_KEY = "custom:ji_long_walk";
|
||||
/** The account's own group, and its own rated factor in it. */
|
||||
export const JI_OWN_CATEGORY_KEY = "customcat:ji_outdoors";
|
||||
export const JI_OWN_FACTOR_KEY = "custom:ji_trail_feel";
|
||||
export const JI_OWN_TAG_LABEL = "Long walk";
|
||||
|
||||
export const JI_MEASUREMENTS = [
|
||||
{
|
||||
@@ -119,11 +125,19 @@ export interface SeededOwner {
|
||||
export async function seedJsonImportCatalogue(
|
||||
prisma: PrismaClient,
|
||||
): Promise<void> {
|
||||
// Fixed ids, as the migrations seed the real catalogue (whose `custom`
|
||||
// group the test database holds too): a group's id is the same on every
|
||||
// server, which is what lets an imported tag find it.
|
||||
const category = await prisma.moodTagCategory.create({
|
||||
data: { key: "ji_category", labelKey: "mood.tagCategory.ji" },
|
||||
data: {
|
||||
id: "mtc_ji_category",
|
||||
key: "ji_category",
|
||||
labelKey: "mood.tagCategory.ji",
|
||||
},
|
||||
});
|
||||
await prisma.moodTag.create({
|
||||
data: {
|
||||
id: "mt_ji_headache",
|
||||
categoryId: category.id,
|
||||
key: JI_TAG_KEY,
|
||||
labelKey: "mood.tag.jiHeadache",
|
||||
@@ -132,6 +146,7 @@ export async function seedJsonImportCatalogue(
|
||||
});
|
||||
await prisma.moodTag.create({
|
||||
data: {
|
||||
id: "mt_ji_sleep_quality",
|
||||
categoryId: category.id,
|
||||
key: JI_FACTOR_KEY,
|
||||
labelKey: "mood.tag.jiSleepQuality",
|
||||
@@ -195,6 +210,31 @@ export async function seedJsonImportOwner(
|
||||
key: JI_OWN_TAG_KEY,
|
||||
labelKey: "mood.tag.custom",
|
||||
kind: "BINARY",
|
||||
labelEncrypted: encryptCustomLabel(JI_OWN_TAG_LABEL),
|
||||
},
|
||||
});
|
||||
const ownCategory = await prisma.moodTagCategory.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
key: JI_OWN_CATEGORY_KEY,
|
||||
labelKey: "mood.tagCategory.custom",
|
||||
icon: null,
|
||||
sortOrder: 7,
|
||||
labelEncrypted: encryptCustomLabel("Outdoors"),
|
||||
},
|
||||
});
|
||||
const ownFactor = await prisma.moodTag.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
categoryId: ownCategory.id,
|
||||
key: JI_OWN_FACTOR_KEY,
|
||||
labelKey: "mood.tag.custom",
|
||||
kind: "RATED",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: true,
|
||||
sortOrder: 2,
|
||||
labelEncrypted: encryptCustomLabel("How the trail felt"),
|
||||
},
|
||||
});
|
||||
const [tag, factor] = await Promise.all([
|
||||
@@ -226,6 +266,7 @@ export async function seedJsonImportOwner(
|
||||
{ moodEntryId: entry.id, moodTagId: tag.id },
|
||||
{ moodEntryId: entry.id, moodTagId: ownTag.id },
|
||||
{ moodEntryId: entry.id, moodTagId: factor.id, rating: 4 },
|
||||
{ moodEntryId: entry.id, moodTagId: ownFactor.id, rating: 2 },
|
||||
],
|
||||
});
|
||||
await prisma.moodContext.create({
|
||||
|
||||
Reference in new issue
Block a user