mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-10-13 16:57:27 +08:00
162 lines
6.8 KiB
TypeScript
162 lines
6.8 KiB
TypeScript
import { Context } from 'hono';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import middleware from '../lib/middleware/cache';
|
|
|
|
const cache = vi.hoisted(() => ({
|
|
supportsAtomicClaims: false,
|
|
get: vi.fn(),
|
|
set: vi.fn(),
|
|
claim: vi.fn(),
|
|
}));
|
|
vi.mock('../lib/utils/cache/index', () => ({ default: { status: { available: true }, globalCache: cache } }));
|
|
vi.mock('../lib/config', () => ({ config: { format: 'rss', cache: { requestTimeout: 60, routeExpire: 300 } } }));
|
|
|
|
const context = () => new Context(new Request('https://rsshub.example/test/cache'), { env: {}, path: '/test/cache' });
|
|
const feed = { title: 'Feed', link: 'https://example.com', item: [{ title: 'Entry', link: 'https://example.com/entry' }] };
|
|
const isControl = (key: string) => key.startsWith('rsshub:path-requested:');
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.useFakeTimers();
|
|
cache.supportsAtomicClaims = false;
|
|
// Model a stale remote control key, including after the prior writer finished.
|
|
cache.get.mockImplementation((key: string) => (isControl(key) ? '1' : null));
|
|
cache.claim.mockResolvedValue(false);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('cache coordination capabilities', () => {
|
|
it('serves an HTTP/KV feed hit without touching a stale remote lock', async () => {
|
|
cache.get.mockImplementation((key: string) => (isControl(key) ? '1' : JSON.stringify(feed)));
|
|
const ctx = context();
|
|
|
|
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 = context();
|
|
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]) => !isControl(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(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 = [context(), context()];
|
|
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]) => !isControl(key))).toBe(true);
|
|
expect(cache.set.mock.calls.every(([key]) => !isControl(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(context(), () => 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 () => {
|
|
cache.supportsAtomicClaims = true;
|
|
const entries = new Map<string, string>();
|
|
cache.get.mockImplementation((key: string) => entries.get(key));
|
|
cache.set.mockImplementation((key: string, value: string) => entries.set(key, value));
|
|
cache.claim.mockImplementation((key: string) => {
|
|
if (entries.get(key) === '1') {
|
|
return false;
|
|
}
|
|
entries.set(key, '1');
|
|
return true;
|
|
});
|
|
const { promise: producerReady, resolve: markReady } = Promise.withResolvers<void>();
|
|
const { promise: releaseProducer, resolve: release } = Promise.withResolvers<void>();
|
|
const first = context();
|
|
const second = context();
|
|
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]) => !isControl(key))).toHaveLength(1);
|
|
expect(cache.set.mock.calls.filter(([key, value]) => isControl(key) && value === '0')).toHaveLength(1);
|
|
});
|
|
|
|
it.each(['route', 'cache write'])('releases an owned atomic claim when the %s fails', async (failure) => {
|
|
cache.supportsAtomicClaims = true;
|
|
cache.claim.mockResolvedValue(true);
|
|
cache.set.mockImplementation((key: string) => {
|
|
if (!isControl(key)) {
|
|
throw new Error('cache write failed');
|
|
}
|
|
});
|
|
const ctx = context();
|
|
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 () => {
|
|
cache.supportsAtomicClaims = true;
|
|
cache.get.mockResolvedValue(null);
|
|
cache.claim.mockResolvedValue(false);
|
|
const next = vi.fn();
|
|
const result = expect(middleware(context(), 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();
|
|
});
|
|
});
|