fix: serialize concurrent demo seeding

This commit is contained in:
2026-07-21 20:21:49 +08:00
parent d2ef10763c
commit d9440b5334
3 changed files with 311 additions and 24 deletions

View File

@@ -2,10 +2,13 @@ package seed
import (
"fmt"
"sync/atomic"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"senlinai-agent/backend/internal/logic/auth"
@@ -87,6 +90,7 @@ func TestDemoSeedUsesIdentifierAsStableProjectKey(t *testing.T) {
require.NoError(t, err)
require.Equal(t, original.ID, second.Projects[0].ID)
require.Equal(t, "A1", second.Projects[0].Identifier)
require.Equal(t, "本地重命名项目", second.Projects[0].Name)
var projectCount int64
require.NoError(t, database.Model(&models.SenlinAgentProject{}).
Where("owner_id = ? AND identifier = ?", first.User.ID, "A1").
@@ -94,6 +98,77 @@ func TestDemoSeedUsesIdentifierAsStableProjectKey(t *testing.T) {
require.Equal(t, int64(1), projectCount)
}
func TestDemoSeedAcceptsAConcurrentWinnerWithoutOverwritingUser(t *testing.T) {
database := newTestDB(t)
const email = "seed-race@senlin.ai"
winnerHash, err := bcrypt.GenerateFromPassword([]byte("winner-password"), bcrypt.MinCost)
require.NoError(t, err)
var injected atomic.Bool
callbackName := "test:inject_concurrent_seed_user"
require.NoError(t, database.Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement.Schema == nil || tx.Statement.Schema.Table != (models.SenlinAgentUser{}).TableName() {
return
}
if !injected.CompareAndSwap(false, true) {
return
}
now := time.Now().UTC()
tx.AddError(tx.Exec(
"INSERT INTO senlin_agent_users (identity, email, display_name, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"00000000-0000-7000-8000-000000000001", email, "并发胜出用户", string(winnerHash), "user", now, now,
).Error)
}))
t.Cleanup(func() { database.Callback().Create().Remove(callbackName) })
result, err := Demo(database, DemoOptions{Email: email, DisplayName: "不应覆盖", Password: "loser-password"})
require.NoError(t, err)
require.True(t, injected.Load())
require.Equal(t, "并发胜出用户", result.User.DisplayName)
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(result.User.PasswordHash), []byte("winner-password")))
require.Error(t, bcrypt.CompareHashAndPassword([]byte(result.User.PasswordHash), []byte("loser-password")))
var userCount int64
require.NoError(t, database.Model(&models.SenlinAgentUser{}).Where("email = ?", email).Count(&userCount).Error)
require.Equal(t, int64(1), userCount)
}
func TestDemoSeedRetriesSerializationFailureOutsideTheTransaction(t *testing.T) {
database := newTestDB(t)
var failures atomic.Int32
callbackName := "test:fail_first_seed_user_create"
require.NoError(t, database.Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement.Schema == nil || tx.Statement.Schema.Table != (models.SenlinAgentUser{}).TableName() {
return
}
if failures.Add(1) == 1 {
tx.AddError(testSQLStateError{state: "40001"})
}
}))
t.Cleanup(func() { database.Callback().Create().Remove(callbackName) })
result, err := Demo(database, DemoOptions{Email: "retry@senlin.ai", DisplayName: "重试用户", Password: "password123"})
require.NoError(t, err)
require.Equal(t, int32(2), failures.Load())
require.Equal(t, "retry@senlin.ai", result.User.Email)
var userCount int64
require.NoError(t, database.Model(&models.SenlinAgentUser{}).Where("email = ?", result.User.Email).Count(&userCount).Error)
require.Equal(t, int64(1), userCount)
}
type testSQLStateError struct {
state string
}
func (err testSQLStateError) Error() string {
return "test PostgreSQL transaction failure " + err.state
}
func (err testSQLStateError) SQLState() string {
return err.state
}
func TestDemoSeedKeepsSameNamedTagsScopedToTheirProjects(t *testing.T) {
database := newTestDB(t)