335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""
|
|
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"
|