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>
This commit is contained in:
2025-12-22 19:32:42 -07:00
parent 155bd5fcf3
commit 915e77d994
11 changed files with 4272 additions and 0 deletions

View File

@@ -0,0 +1,545 @@
# 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