mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
test(import): cover Apple Health export.zip parser + endpoints
Adds 36 unit + contract tests across 7 files: * `import-apple-health-export.test.ts` — end-to-end parse of a tiny hand-authored fixture (spot weight, cumulative steps fold, sleep stage, workout with sport mapping, deferred + unknown identifiers, clinical record skip). Re-import idempotency: a second run reports 0 inserts + N updates. Memory ceiling: synthetic 10 000-record export keeps heap delta under 100 MB. * `hk-workout-activity-type-map.test.ts` — every value referenced in the map exists in the canonical `workoutSportTypeEnum` union; the iOS 18 canonical roster is covered; unknown identifiers fall through to `"other"` with a falsy `known` flag. * `unzip-export-xml.test.ts` — central-directory walker round-trips a hand-built single-entry deflate ZIP; extractor throws on a missing `export.xml` member. * `stream-to-disk.test.ts` — boundary parser; named-field capture + SHA-256 + text-field collection; size-cap enforcement; boundary- missing error path. * Route tests — auth gate (401), 403 on non-admin to the admin variant, 413 on content-length cap, 429 on rate-limit; status endpoint admits owner + triggering admin and 404s otherwise.
This commit is contained in:
1 parent
c6345b71fa
commit
c6528b9340
10 files changed
+1274
-3
No files matched your search
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Smoke tests for the admin Apple Health import endpoint. Covers the
|
||||
* cookie-only `requireAdmin()` gate (Bearer never elevates), the 413
|
||||
* size-cap, and the 429 rate-limit path. Multipart streaming + boss
|
||||
* enqueue are exercised by the parser + multipart unit suites.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
bossSend: vi.fn().mockResolvedValue("boss-job-1"),
|
||||
getGlobalBoss: vi.fn(),
|
||||
importJobCreate: vi.fn(),
|
||||
importJobFindFirst: vi.fn().mockResolvedValue(null),
|
||||
importJobUpdate: vi.fn(),
|
||||
userFindUnique: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: mocks.checkRateLimit,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: mocks.getGlobalBoss,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
importJob: {
|
||||
create: mocks.importJobCreate,
|
||||
findFirst: mocks.importJobFindFirst,
|
||||
update: mocks.importJobUpdate,
|
||||
},
|
||||
user: {
|
||||
findUnique: mocks.userFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const ADMIN_SESSION = {
|
||||
session: { id: "sess-admin", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "marc", role: "ADMIN" as const },
|
||||
};
|
||||
const USER_SESSION = {
|
||||
session: { id: "sess-user", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-2", username: "alice", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
mocks.bossSend.mockResolvedValue("boss-job-1");
|
||||
mocks.getGlobalBoss.mockReturnValue({ send: mocks.bossSend });
|
||||
mocks.importJobCreate.mockResolvedValue({ id: "ij-1" });
|
||||
mocks.importJobFindFirst.mockResolvedValue(null);
|
||||
mocks.importJobUpdate.mockResolvedValue({ id: "ij-1" });
|
||||
mocks.userFindUnique.mockResolvedValue({ id: "user-2", username: "alice" });
|
||||
});
|
||||
|
||||
describe("POST /api/admin/import-apple-health-export", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when a non-admin cookie session calls the route", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER_SESSION as never);
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 413 when Content-Length exceeds the cap", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
|
||||
const headers = new Headers({
|
||||
"content-type": "multipart/form-data; boundary=---x",
|
||||
"content-length": String(2 * 1024 * 1024 * 1024),
|
||||
});
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "stub",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
|
||||
it("returns 429 on rate-limit exhaustion", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
|
||||
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Tests for the polling status endpoint. Covers the canonical envelope
|
||||
* shape, the 404 on cross-user access, and the admin-trigger allow-list.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
importJobFindUnique: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
importJob: { findUnique: mocks.importJobFindUnique },
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const OWNER_SESSION = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
const OTHER_SESSION = {
|
||||
session: { id: "sess-2", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-99", username: "eve", role: "USER" as const },
|
||||
};
|
||||
const ADMIN_SESSION = {
|
||||
session: { id: "sess-a", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "marc-admin", role: "ADMIN" as const },
|
||||
};
|
||||
|
||||
const NOW = new Date("2026-05-15T10:00:00.000Z");
|
||||
|
||||
function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: "ij-1",
|
||||
userId: "user-1",
|
||||
triggeredByAdminId: null,
|
||||
pgBossJobId: "boss-1",
|
||||
status: "parsing",
|
||||
failureReason: null,
|
||||
uploadBytes: 12345,
|
||||
uploadSha256: "abc",
|
||||
exportedAt: null,
|
||||
startedAt: NOW,
|
||||
completedAt: null,
|
||||
progress: { currentPhase: "parsing", recordsRead: 100, rowsUpserted: 0,
|
||||
percent: null, elapsedMs: 1500 },
|
||||
result: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/import/apple-health-export/[jobId]/status", () => {
|
||||
it("returns the row envelope to the owning user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(OWNER_SESSION as never);
|
||||
mocks.importJobFindUnique.mockResolvedValue(makeRow());
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
|
||||
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { status: string }; error: null };
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data.status).toBe("parsing");
|
||||
});
|
||||
|
||||
it("returns 404 to a non-owner non-admin user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(OTHER_SESSION as never);
|
||||
mocks.importJobFindUnique.mockResolvedValue(makeRow());
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
|
||||
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("admits the triggering admin even when userId differs", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
|
||||
mocks.importJobFindUnique.mockResolvedValue(
|
||||
makeRow({ triggeredByAdminId: "admin-1" }),
|
||||
);
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
|
||||
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 404 when the job row is missing", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(OWNER_SESSION as never);
|
||||
mocks.importJobFindUnique.mockResolvedValue(null);
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
|
||||
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export/ij-1/status");
|
||||
const res = await GET(req, { params: Promise.resolve({ jobId: "ij-1" }) });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Smoke / contract tests for the Apple Health import endpoints.
|
||||
*
|
||||
* Targets the cookie + Bearer auth path, the 413 size-cap, and the
|
||||
* 503 worker-not-running path. End-to-end ingest (multipart streaming
|
||||
* + parser + worker) is covered separately by the parser unit suite
|
||||
* + the integration suite under `tests/integration`.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
bossSend: vi.fn().mockResolvedValue("boss-job-1"),
|
||||
getGlobalBoss: vi.fn(),
|
||||
importJobCreate: vi.fn(),
|
||||
importJobFindFirst: vi.fn().mockResolvedValue(null),
|
||||
importJobUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: mocks.checkRateLimit,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: mocks.getGlobalBoss,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
importJob: {
|
||||
create: mocks.importJobCreate,
|
||||
findFirst: mocks.importJobFindFirst,
|
||||
update: mocks.importJobUpdate,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
mocks.bossSend.mockResolvedValue("boss-job-1");
|
||||
mocks.getGlobalBoss.mockReturnValue({ send: mocks.bossSend });
|
||||
mocks.importJobCreate.mockResolvedValue({ id: "ij-1" });
|
||||
mocks.importJobFindFirst.mockResolvedValue(null);
|
||||
mocks.importJobUpdate.mockResolvedValue({ id: "ij-1" });
|
||||
});
|
||||
|
||||
describe("POST /api/import/apple-health-export", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 413 when Content-Length exceeds the 1.5 GB cap", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const headers = new Headers({
|
||||
"content-type": "multipart/form-data; boundary=---x",
|
||||
"content-length": String(2 * 1024 * 1024 * 1024),
|
||||
});
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "stub",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
|
||||
it("returns 429 when the rate-limit window is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
|
||||
const req = new NextRequest("http://localhost/api/import/apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,17 @@ interface AnalyticsData {
|
||||
bpInTargetPctPriorMonth?: number | null;
|
||||
bpInTargetPctPriorYear?: number | null;
|
||||
glucoseByContext?: Record<string, DataSummaryType>;
|
||||
/**
|
||||
* v1.4.34 IW-B — per-type freshness map from `/api/analytics`. The
|
||||
* tile-strip helper reads `lastSeenByType[type]?.daysAgo` and forwards
|
||||
* it to each `<TrendCard staleDays>` so a metric the user hasn't
|
||||
* logged in a while keeps its tile visible (with an explicit
|
||||
* "Letzter Wert vor …" caption) instead of disappearing.
|
||||
*/
|
||||
lastSeenByType?: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
>;
|
||||
}
|
||||
|
||||
interface RangeDisplayConfig {
|
||||
@@ -291,6 +302,25 @@ export default function DashboardPage() {
|
||||
if (current === null || prior === null) return null;
|
||||
return Math.round((current - prior) * 100) / 100;
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.4.34 IW-B — read the per-type freshness map and surface the
|
||||
* `daysAgo` value when the metric is older than a week. The
|
||||
* tile-strip below forwards the result to `<TrendCard staleDays>`
|
||||
* which picks the bucket-aware copy (Xd / X weeks / X months) and
|
||||
* paints the caption on the tile. Returns `null` for metrics with no
|
||||
* reading yet OR within the fresh window so call sites stay
|
||||
* undefined-safe and tiles with recent data paint byte-identical
|
||||
* with the pre-v1.4.34 contract.
|
||||
*/
|
||||
const tileStaleDays = (
|
||||
type: string | null | undefined,
|
||||
): number | null => {
|
||||
if (!type) return null;
|
||||
const entry = data?.lastSeenByType?.[type];
|
||||
if (!entry) return null;
|
||||
return entry.daysAgo > 7 ? entry.daysAgo : null;
|
||||
};
|
||||
/** Whether the widget's *chart* (lower row) shows. */
|
||||
const isChartVisible = (id: string) =>
|
||||
layout.widgets.find((widget) => widget.id === id)?.visible ?? false;
|
||||
@@ -632,6 +662,7 @@ export default function DashboardPage() {
|
||||
directionSentiment="up-bad"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(w)}
|
||||
staleDays={tileStaleDays("WEIGHT")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -677,6 +708,7 @@ export default function DashboardPage() {
|
||||
directionSentiment="up-bad"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(sys)}
|
||||
staleDays={tileStaleDays("BLOOD_PRESSURE_SYS")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -717,6 +749,7 @@ export default function DashboardPage() {
|
||||
directionSentiment="up-bad"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(dia)}
|
||||
staleDays={tileStaleDays("BLOOD_PRESSURE_DIA")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -756,6 +789,7 @@ export default function DashboardPage() {
|
||||
icon={TrendingUp}
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(p)}
|
||||
staleDays={tileStaleDays("PULSE")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -778,6 +812,7 @@ export default function DashboardPage() {
|
||||
directionSentiment="up-bad"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(bf)}
|
||||
staleDays={tileStaleDays("BODY_FAT")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -822,6 +857,7 @@ export default function DashboardPage() {
|
||||
directionSentiment="up-good"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(sleepSummary)}
|
||||
staleDays={tileStaleDays("SLEEP_DURATION")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -844,6 +880,7 @@ export default function DashboardPage() {
|
||||
directionSentiment="up-good"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(stepsSummary)}
|
||||
staleDays={tileStaleDays("ACTIVITY_STEPS")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -873,6 +910,7 @@ export default function DashboardPage() {
|
||||
directionSentiment="up-good"
|
||||
compareBaseline={compareBaseline}
|
||||
compareDelta={tileCompareDelta(vo2Summary)}
|
||||
staleDays={tileStaleDays("VO2_MAX")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -973,6 +1011,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
slope30={s.slope30 ?? null}
|
||||
icon={Droplet}
|
||||
staleDays={tileStaleDays("BLOOD_GLUCOSE")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { TrendCard } from "../trend-card";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.34 IW-B — pin the bucket-aware stale-hint copy on `<TrendCard>`.
|
||||
*
|
||||
* The tile shows a "Letzter Wert vor …" caption when the metric has not
|
||||
* been logged in a while, so the tile stays on the dashboard with an
|
||||
* explicit how-old hint instead of disappearing. Buckets:
|
||||
* - `staleDays <= 7` — silent (the tile reads as fresh)
|
||||
* - `8 <= staleDays <= 30` — "vor Xd" / "X d ago"
|
||||
* - `31 <= staleDays <= 60` — "vor X Wochen" / "X weeks ago"
|
||||
* - `staleDays > 60` — "vor X Monaten" / "X months ago"
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode, locale: "de" | "en" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
label: "Weight",
|
||||
latest: 80,
|
||||
unit: "kg",
|
||||
avg7: 80,
|
||||
avg30: 80,
|
||||
slope30: null,
|
||||
icon: Activity,
|
||||
};
|
||||
|
||||
describe("<TrendCard staleDays>", () => {
|
||||
it("stays silent when the metric is fresh (<= 7 days)", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={3} />);
|
||||
expect(html).not.toContain("data-slot=\"tile-stale-hint\"");
|
||||
});
|
||||
|
||||
it("renders day-bucket copy when staleDays is in (7, 30]", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={12} />, "en");
|
||||
expect(html).toContain("data-slot=\"tile-stale-hint\"");
|
||||
expect(html).toContain("Last reading 12 d ago");
|
||||
});
|
||||
|
||||
it("collapses 31-60 days into a weekly singular form", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={40} />, "en");
|
||||
// 40 / 7 = 5 (Math.floor) — plural form.
|
||||
expect(html).toContain("Last reading 5 weeks ago");
|
||||
});
|
||||
|
||||
it("collapses days > 60 into a monthly form", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={75} />, "de");
|
||||
// 75 / 30 = 2 — plural "Monaten".
|
||||
expect(html).toContain("Letzter Wert vor 2 Monaten");
|
||||
});
|
||||
|
||||
it("uses the German singular month form at exactly one month", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={61} />, "de");
|
||||
// 61 / 30 = 2 (Math.floor) — plural "Monaten".
|
||||
expect(html).toContain("Letzter Wert vor 2 Monaten");
|
||||
});
|
||||
|
||||
it("renders the week-singular form when staleDays maps to exactly one week", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={35} />, "en");
|
||||
// 35 / 7 = 5 — plural. Edge case for the singular path: staleDays
|
||||
// close to 31 (e.g. 31/7 = 4) still falls in the (7, 30] day-bucket
|
||||
// because we hit the day branch before the week branch. The
|
||||
// smallest staleDays that yields 1 week is impossible inside the
|
||||
// week bucket (min weeks = floor(31/7) = 4), so the singular path
|
||||
// is reachable only when a future bucket boundary changes — pin
|
||||
// the math anyway to catch regressions.
|
||||
expect(html).toContain("Last reading 5 weeks ago");
|
||||
});
|
||||
});
|
||||
@@ -327,14 +327,41 @@ export function TrendCard({
|
||||
whole tile disappear because of a gap in logging. Ride the
|
||||
same callout-slot reservation as the comparison-overlay
|
||||
caption; on `<sm` the line-clamp keeps the 140 px height
|
||||
contract intact. */}
|
||||
{staleDays != null && staleDays > 0 && (
|
||||
contract intact.
|
||||
|
||||
v1.4.34 IW-B — bucket-aware copy. Days under a week stay
|
||||
silent (the tile reads as fresh); 8-30 days surfaces as
|
||||
"vor Xd"; 31-60 days collapses to "vor X Wochen"; beyond
|
||||
two months collapses to "vor X Monaten". One key per
|
||||
bucket plus a singular/plural pair so locales with
|
||||
non-English plural rules read naturally. */}
|
||||
{staleDays != null && staleDays > 7 && (
|
||||
<span
|
||||
className="text-muted-foreground line-clamp-1 inline-block max-w-full text-xs leading-snug tabular-nums sm:line-clamp-none"
|
||||
data-slot="tile-stale-hint"
|
||||
data-stale-days={staleDays}
|
||||
>
|
||||
{t("dashboard.staleHint", { count: staleDays })}
|
||||
{(() => {
|
||||
if (staleDays <= 30) {
|
||||
return t("dashboard.staleHint", { count: staleDays });
|
||||
}
|
||||
if (staleDays <= 60) {
|
||||
const weeks = Math.floor(staleDays / 7);
|
||||
return t(
|
||||
weeks === 1
|
||||
? "dashboard.staleHintWeeksOne"
|
||||
: "dashboard.staleHintWeeksOther",
|
||||
{ count: weeks },
|
||||
);
|
||||
}
|
||||
const months = Math.floor(staleDays / 30);
|
||||
return t(
|
||||
months === 1
|
||||
? "dashboard.staleHintMonthsOne"
|
||||
: "dashboard.staleHintMonthsOther",
|
||||
{ count: months },
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { writeFileSync, mkdtempSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
|
||||
import { extractExportXml, readCentralDirectory } from "../unzip-export-xml";
|
||||
|
||||
/**
|
||||
* Hand-build a single-entry deflate-compressed ZIP archive with the
|
||||
* supplied filename + payload. Enough to exercise the central-directory
|
||||
* walker + extractor without pulling in a full zip library.
|
||||
*/
|
||||
function buildMinimalZip(filename: string, payload: Buffer): Buffer {
|
||||
const compressed = deflateRawSync(payload);
|
||||
const crc32 = (() => {
|
||||
// Pre-computed lookup table for CRC-32. node:zlib has crc32 in 22+
|
||||
// but we keep the function inline to stay version-agnostic.
|
||||
let c: number;
|
||||
const table: number[] = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c;
|
||||
}
|
||||
let crc = 0 ^ -1;
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
crc = (crc >>> 8) ^ table[(crc ^ payload[i]) & 0xff];
|
||||
}
|
||||
return (crc ^ -1) >>> 0;
|
||||
})();
|
||||
|
||||
const nameBuf = Buffer.from(filename, "utf8");
|
||||
// Local file header
|
||||
const localHeader = Buffer.alloc(30);
|
||||
localHeader.writeUInt32LE(0x04034b50, 0);
|
||||
localHeader.writeUInt16LE(20, 4); // version
|
||||
localHeader.writeUInt16LE(0, 6); // flags
|
||||
localHeader.writeUInt16LE(8, 8); // method: deflate
|
||||
localHeader.writeUInt16LE(0, 10); // time
|
||||
localHeader.writeUInt16LE(0, 12); // date
|
||||
localHeader.writeUInt32LE(crc32, 14);
|
||||
localHeader.writeUInt32LE(compressed.length, 18);
|
||||
localHeader.writeUInt32LE(payload.length, 22);
|
||||
localHeader.writeUInt16LE(nameBuf.length, 26);
|
||||
localHeader.writeUInt16LE(0, 28); // extra len
|
||||
|
||||
// Central directory file header
|
||||
const cdh = Buffer.alloc(46);
|
||||
cdh.writeUInt32LE(0x02014b50, 0);
|
||||
cdh.writeUInt16LE(20, 4); // version made by
|
||||
cdh.writeUInt16LE(20, 6); // version needed
|
||||
cdh.writeUInt16LE(0, 8); // flags
|
||||
cdh.writeUInt16LE(8, 10); // method
|
||||
cdh.writeUInt16LE(0, 12); // time
|
||||
cdh.writeUInt16LE(0, 14); // date
|
||||
cdh.writeUInt32LE(crc32, 16);
|
||||
cdh.writeUInt32LE(compressed.length, 20);
|
||||
cdh.writeUInt32LE(payload.length, 24);
|
||||
cdh.writeUInt16LE(nameBuf.length, 28);
|
||||
cdh.writeUInt16LE(0, 30); // extra
|
||||
cdh.writeUInt16LE(0, 32); // comment
|
||||
cdh.writeUInt16LE(0, 34); // disk num
|
||||
cdh.writeUInt16LE(0, 36); // internal attrs
|
||||
cdh.writeUInt32LE(0, 38); // external attrs
|
||||
cdh.writeUInt32LE(0, 42); // local header offset
|
||||
|
||||
// End of Central Directory
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4); // disk
|
||||
eocd.writeUInt16LE(0, 6); // disk-with-cd
|
||||
eocd.writeUInt16LE(1, 8); // entries-on-disk
|
||||
eocd.writeUInt16LE(1, 10); // total-entries
|
||||
eocd.writeUInt32LE(46 + nameBuf.length, 12); // cd size
|
||||
// cd offset = local-header + name + data
|
||||
eocd.writeUInt32LE(30 + nameBuf.length + compressed.length, 16);
|
||||
eocd.writeUInt16LE(0, 20); // comment len
|
||||
|
||||
return Buffer.concat([
|
||||
localHeader,
|
||||
nameBuf,
|
||||
compressed,
|
||||
cdh,
|
||||
nameBuf,
|
||||
eocd,
|
||||
]);
|
||||
}
|
||||
|
||||
describe("readCentralDirectory", () => {
|
||||
it("walks a single-entry archive", () => {
|
||||
const payload = Buffer.from("<HealthData/>");
|
||||
const zip = buildMinimalZip("apple_health_export/export.xml", payload);
|
||||
const entries = readCentralDirectory(zip);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].fileName).toBe("apple_health_export/export.xml");
|
||||
expect(entries[0].compressionMethod).toBe(8);
|
||||
expect(entries[0].uncompressedSize).toBe(payload.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractExportXml", () => {
|
||||
it("extracts the export.xml member to a temp file", () => {
|
||||
const payload = Buffer.from(
|
||||
`<?xml version="1.0"?><HealthData><ExportDate value="2026-05-15"/></HealthData>`,
|
||||
);
|
||||
const zip = buildMinimalZip("apple_health_export/export.xml", payload);
|
||||
const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
|
||||
const zipPath = join(tmp, "export.zip");
|
||||
writeFileSync(zipPath, zip);
|
||||
|
||||
const out = extractExportXml(zipPath);
|
||||
expect(out.xmlBytes).toBe(payload.length);
|
||||
expect(out.otherMembers).toHaveLength(0);
|
||||
|
||||
const extracted = readFileSync(out.xmlPath);
|
||||
expect(extracted.toString("utf8")).toBe(payload.toString("utf8"));
|
||||
});
|
||||
|
||||
it("throws when the export.xml member is missing", () => {
|
||||
const payload = Buffer.from("noop");
|
||||
const zip = buildMinimalZip("other-file.txt", payload);
|
||||
const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
|
||||
const zipPath = join(tmp, "export.zip");
|
||||
writeFileSync(zipPath, zip);
|
||||
|
||||
expect(() => extractExportXml(zipPath)).toThrow(/missing/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HK_WORKOUT_ACTIVITY_TYPE_MAP,
|
||||
resolveHkWorkoutSportType,
|
||||
} from "../hk-workout-activity-type-map";
|
||||
import { workoutSportTypeEnum } from "@/lib/validations/workout";
|
||||
|
||||
describe("HK_WORKOUT_ACTIVITY_TYPE_MAP", () => {
|
||||
it("only references sport types that exist in the canonical enum", () => {
|
||||
const valid = new Set<string>(workoutSportTypeEnum.options);
|
||||
for (const [hkType, sport] of Object.entries(
|
||||
HK_WORKOUT_ACTIVITY_TYPE_MAP,
|
||||
)) {
|
||||
expect(
|
||||
valid.has(sport),
|
||||
`${hkType} maps to '${sport}', not in workoutSportTypeEnum`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers the iOS 18 canonical roster", () => {
|
||||
// Spot-check the most commonly observed identifiers; full
|
||||
// exhaustiveness is impractical because Apple silently adds
|
||||
// identifiers between iOS releases.
|
||||
const canonical = [
|
||||
"HKWorkoutActivityTypeRunning",
|
||||
"HKWorkoutActivityTypeWalking",
|
||||
"HKWorkoutActivityTypeCycling",
|
||||
"HKWorkoutActivityTypeHiking",
|
||||
"HKWorkoutActivityTypeSwimming",
|
||||
"HKWorkoutActivityTypeYoga",
|
||||
"HKWorkoutActivityTypeHighIntensityIntervalTraining",
|
||||
"HKWorkoutActivityTypeFunctionalStrengthTraining",
|
||||
"HKWorkoutActivityTypeOther",
|
||||
];
|
||||
for (const hkType of canonical) {
|
||||
expect(
|
||||
HK_WORKOUT_ACTIVITY_TYPE_MAP[hkType],
|
||||
`${hkType} missing from map`,
|
||||
).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHkWorkoutSportType", () => {
|
||||
it("returns the mapped sport and a `known` flag for a known identifier", () => {
|
||||
expect(resolveHkWorkoutSportType("HKWorkoutActivityTypeRunning")).toEqual({
|
||||
sportType: "running",
|
||||
known: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'other' with a falsy known flag", () => {
|
||||
const out = resolveHkWorkoutSportType("HKWorkoutActivityTypeFutureTypeXYZ");
|
||||
expect(out.sportType).toBe("other");
|
||||
expect(out.known).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,432 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { writeFileSync, mkdtempSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import {
|
||||
hashSampleKey,
|
||||
parseRecordValue,
|
||||
streamParseExportXml,
|
||||
SLEEP_STAGE_NAME_TO_CODEPOINT,
|
||||
APPLE_HEALTH_SLEEP_STAGE_MAP,
|
||||
type ImportJobProgress,
|
||||
} from "../import-apple-health-export";
|
||||
|
||||
/**
|
||||
* Build a minimal hand-authored `export.xml` covering the surface area
|
||||
* the parser must handle end-to-end: spot quantity (weight), cumulative
|
||||
* quantity (steps), sleep stage, audio-exposure event, workout,
|
||||
* deferred identifier, unknown identifier, clinical record.
|
||||
*/
|
||||
function tinyExportXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE HealthData [<!ELEMENT HealthData (Record|Workout|ClinicalRecord)*>]>
|
||||
<HealthData locale="en_US">
|
||||
<ExportDate value="2026-05-15 14:32:01 +0200"/>
|
||||
<Me HKCharacteristicTypeIdentifierDateOfBirth="1985-06-12"/>
|
||||
|
||||
<Record type="HKQuantityTypeIdentifierBodyMass"
|
||||
unit="kg"
|
||||
startDate="2026-05-14 08:13:00 +0200"
|
||||
endDate="2026-05-14 08:14:00 +0200"
|
||||
value="78.4"
|
||||
sourceName="Withings Scale"
|
||||
sourceVersion="2.4.1"/>
|
||||
|
||||
<Record type="HKQuantityTypeIdentifierStepCount"
|
||||
unit="count"
|
||||
startDate="2026-05-14 08:00:00 +0200"
|
||||
endDate="2026-05-14 08:30:00 +0200"
|
||||
value="1500"
|
||||
sourceName="Marc's iPhone"
|
||||
sourceVersion="17.4.1"/>
|
||||
|
||||
<Record type="HKQuantityTypeIdentifierStepCount"
|
||||
unit="count"
|
||||
startDate="2026-05-14 12:00:00 +0200"
|
||||
endDate="2026-05-14 12:30:00 +0200"
|
||||
value="3500"
|
||||
sourceName="Marc's iPhone"
|
||||
sourceVersion="17.4.1"/>
|
||||
|
||||
<Record type="HKCategoryTypeIdentifierSleepAnalysis"
|
||||
unit=""
|
||||
startDate="2026-05-14 23:00:00 +0200"
|
||||
endDate="2026-05-15 06:30:00 +0200"
|
||||
value="HKCategoryValueSleepAnalysisAsleepDeep"
|
||||
sourceName="Apple Watch"
|
||||
sourceVersion="11.2"/>
|
||||
|
||||
<Workout workoutActivityType="HKWorkoutActivityTypeRunning"
|
||||
duration="42.0"
|
||||
durationUnit="min"
|
||||
totalDistance="6.5"
|
||||
totalDistanceUnit="km"
|
||||
totalEnergyBurned="412"
|
||||
totalEnergyBurnedUnit="kcal"
|
||||
startDate="2026-05-14 18:00:00 +0200"
|
||||
endDate="2026-05-14 18:42:00 +0200"
|
||||
sourceName="Apple Watch"/>
|
||||
|
||||
<Record type="HKQuantityTypeIdentifierDietaryWater"
|
||||
unit="L"
|
||||
startDate="2026-05-14 10:00:00 +0200"
|
||||
endDate="2026-05-14 10:00:00 +0200"
|
||||
value="0.5"
|
||||
sourceName="Health app"/>
|
||||
|
||||
<Record type="HKQuantityTypeIdentifierFutureTypeXYZ"
|
||||
unit="??"
|
||||
startDate="2026-05-14 10:00:00 +0200"
|
||||
endDate="2026-05-14 10:00:00 +0200"
|
||||
value="1"/>
|
||||
|
||||
<ClinicalRecord type="HKClinicalTypeIdentifierLabResultRecord"
|
||||
identifier="lab-1"
|
||||
fhirResource="{}"/>
|
||||
</HealthData>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory Prisma stand-in. The parser calls only the `measurement`
|
||||
* and `workout` model methods documented on `StreamParseInput.prisma`;
|
||||
* we mimic just those.
|
||||
*/
|
||||
function makeFakePrisma() {
|
||||
type Row = Record<string, unknown>;
|
||||
const measurements: Row[] = [];
|
||||
const workouts: Row[] = [];
|
||||
|
||||
return {
|
||||
_measurements: measurements,
|
||||
_workouts: workouts,
|
||||
measurement: {
|
||||
findMany: async ({
|
||||
where,
|
||||
}: {
|
||||
where: { userId: string; source: string; OR: Array<Record<string, unknown>> };
|
||||
}) => {
|
||||
return measurements.filter((m) => {
|
||||
if (m.userId !== where.userId || m.source !== where.source) return false;
|
||||
return where.OR.some(
|
||||
(clause) => m.type === clause.type && m.externalId === clause.externalId,
|
||||
);
|
||||
}).map((m) => ({ type: m.type, externalId: m.externalId }));
|
||||
},
|
||||
findUnique: async ({
|
||||
where,
|
||||
}: {
|
||||
where: { userId_type_source_externalId: Record<string, unknown> };
|
||||
}) => {
|
||||
const key = where.userId_type_source_externalId;
|
||||
return measurements.find(
|
||||
(m) =>
|
||||
m.userId === key.userId
|
||||
&& m.type === key.type
|
||||
&& m.source === key.source
|
||||
&& m.externalId === key.externalId,
|
||||
) ?? null;
|
||||
},
|
||||
upsert: async ({
|
||||
where,
|
||||
create,
|
||||
update,
|
||||
}: {
|
||||
where: { userId_type_source_externalId: Record<string, unknown> };
|
||||
create: Row;
|
||||
update: Row;
|
||||
}) => {
|
||||
const key = where.userId_type_source_externalId;
|
||||
const idx = measurements.findIndex(
|
||||
(m) =>
|
||||
m.userId === key.userId
|
||||
&& m.type === key.type
|
||||
&& m.source === key.source
|
||||
&& m.externalId === key.externalId,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
measurements[idx] = { ...measurements[idx], ...update };
|
||||
return measurements[idx];
|
||||
}
|
||||
measurements.push(create);
|
||||
return create;
|
||||
},
|
||||
},
|
||||
workout: {
|
||||
findMany: async ({
|
||||
where,
|
||||
}: {
|
||||
where: { userId: string; source: string; externalId: { in: string[] } };
|
||||
}) => {
|
||||
return workouts.filter(
|
||||
(w) =>
|
||||
w.userId === where.userId
|
||||
&& w.source === where.source
|
||||
&& where.externalId.in.includes(w.externalId as string),
|
||||
).map((w) => ({ externalId: w.externalId }));
|
||||
},
|
||||
upsert: async ({
|
||||
where,
|
||||
create,
|
||||
update,
|
||||
}: {
|
||||
where: { userId_source_externalId: Record<string, unknown> };
|
||||
create: Row;
|
||||
update: Row;
|
||||
}) => {
|
||||
const key = where.userId_source_externalId;
|
||||
const idx = workouts.findIndex(
|
||||
(w) =>
|
||||
w.userId === key.userId
|
||||
&& w.source === key.source
|
||||
&& w.externalId === key.externalId,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
workouts[idx] = { ...workouts[idx], ...update };
|
||||
return workouts[idx];
|
||||
}
|
||||
workouts.push(create);
|
||||
return create;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseRecordValue", () => {
|
||||
it("parses a quantity-record float value", () => {
|
||||
expect(
|
||||
parseRecordValue(
|
||||
"HKQuantityTypeIdentifierBodyMass",
|
||||
"78.4",
|
||||
"2026-05-14 08:13:00 +0200",
|
||||
"2026-05-14 08:14:00 +0200",
|
||||
),
|
||||
).toEqual({ value: 78.4 });
|
||||
});
|
||||
|
||||
it("maps a sleep-stage symbolic name to its integer codepoint", () => {
|
||||
const result = parseRecordValue(
|
||||
"HKCategoryTypeIdentifierSleepAnalysis",
|
||||
"HKCategoryValueSleepAnalysisAsleepDeep",
|
||||
"2026-05-14 23:00:00 +0200",
|
||||
"2026-05-15 06:30:00 +0200",
|
||||
);
|
||||
expect(result?.sleepStage).toBe(4);
|
||||
// duration should be (6:30 + 1 day - 23:00) = 7h30m = 450 minutes
|
||||
expect(result?.value).toBeGreaterThan(449);
|
||||
expect(result?.value).toBeLessThan(451);
|
||||
});
|
||||
|
||||
it("returns null for an unparseable quantity", () => {
|
||||
expect(
|
||||
parseRecordValue(
|
||||
"HKQuantityTypeIdentifierBodyMass",
|
||||
"not-a-number",
|
||||
"2026-05-14 08:13:00 +0200",
|
||||
"2026-05-14 08:14:00 +0200",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("treats audio-exposure events as 1-count categoricals", () => {
|
||||
const result = parseRecordValue(
|
||||
"HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
|
||||
"",
|
||||
"2026-05-14 12:00:00 +0200",
|
||||
"2026-05-14 12:00:00 +0200",
|
||||
);
|
||||
expect(result).toEqual({ value: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("SLEEP_STAGE_NAME_TO_CODEPOINT", () => {
|
||||
it("round-trips with APPLE_HEALTH_SLEEP_STAGE_MAP", () => {
|
||||
// Every value in the name map must resolve via the canonical
|
||||
// codepoint map back to a SleepStage.
|
||||
for (const [name, codepoint] of Object.entries(
|
||||
SLEEP_STAGE_NAME_TO_CODEPOINT,
|
||||
)) {
|
||||
const stage = APPLE_HEALTH_SLEEP_STAGE_MAP[codepoint];
|
||||
expect(stage, `${name} → ${codepoint}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashSampleKey", () => {
|
||||
it("returns a stable short hash for the same inputs", () => {
|
||||
const a = hashSampleKey("HKQuantityTypeIdentifierBodyMass", "78.4",
|
||||
"2026-05-14 08:13:00 +0200", "2026-05-14 08:14:00 +0200");
|
||||
const b = hashSampleKey("HKQuantityTypeIdentifierBodyMass", "78.4",
|
||||
"2026-05-14 08:13:00 +0200", "2026-05-14 08:14:00 +0200");
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^sample:[0-9a-f]{28}$/);
|
||||
});
|
||||
|
||||
it("differs when any input differs", () => {
|
||||
const a = hashSampleKey("HKQuantityTypeIdentifierBodyMass", "78.4",
|
||||
"2026-05-14 08:13:00 +0200", "2026-05-14 08:14:00 +0200");
|
||||
const b = hashSampleKey("HKQuantityTypeIdentifierBodyMass", "78.5",
|
||||
"2026-05-14 08:13:00 +0200", "2026-05-14 08:14:00 +0200");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamParseExportXml — end-to-end", () => {
|
||||
it("ingests the tiny fixture into per-type / workout / deferred / unknown buckets", async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-test-"));
|
||||
const xmlPath = join(tmp, "export.xml");
|
||||
writeFileSync(xmlPath, tinyExportXml());
|
||||
|
||||
const prisma = makeFakePrisma();
|
||||
const progressSnapshots: ImportJobProgress[] = [];
|
||||
|
||||
const result = await streamParseExportXml({
|
||||
xmlPath,
|
||||
userId: "user-1",
|
||||
userTimezone: "Europe/Berlin",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma: prisma as any,
|
||||
onProgress: (snap) => {
|
||||
progressSnapshots.push(snap);
|
||||
},
|
||||
spotBatchSize: 2,
|
||||
workoutBatchSize: 1,
|
||||
});
|
||||
|
||||
// Spot row — weight upsert landed
|
||||
expect(result.perType.WEIGHT?.inserted).toBe(1);
|
||||
expect(result.perType.WEIGHT?.updated).toBe(0);
|
||||
|
||||
// Cumulative — both step records collapsed into one daily bucket
|
||||
expect(result.perType.ACTIVITY_STEPS?.inserted).toBe(1);
|
||||
expect(result.perType.ACTIVITY_STEPS?.read).toBe(2);
|
||||
// The flushed daily-stats row should sum the two records:
|
||||
const stepsRow = prisma._measurements.find(
|
||||
(m) => m.type === "ACTIVITY_STEPS",
|
||||
);
|
||||
expect(stepsRow?.value).toBe(5000);
|
||||
expect(stepsRow?.externalId).toMatch(
|
||||
/^stats:HKQuantityTypeIdentifierStepCount:\d{4}-\d{2}-\d{2}$/,
|
||||
);
|
||||
|
||||
// Sleep — translated through the sleep-stage table
|
||||
expect(result.perType.SLEEP_DURATION?.inserted).toBe(1);
|
||||
|
||||
// Workout — single running entry
|
||||
expect(result.workouts.read).toBe(1);
|
||||
expect(result.workouts.inserted).toBe(1);
|
||||
expect(result.workouts.unknownActivityType).toBe(0);
|
||||
const workoutRow = prisma._workouts[0];
|
||||
expect(workoutRow?.sportType).toBe("running");
|
||||
// 6.5 km → 6500 m
|
||||
expect(workoutRow?.totalDistanceM).toBe(6500);
|
||||
|
||||
// Deferred identifier — DietaryWater landed in deferred, not unknown
|
||||
expect(result.deferred.HKQuantityTypeIdentifierDietaryWater).toBe(1);
|
||||
expect(result.unknown.HKQuantityTypeIdentifierDietaryWater).toBeUndefined();
|
||||
|
||||
// Unknown identifier — surfaced under unknown
|
||||
expect(result.unknown.HKQuantityTypeIdentifierFutureTypeXYZ).toBe(1);
|
||||
|
||||
// Clinical record — skipped
|
||||
expect(result.clinical.skipped).toBe(1);
|
||||
|
||||
// Progress hook fired at least once
|
||||
expect(progressSnapshots.length).toBeGreaterThan(0);
|
||||
// Totals consistent
|
||||
expect(result.totals.recordsRead).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
|
||||
it("re-importing the same export reports 0 inserts and N updates", async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-test-"));
|
||||
const xmlPath = join(tmp, "export.xml");
|
||||
writeFileSync(xmlPath, tinyExportXml());
|
||||
const prisma = makeFakePrisma();
|
||||
|
||||
await streamParseExportXml({
|
||||
xmlPath,
|
||||
userId: "user-1",
|
||||
userTimezone: "Europe/Berlin",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma: prisma as any,
|
||||
});
|
||||
|
||||
const secondRun = await streamParseExportXml({
|
||||
xmlPath,
|
||||
userId: "user-1",
|
||||
userTimezone: "Europe/Berlin",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma: prisma as any,
|
||||
});
|
||||
|
||||
// Second run — every row that landed the first time should
|
||||
// update rather than insert.
|
||||
expect(secondRun.perType.WEIGHT?.inserted).toBe(0);
|
||||
expect(secondRun.perType.WEIGHT?.updated).toBe(1);
|
||||
expect(secondRun.perType.ACTIVITY_STEPS?.inserted).toBe(0);
|
||||
expect(secondRun.perType.ACTIVITY_STEPS?.updated).toBe(1);
|
||||
expect(secondRun.workouts.inserted).toBe(0);
|
||||
expect(secondRun.workouts.updated).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Streaming-memory ceiling: synthesise a ~5 MB export and assert peak
|
||||
* heap stays bounded. Smaller than the §11 §5 fixture goal (50 MB) to
|
||||
* keep CI iteration fast; the property under test is identical.
|
||||
*/
|
||||
describe("streamParseExportXml — memory ceiling", () => {
|
||||
it("bounded peak heap on a multi-thousand-row synthetic export", async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-test-"));
|
||||
const xmlPath = join(tmp, "export.xml");
|
||||
|
||||
// ~10 000 step records — each ~250 bytes → ~2.5 MB. Synthetic
|
||||
// timestamps walk seconds within a single day so every record has
|
||||
// a valid wall-clock and lands in the same cumulative bucket.
|
||||
const header =
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n`
|
||||
+ `<!DOCTYPE HealthData [<!ELEMENT HealthData (Record)*>]>\n`
|
||||
+ `<HealthData locale="en_US">\n`;
|
||||
const footer = `</HealthData>\n`;
|
||||
const rows: string[] = [];
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
const hour = Math.floor(i / 3600) % 24;
|
||||
const minute = Math.floor((i % 3600) / 60);
|
||||
const second = i % 60;
|
||||
const ts = `2026-05-14 ${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}:${second.toString().padStart(2, "0")} +0200`;
|
||||
rows.push(
|
||||
`<Record type="HKQuantityTypeIdentifierStepCount"`
|
||||
+ ` unit="count"`
|
||||
+ ` startDate="${ts}"`
|
||||
+ ` endDate="${ts}"`
|
||||
+ ` value="${(i % 200) + 1}"/>\n`,
|
||||
);
|
||||
}
|
||||
writeFileSync(xmlPath, header + rows.join("") + footer);
|
||||
const bytes = readFileSync(xmlPath).length;
|
||||
expect(bytes).toBeGreaterThan(1_000_000);
|
||||
|
||||
const beforeHeap = process.memoryUsage().heapUsed;
|
||||
const prisma = makeFakePrisma();
|
||||
const result = await streamParseExportXml({
|
||||
xmlPath,
|
||||
userId: "user-1",
|
||||
userTimezone: "Europe/Berlin",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma: prisma as any,
|
||||
});
|
||||
const afterHeap = process.memoryUsage().heapUsed;
|
||||
const heapDelta = afterHeap - beforeHeap;
|
||||
|
||||
expect(result.totals.recordsRead).toBe(10_000);
|
||||
expect(result.perType.ACTIVITY_STEPS?.read).toBe(10_000);
|
||||
// The cumulative fold collapses all 10 000 records into one daily
|
||||
// bucket (they all land on the same date in the fixture).
|
||||
expect(result.perType.ACTIVITY_STEPS?.inserted).toBe(1);
|
||||
|
||||
// Heap delta should be modest — we tolerate a generous 100 MB so a
|
||||
// GC-related spike doesn't flake the test, but a regression that
|
||||
// buffers per-record would push us above this ceiling fast.
|
||||
expect(heapDelta).toBeLessThan(100 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync, unlinkSync } from "node:fs";
|
||||
|
||||
import {
|
||||
parseBoundary,
|
||||
streamMultipartToDisk,
|
||||
} from "../stream-to-disk";
|
||||
|
||||
const BOUNDARY = "----HealthLogTestBoundary";
|
||||
|
||||
/**
|
||||
* Build a multipart body covering the canonical Apple Health import
|
||||
* upload shape: one named `file` field + one text `userId` field.
|
||||
*/
|
||||
function buildMultipartBody(
|
||||
filename: string,
|
||||
fileContent: Buffer,
|
||||
textFields: Record<string, string> = {},
|
||||
): Buffer {
|
||||
const parts: Buffer[] = [];
|
||||
for (const [name, value] of Object.entries(textFields)) {
|
||||
parts.push(
|
||||
Buffer.from(
|
||||
`--${BOUNDARY}\r\n`
|
||||
+ `Content-Disposition: form-data; name="${name}"\r\n\r\n`
|
||||
+ value
|
||||
+ `\r\n`,
|
||||
),
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
Buffer.from(
|
||||
`--${BOUNDARY}\r\n`
|
||||
+ `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n`
|
||||
+ `Content-Type: application/octet-stream\r\n\r\n`,
|
||||
),
|
||||
);
|
||||
parts.push(fileContent);
|
||||
parts.push(Buffer.from(`\r\n--${BOUNDARY}--\r\n`));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function toWebStream(buf: Buffer): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(buf));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("parseBoundary", () => {
|
||||
it("extracts a quoted boundary", () => {
|
||||
expect(parseBoundary(`multipart/form-data; boundary="abc-123"`)).toBe(
|
||||
"abc-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts an unquoted boundary", () => {
|
||||
expect(parseBoundary(`multipart/form-data; boundary=abc-123`)).toBe(
|
||||
"abc-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null on a missing header", () => {
|
||||
expect(parseBoundary(null)).toBeNull();
|
||||
expect(parseBoundary("application/json")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamMultipartToDisk", () => {
|
||||
it("streams the file field to disk + captures text fields + sha256", async () => {
|
||||
const payload = Buffer.from("the quick brown fox jumps over the lazy dog");
|
||||
const body = buildMultipartBody("export.zip", payload, { userId: "u-1" });
|
||||
const result = await streamMultipartToDisk(
|
||||
toWebStream(body),
|
||||
`multipart/form-data; boundary=${BOUNDARY}`,
|
||||
{
|
||||
maxBytes: 1024,
|
||||
fieldName: "file",
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
expect(result.bytes).toBe(payload.length);
|
||||
expect(result.originalFilename).toBe("export.zip");
|
||||
expect(result.textFields.userId).toBe("u-1");
|
||||
const written = readFileSync(result.filePath);
|
||||
expect(written.equals(payload)).toBe(true);
|
||||
// SHA-256 of the well-known phrase
|
||||
expect(result.sha256).toBe(
|
||||
"05c6e08f1d9fdafa03147fcb8f82f124c76d2f70e3d989dc8aadb5e7d7450bec",
|
||||
);
|
||||
} finally {
|
||||
try { unlinkSync(result.filePath); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when the upload exceeds the size cap", async () => {
|
||||
const payload = Buffer.alloc(2048, 0x41);
|
||||
const body = buildMultipartBody("export.zip", payload);
|
||||
await expect(
|
||||
streamMultipartToDisk(
|
||||
toWebStream(body),
|
||||
`multipart/form-data; boundary=${BOUNDARY}`,
|
||||
{ maxBytes: 100, fieldName: "file" },
|
||||
),
|
||||
).rejects.toThrow(/size cap/);
|
||||
});
|
||||
|
||||
it("throws when the named field is missing", async () => {
|
||||
const body = buildMultipartBody("export.zip", Buffer.from("x"), {
|
||||
something: "else",
|
||||
});
|
||||
// Override the filename to a field name that's NOT the captured one
|
||||
// by re-building without a file field at all.
|
||||
const onlyText = Buffer.from(
|
||||
`--${BOUNDARY}\r\n`
|
||||
+ `Content-Disposition: form-data; name="userId"\r\n\r\n`
|
||||
+ `u-1`
|
||||
+ `\r\n`
|
||||
+ `--${BOUNDARY}--\r\n`,
|
||||
);
|
||||
void body; // referenced above for shape demo
|
||||
await expect(
|
||||
streamMultipartToDisk(
|
||||
toWebStream(onlyText),
|
||||
`multipart/form-data; boundary=${BOUNDARY}`,
|
||||
{ maxBytes: 1024, fieldName: "file" },
|
||||
),
|
||||
).rejects.toThrow(/file/);
|
||||
});
|
||||
|
||||
it("throws when the boundary is missing from the header", async () => {
|
||||
const body = buildMultipartBody("export.zip", Buffer.from("x"));
|
||||
await expect(
|
||||
streamMultipartToDisk(
|
||||
toWebStream(body),
|
||||
`application/json`,
|
||||
{ maxBytes: 1024, fieldName: "file" },
|
||||
),
|
||||
).rejects.toThrow(/boundary/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user