diff --git a/CHANGELOG.md b/CHANGELOG.md index b99a3d8ca..f1b79a04d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ## Release (2025-xx-xx) +- `core`: [v0.18.0](core/CHANGELOG.md#v0180) + - **New:** Added duration utils - `stackitmarketplace`: [v1.16.0](services/stackitmarketplace/CHANGELOG.md#v1160) - **Breaking Change:** Remove unused `ProjectId` model struct - `iaas`: [v1.1.0](services/iaas/CHANGELOG.md#v110) diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index ab73cd486..84e4ef57c 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,3 +1,6 @@ +## v0.18.0 +- **New:** Added duration utils + ## v0.17.3 - **Dependencies:** Bump `github.com/golang-jwt/jwt/v5` from `v5.2.2` to `v5.2.3` diff --git a/core/VERSION b/core/VERSION index 93e7fb92f..a86d3df72 100644 --- a/core/VERSION +++ b/core/VERSION @@ -1 +1 @@ -v0.17.3 +v0.18.0 diff --git a/core/utils/duration.go b/core/utils/duration.go new file mode 100644 index 000000000..a0adb2db6 --- /dev/null +++ b/core/utils/duration.go @@ -0,0 +1,510 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package utils + +// Package utils provides general utility functions for common operations. +// +// The package includes utilities for: +// - Duration parsing and conversion with flexible unit support +// - Time-based calculations including calendar-aware month handling +// - Configurable validation with minimum/maximum boundaries +// +// Duration Conversion: +// +// The main function ConvertToSeconds parses time strings like "30m", "2h", "7d" +// and converts them to seconds. It supports both fixed-rate conversions and +// calendar-aware calculations for months. +// +// Supported units: +// - "s": seconds +// - "m": minutes +// - "h": hours +// - "d": days (24 hours) +// - "M": months (calendar-aware, handles varying month lengths) +// +// Example usage: +// +// // Basic conversion +// seconds, err := utils.ConvertToSeconds("30m") +// +// // With validation boundaries +// seconds, err := utils.ConvertToSeconds("2h", +// utils.WithMinSeconds(60), +// utils.WithMaxSeconds(7200)) +// +// // Calendar-aware month calculation +// seconds, err := utils.ConvertToSeconds("3M", utils.WithNow(time.Now())) +// +// The package uses the functional options pattern for flexible configuration +// and provides extensible interfaces for adding custom duration converters. + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + "unicode" +) + +// ============================================================================= +// Public Types and Interfaces +// ============================================================================= + +// DurationConverter defines the interface for converting a numeric value into a +// time.Duration. This abstraction supports both fixed-rate conversions (like +// seconds to minutes) and complex calendar-aware calculations (like adding months). +type DurationConverter interface { + // ToDuration converts a numeric value into a time.Duration. + // + // The function takes the following parameters: + // - value: The numeric value to convert (e.g., 30 for 30 days). + // - now: The reference time for calendar-based calculations. It can be + // ignored by implementations that are not calendar-dependent. + // + // It returns the calculated time.Duration and a nil error on success. On + // failure, it returns an error, for example if the value is too large to + // be processed. + ToDuration(value uint64, now time.Time) (time.Duration, error) +} + +// ValidationError represents errors that occur during input validation. +// It uses a Type field to distinguish between different validation failures. +type ValidationError struct { + Type ValidationErrorType // The specific type of validation error + Input string // The invalid input value + Reason string // Human-readable reason for the error + Context map[string]any // Additional context (min/max values, valid units, etc.) +} + +type ValidationErrorType string + +const ( + ValidationErrorInvalidFormat ValidationErrorType = "invalid_format" + ValidationErrorInvalidValue ValidationErrorType = "invalid_value" + ValidationErrorInvalidUnit ValidationErrorType = "invalid_unit" + ValidationErrorBelowMinimum ValidationErrorType = "below_minimum" + ValidationErrorAboveMaximum ValidationErrorType = "above_maximum" +) + +func (e *ValidationError) Error() string { + switch e.Type { + case ValidationErrorInvalidFormat: + if e.Reason != "" && e.Input != "" { + return fmt.Sprintf("invalid time string format %q: %s", e.Input, e.Reason) + } + if e.Input != "" { + return fmt.Sprintf("invalid time string format: %q", e.Input) + } + return "invalid time string format" + + case ValidationErrorInvalidValue: + if e.Reason != "" && e.Input != "" { + return fmt.Sprintf("invalid time value %q: %s", e.Input, e.Reason) + } + if e.Input != "" { + return fmt.Sprintf("invalid time value: %q", e.Input) + } + return "invalid time value" + + case ValidationErrorInvalidUnit: + if e.Input != "" { + if validUnits, ok := e.Context["validUnits"].([]string); ok { + return fmt.Sprintf("invalid time unit %q, supported units are %v", e.Input, validUnits) + } + return fmt.Sprintf("invalid time unit: %q", e.Input) + } + return "invalid time unit" + + case ValidationErrorBelowMinimum: + if minValue, ok := e.Context["minimum"].(uint64); ok { + if val, ok := e.Context["value"].(uint64); ok { + return fmt.Sprintf("duration is below minimum: %d seconds (minimum: %d seconds)", val, minValue) + } + } + return "duration is below the allowed minimum" + + case ValidationErrorAboveMaximum: + if maxValue, ok := e.Context["maximum"].(uint64); ok { + if val, ok := e.Context["value"].(uint64); ok { + return fmt.Sprintf("duration exceeds maximum: %d seconds (maximum: %d seconds)", val, maxValue) + } + } + return "duration exceeds the allowed maximum" + + default: + return fmt.Sprintf("validation error: %s", e.Input) + } +} + +// Is implements error matching for errors.Is() +func (e *ValidationError) Is(target error) bool { + if t, ok := target.(*ValidationError); ok { + return e.Type == t.Type + } + return false +} + +// CalculationError represents errors that occur during duration calculations. +type CalculationError struct { + Type CalculationErrorType // The specific type of calculation error + Value uint64 // The value that caused the error + Reason string // Human-readable reason + Context map[string]any // Additional context (multiplier, operation, etc.) +} + +type CalculationErrorType string + +const ( + CalculationErrorOutOfBounds CalculationErrorType = "out_of_bounds" + CalculationErrorNegativeResult CalculationErrorType = "negative_result" + CalculationErrorNegativeMultiplier CalculationErrorType = "negative_multiplier" +) + +func (e *CalculationError) Error() string { + switch e.Type { + case CalculationErrorOutOfBounds: + msg := "calculation result is out of bounds" + if e.Value > 0 { + msg += fmt.Sprintf(" (value: %d)", e.Value) + } + if operation, ok := e.Context["operation"].(string); ok { + msg += fmt.Sprintf(" during %s", operation) + } + if limit, ok := e.Context["limit"].(string); ok { + msg += fmt.Sprintf(" (exceeds %s)", limit) + } + return msg + + case CalculationErrorNegativeResult: + if result, ok := e.Context["result"].(float64); ok { + return fmt.Sprintf("calculated duration is negative: %f", result) + } + return "calculated duration is negative" + + case CalculationErrorNegativeMultiplier: + if multiplier, ok := e.Context["multiplier"].(time.Duration); ok { + return fmt.Sprintf("duration multiplier is negative: %v", multiplier) + } + return "duration multiplier is negative" + + default: + return fmt.Sprintf("calculation error with value %d", e.Value) + } +} + +// Is implements error matching for errors.Is() +func (e *CalculationError) Is(target error) bool { + if t, ok := target.(*CalculationError); ok { + return e.Type == t.Type + } + return false +} + +// ============================================================================= +// Public Functions +// ============================================================================= +// NewValidationError creates a new ValidationError with the specified type and details. +func NewValidationError(errorType ValidationErrorType, input, reason string, context map[string]any) *ValidationError { + return &ValidationError{ + Type: errorType, + Input: input, + Reason: reason, + Context: context, + } +} + +// NewCalculationError creates a new CalculationError with the specified type and details. +func NewCalculationError(errorType CalculationErrorType, value uint64, reason string, context map[string]any) *CalculationError { + return &CalculationError{ + Type: errorType, + Value: value, + Reason: reason, + Context: context, + } +} + +// Constructors for common error cases +func NewInvalidFormatError(input, reason string) *ValidationError { + return NewValidationError(ValidationErrorInvalidFormat, input, reason, nil) +} + +func NewInvalidValueError(input, reason string) *ValidationError { + return NewValidationError(ValidationErrorInvalidValue, input, reason, nil) +} + +func NewInvalidUnitError(unit string, validUnits []string) *ValidationError { + context := map[string]any{"validUnits": validUnits} + return NewValidationError(ValidationErrorInvalidUnit, unit, "", context) +} + +func NewBelowMinimumError(value, minimum uint64) *ValidationError { + context := map[string]any{"value": value, "minimum": minimum} + return NewValidationError(ValidationErrorBelowMinimum, "", "", context) +} + +func NewAboveMaximumError(value, maximum uint64) *ValidationError { + context := map[string]any{"value": value, "maximum": maximum} + return NewValidationError(ValidationErrorAboveMaximum, "", "", context) +} + +// An Option configures a ConvertToSeconds call. +type Option func(*converterConfig) + +// WithMinSeconds sets a minimum duration in seconds. A value of 0 means no limit. +func WithMinSeconds(minSeconds uint64) Option { + return func(c *converterConfig) { + c.minSeconds = &minSeconds + } +} + +// WithMaxSeconds sets a maximum duration in seconds. A value of 0 means no limit. +func WithMaxSeconds(maxSeconds uint64) Option { + return func(c *converterConfig) { + if maxSeconds == 0 { + c.maxSeconds = nil // Remove any previously set limit when 0 + } else { + c.maxSeconds = &maxSeconds + } + } +} + +// WithNow sets the current time to be used by the DurationConverter interface's ToDuration function. +// Useful for determenistic when testing calendar-dependent implementations of DurationConverter. +func WithNow(now time.Time) Option { + return func(c *converterConfig) { + c.now = &now + } +} + +// WithUnits provides a custom map of duration converters. +func WithUnits(units map[string]DurationConverter) Option { + return func(c *converterConfig) { + c.units = units + } +} + +// ConvertToSeconds converts a time string in the form of (e.g., "30m", "1h") into a total number of seconds as a uint64. +// +// The function uses a default set of units: +// - "s": seconds +// - "m": minutes +// - "h": hours +// - "d": days (24 hours) +// - "M": months (calendar-aware) +// +// Optional configurations can be provided using the Option type, such as setting +// minimum/maximum second boundaries (WithMinSeconds, WithMaxSeconds) or providing +// a custom set of units (WithUnits). +// +// It returns an error if the format is invalid, the unit is unsupported, or if the +// calculated value violates any provided boundaries. +func ConvertToSeconds(timeStr string, opts ...Option) (uint64, error) { + timeStr = strings.TrimSpace(timeStr) + + // Define a default config + cfg := &converterConfig{ + units: defaultUnits, + } + // Apply optional config settings, overwriting defaults + for _, opt := range opts { + opt(cfg) + } + + // Separate unit and value of timeStr + valueStr, unit, err := splitValueAndUnit(timeStr) + if err != nil { + return 0, err + } + + // Check for leading zeros, which are not allowed for values > 0 + if len(valueStr) > 1 && valueStr[0] == '0' { + return 0, NewInvalidFormatError(timeStr, "leading zeros are not allowed") + } + + // Parse Value, make sure it's a valid positive number that fit's a uint64 + value, err := strconv.ParseUint(valueStr, 10, 64) + if err != nil { + return 0, NewInvalidValueError(valueStr, err.Error()) + } + + // A value of 0 is not a valid duration. + if value == 0 { + return 0, NewInvalidValueError("0", "a value of 0 is not allowed") + } + + // Look up the DurationConverter to use + converter, ok := cfg.units[unit] + if !ok { + return 0, NewInvalidUnitError(unit, getKeys(cfg.units)) + } + + // Use the provided time.Time for 'now' if provided. Otherwise use system time. + var now time.Time + if cfg.now != nil { + now = *cfg.now + } else { + now = time.Now() + } + + // Calculate time.Duration using the DurationConverter interface + totalDuration, err := converter.ToDuration(value, now) + if err != nil { + return 0, err + } + // Convert time.Duration to Seconds + secondsFloat := totalDuration.Seconds() // float64 + + // Check for negative or overflow values and return an error if necessary + if secondsFloat < 0 { + context := map[string]any{"result": secondsFloat} + return 0, NewCalculationError(CalculationErrorNegativeResult, 0, "", context) + } + if secondsFloat > math.MaxUint64 { + // This case can only be tiggered if a new positive integer type bigger than MaxUint64 is added to Go + // because we currently can not have a custom converter returning bigger values than MaxUint64. + // Thus we currently can not fully test this path but will leave it here (defensive programming). + context := map[string]any{ + "operation": "final conversion", + "limit": "MaxUint64", + } + return 0, NewCalculationError(CalculationErrorOutOfBounds, 0, "result exceeds MaxUint64", context) + } + + // Cast to uint64 for boundary checks and final return + seconds := uint64(secondsFloat) + + // Check if the calculated duration is within the specified, optional boundaries. + if cfg.minSeconds != nil && seconds < *cfg.minSeconds { + return 0, NewBelowMinimumError(seconds, *cfg.minSeconds) + } + if cfg.maxSeconds != nil && seconds > *cfg.maxSeconds { + return 0, NewAboveMaximumError(seconds, *cfg.maxSeconds) + } + + return seconds, nil +} + +// ============================================================================= +// Private Types and Interfaces +// ============================================================================= + +// FixedMultiplier converts a value to a duration using a constant multiplier. +// It implements the DurationConverter interface. +type fixedMultiplier struct { + Multiplier time.Duration +} + +// fixedMultiplier.ToDuration calculates the duration by multiplying the value with the fixed multiplier. +// The `now` parameter is ignored as this calculation is not calendar-dependent. +func (fm fixedMultiplier) ToDuration(value uint64, _ time.Time) (time.Duration, error) { + // A negative multiplier is invalid as it would produce a negative duration and break the following overflow check. Fail early. + if fm.Multiplier < 0 { + context := map[string]any{"multiplier": fm.Multiplier} + return 0, NewCalculationError(CalculationErrorNegativeMultiplier, 0, "", context) + } + + // A zero multiplier will always result in a zero duration. + if fm.Multiplier == 0 { + return 0, nil + } + + // Check for overflow: both the value AND the multiplication result must fit in int64 + multiplierUint := uint64(fm.Multiplier) // #nosec G115 - already validated fm.Multiplier >= 0 + if value > math.MaxInt64 || value > math.MaxInt64/multiplierUint { + context := map[string]any{ + "operation": "multiplication", + "limit": "MaxInt64", + } + return 0, NewCalculationError(CalculationErrorOutOfBounds, value, "value or multiplication result exceeds MaxInt64", context) + } + + return time.Duration(int64(value)) * fm.Multiplier, nil +} + +// MonthCalculator implements calendar-aware month addition. +// Note: Results are normalized - adding 1 month to January 31st +// results in March 2nd/3rd (February 31st normalized). +// This matches the behavior of Go's time.AddDate(). +type monthCalculator struct{} + +// monthCalculator.ToDuration calculates the time.Duration between now and a future date N months from now. +func (mc monthCalculator) ToDuration(value uint64, now time.Time) (time.Duration, error) { + // Check if casting to int for AddDate would cause an overflow. + if value > math.MaxInt { + context := map[string]any{ + "operation": "month calculation", + "limit": "MaxInt", + } + return 0, NewCalculationError(CalculationErrorOutOfBounds, value, "value exceeds MaxInt", context) + } + future := now.AddDate(0, int(value), 0) + return future.Sub(now), nil +} + +// config holds the optional parameters for ConvertToSeconds. +type converterConfig struct { + minSeconds *uint64 + maxSeconds *uint64 + units map[string]DurationConverter + now *time.Time +} + +// ============================================================================= +// Private Variables and Constants +// ============================================================================= + +// DefaultUnits is a map of supported unit characters to their multipliers. +var defaultUnits = map[string]DurationConverter{ + "s": fixedMultiplier{Multiplier: time.Second}, + "m": fixedMultiplier{Multiplier: time.Minute}, + "h": fixedMultiplier{Multiplier: time.Hour}, + "d": fixedMultiplier{Multiplier: 24 * time.Hour}, // Day + "M": monthCalculator{}, // Month (calendar-aware) +} + +// ============================================================================= +// Private Functions +// ============================================================================= + +// Helper to get all keys of DurationConverter for error logging purposes. +func getKeys(m map[string]DurationConverter) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// splitValueAndUnit separates a time string like "30m" into its numeric ("30") and unit ("m") parts. It handles multi-character units. +func splitValueAndUnit(timeStr string) (valueStr, unitStr string, err error) { + if timeStr == "" { + return "", "", NewInvalidFormatError("", "input string is empty") + } + + // Find the first non-digit character in the string. + var splitIndex = -1 + for i, r := range timeStr { + if !unicode.IsDigit(r) { + // If the first non-digit is a '.' or ',' it's a float, which we don't support. + if r == '.' || r == ',' { + return "", "", NewInvalidValueError(timeStr, "floating-point values are not supported") + } + splitIndex = i + break + } + } + + // Check for invalid formats + if splitIndex == -1 { + return "", "", NewInvalidFormatError(timeStr, "contains no unit, expected format ") + } + if splitIndex == 0 { + return "", "", NewInvalidFormatError(timeStr, "must start with a number, expected format ") + } + + valueStr = timeStr[:splitIndex] + unitStr = timeStr[splitIndex:] + return valueStr, unitStr, nil +} diff --git a/core/utils/duration_test.go b/core/utils/duration_test.go new file mode 100644 index 000000000..997a78261 --- /dev/null +++ b/core/utils/duration_test.go @@ -0,0 +1,603 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package utils + +import ( + "errors" + "fmt" + "math" + "testing" + "time" +) + +// A fixed time for deterministic month calculations +var fixedNow = time.Date(2025, 10, 31, 12, 0, 0, 0, time.UTC) + +// Custom converters for edge case testing +type testNegativeConverter struct{} + +func (tnc testNegativeConverter) ToDuration(value uint64, _ time.Time) (time.Duration, error) { + // Directly return a negative duration to test the secondsFloat < 0 case + if value > math.MaxInt64 { + return 0, fmt.Errorf("value exceeds MaxInt64") + } + return -time.Duration(int64(value)) * time.Second, nil +} + +func TestConvertToSeconds(t *testing.T) { + tests := []struct { + name string + timeStr string + opts []Option + want uint64 + wantErr error + }{ + // Basic Success Cases + { + name: "seconds", + timeStr: "30s", + want: 30, + }, + { + name: "minutes", + timeStr: "2m", + want: 120, + }, + { + name: "hours", + timeStr: "1h", + want: 3600, + }, + { + name: "days", + timeStr: "1d", + want: 86400, + }, + { + name: "months", + timeStr: "1M", + opts: []Option{WithNow(fixedNow)}, + want: uint64(fixedNow.AddDate(0, 1, 0).Sub(fixedNow).Seconds()), + }, + // Large values that should work + { + name: "large but valid seconds", + timeStr: "86400s", // 1 day in seconds + want: 86400, + }, + { + name: "large but valid hours", + timeStr: "24h", + want: 86400, + }, + // Mixed boundary conditions + { + name: "exactly at min and max", + timeStr: "100s", + opts: []Option{WithMinSeconds(100), WithMaxSeconds(100)}, + want: 100, + }, + { + name: "max seconds is 0 (no limit)", + timeStr: "999999s", + opts: []Option{WithMaxSeconds(0)}, + want: 999999, + }, + + { + name: "zero multiplier custom unit", + timeStr: "5z", // Any value with zero multiplier should return 0 + opts: []Option{WithUnits(map[string]DurationConverter{ + "z": fixedMultiplier{Multiplier: 0}, + })}, + want: 0, + }, + { + name: "zero value", + timeStr: "0s", + wantErr: &ValidationError{Type: ValidationErrorInvalidValue}, + }, + { + name: "negative multiplier custom unit", + timeStr: "5n", // Any value with negative multiplier should error + opts: []Option{WithUnits(map[string]DurationConverter{ + "n": fixedMultiplier{Multiplier: -time.Second}, + })}, + wantErr: &CalculationError{Type: CalculationErrorNegativeMultiplier}, + }, + { + name: "result exceeds MaxUint64", + timeStr: fmt.Sprintf("%dx", math.MaxUint32), + opts: []Option{WithUnits(map[string]DurationConverter{ + "x": fixedMultiplier{Multiplier: time.Duration(math.MaxInt64)}, // Very large multiplier + })}, + wantErr: &CalculationError{Type: CalculationErrorOutOfBounds}, + }, + { + name: "negative result from calculation", + timeStr: "5neg", // Use custom converter that returns negative duration + opts: []Option{WithUnits(map[string]DurationConverter{ + "neg": testNegativeConverter{}, // Custom converter that returns negative duration + })}, + wantErr: &CalculationError{Type: CalculationErrorNegativeResult}, + }, + + // Month edge cases (calendar-aware) + { + name: "month from end of month", + timeStr: "1M", + opts: []Option{WithNow(time.Date(2025, 1, 31, 12, 0, 0, 0, time.UTC))}, // Jan 31 -> Feb 28/29 + want: uint64(time.Date(2025, 3, 3, 12, 0, 0, 0, time.UTC).Sub(time.Date(2025, 1, 31, 12, 0, 0, 0, time.UTC)).Seconds()), + }, + { + name: "multiple months", + timeStr: "3M", + opts: []Option{WithNow(fixedNow)}, + want: uint64(fixedNow.AddDate(0, 3, 0).Sub(fixedNow).Seconds()), + }, + { + name: "month value too large for MaxInt", + timeStr: fmt.Sprintf("%dM", uint64(math.MaxInt)+1), + opts: []Option{WithNow(fixedNow)}, + wantErr: &CalculationError{Type: CalculationErrorOutOfBounds}, + }, + + // Boundary Checks (min/max) + { + name: "below minimum", + timeStr: "59s", + opts: []Option{WithMinSeconds(60)}, + wantErr: &ValidationError{Type: ValidationErrorBelowMinimum}, + }, + { + name: "at minimum", + timeStr: "60s", + opts: []Option{WithMinSeconds(60)}, + want: 60, + }, + { + name: "above maximum", + timeStr: "61s", + opts: []Option{WithMaxSeconds(60)}, + wantErr: &ValidationError{Type: ValidationErrorAboveMaximum}, + }, + { + name: "at maximum", + timeStr: "60s", + opts: []Option{WithMaxSeconds(60)}, + want: 60, + }, + { + name: "within boundaries", + timeStr: "30s", + opts: []Option{WithMinSeconds(10), WithMaxSeconds(40)}, + want: 30, + }, + + // Custom units + { + name: "custom unit", + timeStr: "2w", + opts: []Option{WithUnits(map[string]DurationConverter{ + "w": fixedMultiplier{Multiplier: 7 * 24 * time.Hour}, + })}, + want: 1209600, // 2 * 7 * 24 * 3600 + }, + { + name: "custom multi char unit", + timeStr: "3wk", + opts: []Option{WithUnits(map[string]DurationConverter{ + "wk": fixedMultiplier{Multiplier: 7 * 24 * time.Hour}, + })}, + want: 1814400, // 3 * 7 * 24 * 3600 + }, + // Whitespace handling + { + name: "leading whitespace", + timeStr: " 10s", + want: 10, + }, + { + name: "trailing whitespace", + timeStr: "10s ", + want: 10, + }, + + // Leading zeros (should be invalid) + { + name: "leading zeros invalid", + timeStr: "01s", + wantErr: &ValidationError{Type: ValidationErrorInvalidFormat}, + }, + { + name: "multiple leading zeros", + timeStr: "007m", + wantErr: &ValidationError{Type: ValidationErrorInvalidFormat}, + }, + + // Other error cases + { + name: "invalid format no unit", + timeStr: "123", + wantErr: &ValidationError{Type: ValidationErrorInvalidFormat}, + }, + { + name: "invalid format starts with unit", + timeStr: "m30", + wantErr: &ValidationError{Type: ValidationErrorInvalidFormat}, + }, + { + name: "invalid format empty string", + timeStr: "", + wantErr: &ValidationError{Type: ValidationErrorInvalidFormat}, + }, + { + name: "invalid format value missing", + timeStr: "abcS", + wantErr: &ValidationError{Type: ValidationErrorInvalidFormat}, + }, + { + name: "unsupported unit", + timeStr: "1y", + wantErr: &ValidationError{Type: ValidationErrorInvalidUnit}, + }, + { + name: "multi-char unit with default units", + timeStr: "5ms", + wantErr: &ValidationError{Type: ValidationErrorInvalidUnit}, + }, + { + name: "value too large for int64 duration", + timeStr: fmt.Sprintf("%ds", math.MaxInt64), // This will overflow + wantErr: &CalculationError{Type: CalculationErrorOutOfBounds}, + }, + { + name: "very large number string exceeds uint64 when parsing", + timeStr: "999999999999999999999s", // Larger than uint64 max + wantErr: &ValidationError{Type: ValidationErrorInvalidValue}, + }, + // Behavior with non-integer and negative values + { + name: "floating point value", + timeStr: "1.5h", + wantErr: &ValidationError{Type: ValidationErrorInvalidValue}, + }, + { + name: "negative value", + timeStr: "-10s", + wantErr: &ValidationError{Type: ValidationErrorInvalidFormat}, + }, + { + name: "comma as decimal separator", + timeStr: "1,5h", + wantErr: &ValidationError{Type: ValidationErrorInvalidValue}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var seconds uint64 + var err error + + seconds, err = ConvertToSeconds(tt.timeStr, tt.opts...) + + if tt.wantErr != nil { + if err == nil { + t.Fatalf("expected error '%v', but got nil (%v)", tt.wantErr, tt.name) + } + if !errors.Is(err, tt.wantErr) { + t.Fatalf("expected error to be '%v', but got '%v' (%v)", tt.wantErr, err, tt.name) + } + return // Test passed + } + + if err != nil { + t.Fatalf("expected no error, but got: %v", err) + } + + if seconds != tt.want { + t.Errorf("expected %d seconds, but got %d", tt.want, seconds) + } + }) + } +} + +func TestValidationErrorString(t *testing.T) { + tests := []struct { + name string + err *ValidationError + want string + }{ + // InvalidFormat cases + { + name: "invalid format with input and reason", + err: &ValidationError{Type: ValidationErrorInvalidFormat, Input: "30m", Reason: "leading zeros not allowed"}, + want: `invalid time string format "30m": leading zeros not allowed`, + }, + { + name: "invalid format with input only", + err: &ValidationError{Type: ValidationErrorInvalidFormat, Input: "30m"}, + want: `invalid time string format: "30m"`, + }, + { + name: "invalid format minimal", + err: &ValidationError{Type: ValidationErrorInvalidFormat}, + want: "invalid time string format", + }, + + // InvalidValue cases + { + name: "invalid value with input and reason", + err: &ValidationError{Type: ValidationErrorInvalidValue, Input: "abc", Reason: "not a number"}, + want: `invalid time value "abc": not a number`, + }, + { + name: "invalid value with input only", + err: &ValidationError{Type: ValidationErrorInvalidValue, Input: "abc"}, + want: `invalid time value: "abc"`, + }, + { + name: "invalid value minimal", + err: &ValidationError{Type: ValidationErrorInvalidValue}, + want: "invalid time value", + }, + + // InvalidUnit cases + { + name: "invalid unit with valid units list", + err: &ValidationError{ + Type: ValidationErrorInvalidUnit, + Input: "x", + Context: map[string]any{"validUnits": []string{"s", "m", "h"}}, + }, + want: `invalid time unit "x", supported units are [s m h]`, + }, + { + name: "invalid unit with input only", + err: &ValidationError{Type: ValidationErrorInvalidUnit, Input: "x"}, + want: `invalid time unit: "x"`, + }, + { + name: "invalid unit minimal", + err: &ValidationError{Type: ValidationErrorInvalidUnit}, + want: "invalid time unit", + }, + + // BelowMinimum cases + { + name: "below minimum with values", + err: &ValidationError{ + Type: ValidationErrorBelowMinimum, + Context: map[string]any{"value": uint64(50), "minimum": uint64(60)}, + }, + want: "duration is below minimum: 50 seconds (minimum: 60 seconds)", + }, + { + name: "below minimum minimal", + err: &ValidationError{Type: ValidationErrorBelowMinimum}, + want: "duration is below the allowed minimum", + }, + + // AboveMaximum cases + { + name: "above maximum with values", + err: &ValidationError{ + Type: ValidationErrorAboveMaximum, + Context: map[string]any{"value": uint64(120), "maximum": uint64(100)}, + }, + want: "duration exceeds maximum: 120 seconds (maximum: 100 seconds)", + }, + { + name: "above maximum minimal", + err: &ValidationError{Type: ValidationErrorAboveMaximum}, + want: "duration exceeds the allowed maximum", + }, + + // Default case + { + name: "unknown validation error type", + err: &ValidationError{Type: "unknown_type", Input: "test"}, + want: "validation error: test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.err.Error() + if got != tt.want { + t.Errorf("ValidationError.Error() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCalculationErrorString(t *testing.T) { + tests := []struct { + name string + err *CalculationError + want string + }{ + // OutOfBounds cases + { + name: "out of bounds with all context", + err: &CalculationError{ + Type: CalculationErrorOutOfBounds, + Value: 12345, + Context: map[string]any{"operation": "multiplication", "limit": "MaxInt64"}, + }, + want: "calculation result is out of bounds (value: 12345) during multiplication (exceeds MaxInt64)", + }, + { + name: "out of bounds with value and operation", + err: &CalculationError{ + Type: CalculationErrorOutOfBounds, + Value: 12345, + Context: map[string]any{"operation": "multiplication"}, + }, + want: "calculation result is out of bounds (value: 12345) during multiplication", + }, + { + name: "out of bounds with value only", + err: &CalculationError{ + Type: CalculationErrorOutOfBounds, + Value: 12345, + }, + want: "calculation result is out of bounds (value: 12345)", + }, + { + name: "out of bounds minimal", + err: &CalculationError{Type: CalculationErrorOutOfBounds}, + want: "calculation result is out of bounds", + }, + + // NegativeResult cases + { + name: "negative result with result value", + err: &CalculationError{ + Type: CalculationErrorNegativeResult, + Context: map[string]any{"result": -123.456}, + }, + want: "calculated duration is negative: -123.456000", + }, + { + name: "negative result minimal", + err: &CalculationError{Type: CalculationErrorNegativeResult}, + want: "calculated duration is negative", + }, + + // NegativeMultiplier cases + { + name: "negative multiplier with multiplier value", + err: &CalculationError{ + Type: CalculationErrorNegativeMultiplier, + Context: map[string]any{"multiplier": -5 * time.Second}, + }, + want: "duration multiplier is negative: -5s", + }, + { + name: "negative multiplier minimal", + err: &CalculationError{Type: CalculationErrorNegativeMultiplier}, + want: "duration multiplier is negative", + }, + + // Default case + { + name: "unknown calculation error type", + err: &CalculationError{Type: "unknown_type", Value: 123}, + want: "calculation error with value 123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.err.Error() + if got != tt.want { + t.Errorf("CalculationError.Error() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestValidationErrorIs(t *testing.T) { + tests := []struct { + name string + err *ValidationError + target error + want bool + }{ + // True cases - same type and same error type + { + name: "same validation error type matches", + err: &ValidationError{Type: ValidationErrorInvalidFormat}, + target: &ValidationError{Type: ValidationErrorInvalidFormat}, + want: true, + }, + { + name: "different validation error types don't match", + err: &ValidationError{Type: ValidationErrorInvalidFormat}, + target: &ValidationError{Type: ValidationErrorInvalidValue}, + want: false, + }, + // False cases - different error types + { + name: "validation error vs calculation error", + err: &ValidationError{Type: ValidationErrorInvalidFormat}, + target: &CalculationError{Type: CalculationErrorOutOfBounds}, + want: false, + }, + { + name: "validation error vs standard error", + err: &ValidationError{Type: ValidationErrorInvalidFormat}, + target: errors.New("some other error"), + want: false, + }, + { + name: "validation error vs nil", + err: &ValidationError{Type: ValidationErrorInvalidFormat}, + target: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.err.Is(tt.target) + if got != tt.want { + t.Errorf("ValidationError.Is() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCalculationErrorIs(t *testing.T) { + tests := []struct { + name string + err *CalculationError + target error + want bool + }{ + // True cases - same type and same error type + { + name: "same calculation error type matches", + err: &CalculationError{Type: CalculationErrorOutOfBounds}, + target: &CalculationError{Type: CalculationErrorOutOfBounds}, + want: true, + }, + // False cases - different calculation error types + { + name: "different calculation error types don't match", + err: &CalculationError{Type: CalculationErrorOutOfBounds}, + target: &CalculationError{Type: CalculationErrorNegativeResult}, + want: false, + }, + // False cases - different error types + { + name: "calculation error vs validation error", + err: &CalculationError{Type: CalculationErrorOutOfBounds}, + target: &ValidationError{Type: ValidationErrorInvalidFormat}, + want: false, + }, + { + name: "calculation error vs standard error", + err: &CalculationError{Type: CalculationErrorOutOfBounds}, + target: errors.New("some other error"), + want: false, + }, + { + name: "calculation error vs nil", + err: &CalculationError{Type: CalculationErrorOutOfBounds}, + target: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.err.Is(tt.target) + if got != tt.want { + t.Errorf("CalculationError.Is() = %v, want %v", got, tt.want) + } + }) + } +}