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); }); });