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
8 changes: 1 addition & 7 deletions karma-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
"$comment": "Specs still on the deprecated Karma runner. Written by scripts/check-test-runner.mjs; files may leave this list, never join it. See DOCS/adr/0004-vitest-migration.md.",
"grandfathered": ["src/app/features/tasks/checker-inbox/checker-inbox.component.spec.ts"],
"specs": [
"src/app/core/interceptors/retry.interceptor.spec.ts",
"src/app/core/services/idle.service.spec.ts",
"src/app/features/clients/charges/client-charge-form.component.spec.ts",
"src/app/features/clients/charges/client-charges-list.component.spec.ts",
"src/app/features/clients/client-action-dialog.component.spec.ts",
Expand All @@ -18,7 +16,6 @@
"src/app/features/clients/kyc/client-note-form.component.spec.ts",
"src/app/features/clients/tabs/client-documents-list.component.spec.ts",
"src/app/features/clients/transactions/client-transactions-list.component.spec.ts",
"src/app/features/groups/group-members-dialog.component.spec.ts",
"src/app/features/loans/bulk-reassignment/bulk-reassignment.component.spec.ts",
"src/app/features/loans/collateral/collateral-form.component.spec.ts",
"src/app/features/loans/collateral/collateral-list.component.spec.ts",
Expand Down Expand Up @@ -62,9 +59,6 @@
"src/app/features/products/tax-components/tax-component-form.component.spec.ts",
"src/app/features/products/tax-components/tax-components-list.component.spec.ts",
"src/app/features/products/tax-groups/tax-group-form.component.spec.ts",
"src/app/features/products/tax-groups/tax-groups-list.component.spec.ts",
"src/app/shared/components/client-search/client-search.component.spec.ts",
"src/app/shared/components/search-filter/search-filter.component.spec.ts",
"src/app/shared/directives/tooltip.directive.spec.ts"
"src/app/features/products/tax-groups/tax-groups-list.component.spec.ts"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* under the License.
*/

import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { idempotent, withRetry, withTimeout } from '../http/http-context';
Expand All @@ -32,6 +32,7 @@ describe('retryInterceptor', () => {
const PAST_ALL_BACKOFF = 5000;

beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([retryInterceptor])),
Expand All @@ -43,17 +44,19 @@ describe('retryInterceptor', () => {
http = TestBed.inject(HttpTestingController);
});

afterEach(() => vi.useRealTimers());

/** Fails every request made to `url` so far, then lets the backoff timer elapse. */
function failAll(url: string, status: number): number {
const pending = http.match(url);
for (const req of pending) {
req.flush(null, { status, statusText: 'Failed' });
}
tick(PAST_ALL_BACKOFF);
vi.advanceTimersByTime(PAST_ALL_BACKOFF);
return pending.length;
}

it('should retry a GET on a transient failure and surface the last one', fakeAsync(() => {
it('should retry a GET on a transient failure and surface the last one', () => {
let received: unknown = null;
httpClient.get(testUrl).subscribe({ error: (err) => (received = err) });

Expand All @@ -66,51 +69,49 @@ describe('retryInterceptor', () => {
expect(attempts).toBe(DEFAULT_RETRY_COUNT + 1);
expect(received).toBeTruthy();
http.verify();
}));
});

it('should stop retrying as soon as an attempt succeeds', fakeAsync(() => {
it('should stop retrying as soon as an attempt succeeds', () => {
let response: unknown = null;
httpClient.get(testUrl).subscribe((r) => (response = r));

failAll(testUrl, 503);
http.expectOne(testUrl).flush({ data: 'ok' });
tick(PAST_ALL_BACKOFF);
vi.advanceTimersByTime(PAST_ALL_BACKOFF);

expect(response).toEqual({ data: 'ok' });
http.verify();
}));
});

it('should not retry a 4xx, which repeating cannot fix', fakeAsync(() => {
httpClient.get(testUrl).subscribe({ error: () => expect().nothing() });
it('should not retry a 4xx, which repeating cannot fix', () => {
httpClient.get(testUrl).subscribe({ error: () => undefined });

http.expectOne(testUrl).flush(null, { status: 400, statusText: 'Bad Request' });
tick(PAST_ALL_BACKOFF);
vi.advanceTimersByTime(PAST_ALL_BACKOFF);

http.verify();
}));
});

it('should not retry a 500, which is deterministic more often than not', fakeAsync(() => {
httpClient.get(testUrl).subscribe({ error: () => expect().nothing() });
it('should not retry a 500, which is deterministic more often than not', () => {
httpClient.get(testUrl).subscribe({ error: () => undefined });

http.expectOne(testUrl).flush(null, { status: 500, statusText: 'Server Error' });
tick(PAST_ALL_BACKOFF);
vi.advanceTimersByTime(PAST_ALL_BACKOFF);

http.verify();
}));
});

it('should not repeat a POST, which may already have been applied', fakeAsync(() => {
httpClient.post(testUrl, {}).subscribe({ error: () => expect().nothing() });
it('should not repeat a POST, which may already have been applied', () => {
httpClient.post(testUrl, {}).subscribe({ error: () => undefined });

http.expectOne(testUrl).flush(null, { status: 503, statusText: 'Unavailable' });
tick(PAST_ALL_BACKOFF);
vi.advanceTimersByTime(PAST_ALL_BACKOFF);

http.verify();
}));
});

it('should repeat a POST the call site declared idempotent', fakeAsync(() => {
httpClient
.post(testUrl, {}, { context: idempotent() })
.subscribe({ error: () => expect().nothing() });
it('should repeat a POST the call site declared idempotent', () => {
httpClient.post(testUrl, {}, { context: idempotent() }).subscribe({ error: () => undefined });

let attempts = 0;
for (let i = 0; i <= DEFAULT_RETRY_COUNT; i++) {
Expand All @@ -119,58 +120,56 @@ describe('retryInterceptor', () => {

expect(attempts).toBe(DEFAULT_RETRY_COUNT + 1);
http.verify();
}));
});

it('should not treat an explicit count as permission to repeat a POST', fakeAsync(() => {
it('should not treat an explicit count as permission to repeat a POST', () => {
// withRetry() tunes retrying where it is allowed; idempotent() is what authorises it.
httpClient
.post(testUrl, {}, { context: withRetry(3) })
.subscribe({ error: () => expect().nothing() });
httpClient.post(testUrl, {}, { context: withRetry(3) }).subscribe({ error: () => undefined });

http.expectOne(testUrl).flush(null, { status: 503, statusText: 'Unavailable' });
tick(PAST_ALL_BACKOFF);
vi.advanceTimersByTime(PAST_ALL_BACKOFF);

http.verify();
}));
});

it('should let a GET opt out with a count of zero', fakeAsync(() => {
httpClient
.get(testUrl, { context: withRetry(0) })
.subscribe({ error: () => expect().nothing() });
it('should let a GET opt out with a count of zero', () => {
httpClient.get(testUrl, { context: withRetry(0) }).subscribe({ error: () => undefined });

http.expectOne(testUrl).flush(null, { status: 503, statusText: 'Unavailable' });
tick(PAST_ALL_BACKOFF);
vi.advanceTimersByTime(PAST_ALL_BACKOFF);

http.verify();
}));
});

it('should not impose a timeout unless the call site asks for one', fakeAsync(() => {
it('should not impose a timeout unless the call site asks for one', () => {
let response: unknown = null;
httpClient.get(testUrl).subscribe((r) => (response = r));

// Longer than any default would plausibly be — a report or batch job runs this long.
tick(120_000);
vi.advanceTimersByTime(120_000);
http.expectOne(testUrl).flush({ data: 'ok' });
tick(PAST_ALL_BACKOFF);
vi.advanceTimersByTime(PAST_ALL_BACKOFF);

expect(response).toEqual({ data: 'ok' });
http.verify();
}));
});

it('should abandon a request that outruns its own deadline', fakeAsync(() => {
it('should abandon a request that outruns its own deadline', () => {
let received: unknown = null;
httpClient.get(testUrl, { context: withTimeout(1000) }).subscribe({
next: () => fail('expected a timeout'),
next: () => {
throw new Error('expected a timeout');
},
error: (err) => (received = err),
});

const req = http.expectOne(testUrl);
tick(1500);
vi.advanceTimersByTime(1500);

expect(received).toBeTruthy();
// The deadline is honoured, not multiplied by the retry count.
expect(req.cancelled).toBeTrue();
tick(PAST_ALL_BACKOFF);
expect(req.cancelled).toBe(true);
vi.advanceTimersByTime(PAST_ALL_BACKOFF);
http.verify();
}));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,26 @@
* under the License.
*/

import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { IdleService } from './idle.service';
import { AuthService } from './auth.service';
import { DialogService } from './dialog.service';
import { FakeOverlayAdapter, provideFakeAdapters } from '../../testing/adapters';
import { createSpyObj, SpyObj } from '../../testing/mocks';

describe('IdleService', () => {
let service: IdleService;
let authServiceSpy: jasmine.SpyObj<AuthService>;
let routerSpy: jasmine.SpyObj<Router>;
let authServiceSpy: SpyObj<AuthService>;
let routerSpy: SpyObj<Router>;
let routerEvents: Subject<unknown>;
let overlay: FakeOverlayAdapter;

beforeEach(() => {
authServiceSpy = jasmine.createSpyObj('AuthService', ['logout', 'isAuthenticated']);
routerSpy = jasmine.createSpyObj('Router', ['navigate']);
vi.useFakeTimers();
authServiceSpy = createSpyObj<AuthService>(['logout', 'isAuthenticated']);
routerSpy = createSpyObj<Router>(['navigate']);
routerEvents = new Subject<unknown>();
// `Router.events` is a real Observable, not a spied method — the constructor subscribes
// to it directly, so the double has to supply one rather than leave it undefined.
Expand All @@ -52,55 +54,57 @@ describe('IdleService', () => {
],
});

authServiceSpy.isAuthenticated.and.returnValue(false);
authServiceSpy.isAuthenticated.mockReturnValue(false);
});

afterEach(() => vi.useRealTimers());

it('should be created', () => {
service = TestBed.inject(IdleService);
expect(service).toBeTruthy();
});

it('should show warning dialog before timeout', fakeAsync(() => {
authServiceSpy.isAuthenticated.and.returnValue(true);
it('should show warning dialog before timeout', () => {
authServiceSpy.isAuthenticated.mockReturnValue(true);
overlay.nextModalResult = true;

service = TestBed.inject(IdleService);

// Total 15m, Warning at 13m. Advance to 13m
tick(13 * 60 * 1000 + 1000);
vi.advanceTimersByTime(13 * 60 * 1000 + 1000);

expect(overlay.modals).toHaveSize(1);
expect(overlay.modals).toHaveLength(1);
// Ignoring the warning has to fall through to the logout timer, so neither the backdrop
// nor Escape may take it down.
expect(overlay.lastModal!.dismissible).toBe(false);
service.ngOnDestroy();
}));
});

it('should logout if user does not respond to warning', fakeAsync(() => {
authServiceSpy.isAuthenticated.and.returnValue(true);
it('should logout if user does not respond to warning', () => {
authServiceSpy.isAuthenticated.mockReturnValue(true);
// The user never answers the warning, so the hard logout timer must fire.
spyOn(overlay, 'presentModal').and.resolveTo({
vi.spyOn(overlay, 'presentModal').mockResolvedValue({
result: new Promise(() => undefined),
dismiss: () => Promise.resolve(),
});

service = TestBed.inject(IdleService);

// Advance to 13m (warning shows)
tick(13 * 60 * 1000 + 1000);
vi.advanceTimersByTime(13 * 60 * 1000 + 1000);
expect(overlay.presentModal).toHaveBeenCalled();

// Advance remaining 2m
tick(2 * 60 * 1000 + 1000);
vi.advanceTimersByTime(2 * 60 * 1000 + 1000);

expect(authServiceSpy.logout).toHaveBeenCalled();
expect(routerSpy.navigate).toHaveBeenCalled();

service.ngOnDestroy();
}));
});

it('keeps the hard logout timer running when activity is reported while the warning is still being presented', fakeAsync(() => {
authServiceSpy.isAuthenticated.and.returnValue(true);
it('keeps the hard logout timer running when activity is reported while the warning is still being presented', async () => {
authServiceSpy.isAuthenticated.mockReturnValue(true);

// Simulates the window between the warning timer firing and `dialogService.present()`
// actually resolving — the exact gap in which `dialogRef` used to sit unset, making the
Expand All @@ -112,56 +116,59 @@ describe('IdleService', () => {
result: Promise<unknown>;
dismiss: () => Promise<void>;
}) => void;
spyOn(overlay, 'presentModal').and.returnValue(
vi.spyOn(overlay, 'presentModal').mockReturnValue(
new Promise((resolve) => {
resolvePresent = resolve;
}),
);

service = TestBed.inject(IdleService);

tick(13 * 60 * 1000 + 1000);
vi.advanceTimersByTime(13 * 60 * 1000 + 1000);
expect(overlay.presentModal).toHaveBeenCalledTimes(1);

// An activity event lands before the modal has actually presented.
window.dispatchEvent(new Event('mousemove'));
tick(1);
vi.advanceTimersByTime(1);

// The modal now presents, and the user never answers it.
resolvePresent({ result: new Promise(() => undefined), dismiss: () => Promise.resolve() });
tick();
await Promise.resolve();

// If the stray activity event had reset the timer, this would not be enough time to
// reach the hard logout — it would take another ~13 minutes instead.
tick(2 * 60 * 1000 + 1000);
vi.advanceTimersByTime(2 * 60 * 1000 + 1000);

expect(authServiceSpy.logout).toHaveBeenCalled();
expect(overlay.presentModal).toHaveBeenCalledTimes(1);

service.ngOnDestroy();
}));
});

it('closes the warning dialog once navigation actually lands on the login page', fakeAsync(() => {
authServiceSpy.isAuthenticated.and.returnValue(true);
it('closes the warning dialog once navigation actually lands on the login page', async () => {
authServiceSpy.isAuthenticated.mockReturnValue(true);
// The user never answers, so nothing else would close this dialog before the hard
// logout timer does — the router subscription below has to be what closes it instead.
const dismissSpy = jasmine.createSpy('dismiss').and.resolveTo(undefined);
spyOn(overlay, 'presentModal').and.resolveTo({
const dismissSpy = vi.fn().mockResolvedValue(undefined);
vi.spyOn(overlay, 'presentModal').mockResolvedValue({
result: new Promise(() => undefined),
dismiss: dismissSpy,
});

service = TestBed.inject(IdleService);

tick(13 * 60 * 1000 + 1000);
vi.advanceTimersByTime(13 * 60 * 1000 + 1000);
expect(overlay.presentModal).toHaveBeenCalled();
expect(dismissSpy).not.toHaveBeenCalled();
// `presentModal()` resolves on a native promise. Let the dialog handle become available
// before the router event asks the service to close it.
await Promise.resolve();

routerEvents.next(new NavigationEnd(1, '/login?reason=inactivity', '/login?reason=inactivity'));
tick();
await Promise.resolve();

expect(dismissSpy).toHaveBeenCalled();

service.ngOnDestroy();
}));
});
});
Loading
Loading