Task #349 follow-up: brand-logo embed test + label module + user-scope guard
Original task: Executive Meetings PDF improvements — respect user
font prefs, render saved per-meeting rowColor, drop legacy
isHighlighted baking, brand logo on left of PDF header with title
"قائمة بأسماء حضور الاجتماعات", and a logo upload + font color
picker in the font-settings page.
Prior mark_task_complete was rejected for three issues. This commit
closes all of them:
- Extract bilingual PDF labels into
artifacts/api-server/src/lib/pdf-labels.ts and import it from the
PDF route. Mirror the same keys under executiveMeetings.pdf.* in
the tx-os ar.json / en.json locales so the frontend stays in
sync.
- Add an end-to-end test that exercises the brand-logo path: sign
an upload URL, PUT the bytes through the storage sidecar, save
the path on the global font-settings row, render the PDF, and
assert PDFKit emitted "/Subtype /Image" with "/Width 1". This
test caught a real silent failure: PDFKit's png-js decoder
rejected the canonical 67-byte base64 "smallest valid PNG", so
the renderer was logging a warning and proceeding without a
logo. The fixture now constructs a real 1x1 RGB PNG inline using
zlib + a CRC32 routine (no new dependencies). Skip is narrowed
to network errors / 5xx (4xx fails loudly), and the global-row
mutation is wrapped in try/finally so cleanup always runs.
- Reject user-scope writes that try to set logoObjectPath with a
400 ("logo_user_scope_forbidden") instead of silently dropping
the field. The brand logo is a global asset; silent drops would
mislead callers into thinking their upload was saved. Added a
focused test asserting the 400 response and that explicit
logoObjectPath:null on the user row still works.
Also removed 12 stray backup/snapshot files at the repo root
(*.old, *-base.{ts,tsx,mjs}, locale snapshots) that were
accidentally tracked in the previous commit.
Out of scope: three pre-existing tsc errors at lines 635/778/2921
of executive-meetings.ts (unrelated to PDF code) and a flaky
"Reorder: POST /reorder" test that was already failing before
these changes.
This commit is contained in:
@@ -697,10 +697,6 @@ test("Font settings: valid combo 200; invalid weight/size/family 400", async ()
|
||||
});
|
||||
|
||||
test("Font settings: user-scope rejects logoObjectPath with 400; null is allowed", async () => {
|
||||
// User-scope writes that try to set a non-null logo path must be
|
||||
// rejected loudly. The brand logo is a global asset; silently
|
||||
// dropping the field would mislead callers into thinking their
|
||||
// upload was saved.
|
||||
const rejected = await api(
|
||||
adminCookie,
|
||||
"PUT",
|
||||
@@ -719,9 +715,7 @@ test("Font settings: user-scope rejects logoObjectPath with 400; null is allowed
|
||||
const body = await rejected.json();
|
||||
assert.equal(body.code, "logo_user_scope_forbidden");
|
||||
|
||||
// Explicit null (i.e. "I'm not setting a logo") must still work
|
||||
// on the user row so a generic prefs UI doesn't have to omit the
|
||||
// field.
|
||||
// null is allowed on user rows.
|
||||
const okNull = await api(
|
||||
adminCookie,
|
||||
"PUT",
|
||||
@@ -915,12 +909,7 @@ test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async
|
||||
});
|
||||
|
||||
test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor", async () => {
|
||||
// This guards Task #349's visual contract directly against the
|
||||
// bytes the renderer emits, complementing the higher-level test
|
||||
// above which only checks status + magic. We render a PDF for a
|
||||
// throw-away date with two meetings — one tinted via the saved
|
||||
// `rowColor`, one only flagged isHighlighted — and verify by
|
||||
// inflating PDFKit's FlateDecode'd content streams.
|
||||
// Inflate FlateDecode streams and assert title/tint/fontColor ops.
|
||||
const zlib = await import("node:zlib");
|
||||
const decodeStreams = (buf) => {
|
||||
const out = [];
|
||||
@@ -944,7 +933,6 @@ test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor"
|
||||
return out.join("\n");
|
||||
};
|
||||
|
||||
// Use a unique future date so we can't collide with other tests.
|
||||
const pdfDate = "2099-05-03";
|
||||
|
||||
const ambered = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
@@ -956,7 +944,6 @@ test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor"
|
||||
assert.equal(ambered.status, 201);
|
||||
const amberedId = (await ambered.json()).id;
|
||||
created.meetingIds.push(amberedId);
|
||||
// rowColor is set via PATCH (not accepted by POST today).
|
||||
const tint = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
@@ -975,8 +962,7 @@ test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor"
|
||||
assert.equal(highlighted.status, 201);
|
||||
created.meetingIds.push((await highlighted.json()).id);
|
||||
|
||||
// Pick a non-default tint that's distinguishable from header chrome.
|
||||
// #336699 → 0.2, 0.4, 0.6 in PDFKit's `rg` op.
|
||||
// #336699 → 0.2/0.4/0.6 in PDFKit's rg op.
|
||||
const setColor = await api(
|
||||
adminCookie,
|
||||
"PUT",
|
||||
@@ -1002,13 +988,7 @@ test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor"
|
||||
const ascii = buf.toString("latin1");
|
||||
const decoded = decodeStreams(buf);
|
||||
|
||||
// (1) Title — "قائمة بأسماء حضور الاجتماعات" survives into the
|
||||
// PDF metadata `/Title` field. PDFKit serializes non-ASCII PDF
|
||||
// strings as parenthesised literals: BOM `\xFE\xFF` + UTF-16BE,
|
||||
// with `(`, `)` and `\` byte values backslash-escaped. To dodge
|
||||
// the escape problem we only assert on the UTF-16BE prefix of
|
||||
// the first four glyphs ("قائم"), which contains no metacharacters.
|
||||
// ق=0x0642 ا=0x0627 ئ=0x0626 م=0x0645
|
||||
// (1) /Title prefix as UTF-16BE for "قائم" (0x0642 0x0627 0x0626 0x0645).
|
||||
const titlePrefix = Buffer.from([
|
||||
0xfe, 0xff, 0x06, 0x42, 0x06, 0x27, 0x06, 0x26, 0x06, 0x45,
|
||||
]);
|
||||
@@ -1017,10 +997,7 @@ test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor"
|
||||
"PDF /Title metadata must start with the new Arabic title (قائم…)",
|
||||
);
|
||||
|
||||
// PDFKit emits full-precision floats and uses the `scn` operator
|
||||
// (set non-stroke color in the current color space) once a color
|
||||
// space has been selected with `cs`. Older PDFKit emits `rg` for
|
||||
// pure-DeviceRGB writes — accept either form.
|
||||
// Accept rg or scn (PDFKit varies by version).
|
||||
const op = "(?:rg|scn)";
|
||||
|
||||
// (2) rowColor=amber → #fef3c7 = 254/255, 243/255, 199/255.
|
||||
@@ -1030,42 +1007,25 @@ test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor"
|
||||
"amber rowColor must paint the saved palette fill",
|
||||
);
|
||||
|
||||
// (3) The legacy isHighlighted overlay (#fecaca = 254/255, 202/255,
|
||||
// 202/255) must NOT appear anymore — archival PDFs reflect
|
||||
// editorial state only.
|
||||
// (3) Legacy isHighlighted fill (#fecaca) must NOT appear.
|
||||
assert.doesNotMatch(
|
||||
decoded,
|
||||
new RegExp(`0\\.9960\\d* 0\\.7921\\d* 0\\.7921\\d* ${op}`),
|
||||
"isHighlighted must no longer be baked into the PDF",
|
||||
);
|
||||
|
||||
// (4) The user's fontColor must reach body text as a fill op.
|
||||
// #336699 = 51/255 = 0.2 (exact), 102/255 = 0.4 (exact), 153/255 =
|
||||
// 0.6 (exact) — PDFKit prints them without trailing decimals.
|
||||
// (4) fontColor #336699 must appear as a fill op.
|
||||
assert.match(
|
||||
decoded,
|
||||
new RegExp(`(?:^|\\s)0\\.2 0\\.4 0\\.6 ${op}`),
|
||||
"user fontColor #336699 must appear as a fill operator",
|
||||
);
|
||||
|
||||
void ascii; // kept for future ad-hoc debugging
|
||||
void ascii;
|
||||
});
|
||||
|
||||
test("PDF embeds the brand logo when set in global font settings", async () => {
|
||||
// Task #349 done-criterion: when an admin uploads a brand logo via
|
||||
// the global font-settings, the rendered PDF must actually carry
|
||||
// the bytes as an embedded Image XObject (not just remember the
|
||||
// path). We assert on PDFKit's emitted PDF object dictionary
|
||||
// (`/Subtype /Image`) which is the contract PDFKit promises when
|
||||
// `doc.image()` succeeds.
|
||||
//
|
||||
// We construct a real 1x1 RGB PNG inline — PDFKit's bundled
|
||||
// png-js decoder is strict about CRCs and chunk layout, so the
|
||||
// canonical "smallest valid PNG" base64 strings floating around
|
||||
// online are rejected as "Incomplete or corrupt PNG file".
|
||||
// Building it from scratch keeps the assertion honest: if the
|
||||
// bytes don't end up in the rendered PDF, it isn't because the
|
||||
// fixture rotted.
|
||||
// Build a real 1x1 RGB PNG (png-js rejects most base64 fixtures).
|
||||
const u32 = (n) => {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n, 0);
|
||||
@@ -1089,9 +1049,7 @@ test("PDF embeds the brand logo when set in global font settings", async () => {
|
||||
const crc = crc32(Buffer.concat([typeBuf, data]));
|
||||
return Buffer.concat([u32(data.length), typeBuf, data, u32(crc)]);
|
||||
};
|
||||
// IHDR: 1x1, depth=8, color type=2 (RGB), default compression/filter/interlace
|
||||
const ihdr = Buffer.concat([u32(1), u32(1), Buffer.from([8, 2, 0, 0, 0])]);
|
||||
// Single scanline: filter byte 0 + RGB pixel (0, 255, 0)
|
||||
const idat = zlib.deflateSync(Buffer.from([0, 0, 255, 0]));
|
||||
const tinyPng = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
@@ -1100,8 +1058,7 @@ test("PDF embeds the brand logo when set in global font settings", async () => {
|
||||
mkChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
|
||||
// 1) Sign an upload URL via the server's storage endpoint. We use
|
||||
// the admin cookie so the requireAuth gate passes.
|
||||
// 1) Sign upload URL.
|
||||
const signRes = await api(
|
||||
adminCookie,
|
||||
"POST",
|
||||
@@ -1116,15 +1073,7 @@ test("PDF embeds the brand logo when set in global font settings", async () => {
|
||||
const { uploadURL, objectPath } = await signRes.json();
|
||||
assert.ok(/^\/objects\//.test(objectPath), `expected /objects/<id>, got ${objectPath}`);
|
||||
|
||||
// 2) PUT the PNG bytes through the signed URL. If the local sidecar
|
||||
// isn't reachable in this environment, skip the rest of the test
|
||||
// rather than failing — the assertion this guards is about PDF
|
||||
// output once the bytes are in storage, and CI environments
|
||||
// without the storage sidecar can't exercise it.
|
||||
// We only treat the upload as "skip-able" for clearly
|
||||
// environmental failures (the sidecar isn't reachable or returns
|
||||
// 5xx). Auth/signing regressions surface as 4xx, and we want
|
||||
// those to fail loudly — not silently skip.
|
||||
// 2) PUT bytes; skip on network/5xx, fail on 4xx.
|
||||
let putRes;
|
||||
try {
|
||||
putRes = await fetch(uploadURL, {
|
||||
@@ -1152,10 +1101,7 @@ test("PDF embeds the brand logo when set in global font settings", async () => {
|
||||
`signed PUT must succeed (got ${putRes.status} ${putRes.statusText})`,
|
||||
);
|
||||
|
||||
// 3) Save the path on the GLOBAL font-settings row (the only scope
|
||||
// that accepts a logo). Wrap the mutation in try/finally so we
|
||||
// always reset the global row, even if a later assertion fails
|
||||
// — otherwise leaked state poisons subsequent test runs.
|
||||
// 3) Save logo on global row (try/finally resets it).
|
||||
const setLogo = await api(
|
||||
adminCookie,
|
||||
"PUT",
|
||||
@@ -1173,8 +1119,7 @@ test("PDF embeds the brand logo when set in global font settings", async () => {
|
||||
assert.equal(setLogo.status, 200, "admin must be able to save the global logo");
|
||||
|
||||
try {
|
||||
// 4) Render a PDF. Date doesn't need meetings — the header
|
||||
// (and therefore the logo) is drawn unconditionally.
|
||||
// 4) Render PDF (header draws unconditionally).
|
||||
const pdfDate = "2099-05-04";
|
||||
const res = await api(
|
||||
adminCookie,
|
||||
@@ -1189,19 +1134,13 @@ test("PDF embeds the brand logo when set in global font settings", async () => {
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const ascii = buf.toString("latin1");
|
||||
|
||||
// 5) Assert PDFKit emitted an Image XObject. PDFKit writes
|
||||
// image objects as `<<\n/Type /XObject\n/Subtype /Image\n…>>`
|
||||
// — the `/Subtype /Image` substring is the canonical marker
|
||||
// that the bytes were embedded (vs the path being silently
|
||||
// dropped at render time).
|
||||
// 5) Assert PDFKit emitted an Image XObject.
|
||||
assert.match(
|
||||
ascii,
|
||||
/\/Subtype\s*\/Image/,
|
||||
"uploaded brand logo must be embedded as an /Image XObject in the PDF",
|
||||
);
|
||||
// The 1x1 PNG should appear with /Width 1 in the image
|
||||
// dictionary — a stronger guarantee that the *uploaded* image
|
||||
// (and not some other XObject) made it through.
|
||||
// /Width 1 confirms our 1x1 fixture was embedded.
|
||||
assert.match(
|
||||
ascii,
|
||||
/\/Width\s+1[^0-9]/,
|
||||
|
||||
Reference in New Issue
Block a user