Skip to content

Commit

Permalink
feat(cart): rework resolve cart guard (#2522)
Browse files Browse the repository at this point in the history
* feat(cart): rework resolve cart guard

* feat: rework guard

Extract `canActivate` function. Clarify the awaiting of the next state for
attempted resolution. When we encounter a possibly unknown state in the resolving enum,
we now treat this as a failure instead of a awaited resolution.

---------

Co-authored-by: Damien Retzinger <[email protected]>
  • Loading branch information
griest024 and damienwebdev committed Jul 31, 2023
1 parent 3655843 commit f460cc8
Show file tree
Hide file tree
Showing 2 changed files with 175 additions and 91 deletions.
175 changes: 122 additions & 53 deletions libs/cart/routing/src/guards/resolve-cart/resolve-cart.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,30 @@ import {
} from '@angular/core/testing';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import {
StoreModule,
combineReducers,
Store,
} from '@ngrx/store';
import { Observable } from 'rxjs';
cold,
hot,
} from 'jasmine-marbles';
import { BehaviorSubject } from 'rxjs';

import {
DaffCart,
DaffCartStorageService,
} from '@daffodil/cart';
import { DaffResolveCartGuardRedirectUrl } from '@daffodil/cart/routing';
import {
daffCartReducers,
DaffCartStateRootSlice,
DaffResolveCartSuccess,
DaffResolveCartFailure,
DaffResolveCartServerSide,
DaffResolveCart,
DAFF_CART_STORE_FEATURE_KEY,
DaffResolveCartPartialSuccess,
DaffCartResolveState,
DaffCartFacade,
} from '@daffodil/cart/state';
import { DaffCartTestingModule } from '@daffodil/cart/state/testing';
import { DaffCartFactory } from '@daffodil/cart/testing';

import { DaffResolveCartGuard } from './resolve-cart.guard';

describe('@daffodil/cart/routing | DaffResolveCartGuard', () => {
const actions$: Observable<any> = null;
let cartResolver: DaffResolveCartGuard;
let store: Store<DaffCartStateRootSlice>;
let cartFacade: DaffCartFacade;
let cartFactory: DaffCartFactory;

let cartStorageSpy: jasmine.SpyObj<DaffCartStorageService>;
Expand All @@ -48,13 +41,10 @@ describe('@daffodil/cart/routing | DaffResolveCartGuard', () => {

TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({
[DAFF_CART_STORE_FEATURE_KEY]: combineReducers(daffCartReducers),
}),
DaffCartTestingModule,
RouterTestingModule,
],
providers: [
provideMockActions(() => actions$),
{ provide: DaffResolveCartGuardRedirectUrl, useValue: stubUrl },
{
provide: DaffCartStorageService,
Expand All @@ -65,7 +55,7 @@ describe('@daffodil/cart/routing | DaffResolveCartGuard', () => {

cartResolver = TestBed.inject(DaffResolveCartGuard);
cartFactory = TestBed.inject(DaffCartFactory);
store = TestBed.inject(Store);
cartFacade = TestBed.inject(DaffCartFacade);
router = TestBed.inject(Router);

stubCart = cartFactory.create();
Expand All @@ -84,66 +74,145 @@ describe('@daffodil/cart/routing | DaffResolveCartGuard', () => {
cartStorageSpy.getCartId.and.returnValue(stubCart.id);
});

it('should dispatch a DaffResolveCart action', () => {
spyOn(store, 'dispatch');
cartResolver.canActivate().subscribe();
describe('and when the resolved state is default and the cart is resolved successfully', () => {
beforeEach(() => {
cartFacade.resolved$ = hot('abc', { a: DaffCartResolveState.Default, b: DaffCartResolveState.Resolving, c: DaffCartResolveState.Succeeded });
});

it('should dispatch a DaffResolveCart action', () => {
spyOn(cartFacade, 'dispatch');
const expected = cold('--a', { a: true });
expect(cartResolver.canActivate()).toBeObservable(expected);
expect(cartFacade.dispatch).toHaveBeenCalledWith(new DaffResolveCart());
});

expect(store.dispatch).toHaveBeenCalledWith(new DaffResolveCart());
it('should return true when success is dispatched', () => {
const expected = cold('--a', { a: true });
expect(cartResolver.canActivate()).toBeObservable(expected);
});
});

describe('when DaffResolveCartSuccess is dispatched', () => {
it('should return true', done => {
cartResolver.canActivate().subscribe((returnedValue) => {
expect(returnedValue).toBeTrue();
done();
});
describe('and when the resolved state is default and the cart is not resolved successfully', () => {
beforeEach(() => {
cartFacade.resolved$ = hot('abc', { a: DaffCartResolveState.Default, b: DaffCartResolveState.Resolving, c: DaffCartResolveState.Failed });
});

store.dispatch(new DaffResolveCartPartialSuccess(stubCart, []));
it('should dispatch a DaffResolveCart action', () => {
spyOn(cartFacade, 'dispatch');
const expected = cold('--a', { a: router.parseUrl(stubUrl) });
expect(cartResolver.canActivate()).toBeObservable(expected);
expect(cartFacade.dispatch).toHaveBeenCalledWith(new DaffResolveCart());
});

it('should redirect when failure is dispatched', () => {
const expected = cold('--a', { a: router.parseUrl(stubUrl) });
expect(cartResolver.canActivate()).toBeObservable(expected);
});
});

describe('when DaffResolveCartPartialSuccess is dispatched', () => {
it('should return true', done => {
cartResolver.canActivate().subscribe((returnedValue) => {
expect(returnedValue).toBeTrue();
done();
});
describe('and when the resolved state is resolving and the cart is resolved successfully', () => {
beforeEach(() => {
cartFacade.resolved$ = hot('-bc', { b: DaffCartResolveState.Resolving, c: DaffCartResolveState.Succeeded });
});

store.dispatch(new DaffResolveCartPartialSuccess(stubCart, []));
it('should not dispatch a DaffResolveCart action', () => {
spyOn(cartFacade, 'dispatch');
const expected = cold('--a', { a: true });
expect(cartResolver.canActivate()).toBeObservable(expected);
expect(cartFacade.dispatch).not.toHaveBeenCalledWith(new DaffResolveCart());
});

it('should return true when success is dispatched', () => {
const expected = cold('--a', { a: true });
expect(cartResolver.canActivate()).toBeObservable(expected);
});
});

describe('when DaffResolveCartFailure is dispatched', () => {
it('should redirect to the provided DaffResolveCartGuardRedirectUrl', done => {
cartResolver.canActivate().subscribe((resp) => {
expect(resp).toEqual(router.parseUrl(stubUrl));
done();
});
describe('and when the resolved state is resolving and the cart is not resolved successfully', () => {
beforeEach(() => {
cartFacade.resolved$ = hot('-bc', { b: DaffCartResolveState.Resolving, c: DaffCartResolveState.Failed });
});

it('should not dispatch a DaffResolveCart action', () => {
spyOn(cartFacade, 'dispatch');
const expected = cold('--a', { a: router.parseUrl(stubUrl) });
expect(cartResolver.canActivate()).toBeObservable(expected);
expect(cartFacade.dispatch).not.toHaveBeenCalledWith(new DaffResolveCart());
});

store.dispatch(new DaffResolveCartFailure([]));
it('should redirect when failure is dispatched', () => {
const expected = cold('--a', { a: router.parseUrl(stubUrl) });
expect(cartResolver.canActivate()).toBeObservable(expected);
});
});

describe('when DaffResolveCartServerSide is dispatched', () => {
it('should redirect to the provided DaffResolveCartGuardRedirectUrl', done => {
cartResolver.canActivate().subscribe((resp) => {
expect(resp).toEqual(router.parseUrl(stubUrl));
describe('and when the cart is resolved successfully', () => {
beforeEach(() => {
cartFacade.resolved$ = new BehaviorSubject(DaffCartResolveState.Succeeded);
});

it('should not dispatch a DaffResolveCart action', (done) => {
spyOn(cartFacade, 'dispatch');
cartResolver.canActivate().subscribe((res) => {
expect(cartFacade.dispatch).not.toHaveBeenCalledWith(new DaffResolveCart());
done();
});
});

it('should return true', (done) => {
cartResolver.canActivate().subscribe((res) => {
expect(res).toBeTrue();
done();
});
});
});

describe('and when the resolved state is failed and the cart is resolved successfully', () => {
beforeEach(() => {
cartFacade.resolved$ = hot('abc', { a: DaffCartResolveState.Failed, b: DaffCartResolveState.Resolving, c: DaffCartResolveState.Succeeded });
});

it('should dispatch a DaffResolveCart action', () => {
spyOn(cartFacade, 'dispatch');
const expected = cold('--a', { a: true });
expect(cartResolver.canActivate()).toBeObservable(expected);
expect(cartFacade.dispatch).toHaveBeenCalledWith(new DaffResolveCart());
});

it('should return true when success is dispatched', () => {
const expected = cold('--a', { a: true });
expect(cartResolver.canActivate()).toBeObservable(expected);
});
});

describe('and when the resolved state is failed and the cart is not resolved successfully', () => {
beforeEach(() => {
cartFacade.resolved$ = hot('abc', { a: DaffCartResolveState.Failed, b: DaffCartResolveState.Resolving, c: DaffCartResolveState.Failed });
});

it('should dispatch a DaffResolveCart action', () => {
spyOn(cartFacade, 'dispatch');
const expected = cold('--a', { a: router.parseUrl(stubUrl) });
expect(cartResolver.canActivate()).toBeObservable(expected);
expect(cartFacade.dispatch).toHaveBeenCalledWith(new DaffResolveCart());
});

store.dispatch(new DaffResolveCartServerSide(null));
it('should redirect when failure is dispatched', () => {
const expected = cold('--a', { a: router.parseUrl(stubUrl) });
expect(cartResolver.canActivate()).toBeObservable(expected);
});
});
});

describe('when there is not a cart ID in storage', () => {
beforeEach(() => {
cartFacade.resolved$ = new BehaviorSubject(DaffCartResolveState.Default);
cartStorageSpy.getCartId.and.returnValue(null);
});

it('should redirect', done => {
cartResolver.canActivate().subscribe((resp) => {
expect(resp).toEqual(router.parseUrl(stubUrl));
it('should redirect', (done) => {
cartResolver.canActivate().subscribe((res) => {
expect(res).toEqual(router.parseUrl(stubUrl));
done();
});
});
Expand Down
91 changes: 53 additions & 38 deletions libs/cart/routing/src/guards/resolve-cart/resolve-cart.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,49 @@ import {
Router,
UrlTree,
} from '@angular/router';
import {
ActionsSubject,
Store,
} from '@ngrx/store';
import {
Observable,
iif,
of,
} from 'rxjs';
import {
tap,
map,
switchMap,
skipUntil,
filter,
take,
map,
} from 'rxjs/operators';

import { DaffCartStorageService } from '@daffodil/cart';
import {
DaffCartActionTypes,
DaffCartActions,
DaffCartFacade,
DaffCartResolveState,
DaffCartStateRootSlice,
DaffResolveCart,
} from '@daffodil/cart/state';

import {
DaffCartRoutingConfiguration,
DAFF_CART_ROUTING_CONFIG,
} from '../../config/config';
import { DaffResolveCartGuardRedirectUrl } from './redirect.token';

const shouldAttemptResolution = (resolvedState: DaffCartResolveState, hasIDInStorage: boolean): boolean =>
(hasIDInStorage && resolvedState === DaffCartResolveState.Default) || resolvedState === DaffCartResolveState.Failed;

const canActivate = (resolved: DaffCartResolveState): boolean => {
switch (resolved) {
// proceed with navigation
case DaffCartResolveState.Succeeded:
return true;
// we should wait for resolution to finish
case DaffCartResolveState.Resolving:
return null;
// navigation cannot proceed, redirect
case DaffCartResolveState.Default:
case DaffCartResolveState.Failed:
case DaffCartResolveState.ServerSide:
default:
return false;
}
};

/**
* A routing guard that will optionally redirect to a given url if the cart is not properly resolved.
* It will initiate cart resolution.
Expand All @@ -50,38 +62,41 @@ import { DaffResolveCartGuardRedirectUrl } from './redirect.token';
})
export class DaffResolveCartGuard implements CanActivate {
constructor(
private store: Store<DaffCartStateRootSlice>,
private dispatcher: ActionsSubject,
private facade: DaffCartFacade,
private router: Router,
@Inject(DaffResolveCartGuardRedirectUrl) private redirectUrl: string,
private storage: DaffCartStorageService,
) {}

canActivate(): Observable<boolean | UrlTree> {
if (this.storage.getCartId()) {
this.store.dispatch(new DaffResolveCart());

return this.dispatcher.pipe(
filter<DaffCartActions>(action =>
action.type === DaffCartActionTypes.ResolveCartServerSideAction
|| action.type === DaffCartActionTypes.ResolveCartFailureAction
|| action.type === DaffCartActionTypes.ResolveCartSuccessAction
|| action.type === DaffCartActionTypes.ResolveCartPartialSuccessAction,
return this.facade.resolved$.pipe(
take(1),
// first step: decide if we should resolve a cart
map((resolved) => shouldAttemptResolution(resolved, !!this.storage.getCartId())),
tap((shouldAttempt) => {
if(shouldAttempt) {
this.facade.dispatch(new DaffResolveCart());
}
}),
// If we dispatch above, we can't immediately proceed with the next step
// as the resolved state is not guaranteed to be updated in time.
// This ensures that the above dispatched DaffResolveCart is processed
// by the reducers before this guard proceeds to the next step
switchMap((shouldAttempt) => iif(
() => shouldAttempt,
this.facade.resolved$.pipe(
skipUntil(this.facade.resolved$.pipe(
filter((innerResolved) => innerResolved === DaffCartResolveState.Resolving),
)),
),
map((action) => {
if (
action.type === DaffCartActionTypes.ResolveCartSuccessAction
|| action.type === DaffCartActionTypes.ResolveCartPartialSuccessAction
) {
return true;
}

return this.router.parseUrl(this.redirectUrl);
}),
take(1),
);
} else {
return of(this.router.parseUrl(this.redirectUrl));
}
this.facade.resolved$,
)),
// second step: decide if navigation should proceed
map((resolved) => canActivate(resolved)),
// ensure that the pipe does not emit before resolution has been completed
filter((response) => response !== null && response !== undefined),
// finally we can resolve, but to where do we resolve
map((resolved) => resolved ? resolved : this.router.parseUrl(this.redirectUrl)),
);
}
}

0 comments on commit f460cc8

Please sign in to comment.