feat: implement reminder preferences and withdrawal (Stories 6.3, 6.2)

Implement Phase 3 participant self-management features:

Story 6.3 - Reminder Preferences:
- Add ReminderPreferenceForm to participant forms
- Add update_preferences route for preference updates
- Update dashboard template with reminder preference toggle
- Participants can enable/disable reminder emails at any time

Story 6.2 - Withdrawal from Exchange:
- Add can_withdraw utility function for state validation
- Create WithdrawalService to handle withdrawal process
- Add WithdrawForm with explicit confirmation requirement
- Add withdraw route with GET (confirmation) and POST (process)
- Add withdrawal confirmation email template
- Update dashboard to show withdraw link when allowed
- Withdrawal only allowed before registration closes
- Session cleared after withdrawal, user redirected to registration

All acceptance criteria met for both stories.
Test coverage: 90.02% (156 tests passing)
Linting and type checking: passed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-22 21:18:18 -07:00
parent 4fbb681e03
commit c2b3641d74
12 changed files with 725 additions and 2 deletions

View 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]

View 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

View File

@@ -4,6 +4,7 @@ from datetime import UTC, datetime
from src.utils.participant import (
can_update_profile,
can_withdraw,
get_active_participants,
is_withdrawn,
)
@@ -121,3 +122,37 @@ def test_can_update_profile_completed_state(participant_factory, exchange_factor
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

View 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