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])]