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:
Marc-André Bombeck committed 2026-05-16 20:41:02 +02:00
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);
});
});
+39
View File
@@ -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");
});
});
+30 -3
View File
@@ -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/);
});
});