From 847d9f96db59ed3f01f3f39ecbf795a67646b6aa Mon Sep 17 00:00:00 2001 From: "WIN-JHFT4D3SIVT\\caoxiaozhu" Date: Sat, 11 Apr 2026 08:50:47 +0800 Subject: [PATCH] test(backend): add Hermes runtime and task router tests Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../app/services/test_hermes_runtime.py | 75 ++++++ backend/tests/backend/app/test_task_router.py | 221 ++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 backend/tests/backend/app/services/test_hermes_runtime.py create mode 100644 backend/tests/backend/app/test_task_router.py diff --git a/backend/tests/backend/app/services/test_hermes_runtime.py b/backend/tests/backend/app/services/test_hermes_runtime.py new file mode 100644 index 0000000..7edf9f5 --- /dev/null +++ b/backend/tests/backend/app/services/test_hermes_runtime.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from app.services.agent_runtime.base import RuntimePreparedContext +from app.services.agent_runtime.hermes_runtime import HermesRuntimeAdapter +from app.services.agent_runtime.hermes_session_manager import hermes_session_manager + + +class FakeAgent: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.model = kwargs.get("model", "fake-hermes-model") + + def run_conversation(self, user_message, system_message=None, stream_callback=None): + if stream_callback is not None: + stream_callback("hello ") + stream_callback("world") + return {"final_response": "hello world"} + + +@pytest.fixture(autouse=True) +def clear_hermes_sessions(): + hermes_session_manager._sessions.clear() + yield + hermes_session_manager._sessions.clear() + + +@pytest.fixture +def prepared_context(): + return RuntimePreparedContext( + user=SimpleNamespace(id="user-1"), + conversation=SimpleNamespace(id="conv-1"), + user_message=SimpleNamespace(id="msg-user"), + assistant_message=SimpleNamespace(id="msg-assistant"), + raw_message="hi", + full_message="hi", + file_ids=[], + model_name="hermes-test-model", + memory_context="memory block", + ) + + +@pytest.mark.asyncio +async def test_chat_once_calls_ai_agent(monkeypatch, prepared_context): + adapter = HermesRuntimeAdapter() + monkeypatch.setattr(adapter, "_load_agent_class", lambda: FakeAgent) + + content, model = await adapter.chat_once(prepared_context) + + assert content == "hello world" + assert model == "hermes-test-model" + handle = hermes_session_manager.get_or_create(conversation_id="conv-1", user_id="user-1") + assert handle.metadata["model"] == "hermes-test-model" + assert handle.metadata["last_error"] is None + + +@pytest.mark.asyncio +async def test_chat_stream_emits_progress_and_chunks(monkeypatch, prepared_context): + adapter = HermesRuntimeAdapter() + monkeypatch.setattr(adapter, "_load_agent_class", lambda: FakeAgent) + + events = [] + async for event in adapter.chat_stream(prepared_context): + events.append(event) + + assert events[0]["type"] == "progress" + chunks = [event["content"] for event in events if event["type"] == "chunk"] + assert "hello " in chunks + assert "world" in chunks + handle = hermes_session_manager.get_or_create(conversation_id="conv-1", user_id="user-1") + assert handle.metadata["model"] == "hermes-test-model" + assert handle.metadata["last_error"] is None diff --git a/backend/tests/backend/app/test_task_router.py b/backend/tests/backend/app/test_task_router.py new file mode 100644 index 0000000..7be7537 --- /dev/null +++ b/backend/tests/backend/app/test_task_router.py @@ -0,0 +1,221 @@ +import asyncio +import sys +from datetime import UTC, date, datetime +from unittest.mock import Mock + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +sys.modules.setdefault('psutil', Mock()) + +import app.models # noqa: F401 +from app.database import Base, get_db +from app.models.user import User +from app.routers.auth import get_current_user +from app.routers.schedule_center import router as schedule_center_router +from app.routers.task import router as task_router +from app.services.auth_service import get_password_hash + + +@pytest.fixture +async def task_env(tmp_path): + db_path = tmp_path / 'test_task_router.db' + engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with session_factory() as session: + user = User( + username='task_user', + email='task@example.com', + hashed_password=get_password_hash('secret123'), + full_name='Task Tester', + ) + session.add(user) + await session.commit() + await session.refresh(user) + + async def override_get_db(): + async with session_factory() as session: + yield session + + async def override_get_current_user(): + return user + + test_app = FastAPI() + test_app.include_router(task_router) + test_app.include_router(schedule_center_router) + test_app.dependency_overrides[get_db] = override_get_db + test_app.dependency_overrides[get_current_user] = override_get_current_user + + try: + yield test_app + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_create_task_and_get_detail_with_business_fields(task_env): + transport = ASGITransport(app=task_env) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + create_response = await client.post( + '/api/tasks', + json={ + 'title': 'Prepare daily status', + 'description': 'Assemble the updated today status payload', + 'priority': 'high', + 'due_date': '2026-04-10T09:00:00Z', + 'tags': ['today-status', 'chat'], + 'source': 'chat', + 'conversation_id': 'conv-123', + 'quadrant': 'urgent-important', + 'assignee_type': 'commander', + 'assignee_id': 'code_commander', + }, + ) + + assert create_response.status_code == 201 + created = create_response.json() + detail_response = await client.get(f"/api/tasks/{created['id']}") + + assert detail_response.status_code == 200 + payload = detail_response.json() + assert payload['title'] == 'Prepare daily status' + assert payload['tags'] == ['today-status', 'chat'] + assert payload['source'] == 'chat' + assert payload['conversation_id'] == 'conv-123' + assert payload['quadrant'] == 'urgent-important' + assert payload['assignee_type'] == 'commander' + assert payload['assignee_id'] == 'code_commander' + assert payload['dispatch']['status'] == 'idle' + assert [item['action'] for item in payload['history'][:2]] == ['created_from_chat', 'created'] + + +@pytest.mark.asyncio +async def test_subtask_crud_and_reorder(task_env): + transport = ASGITransport(app=task_env) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + task_response = await client.post( + '/api/tasks', + json={ + 'title': 'Implement kanban detail', + 'due_date': '2026-04-10T11:00:00Z', + 'quadrant': 'not-urgent-important', + }, + ) + task_id = task_response.json()['id'] + + first_subtask = await client.post( + f'/api/tasks/{task_id}/subtasks', + json={'title': 'Load task detail', 'assignee_type': 'agent', 'assignee_id': 'planner'}, + ) + second_subtask = await client.post( + f'/api/tasks/{task_id}/subtasks', + json={'title': 'Persist task edits', 'assignee_type': 'agent', 'assignee_id': 'executor'}, + ) + first_id = first_subtask.json()['subtasks'][0]['id'] + second_id = second_subtask.json()['subtasks'][1]['id'] + + update_response = await client.patch( + f'/api/tasks/{task_id}/subtasks/{first_id}', + json={'status': 'done'}, + ) + reorder_response = await client.post( + f'/api/tasks/{task_id}/subtasks/reorder', + json={'items': [{'id': first_id, 'order_index': 1}, {'id': second_id, 'order_index': 0}]}, + ) + + assert update_response.status_code == 200 + assert reorder_response.status_code == 200 + reordered = reorder_response.json() + assert [item['id'] for item in reordered['subtasks']] == [second_id, first_id] + assert reordered['subtasks'][1]['status'] == 'done' + assert any(item['action'] == 'subtask_reordered' for item in reordered['history']) + + +@pytest.mark.asyncio +async def test_create_today_status_task_persists_status_and_subtasks(task_env): + transport = ASGITransport(app=task_env) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + create_response = await client.post( + '/api/tasks', + json={ + 'title': '今日看板任务', + 'description': '来自今日状态看板', + 'status': 'in_progress', + 'priority': 'high', + 'source': 'today_status', + 'quadrant': 'urgent-important', + 'subtasks': [ + {'title': '子任务一', 'status': 'todo'}, + {'title': '子任务二', 'status': 'done'}, + ], + }, + ) + assert create_response.status_code == 201 + payload = create_response.json() + + detail_response = await client.get(f"/api/tasks/{payload['id']}") + + assert detail_response.status_code == 200 + detail_payload = detail_response.json() + assert detail_payload['status'] == 'in_progress' + assert detail_payload['source'] == 'today_status' + assert detail_payload['quadrant'] == 'urgent-important' + assert [item['title'] for item in detail_payload['subtasks']] == ['子任务一', '子任务二'] + assert detail_payload['subtasks'][0]['order_index'] == 0 + assert detail_payload['subtasks'][1]['order_index'] == 1 + assert detail_payload['subtasks'][1]['status'] == 'done' + assert detail_payload['subtasks'][1]['completed_at'] is not None + + +@pytest.mark.asyncio +async def test_dispatch_updates_task_and_schedule_center_summary(task_env): + transport = ASGITransport(app=task_env) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + task_response = await client.post( + '/api/tasks', + json={ + 'title': 'Dispatch to commander', + 'priority': 'urgent', + 'due_date': '2026-04-10T15:00:00Z', + 'quadrant': 'urgent-important', + }, + ) + task_id = task_response.json()['id'] + + dispatch_response = await client.post( + f'/api/tasks/{task_id}/dispatch', + json={'target': 'commander'}, + ) + assert dispatch_response.status_code == 200 + assert dispatch_response.json()['task']['dispatch']['status'] == 'queued' + + await asyncio.sleep(0.18) + + detail_response = await client.get(f'/api/tasks/{task_id}') + date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'}) + + assert detail_response.status_code == 200 + detail = detail_response.json() + assert detail['dispatch']['status'] == 'completed' + assert detail['status'] == 'done' + assert detail['dispatch']['run_id'] + assert detail['dispatch']['result_summary'] + + assert date_response.status_code == 200 + payload = date_response.json() + assert payload['commander_summary'] == { + 'total': 1, + 'queued': 0, + 'running': 0, + 'completed': 1, + 'failed': 0, + } + assert payload['focus_tasks'][0]['id'] == task_id + quadrants = {item['id']: item for item in payload['quadrants']} + assert quadrants['urgent-important']['tasks'][0]['id'] == task_id