diff --git a/playwright.config.ts b/playwright.config.ts index e5fd3a9..be73910 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -51,6 +51,18 @@ export default defineConfig({ viewport: { width: 1440, height: 900 }, }, }, + { + // Real-API tests hit live external services (Documenso, IMAP, etc.). + // Opt-in only: pnpm exec playwright test --project=realapi + name: 'realapi', + testMatch: /realapi\/.*\.spec\.ts/, + dependencies: ['setup'], + timeout: 120_000, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 900 }, + }, + }, ], // Don't start the dev server — we expect it to already be running diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts index e600111..595a776 100644 --- a/src/lib/services/documenso-client.ts +++ b/src/lib/services/documenso-client.ts @@ -23,6 +23,27 @@ async function documensoFetch(path: string, options?: RequestInit): Promise; + const id = String(r.documentId ?? r.id ?? ''); + const status = String(r.status ?? 'PENDING'); + const recipientsRaw = (r.recipients as Array> | undefined) ?? []; + const recipients = recipientsRaw.map((rec) => ({ + id: String(rec.recipientId ?? rec.id ?? ''), + name: String(rec.name ?? ''), + email: String(rec.email ?? ''), + role: String(rec.role ?? ''), + signingOrder: Number(rec.signingOrder ?? 0), + status: String(rec.signingStatus ?? rec.status ?? 'PENDING'), + signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined, + embeddedUrl: typeof rec.embeddedUrl === 'string' ? rec.embeddedUrl : undefined, + })); + return { id, status, recipients }; +} + export interface DocumensoRecipient { name: string; email: string; @@ -53,7 +74,7 @@ export async function createDocument( return documensoFetch('/api/v1/documents', { method: 'POST', body: JSON.stringify({ title, document: pdfBase64, recipients }), - }) as Promise; + }).then(normalizeDocument); } export async function generateDocumentFromTemplate( @@ -63,17 +84,17 @@ export async function generateDocumentFromTemplate( return documensoFetch(`/api/v1/templates/${templateId}/generate-document`, { method: 'POST', body: JSON.stringify(payload), - }) as Promise; + }).then(normalizeDocument); } export async function sendDocument(docId: string): Promise { return documensoFetch(`/api/v1/documents/${docId}/send`, { method: 'POST', - }) as Promise; + }).then(normalizeDocument); } export async function getDocument(docId: string): Promise { - return documensoFetch(`/api/v1/documents/${docId}`) as Promise; + return documensoFetch(`/api/v1/documents/${docId}`).then(normalizeDocument); } export async function sendReminder(docId: string, signerId: string): Promise { diff --git a/tests/e2e/realapi/documenso-real-api.spec.ts b/tests/e2e/realapi/documenso-real-api.spec.ts new file mode 100644 index 0000000..26a3e78 --- /dev/null +++ b/tests/e2e/realapi/documenso-real-api.spec.ts @@ -0,0 +1,139 @@ +import 'dotenv/config'; +import { test, expect } from '@playwright/test'; + +import { login, apiHeaders } from '../smoke/helpers'; + +/** + * Real-API end-to-end test for the Documenso documenso-template pathway. + * + * This spec exercises the SEND side of the integration only — it creates a + * fully-loaded interest, fires generate-and-sign, and asserts both + * (a) the CRM persisted a documensoId on the documents row, and + * (b) Documenso itself returns 200 for the freshly-created document. + * + * The receive side (webhook → state update) is validated separately by + * triggering a real signing event in the Documenso UI and watching + * /tmp/dev-server.log; it isn't automated here because Documenso has no + * machine-friendly "sign on behalf of recipient" endpoint we can drive + * deterministically from CI. + * + * Requires DOCUMENSO_API_URL + DOCUMENSO_API_KEY + DOCUMENSO_TEMPLATE_ID_EOI + * in the environment (loaded via dotenv). + */ + +const DOCUMENSO_BASE = process.env.DOCUMENSO_API_URL; +const DOCUMENSO_API_KEY = process.env.DOCUMENSO_API_KEY; + +test.describe('Documenso real-API: documenso-template pathway', () => { + test.skip(!DOCUMENSO_BASE || !DOCUMENSO_API_KEY, 'DOCUMENSO_API_URL / DOCUMENSO_API_KEY not set'); + + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('generate-and-sign creates a real Documenso document', async ({ page }) => { + const stamp = Date.now(); + const headers = await apiHeaders(page); + + // ─── 1. Seed client (with a contact email so Documenso has a recipient) ── + const clientRes = await page.request.post('/api/v1/clients', { + headers, + data: { + fullName: `Documenso E2E Client ${stamp}`, + contacts: [ + { channel: 'email', value: `documenso-e2e-${stamp}@example.test`, isPrimary: true }, + ], + }, + }); + expect(clientRes.ok(), `client create: ${clientRes.status()} ${await clientRes.text()}`).toBe( + true, + ); + const clientId = (await clientRes.json()).data.id as string; + + // ─── 2. Seed yacht owned by that client ────────────────────────────────── + const yachtRes = await page.request.post('/api/v1/yachts', { + headers, + data: { + name: `E2E Yacht ${stamp}`, + owner: { type: 'client', id: clientId }, + }, + }); + expect(yachtRes.ok(), `yacht create: ${yachtRes.status()} ${await yachtRes.text()}`).toBe(true); + const yachtId = (await yachtRes.json()).data.id as string; + + // ─── 3. Seed berth ─────────────────────────────────────────────────────── + const berthRes = await page.request.post('/api/v1/berths', { + headers, + data: { + mooringNumber: `E2E-${stamp}`, + area: 'Test Area', + }, + }); + expect(berthRes.ok(), `berth create: ${berthRes.status()} ${await berthRes.text()}`).toBe(true); + const berthId = (await berthRes.json()).data.id as string; + + // ─── 4. Create the interest linking all three ──────────────────────────── + const interestRes = await page.request.post('/api/v1/interests', { + headers, + data: { clientId, yachtId, berthId, pipelineStage: 'open' }, + }); + expect( + interestRes.ok(), + `interest create: ${interestRes.status()} ${await interestRes.text()}`, + ).toBe(true); + const interestId = (await interestRes.json()).data.id as string; + + // ─── 5. Fire generate-and-sign through the documenso-template pathway ──── + const signRes = await page.request.post( + '/api/v1/document-templates/documenso-template/generate-and-sign', + { + headers, + data: { + interestId, + clientId, + berthId, + pathway: 'documenso-template', + signers: [], + }, + }, + ); + expect(signRes.ok(), `generate-and-sign: ${signRes.status()} ${await signRes.text()}`).toBe( + true, + ); + + const body = (await signRes.json()) as { + data: { document: { id: string; documensoId: string | null; status: string } }; + }; + expect(body.data.document.documensoId, 'documensoId on response').toBeTruthy(); + expect(body.data.document.status).toBe('sent'); + + const documensoId = body.data.document.documensoId!; + + // ─── 6. Verify the document exists on the Documenso side ───────────────── + const documensoRes = await page.request.get( + `${DOCUMENSO_BASE}/api/v1/documents/${documensoId}`, + { + headers: { Authorization: `Bearer ${DOCUMENSO_API_KEY}` }, + }, + ); + expect( + documensoRes.ok(), + `documenso GET: ${documensoRes.status()} ${await documensoRes.text()}`, + ).toBe(true); + + const documensoDoc = (await documensoRes.json()) as { + id: number; + status: string; + recipients?: Array<{ email: string; signingStatus: string }>; + }; + + expect(String(documensoDoc.id)).toBe(documensoId); + // Freshly-sent doc should be PENDING (or DRAFT if Documenso queued it). + expect(['PENDING', 'DRAFT'], `unexpected status ${documensoDoc.status}`).toContain( + documensoDoc.status, + ); + // The template is configured with ≥1 recipient role, so we should have + // at least the client's email populated. + expect(documensoDoc.recipients?.length ?? 0).toBeGreaterThan(0); + }); +});