model_plaza_handler_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. //go:build unit
  2. package handler
  3. import (
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. "github.com/gin-gonic/gin"
  10. "github.com/stretchr/testify/require"
  11. )
  12. func plazaGroups() []service.PlazaGroup {
  13. return []service.PlazaGroup{
  14. {ID: 1, Name: "public-standard", Platform: "anthropic", SubscriptionType: "standard", RateMultiplier: 1},
  15. {ID: 2, Name: "exclusive-a", Platform: "anthropic", IsExclusive: true, RateMultiplier: 0.5},
  16. {ID: 3, Name: "public-subscription", Platform: "openai", SubscriptionType: "subscription", RateMultiplier: 1},
  17. {ID: 4, Name: "exclusive-b", Platform: "openai", IsExclusive: true, RateMultiplier: 0.8},
  18. }
  19. }
  20. func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) {
  21. // 匿名(allowedExclusive == nil):仅非专属分组;订阅型公开分组照常可见(橱窗语义)。
  22. visible := filterPlazaVisibleGroups(plazaGroups(), nil, false)
  23. require.Len(t, visible, 2)
  24. ids := []int64{visible[0].ID, visible[1].ID}
  25. require.ElementsMatch(t, []int64{1, 3}, ids)
  26. }
  27. func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) {
  28. // 登录:非专属 + 授权的专属;未授权的专属仍不可见。
  29. allowed := map[int64]struct{}{2: {}}
  30. visible := filterPlazaVisibleGroups(plazaGroups(), allowed, false)
  31. require.Len(t, visible, 3)
  32. ids := make([]int64, 0, len(visible))
  33. for _, g := range visible {
  34. ids = append(ids, g.ID)
  35. }
  36. require.ElementsMatch(t, []int64{1, 2, 3}, ids)
  37. }
  38. func TestFilterPlazaVisibleGroups_AuthedEmptySetSeesNoExclusive(t *testing.T) {
  39. // 登录但无任何专属授权(空集合,非 nil):与匿名同样只见非专属,
  40. // 但语义区分要保持——空集合不能被当作 nil 匿名分支。
  41. visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{}, false)
  42. require.Len(t, visible, 2)
  43. }
  44. func TestFilterPlazaVisibleGroups_RestrictedUserSeesOnlyGrantedPublic(t *testing.T) {
  45. // 开启公开分组限制后,公开分组也必须落在授权集合内,否则用户会在广场
  46. // 看到自己实际绑定不了的分组。
  47. allowed := map[int64]struct{}{1: {}, 2: {}}
  48. visible := filterPlazaVisibleGroups(plazaGroups(), allowed, true)
  49. ids := make([]int64, 0, len(visible))
  50. for _, g := range visible {
  51. ids = append(ids, g.ID)
  52. }
  53. // 3 是未授权的公开分组,受限后不可见;4 是未授权的专属分组,一贯不可见。
  54. require.ElementsMatch(t, []int64{1, 2}, ids)
  55. }
  56. func TestFilterPlazaVisibleGroups_RestrictionDoesNotAffectAnonymous(t *testing.T) {
  57. // 匿名没有用户记录,限制标志无从谈起,可见性必须与未受限时一致。
  58. visible := filterPlazaVisibleGroups(plazaGroups(), nil, true)
  59. ids := make([]int64, 0, len(visible))
  60. for _, g := range visible {
  61. ids = append(ids, g.ID)
  62. }
  63. require.ElementsMatch(t, []int64{1, 3}, ids)
  64. }
  65. func TestModelPlazaHandler_NilSettingServiceFailsClosed404(t *testing.T) {
  66. gin.SetMode(gin.TestMode)
  67. h := &ModelPlazaHandler{} // settingService == nil → fail-closed
  68. w := httptest.NewRecorder()
  69. c, _ := gin.CreateTestContext(w)
  70. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/model-plaza", nil)
  71. h.Get(c)
  72. require.Equal(t, http.StatusNotFound, w.Code)
  73. }
  74. func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
  75. g := service.PlazaGroup{
  76. ID: 2, Name: "vip", Description: "d", Platform: "anthropic",
  77. SubscriptionType: "standard", RateMultiplier: 1, IsExclusive: true,
  78. Models: []service.PlazaModel{{
  79. Name: "claude-sonnet",
  80. Platform: "anthropic",
  81. Pricing: &service.ChannelModelPricing{
  82. BillingMode: service.BillingModeToken,
  83. InputPrice: testPtr(3e-6),
  84. },
  85. OfficialPricing: &service.PlazaOfficialPricing{
  86. InputPrice: testPtr(3e-6),
  87. CacheReadPrice: testPtr(3e-7),
  88. },
  89. }},
  90. }
  91. // 有专属倍率:user_rate_multiplier 序列化输出
  92. dto := toModelPlazaGroupDTO(&g, map[int64]float64{2: 0.5})
  93. raw, err := json.Marshal(dto)
  94. require.NoError(t, err)
  95. var decoded map[string]any
  96. require.NoError(t, json.Unmarshal(raw, &decoded))
  97. for _, key := range []string{
  98. "id", "name", "description", "platform", "subscription_type",
  99. "rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
  100. "peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
  101. "image_rate_independent", "image_rate_multiplier", "long_context_pricing_enabled",
  102. } {
  103. _, exists := decoded[key]
  104. require.Truef(t, exists, "plaza group DTO must expose %q", key)
  105. }
  106. require.InDelta(t, 0.5, decoded["user_rate_multiplier"].(float64), 1e-9)
  107. // 模型条目:pricing + official_pricing 并存;official 缺失字段输出 null 而非省略
  108. models := decoded["models"].([]any)
  109. require.Len(t, models, 1)
  110. model := models[0].(map[string]any)
  111. require.Contains(t, model, "pricing")
  112. require.Contains(t, model, "official_pricing")
  113. official := model["official_pricing"].(map[string]any)
  114. require.Contains(t, official, "input_price")
  115. require.Contains(t, official, "cache_read_price")
  116. _, has1h := official["cache_write_1h_price"]
  117. require.False(t, has1h, "1h 缓存写价为 nil 时应 omitempty")
  118. _, hasOfficialIntervals := official["intervals"]
  119. require.False(t, hasOfficialIntervals, "官方无阶梯时 intervals 应 omitempty")
  120. _, hasBasis := model["long_context_basis"]
  121. require.False(t, hasBasis, "单档模型不输出 long_context_basis")
  122. _, hasTimePricing := model["time_pricing"]
  123. require.False(t, hasTimePricing, "无分时时不输出 time_pricing")
  124. // 无专属倍率:user_rate_multiplier 整个字段省略
  125. dtoNoRate := toModelPlazaGroupDTO(&g, nil)
  126. rawNoRate, err := json.Marshal(dtoNoRate)
  127. require.NoError(t, err)
  128. var decodedNoRate map[string]any
  129. require.NoError(t, json.Unmarshal(rawNoRate, &decodedNoRate))
  130. _, hasRate := decodedNoRate["user_rate_multiplier"]
  131. require.False(t, hasRate, "无专属倍率时 user_rate_multiplier 应 omitempty")
  132. }
  133. func TestToModelPlazaOfficialPricing_NilPassthrough(t *testing.T) {
  134. require.Nil(t, toModelPlazaOfficialPricing(nil))
  135. }
  136. func TestToModelPlazaGroupDTO_LongContextTiersAndBasis(t *testing.T) {
  137. maxTokens := 272000
  138. g := service.PlazaGroup{
  139. ID: 3, Name: "ladder", Platform: "openai", SubscriptionType: "standard", RateMultiplier: 1,
  140. LongContextPricingEnabled: true,
  141. Models: []service.PlazaModel{{
  142. Name: "gpt-5.4",
  143. Platform: "openai",
  144. Pricing: &service.ChannelModelPricing{
  145. BillingMode: service.BillingModeToken,
  146. InputPrice: testPtr(2.5e-6),
  147. Intervals: []service.PricingInterval{
  148. {MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)},
  149. {MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)},
  150. },
  151. },
  152. OfficialPricing: &service.PlazaOfficialPricing{
  153. InputPrice: testPtr(2.5e-6),
  154. Intervals: []service.PricingInterval{
  155. {MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)},
  156. {MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)},
  157. },
  158. },
  159. LongContextBasis: service.ContextPricingBasisWholeRequest,
  160. }},
  161. }
  162. raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil))
  163. require.NoError(t, err)
  164. var decoded map[string]any
  165. require.NoError(t, json.Unmarshal(raw, &decoded))
  166. require.Equal(t, true, decoded["long_context_pricing_enabled"])
  167. model := decoded["models"].([]any)[0].(map[string]any)
  168. require.Equal(t, "whole_request", model["long_context_basis"])
  169. pricing := model["pricing"].(map[string]any)
  170. paidTiers := pricing["intervals"].([]any)
  171. require.Len(t, paidTiers, 2)
  172. require.Equal(t, ">272K", paidTiers[1].(map[string]any)["tier_label"])
  173. official := model["official_pricing"].(map[string]any)
  174. officialTiers := official["intervals"].([]any)
  175. require.Len(t, officialTiers, 2)
  176. first := officialTiers[0].(map[string]any)
  177. require.Equal(t, "≤272K", first["tier_label"])
  178. require.InDelta(t, 272000, first["max_tokens"].(float64), 0)
  179. require.Contains(t, first, "cache_write_price", "区间 DTO 字段齐全(nil 输出 null)")
  180. }
  181. func testPtr(v float64) *float64 { return &v }
  182. func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) {
  183. g := service.PlazaGroup{
  184. ID: 4, Name: "cn", Platform: "deepseek", SubscriptionType: "standard", RateMultiplier: 1,
  185. Models: []service.PlazaModel{{
  186. Name: "deepseek-chat",
  187. Platform: "deepseek",
  188. Pricing: &service.ChannelModelPricing{BillingMode: service.BillingModeToken, InputPrice: testPtr(0.28e-6)},
  189. TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", Periods: []service.TimePricingPeriod{
  190. {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
  191. }},
  192. }, {
  193. Name: "deepseek-reasoner",
  194. Platform: "deepseek",
  195. Pricing: &service.ChannelModelPricing{BillingMode: service.BillingModeToken, InputPrice: testPtr(0.56e-6)},
  196. TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", WeekdaysOnly: true, Periods: []service.TimePricingPeriod{
  197. {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
  198. }},
  199. }},
  200. }
  201. raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil))
  202. require.NoError(t, err)
  203. var decoded map[string]any
  204. require.NoError(t, json.Unmarshal(raw, &decoded))
  205. model := decoded["models"].([]any)[0].(map[string]any)
  206. tp := model["time_pricing"].(map[string]any)
  207. require.Equal(t, "Asia/Shanghai", tp["timezone"])
  208. _, hasWeekdaysOnly := tp["weekdays_only"]
  209. require.False(t, hasWeekdaysOnly, "未开启仅工作日时字段省略")
  210. periods := tp["periods"].([]any)
  211. require.Len(t, periods, 1)
  212. first := periods[0].(map[string]any)
  213. require.Equal(t, "00:30", first["start_time"])
  214. require.Equal(t, "08:30", first["end_time"])
  215. require.InDelta(t, 0.5, first["multiplier"].(float64), 1e-12)
  216. weekdaysModel := decoded["models"].([]any)[1].(map[string]any)
  217. weekdaysTP := weekdaysModel["time_pricing"].(map[string]any)
  218. require.Equal(t, true, weekdaysTP["weekdays_only"])
  219. }
  220. func TestFilterPlazaVisibleGroups_SubscribedExclusiveGroup(t *testing.T) {
  221. groups := []service.PlazaGroup{
  222. {ID: 42, IsExclusive: true, SubscriptionType: "subscription"},
  223. {ID: 43, IsExclusive: true, SubscriptionType: "subscription"},
  224. {ID: 44, IsExclusive: true, SubscriptionType: "standard"},
  225. }
  226. require.Empty(t, filterPlazaVisibleGroups(groups, nil, false))
  227. for _, restricted := range []bool{false, true} {
  228. visible := filterPlazaVisibleGroups(groups, map[int64]struct{}{42: {}}, restricted)
  229. require.Len(t, visible, 1)
  230. require.Equal(t, int64(42), visible[0].ID)
  231. }
  232. }