diff --git a/tests/integration/test_load_structured_data_integration.py b/tests/integration/test_load_structured_data_integration.py new file mode 100644 index 00000000..6d087f05 --- /dev/null +++ b/tests/integration/test_load_structured_data_integration.py @@ -0,0 +1,531 @@ +""" +Integration tests for tg-load-structured-data with actual TrustGraph instance. +Tests end-to-end functionality including WebSocket connections and data storage. +""" + +import pytest +import asyncio +import json +import tempfile +import os +import csv +import time +from unittest.mock import Mock, patch, AsyncMock +from websockets.asyncio.client import connect + +from trustgraph.cli.load_structured_data import load_structured_data + + +@pytest.mark.integration +class TestLoadStructuredDataIntegration: + """Integration tests for complete pipeline""" + + def setup_method(self): + """Set up test fixtures""" + self.api_url = "http://localhost:8088" + self.test_schema_name = "integration_test_schema" + + self.test_csv_data = """name,email,age,country,status +John Smith,john@email.com,35,US,active +Jane Doe,jane@email.com,28,CA,active +Bob Johnson,bob@company.org,42,UK,inactive +Alice Brown,alice@email.com,31,AU,active +Charlie Davis,charlie@email.com,39,DE,inactive""" + + self.test_json_data = [ + {"name": "John Smith", "email": "john@email.com", "age": 35, "country": "US", "status": "active"}, + {"name": "Jane Doe", "email": "jane@email.com", "age": 28, "country": "CA", "status": "active"}, + {"name": "Bob Johnson", "email": "bob@company.org", "age": 42, "country": "UK", "status": "inactive"} + ] + + self.test_xml_data = """ + + + + John Smith + john@email.com + 35 + US + active + + + Jane Doe + jane@email.com + 28 + CA + active + + + Bob Johnson + bob@company.org + 42 + UK + inactive + + +""" + + self.test_descriptor = { + "version": "1.0", + "metadata": { + "name": "IntegrationTest", + "description": "Test descriptor for integration tests", + "author": "Test Suite" + }, + "format": { + "type": "csv", + "encoding": "utf-8", + "options": { + "header": True, + "delimiter": "," + } + }, + "mappings": [ + { + "source_field": "name", + "target_field": "name", + "transforms": [{"type": "trim"}], + "validation": [{"type": "required"}] + }, + { + "source_field": "email", + "target_field": "email", + "transforms": [{"type": "trim"}, {"type": "lower"}], + "validation": [{"type": "required"}] + }, + { + "source_field": "age", + "target_field": "age", + "transforms": [{"type": "to_int"}], + "validation": [{"type": "required"}] + }, + { + "source_field": "country", + "target_field": "country", + "transforms": [{"type": "trim"}, {"type": "upper"}], + "validation": [{"type": "required"}] + }, + { + "source_field": "status", + "target_field": "status", + "transforms": [{"type": "trim"}, {"type": "lower"}], + "validation": [{"type": "required"}] + } + ], + "output": { + "format": "trustgraph-objects", + "schema_name": self.test_schema_name, + "options": { + "confidence": 0.9, + "batch_size": 3 + } + } + } + + def create_temp_file(self, content, suffix='.txt'): + """Create a temporary file with given content""" + temp_file = tempfile.NamedTemporaryFile(mode='w', suffix=suffix, delete=False) + temp_file.write(content) + temp_file.flush() + temp_file.close() + return temp_file.name + + def cleanup_temp_file(self, file_path): + """Clean up temporary file""" + try: + os.unlink(file_path) + except: + pass + + # End-to-end Pipeline Tests + @pytest.mark.asyncio + async def test_csv_to_trustgraph_pipeline(self): + """Test complete CSV to TrustGraph pipeline""" + input_file = self.create_temp_file(self.test_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + # Test with dry run first + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + dry_run=True, + batch_size=2, + flow='obj-ex' + ) + + # Should complete without errors in dry run mode + assert result is None # dry_run returns None + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + @pytest.mark.asyncio + async def test_xml_to_trustgraph_pipeline(self): + """Test complete XML to TrustGraph pipeline""" + # Create XML descriptor + xml_descriptor = { + **self.test_descriptor, + "format": { + "type": "xml", + "encoding": "utf-8", + "options": { + "record_path": "/ROOT/data/record", + "field_attribute": "name" + } + } + } + + input_file = self.create_temp_file(self.test_xml_data, '.xml') + descriptor_file = self.create_temp_file(json.dumps(xml_descriptor), '.json') + + try: + # Test with dry run + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + dry_run=True, + batch_size=2, + flow='obj-ex' + ) + + assert result is None # dry_run returns None + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + @pytest.mark.asyncio + async def test_json_to_trustgraph_pipeline(self): + """Test complete JSON to TrustGraph pipeline""" + json_descriptor = { + **self.test_descriptor, + "format": { + "type": "json", + "encoding": "utf-8" + } + } + + input_file = self.create_temp_file(json.dumps(self.test_json_data), '.json') + descriptor_file = self.create_temp_file(json.dumps(json_descriptor), '.json') + + try: + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + dry_run=True, + batch_size=2, + flow='obj-ex' + ) + + assert result is None # dry_run returns None + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + # Batching Integration Tests + @pytest.mark.asyncio + async def test_large_dataset_batching(self): + """Test batching with larger dataset""" + # Generate larger dataset + large_csv_data = "name,email,age,country,status\n" + for i in range(1000): + large_csv_data += f"User{i},user{i}@example.com,{25+i%40},US,active\n" + + input_file = self.create_temp_file(large_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + start_time = time.time() + + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + dry_run=True, + batch_size=50, # Test with moderate batch size + flow='obj-ex' + ) + + end_time = time.time() + processing_time = end_time - start_time + + # Should process 1000 records reasonably quickly + assert processing_time < 30 # Should complete in under 30 seconds + assert result is None # dry_run returns None + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + @pytest.mark.asyncio + async def test_batch_size_performance(self): + """Test different batch sizes for performance""" + # Generate test dataset + test_csv_data = "name,email,age,country,status\n" + for i in range(100): + test_csv_data += f"User{i},user{i}@example.com,{25+i%40},US,active\n" + + input_file = self.create_temp_file(test_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + # Test different batch sizes + batch_sizes = [1, 10, 25, 50, 100] + processing_times = {} + + for batch_size in batch_sizes: + start_time = time.time() + + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + dry_run=True, + batch_size=batch_size, + flow='obj-ex' + ) + + end_time = time.time() + processing_times[batch_size] = end_time - start_time + + assert result is None # dry_run returns None + + # All batch sizes should complete reasonably quickly + for batch_size, time_taken in processing_times.items(): + assert time_taken < 10, f"Batch size {batch_size} took {time_taken}s" + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + # Parse-Only Mode Tests + @pytest.mark.asyncio + async def test_parse_only_mode(self): + """Test parse-only mode functionality""" + input_file = self.create_temp_file(self.test_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + output_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) + output_file.close() + + try: + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + parse_only=True, + output_file=output_file.name + ) + + # Check output file was created and contains parsed data + assert os.path.exists(output_file.name) + with open(output_file.name, 'r') as f: + parsed_data = json.load(f) + assert isinstance(parsed_data, list) + assert len(parsed_data) == 5 # Should have 5 records + assert parsed_data[0]["name"] == "John Smith" + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + self.cleanup_temp_file(output_file.name) + + # Schema Suggestion Integration Tests + @patch('trustgraph.cli.load_structured_data.TrustGraphAPI') + @pytest.mark.asyncio + async def test_schema_suggestion_integration(self, mock_api_class): + """Test schema suggestion integration with API""" + # Setup mock API responses + mock_api = Mock() + mock_api_class.return_value = mock_api + mock_config_api = Mock() + mock_api.config.return_value = mock_config_api + mock_config_api.get_config_items.return_value = { + "schema": { + "customer": '{"name": "customer", "description": "Customer records"}', + "product": '{"name": "product", "description": "Product catalog"}' + } + } + + mock_flow = Mock() + mock_api.flow.return_value = mock_flow + mock_flow.id.return_value = mock_flow + mock_prompt_client = Mock() + mock_flow.prompt.return_value = mock_prompt_client + mock_prompt_client.schema_selection.return_value = "The customer schema is most appropriate for this data containing names and emails." + + input_file = self.create_temp_file(self.test_csv_data, '.csv') + + try: + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + suggest_schema=True, + sample_size=100, + sample_chars=500 + ) + + # Verify API calls were made correctly + mock_config_api.get_config_items.assert_called_once() + mock_prompt_client.schema_selection.assert_called_once() + + # Check that schemas were passed correctly + call_args = mock_prompt_client.schema_selection.call_args + assert 'schemas' in call_args.kwargs + assert 'sample' in call_args.kwargs + + finally: + self.cleanup_temp_file(input_file) + + # Descriptor Generation Integration Tests + @patch('trustgraph.cli.load_structured_data.TrustGraphAPI') + @pytest.mark.asyncio + async def test_descriptor_generation_integration(self, mock_api_class): + """Test descriptor generation integration""" + # Setup mock API + mock_api = Mock() + mock_api_class.return_value = mock_api + mock_config_api = Mock() + mock_api.config.return_value = mock_config_api + mock_config_api.get_config_items.return_value = { + "schema": { + "customer": '{"name": "customer", "fields": [{"name": "name", "type": "string"}]}' + } + } + + mock_flow = Mock() + mock_api.flow.return_value = mock_flow + mock_flow.id.return_value = mock_flow + mock_prompt_client = Mock() + mock_flow.prompt.return_value = mock_prompt_client + + # Mock descriptor generation response + generated_descriptor = {**self.test_descriptor} + mock_prompt_client.diagnose_structured_data.return_value = json.dumps(generated_descriptor) + + input_file = self.create_temp_file(self.test_csv_data, '.csv') + + try: + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + generate_descriptor=True, + sample_chars=1000 + ) + + # Verify API calls + mock_prompt_client.diagnose_structured_data.assert_called_once() + + # Check call arguments + call_args = mock_prompt_client.diagnose_structured_data.call_args + assert 'schemas' in call_args.kwargs + assert 'sample' in call_args.kwargs + + finally: + self.cleanup_temp_file(input_file) + + # Error Handling Integration Tests + @pytest.mark.asyncio + async def test_malformed_data_handling(self): + """Test handling of malformed data""" + malformed_csv = """name,email,age +John Smith,john@email.com,35 +Jane Doe,jane@email.com # Missing age field +Bob Johnson,bob@company.org,not_a_number""" + + input_file = self.create_temp_file(malformed_csv, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + # Should handle malformed data gracefully + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + dry_run=True, + max_errors=5 # Allow some errors + ) + + # Should complete even with some malformed records + assert result is None # dry_run returns None + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + # WebSocket Connection Tests + @pytest.mark.asyncio + async def test_websocket_connection_handling(self): + """Test WebSocket connection behavior""" + input_file = self.create_temp_file(self.test_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + # Test with invalid API URL (should fail gracefully) + with pytest.raises(Exception): # Connection error expected + result = load_structured_data( + api_url="http://invalid-url:9999", + input_file=input_file, + descriptor_file=descriptor_file, + batch_size=2, + flow='obj-ex' + ) + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + # Flow Parameter Tests + @pytest.mark.asyncio + async def test_flow_parameter_integration(self): + """Test flow parameter functionality""" + input_file = self.create_temp_file(self.test_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + # Test with different flow values + flows = ['default', 'obj-ex', 'custom-flow'] + + for flow in flows: + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + dry_run=True, + flow=flow + ) + + assert result is None # dry_run returns None + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + # Mixed Format Tests + @pytest.mark.asyncio + async def test_encoding_variations(self): + """Test different encoding variations""" + # Test UTF-8 with BOM + utf8_bom_data = '\ufeff' + self.test_csv_data + + input_file = self.create_temp_file(utf8_bom_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + dry_run=True + ) + + assert result is None # Should handle BOM correctly + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) \ No newline at end of file diff --git a/tests/integration/test_load_structured_data_websocket.py b/tests/integration/test_load_structured_data_websocket.py new file mode 100644 index 00000000..ab647f18 --- /dev/null +++ b/tests/integration/test_load_structured_data_websocket.py @@ -0,0 +1,481 @@ +""" +WebSocket-specific integration tests for tg-load-structured-data. +Tests WebSocket connection handling, message formats, and batching behavior. +""" + +import pytest +import asyncio +import json +import tempfile +import os +from unittest.mock import Mock, patch, AsyncMock, MagicMock +import websockets +from websockets.exceptions import ConnectionClosedError, InvalidHandshake + +from trustgraph.cli.load_structured_data import load_structured_data + + +@pytest.mark.integration +class TestLoadStructuredDataWebSocket: + """WebSocket-specific integration tests""" + + def setup_method(self): + """Set up test fixtures""" + self.api_url = "http://localhost:8088" + self.ws_url = "ws://localhost:8088" + + self.test_csv_data = """name,email,age,country +John Smith,john@email.com,35,US +Jane Doe,jane@email.com,28,CA +Bob Johnson,bob@company.org,42,UK +Alice Brown,alice@email.com,31,AU +Charlie Davis,charlie@email.com,39,DE""" + + self.test_descriptor = { + "version": "1.0", + "format": { + "type": "csv", + "encoding": "utf-8", + "options": {"header": True, "delimiter": ","} + }, + "mappings": [ + {"source_field": "name", "target_field": "name", "transforms": [{"type": "trim"}]}, + {"source_field": "email", "target_field": "email", "transforms": [{"type": "lower"}]}, + {"source_field": "age", "target_field": "age", "transforms": [{"type": "to_int"}]}, + {"source_field": "country", "target_field": "country", "transforms": [{"type": "upper"}]} + ], + "output": { + "format": "trustgraph-objects", + "schema_name": "test_customer", + "options": {"confidence": 0.9, "batch_size": 2} + } + } + + def create_temp_file(self, content, suffix='.txt'): + """Create a temporary file with given content""" + temp_file = tempfile.NamedTemporaryFile(mode='w', suffix=suffix, delete=False) + temp_file.write(content) + temp_file.flush() + temp_file.close() + return temp_file.name + + def cleanup_temp_file(self, file_path): + """Clean up temporary file""" + try: + os.unlink(file_path) + except: + pass + + @pytest.mark.asyncio + async def test_websocket_message_format(self): + """Test that WebSocket messages are formatted correctly for batching""" + messages_sent = [] + + # Mock WebSocket connection + async def mock_websocket_handler(websocket, path): + try: + while True: + message = await websocket.recv() + messages_sent.append(json.loads(message)) + except websockets.exceptions.ConnectionClosed: + pass + + # Start mock WebSocket server + server = await websockets.serve(mock_websocket_handler, "localhost", 8089) + + try: + input_file = self.create_temp_file(self.test_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + # Test with mock server + with patch('websockets.asyncio.client.connect') as mock_connect: + mock_ws = AsyncMock() + mock_connect.return_value.__aenter__.return_value = mock_ws + + # Capture messages sent + sent_messages = [] + mock_ws.send = AsyncMock(side_effect=lambda msg: sent_messages.append(json.loads(msg))) + + try: + result = load_structured_data( + api_url="http://localhost:8089", + input_file=input_file, + descriptor_file=descriptor_file, + batch_size=2, + flow='obj-ex' + ) + + # Verify message format + assert len(sent_messages) > 0 + + for message in sent_messages: + # Check required fields + assert "metadata" in message + assert "schema_name" in message + assert "values" in message + assert "confidence" in message + assert "source_span" in message + + # Check metadata structure + metadata = message["metadata"] + assert "id" in metadata + assert "metadata" in metadata + assert "user" in metadata + assert "collection" in metadata + + # Check batched values format + values = message["values"] + assert isinstance(values, list), "Values should be a list (batched)" + assert len(values) <= 2, "Batch size should be respected" + + # Check each object in batch + for obj in values: + assert isinstance(obj, dict) + assert "name" in obj + assert "email" in obj + assert "age" in obj + assert "country" in obj + + # Check transformations were applied + assert obj["email"].islower(), "Email should be lowercase" + assert obj["country"].isupper(), "Country should be uppercase" + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + finally: + server.close() + await server.wait_closed() + + @pytest.mark.asyncio + async def test_websocket_connection_retry(self): + """Test WebSocket connection retry behavior""" + input_file = self.create_temp_file(self.test_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + # Test connection to non-existent server + with pytest.raises(Exception) as exc_info: + result = load_structured_data( + api_url="http://localhost:9999", # Non-existent server + input_file=input_file, + descriptor_file=descriptor_file, + batch_size=2, + flow='obj-ex' + ) + + # Should get connection error + assert "connection" in str(exc_info.value).lower() or "refused" in str(exc_info.value).lower() + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + @pytest.mark.asyncio + async def test_websocket_large_message_handling(self): + """Test WebSocket handling of large batched messages""" + # Generate larger dataset + large_csv_data = "name,email,age,country\n" + for i in range(100): + large_csv_data += f"User{i},user{i}@example.com,{25+i%40},US\n" + + # Create descriptor with larger batch size + large_batch_descriptor = { + **self.test_descriptor, + "output": { + **self.test_descriptor["output"], + "batch_size": 50 # Large batch size + } + } + + input_file = self.create_temp_file(large_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(large_batch_descriptor), '.json') + + try: + with patch('websockets.asyncio.client.connect') as mock_connect: + mock_ws = AsyncMock() + mock_connect.return_value.__aenter__.return_value = mock_ws + + sent_messages = [] + mock_ws.send = AsyncMock(side_effect=lambda msg: sent_messages.append(json.loads(msg))) + + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + batch_size=50, + flow='obj-ex' + ) + + # Should handle large batches + assert len(sent_messages) >= 2 # 100 records with batch_size=50 -> 2 messages + + # Check message sizes + for message in sent_messages: + values = message["values"] + assert len(values) <= 50 + + # Check message is not too large (rough size check) + message_size = len(json.dumps(message)) + assert message_size < 1024 * 1024 # Less than 1MB per message + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + @pytest.mark.asyncio + async def test_websocket_connection_interruption(self): + """Test handling of WebSocket connection interruptions""" + input_file = self.create_temp_file(self.test_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + with patch('websockets.asyncio.client.connect') as mock_connect: + mock_ws = AsyncMock() + mock_connect.return_value.__aenter__.return_value = mock_ws + + # Simulate connection being closed mid-send + call_count = 0 + def send_with_failure(msg): + nonlocal call_count + call_count += 1 + if call_count > 1: # Fail after first message + raise ConnectionClosedError(None, None) + return AsyncMock() + + mock_ws.send.side_effect = send_with_failure + + # Should handle connection errors + with pytest.raises(ConnectionClosedError): + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + batch_size=2, + flow='obj-ex' + ) + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + @pytest.mark.asyncio + async def test_websocket_url_conversion(self): + """Test proper URL conversion from HTTP to WebSocket""" + input_file = self.create_temp_file(self.test_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + with patch('websockets.asyncio.client.connect') as mock_connect: + mock_ws = AsyncMock() + mock_connect.return_value.__aenter__.return_value = mock_ws + mock_ws.send = AsyncMock() + + # Test HTTP URL conversion + result = load_structured_data( + api_url="http://localhost:8088", # HTTP URL + input_file=input_file, + descriptor_file=descriptor_file, + batch_size=2, + flow='obj-ex' + ) + + # Check that WebSocket URL was used + mock_connect.assert_called_once() + called_url = mock_connect.call_args[0][0] + assert called_url.startswith("ws://") + assert "api/v1/flow/obj-ex/import/objects" in called_url + + # Test HTTPS URL conversion + mock_connect.reset_mock() + + result = load_structured_data( + api_url="https://example.com:8088", # HTTPS URL + input_file=input_file, + descriptor_file=descriptor_file, + batch_size=2, + flow='test-flow' + ) + + # Check that secure WebSocket URL was used + mock_connect.assert_called_once() + called_url = mock_connect.call_args[0][0] + assert called_url.startswith("wss://") + assert "api/v1/flow/test-flow/import/objects" in called_url + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + @pytest.mark.asyncio + async def test_websocket_batch_ordering(self): + """Test that batches are sent in correct order""" + # Create ordered test data + ordered_csv_data = "name,id\n" + for i in range(10): + ordered_csv_data += f"User{i:02d},{i}\n" + + input_file = self.create_temp_file(ordered_csv_data, '.csv') + + # Create descriptor for this test + ordered_descriptor = { + **self.test_descriptor, + "mappings": [ + {"source_field": "name", "target_field": "name", "transforms": []}, + {"source_field": "id", "target_field": "id", "transforms": [{"type": "to_int"}]} + ], + "output": { + **self.test_descriptor["output"], + "batch_size": 3 + } + } + descriptor_file = self.create_temp_file(json.dumps(ordered_descriptor), '.json') + + try: + with patch('websockets.asyncio.client.connect') as mock_connect: + mock_ws = AsyncMock() + mock_connect.return_value.__aenter__.return_value = mock_ws + + sent_messages = [] + mock_ws.send = AsyncMock(side_effect=lambda msg: sent_messages.append(json.loads(msg))) + + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + batch_size=3, + flow='obj-ex' + ) + + # Should have 4 messages (10 records, batch_size=3: 3+3+3+1) + assert len(sent_messages) == 4 + + # Check ordering within batches + expected_ids = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + actual_ids = [] + + for message in sent_messages: + values = message["values"] + for obj in values: + actual_ids.append(int(obj["id"])) + + assert actual_ids == expected_ids, "Records should maintain order" + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + @pytest.mark.asyncio + async def test_websocket_authentication_headers(self): + """Test WebSocket connection with authentication headers""" + input_file = self.create_temp_file(self.test_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + with patch('websockets.asyncio.client.connect') as mock_connect: + mock_ws = AsyncMock() + mock_connect.return_value.__aenter__.return_value = mock_ws + mock_ws.send = AsyncMock() + + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + batch_size=2, + flow='obj-ex' + ) + + # Verify WebSocket connect was called + mock_connect.assert_called_once() + + # In real implementation, could check for auth headers + # For now, just verify the connection was attempted + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + @pytest.mark.asyncio + async def test_websocket_empty_batch_handling(self): + """Test handling of empty batches""" + # Create CSV with some invalid records + invalid_csv_data = """name,email,age,country +,invalid@email,not_a_number, +Valid User,valid@email.com,25,US""" + + input_file = self.create_temp_file(invalid_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + with patch('websockets.asyncio.client.connect') as mock_connect: + mock_ws = AsyncMock() + mock_connect.return_value.__aenter__.return_value = mock_ws + + sent_messages = [] + mock_ws.send = AsyncMock(side_effect=lambda msg: sent_messages.append(json.loads(msg))) + + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + batch_size=2, + flow='obj-ex' + ) + + # Should still send messages for valid records + assert len(sent_messages) >= 1 + + # Check that messages are not empty + for message in sent_messages: + values = message["values"] + assert len(values) > 0, "Should not send empty batches" + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) + + @pytest.mark.asyncio + async def test_websocket_progress_reporting(self): + """Test progress reporting during WebSocket sends""" + # Generate larger dataset for progress testing + progress_csv_data = "name,email,age\n" + for i in range(50): + progress_csv_data += f"User{i},user{i}@example.com,{25+i}\n" + + input_file = self.create_temp_file(progress_csv_data, '.csv') + descriptor_file = self.create_temp_file(json.dumps(self.test_descriptor), '.json') + + try: + with patch('websockets.asyncio.client.connect') as mock_connect: + mock_ws = AsyncMock() + mock_connect.return_value.__aenter__.return_value = mock_ws + + send_count = 0 + def count_sends(msg): + nonlocal send_count + send_count += 1 + return AsyncMock() + + mock_ws.send.side_effect = count_sends + + # Capture logging output to check for progress messages + with patch('logging.getLogger') as mock_logger: + mock_log = Mock() + mock_logger.return_value = mock_log + + result = load_structured_data( + api_url=self.api_url, + input_file=input_file, + descriptor_file=descriptor_file, + batch_size=10, # Should result in 5 batches + flow='obj-ex', + verbose=True + ) + + # Should have sent multiple batches + assert send_count >= 5 + + finally: + self.cleanup_temp_file(input_file) + self.cleanup_temp_file(descriptor_file) \ No newline at end of file diff --git a/tests/unit/test_cli/test_load_structured_data.py b/tests/unit/test_cli/test_load_structured_data.py new file mode 100644 index 00000000..f84e1e5c --- /dev/null +++ b/tests/unit/test_cli/test_load_structured_data.py @@ -0,0 +1,431 @@ +""" +Unit tests for tg-load-structured-data CLI command. +Tests all modes: suggest-schema, generate-descriptor, parse-only, full pipeline. +""" + +import pytest +import json +import tempfile +import os +import csv +import xml.etree.ElementTree as ET +from unittest.mock import Mock, patch, AsyncMock, MagicMock, call +from io import StringIO +import asyncio + +# Import the function we're testing +from trustgraph.cli.load_structured_data import ( + load_structured_data, + parse_csv_data, + parse_json_data, + parse_xml_data, + apply_transformations +) + + +class TestLoadStructuredDataUnit: + """Unit tests for load_structured_data functionality""" + + def setup_method(self): + """Set up test fixtures""" + self.test_csv_data = """name,email,age,country +John Smith,john@email.com,35,US +Jane Doe,jane@email.com,28,CA +Bob Johnson,bob@company.org,42,UK""" + + self.test_json_data = [ + {"name": "John Smith", "email": "john@email.com", "age": 35, "country": "US"}, + {"name": "Jane Doe", "email": "jane@email.com", "age": 28, "country": "CA"} + ] + + self.test_xml_data = """ + + + + John Smith + john@email.com + 35 + + + Jane Doe + jane@email.com + 28 + + +""" + + self.test_descriptor = { + "version": "1.0", + "format": {"type": "csv", "encoding": "utf-8", "options": {"header": True}}, + "mappings": [ + {"source_field": "name", "target_field": "name", "transforms": [{"type": "trim"}]}, + {"source_field": "email", "target_field": "email", "transforms": [{"type": "lower"}]} + ], + "output": { + "format": "trustgraph-objects", + "schema_name": "customer", + "options": {"confidence": 0.9, "batch_size": 100} + } + } + + # CSV Parsing Tests + def test_csv_parsing_with_header(self): + """Test CSV parsing with header row""" + format_info = {"type": "csv", "encoding": "utf-8", "options": {"header": True, "delimiter": ","}} + + records = parse_csv_data(self.test_csv_data, format_info) + + assert len(records) == 3 + assert records[0]["name"] == "John Smith" + assert records[0]["email"] == "john@email.com" + assert records[1]["country"] == "CA" + + def test_csv_parsing_without_header(self): + """Test CSV parsing without header row""" + csv_data = """John Smith,john@email.com,35,US +Jane Doe,jane@email.com,28,CA""" + format_info = {"type": "csv", "encoding": "utf-8", "options": {"header": False, "delimiter": ","}} + + records = parse_csv_data(csv_data, format_info) + + assert len(records) == 2 + # Should use column indices as keys + assert records[0]["0"] == "John Smith" + assert records[0]["1"] == "john@email.com" + + def test_csv_parsing_custom_delimiter(self): + """Test CSV parsing with custom delimiter""" + csv_data = """name;email;age +John Smith;john@email.com;35 +Jane Doe;jane@email.com;28""" + format_info = {"type": "csv", "encoding": "utf-8", "options": {"header": True, "delimiter": ";"}} + + records = parse_csv_data(csv_data, format_info) + + assert len(records) == 2 + assert records[0]["name"] == "John Smith" + assert records[1]["email"] == "jane@email.com" + + # JSON Parsing Tests + def test_json_parsing_array_format(self): + """Test JSON parsing with array format""" + json_data = json.dumps(self.test_json_data) + format_info = {"type": "json", "encoding": "utf-8"} + + records = parse_json_data(json_data, format_info) + + assert len(records) == 2 + assert records[0]["name"] == "John Smith" + assert records[1]["country"] == "CA" + + def test_json_parsing_newline_delimited(self): + """Test JSON parsing with newline-delimited format""" + json_data = '{"name": "John", "age": 35}\n{"name": "Jane", "age": 28}' + format_info = {"type": "json", "encoding": "utf-8", "options": {"newline_delimited": True}} + + records = parse_json_data(json_data, format_info) + + assert len(records) == 2 + assert records[0]["name"] == "John" + assert records[1]["age"] == 28 + + def test_json_parsing_malformed_data(self): + """Test JSON parsing with malformed data""" + json_data = '{"name": "John", "age": 35' # Missing closing brace + format_info = {"type": "json", "encoding": "utf-8"} + + with pytest.raises(json.JSONDecodeError): + parse_json_data(json_data, format_info) + + # XML Parsing Tests + def test_xml_parsing_xpath_expressions(self): + """Test XML parsing with XPath expressions""" + format_info = { + "type": "xml", + "encoding": "utf-8", + "options": { + "record_path": "/ROOT/data/record", + "field_attribute": "name" + } + } + + records = parse_xml_data(self.test_xml_data, format_info) + + assert len(records) == 2 + assert records[0]["name"] == "John Smith" + assert records[0]["email"] == "john@email.com" + assert records[1]["name"] == "Jane Doe" + + def test_xml_parsing_field_attributes(self): + """Test XML parsing with field attributes""" + xml_data = """ + + + +""" + + format_info = { + "type": "xml", + "encoding": "utf-8", + "options": {"record_path": "//item"} + } + + records = parse_xml_data(xml_data, format_info) + + assert len(records) == 2 + assert records[0]["id"] == "1" + assert records[0]["name"] == "Product A" + assert records[1]["price"] == "29.99" + + # Data Transformation Tests + def test_field_mappings_application(self): + """Test field mapping application""" + record = {"source_name": "John Smith", "source_email": "JOHN@EMAIL.COM"} + mappings = [ + {"source_field": "source_name", "target_field": "name", "transforms": []}, + {"source_field": "source_email", "target_field": "email", "transforms": []} + ] + + result = apply_transformations(record, mappings) + + assert result["name"] == "John Smith" + assert result["email"] == "JOHN@EMAIL.COM" + + def test_data_transforms_trim(self): + """Test trim transformation""" + record = {"name": " John Smith ", "email": "john@email.com"} + mappings = [ + {"source_field": "name", "target_field": "name", "transforms": [{"type": "trim"}]} + ] + + result = apply_transformations(record, mappings) + + assert result["name"] == "John Smith" + + def test_data_transforms_case_conversion(self): + """Test case conversion transformations""" + record = {"name": "John Smith", "email": "JOHN@EMAIL.COM"} + mappings = [ + {"source_field": "name", "target_field": "name", "transforms": [{"type": "upper"}]}, + {"source_field": "email", "target_field": "email", "transforms": [{"type": "lower"}]} + ] + + result = apply_transformations(record, mappings) + + assert result["name"] == "JOHN SMITH" + assert result["email"] == "john@email.com" + + def test_data_transforms_type_conversion(self): + """Test type conversion transformations""" + record = {"age": "35", "price": "19.99", "active": "true"} + mappings = [ + {"source_field": "age", "target_field": "age", "transforms": [{"type": "to_int"}]}, + {"source_field": "price", "target_field": "price", "transforms": [{"type": "to_float"}]}, + {"source_field": "active", "target_field": "active", "transforms": [{"type": "to_bool"}]} + ] + + result = apply_transformations(record, mappings) + + # All values should be converted to strings for ExtractedObject compatibility + assert result["age"] == "35" + assert result["price"] == "19.99" + assert result["active"] == "True" + + # Batching Functionality Tests + def test_batch_processing_single_object(self): + """Test batch processing with single object""" + records = [{"name": "John", "age": "35"}] + batch_size = 10 + schema_name = "customer" + + # Simulate batching logic + batches = [] + for i in range(0, len(records), batch_size): + batch_records = records[i:i + batch_size] + batch_values = [record for record in batch_records] + batches.append({ + "schema_name": schema_name, + "values": batch_values + }) + + assert len(batches) == 1 + assert len(batches[0]["values"]) == 1 + assert batches[0]["values"][0]["name"] == "John" + + def test_batch_processing_multiple_objects(self): + """Test batch processing with multiple objects""" + records = [{"name": f"User{i}", "age": str(20+i)} for i in range(5)] + batch_size = 2 + schema_name = "customer" + + # Simulate batching logic + batches = [] + for i in range(0, len(records), batch_size): + batch_records = records[i:i + batch_size] + batch_values = [record for record in batch_records] + batches.append({ + "schema_name": schema_name, + "values": batch_values + }) + + assert len(batches) == 3 # 5 records with batch_size=2 -> 3 batches + assert len(batches[0]["values"]) == 2 + assert len(batches[1]["values"]) == 2 + assert len(batches[2]["values"]) == 1 + assert batches[2]["values"][0]["name"] == "User4" + + def test_batch_size_configuration(self): + """Test batch size configuration""" + records = [{"id": str(i)} for i in range(10)] + + for batch_size in [1, 3, 5, 10, 15]: + batches = [] + for i in range(0, len(records), batch_size): + batch_records = records[i:i + batch_size] + batches.append(batch_records) + + expected_batches = (len(records) + batch_size - 1) // batch_size + assert len(batches) == expected_batches + + # Check all records are included + total_records = sum(len(batch) for batch in batches) + assert total_records == len(records) + + # Schema Suggestion Tests + @patch('trustgraph.cli.load_structured_data.TrustGraphAPI') + def test_suggest_schema_api_integration(self, mock_api_class): + """Test schema suggestion with API integration""" + # Setup mock API + mock_api = Mock() + mock_api_class.return_value = mock_api + mock_config_api = Mock() + mock_api.config.return_value = mock_config_api + mock_config_api.get_config_items.return_value = {"schema": {"customer": '{"name": "customer"}'}} + + mock_flow = Mock() + mock_api.flow.return_value = mock_flow + mock_flow.id.return_value = mock_flow + mock_prompt_client = Mock() + mock_flow.prompt.return_value = mock_prompt_client + mock_prompt_client.schema_selection.return_value = "customer schema looks best for this data" + + with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f: + f.write(self.test_csv_data) + f.flush() + + try: + # This should not raise an exception + result = load_structured_data( + api_url="http://localhost:8088", + input_file=f.name, + suggest_schema=True, + sample_size=100, + sample_chars=500 + ) + + # Verify API calls were made + mock_config_api.get_config_items.assert_called_once() + mock_prompt_client.schema_selection.assert_called_once() + + finally: + os.unlink(f.name) + + # Descriptor Generation Tests + @patch('trustgraph.cli.load_structured_data.TrustGraphAPI') + def test_generate_descriptor_csv_format(self, mock_api_class): + """Test descriptor generation for CSV format""" + # Setup mock API + mock_api = Mock() + mock_api_class.return_value = mock_api + mock_config_api = Mock() + mock_api.config.return_value = mock_config_api + mock_config_api.get_config_items.return_value = {"schema": {"customer": '{"name": "customer"}'}} + + mock_flow = Mock() + mock_api.flow.return_value = mock_flow + mock_flow.id.return_value = mock_flow + mock_prompt_client = Mock() + mock_flow.prompt.return_value = mock_prompt_client + mock_prompt_client.diagnose_structured_data.return_value = json.dumps(self.test_descriptor) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f: + f.write(self.test_csv_data) + f.flush() + + try: + result = load_structured_data( + api_url="http://localhost:8088", + input_file=f.name, + generate_descriptor=True, + sample_chars=500 + ) + + # Verify API calls were made + mock_prompt_client.diagnose_structured_data.assert_called_once() + + finally: + os.unlink(f.name) + + # Error Handling Tests + def test_file_not_found_error(self): + """Test handling of file not found error""" + with pytest.raises(FileNotFoundError): + load_structured_data( + api_url="http://localhost:8088", + input_file="/nonexistent/file.csv" + ) + + def test_invalid_descriptor_format(self): + """Test handling of invalid descriptor format""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as input_file: + input_file.write(self.test_csv_data) + input_file.flush() + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as desc_file: + desc_file.write('{"invalid": "descriptor"}') # Missing required fields + desc_file.flush() + + try: + with pytest.raises((KeyError, ValueError)): + load_structured_data( + api_url="http://localhost:8088", + input_file=input_file.name, + descriptor_file=desc_file.name + ) + finally: + os.unlink(input_file.name) + os.unlink(desc_file.name) + + def test_parsing_errors_handling(self): + """Test handling of parsing errors""" + invalid_csv = "name,email\n\"unclosed quote,test@email.com" + format_info = {"type": "csv", "encoding": "utf-8", "options": {"header": True}} + + # Should handle parsing errors gracefully + with pytest.raises(Exception): + parse_csv_data(invalid_csv, format_info) + + # Validation Tests + def test_validation_rules_required_fields(self): + """Test validation rules for required fields""" + record = {"name": "John", "email": ""} # Missing required email + mappings = [ + { + "source_field": "name", + "target_field": "name", + "transforms": [], + "validation": [{"type": "required"}] + }, + { + "source_field": "email", + "target_field": "email", + "transforms": [], + "validation": [{"type": "required"}] + } + ] + + result = apply_transformations(record, mappings) + + # Should still process but may log warnings + assert "name" in result + assert "email" in result \ No newline at end of file