Files
RSSHub/lib/registry-dev.test.ts
T
Tony bb12119123 refactor: enforce typecheck (#22917)
* 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
2026-08-04 09:00:41 +08:00

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