channel_monitor_quota_integration_test.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "testing"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/domain"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. "github.com/stretchr/testify/require"
  10. )
  11. // 配额模式 repo 层集成测试:
  12. // - Create/GetByID/Update 的 check_mode/account_id 往返
  13. // - InsertHistoryBatch → ListHistory / ListLatestForMonitorIDs 的 quota JSONB 回读
  14. // (裸 SQL 列 + scanMonitorQuota),以及探活模式旧行 quota=NULL 的兼容
  15. //
  16. // 注意 channelMonitorRepository 的 GetByID/裸 SQL 走全局 client(不识别 tx ctx),
  17. // 因此本文件用 integrationEntClient 直连 + t.Cleanup 显式清理,不走 testEntTx 回滚。
  18. func TestChannelMonitorQuotaModeRoundTrip(t *testing.T) {
  19. ctx := context.Background()
  20. repo := NewChannelMonitorRepository(integrationEntClient, integrationDB)
  21. account := mustCreateAccount(t, integrationEntClient, &service.Account{
  22. Name: "quota-linked-kimi", Platform: domain.PlatformKimi, Type: service.AccountTypeAPIKey,
  23. Credentials: map[string]any{"api_key": "sk-kimi", "account_mode": service.AccountModeCoding},
  24. })
  25. t.Cleanup(func() {
  26. _ = integrationEntClient.Account.DeleteOneID(account.ID).Exec(ctx)
  27. })
  28. created := &service.ChannelMonitor{
  29. Name: "kimi-quota-roundtrip",
  30. Provider: service.MonitorProviderKimi,
  31. APIMode: service.MonitorAPIModeChatCompletions,
  32. Endpoint: "",
  33. APIKey: "encrypted-empty",
  34. PrimaryModel: "quota",
  35. Enabled: true,
  36. IntervalSeconds: 60,
  37. CheckMode: service.MonitorCheckModeQuota,
  38. AccountID: &account.ID,
  39. BodyOverrideMode: service.MonitorBodyOverrideModeOff,
  40. }
  41. require.NoError(t, repo.Create(ctx, created))
  42. t.Cleanup(func() {
  43. _ = repo.Delete(ctx, created.ID)
  44. })
  45. loaded, err := repo.GetByID(ctx, created.ID)
  46. require.NoError(t, err)
  47. require.Equal(t, service.MonitorCheckModeQuota, loaded.CheckMode)
  48. require.NotNil(t, loaded.AccountID)
  49. require.Equal(t, account.ID, *loaded.AccountID)
  50. // Update:切换模式并清空关联账号(probe 化)。
  51. loaded.CheckMode = service.MonitorCheckModeProbe
  52. loaded.AccountID = nil
  53. loaded.Endpoint = "https://api.moonshot.cn"
  54. require.NoError(t, repo.Update(ctx, loaded))
  55. reloaded, err := repo.GetByID(ctx, created.ID)
  56. require.NoError(t, err)
  57. require.Equal(t, service.MonitorCheckModeProbe, reloaded.CheckMode)
  58. require.Nil(t, reloaded.AccountID)
  59. // 重新绑定账号。
  60. reloaded.CheckMode = service.MonitorCheckModeQuotaProbe
  61. reloaded.AccountID = &account.ID
  62. require.NoError(t, repo.Update(ctx, reloaded))
  63. final, err := repo.GetByID(ctx, created.ID)
  64. require.NoError(t, err)
  65. require.Equal(t, service.MonitorCheckModeQuotaProbe, final.CheckMode)
  66. require.NotNil(t, final.AccountID)
  67. }
  68. func TestChannelMonitorHistoryQuotaRoundTrip(t *testing.T) {
  69. ctx := context.Background()
  70. repo := NewChannelMonitorRepository(integrationEntClient, integrationDB)
  71. monitor := &service.ChannelMonitor{
  72. Name: "quota-history-roundtrip",
  73. Provider: service.MonitorProviderOpenAI,
  74. APIMode: service.MonitorAPIModeChatCompletions,
  75. Endpoint: "https://api.openai.com",
  76. APIKey: "encrypted",
  77. PrimaryModel: "gpt-test",
  78. ExtraModels: []string{"gpt-extra"},
  79. Enabled: true,
  80. IntervalSeconds: 60,
  81. BodyOverrideMode: service.MonitorBodyOverrideModeOff,
  82. }
  83. require.NoError(t, repo.Create(ctx, monitor))
  84. t.Cleanup(func() {
  85. _ = repo.Delete(ctx, monitor.ID) // histories 级联删除
  86. })
  87. now := time.Now().UTC()
  88. rows := []*service.ChannelMonitorHistoryRow{
  89. {
  90. MonitorID: monitor.ID, Model: "gpt-test", Status: service.MonitorStatusOperational,
  91. Message: "ok", CheckedAt: now,
  92. Quota: &domain.MonitorQuotaSnapshot{
  93. Source: "usage",
  94. Success: true,
  95. PlanLevel: "PRO",
  96. Tiers: []domain.MonitorQuotaTier{
  97. {Window: "5h", UsedPercent: 42.5, Used: 17, Limit: 40, ResetAt: now.Add(time.Hour).Format(time.RFC3339)},
  98. },
  99. FetchedAt: now,
  100. },
  101. },
  102. {
  103. // 探活模式旧行:无 quota(NULL 兼容)。
  104. MonitorID: monitor.ID, Model: "gpt-extra", Status: service.MonitorStatusOperational,
  105. Message: "ok", CheckedAt: now,
  106. },
  107. }
  108. require.NoError(t, repo.InsertHistoryBatch(ctx, rows))
  109. history, err := repo.ListHistory(ctx, monitor.ID, "", 10)
  110. require.NoError(t, err)
  111. require.Len(t, history, 2)
  112. byModel := map[string]*service.ChannelMonitorHistoryEntry{}
  113. for _, entry := range history {
  114. byModel[entry.Model] = entry
  115. }
  116. withQuota := byModel["gpt-test"]
  117. require.NotNil(t, withQuota.Quota)
  118. require.True(t, withQuota.Quota.Success)
  119. require.Equal(t, "usage", withQuota.Quota.Source)
  120. require.Equal(t, "PRO", withQuota.Quota.PlanLevel)
  121. require.Len(t, withQuota.Quota.Tiers, 1)
  122. require.Equal(t, "5h", withQuota.Quota.Tiers[0].Window)
  123. require.InDelta(t, 42.5, withQuota.Quota.Tiers[0].UsedPercent, 0.001)
  124. require.Nil(t, byModel["gpt-extra"].Quota, "probe rows must read back as NULL quota")
  125. // 用户视图聚合:主模型最近一行带快照。
  126. latest, err := repo.ListLatestForMonitorIDs(ctx, []int64{monitor.ID})
  127. require.NoError(t, err)
  128. primaryRows := latest[monitor.ID]
  129. require.NotEmpty(t, primaryRows)
  130. var primaryQuota *domain.MonitorQuotaSnapshot
  131. for _, row := range primaryRows {
  132. if row.Model == "gpt-test" {
  133. primaryQuota = row.Quota
  134. }
  135. }
  136. require.NotNil(t, primaryQuota, "ListLatestForMonitorIDs must surface quota for the primary model")
  137. require.True(t, primaryQuota.Success)
  138. }