Search code examples
typescriptaxiosjestjsrxjsnestjs

Unit test Axios HttpService pipe in NestJS Middleware


The middleware fetches a json document from a microservice endpoint and attaches it to the request.

The good path test is ok, but I can't get the bad path test to throw the ForbiddenException and prevent it from calling next().

Outside of a Jest test, the middleware will block a request if it fails to fetch the json document.

Any help is appreciated :)

Middleware:

import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common'
import { HttpService } from '@nestjs/axios'
import { NextFunction, Request, Response } from 'express'
import { catchError, firstValueFrom } from 'rxjs'
import { LoggerService } from '../logger.service'

@Injectable()
export class MyMiddleware implements NestMiddleware {
  constructor(
    private logger: LoggerService,
    private httpService: HttpService,
  ) {}

  async use(req: Request, res: Response, next: NextFunction) {

    const response = await firstValueFrom(
      this.httpService
        .get('https://myservice/document-endpoint', {
          headers: {
            Cookie: req.headers['cookie'],
          },
        })
        .pipe(
          catchError((error: Error) => {
            this.logger.error(error.message);
            throw new ForbiddenException()
          }),
        ),
    )

    req.document = response.data.value
    next()
  }
}

Tests:

'Bad path' test is the problem ... the nextFunction method is getting called and I can't manage to get it to throw the error.

import { MyMiddleware } from './mymiddleware'
import { NextFunction, Request, Response } from 'express'
import { LoggerService } from '../logger/logger.service'
import { of } from 'rxjs'
import { createMock } from '@golevelup/nestjs-testing'
import { HttpService } from '@nestjs/axios'
import { AxiosResponse } from 'axios'

describe('Authorization middleware', () => {
  let middleware: MyMiddleware
  let mockRequest: Partial<Request>
  let mockResponse: Partial<Response>
  let nextFunction: NextFunction = jest.fn()
  let mockHttpService = createMock<HttpService>()
  
  mockRequest = {
    headers: {
      cookie: 'idToken=asdasdasd'
    }

  beforeEach(async () => {
    mockResponse = {
      json: jest.fn(),
    }

    middleware = new MyMiddleware(new LoggerService(), mockHttpService)
  })

  test('bad path', async () => {
    jest.clearAllMocks()

    const mockDocumentResponse: AxiosResponse = {
      status: 404, 
      statusText: '',
      headers: {},
      config: {},
      data: { error: 'Not Found' }
    }

    const httpSpy = jest.spyOn(mockHttpService, 'get')
      .mockReturnValue(of(mockDocumentResponse))

    await middleware.use(mockRequest as Request, mockResponse as Response, nextFunction)
    expect(nextFunction).toBeCalledTimes(0)
  })


  test('good path', async () => {
    jest.clearAllMocks()

    const mockDocumentResponse: AxiosResponse = {
      status: 200, 
      statusText: '',
      headers: {},
      config: {},
      data: {
        value: 'example document',
      }
    }

    jest.spyOn(mockHttpService, 'get').mockImplementationOnce(() => of(mockDocumentResponse));
    await middleware.use(mockRequest as Request, mockResponse as Response, nextFunction)
    expect(nextFunction).toBeCalledTimes(1)
  })

})

Solution

  • I never got around to posting the solution, but it seems like the question gets a few visits so here it is.

    The key point is that NestJS integrates RxJS as a first class citizen and embraces the concept of Observables. The HttpService (a wrapper around axios) returns Observables by default.

    Observables are different to promises (https://rxjs.dev/guide/observable), and in the middleware code this is dealt with by using the firstValueFrom method (imported from RxJS), which converts the Observable into a regular javascript promise.

    So, when the 'bad path' is being tested, we are simulating an error using Observables. This can be done by returning a new Observable that immediately throws an error using the s.error(err) method.

    import { Observable, of } from 'rxjs'
    ...
    
    test('bad path', async () => {
      jest.clearAllMocks()
    
      const err = { response: 'resp', status: '500' }
    
      // Instead of returning an error response, we simulate throwing an error.
      const httpSpy = jest.spyOn(mockHttpService, 'get')
        .mockImplementationOnce(() => new Observable(subscriber => subscriber.error(err)))
    
      // Handle the expected ForbiddenException
      await middleware.use(mockRequest as Request, mockResponse as Response, nextFunction)
        .catch((error) => {
          expect(error).toBeInstanceOf(ForbiddenException)
          expect(nextFunction).toBeCalledTimes(0)
        })
    })