error_passthrough_service_test.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "strings"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/model"
  9. "github.com/stretchr/testify/assert"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // mockErrorPassthroughRepo 用于测试的 mock repository
  13. type mockErrorPassthroughRepo struct {
  14. rules []*model.ErrorPassthroughRule
  15. listErr error
  16. getErr error
  17. createErr error
  18. updateErr error
  19. deleteErr error
  20. }
  21. type mockErrorPassthroughCache struct {
  22. rules []*model.ErrorPassthroughRule
  23. hasData bool
  24. getCalled int
  25. setCalled int
  26. invalidateCalled int
  27. notifyCalled int
  28. }
  29. func newMockErrorPassthroughCache(rules []*model.ErrorPassthroughRule, hasData bool) *mockErrorPassthroughCache {
  30. return &mockErrorPassthroughCache{
  31. rules: cloneRules(rules),
  32. hasData: hasData,
  33. }
  34. }
  35. func (m *mockErrorPassthroughCache) Get(ctx context.Context) ([]*model.ErrorPassthroughRule, bool) {
  36. m.getCalled++
  37. if !m.hasData {
  38. return nil, false
  39. }
  40. return cloneRules(m.rules), true
  41. }
  42. func (m *mockErrorPassthroughCache) Set(ctx context.Context, rules []*model.ErrorPassthroughRule) error {
  43. m.setCalled++
  44. m.rules = cloneRules(rules)
  45. m.hasData = true
  46. return nil
  47. }
  48. func (m *mockErrorPassthroughCache) Invalidate(ctx context.Context) error {
  49. m.invalidateCalled++
  50. m.rules = nil
  51. m.hasData = false
  52. return nil
  53. }
  54. func (m *mockErrorPassthroughCache) NotifyUpdate(ctx context.Context) error {
  55. m.notifyCalled++
  56. return nil
  57. }
  58. func (m *mockErrorPassthroughCache) SubscribeUpdates(ctx context.Context, handler func()) {
  59. // 单测中无需订阅行为
  60. }
  61. func cloneRules(rules []*model.ErrorPassthroughRule) []*model.ErrorPassthroughRule {
  62. if rules == nil {
  63. return nil
  64. }
  65. out := make([]*model.ErrorPassthroughRule, len(rules))
  66. copy(out, rules)
  67. return out
  68. }
  69. func (m *mockErrorPassthroughRepo) List(ctx context.Context) ([]*model.ErrorPassthroughRule, error) {
  70. if m.listErr != nil {
  71. return nil, m.listErr
  72. }
  73. return m.rules, nil
  74. }
  75. func (m *mockErrorPassthroughRepo) GetByID(ctx context.Context, id int64) (*model.ErrorPassthroughRule, error) {
  76. if m.getErr != nil {
  77. return nil, m.getErr
  78. }
  79. for _, r := range m.rules {
  80. if r.ID == id {
  81. return r, nil
  82. }
  83. }
  84. return nil, nil
  85. }
  86. func (m *mockErrorPassthroughRepo) Create(ctx context.Context, rule *model.ErrorPassthroughRule) (*model.ErrorPassthroughRule, error) {
  87. if m.createErr != nil {
  88. return nil, m.createErr
  89. }
  90. rule.ID = int64(len(m.rules) + 1)
  91. m.rules = append(m.rules, rule)
  92. return rule, nil
  93. }
  94. func (m *mockErrorPassthroughRepo) Update(ctx context.Context, rule *model.ErrorPassthroughRule) (*model.ErrorPassthroughRule, error) {
  95. if m.updateErr != nil {
  96. return nil, m.updateErr
  97. }
  98. for i, r := range m.rules {
  99. if r.ID == rule.ID {
  100. m.rules[i] = rule
  101. return rule, nil
  102. }
  103. }
  104. return rule, nil
  105. }
  106. func (m *mockErrorPassthroughRepo) Delete(ctx context.Context, id int64) error {
  107. if m.deleteErr != nil {
  108. return m.deleteErr
  109. }
  110. for i, r := range m.rules {
  111. if r.ID == id {
  112. m.rules = append(m.rules[:i], m.rules[i+1:]...)
  113. return nil
  114. }
  115. }
  116. return nil
  117. }
  118. // newTestService 创建测试用的服务实例
  119. func newTestService(rules []*model.ErrorPassthroughRule) *ErrorPassthroughService {
  120. repo := &mockErrorPassthroughRepo{rules: rules}
  121. svc := &ErrorPassthroughService{
  122. repo: repo,
  123. cache: nil, // 不使用缓存
  124. }
  125. // 直接设置本地缓存,避免调用 refreshLocalCache
  126. svc.setLocalCache(rules)
  127. return svc
  128. }
  129. // newCachedRuleForTest 从 model.ErrorPassthroughRule 创建 cachedPassthroughRule(测试用)
  130. func newCachedRuleForTest(rule *model.ErrorPassthroughRule) *cachedPassthroughRule {
  131. cr := &cachedPassthroughRule{ErrorPassthroughRule: rule}
  132. if len(rule.Keywords) > 0 {
  133. cr.lowerKeywords = make([]string, len(rule.Keywords))
  134. for j, kw := range rule.Keywords {
  135. cr.lowerKeywords[j] = strings.ToLower(kw)
  136. }
  137. }
  138. if len(rule.Platforms) > 0 {
  139. cr.lowerPlatforms = make([]string, len(rule.Platforms))
  140. for j, p := range rule.Platforms {
  141. cr.lowerPlatforms[j] = strings.ToLower(p)
  142. }
  143. }
  144. if len(rule.ErrorCodes) > 0 {
  145. cr.errorCodeSet = make(map[int]struct{}, len(rule.ErrorCodes))
  146. for _, code := range rule.ErrorCodes {
  147. cr.errorCodeSet[code] = struct{}{}
  148. }
  149. }
  150. return cr
  151. }
  152. // =============================================================================
  153. // 测试 ruleMatchesOptimized 核心匹配逻辑
  154. // =============================================================================
  155. func TestRuleMatches_NoConditions(t *testing.T) {
  156. // 没有配置任何条件时,不应该匹配
  157. svc := newTestService(nil)
  158. rule := newCachedRuleForTest(&model.ErrorPassthroughRule{
  159. Enabled: true,
  160. ErrorCodes: []int{},
  161. Keywords: []string{},
  162. MatchMode: model.MatchModeAny,
  163. })
  164. var bodyLower string
  165. var bodyLowerDone bool
  166. assert.False(t, svc.ruleMatchesOptimized(rule, 422, []byte("some error message"), &bodyLower, &bodyLowerDone),
  167. "没有配置条件时不应该匹配")
  168. }
  169. func TestRuleMatches_OnlyErrorCodes_AnyMode(t *testing.T) {
  170. svc := newTestService(nil)
  171. rule := newCachedRuleForTest(&model.ErrorPassthroughRule{
  172. Enabled: true,
  173. ErrorCodes: []int{422, 400},
  174. Keywords: []string{},
  175. MatchMode: model.MatchModeAny,
  176. })
  177. tests := []struct {
  178. name string
  179. statusCode int
  180. body string
  181. expected bool
  182. }{
  183. {"状态码匹配 422", 422, "any message", true},
  184. {"状态码匹配 400", 400, "any message", true},
  185. {"状态码不匹配 500", 500, "any message", false},
  186. {"状态码不匹配 429", 429, "any message", false},
  187. }
  188. for _, tt := range tests {
  189. t.Run(tt.name, func(t *testing.T) {
  190. var bodyLower string
  191. var bodyLowerDone bool
  192. result := svc.ruleMatchesOptimized(rule, tt.statusCode, []byte(tt.body), &bodyLower, &bodyLowerDone)
  193. assert.Equal(t, tt.expected, result)
  194. })
  195. }
  196. }
  197. func TestRuleMatches_OnlyKeywords_AnyMode(t *testing.T) {
  198. svc := newTestService(nil)
  199. rule := newCachedRuleForTest(&model.ErrorPassthroughRule{
  200. Enabled: true,
  201. ErrorCodes: []int{},
  202. Keywords: []string{"context limit", "model not supported"},
  203. MatchMode: model.MatchModeAny,
  204. })
  205. tests := []struct {
  206. name string
  207. statusCode int
  208. body string
  209. expected bool
  210. }{
  211. {"关键词匹配 context limit", 500, "error: context limit reached", true},
  212. {"关键词匹配 model not supported", 400, "the model not supported here", true},
  213. {"关键词不匹配", 422, "some other error", false},
  214. {"关键词大小写 - 自动转换", 500, "Context Limit exceeded", true},
  215. }
  216. for _, tt := range tests {
  217. t.Run(tt.name, func(t *testing.T) {
  218. var bodyLower string
  219. var bodyLowerDone bool
  220. result := svc.ruleMatchesOptimized(rule, tt.statusCode, []byte(tt.body), &bodyLower, &bodyLowerDone)
  221. assert.Equal(t, tt.expected, result)
  222. })
  223. }
  224. }
  225. func TestRuleMatches_BothConditions_AnyMode(t *testing.T) {
  226. // any 模式:错误码 OR 关键词
  227. svc := newTestService(nil)
  228. rule := newCachedRuleForTest(&model.ErrorPassthroughRule{
  229. Enabled: true,
  230. ErrorCodes: []int{422, 400},
  231. Keywords: []string{"context limit"},
  232. MatchMode: model.MatchModeAny,
  233. })
  234. tests := []struct {
  235. name string
  236. statusCode int
  237. body string
  238. expected bool
  239. reason string
  240. }{
  241. {
  242. name: "状态码和关键词都匹配",
  243. statusCode: 422,
  244. body: "context limit reached",
  245. expected: true,
  246. reason: "both match",
  247. },
  248. {
  249. name: "只有状态码匹配",
  250. statusCode: 422,
  251. body: "some other error",
  252. expected: true,
  253. reason: "code matches, keyword doesn't - OR mode should match",
  254. },
  255. {
  256. name: "只有关键词匹配",
  257. statusCode: 500,
  258. body: "context limit exceeded",
  259. expected: true,
  260. reason: "keyword matches, code doesn't - OR mode should match",
  261. },
  262. {
  263. name: "都不匹配",
  264. statusCode: 500,
  265. body: "some other error",
  266. expected: false,
  267. reason: "neither matches",
  268. },
  269. }
  270. for _, tt := range tests {
  271. t.Run(tt.name, func(t *testing.T) {
  272. var bodyLower string
  273. var bodyLowerDone bool
  274. result := svc.ruleMatchesOptimized(rule, tt.statusCode, []byte(tt.body), &bodyLower, &bodyLowerDone)
  275. assert.Equal(t, tt.expected, result, tt.reason)
  276. })
  277. }
  278. }
  279. func TestRuleMatches_BothConditions_AllMode(t *testing.T) {
  280. // all 模式:错误码 AND 关键词
  281. svc := newTestService(nil)
  282. rule := newCachedRuleForTest(&model.ErrorPassthroughRule{
  283. Enabled: true,
  284. ErrorCodes: []int{422, 400},
  285. Keywords: []string{"context limit"},
  286. MatchMode: model.MatchModeAll,
  287. })
  288. tests := []struct {
  289. name string
  290. statusCode int
  291. body string
  292. expected bool
  293. reason string
  294. }{
  295. {
  296. name: "状态码和关键词都匹配",
  297. statusCode: 422,
  298. body: "context limit reached",
  299. expected: true,
  300. reason: "both match - AND mode should match",
  301. },
  302. {
  303. name: "只有状态码匹配",
  304. statusCode: 422,
  305. body: "some other error",
  306. expected: false,
  307. reason: "code matches but keyword doesn't - AND mode should NOT match",
  308. },
  309. {
  310. name: "只有关键词匹配",
  311. statusCode: 500,
  312. body: "context limit exceeded",
  313. expected: false,
  314. reason: "keyword matches but code doesn't - AND mode should NOT match",
  315. },
  316. {
  317. name: "都不匹配",
  318. statusCode: 500,
  319. body: "some other error",
  320. expected: false,
  321. reason: "neither matches",
  322. },
  323. }
  324. for _, tt := range tests {
  325. t.Run(tt.name, func(t *testing.T) {
  326. var bodyLower string
  327. var bodyLowerDone bool
  328. result := svc.ruleMatchesOptimized(rule, tt.statusCode, []byte(tt.body), &bodyLower, &bodyLowerDone)
  329. assert.Equal(t, tt.expected, result, tt.reason)
  330. })
  331. }
  332. }
  333. // =============================================================================
  334. // 测试 platformMatchesCached 平台匹配逻辑
  335. // =============================================================================
  336. func TestPlatformMatches(t *testing.T) {
  337. svc := newTestService(nil)
  338. tests := []struct {
  339. name string
  340. rulePlatforms []string
  341. requestPlatform string
  342. expected bool
  343. }{
  344. {
  345. name: "空平台列表匹配所有",
  346. rulePlatforms: []string{},
  347. requestPlatform: "anthropic",
  348. expected: true,
  349. },
  350. {
  351. name: "nil平台列表匹配所有",
  352. rulePlatforms: nil,
  353. requestPlatform: "openai",
  354. expected: true,
  355. },
  356. {
  357. name: "精确匹配 anthropic",
  358. rulePlatforms: []string{"anthropic", "openai"},
  359. requestPlatform: "anthropic",
  360. expected: true,
  361. },
  362. {
  363. name: "精确匹配 openai",
  364. rulePlatforms: []string{"anthropic", "openai"},
  365. requestPlatform: "openai",
  366. expected: true,
  367. },
  368. {
  369. name: "不匹配 gemini",
  370. rulePlatforms: []string{"anthropic", "openai"},
  371. requestPlatform: "gemini",
  372. expected: false,
  373. },
  374. {
  375. name: "大小写不敏感",
  376. rulePlatforms: []string{"Anthropic", "OpenAI"},
  377. requestPlatform: "anthropic",
  378. expected: true,
  379. },
  380. {
  381. name: "匹配 antigravity",
  382. rulePlatforms: []string{"antigravity"},
  383. requestPlatform: "antigravity",
  384. expected: true,
  385. },
  386. }
  387. for _, tt := range tests {
  388. t.Run(tt.name, func(t *testing.T) {
  389. rule := newCachedRuleForTest(&model.ErrorPassthroughRule{
  390. Platforms: tt.rulePlatforms,
  391. })
  392. result := svc.platformMatchesCached(rule, strings.ToLower(tt.requestPlatform))
  393. assert.Equal(t, tt.expected, result)
  394. })
  395. }
  396. }
  397. // =============================================================================
  398. // 测试 MatchRule 完整匹配流程
  399. // =============================================================================
  400. func TestMatchRule_Priority(t *testing.T) {
  401. // 测试规则按优先级排序,优先级小的先匹配
  402. rules := []*model.ErrorPassthroughRule{
  403. {
  404. ID: 1,
  405. Name: "Low Priority",
  406. Enabled: true,
  407. Priority: 10,
  408. ErrorCodes: []int{422},
  409. MatchMode: model.MatchModeAny,
  410. },
  411. {
  412. ID: 2,
  413. Name: "High Priority",
  414. Enabled: true,
  415. Priority: 1,
  416. ErrorCodes: []int{422},
  417. MatchMode: model.MatchModeAny,
  418. },
  419. }
  420. svc := newTestService(rules)
  421. matched := svc.MatchRule("anthropic", 422, []byte("error"))
  422. require.NotNil(t, matched)
  423. assert.Equal(t, int64(2), matched.ID, "应该匹配优先级更高(数值更小)的规则")
  424. assert.Equal(t, "High Priority", matched.Name)
  425. }
  426. func TestMatchRule_DisabledRule(t *testing.T) {
  427. rules := []*model.ErrorPassthroughRule{
  428. {
  429. ID: 1,
  430. Name: "Disabled Rule",
  431. Enabled: false,
  432. Priority: 1,
  433. ErrorCodes: []int{422},
  434. MatchMode: model.MatchModeAny,
  435. },
  436. {
  437. ID: 2,
  438. Name: "Enabled Rule",
  439. Enabled: true,
  440. Priority: 10,
  441. ErrorCodes: []int{422},
  442. MatchMode: model.MatchModeAny,
  443. },
  444. }
  445. svc := newTestService(rules)
  446. matched := svc.MatchRule("anthropic", 422, []byte("error"))
  447. require.NotNil(t, matched)
  448. assert.Equal(t, int64(2), matched.ID, "应该跳过禁用的规则")
  449. }
  450. func TestMatchRule_PlatformFilter(t *testing.T) {
  451. rules := []*model.ErrorPassthroughRule{
  452. {
  453. ID: 1,
  454. Name: "Anthropic Only",
  455. Enabled: true,
  456. Priority: 1,
  457. ErrorCodes: []int{422},
  458. Platforms: []string{"anthropic"},
  459. MatchMode: model.MatchModeAny,
  460. },
  461. {
  462. ID: 2,
  463. Name: "OpenAI Only",
  464. Enabled: true,
  465. Priority: 2,
  466. ErrorCodes: []int{422},
  467. Platforms: []string{"openai"},
  468. MatchMode: model.MatchModeAny,
  469. },
  470. {
  471. ID: 3,
  472. Name: "All Platforms",
  473. Enabled: true,
  474. Priority: 3,
  475. ErrorCodes: []int{422},
  476. Platforms: []string{},
  477. MatchMode: model.MatchModeAny,
  478. },
  479. }
  480. svc := newTestService(rules)
  481. t.Run("Anthropic 请求匹配 Anthropic 规则", func(t *testing.T) {
  482. matched := svc.MatchRule("anthropic", 422, []byte("error"))
  483. require.NotNil(t, matched)
  484. assert.Equal(t, int64(1), matched.ID)
  485. })
  486. t.Run("OpenAI 请求匹配 OpenAI 规则", func(t *testing.T) {
  487. matched := svc.MatchRule("openai", 422, []byte("error"))
  488. require.NotNil(t, matched)
  489. assert.Equal(t, int64(2), matched.ID)
  490. })
  491. t.Run("Gemini 请求匹配全平台规则", func(t *testing.T) {
  492. matched := svc.MatchRule("gemini", 422, []byte("error"))
  493. require.NotNil(t, matched)
  494. assert.Equal(t, int64(3), matched.ID)
  495. })
  496. t.Run("Antigravity 请求匹配全平台规则", func(t *testing.T) {
  497. matched := svc.MatchRule("antigravity", 422, []byte("error"))
  498. require.NotNil(t, matched)
  499. assert.Equal(t, int64(3), matched.ID)
  500. })
  501. }
  502. func TestMatchRule_NoMatch(t *testing.T) {
  503. rules := []*model.ErrorPassthroughRule{
  504. {
  505. ID: 1,
  506. Name: "Rule for 422",
  507. Enabled: true,
  508. Priority: 1,
  509. ErrorCodes: []int{422},
  510. MatchMode: model.MatchModeAny,
  511. },
  512. }
  513. svc := newTestService(rules)
  514. matched := svc.MatchRule("anthropic", 500, []byte("error"))
  515. assert.Nil(t, matched, "不匹配任何规则时应返回 nil")
  516. }
  517. func TestMatchRule_EmptyRules(t *testing.T) {
  518. svc := newTestService([]*model.ErrorPassthroughRule{})
  519. matched := svc.MatchRule("anthropic", 422, []byte("error"))
  520. assert.Nil(t, matched, "没有规则时应返回 nil")
  521. }
  522. func TestMatchRule_CaseInsensitiveKeyword(t *testing.T) {
  523. rules := []*model.ErrorPassthroughRule{
  524. {
  525. ID: 1,
  526. Name: "Context Limit",
  527. Enabled: true,
  528. Priority: 1,
  529. Keywords: []string{"Context Limit"},
  530. MatchMode: model.MatchModeAny,
  531. },
  532. }
  533. svc := newTestService(rules)
  534. tests := []struct {
  535. name string
  536. body string
  537. expected bool
  538. }{
  539. {"完全匹配", "Context Limit reached", true},
  540. {"小写匹配", "context limit reached", true},
  541. {"大写匹配", "CONTEXT LIMIT REACHED", true},
  542. {"混合大小写", "ConTeXt LiMiT error", true},
  543. {"不匹配", "some other error", false},
  544. }
  545. for _, tt := range tests {
  546. t.Run(tt.name, func(t *testing.T) {
  547. matched := svc.MatchRule("anthropic", 500, []byte(tt.body))
  548. if tt.expected {
  549. assert.NotNil(t, matched)
  550. } else {
  551. assert.Nil(t, matched)
  552. }
  553. })
  554. }
  555. }
  556. // =============================================================================
  557. // 测试真实场景
  558. // =============================================================================
  559. func TestMatchRule_RealWorldScenario_ContextLimitPassthrough(t *testing.T) {
  560. // 场景:上游返回 422 + "context limit has been reached",需要透传给客户端
  561. rules := []*model.ErrorPassthroughRule{
  562. {
  563. ID: 1,
  564. Name: "Context Limit Passthrough",
  565. Enabled: true,
  566. Priority: 1,
  567. ErrorCodes: []int{422},
  568. Keywords: []string{"context limit"},
  569. MatchMode: model.MatchModeAll, // 必须同时满足
  570. Platforms: []string{"anthropic", "antigravity"},
  571. PassthroughCode: true,
  572. PassthroughBody: true,
  573. },
  574. }
  575. svc := newTestService(rules)
  576. // 测试 Anthropic 平台
  577. t.Run("Anthropic 422 with context limit", func(t *testing.T) {
  578. body := []byte(`{"type":"error","error":{"type":"invalid_request","message":"The context limit has been reached"}}`)
  579. matched := svc.MatchRule("anthropic", 422, body)
  580. require.NotNil(t, matched)
  581. assert.True(t, matched.PassthroughCode)
  582. assert.True(t, matched.PassthroughBody)
  583. })
  584. // 测试 Antigravity 平台
  585. t.Run("Antigravity 422 with context limit", func(t *testing.T) {
  586. body := []byte(`{"error":"context limit exceeded"}`)
  587. matched := svc.MatchRule("antigravity", 422, body)
  588. require.NotNil(t, matched)
  589. })
  590. // 测试 OpenAI 平台(不在规则的平台列表中)
  591. t.Run("OpenAI should not match", func(t *testing.T) {
  592. body := []byte(`{"error":"context limit exceeded"}`)
  593. matched := svc.MatchRule("openai", 422, body)
  594. assert.Nil(t, matched, "OpenAI 不在规则的平台列表中")
  595. })
  596. // 测试状态码不匹配
  597. t.Run("Wrong status code", func(t *testing.T) {
  598. body := []byte(`{"error":"context limit exceeded"}`)
  599. matched := svc.MatchRule("anthropic", 400, body)
  600. assert.Nil(t, matched, "状态码不匹配")
  601. })
  602. // 测试关键词不匹配
  603. t.Run("Wrong keyword", func(t *testing.T) {
  604. body := []byte(`{"error":"rate limit exceeded"}`)
  605. matched := svc.MatchRule("anthropic", 422, body)
  606. assert.Nil(t, matched, "关键词不匹配")
  607. })
  608. }
  609. func TestMatchRule_RealWorldScenario_CustomErrorMessage(t *testing.T) {
  610. // 场景:某些错误需要返回自定义消息,隐藏上游详细信息
  611. customMsg := "Service temporarily unavailable, please try again later"
  612. responseCode := 503
  613. rules := []*model.ErrorPassthroughRule{
  614. {
  615. ID: 1,
  616. Name: "Hide Internal Errors",
  617. Enabled: true,
  618. Priority: 1,
  619. ErrorCodes: []int{500, 502, 503},
  620. MatchMode: model.MatchModeAny,
  621. PassthroughCode: false,
  622. ResponseCode: &responseCode,
  623. PassthroughBody: false,
  624. CustomMessage: &customMsg,
  625. },
  626. }
  627. svc := newTestService(rules)
  628. matched := svc.MatchRule("anthropic", 500, []byte("internal server error"))
  629. require.NotNil(t, matched)
  630. assert.False(t, matched.PassthroughCode)
  631. assert.Equal(t, 503, *matched.ResponseCode)
  632. assert.False(t, matched.PassthroughBody)
  633. assert.Equal(t, customMsg, *matched.CustomMessage)
  634. }
  635. // =============================================================================
  636. // 测试 model.Validate
  637. // =============================================================================
  638. func TestErrorPassthroughRule_Validate(t *testing.T) {
  639. tests := []struct {
  640. name string
  641. rule *model.ErrorPassthroughRule
  642. expectError bool
  643. errorField string
  644. }{
  645. {
  646. name: "有效规则 - 透传模式(含错误码)",
  647. rule: &model.ErrorPassthroughRule{
  648. Name: "Valid Rule",
  649. MatchMode: model.MatchModeAny,
  650. ErrorCodes: []int{422},
  651. PassthroughCode: true,
  652. PassthroughBody: true,
  653. },
  654. expectError: false,
  655. },
  656. {
  657. name: "有效规则 - 透传模式(含关键词)",
  658. rule: &model.ErrorPassthroughRule{
  659. Name: "Valid Rule",
  660. MatchMode: model.MatchModeAny,
  661. Keywords: []string{"context limit"},
  662. PassthroughCode: true,
  663. PassthroughBody: true,
  664. },
  665. expectError: false,
  666. },
  667. {
  668. name: "有效规则 - 自定义响应",
  669. rule: &model.ErrorPassthroughRule{
  670. Name: "Valid Rule",
  671. MatchMode: model.MatchModeAll,
  672. ErrorCodes: []int{500},
  673. Keywords: []string{"internal error"},
  674. PassthroughCode: false,
  675. ResponseCode: testIntPtr(503),
  676. PassthroughBody: false,
  677. CustomMessage: testStrPtr("Custom error"),
  678. },
  679. expectError: false,
  680. },
  681. {
  682. name: "缺少名称",
  683. rule: &model.ErrorPassthroughRule{
  684. Name: "",
  685. MatchMode: model.MatchModeAny,
  686. ErrorCodes: []int{422},
  687. PassthroughCode: true,
  688. PassthroughBody: true,
  689. },
  690. expectError: true,
  691. errorField: "name",
  692. },
  693. {
  694. name: "无效的匹配模式",
  695. rule: &model.ErrorPassthroughRule{
  696. Name: "Invalid Mode",
  697. MatchMode: "invalid",
  698. ErrorCodes: []int{422},
  699. PassthroughCode: true,
  700. PassthroughBody: true,
  701. },
  702. expectError: true,
  703. errorField: "match_mode",
  704. },
  705. {
  706. name: "缺少匹配条件(错误码和关键词都为空)",
  707. rule: &model.ErrorPassthroughRule{
  708. Name: "No Conditions",
  709. MatchMode: model.MatchModeAny,
  710. ErrorCodes: []int{},
  711. Keywords: []string{},
  712. PassthroughCode: true,
  713. PassthroughBody: true,
  714. },
  715. expectError: true,
  716. errorField: "conditions",
  717. },
  718. {
  719. name: "缺少匹配条件(nil切片)",
  720. rule: &model.ErrorPassthroughRule{
  721. Name: "Nil Conditions",
  722. MatchMode: model.MatchModeAny,
  723. ErrorCodes: nil,
  724. Keywords: nil,
  725. PassthroughCode: true,
  726. PassthroughBody: true,
  727. },
  728. expectError: true,
  729. errorField: "conditions",
  730. },
  731. {
  732. name: "自定义状态码但未提供值",
  733. rule: &model.ErrorPassthroughRule{
  734. Name: "Missing Code",
  735. MatchMode: model.MatchModeAny,
  736. ErrorCodes: []int{422},
  737. PassthroughCode: false,
  738. ResponseCode: nil,
  739. PassthroughBody: true,
  740. },
  741. expectError: true,
  742. errorField: "response_code",
  743. },
  744. {
  745. name: "自定义消息但未提供值",
  746. rule: &model.ErrorPassthroughRule{
  747. Name: "Missing Message",
  748. MatchMode: model.MatchModeAny,
  749. ErrorCodes: []int{422},
  750. PassthroughCode: true,
  751. PassthroughBody: false,
  752. CustomMessage: nil,
  753. },
  754. expectError: true,
  755. errorField: "custom_message",
  756. },
  757. {
  758. name: "自定义消息为空字符串",
  759. rule: &model.ErrorPassthroughRule{
  760. Name: "Empty Message",
  761. MatchMode: model.MatchModeAny,
  762. ErrorCodes: []int{422},
  763. PassthroughCode: true,
  764. PassthroughBody: false,
  765. CustomMessage: testStrPtr(""),
  766. },
  767. expectError: true,
  768. errorField: "custom_message",
  769. },
  770. }
  771. for _, tt := range tests {
  772. t.Run(tt.name, func(t *testing.T) {
  773. err := tt.rule.Validate()
  774. if tt.expectError {
  775. require.Error(t, err)
  776. validationErr, ok := err.(*model.ValidationError)
  777. require.True(t, ok, "应该返回 ValidationError")
  778. assert.Equal(t, tt.errorField, validationErr.Field)
  779. } else {
  780. assert.NoError(t, err)
  781. }
  782. })
  783. }
  784. }
  785. // =============================================================================
  786. // 测试写路径缓存刷新(Create/Update/Delete)
  787. // =============================================================================
  788. func TestCreate_ForceRefreshCacheAfterWrite(t *testing.T) {
  789. ctx := context.Background()
  790. staleRule := newPassthroughRuleForWritePathTest(99, "service temporarily unavailable after multiple", "旧缓存消息")
  791. repo := &mockErrorPassthroughRepo{rules: []*model.ErrorPassthroughRule{}}
  792. cache := newMockErrorPassthroughCache([]*model.ErrorPassthroughRule{staleRule}, true)
  793. svc := &ErrorPassthroughService{repo: repo, cache: cache}
  794. svc.setLocalCache([]*model.ErrorPassthroughRule{staleRule})
  795. newRule := newPassthroughRuleForWritePathTest(0, "service temporarily unavailable after multiple", "上游请求失败")
  796. created, err := svc.Create(ctx, newRule)
  797. require.NoError(t, err)
  798. require.NotNil(t, created)
  799. body := []byte(`{"message":"Service temporarily unavailable after multiple retries, please try again later"}`)
  800. matched := svc.MatchRule("anthropic", 503, body)
  801. require.NotNil(t, matched)
  802. assert.Equal(t, created.ID, matched.ID)
  803. if assert.NotNil(t, matched.CustomMessage) {
  804. assert.Equal(t, "上游请求失败", *matched.CustomMessage)
  805. }
  806. assert.Equal(t, 0, cache.getCalled, "写路径刷新不应依赖 cache.Get")
  807. assert.Equal(t, 1, cache.invalidateCalled)
  808. assert.Equal(t, 1, cache.setCalled)
  809. assert.Equal(t, 1, cache.notifyCalled)
  810. }
  811. func TestUpdate_ForceRefreshCacheAfterWrite(t *testing.T) {
  812. ctx := context.Background()
  813. originalRule := newPassthroughRuleForWritePathTest(1, "old keyword", "旧消息")
  814. repo := &mockErrorPassthroughRepo{rules: []*model.ErrorPassthroughRule{originalRule}}
  815. cache := newMockErrorPassthroughCache([]*model.ErrorPassthroughRule{originalRule}, true)
  816. svc := &ErrorPassthroughService{repo: repo, cache: cache}
  817. svc.setLocalCache([]*model.ErrorPassthroughRule{originalRule})
  818. updatedRule := newPassthroughRuleForWritePathTest(1, "new keyword", "新消息")
  819. _, err := svc.Update(ctx, updatedRule)
  820. require.NoError(t, err)
  821. oldBody := []byte(`{"message":"old keyword"}`)
  822. oldMatched := svc.MatchRule("anthropic", 503, oldBody)
  823. assert.Nil(t, oldMatched, "更新后旧关键词不应继续命中")
  824. newBody := []byte(`{"message":"new keyword"}`)
  825. newMatched := svc.MatchRule("anthropic", 503, newBody)
  826. require.NotNil(t, newMatched)
  827. if assert.NotNil(t, newMatched.CustomMessage) {
  828. assert.Equal(t, "新消息", *newMatched.CustomMessage)
  829. }
  830. assert.Equal(t, 0, cache.getCalled, "写路径刷新不应依赖 cache.Get")
  831. assert.Equal(t, 1, cache.invalidateCalled)
  832. assert.Equal(t, 1, cache.setCalled)
  833. assert.Equal(t, 1, cache.notifyCalled)
  834. }
  835. func TestDelete_ForceRefreshCacheAfterWrite(t *testing.T) {
  836. ctx := context.Background()
  837. rule := newPassthroughRuleForWritePathTest(1, "to be deleted", "删除前消息")
  838. repo := &mockErrorPassthroughRepo{rules: []*model.ErrorPassthroughRule{rule}}
  839. cache := newMockErrorPassthroughCache([]*model.ErrorPassthroughRule{rule}, true)
  840. svc := &ErrorPassthroughService{repo: repo, cache: cache}
  841. svc.setLocalCache([]*model.ErrorPassthroughRule{rule})
  842. err := svc.Delete(ctx, 1)
  843. require.NoError(t, err)
  844. body := []byte(`{"message":"to be deleted"}`)
  845. matched := svc.MatchRule("anthropic", 503, body)
  846. assert.Nil(t, matched, "删除后规则不应再命中")
  847. assert.Equal(t, 0, cache.getCalled, "写路径刷新不应依赖 cache.Get")
  848. assert.Equal(t, 1, cache.invalidateCalled)
  849. assert.Equal(t, 1, cache.setCalled)
  850. assert.Equal(t, 1, cache.notifyCalled)
  851. }
  852. func TestNewService_StartupReloadFromDBToHealStaleCache(t *testing.T) {
  853. staleRule := newPassthroughRuleForWritePathTest(99, "stale keyword", "旧缓存消息")
  854. latestRule := newPassthroughRuleForWritePathTest(1, "fresh keyword", "最新消息")
  855. repo := &mockErrorPassthroughRepo{rules: []*model.ErrorPassthroughRule{latestRule}}
  856. cache := newMockErrorPassthroughCache([]*model.ErrorPassthroughRule{staleRule}, true)
  857. svc := NewErrorPassthroughService(repo, cache)
  858. matchedFresh := svc.MatchRule("anthropic", 503, []byte(`{"message":"fresh keyword"}`))
  859. require.NotNil(t, matchedFresh)
  860. assert.Equal(t, int64(1), matchedFresh.ID)
  861. matchedStale := svc.MatchRule("anthropic", 503, []byte(`{"message":"stale keyword"}`))
  862. assert.Nil(t, matchedStale, "启动后应以 DB 最新规则覆盖旧缓存")
  863. assert.Equal(t, 0, cache.getCalled, "启动强制 DB 刷新不应依赖 cache.Get")
  864. assert.Equal(t, 1, cache.setCalled, "启动后应回写缓存,覆盖陈旧缓存")
  865. }
  866. func TestUpdate_RefreshFailureShouldNotKeepStaleEnabledRule(t *testing.T) {
  867. ctx := context.Background()
  868. staleRule := newPassthroughRuleForWritePathTest(1, "service temporarily unavailable after multiple", "旧缓存消息")
  869. repo := &mockErrorPassthroughRepo{
  870. rules: []*model.ErrorPassthroughRule{staleRule},
  871. listErr: errors.New("db list failed"),
  872. }
  873. cache := newMockErrorPassthroughCache([]*model.ErrorPassthroughRule{staleRule}, true)
  874. svc := &ErrorPassthroughService{repo: repo, cache: cache}
  875. svc.setLocalCache([]*model.ErrorPassthroughRule{staleRule})
  876. disabledRule := *staleRule
  877. disabledRule.Enabled = false
  878. _, err := svc.Update(ctx, &disabledRule)
  879. require.NoError(t, err)
  880. body := []byte(`{"message":"Service temporarily unavailable after multiple retries, please try again later"}`)
  881. matched := svc.MatchRule("anthropic", 503, body)
  882. assert.Nil(t, matched, "刷新失败时不应继续命中旧的启用规则")
  883. svc.localCacheMu.RLock()
  884. assert.Nil(t, svc.localCache, "刷新失败后应清空本地缓存,避免误命中")
  885. svc.localCacheMu.RUnlock()
  886. }
  887. func newPassthroughRuleForWritePathTest(id int64, keyword, customMsg string) *model.ErrorPassthroughRule {
  888. responseCode := 503
  889. rule := &model.ErrorPassthroughRule{
  890. ID: id,
  891. Name: "write-path-cache-refresh",
  892. Enabled: true,
  893. Priority: 1,
  894. ErrorCodes: []int{503},
  895. Keywords: []string{keyword},
  896. MatchMode: model.MatchModeAll,
  897. PassthroughCode: false,
  898. ResponseCode: &responseCode,
  899. PassthroughBody: false,
  900. CustomMessage: &customMsg,
  901. }
  902. return rule
  903. }
  904. // Helper functions
  905. func testIntPtr(i int) *int { return &i }
  906. func testStrPtr(s string) *string { return &s }