311 lines
9.6 KiB
Python
311 lines
9.6 KiB
Python
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])]
|