From 11b646d3b7f5290fb720ad2560903820c551889d Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Tue, 21 Apr 2026 11:40:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui-phase-19):=20verification=20=E2=80=94?= =?UTF-8?q?=20send/status/void=20API=20+=20frontend=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (web/routers/verify.py): POST /send (creates envelope from template), GET /status/{id} (polls envelope state), POST /void/{id} (voids test envelope). Registered in app.py. 7 tests passing. Frontend (verification.js): table of migrated templates, Send Test button opens dialog with pre-filled name/email from settings, polling every 5s, per-row status updates (Sent → Delivered → Verified), Void button for cleanup. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_api_verify.py | 133 ++++++++++++++++ web/app.py | 7 +- web/routers/verify.py | 131 ++++++++++++++++ web/static/js/verification.js | 281 ++++++++++++++++++++++++++++++++++ 4 files changed, 549 insertions(+), 3 deletions(-) create mode 100644 tests/test_api_verify.py create mode 100644 web/routers/verify.py create mode 100644 web/static/js/verification.js diff --git a/tests/test_api_verify.py b/tests/test_api_verify.py new file mode 100644 index 0000000..af74361 --- /dev/null +++ b/tests/test_api_verify.py @@ -0,0 +1,133 @@ +""" +tests/test_api_verify.py +------------------------ +Tests for /api/verify/* endpoints (send test envelope, status, void). +All DocuSign API calls are mocked with respx. +""" + +import pytest +import respx +import httpx +from fastapi.testclient import TestClient + +from web.app import app +from web.session import _serializer, _COOKIE_NAME + +client = TestClient(app, raise_server_exceptions=True) + +DS_BASE = "https://demo.docusign.net/restapi" +DS_ACCOUNT = "verify-account-id" +TEMPLATE_ID = "tpl-verify-001" +ENVELOPE_ID = "env-abc-123" + + +@pytest.fixture(autouse=True) +def patch_settings(monkeypatch): + import web.config as cfg + monkeypatch.setattr(cfg.settings, "docusign_account_id", DS_ACCOUNT) + monkeypatch.setattr(cfg.settings, "docusign_base_url", DS_BASE) + + +def _full_session(): + return _serializer.dumps({ + "adobe_access_token": "adobe-tok", + "docusign_access_token": "ds-tok", + }) + + +def _ds_session(): + return _serializer.dumps({"docusign_access_token": "ds-tok"}) + + +class TestVerifySend: + def test_send_requires_auth(self): + """No session → 401.""" + resp = client.post( + "/api/verify/send", + json={"template_id": TEMPLATE_ID, "recipient_name": "Alice", "recipient_email": "alice@example.com"}, + cookies={}, + ) + assert resp.status_code == 401 + + @respx.mock + def test_send_returns_envelope_id(self): + """Authenticated + valid template → envelope_id returned.""" + respx.post( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes" + ).mock(return_value=httpx.Response(201, json={"envelopeId": ENVELOPE_ID})) + + resp = client.post( + "/api/verify/send", + json={ + "template_id": TEMPLATE_ID, + "recipient_name": "Alice Test", + "recipient_email": "alice@example.com", + }, + cookies={_COOKIE_NAME: _ds_session()}, + ) + assert resp.status_code == 200 + assert resp.json()["envelope_id"] == ENVELOPE_ID + + @respx.mock + def test_send_propagates_docusign_error(self): + """DocuSign 400 → 502 with error detail.""" + respx.post( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes" + ).mock(return_value=httpx.Response(400, json={"message": "Invalid templateId"})) + + resp = client.post( + "/api/verify/send", + json={"template_id": "bad-id", "recipient_name": "X", "recipient_email": "x@x.com"}, + cookies={_COOKIE_NAME: _ds_session()}, + ) + assert resp.status_code == 502 + + +class TestVerifyStatus: + def test_status_requires_auth(self): + resp = client.get(f"/api/verify/status/{ENVELOPE_ID}", cookies={}) + assert resp.status_code == 401 + + @respx.mock + def test_status_returns_envelope_state(self): + """Authenticated → status and sent_at returned.""" + respx.get( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes/{ENVELOPE_ID}" + ).mock(return_value=httpx.Response(200, json={ + "envelopeId": ENVELOPE_ID, + "status": "sent", + "sentDateTime": "2026-04-21T12:00:00Z", + "completedDateTime": None, + })) + + resp = client.get( + f"/api/verify/status/{ENVELOPE_ID}", + cookies={_COOKIE_NAME: _ds_session()}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "sent" + assert data["envelope_id"] == ENVELOPE_ID + assert data["sent_at"] == "2026-04-21T12:00:00Z" + + +class TestVerifyVoid: + def test_void_requires_auth(self): + resp = client.post(f"/api/verify/void/{ENVELOPE_ID}", json={"reason": "test"}, cookies={}) + assert resp.status_code == 401 + + @respx.mock + def test_void_calls_docusign(self): + """Authenticated → PUT envelope status to voided → voided: true.""" + respx.put( + f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes/{ENVELOPE_ID}" + ).mock(return_value=httpx.Response(200, json={})) + + resp = client.post( + f"/api/verify/void/{ENVELOPE_ID}", + json={"reason": "Verification complete"}, + cookies={_COOKIE_NAME: _ds_session()}, + ) + assert resp.status_code == 200 + assert resp.json()["voided"] is True + assert resp.json()["envelope_id"] == ENVELOPE_ID diff --git a/web/app.py b/web/app.py index 7fd6ca6..e561ac1 100644 --- a/web/app.py +++ b/web/app.py @@ -15,7 +15,7 @@ from fastapi.responses import FileResponse import os from web.config import settings -from web.routers import auth, templates, migrate +from web.routers import auth, templates, migrate, verify app = FastAPI( title="Adobe Sign → DocuSign Migrator", @@ -24,9 +24,10 @@ app = FastAPI( ) # Routers -app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(templates.router, prefix="/api/templates", tags=["templates"]) -app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"]) +app.include_router(migrate.router, prefix="/api/migrate", tags=["migrate"]) +app.include_router(verify.router, prefix="/api/verify", tags=["verify"]) # Static files (frontend) _static_dir = os.path.join(os.path.dirname(__file__), "static") diff --git a/web/routers/verify.py b/web/routers/verify.py new file mode 100644 index 0000000..edd8c9e --- /dev/null +++ b/web/routers/verify.py @@ -0,0 +1,131 @@ +""" +web/routers/verify.py +--------------------- +Verification endpoints: send test envelopes, poll status, void. +Uses DocuSign Envelopes API to confirm migrated templates work end-to-end. +""" + +from typing import Optional + +import httpx +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from web.config import settings +from web.session import get_session + +router = APIRouter() + + +class SendRequest(BaseModel): + template_id: str + recipient_name: str + recipient_email: str + + +class VoidRequest(BaseModel): + reason: str = "Test envelope — voided after verification" + + +def _require_docusign(session: dict) -> Optional[JSONResponse]: + if not session.get("docusign_access_token"): + return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401) + return None + + +@router.post("/send") +async def send_test_envelope(body: SendRequest, request: Request): + """Send a test envelope using a migrated DocuSign template.""" + session = get_session(request) + err = _require_docusign(session) + if err: + return err + + payload = { + "templateId": body.template_id, + "status": "sent", + "templateRoles": [ + { + "email": body.recipient_email, + "name": body.recipient_name, + "roleName": "Signer", + } + ], + "emailSubject": f"[Verification Test] Please sign this document", + } + + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes", + headers={ + "Authorization": f"Bearer {session['docusign_access_token']}", + "Content-Type": "application/json", + }, + json=payload, + ) + + if not resp.is_success: + return JSONResponse( + {"error": "DocuSign API error", "detail": resp.text}, + status_code=502, + ) + + data = resp.json() + return {"envelope_id": data.get("envelopeId")} + + +@router.get("/status/{envelope_id}") +async def envelope_status(envelope_id: str, request: Request): + """Get the current status of a test envelope.""" + session = get_session(request) + err = _require_docusign(session) + if err: + return err + + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}", + headers={"Authorization": f"Bearer {session['docusign_access_token']}"}, + ) + + if not resp.is_success: + return JSONResponse( + {"error": "DocuSign API error", "detail": resp.text}, + status_code=502, + ) + + data = resp.json() + return { + "envelope_id": envelope_id, + "status": data.get("status"), + "completed_at": data.get("completedDateTime"), + "sent_at": data.get("sentDateTime"), + } + + +@router.post("/void/{envelope_id}") +async def void_envelope(envelope_id: str, body: VoidRequest, request: Request): + """Void a test envelope after verification is complete.""" + session = get_session(request) + err = _require_docusign(session) + if err: + return err + + async with httpx.AsyncClient() as client: + resp = await client.put( + f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}", + headers={ + "Authorization": f"Bearer {session['docusign_access_token']}", + "Content-Type": "application/json", + }, + json={"status": "voided", "voidedReason": body.reason}, + ) + + if not resp.is_success: + return JSONResponse( + {"error": "DocuSign API error", "detail": resp.text}, + status_code=502, + ) + + return {"voided": True, "envelope_id": envelope_id} diff --git a/web/static/js/verification.js b/web/static/js/verification.js new file mode 100644 index 0000000..d1771c1 --- /dev/null +++ b/web/static/js/verification.js @@ -0,0 +1,281 @@ +// Verification view — send test envelopes to confirm migrated templates work + +import { api } from './api.js'; +import { state } from './state.js'; +import { escHtml, formatDateTime } from './utils.js'; + +const POLL_MS = 5000; +const _envelopes = {}; // { adobeId: { envelopeId, status, sentAt, completedAt, polling } } + +function getSettings() { + try { return JSON.parse(localStorage.getItem('migrator_settings')) || {}; } + catch { return {}; } +} + +export async function renderVerification(preloadedIds = null) { + const outlet = document.getElementById('router-outlet'); + const ids = preloadedIds || state.verifyIds || null; + + // Candidate templates: recently migrated (from state) or all migrated + const candidates = (state.templates || []).filter(t => + t.status === 'migrated' || t.status === 'needs_update' || t.docusign_id + ); + + if (!state.auth.docusign) { + outlet.innerHTML = ` + +
ℹ️Connect DocuSign to send verification envelopes.
`; + return; + } + + if (!candidates.length) { + outlet.innerHTML = ` + +
ℹ️ + No migrated templates yet. Run a migration first, then return here to verify. +
`; + return; + } + + _renderVerifyView(candidates); +} + +function _renderVerifyView(candidates) { + const outlet = document.getElementById('router-outlet'); + const settings = getSettings(); + + outlet.innerHTML = ` + + +
+ ℹ️ + Verification sends a real DocuSign envelope using each template. Test envelopes should be voided after use. + Configure default recipient in Settings. +
+ +
+
+ Migrated Templates + ${candidates.length} templates +
+
+ + + + + + + + + + + ${candidates.map(t => _verifyRow(t, settings)).join('')} + +
TemplateDocuSign IDVerification StatusActions
+
+
+ `; + + // Wire Send Test buttons + document.querySelectorAll('.btn-send-test').forEach(btn => { + btn.addEventListener('click', () => { + const adobeId = btn.dataset.id; + const t = candidates.find(t => t.adobe_id === adobeId); + if (t) _showSendDialog(t, settings); + }); + }); + + // Wire Void buttons + document.querySelectorAll('.btn-void-envelope').forEach(btn => { + btn.addEventListener('click', () => { + _voidEnvelope(btn.dataset.id, btn.dataset.envelopeid); + }); + }); +} + +function _verifyRow(t, settings) { + const env = _envelopes[t.adobe_id]; + let statusCell = 'Not Tested'; + let actionsCell = ``; + + if (env) { + if (env.status === 'completed') { + statusCell = '✓ Verified'; + actionsCell = ``; + } else if (env.status === 'sent' || env.status === 'delivered') { + statusCell = ` ${env.status}`; + actionsCell = ``; + } else if (env.status === 'voided') { + statusCell = 'Voided'; + actionsCell = ``; + } else { + statusCell = `${escHtml(env.status || 'pending')}`; + } + } + + return ` + + +
${escHtml(t.name)}
+
${escHtml(t.adobe_id)}
+ + ${t.docusign_id ? escHtml(t.docusign_id.slice(0,12)) + '…' : '—'} + ${statusCell} + ${actionsCell} + + `; +} + +function _showSendDialog(t, settings) { + const existing = document.getElementById('send-dialog'); + if (existing) existing.remove(); + + const wrapper = document.createElement('div'); + wrapper.id = 'send-dialog'; + wrapper.innerHTML = ` + + + `; + document.body.appendChild(wrapper); + + document.getElementById('sd-close').onclick = () => wrapper.remove(); + document.getElementById('sd-cancel').onclick = () => wrapper.remove(); + document.getElementById('sd-send').onclick = () => _sendEnvelope(t, wrapper); +} + +async function _sendEnvelope(t, wrapper) { + const name = document.getElementById('sd-name')?.value.trim(); + const email = document.getElementById('sd-email')?.value.trim(); + const errorEl = document.getElementById('sd-error'); + + if (!name || !email) { + if (errorEl) errorEl.textContent = 'Recipient name and email are required.'; + return; + } + if (errorEl) errorEl.textContent = ''; + + const sendBtn = document.getElementById('sd-send'); + sendBtn.disabled = true; + sendBtn.textContent = 'Sending…'; + + try { + const data = await api.verify.send(t.docusign_id || t.adobe_id, name, email); + wrapper.remove(); + + _envelopes[t.adobe_id] = { envelopeId: data.envelope_id, status: 'sent', sentAt: new Date().toISOString() }; + _updateVerifyRow(t.adobe_id); + _startPolling(t.adobe_id, data.envelope_id); + + } catch (e) { + if (errorEl) errorEl.textContent = 'Send failed: ' + e.message; + sendBtn.disabled = false; + sendBtn.textContent = 'Send Test →'; + } +} + +function _startPolling(adobeId, envelopeId) { + const env = _envelopes[adobeId]; + if (!env || env.polling) return; + env.polling = true; + + const poll = async () => { + try { + const data = await api.verify.status(envelopeId); + if (_envelopes[adobeId]) { + _envelopes[adobeId].status = data.status; + _envelopes[adobeId].completedAt = data.completed_at; + _updateVerifyRow(adobeId); + if (data.status !== 'completed' && data.status !== 'voided') { + setTimeout(poll, POLL_MS); + } else { + _envelopes[adobeId].polling = false; + } + } + } catch (e) { + console.warn('Polling error:', e.message); + } + }; + setTimeout(poll, POLL_MS); +} + +function _updateVerifyRow(adobeId) { + const t = (state.templates || []).find(t => t.adobe_id === adobeId); + const env = _envelopes[adobeId]; + if (!t || !env) return; + + const statusEl = document.getElementById(`verify-status-${adobeId}`); + const actionsEl = document.getElementById(`verify-actions-${adobeId}`); + if (!statusEl) return; + + if (env.status === 'completed') { + statusEl.innerHTML = '✓ Verified'; + actionsEl.innerHTML = ``; + } else if (env.status === 'sent' || env.status === 'delivered') { + statusEl.innerHTML = ` ${env.status}`; + actionsEl.innerHTML = ``; + } else if (env.status === 'voided') { + statusEl.innerHTML = 'Voided'; + actionsEl.innerHTML = ``; + } + + // Re-wire newly injected buttons + actionsEl.querySelectorAll('.btn-void-envelope').forEach(btn => { + btn.onclick = () => _voidEnvelope(btn.dataset.id, btn.dataset.envelopeid); + }); + actionsEl.querySelectorAll('.btn-send-test').forEach(btn => { + btn.onclick = () => { + const settings = getSettings(); + _showSendDialog(t, settings); + }; + }); +} + +async function _voidEnvelope(adobeId, envelopeId) { + if (!confirm('Void this test envelope?')) return; + try { + await api.verify.void(envelopeId); + if (_envelopes[adobeId]) { + _envelopes[adobeId].status = 'voided'; + _envelopes[adobeId].polling = false; + } + _updateVerifyRow(adobeId); + } catch (e) { + alert('Failed to void envelope: ' + e.message); + } +}