diff --git a/.env.example b/.env.example index c44e11ae..7691de05 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,9 @@ RESUME_EXTRACTOR_VERSION=v1 CRM_SYNC_ENABLED=true CRM_SYNC_INTERVAL_SECONDS=900 CRM_SYNC_PAGE_SIZE=200 +# Optional: restrict Docuseal agreements to this template id. +# If unset, Docuseal agreement processing is ignored. +DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID= # Discord bot (required for bot runtime) DISCORD_BOT_TOKEN=your_bot_token_here diff --git a/apps/worker/src/five08/backend/api.py b/apps/worker/src/five08/backend/api.py index 39fbfbae..8acc721e 100644 --- a/apps/worker/src/five08/backend/api.py +++ b/apps/worker/src/five08/backend/api.py @@ -54,16 +54,22 @@ from five08.worker.config import settings from five08.worker.db_migrations import run_job_migrations from five08.worker.dispatcher import build_queue_client +from five08.worker.masking import mask_email from five08.worker.jobs import ( apply_resume_profile_job, extract_resume_profile_job, process_contact_skills_job, + process_docuseal_agreement_job, process_webhook_event, sync_people_from_crm_job, sync_person_from_crm_job, ) from five08.worker.mailbox_resume_ingest import ResumeMailboxProcessor -from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload +from five08.worker.models import ( + AuditEventPayload, + DocusealWebhookPayload, + EspoCRMWebhookPayload, +) logger = logging.getLogger(__name__) @@ -97,8 +103,13 @@ def _is_authorized(request: Request) -> bool: logger.error("Rejecting request: API_SHARED_SECRET is not configured") return False + # TODO: security-hardening: move webhook auth to per-webhook generated secrets + # sourced from an admin dashboard with copyable callback URLs. provided_secret = request.headers.get("X-API-Secret", "") - return secrets.compare_digest(provided_secret, settings.api_shared_secret) + if secrets.compare_digest(provided_secret, settings.api_shared_secret): + return True + + return False def _extract_idempotency_key(value: object) -> str | None: @@ -680,6 +691,123 @@ async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse: ) +async def docuseal_webhook_handler(request: Request) -> JSONResponse: + """Process a Docuseal form.completed webhook and enqueue agreement job.""" + if not _is_authorized(request): + return JSONResponse({"error": "unauthorized"}, status_code=401) + + try: + payload_data = await request.json() + except Exception: + return JSONResponse({"error": "invalid_json"}, status_code=400) + + if not isinstance(payload_data, dict): + return JSONResponse({"error": "payload_must_be_object"}, status_code=400) + + try: + payload = DocusealWebhookPayload.model_validate(payload_data) + except (ValidationError, TypeError) as exc: + return JSONResponse( + {"error": "invalid_payload", "detail": str(exc)}, + status_code=400, + ) + + if payload.event_type != "form.completed": + return JSONResponse( + { + "status": "ignored", + "reason": f"unhandled event_type: {payload.event_type}", + }, + status_code=200, + ) + + submitter = payload.data + submission_id = ( + submitter.submission_id if submitter.submission_id is not None else submitter.id + ) + + template_filter_id = settings.docuseal_member_agreement_template_id + if template_filter_id is None: + logger.info("Ignoring Docuseal agreement webhook: template filter is unset") + return JSONResponse( + { + "status": "ignored", + "reason": "template_filter_not_configured", + }, + status_code=200, + ) + + template_id = submitter.template.id if submitter.template else None + if template_id != template_filter_id: + logger.info( + "Ignoring Docuseal agreement webhook for unmatched template_id=%s" + " expected=%s submission_id=%s", + template_id, + template_filter_id, + submission_id, + ) + return JSONResponse( + { + "status": "ignored", + "reason": "template_mismatch", + "submission_id": submission_id, + }, + status_code=200, + ) + + email = (submitter.email or "").strip() + + completed_at = submitter.completed_at or payload.timestamp + if isinstance(completed_at, str): + completed_at = completed_at.strip() + if not isinstance(completed_at, str) or not completed_at: + return JSONResponse({"error": "invalid_payload"}, status_code=400) + + try: + datetime.fromisoformat(completed_at.replace("Z", "+00:00")) + except ValueError: + return JSONResponse({"error": "invalid_payload"}, status_code=400) + + if not email: + return JSONResponse({"error": "invalid_payload"}, status_code=400) + + masked_email = mask_email(email) + + queue = request.app.state.queue + try: + job: EnqueuedJob = await asyncio.to_thread( + enqueue_job, + queue=queue, + fn=process_docuseal_agreement_job, + args=(email, completed_at, submission_id), + settings=settings, + idempotency_key=f"docuseal-agreement:{submission_id}", + ) + except Exception: + logger.exception( + "Failed enqueueing Docuseal agreement job masked_email=%s submission_id=%s", + masked_email, + submission_id, + ) + return JSONResponse({"error": "enqueue_failed"}, status_code=503) + + logger.info( + "Enqueued Docuseal agreement job job_id=%s masked_email=%s", + job.id, + masked_email, + ) + return JSONResponse( + { + "status": "queued", + "source": "docuseal", + "job_id": job.id, + "masked_email": masked_email, + "submission_id": submission_id, + }, + status_code=202, + ) + + async def audit_event_handler(request: Request) -> JSONResponse: """Persist one human audit event.""" if not _is_authorized(request): @@ -1228,6 +1356,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI: espocrm_people_sync_webhook_handler, methods=["POST"], ) + app.add_api_route( + "/webhooks/docuseal", + docuseal_webhook_handler, + methods=["POST"], + ) app.add_api_route("/webhooks/{source}", ingest_handler, methods=["POST"]) app.add_api_route( diff --git a/apps/worker/src/five08/worker/actors.py b/apps/worker/src/five08/worker/actors.py index 70bfd476..5846560f 100644 --- a/apps/worker/src/five08/worker/actors.py +++ b/apps/worker/src/five08/worker/actors.py @@ -25,6 +25,7 @@ apply_resume_profile_job, extract_resume_profile_job, process_contact_skills_job, + process_docuseal_agreement_job, process_webhook_event, sync_people_from_crm_job, sync_person_from_crm_job, @@ -48,6 +49,7 @@ apply_resume_profile_job.__name__: apply_resume_profile_job, sync_people_from_crm_job.__name__: sync_people_from_crm_job, sync_person_from_crm_job.__name__: sync_person_from_crm_job, + process_docuseal_agreement_job.__name__: process_docuseal_agreement_job, } diff --git a/apps/worker/src/five08/worker/config.py b/apps/worker/src/five08/worker/config.py index 430e7e73..867e3cdf 100644 --- a/apps/worker/src/five08/worker/config.py +++ b/apps/worker/src/five08/worker/config.py @@ -2,7 +2,7 @@ from urllib.parse import urlparse -from pydantic import model_validator +from pydantic import field_validator, model_validator from five08.settings import SharedSettings @@ -23,6 +23,7 @@ class WorkerSettings(SharedSettings): openai_model: str = "gpt-4o-mini" resume_ai_model: str = "gpt-4o-mini" resume_extractor_version: str = "v1" + docuseal_member_agreement_template_id: int | None = None max_file_size_mb: int = 10 allowed_file_types: str = "pdf,doc,docx,txt" @@ -91,6 +92,23 @@ def validate_auth_cookie_samesite(self) -> "WorkerSettings": self.auth_cookie_samesite = normalized return self + @field_validator("docuseal_member_agreement_template_id", mode="before") + @classmethod + def _normalize_docuseal_member_agreement_template_id( + cls, + value: object, + ) -> int | None: + if value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, str): + normalized = value.strip() + if not normalized: + return None + return int(normalized) + raise TypeError("DOCUSEAL_MEMBER_AGREEMENT_TEMPLATE_ID must be an integer") + @property def allowed_file_extensions(self) -> set[str]: """Allowed resume file extensions.""" diff --git a/apps/worker/src/five08/worker/crm/docuseal_processor.py b/apps/worker/src/five08/worker/crm/docuseal_processor.py new file mode 100644 index 00000000..54823d1d --- /dev/null +++ b/apps/worker/src/five08/worker/crm/docuseal_processor.py @@ -0,0 +1,98 @@ +"""Docuseal member agreement processing workflow.""" + +import logging +from typing import Any + +from five08.clients.espo import EspoAPI, EspoAPIError +from five08.worker.config import settings +from five08.worker.masking import mask_email + +logger = logging.getLogger(__name__) + + +class DocusealAgreementProcessor: + """Look up a CRM contact by email and mark their member agreement as signed.""" + + def __init__(self) -> None: + api_url = settings.espo_base_url.rstrip("/") + "/api/v1" + self.api = EspoAPI(api_url, settings.espo_api_key) + + def process_agreement( + self, + email: str, + completed_at: str, + submission_id: int, + ) -> dict[str, Any]: + """Search for the signer by email and update cMemberAgreementSignedAt.""" + masked_email = mask_email(email) + + try: + result = self.api.request( + "GET", + "Contact", + { + "where": [ + { + "type": "equals", + "attribute": "emailAddress", + "value": email, + } + ], + "maxSize": 1, + "select": "id,name,emailAddress", + }, + ) + except EspoAPIError as exc: + logger.error("CRM search failed for masked_email=%s: %s", masked_email, exc) + return { + "success": False, + "masked_email": masked_email, + "error": f"CRM search failed: {exc}", + } + + contacts = result.get("list", []) + if not contacts: + logger.warning( + "No CRM contact found for masked_email=%s submission_id=%s", + masked_email, + submission_id, + ) + return { + "success": False, + "masked_email": masked_email, + "error": "contact_not_found", + } + + contact = contacts[0] + contact_id = contact["id"] + + try: + self.api.request( + "PUT", + f"Contact/{contact_id}", + { + "cMemberAgreementSignedAt": completed_at, + }, + ) + except EspoAPIError as exc: + logger.error("CRM update failed for contact_id=%s: %s", contact_id, exc) + return { + "success": False, + "masked_email": masked_email, + "submission_id": submission_id, + "contact_id": contact_id, + "error": f"CRM update failed: {exc}", + } + + logger.info( + "Marked member agreement signed contact_id=%s masked_email=%s", + contact_id, + masked_email, + ) + return { + "success": True, + "masked_email": masked_email, + "contact_id": contact_id, + "submission_id": submission_id, + "completed_at": completed_at, + } diff --git a/apps/worker/src/five08/worker/jobs.py b/apps/worker/src/five08/worker/jobs.py index 65c296f1..5d4adbbb 100644 --- a/apps/worker/src/five08/worker/jobs.py +++ b/apps/worker/src/five08/worker/jobs.py @@ -4,9 +4,11 @@ from datetime import datetime, timezone from typing import Any +from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor from five08.worker.crm.people_sync import PeopleSyncProcessor from five08.worker.crm.processor import ContactSkillsProcessor from five08.worker.crm.resume_profile_processor import ResumeProfileProcessor +from five08.worker.masking import mask_email logger = logging.getLogger(__name__) @@ -68,6 +70,21 @@ def apply_resume_profile_job( return result.model_dump() +def process_docuseal_agreement_job( + email: str, + completed_at: str, + submission_id: int, +) -> dict[str, Any]: + """Mark a CRM contact as having signed the member agreement via Docuseal.""" + logger.info( + "Processing Docuseal agreement job masked_email=%s submission_id=%s", + mask_email(email), + submission_id, + ) + processor = DocusealAgreementProcessor() + return processor.process_agreement(email, completed_at, submission_id) + + def sync_people_from_crm_job() -> dict[str, Any]: """Sync a full contacts page-set from CRM into the local people cache.""" logger.info("Processing CRM people full-sync job") diff --git a/apps/worker/src/five08/worker/masking.py b/apps/worker/src/five08/worker/masking.py new file mode 100644 index 00000000..bdbcc3e6 --- /dev/null +++ b/apps/worker/src/five08/worker/masking.py @@ -0,0 +1,15 @@ +"""PII masking helpers used across worker modules.""" + + +def mask_email(email: str) -> str: + """Return a deterministic redacted email representation for logs and responses.""" + local, at, domain = email.partition("@") + if not at: + return "***" + + masked_local = (local[:1] if local else "*") + "***" + + if not domain: + return f"{masked_local}@****..." + + return f"{masked_local}@{domain[:1]}****..." diff --git a/apps/worker/src/five08/worker/models.py b/apps/worker/src/five08/worker/models.py index e5c4e84d..037dc9ad 100644 --- a/apps/worker/src/five08/worker/models.py +++ b/apps/worker/src/five08/worker/models.py @@ -118,6 +118,32 @@ class ResumeApplyResult(BaseModel): error: str | None = None +class DocusealSubmitter(BaseModel): + """Single submitter entry from a Docuseal webhook payload.""" + + class Template(BaseModel): + """Template metadata attached to a Docuseal submitter.""" + + id: int | None = None + + id: int + email: str + status: str + submission_id: int | None = None + completed_at: str | None = None + name: str | None = None + external_id: str | None = None + template: Template | None = None + + +class DocusealWebhookPayload(BaseModel): + """Docuseal form.completed webhook payload.""" + + event_type: str + timestamp: str + data: DocusealSubmitter + + class AuditEventPayload(BaseModel): """Inbound payload for creating a human audit event.""" diff --git a/tests/unit/test_backend_api.py b/tests/unit/test_backend_api.py index fbc99432..67b3cec8 100644 --- a/tests/unit/test_backend_api.py +++ b/tests/unit/test_backend_api.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch from five08.backend import api +from five08.worker.masking import mask_email class _HealthyRedis: @@ -576,3 +577,337 @@ def test_auth_logout_writes_logout_audit(client: TestClient) -> None: assert audit_payload.action == "auth.logout" assert audit_payload.result == api.AuditResult.SUCCESS assert audit_payload.actor_subject == "admin@508.dev" + + +# -- Docuseal webhook tests -------------------------------------------------- + +_DOCUSEAL_PAYLOAD = { + "event_type": "form.completed", + "timestamp": "2026-02-25T12:00:00Z", + "data": { + "id": 42, + "submission_id": 4200, + "email": "member@508.dev", + "status": "completed", + "completed_at": "2026-02-25T12:00:00Z", + "name": "Jane Doe", + "template": {"id": 68}, + }, +} + + +def test_docuseal_webhook_rejects_unauthorized(client: TestClient) -> None: + """Docuseal webhook should reject requests without valid auth.""" + response = client.post("/webhooks/docuseal", json=_DOCUSEAL_PAYLOAD) + assert response.status_code == 401 + assert response.json()["error"] == "unauthorized" + + +def test_docuseal_webhook_enqueues_agreement_job( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Valid form.completed payload should enqueue agreement job.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) + with patch("five08.backend.api.enqueue_job") as mock_enqueue: + mock_enqueue.return_value = Mock(id="job-ds-1") + response = client.post( + "/webhooks/docuseal", + json=_DOCUSEAL_PAYLOAD, + headers=auth_headers, + ) + + payload = response.json() + assert response.status_code == 202 + assert payload["status"] == "queued" + assert payload["source"] == "docuseal" + assert payload["job_id"] == "job-ds-1" + assert payload["masked_email"] == mask_email("member@508.dev") + assert payload["submission_id"] == 4200 + + call_kwargs = mock_enqueue.call_args.kwargs + assert call_kwargs["idempotency_key"] == "docuseal-agreement:4200" + + +def test_docuseal_webhook_ignored_when_template_filter_unset( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Docuseal webhook should be ignored when template filter is unset.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + None, + ) + with ( + patch("five08.backend.api.enqueue_job") as mock_enqueue, + patch("five08.backend.api.logger.info") as mock_info, + ): + response = client.post( + "/webhooks/docuseal", + json=_DOCUSEAL_PAYLOAD, + headers=auth_headers, + ) + + payload = response.json() + assert response.status_code == 200 + assert payload["status"] == "ignored" + assert payload["reason"] == "template_filter_not_configured" + mock_enqueue.assert_not_called() + assert mock_info.call_args.args[0].startswith( + "Ignoring Docuseal agreement webhook: template filter is unset" + ) + + +def test_docuseal_webhook_rejects_invalid_payload( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + """Malformed payload should return 400.""" + response = client.post( + "/webhooks/docuseal", + json={"bad": "data"}, + headers=auth_headers, + ) + assert response.status_code == 400 + assert response.json()["error"] == "invalid_payload" + + +@pytest.mark.parametrize("email", ["", " "]) +def test_docuseal_webhook_rejects_blank_email( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + email: str, +) -> None: + """Blank submitter email should be rejected.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) + payload = { + **_DOCUSEAL_PAYLOAD, + "data": {**_DOCUSEAL_PAYLOAD["data"], "email": email}, + } + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + + assert response.status_code == 400 + assert response.json()["error"] == "invalid_payload" + + +@pytest.mark.parametrize("timestamp", ["", " "]) +def test_docuseal_webhook_rejects_blank_timestamp( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + timestamp: str, +) -> None: + """Blank submitter completion time should be rejected.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) + payload = { + **_DOCUSEAL_PAYLOAD, + "timestamp": timestamp, + "data": {**_DOCUSEAL_PAYLOAD["data"], "completed_at": ""}, + } + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + + assert response.status_code == 400 + assert response.json()["error"] == "invalid_payload" + + +def test_docuseal_webhook_ignores_unmatched_template( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Webhooks for non-target templates should be ignored when template filter is set.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 100, + ) + payload = { + **_DOCUSEAL_PAYLOAD, + "data": { + **_DOCUSEAL_PAYLOAD["data"], + "template": {"id": 101}, + }, + } + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "ignored" + assert response.json()["reason"] == "template_mismatch" + + +def test_docuseal_webhook_processes_matching_template( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Matching template webhooks should still enqueue agreement jobs.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) + with patch("five08.backend.api.enqueue_job") as mock_enqueue: + mock_enqueue.return_value = Mock(id="job-ds-2") + response = client.post( + "/webhooks/docuseal", + json=_DOCUSEAL_PAYLOAD, + headers=auth_headers, + ) + + payload = response.json() + assert response.status_code == 202 + assert payload["status"] == "queued" + assert payload["source"] == "docuseal" + assert payload["job_id"] == "job-ds-2" + assert payload["masked_email"] == mask_email("member@508.dev") + assert payload["submission_id"] == 4200 + assert mock_enqueue.call_args.kwargs["idempotency_key"] == "docuseal-agreement:4200" + + +def test_docuseal_webhook_ignores_when_template_id_missing( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Template-less payloads should be ignored when filter is configured.""" + payload = { + **_DOCUSEAL_PAYLOAD, + "data": { + **_DOCUSEAL_PAYLOAD["data"], + "template": None, + }, + } + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) + with patch("five08.backend.api.enqueue_job") as mock_enqueue: + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + + payload = response.json() + assert response.status_code == 200 + assert payload["status"] == "ignored" + assert payload["reason"] == "template_mismatch" + mock_enqueue.assert_not_called() + + +def test_docuseal_webhook_uses_submitter_id_when_submission_id_missing( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Webhooks without submission_id should fallback to submitter id for idempotency.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) + payload = { + **_DOCUSEAL_PAYLOAD, + "data": { + "id": 42, + "email": "member@508.dev", + "status": "completed", + "completed_at": "2026-02-25T12:00:00Z", + "template": {"id": 68}, + }, + } + with patch("five08.backend.api.enqueue_job") as mock_enqueue: + mock_enqueue.return_value = Mock(id="job-ds-4") + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + + payload = response.json() + assert response.status_code == 202 + assert payload["status"] == "queued" + assert payload["source"] == "docuseal" + assert payload["job_id"] == "job-ds-4" + assert payload["masked_email"] == mask_email("member@508.dev") + assert payload["submission_id"] == 42 + + call_kwargs = mock_enqueue.call_args.kwargs + assert call_kwargs["idempotency_key"] == "docuseal-agreement:42" + + +def test_docuseal_webhook_ignores_non_completed_event( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + """Non form.completed events should be acknowledged but ignored.""" + payload = { + "event_type": "form.viewed", + "timestamp": "2026-02-25T12:00:00Z", + "data": { + "id": 42, + "email": "member@508.dev", + "status": "pending", + }, + } + response = client.post( + "/webhooks/docuseal", + json=payload, + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["status"] == "ignored" + + +def test_docuseal_webhook_returns_503_on_enqueue_failure( + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Enqueue failure should return 503.""" + monkeypatch.setattr( + api.settings, + "docuseal_member_agreement_template_id", + 68, + ) + with patch( + "five08.backend.api.enqueue_job", + side_effect=RuntimeError("queue down"), + ): + response = client.post( + "/webhooks/docuseal", + json=_DOCUSEAL_PAYLOAD, + headers=auth_headers, + ) + + assert response.status_code == 503 + assert response.json()["error"] == "enqueue_failed" diff --git a/tests/unit/test_docuseal_processor.py b/tests/unit/test_docuseal_processor.py new file mode 100644 index 00000000..1fca8ccc --- /dev/null +++ b/tests/unit/test_docuseal_processor.py @@ -0,0 +1,76 @@ +"""Unit tests for Docuseal processor logic.""" + +from unittest.mock import Mock, patch + +from five08.clients.espo import EspoAPIError +from five08.worker.crm.docuseal_processor import DocusealAgreementProcessor +from five08.worker.masking import mask_email + + +def test_docuseal_processor_marks_member_agreement_signed_timestamp() -> None: + """Processor should update the member agreement signed-at timestamp.""" + mock_api = Mock() + mock_api.request.side_effect = [ + {"list": [{"id": "contact-1"}]}, + {"updated": True}, + ] + expected_email = "member@508.dev" + expected_masked = mask_email(expected_email) + + with patch("five08.worker.crm.docuseal_processor.EspoAPI", return_value=mock_api): + processor = DocusealAgreementProcessor() + result = processor.process_agreement( + email=expected_email, + completed_at="2026-02-25T12:00:00Z", + submission_id=416, + ) + + assert mock_api.request.call_count == 2 + assert mock_api.request.call_args_list[1].args[1] == "Contact/contact-1" + assert mock_api.request.call_args_list[1].args[2] == { + "cMemberAgreementSignedAt": "2026-02-25T12:00:00Z", + } + assert result["success"] is True + assert result["masked_email"] == expected_masked + assert result["contact_id"] == "contact-1" + assert result["submission_id"] == 416 + assert "email" not in result + + +def test_docuseal_processor_returns_contact_not_found_when_missing_contact() -> None: + """Processor should return a contact-not-found error without raw email.""" + mock_api = Mock() + mock_api.request.return_value = {"list": []} + + with patch("five08.worker.crm.docuseal_processor.EspoAPI", return_value=mock_api): + processor = DocusealAgreementProcessor() + result = processor.process_agreement( + email="missing@508.dev", + completed_at="2026-02-25T12:00:00Z", + submission_id=123, + ) + + assert result["success"] is False + assert result["error"] == "contact_not_found" + assert result["masked_email"] == mask_email("missing@508.dev") + assert result["masked_email"] != "missing@508.dev" + assert mock_api.request.call_count == 1 + + +def test_docuseal_processor_returns_error_on_search_failure() -> None: + """Processor should return failure payload when CRM search request fails.""" + mock_api = Mock() + mock_api.request.side_effect = EspoAPIError("CRM unavailable") + + with patch("five08.worker.crm.docuseal_processor.EspoAPI", return_value=mock_api): + processor = DocusealAgreementProcessor() + result = processor.process_agreement( + email="broken@508.dev", + completed_at="2026-02-25T12:00:00Z", + submission_id=55, + ) + + assert result["success"] is False + assert result["error"] == "CRM search failed: CRM unavailable" + assert result["masked_email"] == mask_email("broken@508.dev") + assert result["masked_email"] != "broken@508.dev" diff --git a/tests/unit/test_worker_config.py b/tests/unit/test_worker_config.py index 60085e1a..9b7c8769 100644 --- a/tests/unit/test_worker_config.py +++ b/tests/unit/test_worker_config.py @@ -29,3 +29,25 @@ def test_email_intake_validation_passes_with_required_fields() -> None: ) assert settings.email_resume_intake_enabled is True + + +def test_docuseal_template_id_normalizes_blank_string_to_none() -> None: + """Docuseal template filter should treat empty string as unset.""" + settings = WorkerSettings( + espo_base_url="https://crm.test.com", + espo_api_key="test-key", + docuseal_member_agreement_template_id="", + ) + + assert settings.docuseal_member_agreement_template_id is None + + +def test_docuseal_template_id_accepts_numeric_string() -> None: + """Docuseal template filter should coerce numeric strings to int.""" + settings = WorkerSettings( + espo_base_url="https://crm.test.com", + espo_api_key="test-key", + docuseal_member_agreement_template_id="68", + ) + + assert settings.docuseal_member_agreement_template_id == 68 diff --git a/tests/unit/test_worker_models.py b/tests/unit/test_worker_models.py index 46c3c057..3ca1c6de 100644 --- a/tests/unit/test_worker_models.py +++ b/tests/unit/test_worker_models.py @@ -1,6 +1,10 @@ """Unit tests for worker models.""" -from five08.worker.models import AuditEventPayload, EspoCRMWebhookPayload +from five08.worker.models import ( + AuditEventPayload, + DocusealWebhookPayload, + EspoCRMWebhookPayload, +) def test_espocrm_webhook_payload_from_list() -> None: @@ -23,3 +27,64 @@ def test_audit_event_payload_defaults_metadata() -> None: actor_subject="12345", ) assert payload.metadata == {} + + +def test_docuseal_webhook_payload_parses_completed_event() -> None: + """Docuseal payload should parse form.completed event with submitter data.""" + payload = DocusealWebhookPayload.model_validate( + { + "event_type": "form.completed", + "timestamp": "2026-02-25T12:00:00Z", + "data": { + "id": 42, + "email": "member@508.dev", + "status": "completed", + "completed_at": "2026-02-25T12:00:00Z", + "name": "Jane Doe", + }, + } + ) + assert payload.event_type == "form.completed" + assert payload.data.id == 42 + assert payload.data.email == "member@508.dev" + assert payload.data.completed_at == "2026-02-25T12:00:00Z" + assert payload.data.name == "Jane Doe" + + +def test_docuseal_webhook_payload_parses_template_metadata() -> None: + """Docuseal payload should parse template metadata and submission_id.""" + payload = DocusealWebhookPayload.model_validate( + { + "event_type": "form.completed", + "timestamp": "2026-02-25T12:00:00Z", + "data": { + "id": 42, + "submission_id": 123, + "email": "member@508.dev", + "status": "completed", + "completed_at": "2026-02-25T12:00:00Z", + "template": {"id": 68}, + }, + } + ) + + assert payload.data.submission_id == 123 + assert payload.data.template is not None + assert payload.data.template.id == 68 + + +def test_docuseal_webhook_payload_accepts_payload_without_template() -> None: + """Legacy Docuseal payloads without template should still parse.""" + payload = DocusealWebhookPayload.model_validate( + { + "event_type": "form.completed", + "timestamp": "2026-02-25T12:00:00Z", + "data": { + "id": 42, + "email": "member@508.dev", + "status": "completed", + }, + } + ) + + assert payload.data.template is None