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>; 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(); const { promise: releaseProducer, resolve: release } = Promise.withResolvers(); 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); }); });