feat(test):添加核心组件的单元测试
This commit is contained in:
1 parent
8c48ccbacc
commit
f3c98f2315
4 files changed
+695
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user