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:
@@ -46,7 +46,7 @@ async def get_child_obj_li(
|
||||
):
|
||||
"""
|
||||
List Child Objects (One-to-Many).
|
||||
|
||||
|
||||
Retrieves a list of child objects associated with a specific parent.
|
||||
1. Verifies parent existence and user access to the parent.
|
||||
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
|
||||
or_like_dict_obj = None
|
||||
and_in_dict_li_obj = None
|
||||
|
||||
|
||||
jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None
|
||||
if jp_obj:
|
||||
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)
|
||||
obj_name = child_obj_type
|
||||
|
||||
|
||||
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).")
|
||||
|
||||
@@ -125,10 +125,10 @@ async def get_child_obj_li(
|
||||
if sql_result is False:
|
||||
# Standardized rich error bubbling
|
||||
db_err = format_db_error(get_last_sql_error())
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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:
|
||||
@@ -155,7 +155,7 @@ async def search_child_obj_li(
|
||||
):
|
||||
"""
|
||||
Search Child Objects (POST).
|
||||
|
||||
|
||||
Advanced search endpoint for nested objects.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select
|
||||
@@ -239,7 +239,7 @@ async def post_child_obj(
|
||||
):
|
||||
"""
|
||||
Create Child Object.
|
||||
|
||||
|
||||
1. Verifies Parent existence and access.
|
||||
2. Automatically links the new child to the parent (`{parent_obj_type}_id` = parent_id).
|
||||
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)
|
||||
|
||||
# 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):
|
||||
new_obj_id = sql_insert_result
|
||||
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.
|
||||
|
||||
|
||||
Verifies that the child belongs to the specified parent.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select
|
||||
@@ -373,7 +377,7 @@ async def patch_child_obj(
|
||||
):
|
||||
"""
|
||||
Update Child Object.
|
||||
|
||||
|
||||
Verifies that the child belongs to the specified parent before updating.
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
Verifies that the child belongs to the specified parent.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select
|
||||
@@ -484,7 +488,7 @@ async def patch_child_obj(
|
||||
):
|
||||
"""
|
||||
Update Child Object.
|
||||
|
||||
|
||||
Verifies that the child belongs to the specified parent before updating.
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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_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_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_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. |
|
||||
@@ -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
|
||||
|
||||
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
|
||||
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"
|
||||
@@ -1,10 +1,15 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
|
||||
# --- Configuration ---
|
||||
BASE_URL = "https://dev-api.oneskyit.com/v3/crud"
|
||||
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)
|
||||
# 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} ---")
|
||||
url = f"{BASE_URL}/{obj_type}/{record_id}"
|
||||
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=get_headers())
|
||||
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json().get('data', {})
|
||||
failures = []
|
||||
|
||||
|
||||
# 1. Check Vision Standard (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):
|
||||
failures.append(f"{key} is {type(val).__name__} ({val})")
|
||||
|
||||
|
||||
# 2. Specific check for account_id in tracking
|
||||
if obj_type == "event_exhibit_tracking":
|
||||
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:
|
||||
print(f" ❌ [ERROR] Status {response.status_code}: {response.text[:200]}")
|
||||
return False
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f" 💥 [EXCEPTION] {e}")
|
||||
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():
|
||||
"""
|
||||
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"
|
||||
child_id = "xWX-NX-e6-EN"
|
||||
url = f"{BASE_URL}/journal/{parent_id}/entry/{child_id}"
|
||||
|
||||
|
||||
resp = requests.get(url, headers=get_headers())
|
||||
if resp.status_code == 200:
|
||||
print(f" ✅ [PASS] Nested alias resolution successful.")
|
||||
@@ -88,8 +146,9 @@ def test_nested_alias_resolution():
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
suite_start = time.time()
|
||||
print("🚀 Starting Aether V3 Demo Parity Suite\n")
|
||||
|
||||
|
||||
results = []
|
||||
for obj_type, record_id in TARGETS:
|
||||
results.append(verify_demo_parity(obj_type, record_id))
|
||||
@@ -97,7 +156,24 @@ if __name__ == "__main__":
|
||||
|
||||
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):
|
||||
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:
|
||||
print("\n🚨 DEMO SUITE FAILURE: Some critical checks failed.")
|
||||
print(f"\n🚨 DEMO SUITE FAILURE: Some critical checks failed. ({elapsed:.2f}s)")
|
||||
|
||||
Reference in New Issue
Block a user