angular/packages/router/test/integration/guards.spec.ts
SkyZeroZx 0f47fda51b test(router): move timeout and autoTick helpers to shared testing utilities
Centralizes common test helpers under testing utilities and updates usages
2026-02-10 07:45:00 -08:00

2492 lines
81 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {LocationStrategy, HashLocationStrategy, Location} from '@angular/common';
import {
Injectable,
DestroyRef,
inject,
Component,
NgModule,
InjectionToken,
EnvironmentInjector,
} from '@angular/core';
import {TestBed, ComponentFixture} from '@angular/core/testing';
import {Subject, Observable, of, concat, EMPTY} from 'rxjs';
import {tap, mapTo, first, takeWhile, last, switchMap} from 'rxjs/operators';
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
Event,
Router,
NavigationStart,
RoutesRecognized,
Route,
UrlSegment,
GuardsCheckStart,
ChildActivationStart,
ActivationStart,
GuardsCheckEnd,
NavigationCancel,
ResolveStart,
ResolveEnd,
ActivationEnd,
ChildActivationEnd,
NavigationEnd,
provideRouter,
withRouterConfig,
RedirectCommand,
RunGuardsAndResolvers,
Data,
RouterModule,
NavigationCancellationCode,
RouteConfigLoadStart,
RouteConfigLoadEnd,
UrlTree,
CanMatchFn,
CanActivateFn,
CanActivateChildFn,
CanDeactivateFn,
PartialMatchRouteSnapshot,
} from '../../src';
import {wrapIntoObservable} from '../../src/utils/collection';
import {RouterTestingHarness} from '../../testing';
import {expect} from '@angular/private/testing/matchers';
import {
TeamCmp,
RootCmp,
BlankCmp,
expectEvents,
RouteCmp,
RootCmpWithTwoOutlets,
WrapperCmp,
ThrowingCmp,
SimpleCmp,
TwoOutletsCmp,
UserCmp,
ModuleWithBlankCmpAsRoute,
createRoot,
advance,
} from './integration_helpers';
import {timeout} from '@angular/private/testing';
export function guardsIntegrationSuite() {
describe('guards', () => {
describe('CanActivate', () => {
describe('guard completes before emitting a value', () => {
@Injectable({providedIn: 'root'})
class CompletesBeforeEmitting {
private subject$ = new Subject<boolean>();
constructor(destroyRef: DestroyRef) {
destroyRef.onDestroy(() => this.subject$.complete());
}
// Note that this is a simple illustrative case of when an observable
// completes without emitting a value. In a real-world scenario, this
// might represent an HTTP request that never emits before the app is
// destroyed and then completes when the app is destroyed.
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
return this.subject$;
}
}
it('should not thrown an unhandled promise rejection', async () => {
const router = TestBed.inject(Router);
const fixture = await createRoot(router, RootCmp);
const onUnhandledrejection = jasmine.createSpy();
window.addEventListener('unhandledrejection', onUnhandledrejection);
router.resetConfig([
{path: 'team/:id', component: TeamCmp, canActivate: [CompletesBeforeEmitting]},
]);
router.navigateByUrl('/team/22');
// This was previously throwing an error `NG0205: Injector has already been destroyed`.
fixture.destroy();
// Wait until the event task is dispatched.
await new Promise((resolve) => setTimeout(resolve, 10));
window.removeEventListener('unhandledrejection', onUnhandledrejection);
expect(onUnhandledrejection).not.toHaveBeenCalled();
});
});
describe('should not activate a route when CanActivate returns false', () => {
it('works', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
const recordedEvents: Event[] = [];
router.events.forEach((e) => recordedEvents.push(e));
router.resetConfig([{path: 'team/:id', component: TeamCmp, canActivate: [() => false]}]);
router.navigateByUrl('/team/22');
await advance(fixture);
expect(location.path()).toEqual('');
expectEvents(recordedEvents, [
[NavigationStart, '/team/22'],
[RoutesRecognized, '/team/22'],
[GuardsCheckStart, '/team/22'],
[ChildActivationStart],
[ActivationStart],
[GuardsCheckEnd, '/team/22'],
[NavigationCancel, '/team/22'],
]);
expect((recordedEvents[5] as GuardsCheckEnd).shouldActivate).toBe(false);
});
});
describe('should not activate a route when CanActivate returns false (componentless route)', () => {
it('works', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'parent',
canActivate: [() => false],
children: [{path: 'team/:id', component: TeamCmp}],
},
]);
router.navigateByUrl('parent/team/22');
await advance(fixture);
expect(location.path()).toEqual('');
});
});
describe('should activate a route when CanActivate returns true', () => {
it('works', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([{path: 'team/:id', component: TeamCmp, canActivate: [() => true]}]);
router.navigateByUrl('/team/22');
await advance(fixture);
expect(location.path()).toEqual('/team/22');
});
});
describe('should work when given a class', () => {
class AlwaysTrue {
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
return true;
}
}
beforeEach(() => {
TestBed.configureTestingModule({providers: [AlwaysTrue]});
});
it('works', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([{path: 'team/:id', component: TeamCmp, canActivate: [AlwaysTrue]}]);
router.navigateByUrl('/team/22');
await advance(fixture);
expect(location.path()).toEqual('/team/22');
});
});
describe('should work when returns an observable', () => {
it('works', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'team/:id',
component: TeamCmp,
canActivate: [
() =>
new Observable<boolean>((observer) => {
observer.next(false);
}),
],
},
]);
router.navigateByUrl('/team/22');
await advance(fixture);
expect(location.path()).toEqual('');
});
});
describe('should work when returns a promise', () => {
it('works', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'team/:id',
component: TeamCmp,
canActivate: [
(a) => {
if (a.params['id'] === '22') {
return Promise.resolve(true);
} else {
return Promise.resolve(false);
}
},
],
},
]);
router.navigateByUrl('/team/22');
await advance(fixture);
expect(location.path()).toEqual('/team/22');
router.navigateByUrl('/team/33');
await advance(fixture);
expect(location.path()).toEqual('/team/22');
});
});
describe('should reset the location when cancelling a navigation', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{provide: LocationStrategy, useClass: HashLocationStrategy}],
});
});
it('works', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{path: 'one', component: SimpleCmp},
{path: 'two', component: SimpleCmp, canActivate: [() => false]},
]);
router.navigateByUrl('/one');
await advance(fixture);
expect(location.path()).toEqual('/one');
location.go('/two');
location.historyGo(0);
await advance(fixture);
expect(location.path()).toEqual('/one');
});
});
describe('should redirect to / when guard returns false', () => {
it('works', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
router.resetConfig([
{
path: '',
component: SimpleCmp,
},
{
path: 'one',
component: RouteCmp,
canActivate: [
() => {
inject(Router).navigate(['/']);
return false;
},
],
},
]);
const fixture = TestBed.createComponent(RootCmp);
router.navigateByUrl('/one');
await advance(fixture);
expect(location.path()).toEqual('');
expect(fixture.nativeElement).toHaveText('simple');
});
});
describe('should redirect when guard returns UrlTree', () => {
it('works', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const recordedEvents: Event[] = [];
let cancelEvent: NavigationCancel = null!;
router.events.forEach((e) => {
recordedEvents.push(e);
if (e instanceof NavigationCancel) cancelEvent = e;
});
router.resetConfig([
{path: '', component: SimpleCmp},
{
path: 'one',
component: RouteCmp,
canActivate: [() => inject(Router).parseUrl('/redirected')],
},
{path: 'redirected', component: SimpleCmp},
]);
const fixture = TestBed.createComponent(RootCmp);
router.navigateByUrl('/one');
await advance(fixture);
expect(location.path()).toEqual('/redirected');
expect(fixture.nativeElement).toHaveText('simple');
expect(cancelEvent && cancelEvent.reason).toBe(
'NavigationCancelingError: Redirecting to "/redirected"',
);
expectEvents(recordedEvents, [
[NavigationStart, '/one'],
[RoutesRecognized, '/one'],
[GuardsCheckStart, '/one'],
[ChildActivationStart, undefined],
[ActivationStart, undefined],
[NavigationCancel, '/one'],
[NavigationStart, '/redirected'],
[RoutesRecognized, '/redirected'],
[GuardsCheckStart, '/redirected'],
[ChildActivationStart, undefined],
[ActivationStart, undefined],
[GuardsCheckEnd, '/redirected'],
[ResolveStart, '/redirected'],
[ResolveEnd, '/redirected'],
[ActivationEnd, undefined],
[ChildActivationEnd, undefined],
[NavigationEnd, '/redirected'],
]);
});
it('works with root url', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const recordedEvents: Event[] = [];
let cancelEvent: NavigationCancel = null!;
router.events.forEach((e: any) => {
recordedEvents.push(e);
if (e instanceof NavigationCancel) cancelEvent = e;
});
router.resetConfig([
{path: '', component: SimpleCmp},
{
path: 'one',
component: RouteCmp,
canActivate: [() => inject(Router).parseUrl('/')],
},
]);
const fixture = TestBed.createComponent(RootCmp);
router.navigateByUrl('/one');
await advance(fixture);
expect(location.path()).toEqual('');
expect(fixture.nativeElement).toHaveText('simple');
expect(cancelEvent && cancelEvent.reason).toBe(
'NavigationCancelingError: Redirecting to "/"',
);
expectEvents(recordedEvents, [
[NavigationStart, '/one'],
[RoutesRecognized, '/one'],
[GuardsCheckStart, '/one'],
[ChildActivationStart, undefined],
[ActivationStart, undefined],
[NavigationCancel, '/one'],
[NavigationStart, '/'],
[RoutesRecognized, '/'],
[GuardsCheckStart, '/'],
[ChildActivationStart, undefined],
[ActivationStart, undefined],
[GuardsCheckEnd, '/'],
[ResolveStart, '/'],
[ResolveEnd, '/'],
[ActivationEnd, undefined],
[ChildActivationEnd, undefined],
[NavigationEnd, '/'],
]);
});
it('replaces URL when URL is updated eagerly so back button can still work', async () => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))],
});
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
router.resetConfig([
{path: '', component: SimpleCmp},
{
path: 'one',
component: RouteCmp,
canActivate: [() => inject(Router).parseUrl('/redirected')],
},
{path: 'redirected', component: SimpleCmp},
]);
await createRoot(router, RootCmp);
router.navigateByUrl('/one');
const urlChanges: string[] = [];
location.onUrlChange((change) => {
urlChanges.push(change);
});
await timeout();
expect(location.path()).toEqual('/redirected');
expect(urlChanges).toEqual(['/one', '/redirected']);
location.back();
await timeout();
expect(location.path()).toEqual('');
});
it('should resolve navigateByUrl promise after redirect finishes', async () => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))],
});
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
let resolvedPath = '';
router.resetConfig([
{path: '', component: SimpleCmp},
{
path: 'one',
component: RouteCmp,
canActivate: [() => inject(Router).parseUrl('/redirected')],
},
{path: 'redirected', component: SimpleCmp},
]);
const fixture = await createRoot(router, RootCmp);
router.navigateByUrl('/one').then((v) => {
resolvedPath = location.path();
});
await timeout();
expect(resolvedPath).toBe('/redirected');
});
it('can redirect to 404 without changing the URL', async () => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))],
});
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
router.resetConfig([
{path: '', component: SimpleCmp},
{
path: 'one',
component: RouteCmp,
canActivate: [
() => new RedirectCommand(router.parseUrl('/404'), {skipLocationChange: true}),
],
},
{path: '404', component: SimpleCmp},
]);
const fixture = await createRoot(router, RootCmp);
router.navigateByUrl('/one');
await advance(fixture);
expect(location.path()).toEqual('/one');
expect(router.url.toString()).toEqual('/404');
});
it('can redirect while changing state object', async () => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))],
});
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
router.resetConfig([
{path: '', component: SimpleCmp},
{
path: 'one',
component: RouteCmp,
canActivate: [
() => new RedirectCommand(router.parseUrl('/redirected'), {state: {test: 1}}),
],
},
{path: 'redirected', component: SimpleCmp},
]);
const fixture = await createRoot(router, RootCmp);
router.navigateByUrl('/one');
await advance(fixture);
expect(location.path()).toEqual('/redirected');
expect(location.getState()).toEqual(jasmine.objectContaining({test: 1}));
});
});
it('can redirect to 404 without changing the URL', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter([
{
path: 'one',
component: RouteCmp,
canActivate: [
() => {
const router = inject(Router);
router.navigateByUrl('/404', {
browserUrl: router.getCurrentNavigation()?.finalUrl,
});
return false;
},
],
},
{path: '404', component: SimpleCmp},
]),
],
});
const location = TestBed.inject(Location);
await RouterTestingHarness.create('/one');
expect(location.path()).toEqual('/one');
expect(TestBed.inject(Router).url.toString()).toEqual('/404');
});
it('can navigate to same internal route with different browser url', async () => {
TestBed.configureTestingModule({
providers: [provideRouter([{path: 'one', component: RouteCmp}])],
});
const location = TestBed.inject(Location);
const router = TestBed.inject(Router);
await RouterTestingHarness.create('/one');
await router.navigateByUrl('/one', {browserUrl: '/two'});
expect(location.path()).toEqual('/two');
expect(router.url.toString()).toEqual('/one');
});
it('retains browserUrl through UrlTree redirects', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter([
{
path: 'one',
component: RouteCmp,
canActivate: [() => inject(Router).parseUrl('/404')],
},
{path: '404', component: SimpleCmp},
]),
],
});
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
await RouterTestingHarness.create();
await router.navigateByUrl('/one', {browserUrl: router.parseUrl('abc123')});
expect(location.path()).toEqual('/abc123');
expect(TestBed.inject(Router).url.toString()).toEqual('/404');
});
describe('runGuardsAndResolvers', () => {
let guardRunCount = 0;
let resolverRunCount = 0;
beforeEach(() => {
guardRunCount = 0;
resolverRunCount = 0;
TestBed.configureTestingModule({
providers: [{provide: 'resolver', useValue: () => resolverRunCount++}],
});
});
async function configureRouter(
router: Router,
runGuardsAndResolvers: RunGuardsAndResolvers,
): Promise<ComponentFixture<RootCmpWithTwoOutlets>> {
const fixture = await createRoot(router, RootCmpWithTwoOutlets);
router.resetConfig([
{
path: 'a',
runGuardsAndResolvers,
component: RouteCmp,
canActivate: [
() => {
guardRunCount++;
return true;
},
],
resolve: {data: 'resolver'},
},
{path: 'b', component: SimpleCmp, outlet: 'right'},
{
path: 'c/:param',
runGuardsAndResolvers,
component: RouteCmp,
canActivate: [
() => {
guardRunCount++;
return true;
},
],
resolve: {data: 'resolver'},
},
{
path: 'd/:param',
component: WrapperCmp,
runGuardsAndResolvers,
children: [
{
path: 'e/:param',
component: SimpleCmp,
canActivate: [
() => {
guardRunCount++;
return true;
},
],
resolve: {data: 'resolver'},
},
],
},
{
path: 'throwing',
runGuardsAndResolvers,
component: ThrowingCmp,
canActivate: [
() => {
guardRunCount++;
return true;
},
],
resolve: {data: 'resolver'},
},
]);
router.navigateByUrl('/a');
await advance(fixture);
return fixture;
}
it('should rerun guards and resolvers when params change', async () => {
const router = TestBed.inject(Router);
const fixture = await configureRouter(router, 'paramsChange');
const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance;
const recordedData: Data[] = [];
cmp.route.data.subscribe((data) => recordedData.push(data));
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
router.navigateByUrl('/a;p=1');
await advance(fixture);
expect(guardRunCount).toEqual(2);
expect(recordedData).toEqual([{data: 0}, {data: 1}]);
router.navigateByUrl('/a;p=2');
await advance(fixture);
expect(guardRunCount).toEqual(3);
expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]);
router.navigateByUrl('/a;p=2?q=1');
await advance(fixture);
expect(guardRunCount).toEqual(3);
expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]);
});
it('should rerun guards and resolvers when query params change', async () => {
const router = TestBed.inject(Router);
const fixture = await configureRouter(router, 'paramsOrQueryParamsChange');
const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance;
const recordedData: Data[] = [];
cmp.route.data.subscribe((data) => recordedData.push(data));
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
router.navigateByUrl('/a;p=1');
await advance(fixture);
expect(guardRunCount).toEqual(2);
expect(recordedData).toEqual([{data: 0}, {data: 1}]);
router.navigateByUrl('/a;p=2');
await advance(fixture);
expect(guardRunCount).toEqual(3);
expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]);
router.navigateByUrl('/a;p=2?q=1');
await advance(fixture);
expect(guardRunCount).toEqual(4);
expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}]);
router.navigateByUrl('/a;p=2(right:b)?q=1');
await advance(fixture);
expect(guardRunCount).toEqual(4);
expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}]);
});
it('should always rerun guards and resolvers', async () => {
const router = TestBed.inject(Router);
const fixture = await configureRouter(router, 'always');
const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance;
const recordedData: Data[] = [];
cmp.route.data.subscribe((data) => recordedData.push(data));
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
router.navigateByUrl('/a;p=1');
await advance(fixture);
expect(guardRunCount).toEqual(2);
expect(recordedData).toEqual([{data: 0}, {data: 1}]);
router.navigateByUrl('/a;p=2');
await advance(fixture);
expect(guardRunCount).toEqual(3);
expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]);
router.navigateByUrl('/a;p=2?q=1');
await advance(fixture);
expect(guardRunCount).toEqual(4);
expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}]);
router.navigateByUrl('/a;p=2(right:b)?q=1');
await advance(fixture);
expect(guardRunCount).toEqual(5);
expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}, {data: 3}, {data: 4}]);
// Issue #39030, always running guards and resolvers should not throw
// when navigating away from a component with a throwing constructor.
await expectAsync(
(async () => {
router.navigateByUrl('/throwing').catch(() => {});
await advance(fixture);
router.navigateByUrl('/a;p=1');
await advance(fixture);
})(),
).not.toBeRejected();
});
it('should rerun rerun guards and resolvers when path params change', async () => {
const router = TestBed.inject(Router);
const fixture = await configureRouter(router, 'pathParamsChange');
const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance;
const recordedData: Data[] = [];
cmp.route.data.subscribe((data) => recordedData.push(data));
// First navigation has already run
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
// Changing any optional params will not result in running guards or resolvers
router.navigateByUrl('/a;p=1');
await advance(fixture);
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
router.navigateByUrl('/a;p=2');
await advance(fixture);
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
router.navigateByUrl('/a;p=2?q=1');
await advance(fixture);
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
router.navigateByUrl('/a;p=2(right:b)?q=1');
await advance(fixture);
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
// Change to new route with path param should run guards and resolvers
router.navigateByUrl('/c/paramValue');
await advance(fixture);
expect(guardRunCount).toEqual(2);
// Modifying a path param should run guards and resolvers
router.navigateByUrl('/c/paramValueChanged');
await advance(fixture);
expect(guardRunCount).toEqual(3);
// Adding optional params should not cause guards/resolvers to run
router.navigateByUrl('/c/paramValueChanged;p=1?q=2');
await advance(fixture);
expect(guardRunCount).toEqual(3);
});
it('should rerun when a parent segment changes', async () => {
const router = TestBed.inject(Router);
const fixture = await configureRouter(router, 'pathParamsChange');
const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance;
// Land on an initial page
router.navigateByUrl('/d/1;dd=11/e/2;dd=22');
await advance(fixture);
expect(guardRunCount).toEqual(2);
// Changes cause re-run on the config with the guard
router.navigateByUrl('/d/1;dd=11/e/3;ee=22');
await advance(fixture);
expect(guardRunCount).toEqual(3);
// Changes to the parent also cause re-run
router.navigateByUrl('/d/2;dd=11/e/3;ee=22');
await advance(fixture);
expect(guardRunCount).toEqual(4);
});
it('should rerun rerun guards and resolvers when path or query params change', async () => {
const router = TestBed.inject(Router);
const fixture = await configureRouter(router, 'pathParamsOrQueryParamsChange');
const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance;
const recordedData: Data[] = [];
cmp.route.data.subscribe((data) => recordedData.push(data));
// First navigation has already run
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
// Changing matrix params will not result in running guards or resolvers
router.navigateByUrl('/a;p=1');
await advance(fixture);
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
router.navigateByUrl('/a;p=2');
await advance(fixture);
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
// Adding query params will re-run guards/resolvers
router.navigateByUrl('/a;p=2?q=1');
await advance(fixture);
expect(guardRunCount).toEqual(2);
expect(recordedData).toEqual([{data: 0}, {data: 1}]);
// Changing query params will re-run guards/resolvers
router.navigateByUrl('/a;p=2?q=2');
await advance(fixture);
expect(guardRunCount).toEqual(3);
expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]);
});
it('should allow a predicate function to determine when to run guards and resolvers', async () => {
const router = TestBed.inject(Router);
const fixture = await configureRouter(router, (from, to) => to.paramMap.get('p') === '2');
const cmp: RouteCmp = fixture.debugElement.children[1].componentInstance;
const recordedData: Data[] = [];
cmp.route.data.subscribe((data) => recordedData.push(data));
// First navigation has already run
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
// Adding `p` param shouldn't cause re-run
router.navigateByUrl('/a;p=1');
await advance(fixture);
expect(guardRunCount).toEqual(1);
expect(recordedData).toEqual([{data: 0}]);
// Re-run should trigger on p=2
router.navigateByUrl('/a;p=2');
await advance(fixture);
expect(guardRunCount).toEqual(2);
expect(recordedData).toEqual([{data: 0}, {data: 1}]);
// Any other changes don't pass the predicate
router.navigateByUrl('/a;p=3?q=1');
await advance(fixture);
expect(guardRunCount).toEqual(2);
expect(recordedData).toEqual([{data: 0}, {data: 1}]);
// Changing query params will re-run guards/resolvers
router.navigateByUrl('/a;p=3?q=2');
await advance(fixture);
expect(guardRunCount).toEqual(2);
expect(recordedData).toEqual([{data: 0}, {data: 1}]);
});
});
describe('should wait for parent to complete', () => {
let log: string[];
beforeEach(() => {
log = [];
});
function delayPromise(delay: number): Promise<boolean> {
let resolve: (val: boolean) => void;
const promise = new Promise<boolean>((res) => (resolve = res));
setTimeout(() => resolve(true), delay);
return promise;
}
it('works', async () => {
const router = TestBed.inject(Router);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'parent',
canActivate: [
() =>
delayPromise(10).then(() => {
log.push('parent');
return true;
}),
],
children: [
{
path: 'child',
component: SimpleCmp,
canActivate: [
() => {
return delayPromise(5).then(() => {
log.push('child');
return true;
});
},
],
},
],
},
]);
await router.navigateByUrl('/parent/child');
expect(log).toEqual(['parent', 'child']);
});
});
});
describe('CanDeactivate', () => {
let log: any;
const recordingDeactivate: CanDeactivateFn<any> = (c, a) => {
log.push({path: a.routeConfig!.path, component: c});
return true;
};
beforeEach(() => {
log = [];
});
describe('should not deactivate a route when CanDeactivate returns false', () => {
it('works', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'team/:id',
component: TeamCmp,
canDeactivate: [
(c, a) => {
return a.params['id'] === '22';
},
],
},
]);
router.navigateByUrl('/team/22');
await advance(fixture);
expect(location.path()).toEqual('/team/22');
let successStatus: boolean = false;
router.navigateByUrl('/team/33')!.then((res) => (successStatus = res));
await advance(fixture);
expect(location.path()).toEqual('/team/33');
expect(successStatus).toEqual(true);
let canceledStatus: boolean = false;
router.navigateByUrl('/team/44')!.then((res) => (canceledStatus = res));
await advance(fixture);
expect(location.path()).toEqual('/team/33');
expect(canceledStatus).toEqual(false);
});
it('works with componentless routes', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'grandparent',
canDeactivate: [recordingDeactivate],
children: [
{
path: 'parent',
canDeactivate: [recordingDeactivate],
children: [
{
path: 'child',
canDeactivate: [recordingDeactivate],
children: [
{
path: 'simple',
component: SimpleCmp,
canDeactivate: [recordingDeactivate],
},
],
},
],
},
],
},
{path: 'simple', component: SimpleCmp},
]);
router.navigateByUrl('/grandparent/parent/child/simple');
await advance(fixture);
expect(location.path()).toEqual('/grandparent/parent/child/simple');
router.navigateByUrl('/simple');
await advance(fixture);
const child = fixture.debugElement.children[1].componentInstance;
expect(log.map((a: any) => a.path)).toEqual(['simple', 'child', 'parent', 'grandparent']);
expect(log[0].component instanceof SimpleCmp).toBeTruthy();
[1, 2, 3].forEach((i) => expect(log[i].component).toBeNull());
expect(child instanceof SimpleCmp).toBeTruthy();
expect(child).not.toBe(log[0].component);
});
it('works with aux routes', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'two-outlets',
component: TwoOutletsCmp,
children: [
{path: 'a', component: BlankCmp},
{
path: 'b',
canDeactivate: [recordingDeactivate],
component: SimpleCmp,
outlet: 'aux',
},
],
},
]);
router.navigateByUrl('/two-outlets/(a//aux:b)');
await advance(fixture);
expect(location.path()).toEqual('/two-outlets/(a//aux:b)');
router.navigate(['two-outlets', {outlets: {aux: null}}]);
await advance(fixture);
expect(log.map((a: any) => a.path)).toEqual(['b']);
expect(location.path()).toEqual('/two-outlets/a');
});
it('works with a nested route', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'team/:id',
component: TeamCmp,
children: [
{path: '', pathMatch: 'full', component: SimpleCmp},
{
path: 'user/:name',
component: UserCmp,
canDeactivate: [
(c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => {
return a.params['name'] === 'victor';
},
],
},
],
},
]);
router.navigateByUrl('/team/22/user/victor');
await advance(fixture);
// this works because we can deactivate victor
router.navigateByUrl('/team/33');
await advance(fixture);
expect(location.path()).toEqual('/team/33');
router.navigateByUrl('/team/33/user/fedor');
await advance(fixture);
// this doesn't work cause we cannot deactivate fedor
router.navigateByUrl('/team/44');
await advance(fixture);
expect(location.path()).toEqual('/team/33/user/fedor');
});
});
it('should use correct component to deactivate forChild route', async () => {
const router: Router = TestBed.inject(Router);
@Component({
selector: 'admin',
template: '',
standalone: false,
})
class AdminComponent {}
@NgModule({
declarations: [AdminComponent],
imports: [
RouterModule.forChild([
{
path: '',
component: AdminComponent,
canDeactivate: [recordingDeactivate],
},
]),
],
})
class LazyLoadedModule {}
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'a',
component: WrapperCmp,
children: [{path: '', pathMatch: 'full', loadChildren: () => LazyLoadedModule}],
},
{path: 'b', component: SimpleCmp},
]);
router.navigateByUrl('/a');
await advance(fixture);
router.navigateByUrl('/b');
await advance(fixture);
expect(log[0].component).toBeInstanceOf(AdminComponent);
});
it('should not create a route state if navigation is canceled', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'main',
component: TeamCmp,
children: [
{path: 'component1', component: SimpleCmp, canDeactivate: [() => false]},
{path: 'component2', component: SimpleCmp},
],
},
]);
router.navigateByUrl('/main/component1');
await advance(fixture);
router.navigateByUrl('/main/component2');
await advance(fixture);
const teamCmp = fixture.debugElement.children[1].componentInstance;
expect(teamCmp.route.firstChild.url.value[0].path).toEqual('component1');
expect(location.path()).toEqual('/main/component1');
});
it('should not run CanActivate when CanDeactivate returns false', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'main',
component: TeamCmp,
children: [
{
path: 'component1',
component: SimpleCmp,
canDeactivate: [
() => {
log.push('called');
let resolve: (result: boolean) => void;
const promise = new Promise((res) => (resolve = res));
setTimeout(() => resolve(false), 0);
return promise;
},
],
},
{
path: 'component2',
component: SimpleCmp,
canActivate: [
() => {
log.push('canActivate called');
return true;
},
],
},
],
},
]);
router.navigateByUrl('/main/component1');
await advance(fixture);
expect(location.path()).toEqual('/main/component1');
router.navigateByUrl('/main/component2');
await advance(fixture);
expect(location.path()).toEqual('/main/component1');
expect(log).toEqual(['called']);
});
it('should call guards every time when navigating to the same url over and over again', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'simple',
component: SimpleCmp,
canDeactivate: [
() => {
log.push('called');
return false;
},
],
},
{path: 'blank', component: BlankCmp},
]);
router.navigateByUrl('/simple');
await advance(fixture);
router.navigateByUrl('/blank');
await advance(fixture);
expect(log).toEqual(['called']);
expect(location.path()).toEqual('/simple');
router.navigateByUrl('/blank');
await advance(fixture);
expect(log).toEqual(['called', 'called']);
expect(location.path()).toEqual('/simple');
});
describe('next state', () => {
let log: string[];
class ClassWithNextState {
canDeactivate(
component: TeamCmp,
currentRoute: ActivatedRouteSnapshot,
currentState: RouterStateSnapshot,
nextState: RouterStateSnapshot,
): boolean {
log.push(currentState.url, nextState.url);
return true;
}
}
beforeEach(() => {
log = [];
TestBed.configureTestingModule({
providers: [ClassWithNextState],
});
});
it('should pass next state as the 4 argument when guard is a class', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'team/:id',
component: TeamCmp,
canDeactivate: [
(
component: TeamCmp,
currentRoute: ActivatedRouteSnapshot,
currentState: RouterStateSnapshot,
nextState: RouterStateSnapshot,
) =>
inject(ClassWithNextState).canDeactivate(
component,
currentRoute,
currentState,
nextState,
),
],
},
]);
router.navigateByUrl('/team/22');
await advance(fixture);
expect(location.path()).toEqual('/team/22');
router.navigateByUrl('/team/33');
await advance(fixture);
expect(location.path()).toEqual('/team/33');
expect(log).toEqual(['/team/22', '/team/33']);
});
it('should pass next state as the 4 argument when guard is a function', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'team/:id',
component: TeamCmp,
canDeactivate: [
(cmp, currentRoute, currentState, nextState) => {
log.push(currentState.url, nextState.url);
return true;
},
],
},
]);
router.navigateByUrl('/team/22');
await advance(fixture);
expect(location.path()).toEqual('/team/22');
router.navigateByUrl('/team/33');
await advance(fixture);
expect(location.path()).toEqual('/team/33');
expect(log).toEqual(['/team/22', '/team/33']);
});
});
describe('should work when given a class', () => {
class AlwaysTrue {
canDeactivate(): boolean {
return true;
}
}
beforeEach(() => {
TestBed.configureTestingModule({providers: [AlwaysTrue]});
});
it('works', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'team/:id',
component: TeamCmp,
canDeactivate: [() => inject(AlwaysTrue).canDeactivate()],
},
]);
router.navigateByUrl('/team/22');
await advance(fixture);
expect(location.path()).toEqual('/team/22');
router.navigateByUrl('/team/33');
await advance(fixture);
expect(location.path()).toEqual('/team/33');
});
});
describe('should work when returns an observable', () => {
it('works', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'team/:id',
component: TeamCmp,
canDeactivate: [
() => {
return new Observable<boolean>((observer) => {
observer.next(false);
});
},
],
},
]);
router.navigateByUrl('/team/22');
await advance(fixture);
expect(location.path()).toEqual('/team/22');
router.navigateByUrl('/team/33');
await advance(fixture);
expect(location.path()).toEqual('/team/22');
});
});
});
describe('CanActivateChild', () => {
describe('should be invoked when activating a child', () => {
it('works', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: '',
canActivateChild: [(a: any) => a.paramMap.get('id') === '22'],
children: [{path: 'team/:id', component: TeamCmp}],
},
]);
router.navigateByUrl('/team/22');
await advance(fixture);
expect(location.path()).toEqual('/team/22');
router.navigateByUrl('/team/33')!.catch(() => {});
await advance(fixture);
expect(location.path()).toEqual('/team/22');
});
});
it('should find the guard provided in lazy loaded module', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
@Component({
selector: 'admin',
template: '<router-outlet></router-outlet>',
standalone: false,
})
class AdminComponent {}
@Component({
selector: 'lazy',
template: 'lazy-loaded',
standalone: false,
})
class LazyLoadedComponent {}
@NgModule({
declarations: [AdminComponent, LazyLoadedComponent],
imports: [
RouterModule.forChild([
{
path: '',
component: AdminComponent,
children: [
{
path: '',
canActivateChild: [() => true],
children: [{path: '', component: LazyLoadedComponent}],
},
],
},
]),
],
})
class LazyLoadedModule {}
const fixture = await createRoot(router, RootCmp);
router.resetConfig([{path: 'admin', loadChildren: () => LazyLoadedModule}]);
router.navigateByUrl('/admin');
await advance(fixture);
expect(location.path()).toEqual('/admin');
expect(fixture.nativeElement).toHaveText('lazy-loaded');
});
});
describe('CanLoad', () => {
let canLoadRunCount = 0;
beforeEach(() => {
canLoadRunCount = 0;
TestBed.configureTestingModule({
providers: [
{
provide: () => true,
useValue: () => {
canLoadRunCount++;
return true;
},
},
],
});
});
it('should not load children when CanLoad returns false', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
@Component({
selector: 'lazy',
template: 'lazy-loaded',
standalone: false,
})
class LazyLoadedComponent {}
@NgModule({
declarations: [LazyLoadedComponent],
imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])],
})
class LoadedModule {}
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{path: 'lazyFalse', canLoad: [() => false], loadChildren: () => LoadedModule},
{path: 'lazyTrue', canLoad: [() => true], loadChildren: () => LoadedModule},
]);
const recordedEvents: Event[] = [];
router.events.forEach((e) => recordedEvents.push(e));
// failed navigation
router.navigateByUrl('/lazyFalse/loaded');
await advance(fixture);
expect(location.path()).toEqual('');
expectEvents(recordedEvents, [
[NavigationStart, '/lazyFalse/loaded'],
// [GuardsCheckStart, '/lazyFalse/loaded'],
[NavigationCancel, '/lazyFalse/loaded'],
]);
expect((recordedEvents[1] as NavigationCancel).code).toBe(
NavigationCancellationCode.GuardRejected,
);
recordedEvents.splice(0);
// successful navigation
router.navigateByUrl('/lazyTrue/loaded');
await advance(fixture);
expect(location.path()).toEqual('/lazyTrue/loaded');
expectEvents(recordedEvents, [
[NavigationStart, '/lazyTrue/loaded'],
[RouteConfigLoadStart],
[RouteConfigLoadEnd],
[RoutesRecognized, '/lazyTrue/loaded'],
[GuardsCheckStart, '/lazyTrue/loaded'],
[ChildActivationStart],
[ActivationStart],
[ChildActivationStart],
[ActivationStart],
[GuardsCheckEnd, '/lazyTrue/loaded'],
[ResolveStart, '/lazyTrue/loaded'],
[ResolveEnd, '/lazyTrue/loaded'],
[ActivationEnd],
[ChildActivationEnd],
[ActivationEnd],
[ChildActivationEnd],
[NavigationEnd, '/lazyTrue/loaded'],
]);
});
it('should support navigating from within the guard', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'lazyFalse',
canLoad: [
() => {
router.navigate(['blank']);
return false;
},
],
loadChildren: jasmine.createSpy('lazyFalse'),
},
{path: 'blank', component: BlankCmp},
]);
const recordedEvents: Event[] = [];
router.events.forEach((e) => recordedEvents.push(e));
router.navigateByUrl('/lazyFalse/loaded');
await advance(fixture);
expect(location.path()).toEqual('/blank');
expectEvents(recordedEvents, [
[NavigationStart, '/lazyFalse/loaded'],
// No GuardCheck events as `canLoad` is a special guard that's not actually part of
// the guard lifecycle.
[NavigationCancel, '/lazyFalse/loaded'],
[NavigationStart, '/blank'],
[RoutesRecognized, '/blank'],
[GuardsCheckStart, '/blank'],
[ChildActivationStart],
[ActivationStart],
[GuardsCheckEnd, '/blank'],
[ResolveStart, '/blank'],
[ResolveEnd, '/blank'],
[ActivationEnd],
[ChildActivationEnd],
[NavigationEnd, '/blank'],
]);
expect((recordedEvents[1] as NavigationCancel).code).toBe(
NavigationCancellationCode.SupersededByNewNavigation,
);
});
it('should support returning UrlTree from within the guard', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'lazyFalse',
canLoad: [() => inject(Router).createUrlTree(['blank'])],
loadChildren: jasmine.createSpy('lazyFalse'),
},
{path: 'blank', component: BlankCmp},
]);
const recordedEvents: Event[] = [];
router.events.forEach((e) => recordedEvents.push(e));
router.navigateByUrl('/lazyFalse/loaded');
await advance(fixture);
expect(location.path()).toEqual('/blank');
expectEvents(recordedEvents, [
[NavigationStart, '/lazyFalse/loaded'],
// No GuardCheck events as `canLoad` is a special guard that's not actually part of
// the guard lifecycle.
[NavigationCancel, '/lazyFalse/loaded'],
[NavigationStart, '/blank'],
[RoutesRecognized, '/blank'],
[GuardsCheckStart, '/blank'],
[ChildActivationStart],
[ActivationStart],
[GuardsCheckEnd, '/blank'],
[ResolveStart, '/blank'],
[ResolveEnd, '/blank'],
[ActivationEnd],
[ChildActivationEnd],
[NavigationEnd, '/blank'],
]);
expect((recordedEvents[1] as NavigationCancel).code).toBe(
NavigationCancellationCode.Redirect,
);
});
// Regression where navigateByUrl with false CanLoad no longer resolved `false` value on
// navigateByUrl promise: https://github.com/angular/angular/issues/26284
it('should resolve navigateByUrl promise after CanLoad executes', async () => {
const router = TestBed.inject(Router);
@Component({
selector: 'lazy',
template: 'lazy-loaded',
standalone: false,
})
class LazyLoadedComponent {}
@NgModule({
declarations: [LazyLoadedComponent],
imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])],
})
class LazyLoadedModule {}
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{path: 'lazy-false', canLoad: [() => false], loadChildren: () => LazyLoadedModule},
{path: 'lazy-true', canLoad: [() => true], loadChildren: () => LazyLoadedModule},
]);
let navFalseResult = true;
let navTrueResult = false;
router.navigateByUrl('/lazy-false').then((v) => {
navFalseResult = v;
});
await advance(fixture);
router.navigateByUrl('/lazy-true').then((v) => {
navTrueResult = v;
});
await advance(fixture);
expect(navFalseResult).toBe(false);
expect(navTrueResult).toBe(true);
});
it('should execute CanLoad only once', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
@Component({
selector: 'lazy',
template: 'lazy-loaded',
standalone: false,
})
class LazyLoadedComponent {}
@NgModule({
declarations: [LazyLoadedComponent],
imports: [RouterModule.forChild([{path: 'loaded', component: LazyLoadedComponent}])],
})
class LazyLoadedModule {}
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'lazy',
canLoad: [
() => {
canLoadRunCount++;
return true;
},
],
loadChildren: () => LazyLoadedModule,
},
]);
router.navigateByUrl('/lazy/loaded');
await advance(fixture);
expect(location.path()).toEqual('/lazy/loaded');
expect(canLoadRunCount).toEqual(1);
router.navigateByUrl('/');
await advance(fixture);
expect(location.path()).toEqual('');
router.navigateByUrl('/lazy/loaded');
await advance(fixture);
expect(location.path()).toEqual('/lazy/loaded');
expect(canLoadRunCount).toEqual(1);
});
it('cancels guard execution when a new navigation happens', async () => {
@Injectable({providedIn: 'root'})
class DelayedGuard {
static delayedExecutions = 0;
static canLoadCalls = 0;
canLoad() {
DelayedGuard.canLoadCalls++;
return of(true).pipe(
switchMap((v) => new Promise((r) => setTimeout(r, 10)).then(() => v)),
tap(() => {
DelayedGuard.delayedExecutions++;
}),
);
}
}
const router = TestBed.inject(Router);
router.resetConfig([
{path: 'a', canLoad: [DelayedGuard], loadChildren: () => [], component: SimpleCmp},
{path: 'team/:id', component: TeamCmp},
]);
const fixture = await createRoot(router, RootCmp);
router.navigateByUrl('/a');
await timeout(1);
// The delayed guard should have started
expect(DelayedGuard.canLoadCalls).toEqual(1);
await router.navigateByUrl('/team/1');
expect(fixture.nativeElement.innerHTML).toContain('team');
// The delayed guard should not execute the delayed condition because a new navigation
// cancels the current one and unsubscribes from intermediate results.
expect(DelayedGuard.delayedExecutions).toEqual(0);
});
});
describe('should run CanLoad guards concurrently', () => {
function delayObservable(delayMs: number): Observable<boolean> {
return of(delayMs).pipe(
switchMap((v) => new Promise((r) => setTimeout(r, delayMs)).then(() => v)),
mapTo(true),
);
}
let log: string[];
const guard1 = () => {
return delayObservable(15).pipe(tap({next: () => log.push('guard1')}));
};
const guard2 = () => {
return delayObservable(0).pipe(tap({next: () => log.push('guard2')}));
};
const returnFalseAndNavigate = () => {
log.push('returnFalseAndNavigate');
inject(Router).navigateByUrl('/redirected');
return false;
};
const returnUrlTree = () => {
const router = inject(Router);
return delayObservable(30).pipe(
mapTo(router.parseUrl('/redirected')),
tap({next: () => log.push('returnUrlTree')}),
);
};
beforeEach(() => {
log = [];
});
it('should only execute canLoad guards of routes being activated', async () => {
const router = TestBed.inject(Router);
router.resetConfig([
{
path: 'lazy',
canLoad: [guard1],
loadChildren: () => of(ModuleWithBlankCmpAsRoute),
},
{path: 'redirected', component: SimpleCmp},
// canLoad should not run for this route because 'lazy' activates first
{
path: '',
canLoad: [returnFalseAndNavigate],
loadChildren: () => of(ModuleWithBlankCmpAsRoute),
},
]);
await router.navigateByUrl('/lazy');
expect(log.length).toEqual(1);
expect(log).toEqual(['guard1']);
});
it('should execute canLoad guards', async () => {
const router = TestBed.inject(Router);
router.resetConfig([
{
path: 'lazy',
canLoad: [guard1, guard2],
loadChildren: () => ModuleWithBlankCmpAsRoute,
},
]);
await router.navigateByUrl('/lazy');
expect(log.length).toEqual(2);
expect(log).toEqual(['guard2', 'guard1']);
});
it('should redirect with UrlTree if higher priority guards have resolved', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
router.resetConfig([
{
path: 'lazy',
canLoad: [returnUrlTree, guard1, guard2],
loadChildren: () => ModuleWithBlankCmpAsRoute,
},
{path: 'redirected', component: SimpleCmp},
]);
await router.navigateByUrl('/lazy');
expect(log.length).toEqual(3);
expect(log).toEqual(['guard2', 'guard1', 'returnUrlTree']);
expect(location.path()).toEqual('/redirected');
});
it('should redirect with UrlTree if UrlTree is lower priority', async () => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
router.resetConfig([
{
path: 'lazy',
canLoad: [guard1, returnUrlTree],
loadChildren: () => ModuleWithBlankCmpAsRoute,
},
{path: 'redirected', component: SimpleCmp},
]);
await router.navigateByUrl('/lazy');
expect(log.length).toEqual(2);
expect(log).toEqual(['guard1', 'returnUrlTree']);
expect(location.path()).toEqual('/redirected');
});
});
describe('order', () => {
class Logger {
logs: string[] = [];
add(thing: string) {
this.logs.push(thing);
}
}
beforeEach(() => {
TestBed.configureTestingModule({
providers: [Logger],
});
});
it('should call guards in the right order', async () => {
const router = TestBed.inject(Router);
const logger = TestBed.inject(Logger);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: '',
canActivateChild: [() => (logger.add('canActivateChild_parent'), true)],
children: [
{
path: 'team/:id',
canActivate: [() => (logger.add('canActivate_team'), true)],
canDeactivate: [() => (logger.add('canDeactivate_team'), true)],
component: TeamCmp,
},
],
},
]);
router.navigateByUrl('/team/22');
await advance(fixture);
router.navigateByUrl('/team/33');
await advance(fixture);
expect(logger.logs).toEqual([
'canActivateChild_parent',
'canActivate_team',
'canDeactivate_team',
'canActivateChild_parent',
'canActivate_team',
]);
});
it('should call deactivate guards from bottom to top', async () => {
const router = TestBed.inject(Router);
const logger = TestBed.inject(Logger);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: '',
children: [
{
path: 'team/:id',
canDeactivate: [() => (logger.add('canDeactivate_team'), true)],
children: [
{
path: '',
component: SimpleCmp,
canDeactivate: [() => (logger.add('canDeactivate_simple'), true)],
},
],
component: TeamCmp,
},
],
},
]);
router.navigateByUrl('/team/22');
await advance(fixture);
router.navigateByUrl('/team/33');
await advance(fixture);
expect(logger.logs).toEqual(['canDeactivate_simple', 'canDeactivate_team']);
});
});
describe('canMatch', () => {
@Injectable({providedIn: 'root'})
class ConfigurableGuard {
result: Promise<boolean | UrlTree> | Observable<boolean | UrlTree> | boolean | UrlTree =
false;
canMatch() {
return this.result;
}
}
it('falls back to second route when canMatch returns false', async () => {
const router = TestBed.inject(Router);
router.resetConfig([
{
path: 'a',
canMatch: [() => inject(ConfigurableGuard).canMatch()],
component: BlankCmp,
},
{path: 'a', component: SimpleCmp},
]);
const fixture = await createRoot(router, RootCmp);
router.navigateByUrl('/a');
await advance(fixture);
expect(fixture.nativeElement.innerHTML).toContain('simple');
});
it('falls back to second route when canMatch returns EMPTY', async () => {
const router = TestBed.inject(Router);
router.resetConfig([
{
path: 'a',
canMatch: [() => EMPTY],
component: BlankCmp,
},
{path: 'a', component: SimpleCmp},
]);
const fixture = await createRoot(router, RootCmp);
router.navigateByUrl('/a');
await advance(fixture);
expect(fixture.nativeElement.innerHTML).toContain('simple');
});
it('uses route when canMatch returns true', async () => {
const router = TestBed.inject(Router);
TestBed.inject(ConfigurableGuard).result = Promise.resolve(true);
router.resetConfig([
{
path: 'a',
canMatch: [() => inject(ConfigurableGuard).canMatch()],
component: SimpleCmp,
},
{path: 'a', component: BlankCmp},
]);
const fixture = await createRoot(router, RootCmp);
router.navigateByUrl('/a');
await advance(fixture);
expect(fixture.nativeElement.innerHTML).toContain('simple');
});
it('can return UrlTree from canMatch guard', async () => {
const router = TestBed.inject(Router);
TestBed.inject(ConfigurableGuard).result = Promise.resolve(
router.createUrlTree(['/team/1']),
);
router.resetConfig([
{
path: 'a',
canMatch: [() => inject(ConfigurableGuard).canMatch()],
component: SimpleCmp,
},
{path: 'team/:id', component: TeamCmp},
]);
const fixture = await createRoot(router, RootCmp);
router.navigateByUrl('/a');
await advance(fixture);
expect(fixture.nativeElement.innerHTML).toContain('team');
});
it('can return UrlTree from CanMatchFn guard', async () => {
const canMatchTeamSection = new InjectionToken('CanMatchTeamSection');
const canMatchFactory: (router: Router) => CanMatchFn = (router: Router) => () =>
router.createUrlTree(['/team/1']);
TestBed.overrideProvider(canMatchTeamSection, {
useFactory: canMatchFactory,
deps: [Router],
});
const router = TestBed.inject(Router);
router.resetConfig([
{path: 'a', canMatch: [canMatchTeamSection], component: SimpleCmp},
{path: 'team/:id', component: TeamCmp},
]);
const fixture = await createRoot(router, RootCmp);
router.navigateByUrl('/a');
await advance(fixture);
expect(fixture.nativeElement.innerHTML).toContain('team');
});
it('runs canMatch guards provided in lazy module', async () => {
const router = TestBed.inject(Router);
@Component({
selector: 'lazy',
template: 'lazy-loaded-parent [<router-outlet></router-outlet>]',
standalone: false,
})
class ParentLazyLoadedComponent {}
@Component({
selector: 'lazy',
template: 'lazy-loaded-child',
standalone: false,
})
class ChildLazyLoadedComponent {}
@Injectable()
class LazyCanMatchFalse {
canMatch() {
return false;
}
}
@Component({
template: 'restricted',
standalone: false,
})
class Restricted {}
@NgModule({
declarations: [ParentLazyLoadedComponent, ChildLazyLoadedComponent, Restricted],
providers: [LazyCanMatchFalse],
imports: [
RouterModule.forChild([
{
path: 'loaded',
canMatch: [LazyCanMatchFalse],
component: Restricted,
children: [{path: 'child', component: Restricted}],
},
{
path: 'loaded',
component: ParentLazyLoadedComponent,
children: [{path: 'child', component: ChildLazyLoadedComponent}],
},
]),
],
})
class LoadedModule {}
const fixture = await createRoot(router, RootCmp);
router.resetConfig([{path: 'lazy', loadChildren: () => LoadedModule}]);
router.navigateByUrl('/lazy/loaded/child');
await advance(fixture);
expect(TestBed.inject(Location).path()).toEqual('/lazy/loaded/child');
expect(fixture.nativeElement).toHaveText('lazy-loaded-parent [lazy-loaded-child]');
});
it('cancels guard execution when a new navigation happens', async () => {
@Injectable({providedIn: 'root'})
class DelayedGuard {
static delayedExecutions = 0;
canMatch() {
return of(true).pipe(
switchMap((v) => new Promise((r) => setTimeout(r, 10)).then(() => v)),
tap(() => {
DelayedGuard.delayedExecutions++;
}),
);
}
}
const router = TestBed.inject(Router);
const delayedGuardSpy = spyOn(TestBed.inject(DelayedGuard), 'canMatch');
delayedGuardSpy.and.callThrough();
const configurableMatchSpy = spyOn(TestBed.inject(ConfigurableGuard), 'canMatch');
configurableMatchSpy.and.callFake(() => {
router.navigateByUrl('/team/1');
return false;
});
router.resetConfig([
{path: 'a', canMatch: [ConfigurableGuard, DelayedGuard], component: SimpleCmp},
{path: 'a', canMatch: [ConfigurableGuard, DelayedGuard], component: SimpleCmp},
{path: 'a', canMatch: [ConfigurableGuard, DelayedGuard], component: SimpleCmp},
{path: 'team/:id', component: TeamCmp},
]);
const fixture = await createRoot(router, RootCmp);
router.navigateByUrl('/a');
await advance(fixture);
expect(fixture.nativeElement.innerHTML).toContain('team');
expect(configurableMatchSpy.calls.count()).toEqual(1);
// The delayed guard should not execute the delayed condition because the other guard
// initiates a new navigation, which cancels the current one and unsubscribes from
// intermediate results.
expect(DelayedGuard.delayedExecutions).toEqual(0);
// The delayed guard should still have executed once because guards are executed at the
// same time
expect(delayedGuardSpy.calls.count()).toEqual(1);
});
it('receives PreMatchRouteSnapshot as third argument', async () => {
const router = TestBed.inject(Router);
const fixture = await createRoot(router, RootCmp);
let capturedSnapshot: PartialMatchRouteSnapshot | undefined;
router.resetConfig([
{
path: 'a/:id',
canMatch: [
(route: Route, segments: UrlSegment[], snapshot: PartialMatchRouteSnapshot) => {
capturedSnapshot = snapshot;
return true;
},
],
component: SimpleCmp,
},
]);
await router.navigateByUrl('/a/1?q=2#f');
expect(capturedSnapshot).toBeDefined();
expect(capturedSnapshot!.params['id']).toBe('1');
expect(capturedSnapshot!.queryParams['q']).toBe('2');
expect(capturedSnapshot!.fragment).toBe('f');
});
it('can redirect based on snapshot params', async () => {
const router = TestBed.inject(Router);
const fixture = await createRoot(router, RootCmp);
router.resetConfig([
{
path: 'a/:id',
canMatch: [
(route: Route, segments: UrlSegment[], snapshot: PartialMatchRouteSnapshot) => {
const router = inject(Router);
if (snapshot.params['id'] === '1') {
return router.parseUrl('/b');
}
return true;
},
],
component: SimpleCmp,
},
{path: 'b', component: BlankCmp},
]);
await router.navigateByUrl('/a/1');
await advance(fixture);
expect(router.url).toEqual('/b');
await router.navigateByUrl('/a/2');
await advance(fixture);
expect(router.url).toEqual('/a/2');
});
});
it('should allow guards as functions', async () => {
@Component({
template: '',
})
class BlankCmp {}
const router = TestBed.inject(Router);
const fixture = await createRoot(router, RootCmp);
const guards = {
canActivate() {
return true;
},
canDeactivate() {
return true;
},
canActivateChild() {
return true;
},
canMatch() {
return true;
},
canLoad() {
return true;
},
};
spyOn(guards, 'canActivate').and.callThrough();
spyOn(guards, 'canActivateChild').and.callThrough();
spyOn(guards, 'canDeactivate').and.callThrough();
spyOn(guards, 'canLoad').and.callThrough();
spyOn(guards, 'canMatch').and.callThrough();
router.resetConfig([
{
path: '',
component: BlankCmp,
loadChildren: () => [{path: '', component: BlankCmp}],
canActivate: [guards.canActivate],
canActivateChild: [guards.canActivateChild],
canLoad: [guards.canLoad],
canDeactivate: [guards.canDeactivate],
canMatch: [guards.canMatch],
},
{
path: 'other',
component: BlankCmp,
},
]);
router.navigateByUrl('/');
await advance(fixture);
expect(guards.canMatch).toHaveBeenCalled();
expect(guards.canLoad).toHaveBeenCalled();
expect(guards.canActivate).toHaveBeenCalled();
expect(guards.canActivateChild).toHaveBeenCalled();
router.navigateByUrl('/other');
await advance(fixture);
expect(guards.canDeactivate).toHaveBeenCalled();
});
it('should allow DI in plain function guards', async () => {
@Component({
template: '',
})
class BlankCmp {}
@Injectable({providedIn: 'root'})
class State {
value = true;
}
const router = TestBed.inject(Router);
const fixture = await createRoot(router, RootCmp);
const guards = {
canActivate() {
return inject(State).value;
},
canDeactivate() {
return inject(State).value;
},
canActivateChild() {
return inject(State).value;
},
canMatch() {
return inject(State).value;
},
canLoad() {
return inject(State).value;
},
};
spyOn(guards, 'canActivate').and.callThrough();
spyOn(guards, 'canActivateChild').and.callThrough();
spyOn(guards, 'canDeactivate').and.callThrough();
spyOn(guards, 'canLoad').and.callThrough();
spyOn(guards, 'canMatch').and.callThrough();
router.resetConfig([
{
path: '',
component: BlankCmp,
loadChildren: () => [{path: '', component: BlankCmp}],
canActivate: [guards.canActivate],
canActivateChild: [guards.canActivateChild],
canLoad: [guards.canLoad],
canDeactivate: [guards.canDeactivate],
canMatch: [guards.canMatch],
},
{
path: 'other',
component: BlankCmp,
},
]);
router.navigateByUrl('/');
await advance(fixture);
expect(guards.canMatch).toHaveBeenCalled();
expect(guards.canLoad).toHaveBeenCalled();
expect(guards.canActivate).toHaveBeenCalled();
expect(guards.canActivateChild).toHaveBeenCalled();
router.navigateByUrl('/other');
await advance(fixture);
expect(guards.canDeactivate).toHaveBeenCalled();
});
it('can run functional guards serially', async () => {
function runSerially(
guards: CanActivateFn[] | CanActivateChildFn[],
): CanActivateFn | CanActivateChildFn {
return (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const injector = inject(EnvironmentInjector);
const observables = guards.map((guard) => {
const guardResult = injector.runInContext(() => guard(route, state));
return wrapIntoObservable(guardResult).pipe(first());
});
return concat(...observables).pipe(
takeWhile((v) => v === true),
last(),
);
};
}
const guardDone: string[] = [];
const guard1: CanActivateFn = () =>
of(true).pipe(
switchMap((v) => new Promise((r) => setTimeout(r, 4)).then(() => v)),
tap(() => guardDone.push('guard1')),
);
const guard2: CanActivateFn = () => of(true).pipe(tap(() => guardDone.push('guard2')));
const guard3: CanActivateFn = () =>
of(true).pipe(
switchMap((v) => new Promise((r) => setTimeout(r, 2)).then(() => v)),
tap(() => guardDone.push('guard3')),
);
const guard4: CanActivateFn = () =>
of(true).pipe(
switchMap((v) => new Promise((r) => setTimeout(r, 6)).then(() => v)),
tap(() => guardDone.push('guard4')),
);
const router = TestBed.inject(Router);
router.resetConfig([
{
path: '**',
component: BlankCmp,
canActivate: [runSerially([guard1, guard2, guard3, guard4])],
},
]);
await router.navigateByUrl('');
expect(guardDone).toEqual(['guard1', 'guard2', 'guard3', 'guard4']);
});
it('should run in injection context', async () => {
@Injectable({providedIn: 'root'})
class MyService {
canRun = false;
}
let resolveCount = 0;
const routes = [
{
path: 'a',
children: [],
resolve: {
x: () => ++resolveCount,
},
runGuardsAndResolvers: () => inject(MyService).canRun,
},
];
const router = TestBed.inject(Router);
router.resetConfig(routes);
const service = TestBed.inject(MyService);
await router.navigateByUrl('/a');
expect(router.url).toEqual('/a');
// Always run on activation
expect(resolveCount).toBe(1);
service.canRun = false;
await router.navigateByUrl('/a?q=1');
expect(resolveCount).toBe(1);
service.canRun = true;
await router.navigateByUrl('/a?q=2');
expect(resolveCount).toBe(2);
});
});
}