diff options
author | 杨宇千 <crupest@outlook.com> | 2019-04-11 20:02:33 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-04-11 20:02:33 +0800 |
commit | 5b5ca3acb1b9decb5ad13798dc79ba2d58f2ce95 (patch) | |
tree | 695f7fc0bd2f6d940f64739a1f1f500c36806cef /Timeline/ClientApp/src/app/user/internal-user-service | |
parent | 1c9edc5914869a3bbde20742c483182636ee4d43 (diff) | |
parent | c28941c6d86f8ea33521bba49d811bf3ff60b3d1 (diff) | |
download | timeline-5b5ca3acb1b9decb5ad13798dc79ba2d58f2ce95.tar.gz timeline-5b5ca3acb1b9decb5ad13798dc79ba2d58f2ce95.tar.bz2 timeline-5b5ca3acb1b9decb5ad13798dc79ba2d58f2ce95.zip |
Merge pull request #17 from crupest/15-user
Remember me and log out feature.
Diffstat (limited to 'Timeline/ClientApp/src/app/user/internal-user-service')
-rw-r--r-- | Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.spec.ts | 168 | ||||
-rw-r--r-- | Timeline/ClientApp/src/app/user/internal-user-service/internal-user.service.ts | 124 |
2 files changed, 174 insertions, 118 deletions
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..6906ed60 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,115 +1,121 @@ -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 { 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'); - }); - }); - - it('login should work well', () => { - const service: InternalUserService = TestBed.get(InternalUserService); - - const mockUserInfo: UserInfo = { - username: 'user', - roles: ['user', 'other'] - }; - - service.tryLogin(mockUserCredentials).subscribe(result => { - expect(result).toEqual(mockUserInfo); - }); - - const httpController = TestBed.get(HttpTestingController) as HttpTestingController; + const mockUserInfo: UserInfo = { + username: 'user', + roles: ['user', 'other'] + }; - httpController.expectOne((request: HttpRequest<CreateTokenRequest>) => - request.url === createTokenUrl && request.body !== null && - request.body.username === 'user' && - request.body.password === 'user').flush(<CreateTokenResponse>{ - token: 'test-token', - userInfo: mockUserInfo + 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()); }); - - expect(service.currentUserInfo).toEqual(mockUserInfo); - - httpController.verify(); + } + + 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' })); + }))); }); - describe('validateUserLoginState', () => { - let service: InternalUserService; - let httpController: HttpTestingController; - - const mockUserInfo: UserInfo = { + describe('login should work well', () => { + const mockUserCredentials: UserCredentials = { username: 'user', - roles: ['user', 'other'] + password: 'user' }; - const mockToken = 'mock-token'; + function createTest(rememberMe: boolean) { + return () => { + const service: InternalUserService = TestBed.get(InternalUserService); - const tokenValidateRequestMatcher = (req: HttpRequest<ValidateTokenRequest>) => { - return req.url === validateTokenUrl && req.body !== null && req.body.token === mockToken; - }; + service.tryLogin({ ...mockUserCredentials, rememberMe: rememberMe }).subscribe(result => { + expect(result).toEqual(mockUserInfo); + }); - beforeEach(() => { - service = TestBed.get(InternalUserService); - httpController = TestBed.get(HttpTestingController); + const httpController = TestBed.get(HttpTestingController) as HttpTestingController; - service.tryLogin(mockUserCredentials).subscribe(); // subscribe to activate login + httpController.expectOne((request: HttpRequest<CreateTokenRequest>) => + request.url === createTokenUrl && request.body !== null && + request.body.username === mockUserCredentials.username && + request.body.password === mockUserCredentials.password).flush(<CreateTokenResponse>{ + token: mockToken, + userInfo: mockUserInfo + }); - httpController.expectOne(createTokenUrl).flush(<CreateTokenResponse>{ - token: mockToken, - userInfo: mockUserInfo - }); - }); + expect(service.currentUserInfo).toEqual(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.verify(); - httpController.expectOne(tokenValidateRequestMatcher).flush(<ValidateTokenResponse>{ isValid: false }); + expect(mockLocalStorage.getItem(TOKEN_STORAGE_KEY)).toBe(rememberMe ? mockToken : null); + }; + } - httpController.verify(); - }); + it('remember me should work well', createTest(true)); + it('not remember me should work well', createTest(false)); }); // 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 4767bd16..6de355f2 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,11 +1,9 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Inject } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Router } from '@angular/router'; -import { Observable, of, throwError, BehaviorSubject } from 'rxjs'; -import { map, catchError, retry } from 'rxjs/operators'; - -import { nullIfUndefined } from '../../utilities/language-untilities'; +import { Observable, throwError, BehaviorSubject, of } from 'rxjs'; +import { map, catchError, retry, switchMap, tap, filter } from 'rxjs/operators'; import { AlreadyLoginError, BadCredentialsError, BadNetworkError, UnknownError } from './errors'; import { @@ -13,9 +11,24 @@ import { CreateTokenResponse, ValidateTokenRequest, ValidateTokenResponse } 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 type UserLoginState = 'nologin' | 'invalidlogin' | 'success'; +export const TOKEN_STORAGE_KEY = 'token'; + +export interface LoginInfo extends UserCredentials { + rememberMe: boolean; +} /** * This service is only used internal in user module. @@ -26,56 +39,80 @@ export type UserLoginState = 'nologin' | 'invalidlogin' | 'success'; export class InternalUserService { private token: string | null = null; - private userInfoSubject = new BehaviorSubject<UserInfo | null>(null); + private userInfoSubject = new BehaviorSubject<UserInfo | null | undefined>(undefined); + + readonly userInfo$: Observable<UserInfo | null> = + <Observable<UserInfo | null>>this.userInfoSubject.pipe(filter(value => value !== undefined)); - get currentUserInfo(): UserInfo | null { + get currentUserInfo(): UserInfo | null | undefined { return this.userInfoSubject.value; } - get userInfo$(): Observable<UserInfo | null> { - return this.userInfoSubject; + private openSnackBar(snackBar: MatSnackBar, textKey: SnackBarTextKey) { + setTimeout(() => snackBar.open(snackBarText[textKey], snackBarText.ok, { duration: 2000 }), 0); } - constructor(private httpClient: HttpClient, private router: Router) { } - - userRouteNavigate(commands: any[] | null) { - this.router.navigate([{ - outlets: { - user: commands - } - }]); - } - - refreshAndGetUserState(): Observable<UserLoginState> { - if (this.token === undefined || this.token === null) { - return of(<UserLoginState>'nologin'); + 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) { + this.openSnackBar(snackBar, 'noLogin'); + this.userInfoSubject.next(null); + } else { + this.validateToken(savedToken).subscribe(result => { + if (result === null) { + this.window.localStorage.removeItem(TOKEN_STORAGE_KEY); + this.openSnackBar(snackBar, 'invalidLogin'); + this.userInfoSubject.next(null); + } else { + this.token = savedToken; + this.userInfoSubject.next(result); + this.openSnackBar(snackBar, 'alreadyLogin'); + } + }, _ => { + this.openSnackBar(snackBar, 'checkFail'); + this.userInfoSubject.next(null); + }); } + } - return this.httpClient.post<ValidateTokenResponse>(validateTokenUrl, <ValidateTokenRequest>{ token: this.token }).pipe( + private validateToken(token: string): Observable<UserInfo | null> { + return this.httpClient.post<ValidateTokenResponse>(validateTokenUrl, <ValidateTokenRequest>{ token: token }).pipe( retry(3), - catchError(error => { - console.error('Failed to validate token.'); - return throwError(error); - }), - map(result => { + switchMap(result => { if (result.isValid) { - this.userInfoSubject.next(nullIfUndefined(result.userInfo)); - return <UserLoginState>'success'; + const { userInfo } = result; + if (userInfo) { + return of(userInfo); + } else { + return throwError(new Error('Wrong server response. IsValid is true but UserInfo is null.')); + } } else { - this.token = null; - this.userInfoSubject.next(null); - return <UserLoginState>'invalidlogin'; + return of(null); } - }) + }), + tap({ + error: error => { + console.error('Failed to validate token.'); + console.error(error); + } + }), ); } - tryLogin(credentials: UserCredentials): Observable<UserInfo> { + userRouteNavigate(commands: any[] | null) { + this.router.navigate([{ + outlets: { + user: commands + } + }]); + } + + tryLogin(info: LoginInfo): Observable<UserInfo> { if (this.token) { return throwError(new AlreadyLoginError()); } - return this.httpClient.post<CreateTokenResponse>(createTokenUrl, <CreateTokenRequest>credentials).pipe( + return this.httpClient.post<CreateTokenResponse>(createTokenUrl, <CreateTokenRequest>info).pipe( catchError((error: HttpErrorResponse) => { if (error.error instanceof ErrorEvent) { console.error('An error occurred when login: ' + error.error.message); @@ -90,9 +127,22 @@ export class InternalUserService { }), map(result => { this.token = result.token; + if (info.rememberMe) { + this.window.localStorage.setItem(TOKEN_STORAGE_KEY, result.token); + } this.userInfoSubject.next(result.userInfo); return result.userInfo; }) ); } + + logout() { + if (this.currentUserInfo === null) { + throw new Error('No login now. You can\'t logout.'); + } + + this.window.localStorage.removeItem(TOKEN_STORAGE_KEY); + this.token = null; + this.userInfoSubject.next(null); + } } |