Files
eden/apps/api/test/documents-pdf.spec.ts
Matheus (Handix) 29430d0d96 feat(documents): renderização real de PDF via Chromium headless
Substitui a geração de PDF simulada (pdf_s3_key/pdf_sha256/pdf_size_bytes
sempre nulos) por Chromium real (Playwright), portado do
chromiumPdfRenderer.js do legado: SSRF impossível por construção
(setContent + bloqueio de toda requisição de rede da página), browser
único reaproveitado com relançamento automático, semáforo de
concorrência + timeout duro.

Infra: MinIO (compose.yaml, primeiro consumidor real do adapter S3) e
instalação condicional do Chromium só na imagem da API
(Dockerfile.node). DocumentsService.createGeneration agora renderiza,
faz upload e persiste hash/tamanho de verdade; novo endpoint de
download GET /v1/documents/generations/:id/pdf.

Verificado ao vivo através do container real: PDF gerado, baixado, e
SHA-256 conferido byte a byte contra o valor persistido no Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
2026-09-04 09:26:31 -03:00

133 lines
5.4 KiB
TypeScript

import { INestApplication } from "@nestjs/common";
import { Readable } from "node:stream";
import { createHash } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js";
/**
* `renderPdf`/`uploadObject`/`getObjectStream` are mocked (the real call
* path through documents.service.ts still runs) — a real Chromium render +
* a real MinIO round trip isn't worth the flakiness/runtime cost in this
* suite; renderer.ts itself is exercised by the manual smoke test noted in
* docs/progress.md. What's under test here is documents.service.ts's own
* logic: it must actually call the renderer with the merged HTML, hash and
* persist the result, skip re-rendering on dedup, and stream the right S3
* key back out on download.
*/
const renderPdfMock = vi.fn(async (_input: { html: string }) => Buffer.from("%PDF-1.4 fake pdf bytes"));
const uploadObjectMock = vi.fn(async (_key: string, _buffer: Buffer, _mimeType: string) => undefined);
const getObjectStreamMock = vi.fn(async (key: string) => Readable.from([Buffer.from(`content-of:${key}`)]));
vi.mock("@eden/integrations", () => ({
renderPdf: (input: { html: string }) => renderPdfMock(input),
uploadObject: (key: string, buffer: Buffer, mimeType: string) => uploadObjectMock(key, buffer, mimeType),
getObjectStream: (key: string) => getObjectStreamMock(key),
}));
describe("documents — PDF generation + download", () => {
let app: INestApplication;
let admin: AuthedTokens;
let templateId: string;
let resellerId: string;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
const template = await agent(app)
.post("/api/v1/documents/templates")
.set(...bearer(admin.accessToken))
.send({ key: `qa_test_pdf_${Date.now()}`, name: "qa-test- Template PDF", docGroup: "revenda" })
.expect(201);
templateId = template.body.id;
await agent(app)
.post(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken))
.expect(201);
await agent(app)
.patch(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken))
.send({ editorContent: { type: "doc", plainText: "Revenda: {{revenda.nome}}" } })
.expect(200);
await agent(app)
.post(`/api/v1/documents/templates/${templateId}/publish`)
.set(...bearer(admin.accessToken))
.expect(201);
const reseller = await agent(app)
.post("/api/v1/resellers")
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Revenda PDF" })
.expect(201);
resellerId = reseller.body.id;
});
afterAll(async () => {
await agent(app)
.patch(`/api/v1/resellers/${resellerId}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
await closeTestApp(app);
});
it("renders a real PDF, uploads it, and persists the hash/size/key", async () => {
const res = await agent(app)
.post("/api/v1/documents/generations")
.set(...bearer(admin.accessToken))
.send({ templateId, resellerId, context: { "revenda.nome": "ACME" } })
.expect(201);
expect(res.body.deduped).toBe(false);
expect(res.body.missingVariables).toEqual([]);
expect(res.body.pdf_s3_key).toMatch(/^documents\/generations\/.+\.pdf$/);
expect(res.body.pdf_size_bytes).toBeGreaterThan(0);
// The HTML actually handed to the renderer had the variable resolved.
expect(renderPdfMock).toHaveBeenCalledTimes(1);
const renderedHtml = renderPdfMock.mock.calls[0]![0].html;
expect(renderedHtml).toContain("Revenda: ACME");
expect(renderedHtml).not.toContain("{{revenda.nome}}");
// Uploaded buffer's hash matches the mock's output and what got persisted.
const uploadCall = uploadObjectMock.mock.calls[0]!;
expect(uploadCall[0]).toBe(res.body.pdf_s3_key);
expect(uploadCall[2]).toBe("application/pdf");
expect(createHash("sha256").update(uploadCall[1]).digest("hex")).toBe(res.body.pdf_sha256);
});
it("dedupes an identical (template version, origin) request without re-rendering", async () => {
renderPdfMock.mockClear();
uploadObjectMock.mockClear();
const res = await agent(app)
.post("/api/v1/documents/generations")
.set(...bearer(admin.accessToken))
.send({ templateId, resellerId, context: { "revenda.nome": "ACME" } })
.expect(201);
expect(res.body.deduped).toBe(true);
expect(renderPdfMock).not.toHaveBeenCalled();
expect(uploadObjectMock).not.toHaveBeenCalled();
});
it("streams the stored PDF back from the download endpoint", async () => {
const listRes = await agent(app)
.get("/api/v1/documents/generations")
.set(...bearer(admin.accessToken))
.query({ resellerId })
.expect(200);
const generation = listRes.body.find((g: { reseller_id: string | null }) => g.reseller_id === resellerId);
expect(generation).toBeTruthy();
const download = await agent(app)
.get(`/api/v1/documents/generations/${generation.id}/pdf`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(download.headers["content-type"]).toBe("application/pdf");
expect(getObjectStreamMock).toHaveBeenCalledWith(generation.pdf_s3_key);
expect(Buffer.isBuffer(download.body) && download.body.toString()).toBe(`content-of:${generation.pdf_s3_key}`);
});
});