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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
run: yarn run affected --verbose

- name: ===== Run ui unit tests =====
run: yarn run test:ui --verbose
run: yarn run test:ui:ci --verbose

- name: ===== Run ui e2e tests =====
run: yarn run e2e:ui --verbose
11 changes: 10 additions & 1 deletion .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
approvedGitRepositories:
- "**"

cacheFolder: ./.yarn/cache
nodeLinker: node-modules

enableInlineBuilds: true

enableScripts: true

nodeLinker: node-modules

npmMinimalAgeGate: 0
3 changes: 2 additions & 1 deletion apps/ui/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@
"executor": "@angular/build:unit-test",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"tsConfig": "apps/ui/tsconfig.spec.json"
"tsConfig": "apps/ui/tsconfig.spec.json",
"browsers": ["chromium"]
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions apps/ui/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, Router, withComponentInputBinding } from '@angular/router';
import { appRoutes } from './app.routes';
import { errorInterceptor } from './core/error/error-interceptor';
import { AppRouter } from './fw-extensions/app-router';
import { authInterceptor } from './interceptors/auth.interceptor';

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(appRoutes, withComponentInputBinding()),
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
provideZonelessChangeDetection(),
{
provide: Router,
useClass: AppRouter
useClass: AppRouter,
},
],
};
6 changes: 4 additions & 2 deletions apps/ui/src/app/components/login/login.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ <h4 class="card-title pb-3">Login with email and password</h4>
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" [formField]="loginForm.password" placeholder="Password" data-testid="password" />
</div>
<button type="button" class="btn btn-primary" id="submitUserPassword" [disabled]="!loginForm().valid() || loginForm().submitting()" (click)="submitForm()">Submit</button>
<button type="button" class="btn btn-primary" id="submitUserPassword" [disabled]="!loginForm().valid() || loginForm().submitting()" data-testid="submit" (click)="submitForm()">
Submit
</button>
@if (loginForm().submitting()) {
<div class="spinner-border spinner-border-sm ms-4" role="status" data-testid="spinner"></div>
}
Expand All @@ -24,7 +26,7 @@ <h4 class="card-title pb-3">Login with email and password</h4>
<div class="login-google card p-3">
<div class="card-body">
<h4 class="card-title pb-3">Login with Google</h4>
<button type="button" class="btn btn-primary" id="submitGoogle" (click)="goToGoogleLogin()">Go</button>
<button type="button" class="btn btn-primary" id="submitGoogle" data-testid="login-google" (click)="goToGoogleLogin()">Go</button>
</div>
</div>
</div>
Expand Down
27 changes: 19 additions & 8 deletions apps/ui/src/app/components/login/login.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { testSetup } from 'utils/test/setup';
import { describe, expect, test } from 'vitest';
import { Locator, page } from 'vitest/browser';
import { LoginComponent } from './login.component';

describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
let emailLocator: Locator;
let passwordLocator: Locator;
let submitButtonLocator: Locator;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LoginComponent],
}).compileComponents();

fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
testSetup(LoginComponent);
emailLocator = page.getByTestId('email');
passwordLocator = page.getByTestId('password');
submitButtonLocator = page.getByTestId('submit');
});

test('should create', () => {
expect(component).toBeTruthy();
test('submit button should not be clickable if email or password is empty', async () => {
await expect.element(submitButtonLocator).toBeDisabled();
});

test('submit button should be clickable if email and password are provided', async () => {
await emailLocator.fill('test@example.com');
await passwordLocator.fill('password123');

await expect.element(submitButtonLocator).toBeEnabled();
});
});
17 changes: 17 additions & 0 deletions apps/ui/src/app/core/error/error-interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { errorInterceptor } from './error-interceptor';

// TODO: Add tests for errorInterceptor functionality

describe('errorInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) => TestBed.runInInjectionContext(() => errorInterceptor(req, next));

beforeEach(() => {
TestBed.configureTestingModule({});
});

it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});
12 changes: 12 additions & 0 deletions apps/ui/src/app/core/error/error-interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
console.error('*** errorInterceptor error = ', error);

return throwError(() => new Error(error.message));
}),
);
};
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import * as bootstrap from 'bootstrap';
import { TestBed } from '@angular/core/testing';
import { Locator, page } from '@vitest/browser/context';
import { testSetup } from 'utils/test/setup';
import { describe, expect, test, vi } from 'vitest';
import { TooltipTriggerDirective } from './tooltip-trigger.directive';

@Component({
selector: 'gh-loader-test',
imports: [TooltipTriggerDirective],
template: `<div ghTooltipTrigger></div>`,
template: `<span ghTooltipTrigger data-bs-title="I have a tooltip">Hover me</span>`,
})
class TooltipTriggerTestComponent {}

vi.mock('bootstrap', () => ({
Tooltip: vi.fn(),
vi.mock('Tooltip', () => ({
constructor() {},
}));

describe('TooltipTriggerDirective', () => {
Expand All @@ -22,20 +22,33 @@ describe('TooltipTriggerDirective', () => {

return { fixture, component };
};
let withTooltip: Locator;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TooltipTriggerTestComponent],
}).compileComponents();
setup();

withTooltip = page.getByText('Hover me');
});

test('tooltip should not show before hover', async () => {
const tooltip = page.getByRole('tooltip');

await expect(tooltip).toHaveLength(0);
});

test('should create an instance', async () => {
let fixture: ComponentFixture<TooltipTriggerTestComponent>;
test('tooltip should show on hover and hide on unhover', async () => {
const tooltip = page.getByRole('tooltip');

await withTooltip.hover(); // Move mouse away to trigger tooltip show

await expect(tooltip).toBeVisible();
await expect(tooltip).toHaveTextContent('I have a tooltip');

fixture = setup().fixture;
await fixture.whenStable();
await withTooltip.unhover(); // Move mouse away to trigger tooltip hide

expect(bootstrap.Tooltip).toHaveBeenCalledTimes(1);
expect(bootstrap.Tooltip).toHaveBeenCalledWith(expect.any(HTMLElement), { trigger: 'hover' });
await expect(tooltip.elements).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive, ElementRef, inject } from '@angular/core';
import * as bootstrap from 'bootstrap';
import { Tooltip } from 'bootstrap';

@Directive({
selector: '[ghTooltipTrigger]',
Expand All @@ -8,6 +8,6 @@ export class TooltipTriggerDirective {
elementRef = inject(ElementRef);

constructor() {
new bootstrap.Tooltip(this.elementRef.nativeElement, { trigger: 'hover'});
new Tooltip(this.elementRef.nativeElement, { trigger: 'hover' });
}
}
2 changes: 1 addition & 1 deletion apps/ui/src/app/guards/auth.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CanActivateFn } from '@angular/router';
import { describe, expect, test } from 'vitest';
import { authGuard } from './auth.guard';

/// TODO: Add tests for authGuard functionality
// TODO: Add tests for authGuard functionality

describe('authGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) => TestBed.runInInjectionContext(() => authGuard(...guardParameters));
Expand Down
150 changes: 142 additions & 8 deletions apps/ui/src/app/interceptors/auth.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,152 @@
import { HttpInterceptorFn } from '@angular/common/http';
// import '@angular/compiler';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { describe, expect, test } from 'vitest';
import { AppRouter } from 'fw-extensions/app-router';
import { firstValueFrom, of } from 'rxjs';
import { AuthService } from 'services/auth.service';
import { PUBLIC_API, REFRESH_API } from 'utils/api';
import { describe, expect, test, vi } from 'vitest';
import { authInterceptor } from './auth.interceptor';

/// TODO: Add tests for authInterceptor functionality

/// Tests for authInterceptor behavior, including token attachment and refresh retry.
describe('authInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) => TestBed.runInInjectionContext(() => authInterceptor(req, next));
let httpTestingController: HttpTestingController;
let httpClient: HttpClient;
let authServiceMock: {
readonly accessToken: string;
readonly refreshToken: string;
readonly refresh: ReturnType<typeof vi.fn>;
readonly saveCredentials: ReturnType<typeof vi.fn>;
};
let appRouterMock: {
readonly navigateToTokenExpired: ReturnType<typeof vi.fn>;
};
const expectOneTestRequest = () => httpTestingController.expectOne('/test');

beforeEach(() => {
TestBed.configureTestingModule({});
let currentAccessToken = 'old-token';
let currentRefreshToken = 'refresh-token';

authServiceMock = {
get accessToken() {
return currentAccessToken;
},
get refreshToken() {
return currentRefreshToken;
},
refresh: vi.fn(() => {
currentAccessToken = 'new-token';
currentRefreshToken = 'new-refresh';

return of({ accessToken: currentAccessToken, refreshToken: currentRefreshToken });
}),
saveCredentials: vi.fn(),
};

appRouterMock = {
navigateToTokenExpired: vi.fn(async () => Promise.resolve()),
};

TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{
provide: AuthService,
useValue: authServiceMock,
},
{
provide: AppRouter,
useValue: appRouterMock,
},
],
});

httpTestingController = TestBed.inject(HttpTestingController);
httpClient = TestBed.inject(HttpClient);
});

afterEach(() => {
httpTestingController.verify();
});

test('should return the correct response if successful', async () => {
const responsePromise = firstValueFrom(httpClient.get<boolean>('/test'));
const req = expectOneTestRequest();

req.flush(true);

await expect(responsePromise).resolves.toEqual(true);
});

test('should throw an error if unsuccessful', async () => {
const responsePromise = firstValueFrom(httpClient.get<boolean>('/test'));
const req = expectOneTestRequest();
const errorStatus = 500;
const errorStatusText = 'Server error';

req.error(new ProgressEvent('Network error'), { status: errorStatus, statusText: errorStatusText });

await expect(responsePromise).rejects.toThrow(`Http failure response for /test: ${errorStatus} ${errorStatusText}`);
});

test('should be created', () => {
expect(interceptor).toBeTruthy();
test('should skip authorization for public requests', async () => {
const responsePromise = firstValueFrom(httpClient.get<boolean>('/test', { context: PUBLIC_API }));
const req = expectOneTestRequest();

req.flush(true);

await responsePromise;

expect(req.request.headers.has('Authorization')).toBe(false);
});

test('should attach authorization header to non-public requests', async () => {
const responsePromise = firstValueFrom(httpClient.get<boolean>('/test'));
const req = expectOneTestRequest();

req.flush(true);

await responsePromise;

expect(req.request.headers.get('Authorization')).toBe('Bearer old-token');
});

test('should refresh token and retry request on 401 error', async () => {
const responsePromise = firstValueFrom(httpClient.get<boolean>('/test'));
const req = expectOneTestRequest();

expect(req.request.headers.get('Authorization')).toBe('Bearer old-token');

// simulate 401 response for initial request
req.flush(null, { status: 401, statusText: 'Unauthorized' });

expect(authServiceMock.refresh).toHaveBeenCalledWith('refresh-token');

// retry request should be made with new token
const retryReq = expectOneTestRequest();

expect(retryReq.request.headers.get('Authorization')).toBe('Bearer new-token');

retryReq.flush(true);
await expect(responsePromise).resolves.toBe(true);

expect(authServiceMock.saveCredentials).toHaveBeenCalled();
});

test('should navigate to token-expired when refresh request fails with 401', async () => {
const responsePromise = firstValueFrom(httpClient.get('/test', { context: REFRESH_API }));
const req = expectOneTestRequest();

expect(req.request.headers.get('Authorization')).toBe('Bearer old-token');

// simulate 401 response for refresh request
req.flush(null, { status: 401, statusText: 'Unauthorized' });

await expect(responsePromise).rejects.toThrow('Http failure response for /test: 401 Unauthorized');

expect(appRouterMock.navigateToTokenExpired).toHaveBeenCalled();
expect(authServiceMock.refresh).not.toHaveBeenCalled();
});
});
Loading
Loading