fix(bot): une reaction qui echoue le dit, au lieu de se taire

Chaque gestionnaire de discord_bot.py enveloppait tout son corps dans un
except Exception qui journalisait et poursuivait. Un refus de la base, une
boite de reception fermee et une faute de frappe dans ce module produisaient
la meme ligne, et la personne qui avait clique n'apprenait rien dans aucun
des trois cas.

Le defaut que l'audit citait en exemple : dans handle_attendance_confirm, le
commit et le message "Your attendance has been confirmed!" etaient dans le
meme bloc protege. Si le commit levait, rien n'etait envoye et rien n'etait
signale. La reaction devenait indiscernable d'un bot arrete.

Trois familles, trois reponses. La base refuse : rollback, l'entree pending
est conservee pour que la reaction reste reessayable, et la personne est
prevenue que rien n'a ete enregistre. Discord est injoignable : apres un
commit c'est du meilleur effort, un DM qui rebondit ne defait pas une
decision prise. Tout le reste est un defaut et remonte, jusqu'a on_error,
qui est ajoute parce que discord.py journalise sur le logger 'discord' que
configure_logging ne collecte pas.

Trouve en appliquant : deux reactions sur le meme message passent toutes
deux le test d'appartenance puis s'attendent sur deux await, et la perdante
levait un KeyError qui se lisait comme une erreur sans consequence ; un
fetch_channel en echec renvoyait sans un mot, donc un clic sans effet et
sans trace ; un start_scheduler en echec supprime tous les rappels a jamais
et laissait trois cles de /health au vert, d'ou reminders_scheduled.

L'ordre des etapes apres le commit est desormais fixe : oublier l'entree
pending avant les messages, sinon un DM rebondi laisse une demande deja
approuvee reactivable une seconde fois.

Les tests ont ete verifies par mutation du code de production. La deuxieme
mutation a trouve une faiblesse dans le test lui-meme, qui ne regardait que
le premier message emis.

QUA-004 (roadmap) / ARCH-008 (constats).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-11 16:28:58 -04:00
co-authored by Claude Opus 5
parent 7a1dab21cd
commit 546f28571b
3 changed files with 1013 additions and 294 deletions
+567 -292
View File
File diff suppressed because it is too large Load Diff
+430
View File
@@ -0,0 +1,430 @@
"""Three families of failure, and the three different answers they get.
ARCH-008 / QUA-004. Every handler in `discord_bot.py` used to wrap its
whole body in one `except Exception` that logged a line and returned, so
these three all looked the same from the outside and two of them looked the
same from the inside:
- the database refused the write — nothing happened, and the person who
clicked has to be told, because a silent reaction is indistinguishable
from a bot that is not running;
- Discord could not be reached — after a commit, the thing is recorded
whether or not the message got through, and must stay recorded;
- something else went wrong — a defect, which has to reach a traceback
rather than be flattened into "Error handling attendance confirmation".
The coroutines are driven with `asyncio.run` rather than pytest-asyncio,
which the project does not depend on — same convention as
`test_discord_delivery.py`.
"""
import asyncio
import logging
from datetime import date, time
import pytest
from discord import HTTPException
from sqlalchemy.exc import OperationalError
from app import discord_bot
from app.discord_bot import TeamTryoutsBot
class FakeChannel:
"""The DM channel a reaction came from. Records what it was told to say."""
def __init__(self, raises=None):
self.raises = raises
self.sent = []
async def send(self, message):
if self.raises is not None:
raise self.raises
self.sent.append(message)
return object()
class FakeReactor:
"""The Discord account that clicked. Only its snowflake is ever read."""
def __init__(self, uid):
self.id = uid
def http_error():
class _Response:
status = 500
reason = 'test'
return HTTPException(_Response(), 'Internal Server Error')
def database_error():
"""A refusal that is not an IntegrityError.
Deliberately: the handlers catch `SQLAlchemyError`, and picking the
subclass a constraint violation produces would let a narrower catch
pass this suite while still dropping a lost connection on the floor.
"""
return OperationalError('SELECT 1', {}, Exception('server closed the connection'))
@pytest.fixture
def logs(caplog):
"""Let the bot's records reach caplog.
`configure_logging` sets propagate = False on the 'app' logger, and
caplog's handler sits on the root. Without this the assertions below
read an empty log and pass for the wrong reason — the trap recorded in
the wave G notes.
"""
package_logger = logging.getLogger('app')
previous = package_logger.propagate
package_logger.propagate = True
caplog.set_level(logging.INFO, logger='app.discord_bot')
yield caplog
package_logger.propagate = previous
@pytest.fixture
def bot():
"""A bot with the state the reaction handlers touch, and nothing else.
`__new__` because the constructor builds a real discord.py client.
`_save_pending` is replaced: the real one writes `discord_pending.json`
at the repository root.
"""
instance = TeamTryoutsBot.__new__(TeamTryoutsBot)
instance.flask_app = None
instance.pending_requests = {}
instance._user_cache = {}
instance.reminders_scheduled = False
instance.saves = 0
def _save():
instance.saves += 1
instance._save_pending = _save
return instance
@pytest.fixture
def participation(db, make_user):
"""A player on a match, plus the pending message asking them to confirm.
Returns (participation row id, the player's Discord snowflake).
"""
from app.models import Match, MatchParticipant, Tryout
snowflake = '4242'
player_id = make_user('player', discord_user_id=snowflake)
admin_id = make_user('admin')
tryout = Tryout(title='Sélection', game='valorant', date=date(2026, 9, 1), created_by=admin_id)
db.session.add(tryout)
db.session.flush()
match = Match(
title='Finale',
date=date(2026, 9, 2),
start_time=time(18, 0),
created_by=admin_id,
tryout_id=tryout.id,
match_type='intra',
)
db.session.add(match)
db.session.flush()
row = MatchParticipant(match_id=match.id, player_id=player_id)
db.session.add(row)
db.session.commit()
return row.id, snowflake
def _pending(bot, message_id, row_id):
bot.pending_requests[message_id] = {
'type': 'schedule_addition',
'id': row_id,
'event_type': 'match',
}
def _confirmed(row_id):
from app.models import MatchParticipant
return MatchParticipant.query.get(row_id).attendance_confirmed
class TestTheDatabaseRefusedTheWrite:
def test_the_happy_path_records_the_confirmation(self, bot, db, participation, logs):
"""The premise of every test below. Without it they could all pass
against a handler that never records anything at all."""
row_id, snowflake = participation
_pending(bot, 7, row_id)
channel = FakeChannel()
asyncio.run(bot.handle_attendance_confirm(FakeReactor(snowflake), 7, row_id, channel))
assert _confirmed(row_id) is True
assert channel.sent == ["✅ Your attendance has been confirmed!"]
assert 7 not in bot.pending_requests
def test_a_refused_commit_tells_the_player_instead_of_going_quiet(
self, bot, db, participation, monkeypatch, logs
):
"""The defect ARCH-008 was written about.
The success message sat inside the same blanket handler as the
commit, so a refused write produced no message at all — the reaction
looked ignored, which is what a stopped bot looks like too.
"""
row_id, snowflake = participation
_pending(bot, 7, row_id)
channel = FakeChannel()
def refuse():
raise database_error()
monkeypatch.setattr(db.session, 'commit', refuse)
asyncio.run(bot.handle_attendance_confirm(FakeReactor(snowflake), 7, row_id, channel))
assert channel.sent, 'silence is the one answer this must never give'
assert 'Nothing was recorded' in channel.sent[0]
# Every message, not just the first: an earlier version of this test
# looked at sent[0] alone, and a handler that reported the refusal
# and then sent "✅ Your attendance has been confirmed!" anyway
# passed it.
assert not any("" in message for message in channel.sent), (
'a failed write must not be followed by a success message'
)
def test_a_refused_commit_leaves_the_row_untouched(
self, bot, db, participation, monkeypatch, logs
):
row_id, snowflake = participation
_pending(bot, 7, row_id)
def refuse():
raise database_error()
monkeypatch.setattr(db.session, 'commit', refuse)
asyncio.run(bot.handle_attendance_confirm(FakeReactor(snowflake), 7, row_id, FakeChannel()))
monkeypatch.undo()
assert _confirmed(row_id) is False, (
'the handler must roll back; leaving the change pending in the '
'session hands it to whatever commits next'
)
def test_a_refused_commit_keeps_the_reaction_answerable(
self, bot, db, participation, monkeypatch, logs
):
"""Dropping the pending entry on a failed write would make the
second attempt do nothing, silently, forever."""
row_id, snowflake = participation
_pending(bot, 7, row_id)
def refuse():
raise database_error()
monkeypatch.setattr(db.session, 'commit', refuse)
asyncio.run(bot.handle_attendance_confirm(FakeReactor(snowflake), 7, row_id, FakeChannel()))
assert 7 in bot.pending_requests
def test_the_refusal_reaches_the_log_with_its_traceback(
self, bot, db, participation, monkeypatch, logs
):
row_id, snowflake = participation
_pending(bot, 7, row_id)
def refuse():
raise database_error()
monkeypatch.setattr(db.session, 'commit', refuse)
with logs.at_level(logging.ERROR, logger='app.discord_bot'):
asyncio.run(
bot.handle_attendance_confirm(FakeReactor(snowflake), 7, row_id, FakeChannel())
)
assert any(record.exc_info for record in logs.records), (
'a database refusal is diagnosed from the driver error, which only '
'the traceback carries'
)
def test_a_declined_match_that_cannot_be_deleted_says_so(
self, bot, db, participation, monkeypatch, logs
):
"""The decline path deletes a row, which is exactly what a foreign
key refuses. Same answer as the confirm path."""
row_id, snowflake = participation
_pending(bot, 7, row_id)
channel = FakeChannel()
def refuse():
raise database_error()
monkeypatch.setattr(db.session, 'commit', refuse)
asyncio.run(bot.handle_attendance_decline(FakeReactor(snowflake), 7, row_id, channel))
assert channel.sent and 'Nothing was recorded' in channel.sent[0]
assert 7 in bot.pending_requests
class TestDiscordCouldNotBeReached:
def test_a_bounced_message_does_not_undo_the_record(self, bot, db, participation, logs):
"""The confirmation is committed before the message goes out. A DM
that bounces costs the player a message, not their attendance."""
row_id, snowflake = participation
_pending(bot, 7, row_id)
asyncio.run(
bot.handle_attendance_confirm(
FakeReactor(snowflake), 7, row_id, FakeChannel(raises=http_error())
)
)
assert _confirmed(row_id) is True
assert 7 not in bot.pending_requests, (
'the answer is durable, so the message must not stay answerable'
)
def test_a_bounced_message_is_reported(self, bot, db, participation, logs):
row_id, snowflake = participation
_pending(bot, 7, row_id)
with logs.at_level(logging.ERROR, logger='app.discord_bot'):
asyncio.run(
bot.handle_attendance_confirm(
FakeReactor(snowflake), 7, row_id, FakeChannel(raises=http_error())
)
)
assert 'Could not answer a reaction' in logs.text
class TestAnythingElseIsADefect:
def test_an_unexpected_failure_propagates(self, bot, db, participation, monkeypatch, logs):
"""It used to be caught here and logged as though it were a delivery
problem. Letting it out is what puts it in front of on_error."""
row_id, snowflake = participation
_pending(bot, 7, row_id)
def boom(event_type, reference_id):
raise ZeroDivisionError('a defect, not an outage')
monkeypatch.setattr(TeamTryoutsBot, '_attendance_record', staticmethod(boom))
with pytest.raises(ZeroDivisionError):
asyncio.run(
bot.handle_attendance_confirm(FakeReactor(snowflake), 7, row_id, FakeChannel())
)
def test_on_error_records_it_under_this_application_s_logger(self, bot, logs):
"""discord.py logs escaped event exceptions on the `discord` logger,
which logging_config does not wire to any of this application's
files. That is where these used to go."""
with logs.at_level(logging.ERROR, logger='app.discord_bot'):
try:
raise ZeroDivisionError('boom')
except ZeroDivisionError:
asyncio.run(bot.on_error('on_raw_reaction_add'))
assert 'on_raw_reaction_add' in logs.text
assert 'defect' in logs.text
assert any(record.exc_info for record in logs.records)
class TestTheQueueBoundary:
def test_queueing_never_raises_into_the_request_that_caused_it(self, monkeypatch, logs):
"""This runs in a Flask request thread, after the work it announces
is committed. A notification must not turn a finished page into a
500 — but it must not swallow the reason either."""
def no_bot():
raise RuntimeError('the bot could not be built')
monkeypatch.setattr(discord_bot, 'get_bot', no_bot)
with logs.at_level(logging.ERROR, logger='app.discord_bot'):
queued = discord_bot.send_schedule_notification(1, 'match', 'Finale', 'd', 't', 2)
assert queued is False
assert any(record.exc_info for record in logs.records)
def test_a_queued_notification_reaches_the_queue(self, bot, monkeypatch):
"""The premise of the test above: the same call succeeds normally."""
from queue import Queue
bot.message_queue = Queue()
monkeypatch.setattr(discord_bot, 'get_bot', lambda: bot)
assert discord_bot.send_schedule_notification(1, 'match', 'Finale', 'd', 't', 2) is True
assert bot.message_queue.get_nowait()['type'] == 'schedule_addition'
def test_an_unknown_item_type_is_named_and_dropped(self, bot, logs):
with logs.at_level(logging.ERROR, logger='app.discord_bot'):
asyncio.run(bot._dispatch({'type': 'invented', 'data': {}}))
assert 'invented' in logs.text
class TestTheSchedulerSaysWhetherItStarted:
class FailingScheduler:
@staticmethod
def add_job(*args, **kwargs):
raise RuntimeError('no event loop')
class WorkingScheduler:
def __init__(self):
self.started = False
def add_job(self, *args, **kwargs):
pass
def start(self):
self.started = True
def test_a_scheduler_that_will_not_start_is_visible_from_outside(self, bot, logs):
"""The other four /health keys all read green in this state: the
thread is alive, the gateway is up, and no reminder is ever sent."""
from zoneinfo import ZoneInfo
bot.scheduler = self.FailingScheduler()
bot.timezone = ZoneInfo('America/Toronto')
with logs.at_level(logging.ERROR, logger='app.discord_bot'):
asyncio.run(bot.start_scheduler())
assert bot.reminders_scheduled is False
assert 'No reminder will be sent' in logs.text
def test_a_scheduler_that_starts_says_so(self, bot, logs):
from zoneinfo import ZoneInfo
bot.scheduler = self.WorkingScheduler()
bot.timezone = ZoneInfo('America/Toronto')
asyncio.run(bot.start_scheduler())
assert bot.scheduler.started is True
assert bot.reminders_scheduled is True
class TestTwoReactionsOnOneMessage:
def test_the_second_one_finds_nothing_to_do(self, bot, db, participation, logs):
"""A confirm and a decline arrive together; both got past the
membership check in on_raw_reaction_add before either finished. The
loser used to raise KeyError inside the blanket handler, where it
read as an error with no consequence."""
row_id, snowflake = participation
_pending(bot, 7, row_id)
reactor = FakeReactor(snowflake)
asyncio.run(bot.handle_attendance_confirm(reactor, 7, row_id, FakeChannel()))
second = FakeChannel()
asyncio.run(bot.handle_attendance_decline(reactor, 7, row_id, second))
assert second.sent == [], 'the second reaction must not answer twice'
assert _confirmed(row_id) is True
+16 -2
View File
@@ -143,15 +143,29 @@ class TestLoading:
class TestHealthReporting: class TestHealthReporting:
def test_the_status_shape_is_stable(self, monkeypatch): def test_the_status_shape_is_stable(self, monkeypatch):
"""An external probe reads these keys; they are the contract.""" """An external probe reads these keys; they are the contract.
`reminders_scheduled` joined them with ARCH-008. The other four can
all read green while the 18:00 job never got scheduled — the thread
is alive, the gateway is up, and no reminder is ever sent. Adding a
key is safe for a probe that looks at the ones it knows; removing or
renaming one is not.
"""
monkeypatch.setattr(discord_bot, 'bot_thread', None) monkeypatch.setattr(discord_bot, 'bot_thread', None)
monkeypatch.setattr(discord_bot, 'bot_instance', None) monkeypatch.setattr(discord_bot, 'bot_instance', None)
status = discord_bot.bot_status() status = discord_bot.bot_status()
assert set(status) == {'configured', 'running', 'connected', 'pending'} assert set(status) == {
'configured',
'running',
'connected',
'pending',
'reminders_scheduled',
}
assert status['running'] is False assert status['running'] is False
assert status['connected'] is False assert status['connected'] is False
assert status['reminders_scheduled'] is False
def test_a_dead_thread_reads_as_not_running(self, monkeypatch): def test_a_dead_thread_reads_as_not_running(self, monkeypatch):
class DeadThread: class DeadThread: