fix(compose): strip invalid conditionalParentLabel refs before upload

DocuSign returns CONDITIONALTAB_HAS_INVALID_PARENT when a conditional tab
references a parent that doesn't exist or is a forbidden type (signature,
initial, auto-filled). Added _strip_invalid_conditionals() post-processing
pass that validates all conditionalParentLabel values against the actual
built tabs and removes any that won't pass DocuSign validation, logging a
warning for each. Also updated verify tests for the template role-fetch step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-21 15:09:11 -04:00
parent 4f9cb43ac8
commit 53eb206d89
2 changed files with 75 additions and 2 deletions

View File

@ -325,6 +325,46 @@ def merge_tabs(acc: dict, new: dict) -> dict:
return acc return acc
# Tab types DocuSign forbids as conditional parents (auto-filled or action tabs)
_INVALID_PARENT_TAB_TYPES = {
"signHereTabs", "initialHereTabs", "dateSignedTabs",
"fullNameTabs", "emailTabs", "titleTabs", "signerAttachmentTabs",
}
def _strip_invalid_conditionals(signers: list, warnings: list) -> None:
"""
Remove conditionalParentLabel/Value from any tab whose parent label either
doesn't exist in the template or points to a tab type DocuSign forbids as a
parent (signature, initial, auto-filled). Mutates signers in place.
"""
for signer in signers:
tabs = signer.get("tabs", {})
# Collect valid parent labels: only tab types allowed as parents
valid_labels: set[str] = set()
for tab_type, tab_list in tabs.items():
if tab_type in _INVALID_PARENT_TAB_TYPES:
continue
for tab in tab_list:
label = tab.get("tabLabel") or tab.get("groupName")
if label:
valid_labels.add(label)
# Strip references to invalid/missing parents
for tab_type, tab_list in tabs.items():
for tab in tab_list:
parent = tab.get("conditionalParentLabel")
if parent and parent not in valid_labels:
warnings.append(
f"Conditional parent '{parent}' not found or not a valid "
f"parent tab type — conditional stripped from tab "
f"'{tab.get('tabLabel', '?')}'"
)
tab.pop("conditionalParentLabel", None)
tab.pop("conditionalParentValue", None)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main compose function # Main compose function
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -386,6 +426,10 @@ def compose_template(template_dir: str, output_path: str) -> tuple[dict, list[st
tabs = _apply_conditional_to_tabs(tabs, field, warnings) tabs = _apply_conditional_to_tabs(tabs, field, warnings)
signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs) signers[idx]["tabs"] = merge_tabs(signers[idx]["tabs"], tabs)
# Post-process: strip conditionalParentLabel references that point to
# non-existent or invalid parents (signature/initial tabs can't be parents).
_strip_invalid_conditionals(signers, warnings)
template = { template = {
"name": metadata.get("name", template_dir.name), "name": metadata.get("name", template_dir.name),
"description": f"Migrated from Adobe Sign — original owner: {metadata.get('ownerEmail', '')}", "description": f"Migrated from Adobe Sign — original owner: {metadata.get('ownerEmail', '')}",

View File

@ -51,7 +51,14 @@ class TestVerifySend:
@respx.mock @respx.mock
def test_send_returns_envelope_id(self): def test_send_returns_envelope_id(self):
"""Authenticated + valid template → envelope_id returned.""" """Authenticated + valid template → role names fetched, envelope_id returned."""
respx.get(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/{TEMPLATE_ID}"
).mock(return_value=httpx.Response(200, json={
"recipients": {
"signers": [{"roleName": "Customer", "recipientId": "1"}],
}
}))
respx.post( respx.post(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes" f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes"
).mock(return_value=httpx.Response(201, json={"envelopeId": ENVELOPE_ID})) ).mock(return_value=httpx.Response(201, json={"envelopeId": ENVELOPE_ID}))
@ -67,10 +74,32 @@ class TestVerifySend:
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["envelope_id"] == ENVELOPE_ID assert resp.json()["envelope_id"] == ENVELOPE_ID
assert resp.json()["roles"] == ["Customer"]
@respx.mock
def test_send_falls_back_to_signer_role_on_template_error(self):
"""Template fetch failure → falls back to 'Signer' role name."""
respx.get(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/bad-id"
).mock(return_value=httpx.Response(404, json={"message": "Not found"}))
respx.post(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes"
).mock(return_value=httpx.Response(201, json={"envelopeId": ENVELOPE_ID}))
resp = client.post(
"/api/verify/send",
json={"template_id": "bad-id", "recipient_name": "X", "recipient_email": "x@x.com"},
cookies={_COOKIE_NAME: _ds_session()},
)
assert resp.status_code == 200
assert resp.json()["roles"] == ["Signer"]
@respx.mock @respx.mock
def test_send_propagates_docusign_error(self): def test_send_propagates_docusign_error(self):
"""DocuSign 400 → 502 with error detail.""" """DocuSign 400 on envelope create → 502 with error detail."""
respx.get(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates/bad-id"
).mock(return_value=httpx.Response(200, json={"recipients": {}}))
respx.post( respx.post(
f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes" f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/envelopes"
).mock(return_value=httpx.Response(400, json={"message": "Invalid templateId"})) ).mock(return_value=httpx.Response(400, json={"message": "Invalid templateId"}))