From 63735a8267d44892a64da5b599b7c2e20f373464 Mon Sep 17 00:00:00 2001
From: crupest 
Date: Wed, 6 Mar 2019 21:29:36 +0800
Subject: Reorganize file structure.
---
 .../src/app/user/user-service/user.service.spec.ts |  12 +++
 .../src/app/user/user-service/user.service.ts      | 116 +++++++++++++++++++++
 2 files changed, 128 insertions(+)
 create mode 100644 Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts
 create mode 100644 Timeline/ClientApp/src/app/user/user-service/user.service.ts
(limited to 'Timeline/ClientApp/src/app/user/user-service')
diff --git a/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts b/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts
new file mode 100644
index 00000000..b9221b90
--- /dev/null
+++ b/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts
@@ -0,0 +1,12 @@
+import { TestBed } from '@angular/core/testing';
+
+import { UserService } from './user.service';
+
+xdescribe('UserService', () => {
+  beforeEach(() => TestBed.configureTestingModule({}));
+
+  it('should be created', () => {
+    const service: UserService = TestBed.get(UserService);
+    expect(service).toBeTruthy();
+  });
+});
diff --git a/Timeline/ClientApp/src/app/user/user-service/user.service.ts b/Timeline/ClientApp/src/app/user/user-service/user.service.ts
new file mode 100644
index 00000000..009e5292
--- /dev/null
+++ b/Timeline/ClientApp/src/app/user/user-service/user.service.ts
@@ -0,0 +1,116 @@
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
+import { Observable, of, throwError } from 'rxjs';
+import { map, catchError, retry } from 'rxjs/operators';
+
+import { UserInfo } from '../user-info';
+
+export interface UserCredentials {
+  username: string;
+  password: string;
+}
+
+export interface CreateTokenResult {
+  token: string;
+  userInfo: UserInfo;
+}
+
+export interface TokenValidationRequest {
+  token: string;
+}
+
+export interface TokenValidationResult {
+  isValid: boolean;
+  userInfo?: UserInfo;
+}
+
+export interface UserLoginState {
+  state: 'nologin' | 'invalidlogin' | 'success';
+  userInfo?: UserInfo;
+}
+
+export class BadNetworkException extends Error {
+  constructor() {
+    super('Network is bad.');
+  }
+}
+
+export class AlreadyLoginException extends Error {
+  constructor() {
+    super('There is already a token saved. Please call validateUserLoginState first.');
+  }
+}
+
+export class BadCredentialsException extends Error {
+  constructor() {
+    super(`Username or password is wrong.`);
+  }
+}
+
+@Injectable({
+  providedIn: 'root'
+})
+export class UserService {
+
+  private token: string;
+  private userInfo: UserInfo;
+
+  constructor(private httpClient: HttpClient) { }
+
+  validateUserLoginState(): Observable {
+    if (this.token === undefined || this.token === null) {
+      return of({ state: 'nologin' });
+    }
+
+    return this.httpClient.post('/api/User/ValidateToken', { token: this.token }).pipe(
+      retry(3),
+      catchError(error => {
+        console.error('Failed to validate token.');
+        return throwError(error);
+      }),
+      map(result => {
+        if (result.isValid) {
+          this.userInfo = result.userInfo;
+          return {
+            state: 'success',
+            userInfo: result.userInfo
+          };
+        } else {
+          this.token = null;
+          this.userInfo = null;
+          return {
+            state: 'invalidlogin'
+          };
+        }
+      })
+    );
+  }
+
+  tryLogin(username: string, password: string): Observable {
+    if (this.token) {
+      return throwError(new AlreadyLoginException());
+    }
+
+    return this.httpClient.post('/api/User/CreateToken', {
+      username, password
+    }).pipe(
+      catchError((error: HttpErrorResponse) => {
+        if (error.error instanceof ErrorEvent) {
+          console.error('An error occurred when login: ' + error.error.message);
+          return throwError(new BadNetworkException());
+        } else if (error.status === 400) {
+          console.error('An error occurred when login: wrong credentials.');
+          return throwError(new BadCredentialsException());
+        } else {
+          console.error('An unknown error occurred when login: ' + error);
+          return throwError(error);
+        }
+      }),
+      map(result => {
+        this.token = result.token;
+        this.userInfo = result.userInfo;
+        return result.userInfo;
+      })
+    );
+  }
+}
-- 
cgit v1.2.3
From 2ee5c455152a0553453e400b387109b0b518ec99 Mon Sep 17 00:00:00 2001
From: crupest 
Date: Wed, 6 Mar 2019 23:14:45 +0800
Subject: Write all unit tests.
---
 .../user/user-dialog/user-dialog.component.spec.ts | 101 +++++++++++++++++--
 .../user-login-success.component.html              |   2 +-
 .../user-login-success.component.spec.ts           |  18 +++-
 .../user/user-login/user-login.component.spec.ts   |  43 +++++++-
 .../src/app/user/user-service/user.service.spec.ts | 108 ++++++++++++++++++++-
 5 files changed, 256 insertions(+), 16 deletions(-)
(limited to 'Timeline/ClientApp/src/app/user/user-service')
diff --git a/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts
index 884a3710..d24c0cd2 100644
--- a/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts
+++ b/Timeline/ClientApp/src/app/user/user-dialog/user-dialog.component.spec.ts
@@ -1,25 +1,114 @@
-import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { Component, Output, EventEmitter } from '@angular/core';
+import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { of } from 'rxjs';
+import { delay } from 'rxjs/operators';
 
+import { UserInfo } from '../user-info';
 import { UserDialogComponent } from './user-dialog.component';
+import { UserService, UserLoginState } from '../user-service/user.service';
+import { LoginEvent } from '../user-login/user-login.component';
 
-xdescribe('UserDialogComponent', () => {
+@Component({
+  /* tslint:disable-next-line:component-selector*/
+  selector: 'mat-progress-spinner',
+  template: ''
+})
+class MatProgressSpinnerStubComponent { }
+
+@Component({
+  selector: 'app-user-login',
+  /* tslint:disable-next-line:use-input-property-decorator*/
+  inputs: ['message'],
+  template: ''
+})
+class UserLoginStubComponent {
+  @Output()
+  login = new EventEmitter();
+}
+
+@Component({
+  selector: 'app-user-login-success',
+  /* tslint:disable-next-line:use-input-property-decorator*/
+  inputs: ['userInfo', 'displayLoginSuccessMessage'],
+  template: ''
+})
+class UserLoginSuccessStubComponent { }
+
+describe('UserDialogComponent', () => {
   let component: UserDialogComponent;
   let fixture: ComponentFixture;
+  let mockUserService: jasmine.SpyObj;
 
   beforeEach(async(() => {
+    mockUserService = jasmine.createSpyObj('UserService', ['validateUserLoginState', 'tryLogin']);
+
     TestBed.configureTestingModule({
-      declarations: [ UserDialogComponent ]
+      declarations: [UserDialogComponent, MatProgressSpinnerStubComponent,
+        UserLoginStubComponent, UserLoginSuccessStubComponent],
+      providers: [{ provide: UserService, useValue: mockUserService }]
     })
-    .compileComponents();
+      .compileComponents();
   }));
 
   beforeEach(() => {
     fixture = TestBed.createComponent(UserDialogComponent);
     component = fixture.componentInstance;
+  });
+
+  it('progress spinner should work well', fakeAsync(() => {
+    mockUserService.validateUserLoginState.and.returnValue(of({ state: 'nologin' }).pipe(delay(10)));
+    fixture.detectChanges();
+    expect(fixture.debugElement.query(By.css('mat-progress-spinner'))).toBeTruthy();
+    tick(10);
     fixture.detectChanges();
+    expect(fixture.debugElement.query(By.css('mat-progress-spinner'))).toBeFalsy();
+  }));
+
+  it('nologin should work well', () => {
+    mockUserService.validateUserLoginState.and.returnValue(of({ state: 'nologin' }));
+
+    fixture.detectChanges();
+
+    expect(mockUserService.validateUserLoginState).toHaveBeenCalled();
+    expect(fixture.debugElement.query(By.css('app-user-login'))).toBeTruthy();
+    expect(fixture.debugElement.query(By.css('app-user-login-success'))).toBeFalsy();
   });
 
-  it('should create', () => {
-    expect(component).toBeTruthy();
+  it('success should work well', () => {
+    mockUserService.validateUserLoginState.and.returnValue(of({ state: 'success', userInfo: {} }));
+
+    fixture.detectChanges();
+
+    expect(mockUserService.validateUserLoginState).toHaveBeenCalled();
+    expect(fixture.debugElement.query(By.css('app-user-login'))).toBeFalsy();
+    expect(fixture.debugElement.query(By.css('app-user-login-success'))).toBeTruthy();
+  });
+
+  it('login should work well', () => {
+    mockUserService.validateUserLoginState.and.returnValue(of({ state: 'nologin' }));
+
+    fixture.detectChanges();
+    expect(mockUserService.validateUserLoginState).toHaveBeenCalled();
+    expect(fixture.debugElement.query(By.css('app-user-login'))).toBeTruthy();
+    expect(fixture.debugElement.query(By.css('app-user-login-success'))).toBeFalsy();
+
+    mockUserService.tryLogin.withArgs('user', 'user').and.returnValue(of({
+      username: 'user',
+      roles: ['user']
+    }));
+
+    (fixture.debugElement.query(By.css('app-user-login')).componentInstance as
+      UserLoginStubComponent).login.emit({
+        username: 'user',
+        password: 'user'
+      });
+
+    fixture.detectChanges();
+
+    expect(mockUserService.tryLogin).toHaveBeenCalledWith('user', 'user');
+
+    expect(fixture.debugElement.query(By.css('app-user-login'))).toBeFalsy();
+    expect(fixture.debugElement.query(By.css('app-user-login-success'))).toBeTruthy();
   });
 });
diff --git a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.html b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.html
index 943c137f..e156f0f8 100644
--- a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.html
+++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.html
@@ -2,4 +2,4 @@
   Login succeeds!
 
 You have been login as {{ userInfo.username }}.
-Your roles are {{ userInfo.roles.join(', ') }}.
+Your roles are {{ userInfo.roles.join(', ') }}.
diff --git a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts
index bdcd354b..ba015ae6 100644
--- a/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts
+++ b/Timeline/ClientApp/src/app/user/user-login-success/user-login-success.component.spec.ts
@@ -1,25 +1,39 @@
 import { async, ComponentFixture, TestBed } from '@angular/core/testing';
 
 import { UserLoginSuccessComponent } from './user-login-success.component';
+import { By } from '@angular/platform-browser';
 
 describe('UserLoginSuccessComponent', () => {
   let component: UserLoginSuccessComponent;
   let fixture: ComponentFixture;
 
+  const mockUserInfo = {
+    username: 'crupest',
+    roles: ['superman', 'coder']
+  };
+
   beforeEach(async(() => {
     TestBed.configureTestingModule({
-      declarations: [ UserLoginSuccessComponent ]
+      declarations: [UserLoginSuccessComponent]
     })
-    .compileComponents();
+      .compileComponents();
   }));
 
   beforeEach(() => {
     fixture = TestBed.createComponent(UserLoginSuccessComponent);
     component = fixture.componentInstance;
+    component.userInfo = mockUserInfo;
     fixture.detectChanges();
   });
 
   it('should create', () => {
     expect(component).toBeTruthy();
   });
+
+  it('should work well', () => {
+    expect((fixture.debugElement.query(By.css('span.username')).nativeElement as HTMLSpanElement).textContent)
+      .toBe(mockUserInfo.username);
+    expect((fixture.debugElement.query(By.css('span.roles')).nativeElement as HTMLSpanElement).textContent)
+      .toBe(mockUserInfo.roles.join(', '));
+  });
 });
diff --git a/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts b/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts
index b606b7b4..acd13721 100644
--- a/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts
+++ b/Timeline/ClientApp/src/app/user/user-login/user-login.component.spec.ts
@@ -1,6 +1,9 @@
-import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
 
-import { UserLoginComponent } from './user-login.component';
+import { UserLoginComponent, LoginEvent } from './user-login.component';
 
 describe('UserLoginComponent', () => {
   let component: UserLoginComponent;
@@ -8,9 +11,11 @@ describe('UserLoginComponent', () => {
 
   beforeEach(async(() => {
     TestBed.configureTestingModule({
-      declarations: [ UserLoginComponent ]
+      declarations: [UserLoginComponent],
+      imports: [ReactiveFormsModule],
+      schemas: [NO_ERRORS_SCHEMA]
     })
-    .compileComponents();
+      .compileComponents();
   }));
 
   beforeEach(() => {
@@ -22,4 +27,34 @@ describe('UserLoginComponent', () => {
   it('should create', () => {
     expect(component).toBeTruthy();
   });
+
+  it('reactive form should work well', () => {
+    const usernameInput = fixture.debugElement.query(By.css('input[type=text]')).nativeElement as HTMLInputElement;
+    const passwordInput = fixture.debugElement.query(By.css('input[type=password]')).nativeElement as HTMLInputElement;
+
+    usernameInput.value = 'user';
+    usernameInput.dispatchEvent(new Event('input'));
+    passwordInput.value = 'user';
+    passwordInput.dispatchEvent(new Event('input'));
+
+    fixture.detectChanges();
+
+    expect(component.form.value).toEqual({
+      username: 'user',
+      password: 'user'
+    });
+  });
+
+  it('login event should work well', fakeAsync(() => {
+    let userCredential: LoginEvent;
+    component.login.subscribe((e: LoginEvent) => { userCredential = e; });
+    fixture.detectChanges();
+    const mockValue = {
+      username: 'user',
+      password: 'user'
+    };
+    component.form.setValue(mockValue);
+    component.onLoginButtonClick();
+    expect(userCredential).toEqual(mockValue);
+  }));
 });
diff --git a/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts b/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts
index b9221b90..28cfefd7 100644
--- a/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts
+++ b/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts
@@ -1,12 +1,114 @@
 import { TestBed } from '@angular/core/testing';
+import { HttpRequest } from '@angular/common/http';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
 
-import { UserService } from './user.service';
+import { UserInfo } from '../user-info';
+import {
+  UserService, UserCredentials, CreateTokenResult,
+  UserLoginState, TokenValidationRequest, TokenValidationResult
+} from './user.service';
 
-xdescribe('UserService', () => {
-  beforeEach(() => TestBed.configureTestingModule({}));
+describe('UserService', () => {
+  const tokenCreateUrl = '/api/User/CreateToken';
+
+  beforeEach(() => TestBed.configureTestingModule({
+    imports: [HttpClientTestingModule]
+  }));
 
   it('should be created', () => {
     const service: UserService = TestBed.get(UserService);
     expect(service).toBeTruthy();
   });
+
+  it('should be nologin at first', () => {
+    const service: UserService = TestBed.get(UserService);
+    service.validateUserLoginState().subscribe(result => {
+      expect(result.state).toBe('nologin');
+    });
+  });
+
+  it('login should work well', () => {
+    const service: UserService = TestBed.get(UserService);
+
+    const mockUserInfo: UserInfo = {
+      username: 'user',
+      roles: ['user', 'other']
+    };
+
+    service.tryLogin('user', 'user').subscribe(result => {
+      expect(result).toEqual(mockUserInfo);
+    });
+
+    const httpController = TestBed.get(HttpTestingController) as HttpTestingController;
+
+    httpController.expectOne((request: HttpRequest) =>
+      request.url === tokenCreateUrl &&
+      request.body.username === 'user' &&
+      request.body.password === 'user').flush({
+        token: 'test-token',
+        userInfo: mockUserInfo
+      });
+
+    httpController.verify();
+  });
+
+  describe('validateUserLoginState', () => {
+    let service: UserService;
+    let httpController: HttpTestingController;
+
+    const mockUserInfo: UserInfo = {
+      username: 'user',
+      roles: ['user', 'other']
+    };
+
+    const mockToken = 'mock-token';
+
+    const tokenValidateRequestMatcher = (req: HttpRequest) => {
+      return req.url === '/api/User/ValidateToken' && req.body.token === mockToken;
+    }
+
+    beforeEach(() => {
+      service = TestBed.get(UserService);
+      httpController = TestBed.get(HttpTestingController);
+
+      service.tryLogin('user', 'user').subscribe(); // subscribe to activate login
+
+      httpController.expectOne(tokenCreateUrl).flush({
+        token: mockToken,
+        userInfo: mockUserInfo
+      });
+    });
+
+    it('success should work well', () => {
+      service.validateUserLoginState().subscribe((result: UserLoginState) => {
+        expect(result).toEqual({
+          state: 'success',
+          userInfo: mockUserInfo
+        });
+      });
+
+      httpController.expectOne(tokenValidateRequestMatcher).flush({
+        isValid: true,
+        userInfo: mockUserInfo
+      });
+
+      httpController.verify();
+    });
+
+    it('invalid should work well', () => {
+      service.validateUserLoginState().subscribe((result: UserLoginState) => {
+        expect(result).toEqual({
+          state: 'invalidlogin'
+        });
+      });
+
+      httpController.expectOne(tokenValidateRequestMatcher).flush({
+        isValid: false
+      });
+
+      httpController.verify();
+    });
+  });
+
+  // TODO: test on error situations.
 });
-- 
cgit v1.2.3
From b5e01c4571061cbaf5915aa4c0f1b7126ef1ed18 Mon Sep 17 00:00:00 2001
From: crupest 
Date: Wed, 6 Mar 2019 23:20:49 +0800
Subject: Fix a lint error.
---
 Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'Timeline/ClientApp/src/app/user/user-service')
diff --git a/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts b/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts
index 28cfefd7..0095f031 100644
--- a/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts
+++ b/Timeline/ClientApp/src/app/user/user-service/user.service.spec.ts
@@ -65,7 +65,7 @@ describe('UserService', () => {
 
     const tokenValidateRequestMatcher = (req: HttpRequest) => {
       return req.url === '/api/User/ValidateToken' && req.body.token === mockToken;
-    }
+    };
 
     beforeEach(() => {
       service = TestBed.get(UserService);
-- 
cgit v1.2.3