| from datetime import datetime |
| from unittest.mock import AsyncMock, patch |
|
|
| import pytest |
|
|
| from src.server.schemas.agent_outputs import VoiceProcessResult |
| from src.server.services.stats import stats_service |
| from src.server.services.visit_log_service import visit_log_service |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_scheduling_algorithm_availability(): |
| """ |
| Test stats_service.get_team_availability calculation. |
| Mocks DB responses to simulate Bob & Charlie busy times and asserts correct recommendations in local GMT+8 time. |
| """ |
| target_date = "2026-06-01" |
| user_ids = ["bob-uuid-123", "charlie-uuid-456"] |
|
|
| |
| |
| |
| mock_tasks = [ |
| { |
| "assignee_id": "bob-uuid-123", |
| "due_date": f"{target_date}T11:00:00+08:00", |
| "estimated_hours": 2.0, |
| "status": "todo" |
| }, |
| { |
| "assignee_id": "charlie-uuid-456", |
| "due_date": f"{target_date}T15:00:00+08:00", |
| "estimated_hours": 1.0, |
| "status": "doing" |
| } |
| ] |
|
|
| mock_res = MagicMockResponse(mock_tasks) |
|
|
| |
| with patch.object(stats_service.metrics.system_metrics.supabase, "table") as mock_table: |
| mock_table.return_value.select.return_value.in_.return_value.neq.return_value.or_.return_value.gte.return_value.lte.return_value.execute = MagicMockExecutor(mock_res) |
|
|
| slots = await stats_service.get_team_availability(user_ids, target_date) |
|
|
| assert len(slots) == 3 |
| |
| for slot in slots: |
| st = datetime.fromisoformat(slot["start_time"]) |
| et = datetime.fromisoformat(slot["end_time"]) |
|
|
| |
| bob_start = datetime.fromisoformat(f"{target_date}T09:00:00+08:00") |
| bob_end = datetime.fromisoformat(f"{target_date}T11:00:00+08:00") |
| assert not (st < bob_end and et > bob_start) |
|
|
| |
| charlie_start = datetime.fromisoformat(f"{target_date}T14:00:00+08:00") |
| charlie_end = datetime.fromisoformat(f"{target_date}T15:00:00+08:00") |
| assert not (st < charlie_end and et > charlie_start) |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_fragile_date_parsing_robustness(): |
| """ |
| Verifies that system_metrics cleanly extracts YYYY-MM-DD from dirty dates |
| (e.g., "2026-06-01 (下週一)" or with surrounding text) instead of crashing. |
| """ |
| dirty_date = "2026-06-01 (下週一)" |
| user_ids = ["bob-uuid-123", "charlie-uuid-456"] |
| mock_res = MagicMockResponse([]) |
|
|
| with patch.object(stats_service.metrics.system_metrics.supabase, "table") as mock_table: |
| mock_table.return_value.select.return_value.in_.return_value.neq.return_value.or_.return_value.gte.return_value.lte.return_value.execute = MagicMockExecutor(mock_res) |
|
|
| |
| slots = await stats_service.get_team_availability(user_ids, dirty_date) |
| assert len(slots) == 3 |
| |
| assert slots[0]["start_time"].startswith("2026-06-01") |
|
|
|
|
| @pytest.mark.asyncio |
| async def test_end_to_end_voice_scheduling_integration(): |
| """ |
| Test E2E voice scheduling path in VisitLogService.create_log. |
| Mocks AI transcription response to include a scheduling intent and date. |
| Verifies that the appropriate task is dispatched and recommendation returned. |
| """ |
| |
| data = { |
| "user_id": "user-alice-111", |
| "company_name": "ACME Corp", |
| "customer_id": "cust-999", |
| "latitude": 25.0339, |
| "longitude": 121.5645, |
| "location_address": "Taipei 101" |
| } |
|
|
| mock_ai_res_dict = { |
| "transcript": "我想預約下次 2026-06-01 跟 Bob 與 Charlie 的會議", |
| "summary": "預約下次會議", |
| "tasks": ["預約下次會議"], |
| "scheduling_intent": True, |
| "requested_date": "2026-06-01", |
| "requested_duration_hours": 1.0, |
| "meeting_topic": "需求規格書討論" |
| } |
|
|
| mock_parsed_ai_res = VoiceProcessResult(**mock_ai_res_dict) |
|
|
| |
| class MockAudioFile: |
| content_type = "audio/wav" |
| async def read(self): |
| return b"fake-audio-bytes" |
|
|
| mock_audio = MockAudioFile() |
|
|
| |
| mock_log_db_res = [{"id": "log-uuid-777", "summary": "預約下次會議"}] |
|
|
| |
| with patch.object(visit_log_service, "_process_voice_with_ai", AsyncMock(return_value=( |
| mock_parsed_ai_res.transcript, |
| mock_parsed_ai_res.summary, |
| mock_parsed_ai_res.tasks, |
| mock_parsed_ai_res |
| ))), \ |
| patch.object(visit_log_service, "execute_query", return_value=(True, mock_log_db_res)), \ |
| patch("src.server.services.agent_registry.get_agent_uuid", side_effect=lambda key: f"{key}-uuid-mock"), \ |
| patch("src.server.services.projects.task_service.task_service.create_task", AsyncMock(return_value=(True, {}))) as mock_create_task: |
|
|
| |
| mock_project_db = MagicMockResponse([{"id": "project-uuid-mock"}]) |
| with patch.object(visit_log_service.supabase_client, "table") as mock_table: |
| |
| mock_table.return_value.select.return_value.ilike.return_value.limit.return_value.execute = MagicMockExecutor(mock_project_db) |
|
|
| success, log_res = await visit_log_service.create_log(data, mock_audio) |
|
|
| |
| assert success is True |
| assert "scheduling_recommendation" in log_res |
| rec = log_res["scheduling_recommendation"] |
| assert rec["meeting_topic"] == "需求規格書討論" |
| assert len(rec["suggested_slots"]) == 3 |
|
|
| |
| mock_create_task.assert_called_once() |
| args, kwargs = mock_create_task.call_args |
| assert kwargs["project_id"] == "project-uuid-mock" |
| assert "[待確認會議]" in kwargs["title"] |
| assert kwargs["assignee_id"] == "market-bot-uuid-mock" |
| assert "supervisor-uuid-mock" in kwargs["collaborator_agent_ids"] |
|
|
|
|
| |
| class MagicMockResponse: |
| def __init__(self, data): |
| self.data = data |
|
|
|
|
| class MagicMockExecutor: |
| def __init__(self, response): |
| self.response = response |
|
|
| def __call__(self, *args, **kwargs): |
| return self.response |
|
|