"""Test suite for Flask application endpoints and integration. This module provides comprehensive tests for the Flask application including route testing, model integration, caching behavior, and error handling. Run with: python test_app.py or pytest test_app.py """ import json import os from pathlib import Path import tempfile import unittest from unittest import mock # Mock the environment before importing app to avoid initialization errors with mock.patch.dict(os.environ, {'KEY': 'test_api_key_for_import'}): import app as app_module from app import Model, app, setup_cache _SENSITIVE_REPORT_TEXT = 'PRIVATE PATIENT FINDING' class TestFlaskApplication(unittest.TestCase): @classmethod def setUpClass(cls): cls.test_client = app.test_client() app.config['TESTING'] = True def test_index_route_returns_html(self): response = self.test_client.get('/') self.assertEqual(response.status_code, 200) self.assertIn('text/html', response.content_type) def test_cache_stats_route(self): response = self.test_client.get('/cache/stats') self.assertEqual(response.status_code, 200) self.assertEqual(response.content_type, 'application/json') data = json.loads(response.data) self.assertIsInstance(data, dict) @mock.patch('app.model.predict') def test_predict_route_with_valid_data(self, mock_predict): mock_predict.return_value = { 'segments': [{'type': 'body', 'content': 'test'}], 'text': 'test output', } response = self.test_client.post('/predict', data='FINDINGS: Normal chest CT') self.assertEqual(response.status_code, 200) data = json.loads(response.data) self.assertIn('segments', data) self.assertIn('text', data) def test_predict_route_with_empty_data(self): response = self.test_client.post('/predict', data='') self.assertEqual(response.status_code, 400) data = json.loads(response.data) self.assertIn('error', data) self.assertEqual(data['error'], 'Empty input') self.assertIn('message', data) self.assertEqual(data['message'], 'Input text is required') self.assertIn('max_length', data) @mock.patch('app.model.predict') def test_predict_with_custom_headers(self, mock_predict): mock_predict.return_value = {'segments': [], 'text': 'test'} headers = { 'X-Use-Cache': 'false', 'X-Sample-ID': 'test_sample', 'X-Model-ID': 'gemini-2.5-flash', } response = self.test_client.post( '/predict', data='Test report', headers=headers ) self.assertEqual(response.status_code, 200) mock_predict.assert_called_once_with( 'Test report', model_id='gemini-flash-latest' ) @mock.patch('app.cache_manager.get_cached_result') def test_predict_with_cache_hit(self, mock_get_cached): sample_data = json.loads(Path('static/sample_reports.json').read_text()) sample = sample_data['samples'][0] cached_response = { 'segments': [{'type': 'body', 'content': 'cached'}], 'text': 'cached result', } mock_get_cached.return_value = cached_response response = self.test_client.post( '/predict', data=sample['text'], headers={ 'X-Use-Cache': 'true', 'X-Sample-ID': sample['id'], }, ) data = json.loads(response.data) self.assertTrue(data.get('from_cache')) self.assertIn('segments', data) def test_matching_catalog_sample_uses_prebuilt_cache(self): sample_data = json.loads(Path('static/sample_reports.json').read_text()) sample = sample_data['samples'][0] cached_response = {'segments': [], 'text': 'cached result'} with ( mock.patch.object( app_module.cache_manager, 'get_cached_result', autospec=True, return_value=cached_response, ) as mock_get_cached, mock.patch.object( app_module.model, 'predict', autospec=True ) as mock_predict, ): response = self.test_client.post( '/predict', data=sample['text'], headers={ 'X-Use-Cache': 'true', 'X-Sample-ID': sample['id'], }, ) self.assertEqual(response.status_code, 200) self.assertTrue(response.get_json()['from_cache']) mock_get_cached.assert_called_once() mock_predict.assert_not_called() def test_mismatched_catalog_sample_bypasses_cache(self): cached_response = {'segments': [], 'text': 'wrong cached result'} live_response = {'segments': [], 'text': 'live result'} with ( mock.patch.object( app_module.cache_manager, 'get_cached_result', autospec=True, return_value=cached_response, ) as mock_get_cached, mock.patch.object( app_module.cache_manager, 'cache_result', autospec=True ) as mock_cache_result, mock.patch.object( app_module.model, 'predict', autospec=True, return_value=live_response, ) as mock_predict, ): response = self.test_client.post( '/predict', data='EXAMINATION: A report that is not the chest sample.', headers={ 'X-Use-Cache': 'true', 'X-Sample-ID': 'chest_xray', }, ) self.assertEqual(response.status_code, 200) self.assertFalse(response.get_json().get('from_cache', False)) mock_get_cached.assert_not_called() mock_predict.assert_called_once() mock_cache_result.assert_not_called() def test_unknown_sample_id_is_never_cached(self): live_response = {'segments': [], 'text': 'live result'} with ( mock.patch.object( app_module.cache_manager, 'get_cached_result', autospec=True, ) as mock_get_cached, mock.patch.object( app_module.cache_manager, 'cache_result', autospec=True ) as mock_cache_result, mock.patch.object( app_module.model, 'predict', autospec=True, return_value=live_response, ), ): response = self.test_client.post( '/predict', data='EXAMINATION: Unknown sample.', headers={ 'X-Use-Cache': 'true', 'X-Sample-ID': 'not_in_catalog', }, ) self.assertEqual(response.status_code, 200) mock_get_cached.assert_not_called() mock_cache_result.assert_not_called() def test_catalog_cache_miss_is_not_written_at_runtime(self): sample_data = json.loads(Path('static/sample_reports.json').read_text()) sample = sample_data['samples'][0] live_response = {'segments': [], 'text': 'live result'} with ( mock.patch.object( app_module.cache_manager, 'get_cached_result', autospec=True, return_value=None, ), mock.patch.object( app_module.cache_manager, 'cache_result', autospec=True ) as mock_cache_result, mock.patch.object( app_module.model, 'predict', autospec=True, return_value=live_response, ), ): response = self.test_client.post( '/predict', data=sample['text'], headers={ 'X-Use-Cache': 'true', 'X-Sample-ID': sample['id'], }, ) self.assertEqual(response.status_code, 200) mock_cache_result.assert_not_called() def test_unapproved_model_returns_stable_client_error(self): with mock.patch.object( app_module.model, 'predict', autospec=True, return_value={'segments': [], 'text': 'result'}, ) as mock_predict: response = self.test_client.post( '/predict', data='FINDINGS: Normal chest.', headers={ 'X-Use-Cache': 'false', 'X-Model-ID': 'unapproved-model', }, ) self.assertEqual(response.status_code, 400) self.assertEqual(response.get_json()['error'], 'Unsupported model') self.assertIn('message', response.get_json()) mock_predict.assert_not_called() @mock.patch.dict(os.environ, {'MODEL_ID': 'configured-current-model'}) def test_environment_configured_model_is_allowed(self): with mock.patch.object( app_module.model, 'predict', autospec=True, return_value={'segments': [], 'text': 'result'}, ) as mock_predict: response = self.test_client.post( '/predict', data='FINDINGS: Normal chest.', headers={ 'X-Use-Cache': 'false', 'X-Model-ID': 'configured-current-model', }, ) self.assertEqual(response.status_code, 200) mock_predict.assert_called_once_with( 'FINDINGS: Normal chest.', model_id='configured-current-model' ) def test_legacy_pro_model_resolves_at_http_boundary(self): with mock.patch.object( app_module.model, 'predict', autospec=True, return_value={'segments': [], 'text': 'result'}, ) as mock_predict: response = self.test_client.post( '/predict', data='FINDINGS: Normal chest.', headers={ 'X-Use-Cache': 'false', 'X-Model-ID': 'gemini-2.5-pro', }, ) self.assertEqual(response.status_code, 200) mock_predict.assert_called_once_with( 'FINDINGS: Normal chest.', model_id='gemini-pro-latest' ) class TestModelClass(unittest.TestCase): @mock.patch.dict(os.environ, {'KEY': 'test_api_key'}) def test_model_initialization_with_api_key(self): model = Model() self.assertEqual(model.gemini_api_key, 'test_api_key') self.assertIn('gemini-flash-latest', model._structurers) @mock.patch.dict(os.environ, {}, clear=True) def test_model_initialization_without_api_key(self): with self.assertRaises(ValueError) as context: Model() self.assertIn('KEY environment variable not set', str(context.exception)) @mock.patch.dict(os.environ, {'KEY': 'test_key', 'MODEL_ID': 'custom-model'}) def test_model_initialization_with_custom_model(self): model = Model() self.assertIn('custom-model', model._structurers) @mock.patch.dict(os.environ, {'KEY': 'test_key'}) @mock.patch('app.RadiologyReportStructurer') def test_get_structurer_creates_new_instance(self, mock_structurer_class): model = Model() model._get_structurer('new-model') # Should be called twice: once for default, once for new model self.assertEqual(mock_structurer_class.call_count, 2) @mock.patch.dict(os.environ, {'KEY': 'test_key'}) @mock.patch('app.RadiologyReportStructurer') def test_predict_calls_structurer(self, mock_structurer_class): mock_instance = mock.Mock() mock_instance.predict.return_value = {'result': 'test'} mock_structurer_class.return_value = mock_instance model = Model() result = model.predict('test data', 'test-model') mock_instance.predict.assert_called_once_with('test data') self.assertEqual(result, {'result': 'test'}) @mock.patch.dict(os.environ, {'KEY': 'test_key'}) @mock.patch('app.RadiologyReportStructurer') def test_predict_does_not_log_report_content(self, mock_structurer_class): mock_instance = mock.Mock() mock_instance.predict.return_value = {'text': _SENSITIVE_REPORT_TEXT} mock_structurer_class.return_value = mock_instance model = Model() with mock.patch.object(app_module.logger, 'info', autospec=True) as log: model.predict(_SENSITIVE_REPORT_TEXT, 'gemini-flash-latest') self.assertNotIn(_SENSITIVE_REPORT_TEXT, str(log.call_args_list)) class TestCacheSetup(unittest.TestCase): @mock.patch('os.path.exists') @mock.patch('shutil.copy2') @mock.patch('os.makedirs') def test_setup_cache_copies_existing_file( self, mock_makedirs, mock_copy, mock_exists ): mock_exists.side_effect = [True, False] with mock.patch.object(os.path, 'getsize', autospec=True, return_value=123): cache_dir = setup_cache() expected_cache_dir = tempfile.gettempdir() + '/cache' mock_makedirs.assert_called_once_with(expected_cache_dir, exist_ok=True) mock_copy.assert_called_once_with( 'cache/sample_cache.json', expected_cache_dir + '/sample_cache.json' ) self.assertEqual(cache_dir, expected_cache_dir) @mock.patch('os.path.exists') @mock.patch('os.makedirs') def test_setup_cache_handles_missing_source(self, mock_makedirs, mock_exists): mock_exists.return_value = False cache_dir = setup_cache() expected_cache_dir = tempfile.gettempdir() + '/cache' mock_makedirs.assert_called_once_with(expected_cache_dir, exist_ok=True) self.assertEqual(cache_dir, expected_cache_dir) class TestErrorHandling(unittest.TestCase): @classmethod def setUpClass(cls): cls.test_client = app.test_client() app.config['TESTING'] = True def setUp(self): # Suppress all logging during error tests to reduce noise import logging logging.disable(logging.CRITICAL) def tearDown(self): # Re-enable logging import logging logging.disable(logging.NOTSET) @mock.patch('app.model.predict') @mock.patch('app.logger') def test_predict_handles_type_error(self, mock_logger, mock_predict): mock_predict.side_effect = TypeError('Invalid type') response = self.test_client.post('/predict', data='Test data') self.assertEqual(response.status_code, 500) data = json.loads(response.data) self.assertEqual(data['error'], 'Internal processing error') self.assertIn('message', data) @mock.patch('app.model.predict') @mock.patch('app.logger') def test_predict_handles_provider_error(self, mock_logger, mock_predict): provider_error = RuntimeError(_SENSITIVE_REPORT_TEXT) processing_error = app_module.ReportProcessingError('Report processing failed') processing_error.__cause__ = provider_error mock_predict.side_effect = processing_error response = self.test_client.post('/predict', data='Test data') self.assertEqual(response.status_code, 502) data = response.get_json() self.assertEqual(data['error'], 'Processing unavailable') self.assertIn('message', data) self.assertNotIn(_SENSITIVE_REPORT_TEXT, response.get_data(as_text=True)) self.assertNotIn(_SENSITIVE_REPORT_TEXT, str(mock_logger.method_calls)) @mock.patch('app.model.predict') @mock.patch('app.logger') def test_predict_handles_general_exception(self, mock_logger, mock_predict): mock_predict.side_effect = Exception(_SENSITIVE_REPORT_TEXT) response = self.test_client.post('/predict', data='Test data') self.assertEqual(response.status_code, 500) data = json.loads(response.data) self.assertEqual(data['error'], 'Internal processing error') self.assertIn('message', data) self.assertNotIn(_SENSITIVE_REPORT_TEXT, response.get_data(as_text=True)) self.assertNotIn(_SENSITIVE_REPORT_TEXT, str(mock_logger.method_calls)) class TestFrontendAndProjectConfiguration(unittest.TestCase): def test_demo_studio_contract_is_present_above_supporting_content(self): response = app.test_client().get('/') page = response.get_data(as_text=True) self.assertEqual(response.status_code, 200) self.assertIn('id="transformation-studio"', page) self.assertIn('id="findings-cards"', page) self.assertIn('id="studio-metrics"', page) self.assertIn('role="tablist"', page) self.assertIn('Research demonstration — not for clinical use', page) self.assertLess( page.index('id="transformation-studio"'), page.index('id="how-it-works"'), ) def test_default_sample_load_is_cached_and_has_no_simulated_delay(self): script = Path('static/script.js').read_text() self.assertIn("DEFAULT_SAMPLE_ID: 'chest_xray'", script) self.assertIn('loadSampleReport(defaultSample, { scroll: false })', script) self.assertIn("headers['X-Use-Cache'] = 'true'", script) self.assertNotIn('Math.random() * 1000', script) self.assertNotIn('window.location.search.length', script) self.assertNotIn('window.location.hash.length', script) def test_finding_cards_are_lossless_and_keep_alternate_views(self): script = Path('static/script.js').read_text() template = Path('templates/index.html').read_text() self.assertIn('function renderFindingCards(segments)', script) self.assertIn('segments.forEach((segment, index)', script) self.assertIn('card.dataset.segmentIndex = String(index)', script) self.assertIn('No source span', script) self.assertIn('data-output-view="report"', template) self.assertIn('data-output-view="raw"', template) self.assertIn('id="prompt-toggle"', template) def test_langextract_links_and_sample_copy_are_professional(self): template = Path('templates/index.html').read_text() stylesheet = Path('static/style.css').read_text() self.assertIn('href="https://github.com/google/langextract"', template) self.assertIn('LangExtract on GitHub', template) self.assertIn('LangExtract release blog', template) self.assertGreaterEqual(template.count('rel="noopener noreferrer"'), 2) self.assertIn('
=1.6.0,<2.0.0"', project)
def test_sample_preprocessing_is_idempotent_and_cache_is_complete(self):
samples = json.loads(Path('static/sample_reports.json').read_text())['samples']
cache = json.loads(Path('cache/sample_cache.json').read_text())
for sample in samples:
with self.subTest(sample=sample['id']):
normalized = app_module.preprocess_report(sample['text'])
self.assertEqual(app_module.preprocess_report(normalized), normalized)
self.assertIn(f"sample_{sample['id']}", cache)
def test_pytest_discovers_root_test_files_without_fallback(self):
project = Path('pyproject.toml').read_text()
self.assertIn('testpaths = ["test_app.py", "test_validation.py"]', project)
if __name__ == '__main__':
unittest.main()