| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308 |
- /* eslint-env jest */
- import Vue from 'vue'
- import behavior, { cartTotals, paymentOrder } from '@/ui-refactor/views/guest-shop/guestPurchase'
- import * as shop from '@/api/shop'
- import { getGuestRegionList } from '@/api/config'
- jest.mock('@/api/shop', () => ({ fetchGuestProducts: jest.fn(), fetchGuestCart: jest.fn(), createGuestOrder: jest.fn() }))
- jest.mock('@/api/config', () => ({ getGuestRegionList: jest.fn() }))
- jest.mock('@/router', () => ({ push: jest.fn(), replace: jest.fn() }))
- const product = { ID: 'P1', GOODS_NAME: 'Product', SELL_PRICE: 100, DISCOUNT: 80, STORE_NUMS: 5, TAX_RATE: 7.5, COIN: 'NGN' }
- const instances = []
- const shipping = { consignee: 'Recipient', acceptMobile: '123456789', province: ['R1'], lgaName: 'District', cityName: 'City', detailaddress: 'Street', email: 'guest@example.test' }
- async function settle() { for (let i = 0; i < 12; i++) { await Promise.resolve(); await Vue.nextTick() } }
- async function instance(market = 'ng', stage = 'catalog') {
- const vm = new (Vue.extend(behavior))({ propsData: { market, stage }})
- vm.$router = { push: jest.fn(), replace: jest.fn() }
- vm.$message = { error: jest.fn(), info: jest.fn() }
- vm.$refs.paystack = { payWithPaystack: jest.fn() }
- instances.push(vm)
- await vm.reload()
- await settle()
- return vm
- }
- async function checkout() {
- const vm = await instance()
- vm.toggleProduct(vm.products[0])
- Object.assign(vm.form, shipping)
- vm.stage = 'checkout'
- await settle()
- vm.openEmail()
- return vm
- }
- beforeEach(() => {
- jest.clearAllMocks()
- shop.fetchGuestProducts.mockResolvedValue({ data: { list: [product], totalCount: 1 }})
- shop.fetchGuestCart.mockResolvedValue({ data: { sellType: [{ id: 1, sell_type: [{ label: 'pay_stack', name: 'Paystack' }] }], freight: 10, freeShipping: 500, coin: 'NGN' }})
- getGuestRegionList.mockResolvedValue({ data: [{ value: 'R1', label: 'Region' }] })
- shop.createGuestOrder.mockResolvedValue({ data: { SN: 'ORDER1', amount: 90, currency: 'NGN' }})
- })
- afterEach(() => { instances.splice(0).forEach(vm => vm.$destroy()) })
- it.each([['ng', '620699181539139625'], ['ae', '620699181539139643']])('loads %s using string country IDs on both anonymous endpoints', async(market, countryId) => {
- const vm = await instance(market)
- expect(shop.fetchGuestProducts).toHaveBeenLastCalledWith({ countryId, categoryType: 1, page: 1, limit: 50 })
- expect(shop.fetchGuestCart).toHaveBeenLastCalledWith({ countryId, categoryType: 1 })
- await vm.loadRegions()
- expect(getGuestRegionList).toHaveBeenLastCalledWith({ countryId })
- expect(vm.payType).toBe('pay_stack')
- })
- it.each([
- ['ng', 'NGN', 'AED', 'products'], ['ng', 'NGN', 'AED', 'settings'],
- ['ae', 'AED', 'NGN', 'products'], ['ae', 'AED', 'NGN', 'settings']
- ])('uses %s product currency %s over settings currency %s when %s arrives first', async(market, coin, settingsCoin, first) => {
- const resolve = {}
- shop.fetchGuestProducts.mockReturnValueOnce(new Promise(done => { resolve.products = done }))
- shop.fetchGuestCart.mockReturnValueOnce(new Promise(done => { resolve.settings = done }))
- const responses = {
- products: { data: { list: [{ ...product, COIN: coin }], totalCount: 1 }},
- settings: { data: { sellType: [{ id: 1, sell_type: [{ label: 'pay_stack', name: 'Paystack' }] }], freight: 10, freeShipping: 500, coin: settingsCoin }}
- }
- const loading = instance(market)
- resolve[first](responses[first])
- await settle()
- const last = first === 'products' ? 'settings' : 'products'
- resolve[last](responses[last])
- const vm = await loading
- expect(vm.currency).toBe(coin)
- expect(vm.displayProducts[0].CODE).toBe(coin)
- })
- it('calculates the legacy discount and shipping without adding included tax again', () => {
- expect(cartTotals([{ ...product, quantity: 2 }], 500, 10)).toMatchObject({ subtotal: 160, shipping: 10, total: 170, tax: 13.95 })
- expect(cartTotals([{ ...product, quantity: 5 }], 400, 10).total).toBe(400)
- })
- it('holds quantity and total invariants over a range of stock and prices', () => {
- for (let cents = 1; cents < 130; cents += 7) {
- const row = { ...product, SELL_PRICE: cents / 100, DISCOUNT: 100, quantity: 3 }
- const result = cartTotals([row], 0, 0)
- expect(result.total).toBe(Math.round(cents * 3) / 100)
- expect(result.tax).toBeGreaterThanOrEqual(0)
- expect(result.tax).toBeLessThanOrEqual(result.total)
- }
- })
- it.each([null, '', ' ', 0, -1, 'abc', Infinity, true, ['100'], 1e20])('rejects invalid payment amount %s', amount => {
- expect(() => paymentOrder({ SN: 'O1', amount, currency: 'NGN' })).toThrow()
- })
- it('converts the server major amount once, without imposing the catalog currency', () => {
- expect(paymentOrder({ SN: 'O1', amount: 0.29, currency: 'USD' })).toMatchObject({ SN: 'O1', amount: 0.29, currency: 'USD', subunits: 29 })
- })
- it.each([{}, { SN: 'O1', amount: 100 }, { amount: 100, currency: 'NGN' }])('rejects incomplete payment data %j', data => {
- expect(() => paymentOrder(data)).toThrow()
- })
- it('keeps selections across pages and rejects unavailable or fractional quantities', async() => {
- const vm = await instance()
- vm.toggleProduct(product)
- for (const value of [0, -1, 1.5, 6, NaN]) vm.changeQuantity({ row: product, value })
- expect(vm.items[0].quantity).toBe(1)
- vm.changeQuantity({ row: product, value: 2 })
- vm.products = [{ ...product, ID: 'P2' }]
- vm.toggleProduct(vm.products[0])
- expect(vm.items.map(item => item.ID)).toEqual(['P1', 'P2'])
- vm.toggleProduct({ ...product, ID: 'P3', STORE_NUMS: 0 })
- expect(vm.items).toHaveLength(2)
- })
- it('requires shipping and email before calling the order API, with no passwords or member identity', async() => {
- const vm = await instance()
- vm.toggleProduct(product)
- vm.stage = 'checkout'
- await settle()
- vm.openEmail()
- expect(vm.emailVisible).toBe(false)
- Object.assign(vm.form, shipping, { email: 'invalid' })
- vm.openEmail()
- await vm.continuePayment()
- expect(shop.createGuestOrder).not.toHaveBeenCalled()
- vm.form.email = shipping.email
- await vm.continuePayment()
- expect(shop.createGuestOrder).toHaveBeenCalledWith({ ...shipping, countryId: '620699181539139625', goodsId: ['P1'], goodsNum: [1], payType: 'pay_stack' })
- expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(1)
- })
- it('blocks duplicate submit and retains the same unpaid order after cancellation', async() => {
- const vm = await checkout()
- let resolve
- shop.createGuestOrder.mockReturnValue(new Promise(done => { resolve = done }))
- const first = vm.continuePayment()
- await vm.continuePayment()
- expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
- resolve({ data: { SN: 'ORDER1', amount: 90, currency: 'NGN' }})
- await first
- const callback = vm.paymentCallback
- vm.closePayment()
- expect(vm.pending.SN).toBe('ORDER1')
- vm.changeQuantity({ row: product, value: 2 })
- expect(vm.items[0].quantity).toBe(1)
- vm.openEmail()
- await vm.continuePayment()
- expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
- callback()
- expect(vm.$router.push).not.toHaveBeenCalled()
- vm.paymentCallback()
- vm.paymentCallback()
- expect(vm.$router.push).toHaveBeenCalledTimes(1)
- expect(vm.$router.push).toHaveBeenCalledWith({ path: '/guest-order', query: { order: 'ORDER1' }})
- })
- it('requires review of a changed server payment amount and currency before starting the SDK', async() => {
- const vm = await checkout()
- shop.createGuestOrder.mockResolvedValue({ data: { SN: 'ORDER1', amount: 10, currency: 'USD' }})
- await vm.continuePayment()
- expect(vm.pending.currency).toBe('USD')
- expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
- await vm.continuePayment()
- expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(1)
- expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
- })
- it.each([
- ['ng', '620699181539139625', '104', 'NGN'],
- ['ae', '620699181539139643', '148', 'AED']
- ])('starts and resumes %s payment using the returned order record and matching product currency', async(market, countryId, currencyId, currency) => {
- shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, LOCAL_CURRENCY_ID: currencyId, COIN: currency }], totalCount: 1 }})
- const vm = await instance(market)
- vm.toggleProduct(vm.products[0])
- Object.assign(vm.form, shipping)
- vm.stage = 'checkout'
- await settle()
- vm.openEmail()
- 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 }})
- await vm.continuePayment()
- expect(vm.pending).toEqual({ SN: 'RECORD1', amount: 90, currency, subunits: 9000 })
- expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(1)
- expect(vm.metadata.cart_id).toBe('RECORD1')
- expect(vm.form.email).toBe(shipping.email)
- expect(vm.$router.push).not.toHaveBeenCalled()
- vm.closePayment()
- vm.openEmail()
- await vm.continuePayment()
- expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
- expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(2)
- })
- it('accepts ORDER_AMOUNT when the returned order record has no PAY_AMOUNT', async() => {
- shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, LOCAL_CURRENCY_ID: '104' }], totalCount: 1 }})
- const vm = await checkout()
- shop.createGuestOrder.mockResolvedValue({ data: { SN: 'RECORD1', COUNTRY_ID: '620699181539139625', CURRENCY_ID: '104', ORDER_AMOUNT: 90 }})
- await vm.continuePayment()
- expect(vm.pending).toEqual({ SN: 'RECORD1', amount: 90, currency: 'NGN', subunits: 9000 })
- expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(1)
- })
- it('updates the email amount and requests confirmation when the order record changes the total', async() => {
- shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, LOCAL_CURRENCY_ID: '104' }], totalCount: 1 }})
- const vm = await checkout()
- shop.createGuestOrder.mockResolvedValue({ data: { SN: 'RECORD1', COUNTRY_ID: '620699181539139625', CURRENCY_ID: '104', PAY_AMOUNT: 95 }})
- await vm.continuePayment()
- expect(vm.pending.amount).toBe(95)
- expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
- await vm.continuePayment()
- expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(1)
- expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
- })
- it.each([
- ['620699181539139643', '104'], ['620699181539139625', '148'], ['620699181539139625', undefined]
- ])('retains the order without starting payment for unmatched country/currency %s / %s', async(countryId, currencyId) => {
- shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, LOCAL_CURRENCY_ID: '104' }], totalCount: 1 }})
- const vm = await checkout()
- shop.createGuestOrder.mockResolvedValue({ data: { SN: 'RECORD1', COUNTRY_ID: countryId, CURRENCY_ID: currencyId, PAY_AMOUNT: 90 }})
- await vm.continuePayment()
- expect(vm.pending.SN).toBe('RECORD1')
- expect(vm.sdkActive).toBe(false)
- expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
- await vm.continuePayment()
- expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
- expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
- })
- it.each([0, '', -1])('does not replace an invalid PAY_AMOUNT %s with another order amount', async(amount) => {
- shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, LOCAL_CURRENCY_ID: '104' }], totalCount: 1 }})
- const vm = await checkout()
- shop.createGuestOrder.mockResolvedValue({ data: { SN: 'RECORD1', COUNTRY_ID: '620699181539139625', CURRENCY_ID: '104', PAY_AMOUNT: amount, ORDER_AMOUNT: 90 }})
- await vm.continuePayment()
- expect(vm.pending.SN).toBe('RECORD1')
- expect(vm.pending.amount).toBe(amount)
- expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
- })
- it('recovers after a failed request without starting payment', async() => {
- const vm = await checkout()
- shop.createGuestOrder.mockRejectedValue(new Error('Unavailable'))
- await vm.continuePayment()
- expect(vm.submitting).toBe(false)
- expect(vm.pending).toBeNull()
- expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
- expect(vm.$message.error).toHaveBeenCalled()
- })
- it('routes non-Paystack successful orders directly to the public skeleton', async() => {
- const vm = await checkout()
- vm.payList = [{ label: 'transfer', name: 'Bank transfer' }]
- vm.payType = 'transfer'
- shop.createGuestOrder.mockResolvedValue({ data: { SN: 'BANK1' }})
- await vm.continuePayment()
- expect(vm.$router.push).toHaveBeenCalledWith({ path: '/guest-order', query: { order: 'BANK1' }})
- expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
- })
- it('ignores an order response after its country flow has been destroyed', async() => {
- const vm = await checkout()
- let resolve
- shop.createGuestOrder.mockReturnValue(new Promise(done => { resolve = done }))
- const submit = vm.continuePayment()
- vm.$destroy()
- resolve({ data: { SN: 'OLD', amount: 90, currency: 'NGN' }})
- await submit
- expect(vm.pending).toBeNull()
- expect(vm.$router.push).not.toHaveBeenCalled()
- })
- it('ignores product data from a destroyed country and the older of two pagination requests', async() => {
- const vm = await instance()
- let old
- shop.fetchGuestProducts.mockReturnValueOnce(new Promise(done => { old = done }))
- const first = vm.loadProducts()
- shop.fetchGuestProducts.mockResolvedValue({ data: { list: [{ ...product, ID: 'CURRENT' }], totalCount: 1 }})
- await vm.loadProducts()
- old({ data: { list: [{ ...product, ID: 'OLD' }], totalCount: 1 }})
- await first
- expect(vm.products[0].ID).toBe('CURRENT')
- let late
- shop.fetchGuestProducts.mockReturnValueOnce(new Promise(done => { late = done }))
- const loading = vm.loadProducts()
- vm.$destroy()
- late({ data: { list: [{ ...product, ID: 'OTHER-COUNTRY' }], totalCount: 1 }})
- await loading
- expect(vm.products[0].ID).toBe('CURRENT')
- })
- it('blocks checkout on failed settings and recovers by reloading', async() => {
- const vm = await instance()
- vm.toggleProduct(product)
- shop.fetchGuestCart.mockRejectedValueOnce(new Error('Shipping unavailable'))
- await vm.reload()
- expect(vm.canCheckout).toBe(false)
- expect(vm.settingsLoading).toBe(false)
- await vm.reload()
- expect(vm.canCheckout).toBe(true)
- })
- it('keeps an existing SN when payment fields are invalid and never creates a replacement on retry', async() => {
- const vm = await checkout()
- shop.createGuestOrder.mockResolvedValue({ data: { SN: 'CREATED', amount: null, currency: 'NGN' }})
- await vm.continuePayment()
- expect(vm.pending.SN).toBe('CREATED')
- expect(vm.submitting).toBe(false)
- expect(vm.sdkActive).toBe(false)
- await vm.continuePayment()
- expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
- expect(vm.$refs.paystack.payWithPaystack).not.toHaveBeenCalled()
- })
- it('restores a usable checkout after SDK failure and locks the created order fields', async() => {
- const vm = await checkout()
- await vm.continuePayment()
- vm.changeForm({ consignee: 'Changed' })
- vm.clearCart()
- vm.navigate('catalog')
- expect(vm.form.consignee).toBe('Recipient')
- expect(vm.items).toHaveLength(1)
- expect(vm.$router.push).not.toHaveBeenCalled()
- vm.paymentError(new Error('SDK unavailable'))
- expect(vm.sdkActive).toBe(false)
- expect(vm.pending.SN).toBe('ORDER1')
- await vm.continuePayment()
- expect(shop.createGuestOrder).toHaveBeenCalledTimes(1)
- expect(vm.$refs.paystack.payWithPaystack).toHaveBeenCalledTimes(2)
- })
- it('redirects a direct empty checkout to the empty public cart', async() => {
- const vm = await instance('ae', 'checkout')
- expect(vm.$router.replace).toHaveBeenCalledWith({ path: '/guest-shop/ae/cart' })
- })
|