|
- #!/usr/bin/env python3
- """Write test implementation plan files for Phase 1-5."""
- import os
-
- BASE = r"D:\code\new-api\docs\superpowers\plans"
-
- def write_file(name, content):
- path = os.path.join(BASE, name)
- with open(path, 'w', encoding='utf-8') as f:
- f.write(content)
- lines = content.count('\n')
- print(f" Written: {name} ({lines} lines)")
-
- def main():
- # Phase 1
- write_file("phase1-model-tests.md", PHASE1)
- # Phase 2
- write_file("phase2-service-billing.md", PHASE2)
- # Phase 3
- write_file("phase3-middleware.md", PHASE3)
- # Phase 4
- write_file("phase4-openai-adaptor.md", PHASE4)
- # Phase 5
- write_file("phase5-e2e.md", PHASE5)
- print("All plan files written.")
-
- # ─── Phase 1 ───────────────────────────────────────────────────────────────
- PHASE1 = r"""# Phase 1: Model 层核心 CRUD 测试 实施计划
-
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
-
- **Goal:** 为 model/ 目录下的 user, token, channel, ability, utils 添加约 73 个集成测试
-
- **Architecture:** SQLite 内存 DB + 真实业务逻辑,testutil.SetupTestDB 初始化,表驱动测试优先。
-
- **Tech Stack:** Go, github.com/glebarez/sqlite, github.com/stretchr/testify, gorm.io/gorm
-
- **Pre-requisites:** Phase 0 (testutil) 已完成
-
- ---
-
- ## 通用模式
-
- 所有 model 测试共享 import 和 DB setup:
-
- ```go
- import (
- "fmt"
- "sync"
- "testing"
- "time"
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/model"
- "github.com/QuantumNous/new-api/testutil"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
-
- func setupModelTestDB(t *testing.T) *gorm.DB {
- t.Helper()
- return testutil.SetupTestDB(t,
- &model.User{}, &model.Token{}, &model.Channel{},
- &model.Ability{}, &model.Log{}, &model.Model{},
- )
- }
- ```
-
- ---
-
- ## Task 1: model/user_test.go (22 tests)
-
- - [ ] Write TestUserInsert: normal registration, verify hashed password, defaults, aff code
- - [ ] Write TestUserInsert_DuplicateUsername: unique constraint error
- - [ ] Write TestUserValidateAndFill: table-driven (correct/wrong/not-found/disabled)
- - [ ] Write TestIncreaseUserQuota / TestDecreaseUserQuota: atomic quota operations
- - [ ] Write TestDecreaseUserQuota_Insufficient: behavior when quota goes negative
- - [ ] Write TestAtomicDecreaseSyncedQuota: table-driven (sufficient/exact/insufficient/threshold/zero)
- - [ ] Write TestUserInsert_WithInviter: verify inviter AffCount increment
- - [ ] Write TestUserInsert_WithInviter_Concurrent: 10 goroutines, verify known race condition
- - [ ] Write TestTransferAffQuotaToQuota / _Insufficient / _Concurrent: FOR UPDATE lock verification
- - [ ] Write P2 tests: Update, Edit, Delete, HardDelete, GetAllUsers, SearchUsers
- - [ ] Run: `go test -v -run "TestUser" ./model/ -count=1`
- - [ ] Commit: `git commit -m "test: add user model integration tests (22 tests)"`
-
- ### Key Test Code
-
- ```go
- func TestUserInsert(t *testing.T) {
- db := setupModelTestDB(t)
- _ = db
- user := &model.User{Username: "testuser", Password: "password123"}
- err := user.Insert(0)
- require.NoError(t, err)
- assert.Greater(t, user.Id, 0)
- assert.NotEqual(t, "password123", user.Password)
- assert.Equal(t, common.RoleCommonUser, user.Role)
- assert.NotEmpty(t, user.AffCode)
- }
- ```
-
- ```go
- func TestAtomicDecreaseSyncedQuota(t *testing.T) {
- db := setupModelTestDB(t)
- _ = db
- tests := []struct{ name string; syncedQuota, amount, threshold int; wantOk bool }{
- {"sufficient", 1000, 300, 0, true},
- {"exactly_zero", 300, 300, 0, true},
- {"below_threshold", 500, 300, 300, false},
- {"insufficient", 100, 200, 0, false},
- {"zero_quota", 0, 100, 0, false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- user := testutil.SeedUser(t, db, func(u *model.User) {
- u.Source = "synced"; u.SyncedQuota = tt.syncedQuota
- })
- model.DB.Model(user).Update("synced_quota", tt.syncedQuota)
- balance, ok, err := model.AtomicDecreaseSyncedQuota(user.Id, tt.amount, tt.threshold)
- require.NoError(t, err)
- assert.Equal(t, tt.wantOk, ok)
- if tt.wantOk { assert.Equal(t, tt.syncedQuota-tt.amount, balance) }
- })
- }
- }
- ```
-
- ---
-
- ## Task 2: model/token_test.go (16 tests)
-
- - [ ] Write TestValidateUserToken: table-driven (enabled/exhausted/expired/disabled)
- - [ ] Write TestValidateUserToken_NotFound
- - [ ] Write TestIncreaseTokenQuota / TestDecreaseTokenQuota
- - [ ] Write TestTokenInsert / TestTokenUpdate / TestTokenDelete
- - [ ] Write TestBatchDeleteTokens
- - [ ] Write P2: GetAllUserTokens_Pagination, CountUserTokens, TokenSelectUpdate
- - [ ] Run: `go test -v -run "TestToken|TestValidate|TestIncrease|TestDecrease|TestBatch" ./model/ -count=1`
- - [ ] Commit: `git commit -m "test: add token model integration tests (16 tests)"`
-
- ### Key Test Code
-
- ```go
- func TestValidateUserToken(t *testing.T) {
- db := setupModelTestDB(t)
- _ = db
- tests := []struct {
- name string; setupToken func() *model.Token; wantErr bool; errContains string
- }{
- {"enabled", func() *model.Token {
- return testutil.SeedToken(t, db, 0, func(tok *model.Token) {
- tok.Status = common.TokenStatusEnabled; tok.RemainQuota = 1000; tok.ExpiredTime = -1
- })
- }, false, ""},
- {"exhausted", func() *model.Token {
- return testutil.SeedToken(t, db, 0, func(tok *model.Token) {
- tok.Status = common.TokenStatusExhausted
- })
- }, true, "耗尽"},
- {"expired", func() *model.Token {
- return testutil.SeedToken(t, db, 0, func(tok *model.Token) {
- tok.Status = common.TokenStatusEnabled; tok.ExpiredTime = time.Now().Unix() - 3600
- })
- }, true, "过期"},
- {"disabled", func() *model.Token {
- return testutil.SeedToken(t, db, 0, func(tok *model.Token) {
- tok.Status = common.TokenStatusDisabled
- })
- }, true, ""},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- token := tt.setupToken()
- found, err := model.ValidateUserToken(token.Key)
- if tt.wantErr { assert.Error(t, err) } else {
- assert.NoError(t, err); assert.Equal(t, token.Id, found.Id)
- }
- })
- }
- }
- ```
-
- ---
-
- ## Task 3: model/channel_test.go (17 tests)
-
- - [ ] Write TestChannelInsert: verify Ability auto-generation
- - [ ] Write TestChannelDelete: verify Ability cleanup
- - [ ] Write TestUpdateChannelStatus_SingleKey: enable/disable cycle
- - [ ] Write TestUpdateChannelStatus_MultiKey_AllDisabled: auto AutoDisabled
- - [ ] Write TestUpdateChannelStatus_MultiKey_PartialDisabled
- - [ ] Write TestGetNextEnabledKey_Random / _Polling
- - [ ] Write P1: ChannelUpdate_AbilityRebuild, BatchInsertChannels, BatchDeleteChannels, EnableChannelByTag, EditChannelByTag
- - [ ] Write P2: ChannelSave, GetAllChannels, SearchChannels, DisableChannelByTag, DeleteChannelByStatus
- - [ ] Run: `go test -v -run "TestChannel|TestUpdateChannel|TestGetNext|TestBatch" ./model/ -count=1`
- - [ ] Commit: `git commit -m "test: add channel model integration tests (17 tests)"`
-
- ### Key Test Code
-
- ```go
- func TestChannelInsert(t *testing.T) {
- db := setupModelTestDB(t)
- _ = db
- channel := testutil.SeedChannel(t, db)
- assert.Greater(t, channel.Id, 0)
- abilities, err := model.GetAbilitiesByChannelId(channel.Id)
- require.NoError(t, err)
- assert.GreaterOrEqual(t, len(abilities), 2) // gpt-4 + gpt-3.5-turbo
- }
-
- func TestUpdateChannelStatus_MultiKey_AllDisabled(t *testing.T) {
- db := setupModelTestDB(t)
- _ = db
- keys := []string{"key1", "key2", "key3"}
- channel := testutil.SeedChannel(t, db, func(ch *model.Channel) {
- ch.Keys = keys
- ch.ChannelInfo = model.ChannelInfo{
- IsMultiKey: true, MultiKeySize: 3,
- MultiKeyStatusList: map[int]int{0: 1, 1: 1, 2: 1},
- }
- })
- for i, key := range keys {
- model.UpdateChannelStatus(channel.Id, key, common.ChannelStatusManuallyDisabled, fmt.Sprintf("key %d", i))
- }
- found, _ := model.GetChannelById(channel.Id, true)
- assert.Equal(t, common.ChannelStatusAutoDisabled, found.Status)
- }
- ```
-
- ---
-
- ## Task 4: model/ability_test.go (9 tests)
-
- - [ ] Write TestGetChannel_WeightedRandom: 100 iterations, higher weight selected more
- - [ ] Write TestGetChannel_PriorityFallback: retry=0 high priority, retry=1 fallback
- - [ ] Write TestAddAbilities_ModelSync: verify Model table auto-sync
- - [ ] Write P1: UpdateAbilities, FixAbility, UpdateAbilityStatus
- - [ ] Write P2: GetGroupEnabledModels, GetAllEnableAbilities, GetAbilitiesByChannelId
- - [ ] Run + Commit
-
- ---
-
- ## Task 5: model/utils_test.go (6 tests)
-
- - [ ] Write TestAddNewRecord_Concurrent: 100 goroutines no panic
- - [ ] Write TestBatchUpdate_Flush: addNewRecord + batchUpdate + verify DB
- - [ ] Write TestBatchUpdate_MultipleTypes: user+token+used quota types
- - [ ] Write TestBatchUpdate_EmptyMap: no data no SQL
- - [ ] Write TestShouldUpdateRedis / TestRecordExist
- - [ ] Run + Commit
-
- ---
-
- ## Task 6: Final Verification
-
- - [ ] `go test -v ./model/ -count=1` — all pass
- - [ ] `go test -race ./model/ -count=1` — no race conditions
- """
-
- # ─── Phase 2 ───────────────────────────────────────────────────────────────
- PHASE2 = r"""# Phase 2: Service 层计费系统测试 实施计划
-
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
-
- **Goal:** 为计费系统(funding_source, billing_session, billing, quota)添加约 55 个测试
-
- **Architecture:** SQLite 内存 DB + 真实业务逻辑。需要构造 RelayInfo 和 gin.Context。BillingSession 使用真实 FundingSource 实现。
-
- **Tech Stack:** Go, github.com/glebarez/sqlite, github.com/stretchr/testify, gorm.io/gorm, github.com/gin-gonic/gin
-
- **Pre-requisites:** Phase 0 (testutil) + Phase 1 (model tests) 已完成
-
- ---
-
- ## 通用 setup
-
- ```go
- import (
- "testing"
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/model"
- "github.com/QuantumNous/new-api/service"
- "github.com/QuantumNous/new-api/testutil"
- relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
-
- func setupBillingTestDB(t *testing.T) *gorm.DB {
- t.Helper()
- return testutil.SetupTestDB(t,
- &model.User{}, &model.Token{}, &model.Channel{},
- &model.Ability{}, &model.Log{}, &model.Model{},
- &model.UserSubscription{}, &model.SubscriptionPlan{},
- )
- }
- ```
-
- ---
-
- ## Task 1: service/funding_source_test.go (15 tests)
-
- - [ ] Write WalletFunding tests (8): Source, PreConsume, PreConsume_Zero, Settle_Positive, Settle_Negative, Settle_Zero, Refund, Refund_NotConsumed
- - [ ] Write SubscriptionFunding tests (7): Source, PreConsume, PreConsume_IgnoresAmount, Settle_Positive, Settle_Negative, Refund, Refund_Retry
- - [ ] Run: `go test -v -run "TestWalletFunding|TestSubscriptionFunding" ./service/ -count=1`
- - [ ] Commit: `git commit -m "test: add funding source tests (15 tests)"`
-
- ### Key Test Code
-
- ```go
- func TestWalletFunding_PreConsume(t *testing.T) {
- db := setupBillingTestDB(t)
- _ = db
- user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 10000 })
-
- w := service.NewWalletFunding(user.Id) // 或构造函数
- err := w.PreConsume(1000)
- require.NoError(t, err)
- testutil.AssertQuotaEquals(t, db, user.Id, 9000)
- }
-
- func TestWalletFunding_Refund(t *testing.T) {
- db := setupBillingTestDB(t)
- _ = db
- user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 10000 })
-
- w := service.NewWalletFunding(user.Id)
- _ = w.PreConsume(1000)
- err := w.Refund()
- require.NoError(t, err)
- testutil.AssertQuotaEquals(t, db, user.Id, 10000) // 全额退回
- }
-
- func TestSubscriptionFunding_PreConsume_IgnoresAmount(t *testing.T) {
- db := setupBillingTestDB(t)
- _ = db
- user := testutil.SeedUser(t, db)
- sub := testutil.SeedSubscription(t, db, user.Id)
-
- s := service.NewSubscriptionFunding("req-123", user.Id, "gpt-4", 5000, sub.Id)
- // 传入 9999 但应该被忽略
- err := s.PreConsume(9999)
- require.NoError(t, err)
- assert.Equal(t, "subscription", s.Source())
- }
- ```
-
- Note: `NewWalletFunding` / `NewSubscriptionFunding` 构造函数签名需根据实际代码调整。如果这些类型不是导出的,测试需要在 service 包内。
-
- ---
-
- ## Task 2: service/billing_session_test.go (25 tests)
-
- - [ ] Write PreConsume tests (12): 4 preference paths, trust bypass, force pre-consume, subscription no bypass, funding fail rollback
- - [ ] Write Settle tests (6): zero/positive/negative delta, idempotent, wallet/subscription full flow
- - [ ] Write Refund tests (4): full refund, idempotent, after settle skipped, zero consumed skipped
- - [ ] Write Lifecycle tests (3): normal PreConsume->Settle, failure PreConsume->Refund, concurrent
- - [ ] Run: `go test -v -run "TestNewBillingSession|TestSettle|TestRefund|TestBillingSession_Lifecycle" ./service/ -count=1`
- - [ ] Commit: `git commit -m "test: add billing session tests (25 tests)"`
-
- ### Key Test Code
-
- ```go
- func TestNewBillingSession_WalletOnly(t *testing.T) {
- db := setupBillingTestDB(t)
- _ = db
- user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 100000 })
- token := testutil.SeedToken(t, db, user.Id, func(tok *model.Token) {
- tok.UnlimitedQuota = true
- })
-
- c, _ := testutil.NewTestGinContext("POST", "/v1/chat/completions", nil, nil)
- relayInfo := &relaycommon.RelayInfo{
- UserId: user.Id, TokenId: token.Id, TokenKey: token.Key,
- UserGroup: "default", UsingGroup: "default",
- }
-
- session, apiErr := service.NewBillingSession(c, relayInfo, 1000)
- require.Nil(t, apiErr)
- assert.NotNil(t, session)
- assert.Equal(t, 1000, session.GetPreConsumedQuota())
- }
- ```
-
- Note: `BillingSession` 的构造需要设置用户计费偏好 (`billing_preference`)。可能需要 mock 或设置 `common.NormalizeBillingPreference` 返回值。具体实现取决于代码中偏好设置的读取方式。
-
- ---
-
- ## Task 3: service/billing_test.go (5 tests)
-
- - [ ] Write SettleBilling_WithBillingSession / _WithoutBillingSession
- - [ ] Write PreConsumeBilling_AssignsRelayInfo
- - [ ] Write SettleBilling_NotifiesQuota / _ZeroActualQuota
- - [ ] Run + Commit
-
- ---
-
- ## Task 4: service/quota_test.go (5 tests)
-
- - [ ] Write PostConsumeQuota_Wallet / _Subscription / _NegativeQuota
- - [ ] Write PreConsumeTokenQuota_Success / _Insufficient
- - [ ] Run + Commit
-
- ---
-
- ## Task 5: Final Verification
-
- - [ ] `go test -v ./service/ -count=1` — all pass
- """
-
- # ─── Phase 3 ───────────────────────────────────────────────────────────────
- PHASE3 = r"""# Phase 3: Middleware 层测试 实施计划
-
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
-
- **Goal:** 为 middleware/ 目录下的 auth, rate-limit, distributor 添加约 55 个测试
-
- **Architecture:** gin.TestMode + httptest.NewRecorder 构造 Context。需要 mock model 层全局函数(通过 DB 种子数据或设置全局变量)。
-
- **Tech Stack:** Go, github.com/gin-gonic/gin, net/http/httptest, github.com/stretchr/testify
-
- **Pre-requisites:** Phase 0-1 已完成
-
- ---
-
- ## Task 1: middleware/auth_test.go (20 tests)
-
- - [ ] Write TokenAuth tests (11): BearerToken, MissingAuth, InvalidKey, DisabledToken, ExpiredToken, ExhaustedToken, WithChannelId, SetsContextCorrectly, DisabledUser, GroupNotInRatio, IPWhitelist
- - [ ] Write MultiProtocol tests (4): AnthropicXApiKey, GeminiQueryParam, GeminiHeader, WebSocketProtocol
- - [ ] Write authHelper tests (5): UserAuth_Valid, AdminAuth_NonAdmin, RootAuth_NonRoot, TokenOrUserAuth_Fallback
- - [ ] Run: `go test -v -run "TestTokenAuth|TestUserAuth|TestAdminAuth|TestRootAuth" ./middleware/ -count=1`
- - [ ] Commit
-
- ### Key Pattern
-
- ```go
- func setupAuthTest(t *testing.T, token *model.Token, user *model.User) (*gin.Context, *httptest.ResponseRecorder) {
- t.Helper()
- gin.SetMode(gin.TestMode)
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
- if token != nil {
- c.Request.Header.Set("Authorization", "Bearer "+token.Key)
- }
- return c, w
- }
- ```
-
- Note: TokenAuth 内部直接调用 `model.ValidateUserToken` 等全局函数。测试需要设置真实的 DB 数据(通过 testutil 种子数据),让这些函数正常工作。
-
- ---
-
- ## Task 2: middleware/rate_limit_test.go (15 tests)
-
- - [ ] Write GlobalAPIRateLimit tests (3): within/over/window expires
- - [ ] Write CriticalRateLimit, MemoryMode, ConcurrentRequests
- - [ ] Write ModelRateLimit tests (4): within/over/bygroup/success only
- - [ ] Write other rate limit tests (5): email, search, upload, download, web
- - [ ] Run + Commit
-
- ### Key Pattern
-
- ```go
- func TestRateLimit_MemoryMode(t *testing.T) {
- common.RedisEnabled = false
- defer func() { common.RedisEnabled = true }()
-
- // 设置限流参数
- common.GlobalApiRateLimitNum = 3
- common.GlobalApiRateLimitDuration = 60
- defer func() {
- common.GlobalApiRateLimitNum = 180
- common.GlobalApiRateLimitDuration = 60
- }()
-
- handler := middleware.GlobalAPIRateLimit()
- for i := 0; i < 3; i++ {
- c, w := testutil.NewTestGinContext("GET", "/api/test", nil, nil)
- handler(c)
- assert.Equal(t, 200, w.Code) // 或者 c.IsAborted() == false
- }
- // 第4次应被限流
- c, w := testutil.NewTestGinContext("GET", "/api/test", nil, nil)
- handler(c)
- assert.True(t, c.IsAborted())
- }
- ```
-
- ---
-
- ## Task 3: middleware/distributor_test.go (20 tests)
-
- - [ ] Write getModelRequest tests (9): ChatCompletions, Embeddings, Images, AudioSpeech, AudioTranscription, Rerank, Responses, GeminiNative, Realtime
- - [ ] Write Distribute tests (8): SpecificChannel, Disabled, ModelNotSupported, RandomSelection, AffinityReuse, NoAvailableChannel, TokenModelLimit, Allowed
- - [ ] Write helper tests (3): CORS, RequestId, Recover
- - [ ] Run + Commit
-
- ### Key Pattern
-
- ```go
- func TestGetModelRequest_ChatCompletions(t *testing.T) {
- gin.SetMode(gin.TestMode)
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- body := strings.NewReader(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)
- c.Request = httptest.NewRequest("POST", "/v1/chat/completions", body)
- c.Request.Header.Set("Content-Type", "application/json")
-
- req, shouldSelect, err := getModelRequest(c)
- require.NoError(t, err)
- assert.True(t, shouldSelect)
- assert.Equal(t, "gpt-4", req.Model)
- }
- ```
-
- ---
-
- ## Task 4: Final Verification
-
- - [ ] `go test -v ./middleware/ -count=1`
- """
-
- # ─── Phase 4 ───────────────────────────────────────────────────────────────
- PHASE4 = r"""# Phase 4: OpenAI 适配器测试 实施计划
-
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
-
- **Goal:** 为 relay/channel/openai/ 添加约 55 个测试
-
- **Architecture:** 纯逻辑函数直接测试。HTTP handler 使用 httptest.NewServer mock 上游。不需要数据库。
-
- **Tech Stack:** Go, github.com/gin-gonic/gin, net/http/httptest, github.com/stretchr/testify
-
- **Pre-requisites:** Phase 0 已完成(使用 testutil.NewTestGinContext)
-
- ---
-
- ## Task 1: relay/channel/openai/adaptor_test.go (15 tests)
-
- - [ ] Write parseReasoningEffortFromModelSuffix tests (table-driven)
- - [ ] Write detectImageMimeType tests (table-driven)
- - [ ] Write Init, GetRequestURL (default/azure/custom), SetupRequestHeader (bearer/azure/org)
- - [ ] Write ConvertOpenAIRequest tests (6): MaxCompletionTokens, TemperatureCleared, SystemToDeveloper, ReasoningEffort, NormalModel, OpenRouter
- - [ ] Run + Commit
-
- ### Key Test Code
-
- ```go
- func TestParseReasoningEffortFromModelSuffix(t *testing.T) {
- tests := []struct{ model, wantEffort, wantOrigin string }{
- {"o3-mini:low", "low", "o3-mini"},
- {"o3-mini:high", "high", "o3-mini"},
- {"o3-mini:medium", "medium", "o3-mini"},
- {"o3-mini", "", "o3-mini"},
- {"gpt-4", "", "gpt-4"},
- }
- for _, tt := range tests {
- t.Run(tt.model, func(t *testing.T) {
- effort, origin := parseReasoningEffortFromModelSuffix(tt.model)
- assert.Equal(t, tt.wantEffort, effort)
- assert.Equal(t, tt.wantOrigin, origin)
- })
- }
- }
-
- func TestDetectImageMimeType(t *testing.T) {
- tests := []struct{ filename, want string }{
- {"photo.png", "image/png"},
- {"photo.jpg", "image/jpeg"},
- {"photo.jpeg", "image/jpeg"},
- {"photo.webp", "image/webp"},
- {"photo.gif", "image/png"}, // fallback
- }
- for _, tt := range tests {
- t.Run(tt.filename, func(t *testing.T) {
- assert.Equal(t, tt.want, detectImageMimeType(tt.filename))
- })
- }
- }
- ```
-
- ---
-
- ## Task 2: relay/channel/openai/relay_openai_test.go (20 tests)
-
- - [ ] Write OpenaiHandler tests (5): Success, WithCacheTokens, ErrorResponse, NoPromptTokens, ContentFilter
- - [ ] Write OaiStreamHandler tests (4): BasicStream, UsageExtraction, ThinkingContent, DoneSignal
- - [ ] Write applyUsagePostProcessing tests (6): DeepSeek, Zhipu, Moonshot, NoProvider, ExtractCachedTokens, ExtractMoonshot
- - [ ] Write special handler tests (5): ImageResponse, TTS, STT, FormatConversion_Claude, FormatConversion_Gemini
- - [ ] Run + Commit
-
- ### Key Pattern — HTTP Mock
-
- ```go
- func mockOpenAIResponse(t *testing.T, body string) *http.Response {
- return &http.Response{
- StatusCode: 200,
- Body: io.NopCloser(strings.NewReader(body)),
- Header: http.Header{"Content-Type": []string{"application/json"}},
- }
- }
-
- func TestOpenaiHandler_Success(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := testutil.NewTestGinContext("POST", "/v1/chat/completions", nil, nil)
-
- respBody := `{"id":"chatcmpl-123","object":"chat.completion","model":"gpt-4",` +
- `"choices":[{"message":{"role":"assistant","content":"hello"}}],` +
- `"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`
- resp := mockOpenAIResponse(t, respBody)
-
- info := &relaycommon.RelayInfo{
- IsStream: false, RelayMode: relayconstant.RelayModeChatCompletions,
- UpstreamModelName: "gpt-4",
- }
-
- usage, apiErr := OpenaiHandler(c, info, resp)
- assert.Nil(t, apiErr)
- require.NotNil(t, usage)
- assert.Equal(t, 10, usage.PromptTokens)
- assert.Equal(t, 5, usage.CompletionTokens)
- }
- ```
-
- ---
-
- ## Task 3: relay/channel/openai/helper_test.go (10 tests)
-
- - [ ] Write ProcessStreamResponse, processTokens, handleLastResponse tests
- - [ ] Write HandleStreamFormat_OpenAI, HandleStreamFormat_Claude tests
- - [ ] Run + Commit
-
- ---
-
- ## Task 4: relay/channel/openai/chat_via_responses_test.go (5 tests)
-
- - [ ] Write stringDeltaFromPrefix tests (table-driven)
- - [ ] Write responsesStreamIndexKey tests
- - [ ] Write OaiResponsesToChatHandler, OaiResponsesToChatStreamHandler, ToolCalls tests
- - [ ] Run + Commit
-
- ### Key Test Code
-
- ```go
- func TestStringDeltaFromPrefix(t *testing.T) {
- tests := []struct{ prev, next, want string }{
- {"", "hello", "hello"},
- {"hel", "hello", "lo"},
- {"hello", "hello", ""},
- {"abc", "xyz", "xyz"}, // no prefix match
- }
- for _, tt := range tests {
- got := stringDeltaFromPrefix(tt.prev, tt.next)
- assert.Equal(t, tt.want, got)
- }
- }
- ```
-
- ---
-
- ## Task 5: relay/channel/openai/audio_test.go (5 tests)
-
- - [ ] Write TTS and STT handler tests with mock HTTP responses
- - [ ] Run + Commit
-
- ---
-
- ## Task 6: Final Verification
-
- - [ ] `go test -v ./relay/channel/openai/ -count=1`
- """
-
- # ─── Phase 5 ───────────────────────────────────────────────────────────────
- PHASE5 = r"""# Phase 5: 端到端集成测试 实施计划
-
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
-
- **Goal:** 编写 20 个端到端集成测试,验证完整请求生命周期
-
- **Architecture:** 完整 middleware chain (TokenAuth -> Distribute -> Relay) + mock 上游 HTTP 服务器。使用 SQLite 内存 DB。
-
- **Tech Stack:** Go, github.com/glebarez/sqlite, github.com/gin-gonic/gin, net/http/httptest
-
- **Pre-requisites:** Phase 0-4 全部完成
-
- ---
-
- ## Task 1: test/e2e/billing_lifecycle_test.go (20 tests)
-
- - [ ] Write TestE2E_ChatRequest_WalletBilling: full lifecycle with wallet
- - [ ] Write TestE2E_ChatRequest_SubscriptionBilling: full lifecycle with subscription
- - [ ] Write TestE2E_ChatRequest_UpstreamError_Refund: upstream failure triggers refund
- - [ ] Write TestE2E_StreamingRequest: SSE stream with token counting
- - [ ] Write TestE2E_QuotaExceeded: insufficient quota returns 429
- - [ ] Write TestE2E_TokenExpired: expired token returns 401
- - [ ] Write TestE2E_ChannelFailover: channel failure triggers retry
- - [ ] Write TestE2E_ConcurrentRequests: concurrent quota accuracy
- - [ ] Write TestE2E_FreeModel_NoBilling: free model skips billing
- - [ ] Write TestE2E_PerCallBilling: per-call billing model
- - [ ] Write remaining 10 tests (Embeddings, Images, Audio, Rerank, Responses, Claude/Gemini format, TrustQuota, MultiChannel, Affinity)
- - [ ] Run: `go test -v ./test/e2e/ -count=1`
- - [ ] Commit
-
- ### Key Pattern — E2E Test Setup
-
- ```go
- func setupE2ETest(t *testing.T) (*gin.Engine, *httptest.Server, *gorm.DB) {
- t.Helper()
- db := testutil.SetupTestDB(t,
- &model.User{}, &model.Token{}, &model.Channel{},
- &model.Ability{}, &model.Log{}, &model.Model{},
- &model.UserSubscription{}, &model.SubscriptionPlan{},
- )
-
- // Mock upstream OpenAI server
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(200)
- json.NewEncoder(w).Encode(map[string]any{
- "id": "chatcmpl-test", "object": "chat.completion",
- "model": "gpt-4",
- "choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "hi"}}},
- "usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
- })
- }))
- t.Cleanup(upstream.Close)
-
- // Setup router with real middleware chain
- gin.SetMode(gin.TestMode)
- router := gin.New()
- // ... register middleware + routes
-
- return router, upstream, db
- }
-
- func TestE2E_ChatRequest_WalletBilling(t *testing.T) {
- router, upstream, db := setupE2ETest(t)
- _ = upstream
-
- user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 1000000 })
- token := testutil.SeedToken(t, db, user.Id, func(tok *model.Token) {
- tok.UnlimitedQuota = true
- })
- initialQuota := user.Quota
-
- // Make request
- body := `{"model":"gpt-4","messages":[{"role":"user","content":"hello"}]}`
- req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
- req.Header.Set("Authorization", "Bearer "+token.Key)
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, 200, w.Code)
- testutil.AssertQuotaEquals(t, db, user.Id, initialQuota-expectedCost)
- }
- ```
-
- Note: E2E tests are the most complex. The exact setup depends on how the router is configured. The key is to:
- 1. Create a real gin.Engine with the actual middleware chain
- 2. Replace the upstream HTTP call with a mock server
- 3. Verify the full request/response cycle and quota changes
-
- ---
-
- ## Task 2: Final Verification
-
- - [ ] `go test -v ./test/e2e/ -count=1`
- - [ ] `go test -v ./... -count=1` — full suite
- """
-
- if __name__ == "__main__":
- main()
|