Skip to content

Commit aba4afb

Browse files
author
Mihai Chindris
committed
Add CI and deterministic mocked tests
1 parent 2a1988f commit aba4afb

6 files changed

Lines changed: 166 additions & 17 deletions

File tree

.github/workflows/ci.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: ci-${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
lint-and-typecheck:
18+
runs-on: ubuntu-latest
19+
20+
steps:
21+
- name: Check out repository
22+
uses: actions/checkout@v5
23+
24+
- name: Set up Python
25+
uses: actions/setup-python@v5
26+
with:
27+
python-version: "3.11"
28+
cache: pip
29+
cache-dependency-path: pyproject.toml
30+
31+
- name: Install system dependencies
32+
run: sudo apt-get update && sudo apt-get install -y libolm-dev
33+
34+
- name: Install project dependencies
35+
run: |
36+
python -m pip install --upgrade pip
37+
python -m pip install -e ".[dev]"
38+
39+
- name: Lint with Ruff
40+
run: ruff check src tests
41+
42+
- name: Type check with mypy
43+
run: mypy src
44+
45+
test:
46+
runs-on: ubuntu-latest
47+
strategy:
48+
fail-fast: false
49+
matrix:
50+
python-version:
51+
- "3.11"
52+
- "3.12"
53+
54+
steps:
55+
- name: Check out repository
56+
uses: actions/checkout@v5
57+
58+
- name: Set up Python
59+
uses: actions/setup-python@v5
60+
with:
61+
python-version: ${{ matrix.python-version }}
62+
cache: pip
63+
cache-dependency-path: pyproject.toml
64+
65+
- name: Install system dependencies
66+
run: sudo apt-get update && sudo apt-get install -y libolm-dev
67+
68+
- name: Install project dependencies
69+
run: |
70+
python -m pip install --upgrade pip
71+
python -m pip install -e ".[dev]"
72+
73+
- name: Run test suite
74+
run: pytest -q

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ dev = [
5454
"black>=24.0.0",
5555
"ruff>=0.3.0",
5656
"mypy>=1.8.0",
57+
"types-PyYAML>=6.0.12",
5758
"pre-commit>=3.6.0",
5859
]
5960
github = [
@@ -100,6 +101,10 @@ warn_return_any = true
100101
warn_unused_configs = true
101102
disallow_untyped_defs = true
102103

104+
[[tool.mypy.overrides]]
105+
module = ["markdown", "nio", "nio.*"]
106+
ignore_missing_imports = true
107+
103108
[tool.pytest.ini_options]
104109
asyncio_mode = "auto"
105110
testpaths = ["tests"]

src/codebeep/bot.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
from pathlib import Path
1414
from typing import Any
1515

16-
import markdown
16+
import markdown # type: ignore[import-untyped]
1717
import simplematrixbotlib as botlib
18-
from nio import InviteMemberEvent, MegolmEvent, RoomMessageText, RoomPreset
19-
from nio.responses import (
18+
from nio import InviteMemberEvent, MegolmEvent, RoomMessageText, RoomPreset # type: ignore[import-untyped]
19+
from nio.responses import ( # type: ignore[import-untyped]
2020
RoomCreateError,
2121
RoomCreateResponse,
2222
RoomInviteError,
@@ -59,12 +59,13 @@ def __init__(self, config: Config) -> None:
5959
config: Bot configuration
6060
"""
6161
self.config = config
62-
auth = None
63-
if config.opencode.auth and config.opencode.auth.username and config.opencode.auth.password:
64-
auth = (config.opencode.auth.username, config.opencode.auth.password)
62+
auth_config = config.opencode.auth
63+
auth: tuple[str, str] | None = None
64+
if auth_config is not None and auth_config.username and auth_config.password:
65+
auth = (auth_config.username, auth_config.password)
6566

6667
if auth:
67-
logger.info("OpenCode auth configured for user %s", config.opencode.auth.username)
68+
logger.info("OpenCode auth configured for user %s", auth[0])
6869
else:
6970
logger.warning("OpenCode auth not configured; requests may be unauthorized")
7071

@@ -429,7 +430,8 @@ def _parse_transport_payload(self, transport_response: Any) -> dict[str, Any] |
429430
if isinstance(content, (bytes, bytearray)):
430431
content = content.decode("utf-8", errors="ignore")
431432
if isinstance(content, str):
432-
return json.loads(content)
433+
payload = json.loads(content)
434+
return payload if isinstance(payload, dict) else None
433435
except Exception:
434436
return None
435437
return None
@@ -507,7 +509,7 @@ async def _resolve_room_alias(self, alias: str) -> str | None:
507509
"Room alias resolve", self.bot.api.async_client.room_resolve_alias, alias
508510
)
509511
if isinstance(response, RoomResolveAliasResponse):
510-
return response.room_id
512+
return response.room_id if isinstance(response.room_id, str) else None
511513
if isinstance(response, RoomResolveAliasError):
512514
return None
513515
room_id = getattr(response, "room_id", None)

src/codebeep/commands.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,8 +396,10 @@ class SSHCommand(Command):
396396
usage = "/ssh"
397397
aliases = ["mosh"]
398398

399-
async def execute(self, bot: CodeBeepBot, args: str) -> CommandResult:
400-
del args
399+
async def execute(
400+
self, bot: CodeBeepBot, args: str, context: CommandContext
401+
) -> CommandResult:
402+
del args, context
401403

402404
host = (bot.config.bot.connect_host or "").strip()
403405
if not host:

tests/test_commands.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from codebeep.commands import HelpCommand, SSHCommand
7+
from codebeep.commands import CommandContext, HelpCommand, SSHCommand
88
from codebeep.config import Config
99

1010

@@ -17,13 +17,16 @@ def make_config(**bot_overrides: object) -> Config:
1717
return Config.model_validate(payload)
1818

1919

20+
TEST_CONTEXT = CommandContext(room_id="!room:example.org", sender="@mihai:matrix.org")
21+
22+
2023
@pytest.mark.asyncio
2124
async def test_ssh_command_requires_configured_host() -> None:
2225
"""The connect helper should stay disabled until a host is configured."""
2326
command = SSHCommand()
2427
bot = SimpleNamespace(config=make_config())
2528

26-
result = await command.execute(bot, "")
29+
result = await command.execute(bot, "", TEST_CONTEXT)
2730

2831
assert result.success is True
2932
assert "not configured" in result.message.lower()
@@ -38,7 +41,7 @@ async def test_ssh_command_formats_default_commands() -> None:
3841
config=make_config(connect_host="codebeep.tailnet.example", connect_user="mihai")
3942
)
4043

41-
result = await command.execute(bot, "")
44+
result = await command.execute(bot, "", TEST_CONTEXT)
4245

4346
assert result.success is True
4447
assert "`ssh mihai@codebeep.tailnet.example`" in result.message
@@ -57,7 +60,7 @@ async def test_ssh_command_formats_non_default_port() -> None:
5760
)
5861
)
5962

60-
result = await command.execute(bot, "")
63+
result = await command.execute(bot, "", TEST_CONTEXT)
6164

6265
assert "`ssh -p 2222 mihai@100.64.0.42`" in result.message
6366
assert '`mosh --ssh="ssh -p 2222" mihai@100.64.0.42`' in result.message
@@ -73,8 +76,23 @@ async def test_help_lists_ssh_command_once() -> None:
7376
commands={"ssh": ssh, "mosh": ssh},
7477
)
7578

76-
result = await help_command.execute(bot, "")
79+
result = await help_command.execute(bot, "", TEST_CONTEXT)
7780

7881
assert result.success is True
7982
assert result.message.count("`/ssh`") == 1
8083

84+
85+
@pytest.mark.asyncio
86+
async def test_help_resolves_alias_to_primary_command() -> None:
87+
"""Alias help should render the canonical command entry."""
88+
ssh = SSHCommand()
89+
help_command = HelpCommand()
90+
bot = SimpleNamespace(
91+
config=make_config(),
92+
commands={"ssh": ssh, "mosh": ssh},
93+
)
94+
95+
result = await help_command.execute(bot, "mosh", TEST_CONTEXT)
96+
97+
assert result.success is True
98+
assert result.message.startswith("**/ssh**")

tests/test_opencode_client.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import httpx
1010
import pytest
1111

12-
from codebeep.opencode_client import Message, OpenCodeClient
12+
from codebeep.opencode_client import Message, OpenCodeClient, OpenCodeInvalidResponseError
1313

1414

1515
@pytest.fixture
@@ -64,6 +64,21 @@ def stream(self, method: str, path: str) -> _FakeStreamResponse:
6464
return self._responses.pop(0)
6565

6666

67+
class _RetryingHttpClient:
68+
def __init__(self, responses: list[httpx.Response | Exception]) -> None:
69+
self._responses = responses
70+
self.calls = 0
71+
self.requests: list[dict[str, object]] = []
72+
73+
async def request(self, method: str, path: str, **kwargs) -> httpx.Response:
74+
self.calls += 1
75+
self.requests.append({"method": method, "path": path, **kwargs})
76+
response = self._responses.pop(0)
77+
if isinstance(response, Exception):
78+
raise response
79+
return response
80+
81+
6782
class TestOpenCodeClient:
6883
"""Tests for OpenCodeClient."""
6984

@@ -141,3 +156,36 @@ async def test_subscribe_events_falls_back_to_legacy_path(
141156
assert fake_client.requested_paths == ["/global/event", "/event"]
142157
assert events[0].type == "session.message"
143158
assert client.extract_session_id_from_event(events[0]) == "sess-1"
159+
160+
@pytest.mark.asyncio
161+
async def test_request_retries_transport_error(self, client: OpenCodeClient, monkeypatch) -> None:
162+
request = httpx.Request("GET", "http://127.0.0.1:4096/session")
163+
fake_client = _RetryingHttpClient(
164+
[
165+
httpx.ConnectError("boom", request=request),
166+
httpx.Response(200, request=request, json=[]),
167+
]
168+
)
169+
client._get_client = AsyncMock(return_value=fake_client) # type: ignore[attr-defined]
170+
sleep = AsyncMock()
171+
monkeypatch.setattr("codebeep.opencode_client.asyncio.sleep", sleep)
172+
monkeypatch.setattr("codebeep.opencode_client.random.uniform", lambda _a, _b: 0.0)
173+
174+
sessions = await client.list_sessions()
175+
176+
assert sessions == []
177+
assert fake_client.calls == 2
178+
sleep.assert_awaited_once()
179+
180+
@pytest.mark.asyncio
181+
async def test_list_sessions_rejects_non_json_payload(self, client: OpenCodeClient) -> None:
182+
request = httpx.Request("GET", "http://127.0.0.1:4096/session")
183+
fake_client = _RetryingHttpClient(
184+
[
185+
httpx.Response(200, request=request, text="not-json"),
186+
]
187+
)
188+
client._get_client = AsyncMock(return_value=fake_client) # type: ignore[attr-defined]
189+
190+
with pytest.raises(OpenCodeInvalidResponseError):
191+
await client.list_sessions()

0 commit comments

Comments
 (0)