diff options
10 files changed, 148 insertions, 88 deletions
diff --git a/Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts b/Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts index 72707c5e..40484387 100644 --- a/Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts +++ b/Timeline/ClientApp/src/app/test-utilities/activated-route.mock.ts @@ -3,9 +3,7 @@ import { ParamMap, ActivatedRouteSnapshot, ActivatedRoute } from '@angular/route import { Observable, BehaviorSubject } from 'rxjs'; import { map } from 'rxjs/operators'; -export type PartialMock<T> = { - [P in keyof T]?: T[P] | PartialMock<T[P]>; -}; +import { PartialMock } from './mock'; export interface ParamMapCreator { [name: string]: string | string[]; } diff --git a/Timeline/ClientApp/src/app/test-utilities/mock.ts b/Timeline/ClientApp/src/app/test-utilities/mock.ts new file mode 100644 index 00000000..c3e368f0 --- /dev/null +++ b/Timeline/ClientApp/src/app/test-utilities/mock.ts @@ -0,0 +1,7 @@ +export type Mock<T> = { + [P in keyof T]: T[P] extends Function ? T[P] : T[P] | Mock<T[P]>; +}; + +export type PartialMock<T> = { + [P in keyof T]?: T[P] extends Function ? T[P] : T[P] | PartialMock<T[P]> | Mock<T[P]>; +}; diff --git a/Timeline/ClientApp/src/app/test-utilities/storage.mock.ts b/Timeline/ClientApp/src/app/test-utilities/storage.mock.ts new file mode 100644 index 00000000..0ba5aa35 --- /dev/null +++ b/Timeline/ClientApp/src/app/test-utilities/storage.mock.ts @@ -0,0 +1,28 @@ +import { Mock } from './mock'; +import { nullIfUndefined } from '../utilities/language-untilities'; + +export function createMockStorage(): Mock<Storage> { + const map: { [key: string]: string } = {}; + return { + get length(): number { + return Object.keys(map).length; + }, + key(index: number): string | null { + const keys = Object.keys(map); + if (index >= keys.length) { return null; } + return keys[index]; + }, + clear() { + Object.keys(map).forEach(key => delete map.key); + }, + getItem(key: string): string | null { + return nullIfUndefined(map[key]); + }, + setItem(key: string, value: string) { + map[key] = value; + }, + removeItem(key: string) { + delete map[key]; + } + }; +} diff --git a/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts index 613a8fa6..be6631eb 100644 --- a/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts +++ b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts @@ -1,47 +1,95 @@ -import { TestBed } from '@angular/core/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { HttpRequest } from '@angular/common/http'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpClientTestingModule, HttpTestingController, TestRequest } from '@angular/common/http/testing'; import { Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material'; + +import { Mock } from 'src/app/test-utilities/mock'; +import { createMockStorage } from 'src/app/test-utilities/storage.mock'; +import { WINDOW } from '../window-inject-token'; import { UserInfo, UserCredentials } from '../entities'; import { createTokenUrl, validateTokenUrl, CreateTokenRequest, CreateTokenResponse, ValidateTokenRequest, ValidateTokenResponse } from './http-entities'; -import { InternalUserService, UserLoginState } from './internal-user.service'; +import { InternalUserService, SnackBarTextKey, snackBarText, TOKEN_STORAGE_KEY } from './internal-user.service'; +import { repeat } from 'src/app/utilities/language-untilities'; -describe('InternalUserService', () => { - const mockUserCredentials: UserCredentials = { - username: 'user', - password: 'user' - }; - beforeEach(() => TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: Router, useValue: null }] - })); +describe('InternalUserService', () => { + let mockLocalStorage: Mock<Storage>; + let mockSnackBar: jasmine.SpyObj<MatSnackBar>; + + beforeEach(() => { + mockLocalStorage = createMockStorage(); + mockSnackBar = jasmine.createSpyObj('MatSnackBar', ['open']); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { provide: WINDOW, useValue: { localStorage: mockLocalStorage } }, + { provide: Router, useValue: null }, + { provide: MatSnackBar, useValue: mockSnackBar } + ] + }); + }); it('should be created', () => { const service: InternalUserService = TestBed.get(InternalUserService); expect(service).toBeTruthy(); }); - it('should be nologin at first', () => { - const service: InternalUserService = TestBed.get(InternalUserService); - expect(service.currentUserInfo).toBe(null); - service.refreshAndGetUserState().subscribe(result => { - expect(result).toBe('nologin'); - }); + const mockUserInfo: UserInfo = { + username: 'user', + roles: ['user', 'other'] + }; + + const mockToken = 'mock-token'; + + describe('validate token', () => { + const validateTokenRequestMatcher = (req: HttpRequest<ValidateTokenRequest>): boolean => + req.url === validateTokenUrl && req.body !== null && req.body.token === mockToken; + + function createTest( + expectSnackBarTextKey: SnackBarTextKey, + setStorageToken: boolean, + setHttpController?: (controller: HttpTestingController) => void + ): () => void { + return fakeAsync(() => { + if (setStorageToken) { + mockLocalStorage.setItem(TOKEN_STORAGE_KEY, mockToken); + } + TestBed.get(InternalUserService); + const controller = TestBed.get(HttpTestingController) as HttpTestingController; + if (setHttpController) { + setHttpController(controller); + } + controller.verify(); + tick(); + expect(mockSnackBar.open).toHaveBeenCalledWith(snackBarText[expectSnackBarTextKey], jasmine.anything(), jasmine.anything()); + }); + } + + it('no login should work well', createTest('noLogin', false)); + it('already login should work well', createTest('alreadyLogin', true, + controller => controller.expectOne(validateTokenRequestMatcher).flush( + <ValidateTokenResponse>{ isValid: true, userInfo: mockUserInfo }))); + it('invalid login should work well', createTest('invalidLogin', true, + controller => controller.expectOne(validateTokenRequestMatcher).flush(<ValidateTokenResponse>{ isValid: false }))); + it('check fail should work well', createTest('checkFail', true, + controller => repeat(4, () => { + controller.expectOne(validateTokenRequestMatcher).error(new ErrorEvent('Network error', { message: 'simulated network error' })); + }))); }); it('login should work well', () => { - const service: InternalUserService = TestBed.get(InternalUserService); - - const mockUserInfo: UserInfo = { + const mockUserCredentials: UserCredentials = { username: 'user', - roles: ['user', 'other'] + password: 'user' }; + const service: InternalUserService = TestBed.get(InternalUserService); + service.tryLogin(mockUserCredentials).subscribe(result => { expect(result).toEqual(mockUserInfo); }); @@ -50,66 +98,17 @@ describe('InternalUserService', () => { httpController.expectOne((request: HttpRequest<CreateTokenRequest>) => request.url === createTokenUrl && request.body !== null && - request.body.username === 'user' && - request.body.password === 'user').flush(<CreateTokenResponse>{ - token: 'test-token', + request.body.username === mockUserCredentials.username && + request.body.password === mockUserCredentials.password).flush(<CreateTokenResponse>{ + token: mockToken, userInfo: mockUserInfo }); expect(service.currentUserInfo).toEqual(mockUserInfo); httpController.verify(); - }); - - describe('validateUserLoginState', () => { - let service: InternalUserService; - let httpController: HttpTestingController; - - const mockUserInfo: UserInfo = { - username: 'user', - roles: ['user', 'other'] - }; - - const mockToken = 'mock-token'; - - const tokenValidateRequestMatcher = (req: HttpRequest<ValidateTokenRequest>) => { - return req.url === validateTokenUrl && req.body !== null && req.body.token === mockToken; - }; - - beforeEach(() => { - service = TestBed.get(InternalUserService); - httpController = TestBed.get(HttpTestingController); - - service.tryLogin(mockUserCredentials).subscribe(); // subscribe to activate login - - httpController.expectOne(createTokenUrl).flush(<CreateTokenResponse>{ - token: mockToken, - userInfo: mockUserInfo - }); - }); - it('success should work well', () => { - service.refreshAndGetUserState().subscribe((result: UserLoginState) => { - expect(result).toEqual(<UserLoginState>'success'); - }); - - httpController.expectOne(tokenValidateRequestMatcher).flush(<ValidateTokenResponse>{ - isValid: true, - userInfo: mockUserInfo - }); - - httpController.verify(); - }); - - it('invalid should work well', () => { - service.refreshAndGetUserState().subscribe((result: UserLoginState) => { - expect(result).toEqual(<UserLoginState>'invalidlogin'); - }); - - httpController.expectOne(tokenValidateRequestMatcher).flush(<ValidateTokenResponse>{ isValid: false }); - - httpController.verify(); - }); + expect(mockLocalStorage.getItem(TOKEN_STORAGE_KEY)).toBe(mockToken); }); // TODO: test on error situations. diff --git a/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts index 604393f4..2098391e 100644 --- a/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts +++ b/Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Inject } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Router } from '@angular/router'; @@ -12,7 +12,19 @@ import { } from './http-entities'; import { UserCredentials, UserInfo } from '../entities'; import { MatSnackBar } from '@angular/material'; +import { WINDOW } from '../window-inject-token'; +export const snackBarText = { + checkFail: 'Failed to check last login', + noLogin: 'No login before!', + alreadyLogin: 'You have login already!', + invalidLogin: 'Last login is no longer invalid!', + ok: 'ok' +}; + +export type SnackBarTextKey = Exclude<keyof typeof snackBarText, 'ok'>; + +export const TOKEN_STORAGE_KEY = 'token'; /** * This service is only used internal in user module. @@ -33,25 +45,28 @@ export class InternalUserService { return this.userInfoSubject; } - constructor(private httpClient: HttpClient, private router: Router, private snackBar: MatSnackBar) { - const savedToken = window.localStorage.getItem('token'); + private openSnackBar(snackBar: MatSnackBar, textKey: SnackBarTextKey) { + setTimeout(() => snackBar.open(snackBarText[textKey], snackBarText.ok, { duration: 2000 }), 0); + } + + constructor(@Inject(WINDOW) private window: Window, private httpClient: HttpClient, private router: Router, snackBar: MatSnackBar) { + const savedToken = this.window.localStorage.getItem(TOKEN_STORAGE_KEY); if (savedToken === null) { - setTimeout(() => snackBar.open('No login before!', 'ok', { duration: 2000 }), 0); + this.openSnackBar(snackBar, 'noLogin'); } else { this.validateToken(savedToken).subscribe(result => { if (result === null) { - window.localStorage.removeItem('token'); - setTimeout(() => snackBar.open('Last login is no longer invalid!', 'ok', { duration: 2000 }), 0); + this.window.localStorage.removeItem(TOKEN_STORAGE_KEY); + this.openSnackBar(snackBar, 'invalidLogin'); } else { this.token = savedToken; this.userInfoSubject.next(result); - setTimeout(() => snackBar.open('You have login already!', 'ok', { duration: 2000 }), 0); + this.openSnackBar(snackBar, 'alreadyLogin'); } }, _ => { - setTimeout(() => snackBar.open('Failed to check last login', 'ok', { duration: 2000 }), 0); + this.openSnackBar(snackBar, 'checkFail'); }); } - } private validateToken(token: string): Observable<UserInfo | null> { @@ -107,7 +122,7 @@ export class InternalUserService { map(result => { this.token = result.token; if (options.remember) { - window.localStorage.setItem('token', result.token); + this.window.localStorage.setItem(TOKEN_STORAGE_KEY, result.token); } this.userInfoSubject.next(result.userInfo); return result.userInfo; diff --git a/Timeline/ClientApp/src/app/user/user.module.ts b/Timeline/ClientApp/src/app/user/user.module.ts index 7645d61d..dcb61736 100644 --- a/Timeline/ClientApp/src/app/user/user.module.ts +++ b/Timeline/ClientApp/src/app/user/user.module.ts @@ -15,6 +15,7 @@ import { UserLoginComponent } from './user-login/user-login.component'; import { UserLoginSuccessComponent } from './user-login-success/user-login-success.component'; import { RedirectComponent } from './redirect.component'; import { UtilityModule } from '../utilities/utility.module'; +import { WINDOW } from './window-inject-token'; @NgModule({ declarations: [UserDialogComponent, UserLoginComponent, UserLoginSuccessComponent, RedirectComponent], @@ -28,6 +29,7 @@ import { UtilityModule } from '../utilities/utility.module'; MatFormFieldModule, MatProgressSpinnerModule, MatDialogModule, MatInputModule, MatButtonModule, MatSnackBarModule, UtilityModule ], + providers: [{provide: WINDOW, useValue: window}], exports: [RouterModule], entryComponents: [UserDialogComponent] }) diff --git a/Timeline/ClientApp/src/app/user/window-inject-token.ts b/Timeline/ClientApp/src/app/user/window-inject-token.ts new file mode 100644 index 00000000..9f8723f6 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/window-inject-token.ts @@ -0,0 +1,3 @@ +import { InjectionToken } from '@angular/core'; + +export const WINDOW = new InjectionToken<Window>('global window'); diff --git a/Timeline/ClientApp/src/app/utilities/language-untilities.ts b/Timeline/ClientApp/src/app/utilities/language-untilities.ts index 7f38f3e4..94434665 100644 --- a/Timeline/ClientApp/src/app/utilities/language-untilities.ts +++ b/Timeline/ClientApp/src/app/utilities/language-untilities.ts @@ -10,3 +10,9 @@ export function throwIfNullOrUndefined<T>(value: T | null | undefined, return value; } } + +export function repeat(time: number, action: (index?: number) => void) { + for (let i = 0; i < time; i++) { + action(i); + } +} diff --git a/Timeline/ClientApp/src/tsconfig.app.json b/Timeline/ClientApp/src/tsconfig.app.json index 13151ca4..ec3cc7f4 100644 --- a/Timeline/ClientApp/src/tsconfig.app.json +++ b/Timeline/ClientApp/src/tsconfig.app.json @@ -7,6 +7,7 @@ }, "exclude": [ "src/test.ts", + "test-utilities/**", "**/*.spec.ts", "**/*.mock.ts", "**/*.test.ts" diff --git a/Timeline/ClientApp/src/tsconfig.spec.json b/Timeline/ClientApp/src/tsconfig.spec.json index 6e4460f8..ccf4b2ee 100644 --- a/Timeline/ClientApp/src/tsconfig.spec.json +++ b/Timeline/ClientApp/src/tsconfig.spec.json @@ -13,6 +13,7 @@ "polyfills.ts" ], "include": [ + "test-utilities/**", "**/*.spec.ts", "**/*.d.ts", "**/*.mock.ts", |