diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0fab69cc..9024b7a3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,12 +8,16 @@ on: paths: - 'package.json' +permissions: + contents: write + jobs: build: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: Release version id: release_version @@ -22,22 +26,30 @@ jobs: echo "frontend_version=v$frontend_version" >> $GITHUB_ENV - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v7 with: - node-version: '20' - cache: 'yarn' + node-version: '24' + cache: yarn + cache-dependency-path: yarn.lock + + - name: Enable project Yarn version + run: | + corepack enable + corepack install --global yarn@1.22.22 + + - name: Install dependencies + run: yarn --frozen-lockfile - name: Download Icons run: | pwd - curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - + curl -fsSL --retry 3 "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - mv /tmp/MoviePilot-Plugins-main/icons public/plugin_icon rm -rf /tmp/MoviePilot-Plugins-main - name: Build frontend id: build_frontend run: | - yarn yarn build echo "$frontend_version" > dist/version.txt zip -r dist.zip dist diff --git a/.prettierignore b/.prettierignore index 82c47fe6..43d4a936 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ dist node_modules +/.worktrees/ # 自动生成文件的格式由各自生成器决定。 auto-imports.d.ts diff --git a/README_EN.md b/README_EN.md index ac3e3566..148a96fb 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,8 +1,9 @@ # MoviePilot-Frontend -*[中文](README.md) | English* +_[中文](README.md) | English_ -Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS version required: >= `v20.12.1`. +Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot). The minimum supported Node.js version is +`20.19`; Node.js `24` is recommended. ## Features @@ -17,7 +18,7 @@ Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (disable Vetur). -### Configure Vite +### Configure Vite See [Vite Configuration Reference](https://vitejs.dev/config/). @@ -39,6 +40,17 @@ yarn dev yarn build ``` +### Unit Tests + +```sh +yarn test:run +yarn test:coverage +``` + +See the [unit testing architecture](docs/testing.md) for test organization, shared facilities, HTTP fixtures, and +coverage conventions. See the [frontend code quality evolution](docs/code-quality.md) for ESLint, Prettier, Node.js +compatibility, and incremental CI gates. + ### Static Deployment 1. Host the `dist` static files using a web server like `nginx`. Refer to `public/nginx.conf` for nginx configuration. @@ -47,7 +59,7 @@ yarn build ```shell node dist/service.js -``` +``` ### Module Federation diff --git a/docs/code-quality.md b/docs/code-quality.md index 5d2571fa..0b91d8e6 100644 --- a/docs/code-quality.md +++ b/docs/code-quality.md @@ -65,6 +65,11 @@ ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业 `eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline;修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`。 +baseline 是刻意设计的渐进治理机制,不等于被抑制的违规已被认可为长期编码规范。2026-08-24 的基线快照为 +141 个文件、564 项受控违规,其中 `@typescript-eslint/no-explicit-any` 300 项、`vue/no-mutating-props` +82 项、`@typescript-eslint/no-unused-vars` 64 项。生产 PR 修改到某个受控文件时,应审计并偿还该文件内 +不改变业务语义且可安全处理的存量,随后 prune;纯测试、文档或 CI 变更不为清理未触及生产文件扩大 diff。 + 迁移完成的最低验收为: ```sh diff --git a/docs/testing.md b/docs/testing.md index 7e00561c..99114813 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -45,6 +45,7 @@ tests/ - `tests/support/render.ts` 提供带 Vuetify、i18n、Router 和 Pinia 的标准渲染入口。 - `tests/support/factories/` 按业务对象提供最小有效测试数据工厂。 - `tests/support/msw/handlers/` 按业务域定义 HTTP handler;`server.ts` 只负责 MSW server 实例。 +- `tests/support/msw/response.ts` 显式构造主程序普通 API 的成功或业务失败 envelope。 - spec 通过 `@tests/*` 访问共享测试设施,通过 `@/*` 访问生产源码。 ## 工具职责 @@ -65,11 +66,31 @@ tests/ - 每个用例保持独立,不依赖文件执行顺序;timer、mock、storage、DOM 和未完成请求由全局 setup 恢复。 - 不使用大面积快照或覆盖率占位用例。 +## HTTP 响应夹具 + +主程序普通 API 使用固定的 `{ success, message, data }` envelope。MSW handler 必须通过 +`apiJson(data)` 或 `apiFailureJson(message, data)` 显式声明成功或业务失败,不得在全局 setup 中改写 +`HttpResponse.json()`、自动包装裸数据或补齐缺失字段。这样测试夹具与当前后端协议不一致时会直接失败, +不会由兼容层掩盖。 + +HTTP 4xx/5xx 的原始错误体、插件自定义端点和其他明确不使用主程序 envelope 的协议继续直接调用 +`HttpResponse.json()`。是否使用 envelope 由端点契约决定,不根据状态码或载荷形状自动猜测。 + +## 测试性能 + +- 根据逐文件和逐用例耗时日志识别热点,不按文件行数或用例数量机械拆分。 +- 业务测试可以对与断言无关的第三方重型 UI 边界使用局部 test double,但必须保留 props、emits、 + `v-model`、可访问名称及被测业务使用的值类型;不得以直接赋值组件私有状态替代用户交互。 +- 优先减少动画、布局、定位和重复挂载等 jsdom 无法证明的成本。真实组件集成仍由代表性组件测试或浏览器回归负责。 +- 优化前后使用同一命令和 reporter 比较 focused 文件与热点用例;只有保持断言和业务契约后取得稳定收益才保留优化。 + ## 新增测试 1. 业务测试在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`;工具链配置契约测试放在 `tests/config/`。 2. 纯函数、store 和无渲染模块直接使用 Vitest;Vue 组件使用标准渲染入口。 3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/.ts` 增加对应 handler。 + 主程序普通 API 响应使用 `apiJson()` / `apiFailureJson()`;只有明确的非 envelope 协议才直接使用 + `HttpResponse.json()`。 4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。 5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts` 的 `coverage.include`。 6. 提交前按影响面运行测试、类型检查、lint 和生产构建;覆盖率报告按需本地执行。 diff --git a/package.json b/package.json index 8ace7e22..359e686f 100644 --- a/package.json +++ b/package.json @@ -157,7 +157,7 @@ "workbox-build": "^7.3.0", "workbox-window": "^7.3.0" }, - "packageManager": "yarn@1.22.18", + "packageManager": "yarn@1.22.22", "engines": { "node": ">=20.19" } diff --git a/src/components/cards/__tests__/MediaCard.spec.ts b/src/components/cards/__tests__/MediaCard.spec.ts index 6ac700cb..1ef670e3 100644 --- a/src/components/cards/__tests__/MediaCard.spec.ts +++ b/src/components/cards/__tests__/MediaCard.spec.ts @@ -13,6 +13,7 @@ import { import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, reactive, ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -206,8 +207,8 @@ function installSearchHandlers( tv: tvSiteListUrl, }[mediaType] server.use( - http.get(siteListUrl, () => HttpResponse.json(sites)), - http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })), + http.get(siteListUrl, () => apiJson(sites)), + http.get(selectedSitesUrl, () => apiJson({ value: selected })), ) } @@ -548,7 +549,7 @@ describe('MediaCard', () => { it('falls back to global search when site settings cannot provide active selections', async () => { server.use( http.get(movieSiteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })), - http.get(selectedSitesUrl, () => HttpResponse.json({ success: true })), + http.get(selectedSitesUrl, () => apiJson(null)), ) const media = createMediaInfo({ title: '站点失败搜索', tmdb_id: 9503 }) const { container } = await renderCard(media) @@ -607,7 +608,7 @@ describe('MediaCard', () => { it('opens active sites with an empty selection when the saved setting fails', async () => { server.use( http.get(movieSiteListUrl, () => - HttpResponse.json([ + apiJson([ { domain: 'fallback.example', downloader: 'default', @@ -655,7 +656,7 @@ describe('MediaCard', () => { subscribeListRequest, ), http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () => - HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }), + apiJson({ value: { best_version: 0 } }), ), ) const { container } = await renderCard(media) @@ -703,7 +704,7 @@ describe('MediaCard', () => { { id: 92, media_id: 'other', media_source: 'bilibili', season: 5, type: '电视剧' }, ]), http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () => - HttpResponse.json({ data: { value: {} }, success: true }), + apiJson({ value: {} }), ), ) const { container } = await renderCard(media) @@ -758,7 +759,7 @@ describe('MediaCard', () => { mediaExistsHandler({ data: { item: {} }, success: true }), subscribeListHandler(subscribes), http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () => - HttpResponse.json({ data: { value: {} }, success: true }), + apiJson({ value: {} }), ), ) const { container } = await renderCard(media) diff --git a/src/components/dialog/__tests__/AddDownloadDialog.spec.ts b/src/components/dialog/__tests__/AddDownloadDialog.spec.ts index 89fcbc33..710e3f93 100644 --- a/src/components/dialog/__tests__/AddDownloadDialog.spec.ts +++ b/src/components/dialog/__tests__/AddDownloadDialog.spec.ts @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event' import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http, type JsonBodyType } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, type PropType } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -185,12 +186,12 @@ function createDeferred() { function directoriesHandler(directories: TransferDirectoryConf[]) { return http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () => - HttpResponse.json({ data: { value: directories }, success: true }), + apiJson({ value: directories }), ) } function downloadersHandler(downloaders: Array<{ name: string; type: string }> = []) { - return http.get(new URL('download/clients', API_BASE_URL).href, () => HttpResponse.json(downloaders)) + return http.get(new URL('download/clients', API_BASE_URL).href, () => apiJson(downloaders)) } function downloadHandler( @@ -201,7 +202,9 @@ function downloadHandler( ) { return http.post(new URL(endpoint, API_BASE_URL).href, async ({ request }) => { await onRequest(await request.json()) - return HttpResponse.json(await response, { status }) + const body = await response + if (status >= 400) return HttpResponse.json(body, { status }) + return HttpResponse.json(body, { status }) }) } @@ -320,7 +323,7 @@ describe('AddDownloadDialog submissions', () => { it('submits a directly entered media ID through v-model', async () => { const submitted = vi.fn() - server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted)) + server.use(downloadHandler('download/add', { data: null, message: '', success: true }, 200, submitted)) const user = userEvent.setup() await renderDialog({ @@ -376,7 +379,7 @@ describe('AddDownloadDialog submissions', () => { expect(submitted).toHaveBeenCalledOnce() expect(mocks.startNProgress).toHaveBeenCalledOnce() - deferred.resolve({ data: null, success: true }) + deferred.resolve({ data: null, message: '', success: true }) await waitFor(() => expect(events.done).toHaveBeenCalledWith(torrent.enclosure)) expect(mocks.toastSuccess).toHaveBeenCalledWith('测试站 测试种子 下载成功!') @@ -386,7 +389,7 @@ describe('AddDownloadDialog submissions', () => { it('submits the selected album namespace for a music torrent without media context', async () => { const submitted = vi.fn() - server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted)) + server.use(downloadHandler('download/add', { data: null, message: '', success: true }, 200, submitted)) const user = userEvent.setup() await renderDialog({ @@ -493,7 +496,7 @@ describe('AddDownloadDialog submissions', () => { it('uses the source-native identity carried by a torrent without auxiliary ID fallback', async () => { const submitted = vi.fn() - server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted)) + server.use(downloadHandler('download/add', { data: null, message: '', success: true }, 200, submitted)) const torrent = createTorrent({ media_id: 'tt0111161', media_source: 'imdb' }) const user = userEvent.setup() @@ -509,7 +512,7 @@ describe('AddDownloadDialog submissions', () => { it('uses download/ for an existing media without locking unrelated optional fields', async () => { const submitted = vi.fn() - server.use(downloadHandler('download/', { data: null, success: true }, 200, submitted)) + server.use(downloadHandler('download/', { data: null, message: '', success: true }, 200, submitted)) const media = createMedia() const torrent = createTorrent() const user = userEvent.setup() @@ -540,7 +543,7 @@ describe('AddDownloadDialog submissions', () => { success: false, }) } - return HttpResponse.json({ data: { download_id: 'collection-download' }, success: true }) + return apiJson({ download_id: 'collection-download' }) }), ) mocks.confirm.mockResolvedValue(true) diff --git a/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts b/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts index 3bc8b924..25b30f07 100644 --- a/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts +++ b/src/components/dialog/__tests__/AddSubtitleDownloadDialog.spec.ts @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event' import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http, type JsonBodyType } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, type PropType } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -151,7 +152,7 @@ function createDeferred() { function directoriesHandler(directories: TransferDirectoryConf[]) { return http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () => - HttpResponse.json({ data: { value: directories }, success: true }), + apiJson({ value: directories }), ) } @@ -162,7 +163,9 @@ function subtitleDownloadHandler( ) { return http.post(new URL('download/subtitle', API_BASE_URL).href, async ({ request }) => { await onRequest(await request.json()) - return HttpResponse.json(await response, { status }) + const body = await response + if (status >= 400) return HttpResponse.json(body, { status }) + return HttpResponse.json(body, { status }) }) } @@ -277,7 +280,7 @@ describe('AddSubtitleDownloadDialog submissions', () => { it('submits a directly entered media ID through v-model', async () => { const submitted = vi.fn() - server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted)) + server.use(subtitleDownloadHandler({ data: null, message: '', success: true }, 200, submitted)) const user = userEvent.setup() await renderDialog({ mediaId: null, recognizeSource: 'douban' }) @@ -294,7 +297,7 @@ describe('AddSubtitleDownloadDialog submissions', () => { it('reveals advanced options and submits the selected media source identity', async () => { const submitted = vi.fn() - server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted)) + server.use(subtitleDownloadHandler({ data: null, message: '', success: true }, 200, submitted)) const user = userEvent.setup() await renderDialog({ mediaId: '84', mediaSource: 'themoviedb' }) @@ -348,7 +351,7 @@ describe('AddSubtitleDownloadDialog submissions', () => { expect(submitted).toHaveBeenCalledOnce() expect(mocks.startNProgress).toHaveBeenCalledOnce() - deferred.resolve({ data: null, success: true }) + deferred.resolve({ data: null, message: '', success: true }) await waitFor(() => expect(events.done).toHaveBeenCalledWith(expectedEnclosure)) expect(mocks.toastSuccess).toHaveBeenCalledWith('字幕站 测试字幕 字幕下载成功!') @@ -358,7 +361,7 @@ describe('AddSubtitleDownloadDialog submissions', () => { it('submits the exact-search identity passed by the resource result', async () => { const submitted = vi.fn() - server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted)) + server.use(subtitleDownloadHandler({ data: null, message: '', success: true }, 200, submitted)) const user = userEvent.setup() await renderDialog({ mediaId: '84', mediaSource: 'themoviedb', recognizeSource: 'douban' }) diff --git a/src/components/dialog/__tests__/DownloadHistoryDialog.spec.ts b/src/components/dialog/__tests__/DownloadHistoryDialog.spec.ts index ef771e27..f5ff91d9 100644 --- a/src/components/dialog/__tests__/DownloadHistoryDialog.spec.ts +++ b/src/components/dialog/__tests__/DownloadHistoryDialog.spec.ts @@ -11,6 +11,7 @@ import { import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, onMounted, ref, type PropType } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -207,9 +208,9 @@ describe('DownloadHistoryDialog', () => { http.get(downloadApiUrls.history, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - if (page === '1') return HttpResponse.json([first]) - if (page === '2') return HttpResponse.json([second]) - return HttpResponse.json([]) + if (page === '1') return apiJson([first]) + if (page === '2') return apiJson([second]) + return apiJson([]) }), ) const user = userEvent.setup() @@ -262,7 +263,7 @@ describe('DownloadHistoryDialog', () => { http.get(downloadApiUrls.history, () => { requestCount += 1 if (requestCount === 1) return HttpResponse.json({}, { status: 500 }) - return HttpResponse.json([item]) + return apiJson([item]) }), ) const user = userEvent.setup() diff --git a/src/components/dialog/__tests__/SiteImportDialog.spec.ts b/src/components/dialog/__tests__/SiteImportDialog.spec.ts index 8cb182ef..b763ef5a 100644 --- a/src/components/dialog/__tests__/SiteImportDialog.spec.ts +++ b/src/components/dialog/__tests__/SiteImportDialog.spec.ts @@ -7,6 +7,7 @@ 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 { apiJson } from '@tests/support/msw/response' import { describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -183,7 +184,7 @@ describe('SiteImportDialog', () => { server.use( http.post(siteApiUrls.list, () => { requestIndex += 1 - if (requestIndex === 1) return HttpResponse.json({ success: true }) + if (requestIndex === 1) return apiJson(null) return HttpResponse.json({ message: '第二站请求失败', success: false }, { status: 500 }) }), ) diff --git a/src/components/dialog/__tests__/SiteResourceDialog.spec.ts b/src/components/dialog/__tests__/SiteResourceDialog.spec.ts index 0bf5ebae..f41eb62d 100644 --- a/src/components/dialog/__tests__/SiteResourceDialog.spec.ts +++ b/src/components/dialog/__tests__/SiteResourceDialog.spec.ts @@ -9,6 +9,7 @@ import { siteApiUrls, siteCategoriesHandler, siteResourcesHandler } from '@tests import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, type Component, type PropType } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -128,13 +129,13 @@ describe('SiteResourceDialog', () => { const keyword = new URL(request.url).searchParams.get('keyword') if (keyword) { latestRequested() - return HttpResponse.json(await latestResponse.promise) + return apiJson(await latestResponse.promise) } oldRequested() await oldResponse.promise return oldStatus === 200 - ? HttpResponse.json([createTorrentInfo({ title: '旧条件结果' })]) + ? apiJson([createTorrentInfo({ title: '旧条件结果' })]) : HttpResponse.json({ detail: 'stale failure' }, { status: oldStatus }) }), ) @@ -176,7 +177,7 @@ describe('SiteResourceDialog', () => { attempts += 1 if (attempts === 1) return HttpResponse.json({ detail: 'temporary failure' }, { status: 500 }) - return HttpResponse.json([createTorrentInfo({ title: '重试恢复结果' })]) + return apiJson([createTorrentInfo({ title: '重试恢复结果' })]) }), ) const user = userEvent.setup() @@ -207,7 +208,7 @@ describe('SiteResourceDialog', () => { siteCategoriesHandler(501, []), http.get(siteApiUrls.resources(501), () => { attempts += 1 - if (attempts === 1) return HttpResponse.json([createTorrentInfo({ title: '已有资源' })]) + if (attempts === 1) return apiJson([createTorrentInfo({ title: '已有资源' })]) return HttpResponse.json({ detail: 'temporary failure' }, { status: 500 }) }), @@ -283,8 +284,8 @@ describe('SiteResourceDialog', () => { siteCategoriesHandler(501, []), http.get(siteApiUrls.resources(501), async () => { requestCount += 1 - if (requestCount === 1) return HttpResponse.json([]) - return HttpResponse.json(await nextResponse.promise) + if (requestCount === 1) return apiJson([]) + return apiJson(await nextResponse.promise) }), ) const user = userEvent.setup() diff --git a/src/components/dialog/__tests__/SubscribeFilesDialog.spec.ts b/src/components/dialog/__tests__/SubscribeFilesDialog.spec.ts index 77623727..acad4dff 100644 --- a/src/components/dialog/__tests__/SubscribeFilesDialog.spec.ts +++ b/src/components/dialog/__tests__/SubscribeFilesDialog.spec.ts @@ -6,6 +6,7 @@ import { server } from '@tests/support/msw/server' import { subscribeApiUrls, subscribeFilesHandler } from '@tests/support/msw/handlers/subscribe' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http, type JsonBodyType } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -437,7 +438,7 @@ describe('SubscribeFilesDialog', () => { http.get(subscribeApiUrls.filesById(3102), () => { requestCount += 1 if (requestCount === 1) return HttpResponse.json({}, { status: 500 }) - return HttpResponse.json(info as unknown as JsonBodyType) + return apiJson(info as unknown as JsonBodyType) }), ) const user = userEvent.setup() @@ -456,7 +457,7 @@ describe('SubscribeFilesDialog', () => { const deferred = createDeferred() server.use( http.get(subscribeApiUrls.filesById(3116), async () => { - return HttpResponse.json(await deferred.promise) + return apiJson(await deferred.promise) }), ) diff --git a/src/components/dialog/__tests__/SubscribeHistoryDialog.spec.ts b/src/components/dialog/__tests__/SubscribeHistoryDialog.spec.ts index c7998273..0aa0455a 100644 --- a/src/components/dialog/__tests__/SubscribeHistoryDialog.spec.ts +++ b/src/components/dialog/__tests__/SubscribeHistoryDialog.spec.ts @@ -13,6 +13,7 @@ import { import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http, type JsonBodyType } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, onMounted, ref, type PropType } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -246,9 +247,9 @@ describe('SubscribeHistoryDialog', () => { const url = new URL(request.url) const page = url.searchParams.get('page') ?? '' requestedPages.push(page) - if (page === '1') return HttpResponse.json([first]) - if (page === '2') return HttpResponse.json([second]) - return HttpResponse.json([]) + if (page === '1') return apiJson([first]) + if (page === '2') return apiJson([second]) + return apiJson([]) }), ) const user = userEvent.setup() @@ -283,7 +284,7 @@ describe('SubscribeHistoryDialog', () => { const url = new URL(request.url) requestedPages.push(url.searchParams.get('page') ?? '') if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 }) - return HttpResponse.json([recovered]) + return apiJson([recovered]) }), ) const user = userEvent.setup() @@ -305,7 +306,7 @@ describe('SubscribeHistoryDialog', () => { server.use( http.get(subscribeApiUrls.historyByType('电影'), async () => { requested() - return HttpResponse.json(await pending.promise) + return apiJson(await pending.promise) }), ) @@ -329,13 +330,13 @@ describe('SubscribeHistoryDialog', () => { ], ['音乐', createHistory({ name: '重新订阅专辑', type: '音乐' }), '正在重新订阅 重新订阅专辑...'], ] as const)('shows the %s pending copy and emits save only after success', async (type, item, progressText) => { - const pending = createDeferred<{ success: boolean }>() + const pending = createDeferred() let payload: JsonBodyType | undefined server.use( subscribeHistoryHandler(type, [item]), http.post(subscribeApiUrls.create, async ({ request }) => { payload = (await request.json()) as JsonBodyType - return HttpResponse.json(await pending.promise) + return apiJson(await pending.promise) }), ) const user = userEvent.setup() @@ -346,7 +347,7 @@ describe('SubscribeHistoryDialog', () => { expect(await screen.findByRole('status')).toHaveTextContent(progressText) expect(events.save).not.toHaveBeenCalled() - pending.resolve({ success: true }) + pending.resolve(null) await waitFor(() => expect(events.save).toHaveBeenCalledOnce()) expect(payload).toEqual(item) diff --git a/src/pages/__tests__/appcenter.spec.ts b/src/pages/__tests__/appcenter.spec.ts index 33732e06..0cf78f6e 100644 --- a/src/pages/__tests__/appcenter.spec.ts +++ b/src/pages/__tests__/appcenter.spec.ts @@ -5,7 +5,8 @@ import { useUserStore } from '@/stores/user' import { screen, waitFor } from '@testing-library/vue' import { renderWithProviders } from '@tests/support/render' import { server } from '@tests/support/msw/server' -import { http, HttpResponse } from 'msw' +import { http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h } from 'vue' import { describe, expect, it } from 'vitest' @@ -31,7 +32,7 @@ function createNavItem(overrides: Partial = {}): PluginSid } function sidebarNavHandler(items: PluginSidebarNavItem[]) { - return http.get(SIDEBAR_NAV_URL, () => HttpResponse.json(items)) + return http.get(SIDEBAR_NAV_URL, () => apiJson(items)) } async function renderAppCenter(items: PluginSidebarNavItem[], permissions: Record = {}) { diff --git a/src/pages/__tests__/discover.spec.ts b/src/pages/__tests__/discover.spec.ts index 632a2246..b12fa1af 100644 --- a/src/pages/__tests__/discover.spec.ts +++ b/src/pages/__tests__/discover.spec.ts @@ -12,6 +12,7 @@ import { } from '@tests/support/msw/handlers/discover' import { server } from '@tests/support/msw/server' import { HttpResponse, http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, ref, unref, type ComputedRef, type Ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -364,7 +365,7 @@ describe('discover page', () => { server.use( http.get(discoverApiUrls.sources, () => { requested() - return HttpResponse.json(sources) + return apiJson(sources) }), ) await renderDiscover() @@ -388,7 +389,8 @@ describe('discover page', () => { server.use( http.get(discoverApiUrls.sources, () => { requested() - return HttpResponse.json(status === 200 ? [createSource('缓存来源', 'cached')] : [], { status }) + if (status >= 400) return HttpResponse.json([], { status }) + return apiJson([createSource('缓存来源', 'cached')]) }), ) await renderDiscover() @@ -417,7 +419,7 @@ describe('discover page', () => { server.use( http.get(discoverApiUrls.sources, () => { requested() - return HttpResponse.json(sources) + return apiJson(sources) }), ) await renderDiscover() diff --git a/src/pages/__tests__/recommend.spec.ts b/src/pages/__tests__/recommend.spec.ts index 512c3c60..14a1fc50 100644 --- a/src/pages/__tests__/recommend.spec.ts +++ b/src/pages/__tests__/recommend.spec.ts @@ -11,7 +11,8 @@ import { saveRecommendConfigHandler, } from '@tests/support/msw/handlers/recommend' import { server } from '@tests/support/msw/server' -import { HttpResponse, http } from 'msw' +import { http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, nextTick, ref, type Ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -111,7 +112,7 @@ async function renderKeptAliveRecommend() { function dynamicRecommendSources(getSources: () => RecommendSource[], onRequest = vi.fn()) { return http.get(recommendApiUrls.sources, () => { onRequest() - return HttpResponse.json(getSources()) + return apiJson(getSources()) }) } @@ -183,7 +184,7 @@ describe('recommend page', () => { http.get(recommendApiUrls.sources, async () => { requested() await responseGate - return HttpResponse.json([{ api_path: 'recommend/completed', name: '请求完成来源', type: '扩展' }]) + return apiJson([{ api_path: 'recommend/completed', name: '请求完成来源', type: '扩展' }]) }), ) diff --git a/src/views/dashboard/__tests__/MediaRecommend.spec.ts b/src/views/dashboard/__tests__/MediaRecommend.spec.ts index 87548cbb..0fcfac29 100644 --- a/src/views/dashboard/__tests__/MediaRecommend.spec.ts +++ b/src/views/dashboard/__tests__/MediaRecommend.spec.ts @@ -6,7 +6,8 @@ import { createMediaInfo } from '@tests/support/factories/media' import { recommendApiUrls, recommendMediaHandler } from '@tests/support/msw/handlers/recommend' import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' -import { http, HttpResponse } from 'msw' +import { http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, ref } from 'vue' import { describe, expect, it, vi } from 'vitest' @@ -148,7 +149,7 @@ describe('MediaRecommend', () => { expect(await screen.findByText('快照推荐')).toBeInTheDocument() await waitFor(() => expect(requested).toHaveBeenCalledOnce()) - resolveRequest?.(HttpResponse.json([createMediaInfo({ title: '刷新推荐' })])) + resolveRequest?.(apiJson([createMediaInfo({ title: '刷新推荐' })])) expect(await screen.findByText('刷新推荐')).toBeInTheDocument() second.unmount() }) @@ -324,7 +325,7 @@ describe('MediaRecommend', () => { await fireEvent.click(screen.getByRole('button', { name: '停用慢请求推荐' })) setInterval.mockClear() - resolveRequest?.(HttpResponse.json([createMediaInfo({ title: '迟到推荐' })])) + resolveRequest?.(apiJson([createMediaInfo({ title: '迟到推荐' })])) await waitFor(() => expect(getActiveRequestsCount()).toBe(0)) await new Promise(resolve => window.setTimeout(resolve, 0)) @@ -380,7 +381,7 @@ describe('MediaRecommend', () => { await fireEvent.click(screen.getByRole('button', { name: '恢复切源推荐' })) expect(setInterval).not.toHaveBeenCalledWith(expect.any(Function), 8000) - resolveMovies?.(HttpResponse.json([createMediaInfo({ title: '切源完成' })])) + resolveMovies?.(apiJson([createMediaInfo({ title: '切源完成' })])) await waitFor(() => expect(getActiveRequestsCount()).toBe(0)) await screen.findByText('切源完成') @@ -435,7 +436,7 @@ describe('MediaRecommend', () => { expect(await screen.findByText('初始结果')).toBeInTheDocument() await waitFor(() => expect(resolveMovies).toBeTypeOf('function')) - resolveMovies?.(HttpResponse.json([createMediaInfo({ title: '过期结果' })])) + resolveMovies?.(apiJson([createMediaInfo({ title: '过期结果' })])) await waitFor(() => expect(getActiveRequestsCount()).toBe(0)) await new Promise(resolve => window.setTimeout(resolve, 0)) diff --git a/src/views/discover/__tests__/MediaCardListView.spec.ts b/src/views/discover/__tests__/MediaCardListView.spec.ts index 8ac162bd..7c184616 100644 --- a/src/views/discover/__tests__/MediaCardListView.spec.ts +++ b/src/views/discover/__tests__/MediaCardListView.spec.ts @@ -6,6 +6,7 @@ import { createMediaInfo } from '@tests/support/factories/media' import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http, type JsonBodyType } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, onMounted, ref, type PropType } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -178,7 +179,7 @@ describe('MediaCardListView', () => { server.use( http.get(LIST_URL, ({ request }) => { requests.push(new URL(request.url)) - return HttpResponse.json([createMediaInfo({ title: '内部页码结果' })]) + return apiJson([createMediaInfo({ title: '内部页码结果' })]) }), ) @@ -198,7 +199,7 @@ describe('MediaCardListView', () => { server.use( http.get(LIST_URL, ({ request }) => { requests.push(new URL(request.url)) - return HttpResponse.json([createMediaInfo({ title: '多来源结果' })]) + return apiJson([createMediaInfo({ title: '多来源结果' })]) }), ) @@ -216,7 +217,7 @@ describe('MediaCardListView', () => { http.get(LIST_URL, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - return HttpResponse.json([createMediaInfo({ title: '单页媒体' })]) + return apiJson([createMediaInfo({ title: '单页媒体' })]) }), ) @@ -233,7 +234,7 @@ describe('MediaCardListView', () => { http.get(LIST_URL, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - return HttpResponse.json([createMediaInfo({ title: page === '1' ? '未满屏第一页' : '未满屏第二页' })]) + return apiJson([createMediaInfo({ title: page === '1' ? '未满屏第一页' : '未满屏第二页' })]) }), ) @@ -260,7 +261,7 @@ describe('MediaCardListView', () => { { media_id: 'media-43', title: '不同 media_id' }, ] const response = [base, { ...base, title: '完全重复项' }, ...variants.map(variant => ({ ...base, ...variant }))] - server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType))) + server.use(http.get(LIST_URL, () => apiJson(response as unknown as JsonBodyType))) await renderList() @@ -285,7 +286,7 @@ describe('MediaCardListView', () => { tmdb_id: undefined, }), ) - server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType))) + server.use(http.get(LIST_URL, () => apiJson(response as unknown as JsonBodyType))) await renderList() @@ -302,7 +303,7 @@ describe('MediaCardListView', () => { createMediaInfo({ media_id: undefined, media_source: undefined, title: '无标识媒体 A', tmdb_id: undefined }), createMediaInfo({ media_id: undefined, media_source: undefined, title: '无标识媒体 B', tmdb_id: undefined }), ] - server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType))) + server.use(http.get(LIST_URL, () => apiJson(response as unknown as JsonBodyType))) await renderList() @@ -321,9 +322,9 @@ describe('MediaCardListView', () => { http.get(LIST_URL, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - if (page === '1') return HttpResponse.json([first, second] as unknown as JsonBodyType) - if (page === '2') return HttpResponse.json([{ ...first, title: '第二页全重复' }] as unknown as JsonBodyType) - return HttpResponse.json([later] as unknown as JsonBodyType) + if (page === '1') return apiJson([first, second] as unknown as JsonBodyType) + if (page === '2') return apiJson([{ ...first, title: '第二页全重复' }] as unknown as JsonBodyType) + return apiJson([later] as unknown as JsonBodyType) }), ) @@ -345,10 +346,10 @@ describe('MediaCardListView', () => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) if (page === '1') { - return HttpResponse.json([first, { ...first, title: '页内重复 A' }, second] as unknown as JsonBodyType) + return apiJson([first, { ...first, title: '页内重复 A' }, second] as unknown as JsonBodyType) } - if (page === '2') return HttpResponse.json([between] as unknown as JsonBodyType) - return HttpResponse.json([{ ...second }, { ...first }] as unknown as JsonBodyType) + if (page === '2') return apiJson([between] as unknown as JsonBodyType) + return apiJson([{ ...second }, { ...first }] as unknown as JsonBodyType) }), ) @@ -362,7 +363,7 @@ describe('MediaCardListView', () => { it('marks an empty first page as the pagination terminal state', async () => { setScrollHeight(() => 900) - server.use(http.get(LIST_URL, () => HttpResponse.json([]))) + server.use(http.get(LIST_URL, () => apiJson([]))) await renderList() @@ -372,7 +373,7 @@ describe('MediaCardListView', () => { it('renders HTTP 200 plus an empty array as ordinary no-data rather than a network error', async () => { setScrollHeight(() => 900) - server.use(http.get(LIST_URL, () => HttpResponse.json([]))) + server.use(http.get(LIST_URL, () => apiJson([]))) await renderList() @@ -389,7 +390,7 @@ describe('MediaCardListView', () => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 }) - return HttpResponse.json([createMediaInfo({ title: '首载重试成功' })]) + return apiJson([createMediaInfo({ title: '首载重试成功' })]) }), ) const user = userEvent.setup() @@ -412,10 +413,10 @@ describe('MediaCardListView', () => { http.get(LIST_URL, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - if (page === '1') return HttpResponse.json([createMediaInfo({ title: '保留第一页' })]) + if (page === '1') return apiJson([createMediaInfo({ title: '保留第一页' })]) pageTwoAttempts += 1 if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 }) - return HttpResponse.json([createMediaInfo({ title: '第二页重试成功' })]) + return apiJson([createMediaInfo({ title: '第二页重试成功' })]) }), ) const user = userEvent.setup() @@ -438,7 +439,7 @@ describe('MediaCardListView', () => { http.get(LIST_URL, async ({ request }) => { requests.push(new URL(request.url)) await gate.promise - return HttpResponse.json([createMediaInfo({ title: '在途请求结果' })]) + return apiJson([createMediaInfo({ title: '在途请求结果' })]) }), ) diff --git a/src/views/discover/__tests__/MediaDetailView.spec.ts b/src/views/discover/__tests__/MediaDetailView.spec.ts index 64d29970..9e6dcd71 100644 --- a/src/views/discover/__tests__/MediaDetailView.spec.ts +++ b/src/views/discover/__tests__/MediaDetailView.spec.ts @@ -32,6 +32,7 @@ import { import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, type PropType } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -137,8 +138,8 @@ interface RenderDetailOptions { function installSiteHandlers(sites: Site[] = [], selected: number[] = [], type = '电影') { server.use( - http.get(type === '电视剧' ? tvSiteListUrl : movieSiteListUrl, () => HttpResponse.json(sites)), - http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })), + http.get(type === '电视剧' ? tvSiteListUrl : movieSiteListUrl, () => apiJson(sites)), + http.get(selectedSitesUrl, () => apiJson({ value: selected })), ) } @@ -593,8 +594,8 @@ describe('MediaDetailView detail and actions', () => { const site = createSubscribeSite({ id: 92, is_active: true, name: '空设置站点' }) await renderDetail() server.use( - http.get(movieSiteListUrl, () => HttpResponse.json([site])), - http.get(selectedSitesUrl, () => HttpResponse.json({ data: {}, success: true })), + http.get(movieSiteListUrl, () => apiJson([site])), + http.get(selectedSitesUrl, () => apiJson({})), ) await fireEvent.click(screen.getByRole('button', { name: /搜索字幕/ })) @@ -1112,7 +1113,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => { staleRequest() const response = await staleSeasons.promise staleResponseReturned.resolve() - return HttpResponse.json(response) + return apiJson(response) }), mediaGroupSeasonsHandler('group-b', [createMediaSeason({ season_number: 2 })]), ) @@ -1163,12 +1164,12 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => { staleMissingRequest() const response = await staleMissing.promise staleMissingReturned.resolve() - return HttpResponse.json(response) + return apiJson(response) } if (payload.episode_group === 'group-b') { - return HttpResponse.json([createNotExistMediaInfo({ episodes: [2], season: 1, total_episode: 2 })]) + return apiJson([createNotExistMediaInfo({ episodes: [2], season: 1, total_episode: 2 })]) } - return HttpResponse.json([]) + return apiJson([]) }), http.post(mediaApiUrls.existsRemote, async ({ request }) => { const payload = (await request.json()) as Record @@ -1176,13 +1177,13 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => { staleRemoteRequest() const response = await staleRemoteExists.promise staleRemoteReturned.resolve() - return HttpResponse.json(response) + return apiJson(response) } - return HttpResponse.json(payload.episode_group === 'group-b' ? { 1: [2] } : {}) + return apiJson(payload.episode_group === 'group-b' ? { 1: [2] } : {}) }), http.get(new URL('tmdb/8712/1', API_BASE_URL).href, ({ request }) => { const group = new URL(request.url).searchParams.get('episode_group') - return HttpResponse.json([ + return apiJson([ createTmdbEpisode({ episode_number: 1, name: `${group} 第一集`, season_number: 1 }), createTmdbEpisode({ episode_number: 2, name: `${group} 第二集`, season_number: 1 }), ]) @@ -1254,10 +1255,10 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => { staleRequest() const response = await staleEpisodes.promise staleResponseReturned.resolve() - return HttpResponse.json(response) + return apiJson(response) } currentRequest() - return HttpResponse.json([createTmdbEpisode({ episode_number: 1, name: 'B 组第一集', season_number: 1 })]) + return apiJson([createTmdbEpisode({ episode_number: 1, name: 'B 组第一集', season_number: 1 })]) }), ) diff --git a/src/views/discover/__tests__/PersonCardListView.spec.ts b/src/views/discover/__tests__/PersonCardListView.spec.ts index ec1f7792..dfe38b22 100644 --- a/src/views/discover/__tests__/PersonCardListView.spec.ts +++ b/src/views/discover/__tests__/PersonCardListView.spec.ts @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event' import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, onMounted, ref, type PropType } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -112,9 +113,7 @@ describe('PersonCardListView', () => { it('loads people with the configured prefetch margin', async () => { server.use( - http.get(LIST_URL, () => - HttpResponse.json([{ id: 101, name: '探索人物', source: 'themoviedb' } satisfies Person]), - ), + http.get(LIST_URL, () => apiJson([{ id: 101, name: '探索人物', source: 'themoviedb' } satisfies Person])), ) await renderList() @@ -129,7 +128,7 @@ describe('PersonCardListView', () => { server.use( http.get(LIST_URL, ({ request }) => { requests.push(new URL(request.url)) - return HttpResponse.json([{ id: 303, name: '多来源人物', source: 'themoviedb' } satisfies Person]) + return apiJson([{ id: 303, name: '多来源人物', source: 'themoviedb' } satisfies Person]) }), ) @@ -146,7 +145,7 @@ describe('PersonCardListView', () => { http.get(LIST_URL, () => { requests++ if (requests === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 }) - return HttpResponse.json([{ id: 202, name: '人物重试结果', source: 'themoviedb' } satisfies Person]) + return apiJson([{ id: 202, name: '人物重试结果', source: 'themoviedb' } satisfies Person]) }), ) const user = userEvent.setup() diff --git a/src/views/discover/__tests__/PersonCardSlideView.spec.ts b/src/views/discover/__tests__/PersonCardSlideView.spec.ts index 2a6e4d69..9e9e9b46 100644 --- a/src/views/discover/__tests__/PersonCardSlideView.spec.ts +++ b/src/views/discover/__tests__/PersonCardSlideView.spec.ts @@ -4,6 +4,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/vue' import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, type PropType, ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -75,7 +76,8 @@ const PersonCardStub = defineComponent({ function personResponse(people: Person[], status = 200, onRequest: () => void = () => {}) { return http.get(API_URL, () => { onRequest() - return HttpResponse.json(people, { status }) + if (status >= 400) return HttpResponse.json(people, { status }) + return apiJson(people, { status }) }) } diff --git a/src/views/plugin/__tests__/PluginCardListView.spec.ts b/src/views/plugin/__tests__/PluginCardListView.spec.ts index 75dcb6bc..b7d99630 100644 --- a/src/views/plugin/__tests__/PluginCardListView.spec.ts +++ b/src/views/plugin/__tests__/PluginCardListView.spec.ts @@ -9,6 +9,7 @@ import { fireEvent, screen, waitFor, within } from '@testing-library/vue' import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http, type JsonBodyType } from 'msw' +import { apiFailureJson, apiJson } from '@tests/support/msw/response' import { computed, defineComponent, h, nextTick, unref, type ComputedRef, type PropType, type Ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -384,20 +385,18 @@ interface ListResponses { function registerListHandlers(responses: ListResponses = {}) { server.use( - http.get(apiUrls.order, () => - HttpResponse.json({ data: { value: responses.order ?? [] }, success: true } as JsonBodyType), - ), - http.get(apiUrls.folders, async () => HttpResponse.json((await responses.folders?.()) ?? {})), + http.get(apiUrls.order, () => apiJson({ value: responses.order ?? [] })), + http.get(apiUrls.folders, async () => apiJson((await responses.folders?.()) ?? {})), http.get(apiUrls.list, async ({ request }) => { const state = new URL(request.url).searchParams.get('state') const plugins = state === 'installed' ? await responses.installed?.() : await responses.market?.() - return HttpResponse.json((plugins ?? []) as unknown as JsonBodyType, { + return apiJson((plugins ?? []) as unknown as JsonBodyType, { status: state === 'installed' ? (responses.installedStatus ?? 200) : (responses.marketStatus ?? 200), }) }), - http.get(apiUrls.statistic, async () => HttpResponse.json((await responses.statistic?.()) ?? {})), + http.get(apiUrls.statistic, async () => apiJson((await responses.statistic?.()) ?? {})), http.get(apiUrls.runtime, async () => - HttpResponse.json( + apiJson( (await responses.runtime?.()) ?? { failed_count: 0, generation: 0, @@ -406,10 +405,10 @@ function registerListHandlers(responses: ListResponses = {}) { }, ), ), - http.get(apiUrls.sidebar, () => HttpResponse.json([])), + http.get(apiUrls.sidebar, () => apiJson([])), http.get(apiUrls.rating, async ({ request }) => { const ids = new URL(request.url).searchParams.get('plugin_ids')?.split(',').filter(Boolean) ?? [] - return HttpResponse.json(((await responses.rating?.(ids)) ?? {}) as unknown as JsonBodyType) + return apiJson(((await responses.rating?.(ids)) ?? {}) as unknown as JsonBodyType) }), ) } @@ -760,7 +759,7 @@ describe('PluginCardListView loading and request ownership', () => { const state = new URL(request.url).searchParams.get('state') const plugins = state === 'installed' ? [createPlugin({ id: 'Recovered', installed: true, plugin_name: '重试恢复插件' })] : [] - return HttpResponse.json(plugins as unknown as JsonBodyType) + return apiJson(plugins as unknown as JsonBodyType) }), ) await fireEvent.click(screen.getByRole('button', { name: '重试' })) @@ -844,7 +843,7 @@ describe('PluginCardListView loading and request ownership', () => { const state = new URL(request.url).searchParams.get('state') const plugins = state === 'market' ? [createPlugin({ id: 'MarketRecovered', plugin_name: '市场重试恢复插件' })] : [] - return HttpResponse.json(plugins as unknown as JsonBodyType) + return apiJson(plugins as unknown as JsonBodyType) }), ) await fireEvent.click(screen.getByRole('button', { name: '重试' })) @@ -1217,7 +1216,7 @@ describe('PluginCardListView installed filtering and host callbacks', () => { getDialogEvents().changed() await waitFor(() => expect(marketRequests).toBeGreaterThan(requestsAfterSave)) - server.use(http.get(apiUrls.install('Available'), () => HttpResponse.json({ data: null, success: true }))) + server.use(http.get(apiUrls.install('Available'), () => apiJson(null))) await fireEvent.click(screen.getByRole('button', { name: 'installed-Available' })) await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)) await waitForRequestsToFinish() @@ -1237,7 +1236,7 @@ describe('PluginCardListView installed filtering and host callbacks', () => { http.get(apiUrls.install('MarketInstall'), async () => { await installGate.promise installed = true - return HttpResponse.json({ data: null, success: true }) + return apiJson(null) }), ) @@ -1272,7 +1271,7 @@ describe('PluginCardListView search installation', () => { http.get(apiUrls.install('SearchPlugin'), () => { installRequests += 1 return mode === 'business' - ? HttpResponse.json({ message: 'Rejected', success: false }) + ? apiFailureJson('Rejected') : HttpResponse.json({ message: 'HTTP failure' }, { status: 500 }) }), ) @@ -1330,7 +1329,7 @@ describe('PluginCardListView search installation', () => { http.get(apiUrls.install('SearchPlugin'), ({ request }) => { installUrl = new URL(request.url) installed = true - return HttpResponse.json({ data: null, success: true }) + return apiJson(null) }), ) const sidebarStore = usePluginSidebarNavStore(pinia) @@ -1370,7 +1369,7 @@ describe('PluginCardListView search installation', () => { http.get(apiUrls.install('PendingPlugin'), async () => { await installGate.promise installed = true - return HttpResponse.json({ data: null, success: true }) + return apiJson(null) }), ) @@ -1400,7 +1399,7 @@ describe('PluginCardListView search installation', () => { http.get(apiUrls.install('DuplicatePlugin'), async () => { installRequests += 1 await installGate.promise - return HttpResponse.json({ data: null, success: true }) + return apiJson(null) }), ) @@ -1432,11 +1431,7 @@ describe('PluginCardListView search installation', () => { market: () => [target], }) await waitForRequestsToFinish() - server.use( - http.get(apiUrls.install('SlowRollbackPlugin'), () => - HttpResponse.json({ message: '依赖安装失败', success: false }), - ), - ) + server.use(http.get(apiUrls.install('SlowRollbackPlugin'), () => apiFailureJson('依赖安装失败'))) getDynamicButtonConfig().onClick() await getDialogEvents()['open-plugin'](target) @@ -1453,9 +1448,7 @@ describe('PluginCardListView search installation', () => { const target = createPlugin({ id: 'FailedPlugin', plugin_name: '失败插件' }) await renderList({ installed: () => [stable], market: () => [target] }) await waitForRequestsToFinish() - server.use( - http.get(apiUrls.install('FailedPlugin'), () => HttpResponse.json({ message: '依赖安装失败', success: false })), - ) + server.use(http.get(apiUrls.install('FailedPlugin'), () => apiFailureJson('依赖安装失败'))) getDynamicButtonConfig().onClick() await getDialogEvents()['open-plugin'](target) @@ -1525,7 +1518,7 @@ describe('PluginCardListView folders and persistence', () => { }) await screen.findByText('plugin:已安装插件') await waitForRequestsToFinish() - server.use(http.post(apiUrls.folders, () => HttpResponse.json({ message: '保存被拒绝', success: false }))) + server.use(http.post(apiUrls.folders, () => apiFailureJson('保存被拒绝'))) getDynamicMenuItem('plugin.newFolder').action() const events = getDialogEvents() @@ -1543,7 +1536,7 @@ describe('PluginCardListView folders and persistence', () => { await renderList({ folders: () => ({ Existing: [] }) }) await screen.findByText('folder:Existing') await waitForRequestsToFinish() - server.use(http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true }))) + server.use(http.post(apiUrls.folders, () => apiJson(null))) getDynamicMenuItem('plugin.newFolder').action() const events = getDialogEvents() @@ -1568,7 +1561,7 @@ describe('PluginCardListView folders and persistence', () => { }) await screen.findByText('folder:Tools') await waitForRequestsToFinish() - server.use(http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true }))) + server.use(http.post(apiUrls.folders, () => apiJson(null))) await fireEvent.click(screen.getByRole('button', { name: 'configure-folder-Tools' })) await waitFor(() => expect(screen.getByLabelText('folder-color-Tools')).toHaveTextContent('#ff0000')) @@ -1597,7 +1590,7 @@ describe('PluginCardListView folders and persistence', () => { saveAttempt += 1 return saveAttempt === 2 ? HttpResponse.json({ message: 'HTTP failure' }, { status: 500 }) - : HttpResponse.json({ message: 'Rejected', success: false }) + : apiFailureJson('Rejected') }), ) @@ -1626,9 +1619,7 @@ describe('PluginCardListView folders and persistence', () => { await waitForRequestsToFinish() server.use( http.post(apiUrls.folders, () => - saveSucceeds - ? HttpResponse.json({ data: null, success: true }) - : HttpResponse.json({ message: 'Rejected' }, { status: 500 }), + saveSucceeds ? apiJson(null) : HttpResponse.json({ message: 'Rejected' }, { status: 500 }), ), ) @@ -1664,11 +1655,9 @@ describe('PluginCardListView folders and persistence', () => { server.use( http.post(apiUrls.order, async ({ request }) => { savedOrder = await request.json() - return orderSucceeds - ? HttpResponse.json({ data: null, success: true }) - : HttpResponse.json({ message: 'Rejected', success: false }) + return orderSucceeds ? apiJson(null) : apiFailureJson('Rejected') }), - http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true })), + http.post(apiUrls.folders, () => apiJson(null)), ) getHeaderButton('mdi-sort-variant').action?.() @@ -1712,17 +1701,17 @@ describe('PluginCardListView folders and persistence', () => { server.use( http.get(apiUrls.order, () => { orderReads += 1 - return HttpResponse.json({ data: { value: persistedOrder }, success: true }) + return apiJson({ value: persistedOrder }) }), http.get(apiUrls.folders, () => { folderReads += 1 - return HttpResponse.json({ Tools: [] }) + return apiJson({ Tools: [] }) }), http.post(apiUrls.order, async ({ request }) => { persistedOrder = (await request.json()) as unknown[] - return HttpResponse.json({ data: null, success: true }) + return apiJson(null) }), - http.post(apiUrls.folders, () => HttpResponse.json({ message: 'Rejected', success: false })), + http.post(apiUrls.folders, () => apiFailureJson('Rejected')), ) getHeaderButton('mdi-sort-variant').action?.() @@ -1762,15 +1751,15 @@ describe('PluginCardListView folders and persistence', () => { server.use( http.get(apiUrls.order, () => { orderReads += 1 - return HttpResponse.json({ data: { value: persistedOrder }, success: true }) + return apiJson({ value: persistedOrder }) }), http.get(apiUrls.folders, () => { folderReads += 1 - return HttpResponse.json({ Tools: ['Plugin-A', 'Plugin-B'] }) + return apiJson({ Tools: ['Plugin-A', 'Plugin-B'] }) }), http.post(apiUrls.order, async ({ request }) => { persistedOrder = (await request.json()) as unknown[] - return HttpResponse.json({ data: null, success: true }) + return apiJson(null) }), http.post(apiUrls.folders, () => HttpResponse.json({ message: 'Rejected' }, { status: 500 })), ) @@ -1800,12 +1789,8 @@ describe('PluginCardListView folders and persistence', () => { await screen.findByText('folder:Tools') await waitForRequestsToFinish() server.use( - http.post(apiUrls.order, () => HttpResponse.json({ data: null, success: true })), - http.post(apiUrls.folders, () => - folderSaveSucceeds - ? HttpResponse.json({ data: null, success: true }) - : HttpResponse.json({ message: 'Rejected', success: false }), - ), + http.post(apiUrls.order, () => apiJson(null)), + http.post(apiUrls.folders, () => (folderSaveSucceeds ? apiJson(null) : apiFailureJson('Rejected'))), ) getHeaderButton('mdi-sort-variant').action?.() @@ -1845,12 +1830,8 @@ describe('PluginCardListView folders and persistence', () => { await screen.findByText('folder:Tools') await waitForRequestsToFinish() server.use( - http.post(apiUrls.order, () => - orderSucceeds - ? HttpResponse.json({ data: null, success: true }) - : HttpResponse.json({ message: 'Rejected', success: false }), - ), - http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true })), + http.post(apiUrls.order, () => (orderSucceeds ? apiJson(null) : apiFailureJson('Rejected'))), + http.post(apiUrls.folders, () => apiJson(null)), ) await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' })) diff --git a/src/views/setting/__tests__/AccountSettingSystem.spec.ts b/src/views/setting/__tests__/AccountSettingSystem.spec.ts index bc03a90d..1a37661c 100644 --- a/src/views/setting/__tests__/AccountSettingSystem.spec.ts +++ b/src/views/setting/__tests__/AccountSettingSystem.spec.ts @@ -306,6 +306,62 @@ function createModelFieldStub(name: string) { }) } +/** 保留选择值契约,排除 Vuetify 菜单动画和定位对设置业务测试的影响。 */ +const SelectFieldStub = defineComponent({ + name: 'VSelectStub', + props: { + disabled: { type: Boolean, default: false }, + itemTitle: { type: String, default: 'title' }, + itemValue: { type: String, default: 'value' }, + items: { type: Array, default: () => [] }, + label: { type: String, default: '' }, + modelValue: { default: null }, + multiple: { type: Boolean, default: false }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + const itemValue = (item: unknown) => { + if (!item || typeof item !== 'object') return item + return (item as Record)[props.itemValue] + } + const itemTitle = (item: unknown) => { + if (!item || typeof item !== 'object') return String(item ?? '') + return String((item as Record)[props.itemTitle] ?? '') + } + const findItemValue = (value: string) => props.items.map(itemValue).find(item => String(item) === value) + + return () => + h('label', [ + h('span', props.label), + h( + 'select', + { + 'aria-label': props.label, + disabled: props.disabled, + multiple: props.multiple, + onChange: (event: Event) => { + const select = event.target as HTMLSelectElement + if (props.multiple) { + const selectedValues = Array.from(select.selectedOptions, option => findItemValue(option.value)) + emit('update:modelValue', selectedValues) + return + } + emit('update:modelValue', findItemValue(select.value)) + }, + ...(props.multiple ? {} : { value: String(props.modelValue ?? '') }), + }, + props.items.map(item => { + const value = itemValue(item) + const selected = props.multiple + ? (props.modelValue as unknown[] | null)?.some(modelValue => String(modelValue) === String(value)) + : String(props.modelValue ?? '') === String(value) + return h('option', { selected, value: String(value) }, itemTitle(item)) + }), + ), + ]) + }, +}) + const CronFieldStub = createModelFieldStub('VCronFieldStub') const PathFieldStub = createModelFieldStub('VPathFieldStub') @@ -314,7 +370,7 @@ async function renderSettings(props: { active?: boolean } = {}) { props, global: { components: { VCronField: CronFieldStub, VPathField: PathFieldStub }, - stubs: { VDialogCloseBtn: true }, + stubs: { VDialogCloseBtn: true, VSelect: SelectFieldStub }, }, stubActions: false, }) @@ -327,7 +383,7 @@ function getBasicCard() { } function getSettingsCard(title: string) { - const card = screen.getByText(title).closest('.v-card') + const card = screen.getByText(title, { selector: '.v-card-title' }).closest('.v-card') expect(card).not.toBeNull() return within(card as HTMLElement) } @@ -340,7 +396,17 @@ async function openAdvancedTab(tab: string) { async function selectOption(label: string, option: string) { const user = userEvent.setup() - await user.click(screen.getByLabelText(label)) + const control = screen.getByLabelText(label) + if (control instanceof HTMLSelectElement) { + if (control.multiple) { + const nextOption = within(control).getByRole('option', { name: option }) as HTMLOptionElement + await user.selectOptions(control, nextOption.value) + } else { + await user.selectOptions(control, option) + } + return + } + await user.click(control) await user.click(await screen.findByRole('option', { name: option })) } @@ -954,8 +1020,12 @@ describe('AccountSettingSystem', () => { ]) { await fireEvent.click(dialog.getByLabelText(label)) } - await user.click(dialog.getByLabelText('Fanart语言')) - await user.click(await screen.findByRole('option', { name: '日文' })) + const fanartLanguages = dialog.getByLabelText('Fanart语言') + expect(fanartLanguages).toHaveValue(['zh', 'en']) + await selectOption('Fanart语言', '日文') + await waitFor(() => expect(fanartLanguages).toHaveValue(['zh', 'en', 'ja'])) + await user.deselectOptions(fanartLanguages, 'en') + await waitFor(() => expect(fanartLanguages).toHaveValue(['zh', 'ja'])) await fireEvent.click(dialog.getByRole('button', { name: '保存' })) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) @@ -963,7 +1033,7 @@ describe('AccountSettingSystem', () => { expect.objectContaining({ ACOUSTID_API_KEY: 'acoustid-key', FANART_ENABLE: true, - FANART_LANG: 'zh,en,ja', + FANART_LANG: 'zh,ja', MEDIA_RECOGNIZE_SHARE: false, META_CACHE_EXPIRE: '48', MUSIC_COVER_PROXY: 'https://music.example', diff --git a/src/views/subscribe/__tests__/FullCalendarView.spec.ts b/src/views/subscribe/__tests__/FullCalendarView.spec.ts index 5ce98085..561ee5e0 100644 --- a/src/views/subscribe/__tests__/FullCalendarView.spec.ts +++ b/src/views/subscribe/__tests__/FullCalendarView.spec.ts @@ -8,6 +8,7 @@ import { subscribeApiUrls, subscribeListHandler } from '@tests/support/msw/handl import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -142,7 +143,8 @@ function sequenceSubscribeList(responses: Array<{ body: Subscribe[]; status?: nu onRequest() const response = responses[Math.min(index, responses.length - 1)] index += 1 - return HttpResponse.json(response.body, { status: response.status ?? 200 }) + if ((response.status ?? 200) >= 400) return HttpResponse.json(response.body, { status: response.status }) + return apiJson(response.body, { status: response.status ?? 200 }) }) } diff --git a/src/views/subscribe/__tests__/SubscribePopularView.spec.ts b/src/views/subscribe/__tests__/SubscribePopularView.spec.ts index 7d053958..163ce498 100644 --- a/src/views/subscribe/__tests__/SubscribePopularView.spec.ts +++ b/src/views/subscribe/__tests__/SubscribePopularView.spec.ts @@ -8,6 +8,7 @@ import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { flushPromises } from '@vue/test-utils' import { HttpResponse, http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, onMounted, ref, type PropType } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -196,7 +197,7 @@ describe('SubscribePopularView', () => { : url.searchParams.get('sort_type') === 'time' ? '最新热门结果' : '默认热门结果' - return HttpResponse.json([createSubscribeMovie({ title })]) + return apiJson([createSubscribeMovie({ title })]) }), ) const user = userEvent.setup() @@ -232,9 +233,9 @@ describe('SubscribePopularView', () => { http.get(subscribeApiUrls.popular, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - if (page === '1') return HttpResponse.json([first]) - if (page === '2') return HttpResponse.json([second]) - return HttpResponse.json([]) + if (page === '1') return apiJson([first]) + if (page === '2') return apiJson([second]) + return apiJson([]) }), ) const user = userEvent.setup() @@ -260,11 +261,11 @@ describe('SubscribePopularView', () => { http.get(subscribeApiUrls.popular, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - if (page === '1') return HttpResponse.json([first]) + if (page === '1') return apiJson([first]) if (page === '2') { - return HttpResponse.json([{ ...first, title: '第一页跨页重复项' }, second]) + return apiJson([{ ...first, title: '第一页跨页重复项' }, second]) } - return HttpResponse.json([ + return apiJson([ { ...second, title: '第二页乱序重复项' }, { ...first, title: '第一页乱序重复项' }, { ...first, title: '第一页页内重复项' }, @@ -320,7 +321,7 @@ describe('SubscribePopularView', () => { http.get(subscribeApiUrls.popular, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - return HttpResponse.json(page === '1' ? [first] : [second]) + return apiJson(page === '1' ? [first] : [second]) }), ) @@ -371,7 +372,7 @@ describe('SubscribePopularView', () => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 }) - return HttpResponse.json([createSubscribeMovie({ title: '热门首载重试成功' })]) + return apiJson([createSubscribeMovie({ title: '热门首载重试成功' })]) }), ) const user = userEvent.setup() @@ -395,10 +396,10 @@ describe('SubscribePopularView', () => { http.get(subscribeApiUrls.popular, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - if (page === '1') return HttpResponse.json([first]) + if (page === '1') return apiJson([first]) pageTwoAttempts += 1 if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 }) - return HttpResponse.json([second]) + return apiJson([second]) }), ) const user = userEvent.setup() @@ -427,9 +428,9 @@ describe('SubscribePopularView', () => { if (!url.searchParams.has('genre_id')) { await gate.promise staleResponse() - return HttpResponse.json([createSubscribeMovie({ title: '过期热门结果' })]) + return apiJson([createSubscribeMovie({ title: '过期热门结果' })]) } - return HttpResponse.json([createSubscribeMovie({ title: '新筛选热门结果' })]) + return apiJson([createSubscribeMovie({ title: '新筛选热门结果' })]) }), ) const user = userEvent.setup() diff --git a/src/views/subscribe/__tests__/SubscribeShareView.spec.ts b/src/views/subscribe/__tests__/SubscribeShareView.spec.ts index 2ff8ac34..0cf33fca 100644 --- a/src/views/subscribe/__tests__/SubscribeShareView.spec.ts +++ b/src/views/subscribe/__tests__/SubscribeShareView.spec.ts @@ -8,6 +8,7 @@ import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { flushPromises } from '@vue/test-utils' import { HttpResponse, http } from 'msw' +import { apiJson } from '@tests/support/msw/response' import { defineComponent, h, onMounted, ref, type PropType } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -213,7 +214,7 @@ describe('SubscribeShareView', () => { : url.searchParams.get('sort_type') === 'count' ? '热门分享结果' : '默认分享结果' - return HttpResponse.json([createSubscribeShare({ share_title: shareTitle })]) + return apiJson([createSubscribeShare({ share_title: shareTitle })]) }), ) const user = userEvent.setup() @@ -249,9 +250,9 @@ describe('SubscribeShareView', () => { http.get(subscribeApiUrls.shares, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - if (page === '1') return HttpResponse.json([first]) - if (page === '2') return HttpResponse.json([second]) - return HttpResponse.json([]) + if (page === '1') return apiJson([first]) + if (page === '2') return apiJson([second]) + return apiJson([]) }), ) const user = userEvent.setup() @@ -280,7 +281,7 @@ describe('SubscribeShareView', () => { http.get(subscribeApiUrls.shares, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - return HttpResponse.json(page === '1' ? [first] : [second]) + return apiJson(page === '1' ? [first] : [second]) }), ) @@ -332,7 +333,7 @@ describe('SubscribeShareView', () => { const url = new URL(request.url) requests.push(url) const keyword = url.searchParams.get('name') ?? '' - return HttpResponse.json([ + return apiJson([ createSubscribeShare({ share_title: keyword === '新关键字' ? '新关键字分享' : '旧关键字分享' }), ]) }), @@ -371,7 +372,7 @@ describe('SubscribeShareView', () => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 }) - return HttpResponse.json([createSubscribeShare({ share_title: '分享首载重试成功' })]) + return apiJson([createSubscribeShare({ share_title: '分享首载重试成功' })]) }), ) const user = userEvent.setup() @@ -395,10 +396,10 @@ describe('SubscribeShareView', () => { http.get(subscribeApiUrls.shares, ({ request }) => { const page = new URL(request.url).searchParams.get('page') ?? '' requestedPages.push(page) - if (page === '1') return HttpResponse.json([first]) + if (page === '1') return apiJson([first]) pageTwoAttempts += 1 if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 }) - return HttpResponse.json([second]) + return apiJson([second]) }), ) const user = userEvent.setup() @@ -427,9 +428,9 @@ describe('SubscribeShareView', () => { if (url.searchParams.get('name') === '旧关键字') { await gate.promise staleResponse() - return HttpResponse.json([createSubscribeShare({ share_title: '过期关键字分享' })]) + return apiJson([createSubscribeShare({ share_title: '过期关键字分享' })]) } - return HttpResponse.json([createSubscribeShare({ share_title: '新关键字实时分享' })]) + return apiJson([createSubscribeShare({ share_title: '新关键字实时分享' })]) }), ) diff --git a/tests/config/api-response-fixture.spec.ts b/tests/config/api-response-fixture.spec.ts new file mode 100644 index 00000000..d67fe742 --- /dev/null +++ b/tests/config/api-response-fixture.spec.ts @@ -0,0 +1,23 @@ +import { HttpResponse } from 'msw' +import { describe, expect, it } from 'vitest' +import { apiFailureJson, apiJson } from '../support/msw/response' + +describe('API response fixtures', () => { + it('keeps raw MSW responses unchanged for HTTP errors and plugin protocols', async () => { + const response = HttpResponse.json([{ id: 1 }]) + + await expect(response.json()).resolves.toEqual([{ id: 1 }]) + }) + + it('builds the complete main-program success envelope explicitly', async () => { + const response = apiJson({ id: 1 }) + + await expect(response.json()).resolves.toEqual({ data: { id: 1 }, message: '', success: true }) + }) + + it('builds the complete main-program business-failure envelope explicitly', async () => { + const response = apiFailureJson('rejected', { id: 1 }) + + await expect(response.json()).resolves.toEqual({ data: { id: 1 }, message: 'rejected', success: false }) + }) +}) diff --git a/tests/config/frontend-workflow.spec.ts b/tests/config/frontend-workflow.spec.ts index f04c466a..55ba7cd9 100644 --- a/tests/config/frontend-workflow.spec.ts +++ b/tests/config/frontend-workflow.spec.ts @@ -6,6 +6,7 @@ const workflowPath = resolve(process.cwd(), '.github/workflows/test.yml') const releaseWorkflowPath = resolve(process.cwd(), '.github/workflows/build.yml') const testingGuidePath = resolve(process.cwd(), 'docs/testing.md') const codeQualityGuidePath = resolve(process.cwd(), 'docs/code-quality.md') +const prettierIgnorePath = resolve(process.cwd(), '.prettierignore') describe('前端测试 workflow', () => { it('在 PR 与 v3 push 上运行,并将变更文件格式检查限制为 PR', () => { @@ -59,9 +60,25 @@ describe('前端测试 workflow', () => { const workflow = readFileSync(releaseWorkflowPath, 'utf8') expect(workflow).toContain('name: Build Moviepilot-Frontend v3') + expect(workflow).toContain('permissions:\n contents: write') expect(workflow).toContain('push:\n branches:\n - v3') expect(workflow).toContain(" - 'package.json'") + expect(workflow).toContain('timeout-minutes: 30') + expect(workflow).toContain('uses: actions/checkout@v7') + expect(workflow).toContain('uses: actions/setup-node@v7') + expect(workflow).toContain("node-version: '24'") + expect(workflow).toContain('cache-dependency-path: yarn.lock') + expect(workflow).toContain('corepack install --global yarn@1.22.22') + expect(workflow).toContain('run: yarn --frozen-lockfile') expect(workflow).toContain('echo "frontend_version=v$frontend_version"') + expect(workflow).not.toContain('actions/checkout@v3') + expect(workflow).not.toContain('actions/setup-node@v3') expect(workflow).not.toContain(' - v2') }) + + it('全仓格式检查排除仓内 linked worktree', () => { + const prettierIgnore = readFileSync(prettierIgnorePath, 'utf8') + + expect(prettierIgnore).toContain('/.worktrees/') + }) }) diff --git a/tests/setup.ts b/tests/setup.ts index dd7a2678..6b7a610b 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,7 +1,6 @@ import '@testing-library/jest-dom/vitest' import { abortAllRequests } from '@/utils/requestOptimizer' import { cleanup } from '@testing-library/vue' -import { HttpResponse, type HttpResponseInit, type JsonBodyType } from 'msw' import { afterAll, afterEach, beforeAll, vi } from 'vitest' import { createDataApiMock as buildDataApiMock } from './support/apiMock' import { server } from './support/msw/server' @@ -13,61 +12,6 @@ declare global { globalThis.createDataApiMock = buildDataApiMock -const originalJsonResponse = HttpResponse.json.bind(HttpResponse) - -/** 判断测试夹具是否已经表达了业务响应状态。 */ -function isLegacyApiEnvelope(body: unknown): body is Record & { success: boolean } { - return ( - body !== null && - typeof body === 'object' && - !Array.isArray(body) && - typeof (body as { success?: unknown }).success === 'boolean' - ) -} - -/** 识别 MSW 夹具中历史遗留的 Axios `{ data }` 响应壳。 */ -function isLegacyDataWrapper(body: unknown): body is { data: unknown } { - if (!body || typeof body !== 'object' || Array.isArray(body)) return false - return Object.keys(body).length === 1 && 'data' in body -} - -/** - * 将业务测试中的裸成功数据适配为当前后端统一 envelope。 - * - * 低层客户端协议测试使用 Axios adapter,不经过这里;HTTP 错误保持原载荷, - * 便于继续覆盖 detail、Blob 和非标准错误响应的归一化行为。 - */ -function installApiEnvelopeFixtureAdapter() { - Object.defineProperty(HttpResponse, 'json', { - configurable: true, - value: (body?: BodyType | null, init: HttpResponseInit = {}) => { - const status = init.status ?? 200 - if (status >= 400) return originalJsonResponse(body, init) - - if (isLegacyApiEnvelope(body)) { - const envelope = body as Record & { success: boolean } - return originalJsonResponse( - { - ...envelope, - message: typeof envelope.message === 'string' ? envelope.message : '', - data: Object.hasOwn(envelope, 'data') ? envelope.data : null, - }, - init, - ) - } - - if (isLegacyDataWrapper(body)) { - return originalJsonResponse({ success: true, message: '', data: body.data }, init) - } - - return originalJsonResponse({ success: true, message: '', data: body ?? null }, init) - }, - writable: true, - }) -} - -installApiEnvelopeFixtureAdapter() - class ResizeObserverStub implements ResizeObserver { disconnect() {} observe() {} diff --git a/tests/support/msw/handlers/discover.ts b/tests/support/msw/handlers/discover.ts index a3569ae3..b0248707 100644 --- a/tests/support/msw/handlers/discover.ts +++ b/tests/support/msw/handlers/discover.ts @@ -1,5 +1,6 @@ import type { DiscoverSource } from '@/api/types' import { HttpResponse, http, type JsonBodyType } from 'msw' +import { apiJson } from '../response' const API_BASE_URL = 'http://localhost/api/v1/' @@ -21,7 +22,8 @@ export function discoverSourcesHandler( ) { return http.get(discoverApiUrls.sources, async () => { await onRequest() - return HttpResponse.json(sources as unknown as JsonBodyType, { status }) + if (status >= 400) return HttpResponse.json(sources as unknown as JsonBodyType, { status }) + return apiJson(sources, { status }) }) } @@ -32,7 +34,8 @@ export function discoverOrderConfigHandler( ) { return http.get(discoverApiUrls.orderConfig, async () => { await onRequest() - return HttpResponse.json({ data: { value: order }, success: status < 400 }, { status }) + if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status }) + return apiJson({ value: order }, { status }) }) } @@ -43,6 +46,7 @@ export function saveDiscoverOrderHandler( return http.post(discoverApiUrls.orderConfig, async ({ request }) => { const order = (await request.json()) as DiscoverTabConfigItem[] await onSave(order) - return HttpResponse.json({ success: status < 400 }, { status }) + if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status }) + return apiJson(null, { status }) }) } diff --git a/tests/support/msw/handlers/download.ts b/tests/support/msw/handlers/download.ts index 332b8f5c..293824f7 100644 --- a/tests/support/msw/handlers/download.ts +++ b/tests/support/msw/handlers/download.ts @@ -1,5 +1,6 @@ import type { DownloadHistory, DownloadingInfo } from '@/api/types' import { HttpResponse, http, type JsonBodyType } from 'msw' +import { apiFailureJson, apiJson } from '../response' const API_BASE_URL = 'http://localhost/api/v1/' @@ -15,8 +16,15 @@ export const downloadApiUrls = { history: new URL('history/download', API_BASE_URL).href, } -function jsonResponse(body: JsonBodyType, status: number) { - return HttpResponse.json(body, { status }) +function dataResponse(body: JsonBodyType, status: number) { + if (status >= 400) return HttpResponse.json(body, { status }) + return apiJson(body, { status }) +} + +function mutationResponse(response: DownloadMutationResponse, status: number) { + if (status >= 400) return HttpResponse.json(response, { status }) + if (!response.success) return apiFailureJson(response.message ?? '', null, { status }) + return apiJson(null, { status }) } /** 拦截下载任务快照查询,并保留下载器查询参数供断言。 */ @@ -29,7 +37,7 @@ export function downloadingListHandler( const url = new URL(request.url) await onRequest(url) const body = typeof response === 'function' ? await response(url) : response - return jsonResponse(body as unknown as JsonBodyType, status) + return dataResponse(body as unknown as JsonBodyType, status) }) } @@ -44,7 +52,7 @@ export function downloadActionHandler( return http.get(downloadApiUrls.action(operation, hash), async ({ request }) => { const url = new URL(request.url) await onRequest(url) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -58,7 +66,7 @@ export function deleteDownloadHandler( return http.delete(downloadApiUrls.delete(hash), async ({ request }) => { const url = new URL(request.url) await onRequest(url) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -72,7 +80,7 @@ export function downloadHistoryHandler( const url = new URL(request.url) await onRequest(url) const body = typeof response === 'function' ? await response(url) : response - return jsonResponse(body as unknown as JsonBodyType, status) + return dataResponse(body as unknown as JsonBodyType, status) }) } @@ -84,6 +92,6 @@ export function deleteDownloadHistoryHandler( ) { return http.delete(downloadApiUrls.history, async ({ request }) => { await onRequest((await request.json()) as DownloadHistory) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } diff --git a/tests/support/msw/handlers/media.ts b/tests/support/msw/handlers/media.ts index 468324f7..f776cfa5 100644 --- a/tests/support/msw/handlers/media.ts +++ b/tests/support/msw/handlers/media.ts @@ -1,5 +1,6 @@ import type { MediaInfo, MediaSeason, NotExistMediaInfo, TmdbEpisode } from '@/api/types' import { HttpResponse, http, type JsonBodyType } from 'msw' +import { apiFailureJson, apiJson } from '../response' const API_BASE_URL = 'http://localhost/api/v1/' @@ -14,6 +15,11 @@ export const mediaApiUrls = { seasons: new URL('media/seasons', API_BASE_URL).href, } +function dataResponse(body: JsonBodyType, status: number) { + if (status >= 400) return HttpResponse.json(body, { status }) + return apiJson(body, { status }) +} + export function mediaExistsHandler( response: { data?: Record; message?: string; success: boolean }, status = 200, @@ -21,7 +27,8 @@ export function mediaExistsHandler( ) { return http.get(mediaApiUrls.exists, async ({ request }) => { await onRequest(new URL(request.url)) - return HttpResponse.json({ message: '', ...response } as JsonBodyType, { status }) + if (!response.success) return apiFailureJson(response.message ?? '', response.data ?? null, { status }) + return apiJson(response.data ?? null, { status }) }) } @@ -33,7 +40,7 @@ export function mediaDetailsHandler( ) { return http.get(mediaApiUrls.details(String(mediaId)), ({ request }) => { onRequest(new URL(request.url)) - return HttpResponse.json(response as unknown as JsonBodyType, { status }) + return dataResponse(response as unknown as JsonBodyType, status) }) } @@ -44,7 +51,7 @@ export function mediaRemoteExistsHandler( ) { return http.post(mediaApiUrls.existsRemote, async ({ request }) => { await onRequest((await request.json()) as Record) - return HttpResponse.json(response as JsonBodyType, { status }) + return dataResponse(response as JsonBodyType, status) }) } @@ -56,7 +63,8 @@ export function mediaPlayHandler( ) { return http.get(mediaApiUrls.play(itemId), () => { onRequest() - return HttpResponse.json(response as JsonBodyType, { status }) + if (!response.success) return apiFailureJson(response.message ?? '', response.data ?? null, { status }) + return apiJson(response.data ?? null, { status }) }) } @@ -69,7 +77,7 @@ export function tmdbSeasonEpisodesHandler( ) { return http.get(new URL(`tmdb/${tmdbId}/${season}`, API_BASE_URL).href, ({ request }) => { onRequest(new URL(request.url)) - return HttpResponse.json(response as unknown as JsonBodyType, { status }) + return dataResponse(response as unknown as JsonBodyType, status) }) } @@ -80,7 +88,7 @@ export function mediaSeasonsHandler( ) { return http.get(mediaApiUrls.seasons, async ({ request }) => { await onRequest(new URL(request.url)) - return HttpResponse.json(response as unknown as JsonBodyType, { status }) + return dataResponse(response as unknown as JsonBodyType, status) }) } @@ -92,7 +100,7 @@ export function mediaEpisodeGroupsHandler( ) { return http.get(mediaApiUrls.episodeGroups(tmdbId), async ({ request }) => { await onRequest(new URL(request.url)) - return HttpResponse.json(response as JsonBodyType, { status }) + return dataResponse(response as JsonBodyType, status) }) } @@ -104,7 +112,7 @@ export function mediaGroupSeasonsHandler( ) { return http.get(mediaApiUrls.groupSeasons(episodeGroup), async ({ request }) => { await onRequest(new URL(request.url)) - return HttpResponse.json(response as unknown as JsonBodyType, { status }) + return dataResponse(response as unknown as JsonBodyType, status) }) } @@ -116,6 +124,6 @@ export function mediaNotExistsHandler( return http.post(mediaApiUrls.notExists, async ({ request }) => { const payload = (await request.json()) as Record await onRequest(payload, new URL(request.url)) - return HttpResponse.json(response as unknown as JsonBodyType, { status }) + return dataResponse(response as unknown as JsonBodyType, status) }) } diff --git a/tests/support/msw/handlers/recommend.ts b/tests/support/msw/handlers/recommend.ts index 64e01bf6..beb4e8b3 100644 --- a/tests/support/msw/handlers/recommend.ts +++ b/tests/support/msw/handlers/recommend.ts @@ -1,5 +1,6 @@ import type { RecommendSource } from '@/api/types' import { HttpResponse, http, type JsonBodyType } from 'msw' +import { apiJson } from '../response' const API_BASE_URL = 'http://localhost/api/v1/' @@ -9,36 +10,28 @@ export const recommendApiUrls = { sources: new URL('recommend/source', API_BASE_URL).href, } -export function recommendSourcesHandler( - sources: RecommendSource[], - status = 200, - onRequest: () => void = () => {}, -) { +export function recommendSourcesHandler(sources: RecommendSource[], status = 200, onRequest: () => void = () => {}) { return http.get(recommendApiUrls.sources, () => { onRequest() - return HttpResponse.json(sources, { status }) + if (status >= 400) return HttpResponse.json(sources, { status }) + return apiJson(sources, { status }) }) } -export function recommendConfigHandler( - config: JsonBodyType, - status = 200, - onRequest: () => void = () => {}, -) { +export function recommendConfigHandler(config: JsonBodyType, status = 200, onRequest: () => void = () => {}) { return http.get(recommendApiUrls.config, () => { onRequest() - return HttpResponse.json({ data: { value: config } }, { status }) + if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status }) + return apiJson({ value: config }, { status }) }) } -export function saveRecommendConfigHandler( - onSave: (config: Record) => void = () => {}, - status = 200, -) { +export function saveRecommendConfigHandler(onSave: (config: Record) => void = () => {}, status = 200) { return http.post(recommendApiUrls.config, async ({ request }) => { const config = (await request.json()) as Record onSave(config) - return HttpResponse.json({ success: status < 400 }, { status }) + if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status }) + return apiJson(null, { status }) }) } @@ -50,6 +43,7 @@ export function recommendMediaHandler( ) { return http.get(recommendApiUrls.media(sourcePath), () => { onRequest() - return HttpResponse.json(response as JsonBodyType, { status }) + if (status >= 400) return HttpResponse.json(response as JsonBodyType, { status }) + return apiJson(response, { status }) }) } diff --git a/tests/support/msw/handlers/subscribe.ts b/tests/support/msw/handlers/subscribe.ts index 8f3d7862..43fab3d7 100644 --- a/tests/support/msw/handlers/subscribe.ts +++ b/tests/support/msw/handlers/subscribe.ts @@ -9,6 +9,7 @@ import type { TransferDirectoryConf, } from '@/api/types' import { HttpResponse, http, type JsonBodyType, type RequestHandler } from 'msw' +import { apiFailureJson, apiJson } from '../response' const API_BASE_URL = 'http://localhost/api/v1/' @@ -65,8 +66,15 @@ export const subscribeApiUrls = { update: new URL('subscribe/', API_BASE_URL).href, } -function jsonResponse(body: JsonBodyType, status: number) { - return HttpResponse.json(body, { status }) +function dataResponse(body: JsonBodyType, status: number) { + if (status >= 400) return HttpResponse.json(body, { status }) + return apiJson(body, { status }) +} + +function mutationResponse(response: SubscribeMutationResponse, status: number) { + if (status >= 400) return HttpResponse.json(response, { status }) + if (!response.success) return apiFailureJson(response.message ?? '', response.data ?? null, { status }) + return apiJson(response.data ?? null, { status }) } export function subscribeListHandler( @@ -76,7 +84,7 @@ export function subscribeListHandler( ) { return http.get(subscribeApiUrls.list, ({ request }) => { onRequest(new URL(request.url)) - return jsonResponse(response, status) + return dataResponse(response, status) }) } @@ -87,7 +95,7 @@ export function popularSubscribesHandler( ) { return http.get(subscribeApiUrls.popular, async ({ request }) => { await onRequest(new URL(request.url)) - return jsonResponse(response as unknown as JsonBodyType, status) + return dataResponse(response as unknown as JsonBodyType, status) }) } @@ -98,7 +106,7 @@ export function subscribeSharesHandler( ) { return http.get(subscribeApiUrls.shares, async ({ request }) => { await onRequest(new URL(request.url)) - return jsonResponse(response as unknown as JsonBodyType, status) + return dataResponse(response as unknown as JsonBodyType, status) }) } @@ -110,7 +118,7 @@ export function shareSubscribeHandler( return http.post(subscribeApiUrls.share, async ({ request }) => { const payload = (await request.json()) as SubscribeShare await onShare(payload) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -122,7 +130,7 @@ export function forkSubscribeHandler( return http.post(subscribeApiUrls.fork, async ({ request }) => { const payload = (await request.json()) as SubscribeShare await onFork(payload) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -133,7 +141,8 @@ export function followSubscribersSettingHandler( ) { return http.get(subscribeApiUrls.followSubscribers, async ({ request }) => { await onRequest(new URL(request.url)) - return jsonResponse({ data: { value: users }, success: status < 400 }, status) + if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status }) + return apiJson({ value: users }, { status }) }) } @@ -144,7 +153,7 @@ export function followSubscriberHandler( ) { return http.post(subscribeApiUrls.follow, async ({ request }) => { await onRequest(new URL(request.url)) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -155,7 +164,7 @@ export function unfollowSubscriberHandler( ) { return http.delete(subscribeApiUrls.follow, async ({ request }) => { await onRequest(new URL(request.url)) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -167,7 +176,7 @@ export function deleteSubscribeShareHandler( ) { return http.delete(subscribeApiUrls.shareById(id), async ({ request }) => { await onRequest(new URL(request.url)) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -178,7 +187,7 @@ export function subscribeShareStatisticsHandler( ) { return http.get(subscribeApiUrls.shareStatistics, async ({ request }) => { await onRequest(new URL(request.url)) - return jsonResponse(response as unknown as JsonBodyType, status) + return dataResponse(response as unknown as JsonBodyType, status) }) } @@ -190,7 +199,7 @@ export function subscribeFilesHandler( ) { return http.get(subscribeApiUrls.filesById(id), async ({ request }) => { await onRequest(new URL(request.url)) - return jsonResponse(response, status) + return dataResponse(response, status) }) } @@ -202,7 +211,7 @@ export function subscribeHistoryHandler( ) { return http.get(subscribeApiUrls.historyByType(type), async ({ request }) => { await onRequest(new URL(request.url)) - return jsonResponse(response as unknown as JsonBodyType, status) + return dataResponse(response as unknown as JsonBodyType, status) }) } @@ -214,7 +223,7 @@ export function deleteSubscribeHistoryHandler( ) { return http.delete(subscribeApiUrls.historyById(id), async ({ request }) => { await onRequest(new URL(request.url)) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -226,7 +235,8 @@ export function subscribeOrderConfigHandler( ) { return http.get(subscribeApiUrls.orderConfig(type), ({ request }) => { onRequest(new URL(request.url)) - return jsonResponse({ data: { value }, success: status < 400 }, status) + if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status }) + return apiJson({ value }, { status }) }) } @@ -239,7 +249,7 @@ export function saveSubscribeOrderConfigHandler( return http.post(subscribeApiUrls.orderConfig(type), async ({ request }) => { const payload = (await request.json()) as { id: number }[] await onSave(payload, new URL(request.url)) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -251,7 +261,7 @@ export function updateSubscribeStatusHandler( ) { return http.put(subscribeApiUrls.statusById(id), async ({ request }) => { await onRequest(new URL(request.url)) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -263,7 +273,7 @@ export function searchSubscribeByIdHandler( ) { return http.get(subscribeApiUrls.searchById(id), ({ request }) => { onRequest(new URL(request.url)) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -275,7 +285,7 @@ export function resetSubscribeByIdHandler( ) { return http.get(subscribeApiUrls.resetById(id), ({ request }) => { onRequest(new URL(request.url)) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -287,7 +297,7 @@ export function createSubscribeHandler( return http.post(subscribeApiUrls.create, async ({ request }) => { const payload = (await request.json()) as Record onCreate(payload) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -299,7 +309,7 @@ export function updateSubscribeHandler( return http.put(subscribeApiUrls.update, async ({ request }) => { const payload = (await request.json()) as Record onUpdate(payload) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -311,7 +321,7 @@ export function querySubscribeByMediaHandler( ) { return http.get(subscribeApiUrls.queryByMedia(mediaId), ({ request }) => { onRequest(new URL(request.url)) - return jsonResponse(subscribe as JsonBodyType, status) + return dataResponse(subscribe as JsonBodyType, status) }) } @@ -323,7 +333,7 @@ export function deleteSubscribeByMediaHandler( ) { return http.delete(subscribeApiUrls.deleteByMedia(mediaId), ({ request }) => { onRequest(new URL(request.url)) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -335,7 +345,7 @@ export function subscribeDetailsHandler( ) { return http.get(subscribeApiUrls.details(id), () => { onRequest() - return jsonResponse(subscribe as unknown as JsonBodyType, status) + return dataResponse(subscribe as unknown as JsonBodyType, status) }) } @@ -347,7 +357,7 @@ export function deleteSubscribeByIdHandler( ) { return http.delete(subscribeApiUrls.deleteById(id), () => { onRequest() - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -359,7 +369,8 @@ export function defaultSubscribeConfigHandler( ) { return http.get(subscribeApiUrls.defaultConfig(type), () => { onRequest() - return jsonResponse({ data: { value: config }, success: status < 400 }, status) + if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status }) + return apiJson({ value: config }, { status }) }) } @@ -372,7 +383,7 @@ export function saveDefaultSubscribeConfigHandler( return http.post(subscribeApiUrls.defaultConfig(type, true), async ({ request }) => { const payload = (await request.json()) as Record onSave(payload) - return jsonResponse(response, status) + return mutationResponse(response, status) }) } @@ -409,23 +420,23 @@ export function subscribeDialogOptionHandlers(options: SubscribeDialogOptions = return [ http.get(subscribeApiUrls.sites, () => { onSites() - return jsonResponse(sites as unknown as JsonBodyType, 200) + return dataResponse(sites as unknown as JsonBodyType, 200) }), http.get(subscribeApiUrls.downloaders, () => { onDownloaders() - return jsonResponse(downloaders as unknown as JsonBodyType, 200) + return dataResponse(downloaders as unknown as JsonBodyType, 200) }), http.get(subscribeApiUrls.directories, () => { onDirectories() - return jsonResponse({ data: { value: directories }, success: true }, 200) + return apiJson({ value: directories }) }), http.get(subscribeApiUrls.filterRuleGroups, () => { onFilterRuleGroups() - return jsonResponse({ data: { value: filterRuleGroups }, success: true }, 200) + return apiJson({ value: filterRuleGroups }) }), http.get(subscribeApiUrls.episodeGroups(tmdbId), () => { onEpisodeGroups() - return jsonResponse(episodeGroups as unknown as JsonBodyType, 200) + return dataResponse(episodeGroups as unknown as JsonBodyType, 200) }), ] } diff --git a/tests/support/msw/response.ts b/tests/support/msw/response.ts new file mode 100644 index 00000000..cef654be --- /dev/null +++ b/tests/support/msw/response.ts @@ -0,0 +1,18 @@ +import type { ApiResponse } from '@/api/types' +import { HttpResponse, type HttpResponseInit, type JsonBodyType } from 'msw' + +/** 构造主程序普通 API 的固定三段式响应。 */ +export function apiEnvelope(data: T | null, message = ''): ApiResponse { + return { data, message, success: true } +} + +/** 返回主程序普通 API 的成功响应;调用点必须显式选择该协议。 */ +export function apiJson(data: T | null, init: HttpResponseInit = {}) { + return HttpResponse.json(apiEnvelope(data) as unknown as JsonBodyType, init) +} + +/** 返回主程序普通 API 的业务失败响应。 */ +export function apiFailureJson(message: string, data: T | null = null, init: HttpResponseInit = {}) { + const body: ApiResponse = { data, message, success: false } + return HttpResponse.json(body as unknown as JsonBodyType, init) +}