"""Tests for FirestoreService.""" from datetime import UTC, datetime import pytest from inline_snapshot import snapshot from capa_de_integracion.models import ConversationEntry, ConversationSession from capa_de_integracion.models.notification import Notification from capa_de_integracion.services.storage import FirestoreService @pytest.mark.vcr class TestSessionManagement: """Tests for conversation session management.""" async def test_create_session(self, clean_firestore: FirestoreService) -> None: """Test creating a new conversation session.""" session = await clean_firestore.create_session( session_id="test-session-1", user_id="user-123", telefono="+1234567890", pantalla_contexto="home_screen", last_message="Hello", ) assert session.session_id == "test-session-1" assert session.user_id == "user-123" assert session.telefono == "+1234567890" assert session.pantalla_contexto == "home_screen" assert session.last_message == "Hello" assert isinstance(session.last_modified, datetime) async def test_get_session_existing(self, clean_firestore: FirestoreService) -> None: """Test retrieving an existing session.""" # Create a session first created_session = await clean_firestore.create_session( session_id="test-session-2", user_id="user-456", telefono="+9876543210", ) # Retrieve it retrieved_session = await clean_firestore.get_session("test-session-2") assert retrieved_session is not None assert retrieved_session.session_id == created_session.session_id assert retrieved_session.user_id == created_session.user_id assert retrieved_session.telefono == created_session.telefono async def test_get_session_not_found(self, clean_firestore: FirestoreService) -> None: """Test retrieving a non-existent session returns None.""" session = await clean_firestore.get_session("nonexistent-session") assert session is None @pytest.mark.skip(reason="Requires composite index in Firestore emulator. See firestore.indexes.json") async def test_get_session_by_phone_existing( self, clean_firestore: FirestoreService, ) -> None: """Test retrieving session by phone number. Note: This test requires a composite index (telefono + lastModified) which must be configured in the Firestore emulator. To enable this test, restart the emulator with: firebase emulators:start --only firestore --import=./ """ phone = "+1111111111" # Create multiple sessions for same phone await clean_firestore.create_session( session_id="session-1", user_id="user-1", telefono=phone, ) # Wait a bit to ensure different timestamps import asyncio await asyncio.sleep(0.1) session_2 = await clean_firestore.create_session( session_id="session-2", user_id="user-1", telefono=phone, ) # Should retrieve the most recent one retrieved = await clean_firestore.get_session_by_phone(phone) assert retrieved is not None assert retrieved.session_id == session_2.session_id async def test_get_session_by_phone_not_found( self, clean_firestore: FirestoreService, ) -> None: """Test retrieving session by phone when none exists.""" session = await clean_firestore.get_session_by_phone("+9999999999") assert session is None async def test_get_session_by_phone_found( self, clean_firestore: FirestoreService, ) -> None: """Test retrieving session by phone when it exists (mocked).""" from unittest.mock import MagicMock phone = "+1111111111" expected_session = ConversationSession.create( session_id="session-1", user_id="user-1", telefono=phone, ) # Mock the query to return a session mock_doc = MagicMock() mock_doc.to_dict.return_value = expected_session.model_dump() async def mock_stream(): yield mock_doc mock_query = MagicMock() mock_query.stream.return_value = mock_stream() mock_collection = MagicMock() mock_where = MagicMock() mock_order = MagicMock() mock_collection.where.return_value = mock_where mock_where.order_by.return_value = mock_order mock_order.limit.return_value = mock_query original_collection = clean_firestore.db.collection clean_firestore.db.collection = MagicMock(return_value=mock_collection) try: result = await clean_firestore.get_session_by_phone(phone) assert result is not None assert result.session_id == "session-1" assert result.telefono == phone finally: clean_firestore.db.collection = original_collection async def test_save_session(self, clean_firestore: FirestoreService) -> None: """Test saving an updated session.""" # Create initial session session = await clean_firestore.create_session( session_id="test-session-3", user_id="user-789", telefono="+5555555555", ) # Update session session.last_message = "Updated message" session.pantalla_contexto = "new_screen" # Save updated session success = await clean_firestore.save_session(session) assert success is True # Retrieve and verify retrieved = await clean_firestore.get_session("test-session-3") assert retrieved is not None assert retrieved.last_message == "Updated message" assert retrieved.pantalla_contexto == "new_screen" async def test_update_pantalla_contexto( self, clean_firestore: FirestoreService, ) -> None: """Test updating pantalla_contexto field.""" # Create session await clean_firestore.create_session( session_id="test-session-4", user_id="user-101", telefono="+2222222222", pantalla_contexto="initial_screen", ) # Update pantalla_contexto success = await clean_firestore.update_pantalla_contexto( "test-session-4", "updated_screen", ) assert success is True # Verify update session = await clean_firestore.get_session("test-session-4") assert session is not None assert session.pantalla_contexto == "updated_screen" async def test_update_pantalla_contexto_nonexistent_session( self, clean_firestore: FirestoreService, ) -> None: """Test updating pantalla_contexto for non-existent session.""" success = await clean_firestore.update_pantalla_contexto( "nonexistent-session", "some_screen", ) assert success is False async def test_delete_session(self, clean_firestore: FirestoreService) -> None: """Test deleting a session.""" # Create session await clean_firestore.create_session( session_id="test-session-5", user_id="user-202", telefono="+3333333333", ) # Delete session success = await clean_firestore.delete_session("test-session-5") assert success is True # Verify deletion session = await clean_firestore.get_session("test-session-5") assert session is None @pytest.mark.vcr class TestEntryManagement: """Tests for conversation entry management.""" async def test_save_and_get_entries(self, clean_firestore: FirestoreService) -> None: """Test saving and retrieving conversation entries.""" # Create session await clean_firestore.create_session( session_id="test-session-6", user_id="user-303", telefono="+4444444444", ) # Create entries entry1 = ConversationEntry( timestamp=datetime.now(UTC), entity="user", type="CONVERSACION", text="First message", ) entry2 = ConversationEntry( timestamp=datetime.now(UTC), entity="assistant", type="CONVERSACION", text="First response", ) # Save entries success1 = await clean_firestore.save_entry("test-session-6", entry1) success2 = await clean_firestore.save_entry("test-session-6", entry2) assert success1 is True assert success2 is True # Retrieve entries entries = await clean_firestore.get_entries("test-session-6") assert len(entries) == 2 assert entries[0].entity == "user" assert entries[0].text == "First message" assert entries[1].entity == "assistant" assert entries[1].text == "First response" async def test_get_entries_with_limit(self, clean_firestore: FirestoreService) -> None: """Test retrieving entries with limit.""" # Create session await clean_firestore.create_session( session_id="test-session-7", user_id="user-404", telefono="+5555555555", ) # Create multiple entries for i in range(5): entry = ConversationEntry( timestamp=datetime.now(UTC), entity="user" if i % 2 == 0 else "assistant", type="CONVERSACION", text=f"Message {i}", ) await clean_firestore.save_entry("test-session-7", entry) # Retrieve with limit entries = await clean_firestore.get_entries("test-session-7", limit=3) assert len(entries) == 3 # Should get the most recent 3 in chronological order assert entries[-1].text == "Message 4" async def test_get_entries_empty_session( self, clean_firestore: FirestoreService, ) -> None: """Test retrieving entries from session with no entries.""" # Create session without entries await clean_firestore.create_session( session_id="test-session-8", user_id="user-505", telefono="+6666666666", ) entries = await clean_firestore.get_entries("test-session-8") assert entries == [] async def test_delete_session_with_entries( self, clean_firestore: FirestoreService, ) -> None: """Test deleting session also deletes all entries.""" # Create session with entries await clean_firestore.create_session( session_id="test-session-9", user_id="user-606", telefono="+7777777777", ) entry = ConversationEntry( timestamp=datetime.now(UTC), entity="user", type="CONVERSACION", text="Test message", ) await clean_firestore.save_entry("test-session-9", entry) # Delete session success = await clean_firestore.delete_session("test-session-9") assert success is True # Verify entries are also deleted entries = await clean_firestore.get_entries("test-session-9") assert entries == [] @pytest.mark.vcr class TestNotificationManagement: """Tests for notification management.""" async def test_save_new_notification(self, clean_firestore: FirestoreService) -> None: """Test saving a new notification creates new session.""" notification = Notification.create( id_notificacion="notif-1", telefono="+8888888888", texto="Test notification", ) await clean_firestore.save_or_append_notification(notification) # Verify notification was saved doc_ref = clean_firestore._notification_ref("+8888888888") doc = await doc_ref.get() assert doc.exists data = doc.to_dict() assert data is not None assert data["telefono"] == "+8888888888" assert data["session_id"] == "+8888888888" assert len(data["notificaciones"]) == 1 assert data["notificaciones"][0]["texto"] == "Test notification" async def test_append_to_existing_notification_session( self, clean_firestore: FirestoreService, ) -> None: """Test appending notification to existing session.""" phone = "+9999999999" # Create first notification notification1 = Notification.create( id_notificacion="notif-2", telefono=phone, texto="First notification", ) await clean_firestore.save_or_append_notification(notification1) # Append second notification notification2 = Notification.create( id_notificacion="notif-3", telefono=phone, texto="Second notification", ) await clean_firestore.save_or_append_notification(notification2) # Verify both notifications exist doc_ref = clean_firestore._notification_ref(phone) doc = await doc_ref.get() data = doc.to_dict() assert data is not None assert len(data["notificaciones"]) == 2 assert data["notificaciones"][0]["texto"] == "First notification" assert data["notificaciones"][1]["texto"] == "Second notification" async def test_save_notification_without_phone_raises_error( self, clean_firestore: FirestoreService, ) -> None: """Test saving notification without phone number raises ValueError.""" notification = Notification.create( id_notificacion="notif-4", telefono="", texto="Test", ) with pytest.raises(ValueError, match="Phone number is required"): await clean_firestore.save_or_append_notification(notification) async def test_update_notification_status( self, clean_firestore: FirestoreService, ) -> None: """Test updating status of all notifications in session.""" phone = "+1010101010" # Create notifications notification1 = Notification.create( id_notificacion="notif-5", telefono=phone, texto="Notification 1", status="pending", ) notification2 = Notification.create( id_notificacion="notif-6", telefono=phone, texto="Notification 2", status="pending", ) await clean_firestore.save_or_append_notification(notification1) await clean_firestore.save_or_append_notification(notification2) # Update status await clean_firestore.update_notification_status(phone, "sent") # Verify status update doc_ref = clean_firestore._notification_ref(phone) doc = await doc_ref.get() data = doc.to_dict() assert data is not None assert all(notif["status"] == "sent" for notif in data["notificaciones"]) async def test_update_notification_status_nonexistent_session( self, clean_firestore: FirestoreService, ) -> None: """Test updating status for non-existent session logs warning.""" # Should not raise exception, just log warning await clean_firestore.update_notification_status("+0000000000", "sent") async def test_delete_notification(self, clean_firestore: FirestoreService) -> None: """Test deleting notification session.""" phone = "+1212121212" # Create notification notification = Notification.create( id_notificacion="notif-7", telefono=phone, texto="Test", ) await clean_firestore.save_or_append_notification(notification) # Delete notification session success = await clean_firestore.delete_notification(phone) assert success is True # Verify deletion doc_ref = clean_firestore._notification_ref(phone) doc = await doc_ref.get() assert not doc.exists @pytest.mark.vcr class TestEdgeCases: """Tests for edge cases and error handling.""" async def test_concurrent_session_updates( self, clean_firestore: FirestoreService, ) -> None: """Test concurrent updates to same session.""" import asyncio # Create session session = await clean_firestore.create_session( session_id="test-concurrent", user_id="user-999", telefono="+1313131313", ) # Update session fields concurrently session.last_message = "Message 1" task1 = clean_firestore.save_session(session) session.last_message = "Message 2" task2 = clean_firestore.save_session(session) results = await asyncio.gather(task1, task2) assert all(results) # Verify final state final_session = await clean_firestore.get_session("test-concurrent") assert final_session is not None # Last write wins assert final_session.last_message in ["Message 1", "Message 2"] async def test_special_characters_in_data( self, clean_firestore: FirestoreService, ) -> None: """Test handling special characters in session data.""" session = await clean_firestore.create_session( session_id="test-special-chars", user_id="user-special", telefono="+1414141414", pantalla_contexto="screen/with/slashes", last_message="Message with emoji 🎉 and special chars: <>&\"'", ) # Retrieve and verify retrieved = await clean_firestore.get_session("test-special-chars") assert retrieved is not None assert retrieved.pantalla_contexto == "screen/with/slashes" assert "🎉" in retrieved.last_message assert "<>&\"'" in retrieved.last_message @pytest.mark.vcr class TestErrorHandling: """Tests for error handling in Firestore operations.""" async def test_get_session_with_db_error( self, clean_firestore: FirestoreService, ) -> None: """Test get_session handles database errors gracefully.""" from unittest.mock import AsyncMock, MagicMock mock_doc_ref = AsyncMock() mock_doc_ref.get.side_effect = Exception("Database error") original_session_ref = clean_firestore._session_ref clean_firestore._session_ref = MagicMock(return_value=mock_doc_ref) try: result = await clean_firestore.get_session("error-session") assert result is None finally: clean_firestore._session_ref = original_session_ref async def test_get_session_by_phone_with_db_error( self, clean_firestore: FirestoreService, ) -> None: """Test get_session_by_phone handles database errors gracefully.""" from unittest.mock import MagicMock mock_collection = MagicMock() mock_collection.where.side_effect = Exception("Database error") original_collection = clean_firestore.db.collection clean_firestore.db.collection = MagicMock(return_value=mock_collection) try: result = await clean_firestore.get_session_by_phone("+0000000000") assert result is None finally: clean_firestore.db.collection = original_collection async def test_save_session_with_db_error( self, clean_firestore: FirestoreService, ) -> None: """Test save_session handles database errors gracefully.""" from unittest.mock import AsyncMock, MagicMock session = ConversationSession.create( session_id="error-session", user_id="user-error", telefono="+0000000000", ) mock_doc_ref = AsyncMock() mock_doc_ref.set.side_effect = Exception("Database error") original_session_ref = clean_firestore._session_ref clean_firestore._session_ref = MagicMock(return_value=mock_doc_ref) try: result = await clean_firestore.save_session(session) assert result is False finally: clean_firestore._session_ref = original_session_ref async def test_save_entry_with_db_error( self, clean_firestore: FirestoreService, ) -> None: """Test save_entry handles database errors gracefully.""" from unittest.mock import AsyncMock, MagicMock entry = ConversationEntry( timestamp=datetime.now(UTC), entity="user", type="CONVERSACION", text="Test", ) mock_entry_doc = AsyncMock() mock_entry_doc.set.side_effect = Exception("Database error") mock_collection = MagicMock() mock_collection.document.return_value = mock_entry_doc mock_doc_ref = MagicMock() mock_doc_ref.collection.return_value = mock_collection original_session_ref = clean_firestore._session_ref clean_firestore._session_ref = MagicMock(return_value=mock_doc_ref) try: result = await clean_firestore.save_entry("error-session", entry) assert result is False finally: clean_firestore._session_ref = original_session_ref async def test_get_entries_with_db_error( self, clean_firestore: FirestoreService, ) -> None: """Test get_entries handles database errors gracefully.""" from unittest.mock import MagicMock mock_collection = MagicMock() mock_collection.order_by.side_effect = Exception("Database error") mock_doc_ref = MagicMock() mock_doc_ref.collection.return_value = mock_collection original_session_ref = clean_firestore._session_ref clean_firestore._session_ref = MagicMock(return_value=mock_doc_ref) try: result = await clean_firestore.get_entries("error-session") assert result == [] finally: clean_firestore._session_ref = original_session_ref async def test_delete_session_with_db_error( self, clean_firestore: FirestoreService, ) -> None: """Test delete_session handles database errors gracefully.""" from unittest.mock import AsyncMock, MagicMock mock_collection = MagicMock() async def mock_stream(): mock_entry = MagicMock() mock_reference = MagicMock() mock_reference.delete = AsyncMock() mock_entry.reference = mock_reference yield mock_entry mock_collection.stream.return_value = mock_stream() mock_doc_ref = MagicMock() mock_doc_ref.collection.return_value = mock_collection mock_doc_ref.delete = AsyncMock(side_effect=Exception("Database error")) original_session_ref = clean_firestore._session_ref clean_firestore._session_ref = MagicMock(return_value=mock_doc_ref) try: result = await clean_firestore.delete_session("error-session") assert result is False finally: clean_firestore._session_ref = original_session_ref async def test_update_pantalla_contexto_with_db_error( self, clean_firestore: FirestoreService, ) -> None: """Test update_pantalla_contexto handles database errors gracefully.""" from unittest.mock import AsyncMock, MagicMock mock_doc = MagicMock() mock_doc.exists = True mock_doc_ref = AsyncMock() async def mock_get(): return mock_doc mock_doc_ref.get = mock_get mock_doc_ref.update.side_effect = Exception("Database error") original_session_ref = clean_firestore._session_ref clean_firestore._session_ref = MagicMock(return_value=mock_doc_ref) try: result = await clean_firestore.update_pantalla_contexto("error-session", "screen") assert result is False finally: clean_firestore._session_ref = original_session_ref async def test_save_or_append_notification_with_db_error( self, clean_firestore: FirestoreService, ) -> None: """Test save_or_append_notification handles database errors gracefully.""" from unittest.mock import AsyncMock, MagicMock notification = Notification.create( id_notificacion="notif-error", telefono="+0000000000", texto="Test", ) mock_doc_ref = AsyncMock() mock_doc_ref.get.side_effect = Exception("Database error") original_notification_ref = clean_firestore._notification_ref clean_firestore._notification_ref = MagicMock(return_value=mock_doc_ref) try: with pytest.raises(Exception): await clean_firestore.save_or_append_notification(notification) finally: clean_firestore._notification_ref = original_notification_ref async def test_update_notification_status_with_empty_data( self, clean_firestore: FirestoreService, ) -> None: """Test update_notification_status handles empty session data.""" from unittest.mock import AsyncMock, MagicMock mock_doc_ref = AsyncMock() mock_doc = MagicMock() mock_doc.exists = True mock_doc.to_dict.return_value = None async def mock_get(): return mock_doc mock_doc_ref.get = mock_get original_notification_ref = clean_firestore._notification_ref clean_firestore._notification_ref = MagicMock(return_value=mock_doc_ref) try: # Should not raise, just log warning await clean_firestore.update_notification_status("+0000000000", "sent") finally: clean_firestore._notification_ref = original_notification_ref async def test_update_notification_status_with_db_error( self, clean_firestore: FirestoreService, ) -> None: """Test update_notification_status handles database errors gracefully.""" from unittest.mock import AsyncMock, MagicMock mock_doc = MagicMock() mock_doc.exists = True mock_doc.to_dict.return_value = {"notificaciones": [{"status": "pending"}]} mock_doc_ref = AsyncMock() async def mock_get(): return mock_doc mock_doc_ref.get = mock_get mock_doc_ref.update.side_effect = Exception("Database error") original_notification_ref = clean_firestore._notification_ref clean_firestore._notification_ref = MagicMock(return_value=mock_doc_ref) try: with pytest.raises(Exception): await clean_firestore.update_notification_status("+0000000000", "sent") finally: clean_firestore._notification_ref = original_notification_ref async def test_delete_notification_with_db_error( self, clean_firestore: FirestoreService, ) -> None: """Test delete_notification handles database errors gracefully.""" from unittest.mock import AsyncMock, MagicMock mock_doc_ref = AsyncMock() mock_doc_ref.delete.side_effect = Exception("Database error") original_notification_ref = clean_firestore._notification_ref clean_firestore._notification_ref = MagicMock(return_value=mock_doc_ref) try: result = await clean_firestore.delete_notification("+0000000000") assert result is False finally: clean_firestore._notification_ref = original_notification_ref