import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { computeDedupeKey, withDedupe } from '../src/service/request/dedupe'; describe('computeDedupeKey', () => { it('returns null for GET method', () => { assert.equal(computeDedupeKey({ method: 'GET', url: '/api/list' }), null); }); it('returns null for HEAD/OPTIONS method', () => { assert.equal(computeDedupeKey({ method: 'HEAD', url: '/api/x' }), null); assert.equal(computeDedupeKey({ method: 'OPTIONS', url: '/api/x' }), null); }); it('returns null when dedupe is explicitly false', () => { assert.equal(computeDedupeKey({ method: 'POST', url: '/api/x', dedupe: false }), null); }); it('returns null when data is FormData', () => { const fd = new FormData(); fd.append('file', new Blob(['x'])); assert.equal(computeDedupeKey({ method: 'POST', url: '/api/upload', data: fd }), null); }); it('returns null when data is Blob', () => { assert.equal(computeDedupeKey({ method: 'POST', url: '/api/blob', data: new Blob(['x']) }), null); }); it('returns a string fingerprint for write methods', () => { const key = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1 } }); assert.equal(typeof key, 'string'); assert.ok((key as string).length > 0); }); it('uppercases method for canonical fingerprint', () => { const a = computeDedupeKey({ method: 'post', url: '/api/x', data: { a: 1 } }); const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1 } }); assert.equal(a, b); }); it('produces the same fingerprint regardless of params key order', () => { const a = computeDedupeKey({ method: 'POST', url: '/api/x', params: { b: 2, a: 1 } }); const b = computeDedupeKey({ method: 'POST', url: '/api/x', params: { a: 1, b: 2 } }); assert.equal(a, b); }); it('produces the same fingerprint regardless of body key order', () => { const a = computeDedupeKey({ method: 'POST', url: '/api/x', data: { b: 2, a: 1 } }); const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1, b: 2 } }); assert.equal(a, b); }); it('produces different fingerprint when body differs', () => { const a = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1 } }); const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 2 } }); assert.notEqual(a, b); }); it('handles undefined body (e.g. DELETE) without throwing', () => { const key = computeDedupeKey({ method: 'DELETE', url: '/api/x/1' }); assert.equal(typeof key, 'string'); }); it('handles primitive body (string)', () => { const key = computeDedupeKey({ method: 'POST', url: '/api/x', data: 'raw=text' }); assert.equal(typeof key, 'string'); }); it('handles array body without key sorting (arrays keep order)', () => { const a = computeDedupeKey({ method: 'POST', url: '/api/x', data: [1, 2, 3] }); const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: [3, 2, 1] }); assert.notEqual(a, b); }); }); describe('withDedupe', () => { const buildPending = () => { let resolve!: (value: unknown) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; }; it('returns the cached Promise for concurrent identical write requests', async () => { let callCount = 0; const pending = buildPending(); const inner = (_config: unknown) => { callCount += 1; return pending.promise; }; const wrapped = withDedupe(inner); const config = { method: 'POST', url: '/api/x', data: { a: 1 } }; const p1 = wrapped(config); const p2 = wrapped(config); assert.equal(callCount, 1); assert.equal(p1, p2); pending.resolve({ data: 'ok', error: null }); const [r1, r2] = await Promise.all([p1, p2]); assert.deepEqual(r1, { data: 'ok', error: null }); assert.equal(r1, r2); }); it('does not dedupe different fingerprints', async () => { let callCount = 0; const inner = async (config: any) => { callCount += 1; return { data: config.data, error: null }; }; const wrapped = withDedupe(inner); await wrapped({ method: 'POST', url: '/api/x', data: { a: 1 } }); await wrapped({ method: 'POST', url: '/api/x', data: { a: 2 } }); assert.equal(callCount, 2); }); it('does not dedupe GET requests', async () => { let callCount = 0; const pending = buildPending(); const inner = (_config: unknown) => { callCount += 1; return pending.promise; }; const wrapped = withDedupe(inner); const config = { method: 'GET', url: '/api/list', params: { page: 1 } }; wrapped(config); wrapped(config); assert.equal(callCount, 2); pending.resolve({ data: [], error: null }); }); it('does not dedupe FormData uploads', async () => { let callCount = 0; const inner = async (_config: unknown) => { callCount += 1; return { data: 'ok', error: null }; }; const wrapped = withDedupe(inner); const fd = new FormData(); fd.append('file', new Blob(['x'])); await wrapped({ method: 'POST', url: '/api/upload', data: fd }); await wrapped({ method: 'POST', url: '/api/upload', data: fd }); assert.equal(callCount, 2); }); it('cleans pending entry after success, so next identical request hits the network again', async () => { let callCount = 0; const inner = async (_config: unknown) => { callCount += 1; return { data: 'ok', error: null }; }; const wrapped = withDedupe(inner); const config = { method: 'POST', url: '/api/x', data: { a: 1 } }; await wrapped(config); await wrapped(config); assert.equal(callCount, 2); }); it('cleans pending entry after failure (FlatRequest never throws, but raw rejection also clears)', async () => { let callCount = 0; const inner = async (_config: unknown) => { callCount += 1; throw new Error('boom'); }; const wrapped = withDedupe(inner); const config = { method: 'POST', url: '/api/x', data: { a: 1 } }; await assert.rejects(wrapped(config), /boom/); await assert.rejects(wrapped(config), /boom/); assert.equal(callCount, 2); }); it('treats entries past TTL as stale and re-enters', async () => { let callCount = 0; const pending = buildPending(); const inner = (_config: unknown) => { callCount += 1; return pending.promise; }; let fakeNow = 1_000_000; const wrapped = withDedupe(inner, { ttlMs: 30_000, now: () => fakeNow }); const config = { method: 'POST', url: '/api/x', data: { a: 1 } }; wrapped(config); assert.equal(callCount, 1); fakeNow += 31_000; wrapped(config); assert.equal(callCount, 2); pending.resolve({ data: 'ok', error: null }); }); it('preserves extension properties on the wrapped function (state / cancelAllRequest)', () => { const inner = (() => Promise.resolve('x')) as unknown as ((c: unknown) => Promise) & { state: { foo: number }; cancelAllRequest: () => string; }; inner.state = { foo: 1 }; inner.cancelAllRequest = () => 'cancelled'; const wrapped = withDedupe(inner); assert.equal(wrapped.state.foo, 1); assert.equal(wrapped.cancelAllRequest(), 'cancelled'); wrapped.state.foo = 2; assert.equal(inner.state.foo, 2); }); });