test: add tests for prommetrics, common routes, and Sora admin page
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

- Add prommetrics package tests (12 tests covering all metric functions)
- Add routes/common_test.go with health check, readiness, liveness tests
- Add SoraAdminView.spec.ts with 11 component tests
This commit is contained in:
User
2026-04-16 13:04:03 +08:00
parent c4007afe6b
commit c9992af876
3 changed files with 605 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
package prommetrics
import (
"testing"
"time"
)
func TestRegistryNotNil(t *testing.T) {
if Registry == nil {
t.Fatal("Registry should not be nil")
}
}
func TestSetDBConnections(t *testing.T) {
// Should not panic
SetDBConnections(10, 5)
SetDBConnections(0, 0)
}
func TestSetRedisConnections(t *testing.T) {
// Should not panic
SetRedisConnections(20, 8)
SetRedisConnections(0, 0)
}
func TestSetActiveAccounts(t *testing.T) {
SetActiveAccounts(100)
SetActiveAccounts(0)
}
func TestSetRequestQueueDepth(t *testing.T) {
SetRequestQueueDepth(50)
SetRequestQueueDepth(0)
}
func TestSetOpsHealthScore(t *testing.T) {
SetOpsHealthScore(95)
SetOpsHealthScore(0)
SetOpsHealthScore(100)
}
func TestSetErrorRate(t *testing.T) {
SetErrorRate(0.05)
SetErrorRate(0)
SetErrorRate(1.0)
}
func TestSetSuccessRate(t *testing.T) {
SetSuccessRate(0.95)
SetSuccessRate(0)
SetSuccessRate(1.0)
}
func TestSetQPS(t *testing.T) {
SetQPS(123.45)
SetQPS(0)
}
func TestSetTPS(t *testing.T) {
SetTPS(4567.89)
SetTPS(0)
}
func TestRecordHTTPRequest(t *testing.T) {
RecordHTTPRequest("GET", "/api/v1/chat", 200, 100*time.Millisecond)
RecordHTTPRequest("POST", "/api/v1/sora/generate", 201, 200*time.Millisecond)
RecordHTTPRequest("GET", "/api/v1/models", 500, 50*time.Millisecond)
}
func TestMetricsRegistered(t *testing.T) {
// Verify all metrics are registered by gathering them
mfs, err := Registry.Gather()
if err != nil {
t.Fatalf("Failed to gather metrics: %v", err)
}
expectedMetrics := map[string]bool{
"sub2api_http_requests_total": false,
"sub2api_http_request_duration_seconds": false,
"sub2api_db_connections_active": false,
"sub2api_db_connections_idle": false,
"sub2api_redis_connections_total": false,
"sub2api_redis_connections_idle": false,
"sub2api_accounts_active_total": false,
"sub2api_request_queue_depth": false,
"sub2api_ops_health_score": false,
"sub2api_error_rate": false,
"sub2api_success_rate": false,
"sub2api_qps": false,
"sub2api_tps": false,
}
for _, mf := range mfs {
if _, ok := expectedMetrics[mf.GetName()]; ok {
expectedMetrics[mf.GetName()] = true
}
}
for name, found := range expectedMetrics {
if !found {
t.Errorf("Expected metric %q not found in registry", name)
}
}
}

View File

@@ -0,0 +1,241 @@
package routes
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestRegisterCommonRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
RegisterCommonRoutes(r)
// Test /health
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Test /live
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/live", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Test /ready (no health checker set)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/ready", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Test /metrics
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/metrics", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Test /setup/status
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/setup/status", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var setupResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &setupResp)
assert.NoError(t, err)
assert.Equal(t, float64(0), setupResp["code"])
// Test POST /api/event_logging/batch
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/event_logging/batch", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHealthHandler_NoHealthChecker(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/health", nil)
healthHandler(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "ok", resp["status"])
components, ok := resp["components"].(map[string]interface{})
assert.True(t, ok)
db, ok := components["database"].(map[string]interface{})
assert.True(t, ok)
assert.Equal(t, "unknown", db["status"])
}
func TestReadinessHandler_NoHealthChecker(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/ready", nil)
readinessHandler(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "ready", resp["status"])
}
func TestLivenessHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/live", nil)
livenessHandler(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "alive", resp["status"])
// Verify timestamp is valid
ts, ok := resp["timestamp"].(string)
assert.True(t, ok)
_, err = time.Parse(time.RFC3339, ts)
assert.NoError(t, err)
}
// mockHealthChecker implements HealthChecker for testing
type mockHealthChecker struct {
dbHealthy bool
redisHealthy bool
}
func (m *mockHealthChecker) CheckDatabase() bool { return m.dbHealthy }
func (m *mockHealthChecker) CheckRedis() bool { return m.redisHealthy }
func TestHealthHandler_WithHealthyChecker(t *testing.T) {
gin.SetMode(gin.TestMode)
// Reset the healthChecker for test
healthChecker = &mockHealthChecker{dbHealthy: true, redisHealthy: true}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/health", nil)
healthHandler(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "ok", resp["status"])
}
func TestHealthHandler_WithUnhealthyDB(t *testing.T) {
gin.SetMode(gin.TestMode)
healthChecker = &mockHealthChecker{dbHealthy: false, redisHealthy: true}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/health", nil)
healthHandler(c)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "degraded", resp["status"])
}
func TestHealthHandler_WithUnhealthyRedis(t *testing.T) {
gin.SetMode(gin.TestMode)
healthChecker = &mockHealthChecker{dbHealthy: true, redisHealthy: false}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/health", nil)
healthHandler(c)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "degraded", resp["status"])
}
func TestReadinessHandler_AllHealthy(t *testing.T) {
gin.SetMode(gin.TestMode)
healthChecker = &mockHealthChecker{dbHealthy: true, redisHealthy: true}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/ready", nil)
readinessHandler(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "ready", resp["status"])
}
func TestReadinessHandler_NotReady(t *testing.T) {
gin.SetMode(gin.TestMode)
healthChecker = &mockHealthChecker{dbHealthy: true, redisHealthy: false}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/ready", nil)
readinessHandler(c)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "not_ready", resp["status"])
}
func TestDelegatedMetricFunctions(t *testing.T) {
// These should not panic - they delegate to prommetrics package
RecordHTTPRequest("GET", "/test", 200, 100*time.Millisecond)
SetDBConnections(5, 3)
SetRedisConnections(10, 4)
SetActiveAccounts(50)
SetRequestQueueDepth(5)
SetOpsHealthScore(90)
SetErrorRate(0.1)
SetSuccessRate(0.9)
SetQPS(100.0)
SetTPS(500.0)
}

View File

@@ -0,0 +1,260 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import SoraAdminView from '../SoraAdminView.vue'
import type { SoraSystemStats, SoraUserStats, SoraGenerationAdmin } from '@/api/admin/sora'
// 使用 vi.hoisted 确保 mock 函数在 mock 工厂函数执行前定义
const {
mockGetSystemStats,
mockListUserStats,
mockListGenerations,
mockClearUserStorage
} = vi.hoisted(() => ({
mockGetSystemStats: vi.fn(),
mockListUserStats: vi.fn(),
mockListGenerations: vi.fn(),
mockClearUserStorage: vi.fn()
}))
vi.mock('@/api/admin/sora', () => ({
default: {
getSystemStats: mockGetSystemStats,
listUserStats: mockListUserStats,
listGenerations: mockListGenerations,
clearUserStorage: mockClearUserStorage
}
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
// Stub child components
const AppLayoutStub = defineComponent({
name: 'AppLayout',
template: '<div class="app-layout"><slot /></div>'
})
const LoadingSpinnerStub = defineComponent({
name: 'LoadingSpinner',
template: '<div class="loading-spinner" />'
})
const IconStub = defineComponent({
name: 'Icon',
props: ['name', 'size'],
template: '<span class="icon" />'
})
const BaseButtonStub = defineComponent({
name: 'BaseButton',
props: ['variant', 'size'],
emits: ['click'],
template: '<button @click="$emit(\'click\')"><slot /></button>'
})
function createMockSystemStats(overrides: Partial<SoraSystemStats> = {}): SoraSystemStats {
return {
total_users: 10,
total_generations: 100,
total_storage_bytes: 5 * 1024 * 1024 * 1024,
active_generations: 3,
by_status: { completed: 80, failed: 15, pending: 5 },
by_model: { sora2: 60, sora1: 40 },
...overrides
}
}
function createMockUserStats(): SoraUserStats[] {
return [
{
user_id: 1,
username: 'user1',
email: 'user1@example.com',
quota_bytes: 10 * 1024 * 1024 * 1024,
used_bytes: 2 * 1024 * 1024 * 1024,
available_bytes: 8 * 1024 * 1024 * 1024,
quota_source: 'user',
generations_count: 20,
active_count: 1,
total_file_size_bytes: 1 * 1024 * 1024 * 1024
}
]
}
function createMockGenerations(): SoraGenerationAdmin[] {
return [
{
id: 1,
user_id: 1,
username: 'user1',
email: 'user1@example.com',
model: 'sora2',
prompt: 'A beautiful sunset',
media_type: 'video',
status: 'completed',
storage_type: 's3',
media_url: 'https://example.com/video.mp4',
file_size_bytes: 10 * 1024 * 1024,
error_message: '',
created_at: '2024-01-01T10:00:00Z',
completed_at: '2024-01-01T10:05:00Z'
}
]
}
describe('SoraAdminView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSystemStats.mockResolvedValue(createMockSystemStats())
mockListUserStats.mockResolvedValue({
items: createMockUserStats(),
total: 1,
page: 1,
page_size: 20,
pages: 1
})
mockListGenerations.mockResolvedValue({
items: createMockGenerations(),
total: 1,
page: 1,
page_size: 20,
pages: 1
})
})
function mountComponent() {
return mount(SoraAdminView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
LoadingSpinner: LoadingSpinnerStub,
Icon: IconStub
}
}
})
}
describe('初始加载', () => {
it('加载时显示 loading spinner', () => {
const wrapper = mountComponent()
expect(wrapper.find('.loading-spinner').exists()).toBe(true)
})
it('加载完成后显示页面内容', async () => {
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('.loading-spinner').exists()).toBe(false)
expect(wrapper.find('.app-layout').exists()).toBe(true)
})
it('加载完成后调用所有 API', async () => {
mountComponent()
await flushPromises()
expect(mockGetSystemStats).toHaveBeenCalled()
expect(mockListUserStats).toHaveBeenCalled()
expect(mockListGenerations).toHaveBeenCalled()
})
})
describe('概览标签页', () => {
it('默认显示概览标签页', async () => {
const wrapper = mountComponent()
await flushPromises()
// 概览标签页应显示统计卡片
const cards = wrapper.findAll('.card')
expect(cards.length).toBeGreaterThan(0)
})
it('显示系统统计数据', async () => {
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('10') // total_users
expect(wrapper.text()).toContain('100') // total_generations
expect(wrapper.text()).toContain('3') // active_generations
})
it('显示按状态分布', async () => {
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('completed')
expect(wrapper.text()).toContain('80')
})
it('显示按模型分布', async () => {
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('sora2')
expect(wrapper.text()).toContain('60')
})
})
describe('用户统计标签页', () => {
it('点击用户统计标签切换到用户列表', async () => {
const wrapper = mountComponent()
await flushPromises()
const tabs = wrapper.findAll('button')
const userTab = tabs.find(b => b.text().includes('admin.sora.userStats'))
await userTab?.trigger('click')
await flushPromises()
expect(wrapper.find('table').exists()).toBe(true)
expect(wrapper.text()).toContain('user1')
expect(wrapper.text()).toContain('user1@example.com')
})
})
describe('生成记录标签页', () => {
it('点击生成记录标签切换到记录列表', async () => {
const wrapper = mountComponent()
await flushPromises()
const tabs = wrapper.findAll('button')
const genTab = tabs.find(b => b.text().includes('admin.sora.generations'))
await genTab?.trigger('click')
await flushPromises()
expect(wrapper.find('table').exists()).toBe(true)
expect(wrapper.text()).toContain('user1')
expect(wrapper.text()).toContain('sora2')
expect(wrapper.text()).toContain('completed')
})
})
describe('API 错误处理', () => {
it('API 错误时不会崩溃', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockGetSystemStats.mockRejectedValue(new Error('Network error'))
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.exists()).toBe(true)
consoleSpy.mockRestore()
})
})
describe('格式化函数', () => {
it('正确格式化字节数', async () => {
const wrapper = mountComponent()
await flushPromises()
// 5 GB 应该显示为 "5 GB"
expect(wrapper.text()).toContain('5 GB')
})
})
})