I am porting/rewriting a plain Express app to NestJS. The app is middleware that transforms/passes along requests to an endpoint. I have a fairly simple Express interceptor that sets an auth token header on outgoing requests, but if that fails/expires it will retry with an updated auth token.
@Injectable()
export class TokenInterceptor implements NestInterceptor {
private readonly authUrl = 'https://example.come/tokens/OAuth';
private token = '';
constructor(private readonly http: HttpService) {
//Get the token when the app starts up
this.fetchToken().subscribe((token) => {
this.token = token;
this.http.axiosRef.defaults.headers.common.Authorization = this.token;
});
}
public intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
this.http.axiosRef.defaults.headers.common.Authorization = this.token;
return next.handle().pipe(
catchError(async (error: AxiosError) => {
if (error.config) {
//If we have an auth error, we probably just need to re-fetch the token
if (error.response?.status === HttpStatus.UNAUTHORIZED && error.config.url !== this.authUrl) {
//without second check, we can loop forever
this.token = await lastValueFrom(this.fetchToken());
this.http.axiosRef.defaults.headers.common.Authorization = this.token; //reset default to valid
return of(error.request); //retry request
}
}
//Some other error type, so we want to throw that back
return throwError(() => {
const msg = error.message;
const code = error.status ?? 500;
return new HttpException(msg, code, { cause: error });
});
}),
);
}
private fetchToken(): Observable<string> {
return this.http
.post<ISharepointTokenResponseData>(
this.authUrl,
{/*special params here*/},
)
.pipe(map((res) => `Bearer ${res.data.access_token}`));
}
}
How do I properly write unit tests for all of this? Here is what I have so far, comments added on tests that are not working
describe('TokenInterceptor', () => {
let module: TestingModule;
let interceptor: SharepointTokenInterceptor;
const httpService = mockDeep<HttpService>();
/**
* @description Normally this would be in the `beforeEach` but we need to put a jest spy on http requests,
* so Call this INSIDE each test AFTER setting up HTTP spies.
* It has to be this way because the class constructor kicks off an initial HTTP request, which would happen before our test could run */
const createTestingModule = async () => {
module = await Test.createTestingModule({
providers: [
SharepointTokenInterceptor,
{
provide: HttpService,
useValue: httpService,
},
],
}).compile();
interceptor = module.get(SharepointTokenInterceptor);
};
const provideMockToken = (mockToken: string) => {
jest.spyOn(httpService, 'post').mockReturnValueOnce(
of({
data: { access_token: mockToken },
} as AxiosResponse<ISharepointTokenResponseData>),
);
};
it('should fetch an auth token initially', async () => {
const mockToken = 'my-fake-token';
provideMockToken(mockToken);
await createTestingModule();
//Make sure the correct HTTP calls are made
expect(httpService.post).toHaveBeenCalledTimes(1);
expect(httpService.post).toHaveBeenCalledWith(
'https://example.come/tokens/OAuth',
{/*special params here*/},
);
//Make sure the value returned from the HTTP calls is properly set on the properties we expect
// eslint-disable-next-line @typescript-eslint/dot-notation -- we are being naughty and accessing a private class member so we have to use string indexing to cheat!
expect(interceptor['token']).toEqual(`Bearer ${mockToken}`);
expect(httpService.axiosRef.defaults.headers.common.Authorization).toEqual(`Bearer ${mockToken}`);
});
it('should add auth headers to outgoing requests when we have an auth token', async () => {
provideMockToken('originalToken');
await createTestingModule();
const executionCtxMock = mockDeep<ExecutionContext>();
const nextMock: CallHandler = {
handle: jest.fn(() => of()),
};
//Manually force a new token - this should never happen but this is just insurance to know that we do set the token on each outgoing request
// eslint-disable-next-line @typescript-eslint/dot-notation -- we have to use string indexing to cheat!
interceptor['token'] = `Bearer new-token`;
interceptor.intercept(executionCtxMock, nextMock);
expect(httpService.axiosRef.defaults.headers.common.Authorization).toEqual(`Bearer new-token`);
expect(nextMock.handle).toHaveBeenCalledTimes(1);
expect(nextMock.handle).toHaveBeenCalledWith();
});
describe('Error Handling', () => {
beforeEach(async () => {
provideMockToken('example-token');
await createTestingModule();
});
//==================================================================
//This test fails because it returns success in the observable, even though it's using throwError!
//I am not sure what is going on here.
//Is my test wrong, or am I re-throwing an error wrong in the interceptor?
it('should pass the error through for non-401 errors', (done: jest.DoneCallback) => {
const executionCtxMock = mockDeep<ExecutionContext>();
const nextMock: CallHandler = {
handle: () =>
throwError(
() =>
new AxiosError('you screwed up!', '403', {
//@ts-expect-error -- we don't need the headers for this test, so this is OK
headers: {},
url: 'https://ytmnd.com',
}),
),
};
interceptor.intercept(executionCtxMock, nextMock).subscribe({
next: (res) => {
console.log(res); //This logs out the exception even though this is in the success block!
//This is not supposed to succeed!
expect(false).toEqual(true);
done();
},
error: (err: unknown) => {
expect(err).toEqual(new BadRequestException('you screwed up!'));
done();
},
});
});
xit('should refetch the auth token when we get a 401 "unauthorized" response and add that new token to outgoing requests', (done: jest.DoneCallback) => {
//Haven't gotten to this one yet
});
xit('should NOT refetch the auth token when we get a 401 "unauthorized" response and the URL is the URL for making an auth request', (done: jest.DoneCallback) => {
//Haven't gotten to this one yet
});
});
});
So apparently I just needed to directly return the error instead of returning throwError
because that would be an inner/nested observable
public intercept(_context: ExecutionContext, next: CallHandler): Observable<Request | HttpException> {
this.http.axiosRef.defaults.headers.common.Authorization = this.token;
return next.handle().pipe(
catchError(async (error: AxiosError) => {
if (error.config) {
//If we have an auth error, we probably just need to re-fetch the token
if (error.response?.status === HttpStatus.UNAUTHORIZED && error.config.url !== this.authUrl) {
//without second check, we can loop forever
this.token = await lastValueFrom(this.fetchToken());
this.http.axiosRef.defaults.headers.common.Authorization = this.token; //reset default to valid
return error.request as Request; //retry request
}
}
//Some other error type, so we want to throw that back
const msg = error.message;
const code = error.status ?? 500;
return new HttpException(msg, code, { cause: error });
}),
);
}
And then my test needs to just get that value back and expect it to be an error
it('should pass the error through for non-401 errors', async () => {
provideMockTokens('flavor-blasted-goldfish');
await createTestingModule();
const mockError = new AxiosError(
'You have been very bad to request such a thing like this!',
'idk what this is',
//@ts-expect-error - we don't care about the headers being incorrect here
{ url: expectedAuthUrl, headers: {} },
{},
{ status: HttpStatus.BAD_REQUEST },
);
const expectedError = new HttpException(mockError.message, HttpStatus.BAD_REQUEST, { cause: mockError });
const executionCtxMock = mockDeep<ExecutionContext>();
const nextMock: CallHandler = {
handle: jest.fn().mockReturnValueOnce(throwError(() => mockError)),
};
const val = await lastValueFrom(interceptor.intercept(executionCtxMock, nextMock));
expect(val).toEqual(expectedError);
expect(nextMock.handle).toHaveBeenCalledTimes(1);
});
I don't fully understand this because I would expect the observable to have failed when I returned an error or a throwError
, but it succeeds with the error as the returned result. Well, whatever, as long as it all works within Nest!