"""Tests for source_preparer module. Tests all 7 scenarios from plan lines 1251-1322: 1. Happy path (Guardian API + S3 + Clinic integration) 2. Fallback when no API URL configured 3. No analyses found (empty Guardian response) 4. S3 download failure 5. Multiple analyses (selects latest COMPLETED) 6. S3 key construction correctness 7. cluster_meta field validation """ import json from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from botocore.exceptions import ClientError from services.source_preparer import ( prepare_alert_source, _fetch_alert_from_alertstore, _fetch_ticket_from_jira, _fetch_alert_from_jira, _format_fallback_comments_summary, _summarize_comments_with_openai, _ensure_runbooks_checkout, _load_alert_rule_lookup_module, _managed_runbooks_dir, _run_git_command, ) from services.config import InvestibookSettings FIXTURE_ROOT = Path(__file__).resolve().parent / "fixtures" / "source_prepare" def test_devops_settings_reuse_existing_skill_environment(monkeypatch): monkeypatch.delenv("INVESTIBOOK_DEVOPS_API_TOKEN", raising=False) monkeypatch.delenv("INVESTIBOOK_DEVOPS_BASE_URL", raising=False) monkeypatch.setenv("DEVOPS_API_KEY", "shared-devops-key") monkeypatch.setenv("DEVOPS_BASE_URL", "https://dev.devops.example") loaded = InvestibookSettings(_env_file=None) assert loaded.devops_api_token == "shared-devops-key" assert loaded.devops_base_url == "https://dev.devops.example" @pytest.mark.asyncio async def test_fetch_alertstore_alert_is_read_only_and_normalized(): detail = { "id": "ALT-2608-1742", "state": "open", "classification": "page", "created_at": "2026-08-17T01:02:03Z", "updated_at": "2026-08-17T01:03:04Z", "external_issue_url": "https://alerts.example/ALT-2608-1742", "attrs": {"owned": True}, "common_labels": { "alertname": "TiKV_disk_almost_full", "tidb_cluster_id": "tidb123456789", "tenant_id": "tenant-1", }, } response = MagicMock() response.is_error = False response.json.return_value = detail client = AsyncMock() client.get.return_value = response with patch("services.source_preparer.settings") as mock_settings, patch( "services.source_preparer.httpx.AsyncClient" ) as mock_httpx: mock_settings.devops_api_token = "devops-token" mock_settings.devops_base_url = "https://devops.example" mock_httpx.return_value.__aenter__.return_value = client mock_httpx.return_value.__aexit__.return_value = None cluster_id, tenant_id, issue = await _fetch_alert_from_alertstore( "ALT-2608-1742" ) assert cluster_id == "123456789" assert tenant_id == "tenant-1" assert issue["alertstore"] == detail assert json.loads(issue["fields"]["customfield_10160"])["labels"]["alertname"] == ( "TiKV_disk_almost_full" ) call = client.get.call_args assert call.args == ( "https://devops.example/api/v1/alertstore/alerts/ALT-2608-1742", ) assert call.kwargs["headers"]["X-API-KEY"] == "devops-token" assert "X-API-TOKEN" not in call.kwargs["headers"] @pytest.mark.asyncio async def test_fetch_alertstore_alert_fails_when_credentials_are_missing(): with patch("services.source_preparer.settings") as mock_settings: mock_settings.devops_api_token = "" with pytest.raises(RuntimeError, match="Alertstore credentials are missing"): await _fetch_alert_from_alertstore("ALT-2608-1742") @pytest.mark.asyncio async def test_prepare_alertstore_source_normalizes_issue_id( tmp_workspace, tmp_templates, prepare_tasks ): task_id = "task-lowercase-alertstore" workspace_id = "ws-lowercase-alertstore" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } alertstore_detail = { "id": "ALT-2608-1742", "attrs": {"owned": True}, "common_labels": {}, } issue = { "key": "ALT-2608-1742", "fields": {}, "alertstore": alertstore_detail, } fetch_alertstore = AsyncMock(return_value=("cluster-1", "tenant-1", issue)) with patch( "services.source_preparer._fetch_alert_from_alertstore", fetch_alertstore ), patch( "services.source_preparer._fetch_cluster_meta_from_clinic", AsyncMock(return_value={"cluster_id": "cluster-1", "tenant_id": "tenant-1"}), ), patch( "services.source_preparer._fetch_analysis_from_guardian", AsyncMock() ), patch( "services.source_preparer._lookup_alert_rule", return_value=None ): await prepare_alert_source( task_id, " alt-2608-1742 ", workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) canonical_id = "ALT-2608-1742" issue_dir = tmp_workspace / workspace_id / "issues" / canonical_id issue_meta = json.loads((issue_dir / "issue_meta.json").read_text()) fetch_alertstore.assert_awaited_once_with(canonical_id) assert issue_meta["jira_issue_id"] == canonical_id assert prepare_tasks[task_id]["status"] == "completed" assert prepare_tasks[task_id]["issue_dir"] == f"issues/{canonical_id}" @pytest.mark.asyncio async def test_summarize_comments_prompt_requests_status_summary(): comments_payload = { "comments": [ { "body": "Alert received again but auto-recovered. Please check if there is potential risk.", "author": {"displayName": "Charles Zhu"}, "created": "2026-04-14T04:27:14.344+0800", } ] } mock_response = MagicMock() mock_response.json.return_value = { "choices": [ { "message": { "content": "Alert 再次触发但已自动恢复。当前 comments 中没有人工诊断结论或明确根因。" } } ] } mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.post.return_value = mock_response with patch("services.source_preparer.settings") as mock_settings: mock_settings.openai_api_key = "test-openai-key" mock_settings.openai_model = "gpt-4o" with patch("services.source_preparer.httpx.AsyncClient") as mock_httpx: mock_httpx.return_value.__aenter__.return_value = mock_client mock_httpx.return_value.__aexit__.return_value = None result = await _summarize_comments_with_openai(comments_payload) assert result == "Alert 再次触发但已自动恢复。当前 comments 中没有人工诊断结论或明确根因。" payload = mock_client.post.call_args.kwargs["json"] system_prompt = payload["messages"][0]["content"] assert "compact status summary" in system_prompt assert "not a timeline or per-comment list" in system_prompt assert "Do not include action items, recommendations, or risk assessment" in system_prompt @pytest.mark.asyncio async def test_prepare_issue_source_uses_local_fixture_when_jira_context_missing( tmp_workspace, tmp_templates, prepare_tasks, monkeypatch ): issue_id = "O11Y-2447801" workspace_id = "ws-local-fixture" task_id = "task-local-fixture" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } monkeypatch.setenv("INVESTIBOOK_SOURCE_PREPARE_FIXTURES_DIR", str(FIXTURE_ROOT)) mock_fetch_alert = AsyncMock(return_value=(None, None, {})) mock_fetch_cluster_meta = AsyncMock(return_value=None) mock_fetch_analysis = AsyncMock() mock_fetch_comments = AsyncMock(return_value=None) with patch("services.source_preparer._fetch_alert_from_jira", mock_fetch_alert), \ patch("services.source_preparer._fetch_cluster_meta_from_clinic", mock_fetch_cluster_meta), \ patch("services.source_preparer._fetch_analysis_from_guardian", mock_fetch_analysis), \ patch("services.source_preparer._fetch_comments_from_jira", mock_fetch_comments): await prepare_alert_source( task_id, issue_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) issue_dir = tmp_workspace / workspace_id / "issues" / issue_id issue_meta = json.loads((issue_dir / "issue_meta.json").read_text()) cluster_meta = json.loads((issue_dir / "cluster_meta.json").read_text()) assert issue_meta["cluster_id"] == "10754652599988043412" assert issue_meta["created"] == "2026-04-22T15:20:00+0800" assert cluster_meta["cluster_id"] == "10754652599988043412" assert cluster_meta["cluster_type_v2"] == "dedicated" @pytest.mark.asyncio async def test_prepare_issue_source_request_can_skip_jira_comments( tmp_workspace, tmp_templates, prepare_tasks, monkeypatch ): issue_id = "O11Y-2447801" workspace_id = "ws-skip-comments" task_id = "task-skip-comments" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } monkeypatch.setenv("INVESTIBOOK_SOURCE_PREPARE_FIXTURES_DIR", str(FIXTURE_ROOT)) issue_dir = tmp_workspace / workspace_id / "issues" / issue_id issue_dir.mkdir(parents=True, exist_ok=True) (issue_dir / "comments.json").write_text('{"comments":[{"body":"stale"}]}') (issue_dir / "comments_summary_ai.md").write_text("stale summary") mock_fetch_comments = AsyncMock(return_value={"comments": [{"body": "must not be read"}]}) with patch( "services.source_preparer._fetch_alert_from_jira", AsyncMock(return_value=(None, None, {})), ), patch( "services.source_preparer._fetch_cluster_meta_from_clinic", AsyncMock(return_value=None), ), patch( "services.source_preparer._fetch_analysis_from_guardian", AsyncMock(), ), patch( "services.source_preparer._fetch_comments_from_jira", mock_fetch_comments, ): await prepare_alert_source( task_id, issue_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, skip_jira_comments=True, ) mock_fetch_comments.assert_not_awaited() assert not (issue_dir / "comments.json").exists() assert not (issue_dir / "comments_summary_ai.md").exists() assert prepare_tasks[task_id]["status"] == "completed" def test_format_fallback_comments_summary_uses_latest_comment_label(): summary = _format_fallback_comments_summary( { "comments": [ { "body": "Please check whether there is any potential risk.", "author": {"displayName": "Charles Zhu"}, "created": "2026-04-14T04:27:14.344+0800", } ] } ) assert summary.startswith( "Latest Jira comment from Charles Zhu at 2026-04-14T04:27:14.344+0800:" ) assert "Please check whether there is any potential risk." in summary @pytest.mark.asyncio async def test_prepare_happy_path( tmp_workspace, tmp_templates, prepare_tasks, mock_jira_response, mock_clinic_response, mock_analysis_response, ): """Test 1: Full integration with mocked Jira, Guardian API, S3, and Clinic.""" task_id = "task-001" alert_id = "O11Y-12345" workspace_id = "ws-001" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } # Mock httpx.AsyncClient for Jira + Jira comments + Guardian + Clinic # Note: httpx Response.json() is synchronous, not async mock_jira_http_response = MagicMock() mock_jira_http_response.json.return_value = mock_jira_response mock_jira_http_response.raise_for_status = MagicMock() # Mock Jira comments response mock_jira_comments_response = MagicMock() mock_jira_comments_response.json.return_value = { "comments": [ { "id": "c1", "body": "First comment body", "author": {"displayName": "User A"}, "created": "2025-07-14T09:00:00.000+0000", "updated": "2025-07-14T09:05:00.000+0000", }, { "id": "c2", "body": "Second comment body", "author": {"displayName": "User B"}, "created": "2025-07-14T10:00:00.000+0000", "updated": "2025-07-14T10:10:00.000+0000", }, ] } mock_jira_comments_response.raise_for_status = MagicMock() mock_guardian_response = MagicMock() mock_guardian_response.json.return_value = mock_analysis_response mock_guardian_response.raise_for_status = MagicMock() mock_clinic_http_response = MagicMock() mock_clinic_http_response.json.return_value = mock_clinic_response mock_clinic_http_response.raise_for_status = MagicMock() mock_http_client = AsyncMock() # Calls in order: Jira issue, Clinic API, Guardian API, Jira comments mock_http_client.get.side_effect = [ mock_jira_http_response, mock_clinic_http_response, mock_guardian_response, mock_jira_comments_response, ] # Mock aioboto3 S3 client insight_data = json.dumps({"test": "insight_data"}).encode() diag_data = json.dumps({"test": "diagnostic_data"}).encode() mock_stream_insight = AsyncMock() mock_stream_insight.read = AsyncMock(return_value=insight_data) mock_body_insight = AsyncMock() mock_body_insight.__aenter__.return_value = mock_stream_insight mock_body_insight.__aexit__.return_value = None mock_stream_diag = AsyncMock() mock_stream_diag.read = AsyncMock(return_value=diag_data) mock_body_diag = AsyncMock() mock_body_diag.__aenter__.return_value = mock_stream_diag mock_body_diag.__aexit__.return_value = None mock_s3_client = AsyncMock() mock_s3_client.get_object.side_effect = [ {"Body": mock_body_insight}, {"Body": mock_body_diag}, ] mock_client_context = AsyncMock() mock_client_context.__aenter__.return_value = mock_s3_client mock_client_context.__aexit__.return_value = None mock_session = MagicMock() mock_session.client.return_value = mock_client_context with patch("services.source_preparer.httpx.AsyncClient") as mock_httpx: mock_httpx.return_value.__aenter__.return_value = mock_http_client mock_httpx.return_value.__aexit__.return_value = None with patch("services.source_preparer.aioboto3.Session") as mock_aioboto3: mock_aioboto3.return_value = mock_session with patch("services.source_preparer.settings") as mock_settings: mock_settings.jira_base_url = "https://jira.example.com" mock_settings.jira_username = "test@example.com" mock_settings.jira_api_token = "test-jira-token" mock_settings.aiops_api_url = "https://api.example.com" mock_settings.s3_bucket = "test-bucket" mock_settings.aws_region = "us-west-2" mock_settings.clinic_endpoint = "https://clinic.example.com" mock_settings.clinic_api_key = "test-key" mock_settings.clinic_cookie = "" mock_settings.clinic_csrf_token = "" mock_settings.openai_api_key = "test-openai-key" mock_settings.openai_model = "gpt-4o" mock_settings.skip_jira_comments = False await prepare_alert_source( task_id, alert_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) # Assertions task = prepare_tasks[task_id] assert task["status"] == "completed" assert task["error"] is None assert len(task["files"]) > 0 # Verify all expected files were created under alerts/{alert_id}/ alert_dir = tmp_workspace / workspace_id / "issues" / alert_id assert (alert_dir / "insight.json").exists() assert (alert_dir / "diagnostic_package.json").exists() assert (alert_dir / "cluster_meta.json").exists() assert (alert_dir / "issue_meta.json").exists() assert (alert_dir / "comments.json").exists() assert (alert_dir / "comments_summary_ai.md").exists() # Verify alert_dir is returned in task assert task["issue_dir"] == f"issues/{alert_id}" # Verify cluster_meta has comprehensive fields (10 fields total) cluster_meta = task["cluster_meta"] expected_fields = { "tenant_id", "clinic_org_id", "cluster_id", "cluster_name", "tidb_version", "cluster_type", "cluster_type_v2", "parent_id", "shoot_name", "keyspace_id", "project_id", "cloud_provider", "region", "status", "create_timestamp", } assert set(cluster_meta.keys()) == expected_fields assert cluster_meta["cluster_id"] == "1379661944646416054" assert cluster_meta["clinic_org_id"] == "test-org-123" assert cluster_meta["cluster_name"] == "test-tidb-cluster" assert cluster_meta["tidb_version"] == "v7.5.0" assert cluster_meta["cluster_type"] == "DEDICATED" assert cluster_meta["cloud_provider"] == "AWS" assert cluster_meta["region"] == "us-west-2" assert cluster_meta["status"] == "AVAILABLE" @pytest.mark.asyncio async def test_prepare_fallback_no_config( tmp_workspace, tmp_templates, prepare_tasks, mock_jira_response ): """Test 2: Pipeline continues when Guardian API is not configured (Jira + Clinic still work).""" task_id = "task-002" alert_id = "O11Y-67890" workspace_id = "ws-002" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } # Mock Jira response mock_jira_http_response = MagicMock() mock_jira_http_response.json.return_value = mock_jira_response mock_jira_http_response.raise_for_status = MagicMock() # Mock Jira comments response mock_jira_comments_response = MagicMock() mock_jira_comments_response.json.return_value = {"comments": []} mock_jira_comments_response.raise_for_status = MagicMock() # Mock Clinic response mock_clinic_http_response = MagicMock() mock_clinic_http_response.json.return_value = { "items": [ { "clusterID": "1379661944646416054", "tenantID": "1372813089209061633", "orgID": "test-org-456", "clusterName": "test-tidb-cluster", "clusterVersion": "v7.6.0", "clusterDeployType": "DEDICATED", "clusterProviderName": "AWS", "clusterRegionName": "us-west-2", "clusterStatus": "AVAILABLE", "clusterCreatedAt": "2024-01-15T10:30:00Z", } ] } mock_clinic_http_response.raise_for_status = MagicMock() mock_http_client = AsyncMock() # Calls in order: Jira issue, Clinic API, Jira comments mock_http_client.get.side_effect = [ mock_jira_http_response, mock_clinic_http_response, mock_jira_comments_response, ] with patch("services.source_preparer.httpx.AsyncClient") as mock_httpx: mock_httpx.return_value.__aenter__.return_value = mock_http_client mock_httpx.return_value.__aexit__.return_value = None with patch("services.source_preparer.settings") as mock_settings: mock_settings.jira_base_url = "https://jira.example.com" mock_settings.jira_username = "test@example.com" mock_settings.jira_api_token = "test-jira-token" mock_settings.aiops_api_url = "" # Guardian not configured mock_settings.s3_bucket = "" mock_settings.clinic_endpoint = "https://clinic.example.com" mock_settings.clinic_api_key = "test-key" mock_settings.clinic_cookie = "" mock_settings.clinic_csrf_token = "" mock_settings.openai_api_key = "test-openai-key" mock_settings.openai_model = "gpt-4o" await prepare_alert_source( task_id, alert_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) # Pipeline completes successfully without Guardian analysis task = prepare_tasks[task_id] assert task["status"] == "completed" assert task["error"] is None # Verify cluster_meta.json and alert_meta.json were created under alerts/{alert_id}/ alert_dir = tmp_workspace / workspace_id / "issues" / alert_id assert (alert_dir / "cluster_meta.json").exists() assert (alert_dir / "issue_meta.json").exists() assert not (alert_dir / "insight.json").exists() # Guardian not configured assert not (alert_dir / "diagnostic_package.json").exists() # Verify cluster_meta from Clinic cluster_meta = task["cluster_meta"] assert cluster_meta["cluster_id"] == "1379661944646416054" assert cluster_meta["clinic_org_id"] == "test-org-456" assert cluster_meta["tidb_version"] == "v7.6.0" assert cluster_meta["cluster_type"] == "DEDICATED" assert cluster_meta["cloud_provider"] == "AWS" assert "region" in cluster_meta @pytest.mark.asyncio async def test_prepare_no_analyses( tmp_workspace, tmp_templates, prepare_tasks, mock_jira_response ): """Test 3: Guardian returns empty items list - pipeline continues without analysis files.""" task_id = "task-003" alert_id = "O11Y-99999" workspace_id = "ws-003" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } # Mock Jira response mock_jira_http_response = MagicMock() mock_jira_http_response.json.return_value = mock_jira_response mock_jira_http_response.raise_for_status = MagicMock() # Mock Jira comments response mock_jira_comments_response = MagicMock() mock_jira_comments_response.json.return_value = {"comments": []} mock_jira_comments_response.raise_for_status = MagicMock() # Mock Clinic response mock_clinic_response = MagicMock() mock_clinic_response.json.return_value = { "items": [ { "clusterID": "1379661944646416054", "tenantID": "1372813089209061633", "orgID": "test-org-789", "clusterName": "test-tidb-cluster", "clusterVersion": "v7.7.0", "clusterDeployType": "DEDICATED", "clusterProviderName": "AWS", "clusterRegionName": "us-west-2", "clusterStatus": "AVAILABLE", "clusterCreatedAt": "2024-01-15T10:30:00Z", } ] } mock_clinic_response.raise_for_status = MagicMock() # Mock Guardian API returning empty list mock_guardian_response = MagicMock() mock_guardian_response.json.return_value = {"items": [], "total": 0} mock_guardian_response.raise_for_status = MagicMock() mock_http_client = AsyncMock() # Jira issue, Clinic API, Guardian API, then Jira comments mock_http_client.get.side_effect = [ mock_jira_http_response, mock_clinic_response, mock_guardian_response, mock_jira_comments_response, ] with patch("services.source_preparer.httpx.AsyncClient") as mock_httpx: mock_httpx.return_value.__aenter__.return_value = mock_http_client mock_httpx.return_value.__aexit__.return_value = None with patch("services.source_preparer.settings") as mock_settings: mock_settings.jira_base_url = "https://jira.example.com" mock_settings.jira_username = "test@example.com" mock_settings.jira_api_token = "test-jira-token" mock_settings.aiops_api_url = "https://api.example.com" mock_settings.s3_bucket = "test-bucket" mock_settings.aws_region = "us-west-2" mock_settings.clinic_endpoint = "https://clinic.example.com" mock_settings.clinic_api_key = "test-key" mock_settings.clinic_cookie = "" mock_settings.clinic_csrf_token = "" mock_settings.openai_api_key = "test-openai-key" mock_settings.openai_model = "gpt-4o" await prepare_alert_source( task_id, alert_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) # Pipeline completes successfully (analysis is optional) task = prepare_tasks[task_id] assert task["status"] == "completed" assert task["error"] is None # Verify cluster_meta.json and alert_meta.json exist (but NO insight/diagnostic files) alert_dir = tmp_workspace / workspace_id / "issues" / alert_id assert (alert_dir / "cluster_meta.json").exists() assert (alert_dir / "issue_meta.json").exists() assert not (alert_dir / "insight.json").exists() # No analyses found assert not (alert_dir / "diagnostic_package.json").exists() @pytest.mark.asyncio async def test_prepare_s3_failure( tmp_workspace, tmp_templates, prepare_tasks, mock_jira_response, mock_analysis_response, ): """Test 4: S3 raises ClientError during download - pipeline continues (S3 is optional).""" task_id = "task-004" alert_id = "O11Y-11111" workspace_id = "ws-004" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } # Mock Jira response mock_jira_http_response = MagicMock() mock_jira_http_response.json.return_value = mock_jira_response mock_jira_http_response.raise_for_status = MagicMock() # Mock Jira comments response mock_jira_comments_response = MagicMock() mock_jira_comments_response.json.return_value = {"comments": []} mock_jira_comments_response.raise_for_status = MagicMock() # Mock Clinic response mock_clinic_response = MagicMock() mock_clinic_response.json.return_value = { "items": [ { "clusterID": "1379661944646416054", "tenantID": "1372813089209061633", "orgID": "test-org-111", "clusterName": "test-tidb-cluster", "clusterVersion": "v7.8.0", "clusterDeployType": "DEDICATED", "clusterProviderName": "AWS", "clusterRegionName": "us-west-2", "clusterStatus": "AVAILABLE", "clusterCreatedAt": "2024-01-15T10:30:00Z", } ] } mock_clinic_response.raise_for_status = MagicMock() # Mock Guardian API mock_guardian_response = MagicMock() mock_guardian_response.json.return_value = mock_analysis_response mock_guardian_response.raise_for_status = MagicMock() mock_http_client = AsyncMock() # Jira issue, Clinic API, Guardian API, then Jira comments mock_http_client.get.side_effect = [ mock_jira_http_response, mock_clinic_response, mock_guardian_response, mock_jira_comments_response, ] # Mock S3 client raising ClientError mock_s3_client = AsyncMock() mock_s3_client.get_object.side_effect = ClientError( {"Error": {"Code": "NoSuchKey", "Message": "Key not found"}}, "GetObject" ) mock_client_context = AsyncMock() mock_client_context.__aenter__.return_value = mock_s3_client mock_client_context.__aexit__.return_value = None mock_session = MagicMock() mock_session.client.return_value = mock_client_context with patch("services.source_preparer.httpx.AsyncClient") as mock_httpx: mock_httpx.return_value.__aenter__.return_value = mock_http_client mock_httpx.return_value.__aexit__.return_value = None with patch("services.source_preparer.aioboto3.Session") as mock_aioboto3: mock_aioboto3.return_value = mock_session with patch("services.source_preparer.settings") as mock_settings: mock_settings.jira_base_url = "https://jira.example.com" mock_settings.jira_username = "test@example.com" mock_settings.jira_api_token = "test-jira-token" mock_settings.aiops_api_url = "https://api.example.com" mock_settings.s3_bucket = "test-bucket" mock_settings.aws_region = "us-west-2" mock_settings.clinic_endpoint = "https://clinic.example.com" mock_settings.clinic_api_key = "test-key" mock_settings.clinic_cookie = "" mock_settings.clinic_csrf_token = "" mock_settings.openai_api_key = "test-openai-key" mock_settings.openai_model = "gpt-4o" await prepare_alert_source( task_id, alert_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) # Pipeline completes successfully despite S3 failure (S3 is optional) task = prepare_tasks[task_id] assert task["status"] == "completed" assert task["error"] is None # Verify cluster_meta.json and alert_meta.json exist under alerts/{alert_id}/ alert_dir = tmp_workspace / workspace_id / "issues" / alert_id assert (alert_dir / "cluster_meta.json").exists() assert (alert_dir / "issue_meta.json").exists() assert not (alert_dir / "insight.json").exists() # S3 download failed assert not (alert_dir / "diagnostic_package.json").exists() @pytest.mark.asyncio async def test_prepare_selects_latest_completed( tmp_workspace, tmp_templates, prepare_tasks, mock_jira_response ): """Test 5: Multiple analyses - selects latest COMPLETED (not latest overall).""" task_id = "task-005" alert_id = "O11Y-22222" workspace_id = "ws-005" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } # Guardian API returns 3 analyses: # - Latest overall: RUNNING (should NOT be selected) # - Middle: COMPLETED (should BE selected) # - Oldest: COMPLETED (should NOT be selected) multi_analysis_response = { "items": [ { "analysis_uuid": "uuid-running", "jira_issue_id": "O11Y-22222", "tenant_id": "tenant-1", "cluster_id": "cluster-1", "time_range_start_ts": 1758028200, "time_range_end_ts": 1758030000, "analyzer_name": "general", "status": "RUNNING", "created_at": "2025-07-14T12:00:00+00:00", }, { "analysis_uuid": "uuid-completed-latest", "jira_issue_id": "O11Y-22222", "tenant_id": "tenant-1", "cluster_id": "cluster-1", "time_range_start_ts": 1758028200, "time_range_end_ts": 1758030000, "analyzer_name": "general", "status": "COMPLETED", "created_at": "2025-07-14T11:00:00+00:00", "completed_at": "2025-07-14T11:30:00+00:00", }, { "analysis_uuid": "uuid-completed-oldest", "jira_issue_id": "O11Y-22222", "tenant_id": "tenant-1", "cluster_id": "cluster-1", "time_range_start_ts": 1758028200, "time_range_end_ts": 1758030000, "analyzer_name": "general", "status": "COMPLETED", "created_at": "2025-07-14T10:00:00+00:00", "completed_at": "2025-07-14T10:30:00+00:00", }, ], "total": 3, } # Mock Jira response mock_jira_http_response = MagicMock() mock_jira_http_response.json.return_value = mock_jira_response mock_jira_http_response.raise_for_status = MagicMock() # Mock Jira comments response mock_jira_comments_response = MagicMock() mock_jira_comments_response.json.return_value = {"comments": []} mock_jira_comments_response.raise_for_status = MagicMock() # Mock Clinic response mock_clinic_response = MagicMock() mock_clinic_response.json.return_value = { "items": [ { "clusterID": "cluster-1", "tenantID": "tenant-1", "orgID": "org-1", "clusterName": "test-tidb-cluster", "clusterVersion": "v8.0.0", "clusterDeployType": "DEDICATED", "clusterProviderName": "AWS", "clusterRegionName": "us-west-2", "clusterStatus": "AVAILABLE", "clusterCreatedAt": "2024-01-15T10:30:00Z", } ] } mock_clinic_response.raise_for_status = MagicMock() # Mock Guardian response mock_guardian_response = MagicMock() mock_guardian_response.json.return_value = multi_analysis_response mock_guardian_response.raise_for_status = MagicMock() mock_http_client = AsyncMock() # Jira issue, Clinic API, Guardian API, then Jira comments mock_http_client.get.side_effect = [ mock_jira_http_response, mock_clinic_response, mock_guardian_response, mock_jira_comments_response, ] # Mock S3 insight_data = json.dumps({"test": "insight"}).encode() diag_data = json.dumps({"test": "diag"}).encode() mock_stream_insight = AsyncMock() mock_stream_insight.read = AsyncMock(return_value=insight_data) mock_body_insight = AsyncMock() mock_body_insight.__aenter__.return_value = mock_stream_insight mock_body_insight.__aexit__.return_value = None mock_stream_diag = AsyncMock() mock_stream_diag.read = AsyncMock(return_value=diag_data) mock_body_diag = AsyncMock() mock_body_diag.__aenter__.return_value = mock_stream_diag mock_body_diag.__aexit__.return_value = None mock_s3_client = AsyncMock() mock_s3_client.get_object.side_effect = [ {"Body": mock_body_insight}, {"Body": mock_body_diag}, ] mock_client_context = AsyncMock() mock_client_context.__aenter__.return_value = mock_s3_client mock_client_context.__aexit__.return_value = None mock_session = MagicMock() mock_session.client.return_value = mock_client_context with patch("services.source_preparer.httpx.AsyncClient") as mock_httpx: mock_httpx.return_value.__aenter__.return_value = mock_http_client mock_httpx.return_value.__aexit__.return_value = None with patch("services.source_preparer.aioboto3.Session") as mock_aioboto3: mock_aioboto3.return_value = mock_session with patch("services.source_preparer.settings") as mock_settings: mock_settings.jira_base_url = "https://jira.example.com" mock_settings.jira_username = "test@example.com" mock_settings.jira_api_token = "test-jira-token" mock_settings.aiops_api_url = "https://api.example.com" mock_settings.s3_bucket = "test-bucket" mock_settings.aws_region = "us-west-2" mock_settings.clinic_endpoint = "https://clinic.example.com" mock_settings.clinic_api_key = "test-key" mock_settings.clinic_cookie = "" mock_settings.clinic_csrf_token = "" mock_settings.openai_api_key = "test-openai-key" mock_settings.openai_model = "gpt-4o" await prepare_alert_source( task_id, alert_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) # Verify S3 keys were constructed for the latest COMPLETED analysis calls = mock_s3_client.get_object.call_args_list assert len(calls) == 2 # First call should be for insight with uuid-completed-latest (created_at 2025-07-14T11:00:00) first_call_key = calls[0][1]["Key"] assert "uuid-completed-latest" in first_call_key assert "2025_07" in first_call_key # YYYY_MM format assert first_call_key.endswith("insight.en.json") # Second call for diagnostic second_call_key = calls[1][1]["Key"] assert "uuid-completed-latest" in second_call_key assert second_call_key.endswith("diagnostic_package.json") @pytest.mark.asyncio async def test_s3_key_construction( tmp_workspace, tmp_templates, prepare_tasks, mock_jira_response, mock_analysis_response, ): """Test 6: Verify S3 key format matches guardian_service/analysis/{tenant}/{cluster}/{YYYY_MM}/{uuid}/filename.""" task_id = "task-006" alert_id = "O11Y-33333" workspace_id = "ws-006" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } # Mock Jira response mock_jira_http_response = MagicMock() mock_jira_http_response.json.return_value = mock_jira_response mock_jira_http_response.raise_for_status = MagicMock() # Mock Clinic response mock_clinic_response = MagicMock() mock_clinic_response.json.return_value = { "items": [ { "clusterID": "1379661944646416054", "tenantID": "1372813089209061633", "orgID": "org-1", "clusterName": "test-tidb-cluster", "clusterVersion": "v7.1.0", "clusterDeployType": "DEDICATED", "clusterProviderName": "AWS", "clusterRegionName": "us-west-2", "clusterStatus": "AVAILABLE", "clusterCreatedAt": "2024-01-15T10:30:00Z", } ] } mock_clinic_response.raise_for_status = MagicMock() # Mock Guardian response mock_guardian_response = MagicMock() mock_guardian_response.json.return_value = mock_analysis_response mock_guardian_response.raise_for_status = MagicMock() mock_http_client = AsyncMock() # Jira, then Clinic, then Guardian mock_http_client.get.side_effect = [ mock_jira_http_response, mock_clinic_response, mock_guardian_response, ] # Mock S3 insight_data = json.dumps({"test": "insight"}).encode() diag_data = json.dumps({"test": "diag"}).encode() mock_stream_insight = AsyncMock() mock_stream_insight.read = AsyncMock(return_value=insight_data) mock_body_insight = AsyncMock() mock_body_insight.__aenter__.return_value = mock_stream_insight mock_body_insight.__aexit__.return_value = None mock_stream_diag = AsyncMock() mock_stream_diag.read = AsyncMock(return_value=diag_data) mock_body_diag = AsyncMock() mock_body_diag.__aenter__.return_value = mock_stream_diag mock_body_diag.__aexit__.return_value = None mock_s3_client = AsyncMock() mock_s3_client.get_object.side_effect = [ {"Body": mock_body_insight}, {"Body": mock_body_diag}, ] mock_client_context = AsyncMock() mock_client_context.__aenter__.return_value = mock_s3_client mock_client_context.__aexit__.return_value = None mock_session = MagicMock() mock_session.client.return_value = mock_client_context with patch("services.source_preparer.httpx.AsyncClient") as mock_httpx: mock_httpx.return_value.__aenter__.return_value = mock_http_client mock_httpx.return_value.__aexit__.return_value = None with patch("services.source_preparer.aioboto3.Session") as mock_aioboto3: mock_aioboto3.return_value = mock_session with patch("services.source_preparer.settings") as mock_settings: mock_settings.jira_base_url = "https://jira.example.com" mock_settings.jira_username = "test@example.com" mock_settings.jira_api_token = "test-jira-token" mock_settings.aiops_api_url = "https://api.example.com" mock_settings.s3_bucket = "test-bucket" mock_settings.aws_region = "us-west-2" mock_settings.clinic_endpoint = "https://clinic.example.com" mock_settings.clinic_api_key = "test-key" mock_settings.clinic_cookie = "" mock_settings.clinic_csrf_token = "" mock_settings.openai_api_key = "test-openai-key" mock_settings.openai_model = "gpt-4o" await prepare_alert_source( task_id, alert_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) # Verify S3 key construction calls = mock_s3_client.get_object.call_args_list assert len(calls) == 2 # Extract expected values from mock_analysis_response analysis = mock_analysis_response["items"][0] tenant_id = analysis["tenant_id"] cluster_id = analysis["cluster_id"] analysis_uuid = analysis["analysis_uuid"] # Created at 2025-07-14 → YYYY_MM = 2025_07 expected_date = "2025_07" # Insight key insight_key = calls[0][1]["Key"] expected_insight_key = f"guardian_service/analysis/{tenant_id}/{cluster_id}/{expected_date}/{analysis_uuid}/insight.en.json" assert insight_key == expected_insight_key # Diagnostic key diag_key = calls[1][1]["Key"] expected_diag_key = f"guardian_service/analysis/{tenant_id}/{cluster_id}/{expected_date}/{analysis_uuid}/diagnostic_package.json" assert diag_key == expected_diag_key @pytest.mark.asyncio async def test_cluster_meta_fields( tmp_workspace, tmp_templates, prepare_tasks, mock_jira_response, mock_analysis_response, ): """Test 7: Validate cluster_meta contains ONLY cluster_id, org_id, tidb_version (NO alert_type, NO alert_time).""" task_id = "task-007" alert_id = "O11Y-44444" workspace_id = "ws-007" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } # Mock Jira response mock_jira_http_response = MagicMock() mock_jira_http_response.json.return_value = mock_jira_response mock_jira_http_response.raise_for_status = MagicMock() # Mock Clinic API - include extra fields to ensure they're NOT copied mock_clinic_response = MagicMock() mock_clinic_response.json.return_value = { "items": [ { "clusterID": "1379661944646416054", "tenantID": "1372813089209061633", "orgID": "final-org-id", "clusterVersion": "v9.0.0", "extra_field": "should_not_appear", "another_field": "also_ignored", } ] } mock_clinic_response.raise_for_status = MagicMock() # Mock Guardian response mock_guardian_response = MagicMock() mock_guardian_response.json.return_value = mock_analysis_response mock_guardian_response.raise_for_status = MagicMock() mock_http_client = AsyncMock() # Jira, then Clinic, then Guardian mock_http_client.get.side_effect = [ mock_jira_http_response, mock_clinic_response, mock_guardian_response, ] # Mock S3 insight_data = json.dumps({"test": "insight"}).encode() diag_data = json.dumps({"test": "diag"}).encode() mock_stream_insight = AsyncMock() mock_stream_insight.read = AsyncMock(return_value=insight_data) mock_body_insight = AsyncMock() mock_body_insight.__aenter__.return_value = mock_stream_insight mock_body_insight.__aexit__.return_value = None mock_stream_diag = AsyncMock() mock_stream_diag.read = AsyncMock(return_value=diag_data) mock_body_diag = AsyncMock() mock_body_diag.__aenter__.return_value = mock_stream_diag mock_body_diag.__aexit__.return_value = None mock_s3_client = AsyncMock() mock_s3_client.get_object.side_effect = [ {"Body": mock_body_insight}, {"Body": mock_body_diag}, ] mock_client_context = AsyncMock() mock_client_context.__aenter__.return_value = mock_s3_client mock_client_context.__aexit__.return_value = None mock_session = MagicMock() mock_session.client.return_value = mock_client_context with patch("services.source_preparer.httpx.AsyncClient") as mock_httpx: mock_httpx.return_value.__aenter__.return_value = mock_http_client mock_httpx.return_value.__aexit__.return_value = None with patch("services.source_preparer.aioboto3.Session") as mock_aioboto3: mock_aioboto3.return_value = mock_session with patch("services.source_preparer.settings") as mock_settings: mock_settings.jira_base_url = "https://jira.example.com" mock_settings.jira_username = "test@example.com" mock_settings.jira_api_token = "test-jira-token" mock_settings.aiops_api_url = "https://api.example.com" mock_settings.s3_bucket = "test-bucket" mock_settings.aws_region = "us-west-2" mock_settings.clinic_endpoint = "https://clinic.example.com" mock_settings.clinic_api_key = "test-key" mock_settings.clinic_cookie = "" mock_settings.clinic_csrf_token = "" mock_settings.openai_api_key = "test-openai-key" mock_settings.openai_model = "gpt-4o" await prepare_alert_source( task_id, alert_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) # Verify cluster_meta has all expected comprehensive fields (10 fields) cluster_meta = prepare_tasks[task_id]["cluster_meta"] assert cluster_meta is not None expected_fields = { "tenant_id", "clinic_org_id", "cluster_id", "cluster_name", "tidb_version", "cluster_type", "cluster_type_v2", "parent_id", "shoot_name", "keyspace_id", "project_id", "cloud_provider", "region", "status", "create_timestamp", } assert set(cluster_meta.keys()) == expected_fields # Verify values assert cluster_meta["cluster_id"] == "1379661944646416054" assert cluster_meta["clinic_org_id"] == "final-org-id" assert cluster_meta["tidb_version"] == "v9.0.0" # Verify NO forbidden fields (extra_field, another_field are from mock, should not appear) assert "alert_type" not in cluster_meta assert "alert_time" not in cluster_meta assert "extra_field" not in cluster_meta assert "another_field" not in cluster_meta assert "node_quantity" not in cluster_meta assert "total_node_num" not in cluster_meta assert "cpu_info" not in cluster_meta assert "memory_info" not in cluster_meta assert "storage_info" not in cluster_meta # Verify cluster_meta.json file has all comprehensive fields alert_dir = tmp_workspace / workspace_id / "issues" / alert_id meta_file = alert_dir / "cluster_meta.json" assert meta_file.exists() with open(meta_file, "r") as f: saved_meta = json.load(f) assert set(saved_meta.keys()) == expected_fields @pytest.mark.asyncio async def test_alert_meta_fields( tmp_workspace, tmp_templates, prepare_tasks, mock_jira_response, mock_analysis_response, ): """Test 8: Validate alert_meta.json contains all required alert details from Jira.""" task_id = "task-008" alert_id = "O11Y-55555" workspace_id = "ws-008" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } # Mock Jira response with alert details mock_jira_http_response = MagicMock() mock_jira_http_response.json.return_value = { "key": "O11Y-55555", "fields": { "summary": "High Memory Usage Alert", "description": "Memory usage exceeded 90% threshold", "status": {"name": "Open"}, "priority": {"name": "High"}, "created": "2025-07-14T10:00:00.000+0000", "updated": "2025-07-14T12:30:00.000+0000", "customfield_10160": json.dumps( { "labels": { "tidb_cluster_id": "1379661944646416054", "o11y_tenant_id": "1372813089209061633", "alertname": "HighMemoryUsage", "component": "tidb", }, "messages": [ "Memory usage exceeded 90% threshold", "RSS keeps increasing on tidb", ], } ), }, } mock_jira_http_response.raise_for_status = MagicMock() # Mock Clinic response mock_clinic_response = MagicMock() mock_clinic_response.json.return_value = { "items": [ { "clusterID": "1379661944646416054", "tenantID": "1372813089209061633", "orgID": "test-org-alert", "clusterName": "test-tidb-cluster", "clusterVersion": "v7.9.0", "clusterDeployType": "DEDICATED", "clusterProviderName": "AWS", "clusterRegionName": "us-west-2", "clusterStatus": "AVAILABLE", "clusterCreatedAt": "2024-01-15T10:30:00Z", } ] } mock_clinic_response.raise_for_status = MagicMock() # Mock Guardian response (empty - not needed for this test) mock_guardian_response = MagicMock() mock_guardian_response.json.return_value = {"items": [], "total": 0} mock_guardian_response.raise_for_status = MagicMock() mock_jira_comments_response = MagicMock() mock_jira_comments_response.json.return_value = {"comments": []} mock_jira_comments_response.raise_for_status = MagicMock() mock_http_client = AsyncMock() # Jira issue, Clinic API, Guardian API, then Jira comments mock_http_client.get.side_effect = [ mock_jira_http_response, mock_clinic_response, mock_guardian_response, mock_jira_comments_response, ] with patch("services.source_preparer.httpx.AsyncClient") as mock_httpx: mock_httpx.return_value.__aenter__.return_value = mock_http_client mock_httpx.return_value.__aexit__.return_value = None with patch("services.source_preparer.settings") as mock_settings: mock_settings.jira_base_url = "https://jira.example.com" mock_settings.jira_username = "test@example.com" mock_settings.jira_api_token = "test-jira-token" mock_settings.aiops_api_url = "https://api.example.com" mock_settings.s3_bucket = "test-bucket" mock_settings.aws_region = "us-west-2" mock_settings.clinic_endpoint = "https://clinic.example.com" mock_settings.clinic_api_key = "test-key" mock_settings.clinic_cookie = "" mock_settings.clinic_csrf_token = "" mock_settings.openai_api_key = "test-openai-key" mock_settings.openai_model = "gpt-4o" await prepare_alert_source( task_id, alert_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) # Verify alert_meta.json was created under alerts/{alert_id}/ alert_dir = tmp_workspace / workspace_id / "issues" / alert_id alert_meta_file = alert_dir / "issue_meta.json" assert alert_meta_file.exists() # Load and verify alert_meta.json content with open(alert_meta_file, "r") as f: alert_meta = json.load(f) # Verify all required fields are present expected_fields = { "jira_issue_id", "summary", "description", "cluster_id", "tenant_id", "alert_name", "component", "messages", "rule", "rule_expr", "rule_for", "status", "priority", "created", "updated", } assert set(alert_meta.keys()) == expected_fields # Verify field values assert alert_meta["jira_issue_id"] == "O11Y-55555" assert alert_meta["summary"] == "High Memory Usage Alert" assert alert_meta["description"] == "Memory usage exceeded 90% threshold" assert alert_meta["messages"] == [ "Memory usage exceeded 90% threshold", "RSS keeps increasing on tidb", ] assert alert_meta["cluster_id"] == "1379661944646416054" assert alert_meta["tenant_id"] == "1372813089209061633" assert alert_meta["alert_name"] == "HighMemoryUsage" assert alert_meta["component"] == "tidb" assert alert_meta["rule"] is None assert alert_meta["rule_expr"] is None assert alert_meta["rule_for"] is None assert alert_meta["status"] == "Open" assert alert_meta["priority"] == "High" assert alert_meta["created"] == "2025-07-14T10:00:00.000+0000" assert alert_meta["updated"] == "2025-07-14T12:30:00.000+0000" @pytest.mark.asyncio async def test_prepare_issue_source_writes_alert_rule_match_when_lookup_succeeds( tmp_workspace, tmp_templates, prepare_tasks, ): task_id = "task-rule-001" alert_id = "O11Y-77777" workspace_id = "ws-rule-001" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } mock_fetch_alert = AsyncMock( return_value=( "cluster-1", "tenant-1", { "fields": { "summary": "High CPU Usage Alert", "status": {"name": "Open"}, "priority": {"name": "High"}, "created": "2025-07-14T10:00:00.000+0000", "updated": "2025-07-14T12:30:00.000+0000", "customfield_10160": json.dumps( { "labels": { "alertname": "HighCPUUsage", "component": "tikv", }, "messages": ["raftstore busy"], } ), } }, ) ) mock_fetch_cluster = AsyncMock( return_value={ "tenant_id": "tenant-1", "clinic_org_id": "org-1", "cluster_id": "cluster-1", "cluster_name": "cluster-a", "tidb_version": "v8.0.0", "cluster_type": "DEDICATED", "cluster_type_v2": "dedicated", "parent_id": "", "shoot_name": "", "keyspace_id": "", "project_id": "", "cloud_provider": "AWS", "region": "us-west-2", "status": "AVAILABLE", "create_timestamp": "", } ) mock_lookup = MagicMock( return_value={ "status": "ok", "top_match": { "file": "rules/tikv/cpu.yaml", "alert": "HighCPUUsage", "expr": "sum(rate(tikv_thread_cpu_seconds_total[5m])) > 0.8", "for": "10m", }, "exact_matches": [], "fuzzy_matches": [], } ) with patch("services.source_preparer._fetch_alert_from_jira", mock_fetch_alert), \ patch("services.source_preparer._fetch_cluster_meta_from_clinic", mock_fetch_cluster), \ patch("services.source_preparer._fetch_analysis_from_guardian", AsyncMock()), \ patch("services.source_preparer._fetch_comments_from_jira", AsyncMock(return_value=None)), \ patch("services.source_preparer._lookup_alert_rule", mock_lookup): await prepare_alert_source( task_id, alert_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) issue_dir = tmp_workspace / workspace_id / "issues" / alert_id issue_meta = json.loads((issue_dir / "issue_meta.json").read_text()) alert_rule_match = json.loads((issue_dir / "alert_rule_match.json").read_text()) assert issue_meta["rule"] == "rules/tikv/cpu.yaml" assert issue_meta["rule_expr"] == "sum(rate(tikv_thread_cpu_seconds_total[5m])) > 0.8" assert issue_meta["rule_for"] == "10m" assert alert_rule_match["top_match"]["file"] == "rules/tikv/cpu.yaml" assert f"issues/{alert_id}/alert_rule_match.json" in {file["path"] for file in prepare_tasks[task_id]["files"]} def test_ensure_runbooks_checkout_clones_when_missing(tmp_path, monkeypatch): repo_dir = tmp_path / "runbooks" monkeypatch.setenv("TIDBCLOUD_RUNBOOKS_DIR", str(repo_dir)) commands: list[list[str]] = [] def fake_run_git(args: list[str], *, cwd=None): commands.append(args) if args[:2] == ["git", "clone"]: repo_dir.mkdir(parents=True, exist_ok=True) (repo_dir / ".git").mkdir() (repo_dir / "rules").mkdir() monkeypatch.setattr("services.source_preparer._run_git_command", fake_run_git) path = _ensure_runbooks_checkout() assert path == repo_dir.resolve() assert commands[0][:5] == ["git", "clone", "--branch", "master", "--single-branch"] assert commands[1] == ["git", "-C", str(repo_dir.resolve()), "fetch", "origin", "master"] assert commands[2] == ["git", "-C", str(repo_dir.resolve()), "checkout", "master"] assert commands[3] == ["git", "-C", str(repo_dir.resolve()), "pull", "origin", "master"] def test_load_alert_rule_lookup_module_prefers_configured_skills_dir(tmp_path, monkeypatch): skills_dir = tmp_path / "skills" script_path = ( skills_dir / "builtin" / "o11y-alert-rule-lookup" / "scripts" / "find_alert_rule.py" ) script_path.parent.mkdir(parents=True, exist_ok=True) script_path.write_text( "UNIQUE_SENTINEL = 'configured-skill-dir'\n" "def search_rules(*args, **kwargs):\n" " return {'status': 'ok'}\n", encoding="utf-8", ) monkeypatch.setattr( "services.source_preparer.app_config.AGENTKIT_SKILLS_DIR", skills_dir, ) module = _load_alert_rule_lookup_module() assert module.UNIQUE_SENTINEL == "configured-skill-dir" def test_ensure_runbooks_checkout_refreshes_existing_repo(tmp_path, monkeypatch): repo_dir = tmp_path / "runbooks" (repo_dir / ".git").mkdir(parents=True) (repo_dir / "rules").mkdir() monkeypatch.setenv("TIDBCLOUD_RUNBOOKS_DIR", str(repo_dir)) commands: list[list[str]] = [] def fake_run_git(args: list[str], *, cwd=None): commands.append(args) monkeypatch.setattr("services.source_preparer._run_git_command", fake_run_git) path = _ensure_runbooks_checkout() assert path == repo_dir.resolve() assert all(cmd[:2] != ["git", "clone"] for cmd in commands) assert commands == [ ["git", "-C", str(repo_dir.resolve()), "fetch", "origin", "master"], ["git", "-C", str(repo_dir.resolve()), "checkout", "master"], ["git", "-C", str(repo_dir.resolve()), "pull", "origin", "master"], ] def test_managed_runbooks_dir_defaults_to_data_dir(monkeypatch, tmp_path): monkeypatch.delenv("TIDBCLOUD_RUNBOOKS_DIR", raising=False) monkeypatch.setattr("services.source_preparer.app_config.DATA_DIR", tmp_path / "shared-data") path = _managed_runbooks_dir() assert path == (tmp_path / "shared-data" / "runbooks").resolve() def test_run_git_command_uses_github_token_header(monkeypatch): captured = {} class Result: returncode = 0 stderr = "" stdout = "" def fake_run(args, **kwargs): captured["args"] = args captured["env"] = kwargs["env"] return Result() monkeypatch.setenv("SKILL_GITHUB_TOKEN", "test-token") monkeypatch.setattr("services.source_preparer.subprocess.run", fake_run) _run_git_command(["git", "status"]) env = captured["env"] assert env["GIT_TERMINAL_PROMPT"] == "0" assert env["GIT_CONFIG_COUNT"] == "1" assert env["GIT_CONFIG_KEY_0"] == "http.https://github.com/.extraheader" assert env["GIT_CONFIG_VALUE_0"].startswith("AUTHORIZATION: basic ") # Unit tests for individual fetch functions @pytest.mark.asyncio async def test_fetch_ticket_from_jira(): """Test that _fetch_ticket_from_jira correctly extracts data from TiDBCloud Tickets (TCID).""" # Mock Jira API response for a TiDBCloud Ticket mock_response = { "key": "TCID-12345", "fields": { "customfield_10088": "987654", # cluster_id "customfield_10086": "tenant_xyz789", # tenant_id "summary": "Database connection timeout", "description": "Cluster experiencing connection issues", "status": {"name": "In Progress"}, "priority": {"name": "High"}, "created": "2024-03-10T10:00:00.000+0000", "updated": "2024-03-10T15:30:00.000+0000", }, } # Mock the httpx.AsyncClient with patch("services.source_preparer.httpx.AsyncClient") as mock_client_class, \ patch("services.source_preparer.settings") as mock_settings: mock_client = AsyncMock() mock_response_obj = MagicMock() mock_response_obj.raise_for_status = MagicMock() mock_response_obj.json = MagicMock(return_value=mock_response) # Mock get to return the response object async def mock_get(*args, **kwargs): return mock_response_obj mock_client.get = mock_get mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = AsyncMock(return_value=None) mock_settings.jira_base_url = "https://jira.example.com" mock_settings.jira_username = "test@example.com" mock_settings.jira_api_token = "test-jira-token" mock_client_class.return_value = mock_client # Call the function cluster_id, tenant_id, issue = await _fetch_ticket_from_jira("TCID-12345") # Verify results assert cluster_id == "987654", f"Expected cluster_id='987654', got '{cluster_id}'" assert ( tenant_id == "tenant_xyz789" ), f"Expected tenant_id='tenant_xyz789', got '{tenant_id}'" assert issue is not None, "Expected issue dict, got None" assert issue["key"] == "TCID-12345", f"Expected key='TCID-12345', got '{issue.get('key')}'" @pytest.mark.asyncio async def test_fetch_alert_from_jira_fast_path(): """Test that _fetch_alert_from_jira uses fast path when custom fields are present.""" # Mock Jira API response with direct custom fields mock_response = { "key": "O11Y-1956041", "fields": { "customfield_10149": "123456", # cluster_id (fast path) "customfield_10157": "tenant_abc123", # tenant_id (fast path) "customfield_10160": '{"labels": {"tidb_cluster_id": "ignored_value"}}', # Should be ignored "summary": "High Memory Usage", "status": {"name": "Open"}, }, } with patch("services.source_preparer.httpx.AsyncClient") as mock_client_class, \ patch("services.source_preparer.settings"): mock_client = AsyncMock() mock_response_obj = MagicMock() mock_response_obj.raise_for_status = MagicMock() mock_response_obj.json = MagicMock(return_value=mock_response) async def mock_get(*args, **kwargs): return mock_response_obj mock_client.get = mock_get mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = AsyncMock(return_value=None) mock_client_class.return_value = mock_client cluster_id, tenant_id, issue = await _fetch_alert_from_jira("O11Y-1956041") assert cluster_id == "123456", f"Expected cluster_id='123456', got '{cluster_id}'" assert ( tenant_id == "tenant_abc123" ), f"Expected tenant_id='tenant_abc123', got '{tenant_id}'" @pytest.mark.asyncio async def test_fetch_alert_from_jira_fallback(): """Test that _fetch_alert_from_jira uses fallback path when needed.""" # Mock Jira API response without direct custom fields mock_response = { "key": "O11Y-1956042", "fields": { "customfield_10160": json.dumps( { "labels": { "tidb_cluster_id": "tidb789012", # Should be trimmed "o11y_tenant_id": "tenant_def456", } } ), "summary": "High CPU Usage", }, } with patch("services.source_preparer.httpx.AsyncClient") as mock_client_class, \ patch("services.source_preparer.settings"): mock_client = AsyncMock() mock_response_obj = MagicMock() mock_response_obj.raise_for_status = MagicMock() mock_response_obj.json = MagicMock(return_value=mock_response) async def mock_get(*args, **kwargs): return mock_response_obj mock_client.get = mock_get mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = AsyncMock(return_value=None) mock_client_class.return_value = mock_client cluster_id, tenant_id, issue = await _fetch_alert_from_jira("O11Y-1956042") assert ( cluster_id == "789012" ), f"Expected cluster_id='789012' (trimmed), got '{cluster_id}'" assert ( tenant_id == "tenant_def456" ), f"Expected tenant_id='tenant_def456', got '{tenant_id}'" @pytest.mark.asyncio async def test_fetch_alert_from_jira_tcoc_prefix(): """Test that _fetch_alert_from_jira works with TiDBCloud Oncall alerts (TCOC).""" # Mock Jira API response for TiDBCloud Oncall alert mock_response = { "key": "TCOC-98765", "fields": { "customfield_10149": "555666", # cluster_id (fast path) "customfield_10157": "tenant_tcoc_test", # tenant_id (fast path) "summary": "High Disk Usage", "status": {"name": "Open"}, }, } with patch("services.source_preparer.httpx.AsyncClient") as mock_client_class, \ patch("services.source_preparer.settings"): mock_client = AsyncMock() mock_response_obj = MagicMock() mock_response_obj.raise_for_status = MagicMock() mock_response_obj.json = MagicMock(return_value=mock_response) async def mock_get(*args, **kwargs): return mock_response_obj mock_client.get = mock_get mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = AsyncMock(return_value=None) mock_client_class.return_value = mock_client cluster_id, tenant_id, issue = await _fetch_alert_from_jira("TCOC-98765") assert cluster_id == "555666", f"Expected cluster_id='555666', got '{cluster_id}'" assert ( tenant_id == "tenant_tcoc_test" ), f"Expected tenant_id='tenant_tcoc_test', got '{tenant_id}'" assert issue["key"] == "TCOC-98765", f"Expected key='TCOC-98765', got '{issue.get('key')}'" @pytest.mark.asyncio async def test_prepare_alert_source_unsupported_prefix( tmp_workspace, tmp_templates, prepare_tasks, ): """Test that prepare_alert_source fails with completely unsupported issue prefix.""" task_id = "task-unsupported" alert_id = "JPID-12345" # Unsupported prefix workspace_id = "ws-unsupported" prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } # Call prepare_alert_source - should handle error gracefully await prepare_alert_source( task_id, alert_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) # Verify task was marked as error assert prepare_tasks[task_id]["status"] == "error" assert "Unsupported issue type" in prepare_tasks[task_id]["error"] assert "JPID-12345" in prepare_tasks[task_id]["error"] @pytest.mark.asyncio async def test_prepare_alert_source_completes_without_cluster_id( tmp_workspace, tmp_templates, prepare_tasks, ): """Test that issue preparation completes with minimal metadata when Jira has no cluster_id.""" task_id = "task-missing-cluster-id" issue_id = "TCID-54321" workspace_id = "ws-missing-cluster-id" jira_issue = { "key": issue_id, "fields": { "summary": "Ticket without cluster metadata", "description": "Customer issue is not tied to a specific cluster", "status": {"name": "Open"}, "priority": {"name": "Medium"}, }, } prepare_tasks[task_id] = { "status": "pending", "files": [], "cluster_meta": None, "error": None, } mock_fetch_ticket = AsyncMock(return_value=(None, "tenant-missing-cluster", jira_issue)) mock_fetch_cluster_meta = AsyncMock() mock_fetch_analysis = AsyncMock() mock_fetch_comments = AsyncMock(return_value=None) with patch("services.source_preparer._fetch_ticket_from_jira", mock_fetch_ticket), \ patch("services.source_preparer._fetch_cluster_meta_from_clinic", mock_fetch_cluster_meta), \ patch("services.source_preparer._fetch_analysis_from_guardian", mock_fetch_analysis), \ patch("services.source_preparer._fetch_comments_from_jira", mock_fetch_comments): await prepare_alert_source( task_id, issue_id, workspace_id, prepare_tasks, tmp_workspace, tmp_templates, ) task = prepare_tasks[task_id] issue_dir = tmp_workspace / workspace_id / "issues" / issue_id assert task["status"] == "completed" assert task["error"] is None assert task["issue_dir"] == f"issues/{issue_id}" assert (issue_dir / "cluster_meta.json").exists() assert (issue_dir / "issue_meta.json").exists() cluster_meta = json.loads((issue_dir / "cluster_meta.json").read_text()) issue_meta = json.loads((issue_dir / "issue_meta.json").read_text()) assert cluster_meta["tenant_id"] == "tenant-missing-cluster" assert cluster_meta["cluster_id"] is None assert issue_meta["tenant_id"] == "tenant-missing-cluster" assert issue_meta["cluster_id"] is None assert {file["path"] for file in task["files"]} == { f"issues/{issue_id}/cluster_meta.json", f"issues/{issue_id}/issue_meta.json", } mock_fetch_cluster_meta.assert_not_awaited() mock_fetch_analysis.assert_not_awaited()