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

546 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<script>alert('XSS')</script>` | 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