feat(channel): 微信 iLink 账号状态三态(诚实口径)

state ∈ 有效 / **被拒(需重扫)** / 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不
写成「过期」**(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记): 三态(每账号逐条三态)本片未做。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:44 +08:00
1 parent f75e7a9c9b
commit 7505c46b70
8 files changed
+808 -17

No files matched your search

@@ -0,0 +1,223 @@
package weixin_ilink
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// 这些测试覆盖会话凭据三态(session_status.go):有效 / 被拒(errcode=-14) / 未知。
// 三态守的是**诚实口径**——被拒不是过期,未知既不是有效也不是过期。
// assertNoExpiryWording 钉住三态文案里绝不出现的措辞。`errcode=-14` 是 iLink
// **拒绝/作废**了会话凭据,不是基于时间的过期(bot token 是长期凭据);「未知」同理。
// 因此任何一条 detail 都不许出现「过期」(或英文 expired)——它会把主人引向
// 「等一会儿就好了」这个错误结论。
func assertNoExpiryWording(t *testing.T, label, detail string) {
t.Helper()
for _, banned := range []string{"过期", "expired"} {
if strings.Contains(detail, banned) {
t.Fatalf("%s detail 含 %q,但 -14 是「被拒」不是「过期」、未知也不许说成过期:%q",
label, banned, detail)
}
}
}
// TestSessionStateOf_ThreeStates 逐条钉住纯函数的判定与文案(三态各一条 + 优先级)。
func TestSessionStateOf_ThreeStates(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sig SessionSignals
want string
substr []string
}{
{
name: "有效:iLink 接受过这份凭据",
sig: SessionSignals{HasToken: true, Verified: true},
want: SessionStateActive,
substr: []string{"已接受"},
},
{
name: "被拒:iLink 以 errcode=-14 拒绝过(需重扫)",
sig: SessionSignals{HasToken: true, Verified: true, Rejected: true},
want: SessionStateRejected,
substr: []string{"拒绝", "重新扫码"},
},
{
name: "未知(一):还没有凭据",
sig: SessionSignals{},
want: SessionStateUnknown,
substr: []string{"未知", "尚未绑定"},
},
{
name: "未知(二):有凭据但从未被 iLink 接受过",
sig: SessionSignals{HasToken: true},
want: SessionStateUnknown,
substr: []string{"未知", "尚未确认"},
},
{
name: "被拒优先于历史证据:先前有效过,事后被 iLink 拒绝 ⇒ 仍是被拒",
sig: SessionSignals{HasToken: true, Verified: true, Rejected: true},
want: SessionStateRejected,
substr: []string{"拒绝"},
},
{
name: "被拒优先于「没有凭据」:拒绝状态不因 token 被清而伪装成未知",
sig: SessionSignals{Rejected: true},
want: SessionStateRejected,
substr: []string{"拒绝"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
state, detail := SessionStateOf(tc.sig)
if state != tc.want {
t.Fatalf("SessionStateOf(%+v) state = %q, want %q", tc.sig, state, tc.want)
}
if detail == "" {
t.Fatalf("SessionStateOf(%+v) detail 为空,面板将无从如实说明", tc.sig)
}
for _, want := range tc.substr {
if !strings.Contains(detail, want) {
t.Fatalf("SessionStateOf(%+v) detail = %q, want 含 %q", tc.sig, detail, want)
}
}
assertNoExpiryWording(t, tc.name, detail)
})
}
}
// TestSessionStatus_UnknownUntilIlinkAccepts 钉住「有凭据 ≠ 有效」:token 已配置、
// 适配器也 Connect 成功,但还没有任何一次被 iLink 接受的调用 ⇒ 状态是**未知**,
// 绝不是有效。
func TestSessionStatus_UnknownUntilIlinkAccepts(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
return handlerResult{body: jsonBody(map[string]any{"msgs": []any{}, "get_updates_buf": "c1"})}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
state, detail := ad.SessionStatus()
if state != SessionStateUnknown {
t.Fatalf("SessionStatus() = %q, want unknown before iLink ever accepted the token", state)
}
if detail == "" {
t.Fatal("SessionStatus() detail 为空,want 一条如实的「尚未确认」说明")
}
assertNoExpiryWording(t, "未知", detail)
// 一次成功的长轮询就是证据:三态随之变成有效。
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(ctx, func(adapter.UnifiedMessage) {})
}()
waitFor(t, func() bool { st, _ := ad.SessionStatus(); return st == SessionStateActive })
cancel()
waitDone(t, done)
state, detail = ad.SessionStatus()
if state != SessionStateActive {
t.Fatalf("SessionStatus() = %q, want active after a successful getupdates", state)
}
if detail == "" {
t.Fatal("SessionStatus() detail 为空,want 一条写明依据的说明")
}
assertNoExpiryWording(t, "有效", detail)
}
// TestSessionStatus_RejectedSaysRejectedNotExpired 钉住铁律:errcode=-14 上报的是
// **被拒 + 需要重新扫码**,文案里绝不出现「过期」。
func TestSessionStatus_RejectedSaysRejectedNotExpired(t *testing.T) {
t.Parallel()
srv := rejectingServer(t)
ad := newRejectedByPollAdapter(t, srv)
state, detail := ad.SessionStatus()
if state != SessionStateRejected {
t.Fatalf("SessionStatus() = %q, want rejected after errcode=-14", state)
}
if !strings.Contains(detail, "拒绝") {
t.Fatalf("detail = %q, want 明说「被拒」", detail)
}
if !strings.Contains(detail, "重新扫码") {
t.Fatalf("detail = %q, want 给出出路(重新扫码)", detail)
}
assertNoExpiryWording(t, "被拒", detail)
}
// TestSessionStatus_NewTokenClearsEvidence 钉住证据不被继承:换了新 token 之后,
// 旧凭据被证实有效这件事**不算**新凭据的战功 —— 新凭据在它自己被证明之前是未知。
func TestSessionStatus_NewTokenClearsEvidence(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
return handlerResult{body: jsonBody(map[string]any{"msgs": []any{}, "get_updates_buf": "c1"})}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok-1"}, srv)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(ctx, func(adapter.UnifiedMessage) {})
}()
waitFor(t, func() bool { st, _ := ad.SessionStatus(); return st == SessionStateActive })
cancel()
waitDone(t, done)
// 面板保存 + 热重载换了一个新 token:旧证据必须作废。
setBotToken(ad, "tok-2")
if state, detail := ad.SessionStatus(); state != SessionStateUnknown {
t.Fatalf("SessionStatus() = %q after a token swap, want unknown(新凭据尚未被 iLink 接受); detail = %q", state, detail)
}
// 再换回那个被证实过的 token:证据同样不作废得而复生(它已被清除)。
setBotToken(ad, "tok-1")
if state, _ := ad.SessionStatus(); state != SessionStateUnknown {
t.Fatalf("SessionStatus() = %q after switching back, want unknown(证据只对「当时被证实的那一份」成立)", state)
}
}
// TestSessionStatus_RejectedOverridesEarlierEvidence 钉住「事后被拒」:一份先前被
// iLink 接受的凭据,一旦 iLink 回以 errcode=-14,三态立即是**被拒**,绝不会因为
// 「历史上有过成功」而继续报有效。
func TestSessionStatus_RejectedOverridesEarlierEvidence(t *testing.T) {
t.Parallel()
calls := 0
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
calls++
if calls == 1 {
return handlerResult{body: jsonBody(map[string]any{"msgs": []any{}, "get_updates_buf": "c1"})}
}
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(context.Background(), func(adapter.UnifiedMessage) {})
}()
// 第一次轮询成功 ⇒ 有效;第二次回 -14 ⇒ 被拒并结束读循环。
waitFor(t, func() bool { st, _ := ad.SessionStatus(); return st == SessionStateRejected })
waitDone(t, done)
state, detail := ad.SessionStatus()
if state != SessionStateRejected {
t.Fatalf("SessionStatus() = %q, want rejected(事后被拒优先于历史证据)", state)
}
assertNoExpiryWording(t, "事后被拒", detail)
}