package service import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "oci-portal/internal/model" ) // telegramCapture 记录假 Telegram 服务收到的 sendMessage 请求。 type telegramCapture struct { mu sync.Mutex paths []string texts []string } // snapshot 返回目前收到的全部文本副本。 func (r *telegramCapture) snapshot() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.texts...) } // newFakeTelegram 起一个假 Telegram API,固定返回 reply 响应体。 func newFakeTelegram(t *testing.T, reply string) (*httptest.Server, *telegramCapture) { t.Helper() rec := &telegramCapture{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var payload struct { Text string `json:"text"` } _ = json.NewDecoder(r.Body).Decode(&payload) rec.mu.Lock() rec.paths = append(rec.paths, r.URL.Path) rec.texts = append(rec.texts, payload.Text) rec.mu.Unlock() w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(reply)) })) t.Cleanup(srv.Close) return srv, rec } // newTestNotifier 组装指向假 Telegram 服务的 Notifier;in 为 nil 表示不预置配置。 func newTestNotifier(t *testing.T, base string, in *UpdateTelegramInput) *Notifier { t.Helper() settings, _ := newSettingEnv(t) if in != nil { if err := settings.UpdateTelegram(context.Background(), *in); err != nil { t.Fatalf("update telegram: %v", err) } } n := NewNotifier(settings) n.base = base return n } func TestNotifierSend(t *testing.T) { token := "123456:AAfake" tests := []struct { name string setup *UpdateTelegramInput reply string wantSent bool wantErr string // 空串表示期望无错误 }{ { name: "启用且配置完整时发送", setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}, reply: `{"ok":true}`, wantSent: true, }, { name: "未启用时跳过", setup: &UpdateTelegramInput{Enabled: false, BotToken: &token, ChatID: "42"}, reply: `{"ok":true}`, }, { name: "未配置 token 时跳过", setup: &UpdateTelegramInput{Enabled: true, ChatID: "42"}, reply: `{"ok":true}`, }, { name: "完全未配置时跳过", reply: `{"ok":true}`, }, { name: "telegram 报错时透出 description", setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}, reply: `{"ok":false,"error_code":401,"description":"Unauthorized"}`, wantSent: true, wantErr: "Unauthorized", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv, rec := newFakeTelegram(t, tt.reply) n := newTestNotifier(t, srv.URL, tt.setup) err := n.Send(context.Background(), "hi &") if tt.wantErr == "" && err != nil { t.Fatalf("Send: %v, want nil", err) } if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) { t.Fatalf("Send err = %v, want contains %q", err, tt.wantErr) } texts := rec.snapshot() if sent := len(texts) > 0; sent != tt.wantSent { t.Fatalf("sent = %v (texts %v), want %v", sent, texts, tt.wantSent) } if tt.wantSent { if got, want := rec.paths[0], "/bot"+token+"/sendMessage"; got != want { t.Errorf("path = %q, want %q", got, want) } if got, want := texts[0], "hi <b>&"; got != want { t.Errorf("text = %q, want HTML 转义后 %q", got, want) } } }) } } func TestNotifierTest(t *testing.T) { token := "123456:AAfake" tests := []struct { name string setup *UpdateTelegramInput reply string wantErr string }{ { name: "配置有效时发送成功", setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}, reply: `{"ok":true}`, }, { name: "未配置时报可读错误", wantErr: "bot token and chat id are required", }, { name: "chat 不存在时透出描述", setup: &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}, reply: `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`, wantErr: "chat not found", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv, _ := newFakeTelegram(t, tt.reply) n := newTestNotifier(t, srv.URL, tt.setup) err := n.Test(context.Background()) if tt.wantErr == "" && err != nil { t.Fatalf("Test: %v, want nil", err) } if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) { t.Fatalf("Test err = %v, want contains %q", err, tt.wantErr) } }) } } // TestNotifierSendErrorHidesToken 验证网络层失败的错误信息不含 bot token: // *url.Error 会拼出完整请求 URL(路径含 token),必须剥壳后再向上返回。 func TestNotifierSendErrorHidesToken(t *testing.T) { token := "123456:AAfake" srv, _ := newFakeTelegram(t, `{"ok":true}`) base := srv.URL srv.Close() // 立刻关闭:请求必然连接失败 n := newTestNotifier(t, base, &UpdateTelegramInput{Enabled: true, BotToken: &token, ChatID: "42"}) err := n.Send(context.Background(), "hi") if err == nil { t.Fatal("Send = nil, want connection error") } if strings.Contains(err.Error(), token) { t.Errorf("err = %v, 错误信息泄漏了 bot token", err) } } func TestNotifyEvents(t *testing.T) { tests := []struct { name string prev taskSnapshot cur taskSnapshot want []notifyEvent // Kind 精确匹配,Vars 为需包含的变量键值;空表示不发 }{ { name: "成功转失败发失败通知", prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive}, cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "Out of host capacity"}, want: []notifyEvent{{Kind: notifyTaskFail, Vars: map[string]string{"task_name": "抢机", "error": "Out of host capacity"}}}, }, { name: "连续失败不重复发", prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "Out of host capacity"}, cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "Out of host capacity"}, want: nil, }, { name: "失败换了错误内容也不重复发", prev: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "capacity"}, cur: taskSnapshot{Name: "抢机", Status: model.TaskStatusActive, LastError: "timeout"}, want: nil, }, { name: "失败恢复成功发恢复通知", prev: taskSnapshot{Name: "测活", Status: model.TaskStatusActive, LastError: "boom"}, cur: taskSnapshot{Name: "测活", Status: model.TaskStatusActive}, want: []notifyEvent{{Kind: notifyTaskRecover, Vars: map[string]string{"task_name": "测活"}}}, }, { name: "持续正常不发", prev: taskSnapshot{Name: "测活", Status: model.TaskStatusActive}, cur: taskSnapshot{Name: "测活", Status: model.TaskStatusActive}, want: nil, }, { name: "非成功转成功发抢机成功", prev: taskSnapshot{Name: "抢A1", Status: model.TaskStatusActive}, cur: taskSnapshot{Name: "抢A1", Status: model.TaskStatusSucceeded, Message: "created 1: ocid1"}, want: []notifyEvent{{Kind: notifySnatchSuccess, Vars: map[string]string{"task_name": "抢A1", "message": "created 1: ocid1"}}}, }, { name: "失败后直接抢满只发抢机成功不叠加恢复", prev: taskSnapshot{Name: "抢A1", Status: model.TaskStatusActive, LastError: "capacity"}, cur: taskSnapshot{Name: "抢A1", Status: model.TaskStatusSucceeded, Message: "created 1: ocid1"}, want: []notifyEvent{{Kind: notifySnatchSuccess, Vars: map[string]string{"task_name": "抢A1", "message": "created 1: ocid1"}}}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := notifyEvents(tt.prev, tt.cur) if len(got) != len(tt.want) { t.Fatalf("events = %v (%d 条), want %d 条", got, len(got), len(tt.want)) } for i, w := range tt.want { if got[i].Kind != w.Kind { t.Errorf("event[%d].Kind = %q, want %q", i, got[i].Kind, w.Kind) } for k, v := range w.Vars { if got[i].Vars[k] != v { t.Errorf("event[%d].Vars[%s] = %q, want %q", i, k, got[i].Vars[k], v) } } } }) } } func TestSameStringSet(t *testing.T) { tests := []struct { name string a, b []string want bool }{ {name: "都为空", a: nil, b: nil, want: true}, {name: "顺序不同视为相同", a: []string{"x", "y"}, b: []string{"y", "x"}, want: true}, {name: "长度不同", a: []string{"x"}, b: []string{"x", "y"}, want: false}, {name: "元素不同", a: []string{"x"}, b: []string{"y"}, want: false}, {name: "重复元素次数不同", a: []string{"x", "y"}, b: []string{"x", "x"}, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := sameStringSet(tt.a, tt.b); got != tt.want { t.Errorf("sameStringSet(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want) } }) } } func TestTruncateText(t *testing.T) { tests := []struct { name string text string limit int want string }{ {name: "未超限原样返回", text: "hello", limit: 10, want: "hello"}, {name: "超限截断加省略号", text: "hello", limit: 3, want: "hel…"}, {name: "多字节按 rune 截断", text: "抢机成功", limit: 2, want: "抢机…"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := truncateText(tt.text, tt.limit); got != tt.want { t.Errorf("truncateText(%q, %d) = %q, want %q", tt.text, tt.limit, got, tt.want) } }) } }