diff --git a/src/app/core/facades/checkout.facade.ts b/src/app/core/facades/checkout.facade.ts index f0466613ed..8c15f640d2 100644 --- a/src/app/core/facades/checkout.facade.ts +++ b/src/app/core/facades/checkout.facade.ts @@ -37,6 +37,7 @@ import { removePromotionCodeFromBasket, setBasketAttribute, setBasketPayment, + startCheckout, updateBasketAddress, updateBasketItems, updateBasketShippingMethod, @@ -54,6 +55,10 @@ export class CheckoutFacade { checkoutStep$ = this.store.pipe(select(selectRouteData('checkoutStep'))); + start() { + this.store.dispatch(startCheckout()); + } + continue(targetStep: number) { this.store.dispatch(continueCheckout({ targetStep })); } diff --git a/src/app/core/store/customer/basket/basket-validation.effects.spec.ts b/src/app/core/store/customer/basket/basket-validation.effects.spec.ts index 79e6b07081..6d527b9000 100644 --- a/src/app/core/store/customer/basket/basket-validation.effects.spec.ts +++ b/src/app/core/store/customer/basket/basket-validation.effects.spec.ts @@ -14,6 +14,8 @@ import { BasketService } from 'ish-core/services/basket/basket.service'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; import { CustomerStoreModule } from 'ish-core/store/customer/customer-store.module'; import { createOrder } from 'ish-core/store/customer/orders'; +import { GeneralStoreModule } from 'ish-core/store/general/general-store.module'; +import { loadServerConfigSuccess } from 'ish-core/store/general/server-config'; import { loadProductSuccess } from 'ish-core/store/shopping/products'; import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; @@ -25,6 +27,9 @@ import { continueCheckoutSuccess, continueCheckoutWithIssues, loadBasketSuccess, + startCheckout, + startCheckoutFail, + startCheckoutSuccess, submitBasket, validateBasket, } from './basket.actions'; @@ -47,8 +52,10 @@ describe('Basket Validation Effects', () => { imports: [ CoreStoreModule.forTesting(), CustomerStoreModule.forTesting('user', 'basket'), + GeneralStoreModule.forTesting('serverConfig'), RouterTestingModule.withRoutes([ { path: 'checkout', children: [{ path: 'address', component: DummyComponent }] }, + { path: 'checkout', children: [{ path: 'review', component: DummyComponent }] }, ]), ], providers: [ @@ -63,6 +70,149 @@ describe('Basket Validation Effects', () => { location = TestBed.inject(Location); }); + describe('startCheckout$', () => { + const basketValidation: BasketValidation = { + basket: BasketMockData.getBasket(), + results: { + valid: true, + adjusted: false, + }, + }; + + beforeEach(() => { + when(basketServiceMock.validateBasket(anything())).thenReturn(of(basketValidation)); + store$.dispatch( + loadServerConfigSuccess({ + config: { basket: { acceleration: true } }, + }) + ); + + store$.dispatch( + loadBasketSuccess({ + basket: BasketMockData.getBasket(), + }) + ); + store$.dispatch(loadProductSuccess({ product: { sku: 'SKU' } as Product })); + }); + + it('should map to action of type ContinueCheckout without basket acceleration', () => { + store$.dispatch( + loadServerConfigSuccess({ + config: { basket: { acceleration: false } }, + }) + ); + + const action = startCheckout(); + const completion = continueCheckout({ + targetStep: 1, + }); + actions$ = hot('-a', { a: action }); + const expected$ = cold('-c', { c: completion }); + + expect(effects.startCheckoutWithoutAcceleration$).toBeObservable(expected$); + }); + + it('should call the basketService for startCheckoutWithAcceleration', done => { + const action = startCheckout(); + actions$ = of(action); + + effects.startCheckoutWithAcceleration$.subscribe(() => { + verify(basketServiceMock.validateBasket(anything())).once(); + done(); + }); + }); + + it('should map invalid request to action of type startCheckoutFail', () => { + when(basketServiceMock.validateBasket(anything())).thenReturn(throwError(makeHttpError({ message: 'invalid' }))); + + const action = startCheckout(); + const completion = startCheckoutFail({ error: makeHttpError({ message: 'invalid' }) }); + actions$ = hot('-a', { a: action }); + const expected$ = cold('-c', { c: completion }); + + expect(effects.startCheckoutWithAcceleration$).toBeObservable(expected$); + }); + + it('should map a valid request to action of type startCheckoutSuccess', () => { + const action = startCheckout(); + + const completion = startCheckoutSuccess({ + targetRoute: undefined, + basketValidation, + }); + actions$ = hot('-a', { a: action }); + const expected$ = cold('-c', { c: completion }); + + expect(effects.startCheckoutWithAcceleration$).toBeObservable(expected$); + }); + }); + + describe('continueCheckoutWithAcceleration$', () => { + const basketValidation: BasketValidation = { + basket: BasketMockData.getBasket(), + results: { + valid: true, + adjusted: false, + }, + }; + + beforeEach(() => { + when(basketServiceMock.validateBasket(anything())).thenReturn(of(basketValidation)); + store$.dispatch( + loadServerConfigSuccess({ + config: { basket: { acceleration: true } }, + }) + ); + + store$.dispatch( + loadBasketSuccess({ + basket: BasketMockData.getBasket(), + }) + ); + store$.dispatch(loadProductSuccess({ product: { sku: 'SKU' } as Product })); + }); + + it('should call the basketService if validation results are valid and not adjusted', done => { + const action = startCheckoutSuccess({ basketValidation }); + actions$ = of(action); + + effects.continueCheckoutWithAcceleration$.subscribe(() => { + verify(basketServiceMock.validateBasket(anything())).once(); + done(); + }); + }); + + it('should not call the basketService if validation results are invalid', done => { + const action = startCheckoutSuccess({ + basketValidation: { ...basketValidation, results: { valid: false, adjusted: false } }, + }); + actions$ = of(action); + + effects.continueCheckoutWithAcceleration$.subscribe( + () => { + verify(basketServiceMock.validateBasket(anything())).never(); + }, + fail, + done + ); + }); + + it('should not call the basketService if validation results are adjusted', done => { + const action = startCheckoutSuccess({ + basketValidation: { ...basketValidation, results: { valid: true, adjusted: true } }, + }); + actions$ = of(action); + + effects.continueCheckoutWithAcceleration$.subscribe( + () => { + verify(basketServiceMock.validateBasket(anything())).never(); + }, + fail, + done + ); + }); + }); + describe('validateBasket$', () => { const basketValidation: BasketValidation = { basket: BasketMockData.getBasket(), diff --git a/src/app/core/store/customer/basket/basket-validation.effects.ts b/src/app/core/store/customer/basket/basket-validation.effects.ts index fe85e2c216..440abf0c90 100644 --- a/src/app/core/store/customer/basket/basket-validation.effects.ts +++ b/src/app/core/store/customer/basket/basket-validation.effects.ts @@ -2,7 +2,8 @@ import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store, select } from '@ngrx/store'; -import { concatMap, filter, map, tap, withLatestFrom } from 'rxjs/operators'; +import { intersection } from 'lodash-es'; +import { concatMap, filter, map, mapTo, tap, withLatestFrom } from 'rxjs/operators'; import { BasketValidationResultType, @@ -10,6 +11,7 @@ import { } from 'ish-core/models/basket-validation/basket-validation.model'; import { BasketService } from 'ish-core/services/basket/basket.service'; import { createOrder } from 'ish-core/store/customer/orders'; +import { getServerConfigParameter } from 'ish-core/store/general/server-config'; import { loadProduct } from 'ish-core/store/shopping/products'; import { mapErrorToAction, mapToPayload, mapToPayloadProperty, whenTruthy } from 'ish-core/utils/operators'; @@ -20,6 +22,9 @@ import { continueCheckoutWithIssues, loadBasketEligiblePaymentMethods, loadBasketEligibleShippingMethods, + startCheckout, + startCheckoutFail, + startCheckoutSuccess, submitBasket, validateBasket, } from './basket.actions'; @@ -29,11 +34,73 @@ import { getCurrentBasket } from './basket.selectors'; export class BasketValidationEffects { constructor( private actions$: Actions, - private router: Router, private store: Store, + private router: Router, private basketService: BasketService ) {} + private validationSteps: { scopes: BasketValidationScopeType[]; route: string }[] = [ + { scopes: ['Products', 'Value'], route: '/basket' }, + { scopes: ['InvoiceAddress', 'ShippingAddress', 'Addresses'], route: '/checkout/address' }, + { scopes: ['Shipping'], route: '/checkout/shipping' }, + { scopes: ['Payment'], route: '/checkout/payment' }, + { scopes: ['All'], route: '/checkout/review' }, + { scopes: ['All'], route: 'auto' }, // targetRoute will be calculated in dependence of the validation result + ]; + + /** + * Jumps to the first checkout step (no basket acceleration) + */ + startCheckoutWithoutAcceleration$ = createEffect(() => + this.actions$.pipe( + ofType(startCheckout), + withLatestFrom(this.store.pipe(select(getServerConfigParameter('basket.acceleration')))), + filter(([, acc]) => !acc), + mapTo(continueCheckout({ targetStep: 1 })) + ) + ); + + /** + * Check the basket before starting the basket acceleration + */ + startCheckoutWithAcceleration$ = createEffect(() => + this.actions$.pipe( + ofType(startCheckout), + withLatestFrom(this.store.pipe(select(getServerConfigParameter('basket.acceleration')))), + filter(([, acc]) => acc), + concatMap(() => + this.basketService.validateBasket(this.validationSteps[0].scopes).pipe( + map(basketValidation => startCheckoutSuccess({ basketValidation })), + mapErrorToAction(startCheckoutFail) + ) + ) + ) + ); + + /** + * Validates the basket and jumps to the next possible checkout step (basket acceleration) + */ + continueCheckoutWithAcceleration$ = createEffect( + () => + this.actions$.pipe( + ofType(startCheckoutSuccess), + mapToPayload(), + map(payload => payload.basketValidation.results), + filter(results => results.valid && !results.adjusted), + concatMap(() => + this.basketService.validateBasket(this.validationSteps[4].scopes).pipe( + tap(basketValidation => { + if (basketValidation?.results?.valid) { + this.router.navigate([this.validationSteps[4].route]); + } + this.jumpToTargetRoute('auto', basketValidation?.results); + }) + ) + ) + ), + { dispatch: false } + ); + /** * validates the basket but doesn't change the route */ @@ -64,37 +131,9 @@ export class BasketValidationEffects { mapToPayloadProperty('targetStep'), whenTruthy(), concatMap(targetStep => { - let scopes: BasketValidationScopeType[] = ['']; - let targetRoute = ''; - switch (targetStep) { - case 1: { - scopes = ['Products', 'Value']; - targetRoute = '/checkout/address'; - break; - } - case 2: { - scopes = ['InvoiceAddress', 'ShippingAddress', 'Addresses']; - targetRoute = '/checkout/shipping'; - break; - } - case 3: { - scopes = ['Shipping']; - targetRoute = '/checkout/payment'; - break; - } - case 4: { - scopes = ['Payment']; - targetRoute = '/checkout/review'; - break; - } - // before order creation the whole basket is validated again - case 5: { - scopes = ['All']; - targetRoute = 'auto'; // targetRoute will be calculated in dependence of the validation result - break; - } - } - return this.basketService.validateBasket(scopes).pipe( + const targetRoute = this.validationSteps[targetStep].route; + + return this.basketService.validateBasket(this.validationSteps[targetStep - 1].scopes).pipe( withLatestFrom(this.store.pipe(select(getCurrentBasket))), concatMap(([basketValidation, basket]) => basketValidation.results.valid @@ -153,38 +192,22 @@ export class BasketValidationEffects { if (!targetRoute || !results) { return; } + if (targetRoute === 'auto') { - // determine where to go if basket validation finished with an issue before order creation - // consider the 1st error or info - maybe this logic can be enhanced later on - let scope = - results.errors && results.errors.find(issue => issue.parameters && issue.parameters.scopes).parameters.scopes; - if (!scope) { - scope = - results.infos && results.infos.find(issue => issue.parameters && issue.parameters.scopes).parameters.scopes; + let scopes = []; + results.errors?.forEach(error => (scopes = scopes.concat(error?.parameters?.scopes))); + if (!scopes?.length) { + results.infos?.forEach(info => (scopes = scopes.concat(info?.parameters?.scopes))); } - switch (scope) { - case 'Products': - case 'Value': { - this.router.navigate(['/basket'], { queryParams: { error: true } }); - break; - } - case 'Addresses': - case 'ShippingAddress': - case 'InvoiceAddress': { - this.router.navigate(['/checkout/address'], { queryParams: { error: true } }); - break; + this.validationSteps.every(step => { + if (intersection(step.scopes, scopes)?.length) { + this.router.navigate([step.route], { queryParams: { error: true } }); + return false; } - case 'Shipping': { - this.router.navigate(['/checkout/shipping'], { queryParams: { error: true } }); - break; - } - case 'Payment': { - this.router.navigate(['/checkout/payment'], { queryParams: { error: true } }); - break; - } - // otherwise stay on the current page - } + return true; + }); + // otherwise stay on the current page } else if (results.valid && !results.adjusted) { this.router.navigate([targetRoute]); } diff --git a/src/app/core/store/customer/basket/basket.actions.ts b/src/app/core/store/customer/basket/basket.actions.ts index debf9960f4..7e72351254 100644 --- a/src/app/core/store/customer/basket/basket.actions.ts +++ b/src/app/core/store/customer/basket/basket.actions.ts @@ -81,6 +81,15 @@ export const validateBasket = createAction( payload<{ scopes: BasketValidationScopeType[] }>() ); +export const startCheckout = createAction('[Basket] Start the checkout process'); + +export const startCheckoutSuccess = createAction( + '[Basket API] Start the checkout process success', + payload<{ basketValidation: BasketValidation; targetRoute?: string }>() +); + +export const startCheckoutFail = createAction('[Basket API] Start the checkout process fail', httpError()); + export const continueCheckout = createAction( '[Basket] Validate Basket and continue checkout', payload<{ targetStep: number }>() diff --git a/src/app/core/store/customer/basket/basket.reducer.ts b/src/app/core/store/customer/basket/basket.reducer.ts index f8111e2d38..871b318cb8 100644 --- a/src/app/core/store/customer/basket/basket.reducer.ts +++ b/src/app/core/store/customer/basket/basket.reducer.ts @@ -55,6 +55,9 @@ import { setBasketPayment, setBasketPaymentFail, setBasketPaymentSuccess, + startCheckout, + startCheckoutFail, + startCheckoutSuccess, submitBasket, submitBasketFail, submitBasketSuccess, @@ -127,7 +130,8 @@ export const basketReducer = createReducer( updateBasketPayment, deleteBasketPayment, submitBasket, - updateConcardisCvcLastUpdated + updateConcardisCvcLastUpdated, + startCheckout ), unsetLoadingAndErrorOn( loadBasketSuccess, @@ -144,7 +148,9 @@ export const basketReducer = createReducer( continueCheckoutWithIssues, loadBasketEligibleShippingMethodsSuccess, loadBasketEligiblePaymentMethodsSuccess, - submitBasketSuccess + updateConcardisCvcLastUpdatedSuccess, + submitBasketSuccess, + startCheckoutSuccess ), setErrorOn( mergeBasketFail, @@ -165,7 +171,7 @@ export const basketReducer = createReducer( deleteBasketPaymentFail, updateConcardisCvcLastUpdatedFail, submitBasketFail, - updateConcardisCvcLastUpdatedSuccess + startCheckoutFail ), on(loadBasketSuccess, mergeBasketSuccess, (state: BasketState, action) => { @@ -203,7 +209,7 @@ export const basketReducer = createReducer( validationResults: initialValidationResults, }) ), - on(continueCheckoutSuccess, continueCheckoutWithIssues, (state: BasketState, action) => { + on(startCheckoutSuccess, continueCheckoutSuccess, continueCheckoutWithIssues, (state: BasketState, action) => { const validation = action.payload.basketValidation; const basket = validation && validation.results.adjusted && validation.basket ? validation.basket : state.basket; diff --git a/src/app/core/store/customer/customer-store.spec.ts b/src/app/core/store/customer/customer-store.spec.ts index 0cd9b37c6a..9a0b06db77 100644 --- a/src/app/core/store/customer/customer-store.spec.ts +++ b/src/app/core/store/customer/customer-store.spec.ts @@ -36,13 +36,14 @@ import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; import { CustomerStoreModule } from 'ish-core/store/customer/customer-store.module'; import { loginUser } from 'ish-core/store/customer/user'; import { UserEffects } from 'ish-core/store/customer/user/user.effects'; +import { GeneralStoreModule } from 'ish-core/store/general/general-store.module'; import { loadProductSuccess } from 'ish-core/store/shopping/products'; import { ShoppingStoreModule } from 'ish-core/store/shopping/shopping-store.module'; import { CookiesService } from 'ish-core/utils/cookies/cookies.service'; import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ngrx-testing'; import { categoryTree } from 'ish-core/utils/dev/test-data-utils'; -import { addProductToBasket, loadBasketSuccess } from './basket'; +import { addProductToBasket, loadBasketSuccess, startCheckout } from './basket'; describe('Customer Store', () => { let store: StoreWithSnapshots; @@ -133,6 +134,15 @@ describe('Customer Store', () => { when(basketServiceMock.getBaskets()).thenReturn(of([])); when(basketServiceMock.mergeBasket(anything(), anything(), anything())).thenReturn(of(basket)); when(basketServiceMock.addItemsToBasket(anything())).thenReturn(of(undefined)); + when(basketServiceMock.validateBasket(anything())).thenReturn( + of({ + basket, + results: { + valid: true, + adjusted: false, + }, + }) + ); const productsServiceMock = mock(ProductsService); when(productsServiceMock.getProduct(anything())).thenReturn(of(product)); @@ -155,11 +165,16 @@ describe('Customer Store', () => { imports: [ CoreStoreModule.forTesting(['configuration'], [UserEffects]), CustomerStoreModule, + GeneralStoreModule.forTesting('serverConfig'), RouterTestingModule.withRoutes([ { path: 'account', component: DummyComponent, }, + { + path: 'checkout/address', + component: DummyComponent, + }, ]), ShoppingStoreModule, TranslateModule.forRoot(), @@ -229,10 +244,12 @@ describe('Customer Store', () => { }); describe('and with basket', () => { - it('should merge basket on user login.', () => { + beforeEach(() => { store.dispatch(loadBasketSuccess({ basket })); store.reset(); + }); + it('should merge basket on user login.', () => { store.dispatch(loginUser({ credentials: {} as Credentials })); expect(store.actionsArray()).toMatchInlineSnapshot(` @@ -245,6 +262,18 @@ describe('Customer Store', () => { basket: {"id":"test","lineItems":[1]} `); }); + + it('should go to checkout address page after starting checkout.', () => { + store.dispatch(startCheckout()); + expect(store.actionsArray()).toMatchInlineSnapshot(` + [Basket] Start the checkout process + [Basket] Validate Basket and continue checkout: + targetStep: 1 + [Basket API] Validate Basket and continue with success: + targetRoute: "/checkout/address" + basketValidation: {"basket":{"id":"test","lineItems":[1]},"results":{"valid":t... + `); + }); }); }); }); diff --git a/src/app/pages/basket/basket-page.component.ts b/src/app/pages/basket/basket-page.component.ts index d5004996bb..0c7f18e9c8 100644 --- a/src/app/pages/basket/basket-page.component.ts +++ b/src/app/pages/basket/basket-page.component.ts @@ -33,6 +33,6 @@ export class BasketPageComponent implements OnInit { } nextStep() { - this.checkoutFacade.continue(1); + this.checkoutFacade.start(); } }