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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
382
frontend/src/components/sora/__tests__/SoraGeneratePage.spec.ts
Normal file
382
frontend/src/components/sora/__tests__/SoraGeneratePage.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user