fix: resolve secondary FKs in nested POST (event_badge_template_id)
In the nested POST handler (api_crud_v3_nested.py), sanitize_payload was running before model instantiation. For secondary FK fields like event_badge_template_id, sanitize_payload resolved the random string → integer, then the model's root_validator stripped the integer back to None (Vision ID anti-leakage guard). Only the parent FK survived because it was explicitly re-injected after serialization. Fix: moved sanitize_payload to run on data_to_insert after serialization, matching the flat V3 POST pattern (api_crud_v3.py). Also moved account_id injection to after sanitize_payload, fixing a latent bug where account_id was silently written as NULL on non-bypass auth. Adds regression test to test_e2e_v3_demo_parity.py that creates an event_badge via nested POST with event_badge_template_id and verifies the field is non-None in the response. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -313,15 +313,6 @@ async def post_child_obj(
|
|||||||
if not table_name_insert or not input_model or not table_name_select or not output_model:
|
if not table_name_insert or not input_model or not table_name_select or not output_model:
|
||||||
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
||||||
|
|
||||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
|
||||||
if 'account_id' in input_model.__fields__:
|
|
||||||
obj_data['account_id'] = account.account_id
|
|
||||||
|
|
||||||
obj_data[f'{parent_obj_type}_id'] = resolved_parent_id
|
|
||||||
|
|
||||||
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
|
|
||||||
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
validated_obj = input_model(**obj_data)
|
validated_obj = input_model(**obj_data)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
@@ -332,8 +323,21 @@ async def post_child_obj(
|
|||||||
|
|
||||||
data_to_insert = validated_obj.dict(exclude_unset=True)
|
data_to_insert = validated_obj.dict(exclude_unset=True)
|
||||||
|
|
||||||
# Re-inject parent FK after model serialization. Some model root_validators strip
|
# Sanitize AFTER serialization so that:
|
||||||
# integer IDs (a Vision ID anti-leakage guard) which would drop the FK from the dict.
|
# 1. The model receives raw Vision ID strings (passes field-length constraints).
|
||||||
|
# 2. ID resolution (string → integer) happens on the dict going to the DB,
|
||||||
|
# avoiding the root_validator's integer-stripping anti-leakage guard.
|
||||||
|
# (Matches the flat V3 POST pattern in api_crud_v3.py.)
|
||||||
|
sanitize_payload(data_to_insert, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
||||||
|
|
||||||
|
# Enforce account ownership AFTER sanitize_payload so the integer account_id goes
|
||||||
|
# straight to the DB without conflicting with Vision ID string constraints in the model.
|
||||||
|
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||||
|
if 'account_id' in input_model.__fields__:
|
||||||
|
data_to_insert['account_id'] = account.account_id
|
||||||
|
|
||||||
|
# Re-inject parent FK last — overrides anything sanitize_payload or the model may have
|
||||||
|
# set — ensuring the child is always linked to the correct parent.
|
||||||
data_to_insert[f'{parent_obj_type}_id'] = resolved_parent_id
|
data_to_insert[f'{parent_obj_type}_id'] = resolved_parent_id
|
||||||
|
|
||||||
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
|
|||||||
# journal account: nqOzejLCDXM | event account: GpLf_bnywCs
|
# journal account: nqOzejLCDXM | event account: GpLf_bnywCs
|
||||||
JOURNAL_PARENT_ID = "OGQK-02-04-94"
|
JOURNAL_PARENT_ID = "OGQK-02-04-94"
|
||||||
EVENT_PARENT_ID = "vfzVJF0LH1O"
|
EVENT_PARENT_ID = "vfzVJF0LH1O"
|
||||||
|
# event_person: ffkKxiHpOEC (16603) "Scott Idem" under Demo event
|
||||||
|
EVENT_PERSON_PARENT_ID = "ffkKxiHpOEC"
|
||||||
|
# event_badge_template: jgfixEpYp1B (18) "Dev Demo 202x"
|
||||||
|
EVENT_BADGE_TEMPLATE_ID = "jgfixEpYp1B"
|
||||||
|
|
||||||
# Test Targets: (Object Type, Valid ID Random)
|
# Test Targets: (Object Type, Valid ID Random)
|
||||||
# Note: These IDs are extracted from real active records.
|
# Note: These IDs are extracted from real active records.
|
||||||
@@ -127,6 +131,75 @@ def test_nested_create_lifecycle(parent_type, parent_id, child_type, payload):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_create_secondary_fk(parent_type, parent_id, child_type, payload, required_fk_fields):
|
||||||
|
"""
|
||||||
|
Regression test for secondary FK resolution in nested POST create.
|
||||||
|
|
||||||
|
Bug: sanitize_payload ran BEFORE model instantiation in the nested POST handler.
|
||||||
|
For FKs other than the parent FK (e.g. event_badge_template_id on event_badge),
|
||||||
|
sanitize_payload resolved the string → integer, then the model's root_validator
|
||||||
|
stripped the integer back to None (Vision ID anti-leakage guard). The parent FK
|
||||||
|
survived only because it was explicitly re-injected; secondary FKs were silently lost.
|
||||||
|
|
||||||
|
Fix (api_crud_v3_nested.py): moved sanitize_payload to run on data_to_insert AFTER
|
||||||
|
model serialization, matching the flat V3 POST pattern.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
1. POST returns 200.
|
||||||
|
2. Each field in required_fk_fields is present AND non-None in the response.
|
||||||
|
3. All *_id fields are strings (Vision Standard).
|
||||||
|
4. Cleanup: DELETE the created record.
|
||||||
|
"""
|
||||||
|
label = f"Nested Secondary FK ({parent_type}/{child_type})"
|
||||||
|
print(f"\n--- Regression: {label} ---")
|
||||||
|
url = f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/"
|
||||||
|
headers = get_headers()
|
||||||
|
|
||||||
|
resp = requests.post(url, headers=headers, json=payload)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f" ❌ [FAIL] POST returned {resp.status_code}: {resp.text[:300]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
data = resp.json().get('data', {})
|
||||||
|
new_id = data.get('id') or data.get('obj_id_random')
|
||||||
|
if not new_id or not isinstance(new_id, str):
|
||||||
|
print(f" ❌ [FAIL] No string 'id' in response. Got: {data}")
|
||||||
|
return False
|
||||||
|
print(f" ✅ [PASS] Created {child_type} with id: {new_id}")
|
||||||
|
|
||||||
|
# Check required secondary FK fields are present and non-None
|
||||||
|
for field in required_fk_fields:
|
||||||
|
val = data.get(field)
|
||||||
|
if val is None:
|
||||||
|
print(f" ❌ [FAIL] Secondary FK '{field}' is None — was not saved to DB.")
|
||||||
|
# Still attempt cleanup
|
||||||
|
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||||
|
return False
|
||||||
|
if not isinstance(val, str):
|
||||||
|
print(f" ❌ [FAIL] Secondary FK '{field}' is {type(val).__name__} ({val}) — must be string (Vision Standard).")
|
||||||
|
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||||
|
return False
|
||||||
|
print(f" ✅ [PASS] Secondary FK '{field}' = {val}")
|
||||||
|
|
||||||
|
# Vision compliance: all *_id fields must be strings
|
||||||
|
for key, val in data.items():
|
||||||
|
if (key == 'id' or key.endswith('_id')) and not key.endswith('external_id'):
|
||||||
|
if val is not None and not isinstance(val, str):
|
||||||
|
print(f" ❌ [FAIL] Vision violation: {key} is {type(val).__name__} ({val})")
|
||||||
|
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||||
|
return False
|
||||||
|
print(f" ✅ [PASS] Vision Standard: all ID fields are strings.")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
del_resp = requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||||
|
if del_resp.status_code == 200:
|
||||||
|
print(f" ✅ [PASS] Cleanup: deleted {new_id}")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ [WARN] Cleanup failed ({del_resp.status_code}) — manual cleanup may be needed for {new_id}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def test_nested_alias_resolution():
|
def test_nested_alias_resolution():
|
||||||
"""
|
"""
|
||||||
Verifies that the 'entry' alias and nested resolution works for journals.
|
Verifies that the 'entry' alias and nested resolution works for journals.
|
||||||
@@ -171,6 +244,21 @@ if __name__ == "__main__":
|
|||||||
child_type='event_session',
|
child_type='event_session',
|
||||||
payload={'name': '[e2e-test] nested create regression', 'enable': False},
|
payload={'name': '[e2e-test] nested create regression', 'enable': False},
|
||||||
))
|
))
|
||||||
|
# Secondary FK regression: event_badge_template_id must survive nested POST
|
||||||
|
# (was silently dropped as NULL before the sanitize_payload order fix)
|
||||||
|
results.append(test_nested_create_secondary_fk(
|
||||||
|
parent_type='event_person',
|
||||||
|
parent_id=EVENT_PERSON_PARENT_ID,
|
||||||
|
child_type='event_badge',
|
||||||
|
payload={
|
||||||
|
'event_badge_template_id': EVENT_BADGE_TEMPLATE_ID,
|
||||||
|
'given_name': '[e2e-test]',
|
||||||
|
'family_name': 'secondary-fk-regression',
|
||||||
|
'enable': False,
|
||||||
|
'hide': True,
|
||||||
|
},
|
||||||
|
required_fk_fields=['event_badge_template_id'],
|
||||||
|
))
|
||||||
|
|
||||||
elapsed = time.time() - suite_start
|
elapsed = time.time() - suite_start
|
||||||
if all(results):
|
if all(results):
|
||||||
|
|||||||
Reference in New Issue
Block a user