fix: невалидное имя варианта проекта — предупреждение вместо ошибки
Имя варианта "main" или с недопустимыми символами больше не отклоняет запрос ошибкой 400 — теперь автоматически конвертируется в валидное (sanitizeProjectVariantName), а API/UI показывают variant_warning вместо блокировки создания/переименования варианта. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
006d7e87c5
commit
2f3bdc609a
+12
-8
@@ -1613,6 +1613,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusOK, simplified)
|
c.JSON(http.StatusOK, simplified)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ProjectWithWarning flattens a project's fields (via embedding) alongside an
|
||||||
|
// optional variant_warning, set when an invalid variant name (reserved "main" or
|
||||||
|
// disallowed characters) was auto-converted into a valid one instead of rejected.
|
||||||
|
type ProjectWithWarning struct {
|
||||||
|
*models.Project
|
||||||
|
VariantWarning string `json:"variant_warning,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
projects.POST("", func(c *gin.Context) {
|
projects.POST("", func(c *gin.Context) {
|
||||||
var req services.CreateProjectRequest
|
var req services.CreateProjectRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -1623,17 +1631,15 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
project, err := projectService.Create(dbUsername, &req)
|
project, variantWarning, err := projectService.Create(dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondByErrCase(c, err,
|
respondByErrCase(c, err,
|
||||||
errCase{services.ErrReservedMainVariant, http.StatusBadRequest, "invalid request"},
|
|
||||||
errCase{services.ErrProjectCodeInvalidChars, http.StatusBadRequest, "invalid request"},
|
errCase{services.ErrProjectCodeInvalidChars, http.StatusBadRequest, "invalid request"},
|
||||||
errCase{services.ErrProjectVariantInvalidChars, http.StatusBadRequest, "invalid request"},
|
|
||||||
errCase{services.ErrProjectCodeExists, http.StatusConflict, "conflict detected"},
|
errCase{services.ErrProjectCodeExists, http.StatusConflict, "conflict detected"},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, project)
|
c.JSON(http.StatusCreated, ProjectWithWarning{Project: project, VariantWarning: variantWarning})
|
||||||
})
|
})
|
||||||
|
|
||||||
projects.GET("/:uuid", func(c *gin.Context) {
|
projects.GET("/:uuid", func(c *gin.Context) {
|
||||||
@@ -1654,20 +1660,18 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
project, variantWarning, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondByErrCase(c, err,
|
respondByErrCase(c, err,
|
||||||
errCase{services.ErrReservedMainVariant, http.StatusBadRequest, "invalid request"},
|
|
||||||
errCase{services.ErrCannotRenameMainVariant, http.StatusBadRequest, "invalid request"},
|
errCase{services.ErrCannotRenameMainVariant, http.StatusBadRequest, "invalid request"},
|
||||||
errCase{services.ErrProjectCodeInvalidChars, http.StatusBadRequest, "invalid request"},
|
errCase{services.ErrProjectCodeInvalidChars, http.StatusBadRequest, "invalid request"},
|
||||||
errCase{services.ErrProjectVariantInvalidChars, http.StatusBadRequest, "invalid request"},
|
|
||||||
errCase{services.ErrProjectCodeExists, http.StatusConflict, "conflict detected"},
|
errCase{services.ErrProjectCodeExists, http.StatusConflict, "conflict detected"},
|
||||||
errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
|
errCase{services.ErrProjectNotFound, http.StatusNotFound, "resource not found"},
|
||||||
errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
|
errCase{services.ErrProjectForbidden, http.StatusForbidden, "access denied"},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, project)
|
c.JSON(http.StatusOK, ProjectWithWarning{Project: project, VariantWarning: variantWarning})
|
||||||
})
|
})
|
||||||
|
|
||||||
projects.POST("/:uuid/archive", func(c *gin.Context) {
|
projects.POST("/:uuid/archive", func(c *gin.Context) {
|
||||||
|
|||||||
@@ -17,19 +17,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrProjectNotFound = errors.New("project not found")
|
ErrProjectNotFound = errors.New("project not found")
|
||||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||||
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
|
||||||
ErrProjectVariantInvalidChars = errors.New("имя варианта содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
|
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
|
||||||
var projectCodeRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
var projectCodeRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||||
|
|
||||||
|
// invalidVariantCharsRe matches runs of characters not allowed in a variant name, so they
|
||||||
|
// can be collapsed to a single separator by sanitizeProjectVariantName.
|
||||||
|
var invalidVariantCharsRe = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||||
|
|
||||||
type ProjectService struct {
|
type ProjectService struct {
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
}
|
}
|
||||||
@@ -61,7 +63,10 @@ type ProjectConfigurationsResult struct {
|
|||||||
Total float64 `json:"total"`
|
Total float64 `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
// Create creates a project. If req.Variant is not a valid variant name (reserved word
|
||||||
|
// "main" or disallowed characters) it is auto-converted into a valid one instead of
|
||||||
|
// rejecting the request; the returned warning is non-empty when that happened.
|
||||||
|
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, string, error) {
|
||||||
var namePtr *string
|
var namePtr *string
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
name := strings.TrimSpace(*req.Name)
|
name := strings.TrimSpace(*req.Name)
|
||||||
@@ -71,17 +76,14 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
|||||||
}
|
}
|
||||||
code := strings.TrimSpace(req.Code)
|
code := strings.TrimSpace(req.Code)
|
||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, fmt.Errorf("project code is required")
|
return nil, "", fmt.Errorf("project code is required")
|
||||||
}
|
}
|
||||||
if !projectCodeRe.MatchString(code) {
|
if !projectCodeRe.MatchString(code) {
|
||||||
return nil, ErrProjectCodeInvalidChars
|
return nil, "", ErrProjectCodeInvalidChars
|
||||||
}
|
|
||||||
variant := strings.TrimSpace(req.Variant)
|
|
||||||
if err := validateProjectVariantName(variant); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
variant, variantWarning := sanitizeProjectVariantName(strings.TrimSpace(req.Variant))
|
||||||
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
|
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -99,43 +101,44 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
|||||||
SyncStatus: "pending",
|
SyncStatus: "pending",
|
||||||
}
|
}
|
||||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := s.enqueueProjectPendingChange(localProject, "create"); err != nil {
|
if err := s.enqueueProjectPendingChange(localProject, "create"); err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
return localdb.LocalToProject(localProject), nil
|
return localdb.LocalToProject(localProject), variantWarning, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdateProjectRequest) (*models.Project, error) {
|
// Update updates a project. Like Create, an invalid req.Variant (reserved word "main" or
|
||||||
|
// disallowed characters) is auto-converted into a valid one instead of rejecting the
|
||||||
|
// request; the returned warning is non-empty when that happened.
|
||||||
|
func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdateProjectRequest) (*models.Project, string, error) {
|
||||||
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrProjectNotFound
|
return nil, "", ErrProjectNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Code != nil {
|
if req.Code != nil {
|
||||||
code := strings.TrimSpace(*req.Code)
|
code := strings.TrimSpace(*req.Code)
|
||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, fmt.Errorf("project code is required")
|
return nil, "", fmt.Errorf("project code is required")
|
||||||
}
|
}
|
||||||
if !projectCodeRe.MatchString(code) {
|
if !projectCodeRe.MatchString(code) {
|
||||||
return nil, ErrProjectCodeInvalidChars
|
return nil, "", ErrProjectCodeInvalidChars
|
||||||
}
|
}
|
||||||
localProject.Code = code
|
localProject.Code = code
|
||||||
}
|
}
|
||||||
|
var variantWarning string
|
||||||
if req.Variant != nil {
|
if req.Variant != nil {
|
||||||
newVariant := strings.TrimSpace(*req.Variant)
|
newVariant := strings.TrimSpace(*req.Variant)
|
||||||
// Block renaming of the main variant (empty Variant) — there must always be a main.
|
// Block renaming of the main variant (empty Variant) — there must always be a main.
|
||||||
if strings.TrimSpace(localProject.Variant) == "" && newVariant != "" {
|
if strings.TrimSpace(localProject.Variant) == "" && newVariant != "" {
|
||||||
return nil, ErrCannotRenameMainVariant
|
return nil, "", ErrCannotRenameMainVariant
|
||||||
}
|
|
||||||
localProject.Variant = newVariant
|
|
||||||
if err := validateProjectVariantName(localProject.Variant); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
localProject.Variant, variantWarning = sanitizeProjectVariantName(newVariant)
|
||||||
}
|
}
|
||||||
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
@@ -163,12 +166,12 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
localProject.UpdatedAt = time.Now()
|
localProject.UpdatedAt = time.Now()
|
||||||
localProject.SyncStatus = "pending"
|
localProject.SyncStatus = "pending"
|
||||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := s.enqueueProjectPendingChange(localProject, "update"); err != nil {
|
if err := s.enqueueProjectPendingChange(localProject, "update"); err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
return localdb.LocalToProject(localProject), nil
|
return localdb.LocalToProject(localProject), variantWarning, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
|
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
|
||||||
@@ -203,14 +206,31 @@ func normalizeProjectVariant(variant string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(variant))
|
return strings.ToLower(strings.TrimSpace(variant))
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateProjectVariantName(variant string) error {
|
// sanitizeProjectVariantName converts an invalid variant name (the reserved word "main",
|
||||||
if normalizeProjectVariant(variant) == "main" {
|
// or a name containing characters outside [A-Za-z0-9._-]) into a valid one, returning a
|
||||||
return ErrReservedMainVariant
|
// warning describing the change. Empty input (the implicit main variant) always passes
|
||||||
|
// through unchanged with no warning. Invalid characters are collapsed to a single "-";
|
||||||
|
// if that empties the name, or the result collides with the reserved "main", a "-variant"
|
||||||
|
// suffix is appended to guarantee a valid, non-reserved result.
|
||||||
|
func sanitizeProjectVariantName(variant string) (sanitized string, warning string) {
|
||||||
|
original := strings.TrimSpace(variant)
|
||||||
|
if original == "" {
|
||||||
|
return "", ""
|
||||||
}
|
}
|
||||||
if variant != "" && !projectCodeRe.MatchString(variant) {
|
sanitized = original
|
||||||
return ErrProjectVariantInvalidChars
|
if !projectCodeRe.MatchString(sanitized) {
|
||||||
|
sanitized = strings.Trim(invalidVariantCharsRe.ReplaceAllString(sanitized, "-"), "-")
|
||||||
}
|
}
|
||||||
return nil
|
if sanitized == "" {
|
||||||
|
sanitized = "variant"
|
||||||
|
}
|
||||||
|
if normalizeProjectVariant(sanitized) == "main" {
|
||||||
|
sanitized += "-variant"
|
||||||
|
}
|
||||||
|
if sanitized == original {
|
||||||
|
return sanitized, ""
|
||||||
|
}
|
||||||
|
return sanitized, fmt.Sprintf("имя варианта %q недопустимо — использовано %q", original, sanitized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
||||||
|
|||||||
@@ -1,37 +1,47 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProjectServiceCreateRejectsReservedMainVariant(t *testing.T) {
|
// TestProjectServiceCreateSanitizesReservedMainVariant verifies that naming a new
|
||||||
|
// variant "main" no longer errors: the name is auto-converted to a valid one and a
|
||||||
|
// warning is returned instead of the request being rejected.
|
||||||
|
func TestProjectServiceCreateSanitizesReservedMainVariant(t *testing.T) {
|
||||||
local, err := newProjectTestLocalDB(t)
|
local, err := newProjectTestLocalDB(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open localdb: %v", err)
|
t.Fatalf("open localdb: %v", err)
|
||||||
}
|
}
|
||||||
service := NewProjectService(local)
|
service := NewProjectService(local)
|
||||||
|
|
||||||
_, err = service.Create("tester", &CreateProjectRequest{
|
project, warning, err := service.Create("tester", &CreateProjectRequest{
|
||||||
Code: "OPS-1",
|
Code: "OPS-1",
|
||||||
Variant: "main",
|
Variant: "main",
|
||||||
})
|
})
|
||||||
if !errors.Is(err, ErrReservedMainVariant) {
|
if err != nil {
|
||||||
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
if warning == "" {
|
||||||
|
t.Fatalf("expected a warning about the reserved variant name")
|
||||||
|
}
|
||||||
|
if project.Variant == "" || normalizeProjectVariant(project.Variant) == "main" {
|
||||||
|
t.Fatalf("expected variant to be converted away from 'main', got %q", project.Variant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProjectServiceUpdateRejectsReservedMainVariant(t *testing.T) {
|
// TestProjectServiceUpdateSanitizesReservedMainVariant mirrors the create case for
|
||||||
|
// renaming an existing (non-main) variant to "main".
|
||||||
|
func TestProjectServiceUpdateSanitizesReservedMainVariant(t *testing.T) {
|
||||||
local, err := newProjectTestLocalDB(t)
|
local, err := newProjectTestLocalDB(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open localdb: %v", err)
|
t.Fatalf("open localdb: %v", err)
|
||||||
}
|
}
|
||||||
service := NewProjectService(local)
|
service := NewProjectService(local)
|
||||||
|
|
||||||
created, err := service.Create("tester", &CreateProjectRequest{
|
created, _, err := service.Create("tester", &CreateProjectRequest{
|
||||||
Code: "OPS-1",
|
Code: "OPS-1",
|
||||||
Variant: "Lenovo",
|
Variant: "Lenovo",
|
||||||
})
|
})
|
||||||
@@ -40,11 +50,41 @@ func TestProjectServiceUpdateRejectsReservedMainVariant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mainName := "main"
|
mainName := "main"
|
||||||
_, err = service.Update(created.UUID, "tester", &UpdateProjectRequest{
|
updated, warning, err := service.Update(created.UUID, "tester", &UpdateProjectRequest{
|
||||||
Variant: &mainName,
|
Variant: &mainName,
|
||||||
})
|
})
|
||||||
if !errors.Is(err, ErrReservedMainVariant) {
|
if err != nil {
|
||||||
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
|
t.Fatalf("update project: %v", err)
|
||||||
|
}
|
||||||
|
if warning == "" {
|
||||||
|
t.Fatalf("expected a warning about the reserved variant name")
|
||||||
|
}
|
||||||
|
if updated.Variant == "" || normalizeProjectVariant(updated.Variant) == "main" {
|
||||||
|
t.Fatalf("expected variant to be converted away from 'main', got %q", updated.Variant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeProjectVariantName_InvalidChars verifies disallowed characters are
|
||||||
|
// stripped/replaced rather than rejected outright.
|
||||||
|
func TestSanitizeProjectVariantName_InvalidChars(t *testing.T) {
|
||||||
|
sanitized, warning := sanitizeProjectVariantName("Лендинг сервер!")
|
||||||
|
if warning == "" {
|
||||||
|
t.Fatalf("expected a warning for invalid characters")
|
||||||
|
}
|
||||||
|
if !projectCodeRe.MatchString(sanitized) {
|
||||||
|
t.Fatalf("sanitized variant %q still fails projectCodeRe", sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeProjectVariantName_ValidPassesThrough ensures already-valid names are
|
||||||
|
// left untouched with no warning.
|
||||||
|
func TestSanitizeProjectVariantName_ValidPassesThrough(t *testing.T) {
|
||||||
|
sanitized, warning := sanitizeProjectVariantName("lenovo-2u")
|
||||||
|
if warning != "" {
|
||||||
|
t.Fatalf("expected no warning, got %q", warning)
|
||||||
|
}
|
||||||
|
if sanitized != "lenovo-2u" {
|
||||||
|
t.Fatalf("expected unchanged variant, got %q", sanitized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
|||||||
projectService := services.NewProjectService(local)
|
projectService := services.NewProjectService(local)
|
||||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||||
|
|
||||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
|
project, _, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create project: %v", err)
|
t.Fatalf("create project: %v", err)
|
||||||
}
|
}
|
||||||
@@ -74,11 +74,11 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
|
|||||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||||
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
|
|
||||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"})
|
project, _, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create project: %v", err)
|
t.Fatalf("create project: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil {
|
if _, _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil {
|
||||||
t.Fatalf("update project: %v", err)
|
t.Fatalf("update project: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -681,8 +681,8 @@ function closeVariantActionModal() {
|
|||||||
|
|
||||||
function findUniqueVariantActionName(baseName, targetCode, excludeProjectUUID) {
|
function findUniqueVariantActionName(baseName, targetCode, excludeProjectUUID) {
|
||||||
const cleanedBase = (baseName || '').trim();
|
const cleanedBase = (baseName || '').trim();
|
||||||
if (!cleanedBase || normalizeVariantLabel(cleanedBase).toLowerCase() === 'main') {
|
if (!cleanedBase) {
|
||||||
return {error: 'Имя варианта не должно быть пустым и не может быть main'};
|
return {error: 'Имя варианта не должно быть пустым'};
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = (targetCode || '').trim();
|
const code = (targetCode || '').trim();
|
||||||
@@ -805,10 +805,6 @@ async function saveVariantAction() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!createResp.ok) {
|
if (!createResp.ok) {
|
||||||
if (createResp.status === 400) {
|
|
||||||
notify('Имя варианта не может быть main', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (createResp.status === 409) {
|
if (createResp.status === 409) {
|
||||||
notify('Вариант с таким кодом и значением уже существует', 'error');
|
notify('Вариант с таким кодом и значением уже существует', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -829,7 +825,7 @@ async function saveVariantAction() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeVariantActionModal();
|
closeVariantActionModal();
|
||||||
notify('Копия варианта создана', 'success');
|
notify(created.variant_warning ? 'Копия варианта создана. ' + created.variant_warning : 'Копия варианта создана', 'success');
|
||||||
window.location.href = '/projects/' + created.uuid;
|
window.location.href = '/projects/' + created.uuid;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -846,10 +842,6 @@ async function saveVariantAction() {
|
|||||||
body: JSON.stringify({code: code, variant: name})
|
body: JSON.stringify({code: code, variant: name})
|
||||||
});
|
});
|
||||||
if (!updateResp.ok) {
|
if (!updateResp.ok) {
|
||||||
if (updateResp.status === 400) {
|
|
||||||
notify('Имя варианта не может быть main', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (updateResp.status === 409) {
|
if (updateResp.status === 409) {
|
||||||
notify('Вариант с таким кодом и значением уже существует', 'error');
|
notify('Вариант с таким кодом и значением уже существует', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -857,12 +849,13 @@ async function saveVariantAction() {
|
|||||||
notify('Не удалось сохранить вариант', 'error');
|
notify('Не удалось сохранить вариант', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const updated = await updateResp.json().catch(() => null);
|
||||||
|
|
||||||
closeVariantActionModal();
|
closeVariantActionModal();
|
||||||
await loadProject();
|
await loadProject();
|
||||||
await loadConfigs();
|
await loadConfigs();
|
||||||
updateDeleteVariantButton();
|
updateDeleteVariantButton();
|
||||||
notify('Вариант обновлён', 'success');
|
notify(updated && updated.variant_warning ? 'Вариант обновлён. ' + updated.variant_warning : 'Вариант обновлён', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createNewVariant() {
|
async function createNewVariant() {
|
||||||
@@ -874,10 +867,6 @@ async function createNewVariant() {
|
|||||||
showToast('Укажите вариант', 'error');
|
showToast('Укажите вариант', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^[A-Za-z0-9._-]+$/.test(variant)) {
|
|
||||||
showToast('Имя варианта содержит недопустимые символы. Разрешены: буквы, цифры, дефис, точка, подчёркивание.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = {
|
const payload = {
|
||||||
code: code,
|
code: code,
|
||||||
variant: variant,
|
variant: variant,
|
||||||
@@ -895,7 +884,11 @@ async function createNewVariant() {
|
|||||||
}
|
}
|
||||||
const created = await resp.json().catch(() => null);
|
const created = await resp.json().catch(() => null);
|
||||||
closeNewVariantModal();
|
closeNewVariantModal();
|
||||||
showToast('Вариант создан', 'success');
|
if (created && created.variant_warning) {
|
||||||
|
showToast('Вариант создан. ' + created.variant_warning, 'info');
|
||||||
|
} else {
|
||||||
|
showToast('Вариант создан', 'success');
|
||||||
|
}
|
||||||
if (created && created.uuid) {
|
if (created && created.uuid) {
|
||||||
window.location.href = '/projects/' + created.uuid;
|
window.location.href = '/projects/' + created.uuid;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -406,10 +406,6 @@ async function createProject() {
|
|||||||
alert('Код проекта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
|
alert('Код проекта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (variant && !/^[A-Za-z0-9._-]+$/.test(variant)) {
|
|
||||||
alert('Имя варианта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resp = await fetch('/api/projects', {
|
const resp = await fetch('/api/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
@@ -433,7 +429,11 @@ async function createProject() {
|
|||||||
alert('Не удалось создать проект');
|
alert('Не удалось создать проект');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const created = await resp.json().catch(() => null);
|
||||||
closeCreateProjectModal();
|
closeCreateProjectModal();
|
||||||
|
if (created && created.variant_warning) {
|
||||||
|
alert(created.variant_warning);
|
||||||
|
}
|
||||||
loadProjects();
|
loadProjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +511,9 @@ async function copyProject(projectUUID, projectName) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newProject = await createResp.json();
|
const newProject = await createResp.json();
|
||||||
|
if (newProject.variant_warning) {
|
||||||
|
alert(newProject.variant_warning);
|
||||||
|
}
|
||||||
|
|
||||||
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
|
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
|
||||||
if (!listResp.ok) {
|
if (!listResp.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user