test: fix config tests and add Sora/User component tests
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

- 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
This commit is contained in:
User
2026-04-16 10:35:54 +08:00
parent 2d59b9ebfc
commit 7fa795e6a4
4 changed files with 931 additions and 7 deletions

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
function createMockUser(overrides: Partial<AdminUser> = {}): 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: '<div v-if="show"><slot /><slot name="footer" /></div>'
})
const IconStub = defineComponent({
name: 'Icon',
props: ['name', 'size'],
template: '<span class="icon">icon</span>'
})
const UserAttributeFormStub = defineComponent({
name: 'UserAttributeForm',
props: ['modelValue', 'userId'],
emits: ['update:modelValue'],
template: '<div class="user-attribute-form"></div>'
})
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()
})
})
})

View File

@@ -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: '<div class="sora-progress-card" :data-id="generation.id">{{ generation.status }}</div>'
})
}))
// 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: '<div class="sora-prompt-bar"><slot /></div>'
})
}))
// 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<typeof import('vue-i18n')>('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> = {}): 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> = {}): 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)
})
})
})