Files
TimeTracker/tests/test_analytics.py

284 lines
11 KiB
Python

"""
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