signal-bot/tests/test_command_switch.py
Krzysztof kuhy Rudnicki d13ae42590
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
A switchboard: any command off, off until, or on its own cooldown
One row per live command on a new Commands tab of the management page,
each with an on/off switch, an optional "off until" moment and a cooldown
that overrides the bot's default for that command alone. Every switch is
live: the bot keeps them in its own SQLite (command_settings, a permanent
table) and reads the row on every attempt, so nothing goes through
/_reload and nothing restarts.

A command that is off answers "BEEP BOOP !kot WYŁĄCZONE." (with "DO HH:MM"
for a timed switch) rather than going quiet, so nobody reports it broken;
the refusal is filed as refused_off, which the analytics count with the
other refusals, and it neither starts the cooldown nor counts as a use.

The bot grows GET /_commands and PUT /_commands/{name}; the management app
proxies them under /api/commands and passes the bot's 404/503 through
unwrapped. !cisza and !odcisz carry no cooldown and the page says so
instead of accepting a number the bot would ignore (test_wiring_ungated
keeps that set honest against the real table).

Proven end to end: scripts/e2e.sh now brackets a PUT with switch_off.yaml
and switch_on.yaml, the same actor refused and then served, with no reload
between them. Also bumps vitest and @vitest/coverage-v8 to 5.0.0 and
@types/react-dom to 19.2.7, which the freshness gate asked for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 15:55:56 +02:00

132 lines
5.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""A command switched off from the page: answered, filed, and live at once."""
from __future__ import annotations
import datetime as dt
from typing import TYPE_CHECKING
import pytest
from signal_bot.command_settings import CommandSetting
from signal_bot.command_switch import off_message
from signal_bot.decorators import cooldown
from signal_bot.registry import CommandRegistry
from tests.conftest import WATCHED_GROUP, envelope
from tests.test_dispatcher_recording import SENDER, build, history
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.context import CommandContext
from signal_bot.discovery import GroupDirectory
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
# 2026-09-04 12:00 UTC, which Warsaw (CEST) reads as 14:00.
NOON_UTC = dt.datetime(2026, 9, 4, 12, 0, tzinfo=dt.UTC).timestamp()
def _registry(runs: list[str], *, cooldown_seconds: float = 0) -> CommandRegistry:
"""!cat, wrapped in a cooldown when asked, that records each run."""
async def handler(ctx: CommandContext) -> None:
runs.append(ctx.command_name)
reg = CommandRegistry()
wrapped = (
cooldown(cooldown_seconds, "poczekaj {seconds} {unit}")(handler)
if cooldown_seconds
else handler
)
reg.register("cat", ["!cat"], wrapped, "kot")
return reg
@pytest.mark.parametrize(
("disabled_until", "text"),
[
(None, "BEEP BOOP !cat WYŁĄCZONE."),
(NOON_UTC + 3600, "BEEP BOOP !cat WYŁĄCZONE DO 15:00."),
# A deadline that has passed is not the reason the command is off.
(NOON_UTC - 1, "BEEP BOOP !cat WYŁĄCZONE."),
(NOON_UTC, "BEEP BOOP !cat WYŁĄCZONE."),
],
)
def test_the_refusal_names_the_deadline_in_warsaw_time(
disabled_until: float | None, text: str
) -> None:
assert off_message("!cat", disabled_until, NOON_UTC) == text
async def test_a_command_that_is_off_answers_and_is_filed_as_a_refusal(
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
) -> None:
"""Every attempt gets the word, none of them counts as a use."""
runs: list[str] = []
dispatcher = build(config, directory, _registry(runs), store, transport)
store.command_settings.put(CommandSetting("cat", enabled=False))
await dispatcher.handle(envelope(text="!cat", timestamp=1000))
await dispatcher.handle(envelope(text="!cat", timestamp=2000))
assert runs == []
assert [m.text for m in transport.sent] == ["BEEP BOOP !cat WYŁĄCZONE."] * 2
assert [row["outcome"] for row in history(store)] == ["refused_off"] * 2
assert store.last_used(WATCHED_GROUP, SENDER, "cat") is None
async def test_the_switch_is_live_in_both_directions(
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
) -> None:
"""Off, then on again, between two frames: no reload in between."""
runs: list[str] = []
dispatcher = build(config, directory, _registry(runs), store, transport)
await dispatcher.handle(envelope(text="!cat", timestamp=1000))
store.command_settings.put(CommandSetting("cat", enabled=False))
await dispatcher.handle(envelope(text="!cat", timestamp=2000))
store.command_settings.put(CommandSetting("cat"))
await dispatcher.handle(envelope(text="!cat", timestamp=3000))
assert runs == ["cat", "cat"]
assert [row["outcome"] for row in history(store)] == ["ok", "refused_off", "ok"]
async def test_a_timed_switch_lifts_itself(
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
) -> None:
runs: list[str] = []
clock = [dt.datetime(2026, 9, 4, 12, 0, tzinfo=dt.UTC)]
dispatcher = build(config, directory, _registry(runs), store, transport, clock=lambda: clock[0])
store.command_settings.put(CommandSetting("cat", disabled_until=NOON_UTC + 60))
await dispatcher.handle(envelope(text="!cat", timestamp=1000))
clock[0] += dt.timedelta(seconds=60)
await dispatcher.handle(envelope(text="!cat", timestamp=2000))
assert runs == ["cat"]
assert transport.sent[0].text == "BEEP BOOP !cat WYŁĄCZONE DO 14:01."
async def test_the_operator_cooldown_is_read_on_every_attempt(
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
) -> None:
"""A deployment cooldown of an hour, cut to nothing from the page, live."""
runs: list[str] = []
dispatcher = build(config, directory, _registry(runs, cooldown_seconds=3600), store, transport)
await dispatcher.handle(envelope(text="!cat", timestamp=1000))
await dispatcher.handle(envelope(text="!cat", timestamp=2000))
assert runs == ["cat"]
assert transport.sent[-1].text.startswith("poczekaj")
store.command_settings.put(CommandSetting("cat", cooldown_seconds=0))
await dispatcher.handle(envelope(text="!cat", timestamp=3000))
assert runs == ["cat", "cat"]
# And back: a longer one than the deployment's bites where it did not.
store.command_settings.put(CommandSetting("cat", cooldown_seconds=7200))
await dispatcher.handle(envelope(text="!cat", timestamp=4000))
assert runs == ["cat", "cat"]
assert [row["outcome"] for row in history(store)][-1] == "refused_cooldown"