# -*- coding: utf-8 -*- """Integration tests for the Console header (default-agent) endpoints. These cover ``/api/console/*`` endpoints that resolve the active agent via ``get_agent_for_request`` (header variant) rather than the ``/api/agents/{agentId}/console/*`` scoped variant. The inbox subset of the same prefix is exercised separately in ``test_inbox.py``; this module focuses on the remaining 4 header endpoints (chat/stop, push-messages, debug/backend-logs, upload). POST ``/api/console/chat`` (the main conversation submit) is intentionally NOT covered here — it requires an LLM round-trip and will be revisited in Sprint 2.3 with a mock LLM. """ from __future__ import annotations import pytest from helpers import default_http_timeout _CONSOLE_HTTP_TIMEOUT = default_http_timeout(30.0) _MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # mirrors console.py MAX_UPLOAD_BYTES @pytest.mark.integration @pytest.mark.p1 def test_console_chat_stop_header_returns_stopped_false_for_unknown_chat_id( app_server, ) -> None: """Test purpose: - Verify POST /api/console/chat/stop returns 200 + ``stopped=false`` when the supplied chat_id is not tracked. Console's "停止生成" button must succeed quietly when no task is running (otherwise every idle-state click would surface as an error toast). Test flow: 1. POST /api/console/chat/stop?chat_id=. 2. Assert 200 + response body ``{"stopped": false}``. API endpoints: - POST /api/console/chat/stop """ resp = app_server.api_request( "POST", "/api/console/chat/stop", params={"chat_id": "integ-header-no-running-chat-0001"}, timeout=_CONSOLE_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() assert resp.json() == {"stopped": False} @pytest.mark.integration @pytest.mark.p2 def test_console_push_messages_header_returns_dict_contract( app_server, ) -> None: """Test purpose: - Verify GET /api/console/push-messages returns the documented contract ``{"messages": [...], "pending_approvals": [...]}`` on a fresh workspace. Console polls this endpoint regularly for live notifications and approvals; a broken contract breaks the whole notification subsystem. Test flow: 1. GET /api/console/push-messages without session_id (global mode — returns recent push messages from the last 60s). 2. Assert 200 and both ``messages`` and ``pending_approvals`` are lists (each may be empty on a fresh workspace). API endpoints: - GET /api/console/push-messages """ resp = app_server.api_request( "GET", "/api/console/push-messages", timeout=_CONSOLE_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() payload = resp.json() assert isinstance(payload, dict) assert isinstance(payload.get("messages"), list) assert isinstance(payload.get("pending_approvals"), list) @pytest.mark.integration @pytest.mark.p2 def test_console_debug_backend_logs_header_returns_contract( app_server, ) -> None: """Test purpose: - Verify GET /api/console/debug/backend-logs returns the documented contract (``path / exists / lines / updated_at / size / content``) so the Console "调试 / 后端日志" panel always renders. Test flow: 1. GET /api/console/debug/backend-logs?lines=50. 2. Assert 200, response is a dict containing the 6 documented fields; ``lines`` echoes the request value; ``content`` is a string (possibly empty on a fresh workspace whose log file hasn't been populated yet); ``exists`` is a boolean. API endpoints: - GET /api/console/debug/backend-logs """ resp = app_server.api_request( "GET", "/api/console/debug/backend-logs", params={"lines": 50}, timeout=_CONSOLE_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() payload = resp.json() assert isinstance(payload, dict) for key in ("path", "exists", "lines", "updated_at", "size", "content"): assert key in payload, f"missing field: {key}" assert payload["lines"] == 50 assert isinstance(payload["exists"], bool) assert isinstance(payload["content"], str) @pytest.mark.integration @pytest.mark.p2 def test_console_upload_header_accepts_file_at_max_size_limit( app_server, ) -> None: """Test purpose: - Verify a payload of EXACTLY ``MAX_UPLOAD_BYTES`` succeeds (200 + normal upload contract). Paired with the ``+1 byte → 400`` case below this locks in the boundary; an off-by-one in either direction would surface here. Test flow: 1. POST multipart upload with payload size == MAX_UPLOAD_BYTES. 2. Assert 200 + response is a dict with url/file_name/size fields, and size matches what we sent. API endpoints: - POST /api/console/upload """ at_limit = b"\x00" * _MAX_UPLOAD_BYTES resp = app_server.api_request( "POST", "/api/console/upload", files={ "file": ( "at_limit.bin", at_limit, "application/octet-stream", ), }, timeout=_CONSOLE_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() payload = resp.json() assert isinstance(payload, dict) assert payload.get("file_name") == "at_limit.bin" assert payload.get("size") == _MAX_UPLOAD_BYTES assert isinstance(payload.get("url"), str) and payload["url"] @pytest.mark.integration @pytest.mark.p2 def test_console_upload_header_rejects_oversized_file(app_server) -> None: """Test purpose: - Verify POST /api/console/upload rejects files larger than the ``MAX_UPLOAD_BYTES`` (10 MiB) limit with 400, before storing anything on disk. A regression here lets users DoS storage by uploading huge attachments. Test flow: 1. POST multipart upload with payload size = MAX_UPLOAD_BYTES + 1. 2. Assert 400 + detail contains "File too large" + the size limit (10 MB). API endpoints: - POST /api/console/upload """ oversized = b"\x00" * (_MAX_UPLOAD_BYTES + 1) resp = app_server.api_request( "POST", "/api/console/upload", files={ "file": ( "oversized.bin", oversized, "application/octet-stream", ), }, timeout=_CONSOLE_HTTP_TIMEOUT, ) assert resp.status_code == 400, app_server.logs_tail() detail = resp.json().get("detail", "") assert "File too large" in detail assert "10" in detail # the "(max 10 MB)" suffix