Files
team-tryouts/tests/test_discord_state.py
T
GGThedandClaude Opus 5 e5c29d8113 fix(ops): rendre visibles les pannes silencieuses du bot Discord
OPS-005, OPS-006, OPS-007, OPS-009 et OPS-012. Cinq constats, un motif
commun : le bot pouvait cesser de fonctionner correctement sans que rien,
nulle part, ne le dise.

OPS-006 -- etat en attente ecrit en place
_save_pending ouvrait le fichier de destination en ecriture puis
serialisait dedans : toute interruption laissait un JSON tronque. Et
_load_pending interceptait l erreur de lecture, la journalisait, puis
repartait avec un dictionnaire vide -- toutes les correspondances message
Discord <-> demande disparaissaient, les reactions en cours cessaient
d avoir un effet, et l interface n en montrait rien.

Ecriture par fichier temporaire voisin puis os.replace : la destination
contient l ancien contenu ou le nouveau, jamais la moitie d un des deux.
A la lecture, un fichier illisible est deplace en .corrupt-<horodatage>
plutot qu ecrase, et le message dit ce qui est perdu.

Ecart assume avec la recommandation d audit (« echouer bruyamment ») : le
bot demarre quand meme. Refuser de demarrer supprimerait toutes les
notifications au lieu de celles deja en vol.

OPS-007 -- fuite lente
Les entrees n etaient retirees qu apres reaction. Elles portent desormais
`created_at` et sont purgees au chargement au-dela de 30 jours. Une entree
sans horodatage est conservee : elle precede ce champ, la supprimer serait
deviner son age.

OPS-005 -- planificateur
`coalesce=True`, `misfire_grace_time=3600`, `max_instances=1`. Sans delai
de grace, un redemarrage a 18 h 05 perdait les rappels du jour sans trace ;
sans coalescence, un planificateur en retard envoie un rappel par
occurrence manquee, donc des messages en double.

OPS-009 -- controle d identite asymetrique
handle_one_on_one_approve et _reject comparent depuis toujours le compte
qui reagit au coach destinataire. Les deux gestionnaires de presence ne le
faisaient pas. Meme forme de message, meme risque, un seul verifiait :
c est l asymetrie qui etait le bug.

Au passage : confirmer sa presence a un tryout ecrivait un attribut qui n a
pas de colonne (DB-008, bloque sur Alembic). Le joueur lisait « confirme »
et rien n etait enregistre. Toujours vrai, mais desormais journalise en
warning avec l identifiant concerne.

OPS-012 -- etat du bot dans /health
Le bot tourne dans un fil demon du processus web. Quand ce fil meurt, le
site continue de servir des pages et plus aucune notification ne part.
/health expose maintenant configured / running / connected / pending.
Signale, pas fatal : un club sans rappels Discord est degrade, pas hors
service, et un 503 le sortirait du repartiteur de charge pour ca.

discord_pending.json passe hors suivi git. La regle d ignore etait en place
mais inerte. Consequence non relevee par l audit : le deploiement etant un
miroir de fichiers, chaque livraison ecrasait l etat vivant du serveur par
celui du depot.

13 tests, sans aucun appel a Discord.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 18:21:05 -04:00

181 lines
6.3 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."""
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'}
assert status['running'] is False
assert status['connected'] 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