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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
This commit is contained in:
Estrella Pan
2026-07-02 17:39:59 +02:00
parent c2261ece0a
commit 92eb224011
19 changed files with 852 additions and 100 deletions

View File

@@ -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',
});
});
});
});

View File

@@ -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,
});
});
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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,
});
});
});

View File

@@ -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,
});
});
});

View File

@@ -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');
});
});

View File

@@ -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
);
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -256,6 +256,8 @@
"enable": "Enable",
"error": "Error",
"name": "Name",
"refresh": "Refresh",
"refresh_all": "Refresh All",
"selectbox": "Select",
"status": "Status",
"title": "RSS Item",

View File

@@ -256,6 +256,8 @@
"enable": "启用",
"error": "错误",
"name": "名称",
"refresh": "刷新",
"refresh_all": "全部刷新",
"selectbox": "选择",
"status": "状态",
"title": "RSS 条目",

View File

@@ -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<number | null>(null);
async function onRefreshOne(id: number) {
refreshingId.value = id;
try {
await refreshRSS(id);
} finally {
refreshingId.value = null;
}
}
const rssColumns = computed<DataTableColumns<RSS>>(() => [
{
type: 'selection',
@@ -69,6 +88,26 @@ const rssColumns = computed<DataTableColumns<RSS>>(() => [
);
},
},
{
title: t('rss.refresh'),
key: 'actions',
align: 'center',
width: 90,
render(rss: RSS) {
const isRowRefreshing = refreshingId.value === rss.id;
return (
<ab-button
size="small"
type="secondary"
title={t('rss.refresh')}
disabled={isRowRefreshing}
onClick={() => onRefreshOne(rss.id)}
>
<div class={isRowRefreshing ? 'i-carbon-renew animate-spin' : 'i-carbon-renew'} />
</ab-button>
);
},
},
]);
const rssRowKey = (row: RSS) => row.id;
@@ -77,6 +116,19 @@ const rssRowKey = (row: RSS) => row.id;
<template>
<div class="page-rss">
<ab-container :title="$t('rss.title')">
<template #title-right>
<ab-button
size="small"
type="secondary"
:title="$t('rss.refresh_all')"
:disabled="isRefreshingAll"
@click="refreshAllRSS"
>
<div :class="isRefreshingAll ? 'i-carbon-renew animate-spin' : 'i-carbon-renew'"></div>
{{ $t('rss.refresh_all') }}
</ab-button>
</template>
<!-- Mobile: Card-based list -->
<ab-data-list
v-if="isMobile"
@@ -112,6 +164,24 @@ const rssRowKey = (row: RSS) => row.id;
:title="item.enabled ? 'active' : 'inactive'"
/>
</div>
<div class="rss-card-actions" @click.stop>
<ab-button
size="small"
type="secondary"
:title="$t('rss.refresh')"
:disabled="refreshingId === item.id"
@click="onRefreshOne(item.id)"
>
<div
:class="
refreshingId === item.id
? 'i-carbon-renew animate-spin'
: 'i-carbon-renew'
"
></div>
{{ $t('rss.refresh') }}
</ab-button>
</div>
</div>
</template>
</ab-data-list>
@@ -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;
}
</style>

View File

@@ -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,
};
});

View File

@@ -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(),
};
}

View File

@@ -8,6 +8,7 @@ export default defineConfig({
vue(),
AutoImport({
imports: ['vue', 'vitest', 'pinia'],
dirs: ['src/api', 'src/store', 'src/hooks', 'src/utils'],
dts: false,
}),
],