diff --git a/src/components/dialog/__tests__/SiteAddEditDialog.spec.ts b/src/components/dialog/__tests__/SiteAddEditDialog.spec.ts
new file mode 100644
index 00000000..bc8d12fe
--- /dev/null
+++ b/src/components/dialog/__tests__/SiteAddEditDialog.spec.ts
@@ -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()
+ })
+})
diff --git a/src/components/dialog/__tests__/SiteCookieUpdateDialog.spec.ts b/src/components/dialog/__tests__/SiteCookieUpdateDialog.spec.ts
new file mode 100644
index 00000000..771b87c5
--- /dev/null
+++ b/src/components/dialog/__tests__/SiteCookieUpdateDialog.spec.ts
@@ -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: '
{{ text }}
' } },
+ },
+ })
+ return { ...result, events, site }
+}
+
+async function fillCredentials(user: ReturnType) {
+ 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(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()
+ })
+})
diff --git a/src/components/dialog/__tests__/SiteImportDialog.spec.ts b/src/components/dialog/__tests__/SiteImportDialog.spec.ts
new file mode 100644
index 00000000..ca9e9eaa
--- /dev/null
+++ b/src/components/dialog/__tests__/SiteImportDialog.spec.ts
@@ -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> = []
+ server.use(
+ addSiteHandler({ success: true }, 200, payload => {
+ order.push(payload as unknown as Record)
+ }),
+ )
+ 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()
+ })
+})
diff --git a/tests/support/factories/site.ts b/tests/support/factories/site.ts
index 05eaf0ba..50e958ee 100644
--- a/tests/support/factories/site.ts
+++ b/tests/support/factories/site.ts
@@ -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 = {}): SiteU
...overrides,
}
}
+
+/** 构造可用的下载器配置测试记录。 */
+export function createSiteDownloader(overrides: Partial = {}): DownloaderConf {
+ return {
+ config: {},
+ default: false,
+ enabled: true,
+ name: '测试下载器',
+ type: 'qbittorrent',
+ ...overrides,
+ }
+}
diff --git a/tests/support/msw/handlers/site.ts b/tests/support/msw/handlers/site.ts
index 19cc1f02..ecc856c2 100644
--- a/tests/support/msw/handlers/site.ts
+++ b/tests/support/msw/handlers/site.ts
@@ -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
+ 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 = () => {},
+) {
+ 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 = () => {},
+) {
+ 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 = () => {},
+) {
+ 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 = () => {},
+) {
+ 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 = () => {},
+) {
+ 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 })
+ })
+}
diff --git a/vite.config.ts b/vite.config.ts
index 06e29390..4b680117 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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,