From 92eb2240116bbca1ad0c656874656c2bd4697b13 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 17:39:59 +0200 Subject: [PATCH] feat(webui): add RSS refresh buttons, fix vitest API contract tests going stale Add per-row and 'refresh all' buttons to the RSS page, wired through new store actions refreshRSS/refreshAllRSS backed by apiRSS.refresh(All). Fix vitest.config.ts's auto-import dirs being out of sync with vite.config.ts, which silently made every src/api/*.ts contract test a no-op (bare axios/apiXxx identifiers threw under vitest, so the specs never called real code). Add a shared axios mock helper and give every src/api/*.ts module a real contract test asserting path + method against the mocked axios instance and cross-checked against the FastAPI routers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z --- webui/src/api/__tests__/auth.test.ts | 65 +++++-- webui/src/api/__tests__/bangumi.test.ts | 182 ++++++++++++++----- webui/src/api/__tests__/check.test.ts | 25 +++ webui/src/api/__tests__/config.test.ts | 32 ++++ webui/src/api/__tests__/download.test.ts | 41 +++++ webui/src/api/__tests__/downloader.test.ts | 60 ++++++ webui/src/api/__tests__/log.test.ts | 31 ++++ webui/src/api/__tests__/notification.test.ts | 48 +++++ webui/src/api/__tests__/passkey.test.ts | 68 +++++++ webui/src/api/__tests__/program.test.ts | 50 +++++ webui/src/api/__tests__/rss.test.ts | 101 ++++++---- webui/src/api/__tests__/search.test.ts | 28 +++ webui/src/api/__tests__/setup.test.ts | 95 ++++++++++ webui/src/i18n/en.json | 2 + webui/src/i18n/zh-CN.json | 2 + webui/src/pages/index/rss.vue | 82 ++++++++- webui/src/store/rss.ts | 18 ++ webui/src/test/mocks/axios.ts | 21 +++ webui/vitest.config.ts | 1 + 19 files changed, 852 insertions(+), 100 deletions(-) create mode 100644 webui/src/api/__tests__/check.test.ts create mode 100644 webui/src/api/__tests__/config.test.ts create mode 100644 webui/src/api/__tests__/download.test.ts create mode 100644 webui/src/api/__tests__/downloader.test.ts create mode 100644 webui/src/api/__tests__/log.test.ts create mode 100644 webui/src/api/__tests__/notification.test.ts create mode 100644 webui/src/api/__tests__/passkey.test.ts create mode 100644 webui/src/api/__tests__/program.test.ts create mode 100644 webui/src/api/__tests__/search.test.ts create mode 100644 webui/src/api/__tests__/setup.test.ts create mode 100644 webui/src/test/mocks/axios.ts diff --git a/webui/src/api/__tests__/auth.test.ts b/webui/src/api/__tests__/auth.test.ts index db80b089..143a2c5c 100644 --- a/webui/src/api/__tests__/auth.test.ts +++ b/webui/src/api/__tests__/auth.test.ts @@ -3,8 +3,14 @@ * Note: These tests focus on the data structures and transformations */ -import { describe, it, expect } from 'vitest'; -import { mockLoginSuccess } from '@/test/mocks/api'; +import { describe, expect, it, vi } from 'vitest'; +import { mockApiSuccess, mockLoginSuccess } from '@/test/mocks/api'; +import { createAxiosMock } from '@/test/mocks/axios'; + +import { apiAuth } from '@/api/auth'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); describe('Auth API Data Structures', () => { describe('login response', () => { @@ -62,28 +68,51 @@ describe('Auth API Data Structures', () => { }); }); - describe('API endpoint paths', () => { - const AUTH_ENDPOINTS = { - login: 'api/v1/auth/login', - logout: 'api/v1/auth/logout', - refresh: 'api/v1/auth/refresh_token', - update: 'api/v1/auth/update', - }; - - it('should have correct login endpoint', () => { - expect(AUTH_ENDPOINTS.login).toBe('api/v1/auth/login'); + // Contract tests: call the real apiAuth functions against a mocked axios + // instance so a drift between the wrapper and the FastAPI routes in + // backend/src/module/api/auth.py fails a test instead of going unnoticed. + describe('API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - it('should have correct logout endpoint', () => { - expect(AUTH_ENDPOINTS.logout).toBe('api/v1/auth/logout'); + it('should POST api/v1/auth/login with form-encoded credentials when logging in', async () => { + (axios.post as any).mockResolvedValue({ data: mockLoginSuccess }); + await apiAuth.login('testuser', 'testpassword'); + + expect(axios.post).toHaveBeenCalledTimes(1); + const [url, body, config] = (axios.post as any).mock.calls[0]; + expect(url).toBe('api/v1/auth/login'); + expect(body).toBeInstanceOf(URLSearchParams); + expect((body as URLSearchParams).get('username')).toBe('testuser'); + expect((body as URLSearchParams).get('password')).toBe('testpassword'); + expect(config.headers['Content-Type']).toBe( + 'application/x-www-form-urlencoded' + ); }); - it('should have correct refresh endpoint', () => { - expect(AUTH_ENDPOINTS.refresh).toBe('api/v1/auth/refresh_token'); + it('should GET api/v1/auth/refresh_token when refreshing the session', async () => { + (axios.get as any).mockResolvedValue({ data: mockLoginSuccess }); + await apiAuth.refresh(); + expect(axios.get).toHaveBeenCalledWith('api/v1/auth/refresh_token'); }); - it('should have correct update endpoint', () => { - expect(AUTH_ENDPOINTS.update).toBe('api/v1/auth/update'); + it('should POST api/v1/auth/logout when logging out', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiAuth.logout(); + expect(axios.post).toHaveBeenCalledWith('api/v1/auth/logout'); + }); + + it('should POST api/v1/auth/update with the new credentials when updating', async () => { + (axios.post as any).mockResolvedValue({ + ...mockLoginSuccess, + message: 'update success', + }); + await apiAuth.update('newuser', 'newpassword123'); + expect(axios.post).toHaveBeenCalledWith('api/v1/auth/update', { + username: 'newuser', + password: 'newpassword123', + }); }); }); }); diff --git a/webui/src/api/__tests__/bangumi.test.ts b/webui/src/api/__tests__/bangumi.test.ts index 28dbbbdf..24b9bf00 100644 --- a/webui/src/api/__tests__/bangumi.test.ts +++ b/webui/src/api/__tests__/bangumi.test.ts @@ -3,11 +3,19 @@ * Note: These tests focus on the filter/rss_link string<->array transformations */ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { + mockApiSuccess, mockBangumiAPI, mockBangumiRule, } from '@/test/mocks/api'; +import { createAxiosMock } from '@/test/mocks/axios'; +import type { DetectOffsetRequest } from '#/bangumi'; + +import { apiBangumi } from '@/api/bangumi'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); describe('Bangumi API Logic', () => { describe('getAll transformation (string to array)', () => { @@ -108,53 +116,143 @@ describe('Bangumi API Logic', () => { }); }); - describe('deleteRule logic', () => { - it('should use single endpoint for single ID', () => { - const id = 1; - const isArray = Array.isArray(id); - - expect(isArray).toBe(false); - // Single ID should use: `api/v1/bangumi/delete/${id}` + // Contract tests: call the real apiBangumi functions against a mocked + // axios instance so a drift between the wrapper and the FastAPI routes in + // backend/src/module/api/bangumi.py fails a test instead of going + // unnoticed. + describe('API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - it('should use many endpoint for array of IDs', () => { - const ids = [1, 2, 3]; - const isArray = Array.isArray(ids); - - expect(isArray).toBe(true); - // Array should use: `api/v1/bangumi/delete/many` - }); - }); - - describe('API endpoint paths', () => { - const BANGUMI_ENDPOINTS = { - getAll: 'api/v1/bangumi/get/all', - getOne: (id: number) => `api/v1/bangumi/get/${id}`, - update: (id: number) => `api/v1/bangumi/update/${id}`, - delete: (id: number) => `api/v1/bangumi/delete/${id}`, - deleteMany: 'api/v1/bangumi/delete/many', - disable: (id: number) => `api/v1/bangumi/disable/${id}`, - disableMany: 'api/v1/bangumi/disable/many', - enable: (id: number) => `api/v1/bangumi/enable/${id}`, - archive: (id: number) => `api/v1/bangumi/archive/${id}`, - unarchive: (id: number) => `api/v1/bangumi/unarchive/${id}`, - resetAll: 'api/v1/bangumi/reset/all', - detectOffset: 'api/v1/bangumi/detect-offset', - needsReview: 'api/v1/bangumi/needs-review', - }; - - it('should generate correct getOne endpoint', () => { - expect(BANGUMI_ENDPOINTS.getOne(42)).toBe('api/v1/bangumi/get/42'); + it('should GET api/v1/bangumi/get/all when fetching all rules', async () => { + (axios.get as any).mockResolvedValue({ data: [mockBangumiAPI] }); + await apiBangumi.getAll(); + expect(axios.get).toHaveBeenCalledWith('api/v1/bangumi/get/all'); }); - it('should generate correct update endpoint', () => { - expect(BANGUMI_ENDPOINTS.update(42)).toBe('api/v1/bangumi/update/42'); + it('should PATCH api/v1/bangumi/update/:id with the id omitted when updating a rule', async () => { + (axios.patch as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.updateRule(1, mockBangumiRule); + expect(axios.patch).toHaveBeenCalledWith( + 'api/v1/bangumi/update/1', + expect.not.objectContaining({ id: expect.anything() }) + ); }); - it('should have correct static endpoints', () => { - expect(BANGUMI_ENDPOINTS.getAll).toBe('api/v1/bangumi/get/all'); - expect(BANGUMI_ENDPOINTS.deleteMany).toBe('api/v1/bangumi/delete/many'); - expect(BANGUMI_ENDPOINTS.needsReview).toBe('api/v1/bangumi/needs-review'); + it('should DELETE api/v1/bangumi/delete/:id with the file param when deleting a single rule', async () => { + (axios.delete as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.deleteRule(1, true); + expect(axios.delete).toHaveBeenCalledWith('api/v1/bangumi/delete/1', { + params: { file: true }, + }); + }); + + it('should POST api/v1/bangumi/delete/many with the id array when batch deleting', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.deleteRule([1, 2, 3], false); + expect(axios.post).toHaveBeenCalledWith( + 'api/v1/bangumi/delete/many', + [1, 2, 3], + { params: { file: false } } + ); + }); + + it('should POST api/v1/bangumi/disable/:id with the file param when disabling a single rule', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.disableRule(1, true); + expect(axios.post).toHaveBeenCalledWith( + 'api/v1/bangumi/disable/1', + null, + { params: { file: true } } + ); + }); + + it('should POST api/v1/bangumi/disable/many with the id array when batch disabling', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.disableRule([1, 2], false); + expect(axios.post).toHaveBeenCalledWith( + 'api/v1/bangumi/disable/many', + [1, 2], + { params: { file: false } } + ); + }); + + it('should POST api/v1/bangumi/enable/:id when enabling a rule', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.enableRule(1); + expect(axios.post).toHaveBeenCalledWith('api/v1/bangumi/enable/1'); + }); + + it('should POST api/v1/bangumi/reset/all when resetting all rules', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.resetAll(); + expect(axios.post).toHaveBeenCalledWith('api/v1/bangumi/reset/all'); + }); + + it('should GET api/v1/bangumi/refresh/poster/all when refreshing posters', async () => { + (axios.get as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.refreshPoster(); + expect(axios.get).toHaveBeenCalledWith('api/v1/bangumi/refresh/poster/all'); + }); + + it('should GET api/v1/bangumi/refresh/calendar when refreshing the calendar', async () => { + (axios.get as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.refreshCalendar(); + expect(axios.get).toHaveBeenCalledWith('api/v1/bangumi/refresh/calendar'); + }); + + it('should PATCH api/v1/bangumi/archive/:id when archiving a rule', async () => { + (axios.patch as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.archiveRule(1); + expect(axios.patch).toHaveBeenCalledWith('api/v1/bangumi/archive/1'); + }); + + it('should PATCH api/v1/bangumi/unarchive/:id when unarchiving a rule', async () => { + (axios.patch as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.unarchiveRule(1); + expect(axios.patch).toHaveBeenCalledWith('api/v1/bangumi/unarchive/1'); + }); + + it('should GET api/v1/bangumi/refresh/metadata when refreshing metadata', async () => { + (axios.get as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.refreshMetadata(); + expect(axios.get).toHaveBeenCalledWith('api/v1/bangumi/refresh/metadata'); + }); + + it('should GET api/v1/bangumi/suggest-offset/:id when suggesting an offset', async () => { + (axios.get as any).mockResolvedValue({ + data: { suggested_offset: 1, reason: 'test' }, + }); + await apiBangumi.suggestOffset(1); + expect(axios.get).toHaveBeenCalledWith('api/v1/bangumi/suggest-offset/1'); + }); + + it('should POST api/v1/bangumi/detect-offset with the request body when detecting offset', async () => { + const request: DetectOffsetRequest = { + title: 'Test Anime', + parsed_season: 1, + parsed_episode: 1, + }; + (axios.post as any).mockResolvedValue({ + data: { has_mismatch: false, suggestion: null, tmdb_info: null }, + }); + await apiBangumi.detectOffset(request); + expect(axios.post).toHaveBeenCalledWith('api/v1/bangumi/detect-offset', request); + }); + + it('should POST api/v1/bangumi/dismiss-review/:id when dismissing a review flag', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.dismissReview(1); + expect(axios.post).toHaveBeenCalledWith('api/v1/bangumi/dismiss-review/1'); + }); + + it('should PATCH api/v1/bangumi/:id/weekday with the weekday when setting the weekday', async () => { + (axios.patch as any).mockResolvedValue({ data: mockApiSuccess }); + await apiBangumi.setWeekday(1, 3); + expect(axios.patch).toHaveBeenCalledWith('api/v1/bangumi/1/weekday', { + weekday: 3, + }); }); }); }); diff --git a/webui/src/api/__tests__/check.test.ts b/webui/src/api/__tests__/check.test.ts new file mode 100644 index 00000000..7d633eef --- /dev/null +++ b/webui/src/api/__tests__/check.test.ts @@ -0,0 +1,25 @@ +/** + * Contract tests for apiCheck: call the real functions against a mocked + * axios instance so a drift between the wrapper and the FastAPI route in + * backend/src/module/api/program.py (the "check/downloader" endpoint) fails + * a test instead of going unnoticed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAxiosMock } from '@/test/mocks/axios'; + +import { apiCheck } from '@/api/check'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); + +describe('Check API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should GET api/v1/check/downloader when checking the downloader', async () => { + (axios.get as any).mockResolvedValue({ data: true }); + await apiCheck.downloader(); + expect(axios.get).toHaveBeenCalledWith('api/v1/check/downloader'); + }); +}); diff --git a/webui/src/api/__tests__/config.test.ts b/webui/src/api/__tests__/config.test.ts new file mode 100644 index 00000000..16ad469e --- /dev/null +++ b/webui/src/api/__tests__/config.test.ts @@ -0,0 +1,32 @@ +/** + * Contract tests for apiConfig: call the real functions against a mocked + * axios instance so a drift between the wrapper and the FastAPI routes in + * backend/src/module/api/config.py fails a test instead of going unnoticed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockApiSuccess, mockConfig } from '@/test/mocks/api'; +import { createAxiosMock } from '@/test/mocks/axios'; +import type { Config } from '#/config'; + +import { apiConfig } from '@/api/config'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); + +describe('Config API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should GET api/v1/config/get when fetching config', async () => { + (axios.get as any).mockResolvedValue({ data: mockConfig }); + await apiConfig.getConfig(); + expect(axios.get).toHaveBeenCalledWith('api/v1/config/get'); + }); + + it('should PATCH api/v1/config/update with the config payload when updating', async () => { + (axios.patch as any).mockResolvedValue({ data: mockApiSuccess }); + await apiConfig.updateConfig(mockConfig as unknown as Config); + expect(axios.patch).toHaveBeenCalledWith('api/v1/config/update', mockConfig); + }); +}); diff --git a/webui/src/api/__tests__/download.test.ts b/webui/src/api/__tests__/download.test.ts new file mode 100644 index 00000000..5fa6b939 --- /dev/null +++ b/webui/src/api/__tests__/download.test.ts @@ -0,0 +1,41 @@ +/** + * Contract tests for apiDownload: call the real functions against a mocked + * axios instance so a drift between the wrapper and the FastAPI routes in + * backend/src/module/api/rss.py (the legacy analysis/collect/subscribe + * endpoints) fails a test instead of going unnoticed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockApiSuccess, mockBangumiAPI, mockBangumiRule, mockRSSItem } from '@/test/mocks/api'; +import { createAxiosMock } from '@/test/mocks/axios'; + +import { apiDownload } from '@/api/download'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); + +describe('Download API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should POST api/v1/rss/analysis with the RSS payload when analysing a link', async () => { + (axios.post as any).mockResolvedValue({ data: mockBangumiAPI }); + await apiDownload.analysis(mockRSSItem); + expect(axios.post).toHaveBeenCalledWith('api/v1/rss/analysis', mockRSSItem); + }); + + it('should POST api/v1/rss/collect with the joined filter/rss_link when collecting a season', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiDownload.collection(mockBangumiRule); + expect(axios.post).toHaveBeenCalledWith('api/v1/rss/collect', mockBangumiAPI); + }); + + it('should POST api/v1/rss/subscribe with the bangumi and rss payload when subscribing', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiDownload.subscribe(mockBangumiRule, mockRSSItem); + expect(axios.post).toHaveBeenCalledWith('api/v1/rss/subscribe', { + data: mockBangumiAPI, + rss: mockRSSItem, + }); + }); +}); diff --git a/webui/src/api/__tests__/downloader.test.ts b/webui/src/api/__tests__/downloader.test.ts new file mode 100644 index 00000000..f32f4c29 --- /dev/null +++ b/webui/src/api/__tests__/downloader.test.ts @@ -0,0 +1,60 @@ +/** + * Contract tests for apiDownloader: call the real functions against a + * mocked axios instance so a drift between the wrapper and the FastAPI + * routes in backend/src/module/api/downloader.py fails a test instead of + * going unnoticed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockApiSuccess, mockTorrents } from '@/test/mocks/api'; +import { createAxiosMock } from '@/test/mocks/axios'; + +import { apiDownloader } from '@/api/downloader'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); + +describe('Downloader API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should GET api/v1/downloader/torrents when fetching torrents', async () => { + (axios.get as any).mockResolvedValue({ data: mockTorrents }); + await apiDownloader.getTorrents(); + expect(axios.get).toHaveBeenCalledWith('api/v1/downloader/torrents'); + }); + + it('should POST api/v1/downloader/torrents/pause with hashes when pausing', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiDownloader.pause(['abc123', 'def456']); + expect(axios.post).toHaveBeenCalledWith('api/v1/downloader/torrents/pause', { + hashes: ['abc123', 'def456'], + }); + }); + + it('should POST api/v1/downloader/torrents/resume with hashes when resuming', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiDownloader.resume(['abc123']); + expect(axios.post).toHaveBeenCalledWith('api/v1/downloader/torrents/resume', { + hashes: ['abc123'], + }); + }); + + it('should POST api/v1/downloader/torrents/delete with hashes and delete_files when deleting', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiDownloader.deleteTorrents(['abc123'], true); + expect(axios.post).toHaveBeenCalledWith('api/v1/downloader/torrents/delete', { + hashes: ['abc123'], + delete_files: true, + }); + }); + + it('should default delete_files to false when deleting without the flag', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiDownloader.deleteTorrents(['abc123']); + expect(axios.post).toHaveBeenCalledWith('api/v1/downloader/torrents/delete', { + hashes: ['abc123'], + delete_files: false, + }); + }); +}); diff --git a/webui/src/api/__tests__/log.test.ts b/webui/src/api/__tests__/log.test.ts new file mode 100644 index 00000000..059c8351 --- /dev/null +++ b/webui/src/api/__tests__/log.test.ts @@ -0,0 +1,31 @@ +/** + * Contract tests for apiLog: call the real functions against a mocked + * axios instance so a drift between the wrapper and the FastAPI routes in + * backend/src/module/api/log.py fails a test instead of going unnoticed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockApiSuccess } from '@/test/mocks/api'; +import { createAxiosMock } from '@/test/mocks/axios'; + +import { apiLog } from '@/api/log'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); + +describe('Log API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should GET api/v1/log when fetching the log', async () => { + (axios.get as any).mockResolvedValue({ data: 'log contents' }); + await apiLog.getLog(); + expect(axios.get).toHaveBeenCalledWith('api/v1/log'); + }); + + it('should POST api/v1/log/clear when clearing the log', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiLog.clearLog(); + expect(axios.post).toHaveBeenCalledWith('api/v1/log/clear'); + }); +}); diff --git a/webui/src/api/__tests__/notification.test.ts b/webui/src/api/__tests__/notification.test.ts new file mode 100644 index 00000000..d8e77eac --- /dev/null +++ b/webui/src/api/__tests__/notification.test.ts @@ -0,0 +1,48 @@ +/** + * Contract tests for apiNotification: call the real functions against a + * mocked axios instance so a drift between the wrapper and the FastAPI + * routes in backend/src/module/api/notification.py fails a test instead of + * going unnoticed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAxiosMock } from '@/test/mocks/axios'; +import type { TestProviderConfigRequest } from '@/api/notification'; + +import { apiNotification } from '@/api/notification'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); + +const mockTestResponse = { + success: true, + message: 'ok', + message_zh: '成功', + message_en: 'ok', +}; + +describe('Notification API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should POST api/v1/notification/test with the provider index when testing a saved provider', async () => { + (axios.post as any).mockResolvedValue({ data: mockTestResponse }); + const request = { provider_index: 0 }; + await apiNotification.testProvider(request); + expect(axios.post).toHaveBeenCalledWith('api/v1/notification/test', request); + }); + + it('should POST api/v1/notification/test-config with the provider config when testing an unsaved provider', async () => { + (axios.post as any).mockResolvedValue({ data: mockTestResponse }); + const request: TestProviderConfigRequest = { + type: 'telegram', + token: 'abc', + chat_id: '123', + }; + await apiNotification.testProviderConfig(request); + expect(axios.post).toHaveBeenCalledWith( + 'api/v1/notification/test-config', + request + ); + }); +}); diff --git a/webui/src/api/__tests__/passkey.test.ts b/webui/src/api/__tests__/passkey.test.ts new file mode 100644 index 00000000..4b91cb23 --- /dev/null +++ b/webui/src/api/__tests__/passkey.test.ts @@ -0,0 +1,68 @@ +/** + * Contract tests for apiPasskey: call the real functions against a mocked + * axios instance so a drift between the wrapper and the FastAPI routes in + * backend/src/module/api/passkey.py fails a test instead of going + * unnoticed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockApiSuccess, mockLoginSuccess } from '@/test/mocks/api'; +import { createAxiosMock } from '@/test/mocks/axios'; +import type { + PasskeyAuthFinishRequest, + PasskeyAuthStartRequest, + PasskeyCreateRequest, + PasskeyDeleteRequest, +} from '#/passkey'; + +import { apiPasskey } from '@/api/passkey'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); + +describe('Passkey API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should POST api/v1/passkey/register/options when starting registration', async () => { + (axios.post as any).mockResolvedValue({ + data: { challenge: 'c', rp: { name: 'AB', id: 'localhost' }, user: {}, pubKeyCredParams: [] }, + }); + await apiPasskey.getRegistrationOptions(); + expect(axios.post).toHaveBeenCalledWith('api/v1/passkey/register/options'); + }); + + it('should POST api/v1/passkey/register/verify with the credential when finishing registration', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + const request: PasskeyCreateRequest = { name: 'my device', attestation_response: {} }; + await apiPasskey.verifyRegistration(request); + expect(axios.post).toHaveBeenCalledWith('api/v1/passkey/register/verify', request); + }); + + it('should POST api/v1/passkey/auth/options with the username when starting login', async () => { + (axios.post as any).mockResolvedValue({ data: { challenge: 'c' } }); + const request: PasskeyAuthStartRequest = { username: 'admin' }; + await apiPasskey.getLoginOptions(request); + expect(axios.post).toHaveBeenCalledWith('api/v1/passkey/auth/options', request); + }); + + it('should POST api/v1/passkey/auth/verify with the credential when finishing login', async () => { + (axios.post as any).mockResolvedValue({ data: mockLoginSuccess }); + const request: PasskeyAuthFinishRequest = { username: 'admin', credential: {} }; + await apiPasskey.loginWithPasskey(request); + expect(axios.post).toHaveBeenCalledWith('api/v1/passkey/auth/verify', request); + }); + + it('should GET api/v1/passkey/list when listing passkeys', async () => { + (axios.get as any).mockResolvedValue({ data: [] }); + await apiPasskey.list(); + expect(axios.get).toHaveBeenCalledWith('api/v1/passkey/list'); + }); + + it('should POST api/v1/passkey/delete with the passkey id when deleting', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + const request: PasskeyDeleteRequest = { passkey_id: 1 }; + await apiPasskey.delete(request); + expect(axios.post).toHaveBeenCalledWith('api/v1/passkey/delete', request); + }); +}); diff --git a/webui/src/api/__tests__/program.test.ts b/webui/src/api/__tests__/program.test.ts new file mode 100644 index 00000000..4c33b0a2 --- /dev/null +++ b/webui/src/api/__tests__/program.test.ts @@ -0,0 +1,50 @@ +/** + * Contract tests for apiProgram: call the real functions against a mocked + * axios instance so a drift between the wrapper and the FastAPI routes in + * backend/src/module/api/program.py fails a test instead of going + * unnoticed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockApiSuccess, mockProgramStatus } from '@/test/mocks/api'; +import { createAxiosMock } from '@/test/mocks/axios'; + +import { apiProgram } from '@/api/program'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); + +describe('Program API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should POST api/v1/restart when restarting the program', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiProgram.restart(); + expect(axios.post).toHaveBeenCalledWith('api/v1/restart'); + }); + + it('should POST api/v1/start when starting the program', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiProgram.start(); + expect(axios.post).toHaveBeenCalledWith('api/v1/start'); + }); + + it('should POST api/v1/stop when stopping the program', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiProgram.stop(); + expect(axios.post).toHaveBeenCalledWith('api/v1/stop'); + }); + + it('should GET api/v1/status when fetching program status', async () => { + (axios.get as any).mockResolvedValue({ data: mockProgramStatus }); + await apiProgram.status(); + expect(axios.get).toHaveBeenCalledWith('api/v1/status'); + }); + + it('should POST api/v1/shutdown when shutting down the program', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiProgram.shutdown(); + expect(axios.post).toHaveBeenCalledWith('api/v1/shutdown'); + }); +}); diff --git a/webui/src/api/__tests__/rss.test.ts b/webui/src/api/__tests__/rss.test.ts index 9bbd4120..2eb6b9b1 100644 --- a/webui/src/api/__tests__/rss.test.ts +++ b/webui/src/api/__tests__/rss.test.ts @@ -3,11 +3,18 @@ * Note: These tests focus on data structures and endpoint paths */ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { + mockApiSuccess, mockRSSItem, mockRSSList, } from '@/test/mocks/api'; +import { createAxiosMock } from '@/test/mocks/axios'; + +import { apiRSS } from '@/api/rss'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); describe('RSS API Logic', () => { describe('RSS data structure', () => { @@ -63,58 +70,78 @@ describe('RSS API Logic', () => { }); }); - describe('API endpoint paths', () => { - const RSS_ENDPOINTS = { - get: 'api/v1/rss', - add: 'api/v1/rss/add', - delete: (id: number) => `api/v1/rss/delete/${id}`, - deleteMany: 'api/v1/rss/delete/many', - disable: (id: number) => `api/v1/rss/disable/${id}`, - disableMany: 'api/v1/rss/disable/many', - update: (id: number) => `api/v1/rss/update/${id}`, - enableMany: 'api/v1/rss/enable/many', - refreshAll: 'api/v1/rss/refresh/all', - refresh: (id: number) => `api/v1/rss/refresh/${id}`, - getTorrent: (id: number) => `api/v1/rss/torrent/${id}`, - }; - - it('should have correct base RSS endpoint', () => { - expect(RSS_ENDPOINTS.get).toBe('api/v1/rss'); + // Contract tests: call the real apiRSS functions against a mocked axios + // instance so a drift between the wrapper and the FastAPI routes in + // backend/src/module/api/rss.py fails a test instead of going unnoticed. + describe('API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - it('should have correct add endpoint', () => { - expect(RSS_ENDPOINTS.add).toBe('api/v1/rss/add'); + it('should GET api/v1/rss when fetching all feeds', async () => { + (axios.get as any).mockResolvedValue({ data: mockRSSList }); + await apiRSS.get(); + expect(axios.get).toHaveBeenCalledWith('api/v1/rss'); }); - it('should generate correct delete endpoint for ID', () => { - expect(RSS_ENDPOINTS.delete(1)).toBe('api/v1/rss/delete/1'); - expect(RSS_ENDPOINTS.delete(42)).toBe('api/v1/rss/delete/42'); + it('should POST api/v1/rss/add with the RSS payload when adding a feed', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiRSS.add(mockRSSItem); + expect(axios.post).toHaveBeenCalledWith('api/v1/rss/add', mockRSSItem); }); - it('should have correct deleteMany endpoint', () => { - expect(RSS_ENDPOINTS.deleteMany).toBe('api/v1/rss/delete/many'); + it('should DELETE api/v1/rss/delete/:id when deleting a single feed', async () => { + (axios.delete as any).mockResolvedValue({ data: mockApiSuccess }); + await apiRSS.delete(7); + expect(axios.delete).toHaveBeenCalledWith('api/v1/rss/delete/7'); }); - it('should generate correct disable endpoint for ID', () => { - expect(RSS_ENDPOINTS.disable(1)).toBe('api/v1/rss/disable/1'); + it('should POST api/v1/rss/delete/many with the id array when batch deleting', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiRSS.deleteMany([1, 2, 3]); + expect(axios.post).toHaveBeenCalledWith('api/v1/rss/delete/many', [1, 2, 3]); }); - it('should have correct batch operation endpoints', () => { - expect(RSS_ENDPOINTS.disableMany).toBe('api/v1/rss/disable/many'); - expect(RSS_ENDPOINTS.enableMany).toBe('api/v1/rss/enable/many'); + it('should PATCH api/v1/rss/disable/:id when disabling a single feed', async () => { + (axios.patch as any).mockResolvedValue({ data: mockApiSuccess }); + await apiRSS.disable(3); + expect(axios.patch).toHaveBeenCalledWith('api/v1/rss/disable/3'); }); - it('should generate correct update endpoint for ID', () => { - expect(RSS_ENDPOINTS.update(1)).toBe('api/v1/rss/update/1'); + it('should POST api/v1/rss/disable/many with the id array when batch disabling', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiRSS.disableMany([1, 2]); + expect(axios.post).toHaveBeenCalledWith('api/v1/rss/disable/many', [1, 2]); }); - it('should have correct refresh endpoints', () => { - expect(RSS_ENDPOINTS.refreshAll).toBe('api/v1/rss/refresh/all'); - expect(RSS_ENDPOINTS.refresh(1)).toBe('api/v1/rss/refresh/1'); + it('should PATCH api/v1/rss/update/:id with the RSS payload when updating', async () => { + (axios.patch as any).mockResolvedValue({ data: mockApiSuccess }); + await apiRSS.update(1, mockRSSItem); + expect(axios.patch).toHaveBeenCalledWith('api/v1/rss/update/1', mockRSSItem); }); - it('should generate correct getTorrent endpoint for ID', () => { - expect(RSS_ENDPOINTS.getTorrent(1)).toBe('api/v1/rss/torrent/1'); + it('should POST api/v1/rss/enable/many with the id array when batch enabling', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiRSS.enableMany([1, 2, 3]); + expect(axios.post).toHaveBeenCalledWith('api/v1/rss/enable/many', [1, 2, 3]); + }); + + it('should POST api/v1/rss/refresh/all when refreshing every feed', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiRSS.refreshAll(); + expect(axios.post).toHaveBeenCalledWith('api/v1/rss/refresh/all'); + }); + + it('should POST api/v1/rss/refresh/:id when refreshing a single feed', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + await apiRSS.refresh(9); + expect(axios.post).toHaveBeenCalledWith('api/v1/rss/refresh/9'); + }); + + it('should GET api/v1/rss/torrent/:id when fetching torrents for a feed', async () => { + (axios.get as any).mockResolvedValue({ data: [] }); + await apiRSS.getTorrent(5); + expect(axios.get).toHaveBeenCalledWith('api/v1/rss/torrent/5'); }); }); diff --git a/webui/src/api/__tests__/search.test.ts b/webui/src/api/__tests__/search.test.ts new file mode 100644 index 00000000..914cb0b9 --- /dev/null +++ b/webui/src/api/__tests__/search.test.ts @@ -0,0 +1,28 @@ +/** + * Contract tests for apiSearch: call the real functions against a mocked + * axios instance so a drift between the wrapper and the FastAPI routes in + * backend/src/module/api/search.py fails a test instead of going + * unnoticed. + * + * Note: `apiSearch.get()` is an EventSource-based composable, not an axios + * call, so it is out of scope for this contract test. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAxiosMock } from '@/test/mocks/axios'; + +import { apiSearch } from '@/api/search'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); + +describe('Search API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should GET api/v1/search/provider when fetching providers', async () => { + (axios.get as any).mockResolvedValue({ data: ['mikan', 'dmhy'] }); + await apiSearch.getProvider(); + expect(axios.get).toHaveBeenCalledWith('api/v1/search/provider'); + }); +}); diff --git a/webui/src/api/__tests__/setup.test.ts b/webui/src/api/__tests__/setup.test.ts new file mode 100644 index 00000000..d6e8ebb3 --- /dev/null +++ b/webui/src/api/__tests__/setup.test.ts @@ -0,0 +1,95 @@ +/** + * Contract tests for apiSetup: call the real functions against a mocked + * axios instance so a drift between the wrapper and the FastAPI routes in + * backend/src/module/api/setup.py fails a test instead of going unnoticed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockApiSuccess } from '@/test/mocks/api'; +import { createAxiosMock } from '@/test/mocks/axios'; +import type { + SetupCompleteRequest, + TestDownloaderRequest, + TestNotificationRequest, +} from '#/setup'; + +import { apiSetup } from '@/api/setup'; +import { axios } from '@/utils/axios'; + +vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() })); + +const mockTestResult = { + success: true, + message_en: 'ok', + message_zh: '成功', +}; + +describe('Setup API contract (path + HTTP method)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should GET api/v1/setup/status when fetching setup status', async () => { + (axios.get as any).mockResolvedValue({ + data: { need_setup: true, version: 'DEV_VERSION' }, + }); + await apiSetup.getStatus(); + expect(axios.get).toHaveBeenCalledWith('api/v1/setup/status'); + }); + + it('should POST api/v1/setup/test-downloader with the config when testing the downloader', async () => { + (axios.post as any).mockResolvedValue({ data: mockTestResult }); + const config: TestDownloaderRequest = { + type: 'qbittorrent', + host: '172.17.0.1:8080', + username: 'admin', + password: 'adminadmin', + ssl: false, + }; + await apiSetup.testDownloader(config); + expect(axios.post).toHaveBeenCalledWith('api/v1/setup/test-downloader', config); + }); + + it('should POST api/v1/setup/test-rss with the url when testing an RSS feed', async () => { + (axios.post as any).mockResolvedValue({ data: mockTestResult }); + await apiSetup.testRSS('https://mikanani.me/RSS/MyBangumi'); + expect(axios.post).toHaveBeenCalledWith('api/v1/setup/test-rss', { + url: 'https://mikanani.me/RSS/MyBangumi', + }); + }); + + it('should POST api/v1/setup/test-notification with the config when testing a notification provider', async () => { + (axios.post as any).mockResolvedValue({ data: mockTestResult }); + const config: TestNotificationRequest = { + type: 'telegram', + token: 'abc', + chat_id: '123', + }; + await apiSetup.testNotification(config); + expect(axios.post).toHaveBeenCalledWith( + 'api/v1/setup/test-notification', + config + ); + }); + + it('should POST api/v1/setup/complete with the wizard config when completing setup', async () => { + (axios.post as any).mockResolvedValue({ data: mockApiSuccess }); + const config: SetupCompleteRequest = { + username: 'admin', + password: 'adminadmin', + downloader_type: 'qbittorrent', + downloader_host: '172.17.0.1:8080', + downloader_username: 'admin', + downloader_password: 'adminadmin', + downloader_path: '/downloads/Bangumi', + downloader_ssl: false, + rss_url: '', + rss_name: '', + notification_enable: false, + notification_type: 'telegram', + notification_token: '', + notification_chat_id: '', + }; + await apiSetup.complete(config); + expect(axios.post).toHaveBeenCalledWith('api/v1/setup/complete', config); + }); +}); diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index b601d7a8..d0100940 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -256,6 +256,8 @@ "enable": "Enable", "error": "Error", "name": "Name", + "refresh": "Refresh", + "refresh_all": "Refresh All", "selectbox": "Select", "status": "Status", "title": "RSS Item", diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index 8dbde49e..9df625b6 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -256,6 +256,8 @@ "enable": "启用", "error": "错误", "name": "名称", + "refresh": "刷新", + "refresh_all": "全部刷新", "selectbox": "选择", "status": "状态", "title": "RSS 条目", diff --git a/webui/src/pages/index/rss.vue b/webui/src/pages/index/rss.vue index 6355bab0..e9a9afbd 100644 --- a/webui/src/pages/index/rss.vue +++ b/webui/src/pages/index/rss.vue @@ -8,14 +8,33 @@ definePage({ const { t } = useMyI18n(); const { isMobile } = useBreakpointQuery(); -const { rss, selectedRSS } = storeToRefs(useRSSStore()); -const { getAll, deleteSelected, disableSelected, enableSelected } = - useRSSStore(); +const { rss, selectedRSS, isRefreshingAll } = storeToRefs(useRSSStore()); +const { + getAll, + deleteSelected, + disableSelected, + enableSelected, + refreshRSS, + refreshAllRSS, +} = useRSSStore(); onActivated(() => { getAll(); }); +// Tracks which single row is refreshing so only its button spins — the +// store's isRefreshingAll only covers the "refresh all" button. +const refreshingId = ref(null); + +async function onRefreshOne(id: number) { + refreshingId.value = id; + try { + await refreshRSS(id); + } finally { + refreshingId.value = null; + } +} + const rssColumns = computed>(() => [ { type: 'selection', @@ -69,6 +88,26 @@ const rssColumns = computed>(() => [ ); }, }, + { + title: t('rss.refresh'), + key: 'actions', + align: 'center', + width: 90, + render(rss: RSS) { + const isRowRefreshing = refreshingId.value === rss.id; + return ( + onRefreshOne(rss.id)} + > +
+ + ); + }, + }, ]); const rssRowKey = (row: RSS) => row.id; @@ -77,6 +116,19 @@ const rssRowKey = (row: RSS) => row.id; @@ -198,4 +268,10 @@ const rssRowKey = (row: RSS) => row.id; flex-wrap: wrap; margin-top: 4px; } + +.rss-card-actions { + display: flex; + justify-content: flex-end; + margin-top: 4px; +} diff --git a/webui/src/store/rss.ts b/webui/src/store/rss.ts index 8c645d3e..1d8e9d53 100644 --- a/webui/src/store/rss.ts +++ b/webui/src/store/rss.ts @@ -34,6 +34,21 @@ export const useRSSStore = defineStore('rss', () => { const deleteSelected = () => deleteRSS(selectedRSS.value); const enableSelected = () => enableRSS(selectedRSS.value); + // Refresh doesn't touch selectedRSS, so it gets its own onSuccess instead + // of reusing `opts`. + const refreshOpts = { + showMessage: true, + onSuccess() { + getAll(); + }, + }; + + const { execute: refreshRSS } = useApi(apiRSS.refresh, refreshOpts); + const { execute: refreshAllRSS, isLoading: isRefreshingAll } = useApi( + apiRSS.refreshAll, + refreshOpts + ); + return { rss, selectedRSS, @@ -46,5 +61,8 @@ export const useRSSStore = defineStore('rss', () => { disableSelected, deleteSelected, enableSelected, + refreshRSS, + refreshAllRSS, + isRefreshingAll, }; }); diff --git a/webui/src/test/mocks/axios.ts b/webui/src/test/mocks/axios.ts new file mode 100644 index 00000000..b1a64756 --- /dev/null +++ b/webui/src/test/mocks/axios.ts @@ -0,0 +1,21 @@ +/** + * Shared axios mock factory for API contract tests. + * + * `src/utils/axios.ts` builds its exported `axios` instance eagerly at + * module scope and pulls in `useAuth`/the router along the way, so it can't + * be imported directly under vitest. Contract tests instead + * `vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() }))` to swap + * in this lightweight double before the real module (and its router chain) + * ever loads. + */ +import { vi } from 'vitest'; + +export function createAxiosMock() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + put: vi.fn(), + }; +} diff --git a/webui/vitest.config.ts b/webui/vitest.config.ts index aa00534e..5c5fa9fd 100644 --- a/webui/vitest.config.ts +++ b/webui/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ vue(), AutoImport({ imports: ['vue', 'vitest', 'pinia'], + dirs: ['src/api', 'src/store', 'src/hooks', 'src/utils'], dts: false, }), ],