diff --git a/packages/venia-concept/src/__mocks__/@magento/peregrine.js b/packages/venia-concept/__mocks__/@magento/peregrine.js similarity index 65% rename from packages/venia-concept/src/__mocks__/@magento/peregrine.js rename to packages/venia-concept/__mocks__/@magento/peregrine.js index a9c6b0f3c1..ebd823551c 100644 --- a/packages/venia-concept/src/__mocks__/@magento/peregrine.js +++ b/packages/venia-concept/__mocks__/@magento/peregrine.js @@ -1,5 +1,13 @@ import { createElement } from 'react'; +export const mockRequest = jest.fn(); + +export const RestApi = { + Magento2: { + request: mockRequest + } +}; + /** * the Price component from @magento/peregrine * has browser-specific functionality and cannot diff --git a/packages/venia-concept/src/__mocks__/store.js b/packages/venia-concept/src/__mocks__/store.js new file mode 100644 index 0000000000..21c5688832 --- /dev/null +++ b/packages/venia-concept/src/__mocks__/store.js @@ -0,0 +1,7 @@ +const initialState = {}; + +export const addReducer = jest.fn(); +export const dispatch = jest.fn(); +export const getState = jest.fn(() => initialState); + +export default { addReducer, dispatch, getState }; diff --git a/packages/venia-concept/src/actions/app.js b/packages/venia-concept/src/actions/app.js deleted file mode 100644 index 7be2a93a97..0000000000 --- a/packages/venia-concept/src/actions/app.js +++ /dev/null @@ -1,30 +0,0 @@ -import { createActions } from 'redux-actions'; - -import { store } from 'src'; - -const prefix = 'APP'; -const actionTypes = ['TOGGLE_DRAWER']; - -const actions = createActions(...actionTypes, { prefix }); -export default actions; - -/* async action creators */ - -export const loadReducers = payload => - async function thunk() { - try { - const reducers = await Promise.all(payload); - - reducers.forEach(({ default: reducer, name }) => { - store.addReducer(name, reducer); - }); - } catch (error) { - console.log(error); - } - }; - -export const toggleDrawer = name => async dispatch => - dispatch(actions.toggleDrawer(name)); - -export const closeDrawer = () => async dispatch => - dispatch(actions.toggleDrawer(null)); diff --git a/packages/venia-concept/src/actions/app/__tests__/actions.spec.js b/packages/venia-concept/src/actions/app/__tests__/actions.spec.js new file mode 100644 index 0000000000..a2da227f14 --- /dev/null +++ b/packages/venia-concept/src/actions/app/__tests__/actions.spec.js @@ -0,0 +1,20 @@ +import actions from '../actions'; + +const payload = 'FOO'; +const error = new Error('BAR'); + +test('toggleDrawer.toString() returns the proper action type', () => { + expect(actions.toggleDrawer.toString()).toBe('APP/TOGGLE_DRAWER'); +}); + +test('toggleDrawer() returns a proper action object', () => { + expect(actions.toggleDrawer(payload)).toEqual({ + type: 'APP/TOGGLE_DRAWER', + payload + }); + expect(actions.toggleDrawer(error)).toEqual({ + type: 'APP/TOGGLE_DRAWER', + payload: error, + error: true + }); +}); diff --git a/packages/venia-concept/src/actions/app/__tests__/asyncActions.spec.js b/packages/venia-concept/src/actions/app/__tests__/asyncActions.spec.js new file mode 100644 index 0000000000..af482a1821 --- /dev/null +++ b/packages/venia-concept/src/actions/app/__tests__/asyncActions.spec.js @@ -0,0 +1,47 @@ +import { dispatch, getState } from 'src/store'; +import actions from '../actions'; +import { closeDrawer, toggleDrawer } from '../asyncActions'; + +jest.mock('src/store'); + +const thunkArgs = [dispatch, getState]; + +afterEach(() => { + dispatch.mockClear(); +}); + +test('toggleDrawer() to return a thunk', () => { + expect(toggleDrawer()).toBeInstanceOf(Function); +}); + +test('toggleDrawer thunk returns undefined', async () => { + const payload = 'FOO'; + const result = await toggleDrawer(payload)(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('toggleDrawer thunk dispatches actions', async () => { + const payload = 'FOO'; + await toggleDrawer(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.toggleDrawer(payload)); + expect(dispatch).toHaveBeenCalledTimes(1); +}); + +test('closeDrawer() to return a thunk ', () => { + expect(closeDrawer()).toBeInstanceOf(Function); +}); + +test('closeDrawer thunk returns undefined', async () => { + const result = await closeDrawer()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('closeDrawer thunk dispatches actions', async () => { + await closeDrawer()(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.toggleDrawer(null)); + expect(dispatch).toHaveBeenCalledTimes(1); +}); diff --git a/packages/venia-concept/src/actions/app/actions.js b/packages/venia-concept/src/actions/app/actions.js new file mode 100644 index 0000000000..972465ba4d --- /dev/null +++ b/packages/venia-concept/src/actions/app/actions.js @@ -0,0 +1,6 @@ +import { createActions } from 'redux-actions'; + +const prefix = 'APP'; +const actionTypes = ['TOGGLE_DRAWER']; + +export default createActions(...actionTypes, { prefix }); diff --git a/packages/venia-concept/src/actions/app/asyncActions.js b/packages/venia-concept/src/actions/app/asyncActions.js new file mode 100644 index 0000000000..b7e2c2e7a1 --- /dev/null +++ b/packages/venia-concept/src/actions/app/asyncActions.js @@ -0,0 +1,7 @@ +import actions from './actions'; + +export const toggleDrawer = name => async dispatch => + dispatch(actions.toggleDrawer(name)); + +export const closeDrawer = () => async dispatch => + dispatch(actions.toggleDrawer(null)); diff --git a/packages/venia-concept/src/actions/app/index.js b/packages/venia-concept/src/actions/app/index.js new file mode 100644 index 0000000000..c6c32a70b8 --- /dev/null +++ b/packages/venia-concept/src/actions/app/index.js @@ -0,0 +1,2 @@ +export { default } from './actions'; +export * from './asyncActions'; diff --git a/packages/venia-concept/src/actions/cart/__tests__/actions.spec.js b/packages/venia-concept/src/actions/cart/__tests__/actions.spec.js new file mode 100644 index 0000000000..73a0179212 --- /dev/null +++ b/packages/venia-concept/src/actions/cart/__tests__/actions.spec.js @@ -0,0 +1,93 @@ +import actions from '../actions'; + +const payload = 'PAYLOAD'; +const error = new Error('ERROR'); + +test('addItem.request.toString() returns the proper action type', () => { + expect(actions.addItem.request.toString()).toBe('CART/ADD_ITEM/REQUEST'); +}); + +test('addItem.request() returns a proper action object', () => { + expect(actions.addItem.request(payload)).toEqual({ + type: 'CART/ADD_ITEM/REQUEST', + payload + }); +}); + +test('addItem.receive.toString() returns the proper action type', () => { + expect(actions.addItem.receive.toString()).toBe('CART/ADD_ITEM/RECEIVE'); +}); + +test('addItem.receive() returns a proper action object', () => { + expect(actions.addItem.receive(payload)).toEqual({ + type: 'CART/ADD_ITEM/RECEIVE', + payload + }); + expect(actions.addItem.receive(error)).toEqual({ + type: 'CART/ADD_ITEM/RECEIVE', + payload: error, + error: true + }); +}); + +test('getGuestCart.request.toString() returns the proper action type', () => { + expect(actions.getGuestCart.request.toString()).toBe( + 'CART/GET_GUEST_CART/REQUEST' + ); +}); + +test('getGuestCart.request() returns a proper action object', () => { + expect(actions.getGuestCart.request(payload)).toEqual({ + type: 'CART/GET_GUEST_CART/REQUEST', + payload + }); +}); + +test('getGuestCart.receive.toString() returns the proper action type', () => { + expect(actions.getGuestCart.receive.toString()).toBe( + 'CART/GET_GUEST_CART/RECEIVE' + ); +}); + +test('getGuestCart.receive() returns a proper action object', () => { + expect(actions.getGuestCart.receive(payload)).toEqual({ + type: 'CART/GET_GUEST_CART/RECEIVE', + payload + }); + expect(actions.getGuestCart.receive(error)).toEqual({ + type: 'CART/GET_GUEST_CART/RECEIVE', + payload: error, + error: true + }); +}); + +test('getDetails.request.toString() returns the proper action type', () => { + expect(actions.getDetails.request.toString()).toBe( + 'CART/GET_DETAILS/REQUEST' + ); +}); + +test('getDetails.request() returns a proper action object', () => { + expect(actions.getDetails.request(payload)).toEqual({ + type: 'CART/GET_DETAILS/REQUEST', + payload + }); +}); + +test('getDetails.receive.toString() returns the proper action type', () => { + expect(actions.getDetails.receive.toString()).toBe( + 'CART/GET_DETAILS/RECEIVE' + ); +}); + +test('getDetails.receive() returns a proper action object', () => { + expect(actions.getDetails.receive(payload)).toEqual({ + type: 'CART/GET_DETAILS/RECEIVE', + payload + }); + expect(actions.getDetails.receive(error)).toEqual({ + type: 'CART/GET_DETAILS/RECEIVE', + payload: error, + error: true + }); +}); diff --git a/packages/venia-concept/src/actions/cart/__tests__/asyncActions.spec.js b/packages/venia-concept/src/actions/cart/__tests__/asyncActions.spec.js new file mode 100644 index 0000000000..09dc204938 --- /dev/null +++ b/packages/venia-concept/src/actions/cart/__tests__/asyncActions.spec.js @@ -0,0 +1,244 @@ +import { RestApi } from '@magento/peregrine'; + +import { dispatch, getState } from 'src/store'; +import checkoutActions from 'src/actions/checkout'; +import { mockGetItem, mockSetItem } from 'src/util/simplePersistence'; +import actions from '../actions'; +import { + addItemToCart, + createGuestCart, + getCartDetails, + toggleCart +} from '../asyncActions'; + +jest.mock('src/store'); +jest.mock('src/util/simplePersistence'); + +const thunkArgs = [dispatch, getState]; +const { request } = RestApi.Magento2; + +beforeAll(() => { + getState.mockImplementation(() => ({ + app: { drawer: null }, + cart: { guestCartId: 'GUEST_CART_ID' } + })); +}); + +afterEach(() => { + dispatch.mockClear(); + request.mockClear(); +}); + +afterAll(() => { + getState.mockRestore(); +}); + +test('createGuestCart() returns a thunk', () => { + expect(createGuestCart()).toBeInstanceOf(Function); +}); + +test('createGuestCart thunk returns undefined', async () => { + const result = await createGuestCart()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('createGuestCart thunk does nothing if a guest cart exists', async () => { + await createGuestCart()(...thunkArgs); + + expect(dispatch).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); +}); + +test('createGuestCart thunk uses the guest cart from storage', async () => { + const storedGuestCartId = 'STORED_GUEST_CART_ID'; + getState.mockImplementationOnce(() => ({ cart: {} })); + mockGetItem.mockImplementationOnce(() => storedGuestCartId); + + await createGuestCart()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, checkoutActions.reset()); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getGuestCart.receive(storedGuestCartId) + ); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(request).not.toHaveBeenCalled(); +}); + +test('createGuestCart thunk dispatches actions on success', async () => { + const response = 'NEW_GUEST_CART_ID'; + + request.mockResolvedValueOnce(response); + getState.mockImplementationOnce(() => ({ cart: {} })); + + await createGuestCart()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, checkoutActions.reset()); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.getGuestCart.request()); + expect(dispatch).toHaveBeenNthCalledWith( + 3, + actions.getGuestCart.receive(response) + ); + expect(dispatch).toHaveBeenCalledTimes(3); + expect(mockSetItem).toHaveBeenCalled(); +}); + +test('createGuestCart thunk dispatches actions on failure', async () => { + const error = new Error('ERROR'); + + request.mockResolvedValueOnce(error); + getState.mockImplementationOnce(() => ({ cart: {} })); + + await createGuestCart()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, checkoutActions.reset()); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.getGuestCart.request()); + expect(dispatch).toHaveBeenNthCalledWith( + 3, + actions.getGuestCart.receive(error) + ); + expect(dispatch).toHaveBeenCalledTimes(3); +}); + +test('addItemToCart() returns a thunk', () => { + expect(addItemToCart()).toBeInstanceOf(Function); +}); + +test('addItemToCart thunk returns undefined', async () => { + const result = await addItemToCart()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('addItemToCart thunk dispatches actions on success', async () => { + const payload = { item: 'ITEM', quantity: 1 }; + const cartItem = 'CART_ITEM'; + + request.mockResolvedValueOnce(cartItem); + await addItemToCart(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.addItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.addItem.receive({ cartItem, ...payload }) + ); + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(4, expect.any(Function)); + expect(dispatch).toHaveBeenCalledTimes(4); +}); + +test('addItemToCart thunk dispatches actions on failure', async () => { + const payload = { item: 'ITEM', quantity: 1 }; + const error = new Error('ERROR'); + + request.mockRejectedValueOnce(error); + await addItemToCart(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.addItem.request(payload) + ); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.addItem.receive(error)); + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(4, expect.any(Function)); + expect(dispatch).toHaveBeenCalledTimes(4); +}); + +test('getCartDetails() returns a thunk', () => { + expect(getCartDetails()).toBeInstanceOf(Function); +}); + +test('getCartDetails thunk returns undefined', async () => { + const result = await getCartDetails()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('getCartDetails thunk dispatches actions on success', async () => { + request.mockResolvedValueOnce(1); + request.mockResolvedValueOnce(2); + + await getCartDetails()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.getDetails.request('GUEST_CART_ID') + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getDetails.receive({ details: 1, totals: 2 }) + ); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('getCartDetails thunk dispatches actions on failure', async () => { + const error = new Error('ERROR'); + request.mockRejectedValueOnce(error); + + await getCartDetails()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 1, + actions.getDetails.request('GUEST_CART_ID') + ); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getDetails.receive(error) + ); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('getCartDetails thunk merges cached item images into details', async () => { + const cache = { SKU_1: 'IMAGE_1' }; + const items = [{ image: 'IMAGE_0', sku: 'SKU_0' }, { sku: 'SKU_1' }]; + const expected = [items[0], { ...items[1], image: cache.SKU_1 }]; + + mockGetItem.mockResolvedValueOnce(cache); + request.mockResolvedValueOnce({ items }); + request.mockResolvedValueOnce(2); + + await getCartDetails()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith( + 2, + actions.getDetails.receive({ details: { items: expected }, totals: 2 }) + ); +}); + +test('toggleCart() returns a thunk', () => { + expect(toggleCart()).toBeInstanceOf(Function); +}); + +test('toggleCart thunk returns undefined', async () => { + const result = await toggleCart()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('toggleCart thunk exits if app state is not present', async () => { + getState.mockImplementationOnce(() => ({ cart: {} })); + + await toggleCart()(...thunkArgs); + + expect(dispatch).not.toHaveBeenCalled(); +}); + +test('toggleCart thunk exits if cart state is not present', async () => { + getState.mockImplementationOnce(() => ({ app: {} })); + + await toggleCart()(...thunkArgs); + + expect(dispatch).not.toHaveBeenCalled(); +}); + +test('toggleCart thunk opens the drawer and refreshes the cart', async () => { + await toggleCart()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(dispatch).toHaveBeenCalledTimes(2); +}); diff --git a/packages/venia-concept/src/actions/cart/actions.js b/packages/venia-concept/src/actions/cart/actions.js new file mode 100644 index 0000000000..6c40dd45cc --- /dev/null +++ b/packages/venia-concept/src/actions/cart/actions.js @@ -0,0 +1,20 @@ +import { createActions } from 'redux-actions'; + +const prefix = 'CART'; + +const actionMap = { + ADD_ITEM: { + REQUEST: null, + RECEIVE: null + }, + GET_GUEST_CART: { + REQUEST: null, + RECEIVE: null + }, + GET_DETAILS: { + REQUEST: null, + RECEIVE: null + } +}; + +export default createActions(actionMap, { prefix }); diff --git a/packages/venia-concept/src/actions/cart.js b/packages/venia-concept/src/actions/cart/asyncActions.js similarity index 79% rename from packages/venia-concept/src/actions/cart.js rename to packages/venia-concept/src/actions/cart/asyncActions.js index a014deb804..86bab0ef94 100644 --- a/packages/venia-concept/src/actions/cart.js +++ b/packages/venia-concept/src/actions/cart/asyncActions.js @@ -1,23 +1,9 @@ -import { createActions } from 'redux-actions'; import { RestApi } from '@magento/peregrine'; import { closeDrawer, toggleDrawer } from 'src/actions/app'; import checkoutActions from 'src/actions/checkout'; import BrowserPersistence from 'src/util/simplePersistence'; - -const prefix = 'CART'; -const actionTypes = [ - 'ADD_ITEM', - 'REQUEST_GUEST_CART', - 'RECEIVE_GUEST_CART', - 'REQUEST_DETAILS', - 'UPDATE_DETAILS' -]; - -const actions = createActions(...actionTypes, { prefix }); -export default actions; - -/* async action creators */ +import actions from './actions'; const { request } = RestApi.Magento2; const storage = new BrowserPersistence(); @@ -39,12 +25,12 @@ export const createGuestCart = () => // if a guest cart exists in storage, act like we just received it if (guestCartId) { - dispatch(actions.receiveGuestCart(guestCartId)); + dispatch(actions.getGuestCart.receive(guestCartId)); return; } // otherwise, request a new guest cart - dispatch(actions.requestGuestCart()); + dispatch(actions.getGuestCart.request()); try { const id = await request('/rest/V1/guest-carts', { @@ -52,11 +38,10 @@ export const createGuestCart = () => }); // write to storage in the background - storage.setItem('guestCartId', id); - - dispatch(actions.receiveGuestCart(id)); + saveGuestCartId(id); + dispatch(actions.getGuestCart.receive(id)); } catch (error) { - dispatch(actions.createGuestCart(error)); + dispatch(actions.getGuestCart.receive(error)); } }; @@ -66,6 +51,8 @@ export const addItemToCart = (payload = {}) => { writeImageToCache(item); return async function thunk(dispatch, getState) { + dispatch(actions.addItem.request(payload)); + try { const { cart } = getState(); const { guestCartId } = cart; @@ -89,10 +76,12 @@ export const addItemToCart = (payload = {}) => { } ); - dispatch(actions.addItem({ cartItem, item, quantity })); + dispatch(actions.addItem.receive({ cartItem, item, quantity })); } catch (error) { const { response } = error; + dispatch(actions.addItem.receive(error)); + // check if the guest cart has expired if (response && response.status === 404) { // if so, create a new one @@ -100,8 +89,6 @@ export const addItemToCart = (payload = {}) => { // then retry this operation return thunk(...arguments); } - - dispatch(actions.addItem(error)); } await Promise.all([ @@ -118,7 +105,7 @@ export const getCartDetails = (payload = {}) => { const { cart } = getState(); const { guestCartId } = cart; - dispatch(actions.requestDetails(guestCartId)); + dispatch(actions.getDetails.request(guestCartId)); // if there isn't a guest cart, create one // then retry this operation @@ -138,14 +125,22 @@ export const getCartDetails = (payload = {}) => { }) ]); - details.items.forEach(item => { - item.image = item.image || imageCache[item.sku] || {}; - }); + const { items } = details; + + // for each item in the cart, look up its image in the cache + // and merge it into the item object + if (imageCache && Array.isArray(items) && items.length) { + items.forEach(item => { + item.image = item.image || imageCache[item.sku] || {}; + }); + } - dispatch(actions.updateDetails({ details, totals })); + dispatch(actions.getDetails.receive({ details, totals })); } catch (error) { const { response } = error; + dispatch(actions.getDetails.receive(error)); + // check if the guest cart has expired if (response && response.status === 404) { // if so, create a new one @@ -153,8 +148,6 @@ export const getCartDetails = (payload = {}) => { // then retry this operation return thunk(...arguments); } - - dispatch(actions.updateDetails(error)); } }; }; @@ -170,14 +163,14 @@ export const toggleCart = () => // if the cart drawer is open, close it if (app.drawer === 'cart') { - await dispatch(closeDrawer()); - return; + return dispatch(closeDrawer()); } // otherwise open the cart and load its contents - await Promise.all[ - (dispatch(toggleDrawer('cart')), dispatch(getCartDetails())) - ]; + await Promise.all([ + dispatch(toggleDrawer('cart')), + dispatch(getCartDetails()) + ]); }; /* helpers */ @@ -196,6 +189,10 @@ export async function retrieveGuestCartId() { return storage.getItem('guestCartId'); } +export async function saveGuestCartId(id) { + return storage.setItem('guestCartId', id); +} + export async function clearGuestCartId() { return storage.removeItem('guestCartId'); } @@ -208,7 +205,7 @@ async function saveImageCache(cache) { return storage.setItem('imagesBySku', cache); } -async function writeImageToCache(item) { +async function writeImageToCache(item = {}) { const { media_gallery_entries: media, sku } = item; if (sku) { diff --git a/packages/venia-concept/src/actions/cart/index.js b/packages/venia-concept/src/actions/cart/index.js new file mode 100644 index 0000000000..c6c32a70b8 --- /dev/null +++ b/packages/venia-concept/src/actions/cart/index.js @@ -0,0 +1,2 @@ +export { default } from './actions'; +export * from './asyncActions'; diff --git a/packages/venia-concept/src/actions/checkout/__tests__/actions.spec.js b/packages/venia-concept/src/actions/checkout/__tests__/actions.spec.js new file mode 100644 index 0000000000..ec3368eb5d --- /dev/null +++ b/packages/venia-concept/src/actions/checkout/__tests__/actions.spec.js @@ -0,0 +1,139 @@ +import actions from '../actions'; + +const payload = 'PAYLOAD'; +const error = new Error('ERROR'); + +test('begin.toString() returns the proper action type', () => { + expect(actions.begin.toString()).toBe('CHECKOUT/BEGIN'); +}); + +test('begin() returns a proper action object', () => { + expect(actions.begin(payload)).toEqual({ type: 'CHECKOUT/BEGIN', payload }); + expect(actions.begin(error)).toEqual({ + type: 'CHECKOUT/BEGIN', + payload: error, + error: true + }); +}); + +test('edit.toString() returns the proper action type', () => { + expect(actions.edit.toString()).toBe('CHECKOUT/EDIT'); +}); + +test('edit() returns a proper action object', () => { + expect(actions.edit(payload)).toEqual({ type: 'CHECKOUT/EDIT', payload }); + expect(actions.edit(error)).toEqual({ + type: 'CHECKOUT/EDIT', + payload: error, + error: true + }); +}); + +test('reset.toString() returns the proper action type', () => { + expect(actions.reset.toString()).toBe('CHECKOUT/RESET'); +}); + +test('reset() returns a proper action object', () => { + expect(actions.reset(payload)).toEqual({ type: 'CHECKOUT/RESET', payload }); + expect(actions.reset(error)).toEqual({ + type: 'CHECKOUT/RESET', + payload: error, + error: true + }); +}); + +test('input.submit.toString() returns the proper action type', () => { + expect(actions.input.submit.toString()).toBe('CHECKOUT/INPUT/SUBMIT'); +}); + +test('input.submit() returns a proper action object', () => { + expect(actions.input.submit(payload)).toEqual({ + type: 'CHECKOUT/INPUT/SUBMIT', + payload + }); + expect(actions.input.submit(error)).toEqual({ + type: 'CHECKOUT/INPUT/SUBMIT', + payload: error, + error: true + }); +}); + +test('input.accept.toString() returns the proper action type', () => { + expect(actions.input.accept.toString()).toBe('CHECKOUT/INPUT/ACCEPT'); +}); + +test('input.accept() returns a proper action object', () => { + expect(actions.input.accept(payload)).toEqual({ + type: 'CHECKOUT/INPUT/ACCEPT', + payload + }); + expect(actions.input.accept(error)).toEqual({ + type: 'CHECKOUT/INPUT/ACCEPT', + payload: error, + error: true + }); +}); + +test('input.reject.toString() returns the proper action type', () => { + expect(actions.input.reject.toString()).toBe('CHECKOUT/INPUT/REJECT'); +}); + +test('input.reject() returns a proper action object', () => { + expect(actions.input.reject(payload)).toEqual({ + type: 'CHECKOUT/INPUT/REJECT', + payload + }); + expect(actions.input.reject(error)).toEqual({ + type: 'CHECKOUT/INPUT/REJECT', + payload: error, + error: true + }); +}); + +test('order.submit.toString() returns the proper action type', () => { + expect(actions.order.submit.toString()).toBe('CHECKOUT/ORDER/SUBMIT'); +}); + +test('order.submit() returns a proper action object', () => { + expect(actions.order.submit(payload)).toEqual({ + type: 'CHECKOUT/ORDER/SUBMIT', + payload + }); + expect(actions.order.submit(error)).toEqual({ + type: 'CHECKOUT/ORDER/SUBMIT', + payload: error, + error: true + }); +}); + +test('order.accept.toString() returns the proper action type', () => { + expect(actions.order.accept.toString()).toBe('CHECKOUT/ORDER/ACCEPT'); +}); + +test('order.accept() returns a proper action object', () => { + expect(actions.order.accept(payload)).toEqual({ + type: 'CHECKOUT/ORDER/ACCEPT', + payload + }); + expect(actions.order.accept(error)).toEqual({ + type: 'CHECKOUT/ORDER/ACCEPT', + payload: error, + error: true + }); +}); + +test('order.reject.toString() returns the proper action type', () => { + expect(actions.order.reject.toString()).toBe('CHECKOUT/ORDER/REJECT'); +}); + +test('order.reject() returns a proper action object', () => { + expect(actions.order.reject(payload)).toEqual({ + type: 'CHECKOUT/ORDER/REJECT', + payload + }); + expect(actions.order.reject(error)).toEqual({ + type: 'CHECKOUT/ORDER/REJECT', + payload: error, + error: true + }); +}); diff --git a/packages/venia-concept/src/actions/checkout/__tests__/asyncActions.spec.js b/packages/venia-concept/src/actions/checkout/__tests__/asyncActions.spec.js new file mode 100644 index 0000000000..694681e650 --- /dev/null +++ b/packages/venia-concept/src/actions/checkout/__tests__/asyncActions.spec.js @@ -0,0 +1,248 @@ +import { RestApi } from '@magento/peregrine'; + +import { dispatch, getState } from 'src/store'; +import actions from '../actions'; +import { + beginCheckout, + editOrder, + formatAddress, + resetCheckout, + submitInput, + submitOrder +} from '../asyncActions'; + +jest.mock('src/store'); + +const thunkArgs = [dispatch, getState]; +const { request } = RestApi.Magento2; + +const address = { + country_id: 'US', + firstname: 'Veronica', + lastname: 'Costello', + street: ['6146 Honey Bluff Parkway'], + city: 'Calder', + postcode: '49628-7978', + region_id: 33, + region_code: 'MI', + region: 'Michigan', + telephone: '(555) 229-3326', + email: 'veronica@example.com' +}; + +const countries = [ + { id: 'US', available_regions: [{ id: 33, code: 'MI', name: 'Michigan' }] } +]; + +beforeAll(() => { + getState.mockImplementation(() => ({ + cart: { guestCartId: 'GUEST_CART_ID' }, + directory: { countries } + })); +}); + +afterEach(() => { + dispatch.mockClear(); + request.mockClear(); +}); + +afterAll(() => { + getState.mockRestore(); +}); + +test('beginCheckout() returns a thunk', () => { + expect(beginCheckout()).toBeInstanceOf(Function); +}); + +test('beginCheckout thunk returns undefined', async () => { + const result = await beginCheckout()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('beginCheckout thunk dispatches actions', async () => { + await beginCheckout()(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.begin()); + expect(dispatch).toHaveBeenCalledTimes(1); +}); + +test('resetCheckout() returns a thunk', () => { + expect(resetCheckout()).toBeInstanceOf(Function); +}); + +test('resetCheckout thunk returns undefined', async () => { + const result = await resetCheckout()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('resetCheckout thunk dispatches actions', async () => { + await resetCheckout()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.reset()); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('editOrder() returns a thunk', () => { + expect(editOrder()).toBeInstanceOf(Function); +}); + +test('editOrder thunk returns undefined', async () => { + const result = await editOrder()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('editOrder thunk dispatches actions', async () => { + const payload = 'PAYLOAD'; + await editOrder(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.edit(payload)); + expect(dispatch).toHaveBeenCalledTimes(1); +}); + +test('submitInput() returns a thunk', () => { + expect(submitInput()).toBeInstanceOf(Function); +}); + +test('submitInput thunk returns undefined', async () => { + const payload = { type: 'address', formValues: address }; + const result = await submitInput(payload)(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('submitInput thunk dispatches actions on success', async () => { + const payload = { type: 'address', formValues: address }; + const response = true; + + request.mockResolvedValueOnce(response); + await submitInput(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, actions.input.submit(payload)); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(4, actions.input.accept(response)); + expect(dispatch).toHaveBeenCalledTimes(4); +}); + +test('submitInput thunk dispatches actions on failure', async () => { + const payload = { type: 'address', formValues: address }; + const error = new Error('ERROR'); + + request.mockRejectedValueOnce(error); + await submitInput(payload)(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, actions.input.submit(payload)); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(3, actions.input.reject(error)); + expect(dispatch).toHaveBeenCalledTimes(3); +}); + +test('submitInput thunk throws if there is no guest cart', async () => { + const payload = { type: 'address', formValues: address }; + + getState.mockImplementationOnce(() => ({ + cart: {}, + directory: { countries } + })); + + await expect(submitInput(payload)(...thunkArgs)).rejects.toThrow( + 'guestCartId' + ); +}); + +test('submitInput thunk throws if payload is invalid', async () => { + const payload = { type: 'address', formValues: {} }; + + await expect(submitInput(payload)(...thunkArgs)).rejects.toThrow(); + expect(dispatch).toHaveBeenNthCalledWith(1, actions.input.submit(payload)); + expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('submitOrder() returns a thunk', () => { + expect(submitOrder()).toBeInstanceOf(Function); +}); + +test('submitOrder thunk returns undefined', async () => { + const result = await submitOrder()(...thunkArgs); + + expect(result).toBeUndefined(); +}); + +test('submitOrder thunk dispatches actions on success', async () => { + const response = true; + + request.mockResolvedValueOnce(response); + await submitOrder()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, actions.order.submit()); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.order.accept(response)); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('submitOrder thunk dispatches actions on failure', async () => { + const error = new Error('ERROR'); + + request.mockRejectedValueOnce(error); + await submitOrder()(...thunkArgs); + + expect(dispatch).toHaveBeenNthCalledWith(1, actions.order.submit()); + expect(dispatch).toHaveBeenNthCalledWith(2, actions.order.reject(error)); + expect(dispatch).toHaveBeenCalledTimes(2); +}); + +test('submitOrder thunk throws if there is no guest cart', async () => { + getState.mockImplementationOnce(() => ({ + cart: {}, + directory: { countries } + })); + + await expect(submitOrder()(...thunkArgs)).rejects.toThrow('guestCartId'); +}); + +test('formatAddress throws if countries does not include country_id', () => { + const shouldThrow = () => formatAddress(); + + expect(shouldThrow).toThrow(); +}); + +test('formatAddress returns a new object', () => { + const result = formatAddress(address, countries); + + expect(result).toEqual(address); + expect(result).not.toBe(address); +}); + +test('formatAddress looks up and adds region data', () => { + const values = { region_code: address.region_code }; + const result = formatAddress(values, countries); + + expect(result).toHaveProperty('region', address.region); + expect(result).toHaveProperty('region_id', address.region_id); + expect(result).toHaveProperty('region_code', address.region_code); +}); + +test('formatAddress throws if country is not found', () => { + const shouldThrow = () => formatAddress(); + + expect(shouldThrow).toThrow('country'); +}); + +test('formatAddress throws if country contains no regions', () => { + const values = { region_code: address.region_code }; + const countries = [{ id: 'US' }]; + const shouldThrow = () => formatAddress(values, countries); + + expect(shouldThrow).toThrow('regions'); +}); + +test('formatAddress throws if region is not found', () => { + const values = { region_code: '|||' }; + const shouldThrow = () => formatAddress(values, countries); + + expect(shouldThrow).toThrow('region'); +}); diff --git a/packages/venia-concept/src/actions/checkout/actions.js b/packages/venia-concept/src/actions/checkout/actions.js new file mode 100644 index 0000000000..8cc83e12df --- /dev/null +++ b/packages/venia-concept/src/actions/checkout/actions.js @@ -0,0 +1,22 @@ +import { createActions } from 'redux-actions'; + +const prefix = 'CHECKOUT'; +const actionTypes = ['BEGIN', 'EDIT', 'RESET']; + +// classify action creators by domain +// e.g., `actions.order.submit` => CHECKOUT/ORDER/SUBMIT +// a `null` value corresponds to the default creator function +const actionMap = { + INPUT: { + SUBMIT: null, + ACCEPT: null, + REJECT: null + }, + ORDER: { + SUBMIT: null, + ACCEPT: null, + REJECT: null + } +}; + +export default createActions(actionMap, ...actionTypes, { prefix }); diff --git a/packages/venia-concept/src/actions/checkout.js b/packages/venia-concept/src/actions/checkout/asyncActions.js similarity index 77% rename from packages/venia-concept/src/actions/checkout.js rename to packages/venia-concept/src/actions/checkout/asyncActions.js index e7573c3dca..e722e20801 100644 --- a/packages/venia-concept/src/actions/checkout.js +++ b/packages/venia-concept/src/actions/checkout/asyncActions.js @@ -1,41 +1,17 @@ -import { createActions } from 'redux-actions'; import { RestApi } from '@magento/peregrine'; import { closeDrawer } from 'src/actions/app'; import { clearGuestCartId, getCartDetails } from 'src/actions/cart'; import { getCountries } from 'src/actions/directory'; - -const prefix = 'CHECKOUT'; -const actionTypes = ['EDIT', 'RESET']; - -// classify action creators by domain -// e.g., `actions.order.submit` => CHECKOUT/ORDER/SUBMIT -// a `null` value corresponds to the default creator function -const actionMap = { - CART: { - SUBMIT: null, - ACCEPT: null, - REJECT: null - }, - INPUT: { - SUBMIT: null, - ACCEPT: null, - REJECT: null - }, - ORDER: { - SUBMIT: null, - ACCEPT: null, - REJECT: null - } -}; - -const actions = createActions(actionMap, ...actionTypes, { prefix }); -export default actions; - -/* async action creators */ +import actions from './actions'; const { request } = RestApi.Magento2; +export const beginCheckout = () => + async function thunk(dispatch) { + dispatch(actions.begin()); + }; + export const resetCheckout = () => async function thunk(dispatch) { await dispatch(closeDrawer()); @@ -47,28 +23,27 @@ export const editOrder = section => dispatch(actions.edit(section)); }; -export const submitCart = () => - async function thunk(dispatch) { - dispatch(actions.cart.accept()); - }; - export const submitInput = payload => async function thunk(dispatch, getState) { - const { cart } = getState(); + dispatch(actions.input.submit(payload)); + await dispatch(getCountries()); + + const { cart, directory } = getState(); const { guestCartId } = cart; + const { countries } = directory; + let { formValues: address } = payload; if (!guestCartId) { throw new Error('Missing required information: guestCartId'); } - dispatch(actions.input.submit(payload)); - await dispatch(getCountries()); - - const { directory } = getState(); - const { countries } = directory; + try { + address = formatAddress(address, countries); + } catch (error) { + throw error; + } try { - const address = formatAddress(payload.formValues, countries); const response = await request( `/rest/V1/guest-carts/${guestCartId}/shipping-information`, { @@ -127,7 +102,7 @@ export const submitOrder = () => /* helpers */ -function formatAddress(address = {}, countries = []) { +export function formatAddress(address = {}, countries = []) { const country = countries.find(({ id }) => id === 'US'); if (!country) { @@ -135,7 +110,12 @@ function formatAddress(address = {}, countries = []) { } const { region_code } = address; - const regions = country.available_regions || []; + const { available_regions: regions } = country; + + if (!(Array.isArray(regions) && regions.length)) { + throw new Error('Country "US" does not contain any available regions.'); + } + const region = regions.find(({ code }) => code === region_code); if (!region) { diff --git a/packages/venia-concept/src/actions/checkout/index.js b/packages/venia-concept/src/actions/checkout/index.js new file mode 100644 index 0000000000..c6c32a70b8 --- /dev/null +++ b/packages/venia-concept/src/actions/checkout/index.js @@ -0,0 +1,2 @@ +export { default } from './actions'; +export * from './asyncActions'; diff --git a/packages/venia-concept/src/actions/directory/__tests__/actions.spec.js b/packages/venia-concept/src/actions/directory/__tests__/actions.spec.js new file mode 100644 index 0000000000..7172235d68 --- /dev/null +++ b/packages/venia-concept/src/actions/directory/__tests__/actions.spec.js @@ -0,0 +1,20 @@ +import actions from '../actions'; + +const payload = 'FOO'; +const error = new Error('BAR'); + +test('getCountries.toString() returns the proper action type', () => { + expect(actions.getCountries.toString()).toBe('DIRECTORY/GET_COUNTRIES'); +}); + +test('getCountries() returns a proper action object', () => { + expect(actions.getCountries(payload)).toEqual({ + type: 'DIRECTORY/GET_COUNTRIES', + payload + }); + expect(actions.getCountries(error)).toEqual({ + type: 'DIRECTORY/GET_COUNTRIES', + payload: error, + error: true + }); +}); diff --git a/packages/venia-concept/src/actions/directory/__tests__/asyncActions.spec.js b/packages/venia-concept/src/actions/directory/__tests__/asyncActions.spec.js new file mode 100644 index 0000000000..af5e044433 --- /dev/null +++ b/packages/venia-concept/src/actions/directory/__tests__/asyncActions.spec.js @@ -0,0 +1,71 @@ +import { RestApi } from '@magento/peregrine'; + +import { dispatch, getState } from 'src/store'; +import actions from '../actions'; +import { getCountries } from '../asyncActions'; + +jest.mock('src/store'); + +const thunkArgs = [dispatch, getState]; +const { request } = RestApi.Magento2; + +afterEach(() => { + dispatch.mockClear(); + request.mockClear(); +}); + +test('getCountries() to return a thunk', () => { + expect(getCountries()).toBeInstanceOf(Function); +}); + +test('getCountries thunk returns undefined', async () => { + const thunk = getCountries(); + + await expect(thunk(...thunkArgs)).resolves.toBeUndefined(); +}); + +test('getCountries thunk does nothing if data is present', async () => { + getState.mockImplementationOnce(() => ({ + directory: { + countries: [] + } + })); + + await getCountries()(...thunkArgs); + + expect(request).not.toHaveBeenCalled(); +}); + +test('getCountries thunk requests API data', async () => { + await getCountries()(...thunkArgs); + + expect(request).toHaveBeenCalled(); +}); + +test('getCountries thunk dispatches actions on success', async () => { + const response = 'FOO'; + + getState.mockImplementationOnce(() => ({ + directory: {} + })); + + request.mockResolvedValueOnce(response); + await getCountries()(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.getCountries(response)); + expect(dispatch).toHaveBeenCalledTimes(1); +}); + +test('getCountries thunk dispatches actions on failure', async () => { + const error = new Error('BAR'); + + getState.mockImplementationOnce(() => ({ + directory: {} + })); + + request.mockRejectedValueOnce(error); + await getCountries()(...thunkArgs); + + expect(dispatch).toHaveBeenCalledWith(actions.getCountries(error)); + expect(dispatch).toHaveBeenCalledTimes(1); +}); diff --git a/packages/venia-concept/src/actions/directory/actions.js b/packages/venia-concept/src/actions/directory/actions.js new file mode 100644 index 0000000000..4dc5e10e5b --- /dev/null +++ b/packages/venia-concept/src/actions/directory/actions.js @@ -0,0 +1,6 @@ +import { createActions } from 'redux-actions'; + +const prefix = 'DIRECTORY'; +const actionTypes = ['GET_COUNTRIES']; + +export default createActions(...actionTypes, { prefix }); diff --git a/packages/venia-concept/src/actions/directory.js b/packages/venia-concept/src/actions/directory/asyncActions.js similarity index 64% rename from packages/venia-concept/src/actions/directory.js rename to packages/venia-concept/src/actions/directory/asyncActions.js index d75783e5db..536ecb3c28 100644 --- a/packages/venia-concept/src/actions/directory.js +++ b/packages/venia-concept/src/actions/directory/asyncActions.js @@ -1,13 +1,6 @@ -import { createActions } from 'redux-actions'; import { RestApi } from '@magento/peregrine'; -const prefix = 'DIRECTORY'; -const actionTypes = ['GET_COUNTRIES']; - -const actions = createActions(...actionTypes, { prefix }); -export default actions; - -/* async action creators */ +import actions from './actions'; const { request } = RestApi.Magento2; @@ -15,7 +8,7 @@ export const getCountries = () => async function thunk(dispatch, getState) { const { directory } = getState(); - if (directory.countries) { + if (directory && directory.countries) { return; } diff --git a/packages/venia-concept/src/actions/directory/index.js b/packages/venia-concept/src/actions/directory/index.js new file mode 100644 index 0000000000..c6c32a70b8 --- /dev/null +++ b/packages/venia-concept/src/actions/directory/index.js @@ -0,0 +1,2 @@ +export { default } from './actions'; +export * from './asyncActions'; diff --git a/packages/venia-concept/src/components/Checkout/cart.js b/packages/venia-concept/src/components/Checkout/cart.js index cbaa290605..995a48a97c 100644 --- a/packages/venia-concept/src/components/Checkout/cart.js +++ b/packages/venia-concept/src/components/Checkout/cart.js @@ -7,16 +7,16 @@ import defaultClasses from './cart.css'; class Cart extends Component { static propTypes = { + beginCheckout: func.isRequired, classes: shape({ root: string }), ready: bool.isRequired, - submitCart: func.isRequired, submitting: bool.isRequired }; render() { - const { classes, ready, submitCart, submitting } = this.props; + const { beginCheckout, classes, ready, submitting } = this.props; return (