Files
Cyrene_v2/plugins/channels/weixin_ilink/delivery_failure_test.go
T
AskaEth 353293e807
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(im): 主动消息「没送达微信」现在说得清(原因码 + 三处可见)
主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`backthink IM push failed … weixin_ilink: proactive push outside 24h window`
—— 她的话**送进了 Web 活跃客户端,但没送进微信**,原因是**平台规则**(微信 iLink
只允许用户最后一条消息后 24h 窗口内主动推送),不是崩溃。

本片只做「如实说清」,不绕规则:
- **原因码**:新增契约层分类件 `internal/channel/adapter/delivery.go`
  (`DeliveryCategory` + 4 个码 + 可选自描述接口 `DeliveryFailure` / 读取函数),
  **沿用**既有 voice `Category(err)` 与 `SystemNoticeNormalizer` 的范式,**不造第二套枚举**;
  平台码由产生错误的层声明:`weixin_ilink` 的 `ErrProactiveWindow` 自报
  `proactive_window_closed`(哨兵文本与 `errors.Is` 判定**逐字未变**)。
- **三处可见**:① 结构化 WARN(补齐 category/channel/platform/route);
  ② 审计 `backthink.push.im_undelivered`(与 `backthink.self_wake.*` 同族);
  ③ 推送结果 `Detail` 追加稳定标签 `im_undelivered=<渠道>:<原因码>` —— 面板**已在
  渲染 Detail**(`Backthink.tsx:185`)⇒ 主人现在就能看到,**无需改前端**。
- **不丢消息**:IM 路失败照旧走活跃客户端路,且**只在 Web 真送达时** `pushed=true`;
  有测试钉住「IM 失败不影响另一条路」(同一条消息没被丢)。
- **不做的**:不重试刷屏、不换渠道冒充微信、不伪造送达、不新增 `.env` 键。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;4 个包 vet/测试全绿;
我用 -overlay 把 `ErrProactiveWindow` 退成裸 `errors.New` ⇒ weixin 包两条用例变红
(`category = "failed", want proactive_window_closed`),channelbridge/backthink 亦红。

⚠️ 未落地的建议(已登记,未硬改):给她的"推送回执"(`backthink_records` 加
`push_category`/`push_channel` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
2026-10-07 10:59:22 +08:00

81 lines
3.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 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)
}
})
}
}