test(common): add unit tests for class serializer interceptor

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.
This commit is contained in:
Lee Donghyun
2026-01-26 00:38:25 +09:00
parent 909b504d2d
commit d88856c3fa

View File

@@ -0,0 +1,468 @@
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);
});
});
});