fix(nested-crud): re-inject parent FK after model serialization to prevent 1364 errors

Root cause: child model root_validators (Vision ID anti-leakage guard) strip
integer IDs before they can be serialized into the INSERT dict, causing MariaDB
to reject the INSERT with 'Field does not have a default value' (1364).

Fix: re-inject resolved_parent_id into data_to_insert after validated_obj.dict()
in post_child_obj(). This is safe — the integer was already verified against the
DB before model validation.

Affected (were all broken since ~2026-01-27):
  - journal/{id}/journal_entry/
  - event/{id}/event_session/
  - event/{id}/event_person/
  - event/{id}/event_registration/
  - event/{id}/event_presenter/
  - event/{id}/event_presentation/
  - event/{id}/event_location/
  - event/{id}/event_track/
  - event/{id}/event_device/
  - event/{id}/event_abstract/
  - event/{id}/event_badge/ (different symptom: NULL FK)

Tests: add nested create lifecycle regression tests to test_e2e_v3_demo_parity.py
  - POST + Vision check + DELETE for journal/journal_entry and event/event_session
  - All 9 checks passing (7s)

Docs: update tests/README.md with accurate demo_parity description and
  a 'When to Run Tests' matrix to prevent future gaps in coverage.
This commit is contained in:
Scott Idem
2026-03-16 12:39:45 -04:00
parent ee28a4f26e
commit eaa18a1d45
3 changed files with 171 additions and 24 deletions

View File

@@ -46,7 +46,7 @@ async def get_child_obj_li(
): ):
""" """
List Child Objects (One-to-Many). List Child Objects (One-to-Many).
Retrieves a list of child objects associated with a specific parent. Retrieves a list of child objects associated with a specific parent.
1. Verifies parent existence and user access to the parent. 1. Verifies parent existence and user access to the parent.
2. Filters children where `{parent_obj_type}_id` matches the parent's ID. 2. Filters children where `{parent_obj_type}_id` matches the parent's ID.
@@ -62,7 +62,7 @@ async def get_child_obj_li(
and_like_dict_obj = None and_like_dict_obj = None
or_like_dict_obj = None or_like_dict_obj = None
and_in_dict_li_obj = None and_in_dict_li_obj = None
jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None
if jp_obj: if jp_obj:
if jp_obj.get('qry'): qry_dict_li = jp_obj['qry'] if jp_obj.get('qry'): qry_dict_li = jp_obj['qry']
@@ -74,7 +74,7 @@ async def get_child_obj_li(
order_by_li = safe_json_loads(order_by_li) order_by_li = safe_json_loads(order_by_li)
obj_name = child_obj_type obj_name = child_obj_type
if obj_name not in obj_type_kv_li or parent_obj_type not in obj_type_kv_li: if obj_name not in obj_type_kv_li or parent_obj_type not in obj_type_kv_li:
return mk_resp(data=False, status_code=400, response=response, status_message=f"Invalid object type(s).") return mk_resp(data=False, status_code=400, response=response, status_message=f"Invalid object type(s).")
@@ -125,10 +125,10 @@ async def get_child_obj_li(
if sql_result is False: if sql_result is False:
# Standardized rich error bubbling # Standardized rich error bubbling
db_err = format_db_error(get_last_sql_error()) db_err = format_db_error(get_last_sql_error())
# If it's a schema error (like Unknown Column), it's a 400 Bad Request # If it's a schema error (like Unknown Column), it's a 400 Bad Request
status_code = 400 if db_err.category == "database_schema" else 500 status_code = 400 if db_err.category == "database_schema" else 500
return mk_resp(data=False, status_code=status_code, response=response, status_message="Listing failed due to database error.", details=db_err.dict()) return mk_resp(data=False, status_code=status_code, response=response, status_message="Listing failed due to database error.", details=db_err.dict())
if sql_result: if sql_result:
@@ -155,7 +155,7 @@ async def search_child_obj_li(
): ):
""" """
Search Child Objects (POST). Search Child Objects (POST).
Advanced search endpoint for nested objects. Advanced search endpoint for nested objects.
""" """
from app.db_sql import redis_lookup_id_random, sql_select from app.db_sql import redis_lookup_id_random, sql_select
@@ -239,7 +239,7 @@ async def post_child_obj(
): ):
""" """
Create Child Object. Create Child Object.
1. Verifies Parent existence and access. 1. Verifies Parent existence and access.
2. Automatically links the new child to the parent (`{parent_obj_type}_id` = parent_id). 2. Automatically links the new child to the parent (`{parent_obj_type}_id` = parent_id).
3. Performs standard creation logic (validation, injection, sanitization). 3. Performs standard creation logic (validation, injection, sanitization).
@@ -295,6 +295,10 @@ 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
# integer IDs (a Vision ID anti-leakage guard) which would drop the FK from the dict.
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):
new_obj_id = sql_insert_result new_obj_id = sql_insert_result
new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=child_obj_type) new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=child_obj_type)
@@ -324,7 +328,7 @@ async def get_child_obj(
): ):
""" """
Retrieve Child Object. Retrieve Child Object.
Verifies that the child belongs to the specified parent. Verifies that the child belongs to the specified parent.
""" """
from app.db_sql import redis_lookup_id_random, sql_select from app.db_sql import redis_lookup_id_random, sql_select
@@ -373,7 +377,7 @@ async def patch_child_obj(
): ):
""" """
Update Child Object. Update Child Object.
Verifies that the child belongs to the specified parent before updating. Verifies that the child belongs to the specified parent before updating.
""" """
from app.db_sql import redis_lookup_id_random, sql_select, sql_update from app.db_sql import redis_lookup_id_random, sql_select, sql_update
@@ -435,7 +439,7 @@ async def get_child_obj(
): ):
""" """
Retrieve Child Object. Retrieve Child Object.
Verifies that the child belongs to the specified parent. Verifies that the child belongs to the specified parent.
""" """
from app.db_sql import redis_lookup_id_random, sql_select from app.db_sql import redis_lookup_id_random, sql_select
@@ -484,7 +488,7 @@ async def patch_child_obj(
): ):
""" """
Update Child Object. Update Child Object.
Verifies that the child belongs to the specified parent before updating. Verifies that the child belongs to the specified parent before updating.
""" """
from app.db_sql import redis_lookup_id_random, sql_select, sql_update from app.db_sql import redis_lookup_id_random, sql_select, sql_update
@@ -545,7 +549,7 @@ async def delete_child_obj(
): ):
""" """
Delete Child Object. Delete Child Object.
Verifies that the child belongs to the specified parent before deleting. Verifies that the child belongs to the specified parent before deleting.
""" """
from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete

View File

@@ -25,7 +25,7 @@ These consolidated scripts are the primary verification tool for the V3 API.
| `test_e2e_v3_event_vision_parity.py`| **Vision ID**: Verifies string-ID enforcement across event models. | | `test_e2e_v3_event_vision_parity.py`| **Vision ID**: Verifies string-ID enforcement across event models. |
| `test_e2e_v3_cms_vision_parity.py`| **Vision ID**: Verifies string-ID enforcement across CMS (post/comment) models. | | `test_e2e_v3_cms_vision_parity.py`| **Vision ID**: Verifies string-ID enforcement across CMS (post/comment) models. |
| `test_e2e_v3_core_vision_parity.py`| **Vision ID**: Verifies string-ID and polymorphic resolution across core models (Account, Person, Address, Contact, DataStore). | | `test_e2e_v3_core_vision_parity.py`| **Vision ID**: Verifies string-ID and polymorphic resolution across core models (Account, Person, Address, Contact, DataStore). |
| `test_e2e_v3_demo_parity.py` | **Demo Parity**: Comprehensive check for Badge, Exhibit, Tracking, and nested Journal Entries. | | `test_e2e_v3_demo_parity.py` | **Demo Parity + Nested Create Regression**: Vision ID check for Badge, Exhibit, Tracking; nested create lifecycle (POST+DELETE) for `journal/journal_entry` and `event/event_session`; alias resolution. **Run after any model or nested-router change.** |
| `test_e2e_v3_action_event_file.py` | **Event Actions**: Specialized atomic upload and linking for event files. | | `test_e2e_v3_action_event_file.py` | **Event Actions**: Specialized atomic upload and linking for event files. |
| `test_e2e_v3_action_zoom.py` | **Zoom Integration**: Verifies OAuth and ticket sync logic for Zoom Events. | | `test_e2e_v3_action_zoom.py` | **Zoom Integration**: Verifies OAuth and ticket sync logic for Zoom Events. |
| `test_e2e_v3_accounts.py` | CRUD verification for the core Account object. | | `test_e2e_v3_accounts.py` | CRUD verification for the core Account object. |
@@ -41,6 +41,21 @@ These consolidated scripts are the primary verification tool for the V3 API.
--- ---
## 🚦 When to Run Tests
Tests exist to be used — run the relevant suite whenever you touch backend code, not just when something breaks.
| Change type | Required suites |
| :--- | :--- |
| Model `root_validator` / ID Vision changes | `test_e2e_v3_demo_parity.py`, `test_e2e_v3_event_vision_parity.py`, `test_e2e_v3_core_vision_parity.py` |
| Nested router (`api_crud_v3_nested.py`) changes | `test_e2e_v3_demo_parity.py` |
| Search / filter changes | `test_e2e_v3_search_engine.py` |
| Auth / account context changes | `test_e2e_v3_security_audit.py`, `test_e2e_v3_auth_security.py` |
| File upload / download changes | `test_e2e_v3_actions_file_lifecycle.py` |
| Any backend change before frontend hand-off | All of the above |
---
## 🧹 Maintenance Policy ## 🧹 Maintenance Policy
1. **Standardization**: All E2E tests should use the standard Agent API Key (`PMM4n50teUCaOMMTN8qOJA`) and provide clean `[✅ PASS]` or `[❌ FAIL]` output. 1. **Standardization**: All E2E tests should use the standard Agent API Key (`PMM4n50teUCaOMMTN8qOJA`) and provide clean `[✅ PASS]` or `[❌ FAIL]` output.
@@ -67,4 +82,56 @@ To maintain a "nice" and readable test suite, follow these patterns in all new P
``` ```
### Path Requirements ### Path Requirements
Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly. Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly.
---
## Development / Testing / Demo environment information
* Use snake_case (or Snake_Case or Snake_case or test_NASA_example or test_API_key)
* Aether test/demo base URL: 'http://demo.localhost:5173'
* Aether development API: 'https://dev-api.oneskyit.com'
These are IDs for records that we can use for testing. Please do not delete them. They are also used for demo purposes with clients.
### Core Modules
* Aether test/demo Account: '_XY7DXtc9MY' (1) "One Sky IT Demo"
* Aether test/demo Site: '92vkYC4fVEl' (12) "One Sky IT Demo"
* Aether test/demo Site Domain: '_6jcTbnJk-o' (12) "demo.localhost:5173"
* Aether test/demo Site Domain: 'heXRgHOs4ns' (30) "sk-demo.oneskyit.com"
* Aether test/demo Site Domain: 'DASm8fP92yw' (69) "dev-demo.oneskyit.com"
* Aether test/demo Site Domain: '2i_0Za6yRPo' (2) "demo.oneskyit.com"
* Aether test/demo Person: 'QWODAPCNLQU' (49) "Osiris Idem"
* Aether test/demo Person: 'HMQRNPIXQMK' (48) "Cleo Idem"
### Events Modules
* Aether test/demo Event: 'pjrcghqwert' (1) "Demo One Sky IT Conference"
* Aether test/demo Event Session: 'DOW3h7v6H42' (703) "How To Do Things"
* Aether test/demo Event Session (Digital Posters): "K8cxUIEWyQk" "The Beginning of Digital Posters!"
* Aether test/demo Event Session (Digital Posters): "1Un1xI1Rgk8" "Poster Session 99: All about posters!"
* Aether test/demo Event Presentation: '7U2eXSjR6H4' (1670) "Build a House"
* Aether test/demo Event Presenter: 'gT-hxnifb-0' (2202) "Bob The Builder"
* Aether test/demo Event File: 'OOsHXtng5mr' (2985) "1 Quick Test for macOS.mp4"
* Aether test/demo Event Badge: 'UIJT-73-63-61' (37163) "Scott Idem"
* Aether test/demo Event Person: 'ffkKxiHpOEC' (16603) "Scott Idem"
* Aether test/demo Event Badge Template: 'jgfixEpYp1B' (18) "Dev Demo 202x"
* Aether test/demo Event Badge Template: 'rzmUgsk7mkq' (19) "Dev Demo 202x Workshops"
* Aether test/demo Event Location: 'VXXY-98-46-14' (26) "Ballroom 1"
* Aether test/demo Event Location: 'FGRN-67-92-45' (298) "Ballroom AB"
* Aether test/demo Event Location: 'PQKB-15-39-81' (78) "Poster Display Station A"
### Journals Module
* Aether test/demo Journal: 'BVYE-94-46-29' (42) "Testing Things"
* Aether test/demo Journal Entry: 'xRx-Y4-h3-fU' (233) "Another Journal Entry in the Test Journal"
### Archives Module (IDAA Archives)
* Aether test/demo Archive: 'nAA2bHLv8RK' (1) "One Sky Test Archive"
* Aether test/demo Archive Content: 'UjKzrk-GKu5' (1) "Hosted File Test"
### Posts Module (IDAA Bulletin Board)
* Aether test/demo Post:
* Aether test/demo Post:
### Events Module (IDAA Recovery Meetings)
* Aether test/demo Event: '1Pkd025vvxU' (36) "IDAA Recovery Meeting Test"
* Aether test/demo Event: 'gIZgAjISkf8' (43) "IDAA Recovery Meeting Test"

View File

@@ -1,10 +1,15 @@
import requests import requests
import json import json
import time
# --- Configuration --- # --- Configuration ---
BASE_URL = "https://dev-api.oneskyit.com/v3/crud" BASE_URL = "https://dev-api.oneskyit.com/v3/crud"
API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
# ACCOUNT_ID = "_XY7DXtc9MY"
# Stable parent IDs used for nested create regression tests.
# journal account: nqOzejLCDXM | event account: GpLf_bnywCs
JOURNAL_PARENT_ID = "OGQK-02-04-94"
EVENT_PARENT_ID = "vfzVJF0LH1O"
# 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.
@@ -30,20 +35,20 @@ def verify_demo_parity(obj_type, record_id):
""" """
print(f"--- Testing {obj_type}: {record_id} ---") print(f"--- Testing {obj_type}: {record_id} ---")
url = f"{BASE_URL}/{obj_type}/{record_id}" url = f"{BASE_URL}/{obj_type}/{record_id}"
try: try:
response = requests.get(url, headers=get_headers()) response = requests.get(url, headers=get_headers())
if response.status_code == 200: if response.status_code == 200:
data = response.json().get('data', {}) data = response.json().get('data', {})
failures = [] failures = []
# 1. Check Vision Standard (All *_id fields must be strings) # 1. Check Vision Standard (All *_id fields must be strings)
for key, val in data.items(): for key, val in data.items():
if key == "id" or (key.endswith("_id") and not key.endswith("external_id")): if key == "id" or (key.endswith("_id") and not key.endswith("external_id")):
if val is not None and not isinstance(val, str): if val is not None and not isinstance(val, str):
failures.append(f"{key} is {type(val).__name__} ({val})") failures.append(f"{key} is {type(val).__name__} ({val})")
# 2. Specific check for account_id in tracking # 2. Specific check for account_id in tracking
if obj_type == "event_exhibit_tracking": if obj_type == "event_exhibit_tracking":
if "account_id" not in data or data["account_id"] is None: if "account_id" not in data or data["account_id"] is None:
@@ -64,11 +69,64 @@ def verify_demo_parity(obj_type, record_id):
else: else:
print(f" ❌ [ERROR] Status {response.status_code}: {response.text[:200]}") print(f" ❌ [ERROR] Status {response.status_code}: {response.text[:200]}")
return False return False
except Exception as e: except Exception as e:
print(f" 💥 [EXCEPTION] {e}") print(f" 💥 [EXCEPTION] {e}")
return False return False
def test_nested_create_lifecycle(parent_type, parent_id, child_type, payload):
"""
Regression test for nested POST create (parent FK injection).
Bug: root_validators on child models stripped integer parent FKs before
INSERT, causing MariaDB 1364 errors. Fixed in api_crud_v3_nested.py by
re-injecting resolved_parent_id into data_to_insert after serialization.
Verifies:
1. POST /{parent_type}/{parent_id}/{child_type}/ returns 200
2. Response data has a string 'id' (Vision Standard)
3. Cleanup: DELETE the created record
"""
label = f"Nested Create ({parent_type}/{child_type})"
print(f"\n--- Regression: {label} ---")
url = f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/"
headers = get_headers()
# --- CREATE ---
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}")
# --- VISION COMPLIANCE: parent FK must not appear as integer ---
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})")
return False
print(f" ✅ [PASS] Vision Standard: all ID fields are strings.")
# --- CLEANUP ---
delete_url = f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}"
del_resp = requests.delete(delete_url, 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.
@@ -78,7 +136,7 @@ def test_nested_alias_resolution():
parent_id = "OGQK-02-04-94" parent_id = "OGQK-02-04-94"
child_id = "xWX-NX-e6-EN" child_id = "xWX-NX-e6-EN"
url = f"{BASE_URL}/journal/{parent_id}/entry/{child_id}" url = f"{BASE_URL}/journal/{parent_id}/entry/{child_id}"
resp = requests.get(url, headers=get_headers()) resp = requests.get(url, headers=get_headers())
if resp.status_code == 200: if resp.status_code == 200:
print(f" ✅ [PASS] Nested alias resolution successful.") print(f" ✅ [PASS] Nested alias resolution successful.")
@@ -88,8 +146,9 @@ def test_nested_alias_resolution():
return False return False
if __name__ == "__main__": if __name__ == "__main__":
suite_start = time.time()
print("🚀 Starting Aether V3 Demo Parity Suite\n") print("🚀 Starting Aether V3 Demo Parity Suite\n")
results = [] results = []
for obj_type, record_id in TARGETS: for obj_type, record_id in TARGETS:
results.append(verify_demo_parity(obj_type, record_id)) results.append(verify_demo_parity(obj_type, record_id))
@@ -97,7 +156,24 @@ if __name__ == "__main__":
results.append(test_nested_alias_resolution()) results.append(test_nested_alias_resolution())
# --- Nested Create Regression Tests ---
# These guard against the Jan 2026 bug where child model root_validators
# stripped the parent FK integer before INSERT, causing MariaDB 1364 errors.
results.append(test_nested_create_lifecycle(
parent_type='journal',
parent_id=JOURNAL_PARENT_ID,
child_type='journal_entry',
payload={'name': '[e2e-test] nested create regression', 'enable': False},
))
results.append(test_nested_create_lifecycle(
parent_type='event',
parent_id=EVENT_PARENT_ID,
child_type='event_session',
payload={'name': '[e2e-test] nested create regression', 'enable': False},
))
elapsed = time.time() - suite_start
if all(results): if all(results):
print("\n🏆 DEMO SUITE SUCCESS: All critical endpoints are verified stable.") print(f"\n🏆 DEMO SUITE SUCCESS: All critical endpoints are verified stable. ({elapsed:.2f}s)")
else: else:
print("\n🚨 DEMO SUITE FAILURE: Some critical checks failed.") print(f"\n🚨 DEMO SUITE FAILURE: Some critical checks failed. ({elapsed:.2f}s)")