fix(setup): protect wizard step orchestration (#679)

This commit is contained in:
InfinityPacer
2026-08-16 16:32:14 +08:00
committed by GitHub
parent 4f6537400d
commit 0acb604466
6 changed files with 1364 additions and 92 deletions
@@ -0,0 +1,993 @@
import { useSetupWizard, type WizardData } from '@/composables/useSetupWizard'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiPut: vi.fn(),
copyToClipboard: vi.fn(),
routerPush: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
put: (...args: unknown[]) => mocks.apiPut(...args),
}),
}))
vi.mock('@/@core/utils/navigator', () => ({
copyToClipboard: (...args: unknown[]) => mocks.copyToClipboard(...args),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${Object.values(params).join(',')}` : key),
}),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mocks.routerPush }),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
function cloneWizardData(data: WizardData) {
return JSON.parse(JSON.stringify(data)) as WizardData
}
const originalWizardData = cloneWizardData(useSetupWizard().wizardData.value)
function resetWizard() {
const wizard = useSetupWizard()
wizard.currentStep.value = 1
wizard.wizardData.value = cloneWizardData(originalWizardData)
wizard.authSites.value = {}
wizard.selectedPreset.value = ''
wizard.connectivityTest.value = {
isTesting: false,
showResult: false,
testMessage: '',
testProgress: 0,
testResult: null,
}
wizard.clearValidationErrors()
return wizard
}
function success(data: unknown = null) {
return { data, message: '', success: true }
}
function businessFailure(message = '业务失败') {
return { data: null, message, success: false }
}
function currentUser() {
return {
avatar: 'avatar.png',
email: 'admin@example.com',
id: 7,
is_active: true,
is_otp: true,
is_superuser: true,
name: 'admin',
nickname: '管理员',
permissions: { manage: true },
settings: { theme: 'dark' },
}
}
function seedValidStep(wizard: ReturnType<typeof useSetupWizard>, step: number) {
wizard.currentStep.value = step
wizard.wizardData.value.basic.username = 'admin'
wizard.wizardData.value.basic.apiToken = 'token'
wizard.wizardData.value.storage.downloadPath = '/downloads'
wizard.wizardData.value.storage.libraryPath = '/media'
if (step === 4) {
Object.assign(wizard.wizardData.value.downloader, {
config: { host: 'http://qb', password: 'secret', username: 'admin' },
name: '主下载器',
type: 'qbittorrent',
})
}
if (step === 5) {
Object.assign(wizard.wizardData.value.mediaServer, {
config: { apikey: 'key', host: 'http://emby' },
name: '主媒体服务器',
sync_libraries: ['Movies'],
type: 'emby',
})
}
if (step === 6) {
Object.assign(wizard.wizardData.value.notification, {
config: { TELEGRAM_CHAT_ID: '123', TELEGRAM_TOKEN: 'token' },
enabled: true,
name: 'Telegram 通知',
switchs: ['资源下载'],
type: 'telegram',
})
}
}
async function resolveConnectivity<T>(promise: Promise<T>) {
await vi.advanceTimersByTimeAsync(2_000)
return promise
}
describe('useSetupWizard defaults, selection and validation', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiPut.mockReset()
mocks.copyToClipboard.mockReset()
mocks.routerPush.mockReset()
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
mocks.apiGet.mockResolvedValue(success())
mocks.apiPost.mockResolvedValue(success())
mocks.apiPut.mockResolvedValue(success())
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
resetWizard()
})
it('exposes eight ordered steps and stable defaults', () => {
const wizard = resetWizard()
expect(wizard.totalSteps).toBe(8)
expect(wizard.stepTitles.value).toEqual([
'setupWizard.basic.title',
'setupWizard.siteAuth.title',
'setupWizard.storage.title',
'setupWizard.downloader.title',
'setupWizard.mediaServer.title',
'setupWizard.notification.title',
'setupWizard.agent.title',
'setupWizard.preferences.title',
])
expect(wizard.stepDescriptions.value).toHaveLength(8)
expect(wizard.wizardData.value).toMatchObject({
agent: {
enabled: false,
maxContextTokens: 64,
model: 'deepseek-chat',
provider: 'deepseek',
thinkingLevel: 'off',
},
basic: { apiToken: '', recognizeSource: 'themoviedb', username: '' },
preferences: { quality: '4K', resolution: '2160p', subtitle: 'chinese' },
storage: { overwriteMode: 'never', transferType: 'link' },
})
})
it('selects, renames and toggles optional service types without clearing configuration', () => {
const wizard = resetWizard()
wizard.wizardData.value.downloader.config = { host: 'http://qb' }
wizard.selectDownloader('qbittorrent')
expect(wizard.wizardData.value.downloader).toMatchObject({
config: { host: 'http://qb' },
name: 'qbittorrent 下载器',
type: 'qbittorrent',
})
wizard.selectDownloader('transmission')
expect(wizard.wizardData.value.downloader.name).toBe('transmission 下载器')
wizard.selectDownloader('transmission')
expect(wizard.wizardData.value.downloader.type).toBe('')
wizard.wizardData.value.mediaServer.config = { token: 'token' }
wizard.selectMediaServer('plex')
expect(wizard.wizardData.value.mediaServer).toMatchObject({
config: { token: 'token' },
name: 'plex 服务器',
type: 'plex',
})
wizard.selectMediaServer('plex')
expect(wizard.wizardData.value.mediaServer.type).toBe('')
wizard.selectNotification('wechat')
expect(wizard.wizardData.value.notification).toMatchObject({
enabled: true,
name: '企业微信 通知',
type: 'wechat',
})
wizard.selectNotification('wechat')
expect(wizard.wizardData.value.notification.type).toBe('')
})
it('preserves custom service names and falls back to the raw notification type', () => {
const wizard = resetWizard()
wizard.wizardData.value.downloader.name = '下载专线'
wizard.selectDownloader('qbittorrent')
expect(wizard.wizardData.value.downloader.name).toBe('下载专线')
wizard.wizardData.value.mediaServer.name = '客厅媒体库'
wizard.selectMediaServer('plex')
expect(wizard.wizardData.value.mediaServer.name).toBe('客厅媒体库')
wizard.wizardData.value.notification.name = ''
wizard.selectNotification('custom')
expect(wizard.wizardData.value.notification.name).toBe('custom 通知')
})
it.each([
['4k', '4K', 'bilingual', '2160p'],
['balanced', '1080P', 'chinese', '1080p'],
['chinese', '1080P', 'chinese', '1080p'],
])('applies the %s preference preset', (preset, quality, subtitle, resolution) => {
const wizard = resetWizard()
wizard.selectPreset(preset)
expect(wizard.selectedPreset.value).toBe(preset)
expect(wizard.wizardData.value.preferences).toMatchObject({ quality, resolution, subtitle })
})
it('stores detailed preference rules', () => {
const wizard = resetWizard()
const options = { excludeBluray: true, excludeDolbyVision: false }
const rules = [{ category: '电影', media_type: '电影', name: '高画质', rule_string: '4K' }]
wizard.updatePreferences(options, rules)
expect(wizard.wizardData.value.preferences).toMatchObject({
personalizationOptions: options,
ruleSequences: rules,
})
})
it('generates and copies API tokens with success and failure feedback', async () => {
const wizard = resetWizard()
vi.spyOn(window.crypto, 'getRandomValues').mockImplementation(array => {
;(array as Uint8Array).fill(1)
return array
})
wizard.createRandomString()
expect(wizard.wizardData.value.basic.apiToken).toBe('1'.repeat(32))
mocks.copyToClipboard
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockRejectedValueOnce(new Error('no'))
await wizard.copyValue('token')
await wizard.copyValue('token')
await wizard.copyValue('token')
expect(mocks.toastSuccess).toHaveBeenCalledWith('setting.system.copySuccess')
expect(mocks.toastError.mock.calls).toEqual([['setting.system.copyFailed'], ['setting.system.copyError']])
})
it('validates basic, storage and missing site-auth values', () => {
const wizard = resetWizard()
expect(wizard.validateCurrentStep()).toEqual({
errors: ['dialog.userAddEdit.usernameRequired', 'setupWizard.basic.apiTokenRequired'],
isValid: false,
})
Object.assign(wizard.wizardData.value.basic, {
apiToken: 'token',
confirmPassword: 'different',
password: 'short',
username: 'admin',
})
expect(wizard.validateCurrentStep().errors).toEqual([
'dialog.userAddEdit.passwordMinLength',
'dialog.userAddEdit.passwordMismatch',
])
wizard.currentStep.value = 2
wizard.wizardData.value.siteAuth.site = 'm-team'
expect(wizard.validateCurrentStep().errors).toEqual(['setupWizard.siteAuth.siteConfigNotExist'])
wizard.authSites.value = {
'm-team': {
icon: 'm-team.png',
name: 'M-Team',
params: { api_key: { name: 'API Key', type: 'text' } },
},
}
expect(wizard.validateCurrentStep().errors).toEqual(['setupWizard.siteAuth.fieldRequired:API Key'])
wizard.wizardData.value.siteAuth.params['M-TEAM_API_KEY'] = 'key'
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
wizard.currentStep.value = 3
expect(wizard.validateCurrentStep().errors).toEqual([
'setupWizard.storage.downloadPathRequired',
'setupWizard.storage.libraryPathRequired',
])
})
it('accepts an omitted optional site and reports a missing password confirmation', () => {
const wizard = resetWizard()
wizard.currentStep.value = 2
expect(wizard.validateSiteAuthFields()).toEqual({ errors: [], isValid: true })
wizard.currentStep.value = 1
Object.assign(wizard.wizardData.value.basic, {
apiToken: 'token',
confirmPassword: '',
password: 'long-enough',
username: 'admin',
})
expect(wizard.validateCurrentStep().errors).toEqual(['dialog.userAddEdit.confirmPasswordRequired'])
})
it.each([
['qbittorrent', { host: 'http://qb' }, ['downloader.usernameRequired', 'downloader.passwordRequired']],
['transmission', { host: 'http://tr' }, ['downloader.usernameRequired', 'downloader.passwordRequired']],
['rtorrent', { host: 'http://rt' }, ['downloader.usernameRequired', 'downloader.passwordRequired']],
])('validates the %s downloader contract', (type, config, expectedErrors) => {
const wizard = resetWizard()
wizard.currentStep.value = 4
Object.assign(wizard.wizardData.value.downloader, { config, name: '下载器', type })
expect(wizard.validateCurrentStep().errors).toEqual(expectedErrors)
wizard.wizardData.value.downloader.config = {
...config,
apikey: type === 'qbittorrent' ? 'key' : undefined,
password: 'password',
username: 'admin',
}
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
})
it.each([
['emby', { host: 'http://emby' }, 'mediaserver.apiKeyRequired'],
['jellyfin', { host: 'http://jellyfin' }, 'mediaserver.apiKeyRequired'],
['plex', { host: 'http://plex' }, 'mediaserver.tokenRequired'],
['zspace', { host: 'http://zspace' }, 'mediaserver.usernameRequired'],
['trimemedia', { host: 'http://trimemedia' }, 'mediaserver.usernameRequired'],
['ugreen', { host: 'http://ugreen' }, 'mediaserver.usernameRequired'],
])('validates the %s media-server contract', (type, config, expectedError) => {
const wizard = resetWizard()
wizard.currentStep.value = 5
Object.assign(wizard.wizardData.value.mediaServer, { config, name: '媒体服务器', type })
expect(wizard.validateCurrentStep().errors).toContain(expectedError)
})
it.each([
['emby', { apikey: 'key', host: 'http://emby' }],
['jellyfin', { apikey: 'key', host: 'http://jellyfin' }],
['plex', { host: 'http://plex', token: 'token' }],
['zspace', { host: 'http://zspace', password: 'secret', username: 'admin' }],
['trimemedia', { host: 'http://trimemedia', password: 'secret', username: 'admin' }],
['ugreen', { host: 'http://ugreen', password: 'secret', username: 'admin' }],
])('accepts the complete %s media-server contract', (type, config) => {
const wizard = resetWizard()
wizard.currentStep.value = 5
Object.assign(wizard.wizardData.value.mediaServer, { config, name: '媒体服务器', type })
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
})
it.each([
['wechat', {}, 'notification.wechat.corpIdRequired'],
['feishu', {}, 'notification.feishu.appIdRequired'],
['telegram', {}, 'notification.telegram.tokenRequired'],
['slack', {}, 'notification.slack.oauthTokenRequired'],
['synologychat', {}, 'notification.synologychat.webhookRequired'],
['vocechat', {}, 'notification.vocechat.hostRequired'],
['webpush', {}, 'notification.webpush.usernameRequired'],
['qqbot', {}, 'notification.qqbot.appIdRequired'],
['wechatclawbot', {}, null],
])('validates the %s notification contract', (type, config, expectedError) => {
const wizard = resetWizard()
wizard.currentStep.value = 6
Object.assign(wizard.wizardData.value.notification, { config, name: '通知', type })
const result = wizard.validateCurrentStep()
if (expectedError) expect(result.errors).toContain(expectedError)
else expect(result).toEqual({ errors: [], isValid: true })
})
it.each([
['wechat', { WECHAT_APP_ID: 'app', WECHAT_APP_SECRET: 'secret', WECHAT_CORPID: 'corp' }],
['feishu', { FEISHU_APP_ID: 'app', FEISHU_APP_SECRET: 'secret' }],
['telegram', { TELEGRAM_CHAT_ID: 'chat', TELEGRAM_TOKEN: 'token' }],
['slack', { SLACK_CHANNEL: 'channel', SLACK_OAUTH_TOKEN: 'token' }],
['synologychat', { SYNOLOGYCHAT_WEBHOOK: 'https://chat.example.com' }],
['vocechat', { VOCECHAT_API_KEY: 'key', VOCECHAT_HOST: 'https://voce.example.com' }],
['webpush', { WEBPUSH_USERNAME: 'admin' }],
['qqbot', { QQ_APP_ID: 'app', QQ_APP_SECRET: 'secret' }],
])('accepts the complete %s notification contract', (type, config) => {
const wizard = resetWizard()
wizard.currentStep.value = 6
Object.assign(wizard.wizardData.value.notification, { config, name: '通知', type })
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
})
it('reports missing service names and hosts before provider-specific fields', () => {
const wizard = resetWizard()
wizard.currentStep.value = 4
Object.assign(wizard.wizardData.value.downloader, { config: {}, name: '', type: 'qbittorrent' })
expect(wizard.validateCurrentStep().errors).toEqual([
'downloader.nameRequired',
'downloader.hostRequired',
'downloader.usernameRequired',
'downloader.passwordRequired',
])
wizard.currentStep.value = 5
Object.assign(wizard.wizardData.value.mediaServer, { config: {}, name: '', type: 'plex' })
expect(wizard.validateCurrentStep().errors).toEqual([
'mediaserver.nameRequired',
'mediaserver.hostRequired',
'mediaserver.tokenRequired',
])
wizard.currentStep.value = 6
Object.assign(wizard.wizardData.value.notification, { config: undefined, name: '', type: 'wechatclawbot' })
expect(wizard.validateCurrentStep().errors).toEqual(['notification.nameRequired'])
})
it('validates enabled agent fields and skips them when disabled', () => {
const wizard = resetWizard()
wizard.currentStep.value = 7
wizard.wizardData.value.agent.enabled = true
Object.assign(wizard.wizardData.value.agent, {
apiKey: '',
authConnected: false,
maxContextTokens: 0,
model: '',
provider: '',
recommendEnabled: true,
recommendMaxItems: 0,
})
expect(wizard.validateCurrentStep().errors).toEqual([
'setupWizard.agent.providerRequired',
'setupWizard.agent.authOrApiKeyRequired',
'setupWizard.agent.modelRequired',
'setupWizard.agent.maxContextTokensRequired',
'setupWizard.agent.recommendMaxItemsRequired',
])
wizard.wizardData.value.agent.enabled = false
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
wizard.currentStep.value = 8
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
})
})
describe('useSetupWizard initialization and step payloads', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiPut.mockReset()
mocks.routerPush.mockReset()
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
mocks.apiPost.mockResolvedValue(success())
mocks.apiPut.mockResolvedValue(success())
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
resetWizard()
})
it('loads existing settings across all eight setup domains', async () => {
mocks.apiGet.mockImplementation((endpoint: unknown) => {
const responses: Record<string, unknown> = {
'site/auth': success({
'm-team': { icon: 'm-team.png', name: 'M-Team', params: {} },
}),
'system/env': success({
AI_AGENT_ENABLE: true,
AI_AGENT_GLOBAL: true,
AI_AGENT_JOB_INTERVAL: 15,
AI_AGENT_RECOMMEND_MAX_ITEMS: 25,
AI_AGENT_RETRY_TRANSFER: true,
AI_RECOMMEND_ENABLED: true,
API_TOKEN: 'existing-token',
APP_DOMAIN: 'https://mp.example.com',
AUXILIARY_AUTH_ENABLE: true,
GITHUB_TOKEN: 'github-token',
LLM_API_KEY: 'llm-key',
LLM_DISABLE_THINKING: false,
LLM_MAX_CONTEXT_TOKENS: 128,
LLM_MODEL: 'deepseek-reasoner',
LLM_PROVIDER: 'deepseek',
LLM_REASONING_EFFORT: 'high',
OCR_HOST: 'http://ocr',
PROXY_HOST: 'http://proxy',
SUPERUSER: 'admin',
}),
'system/setting/Downloaders': success({
value: [{ config: { host: 'http://qb' }, name: 'QB', type: 'qbittorrent' }],
}),
'system/setting/MediaServers': success({
value: [{ config: { host: 'http://emby' }, name: 'Emby', sync_libraries: ['Movies'], type: 'emby' }],
}),
'system/setting/Notifications': success({
value: [{ config: { TOKEN: 'token' }, enabled: true, name: 'Bot', switchs: ['下载'], type: 'telegram' }],
}),
'system/setting/UserSiteAuthParams': success({ value: { params: { M_TEAM_KEY: 'key' }, site: 'm-team' } }),
'system/setting/public/Directories': success({
value: [
{
download_path: '/downloads',
library_path: '/media',
overwrite_mode: 'always',
transfer_type: 'copy',
},
],
}),
}
return responses[String(endpoint)] ?? success()
})
const wizard = resetWizard()
const initializing = wizard.initialize()
expect(wizard.isLoading.value).toBe(true)
await initializing
expect(wizard.isLoading.value).toBe(false)
expect(wizard.wizardData.value).toMatchObject({
agent: {
apiKey: 'llm-key',
enabled: true,
global: true,
maxContextTokens: 128,
model: 'deepseek-reasoner',
thinkingLevel: 'high',
},
basic: {
apiToken: 'existing-token',
appDomain: 'https://mp.example.com',
githubToken: 'github-token',
username: 'admin',
},
downloader: { name: 'QB', type: 'qbittorrent' },
mediaServer: { name: 'Emby', sync_libraries: ['Movies'], type: 'emby' },
notification: { enabled: true, name: 'Bot', type: 'telegram' },
siteAuth: { auxiliaryAuthEnable: true, site: 'm-team' },
storage: {
downloadPath: '/downloads',
libraryPath: '/media',
overwriteMode: 'always',
transferType: 'copy',
},
})
expect(wizard.authSites.value).toHaveProperty('m-team')
})
it.each([
[{ LLM_THINKING_LEVEL: 'ENABLED' }, 'auto'],
[{ LLM_DISABLE_THINKING: true, LLM_REASONING_EFFORT: 'high' }, 'off'],
[{ LLM_DISABLE_THINKING: false }, 'auto'],
])('normalizes thinking configuration %#', async (environment, expected) => {
mocks.apiGet.mockImplementation((endpoint: unknown) =>
endpoint === 'system/env' ? success(environment) : success(),
)
const wizard = resetWizard()
await wizard.initialize()
expect(wizard.wizardData.value.agent.thinkingLevel).toBe(expected)
})
it('applies defaults for sparse successful responses and generates a missing API token', async () => {
mocks.apiGet.mockImplementation((endpoint: unknown) => {
const responses: Record<string, unknown> = {
'site/auth': success(null),
'system/env': success({ LLM_TEMPERATURE: 'invalid' }),
'system/setting/Downloaders': success({ value: [{ config: null, name: 'QB', type: 'qbittorrent' }] }),
'system/setting/MediaServers': success({
value: [{ config: null, name: 'Emby', sync_libraries: null, type: 'emby' }],
}),
'system/setting/Notifications': success({
value: [{ config: null, enabled: false, name: 'Bot', switchs: null, type: 'telegram' }],
}),
'system/setting/UserSiteAuthParams': success({ value: {} }),
'system/setting/public/Directories': success({ value: [{}] }),
}
return responses[String(endpoint)] ?? success()
})
const wizard = resetWizard()
await wizard.initialize()
expect(wizard.wizardData.value.basic.apiToken).toHaveLength(32)
expect(wizard.wizardData.value.agent).toMatchObject({
maxContextTokens: 64,
model: '',
provider: 'deepseek',
temperature: 0.3,
})
expect(wizard.wizardData.value.siteAuth).toMatchObject({ params: {}, site: '' })
expect(wizard.wizardData.value.storage).toMatchObject({
downloadPath: '',
libraryPath: '',
overwriteMode: 'never',
transferType: 'link',
})
expect(wizard.wizardData.value.downloader.config).toEqual({})
expect(wizard.wizardData.value.mediaServer).toMatchObject({ config: {}, sync_libraries: [] })
expect(wizard.wizardData.value.notification).toMatchObject({ config: {}, switchs: [] })
expect(wizard.authSites.value).toEqual({})
})
it('keeps defaults and finishes initialization when every load endpoint fails', async () => {
mocks.apiGet.mockRejectedValue(new Error('offline'))
const wizard = resetWizard()
await expect(wizard.initialize()).resolves.toBeUndefined()
expect(wizard.isLoading.value).toBe(false)
expect(wizard.wizardData.value.storage).toMatchObject({
downloadPath: '',
libraryPath: '',
overwriteMode: 'never',
transferType: 'link',
})
})
it('saves the basic environment payload without changing the password when it is blank', async () => {
const wizard = resetWizard()
seedValidStep(wizard, 1)
Object.assign(wizard.wizardData.value.basic, {
appDomain: 'https://mp.example.com',
githubToken: 'github-token',
ocrHost: 'http://ocr',
proxyHost: 'http://proxy',
})
await expect(wizard.nextStep()).resolves.toBe(true)
expect(mocks.apiPost).toHaveBeenCalledWith('system/env', {
API_TOKEN: 'token',
APP_DOMAIN: 'https://mp.example.com',
GITHUB_TOKEN: 'github-token',
OCR_HOST: 'http://ocr',
PROXY_HOST: 'http://proxy',
RECOGNIZE_SOURCE: 'themoviedb',
})
expect(mocks.apiGet).not.toHaveBeenCalled()
expect(wizard.currentStep.value).toBe(2)
})
it('updates the current user password through the complete User contract', async () => {
mocks.apiGet.mockResolvedValue(success(currentUser()))
const wizard = resetWizard()
seedValidStep(wizard, 1)
const passwordValue = ['updated', currentUser().id, 'credential'].join('-')
Object.assign(wizard.wizardData.value.basic, {
confirmPassword: passwordValue,
password: passwordValue,
})
await expect(wizard.nextStep()).resolves.toBe(true)
expect(mocks.apiGet).toHaveBeenCalledWith('user/current')
expect(mocks.apiPut).toHaveBeenCalledWith('user/', {
avatar: 'avatar.png',
email: 'admin@example.com',
id: 7,
is_active: true,
is_otp: true,
is_superuser: true,
name: 'admin',
password: passwordValue,
permissions: { manage: true },
settings: { theme: 'dark' },
})
expect(mocks.apiPost).not.toHaveBeenCalledWith('user/', expect.anything())
})
it('saves site authentication and storage payloads in their owning steps', async () => {
const wizard = resetWizard()
seedValidStep(wizard, 2)
Object.assign(wizard.wizardData.value.siteAuth, {
auxiliaryAuthEnable: true,
params: { 'M-TEAM_API_KEY': 'key' },
site: 'm-team',
})
wizard.authSites.value = {
'm-team': { icon: '', name: 'M-Team', params: { api_key: { name: 'API Key', type: 'text' } } },
}
await expect(wizard.nextStep()).resolves.toBe(true)
expect(mocks.apiPost.mock.calls).toEqual([
['system/env', { AUXILIARY_AUTH_ENABLE: true }],
['site/auth', { params: { 'M-TEAM_API_KEY': 'key' }, site: 'm-team' }],
])
mocks.apiPost.mockClear()
seedValidStep(wizard, 3)
vi.useFakeTimers()
await resolveConnectivity(wizard.nextStep())
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'system/setting/Storages', [
{ config: {}, name: '本地存储', type: 'local' },
])
expect(mocks.apiPost).toHaveBeenNthCalledWith(
2,
'system/setting/Directories',
expect.arrayContaining([
expect.objectContaining({
download_path: '/downloads',
library_path: '/media',
overwrite_mode: 'never',
transfer_type: 'link',
}),
]),
)
expect(mocks.apiGet).toHaveBeenCalledWith('system/moduletest/FileManagerModule')
})
it('saves auxiliary authentication without a site request when no site is selected', async () => {
const wizard = resetWizard()
seedValidStep(wizard, 2)
wizard.wizardData.value.siteAuth.auxiliaryAuthEnable = true
await expect(wizard.nextStep()).resolves.toBe(true)
expect(mocks.apiPost.mock.calls).toEqual([['system/env', { AUXILIARY_AUTH_ENABLE: true }]])
})
it.each([
[4, 'system/setting/Downloaders', 'system/moduletest/QbittorrentModule'],
[5, 'system/setting/MediaServers', 'system/moduletest/EmbyModule'],
[6, 'system/setting/Notifications', 'system/moduletest/TelegramModule'],
])('saves and tests step %i before advancing', async (step, saveEndpoint, testEndpoint) => {
vi.useFakeTimers()
const wizard = resetWizard()
seedValidStep(wizard, step)
await expect(resolveConnectivity(wizard.nextStep())).resolves.toBe(true)
expect(mocks.apiPost).toHaveBeenCalledWith(saveEndpoint, expect.any(Array))
expect(mocks.apiGet).toHaveBeenCalledWith(testEndpoint)
expect(wizard.currentStep.value).toBe(step + 1)
})
it('skips optional service writes and tests when no service is selected', async () => {
const wizard = resetWizard()
for (const step of [4, 5, 6]) {
wizard.currentStep.value = step
await expect(wizard.nextStep()).resolves.toBe(true)
}
expect(mocks.apiPost).not.toHaveBeenCalled()
expect(mocks.apiGet).not.toHaveBeenCalled()
expect(wizard.currentStep.value).toBe(7)
})
it('saves the full agent environment mapping', async () => {
const wizard = resetWizard()
seedValidStep(wizard, 7)
Object.assign(wizard.wizardData.value.agent, {
apiKey: 'llm-key',
enabled: true,
global: true,
model: 'deepseek-chat',
provider: 'deepseek',
recommendEnabled: true,
retryTransfer: true,
})
await expect(wizard.nextStep()).resolves.toBe(true)
expect(mocks.apiPost).toHaveBeenCalledWith(
'system/env',
expect.objectContaining({
AI_AGENT_ENABLE: true,
AI_AGENT_GLOBAL: true,
AI_AGENT_RETRY_TRANSFER: true,
AI_RECOMMEND_ENABLED: true,
LLM_API_KEY: 'llm-key',
LLM_MODEL: 'deepseek-chat',
LLM_PROVIDER: 'deepseek',
}),
)
expect(wizard.currentStep.value).toBe(8)
})
it('normalizes disabled agent payload values and invalid temperature', async () => {
const wizard = resetWizard()
seedValidStep(wizard, 7)
Object.assign(wizard.wizardData.value.agent, {
apiProtocol: '',
audioInputProvider: '',
audioOutputProvider: '',
baseUrl: '',
enabled: false,
global: true,
jobInterval: 15,
retryTransfer: true,
temperature: Number.NaN,
verbose: true,
webSearchMode: '',
})
await expect(wizard.nextStep()).resolves.toBe(true)
expect(mocks.apiPost).toHaveBeenCalledWith(
'system/env',
expect.objectContaining({
AI_AGENT_GLOBAL: false,
AI_AGENT_JOB_INTERVAL: 0,
AI_AGENT_RETRY_TRANSFER: false,
AI_AGENT_VERBOSE: false,
AUDIO_INPUT_PROVIDER: 'openai',
AUDIO_OUTPUT_PROVIDER: 'openai',
LLM_API_PROTOCOL: 'auto',
LLM_BASE_URL: null,
LLM_TEMPERATURE: 0.3,
LLM_WEB_SEARCH_MODE: 'local',
}),
)
})
it('saves all preference rule groups before reporting the final step as saved', async () => {
const wizard = resetWizard()
seedValidStep(wizard, 8)
wizard.wizardData.value.preferences.ruleSequences = [
{ category: '电影', media_type: '电影', name: '高画质', rule_string: '4K' },
{ category: '电视剧', media_type: '电视剧', name: '小体积', rule_string: '1080P' },
]
await expect(wizard.nextStep()).resolves.toBe(true)
expect(mocks.apiPost.mock.calls).toEqual([
['system/setting/UserFilterRuleGroups', wizard.wizardData.value.preferences.ruleSequences],
['system/setting/SubscribeFilterRuleGroups', ['高画质', '小体积']],
['system/setting/BestVersionFilterRuleGroups', ['高画质', '小体积']],
])
expect(wizard.currentStep.value).toBe(8)
})
})
describe('useSetupWizard failure and concurrency boundaries', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiPut.mockReset()
mocks.routerPush.mockReset()
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
mocks.apiGet.mockResolvedValue(success())
mocks.apiPost.mockResolvedValue(success())
mocks.apiPut.mockResolvedValue(success())
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
resetWizard()
})
it.each([
['business failure', businessFailure('保存被拒绝')],
['HTTP failure', new Error('offline')],
])('does not advance when a step save has a %s', async (_label, failure) => {
const wizard = resetWizard()
seedValidStep(wizard, 1)
if (failure instanceof Error) mocks.apiPost.mockRejectedValueOnce(failure)
else mocks.apiPost.mockResolvedValueOnce(failure)
await expect(wizard.nextStep()).resolves.toBe(false)
expect(wizard.currentStep.value).toBe(1)
expect(mocks.toastError).toHaveBeenCalledWith('setupWizard.saveBasicSettingsFailed')
})
it.each([
['business failure', businessFailure('测试被拒绝')],
['HTTP failure', new Error('offline')],
])('does not advance when connectivity has a %s', async (_label, failure) => {
vi.useFakeTimers()
const wizard = resetWizard()
seedValidStep(wizard, 4)
if (failure instanceof Error) mocks.apiGet.mockRejectedValueOnce(failure)
else mocks.apiGet.mockResolvedValueOnce(failure)
await expect(resolveConnectivity(wizard.nextStep())).resolves.toBe(false)
expect(wizard.currentStep.value).toBe(4)
expect(wizard.connectivityTest.value).toMatchObject({
isTesting: false,
showResult: true,
testResult: 'error',
})
})
it('rejects unsupported connectivity types without calling a module endpoint', async () => {
vi.useFakeTimers()
const wizard = resetWizard()
wizard.wizardData.value.downloader.type = 'unknown'
await expect(resolveConnectivity(wizard.testConnectivity(4))).resolves.toBe(false)
expect(mocks.apiGet).not.toHaveBeenCalled()
expect(wizard.connectivityTest.value.testMessage).toBe('setupWizard.unsupportedDownloaderType:unknown')
wizard.wizardData.value.mediaServer.type = 'unknown'
await expect(resolveConnectivity(wizard.testConnectivity(5))).resolves.toBe(false)
wizard.wizardData.value.notification.type = 'unknown'
await expect(resolveConnectivity(wizard.testConnectivity(6))).resolves.toBe(false)
})
it('allows only one next action while the current save is pending', async () => {
const pendingSave = deferred<ReturnType<typeof success>>()
mocks.apiPost.mockReturnValue(pendingSave.promise)
const wizard = resetWizard()
seedValidStep(wizard, 1)
const first = wizard.nextStep()
const second = wizard.nextStep()
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
pendingSave.resolve(success())
await expect(Promise.all([first, second])).resolves.toEqual([true, false])
expect(wizard.currentStep.value).toBe(2)
})
it('does not move backward while a next action is pending', async () => {
const pendingSave = deferred<ReturnType<typeof success>>()
mocks.apiPost.mockReturnValue(pendingSave.promise)
const wizard = resetWizard()
seedValidStep(wizard, 2)
const next = wizard.nextStep()
wizard.prevStep()
expect(wizard.currentStep.value).toBe(2)
pendingSave.resolve(success())
await expect(next).resolves.toBe(true)
expect(wizard.currentStep.value).toBe(3)
})
it('uses the starting-step snapshot when a save result arrives late', async () => {
const pendingSave = deferred<ReturnType<typeof success>>()
mocks.apiPost.mockReturnValue(pendingSave.promise)
const wizard = resetWizard()
seedValidStep(wizard, 2)
const next = wizard.nextStep()
wizard.currentStep.value = 6
pendingSave.resolve(success())
await expect(next).resolves.toBe(false)
expect(wizard.currentStep.value).toBe(6)
expect(mocks.apiGet).not.toHaveBeenCalled()
})
it.each([
['system/setting/UserFilterRuleGroups'],
['system/setting/SubscribeFilterRuleGroups'],
['system/setting/BestVersionFilterRuleGroups'],
])('does not complete when %s rejects its rule payload', async failingEndpoint => {
mocks.apiPost.mockImplementation((endpoint: unknown) =>
endpoint === failingEndpoint ? businessFailure('规则保存失败') : success(),
)
const wizard = resetWizard()
seedValidStep(wizard, 8)
wizard.wizardData.value.preferences.ruleSequences = [
{ category: '电影', media_type: '电影', name: '高画质', rule_string: '4K' },
]
await wizard.completeWizard()
expect(mocks.apiPost).not.toHaveBeenCalledWith('system/setting/SetupWizardState', '1')
expect(mocks.routerPush).not.toHaveBeenCalled()
})
it('treats SetupWizardState as best-effort and still navigates after all required writes succeed', async () => {
mocks.apiPost.mockImplementation((endpoint: unknown) =>
endpoint === 'system/setting/SetupWizardState' ? businessFailure('状态保存失败') : success(),
)
const wizard = resetWizard()
seedValidStep(wizard, 8)
await wizard.completeWizard()
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/SetupWizardState', '1')
expect(mocks.toastSuccess).toHaveBeenCalledWith('setupWizard.completed')
expect(mocks.routerPush).toHaveBeenCalledWith('/')
})
})
+94 -79
View File
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { copyToClipboard } from '@/@core/utils/navigator'
import type { User } from '@/api/types'
import type { DownloaderConf, MediaServerConf, NotificationConf, TransferDirectoryConf, User } from '@/api/types'
export interface WizardData {
basic: {
@@ -32,21 +32,21 @@ export interface WizardData {
downloader: {
type: string
name: string
config: any
config: DownloaderConf['config']
}
mediaServer: {
type: string
name: string
config: any
sync_libraries: any[]
switchs: any[]
config: MediaServerConf['config']
sync_libraries: NonNullable<MediaServerConf['sync_libraries']>
switchs: unknown[]
}
notification: {
type: string
name: string
enabled: boolean
config: any
switchs: any[]
config: NotificationConf['config']
switchs: NonNullable<NotificationConf['switchs']>
}
agent: {
enabled: boolean
@@ -162,7 +162,7 @@ function normalizeThinkingLevelValue(value?: unknown) {
return aliasMap[normalized] || normalized
}
function resolveThinkingLevelValue(data?: Record<string, any>) {
function resolveThinkingLevelValue(data?: Record<string, unknown>) {
const explicit = normalizeThinkingLevelValue(data?.LLM_THINKING_LEVEL)
if (explicit) return explicit
@@ -179,6 +179,9 @@ const totalSteps = 8
// 加载状态
const isLoading = ref(false)
// 保存、连通性测试和完成状态写入共享同一个提交门禁。
const isActionPending = ref(false)
// 选中的预设规则
const selectedPreset = ref('')
@@ -477,10 +480,6 @@ export function useSetupWizard() {
wizardData.value.preferences.resolution = '2160p'
break
case 'balanced':
wizardData.value.preferences.quality = '1080P'
wizardData.value.preferences.subtitle = 'chinese'
wizardData.value.preferences.resolution = '1080p'
break
case 'chinese':
wizardData.value.preferences.quality = '1080P'
wizardData.value.preferences.subtitle = 'chinese'
@@ -639,7 +638,11 @@ export function useSetupWizard() {
errors.push(t('mediaserver.apiKeyRequired'))
validationErrors.value.mediaServer.apikey = true
}
} else if (wizardData.value.mediaServer.type === 'zspace') {
} else if (
wizardData.value.mediaServer.type === 'zspace' ||
wizardData.value.mediaServer.type === 'trimemedia' ||
wizardData.value.mediaServer.type === 'ugreen'
) {
if (!wizardData.value.mediaServer.config?.username?.trim()) {
errors.push(t('mediaserver.usernameRequired'))
validationErrors.value.mediaServer.username = true
@@ -653,15 +656,6 @@ export function useSetupWizard() {
errors.push(t('mediaserver.tokenRequired'))
validationErrors.value.mediaServer.token = true
}
} else if (wizardData.value.mediaServer.type === 'trimemedia' || wizardData.value.mediaServer.type === 'ugreen') {
if (!wizardData.value.mediaServer.config?.username?.trim()) {
errors.push(t('mediaserver.usernameRequired'))
validationErrors.value.mediaServer.username = true
}
if (!wizardData.value.mediaServer.config?.password?.trim()) {
errors.push(t('mediaserver.passwordRequired'))
validationErrors.value.mediaServer.password = true
}
}
return {
@@ -958,7 +952,7 @@ export function useSetupWizard() {
connectivityTest.value.testMessage = testResult.message || t('setupWizard.connectivityTestFailed')
}
// 成功时2秒后隐藏结果,失败时保持显示直到用户操作
// 成功时立即收起结果,失败时保持显示以便用户判断和重试。
if (testResult.success) {
connectivityTest.value.showResult = false
connectivityTest.value.testResult = null
@@ -1094,8 +1088,12 @@ export function useSetupWizard() {
}
}
// 下一步
async function nextStep() {
/**
* 保存并验证动作开始时所属的步骤。
*
* 异步请求返回后只有步骤仍未变化才允许继续,避免迟到结果推进另一个步骤。
*/
async function runStepAction(step: number) {
// 验证当前步骤的必输项
const validation = validateCurrentStep()
if (!validation.isValid) {
@@ -1107,31 +1105,46 @@ export function useSetupWizard() {
}
// 保存当前步骤的设置
const saved = await saveCurrentStepSettings()
if (!saved) {
const saved = await saveCurrentStepSettings(step)
if (!saved || currentStep.value !== step) {
return false
}
// 检查是否需要进行测试
const needsTest = shouldPerformTest(currentStep.value)
const needsTest = shouldPerformTest(step)
if (needsTest) {
const testResult = await testConnectivity(currentStep.value)
if (!testResult) {
const testResult = await testConnectivity(step)
if (!testResult || currentStep.value !== step) {
return false
}
}
// 如果不是最后一步,则前进到下一步
if (currentStep.value < totalSteps) {
currentStep.value++
if (step < totalSteps) {
currentStep.value = step + 1
connectivityTest.value.showResult = false
}
return true
}
// 下一步
async function nextStep() {
if (isActionPending.value) return false
isActionPending.value = true
const step = currentStep.value
try {
return await runStepAction(step)
} finally {
isActionPending.value = false
}
}
// 上一步
function prevStep() {
if (isActionPending.value) return
if (currentStep.value > 1) {
currentStep.value--
}
@@ -1139,9 +1152,9 @@ export function useSetupWizard() {
}
// 保存当前步骤的设置
async function saveCurrentStepSettings() {
async function saveCurrentStepSettings(step: number) {
try {
switch (currentStep.value) {
switch (step) {
case 1:
return await saveBasicSettings()
case 2:
@@ -1169,9 +1182,12 @@ export function useSetupWizard() {
// 完成向导
async function completeWizard() {
if (isActionPending.value) return
isActionPending.value = true
const step = currentStep.value
try {
// 先处理下一步(保存当前步骤设置)
const saved = await nextStep()
const saved = await runStepAction(step)
if (!saved) {
return
}
@@ -1183,6 +1199,8 @@ export function useSetupWizard() {
} catch (error) {
console.error('Setup wizard failed:', error)
$toast.error(t('setupWizard.failed'))
} finally {
isActionPending.value = false
}
}
@@ -1193,27 +1211,19 @@ export function useSetupWizard() {
// 获取当前用户信息
const currentUser: User = await api.get('user/current')
if (currentUser) {
// 更新现有用户的密码
const userData = {
name: wizardData.value.basic.username,
password: wizardData.value.basic.password,
is_active: currentUser.is_active,
is_superuser: currentUser.is_superuser,
}
await api.put(`user/${currentUser.id}`, userData)
} else {
// 如果用户不存在,创建新用户(通常不会发生)
const userData = {
name: wizardData.value.basic.username,
password: wizardData.value.basic.password,
is_active: true,
is_superuser: true,
}
await api.post('user/', userData)
}
// 用户更新是完整资料契约,所有现有资料必须随新密码原样提交。
await api.put('user/', {
id: currentUser.id,
name: wizardData.value.basic.username,
email: currentUser.email,
password: wizardData.value.basic.password,
is_active: currentUser.is_active,
is_superuser: currentUser.is_superuser,
avatar: currentUser.avatar,
is_otp: currentUser.is_otp,
permissions: currentUser.permissions,
settings: currentUser.settings,
})
} catch (error) {
console.error('Update user password failed:', error)
throw error
@@ -1349,7 +1359,7 @@ export function useSetupWizard() {
return false
}
} else {
// 没有选择下载器时,清空现有配置
// 选择下载器时跳过保存,保留现有配置
console.log('No downloader selected, skipping save')
return true
}
@@ -1379,7 +1389,7 @@ export function useSetupWizard() {
return false
}
} else {
// 没有选择媒体服务器时,清空现有配置
// 选择媒体服务器时跳过保存,保留现有配置
console.log('No media server selected, skipping save')
return true
}
@@ -1409,7 +1419,7 @@ export function useSetupWizard() {
return false
}
} else {
// 没有选择通知时,清空现有配置
// 选择通知时跳过保存,保留现有配置
console.log('No notification selected, skipping save')
return true
}
@@ -1470,22 +1480,18 @@ export function useSetupWizard() {
try {
// 如果有自定义规则序列,保存到用户过滤规则组
if (wizardData.value.preferences.ruleSequences && wizardData.value.preferences.ruleSequences.length > 0) {
try {
// 保存当前选中的规则组到 UserFilterRuleGroups
await api.post<null>('system/setting/UserFilterRuleGroups', wizardData.value.preferences.ruleSequences, {
feedback: 'silent',
})
// 保存规则组名称到其他设置
const ruleGroupNames = wizardData.value.preferences.ruleSequences.map(rule => [rule.name])
// 保存当前选中的规则组到 UserFilterRuleGroups
await api.post<null>('system/setting/UserFilterRuleGroups', wizardData.value.preferences.ruleSequences, {
feedback: 'silent',
})
// 订阅与洗版设置只引用规则组名称,不存储完整规则或嵌套数组。
const ruleGroupNames = wizardData.value.preferences.ruleSequences.map(rule => rule.name)
// 保存到 SubscribeFilterRuleGroups
await api.post<null>('system/setting/SubscribeFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
// 保存到 SubscribeFilterRuleGroups
await api.post<null>('system/setting/SubscribeFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
// 保存到 BestVersionFilterRuleGroups
await api.post<null>('system/setting/BestVersionFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
} catch (error) {
console.error('Save rule sequences failed:', error)
}
// 保存到 BestVersionFilterRuleGroups
await api.post<null>('system/setting/BestVersionFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
}
return true
} catch (error) {
@@ -1588,7 +1594,7 @@ export function useSetupWizard() {
// 加载用户站点认证设置
async function loadSiteAuthSettings() {
try {
const result = await api.get<{ value?: { site?: string; params?: Record<string, any> } }>(
const result = await api.get<{ value?: { site?: string; params?: Record<string, string | number> } }>(
'system/setting/UserSiteAuthParams',
)
if (result.value) {
@@ -1603,7 +1609,9 @@ export function useSetupWizard() {
// 加载存储设置
async function loadStorageSettings() {
try {
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/public/Directories')
const result = await api.get<{ value?: Array<Partial<TransferDirectoryConf>> }>(
'system/setting/public/Directories',
)
if (result.value?.length) {
const directory = result.value[0]
wizardData.value.storage.downloadPath = directory.download_path || ''
@@ -1619,7 +1627,9 @@ export function useSetupWizard() {
// 加载下载器设置
async function loadDownloaderSettings() {
try {
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/Downloaders')
const result = await api.get<{ value?: Array<Pick<DownloaderConf, 'type' | 'name' | 'config'>> }>(
'system/setting/Downloaders',
)
if (result.value?.length) {
const downloader = result.value[0]
wizardData.value.downloader.type = downloader.type
@@ -1634,7 +1644,9 @@ export function useSetupWizard() {
// 加载媒体服务器设置
async function loadMediaServerSettings() {
try {
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/MediaServers')
const result = await api.get<{
value?: Array<Pick<MediaServerConf, 'type' | 'name' | 'config' | 'sync_libraries'>>
}>('system/setting/MediaServers')
if (result.value?.length) {
const mediaServer = result.value[0]
wizardData.value.mediaServer.type = mediaServer.type
@@ -1650,7 +1662,9 @@ export function useSetupWizard() {
// 加载通知设置
async function loadNotificationSettings() {
try {
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/Notifications')
const result = await api.get<{
value?: Array<Pick<NotificationConf, 'type' | 'name' | 'enabled' | 'config' | 'switchs'>>
}>('system/setting/Notifications')
if (result.value?.length) {
const notification = result.value[0]
wizardData.value.notification.type = notification.type
@@ -1692,6 +1706,7 @@ export function useSetupWizard() {
connectivityTest,
validationErrors,
isLoading,
isActionPending,
// 方法
createRandomString,
+258
View File
@@ -0,0 +1,258 @@
import SetupPage from '@/pages/setup.vue'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, type Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
completeWizard: vi.fn(),
initialize: vi.fn(),
nextStep: vi.fn(),
prevStep: vi.fn(),
routerPush: vi.fn(),
state: undefined as
| undefined
| {
connectivityTest: Ref<{ isTesting: boolean }>
currentStep: ReturnType<typeof ref<number>>
isActionPending: ReturnType<typeof ref<boolean>>
isLoading: ReturnType<typeof ref<boolean>>
stepTitles: ReturnType<typeof ref<string[]>>
},
}))
vi.mock('@/composables/useSetupWizard', () => ({
useSetupWizard: () => ({
completeWizard: mocks.completeWizard,
connectivityTest: mocks.state!.connectivityTest,
currentStep: mocks.state!.currentStep,
initialize: mocks.initialize,
isActionPending: mocks.state!.isActionPending,
isLoading: mocks.state!.isLoading,
nextStep: mocks.nextStep,
prevStep: mocks.prevStep,
stepTitles: mocks.state!.stepTitles,
totalSteps: 8,
}),
}))
vi.mock('vue-i18n', async importOriginal => ({
...(await importOriginal<typeof import('vue-i18n')>()),
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vue-router', async importOriginal => ({
...(await importOriginal<typeof import('vue-router')>()),
useRouter: () => ({ push: mocks.routerPush }),
}))
vi.mock('vuetify', async importOriginal => ({
...(await importOriginal<typeof import('vuetify')>()),
useDisplay: () => ({ mdAndUp: ref(true), smAndDown: ref(false) }),
}))
const ButtonStub = defineComponent({
inheritAttrs: false,
props: {
disabled: Boolean,
icon: String,
prependIcon: String,
},
emits: ['click'],
setup(props, { emit, slots }) {
return () =>
h(
'button',
{
'aria-label': props.icon || props.prependIcon,
disabled: props.disabled,
onClick: () => emit('click'),
},
slots.default?.(),
)
},
})
const PassThrough = defineComponent({
setup:
(_, { slots }) =>
() =>
h('div', slots.default?.()),
})
const StepperItemStub = defineComponent({
setup:
(_, { slots }) =>
() =>
h('div', { 'data-testid': 'step-title' }, slots.title?.()),
})
const StepperStub = defineComponent({
props: { modelValue: Number },
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
return () =>
h('div', [
h(
'button',
{
'data-testid': 'stepper-update',
onClick: () => emit('update:modelValue', (props.modelValue ?? 1) + 1),
},
'update step',
),
slots.default?.(),
])
},
})
function stepStub(name: string) {
return defineComponent({
name,
setup: () => () => h('section', { 'data-step': name }, name),
})
}
const globalStubs = {
AgentSettingsStep: stepStub('AgentSettingsStep'),
BasicSettingsStep: stepStub('BasicSettingsStep'),
ConnectivityTest: stepStub('ConnectivityTest'),
DownloaderSettingsStep: stepStub('DownloaderSettingsStep'),
MediaServerSettingsStep: stepStub('MediaServerSettingsStep'),
NotificationSettingsStep: stepStub('NotificationSettingsStep'),
PreferencesSettingsStep: stepStub('PreferencesSettingsStep'),
SiteAuthSettingsStep: stepStub('SiteAuthSettingsStep'),
StorageSettingsStep: stepStub('StorageSettingsStep'),
VBtn: ButtonStub,
VCard: PassThrough,
VCardActions: PassThrough,
VCardText: PassThrough,
VDivider: true,
VProgressCircular: stepStub('LoadingIndicator'),
VStepper: StepperStub,
VStepperHeader: PassThrough,
VStepperItem: StepperItemStub,
VStepperWindow: PassThrough,
VStepperWindowItem: PassThrough,
}
function mountPage() {
return mount(SetupPage, { global: { stubs: globalStubs } })
}
describe('setup page orchestration', () => {
beforeEach(() => {
mocks.completeWizard.mockReset()
mocks.initialize.mockReset()
mocks.nextStep.mockReset()
mocks.prevStep.mockReset()
mocks.routerPush.mockReset()
mocks.initialize.mockResolvedValue(undefined)
mocks.state = {
connectivityTest: ref({ isTesting: false }),
currentStep: ref(1),
isActionPending: ref(false),
isLoading: ref(false),
stepTitles: ref(Array.from({ length: 8 }, (_, index) => `step-${index + 1}`)),
}
})
it('initializes and renders the loading state instead of setup steps', async () => {
mocks.state!.isLoading.value = true
const wrapper = mountPage()
expect(mocks.initialize).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('setupWizard.loading')
expect(wrapper.find('[data-step="LoadingIndicator"]').exists()).toBe(true)
expect(wrapper.find('[data-step="BasicSettingsStep"]').exists()).toBe(false)
})
it('composes all eight step components without duplicating their business templates', () => {
const wrapper = mountPage()
expect(wrapper.findAll('[data-step$="SettingsStep"]').map(node => node.attributes('data-step'))).toEqual([
'BasicSettingsStep',
'SiteAuthSettingsStep',
'StorageSettingsStep',
'DownloaderSettingsStep',
'MediaServerSettingsStep',
'NotificationSettingsStep',
'AgentSettingsStep',
'PreferencesSettingsStep',
])
expect(wrapper.text()).toContain('step-1')
expect(wrapper.text()).toContain('step-8')
})
it('keeps the current step synchronized with stepper model updates', async () => {
const wrapper = mountPage()
await wrapper.get('[data-testid="stepper-update"]').trigger('click')
expect(mocks.state!.currentStep.value).toBe(2)
})
it('routes header commands to settings and home', async () => {
const wrapper = mountPage()
await wrapper.get('button[aria-label="mdi-cog"]').trigger('click')
await wrapper.get('button[aria-label="mdi-close"]').trigger('click')
expect(mocks.routerPush.mock.calls).toEqual([['/setting'], ['/']])
})
it('delegates previous and next while the action is idle', async () => {
mocks.state!.currentStep.value = 4
const wrapper = mountPage()
await wrapper.get('button[aria-label="mdi-chevron-left"]').trigger('click')
const next = wrapper.findAll('button').find(button => button.text().includes('common.next'))
expect(next).toBeDefined()
await next!.trigger('click')
expect(mocks.prevStep).toHaveBeenCalledTimes(1)
expect(mocks.nextStep).toHaveBeenCalledTimes(1)
})
it('disables previous and next for the entire pending action, including save time', async () => {
mocks.state!.currentStep.value = 4
mocks.state!.isActionPending.value = true
const wrapper = mountPage()
const previous = wrapper.get('button[aria-label="mdi-chevron-left"]')
const next = wrapper.findAll('button').find(button => button.text().includes('common.next'))
expect(previous.attributes('disabled')).toBeDefined()
expect(next).toBeDefined()
expect(next!.attributes('disabled')).toBeDefined()
await previous.trigger('click')
await next!.trigger('click')
expect(mocks.prevStep).not.toHaveBeenCalled()
expect(mocks.nextStep).not.toHaveBeenCalled()
})
it('labels the pending next action as connectivity testing while its test is running', () => {
mocks.state!.currentStep.value = 4
mocks.state!.isActionPending.value = true
mocks.state!.connectivityTest.value.isTesting = true
const wrapper = mountPage()
expect(wrapper.text()).toContain('setupWizard.testing')
expect(wrapper.text()).not.toContain('common.next')
})
it('disables complete for the entire pending action and delegates it when idle', async () => {
mocks.state!.currentStep.value = 8
mocks.state!.isActionPending.value = true
const pendingWrapper = mountPage()
const pendingComplete = pendingWrapper.get('button[aria-label="mdi-check"]')
expect(pendingComplete.attributes('disabled')).toBeDefined()
await pendingComplete.trigger('click')
expect(mocks.completeWizard).not.toHaveBeenCalled()
pendingWrapper.unmount()
mocks.state!.isActionPending.value = false
const idleWrapper = mountPage()
await idleWrapper.get('button[aria-label="mdi-check"]').trigger('click')
expect(mocks.completeWizard).toHaveBeenCalledTimes(1)
})
})
+4 -9
View File
@@ -30,6 +30,7 @@ const {
completeWizard,
initialize,
isLoading,
isActionPending,
} = useSetupWizard()
// 初始化
@@ -149,7 +150,7 @@ onMounted(async () => {
v-if="currentStep !== 1"
prepend-icon="mdi-chevron-left"
@click="prevStep"
:disabled="connectivityTest.isTesting"
:disabled="isActionPending"
>
{{ t('common.previous') }}
</VBtn>
@@ -161,17 +162,11 @@ onMounted(async () => {
color="primary"
append-icon="mdi-chevron-right"
@click="nextStep"
:disabled="connectivityTest.isTesting"
:disabled="isActionPending"
>
{{ connectivityTest.isTesting ? t('setupWizard.testing') : t('common.next') }}
</VBtn>
<VBtn
v-else
color="success"
prepend-icon="mdi-check"
@click="completeWizard"
:disabled="connectivityTest.isTesting"
>
<VBtn v-else color="success" prepend-icon="mdi-check" @click="completeWizard" :disabled="isActionPending">
{{ t('setupWizard.complete') }}
</VBtn>
</div>