Skip to content

Commit ec1e27a

Browse files
authored
refactor: extract calculation posting (#251)
* refactor: extract calculation posting
1 parent ea680b8 commit ec1e27a

7 files changed

Lines changed: 294 additions & 152 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ guild_config (
113113
- `typer_bot/commands/admin_panel/`: Admin panel UI views, selects, and modals split out of `admin_commands.py`.
114114
- `typer_bot/handlers/thread_prediction_handler.py`: Thread-based prediction processing (on_message) plus thread prediction cooldown state.
115115
- `typer_bot/commands/admin_commands.py`: `/admin` command surface and orchestration for admin workflows, including admin calculation cooldown state.
116+
- `typer_bot/services/calculation_posting.py`: Post-calculation side effects: best-effort DB backup, league-channel publishing, and admin interaction responses.
116117
- `typer_bot/utils/config.py`: Centralized configuration (data paths via env vars).
117118
- `typer_bot/utils/prediction_parser.py`: Central logic for parsing "2-1" or "2:1" strings.
118119
- `typer_bot/utils/scoring.py`: Point calculation using season scoring rules.

tests/admin_panel/test_fixture_panel_results_actions.py

Lines changed: 56 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import discord
55
import pytest
66

7+
import typer_bot.commands.admin_panel.unified_actions as unified_actions
78
from tests.admin_panel_helpers import get_button as _get_button
89
from tests.admin_panel_helpers import has_button as _has_button
910
from typer_bot.commands.admin_panel import (
@@ -74,6 +75,7 @@ async def test_unified_panel_calculate_scores_button_posts_results(
7475
admin_cog,
7576
mock_interaction_admin,
7677
sample_games,
78+
monkeypatch,
7779
):
7880
fixture_id = await admin_cog.db.create_fixture(
7981
"111111", 45, sample_games, datetime.now(UTC) + timedelta(days=1)
@@ -86,18 +88,10 @@ async def test_unified_panel_calculate_scores_button_posts_results(
8688
["2-1", "1-1", "0-2"],
8789
False,
8890
)
89-
command_channel = MagicMock(spec=discord.TextChannel)
90-
command_channel.id = 999999
91-
command_channel.send = AsyncMock()
92-
league_channel = MagicMock(spec=discord.TextChannel)
93-
league_channel.id = 123456
94-
league_channel.send = AsyncMock()
95-
mock_interaction_admin.channel = command_channel
96-
admin_cog.bot.get_channel.return_value = None
97-
admin_cog.bot.fetch_channel = AsyncMock(return_value=league_channel)
9891
mock_interaction_admin.message = MagicMock()
9992
mock_interaction_admin.message.edit = AsyncMock()
100-
admin_cog._create_backup = AsyncMock()
93+
post_calculation_result = AsyncMock()
94+
monkeypatch.setattr(unified_actions, "post_calculation_result", post_calculation_result)
10195

10296
view = UnifiedAdminPanelView(
10397
admin_cog.db,
@@ -118,74 +112,24 @@ async def test_unified_panel_calculate_scores_button_posts_results(
118112
admin_cog.get_calculate_cooldown("111111", str(mock_interaction_admin.user.id))
119113
is not None
120114
)
121-
league_channel.send.assert_awaited_once()
122-
command_channel.send.assert_not_awaited()
123-
assert (
124-
"Week 45 results calculated and posted to the league channel"
125-
in mock_interaction_admin.response_sent[-1]["content"]
115+
post_calculation_result.assert_awaited_once()
116+
assert post_calculation_result.call_args.args[:3] == (
117+
admin_cog.bot,
118+
admin_cog.db,
119+
mock_interaction_admin,
126120
)
127-
assert "User One" in league_channel.send.call_args.args[0]
128121
assert view.selection.fixture_label == "Week 45 [CLOSED]"
129122
assert _has_button(view, "Calculate Scores") is False
130123
assert _has_button(view, "Delete Fixture") is False
131124
assert mock_interaction_admin.message.edit.await_count == 1
132125

133-
@pytest.mark.asyncio
134-
async def test_unified_panel_calculate_scores_button_rejects_unavailable_league_channel(
135-
self,
136-
admin_cog,
137-
mock_interaction_admin,
138-
sample_games,
139-
):
140-
fixture_id = await admin_cog.db.create_fixture(
141-
"111111", 46, sample_games, datetime.now(UTC) + timedelta(days=1)
142-
)
143-
await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"])
144-
await admin_cog.db.save_prediction(
145-
fixture_id,
146-
"111",
147-
"User One",
148-
["2-1", "1-1", "0-2"],
149-
False,
150-
)
151-
command_channel = MagicMock(spec=discord.TextChannel)
152-
command_channel.send = AsyncMock()
153-
mock_interaction_admin.channel = command_channel
154-
admin_cog.bot.get_channel.return_value = None
155-
admin_cog.bot.fetch_channel = AsyncMock(
156-
side_effect=discord.InvalidData("unknown channel type")
157-
)
158-
mock_interaction_admin.message = MagicMock()
159-
mock_interaction_admin.message.edit = AsyncMock()
160-
admin_cog._create_backup = AsyncMock()
161-
162-
view = UnifiedAdminPanelView(
163-
admin_cog.db,
164-
admin_cog.service,
165-
str(mock_interaction_admin.user.id),
166-
"111111",
167-
admin_commands=admin_cog,
168-
bot=admin_cog.bot,
169-
)
170-
await view.load_fixture_options()
171-
view.fixture_select._values = [str(fixture_id)]
172-
await view.fixture_select.callback(mock_interaction_admin)
173-
174-
calculate_button = _get_button(view, "Calculate Scores")
175-
await calculate_button.callback(mock_interaction_admin)
176-
177-
command_channel.send.assert_not_awaited()
178-
assert (
179-
"configured league channel is unavailable"
180-
in mock_interaction_admin.response_sent[-1]["content"].lower()
181-
)
182-
183126
@pytest.mark.asyncio
184127
async def test_stale_calculate_scores_button_refreshes_when_fixture_already_scored(
185128
self,
186129
admin_cog,
187130
mock_interaction_admin,
188131
sample_games,
132+
monkeypatch,
189133
):
190134
fixture_id = await admin_cog.db.create_fixture(
191135
"111111", 45, sample_games, datetime.now(UTC) + timedelta(days=1)
@@ -200,8 +144,8 @@ async def test_stale_calculate_scores_button_refreshes_when_fixture_already_scor
200144
)
201145
mock_interaction_admin.message = MagicMock()
202146
mock_interaction_admin.message.edit = AsyncMock()
203-
admin_cog._create_backup = AsyncMock()
204-
admin_cog._post_calculation_to_channel = AsyncMock()
147+
post_calculation_result = AsyncMock()
148+
monkeypatch.setattr(unified_actions, "post_calculation_result", post_calculation_result)
205149
view = UnifiedAdminPanelView(
206150
admin_cog.db,
207151
admin_cog.service,
@@ -219,8 +163,7 @@ async def test_stale_calculate_scores_button_refreshes_when_fixture_already_scor
219163
await stale_button.callback(mock_interaction_admin)
220164

221165
assert "no longer open" in mock_interaction_admin.response_sent[-1]["content"]
222-
admin_cog._create_backup.assert_not_awaited()
223-
admin_cog._post_calculation_to_channel.assert_not_awaited()
166+
post_calculation_result.assert_not_awaited()
224167
assert view.selection.fixture_label == "Week 45 [CLOSED]"
225168
assert _has_button(view, "Calculate Scores") is False
226169
assert _has_button(view, "Delete Fixture") is False
@@ -232,6 +175,7 @@ async def test_unified_panel_calculate_scores_button_rejects_active_cooldown(
232175
admin_cog,
233176
mock_interaction_admin,
234177
sample_games,
178+
monkeypatch,
235179
):
236180
fixture_id = await admin_cog.db.create_fixture(
237181
"111111", 47, sample_games, datetime.now(UTC) + timedelta(days=1)
@@ -241,6 +185,8 @@ async def test_unified_panel_calculate_scores_button_rejects_active_cooldown(
241185
"111111", str(mock_interaction_admin.user.id), current_time=now().timestamp()
242186
)
243187
admin_cog.service.calculate_fixture_scores = AsyncMock()
188+
post_calculation_result = AsyncMock()
189+
monkeypatch.setattr(unified_actions, "post_calculation_result", post_calculation_result)
244190

245191
view = UnifiedAdminPanelView(
246192
admin_cog.db,
@@ -258,20 +204,22 @@ async def test_unified_panel_calculate_scores_button_rejects_active_cooldown(
258204
await calculate_button.callback(mock_interaction_admin)
259205

260206
assert "Please wait" in mock_interaction_admin.response_sent[-1]["content"]
207+
post_calculation_result.assert_not_awaited()
261208

262209
@pytest.mark.asyncio
263210
async def test_unified_panel_calculate_scores_button_handles_service_error(
264211
self,
265212
admin_cog,
266213
mock_interaction_admin,
267214
sample_games,
215+
monkeypatch,
268216
):
269217
fixture_id = await admin_cog.db.create_fixture(
270218
"111111", 48, sample_games, datetime.now(UTC) + timedelta(days=1)
271219
)
272220
await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"])
273-
admin_cog._create_backup = AsyncMock()
274-
221+
post_calculation_result = AsyncMock()
222+
monkeypatch.setattr(unified_actions, "post_calculation_result", post_calculation_result)
275223
view = UnifiedAdminPanelView(
276224
admin_cog.db,
277225
admin_cog.service,
@@ -291,6 +239,42 @@ async def test_unified_panel_calculate_scores_button_handles_service_error(
291239
mock_interaction_admin.response_sent[-1]["content"]
292240
== "No predictions found for this fixture"
293241
)
242+
post_calculation_result.assert_not_awaited()
243+
244+
@pytest.mark.asyncio
245+
async def test_unified_panel_calculate_scores_button_requires_bot_context(
246+
self,
247+
admin_cog,
248+
mock_interaction_admin,
249+
sample_games,
250+
monkeypatch,
251+
):
252+
fixture_id = await admin_cog.db.create_fixture(
253+
"111111", 49, sample_games, datetime.now(UTC) + timedelta(days=1)
254+
)
255+
await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"])
256+
admin_cog.service.calculate_fixture_scores = AsyncMock()
257+
post_calculation_result = AsyncMock()
258+
monkeypatch.setattr(unified_actions, "post_calculation_result", post_calculation_result)
259+
260+
view = UnifiedAdminPanelView(
261+
admin_cog.db,
262+
admin_cog.service,
263+
str(mock_interaction_admin.user.id),
264+
"111111",
265+
admin_commands=admin_cog,
266+
bot=None,
267+
)
268+
await view.load_fixture_options()
269+
view.fixture_select._values = [str(fixture_id)]
270+
await view.fixture_select.callback(mock_interaction_admin)
271+
272+
calculate_button = _get_button(view, "Calculate Scores")
273+
await calculate_button.callback(mock_interaction_admin)
274+
275+
assert "unavailable" in mock_interaction_admin.response_sent[-1]["content"]
276+
admin_cog.service.calculate_fixture_scores.assert_not_awaited()
277+
post_calculation_result.assert_not_awaited()
294278

295279
@pytest.mark.asyncio
296280
async def test_unified_panel_post_results_button_opens_confirmation(

tests/test_calculation_posting.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
from unittest.mock import AsyncMock, MagicMock
2+
3+
import discord
4+
import pytest
5+
6+
import typer_bot.services.calculation_posting as calculation_posting
7+
from typer_bot.services.admin_service import FixtureScoreResult
8+
9+
10+
def _score_result(sample_games: list[str]) -> FixtureScoreResult:
11+
return FixtureScoreResult(
12+
fixture={"games": sample_games, "week_number": 7},
13+
results=["2-1", "1-1", "0-2"],
14+
predictions=[],
15+
scores=[],
16+
standings=[
17+
{
18+
"user_id": "111",
19+
"user_name": "User One",
20+
"total_points": 7,
21+
"total_exact": 2,
22+
"total_correct": 1,
23+
}
24+
],
25+
last_fixture=None,
26+
)
27+
28+
29+
def _bot_with_executor() -> MagicMock:
30+
bot = MagicMock(spec=discord.Client)
31+
bot.loop = MagicMock()
32+
33+
async def run_in_executor(_executor, callback):
34+
return callback()
35+
36+
bot.loop.run_in_executor = AsyncMock(side_effect=run_in_executor)
37+
return bot
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_post_calculation_result_posts_to_configured_league_channel(
42+
database,
43+
mock_interaction_admin,
44+
sample_games,
45+
monkeypatch,
46+
):
47+
bot = _bot_with_executor()
48+
channel = MagicMock(spec=discord.TextChannel)
49+
channel.send = AsyncMock()
50+
bot.get_channel.return_value = channel
51+
monkeypatch.setattr(calculation_posting, "create_backup", MagicMock(return_value="backup.sql"))
52+
monkeypatch.setattr(calculation_posting, "cleanup_old_backups", MagicMock(return_value=0))
53+
54+
await calculation_posting.post_calculation_result(
55+
bot, database, mock_interaction_admin, _score_result(sample_games)
56+
)
57+
58+
channel.send.assert_awaited_once()
59+
assert "Week 7 Results" in channel.send.call_args.args[0]
60+
assert "User One" in channel.send.call_args.args[0]
61+
assert "posted to the league channel" in mock_interaction_admin.response_sent[-1]["content"]
62+
63+
64+
@pytest.mark.asyncio
65+
async def test_post_calculation_result_posts_when_backup_fails(
66+
database,
67+
mock_interaction_admin,
68+
sample_games,
69+
monkeypatch,
70+
):
71+
bot = _bot_with_executor()
72+
channel = MagicMock(spec=discord.TextChannel)
73+
channel.send = AsyncMock()
74+
bot.get_channel.return_value = channel
75+
monkeypatch.setattr(
76+
calculation_posting,
77+
"create_backup",
78+
MagicMock(side_effect=RuntimeError("backup failed")),
79+
)
80+
81+
await calculation_posting.post_calculation_result(
82+
bot, database, mock_interaction_admin, _score_result(sample_games)
83+
)
84+
85+
channel.send.assert_awaited_once()
86+
assert "posted to the league channel" in mock_interaction_admin.response_sent[-1]["content"]
87+
88+
89+
@pytest.mark.asyncio
90+
async def test_post_calculation_result_reports_send_failure(
91+
database,
92+
mock_interaction_admin,
93+
sample_games,
94+
monkeypatch,
95+
):
96+
bot = _bot_with_executor()
97+
channel = MagicMock(spec=discord.TextChannel)
98+
channel.send = AsyncMock(side_effect=RuntimeError("discord unavailable"))
99+
bot.get_channel.return_value = channel
100+
monkeypatch.setattr(calculation_posting, "create_backup", MagicMock(return_value="backup.sql"))
101+
monkeypatch.setattr(calculation_posting, "cleanup_old_backups", MagicMock(return_value=0))
102+
103+
await calculation_posting.post_calculation_result(
104+
bot, database, mock_interaction_admin, _score_result(sample_games)
105+
)
106+
107+
assert "failed to post" in mock_interaction_admin.response_sent[-1]["content"]
108+
109+
110+
@pytest.mark.asyncio
111+
async def test_post_calculation_result_reports_unavailable_league_channel(
112+
database,
113+
mock_interaction_admin,
114+
sample_games,
115+
monkeypatch,
116+
):
117+
bot = _bot_with_executor()
118+
bot.get_channel.return_value = None
119+
bot.fetch_channel = AsyncMock(side_effect=discord.InvalidData("unknown channel type"))
120+
monkeypatch.setattr(calculation_posting, "create_backup", MagicMock(return_value="backup.sql"))
121+
monkeypatch.setattr(calculation_posting, "cleanup_old_backups", MagicMock(return_value=0))
122+
123+
await calculation_posting.post_calculation_result(
124+
bot, database, mock_interaction_admin, _score_result(sample_games)
125+
)
126+
127+
assert (
128+
"configured league channel is unavailable"
129+
in mock_interaction_admin.response_sent[-1]["content"].lower()
130+
)

0 commit comments

Comments
 (0)