boardgame-core/tests/utils/middleware.test.ts

437 lines
14 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { createMiddlewareChain, type MiddlewareChain } from '@/utils/middleware';
describe('createMiddlewareChain', () => {
describe('basic execution', () => {
it('should return context when no middlewares and no fallback', async () => {
const chain = createMiddlewareChain<{ value: number }>();
const result = await chain.execute({ value: 42 });
expect(result).toEqual({ value: 42 });
});
it('should call fallback when no middlewares', async () => {
const chain = createMiddlewareChain<{ value: number }, string>(
async ctx => `value is ${ctx.value}`
);
const result = await chain.execute({ value: 42 });
expect(result).toBe('value is 42');
});
it('should pass context to fallback', async () => {
const chain = createMiddlewareChain<{ a: number; b: number }, number>(
async ctx => ctx.a + ctx.b
);
const result = await chain.execute({ a: 3, b: 7 });
expect(result).toBe(10);
});
});
describe('single middleware', () => {
it('should execute a single middleware', async () => {
const chain = createMiddlewareChain<{ count: number }>();
chain.use(async (ctx, next) => {
ctx.count *= 2;
return next();
});
const result = await chain.execute({ count: 5 });
expect(result.count).toBe(10);
});
it('should allow middleware to modify return value', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => ctx.value
);
chain.use(async (ctx, next) => {
const result = await next();
return result * 2;
});
const result = await chain.execute({ value: 21 });
expect(result).toBe(42);
});
it('should allow middleware to short-circuit without calling next', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, _next) => {
return { value: 999 };
});
const result = await chain.execute({ value: 1 });
expect(result.value).toBe(999);
});
});
describe('multiple middlewares', () => {
it('should execute middlewares in order', async () => {
const order: number[] = [];
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, next) => {
order.push(1);
const result = await next();
order.push(4);
return result;
});
chain.use(async (_ctx, next) => {
order.push(2);
const result = await next();
order.push(3);
return result;
});
await chain.execute({ value: 0 });
expect(order).toEqual([1, 2, 3, 4]);
});
it('should accumulate modifications through the chain', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx, next) => {
ctx.value += 1;
return next();
});
chain.use(async (ctx, next) => {
ctx.value *= 2;
return next();
});
chain.use(async (ctx, next) => {
ctx.value += 3;
return next();
});
const result = await chain.execute({ value: 0 });
expect(result.value).toBe(5);
});
it('should allow middleware to modify result on the way back', async () => {
const chain = createMiddlewareChain<{ base: number }, number>(
async ctx => ctx.base
);
chain.use(async (_ctx, next) => {
const result = await next();
return result + 10;
});
chain.use(async (_ctx, next) => {
const result = await next();
return result * 2;
});
const result = await chain.execute({ base: 5 });
expect(result).toBe(20);
});
it('should allow middleware to short-circuit in the middle', async () => {
const executed: number[] = [];
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, next) => {
executed.push(1);
return next();
});
chain.use(async () => {
executed.push(2);
return { value: -1 };
});
chain.use(async (_ctx, next) => {
executed.push(3);
return next();
});
const result = await chain.execute({ value: 100 });
expect(result.value).toBe(-1);
expect(executed).toEqual([1, 2]);
});
});
describe('nested next calls', () => {
it('should advance index on each next call, skipping remaining middlewares', async () => {
const chain = createMiddlewareChain<{ counter: number }>();
chain.use(async (_ctx, next) => {
await next();
await next();
});
const result = await chain.execute({ counter: 0 });
expect(result).toBeUndefined();
});
it('should allow middleware to call next conditionally', async () => {
const chain = createMiddlewareChain<{ skip: boolean; value: number }>();
chain.use(async (ctx, next) => {
if (ctx.skip) {
return { value: -1 };
}
return next();
});
chain.use(async (ctx, next) => {
ctx.value += 10;
return next();
});
const resultA = await chain.execute({ skip: true, value: 0 });
const resultB = await chain.execute({ skip: false, value: 0 });
expect(resultA.value).toBe(-1);
expect(resultB.value).toBe(10);
});
it('should handle middleware that awaits next multiple times with a fallback', async () => {
const log: string[] = [];
const chain = createMiddlewareChain<{ value: number }, string[]>(
async _ctx => log
);
chain.use(async (_ctx, next) => {
log.push('before');
await next();
log.push('after-first');
await next();
log.push('after-second');
return log;
});
chain.use(async (_ctx, next) => {
log.push('mw2');
return next();
});
const result = await chain.execute({ value: 0 });
expect(result).toEqual(['before', 'mw2', 'after-first', 'after-second']);
});
it('should return fallback result on second next call when no more middlewares remain', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => ctx.value * 10
);
chain.use(async (_ctx, next) => {
await next();
return await next();
});
chain.use(async (_ctx, next) => {
return next();
});
const result = await chain.execute({ value: 5 });
expect(result).toBe(50);
});
it('should return fallback result on second next call when no more middlewares remain', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => ctx.value * 10
);
chain.use(async (_ctx, next) => {
await next();
return await next();
});
chain.use(async (_ctx, next) => {
return next();
});
const result = await chain.execute({ value: 5 });
expect(result).toBe(50);
});
});
describe('async behavior', () => {
it('should handle async middlewares', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx, next) => {
await new Promise(resolve => setTimeout(resolve, 10));
ctx.value += 1;
return next();
});
chain.use(async (ctx, next) => {
await new Promise(resolve => setTimeout(resolve, 10));
ctx.value += 2;
return next();
});
const result = await chain.execute({ value: 0 });
expect(result.value).toBe(3);
});
it('should handle async fallback', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => {
await new Promise(resolve => setTimeout(resolve, 10));
return ctx.value * 10;
}
);
const result = await chain.execute({ value: 5 });
expect(result).toBe(50);
});
});
describe('error handling', () => {
it('should propagate errors from middleware', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async () => {
throw new Error('middleware error');
});
await expect(chain.execute({ value: 1 })).rejects.toThrow('middleware error');
});
it('should propagate errors from fallback', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async () => {
throw new Error('fallback error');
}
);
await expect(chain.execute({ value: 1 })).rejects.toThrow('fallback error');
});
it('should allow middleware to catch errors from downstream', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, next) => {
try {
return await next();
} catch {
return { value: -1 };
}
});
chain.use(async () => {
throw new Error('downstream error');
});
const result = await chain.execute({ value: 1 });
expect(result.value).toBe(-1);
});
});
describe('return type override', () => {
it('should support different TReturn type than TContext', async () => {
const chain = createMiddlewareChain<{ name: string }, string>(
async ctx => `Hello, ${ctx.name}!`
);
const result = await chain.execute({ name: 'World' });
expect(result).toBe('Hello, World!');
});
it('should allow middleware to transform return type', async () => {
const chain = createMiddlewareChain<{ items: number[] }, number>(
async ctx => ctx.items.reduce((a, b) => a + b, 0)
);
chain.use(async (_ctx, next) => {
const sum = await next();
return sum * 2;
});
const result = await chain.execute({ items: [1, 2, 3] });
expect(result).toBe(12);
});
});
describe('reusability', () => {
it('should reset index on each execute call', async () => {
const chain = createMiddlewareChain<{ count: number }>();
chain.use(async (ctx, next) => {
ctx.count += 1;
return next();
});
const resultA = await chain.execute({ count: 0 });
const resultB = await chain.execute({ count: 0 });
expect(resultA.count).toBe(1);
expect(resultB.count).toBe(1);
});
it('should share middlewares across execute calls', async () => {
const chain = createMiddlewareChain<{ log: string[] }>();
chain.use(async (ctx, next) => {
ctx.log.push('always');
return next();
});
await chain.execute({ log: [] });
await chain.execute({ log: [] });
expect(chain).toBeDefined();
});
});
describe('edge cases', () => {
it('should handle empty context object', async () => {
const chain = createMiddlewareChain<Record<string, never>>();
const result = await chain.execute({});
expect(result).toEqual({});
});
it('should handle middleware that returns a completely different object', async () => {
const chain = createMiddlewareChain<{ x: number }, { y: string }>(
async () => ({ y: 'default' })
);
chain.use(async (_ctx, next) => {
return next();
});
const result = await chain.execute({ x: 42 });
expect(result).toEqual({ y: 'default' });
});
it('should handle middleware that mutates context without returning', async () => {
const chain = createMiddlewareChain<{ value: number }>(
async ctx => ctx
);
chain.use(async (ctx, next) => {
ctx.value = 100;
return next();
});
const result = await chain.execute({ value: 0 });
expect(result.value).toBe(100);
});
it('should return undefined when middleware does not call next or return', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx) => {
ctx.value = 100;
});
const result = await chain.execute({ value: 0 });
expect(result).toBeUndefined();
});
});
});