channel_monitor_v2_repo_test.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. package repository
  2. import (
  3. "context"
  4. "strings"
  5. "testing"
  6. "time"
  7. "github.com/DATA-DOG/go-sqlmock"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. "github.com/lib/pq"
  10. "github.com/stretchr/testify/require"
  11. )
  12. func TestChannelMonitorV2DisplayModelIsPlatformScoped(t *testing.T) {
  13. cfg := service.ChannelMonitorV2Config{Platforms: []service.ChannelMonitorV2PlatformConfig{
  14. {Platform: "openai", Enabled: true, Models: []string{"shared", "gpt-5"}},
  15. {Platform: "grok", Enabled: true, Models: []string{"grok-4"}},
  16. // Empty models list must NOT collapse everything into __other__.
  17. {Platform: "anthropic", Enabled: true, Models: []string{}},
  18. }}
  19. require.Equal(t, "shared", channelMonitorV2DisplayModel(cfg, "openai", "shared"))
  20. require.Equal(t, service.ChannelMonitorV2OtherModel, channelMonitorV2DisplayModel(cfg, "grok", "shared"))
  21. require.Equal(t, "claude-sonnet-4", channelMonitorV2DisplayModel(cfg, "anthropic", "claude-sonnet-4"))
  22. // Unconfigured platform still surfaces the real model name.
  23. require.Equal(t, "gemini-2.5-pro", channelMonitorV2DisplayModel(cfg, "gemini", "gemini-2.5-pro"))
  24. require.True(t, channelMonitorV2ModelSelected(service.ChannelMonitorV2Filter{Models: []string{service.ChannelMonitorV2OtherModel}}, cfg, "grok", "shared"))
  25. }
  26. func TestChannelMonitorV2MatrixDimensionKey(t *testing.T) {
  27. cfg := service.ChannelMonitorV2Config{Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}}}
  28. key := channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatformGroupModel, cfg, "openai", 7, "gpt-5")
  29. require.Equal(t, channelMonitorV2MatrixKey{platform: "openai", groupID: 7, model: "gpt-5"}, key)
  30. key = channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatformModel, cfg, "openai", 7, "unlisted")
  31. require.Equal(t, channelMonitorV2MatrixKey{platform: "openai", model: service.ChannelMonitorV2OtherModel}, key)
  32. key = channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatform, cfg, "openai", 7, "gpt-5")
  33. require.Equal(t, channelMonitorV2MatrixKey{platform: "openai"}, key)
  34. }
  35. func TestChannelMonitorV2HistogramPercentilesAreMergedFromCounts(t *testing.T) {
  36. // 100 samples: 50@100, 40@500, 10@1000
  37. // target = int64(total*p + 0.999999) truncates: p50→50, p90→90, p95→95
  38. // cumulative hits: p50@100, p90@500 (50+40), p95@1000
  39. histogram := map[int64]int64{100: 50, 500: 40, 1000: 10}
  40. require.Equal(t, int64(100), *histPercentile(histogram, .5))
  41. require.Equal(t, int64(500), *histPercentile(histogram, .9))
  42. require.Equal(t, int64(1000), *histPercentile(histogram, .95))
  43. require.Nil(t, histPercentile(nil, .95))
  44. // latencyMetric exposes avg + p50 + p90 + p95
  45. lat := latencyMetric(1000, 10, histogram)
  46. require.NotNil(t, lat.AvgMs)
  47. require.NotNil(t, lat.P50Ms)
  48. require.NotNil(t, lat.P90Ms)
  49. require.NotNil(t, lat.P95Ms)
  50. require.Equal(t, int64(100), *lat.P50Ms)
  51. require.Equal(t, int64(500), *lat.P90Ms)
  52. require.Equal(t, int64(1000), *lat.P95Ms)
  53. }
  54. func TestChannelMonitorV2MetricIncludesSuccessRate(t *testing.T) {
  55. acc := newMetricAccumulator()
  56. acc.success, acc.errors = 80, 20
  57. metric := acc.metric(1, false)
  58. require.Equal(t, int64(100), metric.RequestCount)
  59. require.InDelta(t, 0.8, metric.SuccessRate, 0.0001)
  60. require.InDelta(t, 0.2, metric.ErrorRate, 0.0001)
  61. require.Nil(t, metric.UpstreamAffectedRequests)
  62. adminMetric := acc.metric(1, true)
  63. require.NotNil(t, adminMetric.UpstreamAffectedRequests)
  64. }
  65. func TestChannelMonitorV2WhereUsesConfiguredScopeAndEmptyFilterMeansAllConfigured(t *testing.T) {
  66. filter := service.ChannelMonitorV2Filter{Start: time.Unix(1, 0), End: time.Unix(2, 0)}
  67. cfg := service.ChannelMonitorV2Config{
  68. Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}, {Platform: "grok", Enabled: false}},
  69. GroupIDs: []int64{3, 4},
  70. }
  71. where, args := channelMonitorV2Where(filter, cfg, "m")
  72. require.Contains(t, where, "m.platform = ANY($3)")
  73. require.Contains(t, where, "m.group_id = ANY($4)")
  74. require.Len(t, args, 4)
  75. }
  76. func TestChannelMonitorV2WhereRejectsGroupFilterOutsideConfiguredScope(t *testing.T) {
  77. filter := service.ChannelMonitorV2Filter{
  78. Start: time.Unix(1, 0), End: time.Unix(2, 0), GroupIDs: []int64{9},
  79. }
  80. cfg := service.ChannelMonitorV2Config{
  81. Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
  82. GroupIDs: []int64{3, 4},
  83. }
  84. where, args := channelMonitorV2Where(filter, cfg, "m")
  85. require.Contains(t, where, "FALSE")
  86. require.NotContains(t, where, "m.group_id = ANY")
  87. require.Len(t, args, 3)
  88. }
  89. func TestChannelMonitorV2WhereRestrictsOrdinaryViewerToAllowedConfiguredGroups(t *testing.T) {
  90. filter := service.ChannelMonitorV2Filter{
  91. Start: time.Unix(1, 0), End: time.Unix(2, 0),
  92. GroupIDs: []int64{4, 9}, AllowedGroupIDs: []int64{3, 4}, RestrictGroups: true,
  93. }
  94. cfg := service.ChannelMonitorV2Config{
  95. Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
  96. GroupIDs: []int64{3, 4, 9},
  97. }
  98. where, args := channelMonitorV2Where(filter, cfg, "m")
  99. require.Contains(t, where, "m.group_id = ANY($4)")
  100. require.Equal(t, pq.Array([]int64{4}), args[3])
  101. }
  102. func TestChannelMonitorV2WhereRejectsOrdinaryViewerWithNoAllowedGroups(t *testing.T) {
  103. filter := service.ChannelMonitorV2Filter{
  104. Start: time.Unix(1, 0), End: time.Unix(2, 0),
  105. GroupIDs: []int64{9}, RestrictGroups: true,
  106. }
  107. cfg := service.ChannelMonitorV2Config{
  108. Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
  109. GroupIDs: []int64{3, 9},
  110. }
  111. where, _ := channelMonitorV2Where(filter, cfg, "m")
  112. require.Contains(t, where, "FALSE")
  113. require.NotContains(t, where, "m.group_id = ANY")
  114. }
  115. func TestChannelMonitorV2CatalogKeepsViewerScopeWhileIgnoringPickerFilters(t *testing.T) {
  116. filter := service.ChannelMonitorV2Filter{
  117. Platforms: []string{"openai"}, GroupIDs: []int64{9}, Models: []string{"gpt-5"},
  118. AllowedGroupIDs: []int64{3}, RestrictGroups: true,
  119. }
  120. catalog := channelMonitorV2CatalogFilter(filter)
  121. require.Empty(t, catalog.Platforms)
  122. require.Empty(t, catalog.GroupIDs)
  123. require.Empty(t, catalog.Models)
  124. require.True(t, catalog.RestrictGroups)
  125. require.Equal(t, []int64{3}, catalog.AllowedGroupIDs)
  126. }
  127. func TestChannelMonitorV2AdminScopeRemainsGlobal(t *testing.T) {
  128. filter := service.ChannelMonitorV2Filter{Start: time.Unix(1, 0), End: time.Unix(2, 0), GroupIDs: []int64{9}}
  129. cfg := service.ChannelMonitorV2Config{
  130. Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
  131. GroupIDs: []int64{3, 9},
  132. }
  133. where, args := channelMonitorV2Where(filter, cfg, "m")
  134. require.Contains(t, where, "m.group_id = ANY($4)")
  135. require.Equal(t, pq.Array([]int64{9}), args[3])
  136. }
  137. func TestChannelMonitorV2MatrixDoesNotSeedGroupsForEmptyViewerScope(t *testing.T) {
  138. filter := service.ChannelMonitorV2Filter{RestrictGroups: true}
  139. cfg := service.ChannelMonitorV2Config{
  140. Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
  141. GroupIDs: []int64{3, 9},
  142. }
  143. accs := seedChannelMonitorV2MatrixAccumulators(filter, cfg, service.ChannelMonitorV2GroupByPlatformGroup, map[int64]channelMonitorV2GroupInfo{
  144. 3: {name: "private"}, 9: {name: "other-private"},
  145. })
  146. require.Empty(t, accs)
  147. }
  148. func TestChannelMonitorV2EmptyRestrictedScopeReturnsEmptyInventory(t *testing.T) {
  149. db, mock, err := sqlmock.New()
  150. require.NoError(t, err)
  151. t.Cleanup(func() { _ = db.Close() })
  152. repo := &channelMonitorV2Repository{db: db}
  153. filter := service.ChannelMonitorV2Filter{RestrictGroups: true}
  154. cfg := service.ChannelMonitorV2Config{
  155. Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}},
  156. GroupIDs: []int64{3, 9},
  157. }
  158. dimensions, err := repo.GetDimensions(context.Background(), filter, cfg)
  159. require.NoError(t, err)
  160. require.Empty(t, dimensions.Platforms)
  161. require.Empty(t, dimensions.Groups)
  162. require.Empty(t, dimensions.Models)
  163. require.NotNil(t, dimensions.Platforms)
  164. require.NotNil(t, dimensions.Groups)
  165. require.NotNil(t, dimensions.Models)
  166. models, err := repo.GetModels(context.Background(), filter, cfg, false)
  167. require.NoError(t, err)
  168. require.Empty(t, models.Items)
  169. require.NotNil(t, models.Items)
  170. require.Equal(t, service.ChannelMonitorV2Coverage{}, models.Coverage)
  171. require.NoError(t, mock.ExpectationsWereMet())
  172. }
  173. func TestChannelMonitorV2EmptyRestrictedScopeReturnsEmptySnapshotWithoutQueries(t *testing.T) {
  174. tests := []struct {
  175. name string
  176. filter service.ChannelMonitorV2Filter
  177. }{
  178. {
  179. name: "no allowed groups",
  180. filter: service.ChannelMonitorV2Filter{RestrictGroups: true},
  181. },
  182. {
  183. name: "requested groups exclude allowed configured scope",
  184. filter: service.ChannelMonitorV2Filter{
  185. GroupIDs: []int64{9}, AllowedGroupIDs: []int64{3}, RestrictGroups: true,
  186. },
  187. },
  188. }
  189. for _, test := range tests {
  190. t.Run(test.name, func(t *testing.T) {
  191. db, mock, err := sqlmock.New()
  192. require.NoError(t, err)
  193. t.Cleanup(func() { _ = db.Close() })
  194. repo := &channelMonitorV2Repository{db: db}
  195. cfg := service.ChannelMonitorV2Config{
  196. Enabled: true,
  197. Platforms: []service.ChannelMonitorV2PlatformConfig{{
  198. Platform: "openai", Enabled: true, Models: []string{"gpt-5"},
  199. }},
  200. GroupIDs: []int64{3},
  201. }
  202. snapshot, err := repo.GetSnapshot(context.Background(), test.filter, cfg, false)
  203. require.NoError(t, err)
  204. require.Equal(t, service.ChannelMonitorV2Config{}, snapshot.Config)
  205. require.Equal(t, service.ChannelMonitorV2Coverage{}, snapshot.Coverage)
  206. require.Equal(t, service.ChannelMonitorV2Metric{}, snapshot.Metrics)
  207. require.Equal(t, service.ChannelMonitorV2Health{}, snapshot.Health)
  208. require.Empty(t, snapshot.Trend)
  209. require.NotNil(t, snapshot.Trend)
  210. require.NoError(t, mock.ExpectationsWereMet())
  211. })
  212. }
  213. }
  214. func TestChannelMonitorV2EmptyRestrictedScopeReturnsEmptyMatrixForEveryGrouping(t *testing.T) {
  215. groupings := []service.ChannelMonitorV2GroupBy{
  216. service.ChannelMonitorV2GroupByPlatform,
  217. service.ChannelMonitorV2GroupByPlatformModel,
  218. service.ChannelMonitorV2GroupByPlatformGroup,
  219. service.ChannelMonitorV2GroupByPlatformGroupModel,
  220. }
  221. for _, groupBy := range groupings {
  222. t.Run(string(groupBy), func(t *testing.T) {
  223. db, mock, err := sqlmock.New()
  224. require.NoError(t, err)
  225. t.Cleanup(func() { _ = db.Close() })
  226. repo := &channelMonitorV2Repository{db: db}
  227. filter := service.ChannelMonitorV2Filter{RestrictGroups: true}
  228. cfg := service.ChannelMonitorV2Config{
  229. Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}},
  230. GroupIDs: []int64{3, 9},
  231. }
  232. matrix, err := repo.GetMatrix(context.Background(), filter, cfg, groupBy, false)
  233. require.NoError(t, err)
  234. require.Equal(t, groupBy, matrix.GroupBy)
  235. require.Empty(t, matrix.Items)
  236. require.NotNil(t, matrix.Items)
  237. require.Equal(t, service.ChannelMonitorV2Coverage{}, matrix.Coverage)
  238. require.NoError(t, mock.ExpectationsWereMet())
  239. })
  240. }
  241. }
  242. func TestChannelMonitorV2ErrorAggregationCountsFinalUserErrorsOnly(t *testing.T) {
  243. query := strings.ToLower(channelMonitorV2ErrorAggregationSQL)
  244. require.Contains(t, query, "not current_error.is_count_tokens")
  245. require.Contains(t, query, "error_type = 'cyber_policy'")
  246. require.Contains(t, query, "distinct on")
  247. require.Contains(t, query, "candidate_ids")
  248. require.Contains(t, query, "where bucket_start >= $1 and bucket_start < $2")
  249. require.Contains(t, query, "upstream_affected_requests")
  250. require.Contains(t, query, "jsonb_array_length(current_error.upstream_errors) > 0")
  251. // request_id dedup must be time-bounded (no full-history scan).
  252. require.Contains(t, query, "interval '90 minutes'")
  253. require.Contains(t, query, "current_error.created_at >= $1 - interval '90 minutes'")
  254. }
  255. func TestChannelMonitorV2ErrorAggregationResolvesCompositePlatform(t *testing.T) {
  256. query := strings.ToLower(channelMonitorV2ErrorAggregationSQL)
  257. // Composite groups are a routing layer: error facts must resolve the concrete
  258. // account platform (joining groups/accounts) so they aggregate under the same
  259. // platform key as usage facts instead of the never-enabled 'composite' platform.
  260. require.Contains(t, query, "g.platform = 'composite'")
  261. require.Contains(t, query, "left join groups g on g.id = current_error.group_id")
  262. require.Contains(t, query, "left join accounts a on a.id = current_error.account_id")
  263. require.Contains(t, query, "a.platform")
  264. require.Contains(t, query, "nullif(trim(a.platform), '')")
  265. require.NotContains(t, query, "nullif(trim(a.platform))")
  266. }
  267. func TestChannelMonitorV2UsageSuccessExcludesCyberBillingRows(t *testing.T) {
  268. for _, query := range []string{channelMonitorV2UsageMetricsSQL, channelMonitorV2UserMetricsSQL} {
  269. require.Contains(t, query, "COALESCE(ul.request_type, 0) NOT IN (4, 6)")
  270. require.Contains(t, query, "ul.actual_cost > 0")
  271. }
  272. require.Contains(t, channelMonitorV2PlatformSQL, "g.platform = 'composite'")
  273. require.Contains(t, channelMonitorV2PlatformSQL, "a.platform")
  274. require.Contains(t, channelMonitorV2HistogramSQL, "ul.actual_cost > 0")
  275. }
  276. func TestChannelMonitorV2RatesUseCoveredWindow(t *testing.T) {
  277. start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
  278. filter := service.ChannelMonitorV2Filter{Start: start, End: start.Add(24 * time.Hour)}
  279. coverage := service.ChannelMonitorV2Coverage{CoverageStart: start.Add(6 * time.Hour), DataThrough: start.Add(18 * time.Hour)}
  280. require.Equal(t, 12*60.0, channelMonitorV2CoveredMinutes(filter, coverage))
  281. effective := channelMonitorV2CommonCoverageFilter(filter, coverage)
  282. require.Equal(t, coverage.CoverageStart, effective.Start)
  283. require.Equal(t, coverage.DataThrough, effective.End)
  284. }
  285. func TestChannelMonitorV2HistoryCoverageCompleteIgnoresTrailingLag(t *testing.T) {
  286. start := time.Date(2026, 8, 7, 2, 20, 0, 0, time.UTC)
  287. // History reaches the window start → complete even if data_through is behind filter.End.
  288. require.True(t, channelMonitorV2HistoryCoverageComplete(start, start))
  289. require.True(t, channelMonitorV2HistoryCoverageComplete(start.Add(-time.Hour), start))
  290. // Backfill still short of the window start → incomplete.
  291. require.False(t, channelMonitorV2HistoryCoverageComplete(start.Add(time.Hour), start))
  292. require.False(t, channelMonitorV2HistoryCoverageComplete(time.Time{}, start))
  293. }
  294. func TestChannelMonitorV2TierRetentionPolicy(t *testing.T) {
  295. require.Equal(t, 3*24*time.Hour, channelMonitorV2RetentionUser1m)
  296. require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionMetrics1m)
  297. require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionError1m)
  298. require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionHistogram1m)
  299. require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionRollup5m)
  300. require.Equal(t, 30*24*time.Hour, channelMonitorV2RetentionRollup1h)
  301. require.Equal(t, 45*24*time.Hour, channelMonitorV2RetentionRollup12h)
  302. require.Equal(t, 90*24*time.Hour, channelMonitorV2RetentionRollup1d)
  303. require.Equal(t, channelMonitorV2RetentionRollup1d, channelMonitorV2MaxRetention())
  304. require.Contains(t, channelMonitorV2WatermarkSQL, "INTERVAL '90 days'")
  305. // Every fixed rollup second must appear with a retention rule.
  306. wantSeconds := map[int]time.Duration{
  307. 300: channelMonitorV2RetentionRollup5m,
  308. 3600: channelMonitorV2RetentionRollup1h,
  309. 43200: channelMonitorV2RetentionRollup12h,
  310. 86400: channelMonitorV2RetentionRollup1d,
  311. }
  312. seen := map[int]time.Duration{}
  313. for _, rule := range channelMonitorV2RetentionRules {
  314. if rule.bucketSeconds == 0 {
  315. require.True(t, rule.retention > 0)
  316. continue
  317. }
  318. if prev, ok := seen[rule.bucketSeconds]; ok {
  319. require.Equal(t, prev, rule.retention)
  320. }
  321. seen[rule.bucketSeconds] = rule.retention
  322. }
  323. for seconds, want := range wantSeconds {
  324. got, ok := seen[seconds]
  325. require.Truef(t, ok, "missing retention rule for bucket_seconds=%d", seconds)
  326. require.Equal(t, want, got)
  327. }
  328. now := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
  329. require.Equal(t, now.Add(-7*24*time.Hour), channelMonitorV2RetentionCutoff(now, channelMonitorV2RetentionMetrics1m))
  330. require.Equal(t, now.Add(-90*24*time.Hour), channelMonitorV2RetentionCutoff(now, channelMonitorV2MaxRetention()))
  331. }
  332. func TestSameFixedRollupBucket(t *testing.T) {
  333. start := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC)
  334. require.True(t, sameFixedRollupBucket(start, start.Add(10*time.Minute), 86400))
  335. require.False(t, sameFixedRollupBucket(start, start.Add(15*time.Hour), 43200))
  336. require.False(t, sameFixedRollupBucket(start, start.Add(24*time.Hour), 86400))
  337. }
  338. // Needles present in service.ClassifyChannelMonitorV2Error must appear in the
  339. // aggregation SQL CASE so rollup categories match drilldown classification.
  340. func TestChannelMonitorV2SQLTaxonomyContainsGoNeedles(t *testing.T) {
  341. sql := channelMonitorV2ErrorAggregationSQL
  342. needles := []string{
  343. "blocked keyword",
  344. "invalid_api_key",
  345. "max_tokens",
  346. "invalid_request",
  347. "model not supported",
  348. "billing hard limit",
  349. "no healthy upstream account",
  350. "rate_limit",
  351. "gateway timeout",
  352. "connection refused",
  353. "unexpected eof",
  354. }
  355. for _, needle := range needles {
  356. require.Containsf(t, strings.ToLower(sql), strings.ToLower(needle), "SQL taxonomy missing Go needle %q", needle)
  357. }
  358. }
  359. func TestApplyIgnoredErrorsAdjustsRatesKeepsAbsoluteVolume(t *testing.T) {
  360. m := service.ChannelMonitorV2Metric{
  361. RequestCount: 100,
  362. ErrorRequests: 20,
  363. ErrorRate: 0.20,
  364. SuccessRate: 0.80,
  365. }
  366. // Success absolute still 80 → success rate stays 0.80 even after ignoring 5 errors.
  367. m.SuccessRequests = 80
  368. applyIgnoredErrors(&m, 5)
  369. require.Equal(t, int64(100), m.RequestCount)
  370. require.Equal(t, int64(20), m.ErrorRequests)
  371. require.InDelta(t, 0.15, m.ErrorRate, 0.0001)
  372. require.InDelta(t, 0.80, m.SuccessRate, 0.0001)
  373. // Clamp ignored > errors: scored error_rate → 0; success stays true ratio.
  374. m2 := service.ChannelMonitorV2Metric{RequestCount: 10, ErrorRequests: 2, SuccessRequests: 8, ErrorRate: 0.2, SuccessRate: 0.8}
  375. applyIgnoredErrors(&m2, 99)
  376. require.InDelta(t, 0.0, m2.ErrorRate, 0.0001)
  377. require.InDelta(t, 0.8, m2.SuccessRate, 0.0001)
  378. // No-op when ignored is zero
  379. m3 := service.ChannelMonitorV2Metric{RequestCount: 10, ErrorRequests: 2, SuccessRequests: 8, ErrorRate: 0.2, SuccessRate: 0.8}
  380. applyIgnoredErrors(&m3, 0)
  381. require.InDelta(t, 0.2, m3.ErrorRate, 0.0001)
  382. require.InDelta(t, 0.8, m3.SuccessRate, 0.0001)
  383. }
  384. func TestRedactChannelMonitorV2MetricZerosVolume(t *testing.T) {
  385. // Service helper is in service package; covered there. Keep a smoke note that
  386. // rates survive a manual zeroing of volume fields used by the UI contract.
  387. m := service.ChannelMonitorV2Metric{
  388. RequestCount: 100, ErrorRequests: 10, SuccessRequests: 90,
  389. TokenCount: 1000, RPM: 5, TPM: 50, ErrorRate: 0.1, SuccessRate: 0.9, CacheRate: 0.4,
  390. }
  391. // Mimic redact: zero volume only
  392. m.RequestCount, m.ErrorRequests, m.SuccessRequests, m.TokenCount = 0, 0, 0, 0
  393. require.Equal(t, 0.1, m.ErrorRate)
  394. require.Equal(t, 5.0, m.RPM)
  395. }
  396. func TestChannelMonitorV2CatalogFilterClearsMultiSelectDimensions(t *testing.T) {
  397. start := time.Unix(1, 0)
  398. end := time.Unix(2, 0)
  399. filter := service.ChannelMonitorV2Filter{
  400. Start: start, End: end, Bucket: time.Minute,
  401. Platforms: []string{"openai"}, GroupIDs: []int64{3}, Models: []string{"gpt-5"},
  402. }
  403. catalog := channelMonitorV2CatalogFilter(filter)
  404. require.Nil(t, catalog.Platforms)
  405. require.Nil(t, catalog.GroupIDs)
  406. require.Nil(t, catalog.Models)
  407. // Time window / coverage-related fields remain.
  408. require.Equal(t, start, catalog.Start)
  409. require.Equal(t, end, catalog.End)
  410. require.Equal(t, time.Minute, catalog.Bucket)
  411. cfg := service.ChannelMonitorV2Config{
  412. Platforms: []service.ChannelMonitorV2PlatformConfig{
  413. {Platform: "openai", Enabled: true},
  414. {Platform: "grok", Enabled: true},
  415. },
  416. GroupIDs: []int64{3, 4},
  417. }
  418. catalogWhere, catalogArgs := channelMonitorV2Where(catalog, cfg, "m")
  419. _, metricArgs := channelMonitorV2Where(filter, cfg, "m")
  420. // Catalog WHERE still applies config scope (enabled platforms + group allow-list).
  421. require.Contains(t, catalogWhere, "m.platform = ANY")
  422. require.Contains(t, catalogWhere, "m.group_id = ANY")
  423. require.Len(t, catalogArgs, 4) // start, end, platforms, groups
  424. require.Len(t, metricArgs, 4)
  425. // Metrics WHERE is narrower once multi-select platforms/groups are applied.
  426. require.NotEqual(t, catalogArgs, metricArgs)
  427. // Group seeding without multi-select uses full config allow-list.
  428. require.Equal(t, []int64{3, 4}, configuredChannelMonitorV2GroupIDs(catalog, cfg))
  429. require.Equal(t, []int64{3}, configuredChannelMonitorV2GroupIDs(filter, cfg))
  430. }