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]>
195 lines
6.8 KiB
Python
195 lines
6.8 KiB
Python
"""The bot's on-disk state and what /health says about it.
|
|
|
|
OPS-005/006/007/012. Four defects that shared a shape: the bot could stop
|
|
working correctly, and nothing anywhere said so.
|
|
|
|
- a truncated pending file was logged and then silently replaced by an
|
|
empty dict, so every in-flight Discord reaction stopped having an
|
|
effect;
|
|
- the file was written in place, so any interruption produced exactly
|
|
that truncated file;
|
|
- entries were never removed unless someone reacted, so the file grew
|
|
for ever;
|
|
- when the bot thread died, the site kept serving pages and every
|
|
notification stopped, invisibly.
|
|
|
|
These tests drive the bot object directly. Nothing here talks to Discord.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from app import discord_bot
|
|
from app.discord_bot import PENDING_MAX_AGE_DAYS, TeamTryoutsBot
|
|
|
|
|
|
@pytest.fixture
|
|
def bot(monkeypatch, tmp_path):
|
|
"""A bot whose pending file lives in a throwaway directory."""
|
|
pending = tmp_path / 'discord_pending.json'
|
|
monkeypatch.setattr(discord_bot, 'PENDING_FILE', str(pending))
|
|
instance = TeamTryoutsBot.__new__(TeamTryoutsBot)
|
|
instance.pending_requests = {}
|
|
return instance, pending
|
|
|
|
|
|
class TestSaving:
|
|
def test_a_saved_file_reloads(self, bot):
|
|
instance, pending = bot
|
|
instance.pending_requests = {123: {'type': 'one_on_one', 'id': 5}}
|
|
instance._save_pending()
|
|
|
|
instance.pending_requests = {}
|
|
instance._load_pending()
|
|
|
|
assert instance.pending_requests == {123: {'type': 'one_on_one', 'id': 5}}
|
|
|
|
def test_no_temporary_file_is_left_behind(self, bot):
|
|
instance, pending = bot
|
|
instance.pending_requests = {1: {'type': 'one_on_one', 'id': 1}}
|
|
instance._save_pending()
|
|
|
|
leftovers = [name for name in os.listdir(pending.parent) if name.startswith('.pending-')]
|
|
assert leftovers == []
|
|
|
|
def test_the_previous_content_survives_a_failed_write(self, bot, monkeypatch):
|
|
"""Writing in place was the whole problem: an interruption left a
|
|
half-written file that the loader could not read."""
|
|
instance, pending = bot
|
|
instance.pending_requests = {1: {'type': 'one_on_one', 'id': 1}}
|
|
instance._save_pending()
|
|
|
|
def _explode(*args, **kwargs):
|
|
raise OSError('disk full')
|
|
|
|
monkeypatch.setattr(discord_bot.tempfile, 'NamedTemporaryFile', _explode)
|
|
instance.pending_requests = {2: {'type': 'one_on_one', 'id': 2}}
|
|
instance._save_pending()
|
|
|
|
assert json.loads(pending.read_text(encoding='utf-8')) == {
|
|
'1': {'type': 'one_on_one', 'id': 1}
|
|
}
|
|
|
|
|
|
class TestLoading:
|
|
def test_a_missing_file_starts_empty(self, bot):
|
|
instance, _pending = bot
|
|
instance._load_pending()
|
|
assert instance.pending_requests == {}
|
|
|
|
def test_a_truncated_file_is_moved_aside_not_overwritten(self, bot, caplog):
|
|
"""Losing the mapping is bad; losing it *and* the evidence is worse."""
|
|
instance, pending = bot
|
|
pending.write_text('{"123": {"type": "one_on', encoding='utf-8')
|
|
|
|
instance._load_pending()
|
|
|
|
assert instance.pending_requests == {}
|
|
quarantined = [name for name in os.listdir(pending.parent) if '.corrupt-' in name]
|
|
assert len(quarantined) == 1
|
|
assert not pending.exists()
|
|
|
|
def test_a_json_document_that_is_not_an_object_is_refused(self, bot):
|
|
instance, pending = bot
|
|
pending.write_text('[1, 2, 3]', encoding='utf-8')
|
|
|
|
instance._load_pending()
|
|
|
|
assert instance.pending_requests == {}
|
|
assert any('.corrupt-' in name for name in os.listdir(pending.parent))
|
|
|
|
def test_stale_entries_are_dropped(self, bot):
|
|
instance, pending = bot
|
|
old = time.time() - (PENDING_MAX_AGE_DAYS + 1) * 86400
|
|
pending.write_text(
|
|
json.dumps(
|
|
{
|
|
'1': {'type': 'one_on_one', 'id': 1, 'created_at': old},
|
|
'2': {'type': 'one_on_one', 'id': 2, 'created_at': time.time()},
|
|
}
|
|
),
|
|
encoding='utf-8',
|
|
)
|
|
|
|
instance._load_pending()
|
|
|
|
assert list(instance.pending_requests) == [2]
|
|
|
|
def test_the_purge_is_written_back(self, bot):
|
|
instance, pending = bot
|
|
old = time.time() - (PENDING_MAX_AGE_DAYS + 1) * 86400
|
|
pending.write_text(
|
|
json.dumps({'1': {'type': 'one_on_one', 'id': 1, 'created_at': old}}),
|
|
encoding='utf-8',
|
|
)
|
|
|
|
instance._load_pending()
|
|
|
|
assert json.loads(pending.read_text(encoding='utf-8')) == {}
|
|
|
|
def test_an_entry_without_a_timestamp_is_kept(self, bot):
|
|
"""Records written before this field existed. Dropping them would
|
|
be guessing at their age."""
|
|
instance, pending = bot
|
|
pending.write_text(json.dumps({'1': {'type': 'one_on_one', 'id': 1}}), encoding='utf-8')
|
|
|
|
instance._load_pending()
|
|
|
|
assert list(instance.pending_requests) == [1]
|
|
|
|
|
|
class TestHealthReporting:
|
|
def test_the_status_shape_is_stable(self, monkeypatch):
|
|
"""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_instance', None)
|
|
|
|
status = discord_bot.bot_status()
|
|
|
|
assert set(status) == {
|
|
'configured',
|
|
'running',
|
|
'connected',
|
|
'pending',
|
|
'reminders_scheduled',
|
|
}
|
|
assert status['running'] is False
|
|
assert status['connected'] is False
|
|
assert status['reminders_scheduled'] is False
|
|
|
|
def test_a_dead_thread_reads_as_not_running(self, monkeypatch):
|
|
class DeadThread:
|
|
@staticmethod
|
|
def is_alive():
|
|
return False
|
|
|
|
monkeypatch.setattr(discord_bot, 'bot_thread', DeadThread())
|
|
monkeypatch.setattr(discord_bot, 'bot_instance', None)
|
|
|
|
assert discord_bot.bot_status()['running'] is False
|
|
|
|
def test_health_reports_the_bot_when_it_is_enabled(self, app):
|
|
"""The suite runs with the bot disabled, so this builds an app that
|
|
claims otherwise rather than starting anything."""
|
|
app.config['ENABLE_DISCORD_BOT'] = True
|
|
|
|
payload = app.test_client().get('/health').get_json()
|
|
|
|
assert 'discord_bot' in payload
|
|
assert payload['status'] == 'healthy'
|
|
|
|
def test_health_stays_quiet_when_the_bot_is_off(self, app, client):
|
|
payload = client.get('/health').get_json()
|
|
|
|
assert 'discord_bot' not in payload
|