Expanded integration coverage and enforced test categories.
Audit / Dependencies (push) Successful in 9s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 1m7s
CI / Type Checking (push) Successful in 12s
CI / Spelling (push) Successful in 8s

This commit is contained in:
2026-06-18 16:10:40 -04:00
parent 49062159f9
commit 5bbb0bbde5
22 changed files with 2433 additions and 550 deletions
+103 -112
View File
@@ -20,7 +20,6 @@ Tests cover the CLI helpers and runtime orchestration from crabstero.cli.
import asyncio
import os
import signal
import socket
import sys
from typing import TYPE_CHECKING, Any, ClassVar, Self
@@ -43,12 +42,37 @@ if TYPE_CHECKING:
class _FakeCrabstero:
"""Fake bot that records CLI lifecycle calls without touching Discord.
Tests can inject callbacks during login/connect and exceptions during
login/close to exercise CLI shutdown ordering and propagation paths.
"""
events: ClassVar[list[object]] = []
login_error: ClassVar[BaseException | None] = None
login_callback: ClassVar[Callable[[], None] | None] = None
close_error: ClassVar[BaseException | None] = None
connect_callback: ClassVar[Callable[[], None] | None] = None
@classmethod
def reset(
cls,
*,
login_error: BaseException | None = None,
close_error: BaseException | None = None,
) -> list[object]:
"""Clear scenario state and return the shared lifecycle event log.
The event log is shared across fake instances so tests can assert the
ordering of construction, login, notifications, connect, and close.
"""
cls.events = []
cls.login_error = login_error
cls.login_callback = None
cls.close_error = close_error
cls.connect_callback = None
return cls.events
def __init__(self, **kwargs: object) -> None:
self._closed = False
self.events.append(("init", kwargs))
@@ -94,34 +118,68 @@ class _FakeCrabstero:
return self._closed
def _run_coroutine(
coro: Coroutine[Any, Any, signal.Signals | None],
) -> signal.Signals | None:
return asyncio.run(coro)
def _prepare_main_test(
@pytest.fixture
def prepared_main(
monkeypatch: pytest.MonkeyPatch,
*,
login_error: BaseException | None = None,
close_error: BaseException | None = None,
) -> list[object]:
_FakeCrabstero.events = []
_FakeCrabstero.login_error = login_error
_FakeCrabstero.login_callback = None
_FakeCrabstero.close_error = close_error
_FakeCrabstero.connect_callback = None
) -> Callable[..., list[object]]:
"""Return a factory that prepares cli.main for fake-bot lifecycle tests.
def sd_notify(state: str) -> None:
_FakeCrabstero.events.append(("notify", state))
Each factory call resets the fake bot, routes systemd notifications into
the event log, disables watchdog setup, and makes uvloop.run execute the
coroutine synchronously through asyncio.run.
"""
monkeypatch.setattr(sys, "argv", ["crabstero", "--token", "cli-token"])
monkeypatch.setattr(cli, "Crabstero", _FakeCrabstero)
monkeypatch.setattr("crabstero.cli.uvloop.run", _run_coroutine)
monkeypatch.setattr(cli, "_sd_notify", sd_notify)
monkeypatch.setattr(cli, "_watchdog_interval", lambda: None)
def prepare(
*,
login_error: BaseException | None = None,
close_error: BaseException | None = None,
) -> list[object]:
events = _FakeCrabstero.reset(
login_error=login_error,
close_error=close_error,
)
return _FakeCrabstero.events
def sd_notify(state: str) -> None:
events.append(("notify", state))
monkeypatch.setattr(sys, "argv", ["crabstero", "--token", "cli-token"])
monkeypatch.setattr(cli, "Crabstero", _FakeCrabstero)
monkeypatch.setattr("crabstero.cli.uvloop.run", asyncio.run)
monkeypatch.setattr(cli, "_sd_notify", sd_notify)
monkeypatch.setattr(cli, "_watchdog_interval", lambda: None)
return events
return prepare
@pytest.fixture
def signal_handlers(
monkeypatch: pytest.MonkeyPatch,
) -> dict[int, Callable[[], None]]:
"""Patch signal registration and return captured signal callbacks.
The returned mapping lets tests invoke registered SIGINT/SIGTERM handlers
directly and assert that cleanup removes them.
"""
handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
"""Minimal event-loop facade that stores installed signal handlers."""
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
handlers.pop(sig, None)
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
return handlers
class TestParseArgs:
@@ -322,23 +380,6 @@ class TestSystemdNotify:
monkeypatch.delenv("NOTIFY_SOCKET", raising=False)
_sd_notify("READY=1")
def test_sd_notify_sends_datagram(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_sd_notify sends the payload to NOTIFY_SOCKET."""
socket_path = tmp_path / "notify.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as server:
server.bind(str(socket_path))
server.settimeout(1)
monkeypatch.setenv("NOTIFY_SOCKET", str(socket_path))
_sd_notify("READY=1")
assert server.recv(1024) == b"READY=1"
class TestWatchdog:
"""Systemd watchdog helper behavior."""
@@ -426,10 +467,10 @@ class TestMainLifecycle:
def test_constructs_bot_without_token(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""The CLI keeps the Discord token out of Crabstero construction."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert cli.main() == 0
@@ -449,14 +490,12 @@ class TestMainLifecycle:
def test_main_accepts_explicit_argv(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""main(argv) runs from explicit arguments instead of sys.argv."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert (
cli.main(["--token", "argv-token", "--database-path", "/custom.db"]) == 0
)
assert cli.main(["--token", "argv-token", "--database-path", "/custom.db"]) == 0
assert events[0] == (
"init",
@@ -470,10 +509,10 @@ class TestMainLifecycle:
def test_ready_sent_after_login_before_connect(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""READY=1 is sent after login succeeds and before connect starts."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert cli.main() == 0
@@ -486,10 +525,10 @@ class TestMainLifecycle:
def test_stopping_sent_after_clean_connect_return(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""STOPPING=1 is sent when the CLI run exits cleanly after readiness."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert cli.main() == 0
@@ -499,13 +538,10 @@ class TestMainLifecycle:
def test_ready_not_sent_when_login_fails(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""A login failure exits without reporting readiness."""
events = _prepare_main_test(
monkeypatch,
login_error=RuntimeError("login failed"),
)
events = prepared_main(login_error=RuntimeError("login failed"))
with pytest.raises(RuntimeError, match="login failed"):
cli.main()
@@ -515,32 +551,15 @@ class TestMainLifecycle:
def test_shutdown_task_exception_is_propagated(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
signal_handlers: dict[int, Callable[[], None]],
) -> None:
"""A completed shutdown task exception is not silently dropped."""
signal_handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
signal_handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
signal_handlers.pop(sig, None)
events = _prepare_main_test(
monkeypatch,
close_error=RuntimeError("close failed"),
)
events = prepared_main(close_error=RuntimeError("close failed"))
def request_shutdown() -> None:
signal_handlers[signal.SIGTERM]()
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
_FakeCrabstero.connect_callback = request_shutdown
with pytest.raises(RuntimeError, match="close failed"):
@@ -559,29 +578,15 @@ class TestMainLifecycle:
self,
sig: signal.Signals,
exit_code: int,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
signal_handlers: dict[int, Callable[[], None]],
) -> None:
"""Signal callbacks preserve SIGINT and SIGTERM CLI exit semantics."""
signal_handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
signal_handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
signal_handlers.pop(sig, None)
events = _prepare_main_test(monkeypatch)
events = prepared_main()
def request_shutdown() -> None:
signal_handlers[sig]()
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
_FakeCrabstero.connect_callback = request_shutdown
assert cli.main() == exit_code
@@ -591,29 +596,15 @@ class TestMainLifecycle:
def test_registered_signal_during_login_exits_before_ready(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
signal_handlers: dict[int, Callable[[], None]],
) -> None:
"""A signal during login cancels startup without reporting readiness."""
signal_handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
signal_handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
signal_handlers.pop(sig, None)
events = _prepare_main_test(monkeypatch)
events = prepared_main()
def request_shutdown() -> None:
signal_handlers[signal.SIGINT]()
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
_FakeCrabstero.login_callback = request_shutdown
assert cli.main() == 130