Build reusable bot framework
Some checks failed
CI / test (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled

This commit is contained in:
Chelsea Lee
2026-07-19 21:53:24 -05:00
parent 7ecc1107b2
commit fbdf33e894
66 changed files with 8428 additions and 0 deletions

310
tests/unit/test_adapters.py Normal file
View File

@@ -0,0 +1,310 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, call
import httpx
import pytest
from bot import api_client as apiClientModule
from bot.context import CommandContext
from scheduler import daemon
def _response(status, payload=None, jsonError=False):
response = MagicMock(status_code=status)
if jsonError:
response.json.side_effect = ValueError("not JSON")
else:
response.json.return_value = payload
return response
@pytest.fixture
def apiClient(monkeypatch):
transport = SimpleNamespace(
post=AsyncMock(),
request=AsyncMock(),
aclose=AsyncMock(),
)
clientFactory = MagicMock(return_value=transport)
monkeypatch.setattr(apiClientModule.httpx, "AsyncClient", clientFactory)
monkeypatch.setenv("API_URL", "http://api.test/")
monkeypatch.setenv("BOT_API_KEY", "service-secret")
monkeypatch.setenv("DEFAULT_TIMEZONE", "UTC")
client = apiClientModule.ApiClient("123", "Test User")
clientFactory.assert_called_once_with(timeout=10.0)
return client, transport
@pytest.mark.asyncio
async def test_api_client_authenticate_stores_session(apiClient):
client, transport = apiClient
transport.post.return_value = _response(
200,
{
"token": "user-token",
"user_uuid": "user-1",
"timezone": "America/Chicago",
},
)
result, status = await client.authenticate()
assert status == 200
assert result["user_uuid"] == "user-1"
assert client.token == "user-token"
assert client.user_uuid == "user-1"
assert client.timezone == "America/Chicago"
transport.post.assert_awaited_once_with(
"http://api.test/api/auth/discord/session",
headers={"Authorization": "Bearer service-secret"},
json={"discord_id": "123", "display_name": "Test User"},
)
@pytest.mark.asyncio
async def test_api_client_authentication_error_stops_user_request(apiClient):
client, transport = apiClient
transport.post.side_effect = httpx.ConnectError("offline")
result, status = await client.authenticate()
assert (result, status) == ({"error": "API unavailable"}, 503)
assert client.token is None
result, status = await client.request("get", "/api/reminders")
assert (result, status) == ({"error": "authentication failed"}, 503)
transport.request.assert_not_awaited()
@pytest.mark.asyncio
async def test_api_client_request_sends_user_token_and_payload(apiClient):
client, transport = apiClient
client.token = "user-token"
transport.request.return_value = _response(201, {"id": "item-1"})
result, status = await client.request(
"post",
"/api/items",
{"name": "example"},
params={"source": "test"},
)
assert (result, status) == ({"id": "item-1"}, 201)
transport.request.assert_awaited_once_with(
"POST",
"http://api.test/api/items",
headers={"Authorization": "Bearer user-token"},
params={"source": "test"},
json={"name": "example"},
)
@pytest.mark.asyncio
async def test_api_client_request_returns_safe_transport_error(apiClient):
client, transport = apiClient
client.token = "user-token"
transport.request.side_effect = httpx.ReadTimeout("timed out")
assert await client.request("get", "/api/items") == (
{"error": "API unavailable"},
503,
)
@pytest.mark.asyncio
async def test_api_client_reauthenticates_once_after_401(apiClient):
client, transport = apiClient
client.token = "expired-token"
transport.request.side_effect = [
_response(401, {"error": "unauthorized"}),
_response(200, {"items": [1]}),
]
transport.post.return_value = _response(
200,
{"token": "fresh-token", "user_uuid": "user-1", "timezone": "UTC"},
)
result, status = await client.request("get", "/api/items", params={"page": 2})
assert (result, status) == ({"items": [1]}, 200)
assert client.token == "fresh-token"
assert transport.request.await_args_list == [
call(
"GET",
"http://api.test/api/items",
headers={"Authorization": "Bearer expired-token"},
params={"page": 2},
),
call(
"GET",
"http://api.test/api/items",
headers={"Authorization": "Bearer fresh-token"},
params={"page": 2},
),
]
transport.post.assert_awaited_once()
@pytest.mark.asyncio
async def test_api_client_returns_failed_refresh_result_after_401(apiClient):
client, transport = apiClient
client.token = "expired-token"
transport.request.return_value = _response(401, {"error": "unauthorized"})
transport.post.return_value = _response(403, {"error": "not enrolled"})
assert await client.request("get", "/api/items") == (
{"error": "not enrolled"},
403,
)
transport.request.assert_awaited_once()
@pytest.mark.asyncio
async def test_api_client_service_request_uses_service_key(apiClient):
client, transport = apiClient
transport.request.return_value = _response(200, {"messages": []})
result, status = await client.service_request(
"post",
"/api/internal/outbox/claim",
{"worker_id": "worker-1"},
)
assert (result, status) == ({"messages": []}, 200)
transport.request.assert_awaited_once_with(
"POST",
"http://api.test/api/internal/outbox/claim",
headers={"Authorization": "Bearer service-secret"},
params=None,
json={"worker_id": "worker-1"},
)
@pytest.mark.asyncio
async def test_api_client_handles_non_json_response_and_closes(apiClient):
client, transport = apiClient
transport.request.return_value = _response(502, jsonError=True)
assert await client.service_request("get", "/bad-response") == ({}, 502)
await client.close()
transport.aclose.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_command_context_exposes_platform_neutral_values_and_replies():
channel = SimpleNamespace(send=AsyncMock(return_value="sent-message"))
message = SimpleNamespace(author=SimpleNamespace(id=987), channel=channel)
api = SimpleNamespace(user_uuid="user-1", timezone="America/Chicago")
context = CommandContext(message, api)
assert context.api is api
assert context.user_uuid == "user-1"
assert context.discord_user_id == "987"
assert context.timezone == "America/Chicago"
assert await context.reply("hello") == "sent-message"
channel.send.assert_awaited_once_with("hello")
def test_scheduler_fails_job_with_unknown_type(monkeypatch):
getHandler = MagicMock(return_value=None)
failJob = MagicMock()
monkeypatch.setattr(
daemon,
"module_registry",
SimpleNamespace(get_job_handler=getHandler),
)
monkeypatch.setattr(daemon.jobs, "fail_job", failJob)
daemon.runJob({"id": "job-1", "job_type": "missing"})
getHandler.assert_called_once_with("missing")
failJob.assert_called_once_with(
"job-1",
daemon.WORKER_ID,
"unknown job type: missing",
)
def test_scheduler_runs_handler_and_completes_running_job(monkeypatch):
job = {"id": "job-1", "job_type": "example"}
handler = MagicMock(return_value=None)
completeJob = MagicMock()
failJob = MagicMock()
monkeypatch.setattr(
daemon,
"module_registry",
SimpleNamespace(get_job_handler=MagicMock(return_value=handler)),
)
monkeypatch.setattr(daemon.jobs, "get_job", MagicMock(return_value={"status": "running"}))
monkeypatch.setattr(daemon.jobs, "complete_job", completeJob)
monkeypatch.setattr(daemon.jobs, "fail_job", failJob)
daemon.runJob(job)
handler.assert_called_once_with(job, daemon.WORKER_ID)
completeJob.assert_called_once_with("job-1", daemon.WORKER_ID)
failJob.assert_not_called()
def test_scheduler_awaits_handler_without_double_completion(monkeypatch):
job = {"id": "job-1", "job_type": "async-example"}
handler = AsyncMock(return_value=None)
completeJob = MagicMock()
monkeypatch.setattr(
daemon,
"module_registry",
SimpleNamespace(get_job_handler=MagicMock(return_value=handler)),
)
monkeypatch.setattr(
daemon.jobs,
"get_job",
MagicMock(return_value={"status": "completed"}),
)
monkeypatch.setattr(daemon.jobs, "complete_job", completeJob)
daemon.runJob(job)
handler.assert_awaited_once_with(job, daemon.WORKER_ID)
completeJob.assert_not_called()
def test_scheduler_records_handler_failure(monkeypatch):
job = {"id": "job-1", "job_type": "broken"}
failure = RuntimeError("handler failed")
handler = MagicMock(side_effect=failure)
failJob = MagicMock()
monkeypatch.setattr(
daemon,
"module_registry",
SimpleNamespace(get_job_handler=MagicMock(return_value=handler)),
)
monkeypatch.setattr(daemon.jobs, "fail_job", failJob)
monkeypatch.setattr(daemon.logger, "exception", MagicMock())
daemon.runJob(job)
failJob.assert_called_once_with("job-1", daemon.WORKER_ID, failure)
def test_scheduler_poll_claims_configured_batch_and_runs_each_job(monkeypatch):
claimed = [
{"id": "job-1", "job_type": "one"},
{"id": "job-2", "job_type": "two"},
]
claimJobs = MagicMock(return_value=claimed)
runJob = MagicMock()
monkeypatch.setattr(daemon.jobs, "claim_due_jobs", claimJobs)
monkeypatch.setattr(daemon, "runJob", runJob)
assert daemon.pollJobs() == 2
claimJobs.assert_called_once_with(
daemon.WORKER_ID,
limit=daemon.JOB_BATCH_SIZE,
lease_seconds=daemon.JOB_LEASE_SECONDS,
)
assert runJob.call_args_list == [call(claimed[0]), call(claimed[1])]