available_channel_handler_test.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  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 TestUserAvailableChannel_Unauthenticated401(t *testing.T) {
  13. // 没有 AuthSubject 注入时,handler 应返回 401 且不触达 service 依赖。
  14. gin.SetMode(gin.TestMode)
  15. h := &AvailableChannelHandler{} // nil services — 401 路径不会调用它们
  16. w := httptest.NewRecorder()
  17. c, _ := gin.CreateTestContext(w)
  18. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/channels/available", nil)
  19. h.List(c)
  20. require.Equal(t, http.StatusUnauthorized, w.Code)
  21. }
  22. func TestFilterUserVisibleGroups_IntersectionOnly(t *testing.T) {
  23. // 渠道挂在 {g1, g2, g3},用户只允许 {g1, g3} —— 响应必须仅含 g1/g3。
  24. groups := []service.AvailableGroupRef{
  25. {ID: 1, Name: "g1", Platform: "anthropic"},
  26. {ID: 2, Name: "g2", Platform: "anthropic"},
  27. {ID: 3, Name: "g3", Platform: "openai"},
  28. }
  29. allowed := map[int64]struct{}{1: {}, 3: {}}
  30. visible := filterUserVisibleGroups(groups, allowed)
  31. require.Len(t, visible, 2)
  32. ids := []int64{visible[0].ID, visible[1].ID}
  33. require.ElementsMatch(t, []int64{1, 3}, ids)
  34. }
  35. func TestToUserSupportedModels_FiltersByAllowedPlatforms(t *testing.T) {
  36. // 用户可访问分组只覆盖 anthropic;anthropic 平台的模型保留,openai 模型被剔除。
  37. src := []service.SupportedModel{
  38. {Name: "claude-sonnet-4-6", Platform: "anthropic", Pricing: nil},
  39. {Name: "gpt-4o", Platform: "openai", Pricing: nil},
  40. }
  41. allowed := map[string]struct{}{"anthropic": {}}
  42. out := toUserSupportedModels(src, allowed)
  43. require.Len(t, out, 1)
  44. require.Equal(t, "claude-sonnet-4-6", out[0].Name)
  45. }
  46. func TestToUserSupportedModels_NilAllowedPlatformsKeepsAll(t *testing.T) {
  47. // 显式传 nil allowedPlatforms 表示不做过滤。
  48. src := []service.SupportedModel{
  49. {Name: "a", Platform: "anthropic"},
  50. {Name: "b", Platform: "openai"},
  51. }
  52. require.Len(t, toUserSupportedModels(src, nil), 2)
  53. }
  54. func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
  55. // 通过序列化 userAvailableChannel 结构体验证响应形状:
  56. // 只有 name / description / platforms;不含管理端字段。
  57. row := userAvailableChannel{
  58. Name: "ch",
  59. Description: "d",
  60. Platforms: []userChannelPlatformSection{
  61. {
  62. Platform: "anthropic",
  63. Groups: []userAvailableGroup{{ID: 1, Name: "g1", Platform: "anthropic"}},
  64. SupportedModels: []userSupportedModel{},
  65. },
  66. },
  67. }
  68. raw, err := json.Marshal(row)
  69. require.NoError(t, err)
  70. var decoded map[string]any
  71. require.NoError(t, json.Unmarshal(raw, &decoded))
  72. for _, key := range []string{"id", "status", "billing_model_source", "restrict_models"} {
  73. _, exists := decoded[key]
  74. require.Falsef(t, exists, "user DTO must not expose %q", key)
  75. }
  76. for _, key := range []string{"name", "description", "platforms"} {
  77. _, exists := decoded[key]
  78. require.Truef(t, exists, "user DTO must expose %q", key)
  79. }
  80. // 验证 section 的字段(platform / groups / supported_models)。
  81. rawSection, err := json.Marshal(row.Platforms[0])
  82. require.NoError(t, err)
  83. var sectionDecoded map[string]any
  84. require.NoError(t, json.Unmarshal(rawSection, &sectionDecoded))
  85. for _, key := range []string{"platform", "groups", "supported_models"} {
  86. _, exists := sectionDecoded[key]
  87. require.Truef(t, exists, "platform section must expose %q", key)
  88. }
  89. // Group DTO 暴露区分专属/公开、订阅类型、默认倍率和高峰倍率规则所需的字段,
  90. // 前端据此渲染 GroupBadge 并与 API 密钥页保持一致的视觉。
  91. rawGroup, err := json.Marshal(row.Platforms[0].Groups[0])
  92. require.NoError(t, err)
  93. var groupDecoded map[string]any
  94. require.NoError(t, json.Unmarshal(rawGroup, &groupDecoded))
  95. for _, key := range []string{"id", "name", "platform", "subscription_type", "rate_multiplier", "peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier", "is_exclusive"} {
  96. _, exists := groupDecoded[key]
  97. require.Truef(t, exists, "group DTO must expose %q", key)
  98. }
  99. // pricing interval 白名单:不应暴露 id / sort_order。
  100. inputMultiplier := 2.0
  101. outputMultiplier := 1.5
  102. cacheWriteMultiplier := 2.0
  103. cacheReadMultiplier := 2.0
  104. pricing := toUserPricing(&service.ChannelModelPricing{
  105. BillingMode: service.BillingModeToken,
  106. Intervals: []service.PricingInterval{
  107. {
  108. ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3,
  109. InputMultiplier: &inputMultiplier, OutputMultiplier: &outputMultiplier,
  110. CacheWriteMultiplier: &cacheWriteMultiplier, CacheReadMultiplier: &cacheReadMultiplier,
  111. },
  112. },
  113. })
  114. require.NotNil(t, pricing)
  115. require.Len(t, pricing.Intervals, 1)
  116. rawIv, err := json.Marshal(pricing.Intervals[0])
  117. require.NoError(t, err)
  118. var ivDecoded map[string]any
  119. require.NoError(t, json.Unmarshal(rawIv, &ivDecoded))
  120. for _, key := range []string{"id", "pricing_id", "sort_order"} {
  121. _, exists := ivDecoded[key]
  122. require.Falsef(t, exists, "user pricing interval must not expose %q", key)
  123. }
  124. for key, want := range map[string]float64{
  125. "input_multiplier": inputMultiplier, "output_multiplier": outputMultiplier,
  126. "cache_write_multiplier": cacheWriteMultiplier, "cache_read_multiplier": cacheReadMultiplier,
  127. } {
  128. got, exists := ivDecoded[key]
  129. require.Truef(t, exists, "user pricing interval must expose %q", key)
  130. require.InDelta(t, want, got.(float64), 1e-12)
  131. }
  132. }
  133. func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
  134. // 一个渠道横跨 anthropic / openai / 空平台:应该生成 2 个 section,
  135. // 按 platform 字母序排序,各自 groups 和 supported_models 只含同平台条目。
  136. ch := service.AvailableChannel{
  137. Name: "ch",
  138. SupportedModels: []service.SupportedModel{
  139. {Name: "claude-sonnet-4-6", Platform: "anthropic"},
  140. {Name: "gpt-4o", Platform: "openai"},
  141. },
  142. }
  143. visible := []userAvailableGroup{
  144. {ID: 1, Name: "g-openai", Platform: "openai"},
  145. {ID: 2, Name: "g-ant", Platform: "anthropic"},
  146. {ID: 3, Name: "g-empty", Platform: ""},
  147. }
  148. sections := buildPlatformSections(ch, visible)
  149. require.Len(t, sections, 2)
  150. require.Equal(t, "anthropic", sections[0].Platform)
  151. require.Equal(t, "openai", sections[1].Platform)
  152. require.Len(t, sections[0].Groups, 1)
  153. require.Equal(t, int64(2), sections[0].Groups[0].ID)
  154. require.Len(t, sections[0].SupportedModels, 1)
  155. require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
  156. }
  157. func TestBuildPlatformSections_CompositeGroupExpandsAcrossConfiguredModelPlatforms(t *testing.T) {
  158. anthropicPrice := 3e-6
  159. openAIPrice := 2.5e-6
  160. ch := service.AvailableChannel{
  161. Name: "composite-channel",
  162. SupportedModels: []service.SupportedModel{
  163. {
  164. Name: "claude-sonnet-4-6",
  165. Platform: service.PlatformAnthropic,
  166. Pricing: &service.ChannelModelPricing{InputPrice: &anthropicPrice},
  167. },
  168. {
  169. Name: "gpt-5",
  170. Platform: service.PlatformOpenAI,
  171. Pricing: &service.ChannelModelPricing{InputPrice: &openAIPrice},
  172. },
  173. },
  174. }
  175. visible := []userAvailableGroup{
  176. {ID: 9, Name: "composite", Platform: service.PlatformComposite},
  177. }
  178. sections := buildPlatformSections(ch, visible)
  179. require.Len(t, sections, 2)
  180. require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
  181. require.Equal(t, service.PlatformOpenAI, sections[1].Platform)
  182. for _, section := range sections {
  183. require.Len(t, section.Groups, 1)
  184. require.Equal(t, int64(9), section.Groups[0].ID)
  185. require.Equal(t, service.PlatformComposite, section.Groups[0].Platform)
  186. require.Len(t, section.SupportedModels, 1)
  187. require.Equal(t, section.Platform, section.SupportedModels[0].Platform)
  188. require.NotNil(t, section.SupportedModels[0].Pricing)
  189. }
  190. require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
  191. require.Equal(t, "gpt-5", sections[1].SupportedModels[0].Name)
  192. }
  193. func TestBuildPlatformSections_OrdinaryGroupRemainsPlatformIsolated(t *testing.T) {
  194. ch := service.AvailableChannel{
  195. SupportedModels: []service.SupportedModel{
  196. {Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic},
  197. {Name: "gpt-5", Platform: service.PlatformOpenAI},
  198. },
  199. }
  200. visible := []userAvailableGroup{
  201. {ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic},
  202. }
  203. sections := buildPlatformSections(ch, visible)
  204. require.Len(t, sections, 1)
  205. require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
  206. require.Len(t, sections[0].SupportedModels, 1)
  207. require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
  208. }
  209. func TestBuildPlatformSections_CompositeAndOrdinaryGroupsShareConcreteSection(t *testing.T) {
  210. ch := service.AvailableChannel{
  211. SupportedModels: []service.SupportedModel{
  212. {Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic},
  213. {Name: "gpt-5", Platform: service.PlatformOpenAI},
  214. },
  215. }
  216. visible := []userAvailableGroup{
  217. {ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic},
  218. {ID: 9, Name: "composite", Platform: service.PlatformComposite},
  219. }
  220. sections := buildPlatformSections(ch, visible)
  221. require.Len(t, sections, 2)
  222. require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
  223. require.Equal(t, []int64{1, 9}, []int64{
  224. sections[0].Groups[0].ID,
  225. sections[0].Groups[1].ID,
  226. })
  227. require.Equal(t, service.PlatformOpenAI, sections[1].Platform)
  228. require.Len(t, sections[1].Groups, 1)
  229. require.Equal(t, int64(9), sections[1].Groups[0].ID)
  230. }
  231. func TestBuildPlatformSections_CompositeWithoutModelsKeepsEmptyCompositeSection(t *testing.T) {
  232. visible := []userAvailableGroup{
  233. {ID: 9, Name: "composite", Platform: service.PlatformComposite},
  234. }
  235. sections := buildPlatformSections(service.AvailableChannel{
  236. SupportedModels: []service.SupportedModel{{Name: "invalid-without-platform"}},
  237. }, visible)
  238. require.Len(t, sections, 1)
  239. require.Equal(t, service.PlatformComposite, sections[0].Platform)
  240. require.Len(t, sections[0].Groups, 1)
  241. require.Empty(t, sections[0].SupportedModels)
  242. }