Build reusable bot framework
This commit is contained in:
310
tests/unit/test_adapters.py
Normal file
310
tests/unit/test_adapters.py
Normal 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])]
|
||||
Reference in New Issue
Block a user