mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment lines in 1,308 files lose the work-item identifier at their head. Release versions and issue references stay; nothing outside comment text changes, which the script checks per file against a comment-free printout. healthlog/no-planning-tags is enabled at error level with this commit, so the tree it lands on is clean.
451 lines
13 KiB
TypeScript
451 lines
13 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
toCSV,
|
|
formatMeasurementsForExport,
|
|
formatIntakeEventsForExport,
|
|
formatMoodEntriesForExport,
|
|
} from "../export";
|
|
|
|
describe("toCSV", () => {
|
|
it("returns empty string for empty array", () => {
|
|
expect(toCSV([])).toBe("");
|
|
});
|
|
|
|
it("generates headers and rows", () => {
|
|
const records = [
|
|
{ name: "Alice", age: 30 },
|
|
{ name: "Bob", age: 25 },
|
|
];
|
|
const csv = toCSV(records);
|
|
const lines = csv.split("\n");
|
|
expect(lines[0]).toBe("name,age");
|
|
expect(lines[1]).toBe("Alice,30");
|
|
expect(lines[2]).toBe("Bob,25");
|
|
});
|
|
|
|
it("escapes commas in values", () => {
|
|
const records = [{ note: "hello, world", value: 1 }];
|
|
const csv = toCSV(records);
|
|
expect(csv).toContain('"hello, world"');
|
|
});
|
|
|
|
it("escapes quotes in values", () => {
|
|
const records = [{ note: 'say "hi"', value: 1 }];
|
|
const csv = toCSV(records);
|
|
expect(csv).toContain('"say ""hi"""');
|
|
});
|
|
|
|
it("handles null and undefined values", () => {
|
|
const records = [{ a: null, b: undefined, c: "ok" }];
|
|
const csv = toCSV(records);
|
|
expect(csv).toBe("a,b,c\n,,ok");
|
|
});
|
|
|
|
it("converts Date objects to ISO string", () => {
|
|
const date = new Date("2025-01-15T12:00:00Z");
|
|
const records = [{ date }];
|
|
const csv = toCSV(records);
|
|
expect(csv).toContain("2025-01-15T12:00:00.000Z");
|
|
});
|
|
|
|
// Spreadsheet formula injection (OWASP CSV injection): text cells
|
|
// starting with a formula trigger are neutralised with a leading `'`.
|
|
// Third-party text reaches these cells (notes carried in on an import),
|
|
// so a crafted payload must open as literal text.
|
|
it("neutralises formula prefixes in text cells", () => {
|
|
const records = [
|
|
{ note: '=HYPERLINK("http://evil","x")' },
|
|
{ note: "+SUM(A1:A9)" },
|
|
{ note: "-2+3+cmd|' /C calc'!A0" },
|
|
{ note: "@SUM(A1)" },
|
|
{ note: "\tleading tab" },
|
|
];
|
|
const lines = toCSV(records).split("\n");
|
|
// RFC 4180 quoting layers on top of the neutralised prefix (the
|
|
// payload carries commas + quotes).
|
|
expect(lines[1]).toBe('"\'=HYPERLINK(""http://evil"",""x"")"');
|
|
expect(lines[2]).toBe("'+SUM(A1:A9)");
|
|
expect(lines[3]).toBe("'-2+3+cmd|' /C calc'!A0");
|
|
expect(lines[4]).toBe("'@SUM(A1)");
|
|
expect(lines[5]).toBe("'\tleading tab");
|
|
});
|
|
|
|
it("does not mangle numeric or boolean cells (negative numbers stay bare)", () => {
|
|
const records = [{ delta: -5.2, count: 3, flagged: false }];
|
|
const lines = toCSV(records).split("\n");
|
|
// The number -5.2 is typed `number`, not text — no quote prefix.
|
|
expect(lines[1]).toBe("-5.2,3,false");
|
|
});
|
|
|
|
it("leaves benign text cells untouched", () => {
|
|
const records = [{ note: "felt fine after the walk" }];
|
|
expect(toCSV(records).split("\n")[1]).toBe("felt fine after the walk");
|
|
});
|
|
});
|
|
|
|
describe("formatMeasurementsForExport", () => {
|
|
it("formats measurement records", () => {
|
|
const measurements = [
|
|
{
|
|
type: "WEIGHT",
|
|
value: 75.5,
|
|
unit: "kg",
|
|
measuredAt: new Date("2025-01-15T08:00:00Z"),
|
|
source: "MANUAL",
|
|
notes: null,
|
|
},
|
|
];
|
|
|
|
const result = formatMeasurementsForExport(measurements);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toEqual({
|
|
type: "WEIGHT",
|
|
value: 75.5,
|
|
unit: "kg",
|
|
measuredAt: "2025-01-15T08:00:00.000Z",
|
|
source: "MANUAL",
|
|
notes: "",
|
|
glucoseContext: "",
|
|
});
|
|
});
|
|
|
|
it("round-trips glucoseContext on BLOOD_GLUCOSE rows", () => {
|
|
const measurements = [
|
|
{
|
|
type: "BLOOD_GLUCOSE",
|
|
value: 92,
|
|
unit: "mg/dL",
|
|
measuredAt: new Date("2025-01-15T08:00:00Z"),
|
|
source: "MANUAL",
|
|
notes: null,
|
|
glucoseContext: "FASTING",
|
|
},
|
|
];
|
|
const result = formatMeasurementsForExport(measurements);
|
|
expect(result[0]).toMatchObject({
|
|
type: "BLOOD_GLUCOSE",
|
|
glucoseContext: "FASTING",
|
|
});
|
|
});
|
|
|
|
// v1.16.16 — glucose unit-at-source. BLOOD_GLUCOSE is stored mg/dL; the
|
|
// CSV export must convert to the user's display unit (matching FHIR) and
|
|
// emit that unit in the `unit` column. mg/dL-preference users unchanged.
|
|
it("keeps BLOOD_GLUCOSE in mg/dL when the user prefers mg/dL", () => {
|
|
const measurements = [
|
|
{
|
|
type: "BLOOD_GLUCOSE",
|
|
value: 100,
|
|
unit: "mg/dL",
|
|
measuredAt: new Date("2025-01-15T08:00:00Z"),
|
|
source: "MANUAL",
|
|
notes: null,
|
|
glucoseContext: "FASTING",
|
|
},
|
|
];
|
|
const result = formatMeasurementsForExport(measurements, undefined, {
|
|
glucoseUnit: "mg/dL",
|
|
});
|
|
expect(result[0].value).toBe(100);
|
|
expect(result[0].unit).toBe("mg/dL");
|
|
});
|
|
|
|
it("converts BLOOD_GLUCOSE value + unit to mmol/L when the user prefers mmol/L", () => {
|
|
const measurements = [
|
|
{
|
|
type: "BLOOD_GLUCOSE",
|
|
value: 100,
|
|
unit: "mg/dL",
|
|
measuredAt: new Date("2025-01-15T08:00:00Z"),
|
|
source: "MANUAL",
|
|
notes: null,
|
|
glucoseContext: "FASTING",
|
|
},
|
|
{
|
|
type: "BLOOD_GLUCOSE",
|
|
value: 126,
|
|
unit: "mg/dL",
|
|
measuredAt: new Date("2025-01-16T08:00:00Z"),
|
|
source: "MANUAL",
|
|
notes: null,
|
|
glucoseContext: "FASTING",
|
|
},
|
|
];
|
|
const result = formatMeasurementsForExport(measurements, undefined, {
|
|
glucoseUnit: "mmol/L",
|
|
});
|
|
// 100 mg/dL → 5.5 mmol/L, 126 mg/dL → 7.0 mmol/L.
|
|
expect(result[0].value).toBe(5.5);
|
|
expect(result[0].unit).toBe("mmol/L");
|
|
expect(result[1].value).toBe(7);
|
|
expect(result[1].unit).toBe("mmol/L");
|
|
});
|
|
|
|
it("does not touch non-glucose rows when a glucose unit is set", () => {
|
|
const measurements = [
|
|
{
|
|
type: "WEIGHT",
|
|
value: 80.4,
|
|
unit: "kg",
|
|
measuredAt: new Date("2025-01-15T08:00:00Z"),
|
|
source: "MANUAL",
|
|
notes: null,
|
|
},
|
|
];
|
|
const result = formatMeasurementsForExport(measurements, undefined, {
|
|
glucoseUnit: "mmol/L",
|
|
});
|
|
expect(result[0].value).toBe(80.4);
|
|
expect(result[0].unit).toBe("kg");
|
|
});
|
|
|
|
// v1.4.25 — per-user timezone offset in exports (issue #167).
|
|
it("emits ISO-8601 with user-tz offset when userTz is provided", () => {
|
|
const measurements = [
|
|
{
|
|
type: "WEIGHT",
|
|
value: 75.5,
|
|
unit: "kg",
|
|
// 09:00 UTC = 11:00 Europe/Berlin (CEST in May).
|
|
measuredAt: new Date("2026-05-15T09:00:00Z"),
|
|
source: "MANUAL",
|
|
notes: null,
|
|
},
|
|
];
|
|
const result = formatMeasurementsForExport(measurements, "Europe/Berlin");
|
|
expect(result[0].measuredAt).toBe("2026-05-15T11:00:00+02:00");
|
|
});
|
|
|
|
it("emits the user's positive offset for Pacific/Auckland", () => {
|
|
const measurements = [
|
|
{
|
|
type: "WEIGHT",
|
|
value: 75.5,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-05-15T09:00:00Z"),
|
|
source: "MANUAL",
|
|
notes: null,
|
|
},
|
|
];
|
|
const result = formatMeasurementsForExport(
|
|
measurements,
|
|
"Pacific/Auckland",
|
|
);
|
|
expect(result[0].measuredAt).toBe("2026-05-15T21:00:00+12:00");
|
|
});
|
|
|
|
it("falls back to UTC Z suffix when no userTz is provided", () => {
|
|
const measurements = [
|
|
{
|
|
type: "WEIGHT",
|
|
value: 75.5,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-05-15T09:00:00Z"),
|
|
source: "MANUAL",
|
|
notes: null,
|
|
},
|
|
];
|
|
const result = formatMeasurementsForExport(measurements);
|
|
expect(result[0].measuredAt).toBe("2026-05-15T09:00:00.000Z");
|
|
});
|
|
|
|
// v1.11.5 — sleep collapses to one row per night by default.
|
|
it("collapses SLEEP_DURATION stage rows to one row per night", () => {
|
|
const measurements = [
|
|
{
|
|
type: "SLEEP_DURATION",
|
|
value: 240,
|
|
unit: "minutes",
|
|
measuredAt: new Date("2026-06-04T03:00:00Z"),
|
|
source: "APPLE_HEALTH",
|
|
notes: null,
|
|
sleepStage: "CORE" as const,
|
|
},
|
|
{
|
|
type: "SLEEP_DURATION",
|
|
value: 120,
|
|
unit: "minutes",
|
|
measuredAt: new Date("2026-06-04T05:00:00Z"),
|
|
source: "APPLE_HEALTH",
|
|
notes: null,
|
|
sleepStage: "DEEP" as const,
|
|
},
|
|
{
|
|
type: "SLEEP_DURATION",
|
|
value: 120,
|
|
unit: "minutes",
|
|
measuredAt: new Date("2026-06-04T07:00:00Z"),
|
|
source: "APPLE_HEALTH",
|
|
notes: null,
|
|
sleepStage: "REM" as const,
|
|
},
|
|
];
|
|
const result = formatMeasurementsForExport(measurements, "UTC", {
|
|
sleepTz: "UTC",
|
|
});
|
|
// One night row, not three stage rows.
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].type).toBe("SLEEP_DURATION");
|
|
// TIME ASLEEP = 240 + 120 + 120 = 480 min.
|
|
expect(result[0].value).toBe(480);
|
|
// Stage breakdown carried in notes.
|
|
expect(String(result[0].notes)).toContain("CORE=240m");
|
|
expect(String(result[0].notes)).toContain("DEEP=120m");
|
|
expect(String(result[0].notes)).toContain("REM=120m");
|
|
});
|
|
|
|
it("keeps per-stage sleep rows when granularity is raw", () => {
|
|
const measurements = [
|
|
{
|
|
type: "SLEEP_DURATION",
|
|
value: 240,
|
|
unit: "minutes",
|
|
measuredAt: new Date("2026-06-04T03:00:00Z"),
|
|
source: "APPLE_HEALTH",
|
|
notes: null,
|
|
sleepStage: "CORE" as const,
|
|
},
|
|
{
|
|
type: "SLEEP_DURATION",
|
|
value: 120,
|
|
unit: "minutes",
|
|
measuredAt: new Date("2026-06-04T05:00:00Z"),
|
|
source: "APPLE_HEALTH",
|
|
notes: null,
|
|
sleepStage: "DEEP" as const,
|
|
},
|
|
];
|
|
const result = formatMeasurementsForExport(measurements, "UTC", {
|
|
granularity: "raw",
|
|
});
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
|
|
it("does not collapse non-sleep rows alongside a sleep night", () => {
|
|
const measurements = [
|
|
{
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-06-04T08:00:00Z"),
|
|
source: "MANUAL",
|
|
notes: null,
|
|
},
|
|
{
|
|
type: "SLEEP_DURATION",
|
|
value: 480,
|
|
unit: "minutes",
|
|
measuredAt: new Date("2026-06-04T06:00:00Z"),
|
|
source: "APPLE_HEALTH",
|
|
notes: null,
|
|
sleepStage: "ASLEEP" as const,
|
|
},
|
|
];
|
|
const result = formatMeasurementsForExport(measurements, "UTC", {
|
|
sleepTz: "UTC",
|
|
});
|
|
expect(result).toHaveLength(2);
|
|
// Sorted descending by time → WEIGHT (08:00) first, then the sleep night.
|
|
expect(result[0].type).toBe("WEIGHT");
|
|
expect(result[1].type).toBe("SLEEP_DURATION");
|
|
expect(result[1].value).toBe(480);
|
|
});
|
|
});
|
|
|
|
describe("formatIntakeEventsForExport with userTz", () => {
|
|
it("emits scheduledFor and takenAt with offset", () => {
|
|
const events = [
|
|
{
|
|
medication: { name: "Aspirin" },
|
|
scheduledFor: new Date("2026-05-15T06:00:00Z"),
|
|
takenAt: new Date("2026-05-15T06:05:00Z"),
|
|
skipped: false,
|
|
source: "MANUAL",
|
|
},
|
|
];
|
|
const result = formatIntakeEventsForExport(events, "Europe/Berlin");
|
|
expect(result[0].scheduledFor).toBe("2026-05-15T08:00:00+02:00");
|
|
expect(result[0].takenAt).toBe("2026-05-15T08:05:00+02:00");
|
|
});
|
|
|
|
it("emits empty takenAt for skipped intakes", () => {
|
|
const events = [
|
|
{
|
|
medication: { name: "Aspirin" },
|
|
scheduledFor: new Date("2026-05-15T06:00:00Z"),
|
|
takenAt: null,
|
|
skipped: true,
|
|
source: "MANUAL",
|
|
},
|
|
];
|
|
const result = formatIntakeEventsForExport(events, "Europe/Berlin");
|
|
expect(result[0].takenAt).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("formatMoodEntriesForExport with userTz", () => {
|
|
it("emits loggedAt with offset while preserving the stored date column", () => {
|
|
const entries = [
|
|
{
|
|
date: "2026-05-15",
|
|
mood: "GREAT",
|
|
score: 5,
|
|
tags: null,
|
|
source: "MANUAL",
|
|
moodLoggedAt: new Date("2026-05-15T18:30:00Z"),
|
|
},
|
|
];
|
|
const result = formatMoodEntriesForExport(entries, "America/New_York");
|
|
expect(result[0].date).toBe("2026-05-15"); // Stored column unchanged.
|
|
expect(result[0].loggedAt).toBe("2026-05-15T14:30:00-04:00");
|
|
});
|
|
});
|
|
|
|
describe("formatMoodEntriesForExport level-A columns", () => {
|
|
const base = {
|
|
date: "2026-05-15",
|
|
mood: "OKAY",
|
|
score: 3,
|
|
tags: null,
|
|
source: "MANUAL",
|
|
moodLoggedAt: new Date("2026-05-15T18:30:00Z"),
|
|
};
|
|
|
|
it("appends the five values without moving an existing column", () => {
|
|
const csv = toCSV(
|
|
formatMoodEntriesForExport([
|
|
{ ...base, moodA1: 4, stressA2: 9, energyA3: 0 },
|
|
]),
|
|
);
|
|
const [header, row] = csv.split("\n");
|
|
// The pre-existing columns keep their order and their positions. Somebody's
|
|
// spreadsheet has a formula pointed at column D.
|
|
expect(header).toBe(
|
|
"date,mood,score,tags,note,source,loggedAt,a1,a2,a3,a4,a5",
|
|
);
|
|
expect(row.split(",").slice(0, 3)).toEqual(["2026-05-15", "OKAY", "3"]);
|
|
});
|
|
|
|
it("writes an empty cell for an unanswered value and a zero for a zero", () => {
|
|
const csv = toCSV(
|
|
formatMoodEntriesForExport([
|
|
{ ...base, moodA1: 4, stressA2: 9, energyA3: 0 },
|
|
]),
|
|
);
|
|
const cells = csv.split("\n")[1].split(",");
|
|
// a1, a2, a3, a4, a5 — the last two were never answered.
|
|
expect(cells.slice(-5)).toEqual(["4", "9", "0", "", ""]);
|
|
});
|
|
|
|
it("leaves every level-A cell empty for an entry written before the columns", () => {
|
|
const csv = toCSV(formatMoodEntriesForExport([base]));
|
|
expect(csv.split("\n")[1].split(",").slice(-5)).toEqual([
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
]);
|
|
});
|
|
});
|