# Test Plan - v0.3.0 **Version**: 0.3.0 **Date**: 2025-12-22 **Status**: Test Specification ## Overview This document defines the comprehensive test plan for Phase 3 (Participant Self-Management). It covers unit tests, integration tests, and acceptance criteria for all user stories in scope. ## Test Pyramid ``` ╱╲ ╱ ╲ E2E Tests (Manual QA) ╱────╲ - Full user journeys ╱ ╲ - Cross-browser testing ╱────────╲ ╱ ╲ Integration Tests (pytest) ╱────────────╲ - Route handlers ╱ ╲ - Database operations ╱────────────────╲ - Email sending ╱──────────────────╲ Unit Tests (pytest) - Business logic functions - Form validation - State checks ``` ## Test Coverage Goals - **Overall coverage**: 80%+ (maintain Phase 2 level) - **Business logic**: 95%+ (pure functions) - **Route handlers**: 80%+ (integration tests) - **Templates**: Manual testing (not measured by coverage) ## 1. Unit Tests ### 1.1 Participant Utility Functions **File**: `tests/unit/test_participant_utils.py` | Test Case | Description | Assertions | |-----------|-------------|------------| | `test_can_update_profile_draft_state` | Profile updates allowed in draft | `can_update_profile() == True` | | `test_can_update_profile_registration_open` | Profile updates allowed when open | `can_update_profile() == True` | | `test_can_update_profile_registration_closed` | Profile updates allowed when closed | `can_update_profile() == True` | | `test_can_update_profile_matched_state` | Profile updates blocked after matching | `can_update_profile() == False` | | `test_can_update_profile_completed_state` | Profile updates blocked when completed | `can_update_profile() == False` | | `test_can_withdraw_draft_state` | Withdrawal allowed in draft | `can_withdraw() == True` | | `test_can_withdraw_registration_open` | Withdrawal allowed when open | `can_withdraw() == True` | | `test_can_withdraw_registration_closed` | Withdrawal blocked when closed | `can_withdraw() == False` | | `test_can_withdraw_matched_state` | Withdrawal blocked after matching | `can_withdraw() == False` | | `test_can_withdraw_already_withdrawn` | Withdrawal blocked if already withdrawn | `can_withdraw() == False` | | `test_get_active_participants` | Returns only non-withdrawn participants | Count and names match | | `test_get_active_participants_empty` | Returns empty list when all withdrawn | `len(participants) == 0` | | `test_get_active_participants_ordered` | Participants ordered by name | Names in alphabetical order | | `test_is_withdrawn_true` | Detects withdrawn participant | `is_withdrawn() == True` | | `test_is_withdrawn_false` | Detects active participant | `is_withdrawn() == False` | **Fixtures needed**: ```python @pytest.fixture def exchange_factory(db): """Factory for creating exchanges in different states.""" def _create(state='draft'): exchange = Exchange( slug=generate_slug(), name='Test Exchange', budget='$25-50', max_participants=50, registration_close_date=datetime.utcnow() + timedelta(days=7), exchange_date=datetime.utcnow() + timedelta(days=14), timezone='UTC', state=state ) db.session.add(exchange) db.session.commit() return exchange return _create @pytest.fixture def participant_factory(db): """Factory for creating participants.""" def _create(exchange=None, withdrawn_at=None): if not exchange: exchange = Exchange(...) # Create default exchange db.session.add(exchange) participant = Participant( exchange_id=exchange.id, name='Test Participant', email='test@example.com', gift_ideas='Test ideas', reminder_enabled=True, withdrawn_at=withdrawn_at ) db.session.add(participant) db.session.commit() return participant return _create ``` ### 1.2 Withdrawal Service **File**: `tests/unit/test_withdrawal_service.py` | Test Case | Description | Assertions | |-----------|-------------|------------| | `test_withdraw_participant_success` | Happy path withdrawal | `withdrawn_at` is set, email called | | `test_withdraw_participant_already_withdrawn` | Raises error if already withdrawn | `WithdrawalError` raised | | `test_withdraw_participant_wrong_state_closed` | Raises error if registration closed | `WithdrawalError` with specific message | | `test_withdraw_participant_wrong_state_matched` | Raises error if already matched | `WithdrawalError` with specific message | | `test_withdraw_participant_database_error` | Handles DB error gracefully | `WithdrawalError` raised, rollback called | | `test_withdraw_participant_email_failure` | Continues if email fails | `withdrawn_at` set, error logged | **Mocking strategy**: ```python @pytest.fixture def mock_email_service(monkeypatch): """Mock EmailService for withdrawal tests.""" mock = Mock() monkeypatch.setattr('src.services.withdrawal.EmailService', lambda: mock) return mock ``` ### 1.3 Form Validation **File**: `tests/unit/test_participant_forms.py` | Test Case | Description | Assertions | |-----------|-------------|------------| | `test_profile_form_valid_data` | Valid name and gift ideas | `form.validate() == True` | | `test_profile_form_name_required` | Name is required | Validation error on name field | | `test_profile_form_name_too_long` | Name max 255 chars | Validation error on name field | | `test_profile_form_gift_ideas_optional` | Gift ideas can be empty | `form.validate() == True` | | `test_profile_form_gift_ideas_too_long` | Gift ideas max 10,000 chars | Validation error on gift_ideas field | | `test_reminder_form_boolean_field` | Accepts boolean value | `form.validate() == True` | | `test_withdraw_form_confirmation_required` | Confirmation required | Validation error on confirm field | | `test_withdraw_form_confirmation_true` | Accepts confirmation | `form.validate() == True` | ## 2. Integration Tests ### 2.1 Profile Update Tests **File**: `tests/integration/test_profile_update.py` | Test Case | Description | Setup | Action | Expected Result | |-----------|-------------|-------|--------|-----------------| | `test_profile_update_get_shows_form` | GET shows edit form | Auth'd participant | GET /participant/profile/edit | 200, form with current values | | `test_profile_update_post_success` | POST updates profile | Auth'd participant | POST with valid data | 302 redirect, flash success, DB updated | | `test_profile_update_name_change` | Name updates in DB | Auth'd participant | POST with new name | Participant.name updated | | `test_profile_update_gift_ideas_change` | Gift ideas update in DB | Auth'd participant | POST with new ideas | Participant.gift_ideas updated | | `test_profile_update_clears_whitespace` | Strips leading/trailing spaces | Auth'd participant | POST with " Name " | Stored as "Name" | | `test_profile_update_locked_after_matching` | Blocked when matched | Matched exchange | GET profile edit | 302 redirect, flash error | | `test_profile_update_form_validation_error` | Invalid data shows errors | Auth'd participant | POST with empty name | 200, form with errors | | `test_profile_update_csrf_required` | CSRF token required | Auth'd participant | POST without CSRF | 400 error | | `test_profile_update_requires_auth` | Auth required | No session | GET profile edit | 302 to login | | `test_profile_update_database_error` | Handles DB failure | Auth'd participant, mock DB error | POST valid data | Flash error, no DB change | **Test helpers**: ```python @pytest.fixture def participant_session(client, participant_factory): """Create authenticated participant session.""" participant = participant_factory() with client.session_transaction() as session: session['user_id'] = participant.id session['user_type'] = 'participant' session['exchange_id'] = participant.exchange_id return participant def get_csrf_token(client, url='/participant/dashboard'): """Extract CSRF token from page.""" response = client.get(url) # Parse HTML and extract token return extract_csrf_token(response.data) ``` ### 2.2 Withdrawal Tests **File**: `tests/integration/test_withdrawal.py` | Test Case | Description | Setup | Action | Expected Result | |-----------|-------------|-------|--------|-----------------| | `test_withdrawal_get_shows_form` | GET shows confirmation page | Auth'd participant | GET /participant/withdraw | 200, form with warnings | | `test_withdrawal_post_success` | POST withdraws participant | Auth'd participant | POST with confirmation | 302 redirect, withdrawn_at set, session cleared | | `test_withdrawal_sends_email` | Email sent on withdrawal | Auth'd participant, mock email | POST with confirmation | Email service called | | `test_withdrawal_clears_session` | Session cleared after withdrawal | Auth'd participant | POST with confirmation | Session empty | | `test_withdrawal_redirects_to_public` | Redirects to registration page | Auth'd participant | POST with confirmation | Redirect to /exchange/{slug}/register | | `test_withdrawal_already_withdrawn` | Detects already withdrawn | Withdrawn participant | GET withdraw | Flash info, redirect | | `test_withdrawal_blocked_after_close` | Blocked when registration closed | Closed exchange | GET withdraw | Flash error, redirect to dashboard | | `test_withdrawal_blocked_after_matching` | Blocked when matched | Matched exchange | GET withdraw | Flash error, redirect to dashboard | | `test_withdrawal_requires_confirmation` | Confirmation checkbox required | Auth'd participant | POST without confirm=True | Form errors | | `test_withdrawal_csrf_required` | CSRF token required | Auth'd participant | POST without CSRF | 400 error | | `test_withdrawal_requires_auth` | Auth required | No session | GET withdraw | 302 to login | | `test_withdrawal_database_error` | Handles DB failure gracefully | Auth'd participant, mock DB error | POST valid data | Flash error, not withdrawn | ### 2.3 Reminder Preference Tests **File**: `tests/integration/test_reminder_preferences.py` | Test Case | Description | Setup | Action | Expected Result | |-----------|-------------|-------|--------|-----------------| | `test_update_preferences_enable` | Enable reminders | Auth'd participant (disabled) | POST reminder_enabled=True | Flash success, DB updated | | `test_update_preferences_disable` | Disable reminders | Auth'd participant (enabled) | POST reminder_enabled=False | Flash success, DB updated | | `test_update_preferences_csrf_required` | CSRF token required | Auth'd participant | POST without CSRF | 400 error | | `test_update_preferences_requires_auth` | Auth required | No session | POST preferences | 302 to login | | `test_update_preferences_database_error` | Handles DB failure | Auth'd participant, mock DB error | POST valid data | Flash error, no change | ### 2.4 Participant List Tests **File**: `tests/integration/test_participant_list.py` | Test Case | Description | Setup | Action | Expected Result | |-----------|-------------|-------|--------|-----------------| | `test_participant_list_shows_all_active` | Shows all active participants | 3 active participants | GET dashboard | All 3 names displayed | | `test_participant_list_excludes_withdrawn` | Hides withdrawn participants | 2 active, 1 withdrawn | GET dashboard | Only 2 names displayed | | `test_participant_list_shows_self_badge` | Marks current user | Auth'd participant | GET dashboard | "You" badge on own name | | `test_participant_list_ordered_by_name` | Alphabetical order | Participants: Zoe, Alice, Bob | GET dashboard | Order: Alice, Bob, Zoe | | `test_participant_list_count_excludes_withdrawn` | Count shows active only | 3 active, 1 withdrawn | GET dashboard | "Participants (3)" | | `test_participant_list_empty_state` | Message when alone | Only current participant | GET dashboard | "No other participants yet" | | `test_participant_list_requires_auth` | Auth required | No session | GET dashboard | 302 to login | ## 3. Acceptance Tests ### 3.1 Story 4.5: View Participant List (Pre-Matching) **Acceptance Criteria**: - ✅ Participant list visible after logging in via magic link - ✅ Shows display names only - ✅ Does not show email addresses - ✅ Does not indicate any match information - ✅ Updates as new participants register **Manual Test Steps**: 1. **Setup**: Create exchange, register 3 participants (Alice, Bob, Charlie) 2. **Login as Alice**: Request magic link, login 3. **View Dashboard**: Participant list shows "Bob" and "Charlie" (not Alice's own name in list) 4. **Verify No Emails**: Inspect HTML, confirm no email addresses visible 5. **Register New Participant (Dave)**: Have Dave register 6. **Refresh Dashboard**: Dave now appears in Alice's participant list 7. **Bob Withdraws**: Have Bob withdraw 8. **Refresh Dashboard**: Bob no longer appears in participant list **Expected Results**: Pass all criteria ### 3.2 Story 6.1: Update Profile **Acceptance Criteria**: - ✅ Edit option available when logged in - ✅ Can update name and gift ideas - ✅ Cannot change email (request admin help) - ✅ Only available before matching occurs - ✅ Confirmation after save **Manual Test Steps**: 1. **Login as Participant**: Use magic link to access dashboard 2. **Click "Edit Profile"**: Navigate to profile edit page 3. **Verify Pre-Population**: Name and gift ideas fields show current values 4. **Update Name**: Change name from "Alice" to "Alice Smith" 5. **Update Gift Ideas**: Add new gift idea 6. **Verify Email Not Editable**: Email field not present in form 7. **Save Changes**: Submit form 8. **Verify Success Message**: "Your profile has been updated successfully" 9. **Verify Dashboard Updated**: Dashboard shows new name and gift ideas 10. **Admin Matches Exchange**: Admin triggers matching 11. **Try to Edit Profile**: Click "Edit Profile" (if visible) 12. **Verify Locked**: Error message "Your profile is locked after matching" **Expected Results**: Pass all criteria ### 3.3 Story 6.2: Withdraw from Exchange **Acceptance Criteria**: - ✅ "Withdraw" option available before registration closes - ✅ Confirmation required - ✅ Participant removed from exchange - ✅ Confirmation email sent - ✅ Admin notified (if notifications enabled) - **Deferred to Phase 7** **Manual Test Steps**: 1. **Login as Participant**: Use magic link 2. **Click "Withdraw from Exchange"**: Navigate to withdrawal page 3. **Verify Warning**: Warning box shows consequences 4. **Try Submit Without Confirmation**: Leave checkbox unchecked, submit 5. **Verify Error**: Form error requires confirmation 6. **Check Confirmation Box**: Check "I understand..." box 7. **Submit Withdrawal**: Click "Withdraw from Exchange" 8. **Verify Success Message**: "You have been withdrawn..." 9. **Verify Logged Out**: Session cleared, redirected to public page 10. **Check Email**: Withdrawal confirmation email received 11. **Login as Different Participant**: Login as Bob 12. **Check Participant List**: Withdrawn participant (Alice) not in list 13. **Admin Closes Registration**: Admin closes registration 14. **Try to Withdraw as Bob**: Navigate to withdrawal page 15. **Verify Blocked**: Error message "Registration has closed" **Expected Results**: Pass all criteria (except admin notification - Phase 7) ### 3.4 Story 6.3: Update Reminder Preferences **Acceptance Criteria**: - ✅ Option to enable/disable reminder emails - ✅ Available before exchange completes - ✅ Changes take effect immediately **Manual Test Steps**: 1. **Login as Participant**: Use magic link 2. **View Dashboard**: Reminder preference checkbox visible 3. **Verify Current State**: Checkbox checked (enabled by default) 4. **Uncheck Reminder Box**: Uncheck "Send me reminders" 5. **Click "Update Preferences"**: Submit form 6. **Verify Success Message**: "Reminder emails disabled" 7. **Refresh Page**: Checkbox remains unchecked 8. **Re-Enable Reminders**: Check box, submit 9. **Verify Success Message**: "Reminder emails enabled" 10. **Admin Matches Exchange**: Trigger matching 11. **Verify Still Available**: Reminder preference form still available post-matching **Expected Results**: Pass all criteria ## 4. Edge Cases and Error Scenarios ### 4.1 Race Conditions | Scenario | Expected Behavior | Test Method | |----------|-------------------|-------------| | Participant withdraws while admin is matching | Withdrawal succeeds if submitted before matching, blocked if submitted after | Manual timing test | | Two participants update profiles simultaneously | Both succeed (no conflicts, different records) | Concurrent requests test | | Participant updates profile while admin closes registration | Both succeed (profile lock is at matching, not close) | Manual timing test | ### 4.2 Data Validation | Scenario | Expected Behavior | Test Method | |----------|-------------------|-------------| | Name with only whitespace | Validation error "Name is required" | Integration test | | Gift ideas exactly 10,000 characters | Accepted | Integration test | | Gift ideas 10,001 characters | Validation error | Integration test | | Name with special characters (é, ñ, 中) | Accepted, displayed correctly | Manual test | | XSS attempt in gift ideas | Escaped in display | Manual test | ### 4.3 Session Handling | Scenario | Expected Behavior | Test Method | |----------|-------------------|-------------| | Session expires during profile edit | Redirect to login on submit | Manual test (wait for expiry) | | Withdraw from different browser tab | Both tabs see withdrawal (one succeeds, one sees "already withdrawn") | Manual test | | Participant deleted by admin while logged in | Next request clears session, redirects to login | Integration test | ## 5. Performance Tests ### 5.1 Query Optimization | Test | Query Count | Execution Time | |------|-------------|----------------| | Load dashboard with 50 participants | 3 queries max (exchange, participant, participant list) | < 100ms | | Update profile | 2 queries (load participant, update) | < 50ms | | Withdraw participant | 3 queries (load, update, email) | < 100ms | **Testing Method**: Use Flask-DebugToolbar or SQLAlchemy query logging ### 5.2 Concurrent Operations | Test | Concurrent Requests | Expected Result | |------|---------------------|-----------------| | 10 participants updating profiles | 10 simultaneous | All succeed, no deadlocks | | 5 participants viewing participant list | 5 simultaneous | All succeed, consistent data | **Testing Method**: Use locust or manual concurrent curl requests ## 6. Browser Compatibility **Supported Browsers** (per project requirements): - Chrome (last 2 versions) - Firefox (last 2 versions) - Safari (last 2 versions) - Edge (last 2 versions) **Manual Tests**: - Profile edit form renders correctly - Character counter works (progressive enhancement) - Withdrawal confirmation checkbox works - CSRF tokens submitted correctly - Flash messages display correctly ## 7. Accessibility Tests **WCAG 2.1 AA Compliance**: | Test | Tool | Expected Result | |------|------|-----------------| | Form labels | axe DevTools | All inputs have associated labels | | Keyboard navigation | Manual | All actions accessible via keyboard | | Screen reader | NVDA/JAWS | Forms and messages announced correctly | | Color contrast | axe DevTools | All text meets 4.5:1 contrast ratio | | Error messages | Manual | Errors linked to fields via ARIA | ## 8. Security Tests ### 8.1 Authentication | Test | Method | Expected Result | |------|--------|-----------------| | Access profile edit without login | GET /participant/profile/edit | 302 redirect to login | | Access withdrawal without login | GET /participant/withdraw | 302 redirect to login | | Use expired session | Set session to past timestamp | Session cleared, redirect to login | ### 8.2 Authorization | Test | Method | Expected Result | |------|--------|-----------------| | Edit another participant's profile | Manipulate user_id in session | Decorator uses session, not URL param - not vulnerable | | Withdraw another participant | Manipulate user_id in session | Decorator uses session, not URL param - not vulnerable | ### 8.3 Input Validation | Test | Method | Expected Result | |------|--------|-----------------| | SQL injection in name field | Submit `'; DROP TABLE participants; --` | Escaped, no SQL execution | | XSS in gift ideas | Submit `` | Escaped, rendered as text | | CSRF attack | POST without token | 400 error | ## 9. Test Automation ### 9.1 CI Pipeline ```yaml # .github/workflows/test.yml (example) name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install uv run: pip install uv - name: Install dependencies run: uv sync - name: Run unit tests run: uv run pytest tests/unit -v --cov - name: Run integration tests run: uv run pytest tests/integration -v --cov --cov-append - name: Coverage report run: uv run coverage report --fail-under=80 ``` ### 9.2 Pre-Commit Hooks ```yaml # .pre-commit-config.yaml repos: - repo: local hooks: - id: pytest name: pytest entry: uv run pytest tests/unit language: system pass_filenames: false always_run: true ``` ## 10. Test Data Management ### 10.1 Fixtures **Location**: `tests/conftest.py` ```python @pytest.fixture def app(): """Create test Flask app.""" app = create_app('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 exchange(db): """Create test exchange.""" exchange = Exchange(...) db.session.add(exchange) db.session.commit() return exchange @pytest.fixture def participant(db, exchange): """Create test participant.""" participant = Participant(exchange=exchange, ...) db.session.add(participant) db.session.commit() return participant ``` ### 10.2 Test Database - Use in-memory SQLite for speed: `sqlite:///:memory:` - Reset between tests: `db.drop_all()` + `db.create_all()` - Factories for generating test data with variations ## 11. Regression Tests Tests from Phase 2 that must still pass: - ✅ Participant registration still works - ✅ Magic link authentication still works - ✅ Participant dashboard loads (now with participant list) - ✅ Admin login still works - ✅ Exchange creation still works ## 12. Success Criteria Phase 3 tests are complete when: 1. ✅ All unit tests pass (100% pass rate) 2. ✅ All integration tests pass (100% pass rate) 3. ✅ Code coverage ≥ 80% 4. ✅ All acceptance criteria manually verified 5. ✅ No security vulnerabilities found 6. ✅ Accessibility tests pass 7. ✅ All edge cases handled gracefully 8. ✅ Performance benchmarks met 9. ✅ Browser compatibility verified 10. ✅ Phase 2 regression tests pass --- **Test Plan Version**: 1.0 **Last Updated**: 2025-12-22 **Status**: Ready for Implementation