diff --git a/app/routers/api_crud_v3_nested.py b/app/routers/api_crud_v3_nested.py index 7173191..7386305 100644 --- a/app/routers/api_crud_v3_nested.py +++ b/app/routers/api_crud_v3_nested.py @@ -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: 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: validated_obj = input_model(**obj_data) except ValidationError as e: @@ -332,8 +323,21 @@ async def post_child_obj( data_to_insert = validated_obj.dict(exclude_unset=True) - # Re-inject parent FK after model serialization. Some model root_validators strip - # integer IDs (a Vision ID anti-leakage guard) which would drop the FK from the dict. + # Sanitize AFTER serialization so that: + # 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 if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert): diff --git a/tests/e2e/test_e2e_v3_demo_parity.py b/tests/e2e/test_e2e_v3_demo_parity.py index b998d78..538a2fb 100644 --- a/tests/e2e/test_e2e_v3_demo_parity.py +++ b/tests/e2e/test_e2e_v3_demo_parity.py @@ -10,6 +10,10 @@ API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key # journal account: nqOzejLCDXM | event account: GpLf_bnywCs JOURNAL_PARENT_ID = "OGQK-02-04-94" 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) # 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 +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(): """ Verifies that the 'entry' alias and nested resolution works for journals. @@ -171,6 +244,21 @@ if __name__ == "__main__": child_type='event_session', 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 if all(results):