From eaa18a1d45cfdd66265bb20e8fb67cbe511d3df5 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Mon, 16 Mar 2026 12:39:45 -0400 Subject: [PATCH] fix(nested-crud): re-inject parent FK after model serialization to prevent 1364 errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/routers/api_crud_v3_nested.py | 28 ++++---- tests/README.md | 71 +++++++++++++++++++- tests/e2e/test_e2e_v3_demo_parity.py | 96 +++++++++++++++++++++++++--- 3 files changed, 171 insertions(+), 24 deletions(-) diff --git a/app/routers/api_crud_v3_nested.py b/app/routers/api_crud_v3_nested.py index a12b14d..ed94015 100644 --- a/app/routers/api_crud_v3_nested.py +++ b/app/routers/api_crud_v3_nested.py @@ -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 diff --git a/tests/README.md b/tests/README.md index 1ef57ed..f99d1d8 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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. \ No newline at end of file +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" \ No newline at end of file diff --git a/tests/e2e/test_e2e_v3_demo_parity.py b/tests/e2e/test_e2e_v3_demo_parity.py index 3b693be..b998d78 100644 --- a/tests/e2e/test_e2e_v3_demo_parity.py +++ b/tests/e2e/test_e2e_v3_demo_parity.py @@ -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)")