guestShop.spec.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. /* eslint-env jest */
  2. import Vue from 'vue'
  3. import behavior, { cartTotals, paymentOrder } from '@/ui-refactor/views/guest-shop/guestPurchase'
  4. import * as shop from '@/api/shop'
  5. import { getGuestRegionList } from '@/api/config'
  6. jest.mock('@/api/shop', () => ({ fetchGuestProducts: jest.fn(), fetchGuestCart: jest.fn(), createGuestOrder: jest.fn() }))
  7. jest.mock('@/api/config', () => ({ getGuestRegionList: jest.fn() }))
  8. jest.mock('@/router', () => ({ push: jest.fn(), replace: jest.fn() }))
  9. const product = { ID: 'P1', GOODS_NAME: 'Product', SELL_PRICE: 100, DISCOUNT: 80, STORE_NUMS: 5, TAX_RATE: 7.5, COIN: 'NGN' }
  10. const instances = []
  11. const shipping = { consignee: 'Recipient', acceptMobile: '123456789', province: ['R1'], lgaName: 'District', cityName: 'City', detailaddress: 'Street', email: 'guest@example.test' }
  12. async function settle() { for (let i = 0; i < 12; i++) { await Promise.resolve(); await Vue.nextTick() } }
  13. async function instance(market = 'ng', stage = 'catalog') {
  14. const vm = new (Vue.extend(behavior))({ propsData: { market, stage }})
  15. vm.$router = { push: jest.fn(), replace: jest.fn() }
  16. vm.$message = { error: jest.fn(), info: jest.fn() }
  17. vm.$refs.paystack = { payWithPaystack: jest.fn() }
  18. instances.push(vm)
  19. await vm.reload()
  20. await settle()
  21. return vm
  22. }
  23. async function checkout() {
  24. const vm = await instance()
  25. vm.toggleProduct(vm.products[0])
  26. Object.assign(vm.form, shipping)
  27. vm.stage = 'checkout'
  28. await settle()
  29. vm.openEmail()
  30. return vm
  31. }
  32. beforeEach(() => {
  33. jest.clearAllMocks()
  34. shop.fetchGuestProducts.mockResolvedValue({ data: { list: [product], totalCount: 1 }})
  35. shop.fetchGuestCart.mockResolvedValue({ data: { sellType: [{ id: 1, sell_type: [{ label: 'pay_stack', name: 'Paystack' }] }], freight: 10, freeShipping: 500, coin: 'NGN' }})
  36. getGuestRegionList.mockResolvedValue({ data: [{ value: 'R1', label: 'Region' }] })
  37. shop.createGuestOrder.mockResolvedValue({ data: { SN: 'ORDER1', amount: 90, currency: 'NGN' }})
  38. })
  39. afterEach(() => { instances.splice(0).forEach(vm => vm.$destroy()) })
  40. it.each([['ng', '620699181539139625'], ['ae', '620699181539139643']])('loads %s using string country IDs on both anonymous endpoints', async(market, countryId) => {
  41. const vm = await instance(market)
  42. expect(shop.fetchGuestProducts).toHaveBeenLastCalledWith({ countryId, categoryType: 1, page: 1, limit: 50 })
  43. expect(shop.fetchGuestCart).toHaveBeenLastCalledWith({ countryId, categoryType: 1 })
  44. await vm.loadRegions()
  45. expect(getGuestRegionList).toHaveBeenLastCalledWith({ countryId })
  46. expect(vm.payType).toBe('pay_stack')
  47. })
  48. it.each([
  49. ['ng', 'NGN', 'AED', 'products'], ['ng', 'NGN', 'AED', 'settings'],
  50. ['ae', 'AED', 'NGN', 'products'], ['ae', 'AED', 'NGN', 'settings']
  51. ])('uses %s product currency %s over settings currency %s when %s arrives first', async(market, coin, settingsCoin, first) => {
  52. const resolve = {}
  53. shop.fetchGuestProducts.mockReturnValueOnce(new Promise(done => { resolve.products = done }))
  54. shop.fetchGuestCart.mockReturnValueOnce(new Promise(done => { resolve.settings = done }))
  55. const responses = {
  56. products: { data: { list: [{ ...product, COIN: coin }], totalCount: 1 }},
  57. settings: { data: { sellType: [{ id: 1, sell_type: [{ label: 'pay_stack', name: 'Paystack' }] }], freight: 10, freeShipping: 500, coin: settingsCoin }}
  58. }
  59. const loading = instance(market)
  60. resolve[first](responses[first])
  61. await settle()
  62. const last = first === 'products' ? 'settings' : 'products'
  63. resolve[last](responses[last])
  64. const vm = await loading
  65. expect(vm.currency).toBe(coin)
  66. expect(vm.displayProducts[0].CODE).toBe(coin)
  67. })
  68. it('calculates the legacy discount and shipping without adding included tax again', () => {
  69. expect(cartTotals([{ ...product, quantity: 2 }], 500, 10)).toMatchObject({ subtotal: 160, shipping: 10, total: 170, tax: 13.95 })
  70. expect(cartTotals([{ ...product, quantity: 5 }], 400, 10).total).toBe(400)
  71. })
  72. it('holds quantity and total invariants over a range of stock and prices', () => {
  73. for (let cents = 1; cents < 130; cents += 7) {
  74. const row = { ...product, SELL_PRICE: cents / 100, DISCOUNT: 100, quantity: 3 }
  75. const result = cartTotals([row], 0, 0)
  76. expect(result.total).toBe(Math.round(cents * 3) / 100)
  77. expect(result.tax).toBeGreaterThanOrEqual(0)
  78. expect(result.tax).toBeLessThanOrEqual(result.total)
  79. }
  80. })
  81. it.each([null, '', ' ', 0, -1, 'abc', Infinity, true, ['100'], 1e20])('rejects invalid payment amount %s', amount => {
  82. expect(() => paymentOrder({ SN: 'O1', amount, currency: 'NGN' })).toThrow()
  83. })
  84. it('converts the server major amount once, without imposing the catalog currency', () => {
  85. expect(paymentOrder({ SN: 'O1', amount: 0.29, currency: 'USD' })).toMatchObject({ SN: 'O1', amount: 0.29, currency: 'USD', subunits: 29 })
  86. })
  87. it.each([{}, { SN: 'O1', amount: 100 }, { amount: 100, currency: 'NGN' }])('rejects incomplete payment data %j', data => {
  88. expect(() => paymentOrder(data)).toThrow()
  89. })
  90. it('keeps selections across pages and rejects unavailable or fractional quantities', async() => {
  91. const vm = await instance()
  92. vm.toggleProduct(product)
  93. for (const value of [0, -1, 1.5, 6, NaN]) vm.changeQuantity({ row: product, value })
  94. expect(vm.items[0].quantity).toBe(1)
  95. vm.changeQuantity({ row: product, value: 2 })
  96. vm.products = [{ ...product, ID: 'P2' }]
  97. vm.toggleProduct(vm.products[0])
  98. expect(vm.items.map(item => item.ID)).toEqual(['P1', 'P2'])
  99. vm.toggleProduct({ ...product, ID: 'P3', STORE_NUMS: 0 })
  100. expect(vm.items).toHaveLength(2)
  101. })
  102. it('requires shipping and email before calling the order API, with no passwords or member identity', async() => {
  103. const vm = await instance()
  104. vm.toggleProduct(product)
  105. vm.stage = 'checkout'
  106. await settle()
  107. vm.openEmail()
  108. expect(vm.emailVisible).toBe(false)
  109. Object.assign(vm.form, shipping, { email: 'invalid' })
  110. vm.openEmail()
  111. await vm.continuePayment()
  112. expect(shop.createGuestOrder).not.toHaveBeenCalled()
  113. vm.form.email = shipping.email
  114. await vm.continuePayment()
  115. expect(shop.createGuestOrder).toHaveBeenCalledWith({ ...shipping, countryId: '620699181539139625', goodsId: ['P1'], goodsNum: [1], payType: 'pay_stack' })
  116. expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(1)
  117. })
  118. it('blocks duplicate submit and retains the same unpaid order after cancellation', async() => {
  119. const vm = await checkout()
  120. let resolve
  121. shop.createGuestOrder.mockReturnValue(new Promise(done => { resolve = done }))
  122. const first = vm.continuePayment()
  123. await vm.continuePayment()
  124. expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
  125. resolve({ data: { SN: 'ORDER1', amount: 90, currency: 'NGN' }})
  126. await first
  127. const callback = vm.paymentCallback
  128. vm.closePayment()
  129. expect(vm.pending.SN).toBe('ORDER1')
  130. vm.changeQuantity({ row: product, value: 2 })
  131. expect(vm.items[0].quantity).toBe(1)
  132. vm.openEmail()
  133. await vm.continuePayment()
  134. expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
  135. callback()
  136. expect(vm.$router.push).not.toHaveBeenCalled()
  137. vm.paymentCallback()
  138. vm.paymentCallback()
  139. expect(vm.$router.push).toHaveBeenCalledTimes(1)
  140. expect(vm.$router.push).toHaveBeenCalledWith({ path: '/guest-order', query: { order: 'ORDER1' }})
  141. })
  142. it('requires review of a changed server payment amount and currency before starting the SDK', async() => {
  143. const vm = await checkout()
  144. shop.createGuestOrder.mockResolvedValue({ data: { SN: 'ORDER1', amount: 10, currency: 'USD' }})
  145. await vm.continuePayment()
  146. expect(vm.pending.currency).toBe('USD')
  147. expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
  148. await vm.continuePayment()
  149. expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(1)
  150. expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
  151. })
  152. it.each([
  153. ['ng', '620699181539139625', '104', 'NGN'],
  154. ['ae', '620699181539139643', '148', 'AED']
  155. ])('starts and resumes %s payment using the returned order record and matching product currency', async(market, countryId, currencyId, currency) => {
  156. shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, LOCAL_CURRENCY_ID: currencyId, COIN: currency }], totalCount: 1 }})
  157. const vm = await instance(market)
  158. vm.toggleProduct(vm.products[0])
  159. Object.assign(vm.form, shipping)
  160. vm.stage = 'checkout'
  161. await settle()
  162. vm.openEmail()
  163. shop.createGuestOrder.mockResolvedValue({ data: { SN: 'RECORD1', DEC_SN: 'DECLARATION1', COUNTRY_ID: countryId, CURRENCY_ID: currencyId, PAY_AMOUNT: 90, ORDER_AMOUNT: 100, PAY_AMOUNT_STANDARD: 0.075, EXCHANGE_RATE: '1200', EMAIL: '', STATUS: 1 }})
  164. await vm.continuePayment()
  165. expect(vm.pending).toEqual({ SN: 'RECORD1', amount: 90, currency, subunits: 9000 })
  166. expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(1)
  167. expect(vm.metadata.cart_id).toBe('RECORD1')
  168. expect(vm.form.email).toBe(shipping.email)
  169. expect(vm.$router.push).not.toHaveBeenCalled()
  170. vm.closePayment()
  171. vm.openEmail()
  172. await vm.continuePayment()
  173. expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
  174. expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(2)
  175. })
  176. it('accepts ORDER_AMOUNT when the returned order record has no PAY_AMOUNT', async() => {
  177. shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, LOCAL_CURRENCY_ID: '104' }], totalCount: 1 }})
  178. const vm = await checkout()
  179. shop.createGuestOrder.mockResolvedValue({ data: { SN: 'RECORD1', COUNTRY_ID: '620699181539139625', CURRENCY_ID: '104', ORDER_AMOUNT: 90 }})
  180. await vm.continuePayment()
  181. expect(vm.pending).toEqual({ SN: 'RECORD1', amount: 90, currency: 'NGN', subunits: 9000 })
  182. expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(1)
  183. })
  184. it('updates the email amount and requests confirmation when the order record changes the total', async() => {
  185. shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, LOCAL_CURRENCY_ID: '104' }], totalCount: 1 }})
  186. const vm = await checkout()
  187. shop.createGuestOrder.mockResolvedValue({ data: { SN: 'RECORD1', COUNTRY_ID: '620699181539139625', CURRENCY_ID: '104', PAY_AMOUNT: 95 }})
  188. await vm.continuePayment()
  189. expect(vm.pending.amount).toBe(95)
  190. expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
  191. await vm.continuePayment()
  192. expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(1)
  193. expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
  194. })
  195. it.each([
  196. ['620699181539139643', '104'], ['620699181539139625', '148'], ['620699181539139625', undefined]
  197. ])('retains the order without starting payment for unmatched country/currency %s / %s', async(countryId, currencyId) => {
  198. shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, LOCAL_CURRENCY_ID: '104' }], totalCount: 1 }})
  199. const vm = await checkout()
  200. shop.createGuestOrder.mockResolvedValue({ data: { SN: 'RECORD1', COUNTRY_ID: countryId, CURRENCY_ID: currencyId, PAY_AMOUNT: 90 }})
  201. await vm.continuePayment()
  202. expect(vm.pending.SN).toBe('RECORD1')
  203. expect(vm.sdkActive).toBe(false)
  204. expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
  205. await vm.continuePayment()
  206. expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
  207. expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
  208. })
  209. it.each([0, '', -1])('does not replace an invalid PAY_AMOUNT %s with another order amount', async(amount) => {
  210. shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, LOCAL_CURRENCY_ID: '104' }], totalCount: 1 }})
  211. const vm = await checkout()
  212. shop.createGuestOrder.mockResolvedValue({ data: { SN: 'RECORD1', COUNTRY_ID: '620699181539139625', CURRENCY_ID: '104', PAY_AMOUNT: amount, ORDER_AMOUNT: 90 }})
  213. await vm.continuePayment()
  214. expect(vm.pending.SN).toBe('RECORD1')
  215. expect(vm.pending.amount).toBe(amount)
  216. expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
  217. })
  218. it('recovers after a failed request without starting payment', async() => {
  219. const vm = await checkout()
  220. shop.createGuestOrder.mockRejectedValue(new Error('Unavailable'))
  221. await vm.continuePayment()
  222. expect(vm.submitting).toBe(false)
  223. expect(vm.pending).toBeNull()
  224. expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
  225. expect(vm.$message.error).toHaveBeenCalled()
  226. })
  227. it('routes non-Paystack successful orders directly to the public skeleton', async() => {
  228. const vm = await checkout()
  229. vm.payList = [{ label: 'transfer', name: 'Bank transfer' }]
  230. vm.payType = 'transfer'
  231. shop.createGuestOrder.mockResolvedValue({ data: { SN: 'BANK1' }})
  232. await vm.continuePayment()
  233. expect(vm.$router.push).toHaveBeenCalledWith({ path: '/guest-order', query: { order: 'BANK1' }})
  234. expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
  235. })
  236. it('ignores an order response after its country flow has been destroyed', async() => {
  237. const vm = await checkout()
  238. let resolve
  239. shop.createGuestOrder.mockReturnValue(new Promise(done => { resolve = done }))
  240. const submit = vm.continuePayment()
  241. vm.$destroy()
  242. resolve({ data: { SN: 'OLD', amount: 90, currency: 'NGN' }})
  243. await submit
  244. expect(vm.pending).toBeNull()
  245. expect(vm.$router.push).not.toHaveBeenCalled()
  246. })
  247. it('ignores product data from a destroyed country and the older of two pagination requests', async() => {
  248. const vm = await instance()
  249. let old
  250. shop.fetchGuestProducts.mockReturnValueOnce(new Promise(done => { old = done }))
  251. const first = vm.loadProducts()
  252. shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, ID: 'CURRENT' }], totalCount: 1 }})
  253. await vm.loadProducts()
  254. old({ data: { list: [{ ...product, ID: 'OLD' }], totalCount: 1 }})
  255. await first
  256. expect(vm.products[0].ID).toBe('CURRENT')
  257. let late
  258. shop.fetchGuestProducts.mockReturnValueOnce(new Promise(done => { late = done }))
  259. const loading = vm.loadProducts()
  260. vm.$destroy()
  261. late({ data: { list: [{ ...product, ID: 'OTHER-COUNTRY' }], totalCount: 1 }})
  262. await loading
  263. expect(vm.products[0].ID).toBe('CURRENT')
  264. })
  265. it('blocks checkout on failed settings and recovers by reloading', async() => {
  266. const vm = await instance()
  267. vm.toggleProduct(product)
  268. shop.fetchGuestCart.mockRejectedValueOnce(new Error('Shipping unavailable'))
  269. await vm.reload()
  270. expect(vm.canCheckout).toBe(false)
  271. expect(vm.settingsLoading).toBe(false)
  272. await vm.reload()
  273. expect(vm.canCheckout).toBe(true)
  274. })
  275. it('keeps an existing SN when payment fields are invalid and never creates a replacement on retry', async() => {
  276. const vm = await checkout()
  277. shop.createGuestOrder.mockResolvedValue({ data: { SN: 'CREATED', amount: null, currency: 'NGN' }})
  278. await vm.continuePayment()
  279. expect(vm.pending.SN).toBe('CREATED')
  280. expect(vm.submitting).toBe(false)
  281. expect(vm.sdkActive).toBe(false)
  282. await vm.continuePayment()
  283. expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
  284. expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
  285. })
  286. it('restores a usable checkout after SDK failure and locks the created order fields', async() => {
  287. const vm = await checkout()
  288. await vm.continuePayment()
  289. vm.changeForm({ consignee: 'Changed' })
  290. vm.clearCart()
  291. vm.navigate('catalog')
  292. expect(vm.form.consignee).toBe('Recipient')
  293. expect(vm.items).toHaveLength(1)
  294. expect(vm.$router.push).not.toHaveBeenCalled()
  295. vm.paymentError(new Error('SDK unavailable'))
  296. expect(vm.sdkActive).toBe(false)
  297. expect(vm.pending.SN).toBe('ORDER1')
  298. await vm.continuePayment()
  299. expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
  300. expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(2)
  301. })
  302. it('redirects a direct empty checkout to the empty public cart', async() => {
  303. const vm = await instance('ae', 'checkout')
  304. expect(vm.$router.replace).toHaveBeenCalledWith({ path: '/guest-shop/ae/cart' })
  305. })