import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ChainedDispatchMiddlewareCallback, DispatchMiddleware, DispatchMiddlewareCallback, } from '../src/dispatch-middleware'; import { FlowfulError } from '../src/error'; import { IActionMapExtended } from '../src/action-map'; import { skipDevMessages } from '../src/dev'; import { MODE_DEV, setGlobalMode } from '../src/mode'; setGlobalMode(MODE_DEV); skipDevMessages(true); describe('DispatchMiddleware', () => { let middleware: DispatchMiddleware; let mockActionMap: IActionMapExtended; let mockExecute: ChainedDispatchMiddlewareCallback; beforeEach(() => { middleware = new DispatchMiddleware(); mockActionMap = {} as IActionMapExtended; mockExecute = vi.fn().mockReturnValue('executed'); }); describe('constructor', () => { it('sets default options', () => { expect(middleware.options).toEqual({ cancellable: true }); }); it('allows overriding cancellable option', () => { const nonCancellableMiddleware = new DispatchMiddleware({ cancellable: false }); expect(nonCancellableMiddleware.options).toEqual({ cancellable: false }); }); }); describe('add method', () => { it('adds middleware callback and returns true', () => { const callback: DispatchMiddlewareCallback = (next: ChainedDispatchMiddlewareCallback) => (actionMap) => next(actionMap); const result = middleware.add(callback); expect(result).toBe(true); // Use private property for test verification expect((middleware as any)._middlewareCallbacks.has(callback)).toBe(true); expect((middleware as any)._middlewareCallbacksArray.length).toBe(1); expect((middleware as any)._composed).toBeUndefined(); }); it('returns false when adding the same callback twice', () => { const callback: DispatchMiddlewareCallback = (next: ChainedDispatchMiddlewareCallback) => (actionMap) => next(actionMap); middleware.add(callback); const result = middleware.add(callback); expect(result).toBe(false); expect((middleware as any)._middlewareCallbacksArray.length).toBe(1); }); }); describe('remove method', () => { it('removes middleware callback and returns true', () => { const callback: DispatchMiddlewareCallback = (next: ChainedDispatchMiddlewareCallback) => (actionMap) => next(actionMap); middleware.add(callback); const result = middleware.remove(callback); expect(result).toBe(true); expect((middleware as any)._middlewareCallbacks.has(callback)).toBe(false); expect((middleware as any)._middlewareCallbacksArray.length).toBe(0); expect((middleware as any)._composed).toBeUndefined(); }); it('returns false when removing non-existent callback', () => { const callback: DispatchMiddlewareCallback = (next: ChainedDispatchMiddlewareCallback) => (actionMap) => next(actionMap); const result = middleware.remove(callback); expect(result).toBe(false); }); }); describe('run method', () => { it('executes directly when no middlewares are added', () => { const result = middleware.run(mockActionMap, mockExecute); expect(mockExecute).toHaveBeenCalledWith(mockActionMap); expect(result).toBe('executed'); }); it('executes middlewares in correct order', () => { const executionOrder: string[] = []; const firstMiddleware: DispatchMiddlewareCallback = (next) => (actionMap) => { executionOrder.push('first-before'); const result = next(actionMap); executionOrder.push('first-after'); return result; }; const secondMiddleware: DispatchMiddlewareCallback = (next) => (actionMap) => { executionOrder.push('second-before'); const result = next(actionMap); executionOrder.push('second-after'); return result; }; middleware.add(firstMiddleware); middleware.add(secondMiddleware); middleware.run(mockActionMap, mockExecute); expect(executionOrder).toEqual(['first-before', 'second-before', 'second-after', 'first-after']); expect(mockExecute).toHaveBeenCalledTimes(1); }); it('returns the value from the execute function', () => { const middleware1: DispatchMiddlewareCallback = (next) => (actionMap) => next(actionMap); middleware.add(middleware1); const result = middleware.run(mockActionMap, mockExecute); expect(result).toBe('executed'); }); it('allows middleware to modify the actionMap', () => { const modifiedActionMap = { modified: true } as unknown as IActionMapExtended; const modifierMiddleware: DispatchMiddlewareCallback = (next) => (actionMap) => { return next(modifiedActionMap); }; middleware.add(modifierMiddleware); middleware.run(mockActionMap, mockExecute); expect(mockExecute).toHaveBeenCalledWith(modifiedActionMap); }); it('throws when middleware calls next more than once', () => { const badMiddleware: DispatchMiddlewareCallback = (next) => (actionMap) => { next(actionMap); return next(actionMap); // This will throw }; middleware.add(badMiddleware); expect(() => { middleware.run(mockActionMap, mockExecute); }).toThrow(FlowfulError); expect(() => { middleware.run(mockActionMap, mockExecute); }).toThrow(/The next function in the dispatch middleware was called more than once/); }); it('throws when non-cancellable middleware changes actionMap', () => { const nonCancellableMiddleware = new DispatchMiddleware({ cancellable: false }); const modifiedActionMap = { modified: true } as unknown as IActionMapExtended; const modifierMiddleware: DispatchMiddlewareCallback = (next) => (actionMap) => { return next(modifiedActionMap); // This will throw with non-cancellable middleware }; nonCancellableMiddleware.add(modifierMiddleware); expect(() => { nonCancellableMiddleware.run(mockActionMap, mockExecute); }).toThrow(FlowfulError); expect(() => { nonCancellableMiddleware.run(mockActionMap, mockExecute); }).toThrow(/was called with a different ActionMap/); }); it('throws when non-cancellable middleware does not call next', () => { const nonCancellableMiddleware = new DispatchMiddleware({ cancellable: false }); const noNextMiddleware: DispatchMiddlewareCallback = (next) => (actionMap) => { // Not calling next() will throw with non-cancellable middleware return 'cancelled'; }; nonCancellableMiddleware.add(noNextMiddleware); expect(() => { nonCancellableMiddleware.run(mockActionMap, mockExecute); }).toThrow(FlowfulError); expect(() => { nonCancellableMiddleware.run(mockActionMap, mockExecute); }).toThrow(/was not called, which is not possible when cancellable option is set to false/); }); it('allows middleware to "cancel" execution when cancellable is true (default)', () => { const cancelMiddleware: DispatchMiddlewareCallback = (next) => (actionMap) => { // Not calling next() return 'cancelled'; }; middleware.add(cancelMiddleware); const result = middleware.run(mockActionMap, mockExecute); expect(mockExecute).not.toHaveBeenCalled(); expect(result).toBe('cancelled'); }); it('composes middleware functions when they are added', () => { const spy = vi.spyOn(middleware as any, 'compose'); const callback: DispatchMiddlewareCallback = (next: ChainedDispatchMiddlewareCallback) => (actionMap) => next(actionMap); middleware.add(callback); middleware.run(mockActionMap, mockExecute); expect(spy).toHaveBeenCalledTimes(1); // Run again, should not recompose middleware.run(mockActionMap, mockExecute); expect(spy).toHaveBeenCalledTimes(1); // Add another middleware, should recompose middleware.add((next) => (actionMap) => next(actionMap)); middleware.run(mockActionMap, mockExecute); expect(spy).toHaveBeenCalledTimes(2); }); }); });