feat(test):添加核心组件的单元测试

This commit is contained in:
chengqian committed 2026-05-28 20:36:21 +08:00
1 parent 8c48ccbacc
commit f3c98f2315
4 files changed
+695

No files matched your search

+14
View File
@@ -84,3 +84,17 @@ func TestWalletInfo_WarningThresholdZero(t *testing.T) {
available := AvailableBalance(w)
assertFloat(t, available, 50.0, "available balance")
}
func TestAvailableBalance_NegativeFrozen(t *testing.T) {
w := &WalletInfo{Balance: 100.0, FrozenBalance: -10.0}
got := AvailableBalance(w)
assertFloat(t, got, 110.0, "negative frozen produces inflated balance")
}
func TestAvailableBalance_LargePrecision(t *testing.T) {
w := &WalletInfo{Balance: 0.0000000001, FrozenBalance: 0}
got := AvailableBalance(w)
if got <= 0 {
t.Fatalf("expected positive available balance for tiny balance, got %e", got)
}
}
+240
View File
@@ -0,0 +1,240 @@
package common
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func init() {
jwtSecret = []byte("test-secret-for-unit-tests-only!!")
}
// ─── HashRefreshToken ───────────────────────────────────────────────
func TestHashRefreshToken_Deterministic(t *testing.T) {
token := "abc123def456"
h1 := HashRefreshToken(token)
h2 := HashRefreshToken(token)
if h1 != h2 {
t.Fatalf("same input produced different hashes: %s vs %s", h1, h2)
}
}
func TestHashRefreshToken_DifferentInputs(t *testing.T) {
h1 := HashRefreshToken("token-a")
h2 := HashRefreshToken("token-b")
if h1 == h2 {
t.Fatal("different inputs produced the same hash")
}
}
func TestHashRefreshToken_Length(t *testing.T) {
h := HashRefreshToken("some-token")
if len(h) != 64 {
t.Fatalf("expected SHA-256 hex length 64, got %d", len(h))
}
}
func TestHashRefreshToken_Empty(t *testing.T) {
h := HashRefreshToken("")
if len(h) != 64 {
t.Fatalf("expected SHA-256 hex length 64 for empty input, got %d", len(h))
}
}
// ─── GenerateRefreshToken ───────────────────────────────────────────
func TestGenerateRefreshToken_Length(t *testing.T) {
token, err := GenerateRefreshToken()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(token) != 64 {
t.Fatalf("expected 64-char hex string, got %d chars", len(token))
}
}
func TestGenerateRefreshToken_Unique(t *testing.T) {
t1, _ := GenerateRefreshToken()
t2, _ := GenerateRefreshToken()
if t1 == t2 {
t.Fatal("two generated tokens should be different")
}
}
// ─── GenerateTokenPair + ParseAccessToken round-trip ────────────────
func TestTokenPairRoundTrip(t *testing.T) {
pair, err := generateTestTokenPair(42, "admin", "super_admin", 100, 1)
if err != nil {
t.Fatalf("generate: %v", err)
}
if pair.AccessToken == "" {
t.Fatal("access token is empty")
}
if pair.RefreshToken == "" {
t.Fatal("refresh token is empty")
}
if pair.ExpiresAt.Before(time.Now()) {
t.Fatal("expires_at is in the past")
}
claims, err := ParseAccessToken(pair.AccessToken)
if err != nil {
t.Fatalf("parse: %v", err)
}
if claims.UserID != 42 {
t.Fatalf("UserID = %d, want 42", claims.UserID)
}
if claims.UserType != "admin" {
t.Fatalf("UserType = %q, want admin", claims.UserType)
}
if claims.Role != "super_admin" {
t.Fatalf("Role = %q, want super_admin", claims.Role)
}
if claims.TenantID != 100 {
t.Fatalf("TenantID = %d, want 100", claims.TenantID)
}
if claims.SessionID != 1 {
t.Fatalf("SessionID = %d, want 1", claims.SessionID)
}
}
func TestTokenPairRoundTrip_TenantUser(t *testing.T) {
pair, err := generateTestTokenPair(99, "tenant", "member", 200, 5)
if err != nil {
t.Fatalf("generate: %v", err)
}
claims, err := ParseAccessToken(pair.AccessToken)
if err != nil {
t.Fatalf("parse: %v", err)
}
if claims.UserType != "tenant" {
t.Fatalf("UserType = %q, want tenant", claims.UserType)
}
if claims.TenantID != 200 {
t.Fatalf("TenantID = %d, want 200", claims.TenantID)
}
}
// ─── ParseAccessToken error cases ───────────────────────────────────
func TestParseAccessToken_InvalidString(t *testing.T) {
_, err := ParseAccessToken("not-a-jwt")
if err == nil {
t.Fatal("expected error for invalid token")
}
}
func TestParseAccessToken_Empty(t *testing.T) {
_, err := ParseAccessToken("")
if err == nil {
t.Fatal("expected error for empty token")
}
}
func TestParseAccessToken_Expired(t *testing.T) {
now := time.Now().Add(-10 * time.Minute)
claims := JWTClaims{
UserID: 1,
UserType: "admin",
Role: "admin",
SessionID: 1,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(-5 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString(GetJWTSecret())
_, err := ParseAccessToken(tokenStr)
if err == nil {
t.Fatal("expected error for expired token")
}
}
func TestParseAccessToken_WrongSecret(t *testing.T) {
claims := JWTClaims{
UserID: 1,
UserType: "admin",
Role: "admin",
SessionID: 1,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString([]byte("wrong-secret"))
_, err := ParseAccessToken(tokenStr)
if err == nil {
t.Fatal("expected error for token signed with wrong secret")
}
}
func TestParseAccessToken_RejectsNoneAlgorithm(t *testing.T) {
claims := JWTClaims{
UserID: 1,
UserType: "admin",
Role: "admin",
}
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
tokenStr, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
_, err := ParseAccessToken(tokenStr)
if err == nil {
t.Fatal("expected error for 'none' signing method")
}
}
// ─── RefreshToken hash consistency ──────────────────────────────────
func TestRefreshTokenHashIsNotReversible(t *testing.T) {
token, _ := GenerateRefreshToken()
hash := HashRefreshToken(token)
if strings.Contains(hash, token) {
t.Fatal("hash should not contain the original token")
}
}
// ─── helpers ────────────────────────────────────────────────────────
func generateTestTokenPair(userID int64, userType, role string, tenantID, sessionID int64) (*TokenPair, error) {
now := time.Now()
accessClaims := JWTClaims{
UserID: userID,
UserType: userType,
Role: role,
TenantID: tenantID,
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "team-api-test",
Subject: "42",
ExpiresAt: jwt.NewNumericDate(now.Add(30 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenStr, err := accessToken.SignedString(GetJWTSecret())
if err != nil {
return nil, err
}
refreshTokenStr, err := GenerateRefreshToken()
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessTokenStr,
RefreshToken: refreshTokenStr,
ExpiresAt: now.Add(30 * time.Minute),
}, nil
}
+286
View File
@@ -0,0 +1,286 @@
package common
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// ─── ProvisionalToken round-trip ────────────────────────────────────
func TestProvisionalToken_RoundTrip(t *testing.T) {
token, err := generateTestProvisionalToken(42, "admin", "super_admin", 0)
if err != nil {
t.Fatalf("generate: %v", err)
}
claims, err := ParseProvisionalToken(token)
if err != nil {
t.Fatalf("parse: %v", err)
}
if claims.UserID != 42 {
t.Fatalf("UserID = %d, want 42", claims.UserID)
}
if claims.UserType != "admin" {
t.Fatalf("UserType = %q, want admin", claims.UserType)
}
if claims.Role != "super_admin" {
t.Fatalf("Role = %q, want super_admin", claims.Role)
}
if claims.Purpose != "totp_verify" {
t.Fatalf("Purpose = %q, want totp_verify", claims.Purpose)
}
}
func TestProvisionalToken_TenantUser(t *testing.T) {
token, err := generateTestProvisionalToken(99, "tenant", "member", 200)
if err != nil {
t.Fatalf("generate: %v", err)
}
claims, err := ParseProvisionalToken(token)
if err != nil {
t.Fatalf("parse: %v", err)
}
if claims.TenantID != 200 {
t.Fatalf("TenantID = %d, want 200", claims.TenantID)
}
}
func TestProvisionalToken_Expired(t *testing.T) {
now := time.Now().Add(-10 * time.Minute)
claims := ProvisionalClaims{
UserID: 1,
UserType: "admin",
Role: "admin",
Purpose: "totp_verify",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "team-api",
ExpiresAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString(GetJWTSecret())
_, err := ParseProvisionalToken(tokenStr)
if err == nil {
t.Fatal("expected error for expired provisional token")
}
}
func TestProvisionalToken_WrongPurpose(t *testing.T) {
now := time.Now()
claims := ProvisionalClaims{
UserID: 1,
UserType: "admin",
Role: "admin",
Purpose: "high_risk_confirm",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString(GetJWTSecret())
_, err := ParseProvisionalToken(tokenStr)
if err == nil {
t.Fatal("expected error for wrong purpose in provisional token")
}
}
func TestProvisionalToken_InvalidString(t *testing.T) {
_, err := ParseProvisionalToken("garbage")
if err == nil {
t.Fatal("expected error for invalid token string")
}
}
// ─── ConfirmToken round-trip ────────────────────────────────────────
func TestConfirmToken_RoundTrip(t *testing.T) {
token, err := generateTestConfirmToken(42, "admin")
if err != nil {
t.Fatalf("generate: %v", err)
}
claims, err := ParseConfirmToken(token)
if err != nil {
t.Fatalf("parse: %v", err)
}
if claims.UserID != 42 {
t.Fatalf("UserID = %d, want 42", claims.UserID)
}
if claims.UserType != "admin" {
t.Fatalf("UserType = %q, want admin", claims.UserType)
}
if claims.Purpose != "high_risk_confirm" {
t.Fatalf("Purpose = %q, want high_risk_confirm", claims.Purpose)
}
}
func TestConfirmToken_Expired(t *testing.T) {
now := time.Now().Add(-10 * time.Minute)
claims := ConfirmClaims{
UserID: 1,
UserType: "admin",
Purpose: "high_risk_confirm",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString(GetJWTSecret())
_, err := ParseConfirmToken(tokenStr)
if err == nil {
t.Fatal("expected error for expired confirm token")
}
}
func TestConfirmToken_WrongPurpose(t *testing.T) {
now := time.Now()
claims := ConfirmClaims{
UserID: 1,
UserType: "admin",
Purpose: "totp_verify",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString(GetJWTSecret())
_, err := ParseConfirmToken(tokenStr)
if err == nil {
t.Fatal("expected error for wrong purpose in confirm token")
}
}
func TestConfirmToken_InvalidString(t *testing.T) {
_, err := ParseConfirmToken("")
if err == nil {
t.Fatal("expected error for empty confirm token")
}
}
// ─── Cross-token type rejection ─────────────────────────────────────
func TestProvisionalTokenCannotParseAsConfirm(t *testing.T) {
provToken, _ := generateTestProvisionalToken(1, "admin", "admin", 0)
_, err := ParseConfirmToken(provToken)
if err == nil {
t.Fatal("provisional token should not parse as confirm token")
}
}
func TestConfirmTokenCannotParseAsProvisional(t *testing.T) {
confToken, _ := generateTestConfirmToken(1, "admin")
_, err := ParseProvisionalToken(confToken)
if err == nil {
t.Fatal("confirm token should not parse as provisional token")
}
}
// ─── DeviceFingerprint ──────────────────────────────────────────────
func TestDeviceFingerprint_Deterministic(t *testing.T) {
fp1 := DeviceFingerprint("Mozilla/5.0", "192.168.1.1")
fp2 := DeviceFingerprint("Mozilla/5.0", "192.168.1.1")
if fp1 != fp2 {
t.Fatalf("same input produced different fingerprints: %q vs %q", fp1, fp2)
}
}
func TestDeviceFingerprint_DifferentUA(t *testing.T) {
fp1 := DeviceFingerprint("Chrome/120", "192.168.1.1")
fp2 := DeviceFingerprint("Firefox/115", "192.168.1.1")
if fp1 == fp2 {
t.Fatal("different UAs should produce different fingerprints")
}
}
func TestDeviceFingerprint_DifferentIP(t *testing.T) {
fp1 := DeviceFingerprint("Chrome/120", "10.0.0.1")
fp2 := DeviceFingerprint("Chrome/120", "10.0.0.2")
if fp1 == fp2 {
t.Fatal("different IPs should produce different fingerprints")
}
}
func TestDeviceFingerprint_Length(t *testing.T) {
fp := DeviceFingerprint("some-ua", "1.2.3.4")
if len(fp) != 32 {
t.Fatalf("expected length 32, got %d", len(fp))
}
}
func TestDeviceFingerprint_CaseInsensitive(t *testing.T) {
fp1 := DeviceFingerprint("Mozilla/5.0", "10.0.0.1")
fp2 := DeviceFingerprint("MOZILLA/5.0", "10.0.0.1")
if fp1 != fp2 {
t.Fatal("fingerprint should be case-insensitive on UA")
}
}
func TestDeviceFingerprint_LongUA(t *testing.T) {
longUA := strings.Repeat("A", 500)
fp := DeviceFingerprint(longUA, "1.2.3.4")
if len(fp) != 32 {
t.Fatalf("expected length 32 for long UA, got %d", len(fp))
}
truncatedUA := strings.Repeat("A", 200)
fpTruncated := DeviceFingerprint(truncatedUA, "1.2.3.4")
if fp != fpTruncated {
t.Fatal("UA beyond 200 chars should not affect fingerprint")
}
}
func TestDeviceFingerprint_Empty(t *testing.T) {
fp := DeviceFingerprint("", "")
if len(fp) != 32 {
t.Fatalf("expected length 32 for empty input, got %d", len(fp))
}
}
// ─── helpers ────────────────────────────────────────────────────────
func generateTestProvisionalToken(userID int64, userType, role string, tenantID int64) (string, error) {
now := time.Now()
claims := ProvisionalClaims{
UserID: userID,
UserType: userType,
Role: role,
TenantID: tenantID,
Purpose: "totp_verify",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "team-api",
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(GetJWTSecret())
}
func generateTestConfirmToken(userID int64, userType string) (string, error) {
now := time.Now()
claims := ConfirmClaims{
UserID: userID,
UserType: userType,
Purpose: "high_risk_confirm",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "team-api",
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(GetJWTSecret())
}
+155
View File
@@ -0,0 +1,155 @@
package payment
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// ─── LockOrder / UnlockOrder basic ──────────────────────────────────
func TestLockUnlock_Basic(t *testing.T) {
orderNo := "test-order-basic"
LockOrder(orderNo)
UnlockOrder(orderNo)
}
func TestLockUnlock_DifferentOrders(t *testing.T) {
done := make(chan struct{})
go func() {
LockOrder("order-a")
LockOrder("order-b")
UnlockOrder("order-b")
UnlockOrder("order-a")
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("different orders should not block each other")
}
}
func TestUnlockOrder_NonExistent(t *testing.T) {
UnlockOrder("never-locked-order")
}
// ─── Mutual exclusion ───────────────────────────────────────────────
func TestLockOrder_MutualExclusion(t *testing.T) {
orderNo := "test-order-mutex"
var counter int64
LockOrder(orderNo)
started := make(chan struct{})
done := make(chan struct{})
go func() {
close(started)
LockOrder(orderNo)
atomic.AddInt64(&counter, 1)
UnlockOrder(orderNo)
close(done)
}()
<-started
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt64(&counter) != 0 {
t.Fatal("goroutine should be blocked by the lock")
}
UnlockOrder(orderNo)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("goroutine should have completed after unlock")
}
if atomic.LoadInt64(&counter) != 1 {
t.Fatal("goroutine should have incremented counter")
}
}
// ─── Ref-counting cleanup ───────────────────────────────────────────
func TestLockOrder_RefCounting(t *testing.T) {
orderNo := "test-order-refcount"
var sequence []int
var mu sync.Mutex
LockOrder(orderNo)
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
LockOrder(orderNo)
mu.Lock()
sequence = append(sequence, id)
mu.Unlock()
UnlockOrder(orderNo)
}(i)
}
time.Sleep(50 * time.Millisecond)
UnlockOrder(orderNo)
wg.Wait()
if len(sequence) != 3 {
t.Fatalf("expected 3 goroutines to complete, got %d", len(sequence))
}
_, exists := orderLocks.Load(orderNo)
if exists {
t.Fatal("lock entry should be cleaned up after all refs are released")
}
}
// ─── Concurrent lock/unlock stress ──────────────────────────────────
func TestLockOrder_ConcurrentStress(t *testing.T) {
orderNo := "test-order-stress"
var counter int64
const goroutines = 50
var wg sync.WaitGroup
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
LockOrder(orderNo)
val := atomic.LoadInt64(&counter)
atomic.StoreInt64(&counter, val+1)
UnlockOrder(orderNo)
}()
}
wg.Wait()
if counter != goroutines {
t.Fatalf("expected counter=%d, got %d (race condition detected)", goroutines, counter)
}
}
// ─── Re-lock after full release ─────────────────────────────────────
func TestLockOrder_ReuseAfterRelease(t *testing.T) {
orderNo := "test-order-reuse"
LockOrder(orderNo)
UnlockOrder(orderNo)
_, exists := orderLocks.Load(orderNo)
if exists {
t.Fatal("lock should be cleaned up after release")
}
LockOrder(orderNo)
UnlockOrder(orderNo)
}