""" 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", json={"adobe_template_ids": [ADOBE_ID]}, 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