import datetime import json from typing import Any import pytest from private_gpt.events.models import ( AudioBlock, BinaryBlock, ImageBlock, TextBlock, TLDRBlock, serialize_datetime, ) @pytest.mark.parametrize( ("input_data", "expected_serialized", "description"), [ # Direct instantiation - empty metadata excluded ( {"content": []}, {"type": "tldr", "tldr_side": "left", "content": []}, "Empty metadata should be excluded from serialization", ), # Direct instantiation - non-empty metadata as _meta ( {"metadata": {"pepe": "juan"}, "content": []}, { "type": "tldr", "tldr_side": "left", "_meta": {"pepe": "juan"}, "content": [], }, "Non-empty metadata should serialize as _meta field", ), # Direct instantiation - empty dict metadata excluded ( {"metadata": {}, "content": [TextBlock(text="test").model_dump()]}, { "type": "tldr", "tldr_side": "left", "content": [{"type": "text", "text": "test"}], }, "Empty metadata dict should be excluded", ), # Deserialization - from 'metadata' field ( {"type": "tldr", "metadata": {"pepe": "juan"}, "content": []}, { "type": "tldr", "tldr_side": "left", "_meta": {"pepe": "juan"}, "content": [], }, "Deserialize from 'metadata' field and serialize as '_meta'", ), # Deserialization - from '_meta' field ( {"type": "tldr", "_meta": {"pepe": "juan"}, "content": []}, { "type": "tldr", "tldr_side": "left", "_meta": {"pepe": "juan"}, "content": [], }, "Deserialize from '_meta' field and maintain as '_meta' with default tldr_side", ), # Complex metadata with multiple fields ( { "metadata": {"author": "test", "version": "1.0", "tags": ["ai", "ml"]}, "content": [ TextBlock(text="summary").model_dump(), TextBlock(text="details").model_dump(), ], }, { "type": "tldr", "tldr_side": "left", "_meta": {"author": "test", "version": "1.0", "tags": ["ai", "ml"]}, "content": [ {"type": "text", "text": "summary"}, {"type": "text", "text": "details"}, ], }, "Complex nested metadata should serialize correctly with tldr_side", ), # No metadata field at all - using proper TextBlock objects ( { "type": "tldr", "content": [ TextBlock(text="item1").model_dump(), TextBlock(text="item2").model_dump(), ], }, { "type": "tldr", "tldr_side": "left", "content": [ {"type": "text", "text": "item1"}, {"type": "text", "text": "item2"}, ], }, "No metadata field should result in clean serialization with default tldr_side", ), # Metadata with nested structures ( { "metadata": { "config": {"enabled": True, "settings": {"level": 1}}, "timestamps": ["2024-01-01", "2024-01-02"], }, "content": [], }, { "type": "tldr", "tldr_side": "left", "_meta": { "config": {"enabled": True, "settings": {"level": 1}}, "timestamps": ["2024-01-01", "2024-01-02"], }, "content": [], }, "Nested metadata structures should serialize correctly with tldr_side", ), ], ) def test_tldr_block_metadata_serialization_deserialization( input_data: dict[str, Any], expected_serialized: dict[str, Any], description: str ) -> None: # Test object creation and model_dump block = TLDRBlock(**input_data) dict_result = block.model_dump() assert dict_result == expected_serialized, f"model_dump failed: {description}" # Test round-trip: serialize then deserialize using dict roundtrip_block = TLDRBlock(**dict_result) roundtrip_result = roundtrip_block.model_dump() assert roundtrip_result == expected_serialized, ( f"Round-trip serialization failed: {description}" ) # Test metadata field presence/absence in serialized output for TLDRBlock level input_metadata = input_data.get("metadata") or input_data.get("_meta") has_metadata = input_metadata is not None and bool(input_metadata) assert ("_meta" in dict_result) == has_metadata, ( f"TLDRBlock metadata field presence assertion failed: {description}" ) # Test that original metadata field is excluded from serialization assert "metadata" not in dict_result, ( f"Original metadata field should be excluded: {description}" ) # Test string representations don't raise exceptions str_repr = str(block) repr_repr = repr(block) assert isinstance(str_repr, str), f"String representation failed: {description}" assert isinstance(repr_repr, str), f"Repr representation failed: {description}" assert "TLDRBlock" in repr_repr, f"Repr should contain class name: {description}" def test_image_block_source_base64_roundtrip() -> None: block = ImageBlock.model_validate( { "type": "image", "source": { "type": "base64", "data": "aGVsbG8=", "media_type": "image/png", }, } ) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped == { "type": "image", "source": { "type": "base64", "data": "aGVsbG8=", "media_type": "image/png", }, } assert block.source.data == "aGVsbG8=" assert block.source.media_type == "image/png" def test_audio_block_source_base64_roundtrip() -> None: block = AudioBlock.model_validate( { "type": "audio", "source": { "type": "base64", "data": "YXVkaW8=", "media_type": "audio/mpeg", }, } ) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped == { "type": "audio", "source": { "type": "base64", "data": "YXVkaW8=", "media_type": "audio/mpeg", }, } assert block.source.data == "YXVkaW8=" assert block.source.media_type == "audio/mpeg" def test_binary_block_source_base64_roundtrip() -> None: block = BinaryBlock.model_validate( { "type": "binary", "filename": "document.pdf", "source": { "type": "base64", "data": "YmluYXJ5", "media_type": "application/pdf", }, } ) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped == { "type": "binary", "filename": "document.pdf", "source": { "type": "base64", "data": "YmluYXJ5", "media_type": "application/pdf", }, } assert block.source.type == "base64" assert block.source.data == "YmluYXJ5" assert block.source.media_type == "application/pdf" def test_image_block_legacy_payload_is_still_accepted() -> None: block = ImageBlock.model_validate( {"type": "image", "data": "aGVsbG8=", "mime_type": "image/png"} ) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped["source"]["type"] == "base64" def test_audio_block_legacy_payload_is_still_accepted() -> None: block = AudioBlock.model_validate( {"type": "audio", "data": "YXVkaW8=", "mime_type": "audio/mpeg"} ) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped["source"]["type"] == "base64" def test_binary_block_legacy_payload_is_still_accepted() -> None: block = BinaryBlock.model_validate( { "type": "binary", "data": "YmluYXJ5", "mime_type": "application/pdf", } ) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped["source"]["type"] == "base64" def test_image_block_legacy_url_is_still_accepted() -> None: block = ImageBlock.model_validate({"type": "image", "url": "s3://bucket/image.png"}) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped["source"] == {"type": "url", "url": "s3://bucket/image.png"} def test_audio_block_legacy_url_is_still_accepted() -> None: block = AudioBlock.model_validate({"type": "audio", "url": "s3://bucket/audio.mp3"}) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped["source"] == {"type": "url", "url": "s3://bucket/audio.mp3"} def test_binary_block_legacy_url_is_still_accepted() -> None: block = BinaryBlock.model_validate( {"type": "binary", "url": "s3://bucket/file.pdf"} ) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped["source"] == {"type": "url", "url": "s3://bucket/file.pdf"} @pytest.mark.parametrize( "url", [ "https://example.com/i.png", "http://example.com/i.png", "s3://bucket/image.png", "ftp://example.com/i.png", "custom+v1://bucket/image.png", ], ) def test_image_block_source_url_roundtrip(url: str) -> None: block = ImageBlock.model_validate( {"type": "image", "source": {"type": "url", "url": url}} ) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped == { "type": "image", "source": {"type": "url", "url": url}, } assert block.source.url == url @pytest.mark.parametrize( "url", [ "https://example.com/a.mp3", "http://example.com/a.mp3", "s3://bucket/audio.mp3", "ftp://example.com/a.mp3", "custom+v1://bucket/audio.mp3", ], ) def test_audio_block_source_url_roundtrip(url: str) -> None: block = AudioBlock.model_validate( {"type": "audio", "source": {"type": "url", "url": url}} ) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped == { "type": "audio", "source": {"type": "url", "url": url}, } assert block.source.url == url @pytest.mark.parametrize( "url", [ "https://example.com/f.pdf", "http://example.com/f.pdf", "s3://bucket/file.pdf", "ftp://example.com/f.pdf", "custom+v1://bucket/file.pdf", ], ) def test_binary_block_source_uri_roundtrip(url: str) -> None: block = BinaryBlock.model_validate( {"type": "binary", "source": {"type": "url", "url": url}} ) dumped = block.model_dump(by_alias=True, exclude_none=True) assert dumped == { "type": "binary", "source": {"type": "url", "url": url}, } assert block.source.type == "url" assert block.source.url == url @pytest.mark.parametrize( ("input_data", "expected_serialized", "description"), [ # No timestamps - should be excluded from serialization ( {"type": "text", "text": "sample"}, {"type": "text", "text": "sample"}, "None timestamps should be excluded from serialization", ), # Only start_timestamp set ( { "type": "text", "text": "sample", "start_timestamp": datetime.datetime(2024, 1, 15, 10, 30, 0), }, { "type": "text", "text": "sample", "start_timestamp": "2024-01-15T10:30:00Z", }, "Only start_timestamp should serialize to ISO format", ), # Only stop_timestamp set ( { "type": "text", "text": "sample", "stop_timestamp": datetime.datetime(2024, 1, 15, 11, 45, 30), }, { "type": "text", "text": "sample", "stop_timestamp": "2024-01-15T11:45:30Z", }, "Only stop_timestamp should serialize to ISO format", ), # Both timestamps set ( { "type": "text", "text": "sample", "start_timestamp": datetime.datetime(2024, 1, 15, 10, 30, 0), "stop_timestamp": datetime.datetime(2024, 1, 15, 11, 45, 30), }, { "type": "text", "text": "sample", "start_timestamp": "2024-01-15T10:30:00Z", "stop_timestamp": "2024-01-15T11:45:30Z", }, "Both timestamps should serialize to ISO format", ), # Timestamps with microseconds ( { "type": "text", "text": "sample", "start_timestamp": datetime.datetime(2024, 1, 15, 10, 30, 0, 123456), "stop_timestamp": datetime.datetime(2024, 1, 15, 11, 45, 30, 987654), }, { "type": "text", "text": "sample", "start_timestamp": "2024-01-15T10:30:00.123456Z", "stop_timestamp": "2024-01-15T11:45:30.987654Z", }, "Timestamps with microseconds should serialize correctly", ), # Deserialization from ISO strings ( { "type": "text", "text": "sample", "start_timestamp": "2024-01-15T10:30:00", "stop_timestamp": "2024-01-15T11:45:30", }, { "type": "text", "text": "sample", "start_timestamp": "2024-01-15T10:30:00Z", "stop_timestamp": "2024-01-15T11:45:30Z", }, "Deserialization from ISO strings should work correctly", ), # Mixed explicit None values (should be excluded) ( { "type": "text", "text": "sample", "start_timestamp": datetime.datetime(2024, 1, 15, 10, 30, 0), "stop_timestamp": None, }, { "type": "text", "text": "sample", "start_timestamp": "2024-01-15T10:30:00Z", }, "Explicit None timestamp should be excluded from serialization", ), # Timezone-aware datetimes (converted to UTC) ( { "type": "text", "text": "sample", "start_timestamp": datetime.datetime( 2024, 1, 15, 10, 30, 0, tzinfo=datetime.UTC ), "stop_timestamp": datetime.datetime( 2024, 1, 15, 11, 45, 30, tzinfo=datetime.timezone(datetime.timedelta(hours=5)), ), }, { "type": "text", "text": "sample", "start_timestamp": "2024-01-15T10:30:00Z", "stop_timestamp": "2024-01-15T06:45:30Z", # Converted from UTC+5 to UTC }, "Timezone-aware datetimes should be converted to UTC", ), # With metadata and timestamps ( { "type": "text", "text": "sample", "metadata": {"source": "test"}, "start_timestamp": datetime.datetime(2024, 1, 15, 10, 30, 0), "stop_timestamp": datetime.datetime(2024, 1, 15, 11, 45, 30), }, { "type": "text", "text": "sample", "_meta": {"source": "test"}, "start_timestamp": "2024-01-15T10:30:00Z", "stop_timestamp": "2024-01-15T11:45:30Z", }, "Timestamps with metadata should serialize correctly", ), # Test different timezone offsets to ensure UTC conversion ( { "type": "text", "text": "sample", "start_timestamp": datetime.datetime( 2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=-5)), # EST ), "stop_timestamp": datetime.datetime( 2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=9)), # JST ), }, { "type": "text", "text": "sample", "start_timestamp": "2024-01-15T15:30:00Z", # EST to UTC "stop_timestamp": "2024-01-15T01:30:00Z", # JST to UTC }, "Various timezone offsets should convert to UTC correctly", ), ], ) def test_datetime_serialization_deserialization( input_data: dict[str, Any], expected_serialized: dict[str, Any], description: str ) -> None: """Test datetime field serialization and deserialization for TextBlock.""" # Test object creation and model_dump block = TextBlock(**input_data) dict_result = json.loads(block.model_dump_json()) assert dict_result == expected_serialized, f"model_dump failed: {description}" # Test round-trip: serialize then deserialize using dict roundtrip_block = TextBlock(**dict_result) roundtrip_result = json.loads(roundtrip_block.model_dump_json()) assert roundtrip_result == expected_serialized, ( f"Round-trip serialization failed: {description}" ) # Test timestamp field presence/absence in serialized output input_start = input_data.get("start_timestamp") input_stop = input_data.get("stop_timestamp") has_start = input_start is not None has_stop = input_stop is not None assert ("start_timestamp" in dict_result) == has_start, ( f"start_timestamp field presence assertion failed: {description}" ) assert ("stop_timestamp" in dict_result) == has_stop, ( f"stop_timestamp field presence assertion failed: {description}" ) # Test that datetime objects are properly converted to strings in serialization if has_start and isinstance(input_start, datetime.datetime): assert isinstance(dict_result["start_timestamp"], str), ( f"start_timestamp should be serialized as string: {description}" ) if has_stop and isinstance(input_stop, datetime.datetime): assert isinstance(dict_result["stop_timestamp"], str), ( f"stop_timestamp should be serialized as string: {description}" ) # Test that deserialized objects have proper datetime types if has_start: assert isinstance(block.start_timestamp, datetime.datetime), ( f"Deserialized start_timestamp should be datetime object: {description}" ) if has_stop: assert isinstance(block.stop_timestamp, datetime.datetime), ( f"Deserialized stop_timestamp should be datetime object: {description}" ) # Test string representations don't raise exceptions str_repr = str(block) repr_repr = repr(block) assert isinstance(str_repr, str), f"String representation failed: {description}" assert isinstance(repr_repr, str), f"Repr representation failed: {description}" assert "TextBlock" in repr_repr, f"Repr should contain class name: {description}" @pytest.mark.parametrize( ("timezone_offset", "expected_result"), [ (0, "2024-01-15T10:30:00Z"), # UTC (-5, "2024-01-15T10:30:00Z"), # EST (naive treated as UTC) (1, "2024-01-15T10:30:00Z"), # CET (naive treated as UTC) (9, "2024-01-15T10:30:00Z"), # JST (naive treated as UTC) ], ) def test_environment_independence(timezone_offset: int, expected_result: str) -> None: naive_dt = datetime.datetime(2024, 1, 15, 10, 30, 0) result = serialize_datetime(naive_dt) assert result == expected_result, ( f"Environment independence failed for UTC{timezone_offset:+d}" ) @pytest.mark.parametrize( ("input_dt", "expected_result", "description"), [ ( datetime.datetime(2024, 1, 15, 10, 30, 0, tzinfo=datetime.UTC), "2024-01-15T10:30:00Z", "UTC timezone should remain UTC", ), ( datetime.datetime( 2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=5)), ), "2024-01-15T05:30:00Z", "UTC+5 should convert to UTC", ), ( datetime.datetime( 2024, 1, 15, 10, 30, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=-8)), ), "2024-01-15T18:30:00Z", "UTC-8 should convert to UTC", ), ( datetime.datetime( 2024, 1, 15, 15, 45, 30, tzinfo=datetime.timezone(datetime.timedelta(hours=2)), ), "2024-01-15T13:45:30Z", "UTC+2 should convert to UTC", ), ( datetime.datetime( 2024, 1, 15, 5, 15, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=-10)), ), "2024-01-15T15:15:00Z", "UTC-10 should convert to UTC", ), ], ) def test_timezone_aware_conversion( input_dt: datetime.datetime, expected_result: str, description: str ) -> None: """Test that timezone-aware datetimes are properly converted to UTC.""" result = serialize_datetime(input_dt) assert result == expected_result, f"Timezone conversion failed: {description}"