from __future__ import annotations
import json
import unittest
from ai_triage.feishu import (
FeishuError,
FeishuUserResolver,
FeishuWebhookNotifier,
resolve_owner_mentions,
)
class RecordingTransport:
def __init__(
self,
status: int = 200,
response: bytes = b'{"code":0,"msg":"ok"}',
) -> None:
self.status = status
self.response = response
self.calls: list[tuple[str, bytes, dict[str, str], float]] = []
def __call__(
self,
url: str,
body: bytes,
headers: dict[str, str],
timeout: float,
) -> tuple[int, bytes]:
self.calls.append((url, body, headers, timeout))
return self.status, self.response
class SequencedTransport:
def __init__(self, responses: list[tuple[int, bytes]]) -> None:
self.responses = list(responses)
self.calls: list[tuple[str, bytes, dict[str, str], float]] = []
def __call__(
self,
url: str,
body: bytes,
headers: dict[str, str],
timeout: float,
) -> tuple[int, bytes]:
self.calls.append((url, body, headers, timeout))
return self.responses.pop(0)
def notification(**overrides: object) -> dict[str, object]:
value: dict[str, object] = {
"event_id": "dedicated:101:2:201",
"target_id": "dedicated",
"workflow": "scenario.yaml",
"run_id": 101,
"run_attempt": 2,
"run_url": "https://github.invalid/runs/101",
"job_id": 201,
"job_name": "gcp (case.yaml)",
"job_url": "https://github.invalid/jobs/201",
"incidents": [
{
"failed_step": "Run scenario cases",
"triage": {
"category": "product_error",
"component": "dedicated-control-plane",
"problem_key": "cluster-stuck-creating",
"owner": "component-owner",
"owner_type": "component",
"confidence": 0.9,
"summary": "state did not converge",
"root_cause": "service remained in creating",
"recommendations": ["inspect trace"],
},
"evidence": {
"scenario_yaml": "ng_backups_operation.yaml",
"request_method": "GET",
"request_url": "https://api.example.com/v1/clusters/1",
"expected": "body.state == ACTIVE",
"actual_status": 200,
"response_body": {"state": "CREATING"},
"trace_id": "trace-1",
},
}
],
}
value.update(overrides)
return value
class FeishuWebhookNotifierTests(unittest.TestCase):
def test_resolves_email_owners_to_open_ids_with_app_credentials(self) -> None:
transport = SequencedTransport(
[
(
200,
b'{"code":0,"tenant_access_token":"tenant-token","expire":7200}',
),
(
200,
json.dumps(
{
"code": 0,
"data": {
"user_list": [
{
"email": "case@example.com",
"user_id": "ou_case",
},
{
"email": "infra@example.com",
"user_id": "ou_infra",
},
]
},
}
).encode(),
),
]
)
mentions = resolve_owner_mentions(
"app-id",
"app-secret",
{
"product": "component-name",
"case": "case@example.com",
"infrastructure": "infra@example.com",
},
transport=transport,
)
self.assertEqual(
{"case": "ou_case", "infrastructure": "ou_infra"},
mentions,
)
self.assertEqual(2, len(transport.calls))
self.assertNotIn("app-secret", str(mentions))
self.assertNotIn("tenant-token", str(mentions))
def test_sends_card2_topic_with_columns_highlights_and_owner_mention(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token",
owner_mentions={"component": "ou_component"},
transport=transport,
)
value = notification()
value["incidents"][0]["triage"]["owner_type"] = "component"
notifier.send_failure(value)
payload = json.loads(transport.calls[0][1])
card = payload["card"]
serialized = json.dumps(card, ensure_ascii=False)
self.assertEqual("interactive", payload["msg_type"])
self.assertEqual("2.0", card["schema"])
self.assertEqual("red", card["header"]["template"])
self.assertNotIn("icon", card["header"])
self.assertEqual(
"🚨 场景 E2E 失败",
card["header"]["title"]["content"],
)
column_sets = [
element
for element in card["body"]["elements"]
if element.get("tag") == "column_set"
]
self.assertTrue(
any(len(element.get("columns", [])) >= 2 for element in column_sets)
)
self.assertIn("state did not converge", serialized)
self.assertIn("service remained in creating", serialized)
self.assertIn("", serialized)
self.assertTrue(
any(
behavior.get("type") == "open_url"
and behavior.get("default_url")
== "https://github.invalid/jobs/201"
for element in _walk_card(card)
if element.get("tag") == "button"
for behavior in element.get("behaviors", [])
)
)
def test_product_issue_mentions_live_oncall_and_shows_selected_team(self) -> None:
transport = RecordingTransport()
resolved_emails: list[tuple[str, ...]] = []
def resolve(emails: tuple[str, ...]) -> dict[str, str]:
resolved_emails.append(emails)
return {"oncall@example.com": "ou_oncall"}
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token",
email_resolver=resolve,
transport=transport,
)
value = notification()
value["incidents"][0]["triage"].update(
{
"owner_type": "product_oncall",
"owner": "oncall@example.com",
"owner_emails": ["oncall@example.com"],
"oncall_team": "global_control",
"oncall_team_display": "SOE - Global & Dedicated",
}
)
notifier.send_failure(value)
text = transport.calls[0][1].decode()
self.assertEqual([("oncall@example.com",)], resolved_emails)
self.assertIn("SOE - Global & Dedicated", text)
self.assertIn("当前 Oncall", text)
self.assertIn("", text)
def test_case_issue_labels_configured_owner_as_case_owner(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token",
owner_mentions={"case": "ou_case"},
transport=transport,
)
value = notification()
value["incidents"][0]["triage"].update(
{
"category": "case_error",
"owner_type": "case",
"owner": "case@example.com",
"oncall_team": "unknown",
}
)
notifier.send_failure(value)
payload = json.loads(transport.calls[0][1])
text = transport.calls[0][1].decode()
self.assertEqual("orange", payload["card"]["header"]["template"])
self.assertIn("Case Owner(配置)", text)
self.assertIn("", text)
def test_infrastructure_issue_uses_orange_card_header(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token",
owner_mentions={"infrastructure": "ou_infra"},
transport=transport,
)
value = notification()
value["incidents"][0]["triage"].update(
{
"category": "infrastructure_error",
"owner_type": "infrastructure",
"owner": "infra@example.com",
"oncall_team": "unknown",
}
)
notifier.send_failure(value)
payload = json.loads(transport.calls[0][1])
self.assertEqual("orange", payload["card"]["header"]["template"])
def test_partial_ai_result_keeps_existing_fields_and_marks_missing_fields_gray(
self,
) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token",
owner_mentions={"case": "ou_case"},
transport=transport,
)
value = notification()
value["incidents"][0]["triage"].update(
{
"analysis_status": "ai_partial",
"missing_fields": ["oncall_team"],
"owner_type": "case",
"owner": "case@example.com",
"oncall_team": "unknown",
"summary": "服务端返回了 HTTP 500",
"root_cause": "产品接口处理请求时发生内部错误",
}
)
notifier.send_failure(value)
payload = json.loads(transport.calls[0][1])
text = transport.calls[0][1].decode()
markdown = "\n".join(
element.get("content", "")
for element in _walk_card(payload["card"])
if element.get("tag") == "markdown"
)
self.assertIn("服务端返回了 HTTP 500", text)
self.assertIn("产品接口处理请求时发生内部错误", text)
self.assertIn(
"AI 返回结果缺失字段:oncall\\_team",
markdown,
)
self.assertIn("Case Owner(配置)", text)
self.assertNotIn("AI 分析结果不可用,已按保守策略归类", text)
def test_feishu_user_resolver_caches_dynamic_email_lookups(self) -> None:
transport = SequencedTransport(
[
(
200,
b'{"code":0,"tenant_access_token":"tenant-token","expire":7200}',
),
(
200,
json.dumps(
{
"code": 0,
"data": {
"user_list": [
{
"email": "oncall@example.com",
"user_id": "ou_oncall",
}
]
},
}
).encode(),
),
]
)
resolver = FeishuUserResolver(
"app-id",
"app-secret",
transport=transport,
)
first = resolver.resolve(("oncall@example.com",))
second = resolver.resolve(("oncall@example.com",))
self.assertEqual({"oncall@example.com": "ou_oncall"}, first)
self.assertEqual(first, second)
self.assertEqual(2, len(transport.calls))
def test_card_excludes_internal_fields_and_keeps_failure_link(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token", transport=transport
)
notifier.send_failure(notification())
url, body, headers, _ = transport.calls[0]
payload = json.loads(body)
text = body.decode()
self.assertEqual("https://secret.invalid/hook/token", url)
self.assertEqual("application/json", headers["Content-Type"])
self.assertEqual("interactive", payload["msg_type"])
self.assertNotIn("dedicated:101:2:201", text)
self.assertNotIn("product_error", text)
self.assertNotIn("confidence", text)
self.assertNotIn("problem_key", text)
self.assertIn("body.state == ACTIVE", text)
self.assertIn("CREATING", text)
self.assertIn("https://github.invalid/jobs/201", text)
self.assertNotIn("secret.invalid", text)
def test_card_uses_chinese_user_facing_labels_and_defaults(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token", transport=transport
)
value = notification()
value["incidents"][0]["triage"].update(
{
"summary": "集群状态未在预期时间内收敛",
"root_cause": "控制面持续返回创建中状态",
"recommendations": ["根据链路追踪 ID 检查控制面日志"],
"owner": "",
}
)
notifier.send_failure(value)
text = transport.calls[0][1].decode()
for expected in (
"请求:",
"预期结果:",
"实际状态码:",
"实际响应:",
"链路追踪 ID:",
"测试用例:",
"运行 101",
"尝试 2",
"未配置",
"Dedicated 控制面",
):
self.assertIn(expected, text)
for english_label in (
"Request:",
"Expected:",
"Actual status:",
"Actual response:",
"Trace ID:",
"Testcase:",
"Run 101",
"Attempt 2",
"unassigned",
"not captured",
"dedicated-control-plane",
):
self.assertNotIn(english_label, text)
def test_failure_details_do_not_repeat_top_level_diagnosis_as_phenomenon(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token", transport=transport
)
notifier.send_failure(notification())
text = transport.calls[0][1].decode()
self.assertEqual(1, text.count("state did not converge"))
self.assertEqual(1, text.count("service remained in creating"))
self.assertNotIn("现象", text)
def test_failure_summary_includes_related_metadata(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token", transport=transport
)
value = notification()
value["incidents"][0]["evidence"]["related_metadata"] = {
"clusterId": ["cluster-1234"],
"resourcePoolId": ["pool-9876"],
"orgId": ["org-1", "org-2"],
"restoreId": ["restore-1122"],
"changefeedId": ["changefeed-3344"],
}
notifier.send_failure(value)
payload = json.loads(transport.calls[0][1])
text = transport.calls[0][1].decode()
failure_text = next(
element["content"]
for element in payload["card"]["body"]["elements"]
if element.get("tag") == "markdown"
and "clusterId:cluster-1234" in element.get("content", "")
)
self.assertNotIn("相关元数据", text)
self.assertIn("clusterId:cluster-1234", text)
self.assertIn("resourcePoolId:pool-9876", text)
self.assertIn("orgId:org-1、org-2", text)
self.assertIn("restoreId:restore-1122", text)
self.assertIn("changefeedId:changefeed-3344", text)
self.assertIn(
"ng_backups_operation.yaml)\n- clusterId:cluster-1234",
failure_text,
)
self.assertIn(
"changefeedId:changefeed-3344\n\n- 请求",
failure_text,
)
def test_identical_diagnosis_and_root_cause_are_rendered_once(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token", transport=transport
)
value = notification()
value["incidents"][0]["triage"]["root_cause"] = "state did not converge"
notifier.send_failure(value)
text = transport.calls[0][1].decode()
self.assertEqual(1, text.count("state did not converge"))
self.assertNotIn("根因判断", text)
def test_failure_details_link_to_scenario_testcase_yaml(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token", transport=transport
)
notifier.send_failure(notification())
text = transport.calls[0][1].decode()
self.assertIn(
"https://github.com/tidbcloud/APITestGen/blob/main/"
"scenario_testcases/ng_backups_operation.yaml",
text,
)
def test_testcase_link_falls_back_to_yaml_in_job_name(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token", transport=transport
)
value = notification(job_name="nonprod (ng_manual_backup_restore.yaml)")
value["incidents"][0]["evidence"]["scenario_yaml"] = None
notifier.send_failure(value)
text = transport.calls[0][1].decode()
self.assertIn(
"https://github.com/tidbcloud/APITestGen/blob/main/"
"scenario_testcases/ng_manual_backup_restore.yaml",
text,
)
def test_aggregated_topic_lists_every_failed_job_link(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token", transport=transport
)
first = notification()["incidents"][0]
second = json.loads(json.dumps(first))
second["triage"]["summary"] = "another cluster did not converge"
topic = {
"target_id": "dedicated",
"workflow": "scenario.yaml",
"run_id": 101,
"run_attempt": 2,
"run_url": "https://github.invalid/runs/101",
"event_ids": ["dedicated:101:2:201", "dedicated:101:2:202"],
"topic_key": "topic-internal-only",
"progress_keys": {
"dedicated:101:2:201": ["failure-internal-only-1"],
"dedicated:101:2:202": ["failure-internal-only-2"],
},
"failures": [
{
"job_id": 201,
"job_name": "gcp (case-a.yaml)",
"job_url": "https://github.invalid/jobs/201",
**first,
},
{
"job_id": 202,
"job_name": "gcp (case-b.yaml)",
"job_url": "https://github.invalid/jobs/202",
**second,
},
],
}
notifier.send_failure(topic)
text = transport.calls[0][1].decode()
self.assertIn("2 个失败任务", text)
self.assertIn("https://github.invalid/jobs/201", text)
self.assertIn("https://github.invalid/jobs/202", text)
self.assertNotIn("dedicated:101:2:201", text)
self.assertNotIn("topic-internal-only", text)
self.assertNotIn("failure-internal-only", text)
def test_aggregated_topic_button_opens_the_workflow_run(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token", transport=transport
)
first = notification()["incidents"][0]
topic = {
"target_id": "dedicated",
"workflow": "scenario.yaml",
"run_id": 101,
"run_attempt": 2,
"run_url": "https://github.invalid/runs/101",
"failures": [
{
"job_id": job_id,
"job_name": f"gcp (case-{job_id}.yaml)",
"job_url": f"https://github.invalid/jobs/{job_id}",
**json.loads(json.dumps(first)),
}
for job_id in (201, 202)
],
}
notifier.send_failure(topic)
card = json.loads(transport.calls[0][1])["card"]
destinations = [
behavior.get("default_url")
for element in _walk_card(card)
if element.get("tag") == "button"
for behavior in element.get("behaviors", [])
if behavior.get("type") == "open_url"
]
self.assertEqual(["https://github.invalid/runs/101"], destinations)
def test_large_group_stays_within_custom_webhook_body_limit(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token",
transport=transport,
max_text_chars=20_000,
)
first = notification()["incidents"][0]
topic = {
"target_id": "dedicated",
"workflow": "scenario.yaml",
"run_id": 101,
"run_attempt": 2,
"run_url": "https://github.invalid/runs/101",
"failures": [
{
"job_id": job_id,
"job_name": f"gcp (case-{job_id}.yaml)",
"job_url": f"https://github.invalid/jobs/{job_id}",
**json.loads(json.dumps(first)),
}
for job_id in range(200, 300)
],
}
notifier.send_failure(topic)
body = transport.calls[0][1]
self.assertLessEqual(len(body), 20_000)
self.assertIn(b"https://github.invalid/jobs/200", body)
self.assertIn("100 个失败任务".encode(), body)
self.assertNotIn("另有 95 个失败任务".encode(), body)
def test_large_group_shows_two_details_three_more_links_and_no_empty_lists(
self,
) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token",
transport=transport,
max_text_chars=20_000,
)
first = notification()["incidents"][0]
failures = []
for job_id in range(200, 210):
failure = {
"job_id": job_id,
"job_name": f"gcp (case-{job_id}.yaml)",
"job_url": f"https://github.invalid/jobs/{job_id}",
**json.loads(json.dumps(first)),
}
failure["evidence"] = {}
failure["triage"]["recommendations"] = []
failures.append(failure)
topic = {
"target_id": "dedicated",
"workflow": "scenario.yaml",
"run_id": 101,
"run_attempt": 2,
"run_url": "https://github.invalid/runs/101",
"failures": failures,
}
notifier.send_failure(topic)
card = json.loads(transport.calls[0][1])["card"]
markdown = "\n".join(
element.get("content", "")
for element in _walk_card(card)
if element.get("tag") == "markdown"
)
self.assertIn("10 个失败任务", markdown)
self.assertIn("**失败任务 1", markdown)
self.assertIn("**失败任务 2", markdown)
self.assertNotIn("**失败任务 3", markdown)
for job_id in (202, 203, 204):
self.assertIn(f"https://github.invalid/jobs/{job_id}", markdown)
self.assertNotIn("https://github.invalid/jobs/205", markdown)
more_failures = next(
element["content"]
for element in _walk_card(card)
if element.get("tag") == "markdown"
and element.get("content", "").startswith("**更多失败任务**")
)
self.assertEqual(
3,
sum(line.startswith("- [") for line in more_failures.splitlines()),
)
self.assertNotIn("另有", more_failures)
self.assertNotIn("建议操作", markdown)
self.assertNotIn("\n- \n", markdown)
self.assertNotIn("\n-\n", markdown)
def test_empty_owner_is_unassigned_and_text_is_truncated(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token",
transport=transport,
max_text_chars=220,
)
value = notification()
value["incidents"][0]["triage"]["owner"] = ""
value["incidents"][0]["triage"]["summary"] = "x" * 1000
notifier.send_failure(value)
text = transport.calls[0][1].decode()
self.assertIn("未配置", text)
self.assertIn("已截断", text)
self.assertNotIn("truncated", text)
self.assertLess(len(text), 2_000)
def test_redacts_untrusted_fields_and_link_query(self) -> None:
transport = RecordingTransport()
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token", transport=transport
)
value = notification(job_url="https://github.invalid/jobs/201?token=leak")
value["incidents"][0]["triage"]["summary"] = "Authorization: Bearer should-not-leak"
notifier.send_failure(value)
text = transport.calls[0][1].decode()
self.assertNotIn("should-not-leak", text)
self.assertNotIn("token=leak", text)
def test_http_failure_does_not_include_webhook_secret(self) -> None:
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token",
transport=RecordingTransport(status=500, response=b"failure"),
)
with self.assertRaises(FeishuError) as caught:
notifier.send_failure(notification())
self.assertNotIn("secret.invalid", str(caught.exception))
def test_feishu_api_error_is_reported(self) -> None:
notifier = FeishuWebhookNotifier(
"https://secret.invalid/hook/token",
transport=RecordingTransport(
response=b'{"code":19001,"msg":"bad request"}'
),
)
with self.assertRaises(FeishuError):
notifier.send_failure(notification())
def _walk_card(value: object):
if isinstance(value, dict):
yield value
for child in value.values():
yield from _walk_card(child)
elif isinstance(value, list):
for child in value:
yield from _walk_card(child)
if __name__ == "__main__":
unittest.main()