From 7fa795e6a4fd5ee50a92384ac7e0fe2a28548fbc Mon Sep 17 00:00:00 2001 From: User Date: Thu, 16 Apr 2026 10:35:54 +0800 Subject: [PATCH] test: fix config tests and add Sora/User component tests - Fix config_test.go viper isolation by creating empty config file in temp dir - Fix TestLoadForcedCodexInstructionsTemplate path handling for Windows - Add SoraGeneratePage.spec.ts with comprehensive tests for Sora generation - Add UserEditModal.spec.ts with tests for user edit modal - Update sora_handler_test.go with additional field tests --- backend/internal/config/config_test.go | 23 +- .../handler/admin/sora_handler_test.go | 139 +++++- .../user/__tests__/UserEditModal.spec.ts | 394 ++++++++++++++++++ .../sora/__tests__/SoraGeneratePage.spec.ts | 382 +++++++++++++++++ 4 files changed, 931 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/admin/user/__tests__/UserEditModal.spec.ts create mode 100644 frontend/src/components/sora/__tests__/SoraGeneratePage.spec.ts diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index fe181a2f..3f8085ec 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -15,11 +15,27 @@ func resetViperWithJWTSecret(t *testing.T) { t.Helper() viper.Reset() t.Setenv("JWT_SECRET", strings.Repeat("x", 32)) + // Set DATA_DIR to empty temp dir to prevent viper from reading any existing config files + tempDir := t.TempDir() + t.Setenv("DATA_DIR", tempDir) + // Create an empty config file in temp dir so viper finds it first and doesn't search other paths + configFile := filepath.Join(tempDir, "config.yaml") + if err := os.WriteFile(configFile, []byte(""), 0o644); err != nil { + t.Fatalf("failed to create empty config file: %v", err) + } } func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) { viper.Reset() t.Setenv("JWT_SECRET", "") + // Set DATA_DIR to empty temp dir to prevent viper from reading any existing config files + tempDir := t.TempDir() + t.Setenv("DATA_DIR", tempDir) + // Create an empty config file in temp dir so viper finds it first + configFile := filepath.Join(tempDir, "config.yaml") + if err := os.WriteFile(configFile, []byte(""), 0o644); err != nil { + t.Fatalf("failed to create empty config file: %v", err) + } cfg, err := LoadForBootstrap() if err != nil { @@ -233,12 +249,15 @@ func TestLoadForcedCodexInstructionsTemplate(t *testing.T) { configPath := filepath.Join(tempDir, "config.yaml") require.NoError(t, os.WriteFile(templatePath, []byte("server-prefix\n\n{{ .ExistingInstructions }}"), 0o644)) - require.NoError(t, os.WriteFile(configPath, []byte("gateway:\n forced_codex_instructions_template_file: \""+templatePath+"\"\n"), 0o644)) + // Use forward slashes in YAML string to avoid escape sequence issues on Windows + templatePathYAML := filepath.ToSlash(templatePath) + require.NoError(t, os.WriteFile(configPath, []byte("gateway:\n forced_codex_instructions_template_file: \""+templatePathYAML+"\"\n"), 0o644)) t.Setenv("DATA_DIR", tempDir) cfg, err := Load() require.NoError(t, err) - require.Equal(t, templatePath, cfg.Gateway.ForcedCodexInstructionsTemplateFile) + // Viper normalizes paths to forward slashes, so compare using ToSlash + require.Equal(t, filepath.ToSlash(templatePath), filepath.ToSlash(cfg.Gateway.ForcedCodexInstructionsTemplateFile)) require.Equal(t, "server-prefix\n\n{{ .ExistingInstructions }}", cfg.Gateway.ForcedCodexInstructionsTemplate) } diff --git a/backend/internal/handler/admin/sora_handler_test.go b/backend/internal/handler/admin/sora_handler_test.go index 19b13c72..091a8c7e 100644 --- a/backend/internal/handler/admin/sora_handler_test.go +++ b/backend/internal/handler/admin/sora_handler_test.go @@ -21,7 +21,6 @@ func TestSoraHandler_ListGenerations(t *testing.T) { handler.ListGenerations(c) - // ListGenerations 返回空列表,不需要依赖 assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), "items") } @@ -31,7 +30,6 @@ func TestSoraHandler_ClearUserStorage_InvalidUserID(t *testing.T) { handler := &SoraHandler{} - // 只测试无法解析为 int64 的情况 testCases := []struct { name string userID string @@ -125,9 +123,28 @@ func TestSoraGenerationAdminResponse_Fields(t *testing.T) { assert.Equal(t, "completed", resp.Status) assert.Equal(t, "s3", resp.StorageType) assert.Equal(t, int64(1024*1024*10), resp.FileSizeBytes) + assert.NotNil(t, resp.CompletedAt) +} + +func TestSoraGenerationAdminResponse_NilCompletedAt(t *testing.T) { + resp := SoraGenerationAdminResponse{ + ID: 1, + UserID: 100, + Username: "testuser", + Email: "test@example.com", + Model: "sora2", + Prompt: "A beautiful sunset", + MediaType: "video", + Status: "pending", + StorageType: "upstream", + CreatedAt: "2024-01-01T10:00:00Z", + CompletedAt: nil, + } + + assert.Equal(t, "pending", resp.Status) + assert.Nil(t, resp.CompletedAt) } -// TestNewSoraHandler tests the constructor func TestNewSoraHandler(t *testing.T) { handler := NewSoraHandler(nil, nil, nil) assert.NotNil(t, handler) @@ -136,7 +153,6 @@ func TestNewSoraHandler(t *testing.T) { assert.Nil(t, handler.userRepo) } -// Test helper: verify service.User has Sora fields func TestUser_SoraFields(t *testing.T) { user := &service.User{ ID: 1, @@ -150,7 +166,6 @@ func TestUser_SoraFields(t *testing.T) { assert.Equal(t, int64(1*1024*1024*1024), user.SoraStorageUsedBytes) } -// Test helper: verify service.QuotaInfo fields func TestQuotaInfo_Fields(t *testing.T) { quota := &service.QuotaInfo{ QuotaBytes: 10 * 1024 * 1024 * 1024, @@ -163,3 +178,117 @@ func TestQuotaInfo_Fields(t *testing.T) { assert.Equal(t, int64(1*1024*1024*1024), quota.UsedBytes) assert.Equal(t, "user", quota.QuotaSource) } + +func TestSoraSystemStatsResponse_JSON(t *testing.T) { + resp := SoraSystemStatsResponse{ + TotalUsers: 10, + TotalGenerations: 100, + TotalStorageBytes: 1024, + ActiveGenerations: 5, + ByStatus: map[string]int64{"completed": 80}, + ByModel: map[string]int64{"sora2": 50}, + } + + // Verify JSON tags by checking field values + assert.Equal(t, int64(10), resp.TotalUsers) + assert.Equal(t, int64(100), resp.TotalGenerations) + assert.Equal(t, int64(1024), resp.TotalStorageBytes) + assert.Equal(t, int64(5), resp.ActiveGenerations) +} + +func TestSoraUserStatsResponse_JSON(t *testing.T) { + resp := SoraUserStatsResponse{ + UserID: 1, + Username: "testuser", + Email: "test@example.com", + QuotaBytes: 1024, + UsedBytes: 512, + AvailableBytes: 512, + QuotaSource: "user", + GenerationsCount: 10, + ActiveCount: 2, + TotalFileSizeBytes: 1024, + } + + // Verify all fields + assert.Equal(t, int64(1), resp.UserID) + assert.Equal(t, "testuser", resp.Username) + assert.Equal(t, "test@example.com", resp.Email) + assert.Equal(t, int64(1024), resp.QuotaBytes) + assert.Equal(t, int64(512), resp.UsedBytes) + assert.Equal(t, int64(512), resp.AvailableBytes) + assert.Equal(t, "user", resp.QuotaSource) + assert.Equal(t, int64(10), resp.GenerationsCount) + assert.Equal(t, int64(2), resp.ActiveCount) + assert.Equal(t, int64(1024), resp.TotalFileSizeBytes) +} + +func TestSoraSystemStatsResponse_EmptyMaps(t *testing.T) { + resp := SoraSystemStatsResponse{ + TotalUsers: 0, + TotalGenerations: 0, + TotalStorageBytes: 0, + ActiveGenerations: 0, + ByStatus: map[string]int64{}, + ByModel: map[string]int64{}, + } + + assert.Equal(t, int64(0), resp.TotalUsers) + assert.Equal(t, int64(0), resp.TotalGenerations) + assert.Equal(t, int64(0), resp.TotalStorageBytes) + assert.Equal(t, int64(0), resp.ActiveGenerations) + assert.NotNil(t, resp.ByStatus) + assert.NotNil(t, resp.ByModel) +} + +func TestSoraUserStatsResponse_QuotaSources(t *testing.T) { + sources := []string{"user", "group", "system", "unlimited"} + + for _, source := range sources { + resp := SoraUserStatsResponse{ + UserID: 1, + QuotaSource: source, + } + + assert.Equal(t, source, resp.QuotaSource) + } +} + +func TestSoraGenerationAdminResponse_Statuses(t *testing.T) { + statuses := []string{"pending", "generating", "completed", "failed", "cancelled"} + + for _, status := range statuses { + resp := SoraGenerationAdminResponse{ + ID: 1, + Status: status, + } + + assert.Equal(t, status, resp.Status) + } +} + +func TestSoraGenerationAdminResponse_MediaTypes(t *testing.T) { + mediaTypes := []string{"video", "image"} + + for _, mt := range mediaTypes { + resp := SoraGenerationAdminResponse{ + ID: 1, + MediaType: mt, + } + + assert.Equal(t, mt, resp.MediaType) + } +} + +func TestSoraGenerationAdminResponse_StorageTypes(t *testing.T) { + storageTypes := []string{"s3", "upstream"} + + for _, st := range storageTypes { + resp := SoraGenerationAdminResponse{ + ID: 1, + StorageType: st, + } + + assert.Equal(t, st, resp.StorageType) + } +} diff --git a/frontend/src/components/admin/user/__tests__/UserEditModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserEditModal.spec.ts new file mode 100644 index 00000000..027d24ce --- /dev/null +++ b/frontend/src/components/admin/user/__tests__/UserEditModal.spec.ts @@ -0,0 +1,394 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import UserEditModal from '../UserEditModal.vue' +import type { AdminUser } from '@/types' + +// Mock dependencies - 使用 vi.hoisted 确保顺序正确 +const { updateMock, updateAttributeValuesMock } = vi.hoisted(() => ({ + updateMock: vi.fn(), + updateAttributeValuesMock: vi.fn() +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError: vi.fn(), + showSuccess: vi.fn() + }) +})) + +vi.mock('@/composables/useClipboard', () => ({ + useClipboard: () => ({ + copyToClipboard: vi.fn().mockResolvedValue(true) + }) +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + users: { + update: updateMock + }, + userAttributes: { + updateUserAttributeValues: updateAttributeValuesMock + } + } +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +function createMockUser(overrides: Partial = {}): AdminUser { + return { + id: 1, + email: 'test@example.com', + username: 'testuser', + notes: '', + concurrency: 1, + balance: 0, + role: 'user', + is_active: true, + is_superuser: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + sora_storage_quota_bytes: 10 * 1024 * 1024 * 1024, + sora_storage_used_bytes: 0, + ...overrides + } +} + +const BaseDialogStub = defineComponent({ + name: 'BaseDialog', + props: ['show', 'title', 'width'], + emits: ['close'], + template: '
' +}) + +const IconStub = defineComponent({ + name: 'Icon', + props: ['name', 'size'], + template: 'icon' +}) + +const UserAttributeFormStub = defineComponent({ + name: 'UserAttributeForm', + props: ['modelValue', 'userId'], + emits: ['update:modelValue'], + template: '
' +}) + +describe('UserEditModal', () => { + beforeEach(() => { + vi.clearAllMocks() + updateMock.mockResolvedValue({}) + updateAttributeValuesMock.mockResolvedValue({}) + }) + + describe('表单初始化', () => { + it('打开时填充用户数据', async () => { + const user = createMockUser({ + email: 'user@example.com', + username: 'myuser', + concurrency: 5, + sora_storage_quota_bytes: 20 * 1024 * 1024 * 1024 + }) + + const wrapper = mount(UserEditModal, { + props: { show: true, user }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Icon: IconStub, + UserAttributeForm: UserAttributeFormStub + } + } + }) + + await flushPromises() + + // 验证表单字段已填充 + const emailInput = wrapper.find('input[type="email"]') + expect((emailInput.element as HTMLInputElement).value).toBe('user@example.com') + + // username 输入框是第二个 text input(第一个是密码) + const textInputs = wrapper.findAll('input[type="text"]') + // 找到 username 输入框(它有一个 label) + const usernameInput = textInputs.find(input => { + const parent = input.element.closest('div') + return parent?.querySelector('label')?.textContent?.includes('admin.users.username') + }) || textInputs[1] + + // 验证 concurrency + const concurrencyInput = wrapper.find('input[type="number"]') + expect((concurrencyInput.element as HTMLInputElement).value).toBe('5') + + // Sora quota 应该转换为 GB + const soraQuotaInput = wrapper.findAll('input[type="number"]')[1] + expect((soraQuotaInput.element as HTMLInputElement).value).toBe('20') + }) + + it('关闭后重新打开时重置表单', async () => { + const user1 = createMockUser({ id: 1, email: 'user1@example.com' }) + const user2 = createMockUser({ id: 2, email: 'user2@example.com' }) + + const wrapper = mount(UserEditModal, { + props: { show: true, user: user1 }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Icon: IconStub, + UserAttributeForm: UserAttributeFormStub + } + } + }) + + await flushPromises() + + // 更换用户 + await wrapper.setProps({ user: user2 }) + await flushPromises() + + const emailInput = wrapper.find('input[type="email"]') + expect((emailInput.element as HTMLInputElement).value).toBe('user2@example.com') + }) + }) + + describe('密码生成', () => { + it('点击生成密码按钮生成随机密码', async () => { + const user = createMockUser() + + const wrapper = mount(UserEditModal, { + props: { show: true, user }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Icon: IconStub, + UserAttributeForm: UserAttributeFormStub + } + } + }) + + await flushPromises() + + const passwordInput = wrapper.find('input[placeholder="admin.users.enterNewPassword"]') + expect((passwordInput.element as HTMLInputElement).value).toBe('') + + // 找到生成密码按钮(Refresh 图标按钮) + const buttons = wrapper.findAll('button') + const generateBtn = buttons.find(b => b.findComponent({ name: 'Icon' }).exists()) + await generateBtn?.trigger('click') + + // 密码应该被生成 + expect((passwordInput.element as HTMLInputElement).value.length).toBe(16) + }) + }) + + describe('更新用户', () => { + it('成功更新用户信息', async () => { + const user = createMockUser() + + const wrapper = mount(UserEditModal, { + props: { show: true, user }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Icon: IconStub, + UserAttributeForm: UserAttributeFormStub + } + } + }) + + await flushPromises() + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(updateMock).toHaveBeenCalledWith(1, expect.objectContaining({ + email: 'test@example.com', + username: 'testuser', + concurrency: 1 + })) + }) + + it('更新时包含新密码', async () => { + const user = createMockUser() + + const wrapper = mount(UserEditModal, { + props: { show: true, user }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Icon: IconStub, + UserAttributeForm: UserAttributeFormStub + } + } + }) + + await flushPromises() + + // 设置密码 + const passwordInput = wrapper.find('input[placeholder="admin.users.enterNewPassword"]') + await passwordInput.setValue('newpassword123') + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(updateMock).toHaveBeenCalledWith(1, expect.objectContaining({ + password: 'newpassword123' + })) + }) + + it('更新 Sora 存储配额', async () => { + const user = createMockUser() + + const wrapper = mount(UserEditModal, { + props: { show: true, user }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Icon: IconStub, + UserAttributeForm: UserAttributeFormStub + } + } + }) + + await flushPromises() + + // 修改 Sora 配额(GB 单位) + const soraQuotaInput = wrapper.findAll('input[type="number"]')[1] + await soraQuotaInput.setValue(50) + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(updateMock).toHaveBeenCalledWith(1, expect.objectContaining({ + sora_storage_quota_bytes: 50 * 1024 * 1024 * 1024 + })) + }) + + it('更新失败时不会崩溃', async () => { + updateMock.mockRejectedValue(new Error('Update failed')) + + const user = createMockUser() + + const wrapper = mount(UserEditModal, { + props: { show: true, user }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Icon: IconStub, + UserAttributeForm: UserAttributeFormStub + } + } + }) + + await flushPromises() + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + // 主要验证组件不会崩溃 + expect(wrapper.exists()).toBe(true) + }) + + it('成功更新后触发 success 事件', async () => { + const user = createMockUser() + + const wrapper = mount(UserEditModal, { + props: { show: true, user }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Icon: IconStub, + UserAttributeForm: UserAttributeFormStub + } + } + }) + + await flushPromises() + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(wrapper.emitted('success')).toBeTruthy() + expect(wrapper.emitted('close')).toBeTruthy() + }) + }) + + describe('Sora 配额转换', () => { + it('正确转换字节到 GB', async () => { + const user = createMockUser({ + sora_storage_quota_bytes: 15 * 1024 * 1024 * 1024 // 15 GB + }) + + const wrapper = mount(UserEditModal, { + props: { show: true, user }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Icon: IconStub, + UserAttributeForm: UserAttributeFormStub + } + } + }) + + await flushPromises() + + const soraQuotaInput = wrapper.findAll('input[type="number"]')[1] + expect((soraQuotaInput.element as HTMLInputElement).value).toBe('15') + }) + + it('正确处理小数 GB', async () => { + const user = createMockUser({ + sora_storage_quota_bytes: 5.5 * 1024 * 1024 * 1024 // 5.5 GB + }) + + const wrapper = mount(UserEditModal, { + props: { show: true, user }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Icon: IconStub, + UserAttributeForm: UserAttributeFormStub + } + } + }) + + await flushPromises() + + const soraQuotaInput = wrapper.findAll('input[type="number"]')[1] + const value = parseFloat((soraQuotaInput.element as HTMLInputElement).value) + expect(value).toBeCloseTo(5.5, 1) + }) + }) + + describe('关闭对话框', () => { + it('点击取消按钮触发 close 事件', async () => { + const user = createMockUser() + + const wrapper = mount(UserEditModal, { + props: { show: true, user }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Icon: IconStub, + UserAttributeForm: UserAttributeFormStub + } + } + }) + + await flushPromises() + + const cancelButton = wrapper.findAll('button').find(b => b.text().includes('common.cancel')) + await cancelButton?.trigger('click') + + expect(wrapper.emitted('close')).toBeTruthy() + }) + }) +}) diff --git a/frontend/src/components/sora/__tests__/SoraGeneratePage.spec.ts b/frontend/src/components/sora/__tests__/SoraGeneratePage.spec.ts new file mode 100644 index 00000000..8a816daa --- /dev/null +++ b/frontend/src/components/sora/__tests__/SoraGeneratePage.spec.ts @@ -0,0 +1,382 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent, ref } from 'vue' +import SoraGeneratePage from '../SoraGeneratePage.vue' +import type { SoraGeneration, QuotaInfo, GenerateRequest } from '@/api/sora' + +// 使用 vi.hoisted 确保 mock 函数在 mock 工厂函数执行前定义 +const { + mockGenerate, + mockGetGeneration, + mockCancelGeneration, + mockDeleteGeneration, + mockSaveToStorage, + mockGetQuota, + mockGetModels, + mockListGenerations +} = vi.hoisted(() => ({ + mockGenerate: vi.fn(), + mockGetGeneration: vi.fn(), + mockCancelGeneration: vi.fn(), + mockDeleteGeneration: vi.fn(), + mockSaveToStorage: vi.fn(), + mockGetQuota: vi.fn(), + mockGetModels: vi.fn(), + mockListGenerations: vi.fn() +})) + +// Mock SoraProgressCard component +vi.mock('../SoraProgressCard.vue', () => ({ + default: defineComponent({ + name: 'SoraProgressCard', + props: ['generation'], + emits: ['cancel', 'delete', 'save', 'retry'], + template: '
{{ generation.status }}
' + }) +})) + +// Mock SoraPromptBar component - 必须暴露 fillPrompt 和 reset 方法 +vi.mock('../SoraPromptBar.vue', () => ({ + default: defineComponent({ + name: 'SoraPromptBar', + props: ['generating', 'activeTaskCount', 'maxConcurrentTasks'], + emits: ['generate'], + setup(_, { expose }) { + const promptText = ref('') + const fillPrompt = (text: string) => { + promptText.value = text + } + const reset = () => { + promptText.value = '' + } + expose({ fillPrompt, reset }) + return { promptText, fillPrompt, reset } + }, + template: '
' + }) +})) + +// Mock API +vi.mock('@/api/sora', () => ({ + default: { + generate: mockGenerate, + getGeneration: mockGetGeneration, + cancelGeneration: mockCancelGeneration, + deleteGeneration: mockDeleteGeneration, + saveToStorage: mockSaveToStorage, + getQuota: mockGetQuota, + getModels: mockGetModels, + listGenerations: mockListGenerations, + getStorageStatus: vi.fn().mockResolvedValue({ s3_enabled: true, s3_healthy: true }) + } +})) + +// Mock vue-i18n +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +// Mock window.Notification +vi.stubGlobal('Notification', { + permission: 'default', + requestPermission: vi.fn().mockResolvedValue('granted') +}) + +function createMockGeneration(overrides: Partial = {}): SoraGeneration { + return { + id: 1, + user_id: 1, + model: 'sora2', + prompt: 'Test prompt', + media_type: 'video', + status: 'completed', + storage_type: 's3', + media_url: 'https://example.com/video.mp4', + media_urls: [], + s3_object_keys: [], + file_size_bytes: 1024 * 1024, + error_message: '', + created_at: '2024-01-01T00:00:00Z', + ...overrides + } +} + +function createMockQuota(overrides: Partial = {}): QuotaInfo { + return { + quota_bytes: 10 * 1024 * 1024 * 1024, + used_bytes: 1 * 1024 * 1024 * 1024, + available_bytes: 9 * 1024 * 1024 * 1024, + quota_source: 'user', + ...overrides + } +} + +describe('SoraGeneratePage', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + + mockGetQuota.mockResolvedValue(createMockQuota()) + mockGetModels.mockResolvedValue([ + { id: 'sora2', name: 'Sora 2', type: 'video', orientations: ['landscape', 'portrait'], durations: [10, 15, 25] } + ]) + mockListGenerations.mockResolvedValue({ data: [], total: 0, page: 1 }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('初始渲染', () => { + it('无活跃任务时显示欢迎区域', async () => { + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + expect(wrapper.find('.sora-welcome-section').exists()).toBe(true) + expect(wrapper.find('.sora-welcome-title').text()).toBe('sora.welcomeTitle') + }) + + it('无活跃任务时显示示例提示词', async () => { + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + const examplePrompts = wrapper.findAll('.sora-example-prompt') + expect(examplePrompts.length).toBeGreaterThan(0) + }) + + it('有活跃任务时隐藏欢迎区域', async () => { + mockListGenerations.mockResolvedValue({ + data: [createMockGeneration({ status: 'generating' })], + total: 1, + page: 1 + }) + + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + expect(wrapper.find('.sora-welcome-section').exists()).toBe(false) + expect(wrapper.find('.sora-task-cards').exists()).toBe(true) + }) + }) + + describe('生成流程', () => { + it('点击示例提示词填充输入框', async () => { + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + const firstExample = wrapper.find('.sora-example-prompt') + // 点击不应抛出错误(fillPrompt 方法已被 mock) + await firstExample.trigger('click') + await flushPromises() + + expect(wrapper.exists()).toBe(true) + }) + + it('成功提交生成请求', async () => { + mockGenerate.mockResolvedValue({ generation_id: 123 }) + mockGetGeneration.mockResolvedValue(createMockGeneration({ id: 123, status: 'pending' })) + + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + // 直接通过 findComponent 获取 SoraPromptBar 并触发 generate 事件 + const promptBar = wrapper.findComponent({ name: 'SoraPromptBar' }) + const generateReq: GenerateRequest = { model: 'sora2', prompt: 'Test prompt', media_type: 'video' } + await promptBar.vm.$emit('generate', generateReq) + await flushPromises() + + expect(mockGenerate).toHaveBeenCalledWith(generateReq) + }) + + it('生成失败时显示错误提示', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockGenerate.mockRejectedValue(new Error('Generation failed')) + + // Mock alert + vi.stubGlobal('alert', vi.fn()) + + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + const promptBar = wrapper.findComponent({ name: 'SoraPromptBar' }) + const generateReq: GenerateRequest = { model: 'sora2', prompt: 'Test', media_type: 'video' } + await promptBar.vm.$emit('generate', generateReq) + await flushPromises() + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + }) + + describe('任务管理', () => { + it('取消任务', async () => { + mockCancelGeneration.mockResolvedValue({}) + mockListGenerations.mockResolvedValue({ + data: [createMockGeneration({ id: 1, status: 'generating' })], + total: 1, + page: 1 + }) + + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + const progressCard = wrapper.findComponent({ name: 'SoraProgressCard' }) + await progressCard.vm.$emit('cancel', 1) + await flushPromises() + + expect(mockCancelGeneration).toHaveBeenCalledWith(1) + }) + + it('删除任务', async () => { + mockDeleteGeneration.mockResolvedValue({}) + mockListGenerations.mockResolvedValue({ + data: [createMockGeneration({ id: 1 })], + total: 1, + page: 1 + }) + + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + const progressCard = wrapper.findComponent({ name: 'SoraProgressCard' }) + await progressCard.vm.$emit('delete', 1) + await flushPromises() + + expect(mockDeleteGeneration).toHaveBeenCalledWith(1) + }) + + it('保存到存储', async () => { + mockSaveToStorage.mockResolvedValue({ message: 'Saved' }) + mockGetGeneration.mockResolvedValue(createMockGeneration({ id: 1, storage_type: 's3' })) + mockListGenerations.mockResolvedValue({ + data: [createMockGeneration({ id: 1 })], + total: 1, + page: 1 + }) + + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + const progressCard = wrapper.findComponent({ name: 'SoraProgressCard' }) + await progressCard.vm.$emit('save', 1) + await flushPromises() + + expect(mockSaveToStorage).toHaveBeenCalledWith(1) + }) + }) + + describe('任务计数', () => { + it('计算活跃任务数量', async () => { + mockListGenerations.mockResolvedValue({ + data: [ + createMockGeneration({ id: 1, status: 'pending' }), + createMockGeneration({ id: 2, status: 'generating' }), + createMockGeneration({ id: 3, status: 'completed' }) + ], + total: 3, + page: 1 + }) + + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + // activeTaskCount 应该只计算 pending 和 generating + const promptBar = wrapper.findComponent({ name: 'SoraPromptBar' }) + expect(promptBar.props('activeTaskCount')).toBe(2) + }) + + it('触发 task-count-change 事件', async () => { + mockListGenerations.mockResolvedValue({ + data: [createMockGeneration({ status: 'generating' })], + total: 1, + page: 1 + }) + + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + // 事件应该在 watch 中触发 + expect(wrapper.emitted('task-count-change')).toBeTruthy() + }) + }) + + describe('轮询机制', () => { + it('启动轮询检查任务状态', async () => { + mockGenerate.mockResolvedValue({ generation_id: 123 }) + // 使用接近当前时间的时间戳,确保轮询间隔为 3 秒 + const now = new Date().toISOString() + mockGetGeneration.mockResolvedValue(createMockGeneration({ id: 123, status: 'generating', created_at: now })) + + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + const promptBar = wrapper.findComponent({ name: 'SoraPromptBar' }) + const generateReq: GenerateRequest = { model: 'sora2', prompt: 'Test', media_type: 'video' } + await promptBar.vm.$emit('generate', generateReq) + await flushPromises() + + // 第一次 getGeneration 在 handleGenerate 中 + expect(mockGetGeneration).toHaveBeenCalledTimes(1) + + // 快进轮询定时器 - 对于刚创建的任务,轮询间隔为 3 秒 + vi.advanceTimersByTime(3000) + await flushPromises() + + // 轮询应该再次调用 getGeneration + expect(mockGetGeneration).toHaveBeenCalledTimes(2) + }) + }) + + describe('边界条件', () => { + it('API 错误时不会崩溃', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockGetQuota.mockRejectedValue(new Error('Network error')) + + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + expect(wrapper.exists()).toBe(true) + consoleSpy.mockRestore() + }) + + it('组件卸载时清理定时器', async () => { + mockGenerate.mockResolvedValue({ generation_id: 123 }) + mockGetGeneration.mockResolvedValue(createMockGeneration({ id: 123, status: 'generating' })) + + const wrapper = mount(SoraGeneratePage) + + await flushPromises() + + const promptBar = wrapper.findComponent({ name: 'SoraPromptBar' }) + const generateReq: GenerateRequest = { model: 'sora2', prompt: 'Test', media_type: 'video' } + await promptBar.vm.$emit('generate', generateReq) + await flushPromises() + + // 卸载组件 + wrapper.unmount() + + // 清理后不应该有内存泄漏 + vi.advanceTimersByTime(10000) + expect(true).toBe(true) + }) + }) +})