mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-10-31 20:55:35 +08:00
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:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
25
webui/src/api/__tests__/check.test.ts
Normal file
25
webui/src/api/__tests__/check.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
32
webui/src/api/__tests__/config.test.ts
Normal file
32
webui/src/api/__tests__/config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
41
webui/src/api/__tests__/download.test.ts
Normal file
41
webui/src/api/__tests__/download.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
60
webui/src/api/__tests__/downloader.test.ts
Normal file
60
webui/src/api/__tests__/downloader.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
31
webui/src/api/__tests__/log.test.ts
Normal file
31
webui/src/api/__tests__/log.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
48
webui/src/api/__tests__/notification.test.ts
Normal file
48
webui/src/api/__tests__/notification.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
68
webui/src/api/__tests__/passkey.test.ts
Normal file
68
webui/src/api/__tests__/passkey.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
webui/src/api/__tests__/program.test.ts
Normal file
50
webui/src/api/__tests__/program.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
28
webui/src/api/__tests__/search.test.ts
Normal file
28
webui/src/api/__tests__/search.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
95
webui/src/api/__tests__/setup.test.ts
Normal file
95
webui/src/api/__tests__/setup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -256,6 +256,8 @@
|
||||
"enable": "Enable",
|
||||
"error": "Error",
|
||||
"name": "Name",
|
||||
"refresh": "Refresh",
|
||||
"refresh_all": "Refresh All",
|
||||
"selectbox": "Select",
|
||||
"status": "Status",
|
||||
"title": "RSS Item",
|
||||
|
||||
@@ -256,6 +256,8 @@
|
||||
"enable": "启用",
|
||||
"error": "错误",
|
||||
"name": "名称",
|
||||
"refresh": "刷新",
|
||||
"refresh_all": "全部刷新",
|
||||
"selectbox": "选择",
|
||||
"status": "状态",
|
||||
"title": "RSS 条目",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
21
webui/src/test/mocks/axios.ts
Normal file
21
webui/src/test/mocks/axios.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
vue(),
|
||||
AutoImport({
|
||||
imports: ['vue', 'vitest', 'pinia'],
|
||||
dirs: ['src/api', 'src/store', 'src/hooks', 'src/utils'],
|
||||
dts: false,
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user