[MM-62695] Extend property types for CPA (#30201)
* test: Add unit tests for custom profile attributes select options
* feat: Add custom profile attributes model with validation and constants
* refactor: Trim spaces from name and color in custom profile attribute select option constructor
* gofmt
* refactor: Fix typo in custom profile attributes select option function name
* feat: Add IsValid method to validate CustomProfileAttributesSelectOptions
* refactor: Replace map[string]bool with map[string]struct{} for key existence check
* refactor: Rename NewCustomProfileAttributeSelectOption to NewCustomProfileAttributesSelectOption
* feat: Add validation to prevent empty custom profile attribute options
* refactor: Add validation and creation methods for custom profile attributes
* feat: Add index number to validation error messages in custom profile attributes
* fix tests
* add default visibility
* feat: Add comprehensive test cases for custom profile attributes field validation
* fix: Update custom profile attributes map keys to use capitalized names
* feat: Add support for lowercase and title case keys in custom profile attributes map
* test: Add comprehensive test for NewCustomProfileAttributesSelectOptionFromMap
* feat: Add validation for custom profile attributes fields
* refactor: Update CustomProfileAttributesSelectOption constructor to prioritize ID parameter
* test: Add test cases for preserving IDs in custom profile attributes
* feat: Enhance ID validation and trimming in custom profile attributes
* don't do validation in constructor
* test: Add test case for preserving option IDs when patching select field
* improve test
* i18n
* refactor: Modify CustomProfileAttributesSelectOption to use lowercase JSON keys
* fix casing in custom profilte attributes test
* refactor: Use consistent "ValidateCPAField" in error messages for custom profile attributes
* use custom types rather than string
* lint
* fix api test
* refactor: Make color field optional in custom profile attributes
* style
* generic options
* removed unused i18n
* test: Add tests for NewCPAFieldFromPropertyField and CPAFieldToPropertyField
* test: Add test case for property field with empty attributes
* refactor: Cleanup whitespace and remove empty Attrs in custom profile attributes test
* test: Add test case for CPA field with empty attributes
* refactor: Improve custom profile attributes field handling and validation
* refactor: Move validateCustomProfileAttributesField to Validate method on CPAField struct
* use CPAField
* code style
* add validation and tests
* tests
* i18n
* err->appErr
* fix TestDeleteCPAField test
* i18n
* Add SAML and LDAP attr
* rename CustomProfileAttributes in method to CPA
* rename CPASortOrder method
* rearrange consts
* use Len test method
* sanitize and validate
* manage error the same way property field and value do
* fix: Update test error ID for custom profile attributes validation
* test: Update error ID expectations in custom profile attributes tests
* refactor: Convert CPAAttrs.SortOrder from string to int
* json uses float64
* feat: Add length validation for custom profile attribute option name and color
---------
Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
@@ -3,20 +3,209 @@
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const CustomProfileAttributesPropertyGroupName = "custom_profile_attributes"
|
||||
|
||||
const CustomProfileAttributesPropertyAttrsSortOrder = "sort_order"
|
||||
|
||||
func CustomProfileAttributesPropertySortOrder(p *PropertyField) int {
|
||||
func CPASortOrder(p *PropertyField) int {
|
||||
value, ok := p.Attrs[CustomProfileAttributesPropertyAttrsSortOrder]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
order, ok := value.(float64)
|
||||
sortOrder, ok := value.(float64)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
return int(order)
|
||||
return int(sortOrder)
|
||||
}
|
||||
|
||||
const (
|
||||
// Attributes keys
|
||||
CustomProfileAttributesPropertyAttrsSortOrder = "sort_order"
|
||||
CustomProfileAttributesPropertyAttrsValueType = "value_type"
|
||||
CustomProfileAttributesPropertyAttrsVisibility = "visibility"
|
||||
CustomProfileAttributesPropertyAttrsLDAP = "ldap"
|
||||
CustomProfileAttributesPropertyAttrsSAML = "saml"
|
||||
|
||||
// Value Types
|
||||
CustomProfileAttributesValueTypeEmail = "email"
|
||||
CustomProfileAttributesValueTypeURL = "url"
|
||||
CustomProfileAttributesValueTypePhone = "phone"
|
||||
|
||||
// Visibility
|
||||
CustomProfileAttributesVisibilityHidden = "hidden"
|
||||
CustomProfileAttributesVisibilityWhenSet = "when_set"
|
||||
CustomProfileAttributesVisibilityAlways = "always"
|
||||
CustomProfileAttributesVisibilityDefault = CustomProfileAttributesVisibilityWhenSet
|
||||
)
|
||||
|
||||
const (
|
||||
CPAOptionNameMaxLength = 128
|
||||
CPAOptionColorMaxLength = 128
|
||||
)
|
||||
|
||||
func IsKnownCPAValueType(valueType string) bool {
|
||||
switch valueType {
|
||||
case CustomProfileAttributesValueTypeEmail,
|
||||
CustomProfileAttributesValueTypeURL,
|
||||
CustomProfileAttributesValueTypePhone:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func IsKnownCPAVisibility(visibility string) bool {
|
||||
switch visibility {
|
||||
case CustomProfileAttributesVisibilityHidden,
|
||||
CustomProfileAttributesVisibilityWhenSet,
|
||||
CustomProfileAttributesVisibilityAlways:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type CustomProfileAttributesSelectOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func (c CustomProfileAttributesSelectOption) GetID() string {
|
||||
return c.ID
|
||||
}
|
||||
|
||||
func (c CustomProfileAttributesSelectOption) GetName() string {
|
||||
return c.Name
|
||||
}
|
||||
|
||||
func (c *CustomProfileAttributesSelectOption) SetID(id string) {
|
||||
c.ID = id
|
||||
}
|
||||
|
||||
func (c CustomProfileAttributesSelectOption) IsValid() error {
|
||||
if c.ID == "" {
|
||||
return errors.New("id cannot be empty")
|
||||
}
|
||||
|
||||
if !IsValidId(c.ID) {
|
||||
return errors.New("id is not a valid ID")
|
||||
}
|
||||
|
||||
if c.Name == "" {
|
||||
return errors.New("name cannot be empty")
|
||||
}
|
||||
|
||||
if len(c.Name) > CPAOptionNameMaxLength {
|
||||
return fmt.Errorf("name is too long, max length is %d", CPAOptionNameMaxLength)
|
||||
}
|
||||
|
||||
if c.Color != "" && len(c.Color) > CPAOptionColorMaxLength {
|
||||
return fmt.Errorf("color is too long, max length is %d", CPAOptionColorMaxLength)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type CPAField struct {
|
||||
PropertyField
|
||||
Attrs CPAAttrs
|
||||
}
|
||||
|
||||
type CPAAttrs struct {
|
||||
Visibility string `json:"visibility"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Options PropertyOptions[*CustomProfileAttributesSelectOption] `json:"options"`
|
||||
ValueType string `json:"value_type"`
|
||||
LDAP string `json:"ldap"`
|
||||
SAML string `json:"saml"`
|
||||
}
|
||||
|
||||
func (c *CPAField) ToPropertyField() *PropertyField {
|
||||
pf := c.PropertyField
|
||||
|
||||
pf.Attrs = StringInterface{
|
||||
CustomProfileAttributesPropertyAttrsVisibility: c.Attrs.Visibility,
|
||||
CustomProfileAttributesPropertyAttrsSortOrder: c.Attrs.SortOrder,
|
||||
CustomProfileAttributesPropertyAttrsValueType: c.Attrs.ValueType,
|
||||
PropertyFieldAttributeOptions: c.Attrs.Options,
|
||||
CustomProfileAttributesPropertyAttrsLDAP: c.Attrs.LDAP,
|
||||
CustomProfileAttributesPropertyAttrsSAML: c.Attrs.SAML,
|
||||
}
|
||||
|
||||
return &pf
|
||||
}
|
||||
|
||||
func (c *CPAField) SanitizeAndValidate() *AppError {
|
||||
switch c.Type {
|
||||
case PropertyFieldTypeText:
|
||||
if valueType := strings.TrimSpace(c.Attrs.ValueType); valueType != "" {
|
||||
if !IsKnownCPAValueType(valueType) {
|
||||
return NewAppError("SanitizeAndValidate", "app.custom_profile_attributes.sanitize_and_validate.app_error", map[string]any{
|
||||
"AttributeName": CustomProfileAttributesPropertyAttrsValueType,
|
||||
"Reason": "unknown value type",
|
||||
}, "", http.StatusUnprocessableEntity)
|
||||
}
|
||||
c.Attrs.ValueType = valueType
|
||||
}
|
||||
|
||||
case PropertyFieldTypeSelect, PropertyFieldTypeMultiselect:
|
||||
options := c.Attrs.Options
|
||||
|
||||
// add an ID to options with no ID
|
||||
for i := range options {
|
||||
if options[i].ID == "" {
|
||||
options[i].ID = NewId()
|
||||
}
|
||||
}
|
||||
|
||||
if err := options.IsValid(); err != nil {
|
||||
return NewAppError("SanitizeAndValidate", "app.custom_profile_attributes.sanitize_and_validate.app_error", map[string]any{
|
||||
"AttributeName": PropertyFieldAttributeOptions,
|
||||
"Reason": err.Error(),
|
||||
}, "", http.StatusUnprocessableEntity).Wrap(err)
|
||||
}
|
||||
c.Attrs.Options = options
|
||||
}
|
||||
|
||||
visibility := CustomProfileAttributesVisibilityDefault
|
||||
if visibilityAttr := strings.TrimSpace(c.Attrs.Visibility); visibilityAttr != "" {
|
||||
if !IsKnownCPAVisibility(visibilityAttr) {
|
||||
return NewAppError("SanitizeAndValidate", "app.custom_profile_attributes.sanitize_and_validate.app_error", map[string]any{
|
||||
"AttributeName": CustomProfileAttributesPropertyAttrsVisibility,
|
||||
"Reason": "unknown visibility",
|
||||
}, "", http.StatusUnprocessableEntity)
|
||||
}
|
||||
visibility = visibilityAttr
|
||||
}
|
||||
c.Attrs.Visibility = visibility
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewCPAFieldFromPropertyField(pf *PropertyField) (*CPAField, error) {
|
||||
attrsJSON, err := json.Marshal(pf.Attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var attrs CPAAttrs
|
||||
err = json.Unmarshal(attrsJSON, &attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &CPAField{
|
||||
PropertyField: *pf,
|
||||
Attrs: attrs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
511
server/public/model/custom_profile_attributes_test.go
Обычный файл
511
server/public/model/custom_profile_attributes_test.go
Обычный файл
@@ -0,0 +1,511 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewCPAFieldFromPropertyField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
propertyField *PropertyField
|
||||
wantAttrs CPAAttrs
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid property field with all attributes",
|
||||
propertyField: &PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: CustomProfileAttributesPropertyGroupName,
|
||||
Name: "Test Field",
|
||||
Type: PropertyFieldTypeSelect,
|
||||
Attrs: StringInterface{
|
||||
CustomProfileAttributesPropertyAttrsVisibility: CustomProfileAttributesVisibilityAlways,
|
||||
CustomProfileAttributesPropertyAttrsSortOrder: 1,
|
||||
CustomProfileAttributesPropertyAttrsValueType: CustomProfileAttributesValueTypeEmail,
|
||||
PropertyFieldAttributeOptions: []*CustomProfileAttributesSelectOption{
|
||||
{
|
||||
ID: NewId(),
|
||||
Name: "Option 1",
|
||||
Color: "#FF0000",
|
||||
},
|
||||
},
|
||||
},
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
},
|
||||
wantAttrs: CPAAttrs{
|
||||
Visibility: CustomProfileAttributesVisibilityAlways,
|
||||
SortOrder: 1,
|
||||
ValueType: CustomProfileAttributesValueTypeEmail,
|
||||
Options: []*CustomProfileAttributesSelectOption{
|
||||
{
|
||||
ID: "", // ID will be different in each test run
|
||||
Name: "Option 1",
|
||||
Color: "#FF0000",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid property field with minimal attributes",
|
||||
propertyField: &PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: CustomProfileAttributesPropertyGroupName,
|
||||
Name: "Test Field",
|
||||
Type: PropertyFieldTypeText,
|
||||
Attrs: StringInterface{
|
||||
CustomProfileAttributesPropertyAttrsVisibility: CustomProfileAttributesVisibilityWhenSet,
|
||||
CustomProfileAttributesPropertyAttrsSortOrder: 2,
|
||||
},
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
},
|
||||
wantAttrs: CPAAttrs{
|
||||
Visibility: CustomProfileAttributesVisibilityWhenSet,
|
||||
SortOrder: 2,
|
||||
ValueType: "",
|
||||
Options: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "property field with empty attributes",
|
||||
propertyField: &PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: CustomProfileAttributesPropertyGroupName,
|
||||
Name: "Empty Field",
|
||||
Type: PropertyFieldTypeText,
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
},
|
||||
wantAttrs: CPAAttrs{
|
||||
Visibility: "",
|
||||
SortOrder: 0,
|
||||
ValueType: "",
|
||||
Options: nil,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cpaField, err := NewCPAFieldFromPropertyField(tt.propertyField)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cpaField)
|
||||
|
||||
// Check that the PropertyField was copied correctly
|
||||
assert.Equal(t, tt.propertyField.ID, cpaField.ID)
|
||||
assert.Equal(t, tt.propertyField.GroupID, cpaField.GroupID)
|
||||
assert.Equal(t, tt.propertyField.Name, cpaField.Name)
|
||||
assert.Equal(t, tt.propertyField.Type, cpaField.Type)
|
||||
|
||||
// Check that the attributes were parsed correctly
|
||||
assert.Equal(t, tt.wantAttrs.Visibility, cpaField.Attrs.Visibility)
|
||||
assert.Equal(t, tt.wantAttrs.SortOrder, cpaField.Attrs.SortOrder)
|
||||
assert.Equal(t, tt.wantAttrs.ValueType, cpaField.Attrs.ValueType)
|
||||
|
||||
// For options, we need to check length since IDs will be different
|
||||
if tt.wantAttrs.Options != nil {
|
||||
require.NotNil(t, cpaField.Attrs.Options)
|
||||
assert.Len(t, cpaField.Attrs.Options, len(tt.wantAttrs.Options))
|
||||
if len(tt.wantAttrs.Options) > 0 {
|
||||
assert.Equal(t, tt.wantAttrs.Options[0].Name, cpaField.Attrs.Options[0].Name)
|
||||
assert.Equal(t, tt.wantAttrs.Options[0].Color, cpaField.Attrs.Options[0].Color)
|
||||
}
|
||||
} else {
|
||||
assert.Nil(t, cpaField.Attrs.Options)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPAFieldToPropertyField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cpaField *CPAField
|
||||
}{
|
||||
{
|
||||
name: "convert CPA field with all attributes",
|
||||
cpaField: &CPAField{
|
||||
PropertyField: PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: CustomProfileAttributesPropertyGroupName,
|
||||
Name: "Test Field",
|
||||
Type: PropertyFieldTypeSelect,
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
},
|
||||
Attrs: CPAAttrs{
|
||||
Visibility: CustomProfileAttributesVisibilityAlways,
|
||||
SortOrder: 1,
|
||||
ValueType: CustomProfileAttributesValueTypeEmail,
|
||||
Options: []*CustomProfileAttributesSelectOption{
|
||||
{
|
||||
ID: NewId(),
|
||||
Name: "Option 1",
|
||||
Color: "#FF0000",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert CPA field with minimal attributes",
|
||||
cpaField: &CPAField{
|
||||
PropertyField: PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: CustomProfileAttributesPropertyGroupName,
|
||||
Name: "Test Field",
|
||||
Type: PropertyFieldTypeText,
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
},
|
||||
Attrs: CPAAttrs{
|
||||
Visibility: CustomProfileAttributesVisibilityWhenSet,
|
||||
SortOrder: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert CPA field with empty attributes",
|
||||
cpaField: &CPAField{
|
||||
PropertyField: PropertyField{
|
||||
ID: NewId(),
|
||||
GroupID: CustomProfileAttributesPropertyGroupName,
|
||||
Name: "Empty Field",
|
||||
Type: PropertyFieldTypeText,
|
||||
CreateAt: GetMillis(),
|
||||
UpdateAt: GetMillis(),
|
||||
},
|
||||
Attrs: CPAAttrs{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pf := tt.cpaField.ToPropertyField()
|
||||
|
||||
require.NotNil(t, pf)
|
||||
|
||||
// Check that the PropertyField was copied correctly
|
||||
assert.Equal(t, tt.cpaField.ID, pf.ID)
|
||||
assert.Equal(t, tt.cpaField.GroupID, pf.GroupID)
|
||||
assert.Equal(t, tt.cpaField.Name, pf.Name)
|
||||
assert.Equal(t, tt.cpaField.Type, pf.Type)
|
||||
|
||||
// Check that the attributes were converted correctly
|
||||
assert.Equal(t, tt.cpaField.Attrs.Visibility, pf.Attrs[CustomProfileAttributesPropertyAttrsVisibility])
|
||||
assert.Equal(t, tt.cpaField.Attrs.SortOrder, pf.Attrs[CustomProfileAttributesPropertyAttrsSortOrder])
|
||||
assert.Equal(t, tt.cpaField.Attrs.ValueType, pf.Attrs[CustomProfileAttributesPropertyAttrsValueType])
|
||||
|
||||
// Check options
|
||||
options, ok := pf.Attrs[PropertyFieldAttributeOptions]
|
||||
if tt.cpaField.Attrs.Options != nil {
|
||||
require.True(t, ok)
|
||||
optionsSlice, ok := options.(PropertyOptions[*CustomProfileAttributesSelectOption])
|
||||
require.True(t, ok)
|
||||
assert.Len(t, optionsSlice, len(tt.cpaField.Attrs.Options))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomProfileAttributeSelectOptionIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
option CustomProfileAttributesSelectOption
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid option with color",
|
||||
option: CustomProfileAttributesSelectOption{
|
||||
ID: NewId(),
|
||||
Name: "Test Option",
|
||||
Color: "#FF0000",
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "valid option without color",
|
||||
option: CustomProfileAttributesSelectOption{
|
||||
ID: NewId(),
|
||||
Name: "Test Option",
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "empty ID",
|
||||
option: CustomProfileAttributesSelectOption{
|
||||
ID: "",
|
||||
Name: "Test Option",
|
||||
Color: "#FF0000",
|
||||
},
|
||||
wantErr: "id cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "invalid ID",
|
||||
option: CustomProfileAttributesSelectOption{
|
||||
ID: "invalid-id",
|
||||
Name: "Test Option",
|
||||
Color: "#FF0000",
|
||||
},
|
||||
wantErr: "id is not a valid ID",
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
option: CustomProfileAttributesSelectOption{
|
||||
ID: NewId(),
|
||||
Name: "",
|
||||
Color: "#FF0000",
|
||||
},
|
||||
wantErr: "name cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "name too long",
|
||||
option: CustomProfileAttributesSelectOption{
|
||||
ID: NewId(),
|
||||
Name: strings.Repeat("a", CPAOptionNameMaxLength+1),
|
||||
Color: "#FF0000",
|
||||
},
|
||||
wantErr: fmt.Sprintf("name is too long, max length is %d", CPAOptionNameMaxLength),
|
||||
},
|
||||
{
|
||||
name: "color too long",
|
||||
option: CustomProfileAttributesSelectOption{
|
||||
ID: NewId(),
|
||||
Name: "Test Option",
|
||||
Color: strings.Repeat("a", CPAOptionColorMaxLength+1),
|
||||
},
|
||||
wantErr: fmt.Sprintf("color is too long, max length is %d", CPAOptionColorMaxLength),
|
||||
},
|
||||
{
|
||||
name: "name exactly at max length",
|
||||
option: CustomProfileAttributesSelectOption{
|
||||
ID: NewId(),
|
||||
Name: strings.Repeat("a", CPAOptionNameMaxLength),
|
||||
Color: "#FF0000",
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "color exactly at max length",
|
||||
option: CustomProfileAttributesSelectOption{
|
||||
ID: NewId(),
|
||||
Name: "Test Option",
|
||||
Color: strings.Repeat("a", CPAOptionColorMaxLength),
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.option.IsValid()
|
||||
if tt.wantErr != "" {
|
||||
assert.EqualError(t, err, tt.wantErr)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPAField_SanitizeAndValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
field *CPAField
|
||||
expectError bool
|
||||
errorId string
|
||||
expectedAttrs CPAAttrs
|
||||
checkOptionsID bool
|
||||
}{
|
||||
{
|
||||
name: "valid text field with no value type",
|
||||
field: &CPAField{
|
||||
PropertyField: PropertyField{
|
||||
Type: PropertyFieldTypeText,
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectedAttrs: CPAAttrs{
|
||||
Visibility: "when_set",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid text field with valid value type and whitespace",
|
||||
field: &CPAField{
|
||||
PropertyField: PropertyField{
|
||||
Type: PropertyFieldTypeText,
|
||||
},
|
||||
Attrs: CPAAttrs{
|
||||
ValueType: " email ",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectedAttrs: CPAAttrs{
|
||||
Visibility: "when_set",
|
||||
ValueType: CustomProfileAttributesValueTypeEmail,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid text field with visibility and whitespace",
|
||||
field: &CPAField{
|
||||
PropertyField: PropertyField{
|
||||
Type: PropertyFieldTypeText,
|
||||
},
|
||||
Attrs: CPAAttrs{
|
||||
Visibility: " hidden ",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectedAttrs: CPAAttrs{
|
||||
Visibility: CustomProfileAttributesVisibilityHidden,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid text field with invalid value type",
|
||||
field: &CPAField{
|
||||
PropertyField: PropertyField{
|
||||
Type: PropertyFieldTypeText,
|
||||
},
|
||||
Attrs: CPAAttrs{
|
||||
ValueType: "invalid_type",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorId: "app.custom_profile_attributes.sanitize_and_validate.app_error",
|
||||
},
|
||||
{
|
||||
name: "valid select field with valid options",
|
||||
field: &CPAField{
|
||||
PropertyField: PropertyField{
|
||||
Type: PropertyFieldTypeSelect,
|
||||
},
|
||||
Attrs: CPAAttrs{
|
||||
Options: []*CustomProfileAttributesSelectOption{
|
||||
{
|
||||
Name: "Option 1",
|
||||
Color: "#123456",
|
||||
},
|
||||
{
|
||||
Name: "Option 2",
|
||||
Color: "#654321",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectedAttrs: CPAAttrs{
|
||||
Visibility: CustomProfileAttributesVisibilityDefault,
|
||||
Options: PropertyOptions[*CustomProfileAttributesSelectOption]{
|
||||
{Name: "Option 1", Color: "#123456"},
|
||||
{Name: "Option 2", Color: "#654321"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid select field with valid options with ids",
|
||||
field: &CPAField{
|
||||
PropertyField: PropertyField{
|
||||
Type: PropertyFieldTypeSelect,
|
||||
},
|
||||
Attrs: CPAAttrs{
|
||||
Options: []*CustomProfileAttributesSelectOption{
|
||||
{
|
||||
ID: "t9ceh651eir4zkhyh4m54s5r7w",
|
||||
Name: "Option 1",
|
||||
Color: "#123456",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectedAttrs: CPAAttrs{
|
||||
Visibility: CustomProfileAttributesVisibilityDefault,
|
||||
Options: PropertyOptions[*CustomProfileAttributesSelectOption]{
|
||||
{ID: "t9ceh651eir4zkhyh4m54s5r7w", Name: "Option 1", Color: "#123456"},
|
||||
},
|
||||
},
|
||||
checkOptionsID: true,
|
||||
},
|
||||
{
|
||||
name: "invalid select field with duplicate option names",
|
||||
field: &CPAField{
|
||||
PropertyField: PropertyField{
|
||||
Type: PropertyFieldTypeSelect,
|
||||
},
|
||||
Attrs: CPAAttrs{
|
||||
Options: []*CustomProfileAttributesSelectOption{
|
||||
{
|
||||
Name: "Option 1",
|
||||
Color: "opt1",
|
||||
},
|
||||
{
|
||||
Name: "Option 1",
|
||||
Color: "opt2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorId: "app.custom_profile_attributes.sanitize_and_validate.app_error",
|
||||
},
|
||||
{
|
||||
name: "invalid field with unknown visibility",
|
||||
field: &CPAField{
|
||||
PropertyField: PropertyField{
|
||||
Type: PropertyFieldTypeText,
|
||||
},
|
||||
Attrs: CPAAttrs{
|
||||
Visibility: "unknown",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorId: "app.custom_profile_attributes.sanitize_and_validate.app_error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.field.SanitizeAndValidate()
|
||||
if tt.expectError {
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, tt.errorId, err.Id)
|
||||
} else {
|
||||
var ogErr error
|
||||
if err != nil {
|
||||
ogErr = err.Unwrap()
|
||||
}
|
||||
require.Nilf(t, err, "unexpected error: %v, with original error: %v", err, ogErr)
|
||||
|
||||
assert.Equal(t, tt.expectedAttrs.Visibility, tt.field.Attrs.Visibility)
|
||||
assert.Equal(t, tt.expectedAttrs.ValueType, tt.field.Attrs.ValueType)
|
||||
|
||||
for i := range tt.expectedAttrs.Options {
|
||||
if tt.checkOptionsID {
|
||||
assert.Equal(t, tt.expectedAttrs.Options[i].ID, tt.field.Attrs.Options[i].ID)
|
||||
}
|
||||
assert.Equal(t, tt.expectedAttrs.Options[i].Name, tt.field.Attrs.Options[i].Name)
|
||||
assert.Equal(t, tt.expectedAttrs.Options[i].Color, tt.field.Attrs.Options[i].Color)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -178,3 +180,55 @@ type PropertyFieldSearchOpts struct {
|
||||
func (pf *PropertyField) GetAttr(key string) any {
|
||||
return pf.Attrs[key]
|
||||
}
|
||||
|
||||
const PropertyFieldAttributeOptions = "options"
|
||||
|
||||
type PropertyOption interface {
|
||||
GetID() string
|
||||
GetName() string
|
||||
SetID(id string)
|
||||
IsValid() error
|
||||
}
|
||||
|
||||
type PropertyOptions[T PropertyOption] []T
|
||||
|
||||
func NewPropertyOptionsFromFieldAttrs[T PropertyOption](optionsArr any) (PropertyOptions[T], error) {
|
||||
options := PropertyOptions[T]{}
|
||||
b, err := json.Marshal(optionsArr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal options: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(b, &options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal options: %w", err)
|
||||
}
|
||||
|
||||
for i := range options {
|
||||
if options[i].GetID() == "" {
|
||||
options[i].SetID(NewId())
|
||||
}
|
||||
}
|
||||
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func (p PropertyOptions[T]) IsValid() error {
|
||||
if len(p) == 0 {
|
||||
return errors.New("options list cannot be empty")
|
||||
}
|
||||
|
||||
seenNames := make(map[string]struct{})
|
||||
for i, option := range p {
|
||||
if err := option.IsValid(); err != nil {
|
||||
return fmt.Errorf("invalid option at index %d: %w", i, err)
|
||||
}
|
||||
|
||||
if _, exists := seenNames[option.GetName()]; exists {
|
||||
return fmt.Errorf("duplicate option name found at index %d: %s", i, option.GetName())
|
||||
}
|
||||
seenNames[option.GetName()] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user