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

@@ -37,6 +37,41 @@ You are an expert in:
3. **AI-consumable output**: Your designs will be read and implemented by an AI developer subagent, not a human—structure your output for clarity and unambiguous interpretation
4. **Explicit over implicit**: State assumptions clearly; avoid ambiguity
5. **Security by default**: Design with security in mind from the start
6. **Clean upgrade paths**: All designs must support existing installations upgrading seamlessly
## Upgrade Path Requirements
**CRITICAL**: Sneaky Klaus is now deployed in production. All designs must include:
1. **Migration Strategy**: How will database schema changes be applied to existing data?
2. **Data Preservation**: Existing exchanges, participants, and settings must never be lost
3. **Backward Compatibility**: Consider whether old clients/data can work with new code
4. **Rollback Plan**: What happens if an upgrade fails? Can users revert?
### Database Changes
- All schema changes MUST use Alembic migrations (never `db.create_all()`)
- Migrations MUST be reversible (`upgrade()` and `downgrade()` functions)
- New columns on existing tables MUST have defaults or be nullable
- Column renames or type changes require careful data migration
- Document migration steps in design documents
### Breaking Changes
If a breaking change is unavoidable:
1. Document it clearly in the design
2. Provide a migration path for existing data
3. Consider a multi-step migration if needed
4. Increment the MAJOR version number
### Design Document Requirements
Each design MUST include an **Upgrade Considerations** section covering:
- Required database migrations
- Data migration requirements
- Configuration changes
- Breaking changes (if any)
- Rollback procedure
## Output Locations

View File

@@ -26,6 +26,55 @@ You implement features based on designs provided by the architect. You write pro
4. **Stop on errors**: When you encounter failing tests, design inconsistencies, or blockers, stop and report to the coordinator immediately
5. **Clean code**: Follow Python best practices and PEP standards
6. **Mandatory docstrings**: All modules, classes, and functions must have docstrings
7. **Clean upgrade paths**: All changes must support existing production installations
## Upgrade Path Requirements
**CRITICAL**: Sneaky Klaus is deployed in production with real user data. All changes must:
1. **Preserve existing data**: Never lose exchanges, participants, or settings
2. **Use Alembic migrations**: All database schema changes MUST use Alembic
3. **Be reversible**: Migrations must have working `downgrade()` functions
4. **Handle existing data**: New columns must have defaults or be nullable
### Database Migration Rules
```bash
# NEVER use db.create_all() for schema changes
# ALWAYS create migrations with:
uv run alembic revision --autogenerate -m "description of change"
# Review generated migration before committing
# Ensure both upgrade() and downgrade() work correctly
# Test migration on a copy of production data if possible
```
### Migration Best Practices
1. **New columns on existing tables**:
- Must be nullable OR have a server_default
- Example: `sa.Column('new_field', sa.String(100), nullable=True)`
2. **Renaming columns**:
- Use `op.alter_column()` with proper data preservation
- Never drop and recreate
3. **Changing column types**:
- Create new column, migrate data, drop old column
- Or use `op.alter_column()` if type is compatible
4. **Adding constraints**:
- Ensure existing data satisfies the constraint
- May need data cleanup migration first
### Testing Migrations
Before merging, verify:
- [ ] Migration applies cleanly to fresh database
- [ ] Migration applies cleanly to database with existing data
- [ ] Downgrade works correctly
- [ ] Application functions correctly after migration
## Code Style & Standards

27
.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Sneaky Klaus Environment Configuration
#
# Copy this file to .env and customize for your environment.
# NEVER commit .env to version control!
# Required: Secret key for session signing and CSRF protection
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=your-secret-key-here
# Required for production: Resend API key for sending emails
# Get your API key at https://resend.com
# Leave empty for development mode (emails logged to stdout)
RESEND_API_KEY=
# Email sender address (must be from a verified domain in Resend)
EMAIL_FROM=noreply@example.com
# Public URL of your application (used in email links)
# Include protocol, no trailing slash
APP_URL=https://secretsanta.example.com
# Environment mode: 'production' or 'development'
# In development mode:
# - Emails are logged to stdout instead of sent
# - Debug mode is enabled
# - Session cookies don't require HTTPS
FLASK_ENV=production

View File

@@ -0,0 +1,72 @@
# Sneaky Klaus - Example Docker Compose Configuration
#
# Copy this file to docker-compose.yml and customize for your environment.
#
# Quick Start:
# 1. cp docker-compose.example.yml docker-compose.yml
# 2. Create .env file with your configuration (see .env.example)
# 3. docker compose up -d
# 4. Visit http://localhost:8000 to set up your admin account
#
# Upgrading:
# docker compose pull
# docker compose up -d
# (Migrations run automatically on container start)
version: "3.8"
services:
sneaky-klaus:
image: git.thesatelliteoflove.com/phil/sneakyklaus:latest
container_name: sneaky-klaus
restart: unless-stopped
ports:
- "8000:8000"
environment:
# Required: Generate a secure secret key
# Example: python -c "import secrets; print(secrets.token_hex(32))"
- SECRET_KEY=${SECRET_KEY:?SECRET_KEY is required}
# Required for production email sending via Resend
# Get your API key at https://resend.com
- RESEND_API_KEY=${RESEND_API_KEY:-}
# Email sender address (must be verified domain with Resend)
- EMAIL_FROM=${EMAIL_FROM:-noreply@example.com}
# Public URL of your application (used in magic link emails)
- APP_URL=${APP_URL:-http://localhost:8000}
# Environment: 'production' or 'development'
# In development mode, emails are logged to stdout instead of sent
- FLASK_ENV=${FLASK_ENV:-production}
volumes:
# Persistent storage for SQLite database and session files
# Database migrations are applied automatically on container start
- sneaky-klaus-data:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
sneaky-klaus-data:
driver: local
# Optional: Add a reverse proxy for HTTPS
# Example with Traefik:
#
# services:
# sneaky-klaus:
# labels:
# - "traefik.enable=true"
# - "traefik.http.routers.sneaky-klaus.rule=Host(`secretsanta.example.com`)"
# - "traefik.http.routers.sneaky-klaus.tls.certresolver=letsencrypt"
# networks:
# - traefik
#
# networks:
# traefik:
# external: true

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
version: "3.8"
services:
sneaky-klaus:
image: git.thesatelliteoflove.com/phil/sneakyklaus:latest
container_name: sneaky-klaus
restart: unless-stopped
ports:
- "8000:8000"
environment:
# Required: Generate a secure secret key
# Example: python -c "import secrets; print(secrets.token_hex(32))"
- SECRET_KEY=${SECRET_KEY:?SECRET_KEY is required}
# Required for production email sending
- RESEND_API_KEY=${RESEND_API_KEY:-}
# Email sender address (must be verified with Resend)
- EMAIL_FROM=${EMAIL_FROM:-noreply@example.com}
# Public URL of your application (used for magic links)
- APP_URL=${APP_URL:-http://localhost:8000}
# Set to 'development' for testing (logs emails instead of sending)
- FLASK_ENV=${FLASK_ENV:-production}
volumes:
# Persistent storage for SQLite database and sessions
- sneaky-klaus-data:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
sneaky-klaus-data:
driver: local

View File

@@ -0,0 +1,252 @@
# ADR-0006: Participant State Management
**Status**: Accepted
**Date**: 2025-12-22
**Deciders**: Architect
**Phase**: v0.3.0
## Context
Participants need to manage their own profiles and potentially withdraw from exchanges. We need to decide:
1. When can participants update their profiles (name, gift ideas)?
2. When can participants withdraw from an exchange?
3. How should withdrawals be implemented (hard delete vs soft delete)?
4. Should withdrawn participants be visible in any context?
5. Can withdrawn participants re-join the same exchange?
These decisions impact data integrity, user experience, and admin workflows.
## Decision
### 1. Profile Update Rules
**Profile updates are allowed until matching occurs.**
Participants can update their name and gift ideas when the exchange is in:
- `draft` state
- `registration_open` state
- `registration_closed` state
Profile updates are **locked** when the exchange is in:
- `matched` state
- `completed` state
**Rationale**:
- Gift ideas are sent to Secret Santas during match notification
- Allowing changes after matching would create inconsistency (giver sees old version in email)
- Name changes after matching could confuse participants about who they're buying for
- Registration close is admin's signal to finalize participant list, not to lock profiles
- Locking happens at matching, which is the point where profile data is "consumed" by the system
### 2. Withdrawal Rules
**Withdrawals are allowed until registration closes.**
Participants can withdraw when the exchange is in:
- `draft` state
- `registration_open` state
Withdrawals require admin intervention when the exchange is in:
- `registration_closed` state (admin may be configuring exclusions)
- `matched` state (would require re-matching)
- `completed` state (historical record)
**Rationale**:
- Before registration closes: minimal impact, just removes one participant
- After registration closes: admin is likely configuring exclusions or preparing to match
- After matching: re-matching is a significant operation that should be admin-controlled
- Clear deadline (registration close) sets expectations for participants
- Prevents last-minute dropouts that could disrupt matching
### 3. Withdrawal Implementation (Soft Delete)
**Withdrawals use soft delete via `withdrawn_at` timestamp.**
Technical implementation:
- Set `participant.withdrawn_at = datetime.utcnow()` on withdrawal
- Keep participant record in database
- Filter out withdrawn participants in queries: `Participant.withdrawn_at.is_(None)`
- Cascade rules remain unchanged (deleting exchange deletes all participants)
**Rationale**:
- **Audit trail**: Preserves record of who registered and when they withdrew
- **Email uniqueness**: Prevents re-registration with same email in same exchange (see Decision 5)
- **Admin visibility**: Admins can see withdrawal history for troubleshooting
- **Simplicity**: No cascade delete complexity or foreign key violations
- **Existing pattern**: Data model already includes `withdrawn_at` field (v0.2.0 design)
Alternative considered: Hard delete participants on withdrawal
- Rejected: Loses audit trail, allows immediate re-registration (see Decision 5)
- Rejected: Requires careful cascade handling for tokens, exclusions
- Rejected: Complicates participant count tracking
### 4. Withdrawn Participant Visibility
**Withdrawn participants are visible only to admin.**
Visibility rules:
- **Participant list (participant view)**: Withdrawn participants excluded
- **Participant list (admin view)**: Withdrawn participants shown with indicator (e.g., grayed out, "Withdrawn" badge)
- **Participant count**: Counts exclude withdrawn participants
- **Matching algorithm**: Withdrawn participants excluded from matching pool
**Rationale**:
- **Privacy**: Respects participant's decision to withdraw (no public record)
- **Admin needs**: Admin may need to see who withdrew (for follow-up, re-invites, etc.)
- **Clean UX**: Participants see only active participants (less confusing)
- **Data integrity**: Admin view preserves audit trail
### 5. Re-Registration After Withdrawal
**Withdrawn participants cannot re-join the same exchange (with same email).**
Technical enforcement:
- Unique constraint on `(exchange_id, email)` remains in place
- Soft delete doesn't remove the record, so email remains "taken"
- Participant must use a different email to re-register
**Rationale**:
- **Prevents gaming**: Stops participants from withdrawing to see participant list changes, then re-joining
- **Simplifies logic**: No need to handle "re-activation" of withdrawn participants
- **Clear consequence**: Withdrawal is final (as warned in UI)
- **Data integrity**: Each participant registration is a distinct record
Alternative considered: Allow re-activation of withdrawn participants
- Rejected: Complex state transitions (withdrawn → active → withdrawn → active)
- Rejected: Unclear UX (does re-joining restore old profile or create new?)
- Rejected: Enables abuse (withdraw/rejoin cycle)
If participant genuinely needs to rejoin:
- Use a different email address (e.g., alias like user+exchange@example.com)
- Or: Contact admin, who can manually delete the withdrawn record (future admin feature)
### 6. Reminder Preferences After Withdrawal
**Withdrawn participants do not receive reminder emails.**
Technical implementation:
- Reminder job queries exclude withdrawn participants: `withdrawn_at IS NULL`
- Reminder preference persists in database (for audit) but is not used
**Rationale**:
- Withdrawn participants have no match to be reminded about
- Sending reminders would be confusing and violate withdrawal expectations
- Simple filter in reminder job handles this naturally
## Consequences
### Positive
1. **Clear rules**: Participants know when they can update profiles or withdraw
2. **Data integrity**: Matching always uses consistent profile data
3. **Audit trail**: System preserves record of all registrations and withdrawals
4. **Simple implementation**: Soft delete is easier than hard delete + cascades
5. **Privacy**: Withdrawn participants not visible to other participants
6. **Admin control**: Admin retains visibility for troubleshooting
### Negative
1. **No re-join**: Participants who withdraw accidentally must use different email
2. **Email "wastage"**: Withdrawn participants' emails remain "taken" in that exchange
3. **Database growth**: Withdrawn participants remain in database (minimal impact given small datasets)
### Mitigations
1. **Clear warnings**: UI prominently warns that withdrawal is permanent and cannot be undone
2. **Confirmation required**: Withdrawal requires explicit checkbox confirmation
3. **Confirmation email**: Withdrawn participants receive email confirming withdrawal
4. **Admin override** (future): Admin can manually delete withdrawn participants if needed
## Implementation Notes
### State Check Function
```python
def can_update_profile(participant: Participant) -> bool:
"""Check if participant can update their profile."""
exchange = participant.exchange
allowed_states = ['draft', 'registration_open', 'registration_closed']
return exchange.state in allowed_states
def can_withdraw(participant: Participant) -> bool:
"""Check if participant can withdraw from the exchange."""
if participant.withdrawn_at is not None:
return False # Already withdrawn
exchange = participant.exchange
allowed_states = ['draft', 'registration_open']
return exchange.state in allowed_states
```
### Query Pattern for Active Participants
```python
# Get active participants only
active_participants = Participant.query.filter(
Participant.exchange_id == exchange_id,
Participant.withdrawn_at.is_(None)
).all()
# Count active participants
active_count = Participant.query.filter(
Participant.exchange_id == exchange_id,
Participant.withdrawn_at.is_(None)
).count()
```
### Admin View Enhancement (Future)
```python
# Admin can see all participants including withdrawn
all_participants = Participant.query.filter(
Participant.exchange_id == exchange_id
).all()
# Template can check: participant.withdrawn_at is not None
```
## Related Decisions
- [ADR-0002: Authentication Strategy](0002-authentication-strategy.md) - Participant session management
- [ADR-0003: Participant Session Scoping](0003-participant-session-scoping.md) - Session behavior on withdrawal
- [v0.2.0 Data Model](../designs/v0.2.0/data-model.md) - `withdrawn_at` field design
- [v0.3.0 Participant Self-Management](../designs/v0.3.0/participant-self-management.md) - Implementation details
## Future Considerations
### Phase 6: Admin Participant Management
When implementing admin participant removal (Epic 9):
- Admin should be able to hard delete withdrawn participants (cleanup)
- Admin should be able to remove active participants (sets withdrawn_at + sends notification)
- Admin should see withdrawal history in participant list
### Phase 8: Matching
Matching algorithm must:
- Filter participants by `withdrawn_at IS NULL`
- Validate participant count >= 3 (after filtering)
- Handle case where withdrawals reduce count below minimum
### Potential Future Enhancement: Re-Activation
If user demand requires allowing re-join:
- Add `reactivated_at` timestamp
- Track withdrawal/reactivation history (audit log)
- Clear `withdrawn_at` on re-activation
- Send re-activation email
- Complexity: High, defer until proven necessary
## References
- [Product Backlog](../BACKLOG.md) - Epic 6: Participant Self-Management
- [Project Overview](../PROJECT_OVERVIEW.md) - Self-management principles
- [v0.2.0 Data Model](../designs/v0.2.0/data-model.md) - Participant table schema
---
**Decision Date**: 2025-12-22
**Architect**: Claude Opus 4.5
**Status**: Accepted for v0.3.0

View File

@@ -0,0 +1,278 @@
# Phase 3 (v0.3.0) Design Documentation
**Version**: 0.3.0
**Date**: 2025-12-22
**Status**: Ready for Implementation
## Quick Start
Phase 3 implements participant self-management features, building on the authentication foundation from Phase 2.
**Core Features**:
- Participant list view (see who else registered)
- Profile updates (name and gift ideas)
- Reminder email preferences
- Participant withdrawal (before registration closes)
## Document Index
### 1. [System Overview](overview.md)
High-level architecture, goals, and design decisions for Phase 3.
**Read this first** to understand:
- What's in scope for v0.3.0
- Key design decisions (profile locks, withdrawal rules)
- Data flow diagrams
- State machine changes
**Key sections**:
- Phase Goals (page 1)
- Key Design Decisions (page 2)
- State Machine Impact (page 4)
### 2. [Participant Self-Management Component Design](participant-self-management.md)
Detailed component specifications for all Phase 3 features.
**Use this for**:
- Exact function signatures and implementations
- Form definitions
- Route specifications
- Template structures
- Email templates
**Key sections**:
- Business Logic Functions (page 1)
- Forms (page 6)
- Routes (page 8)
- Templates (page 11)
- Security Checklist (page 19)
### 3. [Test Plan](test-plan.md)
Comprehensive testing specifications for Phase 3.
**Use this for**:
- Unit test cases and fixtures
- Integration test scenarios
- Acceptance test procedures
- Manual QA steps
- Coverage requirements
**Key sections**:
- Unit Tests (page 1)
- Integration Tests (page 4)
- Acceptance Tests (page 9)
- Edge Cases (page 11)
### 4. [Implementation Guide](implementation-guide.md)
Step-by-step implementation instructions using TDD.
**Follow this to**:
- Implement features in correct order
- Write tests first, then code
- Verify each feature before moving on
- Create proper commits and PRs
**Key sections**:
- Implementation Order (page 1)
- Phase 3.1: Participant List (page 2)
- Phase 3.2: Profile Updates (page 4)
- Phase 3.3: Reminder Preferences (page 7)
- Phase 3.4: Withdrawal (page 9)
- Final Steps (page 14)
## Architecture Decision Records
### [ADR-0006: Participant State Management](../../decisions/0006-participant-state-management.md)
Documents key decisions about when participants can update profiles, withdraw, and how withdrawals are implemented.
**Key decisions**:
- Profile updates allowed until matching
- Withdrawals allowed until registration closes
- Soft delete implementation (withdrawn_at timestamp)
- Withdrawn participants visible only to admin
- No re-registration with same email
## User Stories Implemented
Phase 3 completes these backlog items:
### Epic 4: Participant Registration
-**Story 4.5**: View Participant List (Pre-Matching)
### Epic 6: Participant Self-Management
-**Story 6.1**: Update Profile
-**Story 6.2**: Withdraw from Exchange
-**Story 6.3**: Update Reminder Preferences
## Dependencies
### Prerequisites (from Phase 2)
- ✅ Participant model with `withdrawn_at` field
- ✅ Participant authentication (@participant_required decorator)
- ✅ Participant dashboard route
- ✅ Email service for sending emails
### No New Dependencies
Phase 3 requires **no new**:
- Python packages
- Database migrations
- Environment variables
- External services
## Technical Highlights
### New Files Created
```
src/
utils/participant.py # Business logic functions
services/withdrawal.py # Withdrawal service
templates/
participant/
profile_edit.html # Profile edit page
withdraw.html # Withdrawal confirmation
emails/
participant/
withdrawal_confirmation.html # Withdrawal email
withdrawal_confirmation.txt # Plain text version
tests/
unit/
test_participant_utils.py # Unit tests for business logic
test_withdrawal_service.py # Unit tests for withdrawal
integration/
test_profile_update.py # Profile update integration tests
test_withdrawal.py # Withdrawal integration tests
test_participant_list.py # Participant list tests
test_reminder_preferences.py # Preference update tests
```
### Modified Files
```
src/
routes/participant.py # New routes added
forms/participant.py # New forms added
services/email.py # Withdrawal email method added
templates/
participant/dashboard.html # Enhanced with participant list
```
## Design Principles Applied
Phase 3 adheres to project principles:
1. **Simplicity First**
- No new database tables (uses existing fields)
- No new external dependencies
- Soft delete instead of complex cascade handling
2. **State-Based Permissions**
- Clear rules about when operations are allowed
- Based on exchange state (draft, open, closed, matched)
- Easy to test and reason about
3. **TDD Approach**
- Implementation guide follows test-first methodology
- Every feature has unit and integration tests
- 80%+ coverage maintained
4. **Security by Design**
- All routes require authentication
- State validation prevents unauthorized operations
- CSRF protection on all POST operations
- Input sanitization via WTForms and Jinja2
5. **Privacy-Conscious**
- Participant list shows names only (no emails)
- Withdrawn participants hidden from other participants
- Gift ideas not revealed until matching
## Implementation Checklist
Use this to track progress:
- [ ] **Phase 3.1: Participant List View**
- [ ] Create `src/utils/participant.py`
- [ ] Write unit tests for utility functions
- [ ] Update dashboard route
- [ ] Update dashboard template
- [ ] Write integration tests
- [ ] Manual QA
- [ ] **Phase 3.2: Profile Updates**
- [ ] Add `can_update_profile()` function
- [ ] Write unit tests for state validation
- [ ] Create `ProfileUpdateForm`
- [ ] Create profile edit route
- [ ] Create profile edit template
- [ ] Update dashboard with edit link
- [ ] Write integration tests
- [ ] Manual QA
- [ ] **Phase 3.3: Reminder Preferences**
- [ ] Create `ReminderPreferenceForm`
- [ ] Create preference update route
- [ ] Update dashboard with preference form
- [ ] Write integration tests
- [ ] Manual QA
- [ ] **Phase 3.4: Withdrawal**
- [ ] Add `can_withdraw()` function
- [ ] Create `src/services/withdrawal.py`
- [ ] Write unit tests for withdrawal service
- [ ] Create `WithdrawForm`
- [ ] Create withdrawal route
- [ ] Create email templates
- [ ] Update email service
- [ ] Create withdrawal template
- [ ] Update dashboard with withdraw link
- [ ] Write integration tests
- [ ] Manual QA
- [ ] **Final Steps**
- [ ] Run all tests (≥ 80% coverage)
- [ ] Run linting and type checking
- [ ] Complete manual QA from test plan
- [ ] Update documentation if needed
- [ ] Create feature branch
- [ ] Commit changes
- [ ] Create pull request
## Success Criteria
Phase 3 is complete when:
1. ✅ All user stories have passing acceptance tests
2. ✅ Code coverage ≥ 80%
3. ✅ All linting and type checking passes
4. ✅ Manual QA completed
5. ✅ Security checklist verified
6. ✅ Accessibility tests pass
7. ✅ Browser compatibility verified
8. ✅ Phase 2 regression tests still pass
9. ✅ Documentation updated
10. ✅ Pull request approved and merged
## What's Next: Phase 4
After Phase 3 is complete, the next logical phase is:
**Phase 4 (v0.4.0)**: Post-Matching Participant Experience (Epic 11)
- View assigned recipient (match assignment)
- View recipient's gift ideas
- View exchange information post-matching
- Participant list (post-matching version)
This builds on Phase 3's participant dashboard foundation and enables the core Secret Santa experience after admin has matched participants.
## Questions?
- Review the [Project Overview](../../PROJECT_OVERVIEW.md) for product vision
- Check the [Backlog](../../BACKLOG.md) for user stories
- See [v0.2.0 Design](../v0.2.0/overview.md) for foundation architecture
- Consult existing [ADRs](../../decisions/) for architectural context
---
**Phase 3 Design Status**: ✅ Complete and Ready for Implementation

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,566 @@
# System Overview - v0.3.0
**Version**: 0.3.0
**Date**: 2025-12-22
**Status**: Phase 3 Design
## Introduction
This document describes the design for Phase 3 of Sneaky Klaus, building on the participant authentication foundation established in v0.2.0. This phase focuses on participant self-management capabilities and pre-matching participant experience.
**Phase 3 Scope**: Implement Epic 6 (Participant Self-Management) and Epic 4.5 (View Participant List Pre-Matching), enabling participants to manage their own profiles and see who else has registered before matching occurs.
## Phase Goals
The primary goals for v0.3.0 are:
1. **Enable participant self-service**: Allow participants to update their profiles without admin intervention
2. **Support graceful withdrawals**: Handle participants who need to drop out before matching
3. **Build social engagement**: Let participants see who else is participating (pre-matching only)
4. **Maintain data integrity**: Ensure profile changes and withdrawals don't break the exchange flow
## User Stories in Scope
### Epic 6: Participant Self-Management
- **6.1 Update Profile**: Participants can edit their name and gift ideas before matching
- **6.2 Withdraw from Exchange**: Participants can opt out before registration closes
- **6.3 Update Reminder Preferences**: Participants can toggle reminder emails on/off
### Epic 4: Participant Registration (Continuation)
- **4.5 View Participant List (Pre-Matching)**: Registered participants can see who else has joined
## Out of Scope for v0.3.0
The following features are explicitly **not** included in this phase:
- Post-matching participant experience (Epic 11) - deferred to Phase 4
- Matching system (Epic 8) - deferred to Phase 5
- Admin exchange management beyond what v0.1.0 provided
- Participant removal by admin (Epic 9) - deferred to Phase 6
- Notification emails beyond registration confirmation (Epic 10) - partial in Phase 2, remainder in Phase 7
## Architecture Overview
Phase 3 builds incrementally on the v0.2.0 architecture with no new infrastructure components:
```mermaid
graph TB
subgraph "Phase 3 Additions"
ProfileUpdate[Profile Update UI]
WithdrawUI[Withdraw UI]
ParticipantList[Participant List View]
ProfileUpdate --> ParticipantRoutes
WithdrawUI --> ParticipantRoutes
ParticipantList --> ParticipantRoutes
end
subgraph "Existing v0.2.0 Foundation"
ParticipantRoutes[Participant Routes]
ParticipantAuth[Participant Auth Decorator]
ParticipantModel[Participant Model]
ExchangeModel[Exchange Model]
Database[(SQLite)]
ParticipantRoutes --> ParticipantAuth
ParticipantRoutes --> ParticipantModel
ParticipantRoutes --> ExchangeModel
ParticipantModel --> Database
ExchangeModel --> Database
end
```
## Technical Design Principles
This phase adheres to the project's core principles established in v0.1.0 and v0.2.0:
1. **No new database tables**: All features use existing Participant and Exchange models
2. **No new external dependencies**: Pure Flask/SQLAlchemy implementation
3. **Soft deletes**: Withdrawals use `withdrawn_at` timestamp rather than hard deletes
4. **State-based permissions**: Operations restricted based on exchange state
5. **TDD approach**: Write tests first, implement to pass
## Key Design Decisions
### 1. Profile Update Restrictions
**Decision**: Profile updates are only allowed when exchange is in `draft`, `registration_open`, or `registration_closed` states. After matching, profiles are locked.
**Rationale**:
- Gift ideas are sent to givers during match notification - changes after matching would create inconsistency
- Name changes after matching could confuse participants
- Clear state transition prevents data inconsistency
**Implementation**:
```python
def can_update_profile(participant: Participant) -> bool:
"""Check if participant can update their profile."""
exchange = participant.exchange
return exchange.state in ['draft', 'registration_open', 'registration_closed']
```
### 2. Withdrawal Rules
**Decision**: Withdrawals are only allowed before registration closes. After registration closes, admin intervention required.
**Rationale**:
- Pre-closure: minimal impact, just removes one participant
- Post-closure: admin likely already configuring exclusions or has matched
- Post-matching: requires re-match, admin should make this decision
- Clear deadline prevents last-minute dropouts
**Implementation**:
```python
def can_withdraw(participant: Participant) -> bool:
"""Check if participant can withdraw themselves."""
exchange = participant.exchange
return exchange.state in ['draft', 'registration_open']
```
### 3. Withdrawal Implementation (Soft Delete)
**Decision**: Use existing `withdrawn_at` timestamp field, don't delete participant records.
**Rationale**:
- Data model already supports soft deletes (v0.2.0 design)
- Preserves audit trail of who registered
- Prevents re-use of email during same exchange
- Allows admin to see withdrawal history
- Simplifies database constraints (no cascade delete issues)
**Implementation**:
```python
def withdraw_participant(participant: Participant):
"""Mark participant as withdrawn."""
participant.withdrawn_at = datetime.utcnow()
db.session.commit()
```
### 4. Participant List Visibility
**Decision**: Show participant list to all registered participants, but only show names (not emails or gift ideas).
**Rationale**:
- Social aspect: participants want to know who's participating
- Privacy: emails are admin-only
- Security: gift ideas are for givers only (post-matching)
- Pre-matching only: post-matching handled in Phase 4
**Display Rules**:
- Show: participant names, count
- Hide: emails, gift ideas, withdrawn participants
- Filter: exclude withdrawn participants from list
### 5. Reminder Preference Changes
**Decision**: Allow reminder preference changes at any time before exchange completes.
**Rationale**:
- Low-impact change (doesn't affect matching or other participants)
- User preference should be flexible
- No technical reason to restrict after matching
- Allows opting out if circumstances change
**Implementation**: Simple boolean toggle, no state restrictions.
### 6. Form Validation Strategy
**Decision**: Use server-side validation with WTForms, add client-side hints for UX.
**Rationale**:
- Consistent with Phase 2 implementation decisions
- Security: never trust client-side validation
- UX: client-side provides immediate feedback
- Progressive enhancement: works without JavaScript
## Data Flow
### Profile Update Flow
```mermaid
sequenceDiagram
participant P as Participant Browser
participant F as Flask App
participant DB as SQLite Database
P->>F: GET /participant/profile/edit
F->>F: Check @participant_required
F->>DB: Load participant & exchange
F->>F: Check can_update_profile()
F-->>P: Render edit form (or error)
P->>F: POST /participant/profile/edit (name, gift_ideas)
F->>F: Validate form
F->>F: Check can_update_profile()
F->>DB: Update participant record
DB-->>F: Success
F-->>P: Redirect to dashboard with success message
```
### Withdrawal Flow
```mermaid
sequenceDiagram
participant P as Participant Browser
participant F as Flask App
participant DB as SQLite Database
participant E as Email Service
P->>F: POST /participant/withdraw (with confirmation)
F->>F: Check @participant_required
F->>DB: Load participant & exchange
F->>F: Check can_withdraw()
F->>DB: Set withdrawn_at timestamp
DB-->>F: Success
F->>E: Send withdrawal confirmation email
E-->>F: Email sent
F->>F: Clear session (log out participant)
F-->>P: Redirect to public page with confirmation
```
### Participant List Flow
```mermaid
sequenceDiagram
participant P as Participant Browser
participant F as Flask App
participant DB as SQLite Database
P->>F: GET /participant/dashboard
F->>F: Check @participant_required
F->>DB: Load participant's exchange
F->>DB: Query active participants (withdrawn_at IS NULL)
DB-->>F: Participant list (names only)
F-->>P: Render dashboard with participant list
```
## State Machine Impact
Phase 3 doesn't add new exchange states, but adds participant-level state:
```mermaid
stateDiagram-v2
[*] --> Registered: Registration
Registered --> UpdatedProfile: Edit profile
UpdatedProfile --> UpdatedProfile: Edit again
UpdatedProfile --> Withdrawn: Withdraw
Registered --> Withdrawn: Withdraw
UpdatedProfile --> Matched: Admin matches
Registered --> Matched: Admin matches
Withdrawn --> [*]
note right of Withdrawn
Soft delete: withdrawn_at set
Cannot re-activate
Must re-register with new email
end note
note right of UpdatedProfile
Only before matching
Name and gift_ideas editable
end note
```
## Component Design
### Routes (participant_bp)
New routes added to existing `src/routes/participant.py`:
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/participant/profile/edit` | GET, POST | @participant_required | Edit profile (name, gift ideas) |
| `/participant/preferences` | POST | @participant_required | Update reminder preference |
| `/participant/withdraw` | POST | @participant_required | Withdraw from exchange |
Existing routes used:
- `/participant/dashboard` - enhanced to show participant list
### Forms (src/forms/participant.py)
New forms:
**ProfileUpdateForm**:
```python
class ProfileUpdateForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=255)])
gift_ideas = TextAreaField('Gift Ideas', validators=[Length(max=10000)])
submit = SubmitField('Save Changes')
```
**ReminderPreferenceForm**:
```python
class ReminderPreferenceForm(FlaskForm):
reminder_enabled = BooleanField('Send me reminders')
submit = SubmitField('Update Preferences')
```
**WithdrawForm**:
```python
class WithdrawForm(FlaskForm):
confirm = BooleanField('I understand this cannot be undone',
validators=[DataRequired()])
submit = SubmitField('Withdraw from Exchange')
```
### Templates
New templates in `templates/participant/`:
- `profile_edit.html` - Profile update form
- `withdraw.html` - Withdrawal confirmation page (with warnings)
- `participant_list.html` - Reusable component for displaying participant names
Enhanced templates:
- `dashboard.html` - Add participant list section
### Email Templates
New email template in `templates/emails/participant/`:
- `withdrawal_confirmation.html` - Sent when participant withdraws
- `withdrawal_confirmation.txt` - Plain text version
## Security Considerations
### 1. Authorization Checks
Every participant operation must verify:
- User is authenticated as participant (@participant_required)
- User owns the resource (participant_id matches session)
- Exchange state allows the operation (can_update_profile, can_withdraw)
### 2. CSRF Protection
All POST operations require CSRF tokens (WTForms automatic).
### 3. Input Validation
- Name: 1-255 characters, required
- Gift ideas: 0-10,000 characters, optional
- All inputs sanitized via Jinja2 auto-escaping
### 4. Privacy
Participant list shows:
- Names only (public within exchange)
- Not emails (admin-only)
- Not gift ideas (giver-only post-matching)
- Not withdrawn participants (respect withdrawal privacy)
### 5. Rate Limiting
No new rate limiting needed:
- Profile updates: legitimate user operation, no abuse vector
- Withdrawals: self-limiting (can only withdraw once)
- Participant list: read-only, authenticated
## Testing Strategy
### Unit Tests
Test business logic in isolation:
- `can_update_profile()` logic for each exchange state
- `can_withdraw()` logic for each exchange state
- Soft delete implementation (withdrawn_at timestamp)
- Participant list filtering (exclude withdrawn)
### Integration Tests
Test full request/response cycles:
- Profile update happy path
- Profile update when locked (post-matching)
- Withdrawal happy path
- Withdrawal when not allowed
- Participant list rendering
### Test Data Setup
Reuse existing test fixtures from Phase 2, add:
- Multiple participants in same exchange
- Participants in different states (registered, withdrawn)
- Exchanges in different states
### Coverage Target
Maintain 80%+ coverage established in previous phases.
## Error Handling
### Expected Errors
| Scenario | HTTP Status | User Message | Action |
|----------|-------------|--------------|--------|
| Update profile after matching | 400 | "Your profile is locked after matching" | Redirect to dashboard |
| Withdraw after registration closes | 400 | "Withdrawal deadline has passed. Contact admin." | Redirect to dashboard |
| Invalid form data | 400 | Field-specific errors | Re-render form with errors |
| Already withdrawn | 400 | "You have already withdrawn" | Redirect to public page |
### Unexpected Errors
- Database errors: Log, flash generic error, redirect safely
- Missing participant record: Clear session, redirect to landing page
- Orphaned session: Clear session, redirect to landing page
## Performance Considerations
### Database Queries
**Participant List Query**:
```python
# Efficient query with single DB hit
participants = Participant.query.filter(
Participant.exchange_id == exchange_id,
Participant.withdrawn_at.is_(None)
).order_by(Participant.name).all()
```
**Profile Load**:
- Already loaded by @participant_required decorator (uses `g.participant`)
- No additional query needed
### Caching
Not needed for v0.3.0:
- Participant counts are small (3-100 typical)
- Updates are infrequent
- Reads are authenticated (no public caching)
## Migration Requirements
**No database migrations required for v0.3.0.**
All fields already exist:
- `participant.name` - editable
- `participant.gift_ideas` - editable
- `participant.reminder_enabled` - editable
- `participant.withdrawn_at` - set on withdrawal
## Deployment Impact
### Breaking Changes
None. v0.3.0 is fully backward compatible with v0.2.0.
### Configuration Changes
None. No new environment variables or configuration needed.
### Data Migration
None. Existing data fully compatible.
## User Experience Flow
### Participant Journey (Pre-Matching)
1. **Register** (Phase 2)
- Receive confirmation email with magic link
2. **View Dashboard** (Phase 2 + Phase 3)
- See own information
- **NEW**: See list of other participants (names only)
3. **Update Profile** (Phase 3)
- Click "Edit Profile" from dashboard
- Update name or gift ideas
- Save changes
- See confirmation message
4. **Change Reminder Preference** (Phase 3)
- Toggle reminder checkbox on dashboard
- See confirmation message
5. **Withdraw (if needed)** (Phase 3)
- Click "Withdraw" from dashboard
- See warning about permanence
- Confirm withdrawal
- Receive confirmation email
- Logged out and redirected to public page
## Admin Experience Impact
Phase 3 adds visibility for admins:
### Exchange Detail View (Enhancement)
Add to existing exchange detail page:
- Show participant list with withdrawn indicator
- Display: "5 active, 1 withdrawn"
- Allow admin to see withdrawn participants (grayed out)
**Implementation Note**: This is a minor enhancement to existing admin routes, not a major feature. Can be implemented alongside participant features or deferred to Phase 6 (Admin Participant Management).
## Future Considerations
### Phase 4 Dependencies
Phase 3 sets up foundations for Phase 4 (Post-Matching Experience):
- Participant list already implemented (will be reused post-matching)
- Profile lock logic (can_update_profile) prevents changes after matching
- Dashboard structure ready to show match assignment
### Potential Enhancements (Out of Scope)
Not included in v0.3.0 but possible future additions:
- Email notification to admin when participant withdraws (Epic 10.5)
- Allow participants to indicate dietary restrictions or allergies
- Allow participants to add profile pictures
- Allow participants to message admin (anonymous contact form)
## Acceptance Criteria
Phase 3 is complete when:
1. ✅ Participants can update their name and gift ideas before matching
2. ✅ Participants cannot update profile after matching occurs
3. ✅ Participants can withdraw before registration closes
4. ✅ Participants cannot withdraw after registration closes
5. ✅ Withdrawn participants receive confirmation email
6. ✅ Withdrawn participants are logged out and removed from participant list
7. ✅ Participants can view list of other registered participants (names only)
8. ✅ Participant list excludes withdrawn participants
9. ✅ Participants can toggle reminder preferences at any time
10. ✅ All operations require participant authentication
11. ✅ All operations validate exchange state appropriately
12. ✅ All user stories have passing integration tests
13. ✅ Code coverage remains at 80%+
## Implementation Phases
Recommended implementation order (TDD, vertical slices):
### Phase 3.1: Participant List View
- Story 4.5: View Participant List (Pre-Matching)
- Simplest feature, no state changes
- Sets up dashboard enhancements
### Phase 3.2: Profile Updates
- Story 6.1: Update Profile
- Core self-management feature
- Tests state-based permissions
### Phase 3.3: Reminder Preferences
- Story 6.3: Update Reminder Preferences
- Simple toggle, low risk
- Quick win
### Phase 3.4: Withdrawal
- Story 6.2: Withdraw from Exchange
- Most complex (state changes, email, logout)
- Benefits from previous features being solid
## References
- [v0.2.0 System Overview](../v0.2.0/overview.md)
- [v0.2.0 Data Model](../v0.2.0/data-model.md)
- [Product Backlog](../../BACKLOG.md)
- [Project Overview](../../PROJECT_OVERVIEW.md)
- [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)
- [ADR-0003: Participant Session Scoping](../../decisions/0003-participant-session-scoping.md)
---
**End of Phase 3 Design Overview**

File diff suppressed because it is too large Load Diff

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