From c9992af8769643fb824e81f1d578431e18cce076 Mon Sep 17 00:00:00 2001 From: User Date: Thu, 16 Apr 2026 13:04:03 +0800 Subject: [PATCH] test: add tests for prommetrics, common routes, and Sora admin page - 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 --- backend/internal/prommetrics/metrics_test.go | 104 +++++++ backend/internal/server/routes/common_test.go | 241 ++++++++++++++++ .../admin/__tests__/SoraAdminView.spec.ts | 260 ++++++++++++++++++ 3 files changed, 605 insertions(+) create mode 100644 backend/internal/prommetrics/metrics_test.go create mode 100644 backend/internal/server/routes/common_test.go create mode 100644 frontend/src/views/admin/__tests__/SoraAdminView.spec.ts diff --git a/backend/internal/prommetrics/metrics_test.go b/backend/internal/prommetrics/metrics_test.go new file mode 100644 index 00000000..d5206efc --- /dev/null +++ b/backend/internal/prommetrics/metrics_test.go @@ -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) + } + } +} diff --git a/backend/internal/server/routes/common_test.go b/backend/internal/server/routes/common_test.go new file mode 100644 index 00000000..aafb947e --- /dev/null +++ b/backend/internal/server/routes/common_test.go @@ -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) +} diff --git a/frontend/src/views/admin/__tests__/SoraAdminView.spec.ts b/frontend/src/views/admin/__tests__/SoraAdminView.spec.ts new file mode 100644 index 00000000..611361f3 --- /dev/null +++ b/frontend/src/views/admin/__tests__/SoraAdminView.spec.ts @@ -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('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +// Stub child components +const AppLayoutStub = defineComponent({ + name: 'AppLayout', + template: '
' +}) + +const LoadingSpinnerStub = defineComponent({ + name: 'LoadingSpinner', + template: '
' +}) + +const IconStub = defineComponent({ + name: 'Icon', + props: ['name', 'size'], + template: '' +}) + +const BaseButtonStub = defineComponent({ + name: 'BaseButton', + props: ['variant', 'size'], + emits: ['click'], + template: '' +}) + +function createMockSystemStats(overrides: Partial = {}): 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') + }) + }) +})