""" Tests for analytics functionality (logging, Prometheus, PostHog) """ import pytest import os import json from unittest.mock import patch, MagicMock, call from flask import g from app import create_app, log_event, track_event @pytest.fixture def app(): """Create test Flask application and initialize DB tables.""" app = create_app({"TESTING": True, "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:"}) from app import db with app.app_context(): db.create_all() try: yield app finally: try: with app.app_context(): db.drop_all() except Exception: pass @pytest.fixture def client(app): """Create test client""" return app.test_client() class TestLogEvent: """Tests for structured JSON logging""" def test_log_event_basic(self, app): """Test basic log event""" with app.app_context(): with app.test_request_context(): g.request_id = "test-request-123" # This should not raise an exception log_event("test.event", user_id=1, test_data="value") def test_log_event_without_request_context(self, app): """Test that log_event handles missing request context gracefully""" with app.app_context(): # Should not raise an exception even without request context log_event("test.event", user_id=1) def test_log_event_with_extra_data(self, app): """Test log event with various data types""" with app.app_context(): with app.test_request_context(): g.request_id = "test-request-456" log_event("test.event", user_id=1, project_id=42, duration=3600, success=True, tags=["tag1", "tag2"]) class TestTrackEvent: """Tests for PostHog event tracking""" @patch("app.posthog.capture") def test_track_event_when_enabled(self, mock_capture, app): """Test that PostHog events are tracked when API key is set""" with patch.dict(os.environ, {"POSTHOG_API_KEY": "test-key"}): track_event(123, "test.event", {"property": "value"}) # Verify the event was tracked assert mock_capture.called call_args = mock_capture.call_args assert call_args[1]["distinct_id"] == "123" assert call_args[1]["event"] == "test.event" # Verify our property is included (along with context properties) assert call_args[1]["properties"]["property"] == "value" @patch("app.posthog.capture") def test_track_event_when_disabled(self, mock_capture, app): """Test that PostHog events are not tracked when API key is not set""" with patch.dict(os.environ, {"POSTHOG_API_KEY": ""}): track_event(123, "test.event", {"property": "value"}) mock_capture.assert_not_called() @patch("app.posthog.capture") def test_track_event_handles_errors_gracefully(self, mock_capture, app): """Test that tracking errors don't crash the application""" mock_capture.side_effect = Exception("PostHog error") with patch.dict(os.environ, {"POSTHOG_API_KEY": "test-key"}): # Should not raise an exception track_event(123, "test.event", {}) def test_track_event_with_none_properties(self, app): """Test that track_event handles None properties""" with patch.dict(os.environ, {"POSTHOG_API_KEY": "test-key"}): with patch("app.posthog.capture") as mock_capture: track_event(123, "test.event", None) # Should have context properties even when None is passed call_args = mock_capture.call_args # Properties should be a dict (not None) with at least context properties assert isinstance(call_args[1]["properties"], dict) # Context properties should be present assert "environment" in call_args[1]["properties"] class TestPrometheusMetrics: """Tests for Prometheus metrics""" def test_metrics_endpoint_exists(self, client): """Test that /metrics endpoint exists""" response = client.get("/metrics") assert response.status_code == 200 assert response.content_type == "text/plain; version=0.0.4; charset=utf-8" def test_metrics_endpoint_format(self, client): """Test that /metrics returns Prometheus format""" response = client.get("/metrics") data = response.data.decode("utf-8") # Should contain our custom metrics assert "tt_requests_total" in data assert "tt_request_latency_seconds" in data def test_metrics_are_incremented(self, client): """Test that metrics are incremented on requests""" # Make a request to trigger metric recording response = client.get("/metrics") assert response.status_code == 200 # Get metrics response = client.get("/metrics") data = response.data.decode("utf-8") # Should have recorded requests assert "tt_requests_total" in data class TestAnalyticsIntegration: """Integration tests for analytics in routes""" @patch("app.routes.auth.log_event") @patch("app.routes.auth.track_event") def test_login_analytics(self, mock_track, mock_log, authenticated_client): """Test that login events are tracked""" # Use authenticated client to verify analytics are initialized # The actual login tracking is tested via the mocks being available response = authenticated_client.get("/dashboard") # Verify response is successful (analytics don't break the app) assert response.status_code == 200 # Note: This test primarily verifies analytics hooks don't break the login flow @patch("app.routes.timer.log_event") @patch("app.routes.timer.track_event") def test_timer_analytics_integration(self, mock_track, mock_log, app, client): """Test that timer events are tracked (integration test placeholder)""" # This is a placeholder - actual implementation would require: # 1. Authenticated session # 2. Valid project # 3. Timer start/stop operations pass class TestSentryIntegration: """Tests for Sentry error monitoring""" @patch("app.sentry_sdk.init") def test_sentry_initializes_when_dsn_set(self, mock_init): """Test that Sentry initializes when DSN is provided""" with patch.dict( os.environ, {"SENTRY_DSN": "https://test@sentry.io/123", "SENTRY_TRACES_RATE": "0.1", "FLASK_ENV": "production"}, ): app = create_app({"TESTING": True}) # Sentry should have been initialized # Note: The actual initialization happens in create_app def test_sentry_not_initialized_without_dsn(self): """Test that Sentry is not initialized when DSN is not set""" with patch.dict(os.environ, {"SENTRY_DSN": ""}, clear=True): with patch("app.sentry_sdk.init") as mock_init: app = create_app({"TESTING": True}) # Sentry init should not be called mock_init.assert_not_called() class TestRequestIDAttachment: """Tests for request ID attachment""" def test_request_id_attached(self, app, client): """Test that request ID is attached to requests""" with app.app_context(): with app.test_request_context(): # Trigger the before_request hook with client: response = client.get("/metrics") # Request ID should be set in g # Note: This test might need adjustment based on context handling class TestAnalyticsEventSchema: """Tests to ensure analytics events follow the documented schema""" def test_event_naming_convention(self): """Test that event names follow resource.action pattern""" valid_events = [ "auth.login", "auth.logout", "timer.started", "timer.stopped", "project.created", "project.updated", "export.csv", "report.viewed", ] for event_name in valid_events: parts = event_name.split(".") assert len(parts) == 2, f"Event {event_name} should follow resource.action pattern" assert parts[0].isalpha(), f"Resource part should be alphabetic: {event_name}" assert parts[1].replace("_", "").isalpha(), f"Action part should be alphabetic: {event_name}" class TestAnalyticsPrivacy: """Tests to ensure analytics respect privacy guidelines""" def test_no_pii_in_standard_events(self, app): """Test that standard events don't include PII""" # Events should use IDs, not emails or usernames with app.app_context(): with app.test_request_context(): g.request_id = "test-123" # This is acceptable (uses ID) log_event("test.event", user_id=123) # In production, events should NOT include: # - email addresses # - usernames (use IDs instead) # - IP addresses (unless explicitly needed) # - passwords or tokens @patch("app.posthog.capture") def test_posthog_uses_internal_ids(self, mock_capture, app): """Test that PostHog events use internal IDs, not PII""" with patch.dict(os.environ, {"POSTHOG_API_KEY": "test-key"}): # Should use numeric ID, not email track_event(123, "test.event", {"project_id": 456}) call_args = mock_capture.call_args # distinct_id should be the internal user ID (converted to string) assert call_args[1]["distinct_id"] == "123" class TestAnalyticsPerformance: """Tests to ensure analytics don't impact performance""" def test_analytics_dont_block_requests(self, client): """Test that analytics operations don't significantly delay requests""" import time start = time.time() response = client.get("/metrics") duration = time.time() - start # Request should complete quickly even with analytics assert duration < 1.0 # Should complete in less than 1 second assert response.status_code == 200 @patch("app.posthog.capture") def test_analytics_errors_dont_break_app(self, mock_capture, app, client): """Test that analytics failures don't break the application""" mock_capture.side_effect = Exception("Analytics service down") # Application should still work response = client.get("/metrics") assert response.status_code == 200