223 lines
7.3 KiB
TypeScript
223 lines
7.3 KiB
TypeScript
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<string>) & {
|
|
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);
|
|
});
|
|
});
|