Rein Baarsma
Rein Baarsma

Reputation: 1536

Angular 7 testing retryWhen with mock http requests fails to actually retry

I have the following interceptor that tries to use a OAuth refresh_token whenever any 401 (error) response is obtained.

Basically a refresh token is obtained on the first 401 request and after it is obtained, the code waits 2,5 seconds. In most cases the second request will not trigger an error, but if it does (token couldn't be refreshed or whatever), the user is redirect to the login page.

export class RefreshAuthenticationInterceptor implements HttpInterceptor {
    constructor(
        private router: Router,
        private tokenService: TokenService,
    ) {}

    public intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        return next.handle(request)
            .pipe(
                // this catches 401 requests and tries to refresh the auth token and try again.
                retryWhen(errors => {
                    // this subject is returned to retryWhen
                    const subject = new Subject();

                    // didn't know a better way to keep track if this is the first
                    let first = true;

                    errors.subscribe((errorStatus) => {
                        // first time either pass the error through or get new token
                        if (first) {
this.authenticationService.authTokenGet('refresh_token', environment.clientId, environment.clientSecret, this.tokenService.getToken().refresh_token).subscribe((token: OauthAccessToken) => {
                                this.tokenService.save(token);
                            });

                        // second time still error means redirect to login
                        } else {
                            this.router.navigateByUrl('/auth/login')
                                .then(() => subject.complete());

                            return;
                        }

                        // and of course make sure the second time is indeed seen as second time
                        first = false;

                        // trigger retry after 2,5 second to give ample time for token request to succeed
                        setTimeout(() => subject.next(), 2500);
                    });

                    return subject;
                }),
    }
}

The problem lies within the test. Everything works, except for the final check if the router was actually nagivated to /auth/login. It isn't, so the test fails.

With debugging, I know for sure the setTimeout callback is executed, but the subject.next() does not seem to start a new request.

I read somewhere that when normally using rxjs retry() on http mock requests, you should flush the request again. This is commented out in the code below, but gives a "Cannot flush a cancelled request."

    it('should catch 401 invalid_grant errors to try to refresh token the first time, redirect to login the second', fakeAsync(inject([HttpClient, HttpTestingController], (http: HttpClient, mock: HttpTestingController) => {
        const oauthAccessToken: OauthAccessToken = {
            // ...
        };
        authenticationService.authTokenGet.and.returnValue(of(oauthAccessToken));
        tokenService.getToken.and.returnValue(oauthAccessToken);

        // first request
        http.get('/api');

        const req = mock.expectOne('/api');
        req.flush({error: 'invalid_grant'}, {
            status: 401,
            statusText: 'Unauthorized'
        });

        expect(authenticationService.authTokenGet).toHaveBeenCalled();

        // second request
        authenticationService.authTokenGet.calls.reset();

        // req.flush({error: 'invalid_grant'}, {
        //    status: 401,
        //    statusText: 'Unauthorized'
        // });

        tick(2500);
        expect(authenticationService.authTokenGet).not.toHaveBeenCalled();
        expect(router.navigateByUrl).toHaveBeenCalledWith('/auth/login');

        mock.verify();
    })));

Does anyone know how to fix this test?

PS: Any pointers on the code itself are also welcome :)

Upvotes: 4

Views: 3310

Answers (1)

Rein Baarsma
Rein Baarsma

Reputation: 1536

Eventually I refactored the code to not use the first trick above, which helped me solve the problem.

For anyone else struggling with retryWhen and unit tests, here's my final code:

The code in the interceptor (simplified)

retryWhen((errors: Observable<any>) => errors.pipe(
    flatMap((error, index) => {
        // any other error than 401 with {error: 'invalid_grant'} should be ignored by this retryWhen
        if (!error.status || error.status !== 401 || error.error.error !== 'invalid_grant') {
            return throwError(error);
        }

        if (index === 0) {
            // first time execute refresh token logic...
        } else {
            this.router.navigateByUrl('/auth/login');
        }

        return of(error).pipe(delay(2500));
    }),
    take(2) // first request should refresh token and retry, if there's still an error the second time is the last time and should navigate to login
) ),

The code in the unit test:

it('should catch 401 invalid_grant errors to try to refresh token the first time, redirect to login the second', fakeAsync(inject([HttpClient, HttpTestingController], (http: HttpClient, mock: HttpTestingController) => {    
    // first request
    http.get('/api').subscribe();

    const req = mock.expectOne('/api');
    req.flush({error: 'invalid_grant'}, {
        status: 401,
        statusText: 'Unauthorized'
    });

    // the expected delay of 2500 after the first retry 
    tick(2500);

    // second request also unauthorized, should lead to redirect to /auth/login
    const req2 = mock.expectOne('/api');
    req2.flush({error: 'invalid_grant'}, {
        status: 401,
        statusText: 'Unauthorized'
    });

    expect(router.navigateByUrl).toHaveBeenCalledWith('/auth/login');

    // somehow the take(2) will have another delay for another request, which is cancelled before it is executed.. maybe someone else would know how to fix this properly.. but I don't really care anymore at this point ;)
    tick(2500);

    const req3 = mock.expectOne('/api');
    expect(req3.cancelled).toBeTruthy();

    mock.verify();
})));

Upvotes: 4

Related Questions