diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index f72bbb2..2ffb0b4 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -21,6 +21,6 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm ci - - run: npm run check + - run: npm run check:code - run: npm test - run: npm run build --if-present diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b55d5..78a9cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## v2.0.0 + +### Added + +- :warning: **[breaking-change]** Add an identifier for each function registration. When using the same identifier, **the new registered function(s) replace(s) the previous one(s)**. + +- :warning: **[breaking-change]** Add an identifier for predicates functions to mimic to the same behavior as functions registrations. Several predicate functions can now be passed in a single `if()` with a defined identifier. In the same way, if another `if()` predicate is set with the same identifier, **it will override the previous one**. + +- Add `deleteFunctionsWhenJobsStarted` option allowing the deletion of registered functions when the jobs are started. + +- Add `debug.logFunctionRegistrations` and `debug.logFunctionExecutions` options allowing functions registrations and executions logs. Don't forget to set `DEBUG=AsyncProcess:*` as an environment variable or in your browser's local storage. + +- Keep errors thrown by jobs as is. + + Example: If the job is a fetch query and if the response is a 404, the `err` object will be a Response object. + +- AsyncProcess instances now accept two more generics, results (`R`) and an error (`E`), allowing to enforce typings of jobs results and jobs exceptions. + ## v1.2.0 ### Added diff --git a/README.md b/README.md index 748f3e1..91d1f21 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ AsyncProcess.instance('initUsersPage', ['optionalIdentifier']) .onSuccess(hideSpinner) // call the `showError` function when the process is done and if it fails .onError(showError) - // start the async process + // start jobs .start() ``` @@ -71,9 +71,9 @@ It's important to note that nothing is happening unless the `start` function is In order to retrieve the same `AsyncProcess` declarations accross your application, `AsyncProcess` is an unique singleton that saves all instances. -Each instance are referenced via an identifer and optional sub identifiers. +Each instance are referenced via an identifer and optional sub identifiers. There is no inheritance between AsyncProcess instances meaning that an instance with a same identifier but different sub-identifiers are two different instances. -The first identifier can be typed, typically by using an union of different string values, allowing to retrieve your instances in a safe way. Optional sub identifiers can be used for uuid or any arguments that identify your async process more precisely. +The first identifier can be typed, typically by using an union of different string values, allowing to retrieve your instances in a safe way. Optional sub identifiers are generally used by [composite functions](#composition) (`with(...)`) for internal AsyncProcess instances. ### Typed identifiers diff --git a/package-lock.json b/package-lock.json index 63e85f9..9ff3a00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@productive-codebases/async-process", - "version": "1.2.0", + "version": "2.0.0-beta.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@productive-codebases/async-process", - "version": "1.2.0", + "version": "2.0.0-beta.1", "license": "MIT", "devDependencies": { "@types/jest": "^29.2.5", diff --git a/package.json b/package.json index 26dc157..912b3f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@productive-codebases/async-process", - "version": "1.2.0", + "version": "2.0.0-beta.1", "description": "Declare, configure and start asynchronous processes with ease.", "author": "Alexis MINEAUD", "license": "MIT", @@ -12,10 +12,10 @@ "types": "dist/types/index.d.ts", "scripts": { "build": "rm -rf dist && mkdir dist && tsc --project tsconfig.build.json", - "check": "tsc --noEmit", + "check:code": "tsc --noEmit", "lint": "eslint .", - "prepublishOnly": "npm run check && npm run lint && npm t && npm run build", - "test": "jest" + "prepublishOnly": "npm run check:code && npm run lint && npm t && npm run build", + "test": "DEBUG_COLORS=0 DEBUG_HIDE_DATE=true jest" }, "devDependencies": { "@types/jest": "^29.2.5", diff --git a/src/AsyncProcess/composers/__tests__/withLogs.test.ts b/src/AsyncProcess/composers/__tests__/withLogs.test.ts index 4d0fb09..2664eb7 100644 --- a/src/AsyncProcess/composers/__tests__/withLogs.test.ts +++ b/src/AsyncProcess/composers/__tests__/withLogs.test.ts @@ -28,7 +28,7 @@ describe('Composers', () => { const logger = jest.fn() await getAsyncProcessTestInstance('loadFoo') - .do(() => doSomething()) + .do(doSomething) .compose(withLogs(logger)) .start() @@ -65,7 +65,7 @@ describe('Composers', () => { const logger3 = jest.fn() await getAsyncProcessTestInstance('loadFoo') - .do(() => doSomething()) + .do(doSomething) .compose(withLogs(logger1)) .compose(withLogs(logger2, 'secondLogger')) .compose(withLogs(logger3, 'thirdLogger')) diff --git a/src/AsyncProcess/composers/withLogs.ts b/src/AsyncProcess/composers/withLogs.ts index 917d1bc..53451dc 100644 --- a/src/AsyncProcess/composers/withLogs.ts +++ b/src/AsyncProcess/composers/withLogs.ts @@ -36,12 +36,12 @@ export function withLogs( ]) .onStart(() => { logger('Start %s', asyncProcess.identifier) - }) + }, identifier) .onSuccess(() => { logger('Success %s', asyncProcess.identifier) - }) + }, identifier) .onError(err => { logger('Error %s: %o', asyncProcess.identifier, err) - }) + }, identifier) } } diff --git a/src/AsyncProcess/index.ts b/src/AsyncProcess/index.ts index 28299b7..39c4d2c 100644 --- a/src/AsyncProcess/index.ts +++ b/src/AsyncProcess/index.ts @@ -1,12 +1,20 @@ -import { ensureArray, Maybe, MetaData } from '@productive-codebases/toolbox' import { - AsyncErrorFn, + deepMerge, + ensureArray, + Maybe, + MetaData +} from '@productive-codebases/toolbox' +import { LoggerLevel } from '@productive-codebases/toolbox/dist/types/libs/logger/types' +import { logger, LoggerNamespace } from '../logger' +import { AsyncErrorFns, - AsyncFn, - AsyncFns, AsyncProcessIdentifiers, + FnOrAsyncErrorFn, + FnOrAsyncFn, IAsyncProcessFns, - PredicateFn + IAsyncProcessOptions, + Jobs, + PredicateFns } from '../types' /** @@ -14,21 +22,29 @@ import { * and executes functions according to the successes / errors. */ -export class AsyncProcess { +export class AsyncProcess { public metadata = new MetaData() private _identifiers: AsyncProcessIdentifiers - private _error: Maybe = null - - private _fns: IAsyncProcessFns = { - asyncFns: new Set(), - onStartFns: new Set(), - onSuccessFns: new Set(), - onErrorFns: new Set() + private _options: IAsyncProcessOptions = { + /** + * When set to true, registered functions are deleted after AsyncProcess has been started. + * Useful when reusing a same instance of AsyncProcess to not have functions registered several times. + */ + deleteFunctionsWhenJobsStarted: false } - private _predicateFns: Set> = new Set() + private _error: Maybe = null + private _result: Maybe = null + + private _fns: IAsyncProcessFns = { + jobs: new Map(), + onStartFns: new Map(), + onSuccessFns: new Map(), + onErrorFns: new Map(), + predicateFns: new Map() + } /** * Static @@ -58,51 +74,69 @@ export class AsyncProcess { } /** - * Save the async process function(s). + * Set AsyncProcess options. */ - do(asyncFns: AsyncFns): this { - this._fns.asyncFns = new Set(ensureArray(asyncFns)) + setOptions(options: Partial): this { + this._options = { ...this._options, ...options } + return this + } + + /** + * Save jobs function(s). + */ + do(jobs: Jobs, identifier = 'defaultJobs'): this { + this._log('functionsRegistrations')('debug')( + `Register "${identifier}" jobs function(s)` + ) + + this._fns.jobs.set(identifier, new Set(ensureArray(jobs))) return this } /** * Save a predicate function to trigger or not the process. */ - if(predicateFn: PredicateFn): this { - this._predicateFns.add(predicateFn) + if( + predicateFns: PredicateFns, + identifier = 'defaultPredicate' + ): this { + this._fns.predicateFns.set(identifier, new Set(ensureArray(predicateFns))) return this } /** - * Save functions to execute before starting the async process. + * Save functions to execute before starting jobs. */ - onStart(asyncFns: AsyncFns): this { - new Set(ensureArray(asyncFns)).forEach(fn => { - this._fns.onStartFns.add(fn) - }) + onStart(jobs: Jobs, identifier = 'defaultOnStart'): this { + this._log('functionsRegistrations')('debug')( + `Register "${identifier}" onStart function(s)` + ) + this._fns.onStartFns.set(identifier, new Set(ensureArray(jobs))) return this } /** - * Save functions to execute after the async process if succeeded. + * Save functions to execute after jobs are succesful. */ - onSuccess(asyncFns: AsyncFns): this { - new Set(ensureArray(asyncFns)).forEach(fn => { - this._fns.onSuccessFns.add(fn) - }) + onSuccess(jobs: Jobs, identifier = 'defaultOnSuccess'): this { + this._log('functionsRegistrations')('debug')( + `Register "${identifier}" onSuccess function(s)` + ) + this._fns.onSuccessFns.set(identifier, new Set(ensureArray(jobs))) return this } /** - * Save functions to execute after the async process if failed. + * Save functions to execute after jobs are succesful. */ - onError(asyncFns: AsyncErrorFns): this { - new Set(ensureArray(asyncFns)).forEach(fn => { - this._fns.onErrorFns.add(fn) - }) + onError(jobs: AsyncErrorFns, identifier = 'defaultOnError'): this { + this._log('functionsRegistrations')('debug')( + `Register "${identifier}" onError function(s)` + ) + this._fns.onErrorFns.set(identifier, new Set(ensureArray(jobs))) return this } @@ -114,19 +148,22 @@ export class AsyncProcess { asyncProcess: AsyncProcess ) => AsyncProcess ): this { - const fnTypes: Array = [ - 'asyncFns', - 'onStartFns', - 'onSuccessFns', - 'onErrorFns' - ] - const asyncProcessFns = asyncProcessComposer(this).fns - fnTypes.forEach(fnType => { - asyncProcessFns[fnType].forEach(fn => { - this._fns[fnType].add(fn as AsyncFn) - }) + asyncProcessFns.jobs.forEach((fns, identifier) => { + this._fns.jobs.set(identifier, fns) + }) + + asyncProcessFns.onStartFns.forEach((fns, identifier) => { + this._fns.onStartFns.set(identifier, fns) + }) + + asyncProcessFns.onSuccessFns.forEach((fns, identifier) => { + this._fns.onSuccessFns.set(identifier, fns) + }) + + asyncProcessFns.onErrorFns.forEach((fns, identifier) => { + this._fns.onErrorFns.set(identifier, fns) }) return this @@ -147,23 +184,33 @@ export class AsyncProcess { } /** - * Start the async process and executes registered functions. + * Return all identifiers as a string. + */ + get identitiersAsString(): string { + return this._identifiers.filter(Boolean).join('/') + } + + /** + * Start jobs and executes registered functions. */ async start(): Promise { this._error = null try { - if (this._predicateFns && !(await this.shouldStart())) { - await this._execAsyncFns(this._fns.onSuccessFns) - return this + if (this._fns.predicateFns.size && !(await this.shouldStart())) { + this._result = await this._execJobs(this._fns.onSuccessFns) + } else { + await this._execJobs(this._fns.onStartFns) + this._result = await this._execJobs(this._fns.jobs) + await this._execJobs(this._fns.onSuccessFns) } - - await this._execAsyncFns(this._fns.onStartFns) - await this._execAsyncFns(this._fns.asyncFns) - await this._execAsyncFns(this._fns.onSuccessFns) } catch (err) { - this._error = err instanceof Error ? err : new Error('Unknown error') + this._error = err as E + this._result = null + this._execAsyncErrorFns(this._error, this._fns.onErrorFns) + } finally { + this.shouldDeleteFunctions() } return this @@ -173,26 +220,60 @@ export class AsyncProcess { * Return a boolean according to registered predicates values. */ async shouldStart(): Promise { - for (const predicateFn of this._predicateFns) { - if (!(await predicateFn(this))) { - return false + for (const [identifier, predicateFns] of this._fns.predicateFns.entries()) { + for (const predicateFn of predicateFns) { + if (!(await predicateFn(this))) { + this._log('functionsExecutions')('debug')( + `Skip jobs execution because of the "${identifier}" predicate` + ) + + return false + } } } + return true } + /** + * Delete functions. + */ + shouldDeleteFunctions(): this { + if (!this._options.deleteFunctionsWhenJobsStarted) { + return this + } + + this._log('functionsRegistrations')('debug')( + `Delete functions after starting jobs` + ) + + this._fns = { + jobs: new Map(), + onStartFns: new Map(), + onSuccessFns: new Map(), + onErrorFns: new Map(), + predicateFns: new Map() + } + + return this + } + /** * Getters */ - get fns(): IAsyncProcessFns { + get fns(): IAsyncProcessFns { return this._fns } - get error(): Maybe { + get error(): Maybe { return this._error } + get result(): Maybe { + return this._result + } + /** * Private */ @@ -200,38 +281,61 @@ export class AsyncProcess { /** * Execute sequentially async functions. */ - private _execAsyncFns(asyncFns: Set): Promise { - return Array.from(asyncFns.values()) - .reduce( - (promise, nextFn) => promise.then(() => nextFn()), - Promise.resolve(null) - ) - .then(() => this) + private async _execJobs( + jobs: Map>> + ): Promise> { + let results: Maybe = null + + for (const [identifier, fns] of jobs.entries()) { + for (const fn of fns) { + this._log('functionsExecutions')('debug')( + `Execute "${identifier}" jobs function(s)` + ) + + results = deepMerge([results, await fn()]) + } + } + + return results } /** * Execute sequentially async error functions. */ - private _execAsyncErrorFns( - err: Error, - asyncErrorFns: Set + private async _execAsyncErrorFns( + err: E, + asyncErrorFns: Map>> ): Promise { - return Array.from(asyncErrorFns.values()) - .reduce( - (promise, nextFn) => promise.then(() => nextFn(err)), - Promise.resolve(null) - ) - .then(() => this) + for (const [identifier, fns] of asyncErrorFns.entries()) { + for (const fn of fns) { + this._log('functionsExecutions')('debug')( + `Execute "${identifier}" onError function(s)` + ) + + await fn(err) + } + } + + return this + } + + /** + * Log with AsyncProcess identifier prefix. + */ + private _log(namespace: LoggerNamespace) { + return (level: LoggerLevel) => (message: string) => { + logger(namespace)(level)(`[${this.identitiersAsString}] ${message}`) + } } /** * Static */ - static instance( + static instance( identifier: TIdentifier, subIdentifiers?: Maybe - ): AsyncProcess { + ): AsyncProcess { const identifiersAsString = AsyncProcess.computeIdentifiers( identifier, subIdentifiers ?? null @@ -243,10 +347,14 @@ export class AsyncProcess { return instance } - const asyncProcess = new AsyncProcess(identifier, subIdentifiers) + const asyncProcess = new AsyncProcess( + identifier, + subIdentifiers + ) + AsyncProcess._instances.set(identifiersAsString, asyncProcess) - return asyncProcess as AsyncProcess + return asyncProcess } /** @@ -263,6 +371,9 @@ export class AsyncProcess { identifier: TIdentifier, subIdentifiers?: Maybe ): AsyncProcessIdentifiers { - return [identifier, subIdentifiers ? subIdentifiers.join('/') : null] + return [ + identifier, + subIdentifiers ? subIdentifiers.filter(Boolean).join('/') : null + ] } } diff --git a/src/AsyncProcess/predicates/__tests__/olderThan.test.ts b/src/AsyncProcess/predicates/__tests__/olderThan.test.ts index 69911c9..e091b74 100644 --- a/src/AsyncProcess/predicates/__tests__/olderThan.test.ts +++ b/src/AsyncProcess/predicates/__tests__/olderThan.test.ts @@ -25,7 +25,7 @@ describe('Predicates', () => { AsyncProcess.clearInstances() }) - it('should avoid starting during a delay', async () => { + it('should not trigger the job during a delay', async () => { const doSomething = jest.fn() const asyncProcess = getAsyncProcessTestInstance('loadFoo') @@ -35,11 +35,11 @@ describe('Predicates', () => { await asyncProcess.start() await asyncProcess.start() - // 1 instead of 2 + // called only once because the second time was during the delay expect(doSomething).toHaveBeenCalledTimes(1) }) - it('should start after the delay', async () => { + it('should trigger the job after the delay', async () => { const doSomething = jest.fn() const asyncProcess = getAsyncProcessTestInstance('loadFoo') @@ -50,11 +50,69 @@ describe('Predicates', () => { await wait(1.5) await asyncProcess.start() + // called twice because the delay has expired + expect(doSomething).toHaveBeenCalledTimes(2) + }) + + it('should not trigger the job if the dependencies have not changed', async () => { + const doSomething = jest.fn() + + const asyncProcess1 = getAsyncProcessTestInstance('loadFoo') + .do(doSomething) + .if(olderThan(1, [1, 'foo', true])) + + await asyncProcess1.start() + + const asyncProcess2 = getAsyncProcessTestInstance('loadFoo') + .do(doSomething) + .if(olderThan(1, [1, 'foo', true])) + + await asyncProcess2.start() + + // called only once because the dependencies have not changed + expect(doSomething).toHaveBeenCalledTimes(1) + }) + + it('should trigger the job if the dependencies have changed', async () => { + const doSomething = jest.fn() + + const asyncProcess1 = getAsyncProcessTestInstance('loadFoo') + .do(doSomething) + .if(olderThan(1, [1, 'foo', true])) + + await asyncProcess1.start() + + const asyncProcess2 = getAsyncProcessTestInstance('loadFoo') + .do(doSomething) + .if(olderThan(1, [1, 'foo', false])) + + await asyncProcess2.start() + + // called twice because the deps have changed + expect(doSomething).toHaveBeenCalledTimes(2) + }) + + it('should trigger the job if the dependencies have not changed but if this is not the same AP instance', async () => { + const doSomething = jest.fn() + + const asyncProcess1 = getAsyncProcessTestInstance('loadFoo') + .do(doSomething) + .if(olderThan(1, [1, 'foo', true])) + + await asyncProcess1.start() + + const asyncProcess2 = getAsyncProcessTestInstance('loadBar') + .do(doSomething) + .if(olderThan(1, [1, 'foo', true])) + + await asyncProcess2.start() + + // called twice because this is 2 different AP instances expect(doSomething).toHaveBeenCalledTimes(2) }) describe('cancelOlderThanDelay', () => { - it('shouldnt cancel the delay if the sub dependencies dont match', async () => { + it('shouldnt cancel the delay if the sub identifiers dont match', async () => { const doSomething = jest.fn() const asyncProcess = getAsyncProcessTestInstance('loadFoo', ['uuid1']) diff --git a/src/AsyncProcess/predicates/olderThan.ts b/src/AsyncProcess/predicates/olderThan.ts index b01d244..2cf39aa 100644 --- a/src/AsyncProcess/predicates/olderThan.ts +++ b/src/AsyncProcess/predicates/olderThan.ts @@ -1,5 +1,6 @@ import { AsyncProcess } from '..' import { Maybe, MetaData } from '@productive-codebases/toolbox' +import { PredicateFn } from '../../types' /** * Return a predicate function to trigger async functions @@ -17,16 +18,35 @@ import { Maybe, MetaData } from '@productive-codebases/toolbox' * * AsyncProcess.instance(...).metadata.get('expireProcess')() */ -interface IOlderDataMetadata { +interface IOlderThanDataMetadata { olderThan: { lastDate: number cancelDelay: () => void + dependencies: OlderThanDependency[] } } -export function olderThan(seconds: number) { - return (asyncProcess: AsyncProcess): boolean => { - const metadata = asyncProcess.metadata as MetaData +type OlderThanDependency = boolean | string | number + +export function olderThan( + seconds: number, + dependencies: OlderThanDependency[] = [] +): PredicateFn { + function hasDependenciesChanged(previousDependancies: OlderThanDependency[]) { + for (let index = 0; index <= dependencies.length; index++) { + if (previousDependancies[index] !== dependencies[index]) { + return true + } + } + + return false + } + + /** + * Predicate function. + */ + return asyncProcess => { + const metadata = asyncProcess.metadata as MetaData const predicateMetadata = metadata.get('olderThan') if (!predicateMetadata) { @@ -35,8 +55,8 @@ export function olderThan(seconds: number) { metadata.set({ olderThan: { lastDate: newLastDate, - // when calling expireProcess(), delete lastData to set a new lastDate the next time - cancelDelay: () => metadata.delete('olderThan') + cancelDelay: () => metadata.clear(), + dependencies } }) @@ -44,6 +64,11 @@ export function olderThan(seconds: number) { return true } + // if dependencies has changed, load data + if (hasDependenciesChanged(predicateMetadata.dependencies)) { + return true + } + const currentDate = new Date().getTime() const delta = currentDate / 1000 - predicateMetadata.lastDate / 1000 const isDataExpired = delta > seconds @@ -57,6 +82,7 @@ export function olderThan(seconds: number) { }) } + // if data is expired, load data return isDataExpired } } @@ -73,7 +99,7 @@ export function cancelOlderThanDelay( subIdentifiers?: Maybe ): void { const predicateMetadata = AsyncProcess.instance(identifier, subIdentifiers) - .metadata as MetaData + .metadata as MetaData const cancelDelayFn = predicateMetadata.get('olderThan')?.cancelDelay diff --git a/src/__tests__/AsyncProcess.test.ts b/src/__tests__/AsyncProcess.test.ts index 50ee95b..1b9edef 100644 --- a/src/__tests__/AsyncProcess.test.ts +++ b/src/__tests__/AsyncProcess.test.ts @@ -1,414 +1,659 @@ import { Maybe } from '@productive-codebases/toolbox' import { AsyncProcess } from '../AsyncProcess' - -interface IData { - id: number - name: string -} +import { debug } from '../logger' +import { IAsyncProcessOptions } from '../types' type AsyncProcessTestIdentifier = 'loadFoo' | 'loadBar' -function getAsyncProcessTestInstance( - identifier: AsyncProcessTestIdentifier, - subIdentifiers?: string[] -): AsyncProcess { - return AsyncProcess.instance(identifier, subIdentifiers) -} - describe('AsyncProcess', () => { - let data: Maybe = null + describe.each< + Partial< + IAsyncProcessOptions & { + _debug: { + logFunctionRegistrations: boolean + logFunctionExecutions: boolean + } + } + > + >([ + { deleteFunctionsWhenJobsStarted: false }, + { deleteFunctionsWhenJobsStarted: true }, + { + _debug: { + logFunctionRegistrations: true, + logFunctionExecutions: true + } + } + ])('With options: %o', options => { + function getAsyncProcessTestInstance( + identifier: AsyncProcessTestIdentifier, + subIdentifiers?: string[] + ): AsyncProcess { + return AsyncProcess.instance(identifier, subIdentifiers).setOptions( + options + ) + } - const setData = (data_: any) => { - data = data_ - } + beforeEach(() => { + AsyncProcess.clearInstances() + }) - beforeEach(() => { - data = null - AsyncProcess.clearInstances() - }) + describe('On success', () => { + it('should call the onStart / onSuccess fn(s)', async () => { + const fetchData = jest + .fn() + .mockImplementation(() => Promise.resolve({ id: 1, name: 'Bob' })) + const onStartFns = [jest.fn(), jest.fn()] + const onSuccessFns = [jest.fn()] + const onErrorFn = jest.fn() + + const asyncProcess = getAsyncProcessTestInstance('loadFoo') + .do(fetchData) + .onStart(onStartFns) + .onSuccess(onSuccessFns) + .onError(onErrorFn) + + await asyncProcess.start() - describe('On success', () => { - it('should call the onStart / onSuccess fn(s)', async () => { - const fetchData = (): Promise => { - return new Promise(resolve => { - setTimeout(() => { - setData({ id: 1, name: 'Bob' }) - resolve(null) - }, 50) + onStartFns.forEach(onStartFn => { + expect(onStartFn).toHaveBeenCalled() }) - } - const onStartFns = [jest.fn(), jest.fn()] - const onSuccessFns = [jest.fn()] - const onErrorFn = jest.fn() + onSuccessFns.forEach(onSuccessFn => { + expect(onSuccessFn).toHaveBeenCalled() + }) - const asyncProcess = getAsyncProcessTestInstance('loadFoo') - .do(() => fetchData()) - .onStart(onStartFns) - .onSuccess(onSuccessFns) - .onError(onErrorFn) + expect(onErrorFn).not.toHaveBeenCalled() + }) - await asyncProcess.start() + it('should call the functions sequentially', async () => { + const fetchData = jest.fn().mockImplementation(() => Promise.resolve()) - onStartFns.forEach(onStartFn => { - expect(onStartFn).toHaveBeenCalled() - }) + const successValues: number[] = [] - onSuccessFns.forEach(onSuccessFn => { - expect(onSuccessFn).toHaveBeenCalled() - }) + const setSuccess = (value: number) => () => { + successValues.push(value) + } - expect(onErrorFn).not.toHaveBeenCalled() + const onStartFns = [jest.fn(), jest.fn()] + const onSuccessFns = [setSuccess(2), setSuccess(1)] + const onErrorFn = jest.fn() - expect(data).toEqual({ - id: 1, - name: 'Bob' + const asyncProcess = getAsyncProcessTestInstance('loadFoo') + .do(fetchData) + .onStart(onStartFns) + .onSuccess(onSuccessFns) + .onError(onErrorFn) + + await asyncProcess.start() + + expect(successValues).toEqual([2, 1]) }) - }) - it('should call the functions sequentially', async () => { - const fetchData = (): Promise => { - return new Promise(resolve => { - setTimeout(() => { - resolve(null) - }, 50) - }) - } + it('should return a success promise', async () => { + const fetchData = jest.fn().mockImplementation(() => Promise.resolve()) - const successValues: number[] = [] + const asyncProcess = + getAsyncProcessTestInstance('loadFoo').do(fetchData) - const setSuccess = (value: number) => () => { - successValues.push(value) - } + const doSomethingAfterAsyncProcess = jest.fn() - const onStartFns = [jest.fn(), jest.fn()] - const onSuccessFns = [setSuccess(2), setSuccess(1)] - const onErrorFn = jest.fn() + await asyncProcess.start().then(() => doSomethingAfterAsyncProcess()) - const asyncProcess = getAsyncProcessTestInstance('loadFoo') - .do(() => fetchData()) - .onStart(onStartFns) - .onSuccess(onSuccessFns) - .onError(onErrorFn) + expect(doSomethingAfterAsyncProcess).toHaveBeenCalled() + }) - await asyncProcess.start() + it('should expose the result', async () => { + const fetchData = jest + .fn() + .mockImplementation(() => Promise.resolve({ foo: 'bar' })) - expect(successValues).toEqual([2, 1]) - }) + // typings are only indicative, no validation is sone by AsyncProcess + const asyncProcess = getAsyncProcessTestInstance<{ foo: string }>( + 'loadFoo' + ).do(fetchData) - it('should return a success promise', async () => { - const fetchData = (): Promise => { - return new Promise(resolve => { - setTimeout(() => { - resolve(null) - }, 50) - }) - } + await asyncProcess.start() - const asyncProcess = getAsyncProcessTestInstance('loadFoo').do(() => - fetchData() - ) + expect(asyncProcess.result).toEqual({ foo: 'bar' }) + }) + + it('should expose the result as an array is multiple jobs', async () => { + const fetchData1 = jest + .fn() + .mockImplementation(() => Promise.resolve({ foo: 'bar' })) + + const fetchData2 = jest + .fn() + .mockImplementation(() => Promise.resolve({ foo2: 'bar2' })) - const doSomethingAfterAsyncProcess = jest.fn() + const asyncProcess = getAsyncProcessTestInstance('loadFoo').do([ + fetchData1, + fetchData2 + ]) - await asyncProcess.start().then(() => doSomethingAfterAsyncProcess()) + await asyncProcess.start() - expect(doSomethingAfterAsyncProcess).toHaveBeenCalled() + expect(asyncProcess.result).toEqual({ foo: 'bar', foo2: 'bar2' }) + }) }) - }) - describe('On error', () => { - it('should call the onStart / onError fn(s)', async () => { - const fetchData = (): Promise => { - return new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('Something bad happened')) - }, 50) + describe('On error', () => { + it('should call the onStart / onError fn(s)', async () => { + const fetchData = jest + .fn() + .mockImplementation(() => + Promise.reject(new Error('Something bad happened')) + ) + const onStartFns = [jest.fn(), jest.fn()] + const onSuccessFns = [jest.fn()] + const onErrorFn = jest.fn() + + const asyncProcess = getAsyncProcessTestInstance('loadFoo') + .do(fetchData) + .onStart(onStartFns) + .onSuccess(onSuccessFns) + .onError(onErrorFn) + + await asyncProcess.start() + + onStartFns.forEach(onStartFn => { + expect(onStartFn).toHaveBeenCalled() }) - } - const onStartFns = [jest.fn(), jest.fn()] - const onSuccessFns = [jest.fn()] - const onErrorFn = jest.fn() + onSuccessFns.forEach(onSuccessFn => { + expect(onSuccessFn).not.toHaveBeenCalled() + }) - const asyncProcess = getAsyncProcessTestInstance('loadFoo') - .do(() => fetchData()) - .onStart(onStartFns) - .onSuccess(onSuccessFns) - .onError(onErrorFn) + expect(asyncProcess.result).toEqual(null) + }) - await asyncProcess.start() + it('should pass the Error to error functions', async () => { + class CustomError { + constructor(readonly error: string) {} + } - onStartFns.forEach(onStartFn => { - expect(onStartFn).toHaveBeenCalled() + const fetchData = jest + .fn() + .mockImplementation(() => + Promise.reject(new CustomError('Something bad happened')) + ) + const onErrorFn = jest.fn() + + const asyncProcess = getAsyncProcessTestInstance('loadFoo') + .do(fetchData) + .onError(onErrorFn) + + await asyncProcess.start() + + expect(onErrorFn).toHaveBeenCalledWith( + new CustomError('Something bad happened') + ) }) - onSuccessFns.forEach(onSuccessFn => { - expect(onSuccessFn).not.toHaveBeenCalled() + it('should expose the Error object as it', async () => { + class CustomError { + constructor(readonly error: string) {} + } + + const fetchData = jest + .fn() + .mockImplementation(() => + Promise.reject(new CustomError('Something bad happened')) + ) + + const asyncProcess = getAsyncProcessTestInstance( + 'loadFoo' + ).do(fetchData) + + await asyncProcess.start() + + expect(asyncProcess.error).toBeInstanceOf(CustomError) + expect(asyncProcess.error).toEqual( + new CustomError('Something bad happened') + ) }) - expect(data).toEqual(null) - }) + it('should return a success promise even if an error occurred', async () => { + const fetchData = jest.fn().mockImplementation(() => Promise.reject()) - it('should pass the Error to error functions', async () => { - const error = new Error('Something bad happened') + let errorMessage: Maybe = null - const fetchData = (): Promise => { - return new Promise((_, reject) => { - setTimeout(() => { - reject(error) - }, 50) - }) - } + const setErrorMessage = (message: string) => () => { + errorMessage = message + } - const onErrorFn = jest.fn() + const asyncProcess = getAsyncProcessTestInstance('loadFoo') + .do(fetchData) + .onError(setErrorMessage('Something bad happened')) - const asyncProcess = getAsyncProcessTestInstance('loadFoo') - .do(() => fetchData()) - .onError(onErrorFn) + const doSomethingAfterAsyncProcess = jest.fn() - await asyncProcess.start() + await asyncProcess.start().then(() => doSomethingAfterAsyncProcess()) - expect(onErrorFn).toHaveBeenCalledWith(error) + expect(doSomethingAfterAsyncProcess).toHaveBeenCalled() + expect(errorMessage).toEqual('Something bad happened') + }) }) - it('should expose the Error', async () => { - const fetchData = (): Promise => { - return new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('Something bad happened')) - }, 50) + describe('Identifiers', () => { + describe('onStart', () => { + it('should use a default identifier so that fns are replaced by default', () => { + const startSpy1 = jest.fn() + const startSpy2 = jest.fn() + + const foo1 = getAsyncProcessTestInstance('loadFoo') + foo1.onStart(startSpy1) + + const foo2 = getAsyncProcessTestInstance('loadFoo') + foo2.onStart(startSpy1) + foo2.onStart(startSpy2) + + // register only `startSpy2` because added last + expect(foo1.fns.onStartFns.size).toBe(1) + expect(Array.from(foo1.fns.onStartFns.values())).toEqual([ + new Set([startSpy2]) + ]) }) - } + }) - const asyncProcess = getAsyncProcessTestInstance('loadFoo').do(() => - fetchData() - ) + describe('onSuccess', () => { + it('should use a default identifier so that fns are replaced by default', () => { + const successSpy1 = jest.fn() + const successSpy2 = jest.fn() - await asyncProcess.start() + const foo1 = getAsyncProcessTestInstance('loadFoo') + foo1.onSuccess(successSpy1) - expect(data).toEqual(null) - expect(asyncProcess.error).toBeInstanceOf(Error) - expect(asyncProcess.error?.message).toEqual('Something bad happened') - }) + const foo2 = getAsyncProcessTestInstance('loadFoo') + foo2.onSuccess(successSpy1) + foo2.onSuccess(successSpy2) - it('should return a success promise even if an error occurred', async () => { - const fetchData = (): Promise => { - return new Promise((_, reject) => { - setTimeout(() => { - reject() - }, 50) + // register only `successSpy2` because added last + expect(foo1.fns.onSuccessFns.size).toBe(1) }) - } - let errorMessage: Maybe = null + it('should allow using an identifier to add several fns', async () => { + const successSpy1 = jest.fn() + const successSpy2 = jest.fn() + const successSpy3 = jest.fn() + const successSpy4 = jest.fn() - const setErrorMessage = (message: string) => () => { - errorMessage = message - } + const foo1 = getAsyncProcessTestInstance('loadFoo') + foo1.onSuccess([successSpy1, successSpy2], 'success1') - const asyncProcess = getAsyncProcessTestInstance('loadFoo') - .do(() => fetchData()) - .onError(setErrorMessage('Something bad happened')) + const foo2 = getAsyncProcessTestInstance('loadFoo') + foo2.onSuccess([successSpy3, successSpy4], 'success2') - const doSomethingAfterAsyncProcess = jest.fn() + // register `[successSpy1, successSpy2]` and `[successSpy3, successSpy4]`, so 2 entries + expect(foo1.fns.onSuccessFns.size).toBe(2) - await asyncProcess.start().then(() => doSomethingAfterAsyncProcess()) + await foo1.start() - expect(doSomethingAfterAsyncProcess).toHaveBeenCalled() - expect(errorMessage).toEqual('Something bad happened') - }) - }) + // check that the 4 fns have been called + expect(successSpy1).toHaveBeenCalled() + expect(successSpy2).toHaveBeenCalled() + expect(successSpy3).toHaveBeenCalled() + expect(successSpy4).toHaveBeenCalled() + }) + }) - describe('Predicates', () => { - it('should execute the async process if the predicate function is true', async () => { - const fetchData = jest.fn() - const predicateFn1 = jest.fn().mockImplementation(() => true) - const predicateFn2 = jest.fn().mockImplementation(() => true) - const onStartFn = jest.fn() - const onSuccessFn = jest.fn() - const onErrorFn = jest.fn() - - const asyncProcess = getAsyncProcessTestInstance('loadFoo') - .do(() => fetchData()) - .if(() => predicateFn1()) - .if(() => predicateFn2()) - .onStart(onStartFn) - .onSuccess(onSuccessFn) - .onError(onErrorFn) - - await asyncProcess.start() - - expect(predicateFn1).toHaveBeenCalled() - expect(predicateFn2).toHaveBeenCalled() - expect(fetchData).toHaveBeenCalled() - expect(onStartFn).toHaveBeenCalled() - expect(onSuccessFn).toHaveBeenCalled() - expect(onErrorFn).not.toHaveBeenCalled() - }) + describe('onError', () => { + it('should use a default identifier so that fns are replaced by default', () => { + const errorSpy1 = jest.fn() + const errorSpy2 = jest.fn() - it('should not execute the async process if the predicate function is false', async () => { - const fetchData = jest.fn() - const predicateFn1 = jest.fn().mockImplementation(() => false) - const onStartFn = jest.fn() - const onSuccessFn = jest.fn() - const onErrorFn = jest.fn() - - const asyncProcess = getAsyncProcessTestInstance('loadFoo') - .do(() => fetchData()) - .if(() => predicateFn1()) - .onStart(onStartFn) - .onSuccess(onSuccessFn) - .onError(onErrorFn) - - await asyncProcess.start() - - expect(predicateFn1).toHaveBeenCalled() - expect(fetchData).not.toHaveBeenCalled() - expect(onStartFn).not.toHaveBeenCalled() - expect(onSuccessFn).toHaveBeenCalled() - expect(onErrorFn).not.toHaveBeenCalled() - }) + const foo1 = getAsyncProcessTestInstance('loadFoo') + foo1.onError(errorSpy1) - it('should not execute the async process if one of the predicate functions is false', async () => { - const fetchData = jest.fn() - const predicateFn1 = jest.fn().mockImplementation(() => true) - const predicateFn2 = jest.fn().mockImplementation(() => true) - const predicateFn3 = jest.fn().mockImplementation(() => false) - const onStartFn = jest.fn() - const onSuccessFn = jest.fn() - const onErrorFn = jest.fn() - - const asyncProcess = getAsyncProcessTestInstance('loadFoo') - .do(() => fetchData()) - .if(() => predicateFn1()) - .if(() => predicateFn2()) - .if(() => predicateFn3()) - .onStart(onStartFn) - .onSuccess(onSuccessFn) - .onError(onErrorFn) - - await asyncProcess.start() - - expect(predicateFn1).toHaveBeenCalled() - expect(predicateFn2).toHaveBeenCalled() - expect(predicateFn3).toHaveBeenCalled() - expect(fetchData).not.toHaveBeenCalled() - expect(onStartFn).not.toHaveBeenCalled() - expect(onSuccessFn).toHaveBeenCalled() - expect(onErrorFn).not.toHaveBeenCalled() - }) - }) + const foo2 = getAsyncProcessTestInstance('loadFoo') + foo2.onError(errorSpy1) + foo2.onError(errorSpy2) - describe('AsyncProcess composition', () => { - it('should compose AsyncProcess instances', async () => { - const fetchData = (): Promise => { - return new Promise(resolve => { - setTimeout(() => { - setData({ id: 1, name: 'Bob' }) - resolve(null) - }, 50) + // register only `errorSpy2` because added last + expect(foo1.fns.onErrorFns.size).toBe(1) }) - } + }) + }) - const onStartgFn1 = jest.fn() - const onStartgFn2 = jest.fn() + describe('Predicates', () => { + it('should execute jobs if the predicate function is true', async () => { + const fetchData = jest.fn() + const predicateFn1 = jest.fn().mockImplementation(() => true) + const predicateFn2 = jest.fn().mockImplementation(() => true) + const predicateFn3 = jest.fn().mockImplementation(() => true) + const predicateFn4 = jest.fn().mockImplementation(() => true) + const onStartFn = jest.fn() + const onSuccessFn = jest.fn() + const onErrorFn = jest.fn() + + const asyncProcess = getAsyncProcessTestInstance('loadFoo') + .do(fetchData) + .if(predicateFn1, 'predicate1') + .if(predicateFn2, 'predicate2') + .if([predicateFn3, predicateFn4], 'predicate3and4') + .onStart(onStartFn) + .onSuccess(onSuccessFn) + .onError(onErrorFn) - // Add two same functions, should be unique at the end - const onStartFns = [onStartgFn1, onStartgFn1, onStartgFn2] - const onSuccessFns = [jest.fn()] - const onErrorFn = jest.fn() + await asyncProcess.start() - let asyncProcessBaseIdentifiers + expect(predicateFn1).toHaveBeenCalled() + expect(predicateFn2).toHaveBeenCalled() + expect(predicateFn3).toHaveBeenCalled() + expect(predicateFn4).toHaveBeenCalled() + expect(fetchData).toHaveBeenCalled() + expect(onStartFn).toHaveBeenCalled() + expect(onSuccessFn).toHaveBeenCalled() + expect(onErrorFn).not.toHaveBeenCalled() + }) - const asyncProcessExtended = ( - asyncProcess: AsyncProcess - ) => { - const baseAsyncProcess = getAsyncProcessTestInstance( - asyncProcess.identifier, - ['dep1', 'dep2'] - ) - .onStart(onStartFns) - .onSuccess(onSuccessFns) + it('should not execute jobs if the predicate function is false', async () => { + const fetchData = jest.fn() + const predicateFn1 = jest.fn().mockImplementation(() => false) + const onStartFn = jest.fn() + const onSuccessFn = jest.fn() + const onErrorFn = jest.fn() + + const asyncProcess = getAsyncProcessTestInstance('loadFoo') + .do(fetchData) + .if(predicateFn1) + .onStart(onStartFn) + .onSuccess(onSuccessFn) .onError(onErrorFn) - asyncProcessBaseIdentifiers = baseAsyncProcess.identitiers + await asyncProcess.start() - return baseAsyncProcess - } + expect(predicateFn1).toHaveBeenCalled() + expect(fetchData).not.toHaveBeenCalled() + expect(onStartFn).not.toHaveBeenCalled() + expect(onSuccessFn).toHaveBeenCalled() + expect(onErrorFn).not.toHaveBeenCalled() + }) - const asyncProcess = getAsyncProcessTestInstance('loadFoo') - .do(() => fetchData()) - .compose(asyncProcessExtended) + it('should not execute jobs if one of the predicate functions is false', async () => { + const fetchData = jest.fn() + const predicateFn1 = jest.fn().mockImplementation(() => true) + const predicateFn2 = jest.fn().mockImplementation(() => true) + const predicateFn3 = jest.fn().mockImplementation(() => false) + const predicateFn4 = jest.fn().mockImplementation(() => true) + const onStartFn = jest.fn() + const onSuccessFn = jest.fn() + const onErrorFn = jest.fn() + + const asyncProcess = getAsyncProcessTestInstance('loadFoo') + .do(fetchData) + .if(predicateFn1, 'predicate1') + .if(predicateFn2, 'predicate2') + .if([predicateFn3, predicateFn4], 'predicate3and4') + .onStart(onStartFn) + .onSuccess(onSuccessFn) + .onError(onErrorFn) - await asyncProcess.start() + await asyncProcess.start() - onStartFns.forEach(onStartFn => { - expect(onStartFn).toHaveBeenCalled() + expect(predicateFn1).toHaveBeenCalled() + expect(predicateFn2).toHaveBeenCalled() + expect(predicateFn3).toHaveBeenCalled() + // not called because predicateFn3 is falsy + expect(predicateFn4).not.toHaveBeenCalled() + expect(fetchData).not.toHaveBeenCalled() + expect(onStartFn).not.toHaveBeenCalled() + expect(onSuccessFn).toHaveBeenCalled() + expect(onErrorFn).not.toHaveBeenCalled() }) + }) + + describe('AsyncProcess composition', () => { + it('should compose AsyncProcess instances', async () => { + const fetchData = jest + .fn() + .mockImplementation(() => Promise.resolve({ id: 1, name: 'Bob' })) + + const onStartFn0 = jest.fn() + const onStartFn1 = jest.fn() + const onStartFn2 = jest.fn() + const onStartFn3 = jest.fn() + const onStartFn4 = jest.fn() + + const onSuccessFn = jest.fn() + + const onErrorFn = jest.fn() + + let asyncProcessBaseIdentifiers + + const asyncProcessExtended = ( + asyncProcess: AsyncProcess + ) => { + const baseAsyncProcess = getAsyncProcessTestInstance( + asyncProcess.identifier, + ['id1', 'id2'] + ) + // define custom identifiers for each fns addition + .onStart(onStartFn1, 'onStartFn1') + .onStart(onStartFn2, 'onStartFn2') + .onStart([onStartFn3, onStartFn4], 'onStartFn3+onStartFn4') + .onSuccess(onSuccessFn) + .onError(onErrorFn) + + asyncProcessBaseIdentifiers = baseAsyncProcess.identitiers + + return baseAsyncProcess + } + + const asyncProcess = getAsyncProcessTestInstance('loadFoo') + .do(fetchData) + .onStart(onStartFn0, 'onStartFn0') + .compose(asyncProcessExtended) + + await asyncProcess.start() + + expect(onStartFn0).toHaveBeenCalled() + expect(onStartFn1).toHaveBeenCalled() + expect(onStartFn2).toHaveBeenCalled() + expect(onStartFn3).toHaveBeenCalled() + expect(onStartFn4).toHaveBeenCalled() - onSuccessFns.forEach(onSuccessFn => { expect(onSuccessFn).toHaveBeenCalled() + + expect(onErrorFn).not.toHaveBeenCalled() + + // Count the number of additions (by uniq identifiers) of onStartFns entries + expect(asyncProcess.fns.onStartFns.size).toEqual( + options.deleteFunctionsWhenJobsStarted ? 0 : 4 + ) + + expect(asyncProcess.result).toEqual({ + id: 1, + name: 'Bob' + }) + + expect(asyncProcessBaseIdentifiers).toEqual(['loadFoo', 'id1/id2']) }) + }) + + describe('Singleton', () => { + it('should return an unique instance for a same identifier', () => { + const successSpy = jest.fn() - expect(onErrorFn).not.toHaveBeenCalled() + const foo1 = getAsyncProcessTestInstance('loadFoo') + foo1.onSuccess(successSpy) + const foo2 = getAsyncProcessTestInstance('loadFoo') - // Dedup same functions - expect(asyncProcess.fns.onStartFns.size).toEqual(2) + const bar1 = getAsyncProcessTestInstance('loadBar') + const bar2 = getAsyncProcessTestInstance('loadBar') - expect(data).toEqual({ - id: 1, - name: 'Bob' + expect(foo1).toBe(foo2) + expect(bar1).toBe(bar2) + + expect(foo1).not.toBe(bar1) + expect(foo2).not.toBe(bar2) + + expect(foo1.fns.onSuccessFns.size).toBe(1) + expect(foo2.fns.onSuccessFns.size).toBe(1) }) - expect(asyncProcessBaseIdentifiers).toEqual(['loadFoo', 'dep1/dep2']) - }) - }) + it('should return an unique instance for same identifiers', () => { + const successSpy = jest.fn() - describe('Singleton', () => { - it('should return an unique instance for a same identifier', () => { - const successSpy = jest.fn() + const foo1 = getAsyncProcessTestInstance('loadFoo') + foo1.onSuccess(successSpy) - const foo1 = getAsyncProcessTestInstance('loadFoo') - foo1.onSuccess(() => successSpy()) - const foo2 = getAsyncProcessTestInstance('loadFoo') + const foo1Dep1 = getAsyncProcessTestInstance('loadFoo', ['dep1']) + const foo2Dep1 = getAsyncProcessTestInstance('loadFoo', ['dep1']) - const bar1 = getAsyncProcessTestInstance('loadBar') - const bar2 = getAsyncProcessTestInstance('loadBar') + const foo1Dep2 = getAsyncProcessTestInstance('loadFoo', ['dep2']) + const foo2Dep2 = getAsyncProcessTestInstance('loadFoo', ['dep2']) - expect(foo1).toBe(foo2) - expect(bar1).toBe(bar2) + expect(foo1Dep1).toBe(foo2Dep1) + expect(foo1Dep2).toBe(foo2Dep2) - expect(foo1).not.toBe(bar1) - expect(foo2).not.toBe(bar2) + expect(foo1).not.toBe(foo1Dep1) + expect(foo1Dep1).not.toBe(foo1Dep2) - expect(foo1.fns.onSuccessFns.size).toBe(1) - expect(foo2.fns.onSuccessFns.size).toBe(1) + expect(foo1Dep1.identifier).toBe('loadFoo') + expect(foo1Dep1.identitiers).toEqual(['loadFoo', 'dep1']) + + expect(foo1.fns.onSuccessFns.size).toBe(1) + expect(foo1Dep1.fns.onSuccessFns.size).toBe(0) + }) }) - it('should return an unique instance for same identifiers', () => { - const successSpy = jest.fn() + describe('Options', () => { + describe('deleteFunctionsWhenJobsStarted', () => { + it('should reset functions when async process is successful', async () => { + const fetchData = jest.fn() + const successSpy1 = jest.fn() + const successSpy2 = jest.fn() + const successSpy3 = jest.fn() + const errorSpy1 = jest.fn() + + const foo1 = getAsyncProcessTestInstance('loadFoo').do(fetchData) + + foo1 + .onSuccess(successSpy1, 'success1') + .onSuccess(successSpy2, 'success2') + .onError(errorSpy1, 'error1') + + expect(foo1.fns.onSuccessFns.size).toBe(2) + expect(foo1.fns.onErrorFns.size).toBe(1) + + await foo1.start() + + foo1 + .onSuccess(successSpy3, 'success3') + .onError(errorSpy1, 'error1bis') + + expect(foo1.fns.onSuccessFns.size).toBe( + // if deleteFunctionsWhenJobsStarted, fns have been removed after the start, + // so we get only `success3` + options.deleteFunctionsWhenJobsStarted ? 1 : 3 + ) + + expect(foo1.fns.onErrorFns.size).toBe( + // if deleteFunctionsWhenJobsStarted, fns have been removed after the start, + // so we get only `error1bis` + options.deleteFunctionsWhenJobsStarted ? 1 : 2 + ) + }) + + it('should reset functions when async process has failed', async () => { + const fetchData = jest.fn().mockImplementation(() => Promise.reject()) + const successSpy1 = jest.fn() + const successSpy2 = jest.fn() + const successSpy3 = jest.fn() + const errorSpy1 = jest.fn() - const foo1 = getAsyncProcessTestInstance('loadFoo') - foo1.onSuccess(() => successSpy()) + const foo1 = getAsyncProcessTestInstance('loadFoo').do(fetchData) - const foo1Dep1 = getAsyncProcessTestInstance('loadFoo', ['dep1']) - const foo2Dep1 = getAsyncProcessTestInstance('loadFoo', ['dep1']) + foo1 + .onSuccess(successSpy1, 'success1') + .onSuccess(successSpy2, 'success2') + .onError(errorSpy1, 'error1') - const foo1Dep2 = getAsyncProcessTestInstance('loadFoo', ['dep2']) - const foo2Dep2 = getAsyncProcessTestInstance('loadFoo', ['dep2']) + expect(foo1.fns.onSuccessFns.size).toBe(2) + expect(foo1.fns.onErrorFns.size).toBe(1) - expect(foo1Dep1).toBe(foo2Dep1) - expect(foo1Dep2).toBe(foo2Dep2) + await foo1.start() - expect(foo1).not.toBe(foo1Dep1) - expect(foo1Dep1).not.toBe(foo1Dep2) + foo1 + .onSuccess(successSpy3, 'success3') + .onError(errorSpy1, 'error1bis') - expect(foo1Dep1.identifier).toBe('loadFoo') - expect(foo1Dep1.identitiers).toEqual(['loadFoo', 'dep1']) + expect(foo1.fns.onSuccessFns.size).toBe( + // if deleteFunctionsWhenJobsStarted, fns have been removed after the start, + // so we get only `success3` + options.deleteFunctionsWhenJobsStarted ? 1 : 3 + ) - expect(foo1.fns.onSuccessFns.size).toBe(1) - expect(foo1Dep1.fns.onSuccessFns.size).toBe(0) + expect(foo1.fns.onErrorFns.size).toBe( + // if deleteFunctionsWhenJobsStarted, fns have been removed after the start, + // so we get only `error1bis` + options.deleteFunctionsWhenJobsStarted ? 1 : 2 + ) + }) + }) + + describe('Logging', () => { + // Mock the log function + let logs: any[] = [] + + beforeEach(() => { + debug.enable( + options._debug?.logFunctionExecutions ? 'AsyncProcess:*' : false + ) + + debug.formatters.s = (s: string) => { + return s + } + + debug.log = (...args: any[]) => { + logs.push(...args) + } + }) + + afterEach(() => { + // reset logs for each test + logs = [] + }) + + it('should log different events if success', async () => { + const fetchData = jest.fn() + const predicateFn1 = jest.fn().mockImplementation(() => true) + const onStartFn = jest.fn() + const onSuccessFn = jest.fn() + const onErrorFn = jest.fn() + + await getAsyncProcessTestInstance('loadFoo') + .do(fetchData) + .if(predicateFn1) + .onStart(onStartFn) + .onSuccess(onSuccessFn) + .onError(onErrorFn) + .start() + + expect(logs).toMatchSnapshot() + }) + + it('should log different events if error', async () => { + const fetchData = jest.fn().mockImplementation(() => Promise.reject()) + const predicateFn1 = jest.fn().mockImplementation(() => true) + const onStartFn = jest.fn() + const onSuccessFn = jest.fn() + const onErrorFn = jest.fn() + + const asyncProcess = getAsyncProcessTestInstance('loadFoo') + .do(fetchData) + .if(predicateFn1) + .onStart(onStartFn) + .onSuccess(onSuccessFn) + .onError(onErrorFn) + + await asyncProcess.start() + + expect(logs).toMatchSnapshot() + }) + }) }) }) }) diff --git a/src/__tests__/__snapshots__/AsyncProcess.test.ts.snap b/src/__tests__/__snapshots__/AsyncProcess.test.ts.snap new file mode 100644 index 0000000..930be63 --- /dev/null +++ b/src/__tests__/__snapshots__/AsyncProcess.test.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AsyncProcess With options: { + _debug: { logFunctionRegistrations: true, logFunctionExecutions: true } +} Options Logging should log different events if error 1`] = ` +[ + "AsyncProcess:functionsRegistrations:debug [loadFoo] Register "defaultJobs" jobs function(s)", + "AsyncProcess:functionsRegistrations:debug [loadFoo] Register "defaultOnStart" onStart function(s)", + "AsyncProcess:functionsRegistrations:debug [loadFoo] Register "defaultOnSuccess" onSuccess function(s)", + "AsyncProcess:functionsRegistrations:debug [loadFoo] Register "defaultOnError" onError function(s)", + "AsyncProcess:functionsExecutions:debug [loadFoo] Execute "defaultOnStart" jobs function(s)", + "AsyncProcess:functionsExecutions:debug [loadFoo] Execute "defaultJobs" jobs function(s)", + "AsyncProcess:functionsExecutions:debug [loadFoo] Execute "defaultOnError" onError function(s)", +] +`; + +exports[`AsyncProcess With options: { + _debug: { logFunctionRegistrations: true, logFunctionExecutions: true } +} Options Logging should log different events if success 1`] = ` +[ + "AsyncProcess:functionsRegistrations:debug [loadFoo] Register "defaultJobs" jobs function(s)", + "AsyncProcess:functionsRegistrations:debug [loadFoo] Register "defaultOnStart" onStart function(s)", + "AsyncProcess:functionsRegistrations:debug [loadFoo] Register "defaultOnSuccess" onSuccess function(s)", + "AsyncProcess:functionsRegistrations:debug [loadFoo] Register "defaultOnError" onError function(s)", + "AsyncProcess:functionsExecutions:debug [loadFoo] Execute "defaultOnStart" jobs function(s)", + "AsyncProcess:functionsExecutions:debug [loadFoo] Execute "defaultJobs" jobs function(s)", + "AsyncProcess:functionsExecutions:debug [loadFoo] Execute "defaultOnSuccess" jobs function(s)", +] +`; + +exports[`AsyncProcess With options: { deleteFunctionsWhenJobsStarted: false } Options Logging should log different events if error 1`] = `[]`; + +exports[`AsyncProcess With options: { deleteFunctionsWhenJobsStarted: false } Options Logging should log different events if success 1`] = `[]`; + +exports[`AsyncProcess With options: { deleteFunctionsWhenJobsStarted: true } Options Logging should log different events if error 1`] = `[]`; + +exports[`AsyncProcess With options: { deleteFunctionsWhenJobsStarted: true } Options Logging should log different events if success 1`] = `[]`; diff --git a/src/logger.ts b/src/logger.ts index d2546ef..f3eb7bb 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,14 +1,17 @@ import { setupLogger } from '@productive-codebases/toolbox' const loggerMapping = { - asyncprocess: { - runtime: 'runtime' + AsyncProcess: { + functionsRegistrations: 'functionsRegistrations', + functionsExecutions: 'functionsExecutions' } } const { newLogger, debug } = setupLogger(loggerMapping) -export const logger = newLogger('asyncprocess')('runtime') -export { debug } +const logger = newLogger('AsyncProcess') +export { logger, debug } + +export type LoggerNamespace = Parameters[0] export type Logger = typeof logger diff --git a/src/types/index.ts b/src/types/index.ts index 8cb6f94..9d60a7c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,28 +1,38 @@ import { Maybe } from '@productive-codebases/toolbox' import { AsyncProcess } from '../AsyncProcess' -export type Fn = () => any -export type AsyncFn = () => Promise -export type FnOrAsyncFn = Fn | AsyncFn -export type AsyncFns = FnOrAsyncFn | Array +export type Fn = () => R +export type AsyncFn = () => Promise +export type FnOrAsyncFn = Fn | AsyncFn +export type Jobs = FnOrAsyncFn | Array> -export type ErrorFn = (err: Error) => any -export type AsyncErrorFn = (err: Error) => Promise -export type FnOrAsyncErrorFn = ErrorFn | AsyncErrorFn -export type AsyncErrorFns = FnOrAsyncErrorFn | Array +export type ErrorFn = (err: E) => any +export type AsyncErrorFn = (err: E) => Promise +export type FnOrAsyncErrorFn = ErrorFn | AsyncErrorFn +export type AsyncErrorFns = FnOrAsyncErrorFn | Array> export type PredicateFn = ( asyncProcess: AsyncProcess ) => boolean | Promise -export interface IAsyncProcessFns { - asyncFns: Set - onStartFns: Set - onSuccessFns: Set - onErrorFns: Set +export type PredicateFns = + | PredicateFn + | Array> + +export interface IAsyncProcessFns { + jobs: Map>> + onStartFns: Map>> + onSuccessFns: Map>> + onErrorFns: Map>> + predicateFns: Map>> } export type AsyncProcessIdentifiers = [ TIdentifier, Maybe ] + +export interface IAsyncProcessOptions { + // delete registered functions when async process is started + deleteFunctionsWhenJobsStarted: boolean +}