test(backend): 补充改动代码单元测试覆盖率至 85%+
新增 48 个测试用例覆盖修复代码的各分支路径: - subscription_maintenance_queue: nil receiver/task、Stop 幂等、零值参数 (+6) - billing_service: CalculateCostWithConfig、错误传播、SoraImageCost 等 (+12) - timing_wheel_service: Schedule/ScheduleRecurring after Stop (+3) - sora_media_cleanup_service: nil guard、Start/Stop 各分支、timezone (+10) - sora_gateway_service: normalizeSoraMediaURLs、buildSoraContent 等辅助函数 (+17) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -292,6 +292,133 @@ func TestCalculateCost_ZeroTokens(t *testing.T) {
|
|||||||
require.Equal(t, 0.0, cost.ActualCost)
|
require.Equal(t, 0.0, cost.ActualCost)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCalculateCostWithConfig(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Default.RateMultiplier = 1.5
|
||||||
|
svc := NewBillingService(cfg, nil)
|
||||||
|
|
||||||
|
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500}
|
||||||
|
cost, err := svc.CalculateCostWithConfig("claude-sonnet-4", tokens)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.5)
|
||||||
|
require.InDelta(t, expected.ActualCost, cost.ActualCost, 1e-10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateCostWithConfig_ZeroMultiplier(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Default.RateMultiplier = 0
|
||||||
|
svc := NewBillingService(cfg, nil)
|
||||||
|
|
||||||
|
tokens := UsageTokens{InputTokens: 1000}
|
||||||
|
cost, err := svc.CalculateCostWithConfig("claude-sonnet-4", tokens)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 倍率 <=0 时默认 1.0
|
||||||
|
expected, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||||
|
require.InDelta(t, expected.ActualCost, cost.ActualCost, 1e-10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEstimatedCost(t *testing.T) {
|
||||||
|
svc := newTestBillingService()
|
||||||
|
|
||||||
|
est, err := svc.GetEstimatedCost("claude-sonnet-4", 1000, 500)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, est > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSupportedModels(t *testing.T) {
|
||||||
|
svc := newTestBillingService()
|
||||||
|
|
||||||
|
models := svc.ListSupportedModels()
|
||||||
|
require.NotEmpty(t, models)
|
||||||
|
require.GreaterOrEqual(t, len(models), 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPricingServiceStatus_NilService(t *testing.T) {
|
||||||
|
svc := newTestBillingService()
|
||||||
|
|
||||||
|
status := svc.GetPricingServiceStatus()
|
||||||
|
require.NotNil(t, status)
|
||||||
|
require.Equal(t, "using fallback", status["last_updated"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForceUpdatePricing_NilService(t *testing.T) {
|
||||||
|
svc := newTestBillingService()
|
||||||
|
|
||||||
|
err := svc.ForceUpdatePricing()
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateSoraImageCost(t *testing.T) {
|
||||||
|
svc := newTestBillingService()
|
||||||
|
|
||||||
|
price360 := 0.05
|
||||||
|
price540 := 0.08
|
||||||
|
cfg := &SoraPriceConfig{ImagePrice360: &price360, ImagePrice540: &price540}
|
||||||
|
|
||||||
|
cost := svc.CalculateSoraImageCost("360", 2, cfg, 1.0)
|
||||||
|
require.InDelta(t, 0.10, cost.TotalCost, 1e-10)
|
||||||
|
|
||||||
|
cost540 := svc.CalculateSoraImageCost("540", 1, cfg, 2.0)
|
||||||
|
require.InDelta(t, 0.08, cost540.TotalCost, 1e-10)
|
||||||
|
require.InDelta(t, 0.16, cost540.ActualCost, 1e-10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateSoraImageCost_ZeroCount(t *testing.T) {
|
||||||
|
svc := newTestBillingService()
|
||||||
|
cost := svc.CalculateSoraImageCost("360", 0, nil, 1.0)
|
||||||
|
require.Equal(t, 0.0, cost.TotalCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateSoraVideoCost_NilConfig(t *testing.T) {
|
||||||
|
svc := newTestBillingService()
|
||||||
|
cost := svc.CalculateSoraVideoCost("sora-video", nil, 1.0)
|
||||||
|
require.Equal(t, 0.0, cost.TotalCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) {
|
||||||
|
// 使用空的 fallback prices 让 GetModelPricing 失败
|
||||||
|
svc := &BillingService{
|
||||||
|
cfg: &config.Config{},
|
||||||
|
fallbackPrices: make(map[string]*ModelPricing),
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0}
|
||||||
|
_, err := svc.CalculateCostWithLongContext("unknown-model", tokens, 1.0, 200000, 2.0)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "pricing not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) {
|
||||||
|
svc := &BillingService{
|
||||||
|
cfg: &config.Config{},
|
||||||
|
fallbackPrices: map[string]*ModelPricing{
|
||||||
|
"claude-sonnet-4": {
|
||||||
|
InputPricePerToken: 3e-6,
|
||||||
|
OutputPricePerToken: 15e-6,
|
||||||
|
SupportsCacheBreakdown: true,
|
||||||
|
CacheCreation5mPrice: 4.0, // per million tokens
|
||||||
|
CacheCreation1hPrice: 5.0, // per million tokens
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens := UsageTokens{
|
||||||
|
InputTokens: 1000,
|
||||||
|
OutputTokens: 500,
|
||||||
|
CacheCreation5mTokens: 100000,
|
||||||
|
CacheCreation1hTokens: 50000,
|
||||||
|
}
|
||||||
|
cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected5m := float64(100000) / 1_000_000 * 4.0
|
||||||
|
expected1h := float64(50000) / 1_000_000 * 5.0
|
||||||
|
require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCalculateCost_LargeTokenCount(t *testing.T) {
|
func TestCalculateCost_LargeTokenCount(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
svc := newTestBillingService()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -100,6 +101,150 @@ func TestSoraGatewayService_BuildSoraMediaURLSigned(t *testing.T) {
|
|||||||
require.Contains(t, url, "sig=")
|
require.Contains(t, url, "sig=")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSoraMediaURLs_Empty(t *testing.T) {
|
||||||
|
svc := NewSoraGatewayService(nil, nil, nil, &config.Config{})
|
||||||
|
result := svc.normalizeSoraMediaURLs(nil)
|
||||||
|
require.Empty(t, result)
|
||||||
|
|
||||||
|
result = svc.normalizeSoraMediaURLs([]string{})
|
||||||
|
require.Empty(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSoraMediaURLs_HTTPUrls(t *testing.T) {
|
||||||
|
svc := NewSoraGatewayService(nil, nil, nil, &config.Config{})
|
||||||
|
urls := []string{"https://example.com/a.png", "http://example.com/b.mp4"}
|
||||||
|
result := svc.normalizeSoraMediaURLs(urls)
|
||||||
|
require.Equal(t, urls, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSoraMediaURLs_LocalPaths(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
svc := NewSoraGatewayService(nil, nil, nil, cfg)
|
||||||
|
urls := []string{"/image/2025/01/a.png", "video/2025/01/b.mp4"}
|
||||||
|
result := svc.normalizeSoraMediaURLs(urls)
|
||||||
|
require.Len(t, result, 2)
|
||||||
|
require.Contains(t, result[0], "/sora/media")
|
||||||
|
require.Contains(t, result[1], "/sora/media")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSoraMediaURLs_SkipsBlank(t *testing.T) {
|
||||||
|
svc := NewSoraGatewayService(nil, nil, nil, &config.Config{})
|
||||||
|
urls := []string{"https://example.com/a.png", "", " ", "https://example.com/b.png"}
|
||||||
|
result := svc.normalizeSoraMediaURLs(urls)
|
||||||
|
require.Len(t, result, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSoraContent_Image(t *testing.T) {
|
||||||
|
content := buildSoraContent("image", []string{"https://a.com/1.png", "https://a.com/2.png"})
|
||||||
|
require.Contains(t, content, "")
|
||||||
|
require.Contains(t, content, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSoraContent_Video(t *testing.T) {
|
||||||
|
content := buildSoraContent("video", []string{"https://a.com/v.mp4"})
|
||||||
|
require.Contains(t, content, "<video src='https://a.com/v.mp4'")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSoraContent_VideoEmpty(t *testing.T) {
|
||||||
|
content := buildSoraContent("video", nil)
|
||||||
|
require.Empty(t, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSoraContent_Prompt(t *testing.T) {
|
||||||
|
content := buildSoraContent("prompt", nil)
|
||||||
|
require.Empty(t, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraImageSizeFromModel(t *testing.T) {
|
||||||
|
require.Equal(t, "360", soraImageSizeFromModel("gpt-image"))
|
||||||
|
require.Equal(t, "540", soraImageSizeFromModel("gpt-image-landscape"))
|
||||||
|
require.Equal(t, "540", soraImageSizeFromModel("gpt-image-portrait"))
|
||||||
|
require.Equal(t, "540", soraImageSizeFromModel("something-landscape"))
|
||||||
|
require.Equal(t, "360", soraImageSizeFromModel("unknown-model"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstMediaURL(t *testing.T) {
|
||||||
|
require.Equal(t, "", firstMediaURL(nil))
|
||||||
|
require.Equal(t, "", firstMediaURL([]string{}))
|
||||||
|
require.Equal(t, "a", firstMediaURL([]string{"a", "b"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraProErrorMessage(t *testing.T) {
|
||||||
|
require.Contains(t, soraProErrorMessage("sora2pro-hd", ""), "Pro-HD")
|
||||||
|
require.Contains(t, soraProErrorMessage("sora2pro", ""), "Pro")
|
||||||
|
require.Empty(t, soraProErrorMessage("sora-basic", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldFailoverUpstreamError(t *testing.T) {
|
||||||
|
svc := NewSoraGatewayService(nil, nil, nil, &config.Config{})
|
||||||
|
require.True(t, svc.shouldFailoverUpstreamError(401))
|
||||||
|
require.True(t, svc.shouldFailoverUpstreamError(429))
|
||||||
|
require.True(t, svc.shouldFailoverUpstreamError(500))
|
||||||
|
require.True(t, svc.shouldFailoverUpstreamError(502))
|
||||||
|
require.False(t, svc.shouldFailoverUpstreamError(200))
|
||||||
|
require.False(t, svc.shouldFailoverUpstreamError(400))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithSoraTimeout_NilService(t *testing.T) {
|
||||||
|
var svc *SoraGatewayService
|
||||||
|
ctx, cancel := svc.withSoraTimeout(context.Background(), false)
|
||||||
|
require.NotNil(t, ctx)
|
||||||
|
require.Nil(t, cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithSoraTimeout_ZeroTimeout(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
svc := NewSoraGatewayService(nil, nil, nil, cfg)
|
||||||
|
ctx, cancel := svc.withSoraTimeout(context.Background(), false)
|
||||||
|
require.NotNil(t, ctx)
|
||||||
|
require.Nil(t, cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithSoraTimeout_PositiveTimeout(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Gateway: config.GatewayConfig{
|
||||||
|
SoraRequestTimeoutSeconds: 30,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewSoraGatewayService(nil, nil, nil, cfg)
|
||||||
|
ctx, cancel := svc.withSoraTimeout(context.Background(), false)
|
||||||
|
require.NotNil(t, ctx)
|
||||||
|
require.NotNil(t, cancel)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollInterval(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Client: config.SoraClientConfig{
|
||||||
|
PollIntervalSeconds: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewSoraGatewayService(nil, nil, nil, cfg)
|
||||||
|
require.Equal(t, 5*time.Second, svc.pollInterval())
|
||||||
|
|
||||||
|
// 默认值
|
||||||
|
svc2 := NewSoraGatewayService(nil, nil, nil, &config.Config{})
|
||||||
|
require.True(t, svc2.pollInterval() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollMaxAttempts(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Client: config.SoraClientConfig{
|
||||||
|
MaxPollAttempts: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewSoraGatewayService(nil, nil, nil, cfg)
|
||||||
|
require.Equal(t, 100, svc.pollMaxAttempts())
|
||||||
|
|
||||||
|
// 默认值
|
||||||
|
svc2 := NewSoraGatewayService(nil, nil, nil, &config.Config{})
|
||||||
|
require.True(t, svc2.pollMaxAttempts() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDecodeSoraImageInput_BlockPrivateURL(t *testing.T) {
|
func TestDecodeSoraImageInput_BlockPrivateURL(t *testing.T) {
|
||||||
_, _, err := decodeSoraImageInput(context.Background(), "http://127.0.0.1/internal.png")
|
_, _, err := decodeSoraImageInput(context.Background(), "http://127.0.0.1/internal.png")
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
|||||||
@@ -12,6 +12,167 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_RunCleanup_NilCfg(t *testing.T) {
|
||||||
|
storage := &SoraMediaStorage{}
|
||||||
|
svc := &SoraMediaCleanupService{storage: storage, cfg: nil}
|
||||||
|
// 不应 panic
|
||||||
|
svc.runCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_RunCleanup_NilStorage(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
svc := &SoraMediaCleanupService{storage: nil, cfg: cfg}
|
||||||
|
// 不应 panic
|
||||||
|
svc.runCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_RunCleanup_ZeroRetention(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Storage: config.SoraStorageConfig{
|
||||||
|
Type: "local",
|
||||||
|
LocalPath: tmpDir,
|
||||||
|
Cleanup: config.SoraStorageCleanupConfig{
|
||||||
|
Enabled: true,
|
||||||
|
RetentionDays: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
storage := NewSoraMediaStorage(cfg)
|
||||||
|
svc := NewSoraMediaCleanupService(storage, cfg)
|
||||||
|
// retention=0 应跳过清理
|
||||||
|
svc.runCleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_Start_NilCfg(t *testing.T) {
|
||||||
|
svc := NewSoraMediaCleanupService(nil, nil)
|
||||||
|
svc.Start() // cfg == nil 时应直接返回
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_Start_StorageDisabled(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Storage: config.SoraStorageConfig{
|
||||||
|
Cleanup: config.SoraStorageCleanupConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewSoraMediaCleanupService(nil, cfg)
|
||||||
|
svc.Start() // storage == nil 时应直接返回
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_Start_WithTimezone(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Timezone: "Asia/Shanghai",
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Storage: config.SoraStorageConfig{
|
||||||
|
Type: "local",
|
||||||
|
LocalPath: tmpDir,
|
||||||
|
Cleanup: config.SoraStorageCleanupConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Schedule: "0 3 * * *",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
storage := NewSoraMediaStorage(cfg)
|
||||||
|
svc := NewSoraMediaCleanupService(storage, cfg)
|
||||||
|
svc.Start()
|
||||||
|
t.Cleanup(svc.Stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_Start_Disabled(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Storage: config.SoraStorageConfig{
|
||||||
|
Cleanup: config.SoraStorageCleanupConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewSoraMediaCleanupService(nil, cfg)
|
||||||
|
svc.Start() // 不应 panic,也不应启动 cron
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_Start_NilSelf(t *testing.T) {
|
||||||
|
var svc *SoraMediaCleanupService
|
||||||
|
svc.Start() // 不应 panic
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_Start_EmptySchedule(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Storage: config.SoraStorageConfig{
|
||||||
|
Type: "local",
|
||||||
|
LocalPath: tmpDir,
|
||||||
|
Cleanup: config.SoraStorageCleanupConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Schedule: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
storage := NewSoraMediaStorage(cfg)
|
||||||
|
svc := NewSoraMediaCleanupService(storage, cfg)
|
||||||
|
svc.Start() // 空 schedule 不应启动
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_Start_InvalidSchedule(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Storage: config.SoraStorageConfig{
|
||||||
|
Type: "local",
|
||||||
|
LocalPath: tmpDir,
|
||||||
|
Cleanup: config.SoraStorageCleanupConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Schedule: "invalid-cron",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
storage := NewSoraMediaStorage(cfg)
|
||||||
|
svc := NewSoraMediaCleanupService(storage, cfg)
|
||||||
|
svc.Start() // 无效 schedule 不应 panic
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_Start_ValidSchedule(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Storage: config.SoraStorageConfig{
|
||||||
|
Type: "local",
|
||||||
|
LocalPath: tmpDir,
|
||||||
|
Cleanup: config.SoraStorageCleanupConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Schedule: "0 3 * * *",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
storage := NewSoraMediaStorage(cfg)
|
||||||
|
svc := NewSoraMediaCleanupService(storage, cfg)
|
||||||
|
svc.Start()
|
||||||
|
t.Cleanup(svc.Stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_Stop_NilSelf(t *testing.T) {
|
||||||
|
var svc *SoraMediaCleanupService
|
||||||
|
svc.Stop() // 不应 panic
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraMediaCleanupService_Stop_WithoutStart(t *testing.T) {
|
||||||
|
svc := NewSoraMediaCleanupService(nil, &config.Config{})
|
||||||
|
svc.Stop() // cron 未启动时 Stop 不应 panic
|
||||||
|
}
|
||||||
|
|
||||||
func TestSoraMediaCleanupService_RunCleanup(t *testing.T) {
|
func TestSoraMediaCleanupService_RunCleanup(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -52,3 +53,81 @@ func TestSubscriptionMaintenanceQueue_TryEnqueue_PanicDoesNotKillWorker(t *testi
|
|||||||
t.Fatalf("worker did not continue after panic")
|
t.Fatalf("worker did not continue after panic")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionMaintenanceQueue_TryEnqueue_AfterStop(t *testing.T) {
|
||||||
|
q := NewSubscriptionMaintenanceQueue(1, 8)
|
||||||
|
q.Stop()
|
||||||
|
|
||||||
|
err := q.TryEnqueue(func() {})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionMaintenanceQueue_TryEnqueue_NilReceiver(t *testing.T) {
|
||||||
|
var q *SubscriptionMaintenanceQueue
|
||||||
|
err := q.TryEnqueue(func() {})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionMaintenanceQueue_TryEnqueue_NilTask(t *testing.T) {
|
||||||
|
q := NewSubscriptionMaintenanceQueue(1, 8)
|
||||||
|
t.Cleanup(q.Stop)
|
||||||
|
|
||||||
|
err := q.TryEnqueue(nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionMaintenanceQueue_Stop_NilReceiver(t *testing.T) {
|
||||||
|
var q *SubscriptionMaintenanceQueue
|
||||||
|
// 不应 panic
|
||||||
|
q.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionMaintenanceQueue_Stop_Idempotent(t *testing.T) {
|
||||||
|
q := NewSubscriptionMaintenanceQueue(1, 4)
|
||||||
|
q.Stop()
|
||||||
|
q.Stop() // 第二次调用不应 panic
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSubscriptionMaintenanceQueue_ZeroParams(t *testing.T) {
|
||||||
|
q := NewSubscriptionMaintenanceQueue(0, 0)
|
||||||
|
t.Cleanup(q.Stop)
|
||||||
|
|
||||||
|
// workerCount/queueSize 应被修正为 1
|
||||||
|
err := q.TryEnqueue(func() {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSubscriptionMaintenanceQueue_NegativeParams(t *testing.T) {
|
||||||
|
q := NewSubscriptionMaintenanceQueue(-1, -1)
|
||||||
|
t.Cleanup(q.Stop)
|
||||||
|
|
||||||
|
err := q.TryEnqueue(func() {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionMaintenanceQueue_ConcurrentEnqueueAndStop(t *testing.T) {
|
||||||
|
// 并发调用 TryEnqueue 和 Stop 不应 panic
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
q := NewSubscriptionMaintenanceQueue(2, 4)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 50; j++ {
|
||||||
|
_ = q.TryEnqueue(func() {})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
time.Sleep(time.Microsecond * time.Duration(i%10))
|
||||||
|
q.Stop()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,6 +119,41 @@ func TestTimingWheelService_Cancel_PreventsExecution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTimingWheelService_Schedule_AfterStop_LogsError(t *testing.T) {
|
||||||
|
svc, err := NewTimingWheelService()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("期望 err 为 nil,但得到: %v", err)
|
||||||
|
}
|
||||||
|
svc.Stop()
|
||||||
|
|
||||||
|
// Stop 后调用 Schedule 应走 error 日志路径,不应 panic
|
||||||
|
svc.Schedule("after-stop", 100*time.Millisecond, func() {
|
||||||
|
t.Fatal("不应被执行")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimingWheelService_ScheduleRecurring_AfterStop_LogsError(t *testing.T) {
|
||||||
|
svc, err := NewTimingWheelService()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("期望 err 为 nil,但得到: %v", err)
|
||||||
|
}
|
||||||
|
svc.Stop()
|
||||||
|
|
||||||
|
// Stop 后调用 ScheduleRecurring 应走 error 日志路径,不应 panic
|
||||||
|
svc.ScheduleRecurring("after-stop-rec", 100*time.Millisecond, func() {
|
||||||
|
t.Fatal("不应被执行")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimingWheelService_Stop_Idempotent(t *testing.T) {
|
||||||
|
svc, err := NewTimingWheelService()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("期望 err 为 nil,但得到: %v", err)
|
||||||
|
}
|
||||||
|
svc.Stop()
|
||||||
|
svc.Stop() // 第二次调用不应 panic
|
||||||
|
}
|
||||||
|
|
||||||
func TestTimingWheelService_ScheduleRecurring_ExecutesMultipleTimes(t *testing.T) {
|
func TestTimingWheelService_ScheduleRecurring_ExecutesMultipleTimes(t *testing.T) {
|
||||||
original := newTimingWheel
|
original := newTimingWheel
|
||||||
t.Cleanup(func() { newTimingWheel = original })
|
t.Cleanup(func() { newTimingWheel = original })
|
||||||
|
|||||||
Reference in New Issue
Block a user