feat(documents): pré-visualização em PDF do rascunho via Chromium

Novo endpoint POST /documents/templates/:id/draft/preview: renderiza o
conteúdo atual (ainda não salvo) do editor com placeholders {{variavel}}
substituídos por [variavel], via Chromium real, sem persistir nada
(sem generation, sem upload S3) — permite validar visualmente o
resultado antes do "Publicar", que é uma ação permanente.

Corrige de passagem um bug real: css_content do template nunca chegava
ao renderPdf na geração real de documento (createGeneration), sendo
descartado silenciosamente.

No frontend, a pré-visualização aparece embutida num iframe na própria
tela (não em nova aba): abrir em nova aba se mostrou não-confiável ao
vivo — a navegação tardia de uma aba pré-aberta pode ser ignorada pelo
navegador quando o gesto do usuário que a autorizou já expirou no tempo
que o Chromium leva pra renderizar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:59:02 -03:00
parent 8ab18ababc
commit 553c0d9533
7 changed files with 222 additions and 5 deletions

View File

@@ -14,11 +14,11 @@ import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type Authe
* 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 renderPdfMock = vi.fn(async (_input: { html: string; css?: 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),
renderPdf: (input: { html: string; css?: string }) => renderPdfMock(input),
uploadObject: (key: string, buffer: Buffer, mimeType: string) => uploadObjectMock(key, buffer, mimeType),
getObjectStream: (key: string) => getObjectStreamMock(key),
}));
@@ -47,7 +47,10 @@ describe("documents — PDF generation + download", () => {
await agent(app)
.patch(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken))
.send({ editorContent: { type: "doc", plainText: "Revenda: {{revenda.nome}}" } })
.send({
editorContent: { type: "doc", plainText: "Revenda: {{revenda.nome}}" },
cssContent: "p { color: red; }",
})
.expect(200);
await agent(app)
.post(`/api/v1/documents/templates/${templateId}/publish`)
@@ -88,6 +91,10 @@ describe("documents — PDF generation + download", () => {
const renderedHtml = renderPdfMock.mock.calls[0]![0].html;
expect(renderedHtml).toContain("Revenda: ACME");
expect(renderedHtml).not.toContain("{{revenda.nome}}");
// The template version's css_content must actually reach the renderer —
// real gap found and fixed alongside the draft-preview feature: this
// used to be silently discarded (renderPdf({ html: resolved }) only).
expect(renderPdfMock.mock.calls[0]![0].css).toBe("p { color: red; }");
// Uploaded buffer's hash matches the mock's output and what got persisted.
const uploadCall = uploadObjectMock.mock.calls[0]!;
@@ -129,4 +136,48 @@ describe("documents — PDF generation + download", () => {
expect(getObjectStreamMock).toHaveBeenCalledWith(generation.pdf_s3_key);
expect(Buffer.isBuffer(download.body) && download.body.toString()).toBe(`content-of:${generation.pdf_s3_key}`);
});
describe("draft preview", () => {
it("renders the CURRENT (unsaved) editor state with placeholder variables, never persisting anything", async () => {
renderPdfMock.mockClear();
uploadObjectMock.mockClear();
const res = await agent(app)
.post(`/api/v1/documents/templates/${templateId}/draft/preview`)
.set(...bearer(admin.accessToken))
.send({
editorContent: { plainText: "Olá {{cliente.nome}}, plano {{plano}}." },
cssContent: "p { color: blue; }",
})
.expect(201);
expect(res.headers["content-type"]).toBe("application/pdf");
expect(Buffer.isBuffer(res.body) && res.body.toString()).toBe("%PDF-1.4 fake pdf bytes");
expect(renderPdfMock).toHaveBeenCalledTimes(1);
const call = renderPdfMock.mock.calls[0]![0];
expect(call.html).toContain("[cliente.nome]");
expect(call.html).toContain("[plano]");
expect(call.html).not.toContain("{{");
expect(call.css).toBe("p { color: blue; }");
// Never persisted: no S3 upload, and the real (published) generation
// count for this template is unaffected.
expect(uploadObjectMock).not.toHaveBeenCalled();
const listRes = await agent(app)
.get("/api/v1/documents/generations")
.set(...bearer(admin.accessToken))
.query({ resellerId })
.expect(200);
expect(listRes.body).toHaveLength(1); // still just the one from the earlier test
});
it("404s for an unknown template id", async () => {
await agent(app)
.post("/api/v1/documents/templates/00000000-0000-0000-0000-000000000000/draft/preview")
.set(...bearer(admin.accessToken))
.send({ editorContent: { plainText: "x" } })
.expect(404);
});
});
});