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) }