package backthink import ( "bytes" "context" "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // mockExporter 记录已导出的消息,并可按需阻塞或失败, // 使推送器钩子的非阻塞 / 容错行为可测。 type mockExporter struct { mu sync.Mutex msgs []adapter.UnifiedMessage delivered bool err error started chan struct{} startOnce sync.Once release chan struct{} } func (m *mockExporter) Export(ctx context.Context, msg adapter.UnifiedMessage) (bool, error) { m.startOnce.Do(func() { if m.started != nil { close(m.started) } }) if m.release != nil { <-m.release } m.mu.Lock() m.msgs = append(m.msgs, msg) m.mu.Unlock() return m.delivered, m.err } func (m *mockExporter) messages() []adapter.UnifiedMessage { m.mu.Lock() defer m.mu.Unlock() return append([]adapter.UnifiedMessage(nil), m.msgs...) } // TestPushOutletHTTPPrimaryDelivers 证明自托管服务是 // 主级:负载带着 bearer token 被 POST,2xx 响应 // 被上报为已投递。 func TestPushOutletHTTPPrimaryDelivers(t *testing.T) { t.Parallel() type payload struct { Source string `json:"source"` Title string `json:"title"` Content string `json:"content"` Platform string `json:"platform"` Timestamp int64 `json:"timestamp"` } var ( got payload auth string ct string ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth = r.Header.Get("Authorization") ct = r.Header.Get("Content-Type") if err := json.NewDecoder(r.Body).Decode(&got); err != nil { t.Errorf("decode payload: %v", err) } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() exp := NewPushOutlet(PushOutletConfig{ Enabled: true, URL: srv.URL, Token: "s3cret-token", Timeout: 2 * time.Second, Log: discardLog(), }) if exp == nil { t.Fatal("NewPushOutlet returned nil for an enabled outlet") } delivered, err := exp.Export(context.Background(), adapter.UnifiedMessage{ Platform: "cyrene-backthink", Content: "晚安,早点睡。", Sender: adapter.Actor{DisplayName: "Cyrene"}, Timestamp: time.UnixMilli(1700000000000), }) if err != nil || !delivered { t.Fatalf("Export = (%v, %v), want (true, nil)", delivered, err) } if got.Source != "cyrene/backthink" || got.Title != "Cyrene" || got.Content != "晚安,早点睡。" || got.Platform != "cyrene-backthink" { t.Fatalf("payload = %+v, want source/title/content/platform set", got) } if got.Timestamp != 1700000000000 { t.Fatalf("timestamp = %d, want 1700000000000", got.Timestamp) } if auth != "Bearer s3cret-token" { t.Fatalf("Authorization = %q, want the bearer token", auth) } if ct != "application/json" { t.Fatalf("Content-Type = %q, want application/json", ct) } } // TestPushOutletUnreachableLogsGetuiFallback 断言主级不可达是一次 // 记账的未命中(不是错误),会点出待接入的个推降级,且绝不记录 // token。 func TestPushOutletUnreachableLogsGetuiFallback(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) url := srv.URL srv.Close() // 此时已不可达 var buf bytes.Buffer log := slog.New(slog.NewTextHandler(&buf, nil)) exp := NewPushOutlet(PushOutletConfig{Enabled: true, URL: url, Token: "s3cret-token", Timeout: 300 * time.Millisecond, Log: log}) delivered, err := exp.Export(context.Background(), adapter.UnifiedMessage{Content: "hi"}) if err != nil { t.Fatalf("Export err = %v, want nil (logged miss)", err) } if delivered { t.Fatal("Export delivered = true, want false for an unreachable primary") } if !strings.Contains(buf.String(), "getui") { t.Fatalf("log = %q, want the pending Getui fallback note", buf.String()) } if strings.Contains(buf.String(), "s3cret-token") { t.Fatalf("log leaked the token: %q", buf.String()) } } // TestPushOutletHTTPErrorReportsMiss 断言主级非 2xx 响应算未命中。 func TestPushOutletHTTPErrorReportsMiss(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "nope", http.StatusInternalServerError) })) defer srv.Close() exp := NewPushOutlet(PushOutletConfig{Enabled: true, URL: srv.URL, Timeout: 2 * time.Second, Log: discardLog()}) delivered, err := exp.Export(context.Background(), adapter.UnifiedMessage{Content: "hi"}) if err != nil { t.Fatalf("Export err = %v, want nil (logged miss)", err) } if delivered { t.Fatal("Export delivered = true, want false for a 500 response") } } // TestNewPushOutletDisabledOrUnconfigured 覆盖两种降级形态:钩子 // 关闭(nil)与启用但无主级 URL(未命中 + 个推备注)。 func TestNewPushOutletDisabledOrUnconfigured(t *testing.T) { t.Parallel() if exp := NewPushOutlet(PushOutletConfig{Enabled: false, URL: "http://example.invalid"}); exp != nil { t.Fatalf("NewPushOutlet disabled = %v, want nil", exp) } var buf bytes.Buffer log := slog.New(slog.NewTextHandler(&buf, nil)) exp := NewPushOutlet(PushOutletConfig{Enabled: true, Log: log}) if exp == nil { t.Fatal("NewPushOutlet enabled without a URL = nil, want a fallback-only outlet") } delivered, err := exp.Export(context.Background(), adapter.UnifiedMessage{Content: "hi"}) if err != nil || delivered { t.Fatalf("Export = (%v, %v), want (false, nil)", delivered, err) } if !strings.Contains(buf.String(), "getui") { t.Fatalf("log = %q, want the Getui fallback note", buf.String()) } } // TestPusherExportsProactiveToOutlet 断言钩子在 IM 投递之后 // 把已投递的主动消息镜像到出口。 func TestPusherExportsProactiveToOutlet(t *testing.T) { t.Parallel() exp := &mockExporter{delivered: true} im := &mockIMAdapter{} p := newPusher(nil, im, &mockGate{allow: true}, discardLog()).withExporter(exp) p.exportDone = make(chan struct{}, 1) res := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{Content: "在吗"}}) if !res.Pushed || res.Route != RouteIMBridge { t.Fatalf("push = %+v, want pushed via IM bridge", res) } if im.count() != 1 { t.Fatalf("im calls = %d, want 1", im.count()) } select { case <-p.exportDone: case <-time.After(2 * time.Second): t.Fatal("export hook did not run") } msgs := exp.messages() if len(msgs) != 1 || msgs[0].Content != "在吗" { t.Fatalf("exported = %+v, want the proactive content", msgs) } } // TestPusherExportHookNonBlockingAndFailureTolerant 断言挂死/失败的出口 // 绝不延迟推送,也绝不把已投递的主动消息变成 // 失败。 func TestPusherExportHookNonBlockingAndFailureTolerant(t *testing.T) { t.Parallel() started := make(chan struct{}) release := make(chan struct{}) exp := &mockExporter{delivered: false, err: errors.New("outlet down"), started: started, release: release} im := &mockIMAdapter{} p := newPusher(nil, im, &mockGate{allow: true}, discardLog()).withExporter(exp) p.exportDone = make(chan struct{}, 1) begin := time.Now() res := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{Content: "hi"}}) if elapsed := time.Since(begin); elapsed > 100*time.Millisecond { t.Fatalf("Push blocked on the outlet for %v, want non-blocking", elapsed) } if !res.Pushed || res.Route != RouteIMBridge { t.Fatalf("push = %+v, want pushed via IM bridge despite the outlet failure", res) } select { case <-started: case <-time.After(2 * time.Second): t.Fatal("exporter was never invoked") } close(release) select { case <-p.exportDone: case <-time.After(2 * time.Second): t.Fatal("export hook did not finish") } } // TestPusherRateLimitedPushIsNotExported 断言被限流的推送绝不 // 到达出口:该钩子只镜像真实的主动发送。 func TestPusherRateLimitedPushIsNotExported(t *testing.T) { t.Parallel() exp := &mockExporter{delivered: true} p := newPusher(nil, &mockIMAdapter{}, &mockGate{allow: false}, discardLog()).withExporter(exp) p.exportDone = make(chan struct{}, 1) res := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{Content: "hi"}}) if res.Route != RouteRateLimited { t.Fatalf("route = %q, want rate_limited", res.Route) } select { case <-p.exportDone: t.Fatal("rate-limited push should not be exported") case <-time.After(50 * time.Millisecond): } if len(exp.messages()) != 0 { t.Fatalf("exported = %+v, want none", exp.messages()) } }