From d88856c3fa0ebadef490a46b44caeacf7eec8e4b Mon Sep 17 00:00:00 2001 From: Lee Donghyun Date: Mon, 26 Jan 2026 00:38:25 +0900 Subject: [PATCH] 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. --- .../class-serializer.interceptor.spec.ts | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 packages/common/test/serializer/class-serializer.interceptor.spec.ts diff --git a/packages/common/test/serializer/class-serializer.interceptor.spec.ts b/packages/common/test/serializer/class-serializer.interceptor.spec.ts new file mode 100644 index 000000000..5ebc74fbc --- /dev/null +++ b/packages/common/test/serializer/class-serializer.interceptor.spec.ts @@ -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); + }); + }); +});