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:
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
|
||||
@@ -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
|
||||
|
||||
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