# -*- coding: utf-8 -*- """Integration tests for cron job *execution* (text and agent types). Sprint 2.1/2.2 covered cron CRUD lifecycle (create, pause, resume, delete, history seeding). This module covers the **execution** path: POST /run triggers real ``CronExecutor.execute()`` and we verify history records, inbox events, and channel delivery. Agent-type tests use the shared Mock LLM (``helpers.MockLLMHandler``) with tool_call support to validate the full pipeline: Mock LLM → tool_call(get_current_time) → Agent executes → Round 2 → text response → history success. """ from __future__ import annotations import threading import time from http.server import HTTPServer import pytest from helpers import ( MOCK_LLM_PROVIDER_ID, MockLLMHandler, clean_inbox, default_http_timeout, register_mock_provider, unregister_mock_provider, wait_cron_executed, ) _CRON_HTTP_TIMEOUT = default_http_timeout(15.0) _NEVER_FIRE_SCHEDULE = "0 0 1 1 *" # ------------------------------------------------------------------ # # fixtures # ------------------------------------------------------------------ # @pytest.fixture(scope="module") def mock_llm(): """Module-scoped mock OpenAI server with tool_call support.""" srv = HTTPServer(("127.0.0.1", 0), MockLLMHandler) srv.force_error = False srv.force_tool_call = False port = srv.server_address[1] t = threading.Thread(target=srv.serve_forever, daemon=True) t.start() yield srv, f"http://127.0.0.1:{port}/v1" srv.shutdown() # ------------------------------------------------------------------ # # helpers # ------------------------------------------------------------------ # def _text_spec(name, *, text="Hello from cron", channel="console"): return { "name": name, "enabled": True, "schedule": { "type": "cron", "cron": _NEVER_FIRE_SCHEDULE, "timezone": "UTC", }, "task_type": "text", "text": text, "dispatch": { "type": "channel", "channel": channel, "target": { "user_id": f"cron-exec-{name}", "session_id": f"console:cron-exec-{name}-sess", }, "mode": "stream", }, } def _agent_input(text): """Build AgentRequest-compatible input list.""" return [ { "role": "user", "type": "message", "content": [{"type": "text", "text": text}], }, ] def _agent_spec( name, *, input_text="What time is it?", save_inbox=False, ): spec = { "name": name, "enabled": True, "schedule": { "type": "cron", "cron": _NEVER_FIRE_SCHEDULE, "timezone": "UTC", }, "task_type": "agent", "request": {"input": _agent_input(input_text)}, "dispatch": { "type": "channel", "channel": "console", "target": { "user_id": f"cron-exec-{name}", "session_id": f"console:cron-exec-{name}-sess", }, "mode": "stream", }, } if save_inbox: spec["save_result_to_inbox"] = True return spec def _create_job(app_server, spec): resp = app_server.api_request( "POST", "/api/cron/jobs", json=spec, timeout=_CRON_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() job_id = resp.json().get("id") assert isinstance(job_id, str) and job_id return job_id def _delete_job(app_server, job_id): try: app_server.api_request( "DELETE", f"/api/cron/jobs/{job_id}", timeout=_CRON_HTTP_TIMEOUT, ) except Exception: pass def _poll_inbox_cron(app_server, deadline, *, event_type=None): """Poll inbox for cron-sourced events.""" while time.time() < deadline: resp = app_server.api_request( "GET", "/api/console/inbox/events", params={"source_type": "cron"}, timeout=_CRON_HTTP_TIMEOUT, ) if resp.status_code == 200: body = resp.json() events = body.get( "events", body if isinstance(body, list) else [], ) if event_type: events = [ e for e in events if e.get("event_type") == event_type ] if events: return events time.sleep(1.0) return [] # ------------------------------------------------------------------ # # A1: text-type manual run → history record # ------------------------------------------------------------------ # @pytest.mark.integration @pytest.mark.p0 def test_cron_text_manual_run_creates_history(app_server) -> None: """Test purpose: - Verify POST /run on a text-type cron job creates a history record with trigger=manual and status=success. Test flow: 1. POST create text-type cron job (never-fire schedule). 2. POST run (manual trigger). 3. Poll GET history until a record appears. 4. Assert record fields: trigger=manual, status=success. 5. Cleanup: DELETE job. API endpoints: - POST /api/cron/jobs - POST /api/cron/jobs/{job_id}/run - GET /api/cron/jobs/{job_id}/history - DELETE /api/cron/jobs/{job_id} """ spec = _text_spec("integ_text_hist") job_id = _create_job(app_server, spec) try: run_resp = app_server.api_request( "POST", f"/api/cron/jobs/{job_id}/run", timeout=_CRON_HTTP_TIMEOUT, ) assert run_resp.status_code == 200 assert run_resp.json().get("started") is True records = wait_cron_executed( app_server, job_id, time.time() + 15.0, ) assert ( len(records) >= 1 ), f"No history record after 15s: {app_server.logs_tail()}" rec = records[0] assert rec["trigger"] == "manual" assert rec["status"] == "success" assert "run_at" in rec finally: _delete_job(app_server, job_id) # ------------------------------------------------------------------ # # A2: agent-type manual run with Mock LLM (text only, no tool_call) # ------------------------------------------------------------------ # @pytest.mark.integration @pytest.mark.p0 def test_cron_agent_manual_run_with_mock_llm( app_server, mock_llm, # pylint: disable=redefined-outer-name ) -> None: """Test purpose: - Verify POST /run on an agent-type cron job with Mock LLM produces a successful history record. Test flow: 1. Register mock LLM provider. 2. POST create agent-type cron job. 3. POST run (manual trigger). 4. Poll history until success record appears. 5. Cleanup. API endpoints: - POST /api/cron/jobs - POST /api/cron/jobs/{job_id}/run - GET /api/cron/jobs/{job_id}/history - DELETE /api/cron/jobs/{job_id} """ srv, mock_url = mock_llm srv.force_error = False unregister_mock_provider(app_server, MOCK_LLM_PROVIDER_ID) provider_id = register_mock_provider(app_server, mock_url) spec = _agent_spec("integ_agent_hist", input_text="Hello") job_id = _create_job(app_server, spec) try: run_resp = app_server.api_request( "POST", f"/api/cron/jobs/{job_id}/run", timeout=_CRON_HTTP_TIMEOUT, ) assert run_resp.status_code == 200 assert run_resp.json().get("started") is True records = wait_cron_executed( app_server, job_id, time.time() + 30.0, ) assert ( len(records) >= 1 ), f"No history record after 30s: {app_server.logs_tail()}" rec = records[0] assert rec["trigger"] == "manual" assert rec["status"] == "success" finally: _delete_job(app_server, job_id) unregister_mock_provider(app_server, provider_id) # ------------------------------------------------------------------ # # A3: agent-type with tool_call (get_current_time) — full pipeline # ------------------------------------------------------------------ # @pytest.mark.integration @pytest.mark.p0 def test_cron_agent_tool_call_execution( app_server, mock_llm, # pylint: disable=redefined-outer-name ) -> None: """Test purpose: - Verify Mock LLM tool_call round-trip: the agent receives a tool_call for get_current_time, executes it, sends the result back, and the second LLM round produces a text summary. Test flow: 1. Register mock LLM provider. 2. POST create agent-type cron job with save_result_to_inbox=true. 3. POST run. 4. Poll history → status=success. 5. Poll inbox → cron_result event body contains time info. 6. Cleanup. API endpoints: - POST /api/cron/jobs - POST /api/cron/jobs/{job_id}/run - GET /api/cron/jobs/{job_id}/history - GET /api/console/inbox/events - DELETE /api/cron/jobs/{job_id} """ srv, mock_url = mock_llm srv.force_error = False srv.force_tool_call = True clean_inbox(app_server.working_dir) unregister_mock_provider(app_server, MOCK_LLM_PROVIDER_ID) provider_id = register_mock_provider(app_server, mock_url) spec = _agent_spec( "integ_tool_call", input_text="What time is it?", save_inbox=True, ) job_id = _create_job(app_server, spec) try: run_resp = app_server.api_request( "POST", f"/api/cron/jobs/{job_id}/run", timeout=_CRON_HTTP_TIMEOUT, ) assert run_resp.status_code == 200 records = wait_cron_executed( app_server, job_id, time.time() + 30.0, ) assert ( len(records) >= 1 ), f"No history after 30s: {app_server.logs_tail()}" assert records[0]["status"] == "success" events = _poll_inbox_cron( app_server, time.time() + 10.0, event_type="cron_result", ) assert ( len(events) >= 1 ), f"No cron_result event: {app_server.logs_tail()}" event = events[0] assert event["source_type"] == "cron" assert event.get("body"), "event body should not be empty" finally: srv.force_tool_call = False _delete_job(app_server, job_id) clean_inbox(app_server.working_dir) unregister_mock_provider(app_server, provider_id) # ------------------------------------------------------------------ # # A4: agent run → inbox event # ------------------------------------------------------------------ # @pytest.mark.integration @pytest.mark.p1 def test_cron_agent_run_creates_inbox_event( app_server, mock_llm, # pylint: disable=redefined-outer-name ) -> None: """Test purpose: - Verify an agent-type cron run with save_result_to_inbox creates a cron_result inbox event with correct metadata. Test flow: 1. Register mock LLM provider. 2. POST create agent-type job with save_result_to_inbox=true. 3. POST run. 4. Poll inbox for cron_result event. 5. Assert event fields: source_type, event_type, agent_id. 6. Cleanup. API endpoints: - POST /api/cron/jobs - POST /api/cron/jobs/{job_id}/run - GET /api/console/inbox/events - DELETE /api/cron/jobs/{job_id} """ srv, mock_url = mock_llm srv.force_error = False clean_inbox(app_server.working_dir) unregister_mock_provider(app_server, MOCK_LLM_PROVIDER_ID) provider_id = register_mock_provider(app_server, mock_url) spec = _agent_spec( "integ_inbox_event", input_text="Say hello", save_inbox=True, ) job_id = _create_job(app_server, spec) try: app_server.api_request( "POST", f"/api/cron/jobs/{job_id}/run", timeout=_CRON_HTTP_TIMEOUT, ) events = _poll_inbox_cron( app_server, time.time() + 30.0, event_type="cron_result", ) assert ( len(events) >= 1 ), f"No cron_result inbox event: {app_server.logs_tail()}" event = events[0] assert event["source_type"] == "cron" assert event["event_type"] == "cron_result" assert event["agent_id"] == "default" assert event["status"] == "success" assert event["severity"] == "info" finally: _delete_job(app_server, job_id) clean_inbox(app_server.working_dir) unregister_mock_provider(app_server, provider_id) # ------------------------------------------------------------------ # # A6: agent run error → error history record # ------------------------------------------------------------------ # @pytest.mark.integration @pytest.mark.p1 def test_cron_delivery_error_creates_error_record( app_server, ) -> None: """Test purpose: - Verify that a cron job whose channel delivery fails creates a history record with status=error and a descriptive error. Dispatches to a non-existent channel to trigger KeyError in channel_manager.send_text(). Test flow: 1. POST create text-type job dispatched to non-existent channel. 2. POST run. 3. Poll history → status=error record with delivery error. 4. Cleanup. API endpoints: - POST /api/cron/jobs - POST /api/cron/jobs/{job_id}/run - GET /api/cron/jobs/{job_id}/history - DELETE /api/cron/jobs/{job_id} """ spec = _text_spec( "integ_delivery_error", channel="nonexistent_integ_xyz", ) job_id = _create_job(app_server, spec) try: app_server.api_request( "POST", f"/api/cron/jobs/{job_id}/run", timeout=_CRON_HTTP_TIMEOUT, ) records = wait_cron_executed( app_server, job_id, time.time() + 15.0, ) assert ( len(records) >= 1 ), f"No history record after 15s: {app_server.logs_tail()}" assert records[0]["status"] == "error" assert records[0].get("error") finally: _delete_job(app_server, job_id) # ------------------------------------------------------------------ # # A7: scheduler fires on schedule (P2 — nightly only, ~70s wait) # ------------------------------------------------------------------ # @pytest.mark.integration @pytest.mark.p2 def test_cron_scheduler_fires_on_schedule(app_server) -> None: """Test purpose: - Verify a cron job with ``* * * * *`` schedule fires automatically via the APScheduler within ~70 seconds. Test flow: 1. POST create text-type job with ``* * * * *``. 2. Wait up to 80s. 3. Poll history → at least 1 record with trigger=scheduled. 4. Cleanup. API endpoints: - POST /api/cron/jobs - GET /api/cron/jobs/{job_id}/history - DELETE /api/cron/jobs/{job_id} """ spec = _text_spec("integ_sched_fire") spec["schedule"]["cron"] = "* * * * *" job_id = _create_job(app_server, spec) try: records = wait_cron_executed( app_server, job_id, time.time() + 80.0, ) assert len(records) >= 1, ( f"No scheduled record after 80s: " f"{app_server.logs_tail()}" ) scheduled = [r for r in records if r.get("trigger") == "scheduled"] assert len(scheduled) >= 1, f"No trigger=scheduled record: {records}" finally: _delete_job(app_server, job_id) # ------------------------------------------------------------------ # # A8: paused job not fired by scheduler (P2 — nightly only, ~70s) # ------------------------------------------------------------------ # @pytest.mark.integration @pytest.mark.p2 def test_cron_paused_job_not_fired_by_scheduler( app_server, ) -> None: """Test purpose: - Verify a paused cron job with ``* * * * *`` does NOT fire automatically even after waiting past its schedule. Test flow: 1. POST create text-type job with ``* * * * *``. 2. POST pause the job. 3. Wait 70s. 4. GET history → should be empty (no scheduled runs). 5. Cleanup. API endpoints: - POST /api/cron/jobs - POST /api/cron/jobs/{job_id}/pause - GET /api/cron/jobs/{job_id}/history - DELETE /api/cron/jobs/{job_id} """ spec = _text_spec("integ_paused_no_fire") spec["schedule"]["cron"] = "* * * * *" job_id = _create_job(app_server, spec) try: pause_resp = app_server.api_request( "POST", f"/api/cron/jobs/{job_id}/pause", timeout=_CRON_HTTP_TIMEOUT, ) assert pause_resp.status_code == 200 time.sleep(70.0) hist_resp = app_server.api_request( "GET", f"/api/cron/jobs/{job_id}/history", timeout=_CRON_HTTP_TIMEOUT, ) assert hist_resp.status_code == 200 records = hist_resp.json() assert isinstance(records, list) assert len(records) == 0, f"paused job should not fire, got: {records}" finally: _delete_job(app_server, job_id) # ------------------------------------------------------------------ # # A9: validation — text job without text field → 422 # ------------------------------------------------------------------ # @pytest.mark.integration @pytest.mark.p2 def test_cron_create_text_without_text_rejected( app_server, ) -> None: """Test purpose: - Verify POST create with task_type=text but no text field returns 422 (Pydantic validation). Test flow: 1. POST /api/cron/jobs with task_type=text, text omitted. 2. Assert 422. API endpoints: - POST /api/cron/jobs """ spec = { "name": "integ_bad_text_job", "enabled": True, "schedule": { "type": "cron", "cron": "0 0 * * *", }, "task_type": "text", "dispatch": { "type": "channel", "channel": "console", "target": { "user_id": "cron-bad", "session_id": "console:cron-bad-sess", }, }, } resp = app_server.api_request( "POST", "/api/cron/jobs", json=spec, timeout=_CRON_HTTP_TIMEOUT, ) assert ( resp.status_code == 422 ), f"expected 422, got {resp.status_code}: {resp.text}"