Files
HealthLog/tests/integration/job-failures.test.ts
T
Marc-André Bombeck f0c5b5ae94 fix(medications): identify an imported dose by when it was taken
The intake import built its replay key from the row's optional `zaehler`
field. That reads as a per-row counter — the documented example showed one —
but nothing required it to be unique and nothing checked. A reported export
carried the dose quantity there instead: 1 tablet, on all 28 rows. Every entry
queued under the same key, one dose reached the ledger, and the other 27 came
back reported as duplicates of a dose they had nothing to do with.

The key is the instant now, and `zaehler` is out of the contract rather than
accepted-and-ignored. Nothing is lost by dropping it: the live-row unique on
`(user_id, medication_id, scheduled_for, source)` already makes an imported
dose unique per instant, so a re-import of a file first imported under the old
key still dedups.

The telling is the other half. Two different facts were counted as one number:
entries of a submission that collide with each other, and doses the ledger
already holds. They are separate reasons now, grouped with a count, and the
dialog renders them through the same classifier and the same outcome line as
the CSV and JSON import cards — so an import that wrote nothing can no longer
answer in success tone. That rule shipped for the settings import panel and
this surface was not part of it; the guard now lists every import surface by
path, so a fourth one has to be added deliberately. `skippedInvalid` is gone:
it was hardcoded to zero on every path and reported a capability the importer
never had.

Alongside it, the other half of the environment report — a scheduled job that
fails every run has to say so. `recordError()` increments one unnamed counter,
so the admin status page could show a number and not which queue produced it,
and the module's own screen showed a zero day count with nothing to
distinguish "nothing stored yet" from "every run failed". pg-boss has held the
answer the whole time: a job that exhausts its retries keeps its queue name,
its error and its timestamp. The admin status now names the failing queues
with counts and the last error, the environment overview reports its own
account's failed runs, and neither needed a change at any of the thirty
`recordError()` call sites — a visibility fix that has to be remembered per
call site is the same failure one level up.

Refs #650, #646
2026-07-26 14:37:57 +02:00

152 lines
5.4 KiB
TypeScript

/**
* #646 — a broken background job says its own name.
*
* The environment fetch threw on every run for the whole life of the module.
* It was logged and counted, and neither reached a screen: `recordError()`
* increments one unnamed integer, and the worker log only exists where someone
* is tailing the container. The queue held the answer the entire time.
*
* These readers query `pgboss.job` directly, so the only test worth having is
* one against a schema pg-boss itself created. A hand-written `CREATE TABLE`
* would prove the query matches the table the test wrote, which is exactly the
* mistake being fixed. So pg-boss provisions its own schema here and the jobs
* are failed through its own API.
*/
import { PgBoss } from "pg-boss";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
readFailingQueues,
readQueueFailureForUser,
} from "@/lib/jobs/job-failures";
import { getPrismaClient } from "./setup";
const QUEUE = "environment-fetch";
const OTHER_QUEUE = "reminder-check";
let boss: PgBoss;
/** Send a job, take it, and fail it terminally so it lands in `state='failed'`. */
async function failOneJob(queue: string, data: object, error: string) {
const jobId = await boss.send(queue, data, { retryLimit: 0 });
if (!jobId) throw new Error("queue rejected the job");
const [job] = await boss.fetch(queue);
if (!job) throw new Error("nothing to fetch");
await boss.fail(queue, job.id, new Error(error));
return jobId;
}
beforeAll(async () => {
boss = new PgBoss({
connectionString: process.env.DATABASE_URL,
// No maintenance or monitoring timers — the test drives every transition.
supervise: false,
schedule: false,
});
await boss.start();
await boss.createQueue(QUEUE);
await boss.createQueue(OTHER_QUEUE);
}, 60_000);
afterAll(async () => {
await boss?.stop({ graceful: false, close: true });
await getPrismaClient().$executeRawUnsafe(
`DROP SCHEMA IF EXISTS pgboss CASCADE`,
);
});
describe("readFailingQueues", () => {
it("names the queue, counts the failures, and carries the last error", async () => {
await failOneJob(
QUEUE,
{ userId: "user-a" },
'type "public.EnvironmentLocationSource" does not exist',
);
await failOneJob(QUEUE, { userId: "user-b" }, "upstream feed unavailable");
await failOneJob(OTHER_QUEUE, {}, "reminder dispatch failed");
const failing = await readFailingQueues();
expect(failing).not.toBeNull();
const environment = failing?.find((entry) => entry.queue === QUEUE);
expect(environment?.failures).toBe(2);
expect(environment?.lastError).toBe("upstream feed unavailable");
expect(Number.isFinite(Date.parse(environment?.lastFailedAt ?? ""))).toBe(
true,
);
const other = failing?.find((entry) => entry.queue === OTHER_QUEUE);
expect(other?.failures).toBe(1);
expect(other?.lastError).toBe("reminder dispatch failed");
});
it("reports an empty list — not null — when nothing is failing", async () => {
await getPrismaClient().$executeRawUnsafe(
`DELETE FROM pgboss.job WHERE state = 'failed'`,
);
await expect(readFailingQueues()).resolves.toEqual([]);
});
it("ignores a failure older than the window", async () => {
await failOneJob(QUEUE, { userId: "user-a" }, "old failure");
await getPrismaClient().$executeRawUnsafe(
`UPDATE pgboss.job SET completed_on = now() - interval '10 days' WHERE state = 'failed'`,
);
await expect(readFailingQueues(72)).resolves.toEqual([]);
});
});
describe("readQueueFailureForUser", () => {
it("matches on the userId the job payload carries", async () => {
await getPrismaClient().$executeRawUnsafe(
`DELETE FROM pgboss.job WHERE state = 'failed'`,
);
await failOneJob(QUEUE, { userId: "user-a" }, "first");
await failOneJob(QUEUE, { userId: "user-a" }, "second");
await failOneJob(QUEUE, { userId: "user-b" }, "someone else");
const mine = await readQueueFailureForUser(QUEUE, "user-a");
expect(mine?.failures).toBe(2);
expect(Number.isFinite(Date.parse(mine?.lastFailedAt ?? ""))).toBe(true);
// No error text on this side — the message is written for an operator.
expect(Object.keys(mine ?? {}).sort()).toEqual([
"failures",
"lastFailedAt",
]);
});
it("answers null for an account whose jobs did not fail", async () => {
await expect(
readQueueFailureForUser(QUEUE, "user-with-no-failures"),
).resolves.toBeNull();
});
it("does not attribute another queue's failure to this one", async () => {
await getPrismaClient().$executeRawUnsafe(
`DELETE FROM pgboss.job WHERE state = 'failed'`,
);
await failOneJob(OTHER_QUEUE, { userId: "user-a" }, "reminder failed");
await expect(readQueueFailureForUser(QUEUE, "user-a")).resolves.toBeNull();
});
});
describe("no queue schema at all", () => {
it("answers null rather than throwing, so the status page still renders", async () => {
const prisma = getPrismaClient();
await boss.stop({ graceful: false, close: true });
await prisma.$executeRawUnsafe(`ALTER SCHEMA pgboss RENAME TO pgboss_gone`);
try {
await expect(readFailingQueues()).resolves.toBeNull();
await expect(
readQueueFailureForUser(QUEUE, "user-a"),
).resolves.toBeNull();
} finally {
await prisma.$executeRawUnsafe(
`ALTER SCHEMA pgboss_gone RENAME TO pgboss`,
);
}
});
});