fix(projects): 短剧项目 - 修复同名创建重试 (#691)
Co-authored-by: tanzui <305375675@.com>
This commit is contained in:
@@ -23,19 +23,20 @@ const (
|
||||
type ErrorReason = kernel.ErrorReason
|
||||
|
||||
const (
|
||||
ReasonInvalidArgument = kernel.ReasonInvalidArgument
|
||||
ReasonUnauthorized = kernel.ReasonUnauthorized
|
||||
ReasonForbidden = kernel.ReasonForbidden
|
||||
ReasonNotFound = kernel.ReasonNotFound
|
||||
ReasonConflict = kernel.ReasonConflict
|
||||
ReasonFailedPrecondition = kernel.ReasonFailedPrecondition
|
||||
ReasonQuotaExceeded = kernel.ReasonQuotaExceeded
|
||||
ReasonRateLimited = kernel.ReasonRateLimited
|
||||
ReasonUnavailable = kernel.ReasonUnavailable
|
||||
ReasonTimeout = kernel.ReasonTimeout
|
||||
ReasonInternal = kernel.ReasonInternal
|
||||
ReasonBadGateway = kernel.ReasonBadGateway
|
||||
ReasonUpstreamDNSFailed = kernel.ReasonUpstreamDNSFailed
|
||||
ReasonInvalidArgument = kernel.ReasonInvalidArgument
|
||||
ReasonUnauthorized = kernel.ReasonUnauthorized
|
||||
ReasonForbidden = kernel.ReasonForbidden
|
||||
ReasonNotFound = kernel.ReasonNotFound
|
||||
ReasonConflict = kernel.ReasonConflict
|
||||
ReasonProjectNameConflict = kernel.ReasonProjectNameConflict
|
||||
ReasonFailedPrecondition = kernel.ReasonFailedPrecondition
|
||||
ReasonQuotaExceeded = kernel.ReasonQuotaExceeded
|
||||
ReasonRateLimited = kernel.ReasonRateLimited
|
||||
ReasonUnavailable = kernel.ReasonUnavailable
|
||||
ReasonTimeout = kernel.ReasonTimeout
|
||||
ReasonInternal = kernel.ReasonInternal
|
||||
ReasonBadGateway = kernel.ReasonBadGateway
|
||||
ReasonUpstreamDNSFailed = kernel.ReasonUpstreamDNSFailed
|
||||
)
|
||||
|
||||
func ReasonForStatus(status int) ErrorReason {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -254,6 +255,12 @@ func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model.
|
||||
}
|
||||
project := model.Project{ID: newID(), UserID: userID, Name: name, Type: projectType, AspectRatio: aspectRatio, SourceType: sourceType, Description: strings.TrimSpace(req.Description), StylePresetID: stylePresetID, StyleProfileJSON: styleProfileJSON, DefaultImageModel: defaultImageModel, DefaultVideoModel: defaultVideoModel, Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err := s.repo.CreateProject(&project); err != nil {
|
||||
if errors.Is(err, repository.ErrProjectNameConflict) {
|
||||
// 项目名称冲突需要明确返回 409,页面才能按已有逻辑换一个名称重试。
|
||||
conflict := NewAppError(http.StatusConflict, "项目名称已存在")
|
||||
conflict.Reason = ReasonProjectNameConflict
|
||||
return model.Project{}, conflict
|
||||
}
|
||||
return model.Project{}, err
|
||||
}
|
||||
if _, err := s.createProjectWorkflow(project.ID, "", "project"); err != nil {
|
||||
|
||||
120
backend/internal/app/project_name_conflict_test.go
Normal file
120
backend/internal/app/project_name_conflict_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestCreateProjectReturnsStructuredConflictForDuplicateName(t *testing.T) {
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
service, db := newProjectSettingsTestService(t)
|
||||
testStructuredProjectNameConflict(t, service, db)
|
||||
})
|
||||
t.Run("postgres", func(t *testing.T) {
|
||||
service, db := newProjectNameConflictPostgresService(t)
|
||||
testStructuredProjectNameConflict(t, service, db)
|
||||
})
|
||||
}
|
||||
|
||||
// testStructuredProjectNameConflict 验证重名的结构化错误,以及原项目和工作流不会被重复创建请求覆盖。
|
||||
func testStructuredProjectNameConflict(t *testing.T, service *Service, 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)
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&model.Project{}, &model.WorkflowTemplateVersion{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
request := CreateProjectRequest{Name: "同名短剧", Description: "原项目内容"}
|
||||
first, err := service.CreateProject("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatalf("create initial project: %v", err)
|
||||
}
|
||||
|
||||
request.Description = "重复请求的内容"
|
||||
_, err = service.CreateProject("user-1", request)
|
||||
var conflict *AppError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("duplicate project error = %v, want AppError", err)
|
||||
}
|
||||
if conflict.Status != http.StatusConflict || conflict.Reason != ReasonProjectNameConflict || conflict.Message != "项目名称已存在" {
|
||||
t.Fatalf("duplicate project conflict = %#v", conflict)
|
||||
}
|
||||
var stored model.Project
|
||||
if err := db.First(&stored, "id = ?", first.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Description != first.Description || stored.Revision != first.Revision {
|
||||
t.Fatal("duplicate creation changed the existing project")
|
||||
}
|
||||
var workflowCount int64
|
||||
if err := db.Model(&model.WorkflowInstance{}).Where("project_id = ?", first.ID).Count(&workflowCount).Error; err != nil || workflowCount != 1 {
|
||||
t.Fatalf("workflow count = %d, error = %v", workflowCount, err)
|
||||
}
|
||||
if _, err := service.CreateProject("user-2", request); err != nil {
|
||||
t.Fatalf("same name for another user: %v", err)
|
||||
}
|
||||
request.Name = "同名短剧(2)"
|
||||
if _, err := service.CreateProject("user-1", request); err != nil {
|
||||
t.Fatalf("retry with a new name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newProjectNameConflictPostgresService 只在独立测试 schema 中创建项目与工作流表,测试结束后删除该 schema。
|
||||
func newProjectNameConflictPostgresService(t *testing.T) (*Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("CANVAS_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("CANVAS_TEST_POSTGRES_DSN is not configured")
|
||||
}
|
||||
options := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
|
||||
admin, err := gorm.Open(postgres.Open(dsn), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminSQL, err := admin.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = adminSQL.Close() })
|
||||
schema := fmt.Sprintf("project_name_conflict_app_%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.Error(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()), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Service{repo: repository.New(db)}, db
|
||||
}
|
||||
@@ -1564,6 +1564,7 @@ components:
|
||||
- forbidden
|
||||
- not_found
|
||||
- conflict
|
||||
- project_name_conflict
|
||||
- failed_precondition
|
||||
- quota_exceeded
|
||||
- rate_limited
|
||||
|
||||
@@ -41,6 +41,19 @@ func TestFailServiceProjectsAppError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailServiceProjectsProjectNameConflictReason(t *testing.T) {
|
||||
recorder, context := responseTestContext()
|
||||
err := service.NewAppError(http.StatusConflict, "项目名称已存在")
|
||||
err.Reason = service.ReasonProjectNameConflict
|
||||
|
||||
failService(context, err)
|
||||
|
||||
response := decodeFailureEnvelope(t, recorder)
|
||||
if recorder.Code != http.StatusConflict || response.Code != service.CodeConflict || response.Reason != string(service.ReasonProjectNameConflict) || response.Msg != err.Message {
|
||||
t.Fatalf("project name conflict response: status=%d body=%#v", recorder.Code, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailServiceProjectsDNSFailureWithoutTransportDetails(t *testing.T) {
|
||||
recorder, context := responseTestContext()
|
||||
err := service.WrapAppError(http.StatusBadGateway, "外部服务域名解析失败,请检查渠道域名和后端 DNS 配置", errors.New("private-sentinel resolver failure"))
|
||||
|
||||
@@ -31,6 +31,7 @@ const (
|
||||
ReasonForbidden ErrorReason = "forbidden"
|
||||
ReasonNotFound ErrorReason = "not_found"
|
||||
ReasonConflict ErrorReason = "conflict"
|
||||
ReasonProjectNameConflict ErrorReason = "project_name_conflict"
|
||||
ReasonCanvasResourcesMissing ErrorReason = "canvas_history_resources_missing"
|
||||
ReasonFailedPrecondition ErrorReason = "failed_precondition"
|
||||
ReasonQuotaExceeded ErrorReason = "quota_exceeded"
|
||||
|
||||
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 {
|
||||
|
||||
@@ -68,6 +68,7 @@ const (
|
||||
ReasonBadGateway = app.ReasonBadGateway
|
||||
ReasonUpstreamDNSFailed = app.ReasonUpstreamDNSFailed
|
||||
ReasonConflict = app.ReasonConflict
|
||||
ReasonProjectNameConflict = app.ReasonProjectNameConflict
|
||||
ReasonFailedPrecondition = app.ReasonFailedPrecondition
|
||||
ReasonForbidden = app.ReasonForbidden
|
||||
ReasonInternal = app.ReasonInternal
|
||||
|
||||
@@ -347,6 +347,7 @@ CSV 使用中文表头、UTF-8 BOM、CRLF;金额按整数分精确转换为两
|
||||
| 403 | 403 | `forbidden` | 无权限 |
|
||||
| 404 | 404 | `not_found` | 资源不存在 |
|
||||
| 409 | 409 | `conflict` | 冲突 |
|
||||
| 409 | 409 | `project_name_conflict` | `POST /projects` 同一用户下已有同名项目;客户端可有限更换名称重试 |
|
||||
| 429 | 429 | `rate_limited` | 限流(未细分) |
|
||||
| 500 | 500 | `internal` | 内部错误 |
|
||||
| 502 | 502 | `bad_gateway` | 上游失败 |
|
||||
@@ -358,7 +359,7 @@ CSV 使用中文表头、UTF-8 BOM、CRLF;金额按整数分精确转换为两
|
||||
|
||||
模型错误把已有字符串码放进 `reason`(如 `model_capability_not_supported`),`code` 仍为 400。
|
||||
|
||||
常量:后端 `service/error_codes.go`,前端 `web/src/services/api/error-codes.ts`。
|
||||
常量:后端标准原因定义在 `backend/internal/kernel/error_codes.go`,由 `app` 和 `service` 兼容导出;前端业务模块按 `ApiError.reason` 分支。
|
||||
|
||||
## 技能库分类 API
|
||||
|
||||
|
||||
@@ -1276,6 +1276,13 @@ PR #507 核心能力已按主分支实现保留,并补齐小视口尺寸约束
|
||||
- [ ] 登录后用 DeepSeek 等 chat-completion 渠道跑「开始一部新短剧 → AI 生成章节」,确认章节正常导入。
|
||||
- [ ] Claude、Gemini、OpenAI Responses 渠道各跑一次文本生成,确认系统指令生效且没有重复出现在正文里。
|
||||
|
||||
## AI 生成章节的同名项目处理
|
||||
|
||||
- 已实现:创建项目遇到同一用户下的重名时返回 HTTP/code 409、`reason=project_name_conflict`;前端只对该原因有限更换名称重试,其他冲突和服务错误直接显示,不调用模型。
|
||||
- [x] 隔离 PostgreSQL 的仓储和服务测试、HTTP 响应及前端重试边界测试通过:同用户重名返回结构化冲突、不同用户可同名、项目 ID 冲突不被误判、原项目和工作流不被覆盖;更换名称后可正常创建。
|
||||
- [ ] SQLite 同组用例待在启用 CGO 的环境运行。
|
||||
- [ ] 登录真实页面,用已存在项目的同一故事再次执行「AI 生成章节」,确认自动创建带后缀项目、章节正常导入,且只有一次模型生成请求;确认其他 HTTP 409 不触发重试。
|
||||
|
||||
## 画布智能引用
|
||||
|
||||
- [ ] 在简洁、专业模式及展开提示词面板中打开智能引用,检查明暗主题、弹层宽度和开关状态。
|
||||
|
||||
11
web/src/lib/project-name-conflict.ts
Normal file
11
web/src/lib/project-name-conflict.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ApiError } from "@/services/api/request";
|
||||
|
||||
const maximumProjectNameRetries = 5;
|
||||
|
||||
/** 只重试后端明确识别的项目重名,不依赖可能被统一错误处理改写的数据库文案。 */
|
||||
export function shouldRetryProjectNameConflict(error: unknown, attempt: number) {
|
||||
return error instanceof ApiError &&
|
||||
error.status === 409 &&
|
||||
error.reason === "project_name_conflict" &&
|
||||
attempt < maximumProjectNameRetries;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { resourceFileUrl } from "@/services/api/resources";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile";
|
||||
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
|
||||
import { shouldRetryProjectNameConflict } from "@/lib/project-name-conflict";
|
||||
import { settingsPath } from "@/lib/settings-navigation";
|
||||
import { PromptTemplateOperation, parseGeneratedStory, promptTemplateTaskPlaceholder, shortDramaOutlineVariables } from "@/lib/prompts";
|
||||
import { runBackendGenerationTask } from "@/services/api/generation-task";
|
||||
@@ -339,9 +340,7 @@ async function createUniqueProjectName(story: string, selectedStyle: CanvasStyle
|
||||
try {
|
||||
return await createProject(buildInput(attempt === 0 ? base : `${base}(${attempt + 1})`));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const uniqueConflict = message.includes("UNIQUE") || message.includes("projects.user_id") || message.includes("projects.name");
|
||||
if (!uniqueConflict || attempt >= 5) throw error;
|
||||
if (!shouldRetryProjectNameConflict(error, attempt)) throw error;
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
41
web/test/project-name-conflict.test.ts
Normal file
41
web/test/project-name-conflict.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { ApiError } from "../src/services/api/request";
|
||||
import { shouldRetryProjectNameConflict } from "../src/lib/project-name-conflict";
|
||||
|
||||
describe("project name conflict retries", () => {
|
||||
test("retries only the structured project-name conflict", () => {
|
||||
const projectNameConflict = new ApiError("文案可能变化", {
|
||||
status: 409,
|
||||
reason: "project_name_conflict",
|
||||
});
|
||||
|
||||
expect(shouldRetryProjectNameConflict(projectNameConflict, 0)).toBe(true);
|
||||
expect(shouldRetryProjectNameConflict(new ApiError("项目名称已存在", {
|
||||
status: 409,
|
||||
reason: "conflict",
|
||||
}), 0)).toBe(false);
|
||||
expect(shouldRetryProjectNameConflict(new Error("项目名称已存在"), 0)).toBe(false);
|
||||
});
|
||||
|
||||
test("stops after the configured number of name retries", () => {
|
||||
const projectNameConflict = new ApiError("项目名称已存在", {
|
||||
status: 409,
|
||||
reason: "project_name_conflict",
|
||||
});
|
||||
|
||||
expect(shouldRetryProjectNameConflict(projectNameConflict, 4)).toBe(true);
|
||||
expect(shouldRetryProjectNameConflict(projectNameConflict, 5)).toBe(false);
|
||||
});
|
||||
|
||||
test("错误文案、非 409 状态和缺少专用原因的错误不能触发重试", () => {
|
||||
for (const error of [
|
||||
new ApiError("项目名称已存在", { status: 500, reason: "project_name_conflict" }),
|
||||
new ApiError("UNIQUE constraint failed: projects.user_id, projects.name", { status: 409 }),
|
||||
new Error("UNIQUE constraint failed: projects.user_id, projects.name"),
|
||||
{ status: 409, reason: "project_name_conflict" },
|
||||
]) {
|
||||
expect(shouldRetryProjectNameConflict(error, 0)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user