-
Notifications
You must be signed in to change notification settings - Fork 910
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor(experimental): extract the request cache logic into a base i…
…mplementation
- Loading branch information
1 parent
a638dfd
commit 7b7d498
Showing
4 changed files
with
454 additions
and
268 deletions.
There are no files selected for viewing
283 changes: 283 additions & 0 deletions
283
packages/library/src/__tests__/cached-abortable-iterable-test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,283 @@ | ||
import { getCachedAbortableIterableFactory } from '../cached-abortable-iterable'; | ||
|
||
describe('getCachedAbortableIterableFactory', () => { | ||
let asyncGenerator: jest.Mock<AsyncGenerator<unknown, void>>; | ||
let factory: (...args: unknown[]) => Promise<AsyncIterable<unknown>>; | ||
let getAbortSignalFromInputArgs: jest.Mock; | ||
let getCacheKeyFromInputArgs: jest.Mock; | ||
let getCacheEntryMissingError: jest.Mock; | ||
let onCacheHit: jest.Mock; | ||
let onCreateIterable: jest.Mock; | ||
beforeEach(() => { | ||
jest.useFakeTimers(); | ||
asyncGenerator = jest.fn().mockImplementation(async function* () { | ||
yield await new Promise(() => { | ||
/* never resolve */ | ||
}); | ||
}); | ||
getAbortSignalFromInputArgs = jest.fn().mockImplementation(() => new AbortController().signal); | ||
getCacheKeyFromInputArgs = jest.fn().mockReturnValue('cache-key'); | ||
getCacheEntryMissingError = jest.fn(); | ||
onCacheHit = jest.fn(); | ||
onCreateIterable = jest.fn().mockResolvedValue({ | ||
[Symbol.asyncIterator]: asyncGenerator, | ||
}); | ||
factory = getCachedAbortableIterableFactory({ | ||
getAbortSignalFromInputArgs, | ||
getCacheEntryMissingError, | ||
getCacheKeyFromInputArgs, | ||
onCacheHit, | ||
onCreateIterable, | ||
}); | ||
}); | ||
it('reuses the same iterable for multiple invocations in the same runloop', async () => { | ||
expect.assertions(1); | ||
await Promise.all([factory('A'), factory('B')]); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(1); | ||
}); | ||
it('reuses the same iterable for multiple invocations in different runloops', async () => { | ||
expect.assertions(1); | ||
await factory('A'); | ||
await factory('B'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(1); | ||
}); | ||
it('reuses the same iterable so long as there is at least one non-aborted consumer', async () => { | ||
expect.assertions(1); | ||
const abortControllerA = new AbortController(); | ||
getAbortSignalFromInputArgs.mockReturnValue(abortControllerA.signal); | ||
await factory('A'); | ||
await factory('B'); | ||
abortControllerA.abort(); | ||
await jest.runAllTimersAsync(); | ||
await factory('C'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(1); | ||
}); | ||
it('reuses the same iterable even if a single subscription was aborted as many times as there are subscriptions', async () => { | ||
expect.assertions(1); | ||
const abortControllerA = new AbortController(); | ||
getAbortSignalFromInputArgs.mockReturnValueOnce(abortControllerA.signal); | ||
await factory('A'); | ||
await factory('B'); | ||
abortControllerA.abort(); | ||
abortControllerA.abort(); | ||
await jest.runAllTimersAsync(); | ||
await factory('C'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(1); | ||
}); | ||
it('reuses the same iterable so long as there is at least one non-aborted consumer at the end of the runloop, even if all of the existing ones are aborted', async () => { | ||
expect.assertions(1); | ||
const abortControllerA = new AbortController(); | ||
const abortControllerB = new AbortController(); | ||
getAbortSignalFromInputArgs.mockReturnValueOnce(abortControllerA.signal); | ||
await factory('A'); | ||
getAbortSignalFromInputArgs.mockReturnValueOnce(abortControllerB.signal); | ||
await factory('B'); | ||
abortControllerA.abort(); | ||
abortControllerB.abort(); | ||
await factory('C'); | ||
await jest.runAllTimersAsync(); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(1); | ||
}); | ||
it('creates a new connection when all of the prior subscriptions have been aborted', async () => { | ||
expect.assertions(1); | ||
const abortControllerA = new AbortController(); | ||
const abortControllerB = new AbortController(); | ||
getAbortSignalFromInputArgs.mockReturnValueOnce(abortControllerA.signal); | ||
await factory('A'); | ||
getAbortSignalFromInputArgs.mockReturnValueOnce(abortControllerB.signal); | ||
await factory('B'); | ||
abortControllerA.abort(); | ||
abortControllerB.abort(); | ||
// FIXME: Prefer async version of this timer runner. See https://github.com/jestjs/jest/issues/14549 | ||
jest.runAllTimers(); | ||
await factory('C'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(2); | ||
}); | ||
it('creates a new connection for a message given that the prior one failed synchronously', async () => { | ||
expect.assertions(2); | ||
// First time fails synchronously. | ||
onCreateIterable.mockImplementationOnce(() => { | ||
throw new Error('o no'); | ||
}); | ||
try { | ||
await factory('A'); | ||
} catch { | ||
/* empty */ | ||
} | ||
expect(onCreateIterable).toHaveBeenCalledTimes(1); | ||
// Second time succeeds. | ||
await factory('A'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(2); | ||
}); | ||
it('creates a new connection for a message given that the prior one failed asynchronously', async () => { | ||
expect.assertions(2); | ||
// First time fails asynchronously. | ||
onCreateIterable.mockRejectedValueOnce(new Error('o no')); | ||
try { | ||
await factory('A'); | ||
} catch { | ||
/* empty */ | ||
} | ||
expect(onCreateIterable).toHaveBeenCalledTimes(1); | ||
// Second time succeeds. | ||
await factory('A'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(2); | ||
}); | ||
it('creates a new connection for a message given that the prior connection threw', async () => { | ||
expect.assertions(2); | ||
let killConnection; | ||
asyncGenerator.mockImplementationOnce(async function* () { | ||
yield await new Promise((_, reject) => { | ||
killConnection = reject; | ||
}); | ||
}); | ||
await factory('A'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(1); | ||
// FIXME: https://github.com/microsoft/TypeScript/issues/11498 | ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
// @ts-ignore | ||
killConnection(); | ||
await jest.runAllTimersAsync(); | ||
await factory('A'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(2); | ||
}); | ||
it('creates a new connection for a message given that prior connection returned', async () => { | ||
expect.assertions(1); | ||
let returnFromConnection; | ||
asyncGenerator.mockImplementationOnce(async function* () { | ||
try { | ||
yield await new Promise((_, reject) => { | ||
returnFromConnection = reject; | ||
}); | ||
} catch { | ||
return; | ||
} | ||
}); | ||
await factory('A'); | ||
// FIXME: https://github.com/microsoft/TypeScript/issues/11498 | ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
// @ts-ignore | ||
returnFromConnection(); | ||
await jest.runAllTimersAsync(); | ||
await factory('A'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(2); | ||
}); | ||
it('calls `onCreateIterable` with the input args when no cached iterable is found', async () => { | ||
expect.assertions(1); | ||
await factory('A'); | ||
expect(onCreateIterable).toHaveBeenCalledWith(expect.any(AbortSignal), 'A'); | ||
}); | ||
it('calls `onCreateIterable` with an `AbortSignal` different than the one passed in', async () => { | ||
expect.assertions(1); | ||
const signal = new AbortController().signal; | ||
getAbortSignalFromInputArgs.mockReturnValue(signal); | ||
await factory('A'); | ||
expect(onCreateIterable.mock.lastCall[0]).not.toBe(signal); | ||
}); | ||
it('does not call `onCacheHit` in the same runloop until the cached iterable is resolved', async () => { | ||
expect.assertions(2); | ||
let resolve; | ||
onCreateIterable.mockImplementation( | ||
() => | ||
new Promise(r => { | ||
resolve = r; | ||
}) | ||
); | ||
Promise.all([factory('A'), factory('B')]); | ||
expect(onCacheHit).not.toHaveBeenCalled(); | ||
await jest.runAllTimersAsync(); | ||
const iterable = asyncGenerator(); | ||
// FIXME: https://github.com/microsoft/TypeScript/issues/11498 | ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
// @ts-ignore | ||
resolve(iterable); | ||
await jest.runAllTimersAsync(); | ||
expect(onCacheHit).toHaveBeenCalledWith(iterable, 'B'); | ||
}); | ||
it('calls `onCacheHit` in the same runloop when the cached iterable is already resolved', async () => { | ||
expect.assertions(1); | ||
const iterable = asyncGenerator(); | ||
onCreateIterable.mockReturnValue(iterable); | ||
Promise.all([factory('A'), factory('B')]); | ||
expect(onCacheHit).toHaveBeenCalledWith(iterable, 'B'); | ||
}); | ||
it('does not call `onCacheHit` in different runloops until the cached iterable is resolved', async () => { | ||
expect.assertions(2); | ||
let resolve; | ||
onCreateIterable.mockImplementation( | ||
() => | ||
new Promise(r => { | ||
resolve = r; | ||
}) | ||
); | ||
factory('A'); | ||
await jest.runAllTimersAsync(); | ||
factory('B'); | ||
await jest.runAllTimersAsync(); | ||
expect(onCacheHit).not.toHaveBeenCalled(); | ||
const iterable = asyncGenerator(); | ||
// FIXME: https://github.com/microsoft/TypeScript/issues/11498 | ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
// @ts-ignore | ||
resolve(iterable); | ||
await jest.runAllTimersAsync(); | ||
expect(onCacheHit).toHaveBeenCalledWith(iterable, 'B'); | ||
}); | ||
it('calls `onCacheHit` in different runloops when the cached iterable is already resolved', async () => { | ||
expect.assertions(1); | ||
const iterable = asyncGenerator(); | ||
onCreateIterable.mockReturnValue(iterable); | ||
await factory('A'); | ||
await factory('B'); | ||
expect(onCacheHit).toHaveBeenCalledWith(iterable, 'B'); | ||
}); | ||
describe('given payloads that produce the same cache key', () => { | ||
beforeEach(() => { | ||
getCacheKeyFromInputArgs.mockReturnValue('cache-key'); | ||
}); | ||
it('reuses the same iterable for all payloads in the same runloop', async () => { | ||
expect.assertions(1); | ||
await Promise.all([factory('A'), factory('B')]); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(1); | ||
}); | ||
it('reuses the same iterable for all payloads in different runloops', async () => { | ||
expect.assertions(1); | ||
await factory('A'); | ||
await factory('B'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(1); | ||
}); | ||
}); | ||
describe('given payloads that produce different cache keys', () => { | ||
beforeEach(() => { | ||
let shardKey = 0; | ||
getCacheKeyFromInputArgs.mockImplementation(() => `${++shardKey}`); | ||
}); | ||
it('creates a connection for each payload in the same runloop', async () => { | ||
expect.assertions(1); | ||
await Promise.all([factory('A'), factory('B')]); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(2); | ||
}); | ||
it('creates a connection for each payload in different runloops', async () => { | ||
expect.assertions(1); | ||
await factory('A'); | ||
await factory('B'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(2); | ||
}); | ||
}); | ||
describe('given payloads that produce the cache key `undefined`', () => { | ||
beforeEach(() => { | ||
getCacheKeyFromInputArgs.mockReturnValue(undefined); | ||
}); | ||
it('creates a connection for each payload in the same runloop', async () => { | ||
expect.assertions(1); | ||
await Promise.all([factory('A'), factory('B')]); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(2); | ||
}); | ||
it('creates a connection for each payload in different runloops', async () => { | ||
expect.assertions(1); | ||
await factory('A'); | ||
await factory('B'); | ||
expect(onCreateIterable).toHaveBeenCalledTimes(2); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.