add service
Some checks failed
Deploy Smart Search Backend Test / deploy (push) Failing after 1m31s

This commit is contained in:
vallyenfail
2026-01-19 23:50:42 +03:00
parent 87c2671f21
commit d3d004569e
9 changed files with 1285 additions and 6 deletions

View File

@@ -146,18 +146,27 @@ func (r *inviteRepository) DecrementCanBeUsedCountTx(ctx context.Context, tx pgx
query := r.qb.Update("invite_codes"). query := r.qb.Update("invite_codes").
Set("can_be_used_count", sq.Expr("can_be_used_count - 1")). Set("can_be_used_count", sq.Expr("can_be_used_count - 1")).
Set("is_active", sq.Expr("CASE WHEN can_be_used_count - 1 <= 0 THEN false ELSE is_active END")). Set("is_active", sq.Expr("CASE WHEN can_be_used_count - 1 <= 0 THEN false ELSE is_active END")).
Where(sq.Eq{"code": code}) Where(sq.And{
sq.Eq{"code": code},
sq.Expr("can_be_used_count > 0"),
sq.Eq{"is_active": true},
sq.Expr("expires_at > now()"),
})
sqlQuery, args, err := query.ToSql() sqlQuery, args, err := query.ToSql()
if err != nil { if err != nil {
return errs.NewInternalError(errs.DatabaseError, "failed to build query", err) return errs.NewInternalError(errs.DatabaseError, "failed to build query", err)
} }
_, err = tx.Exec(ctx, sqlQuery, args...) result, err := tx.Exec(ctx, sqlQuery, args...)
if err != nil { if err != nil {
return errs.NewInternalError(errs.DatabaseError, "failed to decrement can_be_used_count", err) return errs.NewInternalError(errs.DatabaseError, "failed to decrement can_be_used_count", err)
} }
if result.RowsAffected() == 0 {
return errs.NewBusinessError(errs.InviteInvalidOrExpired, "invite code is invalid, expired, or exhausted")
}
return nil return nil
} }

View File

@@ -11,7 +11,6 @@ import (
) )
type Claims struct { type Claims struct {
Sub string `json:"sub"`
Type string `json:"type"` Type string `json:"type"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -19,9 +18,9 @@ type Claims struct {
func GenerateAccessToken(userID int, secret string) (string, error) { func GenerateAccessToken(userID int, secret string) (string, error) {
now := time.Now() now := time.Now()
claims := Claims{ claims := Claims{
Sub: strconv.Itoa(userID),
Type: "access", Type: "access",
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Subject: strconv.Itoa(userID),
ID: uuid.New().String(), ID: uuid.New().String(),
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
@@ -35,9 +34,9 @@ func GenerateAccessToken(userID int, secret string) (string, error) {
func GenerateRefreshToken(userID int, secret string) (string, error) { func GenerateRefreshToken(userID int, secret string) (string, error) {
now := time.Now() now := time.Now()
claims := Claims{ claims := Claims{
Sub: strconv.Itoa(userID),
Type: "refresh", Type: "refresh",
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Subject: strconv.Itoa(userID),
ID: uuid.New().String(), ID: uuid.New().String(),
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)), ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
@@ -73,7 +72,7 @@ func GetUserIDFromToken(tokenString, secret string) (int, error) {
return 0, err return 0, err
} }
userID, err := strconv.Atoi(claims.Sub) userID, err := strconv.Atoi(claims.Subject)
if err != nil { if err != nil {
return 0, fmt.Errorf("invalid user ID in token: %w", err) return 0, fmt.Errorf("invalid user ID in token: %w", err)
} }

View File

@@ -0,0 +1,243 @@
package tests
import (
"sync"
"sync/atomic"
authpb "git.techease.ru/Smart-search/smart-search-back/pkg/pb/auth"
requestpb "git.techease.ru/Smart-search/smart-search-back/pkg/pb/request"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s *IntegrationSuite) TestConcurrentOwnership_User2TriesApproveTZ_WhileUser1Creates() {
email1, password1, _ := s.createUniqueTestUser("owner1", 1000.0)
email2, password2, _ := s.createUniqueTestUser("attacker1", 1000.0)
login1, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email1,
Password: password1,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
validate1, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: login1.AccessToken,
})
s.Require().NoError(err)
user1ID := validate1.UserId
login2, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email2,
Password: password2,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
validate2, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: login2.AccessToken,
})
s.Require().NoError(err)
user2ID := validate2.UserId
createResp, err := s.requestClient.CreateTZ(s.ctx, &requestpb.CreateTZRequest{
UserId: user1ID,
RequestTxt: "Request от User1 для теста ownership",
})
s.Require().NoError(err)
requestID := createResp.RequestId
var wg sync.WaitGroup
var user1Success, user2Denied int32
startBarrier := make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
<-startBarrier
_, err := s.requestClient.ApproveTZ(s.ctx, &requestpb.ApproveTZRequest{
RequestId: requestID,
FinalTz: "User1 approves",
UserId: user1ID,
})
if err == nil {
atomic.AddInt32(&user1Success, 1)
}
}()
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-startBarrier
_, err := s.requestClient.ApproveTZ(s.ctx, &requestpb.ApproveTZRequest{
RequestId: requestID,
FinalTz: "User2 tries to approve",
UserId: user2ID,
})
if err != nil {
st, ok := status.FromError(err)
if ok && st.Code() == codes.PermissionDenied {
atomic.AddInt32(&user2Denied, 1)
}
}
}()
}
close(startBarrier)
wg.Wait()
s.T().Logf("User1 success: %d, User2 denied: %d", user1Success, user2Denied)
s.Equal(int32(5), user2Denied,
"Все попытки User2 должны быть отклонены с PermissionDenied")
}
func (s *IntegrationSuite) TestConcurrentOwnership_ConcurrentApproveTZ_SameRequest() {
email, password, _ := s.createUniqueTestUser("concurrent_approve", 1000.0)
loginResp, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email,
Password: password,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
validateResp, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: loginResp.AccessToken,
})
s.Require().NoError(err)
userID := validateResp.UserId
createResp, err := s.requestClient.CreateTZ(s.ctx, &requestpb.CreateTZRequest{
UserId: userID,
RequestTxt: "Request для concurrent ApproveTZ",
})
s.Require().NoError(err)
requestID := createResp.RequestId
var wg sync.WaitGroup
var successCount int32
goroutines := 5
startBarrier := make(chan struct{})
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBarrier
_, err := s.requestClient.ApproveTZ(s.ctx, &requestpb.ApproveTZRequest{
RequestId: requestID,
FinalTz: "Concurrent approve attempt",
UserId: userID,
})
if err == nil {
atomic.AddInt32(&successCount, 1)
}
}(i)
}
close(startBarrier)
wg.Wait()
s.T().Logf("Concurrent ApproveTZ success count: %d", successCount)
suppliersCount := s.getRequestSuppliersCount(requestID)
s.T().Logf("Total suppliers for request: %d", suppliersCount)
}
func (s *IntegrationSuite) TestConcurrentOwnership_SessionIsolation_AfterLogout() {
email1, password1, _ := s.createUniqueTestUser("session_iso1", 1000.0)
email2, password2, _ := s.createUniqueTestUser("session_iso2", 1000.0)
login1, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email1,
Password: password1,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
user1Token := login1.AccessToken
validate1, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: user1Token,
})
s.Require().NoError(err)
s.True(validate1.Valid)
login2, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email2,
Password: password2,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
user2Token := login2.AccessToken
_, err = s.authClient.Logout(s.ctx, &authpb.LogoutRequest{
AccessToken: user1Token,
})
s.Require().NoError(err)
validate1After, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: user1Token,
})
s.NoError(err)
s.False(validate1After.Valid,
"Токен User1 должен быть невалиден после logout")
validate2After, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: user2Token,
})
s.NoError(err)
s.True(validate2After.Valid,
"Токен User2 должен оставаться валидным после logout User1")
var wg sync.WaitGroup
var user1Invalid, user2Valid int32
goroutines := 10
startBarrier := make(chan struct{})
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBarrier
if idx%2 == 0 {
resp, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: user1Token,
})
if err == nil && !resp.Valid {
atomic.AddInt32(&user1Invalid, 1)
}
} else {
resp, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: user2Token,
})
if err == nil && resp.Valid {
atomic.AddInt32(&user2Valid, 1)
}
}
}(i)
}
close(startBarrier)
wg.Wait()
s.T().Logf("Session isolation - User1 invalid: %d, User2 valid: %d", user1Invalid, user2Valid)
s.Equal(int32(goroutines/2), user1Invalid,
"Все проверки токена User1 должны показать invalid")
s.Equal(int32(goroutines/2), user2Valid,
"Все проверки токена User2 должны показать valid")
}

View File

@@ -0,0 +1,178 @@
package tests
import (
"fmt"
"sync"
"sync/atomic"
"time"
authpb "git.techease.ru/Smart-search/smart-search-back/pkg/pb/auth"
)
func (s *IntegrationSuite) TestConcurrent_Registration_WithSingleInviteCode() {
maxUses := 3
inviteCode := s.createActiveInviteCode(maxUses)
var wg sync.WaitGroup
var successCount int32
var errorCount int32
goroutines := 20
startBarrier := make(chan struct{})
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBarrier
email := fmt.Sprintf("concurrent_reg_%d_%d@example.com", idx, time.Now().UnixNano())
_, err := s.authClient.Register(s.ctx, &authpb.RegisterRequest{
Email: email,
Password: "testpassword123",
Name: fmt.Sprintf("User %d", idx),
Phone: fmt.Sprintf("+1%010d", idx),
InviteCode: inviteCode,
Ip: "127.0.0.1",
UserAgent: "integration-test",
})
if err == nil {
atomic.AddInt32(&successCount, 1)
} else {
atomic.AddInt32(&errorCount, 1)
}
}(i)
}
close(startBarrier)
wg.Wait()
s.T().Logf("Registration results - Success: %d, Errors: %d", successCount, errorCount)
s.LessOrEqual(int(successCount), maxUses,
"Количество успешных регистраций (%d) не должно превышать лимит invite-кода (%d)", successCount, maxUses)
remainingUses := s.getInviteCodeUsageCount(inviteCode)
s.T().Logf("Remaining invite code uses: %d", remainingUses)
s.Equal(maxUses-int(successCount), remainingUses,
"Оставшееся количество использований должно соответствовать успешным регистрациям")
}
func (s *IntegrationSuite) TestConcurrent_Registration_InviteCodeDeactivation() {
maxUses := 2
inviteCode := s.createActiveInviteCode(maxUses)
s.True(s.isInviteCodeActive(inviteCode), "Invite code должен быть активен изначально")
var wg sync.WaitGroup
var successCount int32
goroutines := 10
startBarrier := make(chan struct{})
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBarrier
email := fmt.Sprintf("deactivation_test_%d_%d@example.com", idx, time.Now().UnixNano())
_, err := s.authClient.Register(s.ctx, &authpb.RegisterRequest{
Email: email,
Password: "testpassword123",
Name: fmt.Sprintf("User %d", idx),
Phone: fmt.Sprintf("+2%010d", idx),
InviteCode: inviteCode,
Ip: "127.0.0.1",
UserAgent: "integration-test",
})
if err == nil {
atomic.AddInt32(&successCount, 1)
}
}(i)
}
close(startBarrier)
wg.Wait()
s.T().Logf("Registration success count: %d", successCount)
s.LessOrEqual(int(successCount), maxUses,
"Не должно быть больше %d успешных регистраций", maxUses)
remainingUses := s.getInviteCodeUsageCount(inviteCode)
s.GreaterOrEqual(remainingUses, 0,
"Количество использований не должно быть отрицательным")
}
func (s *IntegrationSuite) TestConcurrent_Registration_MultipleInviteCodes() {
inviteCode1 := s.createActiveInviteCode(2)
inviteCode2 := s.createActiveInviteCode(2)
var wg sync.WaitGroup
var success1, success2 int32
goroutines := 10
startBarrier := make(chan struct{})
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBarrier
var code int64
if idx%2 == 0 {
code = inviteCode1
} else {
code = inviteCode2
}
email := fmt.Sprintf("multi_invite_%d_%d@example.com", idx, time.Now().UnixNano())
_, err := s.authClient.Register(s.ctx, &authpb.RegisterRequest{
Email: email,
Password: "testpassword123",
Name: fmt.Sprintf("User %d", idx),
Phone: fmt.Sprintf("+3%010d", idx),
InviteCode: code,
Ip: "127.0.0.1",
UserAgent: "integration-test",
})
if err == nil {
if code == inviteCode1 {
atomic.AddInt32(&success1, 1)
} else {
atomic.AddInt32(&success2, 1)
}
}
}(i)
}
close(startBarrier)
wg.Wait()
s.T().Logf("Multi-invite results - Code1: %d, Code2: %d", success1, success2)
s.LessOrEqual(int(success1), 2,
"Invite code 1 не должен превышать лимит")
s.LessOrEqual(int(success2), 2,
"Invite code 2 не должен превышать лимит")
remaining1 := s.getInviteCodeUsageCount(inviteCode1)
remaining2 := s.getInviteCodeUsageCount(inviteCode2)
s.Equal(2-int(success1), remaining1,
"Остаток invite code 1 должен соответствовать успешным регистрациям")
s.Equal(2-int(success2), remaining2,
"Остаток invite code 2 должен соответствовать успешным регистрациям")
}

View File

@@ -0,0 +1,219 @@
package tests
import (
"sync"
"sync/atomic"
authpb "git.techease.ru/Smart-search/smart-search-back/pkg/pb/auth"
requestpb "git.techease.ru/Smart-search/smart-search-back/pkg/pb/request"
)
func (s *IntegrationSuite) TestConcurrentRequest_CreateTZ_LimitedBalance() {
initialBalance := 50.0
email, password, userID := s.createUniqueTestUser("limited_balance_tz", initialBalance)
loginResp, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email,
Password: password,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
validateResp, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: loginResp.AccessToken,
})
s.Require().NoError(err)
var wg sync.WaitGroup
var successCount int32
var errorCount int32
goroutines := 20
startBarrier := make(chan struct{})
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBarrier
_, err := s.requestClient.CreateTZ(s.ctx, &requestpb.CreateTZRequest{
UserId: validateResp.UserId,
RequestTxt: "Параллельный CreateTZ с ограниченным балансом",
})
if err == nil {
atomic.AddInt32(&successCount, 1)
} else {
atomic.AddInt32(&errorCount, 1)
}
}(i)
}
close(startBarrier)
wg.Wait()
s.T().Logf("CreateTZ with limited balance - Success: %d, Errors: %d", successCount, errorCount)
finalBalance := s.getUserBalance(userID)
s.T().Logf("Final balance: %.4f (initial: %.4f)", finalBalance, initialBalance)
s.GreaterOrEqual(finalBalance, 0.0,
"Баланс не должен быть отрицательным")
var requestsWithTZ int
err = s.pool.QueryRow(s.ctx,
"SELECT COUNT(*) FROM requests_for_suppliers WHERE user_id = $1 AND generated_tz = true",
userID,
).Scan(&requestsWithTZ)
s.NoError(err)
s.T().Logf("Requests with generated TZ: %d", requestsWithTZ)
s.GreaterOrEqual(requestsWithTZ, 0,
"Количество запросов с TZ должно быть >= 0")
s.LessOrEqual(requestsWithTZ, int(successCount),
"Количество запросов с TZ не должно превышать успешные операции")
}
func (s *IntegrationSuite) TestConcurrentRequest_MultipleUsers_CreateTZ() {
user1Email, user1Pass, user1ID := s.createUniqueTestUser("multi_user1", 500.0)
user2Email, user2Pass, user2ID := s.createUniqueTestUser("multi_user2", 500.0)
user3Email, user3Pass, user3ID := s.createUniqueTestUser("multi_user3", 500.0)
login1, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: user1Email, Password: user1Pass, Ip: "127.0.0.1", UserAgent: "test",
})
s.Require().NoError(err)
validate1, _ := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{AccessToken: login1.AccessToken})
login2, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: user2Email, Password: user2Pass, Ip: "127.0.0.1", UserAgent: "test",
})
s.Require().NoError(err)
validate2, _ := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{AccessToken: login2.AccessToken})
login3, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: user3Email, Password: user3Pass, Ip: "127.0.0.1", UserAgent: "test",
})
s.Require().NoError(err)
validate3, _ := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{AccessToken: login3.AccessToken})
users := []struct {
userID int64
id int
}{
{validate1.UserId, user1ID},
{validate2.UserId, user2ID},
{validate3.UserId, user3ID},
}
var wg sync.WaitGroup
var totalSuccess int32
requestsPerUser := 5
startBarrier := make(chan struct{})
for _, user := range users {
for i := 0; i < requestsPerUser; i++ {
wg.Add(1)
go func(uid int64) {
defer wg.Done()
<-startBarrier
_, err := s.requestClient.CreateTZ(s.ctx, &requestpb.CreateTZRequest{
UserId: uid,
RequestTxt: "Multi-user concurrent CreateTZ",
})
if err == nil {
atomic.AddInt32(&totalSuccess, 1)
}
}(user.userID)
}
}
close(startBarrier)
wg.Wait()
s.T().Logf("Multi-user CreateTZ total success: %d", totalSuccess)
for _, user := range users {
balance := s.getUserBalance(user.id)
s.T().Logf("User %d final balance: %.4f", user.id, balance)
s.GreaterOrEqual(balance, 0.0,
"Баланс пользователя %d не должен быть отрицательным", user.id)
}
}
func (s *IntegrationSuite) TestConcurrentRequest_BalanceDeduction_Consistency() {
initialBalance := 1000.0
email, password, userID := s.createUniqueTestUser("balance_consistency", initialBalance)
loginResp, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email,
Password: password,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
validateResp, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: loginResp.AccessToken,
})
s.Require().NoError(err)
var wg sync.WaitGroup
var successCount int32
goroutines := 10
startBarrier := make(chan struct{})
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBarrier
_, err := s.requestClient.CreateTZ(s.ctx, &requestpb.CreateTZRequest{
UserId: validateResp.UserId,
RequestTxt: "Balance consistency test",
})
if err == nil {
atomic.AddInt32(&successCount, 1)
}
}(i)
}
close(startBarrier)
wg.Wait()
s.T().Logf("Successful CreateTZ operations: %d", successCount)
finalBalance := s.getUserBalance(userID)
balanceSpent := initialBalance - finalBalance
s.T().Logf("Balance spent: %.4f", balanceSpent)
var totalTokenCost float64
err = s.pool.QueryRow(s.ctx, `
SELECT COALESCE(SUM(tu.token_cost), 0)
FROM request_token_usage tu
JOIN requests_for_suppliers r ON tu.request_id = r.id
WHERE r.user_id = $1
`, userID).Scan(&totalTokenCost)
s.NoError(err)
s.T().Logf("Total token cost from DB: %.4f", totalTokenCost)
s.GreaterOrEqual(finalBalance, 0.0,
"Баланс не должен быть отрицательным")
if totalTokenCost > 0 {
tolerance := 0.01
s.InDelta(totalTokenCost, balanceSpent, tolerance,
"Сумма token_cost должна соответствовать списанному балансу")
}
}

155
tests/idempotency_test.go Normal file
View File

@@ -0,0 +1,155 @@
package tests
import (
"fmt"
"time"
authpb "git.techease.ru/Smart-search/smart-search-back/pkg/pb/auth"
requestpb "git.techease.ru/Smart-search/smart-search-back/pkg/pb/request"
)
func (s *IntegrationSuite) TestIdempotency_DoubleCreateTZ_CreatesTwoRequests() {
email, password, userID := s.createUniqueTestUser("idempotency_tz", 1000.0)
loginResp, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email,
Password: password,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
validateResp, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: loginResp.AccessToken,
})
s.Require().NoError(err)
requestText := "Одинаковый текст запроса для теста идемпотентности"
resp1, err := s.requestClient.CreateTZ(s.ctx, &requestpb.CreateTZRequest{
UserId: validateResp.UserId,
RequestTxt: requestText,
})
s.Require().NoError(err)
requestID1 := resp1.RequestId
resp2, err := s.requestClient.CreateTZ(s.ctx, &requestpb.CreateTZRequest{
UserId: validateResp.UserId,
RequestTxt: requestText,
})
s.Require().NoError(err)
requestID2 := resp2.RequestId
s.T().Logf("Request 1 ID: %s", requestID1)
s.T().Logf("Request 2 ID: %s", requestID2)
s.NotEqual(requestID1, requestID2,
"Два вызова CreateTZ должны создать два разных request")
var requestCount int
err = s.pool.QueryRow(s.ctx,
"SELECT COUNT(*) FROM requests_for_suppliers WHERE user_id = $1 AND request_txt = $2",
userID, requestText,
).Scan(&requestCount)
s.NoError(err)
s.Equal(2, requestCount,
"Должно быть создано 2 запроса с одинаковым текстом")
}
func (s *IntegrationSuite) TestIdempotency_DoubleRegister_SameInviteCode() {
inviteCode := s.createActiveInviteCode(5)
email1 := fmt.Sprintf("double_reg1_%d@example.com", time.Now().UnixNano())
resp1, err := s.authClient.Register(s.ctx, &authpb.RegisterRequest{
Email: email1,
Password: "testpassword",
Name: "User 1",
Phone: fmt.Sprintf("+1%010d", time.Now().UnixNano()%10000000000),
InviteCode: inviteCode,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
s.NotEmpty(resp1.AccessToken)
email2 := fmt.Sprintf("double_reg2_%d@example.com", time.Now().UnixNano())
resp2, err := s.authClient.Register(s.ctx, &authpb.RegisterRequest{
Email: email2,
Password: "testpassword",
Name: "User 2",
Phone: fmt.Sprintf("+2%010d", time.Now().UnixNano()%10000000000),
InviteCode: inviteCode,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
s.NotEmpty(resp2.AccessToken)
remainingUses := s.getInviteCodeUsageCount(inviteCode)
s.T().Logf("Remaining invite uses: %d", remainingUses)
s.Equal(3, remainingUses,
"После двух регистраций должно остаться 3 использования (5-2)")
validate1, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: resp1.AccessToken,
})
s.NoError(err)
validate2, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: resp2.AccessToken,
})
s.NoError(err)
s.NotEqual(validate1.UserId, validate2.UserId,
"Должны быть созданы два разных пользователя")
}
func (s *IntegrationSuite) TestIdempotency_DoubleLogout_SameToken() {
email, password, _ := s.createUniqueTestUser("double_logout", 100.0)
loginResp, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email,
Password: password,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
accessToken := loginResp.AccessToken
validateBefore, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: accessToken,
})
s.NoError(err)
s.True(validateBefore.Valid)
logout1, err := s.authClient.Logout(s.ctx, &authpb.LogoutRequest{
AccessToken: accessToken,
})
s.NoError(err)
s.True(logout1.Success)
validateAfter1, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: accessToken,
})
s.NoError(err)
s.False(validateAfter1.Valid,
"Токен должен быть невалиден после первого logout")
logout2, err := s.authClient.Logout(s.ctx, &authpb.LogoutRequest{
AccessToken: accessToken,
})
s.NoError(err)
s.True(logout2.Success,
"Повторный logout должен быть успешным (идемпотентность)")
validateAfter2, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: accessToken,
})
s.NoError(err)
s.False(validateAfter2.Valid,
"Токен должен оставаться невалидным после повторного logout")
}

View File

@@ -275,3 +275,104 @@ func (s *IntegrationSuite) createSecondTestUser() (email string, password string
return email, password, userID return email, password, userID
} }
func (s *IntegrationSuite) getInviteCodeUsageCount(code int64) int {
var count int
err := s.pool.QueryRow(s.ctx,
"SELECT can_be_used_count FROM invite_codes WHERE code = $1",
code,
).Scan(&count)
if err != nil {
return -1
}
return count
}
func (s *IntegrationSuite) getRequestSuppliersCount(requestID string) int {
var count int
err := s.pool.QueryRow(s.ctx,
"SELECT COUNT(*) FROM suppliers WHERE request_id = $1::uuid",
requestID,
).Scan(&count)
if err != nil {
return -1
}
return count
}
func (s *IntegrationSuite) getUserBalance(userID int) float64 {
var balance float64
err := s.pool.QueryRow(s.ctx,
"SELECT balance FROM users WHERE id = $1",
userID,
).Scan(&balance)
if err != nil {
return -1
}
return balance
}
func (s *IntegrationSuite) getTokenUsageCount(requestID string) int {
var count int
err := s.pool.QueryRow(s.ctx,
"SELECT COUNT(*) FROM request_token_usage WHERE request_id = $1::uuid",
requestID,
).Scan(&count)
if err != nil {
return -1
}
return count
}
func (s *IntegrationSuite) createUniqueTestUser(suffix string, balance float64) (email string, password string, userID int) {
email = fmt.Sprintf("user_%s_%d@example.com", suffix, time.Now().UnixNano())
password = "testpassword"
cryptoHelper := crypto.NewCrypto(testCryptoSecret)
encryptedEmail, err := cryptoHelper.Encrypt(email)
s.Require().NoError(err)
encryptedPhone, err := cryptoHelper.Encrypt(fmt.Sprintf("+1%d", time.Now().UnixNano()%10000000000))
s.Require().NoError(err)
encryptedUserName, err := cryptoHelper.Encrypt(fmt.Sprintf("User %s", suffix))
s.Require().NoError(err)
emailHash := cryptoHelper.EmailHash(email)
passwordHash := crypto.PasswordHash(password)
query := `
INSERT INTO users (email, email_hash, password_hash, phone, user_name, company_name, balance, payment_status, invites_issued, invites_limit)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
`
err = s.pool.QueryRow(s.ctx, query,
encryptedEmail,
emailHash,
passwordHash,
encryptedPhone,
encryptedUserName,
"Test Company",
balance,
"active",
0,
10,
).Scan(&userID)
s.Require().NoError(err)
return email, password, userID
}
func (s *IntegrationSuite) isInviteCodeActive(code int64) bool {
var isActive bool
err := s.pool.QueryRow(s.ctx,
"SELECT is_active FROM invite_codes WHERE code = $1",
code,
).Scan(&isActive)
if err != nil {
return false
}
return isActive
}

View File

@@ -0,0 +1,194 @@
package tests
import (
"sync"
"sync/atomic"
authpb "git.techease.ru/Smart-search/smart-search-back/pkg/pb/auth"
requestpb "git.techease.ru/Smart-search/smart-search-back/pkg/pb/request"
)
func (s *IntegrationSuite) TestTransaction_CreateTZ_InsufficientBalance_Rollback() {
email, password, userID := s.createUniqueTestUser("insufficient_tz", 0.001)
loginResp, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email,
Password: password,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
validateResp, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: loginResp.AccessToken,
})
s.Require().NoError(err)
initialBalance := s.getUserBalance(userID)
s.T().Logf("Initial balance: %.4f", initialBalance)
_, err = s.requestClient.CreateTZ(s.ctx, &requestpb.CreateTZRequest{
UserId: validateResp.UserId,
RequestTxt: "Тест с недостаточным балансом",
})
if err != nil {
s.T().Logf("CreateTZ failed as expected: %v", err)
finalBalance := s.getUserBalance(userID)
s.T().Logf("Final balance after failed CreateTZ: %.4f", finalBalance)
s.GreaterOrEqual(finalBalance, 0.0,
"Баланс не должен быть отрицательным после rollback")
}
}
func (s *IntegrationSuite) TestTransaction_ApproveTZ_InsufficientBalance_NoSuppliers() {
email, password, userID := s.createUniqueTestUser("approve_insufficient", 1000.0)
loginResp, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email,
Password: password,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
validateResp, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: loginResp.AccessToken,
})
s.Require().NoError(err)
createResp, err := s.requestClient.CreateTZ(s.ctx, &requestpb.CreateTZRequest{
UserId: validateResp.UserId,
RequestTxt: "Тест approve с недостаточным балансом",
})
s.Require().NoError(err)
requestID := createResp.RequestId
_, err = s.pool.Exec(s.ctx, "UPDATE users SET balance = 0.001 WHERE id = $1", userID)
s.Require().NoError(err)
suppliersBeforeApprove := s.getRequestSuppliersCount(requestID)
s.T().Logf("Suppliers before ApproveTZ: %d", suppliersBeforeApprove)
_, err = s.requestClient.ApproveTZ(s.ctx, &requestpb.ApproveTZRequest{
RequestId: requestID,
FinalTz: "Утвержденное ТЗ",
UserId: validateResp.UserId,
})
if err != nil {
s.T().Logf("ApproveTZ failed as expected: %v", err)
suppliersAfterApprove := s.getRequestSuppliersCount(requestID)
s.T().Logf("Suppliers after failed ApproveTZ: %d", suppliersAfterApprove)
finalBalance := s.getUserBalance(userID)
s.GreaterOrEqual(finalBalance, 0.0,
"Баланс не должен быть отрицательным")
}
}
func (s *IntegrationSuite) TestTransaction_ConcurrentCreateTZ_BalanceAtomicity() {
email, password, userID := s.createUniqueTestUser("concurrent_tz", 100.0)
loginResp, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email,
Password: password,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
validateResp, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: loginResp.AccessToken,
})
s.Require().NoError(err)
var wg sync.WaitGroup
var successCount int32
var errorCount int32
goroutines := 10
startBarrier := make(chan struct{})
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBarrier
_, err := s.requestClient.CreateTZ(s.ctx, &requestpb.CreateTZRequest{
UserId: validateResp.UserId,
RequestTxt: "Параллельный тест CreateTZ",
})
if err == nil {
atomic.AddInt32(&successCount, 1)
} else {
atomic.AddInt32(&errorCount, 1)
}
}(i)
}
close(startBarrier)
wg.Wait()
s.T().Logf("Concurrent CreateTZ - Success: %d, Errors: %d", successCount, errorCount)
finalBalance := s.getUserBalance(userID)
s.T().Logf("Final balance: %.4f", finalBalance)
s.GreaterOrEqual(finalBalance, 0.0,
"Баланс не должен быть отрицательным после параллельных операций")
}
func (s *IntegrationSuite) TestTransaction_TokenUsage_BalanceConsistency() {
email, password, userID := s.createUniqueTestUser("token_consistency", 1000.0)
initialBalance := s.getUserBalance(userID)
s.T().Logf("Initial balance: %.4f", initialBalance)
loginResp, err := s.authClient.Login(s.ctx, &authpb.LoginRequest{
Email: email,
Password: password,
Ip: "127.0.0.1",
UserAgent: "test-agent",
})
s.Require().NoError(err)
validateResp, err := s.authClient.Validate(s.ctx, &authpb.ValidateRequest{
AccessToken: loginResp.AccessToken,
})
s.Require().NoError(err)
createResp, err := s.requestClient.CreateTZ(s.ctx, &requestpb.CreateTZRequest{
UserId: validateResp.UserId,
RequestTxt: "Тест consistency token_usage и balance",
})
s.Require().NoError(err)
requestID := createResp.RequestId
tokenUsageCount := s.getTokenUsageCount(requestID)
s.T().Logf("Token usage records for request: %d", tokenUsageCount)
finalBalance := s.getUserBalance(userID)
balanceDelta := initialBalance - finalBalance
s.T().Logf("Balance delta: %.4f", balanceDelta)
if tokenUsageCount > 0 {
s.Greater(balanceDelta, 0.0,
"Баланс должен уменьшиться при наличии token_usage записей")
}
var totalTokenCost float64
err = s.pool.QueryRow(s.ctx,
"SELECT COALESCE(SUM(token_cost), 0) FROM request_token_usage WHERE request_id = $1::uuid",
requestID,
).Scan(&totalTokenCost)
s.NoError(err)
s.T().Logf("Total token cost from DB: %.4f, Balance delta: %.4f", totalTokenCost, balanceDelta)
}

View File

@@ -0,0 +1,181 @@
package tests
import (
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
)
func (s *IntegrationSuite) TestWorkerConcurrent_SessionCleanup_MassExpired() {
_, _, userID := s.createUniqueTestUser("session_cleanup", 100.0)
expiredTime := time.Now().Add(-24 * time.Hour)
validTime := time.Now().Add(24 * time.Hour)
expiredCount := 100
validCount := 50
for i := 0; i < expiredCount; i++ {
_, err := s.pool.Exec(s.ctx, `
INSERT INTO sessions (user_id, access_token, refresh_token, ip, user_agent, expires_at)
VALUES ($1, $2, $3, '127.0.0.1', 'test-agent', $4)
`, userID,
fmt.Sprintf("expired_access_%d_%s", i, uuid.New().String()),
fmt.Sprintf("expired_refresh_%d_%s", i, uuid.New().String()),
expiredTime,
)
s.Require().NoError(err)
}
for i := 0; i < validCount; i++ {
_, err := s.pool.Exec(s.ctx, `
INSERT INTO sessions (user_id, access_token, refresh_token, ip, user_agent, expires_at)
VALUES ($1, $2, $3, '127.0.0.1', 'test-agent', $4)
`, userID,
fmt.Sprintf("valid_access_%d_%s", i, uuid.New().String()),
fmt.Sprintf("valid_refresh_%d_%s", i, uuid.New().String()),
validTime,
)
s.Require().NoError(err)
}
var totalBefore int
err := s.pool.QueryRow(s.ctx,
"SELECT COUNT(*) FROM sessions WHERE user_id = $1", userID,
).Scan(&totalBefore)
s.Require().NoError(err)
s.T().Logf("Sessions before cleanup: %d", totalBefore)
var wg sync.WaitGroup
var totalDeleted int32
goroutines := 10
startBarrier := make(chan struct{})
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-startBarrier
result, err := s.pool.Exec(s.ctx, `
DELETE FROM sessions
WHERE expires_at < now()
OR (revoked_at IS NOT NULL AND revoked_at < now() - interval '30 days')
`)
if err == nil {
atomic.AddInt32(&totalDeleted, int32(result.RowsAffected()))
}
}()
}
close(startBarrier)
wg.Wait()
s.T().Logf("Total deleted by concurrent cleanup: %d", totalDeleted)
var validRemaining int
err = s.pool.QueryRow(s.ctx,
"SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND expires_at > now()", userID,
).Scan(&validRemaining)
s.NoError(err)
s.T().Logf("Valid sessions remaining: %d (expected: %d)", validRemaining, validCount)
s.Equal(validCount, validRemaining,
"Все валидные сессии должны остаться после cleanup")
s.GreaterOrEqual(int(totalDeleted), expiredCount,
"Все истекшие сессии должны быть удалены")
}
func (s *IntegrationSuite) TestWorkerConcurrent_InviteCleanup_MassExpired() {
_, _, userID := s.createUniqueTestUser("invite_cleanup", 100.0)
_, err := s.pool.Exec(s.ctx, "UPDATE users SET invites_limit = 200 WHERE id = $1", userID)
s.Require().NoError(err)
expiredTime := time.Now().Add(-24 * time.Hour)
validTime := time.Now().Add(24 * time.Hour)
expiredCount := 100
validCount := 50
for i := 0; i < expiredCount; i++ {
code := int64(30000000 + i)
_, err := s.pool.Exec(s.ctx, `
INSERT INTO invite_codes (user_id, code, can_be_used_count, expires_at, is_active)
VALUES ($1, $2, 5, $3, true)
`, userID, code, expiredTime)
s.Require().NoError(err)
}
for i := 0; i < validCount; i++ {
code := int64(40000000 + i)
_, err := s.pool.Exec(s.ctx, `
INSERT INTO invite_codes (user_id, code, can_be_used_count, expires_at, is_active)
VALUES ($1, $2, 5, $3, true)
`, userID, code, validTime)
s.Require().NoError(err)
}
var activeBefore int
err = s.pool.QueryRow(s.ctx,
"SELECT COUNT(*) FROM invite_codes WHERE user_id = $1 AND is_active = true", userID,
).Scan(&activeBefore)
s.Require().NoError(err)
s.T().Logf("Active invites before cleanup: %d", activeBefore)
var wg sync.WaitGroup
var totalDeactivated int32
goroutines := 10
startBarrier := make(chan struct{})
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-startBarrier
result, err := s.pool.Exec(s.ctx, `
UPDATE invite_codes
SET is_active = false
WHERE expires_at < now() AND is_active = true
`)
if err == nil {
atomic.AddInt32(&totalDeactivated, int32(result.RowsAffected()))
}
}()
}
close(startBarrier)
wg.Wait()
s.T().Logf("Total deactivated by concurrent cleanup: %d", totalDeactivated)
var activeRemaining int
err = s.pool.QueryRow(s.ctx,
"SELECT COUNT(*) FROM invite_codes WHERE user_id = $1 AND is_active = true", userID,
).Scan(&activeRemaining)
s.NoError(err)
s.T().Logf("Active invites remaining: %d (expected: %d)", activeRemaining, validCount)
s.Equal(validCount, activeRemaining,
"Все валидные инвайты должны остаться активными после cleanup")
var expiredStillActive int
err = s.pool.QueryRow(s.ctx,
"SELECT COUNT(*) FROM invite_codes WHERE user_id = $1 AND expires_at < now() AND is_active = true", userID,
).Scan(&expiredStillActive)
s.NoError(err)
s.Equal(0, expiredStillActive,
"Не должно остаться активных истекших инвайтов")
}