Skip to content

Commit 7feefa9

Browse files
authored
test: add Angular unit tests (#19)
* feat(members): add invitation and member management UI (ref Sentinent-AI/Sentinent#15) * test(frontend): add Angular unit test coverage
1 parent f88eb7a commit 7feefa9

8 files changed

Lines changed: 599 additions & 5 deletions

File tree

src/app/components/login/login.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ <h2>Welcome back</h2>
4848
<form *ngIf="isLoginVisible" (ngSubmit)="handleLogin()" class="form-section" novalidate>
4949
<label class="input-label" for="loginEmail">Email</label>
5050
<input id="loginEmail" name="loginEmail" class="input-field" type="email" autocomplete="email"
51-
[(ngModel)]="loginEmail" (input)="onLoginEmailInput()" placeholder="you@email.com">
51+
[class.error]="!!loginError" [(ngModel)]="loginEmail" (input)="onLoginEmailInput()" placeholder="you@email.com">
5252

5353
<label class="input-label" for="loginPassword">Password</label>
5454
<input id="loginPassword" name="loginPassword" class="input-field" type="password"
@@ -73,7 +73,7 @@ <h2>Welcome back</h2>
7373
<form *ngIf="isRegisterVisible" (ngSubmit)="handleRegister()" class="form-section" novalidate>
7474
<label class="input-label" for="regEmail">Email</label>
7575
<input id="regEmail" name="regEmail" class="input-field" type="email"
76-
autocomplete="email" [(ngModel)]="regEmail" (input)="onRegisterEmailInput()"
76+
[class.error]="!!registerError" autocomplete="email" [(ngModel)]="regEmail" (input)="onRegisterEmailInput()"
7777
placeholder="you@email.com">
7878

7979
<label class="input-label" for="regPassword">Password</label>
@@ -88,9 +88,10 @@ <h2>Welcome back</h2>
8888

8989
<form *ngIf="showForgot" (ngSubmit)="handleForgot()" class="form-section">
9090
<label class="input-label" for="forgotEmail">Work Email</label>
91-
<input id="forgotEmail" name="forgotEmail" class="input-field" type="email" required [(ngModel)]="forgotEmail"
91+
<input id="forgotEmail" name="forgotEmail" class="input-field" type="email" [class.error]="!!forgotError" required [(ngModel)]="forgotEmail"
9292
(input)="onForgotEmailInput()" placeholder="you@company.com">
9393
<p class="subtle-copy">We'll send a password reset link to your email address.</p>
94+
<p *ngIf="forgotError" class="error-text">{{ forgotError }}</p>
9495

9596
<button type="submit" class="btn-primary" [disabled]="isForgotSubmitting">
9697
Send Reset Link
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { DOCUMENT } from '@angular/common';
2+
import { HttpErrorResponse } from '@angular/common/http';
3+
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
4+
import { Router } from '@angular/router';
5+
import { RouterTestingModule } from '@angular/router/testing';
6+
import { of, throwError } from 'rxjs';
7+
import { Login } from './login';
8+
import { AuthService } from '../../services/auth';
9+
10+
describe('Login', () => {
11+
let component: Login;
12+
let fixture: ComponentFixture<Login>;
13+
let mockAuthService: jasmine.SpyObj<AuthService>;
14+
let router: Router;
15+
let document: Document;
16+
17+
beforeEach(async () => {
18+
mockAuthService = jasmine.createSpyObj<AuthService>('AuthService', ['login', 'signup']);
19+
20+
spyOn(localStorage, 'getItem').and.returnValue(null);
21+
spyOn(localStorage, 'setItem');
22+
spyOn(window, 'matchMedia').and.returnValue({
23+
matches: false,
24+
media: '',
25+
onchange: null,
26+
addListener: () => {},
27+
removeListener: () => {},
28+
addEventListener: () => {},
29+
removeEventListener: () => {},
30+
dispatchEvent: () => false
31+
});
32+
33+
await TestBed.configureTestingModule({
34+
imports: [Login, RouterTestingModule],
35+
providers: [
36+
{ provide: AuthService, useValue: mockAuthService }
37+
]
38+
}).compileComponents();
39+
40+
fixture = TestBed.createComponent(Login);
41+
component = fixture.componentInstance;
42+
router = TestBed.inject(Router);
43+
document = TestBed.inject(DOCUMENT);
44+
fixture.detectChanges();
45+
});
46+
47+
afterEach(() => {
48+
document.documentElement.classList.remove('dark');
49+
});
50+
51+
it('initializes in login mode by default', () => {
52+
expect(component.activeTab).toBe('login');
53+
expect(component.isLoginVisible).toBeTrue();
54+
});
55+
56+
it('switches to the register tab and navigates', () => {
57+
spyOn(router, 'navigate');
58+
59+
component.switchTab('register');
60+
61+
expect(component.activeTab).toBe('register');
62+
expect(router.navigate).toHaveBeenCalledWith(['/signup']);
63+
});
64+
65+
it('toggles the theme and stores the selection', () => {
66+
component.toggleTheme();
67+
68+
expect(component.isDarkMode).toBeTrue();
69+
expect(document.documentElement.classList.contains('dark')).toBeTrue();
70+
expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'dark');
71+
});
72+
73+
it('shows an error when login credentials are missing', () => {
74+
component.loginEmail = ' ';
75+
component.loginPassword = '';
76+
77+
component.handleLogin();
78+
79+
expect(component.loginError).toBe('Invalid credentials');
80+
expect(mockAuthService.login).not.toHaveBeenCalled();
81+
});
82+
83+
it('rejects login when the email format is invalid', () => {
84+
component.loginEmail = 'invalid-email';
85+
component.loginPassword = 'secret';
86+
87+
component.handleLogin();
88+
89+
expect(component.loginError).toBe('Enter a valid email address');
90+
expect(mockAuthService.login).not.toHaveBeenCalled();
91+
});
92+
93+
it('shows a success message and redirects after login', fakeAsync(() => {
94+
spyOn(router, 'navigate');
95+
mockAuthService.login.and.returnValue(of({ token: 'token-1' }));
96+
component.loginEmail = 'user@example.com';
97+
component.loginPassword = 'secret';
98+
99+
component.handleLogin();
100+
tick(1200);
101+
102+
expect(mockAuthService.login).toHaveBeenCalledWith('user@example.com', 'secret');
103+
expect(component.showSuccess).toBeTrue();
104+
expect(component.successTitle).toBe('Welcome back');
105+
expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
106+
}));
107+
108+
it('maps a 401 login response to invalid credentials', () => {
109+
mockAuthService.login.and.returnValue(
110+
throwError(() => new HttpErrorResponse({ status: 401, error: 'invalid credentials' }))
111+
);
112+
component.loginEmail = 'user@example.com';
113+
component.loginPassword = 'wrong';
114+
115+
component.handleLogin();
116+
117+
expect(component.loginError).toBe('Invalid credentials');
118+
expect(component.isLoginSubmitting).toBeFalse();
119+
});
120+
121+
it('prevents register submission when fields are missing', () => {
122+
component.regEmail = '';
123+
component.regPassword = '';
124+
125+
component.handleRegister();
126+
127+
expect(component.registerError).toBe('Invalid credentials');
128+
expect(mockAuthService.signup).not.toHaveBeenCalled();
129+
});
130+
131+
it('rejects registration when the email format is invalid', () => {
132+
component.activeTab = 'register';
133+
component.regEmail = 'invalid-email';
134+
component.regPassword = 'secret';
135+
136+
component.handleRegister();
137+
138+
expect(component.registerError).toBe('Enter a valid email address');
139+
expect(mockAuthService.signup).not.toHaveBeenCalled();
140+
});
141+
142+
it('disables registration when the email format is invalid', () => {
143+
component.regEmail = 'invalid-email';
144+
component.regPassword = 'secret';
145+
146+
expect(component.isRegisterDisabled).toBeTrue();
147+
});
148+
149+
it('shows a duplicate email message when signup returns a conflict', () => {
150+
mockAuthService.signup.and.returnValue(
151+
throwError(() => new HttpErrorResponse({ status: 409, error: 'already exists' }))
152+
);
153+
component.activeTab = 'register';
154+
component.regEmail = 'user@example.com';
155+
component.regPassword = 'secret';
156+
157+
component.handleRegister();
158+
159+
expect(component.registerError).toBe('Email already exists');
160+
expect(component.activeTab).toBe('register');
161+
});
162+
163+
it('returns to login after a successful registration', fakeAsync(() => {
164+
spyOn(router, 'navigate');
165+
mockAuthService.signup.and.returnValue(of(void 0));
166+
component.activeTab = 'register';
167+
component.regEmail = 'new@example.com';
168+
component.regPassword = 'secret';
169+
170+
component.handleRegister();
171+
tick(1500);
172+
173+
expect(component.loginEmail).toBe('new@example.com');
174+
expect(component.activeTab).toBe('login');
175+
expect(router.navigate).toHaveBeenCalledWith(['/login']);
176+
}));
177+
178+
it('rejects forgot-password submission when the email format is invalid', () => {
179+
component.showForgotPassword();
180+
component.forgotEmail = 'invalid-email';
181+
182+
component.handleForgot();
183+
184+
expect(component.forgotError).toBe('Enter a valid email address');
185+
expect(component.isForgotSubmitting).toBeFalse();
186+
});
187+
});

src/app/components/login/login.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ import { AuthService } from '../../services/auth';
1414
styleUrl: './login.css',
1515
})
1616
export class Login implements OnInit {
17+
private readonly emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
18+
1719
activeTab: 'login' | 'register' = 'login';
1820

1921
loginEmail = '';
2022
loginPassword = '';
2123
rememberMe = true;
2224
loginError = '';
2325
registerError = '';
26+
forgotError = '';
2427

2528
regEmail = '';
2629
regPassword = '';
@@ -68,6 +71,7 @@ export class Login implements OnInit {
6871
showForgotPassword(): void {
6972
this.showForgot = true;
7073
this.showSuccess = false;
74+
this.forgotError = '';
7175
}
7276

7377
backToLogin(): void {
@@ -80,6 +84,10 @@ export class Login implements OnInit {
8084
this.loginError = 'Invalid credentials';
8185
return;
8286
}
87+
if (!this.isValidEmail(this.loginEmail)) {
88+
this.loginError = 'Enter a valid email address';
89+
return;
90+
}
8391
this.isLoginSubmitting = true;
8492

8593
this.authService.login(this.loginEmail.trim(), this.loginPassword).pipe(
@@ -112,6 +120,10 @@ export class Login implements OnInit {
112120
this.registerError = 'Invalid credentials';
113121
return;
114122
}
123+
if (!this.isValidEmail(this.regEmail)) {
124+
this.registerError = 'Enter a valid email address';
125+
return;
126+
}
115127
this.isRegisterSubmitting = true;
116128
this.registerError = '';
117129

@@ -158,6 +170,11 @@ export class Login implements OnInit {
158170
}
159171

160172
handleForgot(): void {
173+
this.forgotError = '';
174+
if (!this.isValidEmail(this.forgotEmail)) {
175+
this.forgotError = 'Enter a valid email address';
176+
return;
177+
}
161178
this.isForgotSubmitting = true;
162179
setTimeout(() => {
163180
this.isForgotSubmitting = false;
@@ -182,7 +199,7 @@ export class Login implements OnInit {
182199
}
183200

184201
get isRegisterDisabled(): boolean {
185-
return !this.regEmail.trim() || !this.regPassword;
202+
return !this.regEmail.trim() || !this.regPassword || !this.isValidEmail(this.regEmail);
186203
}
187204

188205
get isAuthFormVisible(): boolean {
@@ -222,7 +239,11 @@ export class Login implements OnInit {
222239
}
223240

224241
onForgotEmailInput(): void {
225-
this.clearGlobalError();
242+
this.forgotError = '';
243+
}
244+
245+
private isValidEmail(email: string): boolean {
246+
return this.emailPattern.test(email.trim());
226247
}
227248

228249
private syncView(): void {

src/app/guards/auth-guard.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { Router } from '@angular/router';
3+
import { authGuard } from './auth-guard';
4+
import { AuthService } from '../services/auth';
5+
6+
describe('authGuard', () => {
7+
let mockAuthService: jasmine.SpyObj<AuthService>;
8+
let mockRouter: jasmine.SpyObj<Router>;
9+
10+
beforeEach(() => {
11+
mockAuthService = jasmine.createSpyObj<AuthService>('AuthService', ['isLoggedIn']);
12+
mockRouter = jasmine.createSpyObj<Router>('Router', ['navigate']);
13+
14+
TestBed.configureTestingModule({
15+
providers: [
16+
{ provide: AuthService, useValue: mockAuthService },
17+
{ provide: Router, useValue: mockRouter }
18+
]
19+
});
20+
});
21+
22+
it('returns true when the user is logged in', () => {
23+
mockAuthService.isLoggedIn.and.returnValue(true);
24+
25+
const result = TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
26+
27+
expect(result).toBeTrue();
28+
expect(mockRouter.navigate).not.toHaveBeenCalled();
29+
});
30+
31+
it('redirects to login when the user is not logged in', () => {
32+
mockAuthService.isLoggedIn.and.returnValue(false);
33+
34+
const result = TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
35+
36+
expect(result).toBeFalse();
37+
expect(mockRouter.navigate).toHaveBeenCalledWith(['/login']);
38+
});
39+
});

0 commit comments

Comments
 (0)