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,