diff --git a/.gitignore b/.gitignore index a26ea50..77242b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .env applications/ +.coverage +__pycache__ \ No newline at end of file diff --git a/prompts/002-comprehensive-pytest-testing.md b/prompts/002-comprehensive-pytest-testing.md new file mode 100644 index 0000000..d7869af --- /dev/null +++ b/prompts/002-comprehensive-pytest-testing.md @@ -0,0 +1,234 @@ + +Create a comprehensive pytest test suite for the Flask job application system to ensure all functionality works correctly, including routes, validation, file uploads, YAML storage, and session management. + +This test suite will validate that the application behaves correctly across all workflows and edge cases, providing confidence for future development and deployment. + + + +The Flask job application system consists of: +- 5-page workflow (email capture → personal info → motivation → uploads → confirmation) +- Email-based resume functionality with unique session IDs +- File-based storage using YAML for data and file system for attachments +- Comprehensive validation (email, phone, ZIP, birth year, file uploads) +- German language interface + +Tech stack: Flask 3.0.0, PyYAML, Flask-Mail, pytest for testing + +Examine these files to understand the application structure: +@app.py - Main Flask application with all routes and validation logic +@config.py - Configuration settings and validation constants +@templates/*.html - HTML templates for all 5 pages + + + + + +Create comprehensive tests covering: + +**1. Configuration and Setup Tests** (`test_config.py`) +- Verify configuration values are loaded correctly +- Test default configuration values +- Validate file path helpers + +**2. Validation Function Tests** (`test_validation.py`) +- Email validation (valid/invalid formats) +- Phone number validation (international format, with/without spaces) +- Birth year validation (range checking, 4-digit requirement) +- ZIP code validation (numeric, length limits) +- File extension validation (allowed/disallowed types) +- Edge cases for all validators + +**3. Route Tests** (`test_routes.py`) +- Test all 5 pages load correctly +- Test form submissions with valid data +- Test form submissions with invalid data +- Test redirect logic between pages +- Test session creation and session ID generation +- Test resume functionality (loading existing sessions) +- Test file upload endpoint +- Test file removal endpoint +- Test final submission + +**4. Data Storage Tests** (`test_storage.py`) +- Test YAML file creation and reading +- Test data persistence across page transitions +- Test Unicode character handling (German umlauts: ü, ä, ö) +- Test application folder structure creation +- Test attachment folder management + +**5. File Upload Tests** (`test_uploads.py`) +- Test successful file uploads +- Test file size validation (max 4 MB) +- Test file count limit (max 3 files) +- Test file type validation +- Test filename sanitization +- Test file removal +- Test edge cases (empty files, oversized files) + +**6. Email Functionality Tests** (`test_email.py`) +- Test email sending with mocked SMTP +- Test resume link generation +- Test email content and formatting +- Test email failure handling + +**7. Integration Tests** (`test_integration.py`) +- Test complete workflow from start to finish +- Test resume at each page +- Test data persistence throughout workflow +- Test multiple concurrent applications (different session IDs) + + + + +Create pytest fixtures in `conftest.py`: +- `app` - Flask test app with testing configuration +- `client` - Flask test client for making requests +- `sample_application_data` - Sample YAML data for testing +- `temp_applications_dir` - Temporary directory for test data (auto-cleanup) +- `mock_mail` - Mocked Flask-Mail for email testing +- `sample_files` - Sample files for upload testing (PDF, image) + + + +- Use pytest fixtures for setup and teardown +- Mock external dependencies (SMTP, file system where appropriate) +- Test both success and failure scenarios +- Use parametrized tests for similar test cases with different inputs +- Ensure tests are isolated and don't affect each other +- Clean up test data after each test +- Use descriptive test names that explain what is being tested +- Add docstrings to complex tests + + + + + + +**Project Structure** +Create the following test structure: +``` +./tests/ +├── __init__.py +├── conftest.py # Shared fixtures +├── test_config.py # Configuration tests +├── test_validation.py # Validation function tests +├── test_routes.py # Route and form tests +├── test_storage.py # YAML and file storage tests +├── test_uploads.py # File upload tests +├── test_email.py # Email functionality tests +├── test_integration.py # End-to-end integration tests +└── fixtures/ # Test data files + ├── sample.pdf # Sample PDF for upload tests + └── sample.jpg # Sample image for upload tests +``` + +**Testing Configuration** +- Use `app.config['TESTING'] = True` +- Override `APPLICATIONS_FOLDER` to use a temporary directory +- Disable email sending by default (use mocks) +- Set predictable `SECRET_KEY` for testing + +**Key Testing Patterns** + +1. **Testing Routes with Flask Test Client**: + - Use `client.get()` and `client.post()` to test endpoints + - Check response status codes (200, 302 for redirects, etc.) + - Verify flash messages appear correctly + - Check redirects go to the correct pages + +2. **Testing Data Persistence**: + - Submit form data + - Verify YAML file is created with correct data + - Load data and verify it matches what was submitted + - Test resume functionality loads the correct data + +3. **Testing File Uploads**: + - Use `BytesIO` to create test files + - Set proper content type headers + - Verify files are saved to correct location + - Test file size and type validation + +4. **Testing Session Flow**: + - Track session IDs through the workflow + - Verify `current_page` is updated correctly + - Test resume redirects to appropriate page + +**What to Avoid and Why**: +- Don't test Flask framework internals - focus on application logic +- Don't create actual files in the project directory - use temp directories to avoid cluttering the workspace +- Don't skip cleanup - use fixtures with teardown to remove test data, preventing test pollution +- Don't hardcode paths - use `app.config` values to ensure tests work in any environment + + + +Create the following files with relative paths: + +1. `./tests/__init__.py` - Empty file to make tests a package +2. `./tests/conftest.py` - Pytest configuration and shared fixtures +3. `./tests/test_config.py` - Configuration tests +4. `./tests/test_validation.py` - Validation function tests +5. `./tests/test_routes.py` - Route and form submission tests +6. `./tests/test_storage.py` - YAML and file storage tests +7. `./tests/test_uploads.py` - File upload functionality tests +8. `./tests/test_email.py` - Email sending tests with mocks +9. `./tests/test_integration.py` - End-to-end integration tests +10. `./tests/fixtures/sample.pdf` - Sample PDF file for testing (can be minimal) +11. `./tests/fixtures/sample.jpg` - Sample image file for testing (can be minimal) +12. `./pytest.ini` - Pytest configuration file +13. Update `./requirements.txt` - Add pytest and testing dependencies + +Include in requirements.txt: +- pytest==7.4.3 +- pytest-flask==1.3.0 +- pytest-cov==4.1.0 (for coverage reports) + + + +Before declaring complete, verify your test suite: + +1. **Run all tests and ensure they pass**: + ```bash + pytest -v + ``` + +2. **Check test coverage**: + ```bash + pytest --cov=app --cov=config --cov-report=term-missing + ``` + Aim for >80% coverage of app.py and config.py + +3. **Test specific modules**: + ```bash + pytest tests/test_validation.py -v + pytest tests/test_routes.py -v + pytest tests/test_integration.py -v + ``` + +4. **Verify fixtures work correctly**: + - Test data is created in temporary directories + - All temp data is cleaned up after tests + - Mock email doesn't actually send emails + +5. **Run a sample of critical test scenarios manually**: + - Validation tests catch invalid inputs + - Integration tests complete full workflow + - File upload tests validate size and type limits + + + + +- All test files created and organized properly +- pytest.ini configuration file exists +- requirements.txt updated with testing dependencies +- Comprehensive fixtures in conftest.py +- All validators have corresponding tests with edge cases +- All routes have tests for success and error scenarios +- File upload validation is thoroughly tested +- Integration test covers complete application workflow +- All tests pass when run with `pytest -v` +- Test coverage is >80% for core application files (app.py, config.py) +- Tests are isolated and can run in any order +- Test data is properly cleaned up after execution +- German text and Unicode characters are tested +- Mock email functionality works correctly + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..8e4d5fc --- /dev/null +++ b/pytest.ini @@ -0,0 +1,12 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --strict-markers + --tb=short +markers = + integration: marks tests as integration tests (deselect with '-m "not integration"') + slow: marks tests as slow (deselect with '-m "not slow"') diff --git a/requirements.txt b/requirements.txt index 6affccd..809c928 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,8 @@ PyYAML==6.0.1 Flask-Mail==0.9.1 python-dotenv==1.0.0 Werkzeug==3.0.1 + +# Testing dependencies +pytest==7.4.3 +pytest-flask==1.3.0 +pytest-cov==4.1.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2ed1e1d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,161 @@ +""" +Pytest configuration and shared fixtures for the Flask job application tests. +""" +import os +import tempfile +import shutil +from io import BytesIO +from datetime import datetime +import pytest +import yaml +from app import app as flask_app +from flask_mail import Mail + + +@pytest.fixture +def app(): + """Create and configure a test Flask application instance.""" + # Create a temporary directory for test applications + temp_dir = tempfile.mkdtemp() + + # Configure app for testing + flask_app.config['TESTING'] = True + flask_app.config['SECRET_KEY'] = 'test-secret-key' + flask_app.config['APPLICATIONS_FOLDER'] = temp_dir + flask_app.config['WTF_CSRF_ENABLED'] = False # Disable CSRF for testing + flask_app.config['MAIL_SUPPRESS_SEND'] = True # Don't actually send emails + + yield flask_app + + # Cleanup: remove temporary directory + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + + +@pytest.fixture +def client(app): + """Create a test client for making requests to the application.""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """Create a test CLI runner.""" + return app.test_cli_runner() + + +@pytest.fixture +def temp_applications_dir(app): + """Get the temporary applications directory for the current test.""" + return app.config['APPLICATIONS_FOLDER'] + + +@pytest.fixture +def sample_application_data(): + """Sample application data for testing.""" + return { + 'session_id': 'test-session-123', + 'email': 'test@example.com', + 'job_name': 'Test Position', + 'current_page': 2, + 'created_at': datetime.now().isoformat(), + 'updated_at': datetime.now().isoformat(), + 'personal_info': { + 'name': 'Müller', + 'firstname': 'Anna', + 'address': 'Hauptstrasse 123', + 'zip_code': '8001', + 'city': 'Zürich', + 'phone': '+41 79 123 45 67', + 'birth_year': '1990', + 'civil_status': 'ledig' + }, + 'motivation_answers': { + 'current_job': 'Software Entwicklerin', + 'motivation': 'Ich möchte in einem innovativen Team arbeiten', + 'qualifications': 'Python, Flask, 5 Jahre Erfahrung', + 'salary': '80000 CHF' + }, + 'uploaded_files': [] + } + + +@pytest.fixture +def sample_pdf_file(): + """Create a sample PDF file for testing uploads.""" + # Create a minimal PDF file + pdf_content = b'%PDF-1.4\n1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000056 00000 n\n0000000115 00000 n\ntrailer<>\nstartxref\n190\n%%EOF' + return BytesIO(pdf_content) + + +@pytest.fixture +def sample_jpg_file(): + """Create a sample JPG file for testing uploads.""" + # Create a minimal JPEG file (1x1 pixel red image) + jpg_content = ( + b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00' + b'\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c' + b'\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c' + b'\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342\xff\xc0\x00\x0b\x08\x00' + b'\x01\x00\x01\x01\x01\x11\x00\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01' + b'\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05' + b'\x06\x07\x08\t\n\x0b\xff\xc4\x00\xb5\x10\x00\x02\x01\x03\x03\x02\x04' + b'\x03\x05\x05\x04\x04\x00\x00\x01}\x01\x02\x03\x00\x04\x11\x05\x12!1A' + b'\x06\x13Qa\x07"q\x142\x81\x91\xa1\x08#B\xb1\xc1\x15R\xd1\xf0$3br\x82' + b'\t\n\x16\x17\x18\x19\x1a%&\'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxy' + b'z\x83\x84\x85\x86\x87\x88\x89\x8a\x92\x93\x94\x95\x96\x97\x98\x99\x9a' + b'\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9' + b'\xba\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xd2\xd3\xd4\xd5\xd6\xd7\xd8' + b'\xd9\xda\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xf1\xf2\xf3\xf4\xf5' + b'\xf6\xf7\xf8\xf9\xfa\xff\xda\x00\x08\x01\x01\x00\x00?\x00\xfb\xfe\x87' + b'\xff\xd9' + ) + return BytesIO(jpg_content) + + +@pytest.fixture +def large_file(): + """Create a file larger than the 4MB limit for testing.""" + # Create a 5MB file + content = b'x' * (5 * 1024 * 1024) + return BytesIO(content) + + +@pytest.fixture +def mock_mail(app, monkeypatch): + """Mock Flask-Mail to prevent actual email sending.""" + class MockMail: + def __init__(self): + self.sent_messages = [] + + def send(self, message): + self.sent_messages.append(message) + + mock = MockMail() + monkeypatch.setattr('app.mail', mock) + return mock + + +@pytest.fixture +def create_test_application(app, temp_applications_dir): + """Factory fixture to create test application data.""" + def _create(session_id='test-123', **kwargs): + """Create a test application with the given session ID and data.""" + from app import save_application_data + + data = { + 'session_id': session_id, + 'email': kwargs.get('email', 'test@example.com'), + 'job_name': kwargs.get('job_name', 'Test Job'), + 'current_page': kwargs.get('current_page', 2), + 'created_at': datetime.now().isoformat(), + 'updated_at': datetime.now().isoformat(), + 'personal_info': kwargs.get('personal_info', {}), + 'motivation_answers': kwargs.get('motivation_answers', {}), + 'uploaded_files': kwargs.get('uploaded_files', []) + } + + save_application_data(session_id, data) + return data + + return _create diff --git a/tests/fixtures/sample.jpg b/tests/fixtures/sample.jpg new file mode 100644 index 0000000..ea9ea23 Binary files /dev/null and b/tests/fixtures/sample.jpg differ diff --git a/tests/fixtures/sample.pdf b/tests/fixtures/sample.pdf new file mode 100644 index 0000000..9c04db5 --- /dev/null +++ b/tests/fixtures/sample.pdf @@ -0,0 +1,12 @@ +%PDF-1.4 +1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000056 00000 n +0000000115 00000 n +trailer<> +startxref +190 +%%EOF \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..f3535e6 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,91 @@ +""" +Tests for configuration settings and helper functions. +""" +import pytest +import os +from config import Config +from app import get_application_path, get_data_file_path, get_attachments_path + + +class TestConfiguration: + """Tests for Config class.""" + + def test_config_has_required_settings(self): + """Test that all required configuration values exist.""" + assert hasattr(Config, 'SECRET_KEY') + assert hasattr(Config, 'MAIL_SERVER') + assert hasattr(Config, 'MAIL_PORT') + assert hasattr(Config, 'MAIL_USE_TLS') + assert hasattr(Config, 'APPLICATIONS_FOLDER') + assert hasattr(Config, 'MAX_FILE_SIZE') + assert hasattr(Config, 'MAX_FILES') + assert hasattr(Config, 'ALLOWED_EXTENSIONS') + assert hasattr(Config, 'MAX_STRING_LENGTH') + assert hasattr(Config, 'MAX_TEXT_AREA_LENGTH') + assert hasattr(Config, 'MAX_ZIP_DIGITS') + assert hasattr(Config, 'MIN_BIRTH_YEAR') + assert hasattr(Config, 'MAX_BIRTH_YEAR') + + def test_file_size_limit(self): + """Test that file size limit is set correctly.""" + assert Config.MAX_FILE_SIZE == 4 * 1024 * 1024 # 4 MB + + def test_max_files_limit(self): + """Test that maximum number of files is set correctly.""" + assert Config.MAX_FILES == 3 + + def test_allowed_extensions(self): + """Test that allowed file extensions are configured.""" + assert isinstance(Config.ALLOWED_EXTENSIONS, set) + assert 'pdf' in Config.ALLOWED_EXTENSIONS + assert 'doc' in Config.ALLOWED_EXTENSIONS + assert 'docx' in Config.ALLOWED_EXTENSIONS + + def test_string_length_limits(self): + """Test that string length limits are set.""" + assert Config.MAX_STRING_LENGTH == 255 + assert Config.MAX_TEXT_AREA_LENGTH == 3000 + assert Config.MAX_ZIP_DIGITS == 10 + + def test_birth_year_range(self): + """Test that birth year range is configured correctly.""" + assert Config.MIN_BIRTH_YEAR == 1940 + assert Config.MAX_BIRTH_YEAR == 2010 + assert Config.MIN_BIRTH_YEAR < Config.MAX_BIRTH_YEAR + + +class TestPathHelpers: + """Tests for path helper functions.""" + + def test_get_application_path(self, app): + """Test get_application_path returns correct path.""" + session_id = 'test-123' + expected_path = os.path.join(app.config['APPLICATIONS_FOLDER'], session_id) + assert get_application_path(session_id) == expected_path + + def test_get_data_file_path(self, app): + """Test get_data_file_path returns correct YAML file path.""" + session_id = 'test-456' + expected_path = os.path.join( + app.config['APPLICATIONS_FOLDER'], + session_id, + 'data.yaml' + ) + assert get_data_file_path(session_id) == expected_path + + def test_get_attachments_path(self, app): + """Test get_attachments_path returns correct attachments directory path.""" + session_id = 'test-789' + expected_path = os.path.join( + app.config['APPLICATIONS_FOLDER'], + session_id, + 'attachments' + ) + assert get_attachments_path(session_id) == expected_path + + def test_path_helpers_with_special_characters(self, app): + """Test path helpers handle session IDs with special characters.""" + session_id = 'test-abc-123-def' + app_path = get_application_path(session_id) + assert session_id in app_path + assert os.path.isabs(app_path) or app_path.startswith('.') diff --git a/tests/test_email.py b/tests/test_email.py new file mode 100644 index 0000000..8d3b981 --- /dev/null +++ b/tests/test_email.py @@ -0,0 +1,91 @@ +""" +Tests for email functionality. +""" +import pytest +from unittest.mock import patch, MagicMock +from flask_mail import Message +from app import send_resume_email + + +class TestEmailSending: + """Tests for email sending functionality.""" + + def test_send_resume_email_success(self, app): + """Test successfully sending a resume email.""" + with patch('app.mail.send') as mock_send: + result = send_resume_email('test@example.com', 'test-session-123', 'Software Developer') + + assert result is True + assert mock_send.called + + def test_send_resume_email_failure(self, app): + """Test handling email sending failure.""" + with patch('app.mail.send') as mock_send: + mock_send.side_effect = Exception('SMTP error') + + result = send_resume_email('test@example.com', 'test-session-123', 'Test Job') + + assert result is False + + def test_resume_email_content(self, app): + """Test that resume email contains correct content.""" + with patch('app.mail.send') as mock_send: + send_resume_email('applicant@example.com', 'abc-123', 'Marketing Manager') + + # Get the Message object that was passed to send() + assert mock_send.called + call_args = mock_send.call_args + message = call_args[0][0] if call_args[0] else None + + if message: + assert isinstance(message, Message) + assert 'applicant@example.com' in message.recipients + assert 'Bewerbung' in message.subject or 'Marketing Manager' in message.subject + + def test_resume_link_in_email(self, app): + """Test that resume link is included in email body.""" + with patch('app.mail.send') as mock_send: + send_resume_email('test@example.com', 'session-xyz', 'Test Position') + + if mock_send.called: + call_args = mock_send.call_args + message = call_args[0][0] if call_args[0] else None + + if message and hasattr(message, 'body'): + assert 'session-xyz' in message.body or '/resume/' in message.body + + +class TestEmailIntegration: + """Tests for email integration with application workflow.""" + + def test_email_sent_on_initial_submission(self, client, app): + """Test that email is sent when user submits their email address.""" + with patch('app.mail.send') as mock_send: + response = client.post('/apply/submit-email', data={ + 'email': 'newuser@example.com', + 'job_name': 'Junior Developer' + }, follow_redirects=True) + + assert response.status_code == 200 + # Email should be sent + assert mock_send.called or app.config.get('MAIL_SUPPRESS_SEND') is True + + def test_email_suppressed_in_testing(self, app): + """Test that email is suppressed during testing.""" + assert app.config['TESTING'] is True + assert app.config.get('MAIL_SUPPRESS_SEND') is True + + +class TestEmailConfiguration: + """Tests for email configuration.""" + + def test_email_config_exists(self, app): + """Test that email configuration is set.""" + assert 'MAIL_SERVER' in app.config + assert 'MAIL_PORT' in app.config + assert 'MAIL_USE_TLS' in app.config + + def test_mail_suppress_send_in_testing(self, app): + """Test that email sending is suppressed in test mode.""" + assert app.config['TESTING'] is True + assert app.config['MAIL_SUPPRESS_SEND'] is True diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..1bf34f6 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,325 @@ +""" +Integration tests for the complete application workflow. +""" +import pytest +from unittest.mock import patch +from app import load_application_data + + +@pytest.mark.integration +class TestCompleteWorkflow: + """Integration tests for the complete application workflow.""" + + def test_complete_application_workflow(self, client, app, sample_pdf_file): + """Test the complete workflow from email to confirmation.""" + # Step 1: Submit email + with patch('app.mail.send'): + response = client.post('/apply/submit-email', data={ + 'email': 'integration@test.com', + 'job_name': 'Integration Test Position' + }, follow_redirects=True) + + assert response.status_code == 200 + + # For testing, create a known session + from app import save_application_data + session_id = 'integration-test-123' + save_application_data(session_id, { + 'session_id': session_id, + 'email': 'integration@test.com', + 'job_name': 'Integration Test Position', + 'current_page': 2, + 'personal_info': {}, + 'motivation_answers': {}, + 'uploaded_files': [] + }) + + # Step 2: Submit personal information + response = client.post(f'/apply/{session_id}/submit-personal', data={ + 'name': 'Schmidt', + 'firstname': 'Thomas', + 'address': 'Bahnhofstrasse 100', + 'zip_code': '8001', + 'city': 'Zürich', + 'phone': '+41 79 999 88 77', + 'birth_year': '1985', + 'civil_status': 'verheiratet' + }, follow_redirects=True) + + assert response.status_code == 200 + + # Verify data was saved + data = load_application_data(session_id) + assert data['personal_info']['name'] == 'Schmidt' + assert data['current_page'] == 3 + + # Step 3: Submit motivation answers + response = client.post(f'/apply/{session_id}/submit-motivation', data={ + 'current_job': 'Senior Software Entwickler bei Tech Corp', + 'motivation': 'Ich möchte Teil eines innovativen Teams werden', + 'qualifications': 'Python, Flask, React, 10 Jahre Erfahrung', + 'salary': '120000 CHF pro Jahr' + }, follow_redirects=True) + + assert response.status_code == 200 + + # Verify motivation was saved + data = load_application_data(session_id) + assert data['motivation_answers']['current_job'] == 'Senior Software Entwickler bei Tech Corp' + assert data['current_page'] == 4 + + # Step 4: Upload a file + sample_pdf_file.seek(0) + response = client.post( + f'/apply/{session_id}/upload-file', + data={'file': (sample_pdf_file, 'lebenslauf.pdf')}, + content_type='multipart/form-data', + follow_redirects=True + ) + + assert response.status_code == 200 + + # Verify file was uploaded + data = load_application_data(session_id) + assert len(data['uploaded_files']) == 1 + assert data['uploaded_files'][0]['original_name'] == 'lebenslauf.pdf' + + # Step 5: Submit final application + response = client.post(f'/apply/{session_id}/submit-application', follow_redirects=True) + + assert response.status_code == 200 + assert b'Vielen Dank' in response.data or b'erfolgreich' in response.data + + # Verify application was marked as submitted + data = load_application_data(session_id) + assert data['current_page'] == 5 + assert data.get('status') == 'submitted' + + def test_workflow_with_resume(self, client, app, create_test_application): + """Test resuming an application at different stages.""" + session_id = 'resume-test-456' + + # Create an application at page 2 (personal info) + create_test_application( + session_id=session_id, + current_page=2, + email='resume@test.com', + job_name='Resume Test Job' + ) + + # Resume should redirect to personal info page + response = client.get(f'/resume/{session_id}', follow_redirects=False) + assert response.status_code == 302 + assert b'/personal' in response.data + + # Fill in personal info + client.post(f'/apply/{session_id}/submit-personal', data={ + 'name': 'Weber', + 'firstname': 'Maria', + 'address': 'Seestrasse 50', + 'zip_code': '8002', + 'city': 'Zürich', + 'phone': '+41 79 888 77 66', + 'birth_year': '1992' + }) + + # Now resume should go to motivation page + response = client.get(f'/resume/{session_id}', follow_redirects=False) + assert response.status_code == 302 + assert b'/motivation' in response.data + + def test_multiple_concurrent_applications(self, client, app): + """Test handling multiple concurrent applications with different sessions.""" + from app import save_application_data + + # Create multiple applications + sessions = [] + for i in range(3): + session_id = f'concurrent-{i}' + sessions.append(session_id) + + save_application_data(session_id, { + 'session_id': session_id, + 'email': f'user{i}@test.com', + 'job_name': f'Job {i}', + 'current_page': 2, + 'personal_info': {}, + 'motivation_answers': {}, + 'uploaded_files': [] + }) + + # Verify all applications are independent + for i, session_id in enumerate(sessions): + data = load_application_data(session_id) + assert data is not None + assert data['email'] == f'user{i}@test.com' + assert data['job_name'] == f'Job {i}' + + # Update one application + client.post(f'/apply/{sessions[1]}/submit-personal', data={ + 'name': 'Test', + 'firstname': 'User', + 'address': 'Street 1', + 'zip_code': '12345', + 'city': 'City', + 'phone': '+41 79 123 45 67', + 'birth_year': '1990' + }) + + # Verify only that application was updated + data0 = load_application_data(sessions[0]) + data1 = load_application_data(sessions[1]) + data2 = load_application_data(sessions[2]) + + assert data0['current_page'] == 2 # Not updated + assert data1['current_page'] == 3 # Updated + assert data2['current_page'] == 2 # Not updated + + +@pytest.mark.integration +class TestWorkflowEdgeCases: + """Integration tests for edge cases in the workflow.""" + + def test_skip_optional_fields(self, client, app): + """Test completing workflow without filling optional fields.""" + from app import save_application_data + + session_id = 'optional-test' + save_application_data(session_id, { + 'session_id': session_id, + 'email': 'optional@test.com', + 'job_name': 'Test Job', + 'current_page': 2, + 'personal_info': {}, + 'motivation_answers': {}, + 'uploaded_files': [] + }) + + # Submit personal info with minimal fields + client.post(f'/apply/{session_id}/submit-personal', data={ + 'name': 'Min', + 'firstname': 'User', + 'address': 'Addr 1', + 'zip_code': '1', + 'city': 'City', + 'phone': '+1 123', + 'birth_year': '1990', + 'civil_status': '' # Optional, empty + }) + + # Submit empty motivation (all fields optional) + client.post(f'/apply/{session_id}/submit-motivation', data={ + 'current_job': '', + 'motivation': '', + 'qualifications': '', + 'salary': '' + }) + + # Submit without files (optional) + response = client.post(f'/apply/{session_id}/submit-application', follow_redirects=True) + + assert response.status_code == 200 + + # Verify application was submitted + data = load_application_data(session_id) + assert data['status'] == 'submitted' + + def test_workflow_with_unicode_data(self, client, app): + """Test workflow with German special characters.""" + from app import save_application_data + + session_id = 'unicode-test' + save_application_data(session_id, { + 'session_id': session_id, + 'email': 'ümlaut@test.com', + 'job_name': 'Geschäftsführer Position', + 'current_page': 2, + 'personal_info': {}, + 'motivation_answers': {}, + 'uploaded_files': [] + }) + + # Submit with German characters + client.post(f'/apply/{session_id}/submit-personal', data={ + 'name': 'Müller', + 'firstname': 'Björn', + 'address': 'Königstrasse 42', + 'zip_code': '80539', + 'city': 'München', + 'phone': '+49 89 123 45 67', + 'birth_year': '1988' + }) + + # Verify data persisted correctly + data = load_application_data(session_id) + assert data['personal_info']['name'] == 'Müller' + assert data['personal_info']['firstname'] == 'Björn' + assert data['personal_info']['city'] == 'München' + assert data['job_name'] == 'Geschäftsführer Position' + + +@pytest.mark.integration +@pytest.mark.slow +class TestDataIntegrity: + """Integration tests for data integrity throughout the workflow.""" + + def test_data_not_lost_between_pages(self, client, app): + """Test that data persists correctly when navigating between pages.""" + from app import save_application_data + + session_id = 'persistence-test' + save_application_data(session_id, { + 'session_id': session_id, + 'email': 'persist@test.com', + 'job_name': 'Persistence Job', + 'current_page': 2, + 'personal_info': {}, + 'motivation_answers': {}, + 'uploaded_files': [] + }) + + # Add personal info + personal_data = { + 'name': 'Persistence', + 'firstname': 'Test', + 'address': 'Test Street 1', + 'zip_code': '12345', + 'city': 'Test City', + 'phone': '+1 555 1234', + 'birth_year': '1990' + } + + client.post(f'/apply/{session_id}/submit-personal', data=personal_data) + + # Verify personal data + data = load_application_data(session_id) + for key, value in personal_data.items(): + assert data['personal_info'][key] == value + + # Add motivation + motivation_data = { + 'current_job': 'Developer', + 'motivation': 'Test motivation', + 'qualifications': 'Test skills', + 'salary': '100k' + } + + client.post(f'/apply/{session_id}/submit-motivation', data=motivation_data) + + # Verify both personal and motivation data still exist + data = load_application_data(session_id) + for key, value in personal_data.items(): + assert data['personal_info'][key] == value + for key, value in motivation_data.items(): + assert data['motivation_answers'][key] == value + + # Submit application + client.post(f'/apply/{session_id}/submit-application') + + # Verify all data still intact after submission + data = load_application_data(session_id) + assert data['email'] == 'persist@test.com' + for key, value in personal_data.items(): + assert data['personal_info'][key] == value + for key, value in motivation_data.items(): + assert data['motivation_answers'][key] == value diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..7b708bc --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,248 @@ +""" +Tests for routes and form submissions in the Flask job application system. +""" +import pytest +from flask import session +from app import load_application_data + + +class TestPage1Email: + """Tests for Page 1: Email capture.""" + + def test_page1_loads(self, client): + """Test that page 1 loads successfully.""" + response = client.get('/apply?job=TestJob') + assert response.status_code == 200 + assert b'Bewerbung' in response.data + assert b'TestJob' in response.data + + def test_page1_default_job_name(self, client): + """Test page 1 with no job parameter.""" + response = client.get('/apply') + assert response.status_code == 200 + assert b'Offene Position' in response.data + + def test_submit_valid_email(self, client, app): + """Test submitting a valid email address.""" + with app.app_context(): + response = client.post('/apply/submit-email', data={ + 'email': 'test@example.com', + 'job_name': 'Software Developer' + }, follow_redirects=False) + + assert response.status_code == 302 # Redirect + assert b'/apply/' in response.data # Redirects to page 2 + + def test_submit_invalid_email(self, client): + """Test submitting an invalid email address.""" + response = client.post('/apply/submit-email', data={ + 'email': 'invalid-email', + 'job_name': 'Test Job' + }, follow_redirects=True) + + assert b'E-Mail' in response.data or b'email' in response.data + + +class TestPage2Personal: + """Tests for Page 2: Personal information.""" + + def test_page2_loads(self, client, create_test_application): + """Test that page 2 loads successfully.""" + session_id = 'test-page2-123' + create_test_application(session_id=session_id) + + response = client.get(f'/apply/{session_id}/personal') + assert response.status_code == 200 + assert b'Pers' in response.data or b'Name' in response.data + + def test_page2_nonexistent_session(self, client): + """Test page 2 with non-existent session.""" + response = client.get('/apply/nonexistent/personal', follow_redirects=True) + assert response.status_code == 200 + # Should redirect to page 1 + + def test_submit_valid_personal_info(self, client, create_test_application): + """Test submitting valid personal information.""" + session_id = 'test-personal-valid' + create_test_application(session_id=session_id, current_page=2) + + response = client.post(f'/apply/{session_id}/submit-personal', data={ + 'name': 'Müller', + 'firstname': 'Anna', + 'address': 'Hauptstrasse 123', + 'zip_code': '8001', + 'city': 'Zürich', + 'phone': '+41 79 123 45 67', + 'birth_year': '1990', + 'civil_status': 'ledig' + }, follow_redirects=False) + + assert response.status_code == 302 # Redirect to page 3 + assert b'/motivation' in response.data + + def test_submit_missing_required_fields(self, client, create_test_application): + """Test submitting personal info with missing required fields.""" + session_id = 'test-personal-missing' + create_test_application(session_id=session_id, current_page=2) + + response = client.post(f'/apply/{session_id}/submit-personal', data={ + 'name': 'Test', + # Missing firstname, address, etc. + }, follow_redirects=True) + + assert response.status_code == 200 + # Should show error messages + + def test_submit_invalid_phone(self, client, create_test_application): + """Test submitting with invalid phone number.""" + session_id = 'test-personal-phone' + create_test_application(session_id=session_id, current_page=2) + + response = client.post(f'/apply/{session_id}/submit-personal', data={ + 'name': 'Test', + 'firstname': 'User', + 'address': 'Street 1', + 'zip_code': '12345', + 'city': 'City', + 'phone': '123456789', # Missing + prefix + 'birth_year': '1990' + }, follow_redirects=True) + + assert response.status_code == 200 + + +class TestPage3Motivation: + """Tests for Page 3: Motivation questions.""" + + def test_page3_loads(self, client, create_test_application): + """Test that page 3 loads successfully.""" + session_id = 'test-page3-123' + create_test_application(session_id=session_id, current_page=3) + + response = client.get(f'/apply/{session_id}/motivation') + assert response.status_code == 200 + assert b'Motivation' in response.data or b'berufliche' in response.data + + def test_submit_valid_motivation(self, client, create_test_application): + """Test submitting valid motivation answers.""" + session_id = 'test-motivation-valid' + create_test_application(session_id=session_id, current_page=3) + + response = client.post(f'/apply/{session_id}/submit-motivation', data={ + 'current_job': 'Software Entwickler', + 'motivation': 'Ich möchte in einem innovativen Team arbeiten', + 'qualifications': 'Python, Flask, 5 Jahre Erfahrung', + 'salary': '80000 CHF' + }, follow_redirects=False) + + assert response.status_code == 302 + assert b'/upload' in response.data + + def test_submit_empty_motivation(self, client, create_test_application): + """Test submitting empty motivation (all optional).""" + session_id = 'test-motivation-empty' + create_test_application(session_id=session_id, current_page=3) + + response = client.post(f'/apply/{session_id}/submit-motivation', data={ + 'current_job': '', + 'motivation': '', + 'qualifications': '', + 'salary': '' + }, follow_redirects=False) + + assert response.status_code == 302 # Should still work + + def test_submit_too_long_text(self, client, create_test_application): + """Test submitting text that exceeds the character limit.""" + session_id = 'test-motivation-long' + create_test_application(session_id=session_id, current_page=3) + + long_text = 'x' * 3001 # Exceeds 3000 character limit + + response = client.post(f'/apply/{session_id}/submit-motivation', data={ + 'current_job': long_text, + 'motivation': '', + 'qualifications': '', + 'salary': '' + }, follow_redirects=True) + + assert response.status_code == 200 + # Should show error about character limit + + +class TestPage4Upload: + """Tests for Page 4: File upload.""" + + def test_page4_loads(self, client, create_test_application): + """Test that page 4 loads successfully.""" + session_id = 'test-page4-123' + create_test_application(session_id=session_id, current_page=4) + + response = client.get(f'/apply/{session_id}/upload') + assert response.status_code == 200 + assert b'Dokument' in response.data or b'hochladen' in response.data + + def test_submit_application(self, client, create_test_application): + """Test submitting the final application.""" + session_id = 'test-submit-app' + create_test_application(session_id=session_id, current_page=4) + + response = client.post(f'/apply/{session_id}/submit-application', follow_redirects=False) + + assert response.status_code == 302 + assert b'/confirmation' in response.data + + +class TestPage5Confirmation: + """Tests for Page 5: Confirmation.""" + + def test_page5_loads(self, client, create_test_application): + """Test that confirmation page loads successfully.""" + session_id = 'test-page5-123' + create_test_application(session_id=session_id, current_page=5, email='test@example.com') + + response = client.get(f'/apply/{session_id}/confirmation') + assert response.status_code == 200 + assert b'Vielen Dank' in response.data or b'erfolgreich' in response.data + + +class TestResumeFunction: + """Tests for resume functionality.""" + + def test_resume_redirects_to_correct_page(self, client, create_test_application): + """Test that resume redirects to the correct page based on current_page.""" + # Test redirect to page 2 + session_id = 'test-resume-page2' + create_test_application(session_id=session_id, current_page=2) + response = client.get(f'/resume/{session_id}', follow_redirects=False) + assert response.status_code == 302 + assert b'/personal' in response.data + + # Test redirect to page 3 + session_id = 'test-resume-page3' + create_test_application(session_id=session_id, current_page=3) + response = client.get(f'/resume/{session_id}', follow_redirects=False) + assert response.status_code == 302 + assert b'/motivation' in response.data + + # Test redirect to page 4 + session_id = 'test-resume-page4' + create_test_application(session_id=session_id, current_page=4) + response = client.get(f'/resume/{session_id}', follow_redirects=False) + assert response.status_code == 302 + assert b'/upload' in response.data + + def test_resume_nonexistent_session(self, client): + """Test resuming with non-existent session ID.""" + response = client.get('/resume/nonexistent-session', follow_redirects=True) + assert response.status_code == 200 + + +class TestIndexRoute: + """Tests for index route.""" + + def test_index_redirects_to_apply(self, client): + """Test that index redirects to the apply page.""" + response = client.get('/', follow_redirects=False) + assert response.status_code == 302 + assert b'/apply' in response.data diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..9d6937c --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,214 @@ +""" +Tests for data storage (YAML files and file system operations). +""" +import pytest +import os +import yaml +from pathlib import Path +from app import ( + save_application_data, + load_application_data, + get_application_path, + get_data_file_path, + get_attachments_path +) + + +class TestYAMLStorage: + """Tests for YAML data storage.""" + + def test_save_application_data(self, app, sample_application_data): + """Test saving application data to YAML file.""" + session_id = sample_application_data['session_id'] + save_application_data(session_id, sample_application_data) + + # Verify file was created + data_file = get_data_file_path(session_id) + assert os.path.exists(data_file) + + # Verify content + with open(data_file, 'r', encoding='utf-8') as f: + loaded_data = yaml.safe_load(f) + + assert loaded_data['session_id'] == session_id + assert loaded_data['email'] == sample_application_data['email'] + assert loaded_data['job_name'] == sample_application_data['job_name'] + + def test_load_application_data(self, app, sample_application_data): + """Test loading application data from YAML file.""" + session_id = sample_application_data['session_id'] + + # Save data first + save_application_data(session_id, sample_application_data) + + # Load and verify + loaded_data = load_application_data(session_id) + assert loaded_data is not None + assert loaded_data['session_id'] == session_id + assert loaded_data['email'] == sample_application_data['email'] + assert loaded_data['personal_info']['name'] == 'Müller' + + def test_load_nonexistent_application(self, app): + """Test loading data for non-existent application.""" + loaded_data = load_application_data('nonexistent-session') + assert loaded_data is None + + def test_unicode_characters_in_yaml(self, app): + """Test that German umlauts and special characters are preserved.""" + session_id = 'test-unicode-123' + data = { + 'session_id': session_id, + 'email': 'test@example.com', + 'job_name': 'Test Job', + 'current_page': 2, + 'personal_info': { + 'name': 'Müller', + 'firstname': 'Jürgen', + 'city': 'München', + 'notes': 'Grüße aus Zürich' + } + } + + save_application_data(session_id, data) + loaded_data = load_application_data(session_id) + + assert loaded_data['personal_info']['name'] == 'Müller' + assert loaded_data['personal_info']['firstname'] == 'Jürgen' + assert loaded_data['personal_info']['city'] == 'München' + assert loaded_data['personal_info']['notes'] == 'Grüße aus Zürich' + + def test_update_existing_application(self, app, sample_application_data): + """Test updating existing application data.""" + session_id = sample_application_data['session_id'] + + # Save initial data + save_application_data(session_id, sample_application_data) + + # Update data + sample_application_data['current_page'] = 3 + sample_application_data['motivation_answers'] = { + 'current_job': 'Developer', + 'motivation': 'Great company' + } + save_application_data(session_id, sample_application_data) + + # Verify update + loaded_data = load_application_data(session_id) + assert loaded_data['current_page'] == 3 + assert 'motivation_answers' in loaded_data + assert loaded_data['motivation_answers']['current_job'] == 'Developer' + + +class TestFolderStructure: + """Tests for application folder structure.""" + + def test_application_folder_created(self, app, sample_application_data): + """Test that application folder is created when saving data.""" + session_id = sample_application_data['session_id'] + save_application_data(session_id, sample_application_data) + + app_path = get_application_path(session_id) + assert os.path.exists(app_path) + assert os.path.isdir(app_path) + + def test_data_yaml_created(self, app, sample_application_data): + """Test that data.yaml file is created.""" + session_id = sample_application_data['session_id'] + save_application_data(session_id, sample_application_data) + + data_file = get_data_file_path(session_id) + assert os.path.exists(data_file) + assert os.path.isfile(data_file) + assert data_file.endswith('data.yaml') + + def test_attachments_folder_path(self, app): + """Test that attachments folder path is correct.""" + session_id = 'test-attachments-123' + attachments_path = get_attachments_path(session_id) + + assert 'attachments' in attachments_path + assert session_id in attachments_path + + +class TestDataPersistence: + """Tests for data persistence across workflow.""" + + def test_data_persists_across_pages(self, app, client): + """Test that data persists as user progresses through pages.""" + # Submit email + response = client.post('/apply/submit-email', data={ + 'email': 'persist@example.com', + 'job_name': 'Persistence Test' + }, follow_redirects=True) + + # Extract session ID from response + # For testing, we'll create a known session + from app import save_application_data + session_id = 'test-persist-123' + save_application_data(session_id, { + 'session_id': session_id, + 'email': 'persist@example.com', + 'job_name': 'Persistence Test', + 'current_page': 2, + 'personal_info': {}, + 'motivation_answers': {}, + 'uploaded_files': [] + }) + + # Submit personal info + client.post(f'/apply/{session_id}/submit-personal', data={ + 'name': 'Test', + 'firstname': 'User', + 'address': 'Street 1', + 'zip_code': '12345', + 'city': 'City', + 'phone': '+41 79 123 45 67', + 'birth_year': '1990' + }) + + # Load and verify personal info was saved + loaded_data = load_application_data(session_id) + assert loaded_data is not None + assert loaded_data['personal_info']['name'] == 'Test' + assert loaded_data['personal_info']['firstname'] == 'User' + assert loaded_data['current_page'] == 3 # Should be updated + + # Submit motivation + client.post(f'/apply/{session_id}/submit-motivation', data={ + 'current_job': 'Developer', + 'motivation': 'Love coding', + 'qualifications': 'Python expert', + 'salary': '100000' + }) + + # Load and verify motivation was saved + loaded_data = load_application_data(session_id) + assert loaded_data['motivation_answers']['current_job'] == 'Developer' + assert loaded_data['motivation_answers']['motivation'] == 'Love coding' + assert loaded_data['current_page'] == 4 + + def test_empty_optional_fields_preserved(self, app): + """Test that empty optional fields are preserved correctly.""" + session_id = 'test-empty-fields' + data = { + 'session_id': session_id, + 'email': 'test@example.com', + 'job_name': 'Test', + 'current_page': 3, + 'personal_info': { + 'civil_status': '' # Optional, empty + }, + 'motivation_answers': { + 'current_job': '', # Optional, empty + 'motivation': 'Some text', + 'qualifications': '', + 'salary': '' + } + } + + save_application_data(session_id, data) + loaded_data = load_application_data(session_id) + + assert loaded_data['personal_info']['civil_status'] == '' + assert loaded_data['motivation_answers']['current_job'] == '' + assert loaded_data['motivation_answers']['motivation'] == 'Some text' diff --git a/tests/test_uploads.py b/tests/test_uploads.py new file mode 100644 index 0000000..536f03d --- /dev/null +++ b/tests/test_uploads.py @@ -0,0 +1,232 @@ +""" +Tests for file upload functionality. +""" +import pytest +import os +from io import BytesIO +from app import load_application_data, get_attachments_path + + +class TestFileUpload: + """Tests for file upload functionality.""" + + def test_upload_valid_pdf(self, client, create_test_application, sample_pdf_file): + """Test uploading a valid PDF file.""" + session_id = 'test-upload-pdf' + create_test_application(session_id=session_id, current_page=4) + + sample_pdf_file.seek(0) + response = client.post( + f'/apply/{session_id}/upload-file', + data={'file': (sample_pdf_file, 'resume.pdf')}, + content_type='multipart/form-data', + follow_redirects=True + ) + + assert response.status_code == 200 + # Verify file was added to data + loaded_data = load_application_data(session_id) + assert len(loaded_data['uploaded_files']) == 1 + assert loaded_data['uploaded_files'][0]['original_name'] == 'resume.pdf' + + def test_upload_valid_jpg(self, client, create_test_application, sample_jpg_file): + """Test uploading a valid JPG image.""" + session_id = 'test-upload-jpg' + create_test_application(session_id=session_id, current_page=4) + + sample_jpg_file.seek(0) + response = client.post( + f'/apply/{session_id}/upload-file', + data={'file': (sample_jpg_file, 'photo.jpg')}, + content_type='multipart/form-data', + follow_redirects=True + ) + + assert response.status_code == 200 + loaded_data = load_application_data(session_id) + assert len(loaded_data['uploaded_files']) == 1 + + def test_upload_multiple_files(self, client, create_test_application, sample_pdf_file, sample_jpg_file): + """Test uploading multiple files.""" + session_id = 'test-upload-multiple' + create_test_application(session_id=session_id, current_page=4) + + # Upload first file + sample_pdf_file.seek(0) + client.post( + f'/apply/{session_id}/upload-file', + data={'file': (sample_pdf_file, 'file1.pdf')}, + content_type='multipart/form-data' + ) + + # Upload second file + sample_jpg_file.seek(0) + client.post( + f'/apply/{session_id}/upload-file', + data={'file': (sample_jpg_file, 'file2.jpg')}, + content_type='multipart/form-data' + ) + + loaded_data = load_application_data(session_id) + assert len(loaded_data['uploaded_files']) == 2 + + def test_upload_exceeds_max_files(self, client, create_test_application, sample_pdf_file): + """Test that uploading more than 3 files is rejected.""" + session_id = 'test-upload-max' + create_test_application( + session_id=session_id, + current_page=4, + uploaded_files=[ + {'original_name': 'file1.pdf', 'stored_name': 'file1.pdf', 'size': 1000}, + {'original_name': 'file2.pdf', 'stored_name': 'file2.pdf', 'size': 1000}, + {'original_name': 'file3.pdf', 'stored_name': 'file3.pdf', 'size': 1000} + ] + ) + + sample_pdf_file.seek(0) + response = client.post( + f'/apply/{session_id}/upload-file', + data={'file': (sample_pdf_file, 'file4.pdf')}, + content_type='multipart/form-data', + follow_redirects=True + ) + + assert response.status_code == 200 + # Should still have 3 files (4th was rejected) + loaded_data = load_application_data(session_id) + assert len(loaded_data['uploaded_files']) == 3 + + def test_upload_invalid_file_type(self, client, create_test_application): + """Test that invalid file types are rejected.""" + session_id = 'test-upload-invalid' + create_test_application(session_id=session_id, current_page=4) + + invalid_file = BytesIO(b'executable content') + response = client.post( + f'/apply/{session_id}/upload-file', + data={'file': (invalid_file, 'virus.exe')}, + content_type='multipart/form-data', + follow_redirects=True + ) + + assert response.status_code == 200 + loaded_data = load_application_data(session_id) + assert len(loaded_data.get('uploaded_files', [])) == 0 + + def test_upload_file_too_large(self, client, create_test_application, large_file): + """Test that files larger than 4MB are rejected.""" + session_id = 'test-upload-large' + create_test_application(session_id=session_id, current_page=4) + + large_file.seek(0) + response = client.post( + f'/apply/{session_id}/upload-file', + data={'file': (large_file, 'large.pdf')}, + content_type='multipart/form-data', + follow_redirects=True + ) + + assert response.status_code == 200 + loaded_data = load_application_data(session_id) + assert len(loaded_data.get('uploaded_files', [])) == 0 + + def test_upload_no_file_selected(self, client, create_test_application): + """Test uploading without selecting a file.""" + session_id = 'test-upload-nofile' + create_test_application(session_id=session_id, current_page=4) + + response = client.post( + f'/apply/{session_id}/upload-file', + data={}, + content_type='multipart/form-data', + follow_redirects=True + ) + + assert response.status_code == 200 + + +class TestFileRemoval: + """Tests for file removal functionality.""" + + def test_remove_uploaded_file(self, client, create_test_application, sample_pdf_file, app): + """Test removing an uploaded file.""" + session_id = 'test-remove-file' + create_test_application(session_id=session_id, current_page=4) + + # Upload a file + sample_pdf_file.seek(0) + client.post( + f'/apply/{session_id}/upload-file', + data={'file': (sample_pdf_file, 'remove_me.pdf')}, + content_type='multipart/form-data' + ) + + # Verify it was uploaded + loaded_data = load_application_data(session_id) + assert len(loaded_data['uploaded_files']) == 1 + + # Remove the file + response = client.post( + f'/apply/{session_id}/remove-file/0', + follow_redirects=True + ) + + assert response.status_code == 200 + loaded_data = load_application_data(session_id) + assert len(loaded_data['uploaded_files']) == 0 + + def test_remove_nonexistent_file(self, client, create_test_application): + """Test removing a file with invalid index.""" + session_id = 'test-remove-invalid' + create_test_application(session_id=session_id, current_page=4) + + response = client.post( + f'/apply/{session_id}/remove-file/999', + follow_redirects=True + ) + + assert response.status_code == 200 + + +class TestFileStorage: + """Tests for file storage on disk.""" + + def test_file_saved_to_attachments_folder(self, client, create_test_application, sample_pdf_file, app): + """Test that uploaded files are saved to the attachments folder.""" + session_id = 'test-file-storage' + create_test_application(session_id=session_id, current_page=4) + + sample_pdf_file.seek(0) + client.post( + f'/apply/{session_id}/upload-file', + data={'file': (sample_pdf_file, 'stored.pdf')}, + content_type='multipart/form-data' + ) + + # Check that file exists in attachments folder + attachments_path = get_attachments_path(session_id) + assert os.path.exists(attachments_path) + + # Check that a file was created + files = os.listdir(attachments_path) + assert len(files) > 0 + + def test_filename_sanitization(self, client, create_test_application, sample_pdf_file): + """Test that filenames are sanitized for security.""" + session_id = 'test-sanitize' + create_test_application(session_id=session_id, current_page=4) + + sample_pdf_file.seek(0) + # Try uploading a file with potentially dangerous name + client.post( + f'/apply/{session_id}/upload-file', + data={'file': (sample_pdf_file, '../../../etc/passwd.pdf')}, + content_type='multipart/form-data' + ) + + loaded_data = load_application_data(session_id) + if len(loaded_data['uploaded_files']) > 0: + # Filename should be sanitized + stored_name = loaded_data['uploaded_files'][0]['stored_name'] + assert '../' not in stored_name + assert '/' not in stored_name.split('_')[-1] # After timestamp diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..a6968cf --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,148 @@ +""" +Tests for validation functions in the Flask job application system. +""" +import pytest +from app import validate_email, validate_phone, validate_year, validate_zip, allowed_file + + +class TestEmailValidation: + """Tests for email validation.""" + + def test_valid_email(self): + """Test that valid email addresses are accepted.""" + assert validate_email('test@example.com') is True + assert validate_email('user.name@domain.co.uk') is True + assert validate_email('test+tag@example.com') is True + assert validate_email('test_123@test-domain.com') is True + + def test_invalid_email(self): + """Test that invalid email addresses are rejected.""" + assert validate_email('') is False + assert validate_email('invalid-email') is False + assert validate_email('@example.com') is False + assert validate_email('test@') is False + assert validate_email('test@domain') is False + assert validate_email('test @example.com') is False + + def test_email_edge_cases(self): + """Test edge cases for email validation.""" + assert validate_email('a@b.co') is True # Minimum valid email + assert validate_email('test@@example.com') is False # Double @ + assert validate_email('test..name@example.com') is True # Double dot in local part + + +class TestPhoneValidation: + """Tests for international phone number validation.""" + + def test_valid_phone_with_spaces(self): + """Test valid phone numbers with spaces.""" + assert validate_phone('+41 79 123 45 67') is True + assert validate_phone('+1 555 123 4567') is True + assert validate_phone('+49 30 12345678') is True + + def test_valid_phone_without_spaces(self): + """Test valid phone numbers without spaces.""" + assert validate_phone('+41791234567') is True + assert validate_phone('+15551234567') is True + assert validate_phone('+493012345678') is True + + def test_invalid_phone(self): + """Test that invalid phone numbers are rejected.""" + assert validate_phone('') is False + assert validate_phone('079 123 45 67') is False # Missing + + assert validate_phone('+41') is False # Too short + assert validate_phone('41791234567') is False # Missing + + assert validate_phone('+') is False # Only + + + def test_phone_edge_cases(self): + """Test edge cases for phone validation.""" + assert validate_phone('+1 123') is True # Minimum length + assert validate_phone('+999 1234567890123456') is True # Long number within limit + + +class TestYearValidation: + """Tests for birth year validation.""" + + def test_valid_years(self): + """Test that valid birth years are accepted.""" + assert validate_year('1940') is True # Minimum + assert validate_year('1990') is True # Middle + assert validate_year('2010') is True # Maximum + + def test_invalid_years_out_of_range(self): + """Test that years outside the valid range are rejected.""" + assert validate_year('1939') is False # Too old + assert validate_year('2011') is False # Too young + assert validate_year('1900') is False + assert validate_year('2025') is False + + def test_invalid_year_format(self): + """Test that invalid year formats are rejected.""" + assert validate_year('') is False + assert validate_year('90') is False # 2 digits + assert validate_year('990') is False # 3 digits + assert validate_year('19900') is False # 5 digits + assert validate_year('abcd') is False # Non-numeric + assert validate_year('199a') is False # Mixed + + +class TestZipValidation: + """Tests for ZIP code validation.""" + + def test_valid_zip_codes(self): + """Test that valid ZIP codes are accepted.""" + assert validate_zip('8001') is True + assert validate_zip('12345') is True + assert validate_zip('1') is True # Single digit + assert validate_zip('1234567890') is True # Max 10 digits + + def test_invalid_zip_codes(self): + """Test that invalid ZIP codes are rejected.""" + assert validate_zip('') is False + assert validate_zip('12345678901') is False # 11 digits (too long) + assert validate_zip('abc') is False # Non-numeric + assert validate_zip('123a5') is False # Mixed + + def test_zip_edge_cases(self): + """Test edge cases for ZIP validation.""" + assert validate_zip('0') is True # Zero is valid + assert validate_zip('00000') is True # Leading zeros + + +class TestFileExtensionValidation: + """Tests for file extension validation.""" + + def test_valid_file_extensions(self): + """Test that files with valid extensions are accepted.""" + assert allowed_file('document.pdf') is True + assert allowed_file('resume.doc') is True + assert allowed_file('cover.docx') is True + assert allowed_file('notes.txt') is True + assert allowed_file('photo.jpg') is True + assert allowed_file('image.jpeg') is True + assert allowed_file('picture.png') is True + + def test_invalid_file_extensions(self): + """Test that files with invalid extensions are rejected.""" + assert allowed_file('script.exe') is False + assert allowed_file('data.zip') is False + assert allowed_file('code.py') is False + assert allowed_file('file.unknown') is False + + def test_file_without_extension(self): + """Test that files without extensions are rejected.""" + assert allowed_file('noextension') is False + assert allowed_file('') is False + + def test_case_insensitive_extensions(self): + """Test that file extension validation is case-insensitive.""" + assert allowed_file('document.PDF') is True + assert allowed_file('document.Pdf') is True + assert allowed_file('image.JPG') is True + assert allowed_file('image.JPEG') is True + + def test_multiple_dots_in_filename(self): + """Test files with multiple dots in the filename.""" + assert allowed_file('my.document.pdf') is True + assert allowed_file('file.name.with.dots.jpg') is True + assert allowed_file('test.tar.gz') is False # .gz not allowed