mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-09-01 14:57:14 +08:00
bb12119123
* feat(types): add upvotes, downvotes, and comments to DataItem * refactor: fix TS2339 * refactor: fix TS2322 * refactor: fix TS2322 * refactor: fix TS2345 * refactor: fix TS2339 * refactor: fix TS2322 * refactor: fix TS2345 * refactor: fix TS2531/2532/18048/18047 * refactor: fix TS18046/TS2571 * refactor: fix TS2538 * refactor: fix TS2554/2769/2558 * refactor: fix TS2362/2363 * refactor: fix TS2353/TS2561 * refactor: fix TS2741/2739/2740 * refactor: fix TS2304 TS2307 TS2305 TS2614 TS2552 TS2551 * refactor: fix TS2352 * refactor: fix TS2604 TS17001 * refactor: fix TS2367 TS2366 * refactor: fix TS2493 * refactor: fix TS2454 * refactor: fix TS2790 * refactor: fix TS2488 * refactor: fix TS2677 * refactor: fix TS18048/18049/2532 * refactor: fix TS2322 * refactor: fix TS2344 * refactor: fix TS2339 * refactor: fix TS2339 * refactor: fix TS2365 * refactor: fix TS1117 * refactor: fix TS2561 * refactor: fix TS2555 * refactor: fix TS2503 * refactor: fix TS2339 mobile api no longer work * fix: clean slop * fix: use Context instead of any * fix: fix more typing after merge * fix: add void return for ctx.redirect in handler * chore: add type check to lint and husky * fix: remove ts ignore * chore: add oxlint rule * fix: suppress build artifact * fix: update getArticles to use cache * fix: codeql * fix(route/typeless): use async gunzip * fix: address review * fix: address review * fix: double quotes escape
218 lines
7.9 KiB
TypeScript
218 lines
7.9 KiB
TypeScript
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { Hono } from 'hono';
|
|
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { createDevRegistry } from '@/registry-dev';
|
|
import type { NamespacesType } from '@/registry-helpers';
|
|
|
|
const directoryImportMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('@/utils/directory-import', () => ({
|
|
directoryImport: directoryImportMock,
|
|
}));
|
|
|
|
// Fake route modules keyed by top-level directory; inner keys are relative to that directory,
|
|
// matching what directoryImport returns for a scoped import.
|
|
const fakeDirectories: Record<string, Record<string, unknown>> = {
|
|
flat: {
|
|
'/single.ts': {
|
|
route: {
|
|
path: '/single',
|
|
name: 'Single',
|
|
handler: () => ({ title: 'flat-single', link: 'https://example.com', item: [], allowEmpty: true }),
|
|
},
|
|
},
|
|
'/param.ts': {
|
|
route: {
|
|
path: '/:id',
|
|
name: 'Param',
|
|
handler: (ctx) => ({ title: `param-${ctx.req.param('id')}`, link: 'https://example.com', item: [], allowEmpty: true }),
|
|
},
|
|
},
|
|
'/outer.ts': {
|
|
route: {
|
|
path: '/outer',
|
|
name: 'Outer',
|
|
handler: (ctx) => ({ title: String(ctx.get('fromOuter')), link: 'https://example.com', item: [], allowEmpty: true }),
|
|
},
|
|
},
|
|
'/boom.ts': {
|
|
route: {
|
|
path: '/boom',
|
|
name: 'Boom',
|
|
handler: () => {
|
|
throw new Error('handler-boom');
|
|
},
|
|
},
|
|
},
|
|
},
|
|
github: {
|
|
'/namespace.ts': { namespace: { name: 'GitHub' } },
|
|
'/enterprise/namespace.ts': { namespace: { name: 'GitHub Enterprise' } },
|
|
'/enterprise/news.ts': {
|
|
route: {
|
|
path: '/news',
|
|
name: 'News',
|
|
handler: () => ({ title: 'github-enterprise-news', link: 'https://example.com', item: [], allowEmpty: true }),
|
|
},
|
|
},
|
|
},
|
|
withapi: {
|
|
'/api.ts': {
|
|
apiRoute: {
|
|
path: '/ping',
|
|
name: 'Ping',
|
|
handler: () => ({ ok: true }),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const mockImplementation = ({ targetDirectoryPath }: { targetDirectoryPath: string }) => {
|
|
const name = targetDirectoryPath.split(/[/\\]/).findLast(Boolean) as string;
|
|
return Promise.resolve(fakeDirectories[name]);
|
|
};
|
|
|
|
// The registry lists real directories at startup; module contents come from the mocked importer
|
|
const routesDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'rsshub-dev-registry-'));
|
|
for (const name of Object.keys(fakeDirectories)) {
|
|
fs.mkdirSync(path.join(routesDirectory, name));
|
|
}
|
|
afterAll(() => {
|
|
fs.rmSync(routesDirectory, { recursive: true, force: true });
|
|
});
|
|
|
|
const buildApp = () => {
|
|
const namespaces: NamespacesType = {};
|
|
const dev = createDevRegistry({ routesDirectory, namespaces });
|
|
const app = new Hono<{ Variables: { fromOuter: string; data: Record<string, unknown>; apiData: Record<string, unknown> } }>();
|
|
app.use(async (ctx, next) => {
|
|
ctx.set('fromOuter', 'bridged');
|
|
await next();
|
|
const apiData = ctx.get('apiData');
|
|
if (apiData) {
|
|
return ctx.json(apiData);
|
|
}
|
|
const data = ctx.get('data');
|
|
if (data) {
|
|
return ctx.json(data);
|
|
}
|
|
});
|
|
app.use('*', dev.middleware);
|
|
app.get('/static', (ctx) => ctx.text('static-ok'));
|
|
return { app, dev, namespaces };
|
|
};
|
|
|
|
describe('createDevRegistry', () => {
|
|
beforeEach(() => {
|
|
directoryImportMock.mockReset().mockImplementation(mockImplementation);
|
|
});
|
|
|
|
it('imports nothing at startup', () => {
|
|
buildApp();
|
|
expect(directoryImportMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('loads a namespace on first request and serves its routes', async () => {
|
|
const { app } = buildApp();
|
|
const response = await app.request('/flat/single');
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.title).toBe('flat-single');
|
|
expect(directoryImportMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('imports each top directory at most once', async () => {
|
|
const { app } = buildApp();
|
|
await app.request('/flat/single');
|
|
const second = await app.request('/flat/abc');
|
|
const body = await second.json();
|
|
expect(body.title).toBe('param-abc');
|
|
expect(directoryImportMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('bridges outer context vars into route handlers', async () => {
|
|
const { app } = buildApp();
|
|
const response = await app.request('/flat/outer');
|
|
const body = await response.json();
|
|
expect(body.title).toBe('bridged');
|
|
});
|
|
|
|
it('serves nested namespaces', async () => {
|
|
const { app } = buildApp();
|
|
const response = await app.request('/github/enterprise/news');
|
|
const body = await response.json();
|
|
expect(body.title).toBe('github-enterprise-news');
|
|
});
|
|
|
|
it('serves api routes under /api', async () => {
|
|
const { app } = buildApp();
|
|
const response = await app.request('/api/withapi/ping');
|
|
const body = await response.json();
|
|
expect(body).toEqual({ ok: true });
|
|
});
|
|
|
|
it('falls through for unknown directories without touching the importer', async () => {
|
|
const { app } = buildApp();
|
|
const first = await app.request('/static');
|
|
const body = await first.text();
|
|
expect(body).toBe('static-ok');
|
|
await app.request('/static');
|
|
expect(directoryImportMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 404 for unmatched paths inside a loaded namespace', async () => {
|
|
const { app } = buildApp();
|
|
const response = await app.request('/withapi/nope');
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it('populates the shared namespaces object', async () => {
|
|
const { app, namespaces } = buildApp();
|
|
await app.request('/github/enterprise/news');
|
|
expect(namespaces['github/enterprise'].routes['/news']).toBeDefined();
|
|
});
|
|
|
|
it('propagates route handler errors to the outer error handler', async () => {
|
|
const { app } = buildApp();
|
|
let seen: unknown = null;
|
|
app.onError((error, ctx) => {
|
|
seen = error;
|
|
return ctx.text('outer-handled', 503);
|
|
});
|
|
const response = await app.request('/flat/boom');
|
|
expect((seen as Error)?.message).toBe('handler-boom');
|
|
expect(response.status).toBe(503);
|
|
const body = await response.text();
|
|
expect(body).toBe('outer-handled');
|
|
});
|
|
|
|
it('retries a directory whose import failed', async () => {
|
|
const { app } = buildApp();
|
|
directoryImportMock.mockRejectedValueOnce(new Error('boom'));
|
|
const first = await app.request('/flat/single');
|
|
expect(first.status).toBe(500);
|
|
const second = await app.request('/flat/single');
|
|
expect(second.status).toBe(200);
|
|
});
|
|
|
|
it('ensureAllLoaded imports every top-level directory', async () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rsshub-dev-registry-'));
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, 'flat'));
|
|
fs.mkdirSync(path.join(tmp, 'github'));
|
|
fs.writeFileSync(path.join(tmp, 'not-a-dir.ts'), '');
|
|
const namespaces: NamespacesType = {};
|
|
const dev = createDevRegistry({ routesDirectory: tmp, namespaces });
|
|
await dev.ensureAllLoaded();
|
|
expect(directoryImportMock).toHaveBeenCalledTimes(2);
|
|
expect(Object.keys(namespaces).toSorted((a, b) => a.localeCompare(b))).toEqual(['flat', 'github', 'github/enterprise']);
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|