Files
Cyrene_v2/internal/services/backthink/push_outlet_test.go
T
AskaEth 3dc1232bc2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
2026-10-04 16:27:54 +08:00

262 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}
}