mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +08:00
fix(setup): protect wizard step orchestration (#679)
This commit is contained in:
@@ -552,10 +552,7 @@
|
|||||||
},
|
},
|
||||||
"src/composables/useSetupWizard.ts": {
|
"src/composables/useSetupWizard.ts": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 13
|
"count": 1
|
||||||
},
|
|
||||||
"sonarjs/no-duplicated-branches": {
|
|
||||||
"count": 2
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/composables/useSharedDialog.ts": {
|
"src/composables/useSharedDialog.ts": {
|
||||||
|
|||||||
@@ -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('/')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
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 {
|
export interface WizardData {
|
||||||
basic: {
|
basic: {
|
||||||
@@ -32,21 +32,21 @@ export interface WizardData {
|
|||||||
downloader: {
|
downloader: {
|
||||||
type: string
|
type: string
|
||||||
name: string
|
name: string
|
||||||
config: any
|
config: DownloaderConf['config']
|
||||||
}
|
}
|
||||||
mediaServer: {
|
mediaServer: {
|
||||||
type: string
|
type: string
|
||||||
name: string
|
name: string
|
||||||
config: any
|
config: MediaServerConf['config']
|
||||||
sync_libraries: any[]
|
sync_libraries: NonNullable<MediaServerConf['sync_libraries']>
|
||||||
switchs: any[]
|
switchs: unknown[]
|
||||||
}
|
}
|
||||||
notification: {
|
notification: {
|
||||||
type: string
|
type: string
|
||||||
name: string
|
name: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
config: any
|
config: NotificationConf['config']
|
||||||
switchs: any[]
|
switchs: NonNullable<NotificationConf['switchs']>
|
||||||
}
|
}
|
||||||
agent: {
|
agent: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -162,7 +162,7 @@ function normalizeThinkingLevelValue(value?: unknown) {
|
|||||||
return aliasMap[normalized] || normalized
|
return aliasMap[normalized] || normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveThinkingLevelValue(data?: Record<string, any>) {
|
function resolveThinkingLevelValue(data?: Record<string, unknown>) {
|
||||||
const explicit = normalizeThinkingLevelValue(data?.LLM_THINKING_LEVEL)
|
const explicit = normalizeThinkingLevelValue(data?.LLM_THINKING_LEVEL)
|
||||||
if (explicit) return explicit
|
if (explicit) return explicit
|
||||||
|
|
||||||
@@ -179,6 +179,9 @@ const totalSteps = 8
|
|||||||
// 加载状态
|
// 加载状态
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
// 保存、连通性测试和完成状态写入共享同一个提交门禁。
|
||||||
|
const isActionPending = ref(false)
|
||||||
|
|
||||||
// 选中的预设规则
|
// 选中的预设规则
|
||||||
const selectedPreset = ref('')
|
const selectedPreset = ref('')
|
||||||
|
|
||||||
@@ -477,10 +480,6 @@ export function useSetupWizard() {
|
|||||||
wizardData.value.preferences.resolution = '2160p'
|
wizardData.value.preferences.resolution = '2160p'
|
||||||
break
|
break
|
||||||
case 'balanced':
|
case 'balanced':
|
||||||
wizardData.value.preferences.quality = '1080P'
|
|
||||||
wizardData.value.preferences.subtitle = 'chinese'
|
|
||||||
wizardData.value.preferences.resolution = '1080p'
|
|
||||||
break
|
|
||||||
case 'chinese':
|
case 'chinese':
|
||||||
wizardData.value.preferences.quality = '1080P'
|
wizardData.value.preferences.quality = '1080P'
|
||||||
wizardData.value.preferences.subtitle = 'chinese'
|
wizardData.value.preferences.subtitle = 'chinese'
|
||||||
@@ -639,7 +638,11 @@ export function useSetupWizard() {
|
|||||||
errors.push(t('mediaserver.apiKeyRequired'))
|
errors.push(t('mediaserver.apiKeyRequired'))
|
||||||
validationErrors.value.mediaServer.apikey = true
|
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()) {
|
if (!wizardData.value.mediaServer.config?.username?.trim()) {
|
||||||
errors.push(t('mediaserver.usernameRequired'))
|
errors.push(t('mediaserver.usernameRequired'))
|
||||||
validationErrors.value.mediaServer.username = true
|
validationErrors.value.mediaServer.username = true
|
||||||
@@ -653,15 +656,6 @@ export function useSetupWizard() {
|
|||||||
errors.push(t('mediaserver.tokenRequired'))
|
errors.push(t('mediaserver.tokenRequired'))
|
||||||
validationErrors.value.mediaServer.token = true
|
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 {
|
return {
|
||||||
@@ -958,7 +952,7 @@ export function useSetupWizard() {
|
|||||||
connectivityTest.value.testMessage = testResult.message || t('setupWizard.connectivityTestFailed')
|
connectivityTest.value.testMessage = testResult.message || t('setupWizard.connectivityTestFailed')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成功时2秒后隐藏结果,失败时保持显示直到用户操作
|
// 成功时立即收起结果,失败时保持显示以便用户判断和重试。
|
||||||
if (testResult.success) {
|
if (testResult.success) {
|
||||||
connectivityTest.value.showResult = false
|
connectivityTest.value.showResult = false
|
||||||
connectivityTest.value.testResult = null
|
connectivityTest.value.testResult = null
|
||||||
@@ -1094,8 +1088,12 @@ export function useSetupWizard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下一步
|
/**
|
||||||
async function nextStep() {
|
* 保存并验证动作开始时所属的步骤。
|
||||||
|
*
|
||||||
|
* 异步请求返回后只有步骤仍未变化才允许继续,避免迟到结果推进另一个步骤。
|
||||||
|
*/
|
||||||
|
async function runStepAction(step: number) {
|
||||||
// 验证当前步骤的必输项
|
// 验证当前步骤的必输项
|
||||||
const validation = validateCurrentStep()
|
const validation = validateCurrentStep()
|
||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
@@ -1107,31 +1105,46 @@ export function useSetupWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 保存当前步骤的设置
|
// 保存当前步骤的设置
|
||||||
const saved = await saveCurrentStepSettings()
|
const saved = await saveCurrentStepSettings(step)
|
||||||
if (!saved) {
|
if (!saved || currentStep.value !== step) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否需要进行测试
|
// 检查是否需要进行测试
|
||||||
const needsTest = shouldPerformTest(currentStep.value)
|
const needsTest = shouldPerformTest(step)
|
||||||
if (needsTest) {
|
if (needsTest) {
|
||||||
const testResult = await testConnectivity(currentStep.value)
|
const testResult = await testConnectivity(step)
|
||||||
if (!testResult) {
|
if (!testResult || currentStep.value !== step) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果不是最后一步,则前进到下一步
|
// 如果不是最后一步,则前进到下一步
|
||||||
if (currentStep.value < totalSteps) {
|
if (step < totalSteps) {
|
||||||
currentStep.value++
|
currentStep.value = step + 1
|
||||||
connectivityTest.value.showResult = false
|
connectivityTest.value.showResult = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
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() {
|
function prevStep() {
|
||||||
|
if (isActionPending.value) return
|
||||||
|
|
||||||
if (currentStep.value > 1) {
|
if (currentStep.value > 1) {
|
||||||
currentStep.value--
|
currentStep.value--
|
||||||
}
|
}
|
||||||
@@ -1139,9 +1152,9 @@ export function useSetupWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 保存当前步骤的设置
|
// 保存当前步骤的设置
|
||||||
async function saveCurrentStepSettings() {
|
async function saveCurrentStepSettings(step: number) {
|
||||||
try {
|
try {
|
||||||
switch (currentStep.value) {
|
switch (step) {
|
||||||
case 1:
|
case 1:
|
||||||
return await saveBasicSettings()
|
return await saveBasicSettings()
|
||||||
case 2:
|
case 2:
|
||||||
@@ -1169,9 +1182,12 @@ export function useSetupWizard() {
|
|||||||
|
|
||||||
// 完成向导
|
// 完成向导
|
||||||
async function completeWizard() {
|
async function completeWizard() {
|
||||||
|
if (isActionPending.value) return
|
||||||
|
|
||||||
|
isActionPending.value = true
|
||||||
|
const step = currentStep.value
|
||||||
try {
|
try {
|
||||||
// 先处理下一步(保存当前步骤设置)
|
const saved = await runStepAction(step)
|
||||||
const saved = await nextStep()
|
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1183,6 +1199,8 @@ export function useSetupWizard() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Setup wizard failed:', error)
|
console.error('Setup wizard failed:', error)
|
||||||
$toast.error(t('setupWizard.failed'))
|
$toast.error(t('setupWizard.failed'))
|
||||||
|
} finally {
|
||||||
|
isActionPending.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1193,27 +1211,19 @@ export function useSetupWizard() {
|
|||||||
// 获取当前用户信息
|
// 获取当前用户信息
|
||||||
const currentUser: User = await api.get('user/current')
|
const currentUser: User = await api.get('user/current')
|
||||||
|
|
||||||
if (currentUser) {
|
// 用户更新是完整资料契约,所有现有资料必须随新密码原样提交。
|
||||||
// 更新现有用户的密码
|
await api.put('user/', {
|
||||||
const userData = {
|
id: currentUser.id,
|
||||||
name: wizardData.value.basic.username,
|
name: wizardData.value.basic.username,
|
||||||
|
email: currentUser.email,
|
||||||
password: wizardData.value.basic.password,
|
password: wizardData.value.basic.password,
|
||||||
is_active: currentUser.is_active,
|
is_active: currentUser.is_active,
|
||||||
is_superuser: currentUser.is_superuser,
|
is_superuser: currentUser.is_superuser,
|
||||||
}
|
avatar: currentUser.avatar,
|
||||||
|
is_otp: currentUser.is_otp,
|
||||||
await api.put(`user/${currentUser.id}`, userData)
|
permissions: currentUser.permissions,
|
||||||
} else {
|
settings: currentUser.settings,
|
||||||
// 如果用户不存在,创建新用户(通常不会发生)
|
})
|
||||||
const userData = {
|
|
||||||
name: wizardData.value.basic.username,
|
|
||||||
password: wizardData.value.basic.password,
|
|
||||||
is_active: true,
|
|
||||||
is_superuser: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.post('user/', userData)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update user password failed:', error)
|
console.error('Update user password failed:', error)
|
||||||
throw error
|
throw error
|
||||||
@@ -1349,7 +1359,7 @@ export function useSetupWizard() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 没有选择下载器时,清空现有配置
|
// 未选择下载器时跳过保存,保留现有配置。
|
||||||
console.log('No downloader selected, skipping save')
|
console.log('No downloader selected, skipping save')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1379,7 +1389,7 @@ export function useSetupWizard() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 没有选择媒体服务器时,清空现有配置
|
// 未选择媒体服务器时跳过保存,保留现有配置。
|
||||||
console.log('No media server selected, skipping save')
|
console.log('No media server selected, skipping save')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1409,7 +1419,7 @@ export function useSetupWizard() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 没有选择通知时,清空现有配置
|
// 未选择通知时跳过保存,保留现有配置。
|
||||||
console.log('No notification selected, skipping save')
|
console.log('No notification selected, skipping save')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1470,22 +1480,18 @@ export function useSetupWizard() {
|
|||||||
try {
|
try {
|
||||||
// 如果有自定义规则序列,保存到用户过滤规则组
|
// 如果有自定义规则序列,保存到用户过滤规则组
|
||||||
if (wizardData.value.preferences.ruleSequences && wizardData.value.preferences.ruleSequences.length > 0) {
|
if (wizardData.value.preferences.ruleSequences && wizardData.value.preferences.ruleSequences.length > 0) {
|
||||||
try {
|
|
||||||
// 保存当前选中的规则组到 UserFilterRuleGroups
|
// 保存当前选中的规则组到 UserFilterRuleGroups
|
||||||
await api.post<null>('system/setting/UserFilterRuleGroups', wizardData.value.preferences.ruleSequences, {
|
await api.post<null>('system/setting/UserFilterRuleGroups', wizardData.value.preferences.ruleSequences, {
|
||||||
feedback: 'silent',
|
feedback: 'silent',
|
||||||
})
|
})
|
||||||
// 保存规则组名称到其他设置
|
// 订阅与洗版设置只引用规则组名称,不存储完整规则或嵌套数组。
|
||||||
const ruleGroupNames = wizardData.value.preferences.ruleSequences.map(rule => [rule.name])
|
const ruleGroupNames = wizardData.value.preferences.ruleSequences.map(rule => rule.name)
|
||||||
|
|
||||||
// 保存到 SubscribeFilterRuleGroups
|
// 保存到 SubscribeFilterRuleGroups
|
||||||
await api.post<null>('system/setting/SubscribeFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
|
await api.post<null>('system/setting/SubscribeFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
|
||||||
|
|
||||||
// 保存到 BestVersionFilterRuleGroups
|
// 保存到 BestVersionFilterRuleGroups
|
||||||
await api.post<null>('system/setting/BestVersionFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
|
await api.post<null>('system/setting/BestVersionFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
|
||||||
} catch (error) {
|
|
||||||
console.error('Save rule sequences failed:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1588,7 +1594,7 @@ export function useSetupWizard() {
|
|||||||
// 加载用户站点认证设置
|
// 加载用户站点认证设置
|
||||||
async function loadSiteAuthSettings() {
|
async function loadSiteAuthSettings() {
|
||||||
try {
|
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',
|
'system/setting/UserSiteAuthParams',
|
||||||
)
|
)
|
||||||
if (result.value) {
|
if (result.value) {
|
||||||
@@ -1603,7 +1609,9 @@ export function useSetupWizard() {
|
|||||||
// 加载存储设置
|
// 加载存储设置
|
||||||
async function loadStorageSettings() {
|
async function loadStorageSettings() {
|
||||||
try {
|
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) {
|
if (result.value?.length) {
|
||||||
const directory = result.value[0]
|
const directory = result.value[0]
|
||||||
wizardData.value.storage.downloadPath = directory.download_path || ''
|
wizardData.value.storage.downloadPath = directory.download_path || ''
|
||||||
@@ -1619,7 +1627,9 @@ export function useSetupWizard() {
|
|||||||
// 加载下载器设置
|
// 加载下载器设置
|
||||||
async function loadDownloaderSettings() {
|
async function loadDownloaderSettings() {
|
||||||
try {
|
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) {
|
if (result.value?.length) {
|
||||||
const downloader = result.value[0]
|
const downloader = result.value[0]
|
||||||
wizardData.value.downloader.type = downloader.type
|
wizardData.value.downloader.type = downloader.type
|
||||||
@@ -1634,7 +1644,9 @@ export function useSetupWizard() {
|
|||||||
// 加载媒体服务器设置
|
// 加载媒体服务器设置
|
||||||
async function loadMediaServerSettings() {
|
async function loadMediaServerSettings() {
|
||||||
try {
|
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) {
|
if (result.value?.length) {
|
||||||
const mediaServer = result.value[0]
|
const mediaServer = result.value[0]
|
||||||
wizardData.value.mediaServer.type = mediaServer.type
|
wizardData.value.mediaServer.type = mediaServer.type
|
||||||
@@ -1650,7 +1662,9 @@ export function useSetupWizard() {
|
|||||||
// 加载通知设置
|
// 加载通知设置
|
||||||
async function loadNotificationSettings() {
|
async function loadNotificationSettings() {
|
||||||
try {
|
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) {
|
if (result.value?.length) {
|
||||||
const notification = result.value[0]
|
const notification = result.value[0]
|
||||||
wizardData.value.notification.type = notification.type
|
wizardData.value.notification.type = notification.type
|
||||||
@@ -1692,6 +1706,7 @@ export function useSetupWizard() {
|
|||||||
connectivityTest,
|
connectivityTest,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isActionPending,
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
createRandomString,
|
createRandomString,
|
||||||
|
|||||||
@@ -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
@@ -30,6 +30,7 @@ const {
|
|||||||
completeWizard,
|
completeWizard,
|
||||||
initialize,
|
initialize,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isActionPending,
|
||||||
} = useSetupWizard()
|
} = useSetupWizard()
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
@@ -149,7 +150,7 @@ onMounted(async () => {
|
|||||||
v-if="currentStep !== 1"
|
v-if="currentStep !== 1"
|
||||||
prepend-icon="mdi-chevron-left"
|
prepend-icon="mdi-chevron-left"
|
||||||
@click="prevStep"
|
@click="prevStep"
|
||||||
:disabled="connectivityTest.isTesting"
|
:disabled="isActionPending"
|
||||||
>
|
>
|
||||||
{{ t('common.previous') }}
|
{{ t('common.previous') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
@@ -161,17 +162,11 @@ onMounted(async () => {
|
|||||||
color="primary"
|
color="primary"
|
||||||
append-icon="mdi-chevron-right"
|
append-icon="mdi-chevron-right"
|
||||||
@click="nextStep"
|
@click="nextStep"
|
||||||
:disabled="connectivityTest.isTesting"
|
:disabled="isActionPending"
|
||||||
>
|
>
|
||||||
{{ connectivityTest.isTesting ? t('setupWizard.testing') : t('common.next') }}
|
{{ connectivityTest.isTesting ? t('setupWizard.testing') : t('common.next') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn
|
<VBtn v-else color="success" prepend-icon="mdi-check" @click="completeWizard" :disabled="isActionPending">
|
||||||
v-else
|
|
||||||
color="success"
|
|
||||||
prepend-icon="mdi-check"
|
|
||||||
@click="completeWizard"
|
|
||||||
:disabled="connectivityTest.isTesting"
|
|
||||||
>
|
|
||||||
{{ t('setupWizard.complete') }}
|
{{ t('setupWizard.complete') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -314,8 +314,10 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/stores/auth.ts',
|
'src/stores/auth.ts',
|
||||||
'src/stores/user.ts',
|
'src/stores/user.ts',
|
||||||
'src/stores/pluginSidebarNav.ts',
|
'src/stores/pluginSidebarNav.ts',
|
||||||
|
'src/composables/useSetupWizard.ts',
|
||||||
'src/components/auth/LoginMfaStep.vue',
|
'src/components/auth/LoginMfaStep.vue',
|
||||||
'src/pages/login.vue',
|
'src/pages/login.vue',
|
||||||
|
'src/pages/setup.vue',
|
||||||
'src/pages/appcenter.vue',
|
'src/pages/appcenter.vue',
|
||||||
'src/pages/recommend.vue',
|
'src/pages/recommend.vue',
|
||||||
'src/pages/discover.vue',
|
'src/pages/discover.vue',
|
||||||
@@ -745,6 +747,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
|
'src/composables/useSetupWizard.ts': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
|
'src/pages/setup.vue': {
|
||||||
|
branches: 75,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
'src/stores/pluginSidebarNav.ts': {
|
'src/stores/pluginSidebarNav.ts': {
|
||||||
branches: 85,
|
branches: 85,
|
||||||
functions: 90,
|
functions: 90,
|
||||||
|
|||||||
Reference in New Issue
Block a user