109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
|
|
import { describe, test, expect } from 'bun:test';
|
||
|
|
import { IdempotencyCache, IdempotencyConflictError } from '../../src/index.js';
|
||
|
|
|
||
|
|
describe('IdempotencyCache.begin', () => {
|
||
|
|
test('first call returns fresh', () => {
|
||
|
|
const cache = new IdempotencyCache();
|
||
|
|
const result = cache.begin('alice', 'key1', { path: '/foo' });
|
||
|
|
expect(result.status).toBe('fresh');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('replay returns cached response', () => {
|
||
|
|
const cache = new IdempotencyCache();
|
||
|
|
const a = cache.begin('alice', 'key1', { path: '/foo' });
|
||
|
|
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||
|
|
a.commit({ ok: true });
|
||
|
|
|
||
|
|
const b = cache.begin('alice', 'key1', { path: '/foo' });
|
||
|
|
expect(b.status).toBe('replay');
|
||
|
|
if (b.status === 'replay') {
|
||
|
|
expect(b.response).toEqual({ ok: true });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('argsHash mismatch throws IdempotencyConflictError', () => {
|
||
|
|
const cache = new IdempotencyCache();
|
||
|
|
const a = cache.begin('alice', 'key1', { path: '/foo' });
|
||
|
|
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||
|
|
a.commit({ ok: true });
|
||
|
|
|
||
|
|
expect(() =>
|
||
|
|
cache.begin('alice', 'key1', { path: '/different' }),
|
||
|
|
).toThrow(IdempotencyConflictError);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('inflight retry returns wait-promise', async () => {
|
||
|
|
const cache = new IdempotencyCache();
|
||
|
|
const a = cache.begin('alice', 'key1', { x: 1 });
|
||
|
|
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||
|
|
|
||
|
|
const b = cache.begin('alice', 'key1', { x: 1 });
|
||
|
|
expect(b.status).toBe('wait');
|
||
|
|
|
||
|
|
if (b.status === 'wait') {
|
||
|
|
a.commit({ result: 42 });
|
||
|
|
const v = await b.promise;
|
||
|
|
expect(v).toEqual({ result: 42 });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('different senders are isolated', () => {
|
||
|
|
const cache = new IdempotencyCache();
|
||
|
|
const a = cache.begin('alice', 'k', { x: 1 });
|
||
|
|
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||
|
|
a.commit({ side: 'alice' });
|
||
|
|
|
||
|
|
const b = cache.begin('bob', 'k', { x: 1 });
|
||
|
|
expect(b.status).toBe('fresh');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('abandon removes the entry so retries proceed fresh', () => {
|
||
|
|
const cache = new IdempotencyCache();
|
||
|
|
const a = cache.begin('alice', 'key', { p: 1 });
|
||
|
|
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||
|
|
a.abandon();
|
||
|
|
|
||
|
|
const b = cache.begin('alice', 'key', { p: 1 });
|
||
|
|
expect(b.status).toBe('fresh');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('IdempotencyCache TTL + LRU', () => {
|
||
|
|
test('expired entries are evicted on next access', async () => {
|
||
|
|
const cache = new IdempotencyCache({ ttlMs: 5 });
|
||
|
|
const a = cache.begin('alice', 'k', { x: 1 });
|
||
|
|
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||
|
|
a.commit('done');
|
||
|
|
|
||
|
|
await new Promise((r) => setTimeout(r, 15));
|
||
|
|
|
||
|
|
const b = cache.begin('alice', 'k', { x: 1 });
|
||
|
|
expect(b.status).toBe('fresh'); // expired → fresh again
|
||
|
|
});
|
||
|
|
|
||
|
|
test('LRU caps per-sender entries', () => {
|
||
|
|
const cache = new IdempotencyCache({ maxEntriesPerSender: 3 });
|
||
|
|
for (let i = 0; i < 5; i++) {
|
||
|
|
const r = cache.begin('alice', `key${i}`, { i });
|
||
|
|
if (r.status === 'fresh') r.commit(i);
|
||
|
|
}
|
||
|
|
expect(cache.size()).toBe(3); // first two evicted
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('IdempotencyCache.prune', () => {
|
||
|
|
test('removes only TTL-expired entries', async () => {
|
||
|
|
// Two senders so begin() on one doesn't auto-evict the other's expired entry.
|
||
|
|
const cache = new IdempotencyCache({ ttlMs: 5 });
|
||
|
|
const a = cache.begin('alice', 'old', { x: 1 });
|
||
|
|
if (a.status === 'fresh') a.commit(1);
|
||
|
|
await new Promise((r) => setTimeout(r, 15));
|
||
|
|
const b = cache.begin('bob', 'new', { x: 2 });
|
||
|
|
if (b.status === 'fresh') b.commit(2);
|
||
|
|
|
||
|
|
const removed = cache.prune();
|
||
|
|
expect(removed).toBe(1); // alice/old expired; bob/new fresh
|
||
|
|
expect(cache.size()).toBe(1);
|
||
|
|
});
|
||
|
|
});
|