From c2b3641d74955fed7b6713f9903d7d48403eb056 Mon Sep 17 00:00:00 2001 From: Phil Skentelbery Date: Mon, 22 Dec 2025 21:18:18 -0700 Subject: [PATCH] feat: implement reminder preferences and withdrawal (Stories 6.3, 6.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app.py | 2 + src/forms/participant.py | 25 ++- src/routes/participant.py | 124 ++++++++++++- src/services/email.py | 50 +++++ src/services/withdrawal.py | 73 ++++++++ src/templates/participant/dashboard.html | 28 +++ src/templates/participant/withdraw.html | 47 +++++ src/utils/participant.py | 21 +++ .../integration/test_reminder_preferences.py | 83 +++++++++ tests/integration/test_withdrawal.py | 172 ++++++++++++++++++ tests/unit/test_participant_utils.py | 35 ++++ tests/unit/test_withdrawal_service.py | 67 +++++++ 12 files changed, 725 insertions(+), 2 deletions(-) create mode 100644 src/services/withdrawal.py create mode 100644 src/templates/participant/withdraw.html create mode 100644 tests/integration/test_reminder_preferences.py create mode 100644 tests/integration/test_withdrawal.py create mode 100644 tests/unit/test_withdrawal_service.py diff --git a/src/app.py b/src/app.py index 5bb5ec3..0cee4f3 100644 --- a/src/app.py +++ b/src/app.py @@ -145,6 +145,8 @@ def register_setup_check(app: Flask) -> None: "participant.dashboard", "participant.logout", "participant.profile_edit", + "participant.update_preferences", + "participant.withdraw", ]: return diff --git a/src/forms/participant.py b/src/forms/participant.py index 9a1cb9f..c9a7bca 100644 --- a/src/forms/participant.py +++ b/src/forms/participant.py @@ -1,7 +1,7 @@ """Forms for participant registration and management.""" 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 @@ -70,3 +70,26 @@ class ProfileUpdateForm(FlaskForm): 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") diff --git a/src/routes/participant.py b/src/routes/participant.py index c592c60..e794e46 100644 --- a/src/routes/participant.py +++ b/src/routes/participant.py @@ -21,6 +21,8 @@ from src.forms.participant import ( MagicLinkRequestForm, ParticipantRegistrationForm, ProfileUpdateForm, + ReminderPreferenceForm, + WithdrawForm, ) from src.models.exchange import Exchange from src.models.magic_token import MagicToken @@ -346,10 +348,20 @@ def dashboard(id: int): # noqa: A002 abort(404) # Get list of active participants - from src.utils.participant import can_update_profile, get_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( "participant/dashboard.html", @@ -358,6 +370,8 @@ def dashboard(id: int): # noqa: A002 participants=participants, participant_count=len(participants), can_edit_profile=can_edit, + can_withdraw=can_leave, + reminder_form=reminder_form, ) @@ -420,6 +434,114 @@ def profile_edit(): ) +@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, + ) + + @participant_bp.route("/participant/logout", methods=["POST"]) def logout(): """Participant logout. diff --git a/src/services/email.py b/src/services/email.py index 539ddcc..febb696 100644 --- a/src/services/email.py +++ b/src/services/email.py @@ -174,3 +174,53 @@ class EmailService: """ 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""" + + +

Withdrawal Confirmed

+

Hello {participant.name},

+

+ This email confirms that you have withdrawn from the + Secret Santa exchange {exchange.name}. +

+
+

What happens now:

+
    +
  • You have been removed from the participant list
  • +
  • Your profile information has been archived
  • +
  • + You will not receive further emails about this exchange +
  • +
+
+

+ If you withdrew by mistake, you can re-register using a + different email address while registration is still open. +

+
+

+ This is an automated message from Sneaky Klaus. +

+ + + """ + + return self.send_email( + to=participant.email, subject=subject, html_body=html_body + ) diff --git a/src/services/withdrawal.py b/src/services/withdrawal.py new file mode 100644 index 0000000..d4f206f --- /dev/null +++ b/src/services/withdrawal.py @@ -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 diff --git a/src/templates/participant/dashboard.html b/src/templates/participant/dashboard.html index 2927ae7..b720c79 100644 --- a/src/templates/participant/dashboard.html +++ b/src/templates/participant/dashboard.html @@ -61,6 +61,34 @@ {% endif %} +
+

Email Reminders

+
+ {{ reminder_form.hidden_tag() }} +
+ {{ reminder_form.reminder_enabled() }} + {{ reminder_form.reminder_enabled.label }} +
+ {% if reminder_form.reminder_enabled.description %} + {{ reminder_form.reminder_enabled.description }} + {% endif %} + +
+
+ + {% if can_withdraw %} +
+

Withdraw from Exchange

+

+ If you can no longer participate, you can withdraw from this exchange. + This cannot be undone. +

+ + Withdraw from Exchange + +
+ {% endif %} +
diff --git a/src/templates/participant/withdraw.html b/src/templates/participant/withdraw.html new file mode 100644 index 0000000..e90a981 --- /dev/null +++ b/src/templates/participant/withdraw.html @@ -0,0 +1,47 @@ +{% extends "layouts/base.html" %} + +{% block title %}Withdraw from {{ exchange.name }}{% endblock %} + +{% block content %} +
+
+

Withdraw from Exchange

+
+ +
+

⚠️ Are you sure?

+

Withdrawing from this exchange means:

+
    +
  • Your registration will be cancelled
  • +
  • You will be removed from the participant list
  • +
  • You cannot undo this action
  • +
  • You will need to re-register with a different email to rejoin
  • +
+
+ + + {{ form.hidden_tag() }} + +
+ + {% if form.confirm.errors %} +
    + {% for error in form.confirm.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.submit(class="contrast") }} + + Cancel + +
+ +
+{% endblock %} diff --git a/src/utils/participant.py b/src/utils/participant.py index 21e1cbb..58355cb 100644 --- a/src/utils/participant.py +++ b/src/utils/participant.py @@ -53,3 +53,24 @@ def can_update_profile(participant: "Participant") -> bool: 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 diff --git a/tests/integration/test_reminder_preferences.py b/tests/integration/test_reminder_preferences.py new file mode 100644 index 0000000..0531d72 --- /dev/null +++ b/tests/integration/test_reminder_preferences.py @@ -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] diff --git a/tests/integration/test_withdrawal.py b/tests/integration/test_withdrawal.py new file mode 100644 index 0000000..02ada9b --- /dev/null +++ b/tests/integration/test_withdrawal.py @@ -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 diff --git a/tests/unit/test_participant_utils.py b/tests/unit/test_participant_utils.py index 0ce1c5e..8e72aac 100644 --- a/tests/unit/test_participant_utils.py +++ b/tests/unit/test_participant_utils.py @@ -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 diff --git a/tests/unit/test_withdrawal_service.py b/tests/unit/test_withdrawal_service.py new file mode 100644 index 0000000..d049530 --- /dev/null +++ b/tests/unit/test_withdrawal_service.py @@ -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