Files
AskaEth 7505c46b70 feat(channel): 微信 iLink 账号状态三态(诚实口径)
state ∈ 有效 / **被拒(需重扫)** / 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不
写成「过期」**(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记): 三态(每账号逐条三态)本片未做。
2026-10-06 09:03:44 +08:00

224 lines
8.4 KiB
Go
Raw Permalink 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"
"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)
}