diff --git a/src/app/auth/guards/auth-guard.spec.ts b/src/app/auth/guards/auth-guard.spec.ts index f195d16..513c8fa 100644 --- a/src/app/auth/guards/auth-guard.spec.ts +++ b/src/app/auth/guards/auth-guard.spec.ts @@ -1,8 +1,10 @@ -import { signal } from '@angular/core'; +import { inject, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { CanActivateFn } from '@angular/router'; -import { Mocked, vi } from 'vitest'; +import { of } from 'rxjs'; +import { vi } from 'vitest'; import { AuthService } from '../services/auth.service'; +import { User } from '../types/user.interface'; import { authGuard } from './auth-guard'; @@ -10,16 +12,23 @@ describe('authGuard', () => { const executeGuard: CanActivateFn = (...guardParameters) => TestBed.runInInjectionContext(() => authGuard(...guardParameters)); - // Vitest's `Mocked` utility type ensures the stub is type-safe - const authServiceStub: Mocked = { - // Create a real signal to use as the mock value - currentUserSignal: signal(undefined), - } as unknown as Mocked; + const AuthServiceStub = vi.fn(class { + _currentUserSignal = signal(undefined); + currentUserSignal = this._currentUserSignal.asReadonly(); + getCurrentUser = vi.fn(() => { + let currentUser = { username: 'testuser' } as User; + this._currentUserSignal.set(currentUser); + return of(currentUser); + }); + logout = vi.fn(() => { + this._currentUserSignal.set(null); + }); + }) as unknown as { new(): AuthService }; beforeEach(() => { TestBed.configureTestingModule({ providers: [ - { provide: AuthService, useValue: authServiceStub }, + { provide: AuthService, useClass: AuthServiceStub }, ], }); @@ -32,8 +41,9 @@ describe('authGuard', () => { it('should return true when currentUserSignal is null', () => { TestBed.runInInjectionContext(() => { - // Update the signal value directly - authServiceStub.currentUserSignal.set(null); + const authServiceStub = inject(AuthService); + + authServiceStub.logout(); const result = authGuard({} as any, {} as any); expect(result).toBe(true); @@ -42,8 +52,9 @@ describe('authGuard', () => { it('should return false when currentUserSignal is not null', () => { TestBed.runInInjectionContext(() => { - // Update the signal value directly - authServiceStub.currentUserSignal.set({ username: 'testUser', email: '', token: '' }); + const authServiceStub = inject(AuthService); + + authServiceStub.getCurrentUser().subscribe(); const result = authGuard({} as any, {} as any); expect(result).toBe(false); diff --git a/src/app/auth/services/auth.service.spec.ts b/src/app/auth/services/auth.service.spec.ts index fc553cd..0d724c0 100644 --- a/src/app/auth/services/auth.service.spec.ts +++ b/src/app/auth/services/auth.service.spec.ts @@ -130,6 +130,29 @@ describe('AuthService', () => { expect(thrownError!.message).toContain('email'); expect(thrownError!.message).toContain('username'); }); + + it('should format multiple messages for the same field', () => { + const errorResponse = { errors: { password: ['is too short', 'must include a number'] } }; + let thrownError: Error | undefined; + + service.register(registerModel).subscribe({ error: (err: Error) => (thrownError = err) }); + + const req = httpTesting.expectOne(`${environment.authUrl}/api/users`); + req.flush(errorResponse, { status: 422, statusText: 'Unprocessable Entity' }); + + expect(thrownError!.message).toBe('
  • password is too short, password must include a number
'); + }); + + it('should handle HTTP error without errors body gracefully', () => { + let thrownError: unknown; + service.register(registerModel).subscribe({ error: (err) => (thrownError = err) }); + + const req = httpTesting.expectOne(`${environment.authUrl}/api/users`); + req.flush('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }); + + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toContain('Internal Server Error'); + }); }); describe('login()', () => { @@ -206,7 +229,6 @@ describe('AuthService', () => { describe('getCurrentUser()', () => { it('should return null and set signal to null when no token in localStorage', () => { - localStorage.removeItem('token'); let result: User | null | undefined; service.getCurrentUser().subscribe(user => (result = user)); @@ -296,7 +318,14 @@ describe('AuthService', () => { }); it('should set currentUserSignal to null', () => { - service.currentUserSignal.set(mockUser); + // service.currentUserSignal.set(mockUser); + // set up current user + localStorage.setItem('token', 'existing-token'); + + service.getCurrentUser().subscribe(); + + const req = httpTesting.expectOne(`${environment.authUrl}/api/user`); + req.flush(mockUserResponse); service.logout(); diff --git a/src/app/auth/services/auth.service.ts b/src/app/auth/services/auth.service.ts index 6ea0a02..7305192 100644 --- a/src/app/auth/services/auth.service.ts +++ b/src/app/auth/services/auth.service.ts @@ -8,7 +8,8 @@ import { ErrorResponse, LoginModel, RegisterModel, User, UserResponse } from '.. providedIn: 'root', }) export class AuthService { - currentUserSignal = signal(undefined); + private readonly _currentUserSignal = signal(undefined); + currentUserSignal = this._currentUserSignal.asReadonly(); private readonly baseUrl = environment.authUrl; private readonly http = inject(HttpClient); @@ -24,9 +25,8 @@ export class AuthService { ).pipe( map(response => response.user), tap(user => { - console.log('User registered successfully:', user); localStorage.setItem('token', user.token); - this.currentUserSignal.set(user); + this._currentUserSignal.set(user); }), catchError(this.handleError) ); @@ -41,22 +41,22 @@ export class AuthService { map(response => response.user), tap(user => { localStorage.setItem('token', user.token); - this.currentUserSignal.set(user); + this._currentUserSignal.set(user); }), catchError(this.handleError) ); } getCurrentUser() { - if (localStorage.getItem('token') == null) { - this.currentUserSignal.set(null); + if (localStorage.getItem('token') === null) { + this._currentUserSignal.set(null); return of(null); } return this.http.get(`${this.baseUrl}/api/user`).pipe( map(response => response.user), tap(user => { - this.currentUserSignal.set(user); + this._currentUserSignal.set(user); }), catchError(() => { this.logout(); @@ -67,16 +67,22 @@ export class AuthService { logout() { localStorage.removeItem('token'); - this.currentUserSignal.set(null); + this._currentUserSignal.set(null); } private readonly handleError = (response: HttpErrorResponse) => { throw new Error(this.formatError(response)); - } + }; private formatError(response: HttpErrorResponse): string { - return '
    ' + Object.entries((response.error).errors) - .map(([key, messages]) => `
  • ${key} ${messages.join(', ' + key + ' ')}
  • `) - .join("") + '
'; + const errors = (response.error)?.errors; + + if (errors && typeof errors === 'object' && !Array.isArray(errors) && Object.entries(errors).length) { + return '
    ' + Object.entries(errors) + .map(([key, messages]) => `
  • ${key} ${messages.join(', ' + key + ' ')}
  • `) + .join('') + '
'; + } else { + return response.message ?? 'An unknown error occurred'; + } } }