mirror of
https://github.com/nestjs/nest.git
synced 2026-02-21 23:11:44 +00:00
Add comprehensive unit tests for ClassSerializerInterceptor which previously had no test coverage despite being a public API. Test coverage includes: - constructor with custom transformer package injection - intercept() with RxJS pipe and context options merging - serialize() for arrays, objects, and StreamableFile handling - transformToPlain() with type conversion and instanceOf checks - getContextOptions() with metadata precedence (handler > class) - Edge cases: null/undefined, mixed arrays, Date objects, primitives Total: 25+ test cases covering all public methods and edge cases.
469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
import { expect } from 'chai';
|
|
import * as sinon from 'sinon';
|
|
import { of, throwError } from 'rxjs';
|
|
import { ClassSerializerInterceptor } from '../../serializer/class-serializer.interceptor';
|
|
import { ExecutionContext, CallHandler } from '../../interfaces';
|
|
import { StreamableFile } from '../../file-stream';
|
|
|
|
describe('ClassSerializerInterceptor', () => {
|
|
let interceptor: ClassSerializerInterceptor;
|
|
let mockReflector: any;
|
|
let mockTransformerPackage: any;
|
|
let sandbox: sinon.SinonSandbox;
|
|
|
|
beforeEach(() => {
|
|
sandbox = sinon.createSandbox();
|
|
mockReflector = {
|
|
getAllAndOverride: sandbox.stub(),
|
|
};
|
|
mockTransformerPackage = {
|
|
classToPlain: sandbox.stub(),
|
|
plainToInstance: sandbox.stub(),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
sandbox.restore();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should create interceptor with default transformer package', () => {
|
|
// This would normally load 'class-transformer' package
|
|
// For testing, we pass a mock transformer package
|
|
const options = {
|
|
transformerPackage: mockTransformerPackage,
|
|
};
|
|
|
|
interceptor = new ClassSerializerInterceptor(mockReflector, options);
|
|
|
|
expect(interceptor).to.be.instanceOf(ClassSerializerInterceptor);
|
|
});
|
|
|
|
it('should use provided transformer package from options', () => {
|
|
const customTransformer = {
|
|
classToPlain: sandbox.stub(),
|
|
plainToInstance: sandbox.stub(),
|
|
};
|
|
|
|
const options = {
|
|
transformerPackage: customTransformer,
|
|
};
|
|
|
|
interceptor = new ClassSerializerInterceptor(mockReflector, options);
|
|
|
|
expect(interceptor).to.be.instanceOf(ClassSerializerInterceptor);
|
|
});
|
|
});
|
|
|
|
describe('intercept', () => {
|
|
let mockExecutionContext: ExecutionContext;
|
|
let mockCallHandler: CallHandler;
|
|
|
|
beforeEach(() => {
|
|
interceptor = new ClassSerializerInterceptor(mockReflector, {
|
|
transformerPackage: mockTransformerPackage,
|
|
});
|
|
|
|
mockExecutionContext = {
|
|
getHandler: sandbox.stub(),
|
|
getClass: sandbox.stub(),
|
|
} as any;
|
|
|
|
mockCallHandler = {
|
|
handle: sandbox.stub(),
|
|
} as any;
|
|
});
|
|
|
|
it('should transform plain object response', done => {
|
|
const response = { id: 1, name: 'Test' };
|
|
const transformedResponse = { id: 1, name: 'Test' };
|
|
|
|
mockReflector.getAllAndOverride.returns(undefined);
|
|
mockTransformerPackage.classToPlain.returns(transformedResponse);
|
|
(mockCallHandler.handle as sinon.SinonStub).returns(of(response));
|
|
|
|
interceptor
|
|
.intercept(mockExecutionContext, mockCallHandler)
|
|
.subscribe(result => {
|
|
expect(result).to.equal(transformedResponse);
|
|
done();
|
|
});
|
|
});
|
|
|
|
it('should transform array of objects', done => {
|
|
const response = [
|
|
{ id: 1, name: 'Test1' },
|
|
{ id: 2, name: 'Test2' },
|
|
];
|
|
const transformedItem1 = { id: 1, name: 'Test1' };
|
|
const transformedItem2 = { id: 2, name: 'Test2' };
|
|
|
|
mockReflector.getAllAndOverride.returns(undefined);
|
|
mockTransformerPackage.classToPlain
|
|
.onFirstCall()
|
|
.returns(transformedItem1);
|
|
mockTransformerPackage.classToPlain
|
|
.onSecondCall()
|
|
.returns(transformedItem2);
|
|
(mockCallHandler.handle as sinon.SinonStub).returns(of(response));
|
|
|
|
interceptor
|
|
.intercept(mockExecutionContext, mockCallHandler)
|
|
.subscribe(result => {
|
|
expect(result).to.be.an('array').with.lengthOf(2);
|
|
expect(result[0]).to.equal(transformedItem1);
|
|
expect(result[1]).to.equal(transformedItem2);
|
|
done();
|
|
});
|
|
});
|
|
|
|
it('should merge context options with default options', done => {
|
|
const response = { id: 1, name: 'Test' };
|
|
const defaultOptions = { excludeExtraneousValues: true };
|
|
const contextOptions = { groups: ['user'] };
|
|
const transformedResponse = { id: 1 };
|
|
|
|
interceptor = new ClassSerializerInterceptor(mockReflector, {
|
|
transformerPackage: mockTransformerPackage,
|
|
...defaultOptions,
|
|
});
|
|
|
|
mockReflector.getAllAndOverride.returns(contextOptions);
|
|
mockTransformerPackage.classToPlain.returns(transformedResponse);
|
|
(mockCallHandler.handle as sinon.SinonStub).returns(of(response));
|
|
|
|
interceptor
|
|
.intercept(mockExecutionContext, mockCallHandler)
|
|
.subscribe(result => {
|
|
const callArgs = mockTransformerPackage.classToPlain.getCall(0).args;
|
|
expect(callArgs[1]).to.deep.include(defaultOptions);
|
|
expect(callArgs[1]).to.deep.include(contextOptions);
|
|
done();
|
|
});
|
|
});
|
|
|
|
it('should call reflector with handler and class', done => {
|
|
const response = { id: 1 };
|
|
const mockHandler = {};
|
|
const mockClass = {};
|
|
|
|
(mockExecutionContext.getHandler as sinon.SinonStub).returns(mockHandler);
|
|
(mockExecutionContext.getClass as sinon.SinonStub).returns(mockClass);
|
|
mockReflector.getAllAndOverride.returns(undefined);
|
|
mockTransformerPackage.classToPlain.returns(response);
|
|
(mockCallHandler.handle as sinon.SinonStub).returns(of(response));
|
|
|
|
interceptor
|
|
.intercept(mockExecutionContext, mockCallHandler)
|
|
.subscribe(() => {
|
|
expect(mockReflector.getAllAndOverride.calledOnce).to.be.true;
|
|
const args = mockReflector.getAllAndOverride.getCall(0).args;
|
|
expect(args[1]).to.deep.equal([mockHandler, mockClass]);
|
|
done();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('serialize', () => {
|
|
beforeEach(() => {
|
|
interceptor = new ClassSerializerInterceptor(mockReflector, {
|
|
transformerPackage: mockTransformerPackage,
|
|
});
|
|
});
|
|
|
|
it('should return primitive values unchanged', () => {
|
|
expect(interceptor.serialize('string' as any, {})).to.equal('string');
|
|
expect(interceptor.serialize(123 as any, {})).to.equal(123);
|
|
expect(interceptor.serialize(true as any, {})).to.equal(true);
|
|
});
|
|
|
|
it('should return null unchanged', () => {
|
|
expect(interceptor.serialize(null as any, {})).to.be.null;
|
|
});
|
|
|
|
it('should return undefined unchanged', () => {
|
|
expect(interceptor.serialize(undefined as any, {})).to.be.undefined;
|
|
});
|
|
|
|
it('should return StreamableFile unchanged', () => {
|
|
const streamableFile = new StreamableFile(Buffer.from('test'));
|
|
const result = interceptor.serialize(streamableFile as any, {});
|
|
|
|
expect(result).to.equal(streamableFile);
|
|
expect(mockTransformerPackage.classToPlain.called).to.be.false;
|
|
});
|
|
|
|
it('should transform plain object', () => {
|
|
const input = { id: 1, name: 'Test' };
|
|
const output = { id: 1 };
|
|
|
|
mockTransformerPackage.classToPlain.returns(output);
|
|
|
|
const result = interceptor.serialize(input, {});
|
|
|
|
expect(result).to.equal(output);
|
|
expect(mockTransformerPackage.classToPlain.calledOnce).to.be.true;
|
|
});
|
|
|
|
it('should transform array of objects', () => {
|
|
const input = [{ id: 1 }, { id: 2 }];
|
|
const output1 = { id: 1 };
|
|
const output2 = { id: 2 };
|
|
|
|
mockTransformerPackage.classToPlain.onFirstCall().returns(output1);
|
|
mockTransformerPackage.classToPlain.onSecondCall().returns(output2);
|
|
|
|
const result = interceptor.serialize(input, {});
|
|
|
|
expect(result).to.be.an('array').with.lengthOf(2);
|
|
expect(result[0]).to.equal(output1);
|
|
expect(result[1]).to.equal(output2);
|
|
expect(mockTransformerPackage.classToPlain.calledTwice).to.be.true;
|
|
});
|
|
|
|
it('should handle empty array', () => {
|
|
const input: any[] = [];
|
|
|
|
const result = interceptor.serialize(input, {});
|
|
|
|
expect(result).to.be.an('array').that.is.empty;
|
|
expect(mockTransformerPackage.classToPlain.called).to.be.false;
|
|
});
|
|
});
|
|
|
|
describe('transformToPlain', () => {
|
|
beforeEach(() => {
|
|
interceptor = new ClassSerializerInterceptor(mockReflector, {
|
|
transformerPackage: mockTransformerPackage,
|
|
});
|
|
});
|
|
|
|
it('should return falsy values unchanged', () => {
|
|
expect(interceptor.transformToPlain(null, {})).to.be.null;
|
|
expect(interceptor.transformToPlain(undefined, {})).to.be.undefined;
|
|
expect(interceptor.transformToPlain(0 as any, {})).to.equal(0);
|
|
expect(interceptor.transformToPlain(false as any, {})).to.be.false;
|
|
expect(interceptor.transformToPlain('' as any, {})).to.equal('');
|
|
});
|
|
|
|
it('should use classToPlain when no type option provided', () => {
|
|
const input = { id: 1, name: 'Test' };
|
|
const output = { id: 1 };
|
|
|
|
mockTransformerPackage.classToPlain.returns(output);
|
|
|
|
const result = interceptor.transformToPlain(input, {});
|
|
|
|
expect(result).to.equal(output);
|
|
expect(mockTransformerPackage.classToPlain.calledOnceWith(input, {})).to
|
|
.be.true;
|
|
expect(mockTransformerPackage.plainToInstance.called).to.be.false;
|
|
});
|
|
|
|
it('should use classToPlain when input is instance of options.type', () => {
|
|
class UserDto {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
const input = new UserDto();
|
|
input.id = 1;
|
|
input.name = 'Test';
|
|
|
|
const output = { id: 1 };
|
|
const options = { type: UserDto };
|
|
|
|
mockTransformerPackage.classToPlain.returns(output);
|
|
|
|
const result = interceptor.transformToPlain(input, options);
|
|
|
|
expect(result).to.equal(output);
|
|
expect(mockTransformerPackage.classToPlain.calledOnce).to.be.true;
|
|
expect(mockTransformerPackage.plainToInstance.called).to.be.false;
|
|
});
|
|
|
|
it('should convert plain to instance then to plain when type provided but not matching', () => {
|
|
class UserDto {
|
|
id: number;
|
|
}
|
|
|
|
const plainInput = { id: 1, name: 'Test', password: 'secret' };
|
|
const instanceOutput = new UserDto();
|
|
instanceOutput.id = 1;
|
|
|
|
const finalOutput = { id: 1 };
|
|
const options = { type: UserDto };
|
|
|
|
mockTransformerPackage.plainToInstance.returns(instanceOutput);
|
|
mockTransformerPackage.classToPlain.returns(finalOutput);
|
|
|
|
const result = interceptor.transformToPlain(plainInput, options);
|
|
|
|
expect(result).to.equal(finalOutput);
|
|
expect(
|
|
mockTransformerPackage.plainToInstance.calledOnceWith(
|
|
UserDto,
|
|
plainInput,
|
|
options,
|
|
),
|
|
).to.be.true;
|
|
expect(
|
|
mockTransformerPackage.classToPlain.calledOnceWith(
|
|
instanceOutput,
|
|
options,
|
|
),
|
|
).to.be.true;
|
|
});
|
|
|
|
it('should handle complex nested objects', () => {
|
|
const input = {
|
|
user: { id: 1, name: 'Test' },
|
|
posts: [{ id: 1, title: 'Post 1' }],
|
|
};
|
|
const output = {
|
|
user: { id: 1 },
|
|
posts: [{ id: 1 }],
|
|
};
|
|
|
|
mockTransformerPackage.classToPlain.returns(output);
|
|
|
|
const result = interceptor.transformToPlain(input, {});
|
|
|
|
expect(result).to.equal(output);
|
|
});
|
|
});
|
|
|
|
describe('getContextOptions', () => {
|
|
beforeEach(() => {
|
|
interceptor = new ClassSerializerInterceptor(mockReflector, {
|
|
transformerPackage: mockTransformerPackage,
|
|
});
|
|
});
|
|
|
|
it('should call reflector getAllAndOverride with correct arguments', () => {
|
|
const mockHandler = {};
|
|
const mockClass = {};
|
|
const mockContext = {
|
|
getHandler: sandbox.stub().returns(mockHandler),
|
|
getClass: sandbox.stub().returns(mockClass),
|
|
} as any;
|
|
|
|
const expectedOptions = { groups: ['admin'] };
|
|
mockReflector.getAllAndOverride.returns(expectedOptions);
|
|
|
|
const result = (interceptor as any).getContextOptions(mockContext);
|
|
|
|
expect(mockReflector.getAllAndOverride.calledOnce).to.be.true;
|
|
const callArgs = mockReflector.getAllAndOverride.getCall(0).args;
|
|
expect(callArgs[1]).to.deep.equal([mockHandler, mockClass]);
|
|
expect(result).to.equal(expectedOptions);
|
|
});
|
|
|
|
it('should return undefined when no metadata exists', () => {
|
|
const mockContext = {
|
|
getHandler: sandbox.stub().returns({}),
|
|
getClass: sandbox.stub().returns({}),
|
|
} as any;
|
|
|
|
mockReflector.getAllAndOverride.returns(undefined);
|
|
|
|
const result = (interceptor as any).getContextOptions(mockContext);
|
|
|
|
expect(result).to.be.undefined;
|
|
});
|
|
|
|
it('should respect handler metadata over class metadata', () => {
|
|
const mockHandler = {};
|
|
const mockClass = {};
|
|
const mockContext = {
|
|
getHandler: sandbox.stub().returns(mockHandler),
|
|
getClass: sandbox.stub().returns(mockClass),
|
|
} as any;
|
|
|
|
// getAllAndOverride should merge with handler taking precedence
|
|
const handlerOptions = {
|
|
groups: ['user'],
|
|
excludeExtraneousValues: true,
|
|
};
|
|
mockReflector.getAllAndOverride.returns(handlerOptions);
|
|
|
|
const result = (interceptor as any).getContextOptions(mockContext);
|
|
|
|
expect(result).to.deep.equal(handlerOptions);
|
|
// Verify it's checking handler first, then class
|
|
const callArgs = mockReflector.getAllAndOverride.getCall(0).args;
|
|
expect(callArgs[1][0]).to.equal(mockHandler);
|
|
expect(callArgs[1][1]).to.equal(mockClass);
|
|
});
|
|
});
|
|
|
|
describe('edge cases and error handling', () => {
|
|
beforeEach(() => {
|
|
interceptor = new ClassSerializerInterceptor(mockReflector, {
|
|
transformerPackage: mockTransformerPackage,
|
|
});
|
|
});
|
|
|
|
it('should handle array with mixed types', () => {
|
|
const input = [
|
|
{ id: 1, name: 'Test' },
|
|
null,
|
|
undefined,
|
|
{ id: 2, name: 'Test2' },
|
|
];
|
|
|
|
mockTransformerPackage.classToPlain
|
|
.onCall(0)
|
|
.returns({ id: 1, name: 'Test' });
|
|
mockTransformerPackage.classToPlain.onCall(1).returns(null);
|
|
mockTransformerPackage.classToPlain.onCall(2).returns(undefined);
|
|
mockTransformerPackage.classToPlain
|
|
.onCall(3)
|
|
.returns({ id: 2, name: 'Test2' });
|
|
|
|
const result = interceptor.serialize(input, {});
|
|
|
|
expect(result).to.be.an('array').with.lengthOf(4);
|
|
expect(result[1]).to.be.null;
|
|
expect(result[2]).to.be.undefined;
|
|
});
|
|
|
|
it('should not transform when response is not an object', () => {
|
|
const input = 'plain string';
|
|
|
|
const result = interceptor.serialize(input as any, {});
|
|
|
|
expect(result).to.equal(input);
|
|
expect(mockTransformerPackage.classToPlain.called).to.be.false;
|
|
});
|
|
|
|
it('should handle Date objects', () => {
|
|
const date = new Date();
|
|
const output = { date: date.toISOString() };
|
|
|
|
mockTransformerPackage.classToPlain.returns(output);
|
|
|
|
const result = interceptor.serialize({ date } as any, {});
|
|
|
|
expect(mockTransformerPackage.classToPlain.calledOnce).to.be.true;
|
|
});
|
|
|
|
it('should pass through options to transformer', () => {
|
|
const input = { id: 1, name: 'Test', password: 'secret' };
|
|
const options = {
|
|
excludeExtraneousValues: true,
|
|
groups: ['public'],
|
|
strategy: 'excludeAll',
|
|
};
|
|
|
|
mockTransformerPackage.classToPlain.returns({ id: 1, name: 'Test' });
|
|
|
|
interceptor.transformToPlain(input, options as any);
|
|
|
|
expect(mockTransformerPackage.classToPlain.calledOnce).to.be.true;
|
|
const callArgs = mockTransformerPackage.classToPlain.getCall(0).args;
|
|
expect(callArgs[1]).to.deep.include(options);
|
|
});
|
|
});
|
|
});
|