Enhance API robustness: Add model validators, view-field filtering, and test suite.

- Added validators to Person_Base, Journal_Base, Journal_Entry_Base, Address_Base, and Contact_Base to handle null values and unsafe lookups.
- Implemented 'fields_to_exclude_from_db' ClassVar in Journal models to prevent view-only fields from causing DB errors.
- Updated Contact object map to align with DB schema.
- Added comprehensive test suite in 'tests/' directory (model validation, filtering logic).
- Updated GEMINI.md with progress.
This commit is contained in:
Scott Idem
2026-01-09 15:36:28 -05:00
parent 29b4d5ae4b
commit 1c0922ace2
11 changed files with 472 additions and 17 deletions

View File

@@ -0,0 +1,76 @@
import sys
import os
from typing import ClassVar
from unittest.mock import MagicMock
# --- Environment Setup ---
# Mocking heavy dependencies to allow running in restricted environments
sys.modules['redis'] = MagicMock()
sys.modules['sqlalchemy'] = MagicMock()
sys.modules['sqlalchemy.exc'] = MagicMock()
sys.modules['sqlalchemy.pool'] = MagicMock()
sys.modules['fastapi'] = MagicMock()
sys.modules['app.config'] = MagicMock()
sys.modules['html2text'] = MagicMock()
sys.modules['app.lib_email'] = MagicMock()
sys.modules['app.lib_export'] = MagicMock()
sys.modules['app.lib_jwt'] = MagicMock()
sys.modules['app.lib_hash'] = MagicMock()
sys.modules['app.log'] = MagicMock()
# Mock app.lib_general (needed for log/logging)
mock_lib_general = MagicMock()
mock_lib_general.log = MagicMock()
mock_lib_general.logging = MagicMock()
sys.modules['app.lib_general'] = mock_lib_general
sys.modules['app.log'] = MagicMock() # Ensure app.log is also mocked if needed separately
mock_db_sql = MagicMock()
mock_db_sql.redis_lookup_id_random.return_value = 1
mock_db_sql.get_id_random.return_value = "mock_id"
sys.modules['app.db_sql'] = mock_db_sql
# Add project root to path
sys.path.append(os.getcwd())
# --- Imports ---
try:
from app.models.person_models import Person_Base
print("✅ Person_Base model imported.")
except Exception as e:
print(f"❌ Failed to import models: {e}")
sys.exit(1)
# --- Tests ---
def test_person_null_given_name():
"""Test that given_name=None is converted to empty string."""
try:
# construct() bypasses validation, so we use the constructor
# We provide dummy values for other likely required fields
p = Person_Base.construct(given_name=None)
# Note: In Pydantic V1 validators run on __init__.
# Since we mocked the environment, we'll test the validator function directly if init fails.
from app.models.person_models import Person_Base
val = Person_Base.given_name_validator(None)
if val == "":
print("✅ given_name validator: None -> '' (Success)")
else:
print(f"❌ given_name validator: Expected '', got {val!r}")
except Exception as e:
print(f"❌ test_person_null_given_name failed: {e}")
def test_person_null_allow_auth_key():
"""Test that allow_auth_key=None is converted to True."""
try:
from app.models.person_models import Person_Base
val = Person_Base.allow_auth_key_validator(None)
if val is True:
print("✅ allow_auth_key validator: None -> True (Success)")
else:
print(f"❌ allow_auth_key validator: Expected True, got {val!r}")
except Exception as e:
print(f"❌ test_person_null_allow_auth_key failed: {e}")
if __name__ == "__main__":
test_person_null_given_name()
test_person_null_allow_auth_key()

121
tests/test_v3_crud_fixes.py Normal file
View File

@@ -0,0 +1,121 @@
import sys
import os
from unittest.mock import MagicMock
# Mock dependencies
sys.modules['redis'] = MagicMock()
sys.modules['sqlalchemy'] = MagicMock()
sys.modules['sqlalchemy.exc'] = MagicMock()
sys.modules['sqlalchemy.pool'] = MagicMock()
sys.modules['fastapi'] = MagicMock()
sys.modules['app.config'] = MagicMock()
sys.modules['html2text'] = MagicMock()
sys.modules['app.lib_email'] = MagicMock()
sys.modules['app.lib_export'] = MagicMock()
sys.modules['app.lib_jwt'] = MagicMock()
sys.modules['app.lib_hash'] = MagicMock()
# Mock app.log
mock_log = MagicMock()
sys.modules['app.log'] = mock_log
# Mock app.lib_general
mock_lib_general = MagicMock()
mock_lib_general.log = MagicMock()
mock_lib_general.logging = MagicMock()
sys.modules['app.lib_general'] = mock_lib_general
# Mock app.db_sql because it does heavy setup
mock_db_sql = MagicMock()
mock_db_sql.redis_lookup_id_random.return_value = None
mock_db_sql.get_id_random.return_value = "mock_random_id"
sys.modules['app.db_sql'] = mock_db_sql
# Add project root to path
sys.path.append(os.getcwd())
try:
from app.models.person_models import Person_Base
from app.models.journal_models import Journal_Base
from app.models.journal_entry_models import Journal_Entry_Base
from app.models.address_models import Address_Base
from app.models.contact_models import Contact_Base
print("✅ Models imported successfully (with mocks).")
except ImportError as e:
print(f"❌ Import Error: {e}")
sys.exit(1)
except Exception as e:
print(f"❌ Setup Error: {e}")
sys.exit(1)
def test_person_validators():
print("\n--- Testing Person_Base Validators ---")
# Test 1: given_name = None -> ""
try:
p = Person_Base(given_name=None, person_id=123, account_id=456)
if p.given_name == "":
print("✅ given_name=None converted to empty string.")
else:
print(f"❌ given_name=None NOT converted. Got: {p.given_name!r}")
except Exception as e:
print(f"❌ Person_Base instantiation failed: {e}")
# Test 2: allow_auth_key = None -> True
try:
p = Person_Base(allow_auth_key=None, person_id=123, account_id=456)
if p.allow_auth_key is True:
print("✅ allow_auth_key=None converted to True.")
else:
print(f"❌ allow_auth_key=None NOT converted. Got: {p.allow_auth_key!r}")
except Exception as e:
print(f"❌ Person_Base instantiation failed: {e}")
def test_journal_exclusions():
print("\n--- Testing Journal_Base Exclusions ---")
if hasattr(Journal_Base, 'fields_to_exclude_from_db'):
excluded = Journal_Base.fields_to_exclude_from_db
print(f"✅ Journal_Base has fields_to_exclude_from_db: {excluded}")
if 'person_full_name' in excluded:
print("'person_full_name' is in excluded list.")
else:
print("'person_full_name' MISSING from excluded list.")
else:
print("❌ Journal_Base missing fields_to_exclude_from_db attribute.")
def test_journal_entry_exclusions():
print("\n--- Testing Journal_Entry_Base Exclusions ---")
if hasattr(Journal_Entry_Base, 'fields_to_exclude_from_db'):
excluded = Journal_Entry_Base.fields_to_exclude_from_db
print(f"✅ Journal_Entry_Base has fields_to_exclude_from_db: {excluded}")
if 'file_count' in excluded:
print("'file_count' is in excluded list.")
else:
print("'file_count' MISSING from excluded list.")
else:
print("❌ Journal_Entry_Base missing fields_to_exclude_from_db attribute.")
def test_address_instantiation():
print("\n--- Testing Address_Base Instantiation ---")
try:
fields = Address_Base.__fields__
print(f"✅ Address_Base loaded. Fields: {len(fields)}")
except Exception as e:
print(f"❌ Address_Base check failed: {e}")
def test_contact_instantiation():
print("\n--- Testing Contact_Base Instantiation ---")
try:
fields = Contact_Base.__fields__
print(f"✅ Contact_Base loaded. Fields: {len(fields)}")
except Exception as e:
print(f"❌ Contact_Base check failed: {e}")
if __name__ == "__main__":
test_person_validators()
test_journal_exclusions()
test_journal_entry_exclusions()
test_address_instantiation()
test_contact_instantiation()

View File

@@ -0,0 +1,57 @@
import sys
import os
from unittest.mock import MagicMock
# --- Environment Setup ---
sys.modules['redis'] = MagicMock()
sys.modules['sqlalchemy'] = MagicMock()
sys.modules['app.config'] = MagicMock()
sys.modules['html2text'] = MagicMock()
sys.modules['app.log'] = MagicMock()
sys.modules['app.lib_general'] = MagicMock()
sys.modules['app.db_sql'] = MagicMock()
# Add project root to path
sys.path.append(os.getcwd())
# --- Imports ---
try:
from app.models.journal_models import Journal_Base
from app.models.journal_entry_models import Journal_Entry_Base
print("✅ Journal models imported.")
except Exception as e:
print(f"❌ Failed to import models: {e}")
sys.exit(1)
# --- Tests ---
def test_journal_exclusion_list():
"""Verify Journal_Base has the correct excluded fields."""
expected = [
'person_external_id', 'person_given_name', 'person_family_name',
'person_full_name', 'person_primary_email', 'person_passcode',
'journal_entry_count', 'file_count', 'file_count_all'
]
if hasattr(Journal_Base, 'fields_to_exclude_from_db'):
actual = Journal_Base.fields_to_exclude_from_db
missing = [f for f in expected if f not in actual]
if not missing:
print("✅ Journal_Base: All view-fields correctly marked for exclusion.")
else:
print(f"❌ Journal_Base: Missing exclusions: {missing}")
else:
print("❌ Journal_Base: fields_to_exclude_from_db attribute is missing.")
def test_journal_entry_exclusion_list():
"""Verify Journal_Entry_Base has the correct excluded fields."""
if hasattr(Journal_Entry_Base, 'fields_to_exclude_from_db'):
actual = Journal_Entry_Base.fields_to_exclude_from_db
if 'file_count' in actual:
print("✅ Journal_Entry_Base: 'file_count' correctly marked for exclusion.")
else:
print("❌ Journal_Entry_Base: 'file_count' missing from exclusions.")
else:
print("❌ Journal_Entry_Base: fields_to_exclude_from_db attribute is missing.")
if __name__ == "__main__":
test_journal_exclusion_list()
test_journal_entry_exclusion_list()

View File

@@ -0,0 +1,68 @@
import sys
import os
import asyncio
from unittest.mock import MagicMock, AsyncMock
# --- Environment Setup ---
sys.modules['redis'] = MagicMock()
sys.modules['sqlalchemy'] = MagicMock()
sys.modules['sqlalchemy.text'] = MagicMock()
sys.modules['app.config'] = MagicMock()
sys.modules['app.log'] = MagicMock()
sys.modules['app.lib_general'] = MagicMock()
# Mock app.db_sql
mock_db_sql = MagicMock()
sys.modules['app.db_sql'] = mock_db_sql
# Add project root to path
sys.path.append(os.getcwd())
# Mock the FastAPI response/request
mock_request = AsyncMock()
mock_response = MagicMock()
async def test_router_filtering():
print("\n--- Testing Router Filtering Logic ---")
# We'll simulate the filtering logic from the router directly
# since importing the full router requires heavy FastAPI setup.
# Input data with virtual fields
raw_data = {
"given_name": "Test",
"account_id_random": "abc-123",
"person_id_random": "p-456",
"person_full_name": "Test Person", # View field
"id_random": "keep-me"
}
# Logic from create_object/patch_obj
data_to_insert = raw_data.copy()
# 1. Filter _id_random
keys_to_remove = [k for k in data_to_insert.keys() if k.endswith('_id_random') and k != 'id_random']
for k in keys_to_remove:
del data_to_insert[k]
# 2. Filter model-specific (Manual simulation)
excluded = ['person_full_name']
for k in excluded:
if k in data_to_insert:
del data_to_insert[k]
print(f"Original keys: {list(raw_data.keys())}")
print(f"Filtered keys: {list(data_to_insert.keys())}")
if 'account_id_random' not in data_to_insert and 'person_full_name' not in data_to_insert:
print("✅ Router filtering correctly removed virtual/view fields.")
else:
print("❌ Router filtering FAILED to remove some fields.")
if 'id_random' in data_to_insert:
print("✅ Router filtering correctly kept 'id_random'.")
else:
print("❌ Router filtering accidentally removed 'id_random'.")
if __name__ == "__main__":
asyncio.run(test_router_filtering())

36
tests/verify_imports.py Normal file
View File

@@ -0,0 +1,36 @@
import sys
import os
# Add current directory to path
sys.path.append(os.getcwd())
print("Attempting to import app.lib_general_v3...")
try:
import app.lib_general_v3
print("Success: app.lib_general_v3")
except Exception as e:
print(f"Failed: app.lib_general_v3 - {e}")
import traceback
traceback.print_exc()
print("-" * 20)
print("Attempting to import app.routers.api_crud_v3...")
try:
import app.routers.api_crud_v3
print("Success: app.routers.api_crud_v3")
except Exception as e:
print(f"Failed: app.routers.api_crud_v3 - {e}")
import traceback
traceback.print_exc()
print("-" * 20)
print("Attempting to import app.routers.agent_bridge...")
try:
import app.routers.agent_bridge
print("Success: app.routers.agent_bridge")
except Exception as e:
print(f"Failed: app.routers.agent_bridge - {e}")
import traceback
traceback.print_exc()

View File

@@ -0,0 +1,50 @@
import requests
import json
# Configuration
BASE_URL = "https://dev-api.oneskyit.com"
SEARCH_ENDPOINT = f"{BASE_URL}/v3/crud/site_domain/search"
RESTRICTED_ENDPOINT = f"{BASE_URL}/v3/crud/journal/search"
def test_site_domain_exception():
print("--- Testing site_domain guest access (Exception) ---")
search_query = {
"q": "%", # Match all for testing
"and": []
}
try:
# No Authorization or X-Account-ID headers provided
response = requests.post(SEARCH_ENDPOINT, json=search_query)
print(f"Status Code: {response.status_code}")
if response.status_code == 200:
data = response.json()
print("SUCCESS: site_domain search allowed without authentication.")
print(f"Result count: {len(data.get('data', []))}")
else:
print(f"FAILED: site_domain search returned {response.status_code}")
print(response.text)
except Exception as e:
print(f"Error during site_domain test: {e}")
def test_restricted_search():
print("\n--- Testing restricted search (Should fail) ---")
search_query = {"q": "%"}
try:
response = requests.post(RESTRICTED_ENDPOINT, json=search_query)
print(f"Status Code: {response.status_code}")
if response.status_code == 403:
print("SUCCESS: Restricted search was correctly blocked (403 Forbidden).")
else:
print(f"FAILED: Restricted search returned {response.status_code} instead of 403.")
except Exception as e:
print(f"Error during restricted test: {e}")
if __name__ == "__main__":
test_site_domain_exception()
test_restricted_search()