mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(share): date a shared PDF by the owner's day, not the display default
The page at c/[token] resolved the owner's timezone; the PDF download beside it resolved nothing and fell through to the display default. Same link, same reading, two dates — and the one a practice files is the one that was wrong. Resolved in the shared download gate rather than in the route, so the PDF and the FHIR bundle cannot drift apart the way the page and the PDF did. The owner's clock preference travels with it for the same reason. The fixture is deliberately west of UTC: Berlin is the fallback, so a Berlin fixture cannot tell a resolved timezone from a forgotten one.
This commit is contained in:
1 parent
6d800f277d
commit
87877b285f
3 files changed
+104
-2
No files matched your search
@@ -43,6 +43,27 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn() }));
|
||||
// Wraps rather than replaces: the real renderer still runs, so the route is
|
||||
// exercised end to end, and the options it was handed stay observable.
|
||||
const pdfOptionsSeen: Array<Record<string, unknown>> = [];
|
||||
vi.mock("@/lib/doctor-report-pdf-core", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/doctor-report-pdf-core")>();
|
||||
return {
|
||||
...actual,
|
||||
renderDoctorReportPdfBytes: (
|
||||
data: Parameters<typeof actual.renderDoctorReportPdfBytes>[0],
|
||||
options: Parameters<typeof actual.renderDoctorReportPdfBytes>[1],
|
||||
) => {
|
||||
pdfOptionsSeen.push(options as unknown as Record<string, unknown>);
|
||||
return actual.renderDoctorReportPdfBytes(data, options);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
|
||||
vi.mock("@/lib/i18n/server-translator", async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -60,6 +81,8 @@ import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { selectionFromLeaves } from "@/lib/report-selection/selection";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
|
||||
@@ -127,6 +150,12 @@ beforeEach(() => {
|
||||
documents: [],
|
||||
documentOnly: false,
|
||||
} as never);
|
||||
// West of UTC on purpose. Berlin is the display fallback, so a Berlin
|
||||
// fixture cannot tell a resolved timezone apart from a forgotten one.
|
||||
vi.mocked(resolveUserTimezone).mockResolvedValue("America/Los_Angeles");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timeFormat: "H24",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /c/{token}/fhir", () => {
|
||||
@@ -255,6 +284,38 @@ describe("GET /c/{token}/report.pdf", () => {
|
||||
expect(buf.subarray(0, 5).toString("latin1")).toBe("%PDF-");
|
||||
});
|
||||
|
||||
it("prints the owner's day, not the display default", async () => {
|
||||
// The defect: the route handed the renderer no timezone at all, so it fell
|
||||
// back to the display default. A reading is dated by the day it was where
|
||||
// the person lives, and a practice filing the PDF beside the page it came
|
||||
// from must not find two different dates for one reading.
|
||||
pdfOptionsSeen.length = 0;
|
||||
const { GET } = await import("../report.pdf/route");
|
||||
await GET(new Request(`http://localhost/c/${TOKEN}/report.pdf`), params);
|
||||
|
||||
expect(pdfOptionsSeen).toHaveLength(1);
|
||||
expect(
|
||||
pdfOptionsSeen[0].userTz,
|
||||
"the renderer was handed no owner timezone — it will fall back to the display default",
|
||||
).toBe("America/Los_Angeles");
|
||||
expect(
|
||||
pdfOptionsSeen[0].timeFormat,
|
||||
"the owner's clock preference did not reach the renderer",
|
||||
).toBe("H24");
|
||||
});
|
||||
|
||||
it("falls back to AUTO when the owner row carries no clock preference", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
pdfOptionsSeen.length = 0;
|
||||
const { GET } = await import("../report.pdf/route");
|
||||
const res = await GET(
|
||||
new Request(`http://localhost/c/${TOKEN}/report.pdf`),
|
||||
params,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(pdfOptionsSeen[0].timeFormat).toBe("AUTO");
|
||||
});
|
||||
|
||||
it("runs the same gate as the bundle beside it", async () => {
|
||||
vi.mocked(verifyUnlockValue).mockReturnValue(false);
|
||||
const { GET } = await import("../report.pdf/route");
|
||||
|
||||
@@ -34,6 +34,14 @@ export const GET = apiHandler(
|
||||
const bytes = renderDoctorReportPdfBytes(resolved.report, {
|
||||
t,
|
||||
locale,
|
||||
// The locale above is the reader's; these two are the OWNER's, and they
|
||||
// have to be. A reading belongs to the day it was that day where the
|
||||
// person was, so the date beside it cannot follow whoever opens the
|
||||
// link. Without these the renderer fell back to the display default,
|
||||
// which meant a practice west of Berlin could file a PDF dated one day
|
||||
// off from the page it was downloaded from.
|
||||
userTz: resolved.ownerTz,
|
||||
timeFormat: resolved.ownerTimeFormat,
|
||||
insuranceNumber: null,
|
||||
includeCharts: true,
|
||||
});
|
||||
|
||||
@@ -35,6 +35,9 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { ReportSelection } from "@/lib/report-selection/selection";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import type { TimeFormatPreference } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* 20 per hour per link. A practice saves the record once, maybe twice; a
|
||||
@@ -53,7 +56,23 @@ function notFound() {
|
||||
}
|
||||
|
||||
export type ReportDownloadResult =
|
||||
| { ok: true; report: DoctorReportData; selection: ReportSelection }
|
||||
| {
|
||||
ok: true;
|
||||
report: DoctorReportData;
|
||||
selection: ReportSelection;
|
||||
/**
|
||||
* The OWNER's timezone and clock preference, not the reader's.
|
||||
*
|
||||
* A reading was recorded at a moment in the owner's life, and the date
|
||||
* printed beside it has to be the date it was that day where they were.
|
||||
* The page at `c/[token]` already resolves this; the downloads did not,
|
||||
* so the same reading could carry one date on screen and another in the
|
||||
* PDF a practice files. Resolved here rather than per route so the two
|
||||
* download formats cannot drift apart the way the page and the PDF did.
|
||||
*/
|
||||
ownerTz: string;
|
||||
ownerTimeFormat: TimeFormatPreference;
|
||||
}
|
||||
| { ok: false; response: Response };
|
||||
|
||||
/**
|
||||
@@ -128,5 +147,19 @@ export async function resolveShareReportDownload(
|
||||
meta: { format, leafCount: view.selection.leaves.length },
|
||||
});
|
||||
|
||||
return { ok: true, report: view.report, selection: view.selection };
|
||||
const [ownerTz, ownerRow] = await Promise.all([
|
||||
resolveUserTimezone(context.ownerUserId),
|
||||
prisma.user.findUnique({
|
||||
where: { id: context.ownerUserId },
|
||||
select: { timeFormat: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
report: view.report,
|
||||
selection: view.selection,
|
||||
ownerTz,
|
||||
ownerTimeFormat: ownerRow?.timeFormat ?? "AUTO",
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user