Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions src/app/auth/guards/auth-guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
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';

describe('authGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));

// Vitest's `Mocked` utility type ensures the stub is type-safe
const authServiceStub: Mocked<AuthService> = {
// Create a real signal to use as the mock value
currentUserSignal: signal(undefined),
} as unknown as Mocked<AuthService>;
const AuthServiceStub = vi.fn(class {
_currentUserSignal = signal<User | null | undefined>(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 },
],
});

Expand All @@ -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);
Expand All @@ -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);
Expand Down
33 changes: 31 additions & 2 deletions src/app/auth/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<ul><li>password is too short, password must include a number</li></ul>');
});

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()', () => {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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();

Expand Down
30 changes: 18 additions & 12 deletions src/app/auth/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { ErrorResponse, LoginModel, RegisterModel, User, UserResponse } from '..
providedIn: 'root',
})
export class AuthService {
currentUserSignal = signal<User | null | undefined>(undefined);
private readonly _currentUserSignal = signal<User | null | undefined>(undefined);
currentUserSignal = this._currentUserSignal.asReadonly();
private readonly baseUrl = environment.authUrl;
private readonly http = inject(HttpClient);

Expand All @@ -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)
);
Expand All @@ -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<UserResponse>(`${this.baseUrl}/api/user`).pipe(
map(response => response.user),
tap(user => {
this.currentUserSignal.set(user);
this._currentUserSignal.set(user);
}),
catchError(() => {
this.logout();
Expand All @@ -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 '<ul>' + Object.entries((<ErrorResponse>response.error).errors)
.map(([key, messages]) => `<li>${key} ${messages.join(', ' + key + ' ')}</li>`)
.join("") + '</ul>';
const errors = (<ErrorResponse>response.error)?.errors;

if (errors && typeof errors === 'object' && !Array.isArray(errors) && Object.entries(errors).length) {
return '<ul>' + Object.entries(errors)
.map(([key, messages]) => `<li>${key} ${messages.join(', ' + key + ' ')}</li>`)
.join('') + '</ul>';
} else {
return response.message ?? 'An unknown error occurred';
}
}
}
Loading