- 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>
23 KiB
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:
- Setup: Create exchange, register 3 participants (Alice, Bob, Charlie)
- Login as Alice: Request magic link, login
- View Dashboard: Participant list shows "Bob" and "Charlie" (not Alice's own name in list)
- Verify No Emails: Inspect HTML, confirm no email addresses visible
- Register New Participant (Dave): Have Dave register
- Refresh Dashboard: Dave now appears in Alice's participant list
- Bob Withdraws: Have Bob withdraw
- 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:
- Login as Participant: Use magic link to access dashboard
- Click "Edit Profile": Navigate to profile edit page
- Verify Pre-Population: Name and gift ideas fields show current values
- Update Name: Change name from "Alice" to "Alice Smith"
- Update Gift Ideas: Add new gift idea
- Verify Email Not Editable: Email field not present in form
- Save Changes: Submit form
- Verify Success Message: "Your profile has been updated successfully"
- Verify Dashboard Updated: Dashboard shows new name and gift ideas
- Admin Matches Exchange: Admin triggers matching
- Try to Edit Profile: Click "Edit Profile" (if visible)
- 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:
- Login as Participant: Use magic link
- Click "Withdraw from Exchange": Navigate to withdrawal page
- Verify Warning: Warning box shows consequences
- Try Submit Without Confirmation: Leave checkbox unchecked, submit
- Verify Error: Form error requires confirmation
- Check Confirmation Box: Check "I understand..." box
- Submit Withdrawal: Click "Withdraw from Exchange"
- Verify Success Message: "You have been withdrawn..."
- Verify Logged Out: Session cleared, redirected to public page
- Check Email: Withdrawal confirmation email received
- Login as Different Participant: Login as Bob
- Check Participant List: Withdrawn participant (Alice) not in list
- Admin Closes Registration: Admin closes registration
- Try to Withdraw as Bob: Navigate to withdrawal page
- 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:
- Login as Participant: Use magic link
- View Dashboard: Reminder preference checkbox visible
- Verify Current State: Checkbox checked (enabled by default)
- Uncheck Reminder Box: Uncheck "Send me reminders"
- Click "Update Preferences": Submit form
- Verify Success Message: "Reminder emails disabled"
- Refresh Page: Checkbox remains unchecked
- Re-Enable Reminders: Check box, submit
- Verify Success Message: "Reminder emails enabled"
- Admin Matches Exchange: Trigger matching
- 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:
- ✅ All unit tests pass (100% pass rate)
- ✅ All integration tests pass (100% pass rate)
- ✅ Code coverage ≥ 80%
- ✅ All acceptance criteria manually verified
- ✅ No security vulnerabilities found
- ✅ Accessibility tests pass
- ✅ All edge cases handled gracefully
- ✅ Performance benchmarks met
- ✅ Browser compatibility verified
- ✅ Phase 2 regression tests pass
Test Plan Version: 1.0 Last Updated: 2025-12-22 Status: Ready for Implementation