package workbench import ( "encoding/json" "testing" ) // TestInboxPlanMapsEvents 覆盖 inboxPlan 纯函数:每个支持的事件类型都要产出 // 预期类别 / 收件人类别 / 文案关键词,未知事件返回 nil。 func TestInboxPlanMapsEvents(t *testing.T) { payload := func(values map[string]any) json.RawMessage { encoded, err := json.Marshal(values) if err != nil { t.Fatal(err) } return encoded } cases := []struct { name string eventType string values map[string]any wantKind string // recipient_kind wantCategory string wantTitle string wantUserID string wantAll bool wantRequest bool wantPref bool }{ {name: "model_access.requested 通知全部管理员审批", eventType: "model_access.requested", values: map[string]any{"model": "gpt-5"}, wantKind: "admin", wantCategory: "approval", wantTitle: "新的模型访问申请", wantAll: true}, {name: "model_access.decided 已批准回执给申请用户", eventType: "model_access.decided", values: map[string]any{"status": "approved"}, wantKind: "portal", wantCategory: "approval", wantTitle: "模型申请已处理", wantRequest: true}, {name: "model_access.decided 已驳回文案", eventType: "model_access.decided", values: map[string]any{"status": "rejected"}, wantKind: "portal", wantCategory: "approval", wantTitle: "模型申请已处理", wantRequest: true}, {name: "marketplace.installed 发给安装用户", eventType: "marketplace.installed", values: map[string]any{"code": "report-bot", "portal_user_id": "11111111-1111-1111-1111-111111111111"}, wantKind: "portal", wantCategory: "resource", wantTitle: "资源已安装", wantUserID: "11111111-1111-1111-1111-111111111111"}, {name: "knowledge_document.ready 发给执行管理员", eventType: "knowledge_document.ready", values: map[string]any{"chunk_count": "12", "actor_id": "22222222-2222-2222-2222-222222222222"}, wantKind: "admin", wantCategory: "system", wantTitle: "知识文档已入库", wantUserID: "22222222-2222-2222-2222-222222222222"}, {name: "knowledge_document.reprocessed 发给执行管理员", eventType: "knowledge_document.reprocessed", values: map[string]any{"actor_id": "22222222-2222-2222-2222-222222222222"}, wantKind: "admin", wantCategory: "system", wantTitle: "知识文档已重新处理", wantUserID: "22222222-2222-2222-2222-222222222222"}, {name: "knowledge_document.embedding_failed 降级提示", eventType: "knowledge_document.embedding_failed", values: map[string]any{"actor_id": "22222222-2222-2222-2222-222222222222"}, wantKind: "admin", wantCategory: "system", wantTitle: "知识文档向量化失败", wantUserID: "22222222-2222-2222-2222-222222222222"}, {name: "scheduled_task.completed 发给创建者", eventType: "scheduled_task.completed", values: map[string]any{"task_code": "daily-report", "actor_id": "33333333-3333-3333-3333-333333333333"}, wantKind: "admin", wantCategory: "task_result", wantTitle: "定时任务已执行", wantUserID: "33333333-3333-3333-3333-333333333333"}, {name: "scheduled_task.failed 发给创建者", eventType: "scheduled_task.failed", values: map[string]any{"task_code": "daily-report", "error": "timeout", "actor_id": "33333333-3333-3333-3333-333333333333"}, wantKind: "admin", wantCategory: "task_result", wantTitle: "定时任务执行失败", wantUserID: "33333333-3333-3333-3333-333333333333"}, {name: "security.login_detected 发登录提醒且受偏好约束", eventType: "security.login_detected", values: map[string]any{"portal_user_id": "44444444-4444-4444-4444-444444444444", "ip": "203.0.113.7"}, wantKind: "portal", wantCategory: "security", wantTitle: "新设备登录提醒", wantUserID: "44444444-4444-4444-4444-444444444444", wantPref: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { drafts := inboxPlan(tc.eventType, payload(tc.values)) if len(drafts) != 1 { t.Fatalf("expected exactly one draft, got %d", len(drafts)) } draft := drafts[0] if draft.RecipientKind != tc.wantKind { t.Errorf("recipient_kind = %q, want %q", draft.RecipientKind, tc.wantKind) } if draft.Category != tc.wantCategory { t.Errorf("category = %q, want %q", draft.Category, tc.wantCategory) } if draft.Title != tc.wantTitle { t.Errorf("title = %q, want %q", draft.Title, tc.wantTitle) } if draft.UserID != tc.wantUserID { t.Errorf("user_id = %q, want %q", draft.UserID, tc.wantUserID) } if draft.AllAdmins != tc.wantAll { t.Errorf("all_admins = %v, want %v", draft.AllAdmins, tc.wantAll) } if draft.RequestUser != tc.wantRequest { t.Errorf("request_user = %v, want %v", draft.RequestUser, tc.wantRequest) } if draft.NotifyPref != tc.wantPref { t.Errorf("notify_pref = %v, want %v", draft.NotifyPref, tc.wantPref) } }) } if drafts := inboxPlan("some.unknown.event", payload(map[string]any{})); drafts != nil { t.Fatalf("unknown event should map to no drafts, got %+v", drafts) } } // TestInboxPlanModelAccessRejectedBody 校验驳回与批准的不同正文文案。 func TestInboxPlanModelAccessRejectedBody(t *testing.T) { values := func(status string) json.RawMessage { encoded, _ := json.Marshal(map[string]any{"status": status}) return encoded } approved := inboxPlan("model_access.decided", values("approved")) rejected := inboxPlan("model_access.decided", values("rejected")) if !contains(approved[0].Body, "已批准") { t.Errorf("approved body should mention 已批准, got %q", approved[0].Body) } if !contains(rejected[0].Body, "已驳回") { t.Errorf("rejected body should mention 已驳回, got %q", rejected[0].Body) } } func contains(haystack, needle string) bool { for i := 0; i+len(needle) <= len(haystack); i++ { if haystack[i:i+len(needle)] == needle { return true } } return false } // TestPayloadValue 校验 payloadValue 对字符串与数字两类取值的兼容(事件载荷 // 中数字可能被 JSON 解码为 float64)。 func TestPayloadValue(t *testing.T) { payload := json.RawMessage(`{"model":"gpt-5","chunk_count":12,"active":true}`) if got := payloadValue(payload, "model"); got != "gpt-5" { t.Errorf("string key = %q, want gpt-5", got) } if got := payloadValue(payload, "chunk_count"); got != "12" { t.Errorf("numeric key = %q, want 12", got) } if got := payloadValue(payload, "missing"); got != "" { t.Errorf("missing key = %q, want empty", got) } } func TestValidInboxLink(t *testing.T) { cases := map[string]bool{ "": true, "/portal/inbox": true, "https://example.com/notice": true, "http://example.com": true, "javascript:alert(1)": false, "data:text/html,x": false, "//example.com/path": false, "portal/inbox": false, } for link, want := range cases { if got := validInboxLink(link); got != want { t.Errorf("validInboxLink(%q) = %v, want %v", link, got, want) } } }