mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-10-17 05:11:57 +08:00
021f1807d3
Affected files: - lib/middleware/cache.test.ts - lib/middleware/cache.ts
557 lines
24 KiB
TypeScript
557 lines
24 KiB
TypeScript
import { Context } from 'hono';
|
|
import Parser from 'rss-parser';
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type CacheModule from '@/utils/cache';
|
|
import type { getRouteLimit as RouteLimit } from '@/utils/route-limit';
|
|
import wait from '@/utils/wait';
|
|
|
|
import type CacheMiddleware from './cache';
|
|
import type ParameterMiddleware from './parameter';
|
|
|
|
process.env.CACHE_EXPIRE = '1';
|
|
process.env.CACHE_CONTENT_EXPIRE = '2';
|
|
|
|
const parser = new Parser();
|
|
|
|
afterEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
const noCacheTestFunc = async () => {
|
|
const app = (await import('@/app')).default;
|
|
|
|
const response1 = await app.request('/test/cache');
|
|
const response2 = await app.request('/test/cache');
|
|
|
|
const parsed1 = await parser.parseString(await response1.text());
|
|
const parsed2 = await parser.parseString(await response2.text());
|
|
|
|
expect(response2.status).toBe(200);
|
|
expect(response2.headers).not.toHaveProperty('rsshub-cache-status');
|
|
|
|
expect(parsed1.items[0].content).toBe('Cache1');
|
|
expect(parsed2.items[0].content).toBe('Cache2');
|
|
|
|
expect(parsed1.ttl).toEqual('1');
|
|
};
|
|
|
|
describe('cache', () => {
|
|
it('memory', async () => {
|
|
process.env.CACHE_TYPE = 'memory';
|
|
const app = (await import('@/app')).default;
|
|
|
|
const response1 = await app.request('/test/cache');
|
|
const response2 = await app.request('/test/cache');
|
|
|
|
const parsed1 = await parser.parseString(await response1.text());
|
|
const parsed2 = await parser.parseString(await response2.text());
|
|
|
|
delete parsed1.lastBuildDate;
|
|
delete parsed2.lastBuildDate;
|
|
delete parsed1.feedUrl;
|
|
delete parsed2.feedUrl;
|
|
delete parsed1.paginationLinks;
|
|
delete parsed2.paginationLinks;
|
|
expect(parsed2).toMatchObject(parsed1);
|
|
|
|
expect(response2.status).toBe(200);
|
|
expect(response2.headers.get('rsshub-cache-status')).toBe('HIT');
|
|
|
|
expect(parsed1.ttl).toEqual('1');
|
|
|
|
await wait(1 * 1000 + 100);
|
|
const response3 = await app.request('/test/cache');
|
|
expect(response3.headers).not.toHaveProperty('rsshub-cache-status');
|
|
const parsed3 = await parser.parseString(await response3.text());
|
|
|
|
await wait(2 * 1000 + 100);
|
|
const response4 = await app.request('/test/cache');
|
|
const parsed4 = await parser.parseString(await response4.text());
|
|
|
|
expect(parsed1.items[0].content).toBe('Cache1');
|
|
expect(parsed2.items[0].content).toBe('Cache1');
|
|
expect(parsed3.items[0].content).toBe('Cache1');
|
|
expect(parsed4.items[0].content).toBe('Cache2');
|
|
|
|
await app.request('/test/refreshCache');
|
|
await wait(1 * 1000 + 100);
|
|
const response5 = await app.request('/test/refreshCache');
|
|
const parsed5 = await parser.parseString(await response5.text());
|
|
await wait(1 * 1000 + 100);
|
|
const response6 = await app.request('/test/refreshCache');
|
|
const parsed6 = await parser.parseString(await response6.text());
|
|
|
|
expect(parsed5.items[0].content).toBe('1 1');
|
|
expect(parsed6.items[0].content).toBe('1 0');
|
|
}, 10000);
|
|
|
|
it('redis', async () => {
|
|
process.env.CACHE_TYPE = 'redis';
|
|
const app = (await import('@/app')).default;
|
|
|
|
await wait(500);
|
|
const response1 = await app.request('/test/cache');
|
|
const response2 = await app.request('/test/cache');
|
|
|
|
const parsed1 = await parser.parseString(await response1.text());
|
|
const parsed2 = await parser.parseString(await response2.text());
|
|
|
|
delete parsed1.lastBuildDate;
|
|
delete parsed2.lastBuildDate;
|
|
delete parsed1.feedUrl;
|
|
delete parsed2.feedUrl;
|
|
delete parsed1.paginationLinks;
|
|
delete parsed2.paginationLinks;
|
|
expect(parsed2).toMatchObject(parsed1);
|
|
|
|
expect(response2.status).toBe(200);
|
|
expect(response2.headers.get('rsshub-cache-status')).toBe('HIT');
|
|
|
|
expect(parsed1.ttl).toEqual('1');
|
|
|
|
await wait(1 * 1000 + 100);
|
|
const response3 = await app.request('/test/cache');
|
|
expect(response3.headers).not.toHaveProperty('rsshub-cache-status');
|
|
const parsed3 = await parser.parseString(await response3.text());
|
|
|
|
await wait(2 * 1000 + 100);
|
|
const response4 = await app.request('/test/cache');
|
|
const parsed4 = await parser.parseString(await response4.text());
|
|
|
|
expect(parsed1.items[0].content).toBe('Cache1');
|
|
expect(parsed2.items[0].content).toBe('Cache1');
|
|
expect(parsed3.items[0].content).toBe('Cache1');
|
|
expect(parsed4.items[0].content).toBe('Cache2');
|
|
|
|
await app.request('/test/refreshCache');
|
|
await wait(1 * 1000 + 100);
|
|
const response5 = await app.request('/test/refreshCache');
|
|
const parsed5 = await parser.parseString(await response5.text());
|
|
await wait(1 * 1000 + 100);
|
|
const response6 = await app.request('/test/refreshCache');
|
|
const parsed6 = await parser.parseString(await response6.text());
|
|
|
|
expect(parsed5.items[0].content).toBe('1 1');
|
|
expect(parsed6.items[0].content).toBe('1 0');
|
|
|
|
const cache = (await import('@/utils/cache')).default;
|
|
await cache.clients.redisClient!.quit();
|
|
}, 10000);
|
|
|
|
it('redis with quit', async () => {
|
|
process.env.CACHE_TYPE = 'redis';
|
|
const cache = (await import('@/utils/cache')).default;
|
|
await cache.clients.redisClient!.quit();
|
|
await noCacheTestFunc();
|
|
});
|
|
|
|
it('redis with error', async () => {
|
|
process.env.CACHE_TYPE = 'redis';
|
|
process.env.REDIS_URL = 'redis://wrongpath:6379';
|
|
await noCacheTestFunc();
|
|
const cache = (await import('@/utils/cache')).default;
|
|
cache.clients.redisClient?.disconnect();
|
|
}, 20000);
|
|
|
|
it('no cache', async () => {
|
|
process.env.CACHE_TYPE = 'NO';
|
|
await noCacheTestFunc();
|
|
});
|
|
|
|
it('no cache (empty string)', async () => {
|
|
process.env.CACHE_TYPE = '';
|
|
await noCacheTestFunc();
|
|
});
|
|
|
|
it('throws URL key', async () => {
|
|
process.env.CACHE_TYPE = 'memory';
|
|
const app = (await import('@/app')).default;
|
|
|
|
try {
|
|
const response = await app.request('/test/cacheUrlKey');
|
|
expect(response).toThrow(Error);
|
|
} catch (error: any) {
|
|
expect(error.message).toContain('Cache key must be a string');
|
|
}
|
|
});
|
|
|
|
it('RSS TTL (no cache)', async () => {
|
|
process.env.CACHE_TYPE = '';
|
|
process.env.CACHE_EXPIRE = '600';
|
|
const app = (await import('@/app')).default;
|
|
const response = await app.request('/test/cache');
|
|
const parsed = await parser.parseString(await response.text());
|
|
expect(parsed.ttl).toEqual('1');
|
|
});
|
|
|
|
it('RSS TTL (w/ cache)', async () => {
|
|
process.env.CACHE_TYPE = 'memory';
|
|
process.env.CACHE_EXPIRE = '600';
|
|
const app = (await import('@/app')).default;
|
|
const response = await app.request('/test/cache');
|
|
const parsed = await parser.parseString(await response.text());
|
|
expect(parsed.ttl).toEqual('10');
|
|
});
|
|
});
|
|
|
|
describe('cache middleware error handling', () => {
|
|
it('clears control key when downstream throws', async () => {
|
|
process.env.CACHE_TYPE = 'memory';
|
|
const cache = (await import('@/utils/cache')).default;
|
|
const setSpy = vi.spyOn(cache.globalCache, 'set');
|
|
|
|
const { default: cacheMiddleware } = await import('@/middleware/cache');
|
|
|
|
const ctx = new Context(new Request('http://localhost/test'), { env: {}, path: '/test' });
|
|
|
|
await expect(
|
|
cacheMiddleware(ctx, () => {
|
|
throw new Error('boom');
|
|
})
|
|
).rejects.toThrow('boom');
|
|
|
|
expect(setSpy.mock.calls.some(([key, value]) => key.startsWith('rsshub:path-requested:') && value === '0')).toBe(true);
|
|
setSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('cache request variants', () => {
|
|
it('keeps source-order selections separate from date-order selections', async () => {
|
|
vi.stubEnv('CACHE_TYPE', 'memory');
|
|
vi.stubEnv('CACHE_EXPIRE', '300');
|
|
vi.resetModules();
|
|
const { default: middleware } = await import('./cache');
|
|
const { default: cacheModule } = await import('@/utils/cache/index');
|
|
cacheModule.clients.memoryCache?.clear();
|
|
const fetcher = vi.fn((ctx: Context) => {
|
|
if (!ctx.get('data')) {
|
|
const entry = ctx.req.query('sorted') === 'false' ? 'source-first' : 'date-first';
|
|
ctx.set('data', { title: 'Feed', item: [{ title: entry, link: `https://example.com/${entry}` }] });
|
|
}
|
|
return Promise.resolve();
|
|
});
|
|
const request = async (query = '') => {
|
|
const ctx = new Context(new Request(`https://rsshub.example/ordering?limit=1${query}`), { env: {}, path: '/ordering' });
|
|
await middleware(ctx, () => fetcher(ctx));
|
|
return ctx;
|
|
};
|
|
|
|
try {
|
|
const dateOrder = await request();
|
|
const sourceOrder = await request('&sorted=false');
|
|
const explicitDateOrder = await request('&sorted=true');
|
|
const defaultDateOrderHit = await request();
|
|
const dateOrderHit = await request('&sorted=true');
|
|
const sourceOrderHit = await request('&sorted=false');
|
|
|
|
expect(dateOrder.get('data').item[0].title).toBe('date-first');
|
|
expect(sourceOrder.get('data').item[0].title).toBe('source-first');
|
|
expect(explicitDateOrder.get('data').item[0].title).toBe('date-first');
|
|
expect(defaultDateOrderHit.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
|
|
expect(dateOrderHit.get('data').item[0].title).toBe('date-first');
|
|
expect(sourceOrderHit.get('data').item[0].title).toBe('source-first');
|
|
expect(dateOrderHit.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
|
|
expect(sourceOrderHit.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
|
|
expect(sourceOrder.res.headers.get('RSSHub-Cache-Status')).toBeNull();
|
|
expect(explicitDateOrder.res.headers.get('RSSHub-Cache-Status')).toBeNull();
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
});
|
|
|
|
const coordinationContext = () => new Context(new Request('https://rsshub.example/test/cache'), { env: {}, path: '/test/cache' });
|
|
const isControlKey = (key: string) => key.startsWith('rsshub:path-requested:');
|
|
const loadCoordination = async () => {
|
|
// Pin the config values asserted below before the middleware loads the config.
|
|
vi.stubEnv('CACHE_TYPE', 'memory');
|
|
vi.stubEnv('FORMAT', 'rss');
|
|
vi.stubEnv('CACHE_REQUEST_TIMEOUT', '60');
|
|
vi.stubEnv('CACHE_EXPIRE', '300');
|
|
vi.resetModules();
|
|
const { default: middleware } = await import('./cache');
|
|
const { default: cacheModule } = await import('@/utils/cache/index');
|
|
const { globalCache } = cacheModule;
|
|
return {
|
|
middleware,
|
|
cacheModule,
|
|
globalCache,
|
|
cache: {
|
|
get: vi.spyOn(globalCache, 'get'),
|
|
set: vi.spyOn(globalCache, 'set'),
|
|
claim: vi.spyOn(globalCache, 'claim'),
|
|
},
|
|
};
|
|
};
|
|
type Loaded = Awaited<ReturnType<typeof loadCoordination>>;
|
|
|
|
describe('cache coordination capabilities', () => {
|
|
let middleware: Loaded['middleware'];
|
|
let cacheModule: Loaded['cacheModule'];
|
|
let globalCache: Loaded['globalCache'];
|
|
let cache: Loaded['cache'];
|
|
let originalSupportsAtomicClaims: boolean;
|
|
|
|
const feed = { title: 'Feed', link: 'https://example.com', item: [{ title: 'Entry', link: 'https://example.com/entry' }] };
|
|
|
|
beforeAll(async () => {
|
|
({ middleware, cacheModule, globalCache, cache } = await loadCoordination());
|
|
originalSupportsAtomicClaims = globalCache.supportsAtomicClaims;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
cache.set.mockReturnValue(undefined);
|
|
vi.useFakeTimers();
|
|
globalCache.supportsAtomicClaims = false;
|
|
// Model a stale remote control key, including after the prior writer finished.
|
|
cache.get.mockImplementation((key: string) => (isControlKey(key) ? '1' : null));
|
|
cache.claim.mockResolvedValue(false);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
afterAll(() => {
|
|
globalCache.supportsAtomicClaims = originalSupportsAtomicClaims;
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('serves an HTTP/KV feed hit without touching a stale remote lock', async () => {
|
|
cache.get.mockImplementation((key: string) => (isControlKey(key) ? '1' : JSON.stringify(feed)));
|
|
const ctx = coordinationContext();
|
|
|
|
await middleware(ctx, vi.fn());
|
|
|
|
expect(ctx.get('data')).toEqual(feed);
|
|
expect(ctx.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
|
|
expect(cache.get).toHaveBeenCalledTimes(1);
|
|
expect(cache.claim).not.toHaveBeenCalled();
|
|
expect(cache.set).not.toHaveBeenCalled();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('fetches and caches HTTP/KV misses without waiting on or writing control keys', async () => {
|
|
const ctx = coordinationContext();
|
|
const next = vi.fn(() => {
|
|
ctx.set('data', structuredClone(feed));
|
|
return Promise.resolve();
|
|
});
|
|
|
|
await middleware(ctx, next);
|
|
|
|
expect(next).toHaveBeenCalledOnce();
|
|
expect(cache.get.mock.calls.every(([key]) => !isControlKey(key))).toBe(true);
|
|
expect(cache.claim).not.toHaveBeenCalled();
|
|
expect(cache.set).toHaveBeenCalledExactlyOnceWith(expect.stringMatching(/^rsshub:koa-redis-cache:/), expect.any(String), 300);
|
|
expect(JSON.parse(String(cache.set.mock.calls[0][1]))).toMatchObject(feed);
|
|
expect(ctx.get('cacheControlKey')).toBeUndefined();
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('allows concurrent HTTP/KV cold misses even when remote reads remain stale', async () => {
|
|
const contexts = [coordinationContext(), coordinationContext()];
|
|
const fetcher = vi.fn((ctx: Context) => {
|
|
ctx.set('data', structuredClone(feed));
|
|
return Promise.resolve();
|
|
});
|
|
|
|
await Promise.all(contexts.map((ctx) => middleware(ctx, () => fetcher(ctx))));
|
|
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
expect(cache.claim).not.toHaveBeenCalled();
|
|
expect(cache.get.mock.calls.every(([key]) => !isControlKey(key))).toBe(true);
|
|
expect(cache.set.mock.calls.every(([key]) => !isControlKey(key))).toBe(true);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('does not release a remote control key when an HTTP/KV route fails', async () => {
|
|
await expect(middleware(coordinationContext(), () => Promise.reject(new Error('route failed')))).rejects.toThrow('route failed');
|
|
expect(cache.set).not.toHaveBeenCalled();
|
|
expect(cache.claim).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps atomic backends single-flight and serves the waiting request from the completed feed', async () => {
|
|
// Use the real memory backend, which claims atomically.
|
|
globalCache.supportsAtomicClaims = true;
|
|
cacheModule.clients.memoryCache?.clear();
|
|
cache.get.mockReset();
|
|
cache.set.mockReset();
|
|
cache.claim.mockReset();
|
|
const { promise: producerReady, resolve: markReady } = Promise.withResolvers<void>();
|
|
const { promise: releaseProducer, resolve: release } = Promise.withResolvers<void>();
|
|
const first = coordinationContext();
|
|
const second = coordinationContext();
|
|
const fetcher = vi.fn(async (ctx: Context) => {
|
|
if (ctx.get('data')) {
|
|
return;
|
|
}
|
|
markReady();
|
|
await releaseProducer;
|
|
ctx.set('data', structuredClone(feed));
|
|
});
|
|
const producer = middleware(first, () => fetcher(first));
|
|
await producerReady;
|
|
const waiter = middleware(second, () => fetcher(second));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(cache.claim).toHaveBeenCalledTimes(2);
|
|
release();
|
|
await producer;
|
|
await vi.advanceTimersByTimeAsync(6000);
|
|
await waiter;
|
|
|
|
expect(second.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
|
|
expect(second.get('data')).toMatchObject(feed);
|
|
expect(cache.set.mock.calls.filter(([key]) => !isControlKey(key))).toHaveLength(1);
|
|
expect(cache.set.mock.calls.filter(([key, value]) => isControlKey(key) && value === '0')).toHaveLength(1);
|
|
});
|
|
|
|
it.each(['route', 'cache write'])('releases an owned atomic claim when the %s fails', async (failure) => {
|
|
globalCache.supportsAtomicClaims = true;
|
|
cache.claim.mockResolvedValue(true);
|
|
cache.set.mockImplementation((key: string) => {
|
|
if (!isControlKey(key)) {
|
|
throw new Error('cache write failed');
|
|
}
|
|
});
|
|
const ctx = coordinationContext();
|
|
const next = () => {
|
|
if (failure === 'route') {
|
|
return Promise.reject(new Error('route failed'));
|
|
}
|
|
ctx.set('data', structuredClone(feed));
|
|
return Promise.resolve();
|
|
};
|
|
|
|
await expect(middleware(ctx, next)).rejects.toThrow(`${failure} failed`);
|
|
expect(cache.set).toHaveBeenCalledWith(expect.stringMatching(/^rsshub:path-requested:/), '0', 60);
|
|
});
|
|
|
|
it('does not overwrite or release a competing atomic claim during takeover', async () => {
|
|
globalCache.supportsAtomicClaims = true;
|
|
cache.get.mockResolvedValue(null);
|
|
cache.claim.mockResolvedValue(false);
|
|
const next = vi.fn();
|
|
const result = expect(middleware(coordinationContext(), next)).rejects.toThrow('This path is currently fetching');
|
|
await vi.advanceTimersByTimeAsync(6000);
|
|
await result;
|
|
|
|
expect(cache.claim).toHaveBeenCalledTimes(2);
|
|
expect(cache.set).not.toHaveBeenCalled();
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
const candidateEntries = [
|
|
{ title: 'skip', link: 'https://example.com/skip', pubDate: '2026-10-09T12:00:00Z' },
|
|
{ title: 'match older', link: 'https://example.com/older', pubDate: '2026-10-08T12:00:00Z' },
|
|
{ title: 'match newer', link: 'https://example.com/newer', pubDate: '2026-10-09T10:00:00Z' },
|
|
{ title: 'match fourth', link: 'https://example.com/fourth', pubDate: '2026-10-09T09:00:00Z' },
|
|
];
|
|
|
|
describe('filtered candidate cache variants', () => {
|
|
let parameter: typeof ParameterMiddleware;
|
|
let cache: typeof CacheMiddleware;
|
|
let cacheModule: typeof CacheModule;
|
|
let getRouteLimit: typeof RouteLimit;
|
|
|
|
beforeEach(async () => {
|
|
vi.stubEnv('CACHE_TYPE', 'memory');
|
|
vi.stubEnv('CACHE_EXPIRE', '300');
|
|
vi.resetModules();
|
|
({ default: parameter } = await import('./parameter'));
|
|
({ default: cache } = await import('./cache'));
|
|
({ default: cacheModule } = await import('@/utils/cache'));
|
|
({ getRouteLimit } = await import('@/utils/route-limit'));
|
|
cacheModule.clients.memoryCache?.clear();
|
|
});
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
|
|
it.each([false, true])('isolates filtered and unfiltered source pools with filteredFirst=%s', async (filteredFirst) => {
|
|
let fetches = 0;
|
|
const request = async (query: string) => {
|
|
const ctx = new Context(new Request(`https://rsshub.example/candidates?${query}`), { env: {}, path: '/candidates' });
|
|
await parameter(ctx, async () => {
|
|
await cache(ctx, () => {
|
|
if (ctx.get('data')) {
|
|
return Promise.resolve();
|
|
}
|
|
fetches++;
|
|
const candidates = candidateEntries.slice(0, getRouteLimit(ctx, 3));
|
|
ctx.set('data', { title: 'Feed', item: structuredClone(candidates) });
|
|
return Promise.resolve();
|
|
});
|
|
});
|
|
return ctx;
|
|
};
|
|
const filteredQuery = 'limit=1&filter_title=match';
|
|
const limitedQuery = 'limit=1';
|
|
await request(filteredFirst ? filteredQuery : limitedQuery);
|
|
await request(filteredFirst ? limitedQuery : filteredQuery);
|
|
const limitedHit = await request(limitedQuery);
|
|
const filteredHit = await request(filteredQuery);
|
|
const differentFilterHit = await request('limit=1&filter_title=older');
|
|
const expanded = await request('limit=4&filter_title=match');
|
|
const sourceOrder = await request(`${filteredQuery}&sorted=false`);
|
|
const sourceOrderHit = await request(`${filteredQuery}&sorted=false`);
|
|
|
|
expect(limitedHit.get('data').item.map((item) => item.title)).toEqual(['skip']);
|
|
expect(filteredHit.get('data').item.map((item) => item.title)).toEqual(['match newer']);
|
|
expect(differentFilterHit.get('data').item.map((item) => item.title)).toEqual(['match older']);
|
|
expect(expanded.get('data').item).toHaveLength(3);
|
|
expect(sourceOrder.get('data').item.map((item) => item.title)).toEqual(['match older']);
|
|
expect(sourceOrderHit.get('data').item.map((item) => item.title)).toEqual(['match older']);
|
|
for (const ctx of [limitedHit, filteredHit, differentFilterHit, sourceOrderHit]) {
|
|
expect(ctx.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
|
|
}
|
|
expect(fetches).toBe(4);
|
|
});
|
|
|
|
it.each([false, true])('preserves ranked defaults and explicit date ordering with dateFirst=%s', async (dateFirst) => {
|
|
let fetches = 0;
|
|
const rankedEntries = [
|
|
{ title: 'Top ranked', link: 'https://example.com/top', pubDate: '2026-10-07T12:00:00Z' },
|
|
{ title: 'Second ranked', link: 'https://example.com/second', pubDate: '2026-10-08T12:00:00Z' },
|
|
{ title: 'Newest', link: 'https://example.com/newest', pubDate: '2026-10-09T12:00:00Z' },
|
|
];
|
|
const request = async (query = '') => {
|
|
const ctx = new Context(new Request(`https://rsshub.example/ranking?limit=1${query}`), { env: {}, path: '/ranking' });
|
|
await parameter(ctx, async () => {
|
|
await cache(ctx, () => {
|
|
if (ctx.get('data')) {
|
|
return Promise.resolve();
|
|
}
|
|
fetches++;
|
|
const sorted = ctx.req.query('sorted');
|
|
const candidates = sorted !== undefined && sorted !== 'false' ? rankedEntries : rankedEntries.slice(0, getRouteLimit(ctx, 3));
|
|
ctx.set('data', { title: 'Ranking', sortItems: false, item: structuredClone(candidates) });
|
|
return Promise.resolve();
|
|
});
|
|
});
|
|
return ctx;
|
|
};
|
|
|
|
await request(dateFirst ? '&sorted=true' : '');
|
|
await request(dateFirst ? '' : '&sorted=true');
|
|
const rankedHit = await request();
|
|
const datedHit = await request('&sorted=true');
|
|
const truthyHit = await request('&sorted=yes');
|
|
const explicitRank = await request('&sorted=false');
|
|
const explicitRankHit = await request('&sorted=false');
|
|
|
|
expect(rankedHit.get('data').item.map((item) => item.title)).toEqual(['Top ranked']);
|
|
expect(datedHit.get('data').item.map((item) => item.title)).toEqual(['Newest']);
|
|
expect(truthyHit.get('data').item.map((item) => item.title)).toEqual(['Newest']);
|
|
expect(explicitRank.get('data').item.map((item) => item.title)).toEqual(['Top ranked']);
|
|
for (const ctx of [rankedHit, datedHit, truthyHit, explicitRankHit]) {
|
|
expect(ctx.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
|
|
expect(ctx.get('data').sortItems).toBe(false);
|
|
expect(ctx.get('data').item[0].pubDate).toBeTruthy();
|
|
}
|
|
expect(fetches).toBe(3);
|
|
});
|
|
});
|