channel_available_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  8. "github.com/stretchr/testify/require"
  9. )
  10. // stubGroupRepoForAvailable 是 ListAvailable 测试用的 GroupRepository stub,
  11. // 仅实现 ListActive;其他方法对本测试无关,返回零值即可。
  12. // listActiveErr 非 nil 时,ListActive 返回该错误用于错误传播测试。
  13. // listActiveCalls 记录调用次数,用于断言「失败短路时不再访问 groupRepo」等行为。
  14. type stubGroupRepoForAvailable struct {
  15. activeGroups []Group
  16. listActiveErr error
  17. listActiveCalls int
  18. }
  19. func (s *stubGroupRepoForAvailable) ListActive(ctx context.Context) ([]Group, error) {
  20. s.listActiveCalls++
  21. if s.listActiveErr != nil {
  22. return nil, s.listActiveErr
  23. }
  24. return s.activeGroups, nil
  25. }
  26. func (s *stubGroupRepoForAvailable) Create(ctx context.Context, group *Group) error { return nil }
  27. func (s *stubGroupRepoForAvailable) GetByID(ctx context.Context, id int64) (*Group, error) {
  28. return nil, nil
  29. }
  30. func (s *stubGroupRepoForAvailable) GetByIDLite(ctx context.Context, id int64) (*Group, error) {
  31. return nil, nil
  32. }
  33. func (s *stubGroupRepoForAvailable) Update(ctx context.Context, group *Group) error { return nil }
  34. func (s *stubGroupRepoForAvailable) Delete(ctx context.Context, id int64) error { return nil }
  35. func (s *stubGroupRepoForAvailable) DeleteCascade(ctx context.Context, id int64) ([]int64, error) {
  36. return nil, nil
  37. }
  38. func (s *stubGroupRepoForAvailable) List(ctx context.Context, params pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) {
  39. return nil, nil, nil
  40. }
  41. func (s *stubGroupRepoForAvailable) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) {
  42. return nil, nil, nil
  43. }
  44. func (s *stubGroupRepoForAvailable) ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error) {
  45. return nil, nil
  46. }
  47. func (s *stubGroupRepoForAvailable) ExistsByName(ctx context.Context, name string) (bool, error) {
  48. return false, nil
  49. }
  50. func (s *stubGroupRepoForAvailable) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) {
  51. return 0, 0, nil
  52. }
  53. func (s *stubGroupRepoForAvailable) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
  54. return 0, nil
  55. }
  56. func (s *stubGroupRepoForAvailable) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) {
  57. return nil, nil
  58. }
  59. func (s *stubGroupRepoForAvailable) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error {
  60. return nil
  61. }
  62. func (s *stubGroupRepoForAvailable) UpdateSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error {
  63. return nil
  64. }
  65. // newAvailableChannelService 构造一个 ChannelService,channelRepo.ListAll 返回给定 channels,
  66. // groupRepo 由参数决定。传入空 stub 表示「活跃分组列表为空」。
  67. func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) *ChannelService {
  68. repo := &mockChannelRepository{
  69. listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
  70. }
  71. return NewChannelService(repo, groupRepo, nil, nil, nil)
  72. }
  73. func TestListAvailable_EmptyActiveGroups_NoGroupsAttached(t *testing.T) {
  74. // 活跃分组列表为空时,渠道的 Groups 应为空切片,不报错。
  75. channels := []Channel{{
  76. ID: 1,
  77. Name: "chA",
  78. Status: StatusActive,
  79. GroupIDs: []int64{10, 20},
  80. }}
  81. svc := newAvailableChannelService(channels, &stubGroupRepoForAvailable{})
  82. out, err := svc.ListAvailable(context.Background())
  83. require.NoError(t, err)
  84. require.Len(t, out, 1)
  85. require.Empty(t, out[0].Groups)
  86. }
  87. func TestListAvailable_InactiveGroupIDSilentlyDropped(t *testing.T) {
  88. // 渠道 GroupIDs 中引用的 group 未出现在 ListActive 结果中(已停用或删除),应被静默丢弃。
  89. channels := []Channel{{
  90. ID: 1,
  91. Name: "chA",
  92. Status: StatusActive,
  93. GroupIDs: []int64{1, 99},
  94. }}
  95. groupRepo := &stubGroupRepoForAvailable{
  96. activeGroups: []Group{{ID: 1, Name: "g1", Platform: "anthropic"}},
  97. }
  98. svc := newAvailableChannelService(channels, groupRepo)
  99. out, err := svc.ListAvailable(context.Background())
  100. require.NoError(t, err)
  101. require.Len(t, out, 1)
  102. require.Len(t, out[0].Groups, 1)
  103. require.Equal(t, int64(1), out[0].Groups[0].ID)
  104. }
  105. func TestListAvailable_SortedByName(t *testing.T) {
  106. channels := []Channel{
  107. {ID: 1, Name: "beta"},
  108. {ID: 2, Name: "Alpha"},
  109. {ID: 3, Name: "charlie"},
  110. }
  111. svc := newAvailableChannelService(channels, &stubGroupRepoForAvailable{})
  112. out, err := svc.ListAvailable(context.Background())
  113. require.NoError(t, err)
  114. require.Len(t, out, 3)
  115. require.Equal(t, "Alpha", out[0].Name)
  116. require.Equal(t, "beta", out[1].Name)
  117. require.Equal(t, "charlie", out[2].Name)
  118. }
  119. func TestListAvailable_ListAllErrorPropagates(t *testing.T) {
  120. // ListAll 返回错误时 ListAvailable 应直接返回包装后的错误,且不再访问 groupRepo(短路)。
  121. sentinel := errors.New("list-all-boom")
  122. repo := &mockChannelRepository{
  123. listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel },
  124. }
  125. groupRepo := &stubGroupRepoForAvailable{}
  126. svc := NewChannelService(repo, groupRepo, nil, nil, nil)
  127. out, err := svc.ListAvailable(context.Background())
  128. require.Nil(t, out)
  129. require.ErrorIs(t, err, sentinel)
  130. require.Contains(t, err.Error(), "list channels", "wrap 前缀缺失,可能 %w 被改为 %v")
  131. require.Equal(t, 0, groupRepo.listActiveCalls, "ListAll 失败后不应再调用 groupRepo.ListActive")
  132. }
  133. func TestListAvailable_ListActiveErrorPropagates(t *testing.T) {
  134. // groupRepo.ListActive 返回错误时 ListAvailable 应直接返回包装后的错误。
  135. sentinel := errors.New("list-active-boom")
  136. svc := newAvailableChannelService(
  137. []Channel{{ID: 1, Name: "chA"}},
  138. &stubGroupRepoForAvailable{listActiveErr: sentinel},
  139. )
  140. out, err := svc.ListAvailable(context.Background())
  141. require.Nil(t, out)
  142. require.ErrorIs(t, err, sentinel)
  143. require.Contains(t, err.Error(), "list active groups", "wrap 前缀缺失,可能 %w 被改为 %v")
  144. }
  145. func TestListAvailable_DefaultsEmptyBillingModelSource(t *testing.T) {
  146. // 渠道 BillingModelSource 为空时应回填为 BillingModelSourceChannelMapped,
  147. // 显式值应原样保留(由 service 层统一处理,避免各 handler 重复默认逻辑)。
  148. channels := []Channel{
  149. {ID: 1, Name: "empty", BillingModelSource: ""},
  150. {ID: 2, Name: "explicit", BillingModelSource: BillingModelSourceUpstream},
  151. }
  152. svc := newAvailableChannelService(channels, &stubGroupRepoForAvailable{})
  153. out, err := svc.ListAvailable(context.Background())
  154. require.NoError(t, err)
  155. require.Len(t, out, 2)
  156. // 按 Name 查找,避免依赖排序副作用。
  157. byName := make(map[string]string, len(out))
  158. for _, ch := range out {
  159. byName[ch.Name] = ch.BillingModelSource
  160. }
  161. require.Equal(t, BillingModelSourceChannelMapped, byName["empty"])
  162. require.Equal(t, BillingModelSourceUpstream, byName["explicit"])
  163. }
  164. func TestPricingNeedsFallback(t *testing.T) {
  165. tests := []struct {
  166. name string
  167. in *ChannelModelPricing
  168. want bool
  169. }{
  170. {"nil", nil, true},
  171. {"empty struct", &ChannelModelPricing{BillingMode: BillingModeToken}, true},
  172. {"all-empty intervals", &ChannelModelPricing{
  173. BillingMode: BillingModeImage,
  174. Intervals: []PricingInterval{{TierLabel: "1K"}, {TierLabel: "2K"}},
  175. }, true},
  176. {"flat input set", &ChannelModelPricing{InputPrice: testPtrFloat64(3e-6)}, false},
  177. {"flat per_request set", &ChannelModelPricing{PerRequestPrice: testPtrFloat64(0.04)}, false},
  178. {"interval with price", &ChannelModelPricing{
  179. Intervals: []PricingInterval{{TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)}},
  180. }, false},
  181. }
  182. for _, tt := range tests {
  183. t.Run(tt.name, func(t *testing.T) {
  184. require.Equal(t, tt.want, pricingNeedsFallback(tt.in))
  185. })
  186. }
  187. }
  188. func TestSynthesizePricingFromLiteLLM_TokenMode(t *testing.T) {
  189. lp := &LiteLLMModelPricing{
  190. Mode: "chat",
  191. InputCostPerToken: 3e-6,
  192. OutputCostPerToken: 1.5e-5,
  193. CacheCreationInputTokenCost: 3.75e-6,
  194. CacheReadInputTokenCost: 3e-7,
  195. }
  196. got := synthesizePricingFromLiteLLM(lp, nil)
  197. require.NotNil(t, got)
  198. require.Equal(t, BillingModeToken, got.BillingMode)
  199. require.NotNil(t, got.InputPrice)
  200. require.InDelta(t, 3e-6, *got.InputPrice, 1e-12)
  201. require.NotNil(t, got.CacheReadPrice)
  202. }
  203. func TestSynthesizePricingFromLiteLLM_ImageGenerationMode(t *testing.T) {
  204. // LiteLLM mode=image_generation 且渠道未声明模式时,按 image 合成。
  205. lp := &LiteLLMModelPricing{
  206. Mode: "image_generation",
  207. OutputCostPerImageToken: 4e-5,
  208. }
  209. got := synthesizePricingFromLiteLLM(lp, nil)
  210. require.NotNil(t, got)
  211. require.Equal(t, BillingModeImage, got.BillingMode)
  212. require.Nil(t, got.PerRequestPrice)
  213. require.NotNil(t, got.ImageOutputPrice)
  214. }
  215. func TestSynthesizePricingFromLiteLLM_RespectsExistingChannelMode(t *testing.T) {
  216. // admin UI 选了 per_request 但没填价:LiteLLM 数据按 per_request 合成,
  217. // 即便 LiteLLM 标的是 chat 模式也尊重渠道选择。
  218. lp := &LiteLLMModelPricing{
  219. Mode: "chat",
  220. InputCostPerToken: 5e-6,
  221. OutputCostPerImage: 0.04,
  222. }
  223. existing := &ChannelModelPricing{BillingMode: BillingModePerRequest}
  224. got := synthesizePricingFromLiteLLM(lp, existing)
  225. require.NotNil(t, got)
  226. require.Equal(t, BillingModePerRequest, got.BillingMode)
  227. require.NotNil(t, got.PerRequestPrice)
  228. require.InDelta(t, 0.04, *got.PerRequestPrice, 1e-12)
  229. }
  230. func TestFillGlobalPricingFallback_NilPricing(t *testing.T) {
  231. pricingSvc := newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
  232. "claude-opus-4-5": {Mode: "chat", InputCostPerToken: 5e-6},
  233. })
  234. svc := &ChannelService{pricingService: pricingSvc}
  235. models := []SupportedModel{
  236. {Name: "claude-opus-4-5", Platform: "anthropic"},
  237. }
  238. fillGlobalPricingFallback(svc.pricingService, models)
  239. require.NotNil(t, models[0].Pricing)
  240. require.NotNil(t, models[0].Pricing.InputPrice)
  241. require.InDelta(t, 5e-6, *models[0].Pricing.InputPrice, 1e-12)
  242. }
  243. func TestFillGlobalPricingFallback_EmptyPricingFillsFromLiteLLM(t *testing.T) {
  244. // 核心场景:admin UI 建了 pricing 条目(image 模式)但没填价,应走 LiteLLM 兜底。
  245. pricingSvc := newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
  246. "gpt-image-1": {
  247. Mode: "image_generation",
  248. OutputCostPerImageToken: 4e-5,
  249. },
  250. })
  251. svc := &ChannelService{pricingService: pricingSvc}
  252. models := []SupportedModel{
  253. {
  254. Name: "gpt-image-1",
  255. Platform: "openai",
  256. Pricing: &ChannelModelPricing{
  257. BillingMode: BillingModeImage,
  258. Intervals: []PricingInterval{{TierLabel: "1K"}, {TierLabel: "2K"}},
  259. },
  260. },
  261. }
  262. fillGlobalPricingFallback(svc.pricingService, models)
  263. require.NotNil(t, models[0].Pricing)
  264. require.Equal(t, BillingModeImage, models[0].Pricing.BillingMode)
  265. require.NotNil(t, models[0].Pricing.ImageOutputPrice)
  266. require.InDelta(t, 4e-5, *models[0].Pricing.ImageOutputPrice, 1e-12)
  267. }
  268. func TestFillGlobalPricingFallback_KeepsExistingPrice(t *testing.T) {
  269. // 渠道已经填了价格的条目不应被回落覆盖。
  270. pricingSvc := newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
  271. "served-model": {Mode: "chat", InputCostPerToken: 1e-6},
  272. })
  273. svc := &ChannelService{pricingService: pricingSvc}
  274. existing := &ChannelModelPricing{
  275. BillingMode: BillingModeToken,
  276. InputPrice: testPtrFloat64(9e-9),
  277. }
  278. models := []SupportedModel{
  279. {Name: "served-model", Platform: "anthropic", Pricing: existing},
  280. }
  281. fillGlobalPricingFallback(svc.pricingService, models)
  282. require.Same(t, existing, models[0].Pricing)
  283. }
  284. func newStubPricingServiceFromMap(data map[string]*LiteLLMModelPricing) *PricingService {
  285. return &PricingService{pricingData: data}
  286. }