diff --git a/src/ClientApp/src/app/infrastructure/services/auth-service.spec.ts b/src/ClientApp/src/app/infrastructure/services/auth-service.spec.ts index 3396181..f9289a2 100644 --- a/src/ClientApp/src/app/infrastructure/services/auth-service.spec.ts +++ b/src/ClientApp/src/app/infrastructure/services/auth-service.spec.ts @@ -1,16 +1,27 @@ import {TestBed} from '@angular/core/testing'; import {provideHttpClient, withXhr} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; import {AuthService} from './auth-service'; +import {environment} from '../../../environments/environment.development'; describe('AuthService', () => { let service: AuthService; + let httpTesting: HttpTestingController; beforeEach(() => { localStorage.clear(); TestBed.configureTestingModule({ - providers: [provideHttpClient(withXhr())] + providers: [ + provideHttpClient(withXhr()), + provideHttpClientTesting(), + ] }); service = TestBed.inject(AuthService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); }); it('should be created', () => { @@ -42,4 +53,42 @@ describe('AuthService', () => { service.signOut(); expect(service.isLoggedIn()).toBeFalse(); }); + + it('should POST to auth/login on login', () => { + const credentials = {email: 'test@example.com', password: 'password123'}; + + service.login(credentials).subscribe(); + + const req = httpTesting.expectOne(`${environment.baseUrl}auth/login`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(credentials); + req.flush({value: {token: {accessToken: 'access', refreshToken: 'refresh'}}}); + }); + + it('should POST to auth/register on register', () => { + const request = {username: 'newuser', email: 'new@example.com', password: 'StrongP@ss1'}; + + service.register(request).subscribe(); + + const req = httpTesting.expectOne(`${environment.baseUrl}auth/register`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(request); + req.flush({value: 'User registered successfully'}); + }); + + it('should POST to auth/refresh-token on renewToken', () => { + const refreshRequest = {userId: 1, refreshToken: 'refresh-token'}; + + service.renewToken(refreshRequest).subscribe(); + + const req = httpTesting.expectOne(`${environment.baseUrl}auth/refresh-token`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(refreshRequest); + req.flush({value: {accessToken: 'new-access', refreshToken: 'new-refresh'}}); + }); + + it('should return null for decodedToken when no token', () => { + localStorage.removeItem('token'); + expect(service.decodedToken()).toBeNull(); + }); }); diff --git a/src/ClientApp/src/app/infrastructure/services/dark-mode.service.spec.ts b/src/ClientApp/src/app/infrastructure/services/dark-mode.service.spec.ts index 83572cc..a76edbd 100644 --- a/src/ClientApp/src/app/infrastructure/services/dark-mode.service.spec.ts +++ b/src/ClientApp/src/app/infrastructure/services/dark-mode.service.spec.ts @@ -1,27 +1,66 @@ import {TestBed} from '@angular/core/testing'; +import {PLATFORM_ID} from '@angular/core'; import {DarkModeService} from './dark-mode.service'; describe('DarkModeService', () => { - let service: DarkModeService; - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(DarkModeService); + localStorage.clear(); }); it('should be created', () => { + TestBed.configureTestingModule({}); + const service = TestBed.inject(DarkModeService); expect(service).toBeTruthy(); }); it('should toggle from null to dark', () => { + TestBed.configureTestingModule({}); + const service = TestBed.inject(DarkModeService); service.darkModeSignal.set(null); service.updateDarkMode(); expect(service.darkModeSignal()).toBe('dark'); }); it('should toggle from dark to null', () => { + TestBed.configureTestingModule({}); + const service = TestBed.inject(DarkModeService); service.darkModeSignal.set('dark'); service.updateDarkMode(); expect(service.darkModeSignal()).toBeNull(); }); + + it('should start with null when localStorage is empty', () => { + TestBed.configureTestingModule({}); + const service = TestBed.inject(DarkModeService); + expect(localStorage.getItem('darkModeSignal')).toBeNull(); + expect(service.darkModeSignal()).toBeNull(); + }); + + it('should read initial value from localStorage', () => { + localStorage.setItem('darkModeSignal', '"dark"'); + TestBed.configureTestingModule({}); + const service = TestBed.inject(DarkModeService); + TestBed.flushEffects(); + expect(service.darkModeSignal()).toBe('dark'); + }); + + it('should persist to localStorage when value changes', () => { + TestBed.configureTestingModule({}); + const service = TestBed.inject(DarkModeService); + service.darkModeSignal.set('dark'); + TestBed.flushEffects(); + expect(localStorage.getItem('darkModeSignal')).toBe('"dark"'); + service.darkModeSignal.set(null); + TestBed.flushEffects(); + expect(localStorage.getItem('darkModeSignal')).toBe('null'); + }); + + it('should do nothing in non-browser platform', () => { + TestBed.configureTestingModule({ + providers: [{provide: PLATFORM_ID, useValue: 'server'}] + }); + const serverService = TestBed.inject(DarkModeService); + serverService.updateDarkMode(); + expect(serverService.darkModeSignal()).toBe('dark'); + }); }); diff --git a/src/ClientApp/src/app/presentation/authentication/forget-password-popup/forget-password-popup.component.spec.ts b/src/ClientApp/src/app/presentation/authentication/forget-password-popup/forget-password-popup.component.spec.ts index ad97d63..5405540 100644 --- a/src/ClientApp/src/app/presentation/authentication/forget-password-popup/forget-password-popup.component.spec.ts +++ b/src/ClientApp/src/app/presentation/authentication/forget-password-popup/forget-password-popup.component.spec.ts @@ -67,4 +67,51 @@ describe('ForgetPasswordPopupComponent', () => { expect(toastService.show).toHaveBeenCalledWith('User not found', jasmine.any(Object)); }); + + it('should emit close event on successful send', () => { + const closeSpy = jasmine.createSpy(); + component.close.subscribe(closeSpy); + + resetService.sendResetPasswordLink.and.returnValue(of({value: 'Email sent'})); + component.resetPasswordEmail = 'test@example.com'; + component.isValidEmail = true; + + component.confirmToSend(); + + expect(closeSpy).toHaveBeenCalled(); + }); + + it('should show success toast on successful send', () => { + resetService.sendResetPasswordLink.and.returnValue(of({value: 'Email sent'})); + component.resetPasswordEmail = 'test@example.com'; + component.isValidEmail = true; + + component.confirmToSend(); + + expect(toastService.show).toHaveBeenCalledWith('Email sent', jasmine.objectContaining({ + classname: 'bg-success text-light' + })); + }); + + it('should clear email on successful send', () => { + resetService.sendResetPasswordLink.and.returnValue(of({value: 'Email sent'})); + component.resetPasswordEmail = 'test@example.com'; + component.isValidEmail = true; + + component.confirmToSend(); + + expect(component.resetPasswordEmail).toBe(''); + }); + + it('should clear email on error', () => { + resetService.sendResetPasswordLink.and.returnValue(throwError(() => ({ + error: {error: {message: 'Error'}} + }))); + component.resetPasswordEmail = 'test@example.com'; + component.isValidEmail = true; + + component.confirmToSend(); + + expect(component.resetPasswordEmail).toBe(''); + }); }); diff --git a/src/ClientApp/src/app/presentation/components/header/header.component.spec.ts b/src/ClientApp/src/app/presentation/components/header/header.component.spec.ts index b6dae41..009ea47 100644 --- a/src/ClientApp/src/app/presentation/components/header/header.component.spec.ts +++ b/src/ClientApp/src/app/presentation/components/header/header.component.spec.ts @@ -1,32 +1,68 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {HeaderComponent} from './header.component'; import {AuthService} from '../../../infrastructure/services/auth-service'; +import {UserStoreService} from '../../../infrastructure/services/user-store.service'; import {ActivatedRoute} from '@angular/router'; describe('HeaderComponent', () => { let component: HeaderComponent; let fixture: ComponentFixture; + let authService: jasmine.SpyObj; beforeEach(async () => { - const authSpy = jasmine.createSpyObj('AuthService', [ - 'getUsernameFromToken', 'getRoleFromToken', 'getEmailFromToken', 'getUserIdFromToken', - 'isLoggedIn', 'getToken', 'signOut', 'decodedToken' + authService = jasmine.createSpyObj('AuthService', [ + 'getUsernameFromToken', 'getRoleFromToken', 'getEmailFromToken', + 'getUserIdFromToken', 'isLoggedIn', 'getToken', 'signOut', 'decodedToken' ]); + authService.getUsernameFromToken.and.returnValue('default-user'); + authService.getRoleFromToken.and.returnValue('User'); await TestBed.configureTestingModule({ imports: [HeaderComponent], providers: [ - {provide: AuthService, useValue: authSpy}, - {provide: ActivatedRoute, useValue: {snapshot: {data: {}}, firstChild: null}}, + {provide: AuthService, useValue: authService}, + { + provide: ActivatedRoute, + useValue: {snapshot: {data: {}}, firstChild: null} + }, + UserStoreService, ] }).compileComponents(); fixture = TestBed.createComponent(HeaderComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); + + it('should load username from store or token fallback', () => { + const userStore = TestBed.inject(UserStoreService); + userStore.setUsernameForStore('store-user'); + fixture.detectChanges(); + + expect(component.username).toBe('store-user'); + }); + + it('should fall back to token username when store is empty', () => { + authService.getUsernameFromToken.and.returnValue('token-user'); + fixture.detectChanges(); + + expect(component.username).toBe('token-user'); + }); + + it('should load role from store or token fallback', () => { + authService.getRoleFromToken.and.returnValue('Admin'); + fixture.detectChanges(); + + expect(component.role).toBe('Admin'); + }); + + it('should call signOut on logout', () => { + fixture.detectChanges(); + component.logOut(); + expect(authService.signOut).toHaveBeenCalled(); + }); }); diff --git a/src/ClientApp/src/app/presentation/interceptors/token.interceptor.spec.ts b/src/ClientApp/src/app/presentation/interceptors/token.interceptor.spec.ts index 4026e41..0ebb6b5 100644 --- a/src/ClientApp/src/app/presentation/interceptors/token.interceptor.spec.ts +++ b/src/ClientApp/src/app/presentation/interceptors/token.interceptor.spec.ts @@ -1,23 +1,35 @@ -import {TestBed} from '@angular/core/testing'; +import {fakeAsync, TestBed, tick} from '@angular/core/testing'; import {HttpClient, HttpInterceptorFn, provideHttpClient, withInterceptors, withXhr} from '@angular/common/http'; import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; import {tokenInterceptor} from './token.interceptor'; import {AuthService} from '../../infrastructure/services/auth-service'; +import {ToastService} from '../../infrastructure/services/toast.service'; +import {Router} from '@angular/router'; +import {of, throwError} from 'rxjs'; describe('tokenInterceptor', () => { let authService: jasmine.SpyObj; + let toastService: jasmine.SpyObj; + let router: jasmine.SpyObj; let httpClient: HttpClient; let httpTesting: HttpTestingController; beforeEach(() => { - authService = jasmine.createSpyObj('AuthService', ['getToken', 'getRefreshToken', 'getUserIdFromToken', - 'storeToken', 'storeRefreshToken', 'signOut', 'renewToken']); + authService = jasmine.createSpyObj('AuthService', [ + 'getToken', 'getRefreshToken', 'getUserIdFromToken', + 'storeToken', 'storeRefreshToken', 'signOut', 'renewToken' + ]); + toastService = jasmine.createSpyObj('ToastService', ['show']); + router = jasmine.createSpyObj('Router', ['navigate']); + router.navigate.and.returnValue(Promise.resolve(true)); TestBed.configureTestingModule({ providers: [ provideHttpClient(withXhr(), withInterceptors([tokenInterceptor])), provideHttpClientTesting(), {provide: AuthService, useValue: authService}, + {provide: ToastService, useValue: toastService}, + {provide: Router, useValue: router}, ] }); httpClient = TestBed.inject(HttpClient); @@ -47,4 +59,61 @@ describe('tokenInterceptor', () => { expect(req.request.headers.has('Authorization')).toBeFalse(); req.flush({}); }); + + it('should retry with new token on 401 and refresh succeeds', () => { + authService.getToken.and.returnValues('expired-token', 'new-access-token'); + authService.getRefreshToken.and.returnValue('valid-refresh'); + authService.getUserIdFromToken.and.returnValue(1); + authService.renewToken.and.returnValue(of({ + value: {accessToken: 'new-access-token', refreshToken: 'new-refresh-token'} + })); + + const results: any[] = []; + httpClient.get('/api/data').subscribe({next: r => results.push(r)}); + + const req1 = httpTesting.expectOne('/api/data'); + req1.flush('Unauthorized', {status: 401, statusText: 'Unauthorized'}); + + const req2 = httpTesting.expectOne('/api/data'); + expect(req2.request.headers.get('Authorization')).toBe('Bearer new-access-token'); + req2.flush({data: 'success'}); + + expect(results).toEqual([{data: 'success'}]); + expect(authService.storeToken).toHaveBeenCalledWith('new-access-token'); + expect(authService.storeRefreshToken).toHaveBeenCalledWith('new-refresh-token'); + }); + + it('should sign out and redirect on 401 when refresh fails', fakeAsync(() => { + authService.getToken.and.returnValue('expired-token'); + authService.getRefreshToken.and.returnValue('expired-refresh'); + authService.getUserIdFromToken.and.returnValue(1); + authService.renewToken.and.returnValue(throwError(() => ({ + error: {error: {message: 'Token expired'}} + }))); + + httpClient.get('/api/data').subscribe({error: () => {}}); + + const req = httpTesting.expectOne('/api/data'); + req.flush('Unauthorized', {status: 401, statusText: 'Unauthorized'}); + tick(); + + expect(authService.signOut).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['login']); + expect(toastService.show).toHaveBeenCalledWith('Token expired', jasmine.objectContaining({ + classname: 'bg-warning text-light' + })); + })); + + it('should pass through non-401 errors', () => { + authService.getToken.and.returnValue('some-token'); + + const errors: any[] = []; + httpClient.get('/api/test').subscribe({error: e => errors.push(e)}); + + const req = httpTesting.expectOne('/api/test'); + req.flush('Forbidden', {status: 403, statusText: 'Forbidden'}); + + expect(errors.length).toBe(1); + expect(errors[0].status).toBe(403); + }); }); diff --git a/src/ClientApp/src/app/presentation/shared/popup-modal/popup-modal.component.spec.ts b/src/ClientApp/src/app/presentation/shared/popup-modal/popup-modal.component.spec.ts index 592d4d6..35351de 100644 --- a/src/ClientApp/src/app/presentation/shared/popup-modal/popup-modal.component.spec.ts +++ b/src/ClientApp/src/app/presentation/shared/popup-modal/popup-modal.component.spec.ts @@ -1,6 +1,5 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PopupModalComponent } from './popup-modal.component'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {PopupModalComponent} from './popup-modal.component'; describe('PopupModalComponent', () => { let component: PopupModalComponent; @@ -9,8 +8,7 @@ describe('PopupModalComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [PopupModalComponent] - }) - .compileComponents(); + }).compileComponents(); fixture = TestBed.createComponent(PopupModalComponent); component = fixture.componentInstance; @@ -20,4 +18,29 @@ describe('PopupModalComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should have default headerClass', () => { + expect(component.headerClass).toBe('bg-light'); + }); + + it('should bind title input', () => { + component.title = 'Warning'; + fixture.detectChanges(); + const titleEl: HTMLElement = fixture.nativeElement.querySelector('.modal-title'); + expect(titleEl.textContent).toContain('Warning'); + }); + + it('should bind body input', () => { + component.body = 'Are you sure?'; + fixture.detectChanges(); + const bodyEl: HTMLElement = fixture.nativeElement.querySelector('.modal-body p'); + expect(bodyEl.textContent).toContain('Are you sure?'); + }); + + it('should emit confirm on onConfirm', () => { + const spy = jasmine.createSpy(); + component.confirm.subscribe(spy); + component.onConfirm(); + expect(spy).toHaveBeenCalled(); + }); }); diff --git a/src/ClientApp/src/app/presentation/shared/toast/toast.component.spec.ts b/src/ClientApp/src/app/presentation/shared/toast/toast.component.spec.ts index 8c3543b..2eb2500 100644 --- a/src/ClientApp/src/app/presentation/shared/toast/toast.component.spec.ts +++ b/src/ClientApp/src/app/presentation/shared/toast/toast.component.spec.ts @@ -1,17 +1,18 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; - import {ToastComponent} from './toast.component'; +import {ToastService} from '../../../infrastructure/services/toast.service'; describe('ToastComponent', () => { let component: ToastComponent; let fixture: ComponentFixture; + let toastService: ToastService; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ToastComponent] - }) - .compileComponents(); + }).compileComponents(); + toastService = TestBed.inject(ToastService); fixture = TestBed.createComponent(ToastComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -20,4 +21,45 @@ describe('ToastComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should render toasts from service', () => { + toastService.show('Hello World', {classname: 'bg-info'}); + fixture.detectChanges(); + + const toastEl = fixture.nativeElement.querySelector('.toast'); + expect(toastEl).toBeTruthy(); + expect(toastEl.textContent).toContain('Hello World'); + expect(toastEl.classList).toContain('bg-info'); + }); + + it('should render multiple toasts', () => { + toastService.show('First'); + toastService.show('Second'); + fixture.detectChanges(); + + const toastEls = fixture.nativeElement.querySelectorAll('.toast'); + expect(toastEls.length).toBe(2); + }); + + it('should remove toast on close button click', () => { + toastService.show('Dismiss me'); + fixture.detectChanges(); + + const closeBtn = fixture.nativeElement.querySelector('.btn-close'); + closeBtn.click(); + fixture.detectChanges(); + + const toastEls = fixture.nativeElement.querySelectorAll('.toast'); + expect(toastEls.length).toBe(0); + }); + + it('should stop rendering after toast is removed', () => { + toastService.show('Temporary'); + toastService.show('Persistent'); + toastService.remove(toastService.toasts[0]); + fixture.detectChanges(); + + const toastEls = fixture.nativeElement.querySelectorAll('.toast'); + expect(toastEls.length).toBe(1); + }); });