dorfteich/apps/api/src/import-export/pdf.fidelity.test.ts
Claude Opus 4.8 aaa9a253ae
Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CD / Build and push images (push) Failing after 27m51s
CI / Lint, typecheck, test (push) Successful in 3m11s
CI / Auth e2e pack (push) Successful in 4m0s
CI / Import/export fidelity gate (push) Failing after 36s
CI / Build container images (push) Has been skipped
Add import/export fidelity gate to CI (#69)
Make the "structure-true best effort" fidelity contract (ADR 0009) an
objective, pipeline-gated suite so "best effort" cannot erode silently.

- New CI job "Import/export fidelity gate" (.gitea/workflows/ci.yml) runs
  the corpus suites against the pinned sidecar images the stages use
  (pandoc/core:3.6, gotenberg/gotenberg:8), started via docker run and
  reached over the host gateway. Small and separate so it stays well under
  five minutes; the suites self-skip in the main checks job (no sidecars).
- Export fidelity: fixtures/export corpus + gen-export-fixtures.mjs +
  export.fidelity.test.ts — exports Markdown to docx/odt through the real
  pinned pandoc and reads it back, snapshotting the round trip so a writer
  drift (ours or a version bump) fails the gate.
- PDF smoke: pdf.fidelity.test.ts renders a page through real Gotenberg and
  asserts the extracted text and a sane page count (pdf-parse, dev-only).
- Fidelity contract doc: fixtures/README.md defines "corpus green = fidelity
  acceptable" and the fixture-first bug process; per-corpus READMEs updated.

Because the snapshots are byte-exact and generated with the pinned tools,
bumping a sidecar without regenerating shifts the output and fails the suite
(AC3). The import corpus (#63) is folded into the same gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 13:59:10 +02:00

74 lines
3.1 KiB
TypeScript

import { DEFAULT_FONTS, PondFonts } from '@dorfteich/shared';
import { PDFParse } from 'pdf-parse';
import { beforeAll, describe, expect, it, TestContext } from 'vitest';
import { AppConfig } from '../config/app-config.service';
import { GotenbergHttpRenderer } from './gotenberg.renderer';
import { buildPdfHtml } from './pdf-html';
/**
* PDF export smoke check (issue #69, ADR 0009): renders a known page to PDF
* through the real pinned Gotenberg and asserts the output is a PDF whose
* extracted text carries the expected strings, with a sane page count. This
* catches a broken renderer or a template regression that unit tests (which use
* a fake renderer) cannot. Needs a reachable Gotenberg sidecar
* (`gotenberg/gotenberg:8`); the test skips itself when none is configured, and
* CI starts one and points `GOTENBERG_URL` at it.
*/
const GOTENBERG_URL = process.env.GOTENBERG_URL ?? 'http://localhost:3000';
const renderer = new GotenbergHttpRenderer({ env: { GOTENBERG_URL } } as unknown as AppConfig);
let reachable = false;
/** Extract the concatenated text and page count from PDF bytes. */
async function readPdf(pdf: Buffer): Promise<{ text: string; pages: number }> {
const parser = new PDFParse({ data: new Uint8Array(pdf) });
try {
const result = await parser.getText();
return { text: result.text, pages: result.total };
} finally {
await parser.destroy();
}
}
describe('PDF export smoke (real Gotenberg, issue #69)', () => {
beforeAll(async () => {
reachable = await renderer.reachable().catch(() => false);
});
it('renders a page to a PDF containing its text, with a sane page count', async (ctx: TestContext) => {
if (!reachable) ctx.skip();
const html = buildPdfHtml({
title: 'Pond Fidelity Report',
pondName: 'Fidelity Pond',
// A forced page break so we can assert multi-page sanity; the markers are
// distinctive strings we can look for in the extracted text.
bodyHtml:
'<p>The northern reeds have spread noticeably this season.</p>' +
'<div style="page-break-before: always"></div>' +
'<p>Recorded water temperature was fourteen degrees.</p>',
fonts: DEFAULT_FONTS as PondFonts,
// No inlined font faces — the render falls back to the system stack, which
// still produces selectable text (fonts are covered by the #66/#67 tests).
fontFaceCss: '',
});
const pdf = await renderer.renderHtmlToPdf(html);
expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-');
const { text, pages } = await readPdf(pdf);
// Title + pond name (from the header) and both body markers survive to text.
expect(text).toContain('Pond Fidelity Report');
expect(text).toContain('Fidelity Pond');
expect(text).toContain('northern reeds');
expect(text).toContain('water temperature');
// Page-count sanity: the forced break must produce a second page (≥ 2), and
// this trivial document must not balloon (≤ 3) — a runaway render from a CSS
// regression would blow well past that.
expect(pages).toBeGreaterThanOrEqual(2);
expect(pages).toBeLessThanOrEqual(3);
});
});