state ∈ 有效 / **被拒(需重扫)** / 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不 写成「过期」**(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。 ⚠️ 未做(如实登记): 三态(每账号逐条三态)本片未做。
224 lines
8.4 KiB
Go
224 lines
8.4 KiB
Go
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)
|
||
}
|