custom_channel_time_pricing_test.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. //go:build unit
  2. package service
  3. import (
  4. "math"
  5. "strings"
  6. "testing"
  7. "time"
  8. "github.com/stretchr/testify/require"
  9. )
  10. func timeConfig(periods ...ChannelTimePricingPeriod) *ChannelTimePricing {
  11. return &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: periods}
  12. }
  13. func onePeriod() []ChannelTimePricingPeriod {
  14. return []ChannelTimePricingPeriod{{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}}
  15. }
  16. func TestValidateChannelTimePricing(t *testing.T) {
  17. tests := []struct {
  18. name string
  19. config *ChannelTimePricing
  20. wantErr string
  21. }{
  22. {name: "nil disabled", config: nil},
  23. {name: "empty disabled", config: &ChannelTimePricing{Timezone: "Asia/Shanghai"}},
  24. {name: "adjacent", config: timeConfig(
  25. ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2},
  26. ChannelTimePricingPeriod{StartTime: "12:00", EndTime: "14:00", Multiplier: 1.5})},
  27. {name: "midnight split", config: timeConfig(
  28. ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "00:00", Multiplier: 2},
  29. ChannelTimePricingPeriod{StartTime: "00:00", EndTime: "02:00", Multiplier: 2})},
  30. {name: "second precision", config: timeConfig(
  31. ChannelTimePricingPeriod{StartTime: "09:00:00", EndTime: "12:00:00", Multiplier: 2},
  32. ChannelTimePricingPeriod{StartTime: "14:00:00", EndTime: "18:00:00", Multiplier: 2})},
  33. {name: "second precision overlap", config: timeConfig(
  34. ChannelTimePricingPeriod{StartTime: "09:00:00", EndTime: "12:00:00", Multiplier: 2},
  35. ChannelTimePricingPeriod{StartTime: "11:59:59", EndTime: "14:00:00", Multiplier: 2}), wantErr: "overlap"},
  36. {name: "empty timezone", config: &ChannelTimePricing{Periods: onePeriod()}, wantErr: "timezone"},
  37. {name: "whitespace timezone", config: &ChannelTimePricing{Timezone: " ", Periods: onePeriod()}, wantErr: "timezone"},
  38. {name: "timezone", config: &ChannelTimePricing{Timezone: "UTC+8", Periods: onePeriod()}, wantErr: "timezone"},
  39. {name: "format", config: timeConfig(ChannelTimePricingPeriod{StartTime: "9:00", EndTime: "12:00", Multiplier: 2}), wantErr: "HH:mm"},
  40. {name: "equal midnight", config: timeConfig(ChannelTimePricingPeriod{StartTime: "00:00", EndTime: "00:00", Multiplier: 2}), wantErr: "before"},
  41. {name: "cross midnight", config: timeConfig(ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "02:00", Multiplier: 2}), wantErr: "before"},
  42. {name: "overlap", config: timeConfig(
  43. ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2},
  44. ChannelTimePricingPeriod{StartTime: "11:59", EndTime: "14:00", Multiplier: 2}), wantErr: "overlap"},
  45. {name: "zero", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 0}), wantErr: "greater than 0"},
  46. {name: "minimum positive", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 0.01})},
  47. {name: "tiny positive", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 1e-12}), wantErr: "at least 0.01"},
  48. {name: "below minimum", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 0.001}), wantErr: "at least 0.01"},
  49. {name: "three decimals", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 1.001}), wantErr: "decimal"},
  50. {name: "scaled overflow", config: timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: math.MaxFloat64}), wantErr: "finite"},
  51. }
  52. for _, tt := range tests {
  53. t.Run(tt.name, func(t *testing.T) {
  54. err := validateChannelTimePricing(tt.config)
  55. if tt.wantErr == "" {
  56. require.NoError(t, err)
  57. return
  58. }
  59. require.Error(t, err)
  60. require.True(t, strings.Contains(err.Error(), tt.wantErr), "error %q does not contain %q", err, tt.wantErr)
  61. })
  62. }
  63. }
  64. func TestChannelTimePricingMultiplierAt(t *testing.T) {
  65. config := timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2})
  66. tests := []struct {
  67. name string
  68. at time.Time
  69. want float64
  70. }{
  71. {name: "Shanghai 08:59", at: time.Date(2026, 6, 29, 0, 59, 0, 0, time.UTC), want: 1},
  72. {name: "Shanghai 09:00", at: time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC), want: 2},
  73. {name: "Shanghai 11:59", at: time.Date(2026, 6, 29, 3, 59, 0, 0, time.UTC), want: 2},
  74. {name: "Shanghai 12:00", at: time.Date(2026, 6, 29, 4, 0, 0, 0, time.UTC), want: 1},
  75. {name: "Shanghai 14:00", at: time.Date(2026, 6, 29, 6, 0, 0, 0, time.UTC), want: 1},
  76. }
  77. for _, tt := range tests {
  78. t.Run(tt.name, func(t *testing.T) {
  79. require.Equal(t, tt.want, config.MultiplierAt(tt.at))
  80. })
  81. }
  82. newYork := &ChannelTimePricing{Timezone: "America/New_York", Periods: onePeriod()}
  83. at := time.Date(2026, 6, 29, 14, 0, 0, 0, time.UTC)
  84. require.Equal(t, 1.0, config.MultiplierAt(at))
  85. require.Equal(t, 2.0, newYork.MultiplierAt(at))
  86. }
  87. func TestChannelTimePricingMultiplierAtWeekdaysOnly(t *testing.T) {
  88. config := &ChannelTimePricing{
  89. Timezone: "Asia/Shanghai",
  90. WeekdaysOnly: true,
  91. Periods: onePeriod(),
  92. }
  93. tests := []struct {
  94. name string
  95. at time.Time
  96. want float64
  97. }{
  98. {name: "Monday in configured timezone", at: time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC), want: 2},
  99. {name: "Saturday in configured timezone", at: time.Date(2026, 7, 4, 1, 0, 0, 0, time.UTC), want: 1},
  100. {name: "Sunday in configured timezone", at: time.Date(2026, 7, 5, 1, 0, 0, 0, time.UTC), want: 1},
  101. }
  102. for _, tt := range tests {
  103. t.Run(tt.name, func(t *testing.T) {
  104. require.Equal(t, tt.want, config.MultiplierAt(tt.at))
  105. })
  106. }
  107. }
  108. func TestChannelTimePricingMultiplierAtSecondPrecision(t *testing.T) {
  109. config := timeConfig(ChannelTimePricingPeriod{StartTime: "09:00:30", EndTime: "09:00:45", Multiplier: 2})
  110. shanghai, err := time.LoadLocation("Asia/Shanghai")
  111. require.NoError(t, err)
  112. tests := []struct {
  113. name string
  114. at time.Time
  115. want float64
  116. }{
  117. {name: "before", at: time.Date(2026, 6, 29, 9, 0, 29, 0, shanghai), want: 1},
  118. {name: "start", at: time.Date(2026, 6, 29, 9, 0, 30, 0, shanghai), want: 2},
  119. {name: "last matching second", at: time.Date(2026, 6, 29, 9, 0, 44, 999_999_999, shanghai), want: 2},
  120. {name: "end", at: time.Date(2026, 6, 29, 9, 0, 45, 0, shanghai), want: 1},
  121. }
  122. for _, tt := range tests {
  123. t.Run(tt.name, func(t *testing.T) {
  124. require.Equal(t, tt.want, config.MultiplierAt(tt.at))
  125. })
  126. }
  127. }
  128. func TestChannelTimePricingMultiplierAtMidnightSplit(t *testing.T) {
  129. config := timeConfig(
  130. ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "00:00", Multiplier: 2},
  131. ChannelTimePricingPeriod{StartTime: "00:00", EndTime: "02:00", Multiplier: 3},
  132. )
  133. shanghai, err := time.LoadLocation("Asia/Shanghai")
  134. require.NoError(t, err)
  135. tests := []struct {
  136. name string
  137. at time.Time
  138. want float64
  139. }{
  140. {name: "23:59", at: time.Date(2026, 6, 29, 23, 59, 0, 0, shanghai), want: 2},
  141. {name: "next day 00:00", at: time.Date(2026, 6, 30, 0, 0, 0, 0, shanghai), want: 3},
  142. {name: "02:00", at: time.Date(2026, 6, 30, 2, 0, 0, 0, shanghai), want: 1},
  143. }
  144. for _, tt := range tests {
  145. t.Run(tt.name, func(t *testing.T) {
  146. require.Equal(t, tt.want, config.MultiplierAt(tt.at))
  147. })
  148. }
  149. }
  150. func TestChannelTimePricingMultiplierAtDegradesForInvalidConfigurations(t *testing.T) {
  151. var nilConfig *ChannelTimePricing
  152. zeroTime := time.Time{}
  153. validAt := time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC)
  154. require.Equal(t, 1.0, nilConfig.MultiplierAt(validAt))
  155. require.Equal(t, 1.0, timeConfig().MultiplierAt(validAt))
  156. require.Equal(t, 1.0, timeConfig(ChannelTimePricingPeriod{StartTime: "09:00", EndTime: "12:00", Multiplier: 2}).MultiplierAt(zeroTime))
  157. require.Equal(t, 1.0, (&ChannelTimePricing{Periods: onePeriod()}).MultiplierAt(validAt))
  158. require.Equal(t, 1.0, (&ChannelTimePricing{Timezone: " ", Periods: onePeriod()}).MultiplierAt(validAt))
  159. require.Equal(t, 1.0, (&ChannelTimePricing{Timezone: "UTC+8", Periods: onePeriod()}).MultiplierAt(validAt))
  160. require.Equal(t, 1.0, timeConfig(ChannelTimePricingPeriod{StartTime: "22:00", EndTime: "02:00", Multiplier: 2}).MultiplierAt(validAt))
  161. }
  162. func TestChannelTimePricingRejectsLocalTimezone(t *testing.T) {
  163. config := &ChannelTimePricing{Timezone: "Local", Periods: onePeriod()}
  164. err := validateChannelTimePricing(config)
  165. require.Error(t, err)
  166. require.Contains(t, err.Error(), "timezone")
  167. require.Equal(t, 1.0, config.MultiplierAt(time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC)))
  168. }