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]>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user