mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
222 lines
7.3 KiB
TypeScript
222 lines
7.3 KiB
TypeScript
/**
|
|
* v1.28.33 — failure-path contracts for the Apple Health import worker
|
|
* (issue #486).
|
|
*
|
|
* A cumulative re-import that outlives the queue's expiration window is
|
|
* redelivered by pg-boss after the first run already consumed (and
|
|
* unlinked) the staged upload. The redelivery used to re-open the
|
|
* deleted `/tmp/healthlog-apple-health-import-*.bin`, fail with a raw
|
|
* ENOENT, and OVERWRITE the first run's terminal state — masking the
|
|
* real outcome (a genuine failure reason, or even a completed import)
|
|
* behind "ENOENT: no such file or directory". Pinned here against the
|
|
* real Postgres:
|
|
*
|
|
* 1. A delivery for an ImportJob already in a terminal state
|
|
* (`done` / `failed`) is ignored — status, failureReason and
|
|
* result stay exactly as the first run left them.
|
|
*
|
|
* 2. A missing staging file on a live (non-terminal) job surfaces an
|
|
* honest operator-facing reason instead of the raw ENOENT string.
|
|
*
|
|
* 3. A genuine parse failure records ITS message as the failure
|
|
* reason and deterministically removes the staged upload.
|
|
*/
|
|
import { mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { Job } from "pg-boss";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import {
|
|
handleAppleHealthImport,
|
|
_setWorkerPrismaForTests,
|
|
type AppleHealthImportPayload,
|
|
} from "@/lib/jobs/apple-health-import-worker";
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
// No pg-boss attached — the end-of-import rollup fold's enqueue helpers
|
|
// are silent no-ops without a boss.
|
|
vi.mock("@/lib/jobs/boss-instance", () => ({
|
|
getGlobalBoss: vi.fn(() => null),
|
|
}));
|
|
|
|
const scratchDir = mkdtempSync(join(tmpdir(), "hl-import-worker-test-"));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
// Route the worker's Prisma singleton onto the shared testcontainer
|
|
// client so the handler does not open a second pool.
|
|
_setWorkerPrismaForTests(getPrismaClient());
|
|
});
|
|
|
|
afterAll(() => {
|
|
_setWorkerPrismaForTests(null);
|
|
rmSync(scratchDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function createUser(username: string) {
|
|
return getPrismaClient().user.create({
|
|
data: {
|
|
username,
|
|
email: `${username}@example.test`,
|
|
role: "USER",
|
|
},
|
|
});
|
|
}
|
|
|
|
function bossJob(
|
|
id: string,
|
|
payload: AppleHealthImportPayload,
|
|
): Job<AppleHealthImportPayload> {
|
|
return {
|
|
id,
|
|
name: "apple-health-import",
|
|
data: payload,
|
|
} as Job<AppleHealthImportPayload>;
|
|
}
|
|
|
|
describe("apple health import worker — failure paths (issue #486)", () => {
|
|
it("ignores a redelivery for a job already failed — the original reason survives", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("import-terminal-failed");
|
|
const originalReason = "database write failed during upsert";
|
|
await prisma.importJob.create({
|
|
data: {
|
|
userId: user.id,
|
|
pgBossJobId: "boss-terminal-failed",
|
|
status: "failed",
|
|
failureReason: originalReason,
|
|
uploadBytes: 1234,
|
|
completedAt: new Date("2026-04-22T10:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
// The staged upload is long gone — exactly the redelivery scenario.
|
|
await expect(
|
|
handleAppleHealthImport(
|
|
bossJob("boss-terminal-failed", {
|
|
userId: user.id,
|
|
uploadPath: join(scratchDir, "gone.bin"),
|
|
uploadBytes: 1234,
|
|
enqueuedAt: new Date().toISOString(),
|
|
}),
|
|
),
|
|
).resolves.toEqual({ ok: true, did: { skipped: "already_terminal" } });
|
|
|
|
const row = await prisma.importJob.findUnique({
|
|
where: { pgBossJobId: "boss-terminal-failed" },
|
|
});
|
|
expect(row!.status).toBe("failed");
|
|
expect(row!.failureReason).toBe(originalReason);
|
|
expect(row!.failureReason).not.toContain("ENOENT");
|
|
});
|
|
|
|
it("ignores a redelivery for a job already done — the completed import stays done", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("import-terminal-done");
|
|
await prisma.importJob.create({
|
|
data: {
|
|
userId: user.id,
|
|
pgBossJobId: "boss-terminal-done",
|
|
status: "done",
|
|
uploadBytes: 5678,
|
|
completedAt: new Date("2026-04-22T10:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
handleAppleHealthImport(
|
|
bossJob("boss-terminal-done", {
|
|
userId: user.id,
|
|
uploadPath: join(scratchDir, "gone-too.bin"),
|
|
uploadBytes: 5678,
|
|
enqueuedAt: new Date().toISOString(),
|
|
}),
|
|
),
|
|
).resolves.toEqual({ ok: true, did: { skipped: "already_terminal" } });
|
|
|
|
const row = await prisma.importJob.findUnique({
|
|
where: { pgBossJobId: "boss-terminal-done" },
|
|
});
|
|
expect(row!.status).toBe("done");
|
|
expect(row!.failureReason).toBeNull();
|
|
});
|
|
|
|
it("a missing staging file on a live job records an honest reason, not a raw ENOENT", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("import-missing-staging");
|
|
await prisma.importJob.create({
|
|
data: {
|
|
userId: user.id,
|
|
pgBossJobId: "boss-missing-staging",
|
|
status: "queued",
|
|
uploadBytes: 999,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
handleAppleHealthImport(
|
|
bossJob("boss-missing-staging", {
|
|
userId: user.id,
|
|
uploadPath: join(scratchDir, "never-existed.bin"),
|
|
uploadBytes: 999,
|
|
enqueuedAt: new Date().toISOString(),
|
|
}),
|
|
),
|
|
).rejects.toThrow();
|
|
|
|
const row = await prisma.importJob.findUnique({
|
|
where: { pgBossJobId: "boss-missing-staging" },
|
|
});
|
|
expect(row!.status).toBe("failed");
|
|
expect(row!.failureReason).not.toContain("ENOENT");
|
|
expect(row!.failureReason).toContain("no longer available");
|
|
});
|
|
|
|
it("a genuine parse failure keeps ITS message and removes the staged upload", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await createUser("import-real-failure");
|
|
await prisma.importJob.create({
|
|
data: {
|
|
userId: user.id,
|
|
pgBossJobId: "boss-real-failure",
|
|
status: "queued",
|
|
uploadBytes: 64,
|
|
},
|
|
});
|
|
|
|
// Garbage bytes — not a ZIP archive; the extractor throws its own
|
|
// descriptive error, which must reach the ImportJob row verbatim.
|
|
const uploadPath = join(scratchDir, "garbage.bin");
|
|
writeFileSync(uploadPath, Buffer.from("this is not a zip archive"));
|
|
|
|
let thrownMessage = "";
|
|
try {
|
|
await handleAppleHealthImport(
|
|
bossJob("boss-real-failure", {
|
|
userId: user.id,
|
|
uploadPath,
|
|
uploadBytes: 64,
|
|
enqueuedAt: new Date().toISOString(),
|
|
}),
|
|
);
|
|
expect.unreachable("handler must rethrow the parse failure");
|
|
} catch (err) {
|
|
thrownMessage = err instanceof Error ? err.message : String(err);
|
|
}
|
|
|
|
const row = await prisma.importJob.findUnique({
|
|
where: { pgBossJobId: "boss-real-failure" },
|
|
});
|
|
expect(row!.status).toBe("failed");
|
|
expect(row!.failureReason).toBe(thrownMessage.slice(0, 1000));
|
|
expect(row!.failureReason).not.toContain("ENOENT");
|
|
// Deterministic cleanup: the staged upload is gone after the failure.
|
|
expect(existsSync(uploadPath)).toBe(false);
|
|
});
|
|
});
|