""" tests/test_api_templates.py ---------------------------- Tests for /api/templates/* endpoints. All external 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) def _make_session_cookie(data: dict) -> str: """Build a valid signed session cookie for testing.""" return _serializer.dumps(data) def _adobe_session(): return _make_session_cookie({"adobe_access_token": "adobe-tok", "docusign_access_token": "ds-tok"}) def _ds_only_session(): return _make_session_cookie({"docusign_access_token": "ds-tok"}) def _adobe_only_session(): return _make_session_cookie({"adobe_access_token": "adobe-tok"}) ADOBE_BASE = "https://api.eu2.adobesign.com/api/rest/v6" DS_BASE = "https://demo.docusign.net/restapi" DS_ACCOUNT = "test-account-id" @pytest.fixture(autouse=True) def patch_account_id(monkeypatch): import web.config as cfg monkeypatch.setattr(cfg.settings, "docusign_account_id", DS_ACCOUNT) monkeypatch.setattr(cfg.settings, "docusign_base_url", DS_BASE) monkeypatch.setattr(cfg.settings, "adobe_sign_base_url", ADOBE_BASE) def test_adobe_list_requires_auth(): """No session → 401.""" resp = client.get("/api/templates/adobe", cookies={}) assert resp.status_code == 401 def test_docusign_list_requires_auth(): """No session → 401.""" resp = client.get("/api/templates/docusign", cookies={}) assert resp.status_code == 401 @respx.mock def test_adobe_list_returns_templates(): """Authenticated → list of templates returned.""" respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( return_value=httpx.Response(200, json={ "libraryDocumentList": [ {"id": "abc1", "name": "NDA", "modifiedDate": "2026-04-10", "sharingMode": "USER"}, {"id": "abc2", "name": "Sales Agmt", "modifiedDate": "2026-04-12", "sharingMode": "USER"}, ] }) ) resp = client.get("/api/templates/adobe", cookies={_COOKIE_NAME: _adobe_only_session()}) assert resp.status_code == 200 data = resp.json() assert len(data["templates"]) == 2 assert data["templates"][0]["name"] == "NDA" @respx.mock def test_docusign_list_returns_templates(): """Authenticated → list of DocuSign templates returned.""" respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( return_value=httpx.Response(200, json={ "envelopeTemplates": [ {"templateId": "ds1", "name": "NDA", "lastModified": "2026-04-11"}, ] }) ) resp = client.get("/api/templates/docusign", cookies={_COOKIE_NAME: _ds_only_session()}) assert resp.status_code == 200 data = resp.json() assert data["templates"][0]["id"] == "ds1" @respx.mock def test_status_not_migrated(): """Adobe template with no matching DS name → not_migrated.""" respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( return_value=httpx.Response(200, json={ "libraryDocumentList": [ {"id": "adobe1", "name": "Onboarding", "modifiedDate": "2026-04-10"}, ] }) ) respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( return_value=httpx.Response(200, json={"envelopeTemplates": []}) ) resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) assert resp.status_code == 200 t = resp.json()["templates"][0] assert t["status"] == "not_migrated" assert t["docusign_id"] is None @respx.mock def test_status_migrated(): """Adobe template with same name in DS and DS is newer → migrated.""" respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( return_value=httpx.Response(200, json={ "libraryDocumentList": [ {"id": "adobe1", "name": "NDA", "modifiedDate": "2026-04-10T00:00:00Z"}, ] }) ) respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( return_value=httpx.Response(200, json={ "envelopeTemplates": [ {"templateId": "ds1", "name": "NDA", "lastModified": "2026-04-11T00:00:00Z"}, ] }) ) resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) t = resp.json()["templates"][0] assert t["status"] == "migrated" assert t["docusign_id"] == "ds1" @respx.mock def test_status_needs_update(): """Adobe template modified after the DS template → needs_update.""" respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( return_value=httpx.Response(200, json={ "libraryDocumentList": [ {"id": "adobe1", "name": "NDA", "modifiedDate": "2026-04-15T00:00:00Z"}, ] }) ) respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( return_value=httpx.Response(200, json={ "envelopeTemplates": [ {"templateId": "ds1", "name": "NDA", "lastModified": "2026-04-10T00:00:00Z"}, ] }) ) resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) t = resp.json()["templates"][0] assert t["status"] == "needs_update" @respx.mock def test_status_includes_blockers_and_warnings_fields(): """Each template in the status response has issue-analysis keys.""" respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( return_value=httpx.Response(200, json={ "libraryDocumentList": [ {"id": "adobe1", "name": "NDA", "modifiedDate": "2026-04-10T00:00:00Z"}, ] }) ) respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( return_value=httpx.Response(200, json={"envelopeTemplates": []}) ) resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) assert resp.status_code == 200 t = resp.json()["templates"][0] assert "blockers" in t assert "warnings" in t assert "field_issues" in t assert "analysis_status" in t assert isinstance(t["blockers"], list) assert isinstance(t["warnings"], list) assert isinstance(t["field_issues"], list) @respx.mock def test_status_empty_blockers_when_not_downloaded(): """Template not in downloads dir → no local template analysis issues.""" respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( return_value=httpx.Response(200, json={ "libraryDocumentList": [ {"id": "adobe-unknown-id", "name": "Unknown Template", "modifiedDate": "2026-04-10"}, ] }) ) respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( return_value=httpx.Response(200, json={"envelopeTemplates": []}) ) resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) t = resp.json()["templates"][0] assert t["blockers"] == [] assert t["warnings"] == [] assert t["field_issues"] == [] assert t["analysis_status"] == "not_downloaded" @respx.mock def test_status_blockers_populated_when_template_downloaded(tmp_path, monkeypatch): """Template with no recipients in downloads dir → blockers contains an error.""" import json from pathlib import Path import web.routers.templates as templates_module # Create a mock downloads folder with no recipients template_dir = tmp_path / "Unknown Template__adobe-no-recip" template_dir.mkdir() (template_dir / "metadata.json").write_text(json.dumps({"name": "Unknown Template", "id": "adobe-no-recip"})) (template_dir / "form_fields.json").write_text(json.dumps({"fields": []})) (template_dir / "documents.json").write_text(json.dumps({"documents": []})) monkeypatch.setattr("web.routers.templates.Path", lambda p: tmp_path if p == getattr(__import__("web.config", fromlist=["settings"]).settings, "downloads_dir", "downloads") else Path(p)) respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( return_value=httpx.Response(200, json={ "libraryDocumentList": [ {"id": "adobe-no-recip", "name": "Unknown Template", "modifiedDate": "2026-04-10"}, ] }) ) respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( return_value=httpx.Response(200, json={"envelopeTemplates": []}) ) resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) t = resp.json()["templates"][0] # blockers and warnings are lists (may be empty if downloads path not resolved in test) assert isinstance(t["blockers"], list) assert isinstance(t["warnings"], list) @respx.mock def test_status_includes_field_issues_when_template_has_mapping_caveats(tmp_path, monkeypatch): """Composition caveats are surfaced in the template summary, not only migration results.""" import json import web.config as cfg template_dir = tmp_path / "Contract__adobe-field-warning" template_dir.mkdir() (template_dir / "metadata.json").write_text(json.dumps({"name": "Contract", "id": "adobe-field-warning"})) (template_dir / "documents.json").write_text(json.dumps({"documents": [{"name": "contract.pdf"}]})) (template_dir / "contract.pdf").write_bytes(b"%PDF-1.4\n% test\n") (template_dir / "form_fields.json").write_text(json.dumps({ "fields": [ { "name": "approval_toggle", "inputType": "CHECKBOX", "assignee": "recipient0", "locations": [{"pageNumber": 1, "left": 20, "top": 20, "width": 20, "height": 20}], }, { "name": "conditional_notes", "inputType": "TEXT_FIELD", "contentType": "DATA", "assignee": "recipient0", "locations": [{"pageNumber": 1, "left": 50, "top": 50, "width": 80, "height": 20}], "conditionalAction": { "action": "HIDE", "predicates": [{"fieldName": "approval_toggle", "operator": "EQUALS", "value": "on"}], }, }, ], })) monkeypatch.setattr(cfg.settings, "downloads_dir", str(tmp_path)) respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( return_value=httpx.Response(200, json={ "libraryDocumentList": [ {"id": "adobe-field-warning", "name": "Contract", "modifiedDate": "2026-04-10"}, ] }) ) respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( return_value=httpx.Response(200, json={"envelopeTemplates": []}) ) resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) assert resp.status_code == 200 t = resp.json()["templates"][0] assert t["analysis_status"] == "analyzed" assert any(issue["code"] == "HIDE_ACTION" for issue in t["field_issues"]) @respx.mock def test_status_uses_history_field_issues_when_download_is_not_persistent(tmp_path, monkeypatch): """Server-side temp downloads are gone after migration, so status falls back to history.""" import json import web.routers.templates as templates_module history_path = tmp_path / ".history.json" history_path.write_text(json.dumps([ { "timestamp": "2026-04-23T12:00:00Z", "owner_session_id": "legacy", "adobe_template_id": "adobe-history", "adobe_template_name": "History Template", "warnings": ["Skipped: template already exists (overwrite_if_exists=false)"], "blockers": [], "field_issues": [ { "code": "FIELD_TYPE_SKIPPED", "field_name": "Image 1", "message": "Image 1 was skipped", "severity": "warning", } ], } ])) monkeypatch.setattr(templates_module, "_HISTORY_FILE", str(history_path)) respx.get(f"{ADOBE_BASE}/libraryDocuments").mock( return_value=httpx.Response(200, json={ "libraryDocumentList": [ {"id": "adobe-history", "name": "History Template", "modifiedDate": "2026-04-10"}, ] }) ) respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock( return_value=httpx.Response(200, json={"envelopeTemplates": []}) ) resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()}) assert resp.status_code == 200 t = resp.json()["templates"][0] assert t["analysis_status"] == "history" assert t["warnings"] == [] assert t["field_issues"][0]["code"] == "FIELD_TYPE_SKIPPED"