fix(projects): 短剧项目 - 修复同名创建重试 (#691)
Co-authored-by: tanzui <305375675@.com>
This commit is contained in:
114
backend/internal/repository/project_name_conflict_test.go
Normal file
114
backend/internal/repository/project_name_conflict_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestCreateProjectReturnsConflictForDuplicateUserName(t *testing.T) {
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "projects.db")), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closeTestDatabase(t, db)
|
||||
testCreateProjectNameConflict(t, db)
|
||||
})
|
||||
|
||||
t.Run("postgres", func(t *testing.T) {
|
||||
dsn := os.Getenv("CANVAS_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set CANVAS_TEST_POSTGRES_DSN to an isolated test database")
|
||||
}
|
||||
config := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
|
||||
admin, err := gorm.Open(postgres.Open(dsn), config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closeTestDatabase(t, admin)
|
||||
|
||||
schema := fmt.Sprintf("project_name_conflict_%d", time.Now().UnixNano())
|
||||
if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil {
|
||||
t.Errorf("drop test schema: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("search_path", schema)
|
||||
parsed.RawQuery = query.Encode()
|
||||
db, err := gorm.Open(postgres.Open(parsed.String()), config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closeTestDatabase(t, db)
|
||||
testCreateProjectNameConflict(t, db)
|
||||
})
|
||||
}
|
||||
|
||||
// testCreateProjectNameConflict 验证名称冲突按用户区分,且不误判项目 ID 冲突。
|
||||
func testCreateProjectNameConflict(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.AutoMigrate(&model.Project{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := New(db)
|
||||
|
||||
first := model.Project{ID: "project-1", UserID: "user-1", Name: "短剧", Description: "原项目内容"}
|
||||
if err := repo.CreateProject(&first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
duplicate := model.Project{ID: "project-2", UserID: "user-1", Name: "短剧", Description: "重复请求的内容"}
|
||||
if err := repo.CreateProject(&duplicate); !errors.Is(err, ErrProjectNameConflict) {
|
||||
t.Fatalf("duplicate name error = %v, want ErrProjectNameConflict", err)
|
||||
}
|
||||
stored, err := repo.ProjectForUser(first.UserID, first.ID)
|
||||
if err != nil || stored.Description != first.Description {
|
||||
t.Fatalf("duplicate creation changed the original project: project=%#v, error=%v", stored, err)
|
||||
}
|
||||
|
||||
sameNameForAnotherUser := model.Project{ID: "project-3", UserID: "user-2", Name: "短剧"}
|
||||
if err := repo.CreateProject(&sameNameForAnotherUser); err != nil {
|
||||
t.Fatalf("same name for another user: %v", err)
|
||||
}
|
||||
|
||||
primaryKeyConflict := model.Project{ID: "project-1", UserID: "user-3", Name: "其他项目"}
|
||||
if err := repo.CreateProject(&primaryKeyConflict); err == nil || errors.Is(err, ErrProjectNameConflict) {
|
||||
t.Fatalf("primary key conflict error = %v, want a different database error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func closeTestDatabase(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close test database: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -12,6 +13,9 @@ import (
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
// ErrProjectNameConflict 表示同一用户下的项目名称已被占用。
|
||||
var ErrProjectNameConflict = errors.New("project name conflict")
|
||||
|
||||
func (r *Repository) Projects(userID string) ([]model.Project, error) {
|
||||
var projects []model.Project
|
||||
err := r.db.Where("user_id = ?", userID).Order("updated_at desc").Find(&projects).Error
|
||||
@@ -40,7 +44,18 @@ func (r *Repository) ProjectForUser(userID string, id string) (*model.Project, e
|
||||
}
|
||||
|
||||
func (r *Repository) CreateProject(project *model.Project) error {
|
||||
return r.db.Create(project).Error
|
||||
// 只忽略用户与名称这一业务唯一索引;主键等其他冲突仍按数据库错误返回。
|
||||
result := r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "name"}},
|
||||
DoNothing: true,
|
||||
}).Create(project)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrProjectNameConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateProject(project *model.Project) error {
|
||||
|
||||
Reference in New Issue
Block a user