package weixin_ilink import ( "context" "errors" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // 本文件锁定「微信这次发不出去」的**稳定原因码**:24 小时窗口关闭是**平台规则**, // 不是崩溃,因此它必须能以机器可读的码(proactive_window_closed)+ 渠道名交给上层, // 而不是只留一整串自由文本(真机日志 2026-10-07:「主动消息没能送到微信」在面板上 // 只表现为一句英文)。 // // 🔴 口径:错误文本与 errors.Is 判定**逐字不变**(自描述包装只加码,不改语义), // 因此本文件同时是「加码没改行为」的回归探针。 // TestSendProactive_ExpiredWindowCarriesCategory 走**真实适配器路径**(httptest 假 // 服务端 + 已确认连接 + 窗口外的时钟)验证原因码与渠道名,而不是只测哨兵值本身。 func TestSendProactive_ExpiredWindowCarriesCategory(t *testing.T) { t.Parallel() rec := &reqRecorder{} srv := sendOKServer(t, rec) ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv) now := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC) ad.clock = func() time.Time { return now } ad.recordInbound("tok-p", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: now}) now = now.Add(25 * time.Hour) err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{ SenderID: "u-1", Content: "too late", }) if !errors.Is(err, ErrProactiveWindow) { t.Fatalf("err = %v, want ErrProactiveWindow", err) } if got := adapter.DeliveryCategoryOf(err); got != "proactive_window_closed" { t.Fatalf("category = %q, want proactive_window_closed", got) } if got := adapter.DeliveryChannelOf(err); got != platformName { t.Fatalf("channel = %q, want %q", got, platformName) } // 文本与包装之前逐字一致:加码**没有**改任何既有语义。 if want := "weixin_ilink: proactive push outside 24h window"; err.Error() != want { t.Fatalf("err text = %q, want %q", err.Error(), want) } } // TestSentinelErrorsCarryCategories 是三个可操作失败的原因码表:窗口关闭 // (平台规则)、缺少会话凭证(无从关联会话)、会话被拒(必须重新扫码)。其余哨兵 // 不自报码 —— 它们落到契约层兜底码 failed,而不是被安一个更精确的假原因。 func TestSentinelErrorsCarryCategories(t *testing.T) { t.Parallel() tests := []struct { name string err error want adapter.DeliveryCategory wantChannel string }{ {name: "主动推送窗口关闭", err: ErrProactiveWindow, want: "proactive_window_closed", wantChannel: platformName}, {name: "缺少会话凭证", err: ErrNoContextToken, want: "no_context_token", wantChannel: platformName}, {name: "会话被拒(需重新扫码)", err: ErrSessionRejected, want: "session_rejected", wantChannel: platformName}, // 未自报的哨兵连渠道也不报:读取侧得到空串(= 未知),不替它猜平台名。 {name: "未自报的哨兵落兜底", err: ErrMediaTooLarge, want: adapter.CategoryFailed, wantChannel: ""}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := adapter.DeliveryCategoryOf(tc.err); got != tc.want { t.Fatalf("category = %q, want %q", got, tc.want) } if got := adapter.DeliveryChannelOf(tc.err); got != tc.wantChannel { t.Fatalf("channel = %q, want %q", got, tc.wantChannel) } }) } }