Compare commits
9 Commits
release/v0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5660d882d6 | |||
| 0941315a70 | |||
| c2b3641d74 | |||
| 4fbb681e03 | |||
| a7902aa623 | |||
| 75378ac769 | |||
| 6e8a7186cf | |||
| 915e77d994 | |||
| 155bd5fcf3 |
@@ -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
|
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
|
4. **Explicit over implicit**: State assumptions clearly; avoid ambiguity
|
||||||
5. **Security by default**: Design with security in mind from the start
|
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
|
## Output Locations
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
5. **Clean code**: Follow Python best practices and PEP standards
|
||||||
6. **Mandatory docstrings**: All modules, classes, and functions must have docstrings
|
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
|
## Code Style & Standards
|
||||||
|
|
||||||
|
|||||||
27
.env.example
Normal file
27
.env.example
Normal 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
|
||||||
72
docker-compose.example.yml
Normal file
72
docker-compose.example.yml
Normal 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
38
docker-compose.yml
Normal 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
|
||||||
252
docs/decisions/0006-participant-state-management.md
Normal file
252
docs/decisions/0006-participant-state-management.md
Normal 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
|
||||||
278
docs/designs/v0.3.0/README.md
Normal file
278
docs/designs/v0.3.0/README.md
Normal 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
|
||||||
1269
docs/designs/v0.3.0/implementation-guide.md
Normal file
1269
docs/designs/v0.3.0/implementation-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
566
docs/designs/v0.3.0/overview.md
Normal file
566
docs/designs/v0.3.0/overview.md
Normal 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**
|
||||||
1141
docs/designs/v0.3.0/participant-self-management.md
Normal file
1141
docs/designs/v0.3.0/participant-self-management.md
Normal file
File diff suppressed because it is too large
Load Diff
545
docs/designs/v0.3.0/test-plan.md
Normal file
545
docs/designs/v0.3.0/test-plan.md
Normal 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
|
||||||
@@ -4,6 +4,22 @@ set -e # Exit on any error
|
|||||||
# Ensure data directory exists
|
# Ensure data directory exists
|
||||||
mkdir -p /app/data
|
mkdir -p /app/data
|
||||||
|
|
||||||
|
echo "=== Sneaky Klaus Container Startup ==="
|
||||||
|
echo "Data directory: /app/data"
|
||||||
|
echo "Database path: /app/data/sneaky-klaus.db"
|
||||||
|
|
||||||
|
# Check if database exists
|
||||||
|
if [ -f /app/data/sneaky-klaus.db ]; then
|
||||||
|
echo "Existing database found ($(ls -lh /app/data/sneaky-klaus.db | awk '{print $5}'))"
|
||||||
|
else
|
||||||
|
echo "No existing database - will be created by migrations"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# List contents of data directory
|
||||||
|
echo "Data directory contents:"
|
||||||
|
ls -la /app/data/ || echo "(empty)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
if uv run alembic upgrade head; then
|
if uv run alembic upgrade head; then
|
||||||
echo "Database migrations completed successfully"
|
echo "Database migrations completed successfully"
|
||||||
@@ -13,5 +29,6 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "Starting application server..."
|
echo "Starting application server..."
|
||||||
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 main:app
|
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 main:app
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ if config.config_file_name is not None:
|
|||||||
# Create minimal Flask app for migrations (without session initialization)
|
# Create minimal Flask app for migrations (without session initialization)
|
||||||
# This avoids Flask-Session trying to create tables before migrations run
|
# This avoids Flask-Session trying to create tables before migrations run
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
# Use resolve() to ensure absolute paths - critical for database persistence
|
||||||
|
BASE_DIR = Path(__file__).parent.parent.resolve()
|
||||||
DATA_DIR = BASE_DIR / "data"
|
DATA_DIR = BASE_DIR / "data"
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ def register_setup_check(app: Flask) -> None:
|
|||||||
"participant.magic_login",
|
"participant.magic_login",
|
||||||
"participant.dashboard",
|
"participant.dashboard",
|
||||||
"participant.logout",
|
"participant.logout",
|
||||||
|
"participant.profile_edit",
|
||||||
|
"participant.update_preferences",
|
||||||
|
"participant.withdraw",
|
||||||
]:
|
]:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ from pathlib import Path
|
|||||||
class Config:
|
class Config:
|
||||||
"""Base configuration class with common settings."""
|
"""Base configuration class with common settings."""
|
||||||
|
|
||||||
# Base paths
|
# Base paths - use resolve() to ensure absolute paths
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent.resolve()
|
||||||
DATA_DIR = BASE_DIR / "data"
|
DATA_DIR = BASE_DIR / "data"
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Forms for participant registration and management."""
|
"""Forms for participant registration and management."""
|
||||||
|
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import BooleanField, EmailField, StringField, TextAreaField
|
from wtforms import BooleanField, EmailField, StringField, SubmitField, TextAreaField
|
||||||
from wtforms.validators import DataRequired, Email, Length
|
from wtforms.validators import DataRequired, Email, Length
|
||||||
|
|
||||||
|
|
||||||
@@ -48,3 +48,48 @@ class MagicLinkRequestForm(FlaskForm):
|
|||||||
Length(max=255, message="Email must be less than 255 characters"),
|
Length(max=255, message="Email must be less than 255 characters"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdateForm(FlaskForm):
|
||||||
|
"""Form for updating participant profile."""
|
||||||
|
|
||||||
|
name = StringField(
|
||||||
|
"Name",
|
||||||
|
validators=[
|
||||||
|
DataRequired(message="Name is required"),
|
||||||
|
Length(min=1, max=255, message="Name must be 1-255 characters"),
|
||||||
|
],
|
||||||
|
description="Your display name (visible to other participants)",
|
||||||
|
)
|
||||||
|
|
||||||
|
gift_ideas = TextAreaField(
|
||||||
|
"Gift Ideas",
|
||||||
|
validators=[
|
||||||
|
Length(max=10000, message="Gift ideas must be less than 10,000 characters")
|
||||||
|
],
|
||||||
|
description="Optional wishlist or gift preferences for your Secret Santa",
|
||||||
|
render_kw={"rows": 6, "maxlength": 10000},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderPreferenceForm(FlaskForm):
|
||||||
|
"""Form for updating reminder email preferences."""
|
||||||
|
|
||||||
|
reminder_enabled = BooleanField(
|
||||||
|
"Send me reminder emails before the exchange date",
|
||||||
|
description="You can change this at any time",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WithdrawForm(FlaskForm):
|
||||||
|
"""Form for confirming withdrawal from exchange.
|
||||||
|
|
||||||
|
Requires explicit confirmation to prevent accidental withdrawals.
|
||||||
|
"""
|
||||||
|
|
||||||
|
confirm = BooleanField(
|
||||||
|
"I understand this cannot be undone and I will need to re-register to rejoin",
|
||||||
|
validators=[DataRequired(message="You must confirm to withdraw")],
|
||||||
|
)
|
||||||
|
|
||||||
|
submit = SubmitField("Withdraw from Exchange")
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ from flask import (
|
|||||||
|
|
||||||
from src.app import db
|
from src.app import db
|
||||||
from src.decorators.auth import participant_required
|
from src.decorators.auth import participant_required
|
||||||
from src.forms.participant import MagicLinkRequestForm, ParticipantRegistrationForm
|
from src.forms.participant import (
|
||||||
|
MagicLinkRequestForm,
|
||||||
|
ParticipantRegistrationForm,
|
||||||
|
ProfileUpdateForm,
|
||||||
|
ReminderPreferenceForm,
|
||||||
|
WithdrawForm,
|
||||||
|
)
|
||||||
from src.models.exchange import Exchange
|
from src.models.exchange import Exchange
|
||||||
from src.models.magic_token import MagicToken
|
from src.models.magic_token import MagicToken
|
||||||
from src.models.participant import Participant
|
from src.models.participant import Participant
|
||||||
@@ -341,10 +347,198 @@ def dashboard(id: int): # noqa: A002
|
|||||||
if not participant:
|
if not participant:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
# Get list of active participants
|
||||||
|
from src.utils.participant import (
|
||||||
|
can_update_profile,
|
||||||
|
can_withdraw,
|
||||||
|
get_active_participants,
|
||||||
|
)
|
||||||
|
|
||||||
|
participants = get_active_participants(exchange.id)
|
||||||
|
can_edit = can_update_profile(participant)
|
||||||
|
can_leave = can_withdraw(participant)
|
||||||
|
|
||||||
|
# Create reminder preference form
|
||||||
|
reminder_form = ReminderPreferenceForm(
|
||||||
|
reminder_enabled=participant.reminder_enabled
|
||||||
|
)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"participant/dashboard.html",
|
"participant/dashboard.html",
|
||||||
exchange=exchange,
|
exchange=exchange,
|
||||||
participant=participant,
|
participant=participant,
|
||||||
|
participants=participants,
|
||||||
|
participant_count=len(participants),
|
||||||
|
can_edit_profile=can_edit,
|
||||||
|
can_withdraw=can_leave,
|
||||||
|
reminder_form=reminder_form,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@participant_bp.route("/participant/profile/edit", methods=["GET", "POST"])
|
||||||
|
@participant_required
|
||||||
|
def profile_edit():
|
||||||
|
"""Edit participant profile (name and gift ideas).
|
||||||
|
|
||||||
|
Only allowed before matching occurs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GET: Profile edit form
|
||||||
|
POST: Redirect to dashboard on success, or re-render form on error
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
# Get participant from session
|
||||||
|
participant = db.session.query(Participant).filter_by(id=session["user_id"]).first()
|
||||||
|
if not participant:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
# Check if profile editing is allowed
|
||||||
|
from src.utils.participant import can_update_profile
|
||||||
|
|
||||||
|
if not can_update_profile(participant):
|
||||||
|
flash(
|
||||||
|
"Your profile is locked after matching. Contact the admin for changes.",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(url_for("participant.dashboard", id=participant.exchange_id))
|
||||||
|
|
||||||
|
# Create form with current values
|
||||||
|
form = ProfileUpdateForm(obj=participant)
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
# Update participant
|
||||||
|
participant.name = form.name.data.strip()
|
||||||
|
participant.gift_ideas = (
|
||||||
|
form.gift_ideas.data.strip() if form.gift_ideas.data else None
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("Your profile has been updated successfully.", "success")
|
||||||
|
return redirect(
|
||||||
|
url_for("participant.dashboard", id=participant.exchange_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
current_app.logger.error(f"Failed to update participant profile: {e}")
|
||||||
|
flash("Failed to update profile. Please try again.", "error")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"participant/profile_edit.html",
|
||||||
|
form=form,
|
||||||
|
participant=participant,
|
||||||
|
exchange=participant.exchange,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@participant_bp.route("/participant/preferences", methods=["POST"])
|
||||||
|
@participant_required
|
||||||
|
def update_preferences():
|
||||||
|
"""Update participant reminder email preferences.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redirect to dashboard
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
participant = db.session.query(Participant).filter_by(id=session["user_id"]).first()
|
||||||
|
if not participant:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
form = ReminderPreferenceForm()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
participant.reminder_enabled = form.reminder_enabled.data
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if form.reminder_enabled.data:
|
||||||
|
flash("Reminder emails enabled.", "success")
|
||||||
|
else:
|
||||||
|
flash("Reminder emails disabled.", "success")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
current_app.logger.error(f"Failed to update preferences: {e}")
|
||||||
|
flash("Failed to update preferences. Please try again.", "error")
|
||||||
|
else:
|
||||||
|
flash("Invalid request.", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("participant.dashboard", id=participant.exchange_id))
|
||||||
|
|
||||||
|
|
||||||
|
@participant_bp.route("/participant/withdraw", methods=["GET", "POST"])
|
||||||
|
@participant_required
|
||||||
|
def withdraw():
|
||||||
|
"""Withdraw from exchange (soft delete).
|
||||||
|
|
||||||
|
GET: Show confirmation page with warnings
|
||||||
|
POST: Process withdrawal, log out, redirect to public page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GET: Withdrawal confirmation page
|
||||||
|
POST: Redirect to exchange registration page
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
participant = db.session.query(Participant).filter_by(id=session["user_id"]).first()
|
||||||
|
if not participant:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
exchange = participant.exchange
|
||||||
|
|
||||||
|
# Check if withdrawal is allowed
|
||||||
|
from src.utils.participant import can_withdraw, is_withdrawn
|
||||||
|
|
||||||
|
if is_withdrawn(participant):
|
||||||
|
flash("You have already withdrawn from this exchange.", "info")
|
||||||
|
return redirect(url_for("participant.register", slug=exchange.slug))
|
||||||
|
|
||||||
|
if not can_withdraw(participant):
|
||||||
|
if exchange.state == "registration_closed":
|
||||||
|
message = "Registration has closed. Please contact the admin to withdraw."
|
||||||
|
else:
|
||||||
|
message = "Withdrawal is no longer available. Please contact the admin."
|
||||||
|
flash(message, "error")
|
||||||
|
return redirect(url_for("participant.dashboard", id=exchange.id))
|
||||||
|
|
||||||
|
# Create withdrawal confirmation form
|
||||||
|
form = WithdrawForm()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
try:
|
||||||
|
# Perform withdrawal
|
||||||
|
from src.services.withdrawal import WithdrawalError, withdraw_participant
|
||||||
|
|
||||||
|
withdraw_participant(participant)
|
||||||
|
|
||||||
|
# Log out participant
|
||||||
|
session.clear()
|
||||||
|
|
||||||
|
flash(
|
||||||
|
"You have been withdrawn from the exchange. "
|
||||||
|
"A confirmation email has been sent.",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
return redirect(url_for("participant.register", slug=exchange.slug))
|
||||||
|
|
||||||
|
except WithdrawalError as e:
|
||||||
|
flash(str(e), "error")
|
||||||
|
return redirect(url_for("participant.dashboard", id=exchange.id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Unexpected error during withdrawal: {e}")
|
||||||
|
flash("An unexpected error occurred. Please try again.", "error")
|
||||||
|
|
||||||
|
# GET request: show confirmation page
|
||||||
|
return render_template(
|
||||||
|
"participant/withdraw.html",
|
||||||
|
form=form,
|
||||||
|
participant=participant,
|
||||||
|
exchange=exchange,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -174,3 +174,53 @@ class EmailService:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
return self.send_email(to=to, subject=subject, html_body=html_body)
|
return self.send_email(to=to, subject=subject, html_body=html_body)
|
||||||
|
|
||||||
|
def send_withdrawal_confirmation(self, participant: Any) -> Any:
|
||||||
|
"""Send withdrawal confirmation email to participant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
participant: The participant who withdrew
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response from email service
|
||||||
|
"""
|
||||||
|
exchange = participant.exchange
|
||||||
|
|
||||||
|
subject = f"Withdrawal Confirmed - {exchange.name}"
|
||||||
|
html_body = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>Withdrawal Confirmed</h2>
|
||||||
|
<p>Hello {participant.name},</p>
|
||||||
|
<p>
|
||||||
|
This email confirms that you have withdrawn from the
|
||||||
|
Secret Santa exchange <strong>{exchange.name}</strong>.
|
||||||
|
</p>
|
||||||
|
<div style="background-color: #f8f9fa;
|
||||||
|
border-left: 4px solid #e74c3c;
|
||||||
|
padding: 15px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0;"><strong>What happens now:</strong></p>
|
||||||
|
<ul style="margin: 10px 0;">
|
||||||
|
<li>You have been removed from the participant list</li>
|
||||||
|
<li>Your profile information has been archived</li>
|
||||||
|
<li>
|
||||||
|
You will not receive further emails about this exchange
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
If you withdrew by mistake, you can re-register using a
|
||||||
|
different email address while registration is still open.
|
||||||
|
</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #ddd;
|
||||||
|
margin: 30px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">
|
||||||
|
This is an automated message from Sneaky Klaus.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.send_email(
|
||||||
|
to=participant.email, subject=subject, html_body=html_body
|
||||||
|
)
|
||||||
|
|||||||
73
src/services/withdrawal.py
Normal file
73
src/services/withdrawal.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Participant withdrawal service."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from src.app import db
|
||||||
|
from src.models.participant import Participant
|
||||||
|
from src.services.email import EmailService
|
||||||
|
from src.utils.participant import can_withdraw
|
||||||
|
|
||||||
|
|
||||||
|
class WithdrawalError(Exception):
|
||||||
|
"""Raised when withdrawal operation fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def withdraw_participant(participant: Participant) -> None:
|
||||||
|
"""Withdraw a participant from their exchange.
|
||||||
|
|
||||||
|
This performs a soft delete by setting withdrawn_at timestamp.
|
||||||
|
Participant record is retained for audit trail.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
participant: The participant to withdraw
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WithdrawalError: If withdrawal is not allowed
|
||||||
|
|
||||||
|
Side effects:
|
||||||
|
- Sets participant.withdrawn_at to current UTC time
|
||||||
|
- Commits database transaction
|
||||||
|
- Sends withdrawal confirmation email
|
||||||
|
"""
|
||||||
|
# Validate withdrawal is allowed
|
||||||
|
if not can_withdraw(participant):
|
||||||
|
if participant.withdrawn_at is not None:
|
||||||
|
raise WithdrawalError("You have already withdrawn from this exchange.")
|
||||||
|
|
||||||
|
exchange = participant.exchange
|
||||||
|
if exchange.state == "registration_closed":
|
||||||
|
raise WithdrawalError(
|
||||||
|
"Registration has closed. Please contact the admin to withdraw."
|
||||||
|
)
|
||||||
|
elif exchange.state in ["matched", "completed"]:
|
||||||
|
raise WithdrawalError(
|
||||||
|
"Matching has already occurred. Please contact the admin."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise WithdrawalError("Withdrawal is not allowed at this time.")
|
||||||
|
|
||||||
|
# Perform withdrawal
|
||||||
|
participant.withdrawn_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
current_app.logger.info(
|
||||||
|
f"Participant {participant.id} withdrawn from "
|
||||||
|
f"exchange {participant.exchange_id}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
current_app.logger.error(f"Failed to withdraw participant: {e}")
|
||||||
|
raise WithdrawalError("Failed to process withdrawal. Please try again.") from e
|
||||||
|
|
||||||
|
# Send confirmation email
|
||||||
|
try:
|
||||||
|
email_service = EmailService()
|
||||||
|
email_service.send_withdrawal_confirmation(participant)
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Failed to send withdrawal email: {e}")
|
||||||
|
# Don't raise - withdrawal already committed
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ exchange.name }}</td>
|
<td>{{ exchange.name }}</td>
|
||||||
<td><mark>{{ exchange.state }}</mark></td>
|
<td><mark>{{ exchange.state }}</mark></td>
|
||||||
<td>0 / {{ exchange.max_participants }}</td>
|
<td>{{ exchange.participants|selectattr('withdrawn_at', 'none')|list|length }} / {{ exchange.max_participants }}</td>
|
||||||
<td>{{ exchange.exchange_date.strftime('%Y-%m-%d') }}</td>
|
<td>{{ exchange.exchange_date.strftime('%Y-%m-%d') }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('admin.view_exchange', exchange_id=exchange.id) }}">View</a>
|
<a href="{{ url_for('admin.view_exchange', exchange_id=exchange.id) }}">View</a>
|
||||||
|
|||||||
@@ -37,8 +37,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h2>Participants</h2>
|
<h2>Participants ({{ exchange.participants|selectattr('withdrawn_at', 'none')|list|length }} / {{ exchange.max_participants }})</h2>
|
||||||
|
{% set active_participants = exchange.participants|selectattr('withdrawn_at', 'none')|list %}
|
||||||
|
{% if active_participants %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Registered</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for participant in active_participants|sort(attribute='name') %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ participant.name }}</td>
|
||||||
|
<td>{{ participant.email }}</td>
|
||||||
|
<td>{{ participant.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
<p>No participants yet.</p>
|
<p>No participants yet.</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
|||||||
@@ -37,8 +37,58 @@
|
|||||||
<dd>{{ participant.gift_ideas }}</dd>
|
<dd>{{ participant.gift_ideas }}</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
{% if can_edit_profile %}
|
||||||
|
<a href="{{ url_for('participant.profile_edit') }}">Edit Profile</a>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Participants ({{ participant_count }})</h2>
|
||||||
|
{% if participants %}
|
||||||
|
<ul>
|
||||||
|
{% for p in participants %}
|
||||||
|
<li>
|
||||||
|
{{ p.name }}
|
||||||
|
{% if p.id == participant.id %}
|
||||||
|
<span class="badge">You</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>No other participants yet. Share the registration link!</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Email Reminders</h2>
|
||||||
|
<form method="POST" action="{{ url_for('participant.update_preferences') }}">
|
||||||
|
{{ reminder_form.hidden_tag() }}
|
||||||
|
<div>
|
||||||
|
{{ reminder_form.reminder_enabled() }}
|
||||||
|
{{ reminder_form.reminder_enabled.label }}
|
||||||
|
</div>
|
||||||
|
{% if reminder_form.reminder_enabled.description %}
|
||||||
|
<small>{{ reminder_form.reminder_enabled.description }}</small>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit">Update Preferences</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if can_withdraw %}
|
||||||
|
<section>
|
||||||
|
<h2>Withdraw from Exchange</h2>
|
||||||
|
<p>
|
||||||
|
If you can no longer participate, you can withdraw from this exchange.
|
||||||
|
This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('participant.withdraw') }}" role="button" class="secondary">
|
||||||
|
Withdraw from Exchange
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<form method="POST" action="{{ url_for('participant.logout') }}">
|
<form method="POST" action="{{ url_for('participant.logout') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|||||||
64
src/templates/participant/profile_edit.html
Normal file
64
src/templates/participant/profile_edit.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Edit Profile - {{ exchange.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>Edit Your Profile</h1>
|
||||||
|
<p>Update your display name and gift ideas. Your Secret Santa will see this information after matching.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('participant.profile_edit') }}">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form.name.label }}
|
||||||
|
{{ form.name(class="form-control") }}
|
||||||
|
{% if form.name.errors %}
|
||||||
|
<ul class="field-errors">
|
||||||
|
{% for error in form.name.errors %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
<small>{{ form.name.description }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form.gift_ideas.label }}
|
||||||
|
{{ form.gift_ideas(class="form-control") }}
|
||||||
|
{% if form.gift_ideas.errors %}
|
||||||
|
<ul class="field-errors">
|
||||||
|
{% for error in form.gift_ideas.errors %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
<small>{{ form.gift_ideas.description }}</small>
|
||||||
|
<small id="gift-ideas-count">
|
||||||
|
{{ (form.gift_ideas.data or '')|length }} / 10,000 characters
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit">Save Changes</button>
|
||||||
|
<a href="{{ url_for('participant.dashboard', id=exchange.id) }}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Character counter (progressive enhancement)
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const textarea = document.querySelector('textarea[name="gift_ideas"]');
|
||||||
|
const counter = document.getElementById('gift-ideas-count');
|
||||||
|
|
||||||
|
if (textarea && counter) {
|
||||||
|
textarea.addEventListener('input', function() {
|
||||||
|
counter.textContent = this.value.length + ' / 10,000 characters';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
47
src/templates/participant/withdraw.html
Normal file
47
src/templates/participant/withdraw.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Withdraw from {{ exchange.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>Withdraw from Exchange</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div role="alert" style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 1rem; margin: 1rem 0;">
|
||||||
|
<h2 style="margin-top: 0;">⚠️ Are you sure?</h2>
|
||||||
|
<p>Withdrawing from this exchange means:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Your registration will be cancelled</li>
|
||||||
|
<li>You will be removed from the participant list</li>
|
||||||
|
<li>You cannot undo this action</li>
|
||||||
|
<li>You will need to re-register with a different email to rejoin</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('participant.withdraw') }}">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
{{ form.confirm() }}
|
||||||
|
{{ form.confirm.label.text }}
|
||||||
|
</label>
|
||||||
|
{% if form.confirm.errors %}
|
||||||
|
<ul style="color: #dc3545; list-style: none; padding: 0;">
|
||||||
|
{% for error in form.confirm.errors %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ form.submit(class="contrast") }}
|
||||||
|
<a href="{{ url_for('participant.dashboard', id=exchange.id) }}" role="button" class="secondary">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
76
src/utils/participant.py
Normal file
76
src/utils/participant.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Participant business logic utilities."""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models import Participant
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_participants(exchange_id: int) -> list["Participant"]:
|
||||||
|
"""Get all active (non-withdrawn) participants for an exchange.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exchange_id: ID of the exchange
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of active participants, ordered by name
|
||||||
|
"""
|
||||||
|
from src.models.participant import Participant
|
||||||
|
|
||||||
|
result: list[Participant] = (
|
||||||
|
Participant.query.filter(
|
||||||
|
Participant.exchange_id == exchange_id, Participant.withdrawn_at.is_(None)
|
||||||
|
)
|
||||||
|
.order_by(Participant.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def is_withdrawn(participant: "Participant") -> bool:
|
||||||
|
"""Check if participant has withdrawn.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
participant: The participant to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if withdrawn, False otherwise
|
||||||
|
"""
|
||||||
|
return participant.withdrawn_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def can_update_profile(participant: "Participant") -> bool:
|
||||||
|
"""Check if participant can update their profile.
|
||||||
|
|
||||||
|
Profile updates are allowed until matching occurs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
participant: The participant to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if profile updates are allowed, False otherwise
|
||||||
|
"""
|
||||||
|
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.
|
||||||
|
|
||||||
|
Withdrawals are only allowed before registration closes.
|
||||||
|
After that, admin intervention is required.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
participant: The participant to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if withdrawal is allowed, False otherwise
|
||||||
|
"""
|
||||||
|
# Already withdrawn
|
||||||
|
if participant.withdrawn_at is not None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
exchange = participant.exchange
|
||||||
|
allowed_states = ["draft", "registration_open"]
|
||||||
|
return exchange.state in allowed_states
|
||||||
@@ -75,3 +75,97 @@ def admin(db):
|
|||||||
db.session.add(admin)
|
db.session.add(admin)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return admin
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def exchange_factory(db):
|
||||||
|
"""Factory for creating test exchanges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Function that creates and returns Exchange instances.
|
||||||
|
"""
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from src.models.exchange import Exchange
|
||||||
|
|
||||||
|
def _create(state="draft", **kwargs):
|
||||||
|
exchange = Exchange(
|
||||||
|
slug=kwargs.get("slug", Exchange.generate_slug()),
|
||||||
|
name=kwargs.get("name", "Test Exchange"),
|
||||||
|
budget=kwargs.get("budget", "$25-50"),
|
||||||
|
max_participants=kwargs.get("max_participants", 50),
|
||||||
|
registration_close_date=kwargs.get(
|
||||||
|
"registration_close_date", datetime.now(UTC) + timedelta(days=7)
|
||||||
|
),
|
||||||
|
exchange_date=kwargs.get(
|
||||||
|
"exchange_date", datetime.now(UTC) + timedelta(days=14)
|
||||||
|
),
|
||||||
|
timezone=kwargs.get("timezone", "UTC"),
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
db.session.add(exchange)
|
||||||
|
db.session.commit()
|
||||||
|
return exchange
|
||||||
|
|
||||||
|
return _create
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def participant_factory(db, exchange_factory):
|
||||||
|
"""Factory for creating test participants.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database instance.
|
||||||
|
exchange_factory: Exchange factory fixture.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Function that creates and returns Participant instances.
|
||||||
|
"""
|
||||||
|
from src.models.participant import Participant
|
||||||
|
|
||||||
|
counter = {"value": 0}
|
||||||
|
|
||||||
|
def _create(exchange=None, **kwargs):
|
||||||
|
if not exchange:
|
||||||
|
exchange = exchange_factory()
|
||||||
|
|
||||||
|
counter["value"] += 1
|
||||||
|
participant = Participant(
|
||||||
|
exchange_id=exchange.id,
|
||||||
|
name=kwargs.get("name", f"Test Participant {counter['value']}"),
|
||||||
|
email=kwargs.get("email", f"test{counter['value']}@example.com"),
|
||||||
|
gift_ideas=kwargs.get("gift_ideas", "Test ideas"),
|
||||||
|
reminder_enabled=kwargs.get("reminder_enabled", True),
|
||||||
|
withdrawn_at=kwargs.get("withdrawn_at"),
|
||||||
|
)
|
||||||
|
db.session.add(participant)
|
||||||
|
db.session.commit()
|
||||||
|
return participant
|
||||||
|
|
||||||
|
return _create
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_participant(client, exchange_factory, participant_factory):
|
||||||
|
"""Create an authenticated participant session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Flask test client.
|
||||||
|
exchange_factory: Exchange factory fixture.
|
||||||
|
participant_factory: Participant factory fixture.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authenticated participant instance.
|
||||||
|
"""
|
||||||
|
exchange = exchange_factory(state="registration_open")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
session["user_id"] = participant.id
|
||||||
|
session["user_type"] = "participant"
|
||||||
|
session["exchange_id"] = exchange.id
|
||||||
|
|
||||||
|
return participant
|
||||||
|
|||||||
135
tests/integration/test_participant_list.py
Normal file
135
tests/integration/test_participant_list.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""Integration tests for participant list functionality."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
|
||||||
|
def test_participant_list_shows_all_active(
|
||||||
|
client, auth_participant, participant_factory
|
||||||
|
):
|
||||||
|
"""Test participant list shows all active participants."""
|
||||||
|
# auth_participant fixture creates session and one participant
|
||||||
|
exchange = auth_participant.exchange
|
||||||
|
|
||||||
|
# Create 2 more active participants
|
||||||
|
participant_factory(exchange=exchange, name="Bob")
|
||||||
|
participant_factory(exchange=exchange, name="Charlie")
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.dashboard", id=exchange.id))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Bob" in response.data
|
||||||
|
assert b"Charlie" in response.data
|
||||||
|
assert b"Participants (3)" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_participant_list_excludes_withdrawn(
|
||||||
|
client, auth_participant, participant_factory
|
||||||
|
):
|
||||||
|
"""Test withdrawn participants are not shown."""
|
||||||
|
exchange = auth_participant.exchange
|
||||||
|
|
||||||
|
# Create active and withdrawn participants
|
||||||
|
participant_factory(exchange=exchange, name="Active")
|
||||||
|
participant_factory(
|
||||||
|
exchange=exchange, name="Withdrawn", withdrawn_at=datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.dashboard", id=exchange.id))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Active" in response.data
|
||||||
|
assert b"Withdrawn" not in response.data
|
||||||
|
# Count should be 2 (auth_participant + Active)
|
||||||
|
assert b"Participants (2)" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_participant_list_shows_self_badge(client, auth_participant):
|
||||||
|
"""Test participant list marks current user with badge."""
|
||||||
|
exchange = auth_participant.exchange
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.dashboard", id=exchange.id))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Should show the participant's name
|
||||||
|
assert auth_participant.name.encode() in response.data
|
||||||
|
# Should show "You" badge
|
||||||
|
assert b"You" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_participant_list_ordered_by_name(
|
||||||
|
client, auth_participant, participant_factory
|
||||||
|
):
|
||||||
|
"""Test participants are shown in alphabetical order."""
|
||||||
|
exchange = auth_participant.exchange
|
||||||
|
|
||||||
|
# Create participants with names that should be sorted
|
||||||
|
participant_factory(exchange=exchange, name="Zoe")
|
||||||
|
participant_factory(exchange=exchange, name="Alice")
|
||||||
|
participant_factory(exchange=exchange, name="Bob")
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.dashboard", id=exchange.id))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Check order by finding positions in response
|
||||||
|
data = response.data.decode()
|
||||||
|
alice_pos = data.find("Alice")
|
||||||
|
bob_pos = data.find("Bob")
|
||||||
|
zoe_pos = data.find("Zoe")
|
||||||
|
|
||||||
|
# All should be present
|
||||||
|
assert alice_pos > 0
|
||||||
|
assert bob_pos > 0
|
||||||
|
assert zoe_pos > 0
|
||||||
|
|
||||||
|
# Should be in alphabetical order
|
||||||
|
assert alice_pos < bob_pos < zoe_pos
|
||||||
|
|
||||||
|
|
||||||
|
def test_participant_list_empty_state(client, exchange_factory, participant_factory):
|
||||||
|
"""Test message shown when only one participant."""
|
||||||
|
exchange = exchange_factory(state="registration_open")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
|
||||||
|
# Create session for the only participant
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
session["user_id"] = participant.id
|
||||||
|
session["user_type"] = "participant"
|
||||||
|
session["exchange_id"] = exchange.id
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.dashboard", id=exchange.id))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Participants (1)" in response.data
|
||||||
|
# Should show participant's own name
|
||||||
|
assert participant.name.encode() in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_participant_list_requires_auth(client, exchange_factory):
|
||||||
|
"""Test participant list requires authentication."""
|
||||||
|
exchange = exchange_factory()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
url_for("participant.dashboard", id=exchange.id), follow_redirects=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should redirect to login (or show error)
|
||||||
|
assert response.status_code in [302, 403]
|
||||||
|
|
||||||
|
|
||||||
|
def test_participant_list_different_exchange(
|
||||||
|
client, auth_participant, exchange_factory, participant_factory
|
||||||
|
):
|
||||||
|
"""Test participants filtered by exchange."""
|
||||||
|
exchange1 = auth_participant.exchange
|
||||||
|
exchange2 = exchange_factory(state="registration_open")
|
||||||
|
|
||||||
|
# Create participant in different exchange
|
||||||
|
participant_factory(exchange=exchange2, name="Other Exchange")
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.dashboard", id=exchange1.id))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Should not show participant from other exchange
|
||||||
|
assert b"Other Exchange" not in response.data
|
||||||
125
tests/integration/test_profile_update.py
Normal file
125
tests/integration/test_profile_update.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Integration tests for profile update functionality."""
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_update_get_shows_form(client, auth_participant):
|
||||||
|
"""GET shows edit form with current values."""
|
||||||
|
response = client.get(url_for("participant.profile_edit"))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert auth_participant.name.encode() in response.data
|
||||||
|
assert b"Edit Your Profile" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_update_post_success(client, auth_participant, db):
|
||||||
|
"""POST updates profile successfully."""
|
||||||
|
response = client.post(
|
||||||
|
url_for("participant.profile_edit"),
|
||||||
|
data={"name": "Updated Name", "gift_ideas": "Updated ideas"},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"profile has been updated" in response.data
|
||||||
|
|
||||||
|
# Verify database
|
||||||
|
db.session.refresh(auth_participant)
|
||||||
|
assert auth_participant.name == "Updated Name"
|
||||||
|
assert auth_participant.gift_ideas == "Updated ideas"
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_update_name_change(client, auth_participant, db):
|
||||||
|
"""Name updates in database."""
|
||||||
|
original_name = auth_participant.name
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
url_for("participant.profile_edit"),
|
||||||
|
data={"name": "New Name", "gift_ideas": ""},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.refresh(auth_participant)
|
||||||
|
assert auth_participant.name == "New Name"
|
||||||
|
assert auth_participant.name != original_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_update_locked_after_matching(
|
||||||
|
client, exchange_factory, participant_factory
|
||||||
|
):
|
||||||
|
"""Profile edit blocked after matching."""
|
||||||
|
exchange = exchange_factory(state="matched")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
session["user_id"] = participant.id
|
||||||
|
session["user_type"] = "participant"
|
||||||
|
session["exchange_id"] = exchange.id
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.profile_edit"), follow_redirects=True)
|
||||||
|
|
||||||
|
assert b"profile is locked" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_update_form_validation_empty_name(client, auth_participant):
|
||||||
|
"""Empty name shows validation error."""
|
||||||
|
# auth_participant fixture sets up the session
|
||||||
|
_ = auth_participant # Mark as used
|
||||||
|
response = client.post(
|
||||||
|
url_for("participant.profile_edit"), data={"name": "", "gift_ideas": "Test"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (
|
||||||
|
b"Name is required" in response.data
|
||||||
|
or b"This field is required" in response.data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_update_requires_auth(client):
|
||||||
|
"""Profile edit requires authentication."""
|
||||||
|
response = client.get(url_for("participant.profile_edit"), follow_redirects=False)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_update_strips_whitespace(client, auth_participant, db):
|
||||||
|
"""Whitespace is stripped from name and gift ideas."""
|
||||||
|
client.post(
|
||||||
|
url_for("participant.profile_edit"),
|
||||||
|
data={"name": " Spaces ", "gift_ideas": " Gift "},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.refresh(auth_participant)
|
||||||
|
assert auth_participant.name == "Spaces"
|
||||||
|
assert auth_participant.gift_ideas == "Gift"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_shows_edit_link_when_allowed(client, auth_participant):
|
||||||
|
"""Dashboard shows edit profile link when editing is allowed."""
|
||||||
|
response = client.get(
|
||||||
|
url_for("participant.dashboard", id=auth_participant.exchange_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Edit Profile" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_hides_edit_link_after_matching(
|
||||||
|
client, exchange_factory, participant_factory
|
||||||
|
):
|
||||||
|
"""Dashboard hides edit profile link after matching."""
|
||||||
|
exchange = exchange_factory(state="matched")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
session["user_id"] = participant.id
|
||||||
|
session["user_type"] = "participant"
|
||||||
|
session["exchange_id"] = exchange.id
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.dashboard", id=exchange.id))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Edit link should not be present
|
||||||
|
assert b"Edit Profile" not in response.data
|
||||||
83
tests/integration/test_reminder_preferences.py
Normal file
83
tests/integration/test_reminder_preferences.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Integration tests for reminder preferences."""
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
|
||||||
|
def get_csrf_token(client, url):
|
||||||
|
"""Extract CSRF token from a page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Flask test client.
|
||||||
|
url: URL to fetch the CSRF token from.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CSRF token string.
|
||||||
|
"""
|
||||||
|
response = client.get(url)
|
||||||
|
# Extract CSRF token from the form
|
||||||
|
data = response.data.decode()
|
||||||
|
start = data.find('name="csrf_token" value="') + len('name="csrf_token" value="')
|
||||||
|
end = data.find('"', start)
|
||||||
|
return data[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_shows_reminder_preference_form(client, auth_participant):
|
||||||
|
"""Test that dashboard shows reminder preference form."""
|
||||||
|
response = client.get(
|
||||||
|
url_for("participant.dashboard", id=auth_participant.exchange_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Email Reminders" in response.data or b"Send me reminders" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_preferences_enable(client, auth_participant, db):
|
||||||
|
"""Enable reminder emails."""
|
||||||
|
auth_participant.reminder_enabled = False
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
csrf_token = get_csrf_token(
|
||||||
|
client, url_for("participant.dashboard", id=auth_participant.exchange_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url_for("participant.update_preferences"),
|
||||||
|
data={"reminder_enabled": True, "csrf_token": csrf_token},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Reminder emails enabled" in response.data
|
||||||
|
|
||||||
|
db.session.refresh(auth_participant)
|
||||||
|
assert auth_participant.reminder_enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_preferences_disable(client, auth_participant, db):
|
||||||
|
"""Disable reminder emails."""
|
||||||
|
auth_participant.reminder_enabled = True
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
csrf_token = get_csrf_token(
|
||||||
|
client, url_for("participant.dashboard", id=auth_participant.exchange_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url_for("participant.update_preferences"),
|
||||||
|
data={"csrf_token": csrf_token},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Reminder emails disabled" in response.data
|
||||||
|
|
||||||
|
db.session.refresh(auth_participant)
|
||||||
|
assert auth_participant.reminder_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_preferences_requires_login(client):
|
||||||
|
"""Test that update_preferences requires login."""
|
||||||
|
response = client.post(url_for("participant.update_preferences"))
|
||||||
|
|
||||||
|
# Should redirect to login or show error
|
||||||
|
assert response.status_code in [302, 401, 403]
|
||||||
172
tests/integration/test_withdrawal.py
Normal file
172
tests/integration/test_withdrawal.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""Integration tests for withdrawal functionality."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
from src.models.participant import Participant
|
||||||
|
|
||||||
|
|
||||||
|
def get_csrf_token(client, url):
|
||||||
|
"""Extract CSRF token from a page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Flask test client.
|
||||||
|
url: URL to fetch the CSRF token from.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CSRF token string.
|
||||||
|
"""
|
||||||
|
response = client.get(url)
|
||||||
|
# Extract CSRF token from the form
|
||||||
|
data = response.data.decode()
|
||||||
|
start = data.find('name="csrf_token" value="') + len('name="csrf_token" value="')
|
||||||
|
end = data.find('"', start)
|
||||||
|
return data[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdrawal_get_shows_confirmation_page(client, auth_participant): # noqa: ARG001
|
||||||
|
"""Test GET shows withdrawal confirmation page."""
|
||||||
|
response = client.get(url_for("participant.withdraw"))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Withdraw from Exchange" in response.data
|
||||||
|
assert b"Are you sure" in response.data or b"cannot be undone" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdrawal_post_success(client, auth_participant, db):
|
||||||
|
"""Test successful withdrawal flow."""
|
||||||
|
participant_id = auth_participant.id
|
||||||
|
|
||||||
|
csrf_token = get_csrf_token(client, url_for("participant.withdraw"))
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url_for("participant.withdraw"),
|
||||||
|
data={"confirm": True, "csrf_token": csrf_token},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"withdrawn from the exchange" in response.data
|
||||||
|
|
||||||
|
# Verify database
|
||||||
|
participant = db.session.query(Participant).filter_by(id=participant_id).first()
|
||||||
|
assert participant.withdrawn_at is not None
|
||||||
|
|
||||||
|
# Verify session cleared
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
assert "user_id" not in session
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdrawal_requires_confirmation(client, auth_participant): # noqa: ARG001
|
||||||
|
"""Test withdrawal requires checkbox confirmation."""
|
||||||
|
csrf_token = get_csrf_token(client, url_for("participant.withdraw"))
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url_for("participant.withdraw"),
|
||||||
|
data={"csrf_token": csrf_token},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should re-render form with validation error
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"confirm" in response.data.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdrawal_blocked_after_registration_closes(
|
||||||
|
client, exchange_factory, participant_factory
|
||||||
|
):
|
||||||
|
"""Test withdrawal blocked after registration closes."""
|
||||||
|
exchange = exchange_factory(state="registration_closed")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
session["user_id"] = participant.id
|
||||||
|
session["user_type"] = "participant"
|
||||||
|
session["exchange_id"] = exchange.id
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.withdraw"), follow_redirects=True)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
b"Registration has closed" in response.data
|
||||||
|
or b"Contact the admin" in response.data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdrawal_blocked_after_matching(
|
||||||
|
client, exchange_factory, participant_factory
|
||||||
|
):
|
||||||
|
"""Test withdrawal blocked after matching occurs."""
|
||||||
|
exchange = exchange_factory(state="matched")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
session["user_id"] = participant.id
|
||||||
|
session["user_type"] = "participant"
|
||||||
|
session["exchange_id"] = exchange.id
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.withdraw"), follow_redirects=True)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
b"Withdrawal is no longer available" in response.data
|
||||||
|
or b"Contact the admin" in response.data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdrawal_requires_login(client):
|
||||||
|
"""Test that withdrawal requires login."""
|
||||||
|
response = client.get(url_for("participant.withdraw"))
|
||||||
|
|
||||||
|
# Should redirect or show error
|
||||||
|
assert response.status_code in [302, 401, 403]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_shows_withdraw_link_when_allowed(client, auth_participant):
|
||||||
|
"""Dashboard shows withdraw link when withdrawal is allowed."""
|
||||||
|
response = client.get(
|
||||||
|
url_for("participant.dashboard", id=auth_participant.exchange_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Withdraw" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_hides_withdraw_link_after_close(
|
||||||
|
client, exchange_factory, participant_factory
|
||||||
|
):
|
||||||
|
"""Dashboard hides withdraw link after registration closes."""
|
||||||
|
exchange = exchange_factory(state="registration_closed")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
session["user_id"] = participant.id
|
||||||
|
session["user_type"] = "participant"
|
||||||
|
session["exchange_id"] = exchange.id
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.dashboard", id=exchange.id))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Withdraw link should not be present
|
||||||
|
assert b"Withdraw from Exchange" not in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_already_withdrawn_redirects_to_register(
|
||||||
|
client,
|
||||||
|
exchange_factory,
|
||||||
|
participant_factory,
|
||||||
|
db, # noqa: ARG001
|
||||||
|
):
|
||||||
|
"""Test that already withdrawn participants are redirected to register page."""
|
||||||
|
exchange = exchange_factory(state="registration_open")
|
||||||
|
participant = participant_factory(exchange=exchange, withdrawn_at=datetime.utcnow())
|
||||||
|
|
||||||
|
with client.session_transaction() as session:
|
||||||
|
session["user_id"] = participant.id
|
||||||
|
session["user_type"] = "participant"
|
||||||
|
session["exchange_id"] = exchange.id
|
||||||
|
|
||||||
|
response = client.get(url_for("participant.withdraw"), follow_redirects=True)
|
||||||
|
|
||||||
|
assert b"already withdrawn" in response.data
|
||||||
|
# Should be on registration page
|
||||||
|
assert exchange.name.encode() in response.data
|
||||||
158
tests/unit/test_participant_utils.py
Normal file
158
tests/unit/test_participant_utils.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Unit tests for participant utility functions."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from src.utils.participant import (
|
||||||
|
can_update_profile,
|
||||||
|
can_withdraw,
|
||||||
|
get_active_participants,
|
||||||
|
is_withdrawn,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_active_participants_excludes_withdrawn(
|
||||||
|
exchange_factory, participant_factory
|
||||||
|
):
|
||||||
|
"""Test that get_active_participants excludes withdrawn participants."""
|
||||||
|
exchange = exchange_factory()
|
||||||
|
|
||||||
|
# Create 2 active, 1 withdrawn
|
||||||
|
active1 = participant_factory(exchange=exchange, name="Alice")
|
||||||
|
active2 = participant_factory(exchange=exchange, name="Bob")
|
||||||
|
withdrawn = participant_factory(
|
||||||
|
exchange=exchange, name="Charlie", withdrawn_at=datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
participants = get_active_participants(exchange.id)
|
||||||
|
|
||||||
|
assert len(participants) == 2
|
||||||
|
assert active1 in participants
|
||||||
|
assert active2 in participants
|
||||||
|
assert withdrawn not in participants
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_active_participants_ordered_by_name(exchange_factory, participant_factory):
|
||||||
|
"""Test that participants are ordered alphabetically."""
|
||||||
|
exchange = exchange_factory()
|
||||||
|
|
||||||
|
participant_factory(exchange=exchange, name="Zoe")
|
||||||
|
participant_factory(exchange=exchange, name="Alice")
|
||||||
|
participant_factory(exchange=exchange, name="Bob")
|
||||||
|
|
||||||
|
participants = get_active_participants(exchange.id)
|
||||||
|
|
||||||
|
assert len(participants) == 3
|
||||||
|
assert participants[0].name == "Alice"
|
||||||
|
assert participants[1].name == "Bob"
|
||||||
|
assert participants[2].name == "Zoe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_active_participants_empty_when_all_withdrawn(
|
||||||
|
exchange_factory, participant_factory
|
||||||
|
):
|
||||||
|
"""Test that empty list returned when all participants withdrawn."""
|
||||||
|
exchange = exchange_factory()
|
||||||
|
|
||||||
|
participant_factory(exchange=exchange, withdrawn_at=datetime.now(UTC))
|
||||||
|
participant_factory(exchange=exchange, withdrawn_at=datetime.now(UTC))
|
||||||
|
|
||||||
|
participants = get_active_participants(exchange.id)
|
||||||
|
|
||||||
|
assert len(participants) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_active_participants_different_exchanges(
|
||||||
|
exchange_factory, participant_factory
|
||||||
|
):
|
||||||
|
"""Test that participants are filtered by exchange_id."""
|
||||||
|
exchange1 = exchange_factory()
|
||||||
|
exchange2 = exchange_factory()
|
||||||
|
|
||||||
|
participant_factory(exchange=exchange1, name="Alice")
|
||||||
|
participant_factory(exchange=exchange2, name="Bob")
|
||||||
|
|
||||||
|
participants = get_active_participants(exchange1.id)
|
||||||
|
|
||||||
|
assert len(participants) == 1
|
||||||
|
assert participants[0].name == "Alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_withdrawn_true(participant_factory):
|
||||||
|
"""Test is_withdrawn returns True for withdrawn participant."""
|
||||||
|
participant = participant_factory(withdrawn_at=datetime.now(UTC))
|
||||||
|
assert is_withdrawn(participant) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_withdrawn_false(participant_factory):
|
||||||
|
"""Test is_withdrawn returns False for active participant."""
|
||||||
|
participant = participant_factory()
|
||||||
|
assert is_withdrawn(participant) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_update_profile_draft_state(participant_factory, exchange_factory):
|
||||||
|
"""Profile updates allowed in draft state."""
|
||||||
|
exchange = exchange_factory(state="draft")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
assert can_update_profile(participant) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_update_profile_registration_open(participant_factory, exchange_factory):
|
||||||
|
"""Profile updates allowed when registration open."""
|
||||||
|
exchange = exchange_factory(state="registration_open")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
assert can_update_profile(participant) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_update_profile_registration_closed(participant_factory, exchange_factory):
|
||||||
|
"""Profile updates allowed when registration closed."""
|
||||||
|
exchange = exchange_factory(state="registration_closed")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
assert can_update_profile(participant) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_update_profile_matched_state(participant_factory, exchange_factory):
|
||||||
|
"""Profile updates blocked after matching."""
|
||||||
|
exchange = exchange_factory(state="matched")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
assert can_update_profile(participant) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_update_profile_completed_state(participant_factory, exchange_factory):
|
||||||
|
"""Profile updates blocked when completed."""
|
||||||
|
exchange = exchange_factory(state="completed")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
assert can_update_profile(participant) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_withdraw_draft_state(participant_factory, exchange_factory):
|
||||||
|
"""Withdrawal allowed in draft state."""
|
||||||
|
exchange = exchange_factory(state="draft")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
assert can_withdraw(participant) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_withdraw_registration_open(participant_factory, exchange_factory):
|
||||||
|
"""Withdrawal allowed when registration open."""
|
||||||
|
exchange = exchange_factory(state="registration_open")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
assert can_withdraw(participant) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_withdraw_registration_closed(participant_factory, exchange_factory):
|
||||||
|
"""Withdrawal blocked when registration closed."""
|
||||||
|
exchange = exchange_factory(state="registration_closed")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
assert can_withdraw(participant) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_withdraw_matched_state(participant_factory, exchange_factory):
|
||||||
|
"""Withdrawal blocked after matching."""
|
||||||
|
exchange = exchange_factory(state="matched")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
assert can_withdraw(participant) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_withdraw_already_withdrawn(participant_factory):
|
||||||
|
"""Withdrawal blocked if already withdrawn."""
|
||||||
|
participant = participant_factory(withdrawn_at=datetime.now(UTC))
|
||||||
|
assert can_withdraw(participant) is False
|
||||||
67
tests/unit/test_withdrawal_service.py
Normal file
67
tests/unit/test_withdrawal_service.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Unit tests for withdrawal service."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.withdrawal import WithdrawalError, withdraw_participant
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdraw_participant_success(participant_factory, db, app): # noqa: ARG001
|
||||||
|
"""Test successful withdrawal."""
|
||||||
|
with app.app_context():
|
||||||
|
participant = participant_factory()
|
||||||
|
|
||||||
|
with patch("src.services.withdrawal.EmailService") as mock_email_service:
|
||||||
|
withdraw_participant(participant)
|
||||||
|
|
||||||
|
assert participant.withdrawn_at is not None
|
||||||
|
mock_email_service.return_value.send_withdrawal_confirmation.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdraw_participant_already_withdrawn(participant_factory, app):
|
||||||
|
"""Test error when already withdrawn."""
|
||||||
|
with app.app_context():
|
||||||
|
participant = participant_factory(withdrawn_at=datetime.utcnow())
|
||||||
|
|
||||||
|
with pytest.raises(WithdrawalError, match="already withdrawn"):
|
||||||
|
withdraw_participant(participant)
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdraw_participant_registration_closed(
|
||||||
|
exchange_factory, participant_factory, app
|
||||||
|
):
|
||||||
|
"""Test error when registration is closed."""
|
||||||
|
with app.app_context():
|
||||||
|
exchange = exchange_factory(state="registration_closed")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
|
||||||
|
with pytest.raises(WithdrawalError, match="Registration has closed"):
|
||||||
|
withdraw_participant(participant)
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdraw_participant_after_matching(
|
||||||
|
exchange_factory, participant_factory, app
|
||||||
|
):
|
||||||
|
"""Test error when matching has occurred."""
|
||||||
|
with app.app_context():
|
||||||
|
exchange = exchange_factory(state="matched")
|
||||||
|
participant = participant_factory(exchange=exchange)
|
||||||
|
|
||||||
|
with pytest.raises(WithdrawalError, match="Matching has already occurred"):
|
||||||
|
withdraw_participant(participant)
|
||||||
|
|
||||||
|
|
||||||
|
def test_withdraw_participant_sets_timestamp(participant_factory, db, app): # noqa: ARG001
|
||||||
|
"""Test that withdrawal sets timestamp correctly."""
|
||||||
|
with app.app_context():
|
||||||
|
participant = participant_factory()
|
||||||
|
|
||||||
|
with patch("src.services.withdrawal.EmailService"):
|
||||||
|
before = datetime.utcnow()
|
||||||
|
withdraw_participant(participant)
|
||||||
|
after = datetime.utcnow()
|
||||||
|
|
||||||
|
assert participant.withdrawn_at is not None
|
||||||
|
assert before <= participant.withdrawn_at <= after
|
||||||
Reference in New Issue
Block a user