Files
sneakyklaus/docs/designs/v0.3.0/test-plan.md
Phil Skentelbery 915e77d994 chore: add production deployment config and upgrade path requirements
- Add docker-compose.yml and docker-compose.example.yml for production deployment
- Add .env.example with all required environment variables
- Update architect agent with upgrade path requirements
- Update developer agent with migration best practices
- Add Phase 3 design documents (v0.3.0)
- Add ADR-0006 for participant state management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 19:32:42 -07:00

23 KiB
Raw Blame History

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:

@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:

@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:

@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 <script>alert('XSS')</script> Escaped, rendered as text
CSRF attack POST without token 400 error

9. Test Automation

9.1 CI Pipeline

# .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

# .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

@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