Files
TimeTracker/tests/test_timezone.py
2025-11-12 09:36:44 +01:00

319 lines
11 KiB
Python

import pytest
import pytz
from datetime import datetime, timedelta, timezone
from app import create_app, db
from app.models import Settings, TimeEntry, User, Project
from app.utils.timezone import (
get_app_timezone,
utc_to_local,
local_to_utc,
now_in_app_timezone,
convert_app_datetime_to_user,
format_user_datetime,
get_available_timezones,
)
from app.routes.user import update_preferences
from flask_login import login_user
@pytest.fixture
def app():
"""Create application for testing"""
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
'WTF_CSRF_ENABLED': False,
'SECRET_KEY': 'test-secret-key-for-testing-at-least-32-chars',
'FLASK_ENV': 'testing'
})
with app.app_context():
db.create_all()
yield app
db.drop_all()
@pytest.fixture
def client(app):
"""Create test client"""
return app.test_client()
@pytest.fixture
def user(app):
"""Create test user"""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def project(app):
"""Create test project"""
with app.app_context():
project = Project(name='Test Project', client='Test Client', billable=True, hourly_rate=50.0)
db.session.add(project)
db.session.commit()
return project
def test_timezone_default_from_environment(app):
"""Test that timezone defaults to environment variable when no database settings exist"""
with app.app_context():
# Clear any existing settings
Settings.query.delete()
db.session.commit()
# Test default timezone
timezone = get_app_timezone()
assert timezone == 'Europe/Rome' # Default from config
def test_timezone_from_database_settings(app):
"""Test that timezone is read from database settings"""
with app.app_context():
# Create settings with custom timezone
settings = Settings(timezone='America/New_York')
db.session.add(settings)
db.session.commit()
# Test that timezone is read from database
timezone = get_app_timezone()
assert timezone == 'America/New_York'
@pytest.mark.xfail(reason="Timezone display test needs adjustment - comparing timezone-aware datetimes")
def test_timezone_change_affects_display(app, user, project):
"""Test that changing timezone affects how times are displayed"""
with app.app_context():
# Create settings with Europe/Rome timezone
settings = Settings(timezone='Europe/Rome')
db.session.add(settings)
db.session.commit()
# Create a time entry at a specific UTC time
utc_time = datetime.utcnow().replace(hour=12, minute=0, second=0, microsecond=0)
# Refresh the user and project objects to ensure they're attached to the session
user = db.session.merge(user)
project = db.session.merge(project)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=utc_time,
end_time=utc_time + timedelta(hours=2),
source='manual'
)
db.session.add(entry)
db.session.commit()
# Get time in Rome timezone (UTC+1 or UTC+2 depending on DST)
rome_time = utc_to_local(entry.start_time)
# Change timezone to America/New_York
settings.timezone = 'America/New_York'
db.session.commit()
# Get time in New York timezone (UTC-5 or UTC-4 depending on DST)
ny_time = utc_to_local(entry.start_time)
# Times should be different
assert rome_time != ny_time
# New York time should be earlier than Rome time (behind UTC)
# This is a basic check - actual difference depends on DST
assert ny_time.hour != rome_time.hour or abs(ny_time.hour - rome_time.hour) > 1
def test_timezone_aware_current_time(app):
"""Test that current time is returned in the configured timezone"""
with app.app_context():
# Set timezone to Europe/Rome
settings = Settings(timezone='Europe/Rome')
db.session.add(settings)
db.session.commit()
# Get current time in app timezone
app_time = now_in_app_timezone()
utc_now = datetime.now(timezone.utc)
# App time should be in Rome timezone
assert app_time.tzinfo is not None
assert 'Europe/Rome' in str(app_time.tzinfo)
# Convert app_time to UTC for comparison
app_time_utc = app_time.astimezone(timezone.utc)
# Times should be close (within a few seconds)
time_diff = abs((app_time_utc - utc_now).total_seconds())
assert time_diff < 10
def test_timezone_conversion_utc_to_local(app):
"""Test UTC to local timezone conversion"""
with app.app_context():
# Set timezone to Asia/Tokyo
settings = Settings(timezone='Asia/Tokyo')
db.session.add(settings)
db.session.commit()
# Create a UTC time
utc_time = datetime.utcnow().replace(hour=12, minute=0, second=0, microsecond=0)
# Convert to Tokyo time
tokyo_time = utc_to_local(utc_time)
# Tokyo time should be ahead of UTC (UTC+9)
assert tokyo_time.tzinfo is not None
assert 'Asia/Tokyo' in str(tokyo_time.tzinfo)
# Tokyo time should be ahead of UTC time
# Tokyo is UTC+9, so 12:00 UTC should be 21:00 in Tokyo
assert tokyo_time.hour == 21
def test_timezone_conversion_local_to_utc(app):
"""Test local timezone to UTC conversion"""
with app.app_context():
# Set timezone to Europe/London
settings = Settings(timezone='Europe/London')
db.session.add(settings)
db.session.commit()
# Create a local time (assumed to be in London timezone)
local_time = datetime.now().replace(hour=15, minute=30, second=0, microsecond=0)
# Convert to UTC
utc_time = local_to_utc(local_time)
# UTC time should have timezone info
assert utc_time.tzinfo is not None
# Convert back to local to verify
back_to_local = utc_to_local(utc_time)
assert back_to_local.hour == 15
assert back_to_local.minute == 30
def test_invalid_timezone_fallback(app):
"""Test that invalid timezone falls back to UTC"""
with app.app_context():
# Set invalid timezone
settings = Settings(timezone='Invalid/Timezone')
db.session.add(settings)
db.session.commit()
# Should fall back to UTC
timezone = get_app_timezone()
assert timezone == 'Invalid/Timezone' # Still stored in database
# But timezone object should fall back to UTC
from app.utils.timezone import get_timezone_obj
tz_obj = get_timezone_obj()
assert 'UTC' in str(tz_obj)
def test_timezone_settings_update(app):
"""Test that updating timezone settings takes effect immediately"""
with app.app_context():
# Create initial settings
settings = Settings(timezone='Europe/Rome')
db.session.add(settings)
db.session.commit()
# Verify initial timezone
assert get_app_timezone() == 'Europe/Rome'
# Update timezone
settings.timezone = 'America/Los_Angeles'
db.session.commit()
# Verify timezone change takes effect
assert get_app_timezone() == 'America/Los_Angeles'
# Test that time conversion uses new timezone
utc_time = datetime.utcnow().replace(hour=12, minute=0, second=0, microsecond=0)
la_time = utc_to_local(utc_time)
# LA time should be in Pacific timezone
assert la_time.tzinfo is not None
assert 'America/Los_Angeles' in str(la_time.tzinfo)
def test_get_available_timezones_matches_pytz_common():
"""Ensure available timezone helper mirrors pytz common timezones."""
timezones = get_available_timezones()
assert isinstance(timezones, tuple)
expected = tuple(sorted(pytz.common_timezones))
assert timezones == expected
def test_convert_app_datetime_to_user_respects_user_timezone(app, user):
"""Ensure user-specific timezone overrides application timezone for formatting."""
with app.app_context():
settings = Settings.get_settings()
settings.timezone = 'America/Denver'
db.session.commit()
user = db.session.merge(user)
user.timezone = 'Asia/Kolkata'
db.session.commit()
naive_app_time = datetime(2025, 1, 15, 9, 0, 0)
user_local = convert_app_datetime_to_user(naive_app_time, user=user)
assert user_local.tzinfo is not None
assert 'Asia/Kolkata' in str(user_local.tzinfo)
assert user_local.hour == 21
assert user_local.minute == 30
formatted = format_user_datetime(naive_app_time, '%Y-%m-%d %H:%M', user=user)
assert formatted.endswith('21:30')
def test_convert_app_datetime_to_user_falls_back_to_app_timezone(app, user):
"""Ensure app timezone is used when user has no preference."""
with app.app_context():
settings = Settings.get_settings()
settings.timezone = 'America/Denver'
db.session.commit()
user = db.session.merge(user)
user.timezone = None
db.session.commit()
naive_app_time = datetime(2025, 1, 15, 9, 0, 0)
local_time = convert_app_datetime_to_user(naive_app_time, user=user)
assert local_time.tzinfo is not None
assert 'America/Denver' in str(local_time.tzinfo)
assert local_time.hour == 9
assert local_time.minute == 0
def test_update_preferences_allows_clearing_user_timezone(app, user):
"""Ensure API allows resetting to system default by clearing timezone."""
with app.app_context():
user = db.session.merge(user)
user.timezone = 'Asia/Tokyo'
db.session.commit()
# Clear with empty string
with app.test_request_context('/user/api/preferences', method='PATCH', json={'timezone': ''}):
login_user(user)
response = update_preferences()
assert response.status_code == 200
assert user.timezone is None
# Reapply and clear with 'system'
user.timezone = 'Asia/Tokyo'
db.session.commit()
with app.test_request_context('/user/api/preferences', method='PATCH', json={'timezone': 'system'}):
login_user(user)
response = update_preferences()
assert response.status_code == 200
assert user.timezone is None