adobe-to-docusign-migrator/tests/test_api_migrate.py

241 lines
8.3 KiB
Python

"""
tests/test_api_migrate.py
--------------------------
Tests for /api/migrate (POST) and /api/migrate/history (GET).
All Adobe Sign and DocuSign HTTP calls are mocked with respx.
The compose pipeline is mocked at the module level to avoid PDF/file I/O.
"""
import json
import os
import tempfile
from unittest.mock import patch, MagicMock
import pytest
import respx
import httpx
from fastapi.testclient import TestClient
from web.app import app
from web.session import _serializer, _COOKIE_NAME
import web.routers.migrate as migrate_module
client = TestClient(app, raise_server_exceptions=True)
ADOBE_BASE = "https://api.eu2.adobesign.com/api/rest/v6"
DS_BASE = "https://demo.docusign.net/restapi"
DS_ACCOUNT = "test-account-id"
TEMPLATE_NAME = "Test NDA"
ADOBE_ID = "adobe-123"
DS_NEW_ID = "ds-new-456"
DS_EXISTING_ID = "ds-existing-789"
def _full_session():
return _serializer.dumps({
"adobe_access_token": "adobe-tok",
"docusign_access_token": "ds-tok",
})
def _adobe_only_session():
return _serializer.dumps({"adobe_access_token": "adobe-tok"})
@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)
monkeypatch.setattr(cfg.settings, "adobe_sign_base_url", ADOBE_BASE)
@pytest.fixture(autouse=True)
def temp_history(tmp_path, monkeypatch):
"""Redirect history file to a temp path for each test."""
history_path = str(tmp_path / ".history.json")
monkeypatch.setattr(migrate_module, "_HISTORY_FILE", history_path)
return history_path
def _mock_compose(template_dir: str, output_path: str):
"""Write a minimal DocuSign template JSON so the pipeline continues."""
with open(output_path, "w") as f:
json.dump({"name": TEMPLATE_NAME, "description": "mocked"}, f)
def _mock_download(template_id, access_token, output_dir):
"""Write stub Adobe Sign files so compose has something to read."""
os.makedirs(output_dir, exist_ok=True)
with open(os.path.join(output_dir, "metadata.json"), "w") as f:
json.dump({"name": TEMPLATE_NAME, "id": template_id}, f)
with open(os.path.join(output_dir, "form_fields.json"), "w") as f:
json.dump({"fields": []}, f)
with open(os.path.join(output_dir, "documents.json"), "w") as f:
json.dump({"documents": []}, f)
return True
def test_migrate_requires_auth():
"""No session → 401."""
resp = client.post("/api/migrate", json={"adobe_template_ids": [ADOBE_ID]}, cookies={})
assert resp.status_code == 401
def test_migrate_requires_docusign_auth():
"""Only Adobe auth → 401."""
resp = client.post(
"/api/migrate",
json={"adobe_template_ids": [ADOBE_ID]},
cookies={_COOKIE_NAME: _adobe_only_session()},
)
assert resp.status_code == 401
@respx.mock
def test_migrate_single_template_creates():
"""No existing DS template → POST creates; result action=created."""
# DS list: no match
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(200, json={"envelopeTemplates": []})
)
# DS create
respx.post(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(201, json={"templateId": DS_NEW_ID})
)
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
):
resp = client.post(
"/api/migrate",
json={"adobe_template_ids": [ADOBE_ID]},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.status_code == 200
results = resp.json()["results"]
assert len(results) == 1
assert results[0]["action"] == "created"
assert results[0]["docusign_template_id"] == DS_NEW_ID
assert results[0]["status"] == "success"
@respx.mock
def test_migrate_single_template_updates():
"""Existing DS template with same name → PUT updates; result action=updated."""
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(200, json={
"envelopeTemplates": [
{"templateId": DS_EXISTING_ID, "name": TEMPLATE_NAME, "lastModified": "2026-04-10T00:00:00Z"},
]
})
)
respx.put(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/{DS_EXISTING_ID}").mock(
return_value=httpx.Response(200, json={})
)
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
):
resp = client.post(
"/api/migrate",
# overwrite_if_exists=True so the existing template is updated, not skipped
json={"adobe_template_ids": [ADOBE_ID], "options": {"overwrite_if_exists": True}},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.status_code == 200
results = resp.json()["results"]
assert results[0]["action"] == "updated"
assert results[0]["docusign_template_id"] == DS_EXISTING_ID
@respx.mock
def test_migrate_records_history(temp_history):
"""After successful migration, history file is written."""
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(200, json={"envelopeTemplates": []})
)
respx.post(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(201, json={"templateId": DS_NEW_ID})
)
with (
patch.object(migrate_module, "_download_adobe_template", new=_async_wrap(_mock_download)),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
):
client.post(
"/api/migrate",
json={"adobe_template_ids": [ADOBE_ID]},
cookies={_COOKIE_NAME: _full_session()},
)
assert os.path.exists(temp_history)
with open(temp_history) as f:
history = json.load(f)
assert len(history) == 1
assert history[0]["adobe_template_id"] == ADOBE_ID
def test_history_returns_past_runs(temp_history):
"""GET /api/migrate/history returns written records."""
records = [
{"timestamp": "2026-04-17T10:00:00Z", "adobe_template_id": "a1", "status": "success"},
{"timestamp": "2026-04-17T11:00:00Z", "adobe_template_id": "a2", "status": "failed"},
]
with open(temp_history, "w") as f:
json.dump(records, f)
resp = client.get("/api/migrate/history")
assert resp.status_code == 200
assert len(resp.json()["history"]) == 2
@respx.mock
def test_migrate_handles_partial_failure():
"""One template fails (download error), others succeed."""
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(200, json={"envelopeTemplates": []})
)
respx.post(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
return_value=httpx.Response(201, json={"templateId": DS_NEW_ID})
)
call_count = {"n": 0}
async def mock_download_partial(template_id, access_token, output_dir):
call_count["n"] += 1
if call_count["n"] == 1:
return False # first template fails
return await _async_wrap(_mock_download)(template_id, access_token, output_dir)
with (
patch.object(migrate_module, "_download_adobe_template", new=mock_download_partial),
patch.object(migrate_module, "_load_compose", return_value=_mock_compose),
):
resp = client.post(
"/api/migrate",
json={"adobe_template_ids": ["fail-id", ADOBE_ID]},
cookies={_COOKIE_NAME: _full_session()},
)
assert resp.status_code == 200
results = resp.json()["results"]
assert results[0]["status"] == "failed"
assert results[1]["status"] == "success"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _async_wrap(sync_fn):
"""Wrap a sync function to be awaitable (for patching async functions in tests)."""
import asyncio
async def wrapper(*args, **kwargs):
return sync_fn(*args, **kwargs)
return wrapper