mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-03 10:09:40 -06:00
284 lines
11 KiB
Python
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
|