mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 00:23:37 +08:00
test(site): cover site maintenance dialogs (#562)
This commit is contained in:
199
src/components/dialog/__tests__/SiteAddEditDialog.spec.ts
Normal file
199
src/components/dialog/__tests__/SiteAddEditDialog.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createSite, createSiteDownloader } from '@tests/support/factories/site'
|
||||
import {
|
||||
addSiteHandler,
|
||||
siteDetailsHandler,
|
||||
siteDownloadersHandler,
|
||||
updateSiteHandler,
|
||||
} from '@tests/support/msw/handlers/site'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
done: vi.fn(),
|
||||
start: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/nprogress', () => ({
|
||||
configureNProgress: vi.fn(),
|
||||
doneNProgress: mocks.done,
|
||||
startNProgress: mocks.start,
|
||||
}))
|
||||
|
||||
async function renderDialog(oper: 'add' | 'edit', siteid?: number) {
|
||||
const events = { close: vi.fn(), save: vi.fn() }
|
||||
const result = await renderWithProviders(SiteAddEditDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
oper,
|
||||
siteid,
|
||||
onClose: events.close,
|
||||
onSave: events.save,
|
||||
},
|
||||
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
|
||||
})
|
||||
return { ...result, events }
|
||||
}
|
||||
|
||||
describe('SiteAddEditDialog', () => {
|
||||
beforeEach(() => {
|
||||
server.use(siteDownloadersHandler())
|
||||
})
|
||||
|
||||
it('creates a site with the entered form values and loaded downloader', async () => {
|
||||
const saved = vi.fn()
|
||||
server.use(
|
||||
siteDownloadersHandler([createSiteDownloader({ name: '下载器 A' })]),
|
||||
addSiteHandler({ success: true }, 200, saved),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog('add')
|
||||
|
||||
await user.type(screen.getByLabelText('站点地址'), 'https://new.example.com/')
|
||||
await fireEvent.update(screen.getByLabelText('RSS地址'), 'https://new.example.com/rss')
|
||||
await fireEvent.update(screen.getByLabelText('超时时间(秒)'), '30')
|
||||
await fireEvent.update(screen.getByLabelText('站点Cookie'), 'session=test')
|
||||
await fireEvent.update(screen.getByLabelText('站点User-Agent'), 'Goal5C-UA')
|
||||
await user.click(screen.getByRole('tab', { name: 'API' }))
|
||||
await fireEvent.update(screen.getByLabelText('请求头(Authorization)'), 'Bearer goal5c')
|
||||
await fireEvent.update(screen.getByLabelText('令牌(API Key)'), 'goal5c-key')
|
||||
await user.click(screen.getByLabelText('下载器'))
|
||||
await user.click(await screen.findByText('下载器 A'))
|
||||
await user.click(screen.getByLabelText('限制站点访问频率'))
|
||||
await fireEvent.update(screen.getByLabelText('单位周期(秒)'), '60')
|
||||
await fireEvent.update(screen.getByLabelText('周期内访问次数'), '5')
|
||||
await fireEvent.update(screen.getByLabelText('访问间隔(秒)'), '2')
|
||||
await user.click(screen.getByLabelText('使用代理访问'))
|
||||
await user.click(screen.getByLabelText('浏览器仿真'))
|
||||
await user.click(screen.getByRole('button', { name: '新增站点' }))
|
||||
|
||||
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
|
||||
expect(saved.mock.calls[0][0]).toMatchObject({
|
||||
apikey: 'goal5c-key',
|
||||
cookie: 'session=test',
|
||||
downloader: '下载器 A',
|
||||
limit_count: '5',
|
||||
limit_interval: '60',
|
||||
limit_seconds: '2',
|
||||
proxy: true,
|
||||
render: true,
|
||||
rss: 'https://new.example.com/rss',
|
||||
timeout: '30',
|
||||
token: 'Bearer goal5c',
|
||||
ua: 'Goal5C-UA',
|
||||
url: 'https://new.example.com/',
|
||||
})
|
||||
expect(events.save).toHaveBeenCalledOnce()
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('新增站点成功')
|
||||
expect(mocks.start).toHaveBeenCalledOnce()
|
||||
expect(mocks.done).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not submit an add request without a URL', async () => {
|
||||
const saved = vi.fn()
|
||||
server.use(addSiteHandler({ success: true }, 200, saved))
|
||||
const { events } = await renderDialog('add')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '新增站点' }))
|
||||
|
||||
expect(saved).not.toHaveBeenCalled()
|
||||
expect(events.save).not.toHaveBeenCalled()
|
||||
expect(mocks.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a business failure when creation is rejected', async () => {
|
||||
server.use(addSiteHandler({ message: '站点已存在', success: false }))
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog('add')
|
||||
|
||||
await user.type(screen.getByLabelText('站点地址'), 'https://duplicate.example.com/')
|
||||
await user.click(screen.getByRole('button', { name: '新增站点' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('新增站点失败:站点已存在'))
|
||||
expect(events.save).not.toHaveBeenCalled()
|
||||
expect(mocks.done).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores progress and keeps the dialog open after an HTTP creation failure', async () => {
|
||||
server.use(addSiteHandler({ message: '服务异常', success: false }, 500))
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog('add')
|
||||
|
||||
await user.type(screen.getByLabelText('站点地址'), 'https://failed.example.com/')
|
||||
await user.click(screen.getByRole('button', { name: '新增站点' }))
|
||||
|
||||
await waitFor(() => expect(mocks.done).toHaveBeenCalledOnce())
|
||||
expect(events.save).not.toHaveBeenCalled()
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores numeric flags and API mode when editing a rate-limited site', async () => {
|
||||
const site = createSite({
|
||||
apikey: 'api-key',
|
||||
downloader: '下载器 A',
|
||||
limit_count: 4,
|
||||
limit_interval: 60,
|
||||
limit_seconds: 2,
|
||||
proxy: 1,
|
||||
render: 0,
|
||||
token: 'token',
|
||||
})
|
||||
server.use(siteDetailsHandler(site.id, site), siteDownloadersHandler([createSiteDownloader({ name: '下载器 A' })]))
|
||||
await renderDialog('edit', site.id)
|
||||
|
||||
expect(await screen.findByText(site.name)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('使用代理访问')).toBeChecked()
|
||||
expect(screen.getByLabelText('浏览器仿真')).not.toBeChecked()
|
||||
expect(screen.getByLabelText('限制站点访问频率')).toBeChecked()
|
||||
expect(screen.getByDisplayValue('api-key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears rate limits when disabled and emits save after an update', async () => {
|
||||
const site = createSite({ limit_count: 4, limit_interval: 60, limit_seconds: 2, name: '限流站点' })
|
||||
const saved = vi.fn()
|
||||
server.use(siteDetailsHandler(site.id, site), updateSiteHandler({ success: true }, 200, saved))
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog('edit', site.id)
|
||||
|
||||
await user.click(await screen.findByLabelText('限制站点访问频率'))
|
||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
|
||||
expect(saved.mock.calls[0][0]).toMatchObject({ limit_count: 0, limit_interval: 0, limit_seconds: 0 })
|
||||
expect(events.save).toHaveBeenCalledOnce()
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('限流站点 更新成功')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['business', 200, { message: '不允许更新', success: false }, '限流站点 更新失败:不允许更新'],
|
||||
['HTTP', 500, { message: '服务异常', success: false }, '限流站点 更新失败!'],
|
||||
])('keeps the dialog open on %s update failure', async (_case, status, response, message) => {
|
||||
const site = createSite({ name: '限流站点' })
|
||||
server.use(siteDetailsHandler(site.id, site), updateSiteHandler(response, status))
|
||||
const { events } = await renderDialog('edit', site.id)
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(message))
|
||||
expect(events.save).not.toHaveBeenCalled()
|
||||
expect(mocks.done).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('emits close from the dialog close button', async () => {
|
||||
const { events } = await renderDialog('add')
|
||||
|
||||
await fireEvent.click(document.querySelector('.absolute.right-3.top-3')!)
|
||||
|
||||
expect(events.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
128
src/components/dialog/__tests__/SiteCookieUpdateDialog.spec.ts
Normal file
128
src/components/dialog/__tests__/SiteCookieUpdateDialog.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import SiteCookieUpdateDialog from '@/components/dialog/SiteCookieUpdateDialog.vue'
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createSite } from '@tests/support/factories/site'
|
||||
import { updateSiteCookieHandler } from '@tests/support/msw/handlers/site'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ toastError: vi.fn(), toastSuccess: vi.fn() }))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
async function renderDialog() {
|
||||
const site = createSite({ id: 701, name: 'Cookie 站点' })
|
||||
const events = { close: vi.fn(), done: vi.fn() }
|
||||
const result = await renderWithProviders(SiteCookieUpdateDialog, {
|
||||
props: { modelValue: true, site, onClose: events.close, onDone: events.done },
|
||||
global: {
|
||||
components: { VDialogCloseBtn: DialogCloseBtn },
|
||||
stubs: { ProgressDialog: { props: ['text'], template: '<div role="status">{{ text }}</div>' } },
|
||||
},
|
||||
})
|
||||
return { ...result, events, site }
|
||||
}
|
||||
|
||||
async function fillCredentials(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.type(screen.getByLabelText('用户名'), 'tester')
|
||||
await user.type(screen.getByLabelText('密码'), 'secret')
|
||||
await user.type(screen.getByLabelText('验证码'), '654321')
|
||||
}
|
||||
|
||||
describe('SiteCookieUpdateDialog', () => {
|
||||
it('skips requests until username and password are both provided', async () => {
|
||||
const requested = vi.fn()
|
||||
server.use(updateSiteCookieHandler(701, { success: true }, 200, requested))
|
||||
const { events } = await renderDialog()
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '开始更新' }))
|
||||
|
||||
expect(requested).not.toHaveBeenCalled()
|
||||
expect(events.done).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits username, password, and OTP and emits done on success', async () => {
|
||||
const requested = vi.fn()
|
||||
server.use(updateSiteCookieHandler(701, { success: true }, 200, requested))
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog()
|
||||
await fillCredentials(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始更新' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(requested).toHaveBeenCalledWith({ code: '654321', password: 'secret', username: 'tester' }),
|
||||
)
|
||||
expect(events.done).toHaveBeenCalledOnce()
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('Cookie 站点 更新Cookie & UA成功!')
|
||||
expect(screen.getByRole('button', { name: '开始更新' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('toggles password visibility', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderDialog()
|
||||
const password = screen.getByLabelText('密码')
|
||||
const toggle = password.closest('.v-input')!.querySelector('.v-field__append-inner [role="button"]')!
|
||||
|
||||
expect(password).toHaveAttribute('type', 'password')
|
||||
await user.click(toggle)
|
||||
expect(password).toHaveAttribute('type', 'text')
|
||||
})
|
||||
|
||||
it('shows the backend message for a business failure', async () => {
|
||||
server.use(updateSiteCookieHandler(701, { message: '验证码错误', success: false }))
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog()
|
||||
await fillCredentials(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始更新' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('Cookie 站点 更新失败:验证码错误'))
|
||||
expect(events.done).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['detail', { detail: '站点不存在' }, '站点不存在'],
|
||||
['message', { message: '认证服务异常' }, '认证服务异常'],
|
||||
])('shows an HTTP %s response and restores pending state', async (_case, body, message) => {
|
||||
server.use(http.post('http://localhost/api/v1/site/cookie/701', () => HttpResponse.json(body, { status: 500 })))
|
||||
const user = userEvent.setup()
|
||||
await renderDialog()
|
||||
await fillCredentials(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始更新' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(`Cookie 站点 更新失败:${message}`))
|
||||
expect(screen.getByRole('button', { name: '开始更新' })).toBeEnabled()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables the update action while the request is pending and restores it afterward', async () => {
|
||||
let resolveRequest!: () => void
|
||||
const pending = new Promise<void>(resolve => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
server.use(updateSiteCookieHandler(701, { success: true }, 200, () => pending))
|
||||
const user = userEvent.setup()
|
||||
await renderDialog()
|
||||
await fillCredentials(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始更新' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('正在更新 Cookie 站点 Cookie & UA...')
|
||||
expect(screen.getByRole('button', { name: '开始更新' })).toBeDisabled()
|
||||
|
||||
resolveRequest()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '开始更新' })).toBeEnabled())
|
||||
})
|
||||
|
||||
it('emits close from the dialog close button', async () => {
|
||||
const { events } = await renderDialog()
|
||||
await fireEvent.click(document.querySelector('.absolute.right-3.top-3')!)
|
||||
expect(events.close).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
217
src/components/dialog/__tests__/SiteImportDialog.spec.ts
Normal file
217
src/components/dialog/__tests__/SiteImportDialog.spec.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import SiteImportDialog from '@/components/dialog/SiteImportDialog.vue'
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createSite } from '@tests/support/factories/site'
|
||||
import { addSiteHandler, siteApiUrls } from '@tests/support/msw/handlers/site'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
done: vi.fn(),
|
||||
start: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastWarning: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess, warning: mocks.toastWarning }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/nprogress', () => ({
|
||||
configureNProgress: vi.fn(),
|
||||
doneNProgress: mocks.done,
|
||||
startNProgress: mocks.start,
|
||||
}))
|
||||
|
||||
async function renderDialog() {
|
||||
const events = { importSuccess: vi.fn(), update: vi.fn() }
|
||||
const result = await renderWithProviders(SiteImportDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
'onImport-success': events.importSuccess,
|
||||
'onUpdate:modelValue': events.update,
|
||||
},
|
||||
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
|
||||
})
|
||||
return { ...result, events }
|
||||
}
|
||||
|
||||
function jsonFile(data: unknown, name = 'sites.json') {
|
||||
const file = new File([JSON.stringify(data)], name, { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', { value: async () => JSON.stringify(data) })
|
||||
return file
|
||||
}
|
||||
|
||||
function brokenJsonFile() {
|
||||
const file = new File(['{broken'], 'broken.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', { value: async () => '{broken' })
|
||||
return file
|
||||
}
|
||||
|
||||
async function chooseFile(file: File) {
|
||||
await userEvent.setup().upload(screen.getByLabelText('选择文件'), file)
|
||||
}
|
||||
|
||||
describe('SiteImportDialog', () => {
|
||||
it('previews only the first five sites and marks invalid rows', async () => {
|
||||
const sites = Array.from({ length: 6 }, (_, index) =>
|
||||
createSite(index === 1 ? { name: '' } : { name: `导入站点 ${index + 1}` }),
|
||||
)
|
||||
await renderDialog()
|
||||
|
||||
await chooseFile(jsonFile(sites))
|
||||
|
||||
expect(await screen.findByText('预览数据 (6 个站点)')).toBeInTheDocument()
|
||||
expect(screen.getByText('导入站点 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('未命名站点')).toBeInTheDocument()
|
||||
expect(screen.getByText('导入站点 5')).toBeInTheDocument()
|
||||
expect(screen.queryByText('导入站点 6')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('数据无效')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rejects a non-JSON drop before parsing', async () => {
|
||||
await renderDialog()
|
||||
const file = new File(['text'], 'sites.txt', { type: 'text/plain' })
|
||||
|
||||
await fireEvent.drop(document.querySelector('.upload-zone')!, { dataTransfer: { files: [file] } })
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('不支持的文件类型,请选择JSON文件')
|
||||
expect(screen.getByLabelText('选择文件')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['invalid JSON', brokenJsonFile(), '文件解析失败,请检查文件格式'],
|
||||
['non-array JSON', jsonFile({ name: 'single' }), '文件格式无效,请检查文件内容'],
|
||||
])('rejects %s', async (_case, file, message) => {
|
||||
await renderDialog()
|
||||
|
||||
await chooseFile(file)
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(message))
|
||||
expect(screen.getByLabelText('选择文件')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tracks drag state and can reset a selected batch', async () => {
|
||||
await renderDialog()
|
||||
const zone = document.querySelector('.upload-zone')!
|
||||
|
||||
await fireEvent.dragOver(zone)
|
||||
expect(zone).toHaveClass('dragging')
|
||||
await fireEvent.dragLeave(zone)
|
||||
expect(zone).not.toHaveClass('dragging')
|
||||
|
||||
await fireEvent.drop(zone, { dataTransfer: { files: [jsonFile([createSite({ name: '拖入站点' })])] } })
|
||||
expect(await screen.findByText('拖入站点')).toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByRole('button', { name: '重置' }))
|
||||
expect(screen.getByLabelText('选择文件')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('imports valid sites sequentially without their ids and closes on complete success', async () => {
|
||||
const sites = [createSite({ id: 801, name: '第一站' }), createSite({ id: 802, name: '第二站' })]
|
||||
const order: Array<Record<string, unknown>> = []
|
||||
server.use(
|
||||
addSiteHandler({ success: true }, 200, payload => {
|
||||
order.push(payload as unknown as Record<string, unknown>)
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog()
|
||||
await chooseFile(jsonFile(sites))
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '开始导入' }))
|
||||
|
||||
await waitFor(() => expect(order).toHaveLength(2))
|
||||
expect(order.map(site => site.name)).toEqual(['第一站', '第二站'])
|
||||
expect(order.every(site => !('id' in site))).toBe(true)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('成功导入 2 个站点')
|
||||
expect(events.importSuccess).toHaveBeenCalledOnce()
|
||||
expect(events.update).toHaveBeenCalledWith(false)
|
||||
expect(mocks.start).toHaveBeenCalledOnce()
|
||||
expect(mocks.done).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('warns about invalid records and imports only valid records', async () => {
|
||||
const valid = createSite({ name: '有效站点' })
|
||||
const invalid = createSite({ domain: '', name: '无效站点' })
|
||||
const requested = vi.fn()
|
||||
server.use(addSiteHandler({ success: true }, 200, requested))
|
||||
await renderDialog()
|
||||
await chooseFile(jsonFile([valid, invalid]))
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
|
||||
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
expect(mocks.toastWarning).toHaveBeenCalledWith('部分数据无效,有效数据 1/2 个')
|
||||
})
|
||||
|
||||
it('keeps all business failures visible with backend details', async () => {
|
||||
const sites = [createSite({ name: '重复站点' }), createSite({ name: '未支持站点' })]
|
||||
let requestIndex = 0
|
||||
server.use(
|
||||
addSiteHandler({ success: false }, 200, () => {
|
||||
requestIndex += 1
|
||||
}),
|
||||
)
|
||||
await renderDialog()
|
||||
await chooseFile(jsonFile(sites))
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
|
||||
|
||||
expect(await screen.findByText('导入过程中出现 2 个错误')).toBeInTheDocument()
|
||||
expect(requestIndex).toBe(2)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('导入失败,2 个站点全部导入失败')
|
||||
expect(screen.getByText('重复站点 - 错误详情')).toBeInTheDocument()
|
||||
expect(screen.getByText('未支持站点 - 错误详情')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports partial success and preserves the HTTP error message', async () => {
|
||||
const sites = [createSite({ name: '成功站点' }), createSite({ name: '失败站点' })]
|
||||
let requestIndex = 0
|
||||
server.use(
|
||||
http.post(siteApiUrls.list, () => {
|
||||
requestIndex += 1
|
||||
if (requestIndex === 1) return HttpResponse.json({ success: true })
|
||||
return HttpResponse.json({ message: '第二站请求失败', success: false }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
await renderDialog()
|
||||
await chooseFile(jsonFile(sites))
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
|
||||
|
||||
expect(await screen.findByText('导入过程中出现 1 个错误')).toBeInTheDocument()
|
||||
expect(screen.getByText('成功导入 1 个站点')).toBeInTheDocument()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('导入完成,成功 1 个,失败 1 个')
|
||||
await fireEvent.click(screen.getByText('失败站点 - 错误详情'))
|
||||
expect(await screen.findByText('Request failed with status code 500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not start a request when every record is invalid', async () => {
|
||||
const requested = vi.fn()
|
||||
server.use(addSiteHandler({ success: true }, 200, requested))
|
||||
await renderDialog()
|
||||
await chooseFile(jsonFile([createSite({ domain: '', name: '', url: '' })]))
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('没有有效的数据')
|
||||
expect(requested).not.toHaveBeenCalled()
|
||||
expect(mocks.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes a failed result without a success event', async () => {
|
||||
server.use(addSiteHandler({ success: false, message: '已存在' }))
|
||||
const { events } = await renderDialog()
|
||||
await chooseFile(jsonFile([createSite({ name: '失败站点' })]))
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
|
||||
expect(await screen.findByText('导入过程中出现 1 个错误')).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
|
||||
expect(events.update).toHaveBeenCalledWith(false)
|
||||
expect(events.importSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import type { DownloaderConf, Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
|
||||
let siteSeed = 0
|
||||
|
||||
@@ -37,3 +37,15 @@ export function createSiteUserData(overrides: Partial<SiteUserData> = {}): SiteU
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造可用的下载器配置测试记录。 */
|
||||
export function createSiteDownloader(overrides: Partial<DownloaderConf> = {}): DownloaderConf {
|
||||
return {
|
||||
config: {},
|
||||
default: false,
|
||||
enabled: true,
|
||||
name: '测试下载器',
|
||||
type: 'qbittorrent',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import type { ApiResponse, Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import type { ApiResponse, DownloaderConf, Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||
|
||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||
|
||||
interface SiteMutationResponse {
|
||||
success: boolean
|
||||
data?: Record<string, unknown>
|
||||
message?: string
|
||||
}
|
||||
|
||||
export const siteApiUrls = {
|
||||
cookie: (id: number) => new URL(`site/cookie/${id}`, API_BASE_URL).href,
|
||||
delete: (id: number) => new URL(`site/${id}`, API_BASE_URL).href,
|
||||
details: (id: number) => new URL(`site/${id}`, API_BASE_URL).href,
|
||||
downloaders: new URL('download/clients', API_BASE_URL).href,
|
||||
icon: (id: number) => new URL(`site/icon/${id}`, API_BASE_URL).href,
|
||||
list: new URL('site/', API_BASE_URL).href,
|
||||
priorities: new URL('site/priorities', API_BASE_URL).href,
|
||||
@@ -114,3 +123,60 @@ export function deleteSiteHandler(
|
||||
return response(result, status)
|
||||
})
|
||||
}
|
||||
|
||||
export function siteDownloadersHandler(
|
||||
response: DownloaderConf[] = [],
|
||||
status = 200,
|
||||
onRequest: () => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.get(siteApiUrls.downloaders, async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||
})
|
||||
}
|
||||
|
||||
export function siteDetailsHandler(
|
||||
id: number,
|
||||
response: Site,
|
||||
status = 200,
|
||||
onRequest: () => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.get(siteApiUrls.details(id), async () => {
|
||||
await onRequest()
|
||||
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||
})
|
||||
}
|
||||
|
||||
export function addSiteHandler(
|
||||
response: SiteMutationResponse = { success: true },
|
||||
status = 200,
|
||||
onRequest: (payload: Site) => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.post(siteApiUrls.list, async ({ request }) => {
|
||||
await onRequest((await request.json()) as Site)
|
||||
return HttpResponse.json(response, { status })
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSiteHandler(
|
||||
response: SiteMutationResponse = { success: true },
|
||||
status = 200,
|
||||
onRequest: (payload: Site) => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.put(siteApiUrls.list, async ({ request }) => {
|
||||
await onRequest((await request.json()) as Site)
|
||||
return HttpResponse.json(response, { status })
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSiteCookieHandler(
|
||||
id: number,
|
||||
response: SiteMutationResponse = { success: true },
|
||||
status = 200,
|
||||
onRequest: (payload: { code: string; password: string; username: string }) => void | Promise<void> = () => {},
|
||||
) {
|
||||
return http.post(siteApiUrls.cookie(id), async ({ request }) => {
|
||||
await onRequest((await request.json()) as { code: string; password: string; username: string })
|
||||
return HttpResponse.json(response, { status })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -289,6 +289,9 @@ export default defineConfig(({ mode }) => ({
|
||||
'src/views/subscribe/SubscribeShareView.vue',
|
||||
'src/composables/useMediaSubscribe.ts',
|
||||
'src/components/cards/SubscribeCard.vue',
|
||||
'src/components/dialog/SiteAddEditDialog.vue',
|
||||
'src/components/dialog/SiteCookieUpdateDialog.vue',
|
||||
'src/components/dialog/SiteImportDialog.vue',
|
||||
'src/components/cards/SubscribeShareCard.vue',
|
||||
'src/components/dialog/ForkSubscribeDialog.vue',
|
||||
'src/components/dialog/SubscribeEditDialog.vue',
|
||||
@@ -338,6 +341,24 @@ export default defineConfig(({ mode }) => ({
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/dialog/SiteAddEditDialog.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/dialog/SiteCookieUpdateDialog.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/dialog/SiteImportDialog.vue': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/components/dialog/SubscribeEditDialog.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
|
||||
Reference in New Issue
Block a user