diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 61c431f..226d790 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] os: [ubuntu-latest, windows-latest] permissions: diff --git a/pyproject.toml b/pyproject.toml index 9451963..b64992e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aptabase" -version = "0.0.3" +version = "0.0.4" description = "Python SDK for Aptabase analytics" readme = "README.md" requires-python = ">=3.11" diff --git a/samples/textual-counter/pyproject.toml b/samples/textual-counter/pyproject.toml index 9d33eff..baaf6fa 100644 --- a/samples/textual-counter/pyproject.toml +++ b/samples/textual-counter/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "textual>=6.7.1", - "aptabase>=0.0.2", + "aptabase>=0.0.3", ] license = {text = "MIT"} keywords = ["textual", "tui", "analytics", "aptabase", "counter"] diff --git a/samples/textual-counter/uv.lock b/samples/textual-counter/uv.lock index 72f85cb..ddad958 100644 --- a/samples/textual-counter/uv.lock +++ b/samples/textual-counter/uv.lock @@ -17,14 +17,14 @@ wheels = [ [[package]] name = "aptabase" -version = "0.0.2" +version = "0.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/70/ea89c8b329ed0260837bee5b45595bf016e3b5b5ca6f3d19b1623c669a42/aptabase-0.0.2.tar.gz", hash = "sha256:9c1a9cfae29efd41604d39ff038e615d9c23407a0ea1daada1d25fe2f4d18d31", size = 58780, upload-time = "2025-12-12T15:18:53.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/c7/4cd7f130307eaa4b7b66be483d7dde72138330d606c749419bebc2440ffb/aptabase-0.0.3.tar.gz", hash = "sha256:ab13b1b97afdde37d86208676d1ce687f13ebfa3bc2749e88d305b344e1c6532", size = 137390, upload-time = "2025-12-12T15:48:45.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/a8/6c74223286cb22e43dfd2fe18e415fbb554c89041cb648612c41e5238934/aptabase-0.0.2-py3-none-any.whl", hash = "sha256:5142cc44d9a7009d9a740cf00e31bef4ae3197b5292114998737b91493a1bdfd", size = 7174, upload-time = "2025-12-12T15:18:51.993Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e5/06160c68d685848a3a7f4981c5118aff03b49256c5765a2249c986750501/aptabase-0.0.3-py3-none-any.whl", hash = "sha256:2847a6a67bfef9985ce939cad35e40ba7155762322d70c25ac9ab4594c46c702", size = 7475, upload-time = "2025-12-12T15:48:43.931Z" }, ] [[package]] @@ -191,7 +191,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "aptabase", specifier = ">=0.0.2" }, + { name = "aptabase", specifier = ">=0.0.3" }, { name = "textual", specifier = ">=6.7.1" }, ] diff --git a/samples/textual-dashboard/pyproject.toml b/samples/textual-dashboard/pyproject.toml index 57f5627..f4713ad 100644 --- a/samples/textual-dashboard/pyproject.toml +++ b/samples/textual-dashboard/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "textual>=6.7.1", - "aptabase>=0.0.2", + "aptabase>=0.0.3", ] license = {text = "MIT"} keywords = ["textual", "tui", "analytics", "aptabase", "dashboard"] diff --git a/samples/textual-dashboard/uv.lock b/samples/textual-dashboard/uv.lock index ffb4915..7894a20 100644 --- a/samples/textual-dashboard/uv.lock +++ b/samples/textual-dashboard/uv.lock @@ -154,14 +154,14 @@ wheels = [ [[package]] name = "aptabase" -version = "0.0.2" +version = "0.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/70/ea89c8b329ed0260837bee5b45595bf016e3b5b5ca6f3d19b1623c669a42/aptabase-0.0.2.tar.gz", hash = "sha256:9c1a9cfae29efd41604d39ff038e615d9c23407a0ea1daada1d25fe2f4d18d31", size = 58780, upload-time = "2025-12-12T15:18:53.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/c7/4cd7f130307eaa4b7b66be483d7dde72138330d606c749419bebc2440ffb/aptabase-0.0.3.tar.gz", hash = "sha256:ab13b1b97afdde37d86208676d1ce687f13ebfa3bc2749e88d305b344e1c6532", size = 137390, upload-time = "2025-12-12T15:48:45.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/a8/6c74223286cb22e43dfd2fe18e415fbb554c89041cb648612c41e5238934/aptabase-0.0.2-py3-none-any.whl", hash = "sha256:5142cc44d9a7009d9a740cf00e31bef4ae3197b5292114998737b91493a1bdfd", size = 7174, upload-time = "2025-12-12T15:18:51.993Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e5/06160c68d685848a3a7f4981c5118aff03b49256c5765a2249c986750501/aptabase-0.0.3-py3-none-any.whl", hash = "sha256:2847a6a67bfef9985ce939cad35e40ba7155762322d70c25ac9ab4594c46c702", size = 7475, upload-time = "2025-12-12T15:48:43.931Z" }, ] [[package]] @@ -1029,7 +1029,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aptabase", specifier = ">=0.0.2" }, + { name = "aptabase", specifier = ">=0.0.3" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.7.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, diff --git a/src/aptabase/client.py b/src/aptabase/client.py index f0bd721..4841fef 100644 --- a/src/aptabase/client.py +++ b/src/aptabase/client.py @@ -51,7 +51,7 @@ def __init__( if not app_key or not isinstance(app_key, str): raise ConfigurationError("App key is required and must be a string") - if not app_key.startswith(("A-EU-", "A-US-")): + if not app_key.startswith(("A-EU-", "A-US-", "A-SH-")): raise ConfigurationError( "Invalid app key format. Expected format: A-{REGION}-{ID}" ) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..26fd0ad --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,747 @@ +"""Comprehensive tests for the Aptabase async client.""" + +import asyncio +from datetime import datetime, timedelta +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from aptabase.client import _SESSION_TIMEOUT, Aptabase +from aptabase.exceptions import ConfigurationError, NetworkError, ValidationError +from aptabase.models import Event, SystemProperties + + +class TestAptabaseInitialization: + """Test Aptabase client initialization.""" + + def test_init_valid_eu_key(self): + """Test initialization with valid EU app key.""" + client = Aptabase("A-EU-1234567890") + assert client._app_key == "A-EU-1234567890" + assert client._base_url == "https://eu.aptabase.com" + assert client._system_props.app_version == "1.0.0" + assert client._system_props.is_debug is False + + def test_init_valid_us_key(self): + """Test initialization with valid US app key.""" + client = Aptabase("A-US-1234567890") + assert client._app_key == "A-US-1234567890" + assert client._base_url == "https://us.aptabase.com" + + def test_init_with_custom_parameters(self): + """Test initialization with custom parameters.""" + client = Aptabase( + "A-EU-1234567890", + app_version="2.0.0", + is_debug=True, + max_batch_size=10, + flush_interval=5.0, + timeout=60.0, + ) + assert client._system_props.app_version == "2.0.0" + assert client._system_props.is_debug is True + assert client._max_batch_size == 10 + assert client._flush_interval == 5.0 + assert client._timeout == 60.0 + + def test_init_with_custom_base_url(self): + """Test initialization with custom base URL.""" + client = Aptabase("A-EU-1234567890", base_url="https://custom.example.com") + assert client._base_url == "https://custom.example.com" + + def test_init_empty_app_key(self): + """Test initialization with empty app key.""" + with pytest.raises(ConfigurationError, match="App key is required"): + Aptabase("") + + def test_init_none_app_key(self): + """Test initialization with None app key.""" + with pytest.raises(ConfigurationError, match="App key is required"): + Aptabase(cast(str, None)) + + def test_init_non_string_app_key(self): + """Test initialization with non-string app key.""" + with pytest.raises(ConfigurationError, match="must be a string"): + Aptabase(cast(str, 12345)) + + def test_init_invalid_app_key_format(self): + """Test initialization with invalid app key format.""" + with pytest.raises(ConfigurationError, match="Invalid app key format"): + Aptabase("INVALID-KEY") + + def test_init_invalid_region(self): + """Test initialization with invalid region.""" + with pytest.raises( + ConfigurationError, + match="Invalid app key format. Expected format: A-{REGION}-{ID}", + ): + Aptabase("A-XX-1234567890") + + def test_init_self_hosted_without_base_url(self): + """Test initialization with self-hosted key but no base_url.""" + with pytest.raises( + ConfigurationError, match="Self-hosted app key requires base_url" + ): + Aptabase("A-SH-1234567890") + + def test_init_self_hosted_with_base_url(self): + """Test initialization with self-hosted key and base_url.""" + client = Aptabase("A-SH-1234567890", base_url="https://my-server.com") + assert client._base_url == "https://my-server.com" + + def test_init_batch_size_too_large(self): + """Test initialization with batch size exceeding maximum.""" + with pytest.raises(ConfigurationError, match="Maximum batch size is 25"): + Aptabase("A-EU-1234567890", max_batch_size=26) + + def test_init_batch_size_at_maximum(self): + """Test initialization with batch size at maximum.""" + client = Aptabase("A-EU-1234567890", max_batch_size=25) + assert client._max_batch_size == 25 + + def test_get_base_url_malformed_key(self): + """Test _get_base_url with malformed key.""" + client = Aptabase.__new__(Aptabase) + with pytest.raises(ConfigurationError, match="The Aptabase App Key is invalid"): + client._get_base_url("A-EU") + + +class TestAptabaseLifecycle: + """Test Aptabase client lifecycle management.""" + + @pytest.mark.asyncio + async def test_start(self): + """Test starting the client.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + assert client._client is not None + assert isinstance(client._client, httpx.AsyncClient) + assert client._flush_task is not None + assert not client._flush_task.done() + + await client.stop() + + @pytest.mark.asyncio + async def test_start_idempotent(self): + """Test that calling start multiple times is safe.""" + client = Aptabase("A-EU-1234567890") + await client.start() + first_client = client._client + + await client.start() + assert client._client is first_client + + await client.stop() + + @pytest.mark.asyncio + async def test_stop(self): + """Test stopping the client.""" + client = Aptabase("A-EU-1234567890") + await client.start() + await client.stop() + + assert client._client is None + assert client._flush_task is not None and client._flush_task.done() + + @pytest.mark.asyncio + async def test_stop_without_start(self): + """Test stopping a client that was never started.""" + client = Aptabase("A-EU-1234567890") + await client.stop() + + assert client._client is None + + @pytest.mark.asyncio + async def test_context_manager(self): + """Test using client as async context manager.""" + async with Aptabase("A-EU-1234567890") as client: + assert client._client is not None + assert client._flush_task is not None + + assert client._client is None + + @pytest.mark.asyncio + async def test_context_manager_with_exception(self): + """Test context manager handles exceptions properly.""" + with pytest.raises(ValueError): + async with Aptabase("A-EU-1234567890") as client: + assert client._client is not None + raise ValueError("Test error") + + assert client._client is None + + +class TestEventTracking: + """Test event tracking functionality.""" + + @pytest.mark.asyncio + async def test_track_basic_event(self): + """Test tracking a basic event.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + with patch.object(client, "_send_events", new_callable=AsyncMock): + await client.track("test_event") + + assert len(client._event_queue) == 1 + event = client._event_queue[0] + assert event.name == "test_event" + assert event.session_id is not None + assert event.props == {} + + await client.stop() + + @pytest.mark.asyncio + async def test_track_event_with_properties(self): + """Test tracking an event with properties.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + props = {"user_id": "123", "action": "click"} + await client.track("button_click", props) + + assert len(client._event_queue) == 1 + event = client._event_queue[0] + assert event.name == "button_click" + assert event.props == props + + await client.stop() + + @pytest.mark.asyncio + async def test_track_empty_event_name(self): + """Test tracking with empty event name.""" + client = Aptabase("A-EU-1234567890") + + with pytest.raises(ValidationError, match="Event name is required"): + await client.track("") + + @pytest.mark.asyncio + async def test_track_non_string_event_name(self): + """Test tracking with non-string event name.""" + client = Aptabase("A-EU-1234567890") + + with pytest.raises(ValidationError, match="must be a string"): + await client.track(cast(str, None)) + + @pytest.mark.asyncio + async def test_track_invalid_props_type(self): + """Test tracking with invalid properties type.""" + client = Aptabase("A-EU-1234567890") + + with pytest.raises(ValidationError, match="must be a dictionary"): + await client.track("test_event", cast(dict[str, Any], "invalid")) + + @pytest.mark.asyncio + async def test_track_auto_flush_on_batch_size(self): + """Test automatic flush when batch size is reached.""" + client = Aptabase("A-EU-1234567890", max_batch_size=3) + await client.start() + + with patch.object(client, "_send_events", new_callable=AsyncMock) as mock_send: + # Track events up to batch size + await client.track("event1") + await client.track("event2") + assert mock_send.call_count == 0 + + # This should trigger auto-flush + await client.track("event3") + assert mock_send.call_count == 1 + assert len(mock_send.call_args[0][0]) == 3 + + await client.stop() + + @pytest.mark.asyncio + async def test_track_multiple_events_same_session(self): + """Test tracking multiple events maintains same session.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + await client.track("event1") + first_session = client._event_queue[0].session_id + + await client.track("event2") + second_session = client._event_queue[1].session_id + + assert first_session == second_session + + await client.stop() + + +class TestSessionManagement: + """Test session ID management.""" + + def test_get_or_create_session_creates_new(self): + """Test creating a new session.""" + client = Aptabase("A-EU-1234567890") + + session_id = client._get_or_create_session() + + assert session_id is not None + assert client._session_id == session_id + assert client._last_touched is not None + + def test_get_or_create_session_returns_existing(self): + """Test returning existing session within timeout.""" + client = Aptabase("A-EU-1234567890") + + first_session = client._get_or_create_session() + second_session = client._get_or_create_session() + + assert first_session == second_session + + def test_get_or_create_session_updates_last_touched(self): + """Test that accessing session updates last_touched.""" + client = Aptabase("A-EU-1234567890") + + client._get_or_create_session() + first_touched = client._last_touched + + # Small delay + import time + + time.sleep(0.01) + + client._get_or_create_session() + second_touched = client._last_touched + + assert first_touched is not None and second_touched is not None + assert second_touched > first_touched + + def test_get_or_create_session_expires_after_timeout(self): + """Test session expires after timeout.""" + client = Aptabase("A-EU-1234567890") + + first_session = client._get_or_create_session() + + # Manually set last_touched to past timeout + client._last_touched = datetime.now() - _SESSION_TIMEOUT - timedelta(seconds=1) + + second_session = client._get_or_create_session() + + assert first_session != second_session + + def test_new_session_id_format(self): + """Test session ID generation format.""" + session_id = Aptabase._new_session_id() + + assert isinstance(session_id, str) + assert len(session_id) > 0 + assert session_id.isdigit() + + def test_new_session_id_unique(self): + """Test that session IDs are unique.""" + session_id1 = Aptabase._new_session_id() + session_id2 = Aptabase._new_session_id() + + # They might be the same in rare cases, but typically different + # At minimum, verify they're valid + assert session_id1.isdigit() + assert session_id2.isdigit() + + +class TestEventFlushing: + """Test event flushing functionality.""" + + @pytest.mark.asyncio + async def test_manual_flush(self): + """Test manual flush of events.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + with patch.object(client, "_send_events", new_callable=AsyncMock) as mock_send: + await client.track("event1") + await client.track("event2") + + await client.flush() + + assert mock_send.call_count == 1 + assert len(mock_send.call_args[0][0]) == 2 + assert len(client._event_queue) == 0 + + await client.stop() + + @pytest.mark.asyncio + async def test_flush_empty_queue(self): + """Test flushing with empty queue.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + with patch.object(client, "_send_events", new_callable=AsyncMock) as mock_send: + await client.flush() + assert mock_send.call_count == 0 + + await client.stop() + + @pytest.mark.asyncio + async def test_flush_respects_batch_size(self): + """Test flush respects maximum batch size.""" + client = Aptabase("A-EU-1234567890", max_batch_size=2) + await client.start() + + with patch.object(client, "_send_events", new_callable=AsyncMock) as mock_send: + # Add 5 events + for i in range(5): + client._event_queue.append(Event(name=f"event{i}")) + + await client.flush() + + # Should send only 2 events (batch size) + assert mock_send.call_count == 1 + assert len(mock_send.call_args[0][0]) == 2 + # 3 events should remain in queue + assert len(client._event_queue) == 3 + + await client.stop() + + @pytest.mark.asyncio + async def test_flush_on_stop(self): + """Test that stop flushes remaining events.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + with patch.object(client, "_send_events", new_callable=AsyncMock) as mock_send: + await client.track("event1") + + await client.stop() + + assert mock_send.call_count == 1 + + @pytest.mark.asyncio + async def test_flush_without_client(self): + """Test flushing when client is not initialized.""" + client = Aptabase("A-EU-1234567890") + + # Add event to queue without starting client + client._event_queue.append(Event(name="test")) + + async with client._queue_lock: + await client._flush_events() + + # Should not crash, events remain in queue + assert len(client._event_queue) == 1 + + @pytest.mark.asyncio + async def test_periodic_flush(self): + """Test periodic flush task.""" + client = Aptabase("A-EU-1234567890", flush_interval=0.1) + await client.start() + + with patch.object(client, "_send_events", new_callable=AsyncMock) as mock_send: + await client.track("event1") + + # Wait for periodic flush + await asyncio.sleep(0.15) + + assert mock_send.call_count >= 1 + + await client.stop() + + @pytest.mark.asyncio + async def test_flush_error_requeues_events(self): + """Test that flush errors re-queue events.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + await client.track("event1") + await client.track("event2") + + # Mock _send_events to raise an error + with patch.object( + client, + "_send_events", + new_callable=AsyncMock, + side_effect=Exception("Network error"), + ): + async with client._queue_lock: + await client._flush_events() + + # Events should be back in queue + assert len(client._event_queue) == 2 + + await client.stop() + + +class TestNetworkOperations: + """Test network operations.""" + + @pytest.mark.asyncio + async def test_send_events_success(self): + """Test successful event sending.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + events = [Event(name="test_event")] + + with patch.object(client._client, "post", new_callable=AsyncMock) as mock_post: + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + await client._send_events(events) + + assert mock_post.call_count == 1 + call_args = mock_post.call_args + assert "api/v0/events" in call_args[0][0] + assert len(call_args[1]["json"]) == 1 + + await client.stop() + + @pytest.mark.asyncio + async def test_send_events_empty_list(self): + """Test sending empty events list.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + with patch.object(client._client, "post", new_callable=AsyncMock) as mock_post: + await client._send_events([]) + assert mock_post.call_count == 0 + + await client.stop() + + @pytest.mark.asyncio + async def test_send_events_http_error(self): + """Test handling HTTP error during send.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + events = [Event(name="test_event")] + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "Bad Request" + + with patch.object( + client._client, + "post", + new_callable=AsyncMock, + side_effect=httpx.HTTPStatusError( + "Error", request=MagicMock(), response=mock_response + ), + ): + with pytest.raises(NetworkError, match="HTTP error 400"): + await client._send_events(events) + + await client.stop() + + @pytest.mark.asyncio + async def test_send_events_request_error(self): + """Test handling request error during send.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + events = [Event(name="test_event")] + + with patch.object( + client._client, + "post", + new_callable=AsyncMock, + side_effect=httpx.RequestError("Connection failed"), + ): + with pytest.raises(NetworkError, match="Network error"): + await client._send_events(events) + + await client.stop() + + @pytest.mark.asyncio + async def test_send_events_assertion_error(self): + """Test assertion error when client not initialized.""" + client = Aptabase("A-EU-1234567890") + # Don't start client + + events = [Event(name="test_event")] + + with pytest.raises(AssertionError, match="HTTP client is not initialized"): + await client._send_events(events) + + +class TestExceptionClasses: + """Test exception classes.""" + + def test_aptabase_error(self): + """Test base AptabaseError.""" + from aptabase.exceptions import AptabaseError + + error = AptabaseError("Test error") + assert str(error) == "Test error" + + def test_configuration_error(self): + """Test ConfigurationError.""" + error = ConfigurationError("Config error") + assert str(error) == "Config error" + assert isinstance(error, Exception) + + def test_network_error_with_status_code(self): + """Test NetworkError with status code.""" + error = NetworkError("Network failed", status_code=500) + assert str(error) == "Network failed" + assert error.status_code == 500 + + def test_network_error_without_status_code(self): + """Test NetworkError without status code.""" + error = NetworkError("Network failed") + assert str(error) == "Network failed" + assert error.status_code is None + + def test_validation_error(self): + """Test ValidationError.""" + error = ValidationError("Validation failed") + assert str(error) == "Validation failed" + + +class TestModels: + """Test data models.""" + + def test_system_properties_defaults(self): + """Test SystemProperties with defaults.""" + props = SystemProperties() + + assert props.locale == "en-US" + assert props.os_name is not None + assert props.os_version is not None + assert props.device_model is not None + assert props.is_debug is False + assert props.app_version == "1.0.0" + assert props.sdk_version == "0.0.1" + + def test_system_properties_custom(self): + """Test SystemProperties with custom values.""" + props = SystemProperties(locale="fr-FR", is_debug=True, app_version="2.0.0") + + assert props.locale == "fr-FR" + assert props.is_debug is True + assert props.app_version == "2.0.0" + + def test_system_properties_to_dict(self): + """Test SystemProperties to_dict conversion.""" + props = SystemProperties(app_version="1.5.0", is_debug=True) + result = props.to_dict() + + assert result["appVersion"] == "1.5.0" + assert result["isDebug"] is True + assert "locale" in result + assert "osName" in result + assert "osVersion" in result + assert "deviceModel" in result + assert "sdkVersion" in result + + def test_event_defaults(self): + """Test Event with defaults.""" + event = Event(name="test_event") + + assert event.name == "test_event" + assert event.timestamp is not None + assert event.session_id is not None + assert event.props == {} + + def test_event_custom_values(self): + """Test Event with custom values.""" + timestamp = datetime.now() + session_id = "custom-session" + props = {"key": "value"} + + event = Event( + name="custom_event", timestamp=timestamp, session_id=session_id, props=props + ) + + assert event.name == "custom_event" + assert event.timestamp == timestamp + assert event.session_id == session_id + assert event.props == props + + def test_event_to_dict(self): + """Test Event to_dict conversion.""" + timestamp = datetime(2024, 1, 1, 12, 0, 0) + event = Event( + name="test_event", + timestamp=timestamp, + session_id="session123", + props={"user": "alice"}, + ) + + system_props = SystemProperties() + result = event.to_dict(system_props) + + assert result["eventName"] == "test_event" + assert result["sessionId"] == "session123" + assert result["props"] == {"user": "alice"} + assert "timestamp" in result + assert result["timestamp"].endswith("Z") + assert "systemProps" in result + + def test_event_to_dict_no_props(self): + """Test Event to_dict with no props.""" + event = Event(name="test_event", props=None) + system_props = SystemProperties() + result = event.to_dict(system_props) + + assert result["props"] == {} + + +class TestIntegration: + """Integration tests.""" + + @pytest.mark.asyncio + async def test_full_workflow(self): + """Test complete workflow from init to shutdown.""" + async with Aptabase( + "A-EU-1234567890", app_version="1.0.0", max_batch_size=5 + ) as client: + with patch.object( + client, "_send_events", new_callable=AsyncMock + ) as mock_send: + # Track multiple events + await client.track("page_view", {"page": "home"}) + await client.track("button_click", {"button": "submit"}) + await client.track("form_submit", {"form": "contact"}) + + # Manual flush + await client.flush() + + assert mock_send.call_count >= 1 + + @pytest.mark.asyncio + async def test_concurrent_tracking(self): + """Test concurrent event tracking.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + with patch.object(client, "_send_events", new_callable=AsyncMock): + # Track events concurrently + tasks = [client.track(f"event_{i}", {"index": i}) for i in range(10)] + await asyncio.gather(*tasks) + + assert len(client._event_queue) <= 10 + + await client.stop() + + @pytest.mark.asyncio + async def test_error_recovery(self): + """Test client recovery from errors.""" + client = Aptabase("A-EU-1234567890") + await client.start() + + # First send fails + call_count = 0 + + async def mock_send_with_error(events): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise NetworkError("First attempt failed") + + with patch.object(client, "_send_events", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = mock_send_with_error + + await client.track("event1") + + # This flush will fail and requeue + async with client._queue_lock: + await client._flush_events() + + # Events should still be in queue + assert len(client._event_queue) > 0 + + await client.stop() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..bb4a482 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,236 @@ +"""Tests for exception classes.""" + +import pytest + + +def test_imports(): + """Test that all exceptions can be imported.""" + from aptabase.exceptions import ( + AptabaseError, + ConfigurationError, + NetworkError, + ValidationError, + ) + + assert AptabaseError is not None + assert ConfigurationError is not None + assert NetworkError is not None + assert ValidationError is not None + + +class TestAptabaseError: + """Test AptabaseError base class.""" + + def test_create_with_message(self): + """Test creating error with message.""" + from aptabase.exceptions import AptabaseError + + error = AptabaseError("Test error message") + assert str(error) == "Test error message" + + def test_is_exception(self): + """Test that AptabaseError is an Exception.""" + from aptabase.exceptions import AptabaseError + + error = AptabaseError("Test") + assert isinstance(error, Exception) + + def test_can_be_raised(self): + """Test that error can be raised and caught.""" + from aptabase.exceptions import AptabaseError + + with pytest.raises(AptabaseError, match="Test error"): + raise AptabaseError("Test error") + + +class TestConfigurationError: + """Test ConfigurationError class.""" + + def test_create_with_message(self): + """Test creating error with message.""" + from aptabase.exceptions import ConfigurationError + + error = ConfigurationError("Invalid configuration") + assert str(error) == "Invalid configuration" + + def test_inherits_from_aptabase_error(self): + """Test that ConfigurationError inherits from AptabaseError.""" + from aptabase.exceptions import AptabaseError, ConfigurationError + + error = ConfigurationError("Test") + assert isinstance(error, AptabaseError) + assert isinstance(error, Exception) + + def test_can_be_raised(self): + """Test that error can be raised and caught.""" + from aptabase.exceptions import ConfigurationError + + with pytest.raises(ConfigurationError, match="Config error"): + raise ConfigurationError("Config error") + + def test_can_be_caught_as_base_error(self): + """Test that ConfigurationError can be caught as AptabaseError.""" + from aptabase.exceptions import AptabaseError, ConfigurationError + + with pytest.raises(AptabaseError): + raise ConfigurationError("Test") + + +class TestNetworkError: + """Test NetworkError class.""" + + def test_create_with_message_only(self): + """Test creating error with message only.""" + from aptabase.exceptions import NetworkError + + error = NetworkError("Connection failed") + assert str(error) == "Connection failed" + assert error.status_code is None + + def test_create_with_status_code(self): + """Test creating error with status code.""" + from aptabase.exceptions import NetworkError + + error = NetworkError("Request failed", status_code=404) + assert str(error) == "Request failed" + assert error.status_code == 404 + + def test_create_with_various_status_codes(self): + """Test creating error with various HTTP status codes.""" + from aptabase.exceptions import NetworkError + + test_cases = [ + (400, "Bad Request"), + (401, "Unauthorized"), + (403, "Forbidden"), + (404, "Not Found"), + (500, "Internal Server Error"), + (502, "Bad Gateway"), + (503, "Service Unavailable"), + ] + + for status_code, message in test_cases: + error = NetworkError(message, status_code=status_code) + assert error.status_code == status_code + assert str(error) == message + + def test_inherits_from_aptabase_error(self): + """Test that NetworkError inherits from AptabaseError.""" + from aptabase.exceptions import AptabaseError, NetworkError + + error = NetworkError("Test") + assert isinstance(error, AptabaseError) + assert isinstance(error, Exception) + + def test_can_be_raised(self): + """Test that error can be raised and caught.""" + from aptabase.exceptions import NetworkError + + with pytest.raises(NetworkError, match="Network error"): + raise NetworkError("Network error", status_code=500) + + def test_status_code_attribute_accessible(self): + """Test that status_code attribute is accessible.""" + from aptabase.exceptions import NetworkError + + error = NetworkError("Test", status_code=200) + assert hasattr(error, "status_code") + assert error.status_code == 200 + + def test_can_be_caught_as_base_error(self): + """Test that NetworkError can be caught as AptabaseError.""" + from aptabase.exceptions import AptabaseError, NetworkError + + with pytest.raises(AptabaseError): + raise NetworkError("Test", status_code=500) + + +class TestValidationError: + """Test ValidationError class.""" + + def test_create_with_message(self): + """Test creating error with message.""" + from aptabase.exceptions import ValidationError + + error = ValidationError("Invalid data") + assert str(error) == "Invalid data" + + def test_inherits_from_aptabase_error(self): + """Test that ValidationError inherits from AptabaseError.""" + from aptabase.exceptions import AptabaseError, ValidationError + + error = ValidationError("Test") + assert isinstance(error, AptabaseError) + assert isinstance(error, Exception) + + def test_can_be_raised(self): + """Test that error can be raised and caught.""" + from aptabase.exceptions import ValidationError + + with pytest.raises(ValidationError, match="Validation failed"): + raise ValidationError("Validation failed") + + def test_can_be_caught_as_base_error(self): + """Test that ValidationError can be caught as AptabaseError.""" + from aptabase.exceptions import AptabaseError, ValidationError + + with pytest.raises(AptabaseError): + raise ValidationError("Test") + + +class TestExceptionHierarchy: + """Test exception hierarchy and inheritance.""" + + def test_all_inherit_from_aptabase_error(self): + """Test that all custom exceptions inherit from AptabaseError.""" + from aptabase.exceptions import ( + AptabaseError, + ConfigurationError, + NetworkError, + ValidationError, + ) + + errors = [ + ConfigurationError("test"), + NetworkError("test"), + ValidationError("test"), + ] + + for error in errors: + assert isinstance(error, AptabaseError) + + def test_all_inherit_from_exception(self): + """Test that all custom exceptions inherit from Exception.""" + from aptabase.exceptions import ( + ConfigurationError, + NetworkError, + ValidationError, + ) + + errors = [ + ConfigurationError("test"), + NetworkError("test"), + ValidationError("test"), + ] + + for error in errors: + assert isinstance(error, Exception) + + def test_catch_any_with_base_error(self): + """Test catching any custom exception with AptabaseError.""" + from aptabase.exceptions import ( + AptabaseError, + ConfigurationError, + NetworkError, + ValidationError, + ) + + exceptions_to_test = [ + ConfigurationError("config"), + NetworkError("network"), + ValidationError("validation"), + ] + + for exc in exceptions_to_test: + with pytest.raises(AptabaseError): + raise exc diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..d58e1c6 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,415 @@ +"""Tests for data models.""" + +import platform +import uuid +from datetime import datetime + +import pytest + +from aptabase.models import Event, SystemProperties + + +class TestSystemProperties: + """Test SystemProperties model.""" + + def test_default_initialization(self): + """Test SystemProperties with default values.""" + props = SystemProperties() + + assert props.locale == "en-US" + assert props.os_name == platform.system() + assert props.os_version == platform.release() + assert props.device_model == platform.machine() + assert props.is_debug is False + assert props.app_version == "1.0.0" + assert props.sdk_version == "0.0.1" + + def test_custom_initialization(self): + """Test SystemProperties with custom values.""" + props = SystemProperties( + locale="fr-FR", + os_name="CustomOS", + os_version="10.5", + device_model="CustomDevice", + is_debug=True, + app_version="2.5.0", + sdk_version="1.0.0", + ) + + assert props.locale == "fr-FR" + assert props.os_name == "CustomOS" + assert props.os_version == "10.5" + assert props.device_model == "CustomDevice" + assert props.is_debug is True + assert props.app_version == "2.5.0" + assert props.sdk_version == "1.0.0" + + def test_partial_initialization(self): + """Test SystemProperties with some custom values.""" + props = SystemProperties(app_version="3.0.0", is_debug=True) + + assert props.app_version == "3.0.0" + assert props.is_debug is True + # Defaults should still apply + assert props.locale == "en-US" + assert props.sdk_version == "0.0.1" + + def test_to_dict_structure(self): + """Test to_dict returns correct structure.""" + props = SystemProperties() + result = props.to_dict() + + assert isinstance(result, dict) + assert "locale" in result + assert "osName" in result + assert "osVersion" in result + assert "deviceModel" in result + assert "isDebug" in result + assert "appVersion" in result + assert "sdkVersion" in result + + def test_to_dict_values(self): + """Test to_dict returns correct values.""" + props = SystemProperties( + locale="de-DE", + os_name="Linux", + os_version="5.15", + device_model="x86_64", + is_debug=True, + app_version="4.2.0", + sdk_version="2.0.0", + ) + result = props.to_dict() + + assert result["locale"] == "de-DE" + assert result["osName"] == "Linux" + assert result["osVersion"] == "5.15" + assert result["deviceModel"] == "x86_64" + assert result["isDebug"] is True + assert result["appVersion"] == "4.2.0" + assert result["sdkVersion"] == "2.0.0" + + def test_to_dict_camel_case_keys(self): + """Test that to_dict uses camelCase for keys.""" + props = SystemProperties() + result = props.to_dict() + + # Check camelCase + assert "osName" in result + assert "osVersion" in result + assert "deviceModel" in result + assert "isDebug" in result + assert "appVersion" in result + assert "sdkVersion" in result + + # Check snake_case NOT in result + assert "os_name" not in result + assert "os_version" not in result + assert "device_model" not in result + assert "is_debug" not in result + assert "app_version" not in result + assert "sdk_version" not in result + + def test_debug_false_by_default(self): + """Test that is_debug defaults to False.""" + props = SystemProperties() + assert props.is_debug is False + + def test_debug_true_when_set(self): + """Test that is_debug can be set to True.""" + props = SystemProperties(is_debug=True) + assert props.is_debug is True + + +class TestEvent: + """Test Event model.""" + + def test_minimal_initialization(self): + """Test Event with only required name parameter.""" + event = Event(name="test_event") + + assert event.name == "test_event" + assert event.timestamp is not None + assert isinstance(event.timestamp, datetime) + assert event.session_id is not None + assert isinstance(event.session_id, str) + assert event.props == {} + + def test_full_initialization(self): + """Test Event with all parameters.""" + timestamp = datetime(2024, 1, 15, 10, 30, 0) + session_id = "custom-session-123" + props = {"user_id": "123", "action": "click"} + + event = Event( + name="button_click", timestamp=timestamp, session_id=session_id, props=props + ) + + assert event.name == "button_click" + assert event.timestamp == timestamp + assert event.session_id == session_id + assert event.props == props + + def test_timestamp_auto_generation(self): + """Test that timestamp is auto-generated when not provided.""" + before = datetime.now() + event = Event(name="test") + after = datetime.now() + + assert event.timestamp is not None + assert before <= event.timestamp <= after + + def test_session_id_auto_generation(self): + """Test that session_id is auto-generated when not provided.""" + event = Event(name="test") + + assert event.session_id is not None + assert isinstance(event.session_id, str) + assert len(event.session_id) > 0 + + def test_session_id_is_uuid_format(self): + """Test that auto-generated session_id is valid UUID.""" + event = Event(name="test") + + # Should be able to parse as UUID + try: + uuid.UUID(event.session_id) + except ValueError: + pytest.fail("session_id is not a valid UUID") + + def test_props_default_empty_dict(self): + """Test that props defaults to empty dict.""" + event = Event(name="test") + + assert event.props == {} + assert isinstance(event.props, dict) + + def test_props_none_becomes_empty_dict(self): + """Test that props=None becomes empty dict.""" + event = Event(name="test", props=None) + + assert event.props == {} + + def test_props_custom_values(self): + """Test Event with custom props.""" + props = {"user": "alice", "count": 42, "active": True, "tags": ["a", "b", "c"]} + event = Event(name="test", props=props) + + assert event.props == props + + def test_to_dict_structure(self): + """Test to_dict returns correct structure.""" + event = Event(name="test_event") + system_props = SystemProperties() + + result = event.to_dict(system_props) + + assert isinstance(result, dict) + assert "timestamp" in result + assert "sessionId" in result + assert "eventName" in result + assert "systemProps" in result + assert "props" in result + + def test_to_dict_values(self): + """Test to_dict returns correct values.""" + timestamp = datetime(2024, 6, 15, 14, 30, 45) + event = Event( + name="custom_event", + timestamp=timestamp, + session_id="session-abc", + props={"key": "value"}, + ) + system_props = SystemProperties(app_version="2.0.0") + + result = event.to_dict(system_props) + + assert result["eventName"] == "custom_event" + assert result["sessionId"] == "session-abc" + assert result["props"] == {"key": "value"} + assert "2024-06-15" in result["timestamp"] + assert result["timestamp"].endswith("Z") + assert isinstance(result["systemProps"], dict) + + def test_to_dict_timestamp_format(self): + """Test that timestamp is formatted as ISO with Z suffix.""" + timestamp = datetime(2024, 1, 1, 12, 0, 0) + event = Event(name="test", timestamp=timestamp) + system_props = SystemProperties() + + result = event.to_dict(system_props) + + assert result["timestamp"].endswith("Z") + assert "2024-01-01T12:00:00" in result["timestamp"] + + def test_to_dict_camel_case_keys(self): + """Test that to_dict uses camelCase for keys.""" + event = Event(name="test") + system_props = SystemProperties() + + result = event.to_dict(system_props) + + assert "sessionId" in result + assert "eventName" in result + assert "systemProps" in result + + # Check snake_case NOT in result + assert "session_id" not in result + assert "event_name" not in result + assert "system_props" not in result + + def test_to_dict_empty_props(self): + """Test to_dict with empty props.""" + event = Event(name="test", props=None) + system_props = SystemProperties() + + result = event.to_dict(system_props) + + assert result["props"] == {} + + def test_to_dict_system_props_included(self): + """Test that system properties are included in to_dict.""" + event = Event(name="test") + system_props = SystemProperties(app_version="3.0.0", is_debug=True) + + result = event.to_dict(system_props) + + assert "systemProps" in result + assert result["systemProps"]["appVersion"] == "3.0.0" + assert result["systemProps"]["isDebug"] is True + + def test_multiple_events_different_session_ids(self): + """Test that multiple events get different session IDs.""" + event1 = Event(name="event1") + event2 = Event(name="event2") + + # Auto-generated session IDs should be different (UUIDs) + assert event1.session_id != event2.session_id + + def test_multiple_events_different_timestamps(self): + """Test that multiple events get different timestamps.""" + event1 = Event(name="event1") + # Small delay to ensure different timestamp + import time + + time.sleep(0.001) + event2 = Event(name="event2") + + assert event1.timestamp != event2.timestamp + + def test_event_name_preserved(self): + """Test that event name is preserved exactly.""" + names = [ + "simple", + "with spaces", + "with-dashes", + "with_underscores", + "CamelCase", + "with.dots", + "with123numbers", + ] + + for name in names: + event = Event(name=name) + assert event.name == name + + def test_props_complex_types(self): + """Test Event with complex prop types.""" + props = { + "string": "value", + "number": 42, + "float": 3.14, + "bool": True, + "null": None, + "list": [1, 2, 3], + "dict": {"nested": "value"}, + } + + event = Event(name="test", props=props) + assert event.props == props + + def test_to_dict_preserves_prop_types(self): + """Test that to_dict preserves prop types.""" + props = { + "int": 42, + "float": 3.14, + "bool": True, + "none": None, + "list": [1, 2, 3], + } + + event = Event(name="test", props=props) + system_props = SystemProperties() + result = event.to_dict(system_props) + + assert result["props"]["int"] == 42 + assert result["props"]["float"] == 3.14 + assert result["props"]["bool"] is True + assert result["props"]["none"] is None + assert result["props"]["list"] == [1, 2, 3] + + +class TestModelsIntegration: + """Integration tests for models working together.""" + + def test_event_with_system_props_full_workflow(self): + """Test complete workflow of creating event and converting to dict.""" + # Create system properties + system_props = SystemProperties(app_version="1.5.0", is_debug=False) + + # Create event + timestamp = datetime(2024, 3, 15, 9, 30, 0) + event = Event( + name="user_action", + timestamp=timestamp, + session_id="sess-123", + props={"action": "submit", "value": 100}, + ) + + # Convert to dict + result = event.to_dict(system_props) + + # Verify complete structure + assert result["eventName"] == "user_action" + assert result["sessionId"] == "sess-123" + assert result["props"]["action"] == "submit" + assert result["props"]["value"] == 100 + assert result["systemProps"]["appVersion"] == "1.5.0" + assert result["systemProps"]["isDebug"] is False + assert "timestamp" in result + + def test_multiple_events_same_system_props(self): + """Test multiple events can share same system properties.""" + system_props = SystemProperties(app_version="2.0.0") + + event1 = Event(name="event1", props={"num": 1}) + event2 = Event(name="event2", props={"num": 2}) + + result1 = event1.to_dict(system_props) + result2 = event2.to_dict(system_props) + + # Both should have same system props + assert result1["systemProps"] == result2["systemProps"] + # But different event data + assert result1["eventName"] != result2["eventName"] + assert result1["props"] != result2["props"] + + def test_dataclass_fields_accessible(self): + """Test that dataclass fields are accessible.""" + system_props = SystemProperties() + + # Should be able to access all fields + assert hasattr(system_props, "locale") + assert hasattr(system_props, "os_name") + assert hasattr(system_props, "os_version") + assert hasattr(system_props, "device_model") + assert hasattr(system_props, "is_debug") + assert hasattr(system_props, "app_version") + assert hasattr(system_props, "sdk_version") + + event = Event(name="test") + + assert hasattr(event, "name") + assert hasattr(event, "timestamp") + assert hasattr(event, "session_id") + assert hasattr(event, "props") diff --git a/uv.lock b/uv.lock index 438f8d7..561d366 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ wheels = [ [[package]] name = "aptabase" -version = "0.0.3" +version = "0.0.4" source = { editable = "." } dependencies = [ { name = "httpx" },