Files
HealthLog/tests/integration/json-import-seed.ts
T
Marc-André Bombeck a840e30fac feat(import): carry every field the app's own files write
The JSON import read six measurement fields and six mood fields while
the full backup and the JSON export carry twice that. A sleep night came
back as stageless rows, a range reading as a single value, an ECG alert
without its verdict, and a mood entry without its note, its level-A
values, its zone, its tag and factor links or its day context.

The import now reads valueMin/valueMax, sleepStage,
rhythmClassification and deviceType on a reading, and note, a1 to a5,
tz, factors, structuredTags and context on a mood entry. Each mood entry
is written with its links and context in one transaction, the way the
bulk mood ingest writes one. A link to a tag the importing account does
not have is dropped and named in droppedTagKeys / droppedFactorKeys, and
the import card says which. The schema moves to
src/lib/import/json/schema.ts so the guards can read it.

json-import-field-coverage.test.ts runs the JSON export and both backup
variants on an account with every column filled in: every field they
write is either read by the import or named with the reason it is not
(row ids, source, deletion records, ciphertext, sync bookkeeping), and a
reason for a field no exporter writes fails as stale. The round trip
imports each file into a fresh account and compares the re-export field
for field. The published contract is held to the runtime schema.
2026-10-23 21:33:06 +02:00

268 lines
7.1 KiB
TypeScript

/**
* One account with every column the exporters read filled in, for the JSON
* import's round trip and its field-coverage guard.
*
* 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
* 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 { 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. */
export const JI_OWN_TAG_KEY = "custom:ji_long_walk";
export const JI_MEASUREMENTS = [
{
type: "WEIGHT",
value: 81.4,
unit: "kg",
at: "2026-09-10T06:30:00.000Z",
note: "after the run",
},
{
type: "PULSE",
value: 64,
valueMin: 52,
valueMax: 91,
unit: "bpm",
at: "2026-09-11T07:00:00.000Z",
deviceType: "watch",
},
{
type: "BLOOD_GLUCOSE",
value: 104,
unit: "mg/dL",
at: "2026-09-12T08:00:00.000Z",
glucoseContext: "FASTING",
},
{
type: "BLOOD_GLUCOSE",
value: 141,
unit: "mg/dL",
at: "2026-09-12T13:00:00.000Z",
note: "after lunch",
glucoseContext: "POSTPRANDIAL",
},
{
type: "IRREGULAR_RHYTHM_NOTIFICATION",
value: 1,
unit: "count",
at: "2026-09-12T15:00:00.000Z",
rhythmClassification: "IRREGULAR",
deviceType: "watch",
},
// One night, as a watch writes it: a stage per row.
{
type: "SLEEP_DURATION",
value: 70,
unit: "minutes",
at: "2026-09-12T23:40:00.000Z",
sleepStage: "DEEP",
deviceType: "watch",
},
{
type: "SLEEP_DURATION",
value: 240,
unit: "minutes",
at: "2026-09-13T03:40:00.000Z",
sleepStage: "CORE",
deviceType: "watch",
},
{
type: "SLEEP_DURATION",
value: 95,
unit: "minutes",
at: "2026-09-13T05:15:00.000Z",
sleepStage: "REM",
deviceType: "watch",
},
] as const;
export const JI_MOOD = {
date: "2026-09-12",
mood: "GUT",
score: 4,
a1: 8,
a2: 3,
a3: 6,
a4: 7,
a5: 5,
tags: JSON.stringify(["work", "sleep"]),
note: "long day, good evening",
tz: "America/New_York",
at: "2026-09-13T01:30:00.000Z",
} as const;
/** A second entry with nothing but the label: what a one-tap check-in writes. */
export const JI_MOOD_BARE = {
date: "2026-09-11",
mood: "OKAY",
score: 3,
at: "2026-09-11T20:30:00.000Z",
} as const;
export interface SeededOwner {
ownerId: string;
}
/** The catalogue the links resolve against: seeded rows, owned by no account. */
export async function seedJsonImportCatalogue(
prisma: PrismaClient,
): Promise<void> {
const category = await prisma.moodTagCategory.create({
data: { key: "ji_category", labelKey: "mood.tagCategory.ji" },
});
await prisma.moodTag.create({
data: {
categoryId: category.id,
key: JI_TAG_KEY,
labelKey: "mood.tag.jiHeadache",
kind: "BINARY",
},
});
await prisma.moodTag.create({
data: {
categoryId: category.id,
key: JI_FACTOR_KEY,
labelKey: "mood.tag.jiSleepQuality",
kind: "RATED",
scaleMin: 1,
scaleMax: 5,
},
});
}
export async function seedJsonImportOwner(
prisma: PrismaClient,
): Promise<SeededOwner> {
const owner = await makeUser({
timezone: "Europe/Berlin",
glucoseUnit: "mmol/L" /* the JSON export stays in the stored unit */,
});
const ownerId = owner.id;
for (const m of JI_MEASUREMENTS) {
await prisma.measurement.create({
data: {
userId: ownerId,
type: m.type,
value: m.value,
valueMin: "valueMin" in m ? m.valueMin : null,
valueMax: "valueMax" in m ? m.valueMax : null,
unit: m.unit,
measuredAt: new Date(m.at),
source: "APPLE_HEALTH",
notes: null,
notesEncrypted: encryptNote("note" in m ? m.note : null),
glucoseContext: "glucoseContext" in m ? m.glucoseContext : null,
sleepStage: "sleepStage" in m ? m.sleepStage : null,
rhythmClassification:
"rhythmClassification" in m ? m.rhythmClassification : null,
deviceType: "deviceType" in m ? m.deviceType : null,
},
});
}
// A reading deleted before the backup: its row is the record of a
// deletion, and the import leaves it out.
await prisma.measurement.create({
data: {
userId: ownerId,
type: "WEIGHT",
value: 90,
unit: "kg",
measuredAt: new Date("2026-09-09T06:30:00.000Z"),
source: "MANUAL",
deletedAt: new Date("2026-09-09T07:00:00.000Z"),
},
});
const category = await prisma.moodTagCategory.findUniqueOrThrow({
where: { key: "ji_category" },
});
const ownTag = await prisma.moodTag.create({
data: {
userId: ownerId,
categoryId: category.id,
key: JI_OWN_TAG_KEY,
labelKey: "mood.tag.custom",
kind: "BINARY",
},
});
const [tag, factor] = await Promise.all([
prisma.moodTag.findUniqueOrThrow({ where: { key: JI_TAG_KEY } }),
prisma.moodTag.findUniqueOrThrow({ where: { key: JI_FACTOR_KEY } }),
]);
const entry = await prisma.moodEntry.create({
data: {
userId: ownerId,
date: JI_MOOD.date,
mood: JI_MOOD.mood,
score: JI_MOOD.score,
moodA1: JI_MOOD.a1,
stressA2: JI_MOOD.a2,
energyA3: JI_MOOD.a3,
connectionA4: JI_MOOD.a4,
stabilityA5: JI_MOOD.a5,
tags: JI_MOOD.tags,
note: null,
noteEncrypted: encryptNote(JI_MOOD.note),
tz: JI_MOOD.tz,
source: "MANUAL",
moodLoggedAt: new Date(JI_MOOD.at),
},
});
await prisma.moodEntryTagLink.createMany({
data: [
{ moodEntryId: entry.id, moodTagId: tag.id },
{ moodEntryId: entry.id, moodTagId: ownTag.id },
{ moodEntryId: entry.id, moodTagId: factor.id, rating: 4 },
],
});
await prisma.moodContext.create({
data: {
moodEntryId: entry.id,
userId: ownerId,
workStatus: "regular",
workMinutes: 540,
overtimeMinutes: 60,
workLoad: 7,
workSatisfaction: 6,
contactCircles: encodeKeyList(["partner", "family"]),
contactForm: "inPerson",
contactExtent: "extended",
contactQuality: 8,
contactSupport: 7,
leisureCategories: encodeKeyList(["reading"]),
leisureMinutes: 90,
leisureJoy: 8,
leisureRecovery: 7,
eventType: "goodNews",
eventValence: 3,
eventAt: new Date("2026-09-12T18:00:00.000Z"),
notesEncrypted: encryptNote("the call went well"),
},
});
await prisma.moodEntry.create({
data: {
userId: ownerId,
date: JI_MOOD_BARE.date,
mood: JI_MOOD_BARE.mood,
score: JI_MOOD_BARE.score,
moodA1: 5,
source: "MANUAL",
moodLoggedAt: new Date(JI_MOOD_BARE.at),
},
});
return { ownerId };
}