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:
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
|
||||
Reference in New Issue
Block a user