setting_service_platform_quota_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/config"
  7. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  8. "github.com/stretchr/testify/require"
  9. )
  10. func TestMergePlatformQuotaDefaults_PatchSemantics(t *testing.T) {
  11. five := 5.0
  12. base := DefaultPlatformQuotaSetting{
  13. DailyLimitUSD: &five,
  14. WeeklyLimitUSD: &five,
  15. }
  16. ten := 10.0
  17. patch := DefaultPlatformQuotaSetting{DailyLimitUSD: &ten}
  18. mergePlatformQuotaDefaults(&base, &patch)
  19. if base.DailyLimitUSD == nil || *base.DailyLimitUSD != 10.0 {
  20. t.Errorf("daily not patched: %+v", base.DailyLimitUSD)
  21. }
  22. if base.WeeklyLimitUSD == nil || *base.WeeklyLimitUSD != 5.0 {
  23. t.Errorf("weekly should remain 5.0: %+v", base.WeeklyLimitUSD)
  24. }
  25. }
  26. func TestMergePlatformQuotaDefaults_ZeroIsExplicitDisable(t *testing.T) {
  27. five := 5.0
  28. base := DefaultPlatformQuotaSetting{DailyLimitUSD: &five}
  29. zero := 0.0
  30. patch := DefaultPlatformQuotaSetting{DailyLimitUSD: &zero}
  31. mergePlatformQuotaDefaults(&base, &patch)
  32. if base.DailyLimitUSD == nil || *base.DailyLimitUSD != 0 {
  33. t.Errorf("explicit 0 should patch base, got %+v", base.DailyLimitUSD)
  34. }
  35. }
  36. func TestMergePlatformQuotaDefaults_NilSrcIsNoop(t *testing.T) {
  37. five := 5.0
  38. base := DefaultPlatformQuotaSetting{DailyLimitUSD: &five}
  39. mergePlatformQuotaDefaults(&base, nil)
  40. if base.DailyLimitUSD == nil || *base.DailyLimitUSD != 5.0 {
  41. t.Errorf("nil src should be no-op: %+v", base.DailyLimitUSD)
  42. }
  43. }
  44. func floatPtrPQ(v float64) *float64 { return &v }
  45. func newSettingServiceForPlatformQuotaTest(seed map[string]string) *SettingService {
  46. repo := newMockSettingRepo()
  47. for k, v := range seed {
  48. repo.data[k] = v
  49. }
  50. return NewSettingService(repo, &config.Config{})
  51. }
  52. func TestGetDefaultPlatformQuotas_ReturnsAllowedPlatforms(t *testing.T) {
  53. zero := 0.0
  54. svc := newSettingServiceForPlatformQuotaTest(map[string]string{
  55. // 新 JSON 格式:anthropic daily=10.5, openai monthly=0, 其他平台无配置
  56. SettingKeyDefaultPlatformQuotas: `{"anthropic":{"daily":10.5},"openai":{"monthly":0}}`,
  57. })
  58. got, err := svc.GetDefaultPlatformQuotas(context.Background())
  59. if err != nil {
  60. t.Fatalf("unexpected error: %v", err)
  61. }
  62. // 必须包含全部允许 platform key(补齐契约)
  63. for _, platform := range AllowedQuotaPlatforms {
  64. if _, ok := got[platform]; !ok {
  65. t.Errorf("missing platform key: %q", platform)
  66. }
  67. }
  68. // anthropic daily = 10.5
  69. if v := got["anthropic"].DailyLimitUSD; v == nil || *v != 10.5 {
  70. t.Errorf("anthropic daily want 10.5, got %v", v)
  71. }
  72. // openai monthly = 0(显式禁用)
  73. if v := got["openai"].MonthlyLimitUSD; v == nil || *v != zero {
  74. t.Errorf("openai monthly want 0 (explicit disable), got %v", v)
  75. }
  76. // gemini 无配置 → weekly = nil
  77. if v := got["gemini"].WeeklyLimitUSD; v != nil {
  78. t.Errorf("gemini weekly want nil (not configured), got %v", *v)
  79. }
  80. // antigravity 无配置 → daily = nil
  81. if v := got["antigravity"].DailyLimitUSD; v != nil {
  82. t.Errorf("antigravity daily want nil (not configured), got %v", *v)
  83. }
  84. }
  85. func TestGetAuthSourcePlatformQuotas_OnlyConfiguredReturned(t *testing.T) {
  86. source := "email"
  87. // 新 JSON 格式:anthropic daily=5, monthly=100;openai weekly=0;gemini/antigravity 无配置
  88. svc := newSettingServiceForPlatformQuotaTest(map[string]string{
  89. SettingKeyAuthSourcePlatformQuotas(source): `{"anthropic":{"daily":5,"monthly":100},"openai":{"weekly":0}}`,
  90. })
  91. got := svc.GetAuthSourcePlatformQuotas(context.Background(), source)
  92. // anthropic 有配置 → 在结果中
  93. anthro, ok := got["anthropic"]
  94. if !ok {
  95. t.Fatal("expected anthropic to be present")
  96. }
  97. if anthro.DailyLimitUSD == nil || *anthro.DailyLimitUSD != 5.0 {
  98. t.Errorf("anthropic daily want 5.0, got %v", anthro.DailyLimitUSD)
  99. }
  100. if anthro.MonthlyLimitUSD == nil || *anthro.MonthlyLimitUSD != 100.0 {
  101. t.Errorf("anthropic monthly want 100.0, got %v", anthro.MonthlyLimitUSD)
  102. }
  103. if anthro.WeeklyLimitUSD != nil {
  104. t.Errorf("anthropic weekly not configured, want nil, got %v", *anthro.WeeklyLimitUSD)
  105. }
  106. // openai weekly=0 → 在结果中
  107. oai, ok := got["openai"]
  108. if !ok {
  109. t.Fatal("expected openai to be present")
  110. }
  111. if oai.WeeklyLimitUSD == nil || *oai.WeeklyLimitUSD != 0 {
  112. t.Errorf("openai weekly want 0, got %v", oai.WeeklyLimitUSD)
  113. }
  114. // gemini / antigravity 无配置 → 不在结果中(override 语义)
  115. if _, ok := got["gemini"]; ok {
  116. t.Error("gemini not configured, should be absent from result")
  117. }
  118. if _, ok := got["antigravity"]; ok {
  119. t.Error("antigravity not configured, should be absent from result")
  120. }
  121. }
  122. func TestGetAuthSourcePlatformQuotas_AllNegativeOrEmpty_NoEntry(t *testing.T) {
  123. source := "linuxdo"
  124. // 新 JSON 格式:未配置任何平台(空 JSON key)→ 返回空 map
  125. svc := newSettingServiceForPlatformQuotaTest(map[string]string{
  126. SettingKeyAuthSourcePlatformQuotas(source): `{}`,
  127. })
  128. got := svc.GetAuthSourcePlatformQuotas(context.Background(), source)
  129. // 空 map → override 语义,无 openai 条目
  130. if _, ok := got["openai"]; ok {
  131. t.Error("empty JSON object should result in no openai entry")
  132. }
  133. if len(got) != 0 {
  134. t.Errorf("expected empty result map, got %v", got)
  135. }
  136. }
  137. // TestSystemPlatformQuotas_WriteReadRoundTrip 验证系统层 platform quota 经 buildSystemSettingsUpdates(写)
  138. // 再由 GetDefaultPlatformQuotas(读)正确往返,覆盖真实 write→read 路径并锁住平台补齐契约。
  139. func TestSystemPlatformQuotas_WriteReadRoundTrip(t *testing.T) {
  140. svc := newSettingServiceForPlatformQuotaTest(nil)
  141. ctx := context.Background()
  142. ten := 10.0
  143. ss := &SystemSettings{
  144. DefaultPlatformQuotas: map[string]*DefaultPlatformQuotaSetting{
  145. "anthropic": {DailyLimitUSD: &ten, WeeklyLimitUSD: nil, MonthlyLimitUSD: nil},
  146. },
  147. }
  148. if err := svc.UpdateSettings(ctx, ss); err != nil {
  149. t.Fatalf("UpdateSettings: %v", err)
  150. }
  151. got, err := svc.GetDefaultPlatformQuotas(ctx)
  152. if err != nil {
  153. t.Fatal(err)
  154. }
  155. // 平台补齐契约:无论写了几个 platform,读回必须含全部允许平台
  156. for _, p := range AllowedQuotaPlatforms {
  157. if _, ok := got[p]; !ok {
  158. t.Errorf("allowed-platform contract violated: missing platform %q", p)
  159. }
  160. }
  161. // 写入值正确往返
  162. if v := got["anthropic"].DailyLimitUSD; v == nil || *v != ten {
  163. t.Fatalf("anthropic daily round-trip failed: got %v, want 10", v)
  164. }
  165. // 未写入的平台字段为 nil
  166. if got["openai"].DailyLimitUSD != nil {
  167. t.Errorf("openai daily should be nil (not written), got %v", got["openai"].DailyLimitUSD)
  168. }
  169. }
  170. // TestSystemPlatformQuotas_EmptyMapClearsAll 验证空 map 的整体替换语义:
  171. // 写入 DefaultPlatformQuotas={} 后,GetDefaultPlatformQuotas 返回全部允许平台、所有字段均为 nil,
  172. // 明确文档化"空 map = 清空全部配额"是有意为之的 whole-replace 语义。
  173. func TestSystemPlatformQuotas_EmptyMapClearsAll(t *testing.T) {
  174. svc := newSettingServiceForPlatformQuotaTest(nil)
  175. ctx := context.Background()
  176. // 先写入有值的配置
  177. ten := 10.0
  178. if err := svc.UpdateSettings(ctx, &SystemSettings{
  179. DefaultPlatformQuotas: map[string]*DefaultPlatformQuotaSetting{
  180. "anthropic": {DailyLimitUSD: &ten},
  181. },
  182. }); err != nil {
  183. t.Fatalf("initial write: %v", err)
  184. }
  185. // 再写入空 map(整体替换语义:清空全部)
  186. if err := svc.UpdateSettings(ctx, &SystemSettings{
  187. DefaultPlatformQuotas: map[string]*DefaultPlatformQuotaSetting{},
  188. }); err != nil {
  189. t.Fatalf("empty map write: %v", err)
  190. }
  191. got, err := svc.GetDefaultPlatformQuotas(ctx)
  192. if err != nil {
  193. t.Fatal(err)
  194. }
  195. // 全部允许平台 key 仍然存在(补齐契约)
  196. for _, p := range AllowedQuotaPlatforms {
  197. if _, ok := got[p]; !ok {
  198. t.Errorf("allowed-platform contract violated after empty write: missing %q", p)
  199. }
  200. }
  201. // 所有字段 nil(全部已清空)
  202. for _, p := range AllowedQuotaPlatforms {
  203. pq := got[p]
  204. if pq == nil {
  205. continue
  206. }
  207. if pq.DailyLimitUSD != nil || pq.WeeklyLimitUSD != nil || pq.MonthlyLimitUSD != nil {
  208. t.Errorf("platform %q should have all-nil limits after empty-map write, got %+v", p, pq)
  209. }
  210. }
  211. }
  212. // TestUpdateSettingsWithAuthSourceDefaults_PlatformQuotaRoundTrip 验证 round-4 fix:
  213. // PUT /admin/settings 携带的 auth source × platform × window 限额能完整写入并被 GetAuthSourcePlatformQuotas 读回。
  214. // Round-4 之前 writeProviderDefaultGrantUpdates 完全没写 PQ key,前端配置静默丢失。
  215. func TestUpdateSettingsWithAuthSourceDefaults_PlatformQuotaRoundTrip(t *testing.T) {
  216. svc := newSettingServiceForPlatformQuotaTest(nil)
  217. systemSettings := &SystemSettings{}
  218. authDefaults := &AuthSourceDefaultSettings{
  219. Email: ProviderDefaultGrantSettings{
  220. PlatformQuotas: map[string]*DefaultPlatformQuotaSetting{
  221. "anthropic": {
  222. DailyLimitUSD: floatPtrPQ(5.0),
  223. WeeklyLimitUSD: nil, // 无限额
  224. MonthlyLimitUSD: floatPtrPQ(100.0),
  225. },
  226. "openai": {
  227. DailyLimitUSD: floatPtrPQ(0), // 显式禁用
  228. },
  229. },
  230. },
  231. }
  232. if err := svc.UpdateSettingsWithAuthSourceDefaults(context.Background(), systemSettings, authDefaults); err != nil {
  233. t.Fatalf("UpdateSettingsWithAuthSourceDefaults: %v", err)
  234. }
  235. got := svc.GetAuthSourcePlatformQuotas(context.Background(), "email")
  236. anthro := got["anthropic"]
  237. if anthro == nil || anthro.DailyLimitUSD == nil || *anthro.DailyLimitUSD != 5.0 {
  238. t.Errorf("anthropic daily round-trip failed: %+v", anthro)
  239. }
  240. if anthro != nil && anthro.WeeklyLimitUSD != nil {
  241. t.Errorf("anthropic weekly want nil (无限额), got %v", *anthro.WeeklyLimitUSD)
  242. }
  243. if anthro == nil || anthro.MonthlyLimitUSD == nil || *anthro.MonthlyLimitUSD != 100.0 {
  244. t.Errorf("anthropic monthly round-trip failed: %+v", anthro)
  245. }
  246. oai := got["openai"]
  247. if oai == nil || oai.DailyLimitUSD == nil || *oai.DailyLimitUSD != 0 {
  248. t.Errorf("openai daily=0 (禁用) round-trip failed: %+v", oai)
  249. }
  250. // 其他 source 不应有 quota(authDefaults 只填了 Email)
  251. if linux := svc.GetAuthSourcePlatformQuotas(context.Background(), "linuxdo"); len(linux) != 0 {
  252. t.Errorf("linuxdo should be empty, got %+v", linux)
  253. }
  254. }
  255. // TestUpdateSettingsWithAuthSourceDefaults_NilPlatformQuotaPreservesExisting 验证 #2 防御:
  256. // 请求未携带某 auth source 的 platform quota(nil)时跳过写入、保留既有配置,
  257. // 而非整体替换为空 map 清空(与系统层 nil 守卫一致)。
  258. func TestUpdateSettingsWithAuthSourceDefaults_NilPlatformQuotaPreservesExisting(t *testing.T) {
  259. svc := newSettingServiceForPlatformQuotaTest(map[string]string{
  260. SettingKeyAuthSourcePlatformQuotas("email"): `{"anthropic":{"daily":5,"weekly":null,"monthly":null}}`,
  261. })
  262. // authDefaults 不携带 Email 的 PlatformQuotas(nil)——应保留既有配置
  263. authDefaults := &AuthSourceDefaultSettings{
  264. Email: ProviderDefaultGrantSettings{PlatformQuotas: nil},
  265. }
  266. if err := svc.UpdateSettingsWithAuthSourceDefaults(context.Background(), &SystemSettings{}, authDefaults); err != nil {
  267. t.Fatalf("UpdateSettingsWithAuthSourceDefaults: %v", err)
  268. }
  269. anthro := svc.GetAuthSourcePlatformQuotas(context.Background(), "email")["anthropic"]
  270. if anthro == nil || anthro.DailyLimitUSD == nil || *anthro.DailyLimitUSD != 5.0 {
  271. t.Errorf("nil PlatformQuotas 应保留既有 anthropic daily=5,got %+v", anthro)
  272. }
  273. }
  274. // TestGetAuthSourcePlatformQuotas_JSON 验证新 JSON key 读写语义:
  275. // 写入 JSON,断言已配置平台在结果中、未配置平台不在结果中(override 语义)。
  276. func TestGetAuthSourcePlatformQuotas_JSON(t *testing.T) {
  277. svc := newSettingServiceForPlatformQuotaTest(map[string]string{
  278. SettingKeyAuthSourcePlatformQuotas("email"): `{"openai":{"daily":null,"weekly":null,"monthly":20}}`,
  279. })
  280. got := svc.GetAuthSourcePlatformQuotas(context.Background(), "email")
  281. // openai monthly = 20
  282. oai, ok := got["openai"]
  283. if !ok {
  284. t.Fatal("expected openai to be present")
  285. }
  286. if oai.MonthlyLimitUSD == nil || *oai.MonthlyLimitUSD != 20 {
  287. t.Errorf("openai monthly want 20, got %v", oai.MonthlyLimitUSD)
  288. }
  289. if oai.DailyLimitUSD != nil {
  290. t.Errorf("openai daily want nil, got %v", *oai.DailyLimitUSD)
  291. }
  292. if oai.WeeklyLimitUSD != nil {
  293. t.Errorf("openai weekly want nil, got %v", *oai.WeeklyLimitUSD)
  294. }
  295. // anthropic 未配置 → 不在结果中(override 语义)
  296. if _, ok := got["anthropic"]; ok {
  297. t.Error("anthropic not configured, should be absent from result")
  298. }
  299. }
  300. // TestUpdateSettingsWithAuthSourceDefaults_NegativeQuotaRejected 验证改动 C:
  301. // auth-source platform quota 含负数时,UpdateSettingsWithAuthSourceDefaults 返回 BadRequest 错误。
  302. func TestUpdateSettingsWithAuthSourceDefaults_NegativeQuotaRejected(t *testing.T) {
  303. svc := newSettingServiceForPlatformQuotaTest(nil)
  304. neg := -1.0
  305. authDefaults := &AuthSourceDefaultSettings{
  306. Email: ProviderDefaultGrantSettings{
  307. PlatformQuotas: map[string]*DefaultPlatformQuotaSetting{
  308. "anthropic": {DailyLimitUSD: &neg},
  309. },
  310. },
  311. }
  312. err := svc.UpdateSettingsWithAuthSourceDefaults(context.Background(), &SystemSettings{}, authDefaults)
  313. require.Error(t, err, "expected error for negative quota")
  314. require.Equal(t, "INVALID_DEFAULT_PLATFORM_QUOTA", infraerrors.Reason(err))
  315. }