Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 

775 rader
28 KiB

  1. #!/usr/bin/env python3
  2. """Write test implementation plan files for Phase 1-5."""
  3. import os
  4. BASE = r"D:\code\new-api\docs\superpowers\plans"
  5. def write_file(name, content):
  6. path = os.path.join(BASE, name)
  7. with open(path, 'w', encoding='utf-8') as f:
  8. f.write(content)
  9. lines = content.count('\n')
  10. print(f" Written: {name} ({lines} lines)")
  11. def main():
  12. # Phase 1
  13. write_file("phase1-model-tests.md", PHASE1)
  14. # Phase 2
  15. write_file("phase2-service-billing.md", PHASE2)
  16. # Phase 3
  17. write_file("phase3-middleware.md", PHASE3)
  18. # Phase 4
  19. write_file("phase4-openai-adaptor.md", PHASE4)
  20. # Phase 5
  21. write_file("phase5-e2e.md", PHASE5)
  22. print("All plan files written.")
  23. # ─── Phase 1 ───────────────────────────────────────────────────────────────
  24. PHASE1 = r"""# Phase 1: Model 层核心 CRUD 测试 实施计划
  25. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
  26. **Goal:** 为 model/ 目录下的 user, token, channel, ability, utils 添加约 73 个集成测试
  27. **Architecture:** SQLite 内存 DB + 真实业务逻辑,testutil.SetupTestDB 初始化,表驱动测试优先。
  28. **Tech Stack:** Go, github.com/glebarez/sqlite, github.com/stretchr/testify, gorm.io/gorm
  29. **Pre-requisites:** Phase 0 (testutil) 已完成
  30. ---
  31. ## 通用模式
  32. 所有 model 测试共享 import 和 DB setup:
  33. ```go
  34. import (
  35. "fmt"
  36. "sync"
  37. "testing"
  38. "time"
  39. "github.com/QuantumNous/new-api/common"
  40. "github.com/QuantumNous/new-api/model"
  41. "github.com/QuantumNous/new-api/testutil"
  42. "github.com/stretchr/testify/assert"
  43. "github.com/stretchr/testify/require"
  44. )
  45. func setupModelTestDB(t *testing.T) *gorm.DB {
  46. t.Helper()
  47. return testutil.SetupTestDB(t,
  48. &model.User{}, &model.Token{}, &model.Channel{},
  49. &model.Ability{}, &model.Log{}, &model.Model{},
  50. )
  51. }
  52. ```
  53. ---
  54. ## Task 1: model/user_test.go (22 tests)
  55. - [ ] Write TestUserInsert: normal registration, verify hashed password, defaults, aff code
  56. - [ ] Write TestUserInsert_DuplicateUsername: unique constraint error
  57. - [ ] Write TestUserValidateAndFill: table-driven (correct/wrong/not-found/disabled)
  58. - [ ] Write TestIncreaseUserQuota / TestDecreaseUserQuota: atomic quota operations
  59. - [ ] Write TestDecreaseUserQuota_Insufficient: behavior when quota goes negative
  60. - [ ] Write TestAtomicDecreaseSyncedQuota: table-driven (sufficient/exact/insufficient/threshold/zero)
  61. - [ ] Write TestUserInsert_WithInviter: verify inviter AffCount increment
  62. - [ ] Write TestUserInsert_WithInviter_Concurrent: 10 goroutines, verify known race condition
  63. - [ ] Write TestTransferAffQuotaToQuota / _Insufficient / _Concurrent: FOR UPDATE lock verification
  64. - [ ] Write P2 tests: Update, Edit, Delete, HardDelete, GetAllUsers, SearchUsers
  65. - [ ] Run: `go test -v -run "TestUser" ./model/ -count=1`
  66. - [ ] Commit: `git commit -m "test: add user model integration tests (22 tests)"`
  67. ### Key Test Code
  68. ```go
  69. func TestUserInsert(t *testing.T) {
  70. db := setupModelTestDB(t)
  71. _ = db
  72. user := &model.User{Username: "testuser", Password: "password123"}
  73. err := user.Insert(0)
  74. require.NoError(t, err)
  75. assert.Greater(t, user.Id, 0)
  76. assert.NotEqual(t, "password123", user.Password)
  77. assert.Equal(t, common.RoleCommonUser, user.Role)
  78. assert.NotEmpty(t, user.AffCode)
  79. }
  80. ```
  81. ```go
  82. func TestAtomicDecreaseSyncedQuota(t *testing.T) {
  83. db := setupModelTestDB(t)
  84. _ = db
  85. tests := []struct{ name string; syncedQuota, amount, threshold int; wantOk bool }{
  86. {"sufficient", 1000, 300, 0, true},
  87. {"exactly_zero", 300, 300, 0, true},
  88. {"below_threshold", 500, 300, 300, false},
  89. {"insufficient", 100, 200, 0, false},
  90. {"zero_quota", 0, 100, 0, false},
  91. }
  92. for _, tt := range tests {
  93. t.Run(tt.name, func(t *testing.T) {
  94. user := testutil.SeedUser(t, db, func(u *model.User) {
  95. u.Source = "synced"; u.SyncedQuota = tt.syncedQuota
  96. })
  97. model.DB.Model(user).Update("synced_quota", tt.syncedQuota)
  98. balance, ok, err := model.AtomicDecreaseSyncedQuota(user.Id, tt.amount, tt.threshold)
  99. require.NoError(t, err)
  100. assert.Equal(t, tt.wantOk, ok)
  101. if tt.wantOk { assert.Equal(t, tt.syncedQuota-tt.amount, balance) }
  102. })
  103. }
  104. }
  105. ```
  106. ---
  107. ## Task 2: model/token_test.go (16 tests)
  108. - [ ] Write TestValidateUserToken: table-driven (enabled/exhausted/expired/disabled)
  109. - [ ] Write TestValidateUserToken_NotFound
  110. - [ ] Write TestIncreaseTokenQuota / TestDecreaseTokenQuota
  111. - [ ] Write TestTokenInsert / TestTokenUpdate / TestTokenDelete
  112. - [ ] Write TestBatchDeleteTokens
  113. - [ ] Write P2: GetAllUserTokens_Pagination, CountUserTokens, TokenSelectUpdate
  114. - [ ] Run: `go test -v -run "TestToken|TestValidate|TestIncrease|TestDecrease|TestBatch" ./model/ -count=1`
  115. - [ ] Commit: `git commit -m "test: add token model integration tests (16 tests)"`
  116. ### Key Test Code
  117. ```go
  118. func TestValidateUserToken(t *testing.T) {
  119. db := setupModelTestDB(t)
  120. _ = db
  121. tests := []struct {
  122. name string; setupToken func() *model.Token; wantErr bool; errContains string
  123. }{
  124. {"enabled", func() *model.Token {
  125. return testutil.SeedToken(t, db, 0, func(tok *model.Token) {
  126. tok.Status = common.TokenStatusEnabled; tok.RemainQuota = 1000; tok.ExpiredTime = -1
  127. })
  128. }, false, ""},
  129. {"exhausted", func() *model.Token {
  130. return testutil.SeedToken(t, db, 0, func(tok *model.Token) {
  131. tok.Status = common.TokenStatusExhausted
  132. })
  133. }, true, "耗尽"},
  134. {"expired", func() *model.Token {
  135. return testutil.SeedToken(t, db, 0, func(tok *model.Token) {
  136. tok.Status = common.TokenStatusEnabled; tok.ExpiredTime = time.Now().Unix() - 3600
  137. })
  138. }, true, "过期"},
  139. {"disabled", func() *model.Token {
  140. return testutil.SeedToken(t, db, 0, func(tok *model.Token) {
  141. tok.Status = common.TokenStatusDisabled
  142. })
  143. }, true, ""},
  144. }
  145. for _, tt := range tests {
  146. t.Run(tt.name, func(t *testing.T) {
  147. token := tt.setupToken()
  148. found, err := model.ValidateUserToken(token.Key)
  149. if tt.wantErr { assert.Error(t, err) } else {
  150. assert.NoError(t, err); assert.Equal(t, token.Id, found.Id)
  151. }
  152. })
  153. }
  154. }
  155. ```
  156. ---
  157. ## Task 3: model/channel_test.go (17 tests)
  158. - [ ] Write TestChannelInsert: verify Ability auto-generation
  159. - [ ] Write TestChannelDelete: verify Ability cleanup
  160. - [ ] Write TestUpdateChannelStatus_SingleKey: enable/disable cycle
  161. - [ ] Write TestUpdateChannelStatus_MultiKey_AllDisabled: auto AutoDisabled
  162. - [ ] Write TestUpdateChannelStatus_MultiKey_PartialDisabled
  163. - [ ] Write TestGetNextEnabledKey_Random / _Polling
  164. - [ ] Write P1: ChannelUpdate_AbilityRebuild, BatchInsertChannels, BatchDeleteChannels, EnableChannelByTag, EditChannelByTag
  165. - [ ] Write P2: ChannelSave, GetAllChannels, SearchChannels, DisableChannelByTag, DeleteChannelByStatus
  166. - [ ] Run: `go test -v -run "TestChannel|TestUpdateChannel|TestGetNext|TestBatch" ./model/ -count=1`
  167. - [ ] Commit: `git commit -m "test: add channel model integration tests (17 tests)"`
  168. ### Key Test Code
  169. ```go
  170. func TestChannelInsert(t *testing.T) {
  171. db := setupModelTestDB(t)
  172. _ = db
  173. channel := testutil.SeedChannel(t, db)
  174. assert.Greater(t, channel.Id, 0)
  175. abilities, err := model.GetAbilitiesByChannelId(channel.Id)
  176. require.NoError(t, err)
  177. assert.GreaterOrEqual(t, len(abilities), 2) // gpt-4 + gpt-3.5-turbo
  178. }
  179. func TestUpdateChannelStatus_MultiKey_AllDisabled(t *testing.T) {
  180. db := setupModelTestDB(t)
  181. _ = db
  182. keys := []string{"key1", "key2", "key3"}
  183. channel := testutil.SeedChannel(t, db, func(ch *model.Channel) {
  184. ch.Keys = keys
  185. ch.ChannelInfo = model.ChannelInfo{
  186. IsMultiKey: true, MultiKeySize: 3,
  187. MultiKeyStatusList: map[int]int{0: 1, 1: 1, 2: 1},
  188. }
  189. })
  190. for i, key := range keys {
  191. model.UpdateChannelStatus(channel.Id, key, common.ChannelStatusManuallyDisabled, fmt.Sprintf("key %d", i))
  192. }
  193. found, _ := model.GetChannelById(channel.Id, true)
  194. assert.Equal(t, common.ChannelStatusAutoDisabled, found.Status)
  195. }
  196. ```
  197. ---
  198. ## Task 4: model/ability_test.go (9 tests)
  199. - [ ] Write TestGetChannel_WeightedRandom: 100 iterations, higher weight selected more
  200. - [ ] Write TestGetChannel_PriorityFallback: retry=0 high priority, retry=1 fallback
  201. - [ ] Write TestAddAbilities_ModelSync: verify Model table auto-sync
  202. - [ ] Write P1: UpdateAbilities, FixAbility, UpdateAbilityStatus
  203. - [ ] Write P2: GetGroupEnabledModels, GetAllEnableAbilities, GetAbilitiesByChannelId
  204. - [ ] Run + Commit
  205. ---
  206. ## Task 5: model/utils_test.go (6 tests)
  207. - [ ] Write TestAddNewRecord_Concurrent: 100 goroutines no panic
  208. - [ ] Write TestBatchUpdate_Flush: addNewRecord + batchUpdate + verify DB
  209. - [ ] Write TestBatchUpdate_MultipleTypes: user+token+used quota types
  210. - [ ] Write TestBatchUpdate_EmptyMap: no data no SQL
  211. - [ ] Write TestShouldUpdateRedis / TestRecordExist
  212. - [ ] Run + Commit
  213. ---
  214. ## Task 6: Final Verification
  215. - [ ] `go test -v ./model/ -count=1` — all pass
  216. - [ ] `go test -race ./model/ -count=1` — no race conditions
  217. """
  218. # ─── Phase 2 ───────────────────────────────────────────────────────────────
  219. PHASE2 = r"""# Phase 2: Service 层计费系统测试 实施计划
  220. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
  221. **Goal:** 为计费系统(funding_source, billing_session, billing, quota)添加约 55 个测试
  222. **Architecture:** SQLite 内存 DB + 真实业务逻辑。需要构造 RelayInfo 和 gin.Context。BillingSession 使用真实 FundingSource 实现。
  223. **Tech Stack:** Go, github.com/glebarez/sqlite, github.com/stretchr/testify, gorm.io/gorm, github.com/gin-gonic/gin
  224. **Pre-requisites:** Phase 0 (testutil) + Phase 1 (model tests) 已完成
  225. ---
  226. ## 通用 setup
  227. ```go
  228. import (
  229. "testing"
  230. "github.com/QuantumNous/new-api/common"
  231. "github.com/QuantumNous/new-api/model"
  232. "github.com/QuantumNous/new-api/service"
  233. "github.com/QuantumNous/new-api/testutil"
  234. relaycommon "github.com/QuantumNous/new-api/relay/common"
  235. "github.com/gin-gonic/gin"
  236. "github.com/stretchr/testify/assert"
  237. "github.com/stretchr/testify/require"
  238. )
  239. func setupBillingTestDB(t *testing.T) *gorm.DB {
  240. t.Helper()
  241. return testutil.SetupTestDB(t,
  242. &model.User{}, &model.Token{}, &model.Channel{},
  243. &model.Ability{}, &model.Log{}, &model.Model{},
  244. &model.UserSubscription{}, &model.SubscriptionPlan{},
  245. )
  246. }
  247. ```
  248. ---
  249. ## Task 1: service/funding_source_test.go (15 tests)
  250. - [ ] Write WalletFunding tests (8): Source, PreConsume, PreConsume_Zero, Settle_Positive, Settle_Negative, Settle_Zero, Refund, Refund_NotConsumed
  251. - [ ] Write SubscriptionFunding tests (7): Source, PreConsume, PreConsume_IgnoresAmount, Settle_Positive, Settle_Negative, Refund, Refund_Retry
  252. - [ ] Run: `go test -v -run "TestWalletFunding|TestSubscriptionFunding" ./service/ -count=1`
  253. - [ ] Commit: `git commit -m "test: add funding source tests (15 tests)"`
  254. ### Key Test Code
  255. ```go
  256. func TestWalletFunding_PreConsume(t *testing.T) {
  257. db := setupBillingTestDB(t)
  258. _ = db
  259. user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 10000 })
  260. w := service.NewWalletFunding(user.Id) // 或构造函数
  261. err := w.PreConsume(1000)
  262. require.NoError(t, err)
  263. testutil.AssertQuotaEquals(t, db, user.Id, 9000)
  264. }
  265. func TestWalletFunding_Refund(t *testing.T) {
  266. db := setupBillingTestDB(t)
  267. _ = db
  268. user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 10000 })
  269. w := service.NewWalletFunding(user.Id)
  270. _ = w.PreConsume(1000)
  271. err := w.Refund()
  272. require.NoError(t, err)
  273. testutil.AssertQuotaEquals(t, db, user.Id, 10000) // 全额退回
  274. }
  275. func TestSubscriptionFunding_PreConsume_IgnoresAmount(t *testing.T) {
  276. db := setupBillingTestDB(t)
  277. _ = db
  278. user := testutil.SeedUser(t, db)
  279. sub := testutil.SeedSubscription(t, db, user.Id)
  280. s := service.NewSubscriptionFunding("req-123", user.Id, "gpt-4", 5000, sub.Id)
  281. // 传入 9999 但应该被忽略
  282. err := s.PreConsume(9999)
  283. require.NoError(t, err)
  284. assert.Equal(t, "subscription", s.Source())
  285. }
  286. ```
  287. Note: `NewWalletFunding` / `NewSubscriptionFunding` 构造函数签名需根据实际代码调整。如果这些类型不是导出的,测试需要在 service 包内。
  288. ---
  289. ## Task 2: service/billing_session_test.go (25 tests)
  290. - [ ] Write PreConsume tests (12): 4 preference paths, trust bypass, force pre-consume, subscription no bypass, funding fail rollback
  291. - [ ] Write Settle tests (6): zero/positive/negative delta, idempotent, wallet/subscription full flow
  292. - [ ] Write Refund tests (4): full refund, idempotent, after settle skipped, zero consumed skipped
  293. - [ ] Write Lifecycle tests (3): normal PreConsume->Settle, failure PreConsume->Refund, concurrent
  294. - [ ] Run: `go test -v -run "TestNewBillingSession|TestSettle|TestRefund|TestBillingSession_Lifecycle" ./service/ -count=1`
  295. - [ ] Commit: `git commit -m "test: add billing session tests (25 tests)"`
  296. ### Key Test Code
  297. ```go
  298. func TestNewBillingSession_WalletOnly(t *testing.T) {
  299. db := setupBillingTestDB(t)
  300. _ = db
  301. user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 100000 })
  302. token := testutil.SeedToken(t, db, user.Id, func(tok *model.Token) {
  303. tok.UnlimitedQuota = true
  304. })
  305. c, _ := testutil.NewTestGinContext("POST", "/v1/chat/completions", nil, nil)
  306. relayInfo := &relaycommon.RelayInfo{
  307. UserId: user.Id, TokenId: token.Id, TokenKey: token.Key,
  308. UserGroup: "default", UsingGroup: "default",
  309. }
  310. session, apiErr := service.NewBillingSession(c, relayInfo, 1000)
  311. require.Nil(t, apiErr)
  312. assert.NotNil(t, session)
  313. assert.Equal(t, 1000, session.GetPreConsumedQuota())
  314. }
  315. ```
  316. Note: `BillingSession` 的构造需要设置用户计费偏好 (`billing_preference`)。可能需要 mock 或设置 `common.NormalizeBillingPreference` 返回值。具体实现取决于代码中偏好设置的读取方式。
  317. ---
  318. ## Task 3: service/billing_test.go (5 tests)
  319. - [ ] Write SettleBilling_WithBillingSession / _WithoutBillingSession
  320. - [ ] Write PreConsumeBilling_AssignsRelayInfo
  321. - [ ] Write SettleBilling_NotifiesQuota / _ZeroActualQuota
  322. - [ ] Run + Commit
  323. ---
  324. ## Task 4: service/quota_test.go (5 tests)
  325. - [ ] Write PostConsumeQuota_Wallet / _Subscription / _NegativeQuota
  326. - [ ] Write PreConsumeTokenQuota_Success / _Insufficient
  327. - [ ] Run + Commit
  328. ---
  329. ## Task 5: Final Verification
  330. - [ ] `go test -v ./service/ -count=1` — all pass
  331. """
  332. # ─── Phase 3 ───────────────────────────────────────────────────────────────
  333. PHASE3 = r"""# Phase 3: Middleware 层测试 实施计划
  334. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
  335. **Goal:** 为 middleware/ 目录下的 auth, rate-limit, distributor 添加约 55 个测试
  336. **Architecture:** gin.TestMode + httptest.NewRecorder 构造 Context。需要 mock model 层全局函数(通过 DB 种子数据或设置全局变量)。
  337. **Tech Stack:** Go, github.com/gin-gonic/gin, net/http/httptest, github.com/stretchr/testify
  338. **Pre-requisites:** Phase 0-1 已完成
  339. ---
  340. ## Task 1: middleware/auth_test.go (20 tests)
  341. - [ ] Write TokenAuth tests (11): BearerToken, MissingAuth, InvalidKey, DisabledToken, ExpiredToken, ExhaustedToken, WithChannelId, SetsContextCorrectly, DisabledUser, GroupNotInRatio, IPWhitelist
  342. - [ ] Write MultiProtocol tests (4): AnthropicXApiKey, GeminiQueryParam, GeminiHeader, WebSocketProtocol
  343. - [ ] Write authHelper tests (5): UserAuth_Valid, AdminAuth_NonAdmin, RootAuth_NonRoot, TokenOrUserAuth_Fallback
  344. - [ ] Run: `go test -v -run "TestTokenAuth|TestUserAuth|TestAdminAuth|TestRootAuth" ./middleware/ -count=1`
  345. - [ ] Commit
  346. ### Key Pattern
  347. ```go
  348. func setupAuthTest(t *testing.T, token *model.Token, user *model.User) (*gin.Context, *httptest.ResponseRecorder) {
  349. t.Helper()
  350. gin.SetMode(gin.TestMode)
  351. w := httptest.NewRecorder()
  352. c, _ := gin.CreateTestContext(w)
  353. c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
  354. if token != nil {
  355. c.Request.Header.Set("Authorization", "Bearer "+token.Key)
  356. }
  357. return c, w
  358. }
  359. ```
  360. Note: TokenAuth 内部直接调用 `model.ValidateUserToken` 等全局函数。测试需要设置真实的 DB 数据(通过 testutil 种子数据),让这些函数正常工作。
  361. ---
  362. ## Task 2: middleware/rate_limit_test.go (15 tests)
  363. - [ ] Write GlobalAPIRateLimit tests (3): within/over/window expires
  364. - [ ] Write CriticalRateLimit, MemoryMode, ConcurrentRequests
  365. - [ ] Write ModelRateLimit tests (4): within/over/bygroup/success only
  366. - [ ] Write other rate limit tests (5): email, search, upload, download, web
  367. - [ ] Run + Commit
  368. ### Key Pattern
  369. ```go
  370. func TestRateLimit_MemoryMode(t *testing.T) {
  371. common.RedisEnabled = false
  372. defer func() { common.RedisEnabled = true }()
  373. // 设置限流参数
  374. common.GlobalApiRateLimitNum = 3
  375. common.GlobalApiRateLimitDuration = 60
  376. defer func() {
  377. common.GlobalApiRateLimitNum = 180
  378. common.GlobalApiRateLimitDuration = 60
  379. }()
  380. handler := middleware.GlobalAPIRateLimit()
  381. for i := 0; i < 3; i++ {
  382. c, w := testutil.NewTestGinContext("GET", "/api/test", nil, nil)
  383. handler(c)
  384. assert.Equal(t, 200, w.Code) // 或者 c.IsAborted() == false
  385. }
  386. // 第4次应被限流
  387. c, w := testutil.NewTestGinContext("GET", "/api/test", nil, nil)
  388. handler(c)
  389. assert.True(t, c.IsAborted())
  390. }
  391. ```
  392. ---
  393. ## Task 3: middleware/distributor_test.go (20 tests)
  394. - [ ] Write getModelRequest tests (9): ChatCompletions, Embeddings, Images, AudioSpeech, AudioTranscription, Rerank, Responses, GeminiNative, Realtime
  395. - [ ] Write Distribute tests (8): SpecificChannel, Disabled, ModelNotSupported, RandomSelection, AffinityReuse, NoAvailableChannel, TokenModelLimit, Allowed
  396. - [ ] Write helper tests (3): CORS, RequestId, Recover
  397. - [ ] Run + Commit
  398. ### Key Pattern
  399. ```go
  400. func TestGetModelRequest_ChatCompletions(t *testing.T) {
  401. gin.SetMode(gin.TestMode)
  402. w := httptest.NewRecorder()
  403. c, _ := gin.CreateTestContext(w)
  404. body := strings.NewReader(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)
  405. c.Request = httptest.NewRequest("POST", "/v1/chat/completions", body)
  406. c.Request.Header.Set("Content-Type", "application/json")
  407. req, shouldSelect, err := getModelRequest(c)
  408. require.NoError(t, err)
  409. assert.True(t, shouldSelect)
  410. assert.Equal(t, "gpt-4", req.Model)
  411. }
  412. ```
  413. ---
  414. ## Task 4: Final Verification
  415. - [ ] `go test -v ./middleware/ -count=1`
  416. """
  417. # ─── Phase 4 ───────────────────────────────────────────────────────────────
  418. PHASE4 = r"""# Phase 4: OpenAI 适配器测试 实施计划
  419. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
  420. **Goal:** 为 relay/channel/openai/ 添加约 55 个测试
  421. **Architecture:** 纯逻辑函数直接测试。HTTP handler 使用 httptest.NewServer mock 上游。不需要数据库。
  422. **Tech Stack:** Go, github.com/gin-gonic/gin, net/http/httptest, github.com/stretchr/testify
  423. **Pre-requisites:** Phase 0 已完成(使用 testutil.NewTestGinContext)
  424. ---
  425. ## Task 1: relay/channel/openai/adaptor_test.go (15 tests)
  426. - [ ] Write parseReasoningEffortFromModelSuffix tests (table-driven)
  427. - [ ] Write detectImageMimeType tests (table-driven)
  428. - [ ] Write Init, GetRequestURL (default/azure/custom), SetupRequestHeader (bearer/azure/org)
  429. - [ ] Write ConvertOpenAIRequest tests (6): MaxCompletionTokens, TemperatureCleared, SystemToDeveloper, ReasoningEffort, NormalModel, OpenRouter
  430. - [ ] Run + Commit
  431. ### Key Test Code
  432. ```go
  433. func TestParseReasoningEffortFromModelSuffix(t *testing.T) {
  434. tests := []struct{ model, wantEffort, wantOrigin string }{
  435. {"o3-mini:low", "low", "o3-mini"},
  436. {"o3-mini:high", "high", "o3-mini"},
  437. {"o3-mini:medium", "medium", "o3-mini"},
  438. {"o3-mini", "", "o3-mini"},
  439. {"gpt-4", "", "gpt-4"},
  440. }
  441. for _, tt := range tests {
  442. t.Run(tt.model, func(t *testing.T) {
  443. effort, origin := parseReasoningEffortFromModelSuffix(tt.model)
  444. assert.Equal(t, tt.wantEffort, effort)
  445. assert.Equal(t, tt.wantOrigin, origin)
  446. })
  447. }
  448. }
  449. func TestDetectImageMimeType(t *testing.T) {
  450. tests := []struct{ filename, want string }{
  451. {"photo.png", "image/png"},
  452. {"photo.jpg", "image/jpeg"},
  453. {"photo.jpeg", "image/jpeg"},
  454. {"photo.webp", "image/webp"},
  455. {"photo.gif", "image/png"}, // fallback
  456. }
  457. for _, tt := range tests {
  458. t.Run(tt.filename, func(t *testing.T) {
  459. assert.Equal(t, tt.want, detectImageMimeType(tt.filename))
  460. })
  461. }
  462. }
  463. ```
  464. ---
  465. ## Task 2: relay/channel/openai/relay_openai_test.go (20 tests)
  466. - [ ] Write OpenaiHandler tests (5): Success, WithCacheTokens, ErrorResponse, NoPromptTokens, ContentFilter
  467. - [ ] Write OaiStreamHandler tests (4): BasicStream, UsageExtraction, ThinkingContent, DoneSignal
  468. - [ ] Write applyUsagePostProcessing tests (6): DeepSeek, Zhipu, Moonshot, NoProvider, ExtractCachedTokens, ExtractMoonshot
  469. - [ ] Write special handler tests (5): ImageResponse, TTS, STT, FormatConversion_Claude, FormatConversion_Gemini
  470. - [ ] Run + Commit
  471. ### Key Pattern — HTTP Mock
  472. ```go
  473. func mockOpenAIResponse(t *testing.T, body string) *http.Response {
  474. return &http.Response{
  475. StatusCode: 200,
  476. Body: io.NopCloser(strings.NewReader(body)),
  477. Header: http.Header{"Content-Type": []string{"application/json"}},
  478. }
  479. }
  480. func TestOpenaiHandler_Success(t *testing.T) {
  481. gin.SetMode(gin.TestMode)
  482. c, _ := testutil.NewTestGinContext("POST", "/v1/chat/completions", nil, nil)
  483. respBody := `{"id":"chatcmpl-123","object":"chat.completion","model":"gpt-4",` +
  484. `"choices":[{"message":{"role":"assistant","content":"hello"}}],` +
  485. `"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`
  486. resp := mockOpenAIResponse(t, respBody)
  487. info := &relaycommon.RelayInfo{
  488. IsStream: false, RelayMode: relayconstant.RelayModeChatCompletions,
  489. UpstreamModelName: "gpt-4",
  490. }
  491. usage, apiErr := OpenaiHandler(c, info, resp)
  492. assert.Nil(t, apiErr)
  493. require.NotNil(t, usage)
  494. assert.Equal(t, 10, usage.PromptTokens)
  495. assert.Equal(t, 5, usage.CompletionTokens)
  496. }
  497. ```
  498. ---
  499. ## Task 3: relay/channel/openai/helper_test.go (10 tests)
  500. - [ ] Write ProcessStreamResponse, processTokens, handleLastResponse tests
  501. - [ ] Write HandleStreamFormat_OpenAI, HandleStreamFormat_Claude tests
  502. - [ ] Run + Commit
  503. ---
  504. ## Task 4: relay/channel/openai/chat_via_responses_test.go (5 tests)
  505. - [ ] Write stringDeltaFromPrefix tests (table-driven)
  506. - [ ] Write responsesStreamIndexKey tests
  507. - [ ] Write OaiResponsesToChatHandler, OaiResponsesToChatStreamHandler, ToolCalls tests
  508. - [ ] Run + Commit
  509. ### Key Test Code
  510. ```go
  511. func TestStringDeltaFromPrefix(t *testing.T) {
  512. tests := []struct{ prev, next, want string }{
  513. {"", "hello", "hello"},
  514. {"hel", "hello", "lo"},
  515. {"hello", "hello", ""},
  516. {"abc", "xyz", "xyz"}, // no prefix match
  517. }
  518. for _, tt := range tests {
  519. got := stringDeltaFromPrefix(tt.prev, tt.next)
  520. assert.Equal(t, tt.want, got)
  521. }
  522. }
  523. ```
  524. ---
  525. ## Task 5: relay/channel/openai/audio_test.go (5 tests)
  526. - [ ] Write TTS and STT handler tests with mock HTTP responses
  527. - [ ] Run + Commit
  528. ---
  529. ## Task 6: Final Verification
  530. - [ ] `go test -v ./relay/channel/openai/ -count=1`
  531. """
  532. # ─── Phase 5 ───────────────────────────────────────────────────────────────
  533. PHASE5 = r"""# Phase 5: 端到端集成测试 实施计划
  534. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
  535. **Goal:** 编写 20 个端到端集成测试,验证完整请求生命周期
  536. **Architecture:** 完整 middleware chain (TokenAuth -> Distribute -> Relay) + mock 上游 HTTP 服务器。使用 SQLite 内存 DB。
  537. **Tech Stack:** Go, github.com/glebarez/sqlite, github.com/gin-gonic/gin, net/http/httptest
  538. **Pre-requisites:** Phase 0-4 全部完成
  539. ---
  540. ## Task 1: test/e2e/billing_lifecycle_test.go (20 tests)
  541. - [ ] Write TestE2E_ChatRequest_WalletBilling: full lifecycle with wallet
  542. - [ ] Write TestE2E_ChatRequest_SubscriptionBilling: full lifecycle with subscription
  543. - [ ] Write TestE2E_ChatRequest_UpstreamError_Refund: upstream failure triggers refund
  544. - [ ] Write TestE2E_StreamingRequest: SSE stream with token counting
  545. - [ ] Write TestE2E_QuotaExceeded: insufficient quota returns 429
  546. - [ ] Write TestE2E_TokenExpired: expired token returns 401
  547. - [ ] Write TestE2E_ChannelFailover: channel failure triggers retry
  548. - [ ] Write TestE2E_ConcurrentRequests: concurrent quota accuracy
  549. - [ ] Write TestE2E_FreeModel_NoBilling: free model skips billing
  550. - [ ] Write TestE2E_PerCallBilling: per-call billing model
  551. - [ ] Write remaining 10 tests (Embeddings, Images, Audio, Rerank, Responses, Claude/Gemini format, TrustQuota, MultiChannel, Affinity)
  552. - [ ] Run: `go test -v ./test/e2e/ -count=1`
  553. - [ ] Commit
  554. ### Key Pattern — E2E Test Setup
  555. ```go
  556. func setupE2ETest(t *testing.T) (*gin.Engine, *httptest.Server, *gorm.DB) {
  557. t.Helper()
  558. db := testutil.SetupTestDB(t,
  559. &model.User{}, &model.Token{}, &model.Channel{},
  560. &model.Ability{}, &model.Log{}, &model.Model{},
  561. &model.UserSubscription{}, &model.SubscriptionPlan{},
  562. )
  563. // Mock upstream OpenAI server
  564. upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  565. w.Header().Set("Content-Type", "application/json")
  566. w.WriteHeader(200)
  567. json.NewEncoder(w).Encode(map[string]any{
  568. "id": "chatcmpl-test", "object": "chat.completion",
  569. "model": "gpt-4",
  570. "choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "hi"}}},
  571. "usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
  572. })
  573. }))
  574. t.Cleanup(upstream.Close)
  575. // Setup router with real middleware chain
  576. gin.SetMode(gin.TestMode)
  577. router := gin.New()
  578. // ... register middleware + routes
  579. return router, upstream, db
  580. }
  581. func TestE2E_ChatRequest_WalletBilling(t *testing.T) {
  582. router, upstream, db := setupE2ETest(t)
  583. _ = upstream
  584. user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 1000000 })
  585. token := testutil.SeedToken(t, db, user.Id, func(tok *model.Token) {
  586. tok.UnlimitedQuota = true
  587. })
  588. initialQuota := user.Quota
  589. // Make request
  590. body := `{"model":"gpt-4","messages":[{"role":"user","content":"hello"}]}`
  591. req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
  592. req.Header.Set("Authorization", "Bearer "+token.Key)
  593. req.Header.Set("Content-Type", "application/json")
  594. w := httptest.NewRecorder()
  595. router.ServeHTTP(w, req)
  596. assert.Equal(t, 200, w.Code)
  597. testutil.AssertQuotaEquals(t, db, user.Id, initialQuota-expectedCost)
  598. }
  599. ```
  600. Note: E2E tests are the most complex. The exact setup depends on how the router is configured. The key is to:
  601. 1. Create a real gin.Engine with the actual middleware chain
  602. 2. Replace the upstream HTTP call with a mock server
  603. 3. Verify the full request/response cycle and quota changes
  604. ---
  605. ## Task 2: Final Verification
  606. - [ ] `go test -v ./test/e2e/ -count=1`
  607. - [ ] `go test -v ./... -count=1` — full suite
  608. """
  609. if __name__ == "__main__":
  610. main()