Files
team/server/public/model/property_field_test.go
7aec4c4654 PSAv2 endpoint improvements (#36782)
* Updates search opts and cursor for searching property fields

* Adds store changes and tests to accomodate the new search options

* Add an index for improving delta query efficiency

* Enhances the get property fields endpoint to support deltas and hierarchical searches

* Adds new fields search endpoint

* Adds since to the property values endpoints

* Adds property value endpoint documentation

* Fix linter

* Address coderabbit comments

* Complete i18n

* Minor improvements

* Consistently apply DWIM semantics around object_type system

* Add support for DM/GMs

* Move auditRec to the top of the searchPropertyFieldsCore method

* Update since mechanics to use greater or equal

* Update API docs

* Updated tests to filter after the semantics changes

---------

Co-authored-by: Miguel de la Cruz <[email protected]>
2026-06-22 17:27:02 +02:00

1854 lines
50 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPropertyField_PreSave(t *testing.T) {
t.Run("sets ID if empty", func(t *testing.T) {
pf := &PropertyField{}
pf.PreSave()
assert.NotEmpty(t, pf.ID)
assert.Len(t, pf.ID, 26) // Length of NewId()
})
t.Run("keeps existing ID", func(t *testing.T) {
pf := &PropertyField{ID: "existing_id"}
pf.PreSave()
assert.Equal(t, "existing_id", pf.ID)
})
t.Run("sets CreateAt if zero", func(t *testing.T) {
pf := &PropertyField{}
pf.PreSave()
assert.NotZero(t, pf.CreateAt)
})
t.Run("sets UpdateAt equal to CreateAt", func(t *testing.T) {
pf := &PropertyField{}
pf.PreSave()
assert.Equal(t, pf.CreateAt, pf.UpdateAt)
})
t.Run("sets DeleteAt to 0 for new fields", func(t *testing.T) {
pf := &PropertyField{DeleteAt: 12345}
pf.PreSave()
assert.Zero(t, pf.DeleteAt)
})
t.Run("always sets DeleteAt to 0", func(t *testing.T) {
existingCreateAt := int64(12345)
existingDeleteAt := int64(67890)
pf := &PropertyField{
CreateAt: existingCreateAt,
DeleteAt: existingDeleteAt,
}
pf.PreSave()
assert.Zero(t, pf.DeleteAt)
})
}
func TestPropertyField_IsValid(t *testing.T) {
t.Run("valid field", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("invalid ID", func(t *testing.T) {
pf := &PropertyField{
ID: "invalid",
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("invalid GroupID", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: "invalid",
Name: "test field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("empty name", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("invalid type", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: "invalid",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("zero CreateAt", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
CreateAt: 0,
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("zero UpdateAt", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
UpdateAt: 0,
}
require.Error(t, pf.IsValid())
})
t.Run("Name exceeds maximum length", func(t *testing.T) {
longName := strings.Repeat("a", PropertyFieldNameMaxRunes+1)
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: longName,
Type: PropertyFieldTypeText,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("TargetType exceeds maximum length", func(t *testing.T) {
longTargetType := strings.Repeat("a", PropertyFieldTargetTypeMaxRunes+1)
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: longTargetType,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv2 invalid TargetType", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: "invalid",
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv1 custom TargetType is valid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: "custom_target",
ObjectType: "",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("PSAv1 empty TargetType is valid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: "",
ObjectType: "",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("PSAv2 empty TargetType is invalid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: "",
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv2 valid TargetType system", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("PSAv2 valid TargetType team with valid TargetID", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelTeam),
TargetID: NewId(),
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("PSAv2 valid TargetType channel with valid TargetID", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelChannel),
TargetID: NewId(),
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("PSAv2 system TargetType with non-empty TargetID is invalid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
TargetID: NewId(),
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv2 team TargetType with empty TargetID is invalid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelTeam),
TargetID: "",
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv2 channel TargetType with empty TargetID is invalid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelChannel),
TargetID: "",
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv2 system ObjectType with system TargetType is valid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
ObjectType: PropertyFieldObjectTypeSystem,
TargetType: string(PropertyFieldTargetLevelSystem),
TargetID: "",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("PSAv2 system ObjectType with team TargetType is invalid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
ObjectType: PropertyFieldObjectTypeSystem,
TargetType: string(PropertyFieldTargetLevelTeam),
TargetID: NewId(),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv2 system ObjectType with channel TargetType is invalid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
ObjectType: PropertyFieldObjectTypeSystem,
TargetType: string(PropertyFieldTargetLevelChannel),
TargetID: NewId(),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv2 team TargetType with invalid TargetID is invalid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelTeam),
TargetID: "not-a-valid-id",
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv2 channel TargetType with invalid TargetID is invalid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelChannel),
TargetID: "not-a-valid-id",
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv1 team TargetType without TargetID is valid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: "team",
TargetID: "",
ObjectType: "",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("TargetID exceeds maximum length", func(t *testing.T) {
longTargetID := strings.Repeat("a", PropertyFieldTargetIDMaxRunes+1)
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetID: longTargetID,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("Name at maximum length is valid", func(t *testing.T) {
maxLengthName := strings.Repeat("a", PropertyFieldNameMaxRunes)
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: maxLengthName,
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("TargetID at maximum length is valid", func(t *testing.T) {
maxLengthTargetID := strings.Repeat("a", PropertyFieldTargetIDMaxRunes)
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
TargetID: maxLengthTargetID,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("empty ObjectType is valid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
ObjectType: "",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("ObjectType with value is valid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("ObjectType exceeds maximum length", func(t *testing.T) {
longObjectType := strings.Repeat("a", PropertyFieldObjectTypeMaxRunes+1)
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
ObjectType: longObjectType,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv2 invalid ObjectType", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
ObjectType: "invalid",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv2 valid ObjectType post", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
ObjectType: PropertyFieldObjectTypePost,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("PSAv2 valid ObjectType channel", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
ObjectType: PropertyFieldObjectTypeChannel,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("PSAv2 valid ObjectType user", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
ObjectType: PropertyFieldObjectTypeUser,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("template object type requires TargetType", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "template field",
Type: PropertyFieldTypeSelect,
ObjectType: PropertyFieldObjectTypeTemplate,
TargetType: "",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("template object type with valid TargetType", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "template field",
Type: PropertyFieldTypeSelect,
ObjectType: PropertyFieldObjectTypeTemplate,
TargetType: string(PropertyFieldTargetLevelSystem),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("valid LinkedFieldID", func(t *testing.T) {
linkedID := NewId()
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "linked field",
Type: PropertyFieldTypeSelect,
ObjectType: PropertyFieldObjectTypeUser,
TargetType: string(PropertyFieldTargetLevelSystem),
LinkedFieldID: &linkedID,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("invalid LinkedFieldID format", func(t *testing.T) {
invalidID := "not-a-valid-id"
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "linked field",
Type: PropertyFieldTypeSelect,
ObjectType: PropertyFieldObjectTypeUser,
TargetType: string(PropertyFieldTargetLevelSystem),
LinkedFieldID: &invalidID,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv1 cannot have protected set", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
Protected: true,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("nil LinkedFieldID is valid", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "regular field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
LinkedFieldID: nil,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("empty string LinkedFieldID is valid", func(t *testing.T) {
emptyID := ""
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "regular field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
LinkedFieldID: &emptyID,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.NoError(t, pf.IsValid())
})
t.Run("template field with LinkedFieldID is invalid", func(t *testing.T) {
linkedID := NewId()
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "template field",
Type: PropertyFieldTypeSelect,
ObjectType: PropertyFieldObjectTypeTemplate,
TargetType: string(PropertyFieldTargetLevelSystem),
LinkedFieldID: &linkedID,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv1 cannot have permission_field set", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
PermissionField: new(PermissionLevelMember),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv1 cannot have permission_values set", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
PermissionValues: new(PermissionLevelMember),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("PSAv1 cannot have permission_options set", func(t *testing.T) {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
PermissionOptions: new(PermissionLevelMember),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
require.Error(t, pf.IsValid())
})
t.Run("protected field validation", func(t *testing.T) {
baseField := func() *PropertyField {
return &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
TargetType: string(PropertyFieldTargetLevelSystem),
ObjectType: "post",
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
}
t.Run("non-protected field without permissions is valid", func(t *testing.T) {
pf := baseField()
pf.Protected = false
pf.PermissionField = nil
require.NoError(t, pf.IsValid())
})
t.Run("non-protected field with non-none field permission is valid", func(t *testing.T) {
for _, level := range []PermissionLevel{PermissionLevelSysadmin, PermissionLevelMember, PermissionLevelAdmin} {
pf := baseField()
pf.Protected = false
pf.PermissionField = new(level)
pf.PermissionValues = new(PermissionLevelMember)
pf.PermissionOptions = new(PermissionLevelMember)
require.NoError(t, pf.IsValid(), "should be valid with field permission %s", level)
}
})
t.Run("non-protected field with field=none is invalid", func(t *testing.T) {
pf := baseField()
pf.Protected = false
pf.PermissionField = new(PermissionLevelNone)
pf.PermissionValues = new(PermissionLevelMember)
pf.PermissionOptions = new(PermissionLevelMember)
require.Error(t, pf.IsValid())
})
t.Run("protected field with field=none is valid", func(t *testing.T) {
pf := baseField()
pf.Protected = true
pf.PermissionField = new(PermissionLevelNone)
pf.PermissionValues = new(PermissionLevelMember)
pf.PermissionOptions = new(PermissionLevelSysadmin)
require.NoError(t, pf.IsValid())
})
t.Run("protected field with nil permissions is invalid", func(t *testing.T) {
pf := baseField()
pf.Protected = true
pf.PermissionField = nil
require.Error(t, pf.IsValid())
})
t.Run("protected field with non-none field permission is invalid", func(t *testing.T) {
for _, level := range []PermissionLevel{PermissionLevelSysadmin, PermissionLevelMember, PermissionLevelAdmin} {
pf := baseField()
pf.Protected = true
pf.PermissionField = new(level)
pf.PermissionValues = new(PermissionLevelMember)
pf.PermissionOptions = new(PermissionLevelMember)
require.Error(t, pf.IsValid(), "should be invalid with field permission %s", level)
}
})
t.Run("invalid permission_field value is rejected", func(t *testing.T) {
pf := baseField()
pf.PermissionField = new(PermissionLevel("bogus"))
require.Error(t, pf.IsValid())
})
t.Run("invalid permission_values value is rejected", func(t *testing.T) {
pf := baseField()
pf.PermissionField = new(PermissionLevelMember)
pf.PermissionValues = new(PermissionLevel("bogus"))
require.Error(t, pf.IsValid())
})
t.Run("invalid permission_options value is rejected", func(t *testing.T) {
pf := baseField()
pf.PermissionField = new(PermissionLevelMember)
pf.PermissionValues = new(PermissionLevelMember)
pf.PermissionOptions = new(PermissionLevel("bogus"))
require.Error(t, pf.IsValid())
})
})
t.Run("admin permission level", func(t *testing.T) {
baseField := func(target PropertyFieldTargetLevel) *PropertyField {
pf := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "test field",
Type: PropertyFieldTypeText,
ObjectType: PropertyFieldObjectTypePost,
TargetType: string(target),
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
if target != PropertyFieldTargetLevelSystem {
pf.TargetID = NewId()
}
return pf
}
for _, target := range []PropertyFieldTargetLevel{
PropertyFieldTargetLevelSystem,
PropertyFieldTargetLevelTeam,
PropertyFieldTargetLevelChannel,
} {
t.Run("admin is valid on "+string(target)+" target", func(t *testing.T) {
pf := baseField(target)
pf.PermissionField = new(PermissionLevelAdmin)
pf.PermissionValues = new(PermissionLevelAdmin)
pf.PermissionOptions = new(PermissionLevelAdmin)
require.NoError(t, pf.IsValid())
})
}
t.Run("PSAv1 field rejects admin permission level", func(t *testing.T) {
pf := baseField(PropertyFieldTargetLevelChannel)
pf.ObjectType = ""
pf.PermissionField = new(PermissionLevelAdmin)
require.Error(t, pf.IsValid())
})
})
}
func TestPropertyFieldPatch_IsValid(t *testing.T) {
t.Run("valid patch", func(t *testing.T) {
patch := &PropertyFieldPatch{
Name: new("test field"),
Type: new(PropertyFieldTypeText),
}
require.NoError(t, patch.IsValid())
})
t.Run("empty name", func(t *testing.T) {
patch := &PropertyFieldPatch{
Name: new(""),
Type: new(PropertyFieldTypeText),
}
require.Error(t, patch.IsValid())
})
t.Run("invalid type", func(t *testing.T) {
invalidType := PropertyFieldType("invalid")
patch := &PropertyFieldPatch{
Name: new("test field"),
Type: &invalidType,
}
require.Error(t, patch.IsValid())
})
t.Run("nil values are valid", func(t *testing.T) {
patch := &PropertyFieldPatch{
Name: nil,
Type: nil,
}
require.NoError(t, patch.IsValid())
})
t.Run("Name exceeds maximum length", func(t *testing.T) {
longName := strings.Repeat("a", PropertyFieldNameMaxRunes+1)
patch := &PropertyFieldPatch{
Name: &longName,
}
require.Error(t, patch.IsValid())
})
t.Run("TargetType exceeds maximum length", func(t *testing.T) {
longTargetType := strings.Repeat("a", PropertyFieldTargetTypeMaxRunes+1)
patch := &PropertyFieldPatch{
TargetType: &longTargetType,
}
require.Error(t, patch.IsValid())
})
t.Run("TargetID exceeds maximum length", func(t *testing.T) {
longTargetID := strings.Repeat("a", PropertyFieldTargetIDMaxRunes+1)
patch := &PropertyFieldPatch{
TargetID: &longTargetID,
}
require.Error(t, patch.IsValid())
})
t.Run("Name at maximum length is valid", func(t *testing.T) {
maxLengthName := strings.Repeat("a", PropertyFieldNameMaxRunes)
patch := &PropertyFieldPatch{
Name: &maxLengthName,
}
require.NoError(t, patch.IsValid())
})
t.Run("TargetID at maximum length is valid", func(t *testing.T) {
maxLengthTargetID := strings.Repeat("a", PropertyFieldTargetIDMaxRunes)
patch := &PropertyFieldPatch{
TargetID: &maxLengthTargetID,
}
require.NoError(t, patch.IsValid())
})
t.Run("empty TargetType is valid", func(t *testing.T) {
emptyTargetType := ""
patch := &PropertyFieldPatch{
TargetType: &emptyTargetType,
}
require.NoError(t, patch.IsValid())
})
t.Run("custom TargetType is valid", func(t *testing.T) {
customTargetType := "custom_value"
patch := &PropertyFieldPatch{
TargetType: &customTargetType,
}
require.NoError(t, patch.IsValid())
})
t.Run("enum TargetType is valid", func(t *testing.T) {
targetType := string(PropertyFieldTargetLevelSystem)
patch := &PropertyFieldPatch{
TargetType: &targetType,
}
require.NoError(t, patch.IsValid())
})
}
func TestPropertyField_Patch(t *testing.T) {
t.Run("replace mode patches all fields", func(t *testing.T) {
pf := &PropertyField{
Name: "original name",
Type: PropertyFieldTypeText,
TargetID: "original_target",
TargetType: "original_type",
}
patch := &PropertyFieldPatch{
Name: new("new name"),
Type: new(PropertyFieldTypeSelect),
TargetID: new("new_target"),
TargetType: new("new_type"),
Attrs: &StringInterface{"key": "value"},
}
pf.Patch(patch, false)
assert.Equal(t, "new name", pf.Name)
assert.Equal(t, PropertyFieldTypeSelect, pf.Type)
assert.Equal(t, "new_target", pf.TargetID)
assert.Equal(t, "new_type", pf.TargetType)
assert.EqualValues(t, StringInterface{"key": "value"}, pf.Attrs)
})
t.Run("patches only specified fields", func(t *testing.T) {
pf := &PropertyField{
Name: "original name",
Type: PropertyFieldTypeText,
TargetID: "original_target",
TargetType: "original_type",
}
patch := &PropertyFieldPatch{
Name: new("new name"),
}
pf.Patch(patch, false)
assert.Equal(t, "new name", pf.Name)
assert.Equal(t, PropertyFieldTypeText, pf.Type)
assert.Equal(t, "original_target", pf.TargetID)
assert.Equal(t, "original_type", pf.TargetType)
})
t.Run("replace mode replaces attrs entirely", func(t *testing.T) {
pf := &PropertyField{
Name: "test",
Type: PropertyFieldTypeSelect,
Attrs: StringInterface{"subtype": "color", "options": []any{"red", "blue"}},
}
patch := &PropertyFieldPatch{
Attrs: &StringInterface{"options": []any{"green"}},
}
pf.Patch(patch, false)
assert.Equal(t, StringInterface{"options": []any{"green"}}, pf.Attrs)
assert.Nil(t, pf.Attrs["subtype"])
})
t.Run("merge preserves existing keys when patching a subset", func(t *testing.T) {
pf := &PropertyField{
Name: "test",
Type: PropertyFieldTypeSelect,
Attrs: StringInterface{"subtype": "color", "options": []any{"red", "blue"}},
}
patch := &PropertyFieldPatch{
Attrs: &StringInterface{"options": []any{"green"}},
}
pf.Patch(patch, true)
assert.Equal(t, "color", pf.Attrs["subtype"])
assert.EqualValues(t, []any{"green"}, pf.Attrs["options"])
})
t.Run("merge with nil value deletes a key", func(t *testing.T) {
pf := &PropertyField{
Name: "test",
Type: PropertyFieldTypeSelect,
Attrs: StringInterface{"subtype": "color", "options": []any{"red"}},
}
patch := &PropertyFieldPatch{
Attrs: &StringInterface{"subtype": nil},
}
pf.Patch(patch, true)
_, exists := pf.Attrs["subtype"]
assert.False(t, exists)
assert.EqualValues(t, []any{"red"}, pf.Attrs["options"])
})
t.Run("merge on nil existing attrs initializes the map", func(t *testing.T) {
pf := &PropertyField{
Name: "test",
Type: PropertyFieldTypeText,
}
patch := &PropertyFieldPatch{
Attrs: &StringInterface{"key": "value"},
}
pf.Patch(patch, true)
assert.Equal(t, StringInterface{"key": "value"}, pf.Attrs)
})
t.Run("merge on nil existing attrs with a nil key in the patch doesn't store the nil key", func(t *testing.T) {
pf := &PropertyField{
Name: "test",
Type: PropertyFieldTypeText,
}
patch := &PropertyFieldPatch{
Attrs: &StringInterface{"keep": "value", "remove": nil},
}
pf.Patch(patch, true)
assert.Equal(t, "value", pf.Attrs["keep"])
_, exists := pf.Attrs["remove"]
assert.False(t, exists)
assert.Len(t, pf.Attrs, 1)
})
t.Run("patch with empty LinkedFieldID clears the link", func(t *testing.T) {
linkedID := NewId()
pf := &PropertyField{
Name: "test",
Type: PropertyFieldTypeSelect,
LinkedFieldID: &linkedID,
}
emptyStr := ""
patch := &PropertyFieldPatch{
LinkedFieldID: &emptyStr,
}
pf.Patch(patch, false)
assert.Nil(t, pf.LinkedFieldID)
})
t.Run("patch with nil LinkedFieldID does not change the link", func(t *testing.T) {
linkedID := NewId()
pf := &PropertyField{
Name: "test",
Type: PropertyFieldTypeSelect,
LinkedFieldID: &linkedID,
}
patch := &PropertyFieldPatch{
LinkedFieldID: nil,
}
pf.Patch(patch, false)
require.NotNil(t, pf.LinkedFieldID)
assert.Equal(t, linkedID, *pf.LinkedFieldID)
})
t.Run("patch with same LinkedFieldID is a no-op", func(t *testing.T) {
linkedID := NewId()
pf := &PropertyField{
Name: "test",
Type: PropertyFieldTypeSelect,
LinkedFieldID: &linkedID,
}
patch := &PropertyFieldPatch{
LinkedFieldID: &linkedID,
}
pf.Patch(patch, false)
require.NotNil(t, pf.LinkedFieldID)
assert.Equal(t, linkedID, *pf.LinkedFieldID)
})
}
func TestPropertyField_IsPSAv1(t *testing.T) {
t.Run("basic ObjectType tests", func(t *testing.T) {
testCases := []struct {
name string
objectType string
want bool
}{
{
name: "returns true for empty ObjectType (PSAv1 legacy schema)",
objectType: "",
want: true,
},
{
name: "returns false for post ObjectType (PSAv2 typed schema)",
objectType: "post",
want: false,
},
{
name: "returns false for user ObjectType",
objectType: "user",
want: false,
},
{
name: "returns false for channel ObjectType",
objectType: "channel",
want: false,
},
{
name: "returns false for team ObjectType",
objectType: "team",
want: false,
},
{
name: "returns false for board ObjectType",
objectType: "board",
want: false,
},
{
name: "returns false for card ObjectType",
objectType: "card",
want: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
pf := &PropertyField{
ObjectType: tc.objectType,
}
assert.Equal(t, tc.want, pf.IsPSAv1(), "Expected IsPSAv1() to return %v for ObjectType=%q", tc.want, tc.objectType)
})
}
})
t.Run("returns true for zero-value PropertyField", func(t *testing.T) {
pf := &PropertyField{}
assert.True(t, pf.IsPSAv1(), "Zero-value PropertyField should be treated as PSAv1")
})
t.Run("works correctly when used with other fields set", func(t *testing.T) {
// PSAv1 field with all other fields populated
psav1Field := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "Legacy Field",
Type: PropertyFieldTypeText,
TargetID: NewId(),
TargetType: "channel",
ObjectType: "", // PSAv1
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
assert.True(t, psav1Field.IsPSAv1())
// PSAv2 field with all other fields populated
psav2Field := &PropertyField{
ID: NewId(),
GroupID: NewId(),
Name: "Modern Field",
Type: PropertyFieldTypeText,
TargetID: NewId(),
TargetType: "channel",
ObjectType: "post", // PSAv2
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
}
assert.False(t, psav2Field.IsPSAv1())
})
}
func TestPropertyFieldSearchCursor_IsValid(t *testing.T) {
t.Run("empty cursor is valid", func(t *testing.T) {
cursor := PropertyFieldSearchCursor{}
assert.NoError(t, cursor.IsValid())
})
t.Run("valid cursor", func(t *testing.T) {
cursor := PropertyFieldSearchCursor{
PropertyFieldID: NewId(),
CreateAt: GetMillis(),
}
assert.NoError(t, cursor.IsValid())
})
t.Run("invalid PropertyFieldID", func(t *testing.T) {
cursor := PropertyFieldSearchCursor{
PropertyFieldID: "invalid",
CreateAt: GetMillis(),
}
assert.Error(t, cursor.IsValid())
})
t.Run("zero CreateAt", func(t *testing.T) {
cursor := PropertyFieldSearchCursor{
PropertyFieldID: NewId(),
CreateAt: 0,
}
assert.Error(t, cursor.IsValid())
})
t.Run("negative CreateAt", func(t *testing.T) {
cursor := PropertyFieldSearchCursor{
PropertyFieldID: NewId(),
CreateAt: -1,
}
assert.Error(t, cursor.IsValid())
})
t.Run("valid cursor with UpdateAt only", func(t *testing.T) {
cursor := PropertyFieldSearchCursor{
PropertyFieldID: NewId(),
UpdateAt: GetMillis(),
}
assert.NoError(t, cursor.IsValid())
})
t.Run("both CreateAt and UpdateAt set is invalid", func(t *testing.T) {
cursor := PropertyFieldSearchCursor{
PropertyFieldID: NewId(),
CreateAt: 1,
UpdateAt: 1,
}
err := cursor.IsValid()
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly one")
})
t.Run("invalid PropertyFieldID with UpdateAt set", func(t *testing.T) {
cursor := PropertyFieldSearchCursor{
PropertyFieldID: "invalid",
UpdateAt: GetMillis(),
}
assert.Error(t, cursor.IsValid())
})
t.Run("ID set but neither CreateAt nor UpdateAt is invalid", func(t *testing.T) {
// The cursor is non-empty but cannot be paginated against either
// ordering — IsValid must reject it rather than fall through to a
// silent miss in the store.
cursor := PropertyFieldSearchCursor{
PropertyFieldID: NewId(),
}
err := cursor.IsValid()
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly one")
})
t.Run("IsEmpty returns true when UpdateAt is also zero", func(t *testing.T) {
cursor := PropertyFieldSearchCursor{}
assert.True(t, cursor.IsEmpty())
})
t.Run("IsEmpty returns false when UpdateAt is set", func(t *testing.T) {
cursor := PropertyFieldSearchCursor{UpdateAt: 1}
assert.False(t, cursor.IsEmpty())
})
}
func TestPropertyFieldSearchOpts_IsValid(t *testing.T) {
validID := NewId()
t.Run("zero-value opts is valid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{}
assert.NoError(t, opts.IsValid())
})
t.Run("ObjectType alone is valid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{ObjectType: PropertyFieldObjectTypeChannel}
assert.NoError(t, opts.IsValid())
})
t.Run("ObjectTypes alone is valid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{ObjectTypes: []string{PropertyFieldObjectTypeChannel, PropertyFieldObjectTypeSystem}}
assert.NoError(t, opts.IsValid())
})
t.Run("ObjectType and ObjectTypes both set is invalid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{
ObjectType: PropertyFieldObjectTypeChannel,
ObjectTypes: []string{PropertyFieldObjectTypeChannel},
}
err := opts.IsValid()
require.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
})
t.Run("ObjectTypes containing an invalid value mentions that value", func(t *testing.T) {
opts := PropertyFieldSearchOpts{
ObjectTypes: []string{PropertyFieldObjectTypeChannel, "garbage"},
}
err := opts.IsValid()
require.Error(t, err)
assert.Contains(t, err.Error(), "garbage")
})
t.Run("legacy ObjectType containing an invalid value is rejected", func(t *testing.T) {
opts := PropertyFieldSearchOpts{ObjectType: "garbage"}
err := opts.IsValid()
require.Error(t, err)
assert.Contains(t, err.Error(), "garbage")
})
t.Run("ChannelID combined with TargetType is invalid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{
ChannelID: validID,
TeamID: NewId(),
TargetType: "channel",
}
err := opts.IsValid()
require.Error(t, err)
assert.Contains(t, err.Error(), "channel_id/team_id")
})
t.Run("TeamID combined with TargetIDs is invalid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{
TeamID: NewId(),
TargetIDs: []string{NewId()},
}
err := opts.IsValid()
require.Error(t, err)
assert.Contains(t, err.Error(), "channel_id/team_id")
})
t.Run("ChannelID without TeamID is valid (DM/GM scope)", func(t *testing.T) {
// DM/GM channels have no parent team. IsValid must not reject
// them; the store layer handles the system → channel hierarchy
// when TeamID is empty.
opts := PropertyFieldSearchOpts{ChannelID: NewId()}
assert.NoError(t, opts.IsValid())
})
t.Run("TeamID alone is valid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{TeamID: NewId()}
assert.NoError(t, opts.IsValid())
})
t.Run("ChannelID + TeamID is valid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{ChannelID: NewId(), TeamID: NewId()}
assert.NoError(t, opts.IsValid())
})
t.Run("TargetType + TargetIDs is valid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{
TargetType: string(PropertyFieldTargetLevelChannel),
TargetIDs: []string{NewId(), NewId()},
}
assert.NoError(t, opts.IsValid())
})
t.Run("SinceUpdateAt negative is valid (treated as no filter)", func(t *testing.T) {
opts := PropertyFieldSearchOpts{SinceUpdateAt: -1}
assert.NoError(t, opts.IsValid())
})
t.Run("SinceUpdateAt zero is valid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{SinceUpdateAt: 0}
assert.NoError(t, opts.IsValid())
})
t.Run("SinceUpdateAt positive is valid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{SinceUpdateAt: 123}
assert.NoError(t, opts.IsValid())
})
t.Run("delegates to Cursor.IsValid - invalid cursor surfaces", func(t *testing.T) {
opts := PropertyFieldSearchOpts{
Cursor: PropertyFieldSearchCursor{
PropertyFieldID: NewId(),
CreateAt: 1,
UpdateAt: 1,
},
}
err := opts.IsValid()
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly one")
})
t.Run("delegates to Cursor.IsValid - empty cursor is fine", func(t *testing.T) {
opts := PropertyFieldSearchOpts{
TeamID: NewId(),
Cursor: PropertyFieldSearchCursor{},
}
assert.NoError(t, opts.IsValid())
})
t.Run("cursor key must match active ordering: delta mode requires UpdateAt", func(t *testing.T) {
opts := PropertyFieldSearchOpts{
SinceUpdateAt: 100,
Cursor: PropertyFieldSearchCursor{
PropertyFieldID: validID,
CreateAt: 1,
},
}
err := opts.IsValid()
require.Error(t, err)
assert.Contains(t, err.Error(), "cursor_update_at")
})
t.Run("cursor key must match active ordering: directory mode requires CreateAt", func(t *testing.T) {
opts := PropertyFieldSearchOpts{
Cursor: PropertyFieldSearchCursor{
PropertyFieldID: validID,
UpdateAt: 1,
},
}
err := opts.IsValid()
require.Error(t, err)
assert.Contains(t, err.Error(), "cursor_create_at")
})
t.Run("cursor key match: delta + UpdateAt is valid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{
SinceUpdateAt: 100,
Cursor: PropertyFieldSearchCursor{
PropertyFieldID: validID,
UpdateAt: 50,
},
}
assert.NoError(t, opts.IsValid())
})
t.Run("cursor key match: directory + CreateAt is valid", func(t *testing.T) {
opts := PropertyFieldSearchOpts{
Cursor: PropertyFieldSearchCursor{
PropertyFieldID: validID,
CreateAt: 50,
},
}
assert.NoError(t, opts.IsValid())
})
}
func TestPluginPropertyOption(t *testing.T) {
t.Run("NewPluginPropertyOption", func(t *testing.T) {
id := NewId()
option := NewPluginPropertyOption(id, "test-name")
assert.Equal(t, id, option.GetID())
assert.Equal(t, "test-name", option.GetName())
assert.NoError(t, option.IsValid())
})
t.Run("SetID", func(t *testing.T) {
option := &PluginPropertyOption{}
newId := NewId()
option.SetID(newId)
assert.Equal(t, newId, option.GetID())
})
t.Run("GetValue and SetValue", func(t *testing.T) {
option := NewPluginPropertyOption(NewId(), "test-name")
option.SetValue("color", "red")
option.SetValue("description", "test description")
assert.Equal(t, "red", option.GetValue("color"))
assert.Equal(t, "test description", option.GetValue("description"))
assert.Equal(t, "", option.GetValue("nonexistent"))
})
t.Run("IsValid", func(t *testing.T) {
tests := []struct {
name string
option *PluginPropertyOption
wantErr bool
}{
{
name: "valid option",
option: NewPluginPropertyOption(NewId(), "test-name"),
},
{
name: "nil data",
option: &PluginPropertyOption{},
wantErr: true,
},
{
name: "empty id",
option: &PluginPropertyOption{
Data: map[string]string{"name": "test"},
},
wantErr: true,
},
{
name: "empty name",
option: &PluginPropertyOption{
Data: map[string]string{"id": NewId()},
},
wantErr: true,
},
{
name: "invalid id",
option: &PluginPropertyOption{
Data: map[string]string{
"id": "invalid-id-format",
"name": "test",
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.option.IsValid()
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
})
t.Run("PropertyOptions with PluginPropertyOption", func(t *testing.T) {
options := PropertyOptions[*PluginPropertyOption]{
NewPluginPropertyOption(NewId(), "Option 1"),
NewPluginPropertyOption(NewId(), "Option 2"),
}
// Add custom data
options[0].SetValue("color", "blue")
options[1].SetValue("description", "Second option")
assert.NoError(t, options.IsValid())
assert.Equal(t, "blue", options[0].GetValue("color"))
assert.Equal(t, "Second option", options[1].GetValue("description"))
})
t.Run("PropertyField with PluginPropertyOptions", func(t *testing.T) {
fieldID := NewId()
groupID := NewId()
fieldName := "Test Field"
fieldType := PropertyFieldTypeSelect
field := &PropertyField{
ID: fieldID,
GroupID: groupID,
Name: fieldName,
Type: fieldType,
Attrs: make(StringInterface),
}
options := PropertyOptions[*PluginPropertyOption]{
NewPluginPropertyOption(NewId(), "Option 1"),
NewPluginPropertyOption(NewId(), "Option 2"),
}
field.Attrs[PropertyFieldAttributeOptions] = options
// Verify the field properties are set correctly
assert.Equal(t, fieldID, field.ID)
assert.Equal(t, groupID, field.GroupID)
assert.Equal(t, fieldName, field.Name)
assert.Equal(t, fieldType, field.Type)
// Test that we can retrieve the options
if optionsFromField, err := NewPropertyOptionsFromFieldAttrs[*PluginPropertyOption](field.Attrs[PropertyFieldAttributeOptions]); err == nil {
require.Len(t, optionsFromField, 2)
assert.Equal(t, "Option 1", optionsFromField[0].GetName())
assert.Equal(t, "Option 2", optionsFromField[1].GetName())
} else {
t.Fatalf("Failed to retrieve options from field: %v", err)
}
})
t.Run("PluginPropertyOption JSON marshaling", func(t *testing.T) {
opt := NewPluginPropertyOption("test-id", "Test Option")
opt.SetValue("custom", "custom-value")
opt.SetValue("color", "blue")
// Test marshaling - should not wrap in "data"
data, err := json.Marshal(opt)
require.NoError(t, err)
// Parse the JSON to verify structure
var jsonMap map[string]string
err = json.Unmarshal(data, &jsonMap)
require.NoError(t, err)
// Verify the JSON doesn't have a "data" wrapper
assert.Equal(t, "test-id", jsonMap["id"])
assert.Equal(t, "Test Option", jsonMap["name"])
assert.Equal(t, "custom-value", jsonMap["custom"])
assert.Equal(t, "blue", jsonMap["color"])
// Test unmarshaling
var newOpt PluginPropertyOption
err = json.Unmarshal(data, &newOpt)
require.NoError(t, err)
assert.Equal(t, "test-id", newOpt.GetID())
assert.Equal(t, "Test Option", newOpt.GetName())
assert.Equal(t, "custom-value", newOpt.GetValue("custom"))
assert.Equal(t, "blue", newOpt.GetValue("color"))
})
t.Run("PluginPropertyOption slice JSON marshaling", func(t *testing.T) {
options := PropertyOptions[*PluginPropertyOption]{
NewPluginPropertyOption("id1", "Option 1"),
NewPluginPropertyOption("id2", "Option 2"),
}
options[0].SetValue("priority", "high")
options[1].SetValue("priority", "low")
// Test marshaling
data, err := json.Marshal(options)
require.NoError(t, err)
// Verify the JSON structure doesn't have "data" wrappers
var jsonArray []map[string]string
err = json.Unmarshal(data, &jsonArray)
require.NoError(t, err)
require.Len(t, jsonArray, 2)
assert.Equal(t, "id1", jsonArray[0]["id"])
assert.Equal(t, "Option 1", jsonArray[0]["name"])
assert.Equal(t, "high", jsonArray[0]["priority"])
assert.Equal(t, "id2", jsonArray[1]["id"])
assert.Equal(t, "Option 2", jsonArray[1]["name"])
assert.Equal(t, "low", jsonArray[1]["priority"])
// Test unmarshaling
var newOptions PropertyOptions[*PluginPropertyOption]
err = json.Unmarshal(data, &newOptions)
require.NoError(t, err)
require.Len(t, newOptions, 2)
assert.Equal(t, "id1", newOptions[0].GetID())
assert.Equal(t, "Option 1", newOptions[0].GetName())
assert.Equal(t, "high", newOptions[0].GetValue("priority"))
assert.Equal(t, "id2", newOptions[1].GetID())
assert.Equal(t, "Option 2", newOptions[1].GetName())
assert.Equal(t, "low", newOptions[1].GetValue("priority"))
})
}
func TestPropertyFieldType_SupportsOptions(t *testing.T) {
cases := map[PropertyFieldType]bool{
PropertyFieldTypeSelect: true,
PropertyFieldTypeMultiselect: true,
PropertyFieldTypeRank: true,
PropertyFieldTypeText: false,
PropertyFieldTypeDate: false,
PropertyFieldTypeUser: false,
PropertyFieldTypeMultiuser: false,
PropertyFieldType("bogus"): false,
}
for fieldType, expected := range cases {
assert.Equal(t, expected, fieldType.SupportsOptions(), "SupportsOptions for %q", fieldType)
}
}
func TestPropertyField_EnsureOptionIDs(t *testing.T) {
t.Run("generates IDs for multiselect options without IDs", func(t *testing.T) {
pf := &PropertyField{
Type: PropertyFieldTypeMultiselect,
Attrs: StringInterface{
PropertyFieldAttributeOptions: []any{
map[string]any{"name": "Option 1"},
map[string]any{"name": "Option 2"},
map[string]any{"name": "Option 3"},
},
},
}
err := pf.EnsureOptionIDs()
require.NoError(t, err)
options := pf.Attrs[PropertyFieldAttributeOptions].([]any)
require.Len(t, options, 3)
for i, opt := range options {
optMap := opt.(map[string]any)
assert.NotEmpty(t, optMap["id"], "Option %d should have an ID", i)
assert.Len(t, optMap["id"].(string), 26, "Option %d ID should be 26 characters", i)
}
})
t.Run("generates IDs for select options without IDs", func(t *testing.T) {
pf := &PropertyField{
Type: PropertyFieldTypeSelect,
Attrs: StringInterface{
PropertyFieldAttributeOptions: []any{
map[string]any{"name": "Option A"},
map[string]any{"name": "Option B"},
},
},
}
err := pf.EnsureOptionIDs()
require.NoError(t, err)
options := pf.Attrs[PropertyFieldAttributeOptions].([]any)
require.Len(t, options, 2)
for i, opt := range options {
optMap := opt.(map[string]any)
assert.NotEmpty(t, optMap["id"], "Option %d should have an ID", i)
}
})
t.Run("preserves existing IDs", func(t *testing.T) {
existingID1 := "existing_id_1"
existingID2 := "existing_id_2"
pf := &PropertyField{
Type: PropertyFieldTypeMultiselect,
Attrs: StringInterface{
PropertyFieldAttributeOptions: []any{
map[string]any{"id": existingID1, "name": "Option 1"},
map[string]any{"id": existingID2, "name": "Option 2"},
},
},
}
err := pf.EnsureOptionIDs()
require.NoError(t, err)
options := pf.Attrs[PropertyFieldAttributeOptions].([]any)
assert.Equal(t, existingID1, options[0].(map[string]any)["id"])
assert.Equal(t, existingID2, options[1].(map[string]any)["id"])
})
t.Run("mixes existing and new IDs", func(t *testing.T) {
existingID := "existing_id"
pf := &PropertyField{
Type: PropertyFieldTypeMultiselect,
Attrs: StringInterface{
PropertyFieldAttributeOptions: []any{
map[string]any{"id": existingID, "name": "Option 1"},
map[string]any{"name": "Option 2"},
map[string]any{"name": "Option 3"},
},
},
}
err := pf.EnsureOptionIDs()
require.NoError(t, err)
options := pf.Attrs[PropertyFieldAttributeOptions].([]any)
// First option should keep existing ID
assert.Equal(t, existingID, options[0].(map[string]any)["id"])
// Other options should have generated IDs
assert.NotEmpty(t, options[1].(map[string]any)["id"])
assert.NotEmpty(t, options[2].(map[string]any)["id"])
assert.NotEqual(t, existingID, options[1].(map[string]any)["id"])
assert.NotEqual(t, existingID, options[2].(map[string]any)["id"])
})
t.Run("handles option with non-string ID field", func(t *testing.T) {
pf := &PropertyField{
Type: PropertyFieldTypeMultiselect,
Attrs: StringInterface{
PropertyFieldAttributeOptions: []any{
map[string]any{"name": "Option 1", "id": 12345},
map[string]any{"name": "Option 2", "id": nil},
},
},
}
err := pf.EnsureOptionIDs()
require.NoError(t, err)
options := pf.Attrs[PropertyFieldAttributeOptions].([]any)
// Non-string IDs should be replaced with valid IDs
id1 := options[0].(map[string]any)["id"]
assert.IsType(t, "", id1)
assert.Len(t, id1.(string), 26)
id2 := options[1].(map[string]any)["id"]
assert.IsType(t, "", id2)
assert.Len(t, id2.(string), 26)
})
t.Run("returns error when options attribute is not a slice", func(t *testing.T) {
pf := &PropertyField{
ID: "field123",
Type: PropertyFieldTypeSelect,
Attrs: StringInterface{
PropertyFieldAttributeOptions: "not a slice",
},
}
err := pf.EnsureOptionIDs()
require.Error(t, err)
assert.Contains(t, err.Error(), "field123")
})
t.Run("returns error when option is not a map", func(t *testing.T) {
pf := &PropertyField{
ID: "field456",
Type: PropertyFieldTypeMultiselect,
Attrs: StringInterface{
PropertyFieldAttributeOptions: []any{
map[string]any{"name": "Valid Option", "id": "valid_id"},
"not a map",
},
},
}
err := pf.EnsureOptionIDs()
require.Error(t, err)
assert.Contains(t, err.Error(), "field456")
})
}