Compared against the HAR: /agent-foreign/sys/userInfo does not exist and yeeke answered HTTP 500, so every sync after the first successful login failed at the session check. Use /agent-foreign/shopee/user/info, which the web client calls after login. Send the token in the X-Access-Token header like the web client (the list endpoint only accepts the header) instead of a ?token= URL parameter, which also keeps it out of URL logs. Post the list filters as the web client does (column/order, string flags). Also treat "登录...失效" as an expired session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
179 lines
6.1 KiB
Go
179 lines
6.1 KiB
Go
package yeekeclient
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"go-admin/app/goauto/models"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&models.YeekeSession{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
type stubOCR struct {
|
|
codes []string
|
|
calls int
|
|
}
|
|
|
|
func (s *stubOCR) Recognize(context.Context, []byte) (string, error) {
|
|
if s.calls >= len(s.codes) {
|
|
return "", nil
|
|
}
|
|
c := s.codes[s.calls]
|
|
s.calls++
|
|
return c, nil
|
|
}
|
|
|
|
// server builds a fake yeeke backend. loginOK controls whether /login accepts
|
|
// the submitted captcha; sessionValid controls whether /userInfo (used by
|
|
// CheckSession) reports the cached token as still good.
|
|
func fakeServer(t *testing.T, loginOK func(captcha string) bool, sessionValid func(token string) bool) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/agent-foreign/sys/randomImage":
|
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
|
|
case "/agent-foreign/sys/login":
|
|
var body map[string]any
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
captcha, _ := body["captcha"].(string)
|
|
if loginOK(captcha) {
|
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "tok-" + captcha, "userInfo": map[string]any{"id": "u1", "username": "u"}}})
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 1, "message": "验证码错误"})
|
|
case "/agent-foreign/shopee/user/info":
|
|
token := r.Header.Get("X-Access-Token")
|
|
if sessionValid(token) {
|
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{}})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 401, "message": "登录已失效"})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
}
|
|
|
|
// TestConnectLoginsOnceThenReusesSession: a fresh Connect performs exactly one
|
|
// login, and a second Connect call with the cached session valid performs no
|
|
// login at all (token reuse, no re-login when session valid).
|
|
func TestConnectLoginsOnceThenReusesSession(t *testing.T) {
|
|
db := newDB(t)
|
|
loginCalls := 0
|
|
srv := fakeServer(t,
|
|
func(captcha string) bool { loginCalls++; return captcha == "abcd" },
|
|
func(token string) bool { return token == "tok-abcd" },
|
|
)
|
|
defer srv.Close()
|
|
|
|
store := NewSessionStore(db)
|
|
ocr := &stubOCR{codes: []string{"abcd"}}
|
|
client, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr, 3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if client.Token() != "tok-abcd" {
|
|
t.Fatalf("token=%q", client.Token())
|
|
}
|
|
if loginCalls != 1 {
|
|
t.Fatalf("loginCalls=%d, want 1", loginCalls)
|
|
}
|
|
|
|
// Second connect: session is cached and still valid, so this must not
|
|
// touch OCR or /login again.
|
|
ocr2 := &stubOCR{codes: []string{"should-not-be-used"}}
|
|
client2, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr2, 3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if client2.Token() != "tok-abcd" {
|
|
t.Fatalf("reused token=%q", client2.Token())
|
|
}
|
|
if loginCalls != 1 {
|
|
t.Fatalf("loginCalls after reuse=%d, want still 1", loginCalls)
|
|
}
|
|
if ocr2.calls != 0 {
|
|
t.Fatalf("OCR must not be called when the cached session is valid")
|
|
}
|
|
}
|
|
|
|
// TestConnectReLoginsAfterSessionExpiredAndIsBounded: when the cached session
|
|
// is explicitly rejected (ErrSessionInvalid), Connect re-logs in — but only
|
|
// up to maxLogin captcha attempts, never looping forever.
|
|
func TestConnectReLoginsAfterSessionExpiredAndIsBounded(t *testing.T) {
|
|
db := newDB(t)
|
|
store := NewSessionStore(db)
|
|
// Seed an already-cached, not-yet-expired session so Connect's Load finds
|
|
// it and only CheckSession decides it is dead.
|
|
if err := store.Save(context.Background(), Session{
|
|
Username: "u", Token: "stale", CookiesJSON: `[]`, UserID: "u1",
|
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
loginAttempts := 0
|
|
srv := fakeServer(t,
|
|
func(captcha string) bool { loginAttempts++; return false }, // every captcha rejected
|
|
func(token string) bool { return false }, // cached session always invalid
|
|
)
|
|
defer srv.Close()
|
|
|
|
ocr := &stubOCR{codes: []string{"1", "2", "3", "4", "5", "6"}} // more codes than maxLogin allows
|
|
_, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr, 3)
|
|
if err == nil {
|
|
t.Fatal("expected login failure")
|
|
}
|
|
if loginAttempts != 3 {
|
|
t.Fatalf("loginAttempts=%d, want exactly maxLogin=3 (bounded, not endless)", loginAttempts)
|
|
}
|
|
|
|
// The rejected cached session must have been deleted, not left in place.
|
|
if _, loadErr := store.Load(context.Background(), "u", time.Now().UTC()); loadErr != ErrNoSession {
|
|
t.Fatalf("expired/invalid session should have been deleted: %v", loadErr)
|
|
}
|
|
}
|
|
|
|
// TestConnectKeepsCachedSessionOnTimeoutOrServerError: a network-level error
|
|
// checking the session (not an explicit "invalid") must not discard a
|
|
// possibly-still-good cached session.
|
|
func TestConnectKeepsCachedSessionOnTimeoutOrServerError(t *testing.T) {
|
|
db := newDB(t)
|
|
store := NewSessionStore(db)
|
|
if err := store.Save(context.Background(), Session{
|
|
Username: "u", Token: "tok", CookiesJSON: `[]`, UserID: "u1",
|
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
_, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, &stubOCR{}, 3)
|
|
if err == nil {
|
|
t.Fatal("expected a propagated 5xx error")
|
|
}
|
|
if _, loadErr := store.Load(context.Background(), "u", time.Now().UTC()); loadErr != nil {
|
|
t.Fatalf("a 5xx must not discard the cached session: %v", loadErr)
|
|
}
|
|
}
|