diff --git a/setup.py b/setup.py index 8026574..1d9706d 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ from setuptools import setup, find_packages setup( name='timetracker', - version='3.5.1', + version='3.6.0', packages=find_packages(), include_package_data=True, install_requires=[ diff --git a/tests/smoke_test_user_settings.py b/tests/smoke_test_user_settings.py new file mode 100644 index 0000000..2415d34 --- /dev/null +++ b/tests/smoke_test_user_settings.py @@ -0,0 +1,228 @@ +""" +Smoke tests for user settings feature. +Quick validation tests to ensure the feature is working at a basic level. +""" + +import pytest +from flask import url_for +from app.models import User +from app import db + + +class TestUserSettingsSmokeTests: + """Smoke tests for user settings functionality""" + + def test_settings_page_accessible(self, client, user): + """Smoke test: Settings page loads without errors""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.get('/settings') + assert response.status_code == 200, "Settings page should load successfully" + + def test_can_update_basic_profile(self, client, user): + """Smoke test: Can update basic profile information""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'full_name': 'Smoke Test User', + 'email': 'smoke@test.com' + }, follow_redirects=True) + + assert response.status_code == 200, "Settings update should succeed" + assert b'Settings saved successfully' in response.data or b'saved' in response.data.lower() + + # Verify changes + db.session.refresh(user) + assert user.full_name == 'Smoke Test User' + assert user.email == 'smoke@test.com' + + def test_can_toggle_notifications(self, client, user): + """Smoke test: Can toggle email notifications on/off""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + # Enable notifications + response = client.post('/settings', data={ + 'email_notifications': 'on' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.email_notifications is True, "Should enable notifications" + + # Disable notifications + response = client.post('/settings', data={ + # No email_notifications key = unchecked + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.email_notifications is False, "Should disable notifications" + + def test_can_change_theme(self, client, user): + """Smoke test: Can change theme preference""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + # Set to dark theme + response = client.post('/settings', data={ + 'theme_preference': 'dark' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.theme_preference == 'dark' + + def test_can_change_timezone(self, client, user): + """Smoke test: Can change timezone""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'timezone': 'America/New_York' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.timezone == 'America/New_York' + + def test_can_change_date_format(self, client, user): + """Smoke test: Can change date format""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'date_format': 'DD/MM/YYYY' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.date_format == 'DD/MM/YYYY' + + def test_can_enable_time_rounding(self, client, user): + """Smoke test: Can enable and configure time rounding""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'time_rounding_enabled': 'on', + 'time_rounding_minutes': '15', + 'time_rounding_method': 'nearest' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.time_rounding_enabled is True + assert user.time_rounding_minutes == 15 + assert user.time_rounding_method == 'nearest' + + def test_can_set_standard_hours(self, client, user): + """Smoke test: Can set standard hours per day""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'standard_hours_per_day': '7.5' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.standard_hours_per_day == 7.5 + + def test_theme_api_works(self, client, user): + """Smoke test: Theme API endpoint works""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/api/theme', + json={'theme': 'dark'}) + + assert response.status_code == 200 + data = response.get_json() + assert data['success'] is True + + db.session.refresh(user) + assert user.theme_preference == 'dark' + + def test_preferences_api_works(self, client, user): + """Smoke test: Preferences API endpoint works""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.patch('/api/preferences', + json={'email_notifications': False}) + + assert response.status_code == 200 + data = response.get_json() + assert data['success'] is True + + db.session.refresh(user) + assert user.email_notifications is False + + def test_settings_page_has_required_forms(self, client, user): + """Smoke test: Settings page contains all required form elements""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.get('/settings') + data = response.data.decode('utf-8') + + # Check for form fields + assert 'full_name' in data + assert 'email' in data + assert 'theme_preference' in data + assert 'timezone' in data + assert 'date_format' in data + assert 'time_format' in data + assert 'email_notifications' in data + assert 'time_rounding_enabled' in data + assert 'standard_hours_per_day' in data + + def test_invalid_timezone_rejected(self, client, user): + """Smoke test: Invalid timezone is properly rejected""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'timezone': 'NotAValidTimezone' + }, follow_redirects=True) + + assert response.status_code == 200 + # Should show error message + assert b'Invalid timezone' in response.data or b'error' in response.data.lower() + + def test_invalid_hours_rejected(self, client, user): + """Smoke test: Invalid standard hours value is rejected""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'standard_hours_per_day': '100' # Way too high + }, follow_redirects=True) + + assert response.status_code == 200 + # Should show validation error + assert b'between 0.5 and 24' in response.data or b'error' in response.data.lower() + + def test_settings_persist_after_save(self, client, user): + """Smoke test: Settings persist after saving""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + # Save settings + client.post('/settings', data={ + 'full_name': 'Persistent User', + 'theme_preference': 'dark', + 'timezone': 'Europe/London' + }, follow_redirects=True) + + # Reload page + response = client.get('/settings') + data = response.data.decode('utf-8') + + # Verify values are still there + assert 'Persistent User' in data + assert 'Europe/London' in data + diff --git a/tests/test_user_settings.py b/tests/test_user_settings.py new file mode 100644 index 0000000..0b44def --- /dev/null +++ b/tests/test_user_settings.py @@ -0,0 +1,569 @@ +""" +Comprehensive tests for user settings routes and functionality. +Tests settings page rendering, form validation, preference updates, and API endpoints. +""" + +import pytest +from flask import url_for +from app.models import User +from app import db +import pytz + + +class TestUserSettingsPage: + """Tests for the user settings page GET endpoint""" + + def test_settings_page_requires_login(self, client): + """Test that settings page redirects to login if not authenticated""" + response = client.get('/settings', follow_redirects=False) + assert response.status_code == 302 + assert '/login' in response.location + + def test_settings_page_loads_for_authenticated_user(self, client, user): + """Test that settings page loads successfully for authenticated users""" + # Log in the user + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.get('/settings') + assert response.status_code == 200 + assert b'Settings' in response.data + assert b'Notification Preferences' in response.data + assert b'Display Preferences' in response.data + assert b'Regional Settings' in response.data + + def test_settings_page_displays_current_values(self, client, user): + """Test that settings page displays user's current settings""" + # Set some specific settings + user.theme_preference = 'dark' + user.timezone = 'America/New_York' + user.date_format = 'MM/DD/YYYY' + user.email_notifications = True + db.session.commit() + + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.get('/settings') + assert response.status_code == 200 + data = response.data.decode('utf-8') + + # Check that current values are selected + assert 'value="dark" selected' in data or 'value="dark"' in data + assert 'America/New_York' in data + assert 'email_notifications' in data + + def test_settings_page_includes_all_sections(self, client, user): + """Test that all settings sections are present on the page""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.get('/settings') + data = response.data.decode('utf-8') + + # Check for all major sections + assert 'Profile Information' in data + assert 'Notification Preferences' in data + assert 'Display Preferences' in data + assert 'Time Rounding Preferences' in data + assert 'Overtime Settings' in data + assert 'Regional Settings' in data + + +class TestUserSettingsUpdate: + """Tests for updating user settings via POST""" + + def test_update_profile_information(self, client, user): + """Test updating profile information (full name and email)""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'full_name': 'John Doe', + 'email': 'john.doe@example.com' + }, follow_redirects=True) + + assert response.status_code == 200 + assert b'Settings saved successfully' in response.data + + # Verify changes in database + db.session.refresh(user) + assert user.full_name == 'John Doe' + assert user.email == 'john.doe@example.com' + + def test_update_notification_preferences(self, client, user): + """Test updating email notification preferences""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'email_notifications': 'on', + 'notification_overdue_invoices': 'on', + 'notification_task_assigned': 'on', + 'notification_task_comments': 'on', + 'notification_weekly_summary': 'on' + }, follow_redirects=True) + + assert response.status_code == 200 + + # Verify changes + db.session.refresh(user) + assert user.email_notifications is True + assert user.notification_overdue_invoices is True + assert user.notification_task_assigned is True + assert user.notification_task_comments is True + assert user.notification_weekly_summary is True + + def test_update_notification_preferences_all_disabled(self, client, user): + """Test disabling all notification preferences""" + # First enable them + user.email_notifications = True + user.notification_overdue_invoices = True + db.session.commit() + + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + # POST without checkbox values (unchecked checkboxes don't send values) + response = client.post('/settings', data={ + 'full_name': user.full_name or '' + }, follow_redirects=True) + + assert response.status_code == 200 + + # Verify all notifications are disabled + db.session.refresh(user) + assert user.email_notifications is False + assert user.notification_overdue_invoices is False + assert user.notification_task_assigned is False + assert user.notification_task_comments is False + assert user.notification_weekly_summary is False + + def test_update_theme_preference(self, client, user): + """Test updating theme preference""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + # Test setting dark theme + response = client.post('/settings', data={ + 'theme_preference': 'dark' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.theme_preference == 'dark' + + # Test setting light theme + response = client.post('/settings', data={ + 'theme_preference': 'light' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.theme_preference == 'light' + + # Test setting system default (empty string) + response = client.post('/settings', data={ + 'theme_preference': '' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.theme_preference is None + + def test_update_timezone(self, client, user): + """Test updating timezone preference""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'timezone': 'America/New_York' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.timezone == 'America/New_York' + + def test_update_timezone_with_invalid_value(self, client, user): + """Test that invalid timezone is rejected""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'timezone': 'Invalid/Timezone' + }, follow_redirects=True) + + assert response.status_code == 200 + assert b'Invalid timezone selected' in response.data + + def test_update_date_format(self, client, user): + """Test updating date format preference""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + for date_format in ['YYYY-MM-DD', 'MM/DD/YYYY', 'DD/MM/YYYY', 'DD.MM.YYYY']: + response = client.post('/settings', data={ + 'date_format': date_format + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.date_format == date_format + + def test_update_time_format(self, client, user): + """Test updating time format preference (12h/24h)""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + # Test 24-hour format + response = client.post('/settings', data={ + 'time_format': '24h' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.time_format == '24h' + + # Test 12-hour format + response = client.post('/settings', data={ + 'time_format': '12h' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.time_format == '12h' + + def test_update_week_start_day(self, client, user): + """Test updating week start day preference""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + # Test Monday (1) + response = client.post('/settings', data={ + 'week_start_day': '1' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.week_start_day == 1 + + # Test Sunday (0) + response = client.post('/settings', data={ + 'week_start_day': '0' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.week_start_day == 0 + + def test_update_time_rounding_preferences(self, client, user): + """Test updating time rounding preferences""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'time_rounding_enabled': 'on', + 'time_rounding_minutes': '15', + 'time_rounding_method': 'up' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.time_rounding_enabled is True + assert user.time_rounding_minutes == 15 + assert user.time_rounding_method == 'up' + + def test_update_time_rounding_intervals(self, client, user): + """Test all valid time rounding intervals""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + valid_intervals = [1, 5, 10, 15, 30, 60] + + for interval in valid_intervals: + response = client.post('/settings', data={ + 'time_rounding_enabled': 'on', + 'time_rounding_minutes': str(interval) + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.time_rounding_minutes == interval + + def test_update_time_rounding_methods(self, client, user): + """Test all valid time rounding methods""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + valid_methods = ['nearest', 'up', 'down'] + + for method in valid_methods: + response = client.post('/settings', data={ + 'time_rounding_enabled': 'on', + 'time_rounding_method': method + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.time_rounding_method == method + + def test_update_standard_hours_per_day(self, client, user): + """Test updating standard hours per day for overtime calculation""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'standard_hours_per_day': '7.5' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.standard_hours_per_day == 7.5 + + def test_update_standard_hours_validation(self, client, user): + """Test validation of standard hours per day (must be between 0.5 and 24)""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + # Test too low + response = client.post('/settings', data={ + 'standard_hours_per_day': '0.2' + }, follow_redirects=True) + + assert response.status_code == 200 + assert b'Standard hours per day must be between 0.5 and 24' in response.data + + # Test too high + response = client.post('/settings', data={ + 'standard_hours_per_day': '25' + }, follow_redirects=True) + + assert response.status_code == 200 + assert b'Standard hours per day must be between 0.5 and 24' in response.data + + def test_update_language_preference(self, client, user): + """Test updating language preference""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'preferred_language': 'de' + }, follow_redirects=True) + + assert response.status_code == 200 + db.session.refresh(user) + assert user.preferred_language == 'de' + + def test_update_multiple_settings_at_once(self, client, user): + """Test updating multiple settings in a single request""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/settings', data={ + 'full_name': 'Jane Smith', + 'email': 'jane@example.com', + 'theme_preference': 'dark', + 'timezone': 'Europe/London', + 'date_format': 'DD/MM/YYYY', + 'time_format': '24h', + 'email_notifications': 'on', + 'time_rounding_enabled': 'on', + 'time_rounding_minutes': '15', + 'standard_hours_per_day': '8' + }, follow_redirects=True) + + assert response.status_code == 200 + assert b'Settings saved successfully' in response.data + + # Verify all changes + db.session.refresh(user) + assert user.full_name == 'Jane Smith' + assert user.email == 'jane@example.com' + assert user.theme_preference == 'dark' + assert user.timezone == 'Europe/London' + assert user.date_format == 'DD/MM/YYYY' + assert user.time_format == '24h' + assert user.email_notifications is True + assert user.time_rounding_enabled is True + assert user.time_rounding_minutes == 15 + assert user.standard_hours_per_day == 8.0 + + +class TestUserSettingsAPIEndpoints: + """Tests for API endpoints for updating preferences""" + + def test_update_preferences_api_requires_login(self, client): + """Test that API endpoint requires authentication""" + response = client.patch('/api/preferences', + json={'theme_preference': 'dark'}) + assert response.status_code == 302 # Redirect to login + + def test_update_theme_via_api(self, client, user): + """Test updating theme via AJAX API""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.patch('/api/preferences', + json={'theme_preference': 'dark'}) + + assert response.status_code == 200 + data = response.get_json() + assert data['success'] is True + + db.session.refresh(user) + assert user.theme_preference == 'dark' + + def test_update_email_notifications_via_api(self, client, user): + """Test updating email notifications via API""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.patch('/api/preferences', + json={'email_notifications': False}) + + assert response.status_code == 200 + data = response.get_json() + assert data['success'] is True + + db.session.refresh(user) + assert user.email_notifications is False + + def test_update_timezone_via_api(self, client, user): + """Test updating timezone via API""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.patch('/api/preferences', + json={'timezone': 'Asia/Tokyo'}) + + assert response.status_code == 200 + data = response.get_json() + assert data['success'] is True + + db.session.refresh(user) + assert user.timezone == 'Asia/Tokyo' + + def test_update_invalid_timezone_via_api(self, client, user): + """Test that API rejects invalid timezone""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.patch('/api/preferences', + json={'timezone': 'Invalid/Zone'}) + + assert response.status_code == 400 + data = response.get_json() + assert 'error' in data + + def test_set_theme_api_endpoint(self, client, user): + """Test the dedicated theme switcher API endpoint""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + # Test setting dark theme + response = client.post('/api/theme', + json={'theme': 'dark'}) + + assert response.status_code == 200 + data = response.get_json() + assert data['success'] is True + assert data['theme'] == 'dark' + + db.session.refresh(user) + assert user.theme_preference == 'dark' + + # Test setting system default + response = client.post('/api/theme', + json={'theme': ''}) + + assert response.status_code == 200 + data = response.get_json() + assert data['success'] is True + assert data['theme'] == 'system' + + db.session.refresh(user) + assert user.theme_preference is None + + def test_set_invalid_theme_via_api(self, client, user): + """Test that API rejects invalid theme values""" + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + response = client.post('/api/theme', + json={'theme': 'invalid'}) + + assert response.status_code == 400 + data = response.get_json() + assert 'error' in data + + +class TestUserSettingsIntegration: + """Integration tests for user settings feature""" + + def test_settings_persist_across_sessions(self, client, user): + """Test that settings persist after saving and reloading""" + # Save settings + with client.session_transaction() as sess: + sess['_user_id'] = str(user.id) + + client.post('/settings', data={ + 'full_name': 'Test User', + 'email': 'test@example.com', + 'theme_preference': 'dark', + 'timezone': 'America/Los_Angeles', + 'date_format': 'MM/DD/YYYY', + 'email_notifications': 'on', + 'time_rounding_enabled': 'on', + 'time_rounding_minutes': '15', + 'standard_hours_per_day': '8' + }, follow_redirects=True) + + # Reload page and verify settings are still there + response = client.get('/settings') + data = response.data.decode('utf-8') + + assert 'Test User' in data + assert 'test@example.com' in data + assert 'America/Los_Angeles' in data + + def test_default_settings_for_new_user(self, app): + """Test that new users get appropriate default settings""" + with app.app_context(): + new_user = User(username='newuser', role='user') + db.session.add(new_user) + db.session.commit() + + # Check defaults + assert new_user.email_notifications is True + assert new_user.notification_overdue_invoices is True + assert new_user.notification_task_assigned is True + assert new_user.notification_task_comments is True + assert new_user.notification_weekly_summary is False + assert new_user.date_format == 'YYYY-MM-DD' + assert new_user.time_format == '24h' + assert new_user.week_start_day == 1 + assert new_user.time_rounding_enabled is True + assert new_user.time_rounding_minutes == 1 + assert new_user.time_rounding_method == 'nearest' + assert new_user.standard_hours_per_day == 8.0 + + def test_settings_form_csrf_protection(self, app): + """Test that settings form is protected with CSRF token""" + # Create app with CSRF enabled + app.config['WTF_CSRF_ENABLED'] = True + client = app.test_client() + + # Create a test user + with app.app_context(): + user = User(username='testuser', role='user') + db.session.add(user) + db.session.commit() + user_id = user.id + + with client.session_transaction() as sess: + sess['_user_id'] = str(user_id) + + # Verify CSRF token is present in the form + response = client.get('/settings') + assert b'csrf_token' in response.data or b'CSRF' in response.data +