diff --git a/src/app/analytics/impact.service.test.ts b/src/app/analytics/impact.service.test.ts index bb90e177b..495fb3c97 100644 --- a/src/app/analytics/impact.service.test.ts +++ b/src/app/analytics/impact.service.test.ts @@ -51,7 +51,7 @@ const promoCode = { amountOff: undefined, codeId: 'promo_123', percentOff: 99, - codeName: 'PROMO', + codeName: 'CNINTERNXTL', }; const product = { @@ -106,7 +106,7 @@ beforeEach(() => { describe('Testing Impact Service', () => { describe('savePaymentDataInLocalStorage', () => { - it('should save the correct amount to localStorage after applying coupon', () => { + it('When a coupon is applied, then it saves the discounted amount to localStorage', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); savePaymentDataInLocalStorage({ @@ -121,7 +121,7 @@ describe('Testing Impact Service', () => { expect(setToLocalStorageSpy).toHaveBeenCalledWith('amountPaid', expectedAmount); }); - it('should save subscription ID when plan is not lifetime', () => { + it('When the plan is not lifetime, then it saves the subscription ID to localStorage', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); savePaymentDataInLocalStorage({ @@ -136,7 +136,7 @@ describe('Testing Impact Service', () => { expect(setToLocalStorageSpy).toHaveBeenCalledWith('subscriptionId', subId); }); - it('should save payment intent ID when plan is lifetime', () => { + it('When the plan is lifetime, then it saves the payment intent ID to localStorage', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); const lifetimeProduct = { ...product, @@ -155,7 +155,7 @@ describe('Testing Impact Service', () => { expect(setToLocalStorageSpy).toHaveBeenCalledWith('paymentIntentId', paymentIntentId); }); - it('should save product metadata including name, price ID, and currency', () => { + it('When saving payment data, then it saves the product name, price ID, and currency to localStorage', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); savePaymentDataInLocalStorage({ @@ -172,7 +172,7 @@ describe('Testing Impact Service', () => { expect(setToLocalStorageSpy).toHaveBeenCalledWith('currency', product.price.currency); }); - it('should save coupon code when provided', () => { + it('When a coupon code is provided, then it saves the coupon code to localStorage', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); savePaymentDataInLocalStorage({ @@ -187,7 +187,7 @@ describe('Testing Impact Service', () => { expect(setToLocalStorageSpy).toHaveBeenCalledWith('couponCode', promoCode.codeName); }); - it('should save isFirstPurchase flag to localStorage', () => { + it('When saving payment data, then it saves the isFirstPurchase flag to localStorage', () => { const setToLocalStorageSpy = vi.spyOn(localStorageService, 'set'); savePaymentDataInLocalStorage({ @@ -205,7 +205,7 @@ describe('Testing Impact Service', () => { describe('trackSignUp', () => { describe('gtag tracking', () => { - it('should send User Signup event to gtag', async () => { + it('When trackSignUp is called, then it sends a User Signup event to gtag', async () => { const gTagSpy = vi.spyOn(globalThis.window, 'gtag'); await trackSignUp(mockedUserUuid); @@ -213,7 +213,7 @@ describe('Testing Impact Service', () => { expect(gTagSpy).toHaveBeenCalledWith('event', 'User Signup'); }); - it('should report error when gtag fails but continue execution', async () => { + it('When gtag throws an error, then it reports the error and continues execution', async () => { const unknownError = new Error('gtag Error'); const gTagSpy = vi.spyOn(globalThis.window, 'gtag').mockImplementation(() => { throw unknownError; @@ -228,7 +228,7 @@ describe('Testing Impact Service', () => { }); describe('Impact API tracking', () => { - it('should send signup event to Impact API with correct payload', async () => { + it('When trackSignUp is called, then it sends a signup event to the Impact API with the correct payload', async () => { const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({}); await trackSignUp(mockedUserUuid); @@ -246,7 +246,7 @@ describe('Testing Impact Service', () => { ); }); - it('should include message ID in Impact API payload', async () => { + it('When trackSignUp is called, then it includes the message ID in the Impact API payload', async () => { const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({}); await trackSignUp(mockedUserUuid); @@ -256,7 +256,7 @@ describe('Testing Impact Service', () => { expect(callArgs.messageId).toBe(mockedUserUuid); }); - it('should not send to Impact API when source is direct', async () => { + it('When the source is direct, then it does not send to the Impact API', async () => { const getCookieMock = await import('./utils'); vi.mocked(getCookieMock.getCookie).mockImplementation((key) => { if (key === 'impactSource') return 'direct'; @@ -274,7 +274,7 @@ describe('Testing Impact Service', () => { describe('trackPaymentConversion', () => { describe('Impact API tracking', () => { - it('should send payment conversion to Impact API with correct data', async () => { + it('When trackPaymentConversion is called, then it sends the conversion to the Impact API with the correct data', async () => { const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({}); await trackPaymentConversion(); @@ -298,7 +298,7 @@ describe('Testing Impact Service', () => { ); }); - it('should use minimum value of 0.01 when amount is 0 (free purchase)', async () => { + it('When the amount paid is 0, then it uses 0.01 as the minimum impact value', async () => { vi.spyOn(localStorageService, 'get').mockImplementation((key) => { if (key === 'amountPaid') return '0'; if (key === 'subscriptionId') return subId; @@ -314,7 +314,7 @@ describe('Testing Impact Service', () => { expect(callArgs.properties.impact_value).toBe(0.01); }); - it('should include coupon code in properties when available', async () => { + it('When a coupon code is available, then it includes it in the Impact API properties', async () => { const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({}); await trackPaymentConversion(); @@ -323,7 +323,7 @@ describe('Testing Impact Service', () => { expect(callArgs.properties).toHaveProperty('order_promo_code', promoCode.codeName); }); - it('should report error when Impact API call fails', async () => { + it('When the Impact API call fails, then it reports the error', async () => { const unknownError = new Error('API Error'); const axiosSpy = vi.spyOn(axios, 'post').mockRejectedValue(unknownError); const errorServiceSpy = vi.spyOn(errorService, 'reportError'); @@ -334,7 +334,7 @@ describe('Testing Impact Service', () => { expect(errorServiceSpy).toHaveBeenCalledWith(unknownError); }); - it('should not send to Impact when source is direct and no coupon code', async () => { + it('When the source is direct and no coupon code is present, then it does not send to Impact', async () => { const getCookieMock = await import('./utils'); vi.mocked(getCookieMock.getCookie).mockImplementation((key) => { if (key === 'impactSource') return 'direct'; @@ -353,7 +353,50 @@ describe('Testing Impact Service', () => { expect(axiosSpy).not.toHaveBeenCalled(); }); - it('should not send to Impact when isFirstPurchase is false', async () => { + it('When the source is direct and the coupon code is not whitelisted, then it does not send to Impact', async () => { + const getCookieMock = await import('./utils'); + vi.mocked(getCookieMock.getCookie).mockImplementation((key) => { + if (key === 'impactSource') return 'direct'; + return ''; + }); + vi.spyOn(localStorageService, 'get').mockImplementation((key) => { + if (key === 'couponCode') return 'NOT_WHITELISTED'; + if (key === 'amountPaid') return expectedAmount; + if (key === 'isFirstPurchase') return 'true'; + return null; + }); + const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({}); + + await trackPaymentConversion(); + + expect(axiosSpy).not.toHaveBeenCalled(); + }); + + it('When the source is direct but the coupon code is whitelisted, then it sends to Impact', async () => { + const getCookieMock = await import('./utils'); + vi.mocked(getCookieMock.getCookie).mockImplementation((key) => { + if (key === 'impactSource') return 'direct'; + if (key === 'impactAnonymousId') return ''; // Empty, so it uses uuidV4 + return ''; + }); + vi.spyOn(localStorageService, 'get').mockImplementation((key) => { + if (key === 'couponCode') return 'CNINTERNXT'; // In whitelist + if (key === 'amountPaid') return expectedAmount; + if (key === 'subscriptionId') return subId; + if (key === 'isFirstPurchase') return 'true'; + return null; + }); + const axiosSpy = vi.spyOn(axios, 'post').mockResolvedValue({}); + + await trackPaymentConversion(); + + expect(axiosSpy).toHaveBeenCalledTimes(1); + const callArgs = axiosSpy.mock.calls[0][1] as { properties: Record; anonymousId: string }; + expect(callArgs.properties).toHaveProperty('order_promo_code', 'CNINTERNXT'); + expect(callArgs.anonymousId).toBe(mockedUserUuid); // Fallback to uuidV4 + }); + + it('When isFirstPurchase is false, then it does not send to Impact', async () => { vi.spyOn(localStorageService, 'get').mockImplementation((key) => { if (key === 'isFirstPurchase') return 'false'; if (key === 'amountPaid') return expectedAmount; @@ -369,7 +412,7 @@ describe('Testing Impact Service', () => { }); describe('Error handling', () => { - it('should handle missing user settings gracefully', async () => { + it('When user settings are missing, then it resolves without throwing', async () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(localStorageService, 'getUser').mockReturnValue(null); @@ -378,13 +421,13 @@ describe('Testing Impact Service', () => { consoleWarnSpy.mockRestore(); }); - it('should continue execution when gtag is not available', async () => { + it('When gtag is not available, then it continues execution without throwing', async () => { globalThis.window.gtag = undefined as any; await expect(trackPaymentConversion()).resolves.not.toThrow(); }); - it('should handle errors in entire function gracefully', async () => { + it('When an unexpected error occurs, then it resolves without throwing', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(localStorageService, 'getUser').mockImplementation(() => { throw new Error('Storage Error'); @@ -399,7 +442,7 @@ describe('Testing Impact Service', () => { }); describe('uuid library', () => { - it('v4 generates a valid UUID', async () => { + it('When calling v4, then it generates a valid UUID', async () => { const { v4 } = await vi.importActual('uuid'); const id = v4(); expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); diff --git a/src/app/analytics/impact.service.ts b/src/app/analytics/impact.service.ts index db1059d61..a1a20e605 100644 --- a/src/app/analytics/impact.service.ts +++ b/src/app/analytics/impact.service.ts @@ -128,10 +128,13 @@ export async function trackPaymentConversion(): Promise { } const IMPACT_API = envService.getVariable('impactApiUrl'); - const anonymousID = getCookie('impactAnonymousId'); + const anonymousID = getCookie('impactAnonymousId') || uuidV4(); const source = getCookie('impactSource'); - if (isFirstPurchase && ((source && source !== 'direct') || couponCode)) { + const IMPACT_COUPON_WHITELIST = ['CNINTERNXT', 'CNINTERNXTL', 'CLOUDOFF']; + const isImpactCoupon = couponCode && IMPACT_COUPON_WHITELIST.includes(couponCode.toUpperCase()); + + if (isFirstPurchase && ((source && source !== 'direct') || isImpactCoupon)) { try { await axios.post(IMPACT_API, { anonymousId: anonymousID,