Files
insights-go/feature_flags_matching_test.go
T

1308 lines
33 KiB
Go

package insights
import (
"errors"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestMatchPropertyValue(t *testing.T) {
property := FlagProperty{
Key: "Browser",
Value: "Chrome",
Operator: "exact",
}
properties := NewProperties().Set("Browser", "Chrome")
isMatch, err := matchProperty(property, properties)
if err != nil || !isMatch {
t.Error("Value is not a match")
}
}
func TestMatchPropertyInvalidOperator(t *testing.T) {
property := FlagProperty{
Key: "Browser",
Value: "Chrome",
Operator: "is_unknown",
}
properties := NewProperties().Set("Browser", "Chrome")
isMatch, err := matchProperty(property, properties)
if isMatch == true {
t.Error("Should not match")
}
var inconclusiveErr *InconclusiveMatchError
if !errors.As(err, &inconclusiveErr) {
t.Error("Error type is not a match")
}
}
func TestMatchPropertySlice(t *testing.T) {
property := FlagProperty{
Key: "Browser",
Value: []interface{}{"Chrome", "Firefox"},
Operator: "exact",
}
for _, tt := range []struct {
name string
properties Properties
expected bool
err error
}{
{
name: "match with Chrome",
properties: NewProperties().Set("Browser", "Chrome"),
expected: true,
},
{
name: "match with Firefox",
properties: NewProperties().Set("Browser", "Firefox"),
expected: true,
},
{
name: "no match with Explorer",
properties: NewProperties().Set("Browser", "Explorer"),
},
{
name: "no match with unknown key",
properties: NewProperties().Set("Car", "Chrome"),
err: errors.New(""),
},
} {
t.Run(tt.name, func(t *testing.T) {
isMatch, err := matchProperty(property, tt.properties)
if tt.err != nil {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, tt.expected, isMatch)
})
}
}
func TestMatchPropertySliceExact(t *testing.T) {
property := FlagProperty{
Key: "Browser",
Value: []interface{}{"Chrome", "Firefox"},
Operator: "exact",
}
isMatch, err := matchProperty(property, NewProperties().Set("Browser", "Chrome"))
require.NoError(t, err)
require.True(t, isMatch)
isMatch, err = matchProperty(property, NewProperties().Set("Browser", "Firefox"))
require.NoError(t, err)
require.True(t, isMatch)
isMatch, err = matchProperty(property, NewProperties().Set("Browser", "Explorer"))
require.NoError(t, err)
require.False(t, isMatch)
isMatch, err = matchProperty(property, NewProperties().Set("Car", "Fiat"))
require.Error(t, err)
require.False(t, isMatch)
}
func TestMatchPropertyNumber(t *testing.T) {
property := FlagProperty{
Key: "Number",
Value: 5,
Operator: "gt",
}
properties := NewProperties().Set("Number", 7)
isMatch, err := matchProperty(property, properties)
if err != nil {
t.Error(err)
}
if !isMatch {
t.Error("Value is not a match")
}
property = FlagProperty{
Key: "Number",
Value: 5,
Operator: "lt",
}
properties = NewProperties().Set("Number", 4)
isMatch, err = matchProperty(property, properties)
if err != nil {
t.Error(err)
}
if !isMatch {
t.Error("Value is not a match")
}
property = FlagProperty{
Key: "Number",
Value: 5,
Operator: "gte",
}
properties = NewProperties().Set("Number", 5)
isMatch, err = matchProperty(property, properties)
if err != nil {
t.Error(err)
}
if !isMatch {
t.Error("Value is not a match")
}
property = FlagProperty{
Key: "Number",
Value: 5,
Operator: "lte",
}
properties = NewProperties().Set("Number", 4)
isMatch, err = matchProperty(property, properties)
if err != nil {
t.Error(err)
}
if !isMatch {
t.Error("Value is not a match")
}
}
func TestMatchPropertyRegex(t *testing.T) {
shouldMatch := []interface{}{"value.com", "value2.com"}
property := FlagProperty{
Key: "key",
Value: "\\.com$",
Operator: "regex",
}
for _, val := range shouldMatch {
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
if err != nil {
t.Error(err)
}
if !isMatch {
t.Error("Value is not a match")
}
}
shouldNotMatch := []interface{}{".com343tfvalue5", "Alakazam", 123}
for _, val := range shouldNotMatch {
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
if err != nil {
t.Error(err)
}
if isMatch {
t.Error("Value is not a match")
}
}
// invalid regex
property = FlagProperty{
Key: "key",
Value: "?*",
Operator: "regex",
}
shouldNotMatch = []interface{}{"value", "valu2"}
for _, val := range shouldNotMatch {
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
if err != nil {
t.Error(err)
}
if isMatch {
t.Error("Value is not a match")
}
}
// non string value
property = FlagProperty{
Key: "key",
Value: 4,
Operator: "regex",
}
shouldMatch = []interface{}{"4", 4}
for _, val := range shouldMatch {
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
if err != nil {
t.Error(err)
}
if !isMatch {
t.Error("Value is not a match")
}
}
}
func TestMatchPropertyContains(t *testing.T) {
shouldMatch := []interface{}{"value", "value2", "value3", "value4", "343tfvalue5"}
property := FlagProperty{
Key: "key",
Value: "valUe",
Operator: "icontains",
}
for _, val := range shouldMatch {
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
if err != nil {
t.Error(err)
}
if !isMatch {
t.Error("Value is not a match")
}
}
shouldNotMatch := []interface{}{"Alakazam", 123}
for _, val := range shouldNotMatch {
isMatch, err := matchProperty(property, NewProperties().Set("key", val))
if err != nil {
t.Error(err)
}
if isMatch {
t.Error("Value is not a match")
}
}
}
func TestMatchPropertyDateComparison(t *testing.T) {
t.Run("RFC3339 dates", func(t *testing.T) {
// Test is_date_before
property := FlagProperty{
Key: "created_at",
Value: "2024-12-31T23:59:59Z",
Operator: "is_date_before",
}
// Should match: 2024-06-15 is before 2024-12-31
isMatch, err := matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:00:00Z"))
require.NoError(t, err)
require.True(t, isMatch)
// Should not match: 2025-01-01 is after 2024-12-31
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00Z"))
require.NoError(t, err)
require.False(t, isMatch)
// Test is_date_after
property.Operator = "is_date_after"
// Should match: 2025-01-01 is after 2024-12-31
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00Z"))
require.NoError(t, err)
require.True(t, isMatch)
// Should not match: 2024-06-15 is before 2024-12-31
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:00:00Z"))
require.NoError(t, err)
require.False(t, isMatch)
})
t.Run("ISO 8601 date formats", func(t *testing.T) {
// Test date-only format (YYYY-MM-DD)
property := FlagProperty{
Key: "created_at",
Value: "2024-12-31",
Operator: "is_date_before",
}
// Should match: 2024-06-15 is before 2024-12-31
isMatch, err := matchProperty(property, NewProperties().Set("created_at", "2024-06-15"))
require.NoError(t, err)
require.True(t, isMatch)
// Should not match: 2025-01-01 is after 2024-12-31
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01"))
require.NoError(t, err)
require.False(t, isMatch)
// Test datetime without timezone (YYYY-MM-DDTHH:MM:SS)
property.Value = "2024-12-31T23:59:59"
// Should match: 2024-06-15T10:00:00 is before 2024-12-31T23:59:59
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:00:00"))
require.NoError(t, err)
require.True(t, isMatch)
// Should not match: 2025-01-01T00:00:00 is after 2024-12-31T23:59:59
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00"))
require.NoError(t, err)
require.False(t, isMatch)
// Test is_date_after with date-only format
property.Operator = "is_date_after"
property.Value = "2024-06-15"
// Should match: 2025-01-01 is after 2024-06-15
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01"))
require.NoError(t, err)
require.True(t, isMatch)
// Should not match: 2024-01-01 is before 2024-06-15
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-01-01"))
require.NoError(t, err)
require.False(t, isMatch)
// Test mixing formats (RFC3339 value with date-only property)
property.Value = "2024-06-15"
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00Z"))
require.NoError(t, err)
require.True(t, isMatch)
// Test mixing formats (date-only value with datetime property)
property.Value = "2024-06-15T12:00:00"
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01"))
require.NoError(t, err)
require.True(t, isMatch)
})
t.Run("ISO 8601 fractional seconds and timezone offsets", func(t *testing.T) {
// Test fractional seconds with Z timezone
property := FlagProperty{
Key: "created_at",
Value: "2024-12-31T23:59:59.999Z",
Operator: "is_date_before",
}
// Should match: earlier fractional time
isMatch, err := matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:30:00.123Z"))
require.NoError(t, err)
require.True(t, isMatch)
// Should not match: later time
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00.001Z"))
require.NoError(t, err)
require.False(t, isMatch)
// Test timezone offsets (positive)
property.Value = "2024-06-15T10:00:00+05:30"
// Should match: earlier time with different timezone
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T03:00:00Z"))
require.NoError(t, err)
require.True(t, isMatch)
// Should not match: later time
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T12:00:00+05:30"))
require.NoError(t, err)
require.False(t, isMatch)
// Test timezone offsets (negative)
property.Value = "2024-06-15T10:00:00-08:00"
// Should match: earlier time
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T08:00:00-08:00"))
require.NoError(t, err)
require.True(t, isMatch)
// Test combined fractional seconds + timezone offset
property.Value = "2024-12-31T23:59:59.999+01:00"
// Should match: earlier fractional time with timezone
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-12-31T22:59:59.998+01:00"))
require.NoError(t, err)
require.True(t, isMatch)
// Should not match: later time
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2025-01-01T00:00:00.000+01:00"))
require.NoError(t, err)
require.False(t, isMatch)
// Test various fractional second precisions
testCases := []struct {
name string
dateStr string
expected bool
}{
{"1 digit fractional", "2024-01-15T10:30:00.1Z", true},
{"2 digit fractional", "2024-01-15T10:30:00.12Z", true},
{"3 digit fractional", "2024-01-15T10:30:00.123Z", true},
{"6 digit fractional", "2024-01-15T10:30:00.123456Z", true},
{"9 digit fractional", "2024-01-15T10:30:00.123456789Z", true},
{"fractional with +00:00", "2024-01-15T10:30:00.123+00:00", true},
}
property.Operator = "is_date_after"
property.Value = "2024-01-01T00:00:00Z"
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("created_at", tc.dateStr))
require.NoError(t, err)
require.Equal(t, tc.expected, isMatch, "Failed for date: %s", tc.dateStr)
})
}
// Test mixing fractional seconds with non-fractional
property.Value = "2024-06-15T10:30:00Z"
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:30:00.999Z"))
require.NoError(t, err)
require.True(t, isMatch) // .999 is after :00 (operator is still is_date_after)
// Test +00:00 equivalent to Z
property.Value = "2024-06-15T10:30:00+00:00"
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "2024-06-15T10:30:00Z"))
require.NoError(t, err)
require.False(t, isMatch) // Equal times, not after
})
t.Run("Relative dates", func(t *testing.T) {
now := time.Now()
// Test is_date_after with relative date
property := FlagProperty{
Key: "created_at",
Value: "-7d", // 7 days ago
Operator: "is_date_after",
}
// Should match: 3 days ago is after 7 days ago
threeDaysAgo := now.AddDate(0, 0, -3).Format(time.RFC3339)
isMatch, err := matchProperty(property, NewProperties().Set("created_at", threeDaysAgo))
require.NoError(t, err)
require.True(t, isMatch)
// Should not match: 10 days ago is before 7 days ago
tenDaysAgo := now.AddDate(0, 0, -10).Format(time.RFC3339)
isMatch, err = matchProperty(property, NewProperties().Set("created_at", tenDaysAgo))
require.NoError(t, err)
require.False(t, isMatch)
// Test is_date_before with relative date
property.Operator = "is_date_before"
// Should match: 10 days ago is before 7 days ago
isMatch, err = matchProperty(property, NewProperties().Set("created_at", tenDaysAgo))
require.NoError(t, err)
require.True(t, isMatch)
})
t.Run("Various relative date formats", func(t *testing.T) {
testCases := []struct {
name string
relativeDate string
shouldParse bool
}{
{"1 hour", "1h", true},
{"7 days", "7d", true},
{"2 weeks", "2w", true},
{"3 months", "3m", true},
{"1 year", "1y", true},
{"large number rejected", "10000d", false},
{"invalid format", "invalid", false},
{"no number", "d", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := parseRelativeDate(tc.relativeDate)
if tc.shouldParse {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
})
t.Run("Error handling", func(t *testing.T) {
property := FlagProperty{
Key: "created_at",
Value: "2024-12-31T23:59:59Z",
Operator: "is_date_before",
}
// Test with non-string value
isMatch, err := matchProperty(property, NewProperties().Set("created_at", 12345))
require.Error(t, err)
require.False(t, isMatch)
// Test with invalid date format
isMatch, err = matchProperty(property, NewProperties().Set("created_at", "invalid-date"))
require.Error(t, err)
require.False(t, isMatch)
})
}
func TestParseSemver(t *testing.T) {
t.Run("basic parsing", func(t *testing.T) {
testCases := []struct {
input string
expected semverTuple
}{
{"1.2.3", semverTuple{1, 2, 3}},
{"0.0.0", semverTuple{0, 0, 0}},
{"10.20.30", semverTuple{10, 20, 30}},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
result, err := parseSemver(tc.input)
require.NoError(t, err)
require.Equal(t, tc.expected, result)
})
}
})
t.Run("v prefix", func(t *testing.T) {
result, err := parseSemver("v1.2.3")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 2, 3}, result)
result, err = parseSemver("V1.2.3")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 2, 3}, result)
})
t.Run("whitespace", func(t *testing.T) {
result, err := parseSemver(" 1.2.3 ")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 2, 3}, result)
result, err = parseSemver(" v1.2.3 ")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 2, 3}, result)
})
t.Run("pre-release suffixes stripped", func(t *testing.T) {
result, err := parseSemver("1.2.3-alpha")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 2, 3}, result)
result, err = parseSemver("1.2.3-alpha.1")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 2, 3}, result)
result, err = parseSemver("1.2.3-rc.1+build.123")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 2, 3}, result)
})
t.Run("build metadata stripped", func(t *testing.T) {
result, err := parseSemver("1.2.3+build.123")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 2, 3}, result)
})
t.Run("partial versions default to zero", func(t *testing.T) {
result, err := parseSemver("1.2")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 2, 0}, result)
result, err = parseSemver("1")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 0, 0}, result)
})
t.Run("extra components ignored", func(t *testing.T) {
result, err := parseSemver("1.2.3.4")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 2, 3}, result)
result, err = parseSemver("1.2.3.4.5.6")
require.NoError(t, err)
require.Equal(t, semverTuple{1, 2, 3}, result)
})
t.Run("leading zeros rejected per semver 2.0.0 §2", func(t *testing.T) {
invalidCases := []string{
"01.2.3",
"1.02.3",
"1.2.03",
"01.02.03",
"1.07.3",
"001.2.3",
}
for _, input := range invalidCases {
t.Run(input, func(t *testing.T) {
_, err := parseSemver(input)
require.Error(t, err, "expected error for input: %s", input)
})
}
// A literal "0" component is fine
validCases := []string{"0.1.0", "1.0.0", "0.0.0"}
for _, input := range validCases {
t.Run(input, func(t *testing.T) {
_, err := parseSemver(input)
require.NoError(t, err, "expected no error for valid input: %s", input)
})
}
})
t.Run("invalid values", func(t *testing.T) {
invalidCases := []string{
"",
" ",
"v",
"abc",
"1.2.abc",
".1.2.3",
"a.b.c",
}
for _, input := range invalidCases {
t.Run(input, func(t *testing.T) {
_, err := parseSemver(input)
require.Error(t, err)
})
}
})
t.Run("empty components rejected", func(t *testing.T) {
// These malformed inputs should be rejected, not silently parsed
invalidCases := []string{
"1..3", // empty minor
"1.2.", // empty patch (trailing dot)
"1.", // trailing dot with no minor
"1.2..", // double trailing dot
".1.2", // leading dot (empty major)
}
for _, input := range invalidCases {
t.Run(input, func(t *testing.T) {
_, err := parseSemver(input)
require.Error(t, err, "expected error for input: %s", input)
})
}
})
t.Run("malformed pre-release inputs rejected", func(t *testing.T) {
// Inputs where the core version is malformed should be rejected
invalidCases := []string{
"-alpha", // no version before pre-release
"+build", // no version before build metadata
"1.-2.3", // negative in wrong position (looks like suffix delimiter)
}
for _, input := range invalidCases {
t.Run(input, func(t *testing.T) {
_, err := parseSemver(input)
require.Error(t, err, "expected error for input: %s", input)
})
}
})
}
func TestMatchPropertySemverEq(t *testing.T) {
t.Run("exact match", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_eq",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3"))
require.NoError(t, err)
require.True(t, isMatch)
isMatch, err = matchProperty(property, NewProperties().Set("version", "1.2.4"))
require.NoError(t, err)
require.False(t, isMatch)
})
t.Run("pre-release equals base version", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_eq",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3-alpha.1"))
require.NoError(t, err)
require.True(t, isMatch)
})
t.Run("partial versions", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2",
Operator: "semver_eq",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.0"))
require.NoError(t, err)
require.True(t, isMatch)
})
t.Run("v prefix", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "v1.2.3",
Operator: "semver_eq",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3"))
require.NoError(t, err)
require.True(t, isMatch)
isMatch, err = matchProperty(property, NewProperties().Set("version", "v1.2.3"))
require.NoError(t, err)
require.True(t, isMatch)
})
}
func TestMatchPropertySemverNeq(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_neq",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3"))
require.NoError(t, err)
require.False(t, isMatch)
isMatch, err = matchProperty(property, NewProperties().Set("version", "1.2.4"))
require.NoError(t, err)
require.True(t, isMatch)
isMatch, err = matchProperty(property, NewProperties().Set("version", "2.0.0"))
require.NoError(t, err)
require.True(t, isMatch)
}
func TestMatchPropertySemverGt(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_gt",
}
testCases := []struct {
version string
expected bool
}{
{"1.2.4", true},
{"1.3.0", true},
{"2.0.0", true},
{"1.2.3", false},
{"1.2.2", false},
{"1.1.9", false},
{"0.9.9", false},
}
for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
require.NoError(t, err)
require.Equal(t, tc.expected, isMatch)
})
}
}
func TestMatchPropertySemverGte(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_gte",
}
testCases := []struct {
version string
expected bool
}{
{"1.2.4", true},
{"1.3.0", true},
{"2.0.0", true},
{"1.2.3", true}, // Equal
{"1.2.2", false},
{"1.1.9", false},
{"0.9.9", false},
}
for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
require.NoError(t, err)
require.Equal(t, tc.expected, isMatch)
})
}
}
func TestMatchPropertySemverLt(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_lt",
}
testCases := []struct {
version string
expected bool
}{
{"1.2.2", true},
{"1.1.9", true},
{"0.9.9", true},
{"1.2.3", false}, // Equal
{"1.2.4", false},
{"1.3.0", false},
{"2.0.0", false},
}
for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
require.NoError(t, err)
require.Equal(t, tc.expected, isMatch)
})
}
}
func TestMatchPropertySemverLte(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_lte",
}
testCases := []struct {
version string
expected bool
}{
{"1.2.2", true},
{"1.1.9", true},
{"0.9.9", true},
{"1.2.3", true}, // Equal
{"1.2.4", false},
{"1.3.0", false},
{"2.0.0", false},
}
for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
require.NoError(t, err)
require.Equal(t, tc.expected, isMatch)
})
}
}
func TestMatchPropertySemverTilde(t *testing.T) {
// ~1.2.3 means >=1.2.3 and <1.3.0
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_tilde",
}
testCases := []struct {
version string
expected bool
}{
// In range
{"1.2.3", true}, // Lower bound (inclusive)
{"1.2.4", true}, // Within range
{"1.2.99", true}, // Near upper bound
// Out of range
{"1.3.0", false}, // Upper bound (exclusive)
{"1.2.2", false}, // Below lower bound
{"1.1.0", false}, // Too low
{"2.0.0", false}, // Too high
}
for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
require.NoError(t, err)
require.Equal(t, tc.expected, isMatch)
})
}
t.Run("tilde with partial version", func(t *testing.T) {
// ~1.2 means >=1.2.0 and <1.3.0
property := FlagProperty{
Key: "version",
Value: "1.2",
Operator: "semver_tilde",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.0"))
require.NoError(t, err)
require.True(t, isMatch)
isMatch, err = matchProperty(property, NewProperties().Set("version", "1.2.5"))
require.NoError(t, err)
require.True(t, isMatch)
isMatch, err = matchProperty(property, NewProperties().Set("version", "1.3.0"))
require.NoError(t, err)
require.False(t, isMatch)
})
}
func TestMatchPropertySemverCaret(t *testing.T) {
t.Run("major > 0: ^1.2.3 means >=1.2.3 <2.0.0", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_caret",
}
testCases := []struct {
version string
expected bool
}{
{"1.2.3", true}, // Lower bound (inclusive)
{"1.2.4", true}, // Within range
{"1.3.0", true}, // Within range
{"1.99.99", true}, // Near upper bound
{"2.0.0", false}, // Upper bound (exclusive)
{"1.2.2", false}, // Below lower bound
{"0.9.9", false}, // Too low
{"3.0.0", false}, // Too high
}
for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
require.NoError(t, err)
require.Equal(t, tc.expected, isMatch)
})
}
})
t.Run("major == 0, minor > 0: ^0.2.3 means >=0.2.3 <0.3.0", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "0.2.3",
Operator: "semver_caret",
}
testCases := []struct {
version string
expected bool
}{
{"0.2.3", true}, // Lower bound (inclusive)
{"0.2.4", true}, // Within range
{"0.2.99", true}, // Near upper bound
{"0.3.0", false}, // Upper bound (exclusive)
{"0.2.2", false}, // Below lower bound
{"0.1.9", false}, // Too low
{"1.0.0", false}, // Too high
}
for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
require.NoError(t, err)
require.Equal(t, tc.expected, isMatch)
})
}
})
t.Run("major == 0, minor == 0: ^0.0.3 means >=0.0.3 <0.0.4", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "0.0.3",
Operator: "semver_caret",
}
testCases := []struct {
version string
expected bool
}{
{"0.0.3", true}, // Lower bound (inclusive)
{"0.0.4", false}, // Upper bound (exclusive)
{"0.0.2", false}, // Below lower bound
{"0.1.0", false}, // Too high
}
for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
require.NoError(t, err)
require.Equal(t, tc.expected, isMatch)
})
}
})
}
func TestMatchPropertySemverWildcard(t *testing.T) {
t.Run("1.* means >=1.0.0 <2.0.0", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.*",
Operator: "semver_wildcard",
}
testCases := []struct {
version string
expected bool
}{
{"1.0.0", true}, // Lower bound (inclusive)
{"1.0.1", true}, // Within range
{"1.5.0", true}, // Within range
{"1.99.99", true}, // Near upper bound
{"2.0.0", false}, // Upper bound (exclusive)
{"0.9.9", false}, // Too low
{"3.0.0", false}, // Too high
}
for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
require.NoError(t, err)
require.Equal(t, tc.expected, isMatch)
})
}
})
t.Run("1.2.* means >=1.2.0 <1.3.0", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.*",
Operator: "semver_wildcard",
}
testCases := []struct {
version string
expected bool
}{
{"1.2.0", true}, // Lower bound (inclusive)
{"1.2.5", true}, // Within range
{"1.2.99", true}, // Near upper bound
{"1.3.0", false}, // Upper bound (exclusive)
{"1.1.9", false}, // Too low
{"2.0.0", false}, // Too high
}
for _, tc := range testCases {
t.Run(tc.version, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("version", tc.version))
require.NoError(t, err)
require.Equal(t, tc.expected, isMatch)
})
}
})
t.Run("wildcard without asterisk treated as major version match", func(t *testing.T) {
// Test that "1" alone matches same as "1.*"
property := FlagProperty{
Key: "version",
Value: "1",
Operator: "semver_wildcard",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.5.0"))
require.NoError(t, err)
require.True(t, isMatch)
isMatch, err = matchProperty(property, NewProperties().Set("version", "2.0.0"))
require.NoError(t, err)
require.False(t, isMatch)
})
}
func TestMatchPropertySemverErrorHandling(t *testing.T) {
t.Run("missing property key", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_eq",
}
isMatch, err := matchProperty(property, NewProperties().Set("other_key", "1.2.3"))
require.Error(t, err)
require.False(t, isMatch)
var inconclusiveErr *InconclusiveMatchError
require.True(t, errors.As(err, &inconclusiveErr))
})
t.Run("null/nil property value", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_eq",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", nil))
require.Error(t, err)
require.False(t, isMatch)
})
t.Run("non-string property value", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_eq",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", 123))
require.Error(t, err)
require.False(t, isMatch)
var inconclusiveErr *InconclusiveMatchError
require.True(t, errors.As(err, &inconclusiveErr))
})
t.Run("invalid semver in property value", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_eq",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", "not-a-semver"))
require.Error(t, err)
require.False(t, isMatch)
var inconclusiveErr *InconclusiveMatchError
require.True(t, errors.As(err, &inconclusiveErr))
})
t.Run("invalid semver in flag value", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "not-a-semver",
Operator: "semver_eq",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3"))
require.Error(t, err)
require.False(t, isMatch)
var inconclusiveErr *InconclusiveMatchError
require.True(t, errors.As(err, &inconclusiveErr))
})
t.Run("empty string semver", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_eq",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", ""))
require.Error(t, err)
require.False(t, isMatch)
})
t.Run("invalid wildcard pattern", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "*",
Operator: "semver_wildcard",
}
isMatch, err := matchProperty(property, NewProperties().Set("version", "1.2.3"))
require.Error(t, err)
require.False(t, isMatch)
})
t.Run("leading-zero property value rejected", func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: "1.2.3",
Operator: "semver_eq",
}
for _, badValue := range []string{"01.2.3", "1.02.3", "1.2.03", "1.07.3", "001.2.3"} {
t.Run(badValue, func(t *testing.T) {
isMatch, err := matchProperty(property, NewProperties().Set("version", badValue))
require.Error(t, err)
require.False(t, isMatch)
var inconclusiveErr *InconclusiveMatchError
require.True(t, errors.As(err, &inconclusiveErr))
})
}
})
t.Run("leading-zero flag value rejected for caret/tilde/wildcard", func(t *testing.T) {
cases := []struct {
operator string
value string
}{
{"semver_caret", "1.07.0"},
{"semver_tilde", "1.07.0"},
{"semver_wildcard", "01.*"},
{"semver_gt", "01.2.3"},
}
for _, c := range cases {
t.Run(c.operator+"/"+c.value, func(t *testing.T) {
property := FlagProperty{
Key: "version",
Value: c.value,
Operator: c.operator,
}
isMatch, err := matchProperty(property, NewProperties().Set("version", "2.0.0"))
require.Error(t, err)
require.False(t, isMatch)
var inconclusiveErr *InconclusiveMatchError
require.True(t, errors.As(err, &inconclusiveErr))
})
}
})
}
func TestSemverCompareTo(t *testing.T) {
testCases := []struct {
a semverTuple
b semverTuple
expected int
}{
// Equal
{semverTuple{1, 2, 3}, semverTuple{1, 2, 3}, 0},
{semverTuple{0, 0, 0}, semverTuple{0, 0, 0}, 0},
// Major difference
{semverTuple{2, 0, 0}, semverTuple{1, 9, 9}, 1},
{semverTuple{1, 9, 9}, semverTuple{2, 0, 0}, -1},
// Minor difference
{semverTuple{1, 3, 0}, semverTuple{1, 2, 9}, 1},
{semverTuple{1, 2, 9}, semverTuple{1, 3, 0}, -1},
// Patch difference
{semverTuple{1, 2, 4}, semverTuple{1, 2, 3}, 1},
{semverTuple{1, 2, 3}, semverTuple{1, 2, 4}, -1},
}
for _, tc := range testCases {
name := tc.a.String() + " vs " + tc.b.String()
t.Run(name, func(t *testing.T) {
result := tc.a.compareTo(tc.b)
require.Equal(t, tc.expected, result)
})
}
}
// Helper for test naming
func (s semverTuple) String() string {
return strconv.Itoa(s.major) + "." + strconv.Itoa(s.minor) + "." + strconv.Itoa(s.patch)
}