From 39a9058de1472ae1e8f0f54fc262564bcfc0b2bc Mon Sep 17 00:00:00 2001 From: "WIN-JHFT4D3SIVT\\caoxiaozhu" Date: Sat, 11 Apr 2026 08:48:07 +0800 Subject: [PATCH] test(backend): update backend router tests for conversation, schedule center, and schema Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../backend/app/test_conversation_router.py | 62 +++++ .../app/test_database_schema_bootstrap.py | 69 +++++- .../app/test_schedule_center_router.py | 229 +++++++++++++++++- 3 files changed, 356 insertions(+), 4 deletions(-) diff --git a/backend/tests/backend/app/test_conversation_router.py b/backend/tests/backend/app/test_conversation_router.py index 49afa16..0282fff 100644 --- a/backend/tests/backend/app/test_conversation_router.py +++ b/backend/tests/backend/app/test_conversation_router.py @@ -95,3 +95,65 @@ async def test_chat_stream_emits_error_event_when_agent_service_fails_before_str assert response.status_code == 200 assert 'event: error' in response.text assert 'stream boot failed' in response.text + + +@pytest.mark.asyncio +async def test_chat_stream_passes_runtime_to_agent_service(conversation_env, monkeypatch): + recorded: dict[str, object] = {} + + async def fake_chat(self, **kwargs): + recorded.update(kwargs) + + async def empty_stream(): + if False: + yield None + + return 'conv-rt', 'msg-rt', empty_stream() + + monkeypatch.setattr('app.routers.conversation.AgentService.chat', fake_chat) + + transport = ASGITransport(app=conversation_env) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + response = await client.post( + '/api/conversations/chat/stream', + json={'message': 'hello', 'runtime': 'hermes'}, + ) + + assert response.status_code == 200 + assert recorded['runtime'] == 'hermes' + + +@pytest.mark.asyncio +async def test_chat_defaults_agent_name_to_jarvis(conversation_env, monkeypatch): + async def fake_chat_simple(self, **kwargs): + return 'conv-id', 'msg-id', 'ok', 'test-model' + + monkeypatch.setattr('app.routers.conversation.AgentService.chat_simple', fake_chat_simple) + + transport = ASGITransport(app=conversation_env) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + response = await client.post( + '/api/conversations/chat', + json={'message': 'hello'}, + ) + + assert response.status_code == 200 + assert response.json()['agent_name'] == 'jarvis' + + +@pytest.mark.asyncio +async def test_chat_returns_hermes_agent_name_when_requested(conversation_env, monkeypatch): + async def fake_chat_simple(self, **kwargs): + return 'conv-id', 'msg-id', 'ok', 'hermes-model' + + monkeypatch.setattr('app.routers.conversation.AgentService.chat_simple', fake_chat_simple) + + transport = ASGITransport(app=conversation_env) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + response = await client.post( + '/api/conversations/chat', + json={'message': 'hello', 'runtime': 'hermes'}, + ) + + assert response.status_code == 200 + assert response.json()['agent_name'] == 'hermes' diff --git a/backend/tests/backend/app/test_database_schema_bootstrap.py b/backend/tests/backend/app/test_database_schema_bootstrap.py index a8ff6d7..642efd6 100644 --- a/backend/tests/backend/app/test_database_schema_bootstrap.py +++ b/backend/tests/backend/app/test_database_schema_bootstrap.py @@ -2,7 +2,7 @@ import pytest from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine -from app.database import ensure_learning_artifact_tables, ensure_memory_columns, ensure_skill_columns +from app.database import ensure_learning_artifact_tables, ensure_memory_columns, ensure_skill_columns, ensure_task_columns @pytest.mark.anyio @@ -113,3 +113,70 @@ async def test_ensure_learning_artifact_tables_creates_table_and_indexes(tmp_pat assert 'ix_learning_artifacts_artifact_type' in index_names await engine.dispose() + + +@pytest.mark.anyio +async def test_ensure_task_columns_adds_today_status_columns_and_subtask_table(tmp_path): + db_path = tmp_path / 'test_tasks.db' + engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True) + + async with engine.begin() as conn: + await conn.execute(text( + ''' + CREATE TABLE tasks ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + title VARCHAR(500) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL, + priority VARCHAR(20) NOT NULL, + due_date DATETIME, + completed_at DATETIME, + tags VARCHAR(1000), + created_at DATETIME, + updated_at DATETIME + ) + ''' + )) + await conn.execute(text( + ''' + CREATE TABLE task_histories ( + id VARCHAR(36) PRIMARY KEY, + task_id VARCHAR(36) NOT NULL, + action VARCHAR(100) NOT NULL, + old_value TEXT, + new_value TEXT, + created_at DATETIME, + updated_at DATETIME + ) + ''' + )) + + await ensure_task_columns(conn) + + task_columns = await conn.execute(text("PRAGMA table_info(tasks)")) + task_column_names = {row[1] for row in task_columns.fetchall()} + assert 'source' in task_column_names + assert 'conversation_id' in task_column_names + assert 'quadrant' in task_column_names + assert 'assignee_type' in task_column_names + assert 'dispatch_status' in task_column_names + assert 'dispatch_run_id' in task_column_names + assert 'last_synced_at' in task_column_names + + history_columns = await conn.execute(text("PRAGMA table_info(task_histories)")) + history_column_names = {row[1] for row in history_columns.fetchall()} + assert 'subtask_id' in history_column_names + + subtask_columns = await conn.execute(text("PRAGMA table_info(task_subtasks)")) + subtask_column_names = {row[1] for row in subtask_columns.fetchall()} + assert 'task_id' in subtask_column_names + assert 'order_index' in subtask_column_names + assert 'dispatch_status' in subtask_column_names + + indexes = await conn.execute(text("PRAGMA index_list(task_subtasks)")) + index_names = {row[1] for row in indexes.fetchall()} + assert 'ix_task_subtasks_task_id' in index_names + assert 'ix_task_subtasks_dispatch_status' in index_names + + await engine.dispose() diff --git a/backend/tests/backend/app/test_schedule_center_router.py b/backend/tests/backend/app/test_schedule_center_router.py index d3a3daf..3f07aa4 100644 --- a/backend/tests/backend/app/test_schedule_center_router.py +++ b/backend/tests/backend/app/test_schedule_center_router.py @@ -1,10 +1,11 @@ import sys -from datetime import UTC, date, datetime +from datetime import UTC, datetime from unittest.mock import Mock import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from sqlalchemy import text from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine sys.modules.setdefault('psutil', Mock()) @@ -13,7 +14,7 @@ import app.models # noqa: F401 from app.database import Base, get_db from app.models.goal import Goal from app.models.reminder import Reminder -from app.models.task import Task, TaskPriority, TaskStatus +from app.models.task import DispatchStatus, Task, TaskPriority, TaskQuadrant, TaskStatus, TaskSubTask from app.models.todo import DailyTodo, TodoSource from app.models.user import User from app.routers.auth import get_current_user @@ -50,7 +51,7 @@ async def schedule_env(tmp_path): session.add_all([user, other_user]) await session.flush() - session.add_all([ + seeded_items = [ DailyTodo( user_id=user.id, title='Legacy todo', @@ -78,13 +79,19 @@ async def schedule_env(tmp_path): title='High priority task', priority=TaskPriority.HIGH, status=TaskStatus.TODO, + source='schedule_center', + quadrant=TaskQuadrant.URGENT_IMPORTANT, due_date=datetime(2026, 4, 10, 14, 0, tzinfo=UTC), + assignee_type='commander', + assignee_id='master', + dispatch_status=DispatchStatus.RUNNING, ), Task( user_id=user.id, title='Urgent task next day', priority=TaskPriority.URGENT, status=TaskStatus.IN_PROGRESS, + quadrant=TaskQuadrant.NOT_URGENT_IMPORTANT, due_date=datetime(2026, 4, 11, 10, 0, tzinfo=UTC), ), Task( @@ -106,6 +113,30 @@ async def schedule_env(tmp_path): note='Ship MVP', goal_date='2026-04-10', ), + ] + session.add_all(seeded_items) + await session.flush() + high_priority_task = next(item for item in seeded_items if isinstance(item, Task) and item.title == 'High priority task') + session.add_all([ + TaskSubTask( + task_id=high_priority_task.id, + title='Commander follow-up', + status=TaskStatus.TODO, + order_index=0, + assignee_type='agent', + assignee_id='executor', + dispatch_status=DispatchStatus.QUEUED, + ), + TaskSubTask( + task_id=high_priority_task.id, + title='Commander completed step', + status=TaskStatus.DONE, + order_index=1, + assignee_type='agent', + assignee_id='executor', + dispatch_status=DispatchStatus.COMPLETED, + completed_at=datetime(2026, 4, 10, 16, 0, tzinfo=UTC), + ), ]) await session.commit() await session.refresh(user) @@ -211,10 +242,143 @@ async def test_get_schedule_center_date_returns_aggregated_resources(schedule_en 'reminder_total': 1, 'goal_total': 1, } + assert [item['title'] for item in payload['focus_tasks']] == ['High priority task'] + assert [item['id'] for item in payload['quadrants']] == [ + 'urgent-important', + 'not-urgent-important', + 'urgent-not-important', + 'not-urgent-not-important', + ] + assert payload['quadrants'][0]['tasks'][0]['title'] == 'High priority task' + assert payload['commander_summary'] == { + 'total': 3, + 'queued': 1, + 'running': 1, + 'completed': 1, + 'failed': 0, + 'overall_status': 'running', + } assert [item['title'] for item in payload['reminders']] == ['Doctor reminder'] assert [item['title'] for item in payload['goals']] == ['Launch calendar beta'] +@pytest.mark.asyncio +async def test_task_detail_and_subtask_crud(schedule_env): + transport = ASGITransport(app=schedule_env) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + create_response = await client.post( + '/api/tasks', + json={ + 'title': 'Plan Today Status', + 'description': 'Wire task detail and quadrants', + 'priority': 'high', + 'quadrant': 'urgent-important', + 'source': 'today_status', + 'assignee_type': 'commander', + }, + ) + task_id = create_response.json()['id'] + subtask_create = await client.post( + f'/api/tasks/{task_id}/subtasks', + json={'title': 'Model backend', 'assignee_type': 'executor'}, + ) + subtask_id = subtask_create.json()['id'] + subtask_update = await client.patch( + f'/api/tasks/{task_id}/subtasks/{subtask_id}', + json={'status': 'done'}, + ) + detail_response = await client.get(f'/api/tasks/{task_id}') + reorder_response = await client.post( + f'/api/tasks/{task_id}/subtasks/reorder', + json={'items': [{'id': subtask_id, 'order_index': 0}]}, + ) + + assert create_response.status_code == 201 + assert subtask_create.status_code == 201 + assert subtask_update.status_code == 200 + assert detail_response.status_code == 200 + assert reorder_response.status_code == 200 + detail_payload = detail_response.json() + assert detail_payload['source'] == 'today_status' + assert detail_payload['quadrant'] == 'urgent-important' + assert detail_payload['subtasks'][0]['title'] == 'Model backend' + assert detail_payload['subtasks'][0]['status'] == 'done' + assert any(entry['action'] == 'subtask_created' for entry in detail_payload['history']) + + +@pytest.mark.asyncio +async def test_task_dispatch_updates_summary_and_detail(schedule_env): + transport = ASGITransport(app=schedule_env) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + create_response = await client.post( + '/api/tasks', + json={'title': 'Dispatch me', 'priority': 'urgent', 'due_date': '2026-04-10T09:00:00Z'}, + ) + task_id = create_response.json()['id'] + dispatch_response = await client.post( + f'/api/tasks/{task_id}/dispatch', + json={'assignee_type': 'commander', 'note': 'Send to runtime'}, + ) + detail_response = await client.get(f'/api/tasks/{task_id}') + + assert dispatch_response.status_code == 200 + dispatch_payload = dispatch_response.json() + assert dispatch_payload['status'] == 'queued' + assert dispatch_payload['run_id'] + detail_payload = detail_response.json() + assert detail_payload['dispatch_status'] == 'queued' + assert detail_payload['dispatch_summary']['status'] == 'queued' + assert any(entry['action'] == 'dispatched_to_commander' for entry in detail_payload['history']) + + +@pytest.mark.asyncio +async def test_schedule_center_created_task_is_visible_in_today_status_aggregate(schedule_env): + transport = ASGITransport(app=schedule_env) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + create_response = await client.post( + '/api/tasks', + json={ + 'title': 'Schedule Center created task', + 'source': 'schedule_center', + 'priority': 'medium', + 'quadrant': 'not-urgent-important', + 'due_date': '2026-04-10T09:00:00Z', + }, + ) + date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'}) + + assert create_response.status_code == 201 + titles = [item['title'] for item in date_response.json()['tasks']] + assert 'Schedule Center created task' in titles + focus_titles = [item['title'] for item in date_response.json()['focus_tasks']] + assert 'Schedule Center created task' in focus_titles + + +@pytest.mark.asyncio +async def test_today_status_created_task_is_visible_in_schedule_center_aggregate(schedule_env): + transport = ASGITransport(app=schedule_env) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + create_response = await client.post( + '/api/tasks', + json={ + 'title': 'Today Status created task', + 'source': 'today_status', + 'priority': 'high', + 'quadrant': 'urgent-important', + 'due_date': '2026-04-10T11:00:00Z', + }, + ) + date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'}) + + assert create_response.status_code == 201 + quadrant_titles = [ + task['title'] + for quadrant in date_response.json()['quadrants'] + for task in quadrant['tasks'] + ] + assert 'Today Status created task' in quadrant_titles + + @pytest.mark.asyncio async def test_get_schedule_center_month_returns_day_summaries(schedule_env): transport = ASGITransport(app=schedule_env) @@ -239,6 +403,65 @@ async def test_get_schedule_center_month_returns_day_summaries(schedule_env): assert day_11['high_priority_total'] == 1 +@pytest.mark.asyncio +async def test_schedule_center_tolerates_legacy_lowercase_task_enum_values(schedule_env): + app = schedule_env + session_override = app.dependency_overrides[get_db] + + async for session in session_override(): + user_id = ( + await session.execute( + text("SELECT id FROM users WHERE email = 'schedule@example.com'") + ) + ).scalar_one() + await session.execute( + text( + """ + INSERT INTO tasks ( + id, created_at, updated_at, user_id, title, description, + status, priority, due_date, completed_at, tags, source, + conversation_id, quadrant, assignee_type, assignee_id, + dispatch_status, dispatch_run_id, result_summary, started_at, last_synced_at + ) VALUES ( + :id, :created_at, :updated_at, :user_id, :title, NULL, + :status, :priority, :due_date, NULL, NULL, :source, + NULL, :quadrant, :assignee_type, :assignee_id, + :dispatch_status, NULL, NULL, NULL, NULL + ) + """ + ), + { + 'id': 'legacy-task-1', + 'created_at': '2026-04-10 06:00:00', + 'updated_at': '2026-04-10 06:00:00', + 'user_id': user_id, + 'title': 'Legacy lowercase enum task', + 'status': 'todo', + 'priority': 'high', + 'due_date': '2026-04-10 09:00:00', + 'source': 'manual', + 'quadrant': 'urgent-important', + 'assignee_type': 'commander', + 'assignee_id': 'legacy-master', + 'dispatch_status': 'queued', + }, + ) + await session.commit() + break + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + date_response = await client.get('/api/schedule-center/date', params={'date_str': '2026-04-10'}) + month_response = await client.get('/api/schedule-center/month', params={'year': 2026, 'month': 4}) + + assert date_response.status_code == 200 + assert month_response.status_code == 200 + date_payload = date_response.json() + month_payload = month_response.json() + assert 'Legacy lowercase enum task' in [item['title'] for item in date_payload['tasks']] + assert next(item for item in month_payload['days'] if item['date'] == '2026-04-10')['task_due_total'] == 2 + + @pytest.mark.asyncio async def test_create_reminder_with_naive_datetime_and_time_zone_appears_in_schedule_center(schedule_env): transport = ASGITransport(app=schedule_env)