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>
184 lines
7.5 KiB
TypeScript
184 lines
7.5 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; 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; css?: 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}}" },
|
|
cssContent: "p { color: red; }",
|
|
})
|
|
.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}}");
|
|
// 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]!;
|
|
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}`);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|