Files
RSSHub/lib/utils/cache/index.worker.test.ts
DIYgod 17b9b9ff5f fix(cache): clamp Workers KV expirationTtl to its 60s minimum (#23407)
Cloudflare Workers KV rejects put() calls whose expirationTtl is below
60 seconds. Some callers use shorter TTLs that work on the memory and
Redis backends, e.g. the 1-second lock release in the Twitter web API
utils and the data258 route, so on Workers those calls throw
"Invalid expiration_ttl of 1".

Clamp the TTL passed to KV in the kv cache module and in the worker
globalCache.set path. The requested TTL is still stored in the cacheTtl
sidecar key.

Based on #23400 and #23403 by @EngDawood.

Co-authored-by: Dawood Saleh <52585641+EngDawood@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:48:18 +08:00

102 lines
4.4 KiB
TypeScript

import { env } from 'cloudflare:workers';
import { describe, expect, it, vi } from 'vitest';
import cache, { setKVNamespace } from '@/utils/cache/index.worker';
import kv from '@/utils/cache/kv';
describe('worker cache before KV binding', () => {
it('is unavailable and always calls the value function', async () => {
expect(cache.status.available).toBe(false);
const getValue = vi.fn().mockResolvedValue({ fresh: 1 });
const first = await cache.tryGet('worker:fallback', getValue);
const second = await cache.tryGet('worker:fallback', getValue);
expect(first).toEqual({ fresh: 1 });
expect(second).toEqual({ fresh: 1 });
expect(getValue).toHaveBeenCalledTimes(2);
expect(await cache.globalCache.get('worker:fallback')).toBeNull();
expect(await cache.globalCache.has('worker:fallback')).toBe(false);
});
});
// The binding is module-global state, so these run after the unavailable tests above
describe('worker cache with KV binding', () => {
it('becomes available once the KV namespace is bound', () => {
setKVNamespace(env.CACHE);
expect(cache.status.available).toBe(true);
expect(cache.globalCache.supportsAtomicClaims).toBe(false);
});
it('round-trips strings and serializes objects', async () => {
await kv.set('worker:string', 'value');
expect(await kv.get('worker:string')).toBe('value');
await kv.set('worker:object', { a: 1 });
expect(await kv.get('worker:object')).toBe('{"a":1}');
expect(await kv.has('worker:object')).toBe(true);
expect(await kv.has('worker:missing')).toBe(false);
});
it('tryGet caches the computed value and parses JSON on the next hit', async () => {
const getValue = vi.fn().mockResolvedValue({ cached: true });
const miss = await cache.tryGet('worker:tryget', getValue);
expect(miss).toEqual({ cached: true });
expect(await kv.has('worker:tryget')).toBe(true);
const hit = await cache.tryGet('worker:tryget', getValue);
expect(hit).toEqual({ cached: true });
expect(getValue).toHaveBeenCalledTimes(1);
});
it('stores a ttl sidecar key for non-default expiry', async () => {
await kv.set('worker:custom-ttl', 'v', 120);
expect(await env.CACHE.get('rsshub:cacheTtl:worker:custom-ttl')).toBe('120');
await kv.set('worker:default-ttl', 'v');
expect(await env.CACHE.get('rsshub:cacheTtl:worker:default-ttl')).toBeNull();
});
it('accepts expiry below the KV minimum of 60 seconds', async () => {
await kv.set('worker:short-ttl', 'v', 1);
expect(await kv.get('worker:short-ttl')).toBe('v');
expect(await env.CACHE.get('rsshub:cacheTtl:worker:short-ttl')).toBe('1');
await cache.globalCache.set('worker:global-short-ttl', 'v', 1);
expect(await cache.globalCache.get('worker:global-short-ttl')).toBe('v');
});
it('rejects keys using the reserved ttl prefix', async () => {
await expect(kv.get('rsshub:cacheTtl:foo')).rejects.toThrow('reserved');
});
it('claims a key only once', async () => {
expect(await cache.globalCache.claim('worker:claim', 60)).toBe(true);
expect(await cache.globalCache.claim('worker:claim', 60)).toBe(false);
});
it('exposes get/set through globalCache', async () => {
await cache.globalCache.set('worker:global', { b: 2 }, 60);
expect(await cache.globalCache.get('worker:global')).toBe('{"b":2}');
expect(await env.CACHE.get('rsshub:cacheTtl:worker:global')).toBeNull();
});
it('reads raw global keys without applying the content cache sidecar rules', async () => {
await env.CACHE.put('rsshub:cacheTtl:worker:global-raw', '120');
expect(await cache.globalCache.get('rsshub:cacheTtl:worker:global-raw')).toBe('120');
});
it('checks global cache existence without treating empty values as missing', async () => {
expect(await cache.globalCache.has('worker:global-missing')).toBe(false);
expect(await cache.globalCache.has('')).toBe(false);
await cache.globalCache.set('worker:global-existing', { lastBuildDate: 'Sat, 5 Sep 2026 00:00:00 GMT' }, 60);
expect(await cache.globalCache.has('worker:global-existing')).toBe(true);
await cache.globalCache.set('worker:global-empty', '', 60);
expect(await cache.globalCache.has('worker:global-empty')).toBe(true);
});
});