Search code examples
angulartypescripttestingjasminespecifications

Angular service spec not reaching expectations for observables


Problem

I'm trying to write a spec in jasmine for an Angular service I have. The service wraps the @azure/msal-angular and also uses the Microsoft Graph API to get the logged in user's profile picture. I'm having trouble getting the test to succeed. All I'm trying to do is verify that when the Graph API errors out when asking for a picture (i.e. there is a 404 error), that that error is caught, and the default image from my assets folder is returned instead.

However, I'm getting the following error: enter image description here

Below is my service under test, and the test itself. Any and all help is greatly appreciated!

Service

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { BroadcastService, MsalService } from '@azure/msal-angular';
import { Account, AuthError, AuthResponse, ClientAuthError } from 'msal';
import { Observable, ReplaySubject, throwError } from 'rxjs';
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';

import { GraphUser } from '../../models';
import { LocalBlobService } from '../../services';
import { environment } from './../../../environments/environment';

@Injectable({
    providedIn: 'root',
})
export class AuthService {
    isLoggedIn: boolean;

    /** TODO: Cache locally
     * - clear on logout
     * - how often to refresh?
     */
    graphUser$: Observable<GraphUser>;

    /** TODO: Cache locally
     * - clear on logout
     * - how often to refresh?
     */
    graphPicture$: Observable<SafeUrl>;

    get loginFailure$(): Observable<any> {
        return this._loginFailure$.asObservable();
    }

    get loginSuccess$(): Observable<any> {
        return this._loginSuccess$.asObservable();
    }

    get user(): Account {
        const user = this._msal.getAccount();
        this.isLoggedIn = !!user;
        return user;
    }

    private _loginFailure$ = new ReplaySubject<any>();
    private _loginSuccess$ = new ReplaySubject<any>();

    constructor(
        private readonly _http: HttpClient,
        private readonly _msal: MsalService,
        private readonly _broadcasts: BroadcastService,
        private readonly _sanitizer: DomSanitizer,
        private readonly _localBlob: LocalBlobService
    ) {
        this._msal.handleRedirectCallback(this.redirectCallback);
        this.getGraphUserInfo();

        this._broadcasts.subscribe(
            'msal:loginFailure',
            this.loginFailureCallback
        );

        this._broadcasts.subscribe(
            'msal:loginSuccess',
            this.loginSuccessCallback
        );
    }

    updateUserProfilePhoto(file) {
        return this._http
            .put(environment.AD.pictureUrl, file)
            .pipe(
                catchError((error, caught) => this.handlePictureError(error))
            );
    }

    private getGraphUserInfo() {
        if (this.user) {
            this.graphUser$ = this.getGraphUser();
            this.graphPicture$ = this.getGraphPicture();
        }
    }

    private getGraphPicture(): Observable<SafeUrl> {
        return this._http
            .get(environment.AD.pictureUrl, {
                responseType: 'blob',
            })
            .pipe(
                catchError((error, caught) => this.handlePictureError(error)),
                switchMap(blob => this._localBlob.readAsDataURL(blob)),
                map(picture => this._sanitizer.bypassSecurityTrustUrl(picture)),
                shareReplay(1)
            );
    }

    private getGraphUser(): Observable<GraphUser> {
        return this._http.get<GraphUser>(environment.AD.graphUrl).pipe(
            catchError((error, caught) => throwError(error)),
            shareReplay()
        );
    }

    private loginSuccessCallback = payload => {
        this.isLoggedIn = true;
        this._loginSuccess$.next();
        this.getGraphUserInfo();
    };

    private loginFailureCallback = payload => {
        this.isLoggedIn = false;
        this._loginFailure$.next();
    };

    private redirectCallback = (
        redirectError: AuthError,
        redirectResponse: AuthResponse
    ) => {
        if (redirectError) {
            console.error(redirectError);
            return;
        }
        console.log(redirectResponse);
    };

    private handlePictureError(error: ClientAuthError): Observable<Blob> {
        console.log(error);
        return this._http.get('/assets/images/defaultAvatarSmall.png', {
            responseType: 'blob',
        });
    }
}

Test

it('should return default image if graph API picture fails', (done: DoneFn) => {
    // Arrange
    const spy = spyOn(TestBed.inject(HttpClient), 'get')
        .withArgs(environment.AD.graphUrl)
        .and.callThrough()
        .withArgs(environment.AD.pictureUrl, {
            responseType: 'blob',
        } as any)
        .and.returnValue(throwError(new ClientAuthError('')))
        .withArgs('/assets/images/defaultAvatarSmall.png', {
            responseType: 'blob',
        } as any)
        .and.callThrough();

     // Act
    const service: AuthService = TestBed.inject(AuthService);
    service.graphPicture$.subscribe(
        () => {

            // Assert
            expect(spy.calls.allArgs()).toEqual([
                [environment.AD.graphUrl],
                [
                    environment.AD.pictureUrl,
                    { responseType: 'blob' as 'json' },
                ],
                [
                    '/assets/images/defaultAvatarSmall.png',
                    { responseType: 'blob' as 'json' },
                ],
            ]);
            done();
        },
        error => {
            expect(1).toEqual(1);
            done();
        }
    );
});

Solution

  • I finally figured it out. Today was a long day because of this one...

    I changed getGraphPicture in AuthService to be defined like the following:

    
        private getGraphPicture(): Observable<SafeUrl> {
            // Observable stream of image requests where errors are not emitted.
            return onErrorResumeNext<Blob>(
                // Try the logged in user's pictureUrl first
                this._http.get<Blob>(environment.AD.pictureUrl, {
                    responseType: 'blob' as 'json',
                }),
                // Try the default avatar picture in assets next
                this._http.get<Blob>('/assets/images/defaultAvatarSmall.png', {
                    responseType: 'blob' as 'json',
                })
            ).pipe(
                first(), // Only grab the first successful emission
                switchMap(blob => this._localBlob.readAsDataURL(blob)),
                map(picture => this._sanitizer.bypassSecurityTrustUrl(picture)),
                shareReplay(1)
            );
        }
    
    

    I then wrote my test like this:

    
        it('should return default image if graph API picture fails', fakeAsync(() => {
            // Arrange
            const spy = spyOn(TestBed.inject(HttpClient), 'get')
                .withArgs(environment.AD.graphUrl)
                .and.callThrough()
                .withArgs(environment.AD.pictureUrl, {
                    responseType: 'blob' as 'json',
                })
                .and.returnValue(throwError(new ClientAuthError('')))
                .withArgs('/assets/images/defaultAvatarSmall.png', {
                    responseType: 'blob' as 'json',
                })
                .and.callThrough();
    
            // Act
            const service: AuthService = TestBed.inject(AuthService);
            tick();
    
            expect(spy.calls.allArgs()).toEqual([
                [environment.AD.graphUrl],
                [environment.AD.pictureUrl, { responseType: 'blob' as 'json' }],
                [
                    '/assets/images/defaultAvatarSmall.png',
                    { responseType: 'blob' as 'json' },
                ],
            ]);
        }));