diff --git a/packages/fxa-auth-server/jest.config.js b/packages/fxa-auth-server/jest.config.js index 2d7a2f86539..bee5880f336 100644 --- a/packages/fxa-auth-server/jest.config.js +++ b/packages/fxa-auth-server/jest.config.js @@ -6,6 +6,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', rootDir: '.', + modulePathIgnorePatterns: ['/dist/'], testMatch: [ '/lib/**/*.spec.ts', '/config/**/*.spec.ts', @@ -29,7 +30,7 @@ module.exports = { maxWorkers: 4, clearMocks: true, workerIdleMemoryLimit: '512MB', - setupFiles: ['/jest.setup.js', '/jest.setup-proxyquire.js'], + setupFiles: ['/jest.setup.js', '/jest.setup-resolve.js'], testPathIgnorePatterns: ['\\.in\\.spec\\.ts$'], // Coverage configuration (enabled via --coverage flag) collectCoverageFrom: [ diff --git a/packages/fxa-auth-server/jest.setup-proxyquire.js b/packages/fxa-auth-server/jest.setup-resolve.js similarity index 66% rename from packages/fxa-auth-server/jest.setup-proxyquire.js rename to packages/fxa-auth-server/jest.setup-resolve.js index 87ccb36fc8a..76a3a4ba206 100644 --- a/packages/fxa-auth-server/jest.setup-proxyquire.js +++ b/packages/fxa-auth-server/jest.setup-resolve.js @@ -3,14 +3,15 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** - * Fix for proxyquire not finding .ts files under Jest/ts-jest. - * proxyquire uses resolve.sync() which defaults to only .js extensions. + * Fix for module resolution not finding .ts files under Jest/ts-jest. + * resolve.sync() defaults to only .js extensions, which breaks jest.mock() + * for modules that resolve to .ts index files (e.g. fxa-shared/db/models/auth). * This patches resolve.sync to also look for .ts files. */ const resolve = require('resolve'); const originalSync = resolve.sync; -resolve.sync = function(id, opts) { +resolve.sync = function (id, opts) { opts = opts || {}; if (!opts.extensions || opts.extensions.length === 0) { opts.extensions = ['.ts', '.tsx', '.js', '.json', '.node']; diff --git a/packages/fxa-auth-server/lib/account-delete.spec.ts b/packages/fxa-auth-server/lib/account-delete.spec.ts index d04dfc29acc..ab90af89e0d 100644 --- a/packages/fxa-auth-server/lib/account-delete.spec.ts +++ b/packages/fxa-auth-server/lib/account-delete.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import Container from 'typedi'; import { v4 as uuidv4 } from 'uuid'; import { AppError } from '@fxa/accounts/errors'; @@ -13,7 +12,7 @@ import { AppConfig, AuthLogger } from './types'; const mocks = require('../test/mocks'); -const isActiveStub = sinon.stub(); +const isActiveStub = jest.fn(); jest.mock('fxa-shared/db/models/auth', () => ({})); jest.mock('./inactive-accounts', () => { @@ -36,8 +35,6 @@ const expectedSubscriptions = [ const deleteReason = 'fxa_user_requested_account_delete'; describe('AccountDeleteManager', () => { - const sandbox = sinon.createSandbox(); - let mockFxaDb: any; let mockOAuthDb: any; let mockPush: any; @@ -59,17 +56,17 @@ describe('AccountDeleteManager', () => { const { StripeHelper } = require('./payments/stripe'); const { AccountDeleteManager } = require('./account-delete'); - sandbox.reset(); - isActiveStub.reset(); + jest.clearAllMocks(); + isActiveStub.mockReset(); // Set up mock auth models on the mocked module const authModels = require('fxa-shared/db/models/auth'); mockAuthModels = authModels; - mockAuthModels.getAllPayPalBAByUid = sinon.spy(async () => { + mockAuthModels.getAllPayPalBAByUid = jest.fn(async () => { return [{ status: 'Active', billingAgreementId: 'B-test' }]; }); - mockAuthModels.deleteAllPayPalBAs = sinon.spy(async () => {}); - mockAuthModels.getAccountCustomerByUid = sinon.spy( + mockAuthModels.deleteAllPayPalBAs = jest.fn(async () => {}); + mockAuthModels.getAccountCustomerByUid = jest.fn( async (..._args: any[]) => { return { stripeCustomerId: 'cus_993' }; } @@ -77,26 +74,26 @@ describe('AccountDeleteManager', () => { mockFxaDb = { ...mocks.mockDB({ email, emailVerified: true, uid }), - fetchAccountSubscriptions: sinon.spy( + fetchAccountSubscriptions: jest.fn( async (_uid: string) => expectedSubscriptions ), }; mockOAuthDb = {}; mockPush = mocks.mockPush(); mockPushbox = mocks.mockPushbox(); - mockStatsd = { increment: sandbox.stub() }; + mockStatsd = { increment: jest.fn() }; mockGlean = mocks.mockGlean(); mockMailer = mocks.mockMailer(); mockStripeHelper = {}; mockLog = mocks.mockLog(); mockAppleIap = { purchaseManager: { - deletePurchases: sinon.fake.resolves(undefined), + deletePurchases: jest.fn().mockResolvedValue(undefined), }, }; mockPlayBilling = { purchaseManager: { - deletePurchases: sinon.fake.resolves(undefined), + deletePurchases: jest.fn().mockResolvedValue(undefined), }, }; @@ -118,19 +115,23 @@ describe('AccountDeleteManager', () => { 'removeCustomer', 'removeFirestoreCustomer', ]); - mockStripeHelper.removeCustomer = sandbox.stub().resolves(); - mockStripeHelper.removeFirestoreCustomer = sandbox.stub().resolves(); - mockStripeHelper.fetchInvoicesForActiveSubscriptions = sandbox - .stub() - .resolves(); - mockStripeHelper.refundInvoices = sandbox.stub().resolves(); + mockStripeHelper.removeCustomer = jest.fn().mockResolvedValue(undefined); + mockStripeHelper.removeFirestoreCustomer = jest + .fn() + .mockResolvedValue(undefined); + mockStripeHelper.fetchInvoicesForActiveSubscriptions = jest + .fn() + .mockResolvedValue(undefined); + mockStripeHelper.refundInvoices = jest.fn().mockResolvedValue(undefined); mockPaypalHelper = mocks.mockPayPalHelper(['cancelBillingAgreement']); - mockPaypalHelper.cancelBillingAgreement = sandbox.stub().resolves(); - mockPaypalHelper.refundInvoices = sandbox.stub().resolves(); + mockPaypalHelper.cancelBillingAgreement = jest + .fn() + .mockResolvedValue(undefined); + mockPaypalHelper.refundInvoices = jest.fn().mockResolvedValue(undefined); mockOAuthDb = { - removeTokensAndCodes: sinon.fake.resolves(undefined), - removePublicAndCanGrantTokens: sinon.fake.resolves(undefined), + removeTokensAndCodes: jest.fn().mockResolvedValue(undefined), + removePublicAndCanGrantTokens: jest.fn().mockResolvedValue(undefined), }; Container.set(StripeHelper, mockStripeHelper); @@ -155,7 +156,7 @@ describe('AccountDeleteManager', () => { afterEach(() => { Container.reset(); - sandbox.reset(); + jest.clearAllMocks(); }); it('can be instantiated', () => { @@ -164,46 +165,53 @@ describe('AccountDeleteManager', () => { describe('delete account', () => { it('should delete the account', async () => { - mockPush.notifyAccountDestroyed = sinon.fake.resolves(undefined); - mockFxaDb.devices = sinon.fake.resolves(['test123', 'test456']); + mockPush.notifyAccountDestroyed = jest.fn().mockResolvedValue(undefined); + mockFxaDb.devices = jest.fn().mockResolvedValue(['test123', 'test456']); await accountDeleteManager.deleteAccount(uid, deleteReason); - sinon.assert.calledWithMatch(mockFxaDb.deleteAccount, { uid }); - sinon.assert.calledOnceWithExactly(mockStripeHelper.removeCustomer, uid, { + expect(mockFxaDb.deleteAccount).toHaveBeenCalledWith( + expect.objectContaining({ uid }) + ); + expect(mockStripeHelper.removeCustomer).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.removeCustomer).toHaveBeenCalledWith(uid, { cancellation_reason: deleteReason, }); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.removeFirestoreCustomer, + expect(mockStripeHelper.removeFirestoreCustomer).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.removeFirestoreCustomer).toHaveBeenCalledWith( uid ); - sinon.assert.calledOnceWithExactly( - mockAuthModels.getAllPayPalBAByUid, - uid - ); - sinon.assert.calledOnceWithExactly( - mockPaypalHelper.cancelBillingAgreement, + expect(mockAuthModels.getAllPayPalBAByUid).toHaveBeenCalledTimes(1); + expect(mockAuthModels.getAllPayPalBAByUid).toHaveBeenCalledWith(uid); + expect(mockPaypalHelper.cancelBillingAgreement).toHaveBeenCalledTimes(1); + expect(mockPaypalHelper.cancelBillingAgreement).toHaveBeenCalledWith( 'B-test' ); - sinon.assert.calledOnceWithExactly( - mockAuthModels.deleteAllPayPalBAs, + expect(mockAuthModels.deleteAllPayPalBAs).toHaveBeenCalledTimes(1); + expect(mockAuthModels.deleteAllPayPalBAs).toHaveBeenCalledWith(uid); + expect( + mockAppleIap.purchaseManager.deletePurchases + ).toHaveBeenCalledTimes(1); + expect(mockAppleIap.purchaseManager.deletePurchases).toHaveBeenCalledWith( uid ); - sinon.assert.calledOnceWithExactly( - mockAppleIap.purchaseManager.deletePurchases, - uid - ); - sinon.assert.calledOnceWithExactly( - mockPlayBilling.purchaseManager.deletePurchases, - uid - ); - sinon.assert.calledOnceWithExactly(mockPush.notifyAccountDestroyed, uid, [ + expect( + mockPlayBilling.purchaseManager.deletePurchases + ).toHaveBeenCalledTimes(1); + expect( + mockPlayBilling.purchaseManager.deletePurchases + ).toHaveBeenCalledWith(uid); + expect(mockPush.notifyAccountDestroyed).toHaveBeenCalledTimes(1); + expect(mockPush.notifyAccountDestroyed).toHaveBeenCalledWith(uid, [ 'test123', 'test456', ]); - sinon.assert.calledOnceWithExactly(mockPushbox.deleteAccount, uid); - sinon.assert.calledOnceWithExactly(mockOAuthDb.removeTokensAndCodes, uid); - sinon.assert.calledOnceWithExactly(mockLog.activityEvent, { + expect(mockPushbox.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockPushbox.deleteAccount).toHaveBeenCalledWith(uid); + expect(mockOAuthDb.removeTokensAndCodes).toHaveBeenCalledTimes(1); + expect(mockOAuthDb.removeTokensAndCodes).toHaveBeenCalledWith(uid); + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + expect(mockLog.activityEvent).toHaveBeenCalledWith({ uid, email, emailVerified: true, @@ -213,24 +221,28 @@ describe('AccountDeleteManager', () => { it('should delete even if already deleted from fxa db', async () => { const unknownError = AppError.unknownAccount('test@email.com'); - mockFxaDb.account = sinon.fake.rejects(unknownError); - mockPush.notifyAccountDestroyed = sinon.fake.resolves(undefined); + mockFxaDb.account = jest.fn().mockRejectedValue(unknownError); + mockPush.notifyAccountDestroyed = jest.fn().mockResolvedValue(undefined); await accountDeleteManager.deleteAccount(uid, deleteReason); - sinon.assert.calledWithMatch(mockStripeHelper.removeCustomer, uid); - sinon.assert.callCount(mockPush.notifyAccountDestroyed, 0); - sinon.assert.callCount(mockFxaDb.deleteAccount, 0); - sinon.assert.callCount(mockLog.activityEvent, 0); + expect(mockStripeHelper.removeCustomer).toHaveBeenNthCalledWith( + 1, + uid, + expect.anything() + ); + expect(mockPush.notifyAccountDestroyed).toHaveBeenCalledTimes(0); + expect(mockFxaDb.deleteAccount).toHaveBeenCalledTimes(0); + expect(mockLog.activityEvent).toHaveBeenCalledTimes(0); }); it('does not fail if pushbox fails to delete', async () => { - mockPushbox.deleteAccount = sinon.fake.rejects(undefined); + mockPushbox.deleteAccount = jest.fn().mockRejectedValue(undefined); await expect( accountDeleteManager.deleteAccount(uid, deleteReason) ).resolves.not.toThrow(); }); it('should fail if stripeHelper update customer fails', async () => { - mockStripeHelper.removeCustomer(async () => { + mockStripeHelper.removeCustomer.mockImplementation(async () => { throw new Error('wibble'); }); try { @@ -242,7 +254,7 @@ describe('AccountDeleteManager', () => { }); it('should fail if paypalHelper cancel billing agreement fails', async () => { - mockPaypalHelper.cancelBillingAgreement(async () => { + mockPaypalHelper.cancelBillingAgreement.mockImplementation(async () => { throw new Error('wibble'); }); try { @@ -255,30 +267,32 @@ describe('AccountDeleteManager', () => { describe('scheduled inactive account deletion', () => { it('should skip if the account is active', async () => { - isActiveStub.resolves(true); + isActiveStub.mockResolvedValue(true); await accountDeleteManager.deleteAccount( uid, ReasonForDeletion.InactiveAccountScheduled ); - sinon.assert.notCalled(mockFxaDb.deleteAccount); - sinon.assert.calledOnce( + expect(mockFxaDb.deleteAccount).not.toHaveBeenCalled(); + expect( mockGlean.inactiveAccountDeletion.deletionSkipped - ); - sinon.assert.calledOnceWithExactly( - mockStatsd.increment, + ).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.inactive.deletion.skipped.active' ); }); it('should delete the inactive account', async () => { - isActiveStub.resolves(false); + isActiveStub.mockResolvedValue(false); await accountDeleteManager.deleteAccount( uid, ReasonForDeletion.InactiveAccountScheduled ); - sinon.assert.calledWithMatch(mockFxaDb.deleteAccount, { uid }); - sinon.assert.calledOnceWithExactly( - mockLog.info, + expect(mockFxaDb.deleteAccount).toHaveBeenCalledWith( + expect.objectContaining({ uid }) + ); + expect(mockLog.info).toHaveBeenCalledTimes(1); + expect(mockLog.info).toHaveBeenCalledWith( 'accountDeleted.byCloudTask', { uid } ); @@ -290,8 +304,11 @@ describe('AccountDeleteManager', () => { it('should delete the account', async () => { await accountDeleteManager.quickDelete(uid, deleteReason); - sinon.assert.calledWithMatch(mockFxaDb.deleteAccount, { uid }); - sinon.assert.calledOnceWithExactly(mockOAuthDb.removeTokensAndCodes, uid); + expect(mockFxaDb.deleteAccount).toHaveBeenCalledWith( + expect.objectContaining({ uid }) + ); + expect(mockOAuthDb.removeTokensAndCodes).toHaveBeenCalledTimes(1); + expect(mockOAuthDb.removeTokensAndCodes).toHaveBeenCalledWith(uid); }); it('should error if its not user requested', async () => { @@ -307,43 +324,47 @@ describe('AccountDeleteManager', () => { describe('refundSubscriptions', () => { it('returns immediately when delete reason is not for unverified account', async () => { await accountDeleteManager.refundSubscriptions('invalid_reason'); - sinon.assert.notCalled( + expect( mockStripeHelper.fetchInvoicesForActiveSubscriptions - ); + ).not.toHaveBeenCalled(); }); it('returns if no invoices are found', async () => { - mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves([]); + mockStripeHelper.fetchInvoicesForActiveSubscriptions.mockResolvedValue( + [] + ); await accountDeleteManager.refundSubscriptions( 'fxa_unverified_account_delete', 'customerid' ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.fetchInvoicesForActiveSubscriptions, - 'customerid', - 'paid', - undefined - ); - sinon.assert.notCalled(mockStripeHelper.refundInvoices); + expect( + mockStripeHelper.fetchInvoicesForActiveSubscriptions + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.fetchInvoicesForActiveSubscriptions + ).toHaveBeenCalledWith('customerid', 'paid', undefined); + expect(mockStripeHelper.refundInvoices).not.toHaveBeenCalled(); }); it('attempts refunds on invoices created within refundPeriod', async () => { - mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves([]); + mockStripeHelper.fetchInvoicesForActiveSubscriptions.mockResolvedValue( + [] + ); await accountDeleteManager.refundSubscriptions( 'fxa_unverified_account_delete', 'customerid', 34 ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.fetchInvoicesForActiveSubscriptions, - 'customerid', - 'paid', - sinon.match.date - ); - sinon.assert.calledOnce( + expect( mockStripeHelper.fetchInvoicesForActiveSubscriptions - ); - sinon.assert.notCalled(mockStripeHelper.refundInvoices); + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.fetchInvoicesForActiveSubscriptions + ).toHaveBeenCalledWith('customerid', 'paid', expect.any(Date)); + expect( + mockStripeHelper.fetchInvoicesForActiveSubscriptions + ).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.refundInvoices).not.toHaveBeenCalled(); }); it('attempts refunds on invoices', async () => { @@ -356,20 +377,20 @@ describe('AccountDeleteManager', () => { currency: 'usd', }, ]; - mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves( + mockStripeHelper.fetchInvoicesForActiveSubscriptions.mockResolvedValue( expectedInvoices ); - mockStripeHelper.refundInvoices.resolves(expectedRefundResult); + mockStripeHelper.refundInvoices.mockResolvedValue(expectedRefundResult); await accountDeleteManager.refundSubscriptions( 'fxa_unverified_account_delete', 'customerId' ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.refundInvoices, + expect(mockStripeHelper.refundInvoices).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.refundInvoices).toHaveBeenCalledWith( expectedInvoices ); - sinon.assert.calledOnceWithExactly( - mockPaypalHelper.refundInvoices, + expect(mockPaypalHelper.refundInvoices).toHaveBeenCalledTimes(1); + expect(mockPaypalHelper.refundInvoices).toHaveBeenCalledWith( expectedInvoices ); }); @@ -377,10 +398,10 @@ describe('AccountDeleteManager', () => { it('rejects on refundInvoices handler exception', async () => { const expectedInvoices = ['invoice1', 'invoice2']; const expectedError = new Error('expected'); - mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves( + mockStripeHelper.fetchInvoicesForActiveSubscriptions.mockResolvedValue( expectedInvoices ); - mockStripeHelper.refundInvoices.rejects(expectedError); + mockStripeHelper.refundInvoices.mockRejectedValue(expectedError); try { await accountDeleteManager.refundSubscriptions( 'fxa_unverified_account_delete', @@ -388,12 +409,12 @@ describe('AccountDeleteManager', () => { ); throw new Error('expecting refundSubscriptions exception'); } catch (error: any) { - sinon.assert.calledOnceWithExactly( - mockStripeHelper.refundInvoices, + expect(mockStripeHelper.refundInvoices).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.refundInvoices).toHaveBeenCalledWith( expectedInvoices ); - sinon.assert.calledOnceWithExactly( - mockPaypalHelper.refundInvoices, + expect(mockPaypalHelper.refundInvoices).toHaveBeenCalledTimes(1); + expect(mockPaypalHelper.refundInvoices).toHaveBeenCalledWith( expectedInvoices ); expect(error).toEqual(expectedError); diff --git a/packages/fxa-auth-server/lib/account-events.spec.ts b/packages/fxa-auth-server/lib/account-events.spec.ts index e3c1d56a521..88084bb4dcd 100644 --- a/packages/fxa-auth-server/lib/account-events.spec.ts +++ b/packages/fxa-auth-server/lib/account-events.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { StatsD } from 'hot-shots'; import { AccountEventsManager } from './account-events'; import Container from 'typedi'; @@ -16,21 +15,21 @@ describe('Account Events', () => { let usersDbRefMock: any; let firestore: any; let accountEventsManager: AccountEventsManager; - let addMock: sinon.SinonStub; + let addMock: jest.Mock; let statsd: any; let mockDb: any; beforeEach(() => { - addMock = sinon.stub(); + addMock = jest.fn(); usersDbRefMock = { - doc: sinon.stub().returns({ - collection: sinon.stub().returns({ + doc: jest.fn().mockReturnValue({ + collection: jest.fn().mockReturnValue({ add: addMock, }), }), }; firestore = { - collection: sinon.stub().returns(usersDbRefMock), + collection: jest.fn().mockReturnValue(usersDbRefMock), }; const mockConfig = { authFirestore: { @@ -47,7 +46,7 @@ describe('Account Events', () => { mockDb = mocks.mockDB(); Container.set(AppConfig, mockConfig); Container.set(AuthFirestore, firestore); - statsd = { increment: sinon.spy() }; + statsd = { increment: jest.fn() }; Container.set(StatsD, statsd); accountEventsManager = new AccountEventsManager(); @@ -80,20 +79,26 @@ describe('Account Events', () => { eventType: 'emailEvent', name: 'emailSent', }; - sinon.assert.calledOnceWithMatch(addMock, assertMessage); - sinon.assert.calledOnceWithExactly(usersDbRefMock.doc, UID); + expect(addMock).toHaveBeenCalledTimes(1); + expect(addMock).toHaveBeenCalledWith( + expect.objectContaining(assertMessage) + ); + expect(usersDbRefMock.doc).toHaveBeenCalledTimes(1); + expect(usersDbRefMock.doc).toHaveBeenCalledWith(UID); expect(Date.now()).toBeGreaterThanOrEqual( - addMock.firstCall.firstArg.createdAt + addMock.mock.calls[0][0].createdAt ); - sinon.assert.calledOnceWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( 'accountEvents.recordEmailEvent.write' ); }); it('logs and does not throw on failure', async () => { - usersDbRefMock.doc = sinon.stub().throws(); + usersDbRefMock.doc = jest.fn().mockImplementation(() => { + throw new Error(); + }); const message = { template: 'verifyLoginCode', deviceId: 'deviceId', @@ -105,9 +110,9 @@ describe('Account Events', () => { message as any, 'emailSent' ); - expect(addMock.called).toBe(false); - sinon.assert.calledOnceWithExactly( - statsd.increment, + expect(addMock).not.toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( 'accountEvents.recordEmailEvent.error' ); }); @@ -123,10 +128,10 @@ describe('Account Events', () => { message as any, 'emailSent' ); - expect(addMock.called).toBe(true); - expect(addMock.firstCall.firstArg.template).toBeUndefined(); - expect(addMock.firstCall.firstArg.deviceId).toBeUndefined(); - expect(addMock.firstCall.firstArg.flowId).toBeUndefined(); + expect(addMock).toHaveBeenCalled(); + expect(addMock.mock.calls[0][0].template).toBeUndefined(); + expect(addMock.mock.calls[0][0].deviceId).toBeUndefined(); + expect(addMock.mock.calls[0][0].flowId).toBeUndefined(); }); }); @@ -140,10 +145,11 @@ describe('Account Events', () => { }; await accountEventsManager.recordSecurityEvent(mockDb, message); - sinon.assert.calledOnceWithExactly(mockDb.securityEvent, message); + expect(mockDb.securityEvent).toHaveBeenCalledTimes(1); + expect(mockDb.securityEvent).toHaveBeenCalledWith(message); - sinon.assert.calledOnceWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( 'accountEvents.recordSecurityEvent.write.account.login', { clientId: 'none', service: 'none' } ); @@ -162,8 +168,8 @@ describe('Account Events', () => { }; await accountEventsManager.recordSecurityEvent(mockDb, message); - sinon.assert.calledOnceWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( 'accountEvents.recordSecurityEvent.write.account.login', { clientId: '5882386c6d801776', service: 'sync' } ); @@ -181,15 +187,17 @@ describe('Account Events', () => { }; await accountEventsManager.recordSecurityEvent(mockDb, message); - sinon.assert.calledOnceWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( 'accountEvents.recordSecurityEvent.write.account.login', { clientId: 'deadbeefdeadbeef', service: 'none' } ); }); it('logs and does not throw on failure', async () => { - mockDb.securityEvent = sinon.stub().throws(); + mockDb.securityEvent = jest.fn().mockImplementation(() => { + throw new Error(); + }); const message = { name: 'account.login', uid: '000', @@ -197,16 +205,18 @@ describe('Account Events', () => { tokenId: '123', }; await accountEventsManager.recordSecurityEvent(mockDb, message as any); - expect(addMock.called).toBe(false); - sinon.assert.calledOnceWithExactly( - statsd.increment, + expect(addMock).not.toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( 'accountEvents.recordSecurityEvent.error.account.login', { clientId: 'none', service: 'none' } ); }); it('includes tags on error path', async () => { - mockDb.securityEvent = sinon.stub().throws(); + mockDb.securityEvent = jest.fn().mockImplementation(() => { + throw new Error(); + }); const message = { name: 'account.login', uid: '000', @@ -219,8 +229,8 @@ describe('Account Events', () => { }; await accountEventsManager.recordSecurityEvent(mockDb, message as any); - sinon.assert.calledOnceWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( 'accountEvents.recordSecurityEvent.error.account.login', { clientId: '5882386c6d801776', service: 'sync' } ); diff --git a/packages/fxa-auth-server/lib/cad-reminders.in.spec.ts b/packages/fxa-auth-server/lib/cad-reminders.in.spec.ts index d1b7164ee02..07ec074dffc 100644 --- a/packages/fxa-auth-server/lib/cad-reminders.in.spec.ts +++ b/packages/fxa-auth-server/lib/cad-reminders.in.spec.ts @@ -104,7 +104,7 @@ describe('#integration - lib/cad-reminders', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); @@ -120,7 +120,7 @@ describe('#integration - lib/cad-reminders', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); @@ -180,7 +180,7 @@ describe('#integration - lib/cad-reminders', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); }); diff --git a/packages/fxa-auth-server/lib/customs.spec.ts b/packages/fxa-auth-server/lib/customs.spec.ts index adc975d1a4f..d2d9c9231d7 100644 --- a/packages/fxa-auth-server/lib/customs.spec.ts +++ b/packages/fxa-auth-server/lib/customs.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; const mocks = require('../test/mocks'); const { AppError: error } = require('@fxa/accounts/errors'); const nock = require('nock'); @@ -20,7 +19,6 @@ describe('Customs', () => { let customsNoUrl: any; let customsWithUrl: any; let customsInvalidUrl: any; - const sandbox = sinon.createSandbox(); const statsd = { increment: () => {}, timing: () => {}, @@ -42,9 +40,9 @@ describe('Customs', () => { let action: string; beforeEach(() => { - sandbox.stub(statsd, 'increment'); - sandbox.stub(statsd, 'timing'); - sandbox.stub(statsd, 'gauge'); + jest.spyOn(statsd, 'increment'); + jest.spyOn(statsd, 'timing'); + jest.spyOn(statsd, 'gauge'); request = newRequest(); ip = request.app.clientAddress; email = newEmail(); @@ -56,7 +54,7 @@ describe('Customs', () => { afterEach(() => { nock.cleanAll(); - sandbox.restore(); + jest.restoreAllMocks(); }); it("can create a customs object with url as 'none'", async () => { @@ -132,7 +130,9 @@ describe('Customs', () => { try { await customsWithUrl.check(request, email, action); - throw new Error('This should have failed the check since it should be blocked'); + throw new Error( + 'This should have failed the check since it should be blocked' + ); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.THROTTLED); expect(err.message).toBe('Client has sent too many requests'); @@ -168,7 +168,9 @@ describe('Customs', () => { try { await customsWithUrl.check(request, email, action); - throw new Error('This should have failed the check since it should be blocked'); + throw new Error( + 'This should have failed the check since it should be blocked' + ); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.REQUEST_BLOCKED); expect(err.message).toBe('The request was blocked for security reasons'); @@ -201,11 +203,11 @@ describe('Customs', () => { ).rejects.toMatchObject({ errno: error.ERRNO.BACKEND_SERVICE_FAILURE, }); - await expect( - customsInvalidUrl.reset(request, email) - ).rejects.toMatchObject({ - errno: error.ERRNO.BACKEND_SERVICE_FAILURE, - }); + await expect(customsInvalidUrl.reset(request, email)).rejects.toMatchObject( + { + errno: error.ERRNO.BACKEND_SERVICE_FAILURE, + } + ); }); it('can rate limit checkAccountStatus /check', async () => { @@ -254,7 +256,9 @@ describe('Customs', () => { try { await customsWithUrl.check(request, email, action); - throw new Error('This should have failed the check since it should be blocked'); + throw new Error( + 'This should have failed the check since it should be blocked' + ); } catch (err: any) { expect(err.errno).toBe(114); expect(err.message).toBe('Client has sent too many requests'); @@ -336,7 +340,9 @@ describe('Customs', () => { try { await customsWithUrl.checkAuthenticated(request, uid, email, action); - throw new Error('This should have failed the check since it should be blocked'); + throw new Error( + 'This should have failed the check since it should be blocked' + ); } catch (err: any) { expect(err.errno).toBe(114); expect(err.message).toBe('Client has sent too many requests'); @@ -423,9 +429,9 @@ describe('Customs', () => { describe('customs v2', () => { const mockRateLimit = { - check: sinon.spy(), - skip: sinon.spy(), - supportsAction: sinon.spy(), + check: jest.fn(), + skip: jest.fn(), + supportsAction: jest.fn(), }; const customs = new Customs( @@ -437,23 +443,25 @@ describe('Customs', () => { ); beforeEach(() => { - mockRateLimit.check = sinon.spy(); - mockRateLimit.skip = sinon.spy(() => false); - mockRateLimit.supportsAction = sinon.spy(() => true); - const configGetStub = sandbox.stub(configModule.config, 'get'); - configGetStub - .withArgs('rateLimit.emailAliasNormalization') - .returns( - JSON.stringify([ - { domain: 'mozilla.com', regex: '\\+.*', replace: '' }, - ]) - ); - configGetStub.callThrough(); + mockRateLimit.check = jest.fn(); + mockRateLimit.skip = jest.fn(() => false); + mockRateLimit.supportsAction = jest.fn(() => true); + const originalGet = configModule.config.get.bind(configModule.config); + jest + .spyOn(configModule.config, 'get') + .mockImplementation((key: string) => { + if (key === 'rateLimit.emailAliasNormalization') { + return JSON.stringify([ + { domain: 'mozilla.com', regex: '\\+.*', replace: '' }, + ]); + } + return originalGet(key); + }); Customs._reloadEmailNormalization(); }); it('can allow checkAccountStatus with rate-limit lib', async () => { - mockRateLimit.check = sandbox.spy(async () => { + mockRateLimit.check = jest.fn(async () => { return await Promise.resolve(null); }); await customs.checkAuthenticated( @@ -463,9 +471,9 @@ describe('Customs', () => { 'accountStatusCheck' ); - sinon.assert.callCount(mockRateLimit.supportsAction, 1); - sinon.assert.callCount(mockRateLimit.check, 1); - sinon.assert.calledWith(mockRateLimit.check, 'accountStatusCheck', { + expect(mockRateLimit.supportsAction).toHaveBeenCalledTimes(1); + expect(mockRateLimit.check).toHaveBeenCalledTimes(1); + expect(mockRateLimit.check).toHaveBeenCalledWith('accountStatusCheck', { ip, email, uid, @@ -475,7 +483,7 @@ describe('Customs', () => { }); it('can block checkAccountStatus with rate-limit lib', async () => { - mockRateLimit.check = sandbox.spy(async (action: string) => { + mockRateLimit.check = jest.fn(async (action: string) => { if (action === 'accountStatusCheck') { return await Promise.resolve({ retryAfter: 1000, @@ -499,29 +507,26 @@ describe('Customs', () => { 'Client has sent too many requests' ); - sinon.assert.callCount(mockRateLimit.supportsAction, 2); - sinon.assert.calledWith( - mockRateLimit.supportsAction, + expect(mockRateLimit.supportsAction).toHaveBeenCalledTimes(2); + expect(mockRateLimit.supportsAction).toHaveBeenCalledWith( 'accountStatusCheck' ); - sinon.assert.calledWith(mockRateLimit.supportsAction, 'unblockEmail'); + expect(mockRateLimit.supportsAction).toHaveBeenCalledWith('unblockEmail'); - sinon.assert.callCount(mockRateLimit.check, 2); - sinon.assert.calledWith( - mockRateLimit.check, + expect(mockRateLimit.check).toHaveBeenCalledTimes(2); + expect(mockRateLimit.check).toHaveBeenCalledWith( 'accountStatusCheck', - sinon.match({ ip, email, ip_email }) + expect.objectContaining({ ip, email, ip_email }) ); - sinon.assert.calledWith( - mockRateLimit.check, + expect(mockRateLimit.check).toHaveBeenCalledWith( 'unblockEmail', - sinon.match({ ip, email, ip_email }) + expect.objectContaining({ ip, email, ip_email }) ); }); it('can skip certain emails, ips, and uids', async () => { - mockRateLimit.skip = sandbox.spy(() => true); - mockRateLimit.check = sandbox.spy(async () => { + mockRateLimit.skip = jest.fn(() => true); + mockRateLimit.check = jest.fn(async () => { return await Promise.resolve({ retryAfter: 1000, reason: 'too-many-attempts', @@ -530,12 +535,12 @@ describe('Customs', () => { await customs.check(request, email, 'accountStatusCheck'); - sinon.assert.calledWith(mockRateLimit.skip, { ip, email, ip_email }); - sinon.assert.callCount(mockRateLimit.check, 0); + expect(mockRateLimit.skip).toHaveBeenCalledWith({ ip, email, ip_email }); + expect(mockRateLimit.check).toHaveBeenCalledTimes(0); }); it('normalizes emails with plus aliases for configured domains', async () => { - mockRateLimit.check = sandbox.spy(async () => Promise.resolve(null)); + mockRateLimit.check = jest.fn(async () => Promise.resolve(null)); const emailWithAlias = 'user+alias@mozilla.com'; const normalizedEmail = 'user@mozilla.com'; @@ -543,10 +548,9 @@ describe('Customs', () => { await customs.check(request, emailWithAlias, 'accountStatusCheck'); - sinon.assert.calledWith( - mockRateLimit.check, + expect(mockRateLimit.check).toHaveBeenCalledWith( 'accountStatusCheck', - sinon.match({ + expect.objectContaining({ ip, email: normalizedEmail, ip_email: normalizedIpEmail, @@ -555,7 +559,7 @@ describe('Customs', () => { }); it('normalizes emails with different cases', async () => { - mockRateLimit.check = sandbox.spy(async () => Promise.resolve(null)); + mockRateLimit.check = jest.fn(async () => Promise.resolve(null)); const mixedCaseEmail = 'User+Alias@Mozilla.COM'; const normalizedEmail = 'user@mozilla.com'; @@ -563,10 +567,9 @@ describe('Customs', () => { await customs.check(request, mixedCaseEmail, 'accountStatusCheck'); - sinon.assert.calledWith( - mockRateLimit.check, + expect(mockRateLimit.check).toHaveBeenCalledWith( 'accountStatusCheck', - sinon.match({ + expect.objectContaining({ ip, email: normalizedEmail, ip_email: normalizedIpEmail, @@ -575,7 +578,7 @@ describe('Customs', () => { }); it('does not remove aliases for non-configured domains', async () => { - mockRateLimit.check = sandbox.spy(async () => Promise.resolve(null)); + mockRateLimit.check = jest.fn(async () => Promise.resolve(null)); const emailWithAlias = 'user+alias@example.com'; const normalizedEmail = 'user+alias@example.com'; @@ -583,10 +586,9 @@ describe('Customs', () => { await customs.check(request, emailWithAlias, 'accountStatusCheck'); - sinon.assert.calledWith( - mockRateLimit.check, + expect(mockRateLimit.check).toHaveBeenCalledWith( 'accountStatusCheck', - sinon.match({ + expect.objectContaining({ ip, email: normalizedEmail, ip_email: normalizedIpEmail, @@ -595,7 +597,7 @@ describe('Customs', () => { }); it('lowercases emails for all domains', async () => { - mockRateLimit.check = sandbox.spy(async () => Promise.resolve(null)); + mockRateLimit.check = jest.fn(async () => Promise.resolve(null)); const mixedCaseEmail = 'User@Example.COM'; const normalizedEmail = 'user@example.com'; @@ -603,10 +605,9 @@ describe('Customs', () => { await customs.check(request, mixedCaseEmail, 'accountStatusCheck'); - sinon.assert.calledWith( - mockRateLimit.check, + expect(mockRateLimit.check).toHaveBeenCalledWith( 'accountStatusCheck', - sinon.match({ + expect.objectContaining({ ip, email: normalizedEmail, ip_email: normalizedIpEmail, @@ -637,51 +638,44 @@ describe('Customs', () => { it('reports for /check', async () => { customsServer.post('/check').reply(200, tags); - await expect(customsWithUrl.check(request, email, action)).rejects.toThrow(); - expect( - (statsd.increment as sinon.SinonStub).calledWithExactly( - 'customs.request.check', - { - action, - ...validTags, - } - ) - ).toBe(true); - expect( - (statsd.timing as sinon.SinonStub).calledWithMatch( - 'customs.check.success' - ) - ).toBe(true); - expect( - (statsd.gauge as sinon.SinonStub).calledWithMatch( - 'httpAgent.createSocketCount' - ) - ).toBe(true); - expect( - (statsd.gauge as sinon.SinonStub).calledWithMatch( - 'httpsAgent.createSocketCount' - ) - ).toBe(true); + await expect( + customsWithUrl.check(request, email, action) + ).rejects.toThrow(); + expect(statsd.increment).toHaveBeenCalledWith('customs.request.check', { + action, + ...validTags, + }); + expect(statsd.timing).toHaveBeenCalledWith( + expect.stringContaining('customs.check.success'), + expect.anything() + ); + expect(statsd.gauge).toHaveBeenCalledWith( + expect.stringContaining('httpAgent.createSocketCount'), + expect.anything() + ); + expect(statsd.gauge).toHaveBeenCalledWith( + expect.stringContaining('httpsAgent.createSocketCount'), + expect.anything() + ); }); it('reports for /checkIpOnly', async () => { customsServer.post('/checkIpOnly').reply(200, tags); - await expect(customsWithUrl.checkIpOnly(request, action)).rejects.toThrow(); - expect( - (statsd.increment as sinon.SinonStub).calledWithExactly( - 'customs.request.checkIpOnly', - { - action, - ...validTags, - } - ) - ).toBe(true); - expect( - (statsd.timing as sinon.SinonStub).calledWithMatch( - 'customs.checkIpOnly.success' - ) - ).toBe(true); + await expect( + customsWithUrl.checkIpOnly(request, action) + ).rejects.toThrow(); + expect(statsd.increment).toHaveBeenCalledWith( + 'customs.request.checkIpOnly', + { + action, + ...validTags, + } + ); + expect(statsd.timing).toHaveBeenCalledWith( + expect.stringContaining('customs.checkIpOnly.success'), + expect.anything() + ); }); it('reports for /checkAuthenticated', async () => { @@ -691,33 +685,36 @@ describe('Customs', () => { }); await expect( - customsWithUrl.checkAuthenticated(request, 'uid', 'email@mozilla.com', action) - ).rejects.toThrow(); - expect( - (statsd.increment as sinon.SinonStub).calledWithExactly( - 'customs.request.checkAuthenticated', - { - action, - block: true, - blockReason: 'other', - } - ) - ).toBe(true); - expect( - (statsd.timing as sinon.SinonStub).calledWithMatch( - 'customs.checkAuthenticated.success' + customsWithUrl.checkAuthenticated( + request, + 'uid', + 'email@mozilla.com', + action ) - ).toBe(true); + ).rejects.toThrow(); + expect(statsd.increment).toHaveBeenCalledWith( + 'customs.request.checkAuthenticated', + { + action, + block: true, + blockReason: 'other', + } + ); + expect(statsd.timing).toHaveBeenCalledWith( + expect.stringContaining('customs.checkAuthenticated.success'), + expect.anything() + ); }); it('reports failure statsd timing', async () => { customsServer.post('/check').reply(400, tags); - await expect(customsWithUrl.check(request, email, action)).rejects.toThrow(); - expect( - (statsd.timing as sinon.SinonStub).calledWithMatch( - 'customs.check.failure' - ) - ).toBe(true); + await expect( + customsWithUrl.check(request, email, action) + ).rejects.toThrow(); + expect(statsd.timing).toHaveBeenCalledWith( + expect.stringContaining('customs.check.failure'), + expect.anything() + ); }); }); }); diff --git a/packages/fxa-auth-server/lib/db.spec.ts b/packages/fxa-auth-server/lib/db.spec.ts index 3a1928c73ea..c2b13ad948a 100644 --- a/packages/fxa-auth-server/lib/db.spec.ts +++ b/packages/fxa-auth-server/lib/db.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - // Mock fxa-shared/db to prevent real DB connections jest.mock('fxa-shared/db', () => ({ setupAuthDatabase: jest.fn(), @@ -12,7 +10,9 @@ jest.mock('fxa-shared/db', () => ({ // Mock fxa-shared/db/models/auth - defined inline to avoid TDZ issues with jest.mock hoisting jest.mock('fxa-shared/db/models/auth', () => ({ Device: { - delete: jest.fn().mockResolvedValue({ sessionTokenId: 'fakeSessionTokenId' }), + delete: jest + .fn() + .mockResolvedValue({ sessionTokenId: 'fakeSessionTokenId' }), findByPrimaryKey: jest.fn().mockResolvedValue({ id: 'fakeDeviceId' }), findByUid: jest.fn().mockResolvedValue([]), findByUidAndRefreshTokenId: jest.fn(), @@ -92,7 +92,7 @@ describe('db, session tokens expire:', () => { beforeEach(async () => { const now = Date.now(); - models.SessionToken.findByUid = sinon.stub().resolves([ + models.SessionToken.findByUid = jest.fn().mockResolvedValue([ { createdAt: now, tokenId: 'foo' }, { createdAt: now - tokenLifetimes.sessionTokenWithoutDevice - 1, @@ -150,7 +150,7 @@ describe('db, session tokens do not expire:', () => { beforeEach(async () => { const now = Date.now(); - models.SessionToken.findByUid = sinon.stub().resolves([ + models.SessionToken.findByUid = jest.fn().mockResolvedValue([ { createdAt: now, tokenId: 'foo' }, { createdAt: now - tokenLifetimes.sessionTokenWithoutDevice - 1, @@ -200,7 +200,7 @@ describe('db with redis disabled:', () => { }); it('db.sessions succeeds without a redis instance', async () => { - models.SessionToken.findByUid = sinon.stub().resolves([]); + models.SessionToken.findByUid = jest.fn().mockResolvedValue([]); const result = await db.sessions('fakeUid'); expect(result).toEqual([]); }); @@ -233,12 +233,12 @@ describe('redis enabled, token-pruning enabled:', () => { beforeEach(async () => { redis = { - get: sinon.spy(() => Promise.resolve('{}')), - set: sinon.spy(() => Promise.resolve()), - del: sinon.spy(() => Promise.resolve()), - getSessionTokens: sinon.spy(() => Promise.resolve()), - pruneSessionTokens: sinon.spy(() => Promise.resolve()), - touchSessionToken: sinon.spy(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve('{}')), + set: jest.fn(() => Promise.resolve()), + del: jest.fn(() => Promise.resolve()), + getSessionTokens: jest.fn(() => Promise.resolve()), + pruneSessionTokens: jest.fn(() => Promise.resolve()), + touchSessionToken: jest.fn(() => Promise.resolve()), }; redisMockFactory = (...args: any[]) => { expect(args).toHaveLength(2); @@ -290,53 +290,50 @@ describe('redis enabled, token-pruning enabled:', () => { }); it('should call redis and the db in db.devices if uid is not falsey', async () => { - models.Device.findByUid = sinon.stub().resolves([]); + models.Device.findByUid = jest.fn().mockResolvedValue([]); await db.devices('wibble'); - expect(models.Device.findByUid.callCount).toBe(1); - expect(redis.getSessionTokens.callCount).toBe(1); - expect(redis.getSessionTokens.args[0]).toHaveLength(1); - expect(redis.getSessionTokens.args[0][0]).toBe('wibble'); + expect(models.Device.findByUid).toHaveBeenCalledTimes(1); + expect(redis.getSessionTokens).toHaveBeenCalledTimes(1); + expect(redis.getSessionTokens).toHaveBeenCalledWith('wibble'); }); it('should call redis and the db in db.device if uid is not falsey', async () => { - models.Device.findByPrimaryKey = sinon.stub().resolves({}); + models.Device.findByPrimaryKey = jest.fn().mockResolvedValue({}); await db.device('wibble', 'wobble'); - expect(models.Device.findByPrimaryKey.callCount).toBe(1); - expect(redis.getSessionTokens.callCount).toBe(1); - expect(redis.getSessionTokens.args[0]).toHaveLength(1); - expect(redis.getSessionTokens.args[0][0]).toBe('wibble'); + expect(models.Device.findByPrimaryKey).toHaveBeenCalledTimes(1); + expect(redis.getSessionTokens).toHaveBeenCalledTimes(1); + expect(redis.getSessionTokens).toHaveBeenCalledWith('wibble'); }); it('should call redis.getSessionTokens in db.sessions', async () => { - models.SessionToken.findByUid = sinon.stub().resolves([]); + models.SessionToken.findByUid = jest.fn().mockResolvedValue([]); await db.sessions('wibble'); - expect(models.SessionToken.findByUid.callCount).toBe(1); - expect(redis.getSessionTokens.callCount).toBe(1); - expect(redis.getSessionTokens.args[0]).toHaveLength(1); - expect(redis.getSessionTokens.args[0][0]).toBe('wibble'); + expect(models.SessionToken.findByUid).toHaveBeenCalledTimes(1); + expect(redis.getSessionTokens).toHaveBeenCalledTimes(1); + expect(redis.getSessionTokens).toHaveBeenCalledWith('wibble'); - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('should call redis.del in db.deleteAccount', async () => { await db.deleteAccount({ uid: 'wibble' }); - expect(redis.del.callCount).toBe(1); - expect(redis.del.args[0]).toHaveLength(1); - expect(redis.del.args[0][0]).toBe('wibble'); + expect(redis.del).toHaveBeenCalledTimes(1); + expect(redis.del).toHaveBeenCalledWith('wibble'); }); it('should call redis.del in db.resetAccount', async () => { await db.resetAccount({ uid: 'wibble' }, {}); - expect(redis.del.callCount).toBe(1); - expect(redis.del.args[0]).toHaveLength(1); - expect(redis.del.args[0][0]).toBe('wibble'); + expect(redis.del).toHaveBeenCalledTimes(1); + expect(redis.del).toHaveBeenCalledWith('wibble'); }); it('should call redis.touchSessionToken in db.touchSessionToken', async () => { await db.touchSessionToken({ id: 'wibble', uid: 'blee' }); - expect(redis.touchSessionToken.callCount).toBe(1); - expect(redis.touchSessionToken.args[0]).toHaveLength(2); - expect(redis.touchSessionToken.args[0][0]).toBe('blee'); + expect(redis.touchSessionToken).toHaveBeenCalledTimes(1); + expect(redis.touchSessionToken).toHaveBeenCalledWith( + 'blee', + expect.anything() + ); }); it('should call redis.pruneSessionTokens in db.pruneSessionTokens', async () => { @@ -345,9 +342,11 @@ describe('redis enabled, token-pruning enabled:', () => { { id: 'bar', createdAt }, { id: 'baz', createdAt }, ]); - expect(redis.pruneSessionTokens.callCount).toBe(1); - expect(redis.pruneSessionTokens.args[0]).toHaveLength(2); - expect(redis.pruneSessionTokens.args[0][0]).toBe('foo'); + expect(redis.pruneSessionTokens).toHaveBeenCalledTimes(1); + expect(redis.pruneSessionTokens).toHaveBeenCalledWith( + 'foo', + expect.anything() + ); }); it('should not call redis.pruneSessionTokens for unexpired tokens in db.pruneSessionTokens', async () => { @@ -356,40 +355,46 @@ describe('redis enabled, token-pruning enabled:', () => { { id: 'bar', createdAt }, { id: 'baz', createdAt }, ]); - expect(redis.pruneSessionTokens.callCount).toBe(0); + expect(redis.pruneSessionTokens).toHaveBeenCalledTimes(0); }); it('should call redis.pruneSessionTokens in db.deleteSessionToken', async () => { await db.deleteSessionToken({ id: 'wibble', uid: 'blee' }); - expect(redis.pruneSessionTokens.callCount).toBe(1); - expect(redis.pruneSessionTokens.args[0]).toHaveLength(2); - expect(redis.pruneSessionTokens.args[0][0]).toBe('blee'); + expect(redis.pruneSessionTokens).toHaveBeenCalledTimes(1); + expect(redis.pruneSessionTokens).toHaveBeenCalledWith( + 'blee', + expect.anything() + ); }); it('should call redis.pruneSessionTokens in db.deleteDevice', async () => { await db.deleteDevice('wibble', 'blee'); - expect(redis.pruneSessionTokens.callCount).toBe(1); - expect(redis.pruneSessionTokens.args[0]).toHaveLength(2); - expect(redis.pruneSessionTokens.args[0][0]).toBe('wibble'); + expect(redis.pruneSessionTokens).toHaveBeenCalledTimes(1); + expect(redis.pruneSessionTokens).toHaveBeenCalledWith( + 'wibble', + expect.anything() + ); }); it('should call redis.pruneSessionTokens in db.createSessionToken', async () => { await db.createSessionToken({ uid: 'wibble' }); - expect(redis.pruneSessionTokens.callCount).toBe(1); - expect(redis.pruneSessionTokens.args[0]).toHaveLength(2); - expect(redis.pruneSessionTokens.args[0][0]).toBe('wibble'); + expect(redis.pruneSessionTokens).toHaveBeenCalledTimes(1); + expect(redis.pruneSessionTokens).toHaveBeenCalledWith( + 'wibble', + expect.anything() + ); }); describe('mock db.pruneSessionTokens:', () => { beforeEach(() => { - db.pruneSessionTokens = sinon.spy(() => Promise.resolve()); + db.pruneSessionTokens = jest.fn(() => Promise.resolve()); }); describe('with expired tokens from SessionToken.findByUid:', () => { beforeEach(() => { const expiryPoint = Date.now() - tokenLifetimes.sessionTokenWithoutDevice; - models.SessionToken.findByUid = sinon.stub().resolves([ + models.SessionToken.findByUid = jest.fn().mockResolvedValue([ { tokenId: 'unexpired', createdAt: expiryPoint + 1000 }, { tokenId: 'expired1', createdAt: expiryPoint - 1 }, { tokenId: 'expired2', createdAt: 1 }, @@ -401,8 +406,8 @@ describe('redis enabled, token-pruning enabled:', () => { expect(result).toHaveLength(1); expect(result[0].id).toBe('unexpired'); - expect(db.pruneSessionTokens.callCount).toBe(1); - const args = db.pruneSessionTokens.args[0]; + expect(db.pruneSessionTokens).toHaveBeenCalledTimes(1); + const args = db.pruneSessionTokens.mock.calls[0]; expect(args).toHaveLength(2); expect(args[0]).toBe('foo'); expect(Array.isArray(args[1])).toBe(true); @@ -416,7 +421,7 @@ describe('redis enabled, token-pruning enabled:', () => { beforeEach(() => { const expiryPoint = Date.now() - tokenLifetimes.sessionTokenWithoutDevice; - models.SessionToken.findByUid = sinon.stub().resolves([ + models.SessionToken.findByUid = jest.fn().mockResolvedValue([ { tokenId: 'unexpired1', createdAt: expiryPoint + 1000 }, { tokenId: 'unexpired2', createdAt: expiryPoint + 100000 }, { tokenId: 'unexpired3', createdAt: expiryPoint + 10000000 }, @@ -426,7 +431,7 @@ describe('redis enabled, token-pruning enabled:', () => { it('should not call pruneSessionTokens in db.sessions', async () => { const result = await db.sessions('foo'); expect(result).toHaveLength(3); - expect(db.pruneSessionTokens.callCount).toBe(0); + expect(db.pruneSessionTokens).toHaveBeenCalledTimes(0); }); }); }); @@ -441,10 +446,10 @@ describe('redis enabled, token-pruning disabled:', () => { beforeEach(async () => { redis = { - get: sinon.spy(() => Promise.resolve('{}')), - set: sinon.spy(() => Promise.resolve()), - del: sinon.spy(() => Promise.resolve()), - pruneSessionTokens: sinon.spy(() => Promise.resolve()), + get: jest.fn(() => Promise.resolve('{}')), + set: jest.fn(() => Promise.resolve()), + del: jest.fn(() => Promise.resolve()), + pruneSessionTokens: jest.fn(() => Promise.resolve()), }; redisMockFactory = (...args: any[]) => { expect(args).toHaveLength(2); @@ -488,7 +493,7 @@ describe('redis enabled, token-pruning disabled:', () => { it('should not call redis.pruneSessionTokens in db.pruneSessionTokens', async () => { await db.pruneSessionTokens('wibble', [{ id: 'blee', createdAt: 1 }]); - expect(redis.pruneSessionTokens.callCount).toBe(0); + expect(redis.pruneSessionTokens).toHaveBeenCalledTimes(0); }); }); @@ -501,20 +506,20 @@ describe('db.deviceFromRefreshTokenId:', () => { tokens: any, db: any, features: any, - mergeDevicesAndSessionTokens: sinon.SinonStub; + mergeDevicesAndSessionTokens: jest.Mock; beforeEach(async () => { log = mocks.mockLog(); tokens = require('../lib/tokens')(log, { tokenLifetimes }); - models.Device.findByUidAndRefreshTokenId = sinon.stub(); + models.Device.findByUidAndRefreshTokenId = jest.fn(); features = { - isLastAccessTimeEnabledForUser: sinon.stub().returns(false), + isLastAccessTimeEnabledForUser: jest.fn().mockReturnValue(false), }; featuresMockFactory = () => features; - mergeDevicesAndSessionTokens = sinon.stub(); + mergeDevicesAndSessionTokens = jest.fn(); connectedServicesMock = { mergeDevicesAndSessionTokens, filterExpiredTokens: () => [], @@ -562,34 +567,36 @@ describe('db.deviceFromRefreshTokenId:', () => { availableCommands: {}, }; const metrics = { - increment: sinon.spy(), + increment: jest.fn(), }; db.metrics = metrics; - models.Device.findByUidAndRefreshTokenId.resolves(mockDevice); - features.isLastAccessTimeEnabledForUser.returns(false); - mergeDevicesAndSessionTokens.returns([mockNormalizedDevice]); + models.Device.findByUidAndRefreshTokenId.mockResolvedValue(mockDevice); + features.isLastAccessTimeEnabledForUser.mockReturnValue(false); + mergeDevicesAndSessionTokens.mockReturnValue([mockNormalizedDevice]); const result = await db.deviceFromRefreshTokenId(uid, refreshTokenId); - expect(models.Device.findByUidAndRefreshTokenId.callCount).toBe(1); - expect(models.Device.findByUidAndRefreshTokenId.args[0][0]).toBe(uid); - expect(models.Device.findByUidAndRefreshTokenId.args[0][1]).toBe( + expect(models.Device.findByUidAndRefreshTokenId).toHaveBeenCalledTimes(1); + expect(models.Device.findByUidAndRefreshTokenId).toHaveBeenCalledWith( + uid, refreshTokenId ); - expect(features.isLastAccessTimeEnabledForUser.callCount).toBe(1); - expect(features.isLastAccessTimeEnabledForUser.args[0][0]).toBe(uid); - expect(mergeDevicesAndSessionTokens.callCount).toBe(1); - expect(mergeDevicesAndSessionTokens.args[0][0]).toEqual([mockDevice]); - expect(mergeDevicesAndSessionTokens.args[0][1]).toEqual({}); - expect(mergeDevicesAndSessionTokens.args[0][2]).toBe(false); + expect(features.isLastAccessTimeEnabledForUser).toHaveBeenCalledTimes(1); + expect(features.isLastAccessTimeEnabledForUser).toHaveBeenCalledWith(uid); + expect(mergeDevicesAndSessionTokens).toHaveBeenCalledTimes(1); + expect(mergeDevicesAndSessionTokens).toHaveBeenCalledWith( + [mockDevice], + {}, + false + ); expect(result).toEqual(mockNormalizedDevice); // metrics - expect(metrics.increment.callCount).toBe(1); - expect(metrics.increment.args[0][0]).toBe( - 'db.deviceFromRefreshTokenId.retrieve' + expect(metrics.increment).toHaveBeenCalledTimes(1); + expect(metrics.increment).toHaveBeenCalledWith( + 'db.deviceFromRefreshTokenId.retrieve', + { result: 'success' } ); - expect(metrics.increment.args[0][1]).toEqual({ result: 'success' }); }); it('should return normalized device with lastAccessTime when feature is enabled', async () => { @@ -613,16 +620,18 @@ describe('db.deviceFromRefreshTokenId:', () => { availableCommands: {}, }; - models.Device.findByUidAndRefreshTokenId.resolves(mockDevice); - features.isLastAccessTimeEnabledForUser.returns(true); - mergeDevicesAndSessionTokens.returns([mockNormalizedDevice]); + models.Device.findByUidAndRefreshTokenId.mockResolvedValue(mockDevice); + features.isLastAccessTimeEnabledForUser.mockReturnValue(true); + mergeDevicesAndSessionTokens.mockReturnValue([mockNormalizedDevice]); const result = await db.deviceFromRefreshTokenId(uid, refreshTokenId); - expect(mergeDevicesAndSessionTokens.callCount).toBe(1); - expect(mergeDevicesAndSessionTokens.args[0][0]).toEqual([mockDevice]); - expect(mergeDevicesAndSessionTokens.args[0][1]).toEqual({}); - expect(mergeDevicesAndSessionTokens.args[0][2]).toBe(true); + expect(mergeDevicesAndSessionTokens).toHaveBeenCalledTimes(1); + expect(mergeDevicesAndSessionTokens).toHaveBeenCalledWith( + [mockDevice], + {}, + true + ); expect(result).toEqual(mockNormalizedDevice); }); @@ -630,19 +639,19 @@ describe('db.deviceFromRefreshTokenId:', () => { const uid = 'test-uid'; const refreshTokenId = 'test-refresh-token-id'; const metrics = { - increment: sinon.spy(), + increment: jest.fn(), }; db.metrics = metrics; - models.Device.findByUidAndRefreshTokenId.resolves(null); + models.Device.findByUidAndRefreshTokenId.mockResolvedValue(null); const result = await db.deviceFromRefreshTokenId(uid, refreshTokenId); expect(result).toBeNull(); - expect(metrics.increment.callCount).toBe(1); - expect(metrics.increment.args[0][0]).toBe( - 'db.deviceFromRefreshTokenId.retrieve' + expect(metrics.increment).toHaveBeenCalledTimes(1); + expect(metrics.increment).toHaveBeenCalledWith( + 'db.deviceFromRefreshTokenId.retrieve', + { result: 'notFound' } ); - expect(metrics.increment.args[0][1]).toEqual({ result: 'notFound' }); }); it('should not increment metrics when metrics is not available', async () => { @@ -650,7 +659,7 @@ describe('db.deviceFromRefreshTokenId:', () => { const refreshTokenId = 'test-refresh-token-id'; db.metrics = undefined; - models.Device.findByUidAndRefreshTokenId.resolves(null); + models.Device.findByUidAndRefreshTokenId.mockResolvedValue(null); const result = await db.deviceFromRefreshTokenId(uid, refreshTokenId); // basically, just make sure it doesn't blow up without metrics diff --git a/packages/fxa-auth-server/lib/devices.spec.ts b/packages/fxa-auth-server/lib/devices.spec.ts index ec8e97690b9..9fb20641cd4 100644 --- a/packages/fxa-auth-server/lib/devices.spec.ts +++ b/packages/fxa-auth-server/lib/devices.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const crypto = require('crypto'); const mocks = require('../test/mocks'); const { AppError: error } = require('@fxa/accounts/errors'); @@ -91,19 +89,13 @@ describe('lib/devices:', () => { it('returns false when token has different device id', () => { expect( - devices.isSpuriousUpdate( - { id: 'foo' }, - { deviceId: 'bar' } - ) + devices.isSpuriousUpdate({ id: 'foo' }, { deviceId: 'bar' }) ).toBe(false); }); it('returns true when ids match', () => { expect( - devices.isSpuriousUpdate( - { id: 'foo' }, - { deviceId: 'foo' } - ) + devices.isSpuriousUpdate({ id: 'foo' }, { deviceId: 'foo' }) ).toBe(true); }); @@ -278,18 +270,13 @@ describe('lib/devices:', () => { createdAt: deviceCreatedAt, }); - expect(db.updateDevice.callCount).toBe(0); + expect(db.updateDevice).toHaveBeenCalledTimes(0); - expect(db.createDevice.callCount).toBe(1); - let args = db.createDevice.args[0]; - expect(args.length).toBe(2); - expect(args[0]).toEqual(credentials.uid); - expect(args[1]).toBe(device); + expect(db.createDevice).toHaveBeenCalledTimes(1); + expect(db.createDevice).toHaveBeenCalledWith(credentials.uid, device); - expect(log.activityEvent.callCount).toBe(1); - args = log.activityEvent.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toEqual({ + expect(log.activityEvent).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledWith({ country: 'United States', event: 'device.created', region: 'California', @@ -302,25 +289,25 @@ describe('lib/devices:', () => { is_placeholder: false, }); - expect(log.notifyAttachedServices.callCount).toBe(1); - args = log.notifyAttachedServices.args[0]; - expect(args.length).toBe(3); - expect(args[0]).toBe('device:create'); - expect(args[1]).toBe(request); - expect(args[2]).toEqual({ - uid: credentials.uid, - id: deviceId, - type: device.type, - timestamp: deviceCreatedAt, - isPlaceholder: false, - }); - - expect(push.notifyDeviceConnected.callCount).toBe(1); - args = push.notifyDeviceConnected.args[0]; - expect(args.length).toBe(3); - expect(args[0]).toBe(credentials.uid); - expect(Array.isArray(args[1])).toBeTruthy(); - expect(args[2]).toBe(device.name); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( + 'device:create', + request, + { + uid: credentials.uid, + id: deviceId, + type: device.type, + timestamp: deviceCreatedAt, + isPlaceholder: false, + } + ); + + expect(push.notifyDeviceConnected).toHaveBeenCalledTimes(1); + expect(push.notifyDeviceConnected).toHaveBeenCalledWith( + credentials.uid, + expect.any(Array), + device.name + ); }); }); @@ -328,7 +315,7 @@ describe('lib/devices:', () => { credentials.tokenVerified = false; device.name = 'device with an unverified sessionToken'; return devices.upsert(request, credentials, device).then(() => { - expect(push.notifyDeviceConnected.callCount).toBe(0); + expect(push.notifyDeviceConnected).toHaveBeenCalledTimes(0); credentials.tokenVerified = true; }); }); @@ -338,18 +325,27 @@ describe('lib/devices:', () => { return devices .upsert(request, credentials, { uaBrowser: 'Firefox' }) .then(() => { - expect(db.updateDevice.callCount).toBe(0); - expect(db.createDevice.callCount).toBe(1); - - expect(log.activityEvent.callCount).toBe(1); - expect(log.activityEvent.args[0][0].is_placeholder).toBe(true); - - expect(log.notifyAttachedServices.callCount).toBe(1); - expect(log.notifyAttachedServices.args[0][2].isPlaceholder).toBe(true); - - expect(push.notifyDeviceConnected.callCount).toBe(1); - expect(push.notifyDeviceConnected.args[0][0]).toBe(credentials.uid); - expect(push.notifyDeviceConnected.args[0][2]).toBe('Firefox'); + expect(db.updateDevice).toHaveBeenCalledTimes(0); + expect(db.createDevice).toHaveBeenCalledTimes(1); + + expect(log.activityEvent).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledWith( + expect.objectContaining({ is_placeholder: true }) + ); + + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ isPlaceholder: true }) + ); + + expect(push.notifyDeviceConnected).toHaveBeenCalledTimes(1); + expect(push.notifyDeviceConnected).toHaveBeenCalledWith( + credentials.uid, + expect.anything(), + 'Firefox' + ); }); }); @@ -364,22 +360,17 @@ describe('lib/devices:', () => { .then((result) => { expect(result).toBe(deviceInfo); - expect(db.createDevice.callCount).toBe(0); + expect(db.createDevice).toHaveBeenCalledTimes(0); - expect(db.updateDevice.callCount).toBe(1); - let args = db.updateDevice.args[0]; - expect(args.length).toBe(2); - expect(args[0]).toEqual(credentials.uid); - expect(args[1]).toEqual({ + expect(db.updateDevice).toHaveBeenCalledTimes(1); + expect(db.updateDevice).toHaveBeenCalledWith(credentials.uid, { id: deviceId, name: device.name, type: device.type, }); - expect(log.activityEvent.callCount).toBe(1); - args = log.activityEvent.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toEqual({ + expect(log.activityEvent).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledWith({ country: 'United States', event: 'device.updated', region: 'California', @@ -392,15 +383,19 @@ describe('lib/devices:', () => { is_placeholder: false, }); - expect(log.notifyAttachedServices.callCount).toBe(0); - expect(push.notifyDeviceConnected.callCount).toBe(0); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(0); + expect(push.notifyDeviceConnected).toHaveBeenCalledTimes(0); }); }); }); describe('upsert with refreshToken:', () => { let request: ReturnType; - let credentials: { refreshTokenId: string; uid: string; tokenVerified: boolean }; + let credentials: { + refreshTokenId: string; + uid: string; + tokenVerified: boolean; + }; beforeEach(() => { request = mocks.mockRequest({ @@ -422,18 +417,13 @@ describe('lib/devices:', () => { createdAt: deviceCreatedAt, }); - expect(db.updateDevice.callCount).toBe(0); + expect(db.updateDevice).toHaveBeenCalledTimes(0); - expect(db.createDevice.callCount).toBe(1); - let args = db.createDevice.args[0]; - expect(args.length).toBe(2); - expect(args[0]).toEqual(credentials.uid); - expect(args[1]).toBe(device); + expect(db.createDevice).toHaveBeenCalledTimes(1); + expect(db.createDevice).toHaveBeenCalledWith(credentials.uid, device); - expect(log.activityEvent.callCount).toBe(1); - args = log.activityEvent.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toEqual({ + expect(log.activityEvent).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledWith({ country: 'United States', event: 'device.created', region: 'California', @@ -446,25 +436,25 @@ describe('lib/devices:', () => { is_placeholder: false, }); - expect(log.notifyAttachedServices.callCount).toBe(1); - args = log.notifyAttachedServices.args[0]; - expect(args.length).toBe(3); - expect(args[0]).toBe('device:create'); - expect(args[1]).toBe(request); - expect(args[2]).toEqual({ - uid: credentials.uid, - id: deviceId, - type: device.type, - timestamp: deviceCreatedAt, - isPlaceholder: false, - }); - - expect(push.notifyDeviceConnected.callCount).toBe(1); - args = push.notifyDeviceConnected.args[0]; - expect(args.length).toBe(3); - expect(args[0]).toBe(credentials.uid); - expect(Array.isArray(args[1])).toBeTruthy(); - expect(args[2]).toBe(device.name); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( + 'device:create', + request, + { + uid: credentials.uid, + id: deviceId, + type: device.type, + timestamp: deviceCreatedAt, + isPlaceholder: false, + } + ); + + expect(push.notifyDeviceConnected).toHaveBeenCalledTimes(1); + expect(push.notifyDeviceConnected).toHaveBeenCalledWith( + credentials.uid, + expect.any(Array), + device.name + ); }); }); @@ -473,18 +463,27 @@ describe('lib/devices:', () => { return devices .upsert(request, credentials, { uaBrowser: 'Firefox' }) .then(() => { - expect(db.updateDevice.callCount).toBe(0); - expect(db.createDevice.callCount).toBe(1); - - expect(log.activityEvent.callCount).toBe(1); - expect(log.activityEvent.args[0][0].is_placeholder).toBe(true); - - expect(log.notifyAttachedServices.callCount).toBe(1); - expect(log.notifyAttachedServices.args[0][2].isPlaceholder).toBe(true); - - expect(push.notifyDeviceConnected.callCount).toBe(1); - expect(push.notifyDeviceConnected.args[0][0]).toBe(credentials.uid); - expect(push.notifyDeviceConnected.args[0][2]).toBe('Firefox'); + expect(db.updateDevice).toHaveBeenCalledTimes(0); + expect(db.createDevice).toHaveBeenCalledTimes(1); + + expect(log.activityEvent).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledWith( + expect.objectContaining({ is_placeholder: true }) + ); + + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ isPlaceholder: true }) + ); + + expect(push.notifyDeviceConnected).toHaveBeenCalledTimes(1); + expect(push.notifyDeviceConnected).toHaveBeenCalledWith( + credentials.uid, + expect.anything(), + 'Firefox' + ); }); }); @@ -499,22 +498,17 @@ describe('lib/devices:', () => { .then((result) => { expect(result).toBe(deviceInfo); - expect(db.createDevice.callCount).toBe(0); + expect(db.createDevice).toHaveBeenCalledTimes(0); - expect(db.updateDevice.callCount).toBe(1); - let args = db.updateDevice.args[0]; - expect(args.length).toBe(2); - expect(args[0]).toEqual(credentials.uid); - expect(args[1]).toEqual({ + expect(db.updateDevice).toHaveBeenCalledTimes(1); + expect(db.updateDevice).toHaveBeenCalledWith(credentials.uid, { id: deviceId, name: device.name, type: device.type, }); - expect(log.activityEvent.callCount).toBe(1); - args = log.activityEvent.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toEqual({ + expect(log.activityEvent).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledWith({ country: 'United States', event: 'device.updated', region: 'California', @@ -527,8 +521,8 @@ describe('lib/devices:', () => { is_placeholder: false, }); - expect(log.notifyAttachedServices.callCount).toBe(0); - expect(push.notifyDeviceConnected.callCount).toBe(0); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(0); + expect(push.notifyDeviceConnected).toHaveBeenCalledTimes(0); }); }); }); @@ -554,13 +548,13 @@ describe('lib/devices:', () => { devices: [deviceId, deviceId2], credentials, }); - db.deleteDevice = sinon.spy(async () => { + db.deleteDevice = jest.fn(async () => { return device; }); }); it('should destroy the device record', async () => { - db.deleteDevice = sinon.spy(async () => { + db.deleteDevice = jest.fn(async () => { return { sessionTokenId, refreshTokenId: null }; }); device.sessionTokenId = sessionTokenId; @@ -569,31 +563,23 @@ describe('lib/devices:', () => { expect(result.sessionTokenId).toBe(sessionTokenId); expect(result.refreshTokenId).toBeNull(); - expect(db.deleteDevice.callCount).toBe(1); - expect( - db.deleteDevice.calledBefore(push.notifyDeviceDisconnected) - ).toBeTruthy(); - expect(pushbox.deleteDevice.callCount).toBe(1); - expect(pushbox.deleteDevice.firstCall.args).toEqual([ + expect(db.deleteDevice).toHaveBeenCalledTimes(1); + expect(pushbox.deleteDevice).toHaveBeenCalledTimes(1); + expect(pushbox.deleteDevice).toHaveBeenCalledWith( request.auth.credentials.uid, - deviceId, - ]); - expect(push.notifyDeviceDisconnected.callCount).toBe(1); - expect(push.notifyDeviceDisconnected.firstCall.args[0]).toBe( - request.auth.credentials.uid + deviceId + ); + expect(push.notifyDeviceDisconnected).toHaveBeenCalledTimes(1); + expect(push.notifyDeviceDisconnected).toHaveBeenCalledWith( + request.auth.credentials.uid, + [deviceId, deviceId2], + deviceId ); - expect(push.notifyDeviceDisconnected.firstCall.args[1]).toEqual([ - deviceId, - deviceId2, - ]); - expect(push.notifyDeviceDisconnected.firstCall.args[2]).toBe(deviceId); expect(oauthDB.removeRefreshToken).not.toHaveBeenCalled(); - expect(log.activityEvent.callCount).toBe(1); - let args = log.activityEvent.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toEqual({ + expect(log.activityEvent).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledWith({ country: 'United States', event: 'device.deleted', region: 'California', @@ -605,8 +591,8 @@ describe('lib/devices:', () => { device_id: deviceId, }); - expect(log.notifyAttachedServices.callCount).toBe(1); - args = log.notifyAttachedServices.args[0]; + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + const args = log.notifyAttachedServices.mock.calls[0]; expect(args.length).toBe(3); expect(args[0]).toBe('device:delete'); expect(args[1]).toBe(request); @@ -624,10 +610,10 @@ describe('lib/devices:', () => { expect(result.sessionTokenId).toBeFalsy(); expect(result.refreshTokenId).toBe(refreshTokenId); - expect(db.deleteDevice.callCount).toBe(1); + expect(db.deleteDevice).toHaveBeenCalledTimes(1); expect(oauthDB.getRefreshToken).toHaveBeenCalledWith(refreshTokenId); - expect(log.error.callCount).toBe(0); - expect(log.notifyAttachedServices.callCount).toBe(1); + expect(log.error).toHaveBeenCalledTimes(0); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); }); it('should ignore missing tokens when deleting the refreshToken', async () => { @@ -638,10 +624,10 @@ describe('lib/devices:', () => { expect(result.sessionTokenId).toBeFalsy(); expect(result.refreshTokenId).toBe(refreshTokenId); - expect(db.deleteDevice.callCount).toBe(1); + expect(db.deleteDevice).toHaveBeenCalledTimes(1); expect(oauthDB.getRefreshToken).toHaveBeenCalledWith(refreshTokenId); - expect(log.error.callCount).toBe(0); - expect(log.notifyAttachedServices.callCount).toBe(1); + expect(log.error).toHaveBeenCalledTimes(0); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); }); it('should log other errors when deleting the refreshToken, without failing', async () => { @@ -652,12 +638,14 @@ describe('lib/devices:', () => { expect(result.sessionTokenId).toBeFalsy(); expect(result.refreshTokenId).toBe(refreshTokenId); - expect(db.deleteDevice.callCount).toBe(1); + expect(db.deleteDevice).toHaveBeenCalledTimes(1); expect(oauthDB.getRefreshToken).toHaveBeenCalledWith(refreshTokenId); - expect(log.notifyAttachedServices.callCount).toBe(1); - expect( - log.error.calledOnceWith('deviceDestroy.revokeRefreshTokenById.error') - ).toBe(true); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( + 'deviceDestroy.revokeRefreshTokenById.error', + expect.anything() + ); }); }); diff --git a/packages/fxa-auth-server/lib/email-cloud-tasks.spec.ts b/packages/fxa-auth-server/lib/email-cloud-tasks.spec.ts index 84ae912be1f..dd0eba76287 100644 --- a/packages/fxa-auth-server/lib/email-cloud-tasks.spec.ts +++ b/packages/fxa-auth-server/lib/email-cloud-tasks.spec.ts @@ -2,19 +2,16 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { AppConfig } from './types'; import { AccountEventsManager } from './account-events'; -const sandbox = sinon.createSandbox(); - const mockEmailTasks = { - scheduleFirstEmail: sandbox.stub(), - scheduleSecondEmail: sandbox.stub(), - scheduleFinalEmail: sandbox.stub(), + scheduleFirstEmail: jest.fn(), + scheduleSecondEmail: jest.fn(), + scheduleFinalEmail: jest.fn(), }; -const notificationHandlerStub = sandbox.stub(); +const notificationHandlerStub = jest.fn(); jest.mock('@fxa/shared/cloud-tasks', () => { const actual = jest.requireActual('@fxa/shared/cloud-tasks'); @@ -49,17 +46,17 @@ describe('EmailCloudTaskManager', () => { }; const aDayInMs = 24 * 60 * 60 * 1000; let deliveryTime: number; - let mockStatsd: { increment: sinon.SinonStub }; + let mockStatsd: { increment: jest.Mock }; let emailCloudTaskManager: InstanceType; beforeEach(() => { - sandbox.stub(Date, 'now').returns(1736500000000); + jest.spyOn(Date, 'now').mockReturnValue(1736500000000); deliveryTime = Date.now() + 60 * aDayInMs; Container.set(AppConfig, mockConfig); const accountEventsManager = new AccountEventsManager(); Container.set(AccountEventsManager, accountEventsManager); - mockStatsd = { increment: sandbox.stub() }; + mockStatsd = { increment: jest.fn() }; emailCloudTaskManager = new EmailCloudTaskManager({ config: mockConfig, statsd: mockStatsd, @@ -67,8 +64,7 @@ describe('EmailCloudTaskManager', () => { }); afterEach(() => { - (Date.now as sinon.SinonStub).restore(); - sandbox.reset(); + jest.clearAllMocks(); Container.reset(); }); @@ -98,7 +94,8 @@ describe('EmailCloudTaskManager', () => { }, }, }); - sinon.assert.calledOnceWithExactly(mockEmailTasks.scheduleFirstEmail, { + expect(mockEmailTasks.scheduleFirstEmail).toHaveBeenCalledTimes(1); + expect(mockEmailTasks.scheduleFirstEmail).toHaveBeenCalledWith({ payload: mockTaskPayload, emailOptions: { deliveryTime, @@ -107,8 +104,8 @@ describe('EmailCloudTaskManager', () => { taskId: `${mockTaskPayload.uid}-inactive-notification-reschedule-1`, }, }); - sinon.assert.calledOnceWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'cloud-tasks.send-email.rescheduled', { email_type: EmailTypes.INACTIVE_DELETE_FIRST_NOTIFICATION } ); @@ -126,7 +123,8 @@ describe('EmailCloudTaskManager', () => { }, }, }); - sinon.assert.calledOnceWithExactly(mockEmailTasks.scheduleFirstEmail, { + expect(mockEmailTasks.scheduleFirstEmail).toHaveBeenCalledTimes(1); + expect(mockEmailTasks.scheduleFirstEmail).toHaveBeenCalledWith({ payload: mockTaskPayload, emailOptions: { deliveryTime, @@ -150,10 +148,8 @@ describe('EmailCloudTaskManager', () => { }, }, }); - sinon.assert.calledOnceWithExactly( - notificationHandlerStub, - mockTaskPayload - ); + expect(notificationHandlerStub).toHaveBeenCalledTimes(1); + expect(notificationHandlerStub).toHaveBeenCalledWith(mockTaskPayload); }); it('should handle the second notification', async () => { @@ -167,8 +163,8 @@ describe('EmailCloudTaskManager', () => { }, }, }); - sinon.assert.calledOnceWithExactly( - notificationHandlerStub, + expect(notificationHandlerStub).toHaveBeenCalledTimes(1); + expect(notificationHandlerStub).toHaveBeenCalledWith( mockSecondTaskPayload ); }); @@ -184,8 +180,8 @@ describe('EmailCloudTaskManager', () => { }, }, }); - sinon.assert.calledOnceWithExactly( - notificationHandlerStub, + expect(notificationHandlerStub).toHaveBeenCalledTimes(1); + expect(notificationHandlerStub).toHaveBeenCalledWith( mockFinalTaskPayload ); }); diff --git a/packages/fxa-auth-server/lib/email/bounces.spec.ts b/packages/fxa-auth-server/lib/email/bounces.spec.ts index f87788a374d..f0b8927a30f 100644 --- a/packages/fxa-auth-server/lib/email/bounces.spec.ts +++ b/packages/fxa-auth-server/lib/email/bounces.spec.ts @@ -3,7 +3,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { EventEmitter } from 'events'; -import sinon from 'sinon'; import Container from 'typedi'; const bounces = require('./bounces'); @@ -25,7 +24,7 @@ describe('bounce messages', () => { statsd: any; function mockMessage(msg: any) { - msg.del = sinon.spy(); + msg.del = jest.fn(); msg.headers = {}; return msg; } @@ -45,8 +44,8 @@ describe('bounce messages', () => { }, }; mockDB = { - createEmailBounce: sinon.spy(() => Promise.resolve({})), - accountRecord: sinon.spy((email: string) => { + createEmailBounce: jest.fn(() => Promise.resolve({})), + accountRecord: jest.fn((email: string) => { return Promise.resolve({ createdAt: Date.now(), email: email, @@ -54,7 +53,7 @@ describe('bounce messages', () => { uid: '123456', }); }), - deleteAccount: sinon.spy(() => Promise.resolve({})), + deleteAccount: jest.fn(() => Promise.resolve({})), }; mockStripeHelper = { hasActiveSubscription: async () => Promise.resolve(false), @@ -68,27 +67,26 @@ describe('bounce messages', () => { }); it('should not log an error for headers', async () => { - await mockedBounces(log, {}).handleBounce( - mockMessage({ junk: 'message' }) - ); - expect(log.error.callCount).toBe(0); + await mockedBounces(log, {}).handleBounce(mockMessage({ junk: 'message' })); + expect(log.error).toHaveBeenCalledTimes(0); }); it('should log an error for missing headers', async () => { const message = mockMessage({ junk: 'message' }); message.headers = undefined; await mockedBounces(log, {}).handleBounce(message); - expect(log.error.callCount).toBe(1); + expect(log.error).toHaveBeenCalledTimes(1); }); it('should ignore unknown message types', async () => { - await mockedBounces(log, {}).handleBounce( - mockMessage({ junk: 'message' }) + await mockedBounces(log, {}).handleBounce(mockMessage({ junk: 'message' })); + expect(log.info).toHaveBeenCalledTimes(0); + expect(log.error).toHaveBeenCalledTimes(0); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith( + 'emailHeaders.keys', + expect.anything() ); - expect(log.info.callCount).toBe(0); - expect(log.error.callCount).toBe(0); - expect(log.warn.callCount).toBe(1); - expect(log.warn.args[0][0]).toBe('emailHeaders.keys'); }); it('should record metrics about bounce type', async () => { @@ -104,8 +102,8 @@ describe('bounce messages', () => { }, }) ); - expect(statsd.increment.callCount).toBe(1); - sinon.assert.calledWith(statsd.increment, 'email.bounce.message', { + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith('email.bounce.message', { bounceType, bounceSubType: 'none', hasDiagnosticCode: false, @@ -125,12 +123,15 @@ describe('bounce messages', () => { }, }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(mockDB.createEmailBounce.callCount).toBe(2); - expect(mockDB.accountRecord.callCount).toBe(2); - expect(mockDB.deleteAccount.callCount).toBe(2); - expect(mockDB.accountRecord.args[0][0]).toBe('test@example.com'); - expect(mockDB.accountRecord.args[1][0]).toBe('foobar@example.com'); - expect(mockMsg.del.callCount).toBe(1); + expect(mockDB.createEmailBounce).toHaveBeenCalledTimes(2); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(2); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(2); + expect(mockDB.accountRecord).toHaveBeenNthCalledWith(1, 'test@example.com'); + expect(mockDB.accountRecord).toHaveBeenNthCalledWith( + 2, + 'foobar@example.com' + ); + expect(mockMsg.del).toHaveBeenCalledTimes(1); }); it('should not delete account when account delete is disabled', async () => { @@ -145,9 +146,9 @@ describe('bounce messages', () => { }, }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(mockDB.deleteAccount.callCount).toBe(0); - expect(mockMsg.del.callCount).toBe(1); - sinon.assert.calledWith(log.debug, 'accountNotDeleted', { + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(0); + expect(mockMsg.del).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalledWith('accountNotDeleted', { uid: '123456', email: 'test@example.com', accountDeleteEnabled: false, @@ -170,13 +171,13 @@ describe('bounce messages', () => { }, }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(mockDB.deleteAccount.callCount).toBe(1); - expect(mockMsg.del.callCount).toBe(1); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockMsg.del).toHaveBeenCalledTimes(1); }); it('should not delete account that bounces and is older than 6 hours', async () => { const SEVEN_HOURS_AGO = Date.now() - 1000 * 60 * 60 * 7; - mockDB.accountRecord = sinon.spy((email: string) => { + mockDB.accountRecord = jest.fn((email: string) => { return Promise.resolve({ createdAt: SEVEN_HOURS_AGO, uid: '123456', @@ -195,13 +196,13 @@ describe('bounce messages', () => { }, }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(mockDB.deleteAccount.callCount).toBe(0); - expect(mockMsg.del.callCount).toBe(1); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(0); + expect(mockMsg.del).toHaveBeenCalledTimes(1); }); it('should delete account that bounces and is younger than 6 hours', async () => { const FOUR_HOURS_AGO = Date.now() - 1000 * 60 * 60 * 5; - mockDB.accountRecord = sinon.spy((email: string) => { + mockDB.accountRecord = jest.fn((email: string) => { return Promise.resolve({ createdAt: FOUR_HOURS_AGO, uid: '123456', @@ -220,8 +221,8 @@ describe('bounce messages', () => { }, }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(mockDB.deleteAccount.callCount).toBe(1); - expect(mockMsg.del.callCount).toBe(1); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockMsg.del).toHaveBeenCalledTimes(1); }); it('should delete accounts on login verification with a Transient bounce', async () => { @@ -235,8 +236,8 @@ describe('bounce messages', () => { }, }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(mockDB.deleteAccount.callCount).toBe(1); - expect(mockMsg.del.callCount).toBe(1); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockMsg.del).toHaveBeenCalledTimes(1); }); it('should treat complaints like bounces', async () => { @@ -253,28 +254,40 @@ describe('bounce messages', () => { }, }) ); - expect(mockDB.createEmailBounce.callCount).toBe(2); - expect(mockDB.createEmailBounce.args[0][0].bounceType).toBe('Complaint'); - expect(mockDB.createEmailBounce.args[0][0].bounceSubType).toBe( - complaintType - ); - expect(mockDB.accountRecord.callCount).toBe(2); - expect(mockDB.deleteAccount.callCount).toBe(2); - expect(mockDB.accountRecord.args[0][0]).toBe('test@example.com'); - expect(mockDB.accountRecord.args[1][0]).toBe('foobar@example.com'); - expect(log.info.callCount).toBe(6); - expect(log.info.args[0][0]).toBe('emailEvent'); - expect(log.info.args[0][1].domain).toBe('other'); - expect(log.info.args[0][1].type).toBe('bounced'); - expect(log.info.args[4][1].complaint).toBe(true); - expect(log.info.args[4][1].complaintFeedbackType).toBe(complaintType); - expect(log.info.args[4][1].complaintUserAgent).toBe( - 'AnyCompany Feedback Loop (V0.01)' + expect(mockDB.createEmailBounce).toHaveBeenCalledTimes(2); + expect(mockDB.createEmailBounce).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + bounceType: 'Complaint', + bounceSubType: complaintType, + }) + ); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(2); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(2); + expect(mockDB.accountRecord).toHaveBeenNthCalledWith(1, 'test@example.com'); + expect(mockDB.accountRecord).toHaveBeenNthCalledWith( + 2, + 'foobar@example.com' + ); + expect(log.info).toHaveBeenCalledTimes(6); + expect(log.info).toHaveBeenNthCalledWith( + 1, + 'emailEvent', + expect.objectContaining({ domain: 'other', type: 'bounced' }) + ); + expect(log.info).toHaveBeenNthCalledWith( + 5, + expect.anything(), + expect.objectContaining({ + complaint: true, + complaintFeedbackType: complaintType, + complaintUserAgent: 'AnyCompany Feedback Loop (V0.01)', + }) ); }); it('should not delete verified accounts on bounce', async () => { - mockDB.accountRecord = sinon.spy((email: string) => { + mockDB.accountRecord = jest.fn((email: string) => { return Promise.resolve({ createdAt: Date.now(), uid: '123456', @@ -299,32 +312,47 @@ describe('bounce messages', () => { }, }) ); - expect(mockDB.accountRecord.callCount).toBe(2); - expect(mockDB.accountRecord.args[0][0]).toBe('test@example.com'); - expect(mockDB.accountRecord.args[1][0]).toBe('verified@example.com'); - expect(mockDB.deleteAccount.callCount).toBe(1); - expect(mockDB.deleteAccount.args[0][0].email).toBe('test@example.com'); - expect(log.info.callCount).toBe(5); - expect(log.info.args[1][0]).toBe('handleBounce'); - expect(log.info.args[1][1].email).toBe('test@example.com'); - expect(log.info.args[1][1].domain).toBe('other'); - expect(log.info.args[1][1].mailStatus).toBe('5.0.0'); - expect(log.info.args[1][1].action).toBe('failed'); - expect(log.info.args[1][1].diagnosticCode).toBe( - 'smtp; 550 user unknown' - ); - expect(log.info.args[2][0]).toBe('accountDeleted'); - expect(log.info.args[2][1].email).toBe('test@example.com'); - expect(log.info.args[4][0]).toBe('handleBounce'); - expect(log.info.args[4][1].email).toBe('verified@example.com'); - expect(log.info.args[4][1].mailStatus).toBe('4.0.0'); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(2); + expect(mockDB.accountRecord).toHaveBeenNthCalledWith(1, 'test@example.com'); + expect(mockDB.accountRecord).toHaveBeenNthCalledWith( + 2, + 'verified@example.com' + ); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockDB.deleteAccount).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test@example.com' }) + ); + expect(log.info).toHaveBeenCalledTimes(5); + expect(log.info).toHaveBeenNthCalledWith( + 2, + 'handleBounce', + expect.objectContaining({ + email: 'test@example.com', + domain: 'other', + mailStatus: '5.0.0', + action: 'failed', + diagnosticCode: 'smtp; 550 user unknown', + }) + ); + expect(log.info).toHaveBeenNthCalledWith( + 3, + 'accountDeleted', + expect.objectContaining({ email: 'test@example.com' }) + ); + expect(log.info).toHaveBeenNthCalledWith( + 5, + 'handleBounce', + expect.objectContaining({ + email: 'verified@example.com', + mailStatus: '4.0.0', + }) + ); }); it('should not delete an unverified account that bounces, is older than 6 hours but has an active subscription', async () => { - mockStripeHelper.hasActiveSubscription = async () => - Promise.resolve(true); + mockStripeHelper.hasActiveSubscription = async () => Promise.resolve(true); const SEVEN_HOURS_AGO = Date.now() - 1000 * 60 * 60 * 7; - mockDB.accountRecord = sinon.spy((email: string) => { + mockDB.accountRecord = jest.fn((email: string) => { return Promise.resolve({ createdAt: SEVEN_HOURS_AGO, uid: '123456', @@ -343,12 +371,12 @@ describe('bounce messages', () => { }, }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(mockDB.deleteAccount.callCount).toBe(0); - expect(mockMsg.del.callCount).toBe(1); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(0); + expect(mockMsg.del).toHaveBeenCalledTimes(1); }); it('should log errors when looking up the email record', async () => { - mockDB.accountRecord = sinon.spy(() => Promise.reject(new error({}))); + mockDB.accountRecord = jest.fn(() => Promise.reject(new error({}))); const mockMsg = mockMessage({ bounce: { bounceType: 'Permanent', @@ -356,19 +384,25 @@ describe('bounce messages', () => { }, }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(mockDB.accountRecord.callCount).toBe(1); - expect(mockDB.accountRecord.args[0][0]).toBe('test@example.com'); - expect(log.info.callCount).toBe(2); - expect(log.info.args[1][0]).toBe('handleBounce'); - expect(log.info.args[1][1].email).toBe('test@example.com'); - expect(log.error.callCount).toBe(2); - expect(log.error.args[1][0]).toBe('databaseError'); - expect(log.error.args[1][1].email).toBe('test@example.com'); - expect(mockMsg.del.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDB.accountRecord).toHaveBeenCalledWith('test@example.com'); + expect(log.info).toHaveBeenCalledTimes(2); + expect(log.info).toHaveBeenNthCalledWith( + 2, + 'handleBounce', + expect.objectContaining({ email: 'test@example.com' }) + ); + expect(log.error).toHaveBeenCalledTimes(2); + expect(log.error).toHaveBeenNthCalledWith( + 2, + 'databaseError', + expect.objectContaining({ email: 'test@example.com' }) + ); + expect(mockMsg.del).toHaveBeenCalledTimes(1); }); it('should log errors when deleting the email record', async () => { - mockDB.deleteAccount = sinon.spy(() => + mockDB.deleteAccount = jest.fn(() => Promise.reject(error.unknownAccount('test@example.com')) ); const mockMsg = mockMessage({ @@ -378,22 +412,32 @@ describe('bounce messages', () => { }, }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(mockDB.accountRecord.callCount).toBe(1); - expect(mockDB.accountRecord.args[0][0]).toBe('test@example.com'); - expect(mockDB.deleteAccount.callCount).toBe(1); - expect(mockDB.deleteAccount.args[0][0].email).toBe('test@example.com'); - expect(log.info.callCount).toBe(2); - expect(log.info.args[1][0]).toBe('handleBounce'); - expect(log.info.args[1][1].email).toBe('test@example.com'); - expect(log.error.callCount).toBe(2); - expect(log.error.args[1][0]).toBe('databaseError'); - expect(log.error.args[1][1].email).toBe('test@example.com'); - expect(log.error.args[1][1].err.errno).toBe(error.ERRNO.ACCOUNT_UNKNOWN); - expect(mockMsg.del.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDB.accountRecord).toHaveBeenCalledWith('test@example.com'); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockDB.deleteAccount).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test@example.com' }) + ); + expect(log.info).toHaveBeenCalledTimes(2); + expect(log.info).toHaveBeenNthCalledWith( + 2, + 'handleBounce', + expect.objectContaining({ email: 'test@example.com' }) + ); + expect(log.error).toHaveBeenCalledTimes(2); + expect(log.error).toHaveBeenNthCalledWith( + 2, + 'databaseError', + expect.objectContaining({ + email: 'test@example.com', + err: expect.objectContaining({ errno: error.ERRNO.ACCOUNT_UNKNOWN }), + }) + ); + expect(mockMsg.del).toHaveBeenCalledTimes(1); }); it('should normalize quoted email addresses for lookup', async () => { - mockDB.accountRecord = sinon.spy((email: string) => { + mockDB.accountRecord = jest.fn((email: string) => { if (email !== 'test.@example.com') { return Promise.reject(error.unknownAccount(email)); } @@ -412,20 +456,20 @@ describe('bounce messages', () => { }, }) ); - expect(mockDB.createEmailBounce.callCount).toBe(1); - expect(mockDB.createEmailBounce.args[0][0].email).toBe( - 'test.@example.com' + expect(mockDB.createEmailBounce).toHaveBeenCalledTimes(1); + expect(mockDB.createEmailBounce).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test.@example.com' }) ); - expect(mockDB.accountRecord.callCount).toBe(1); - expect(mockDB.accountRecord.args[0][0]).toBe('test.@example.com'); - expect(mockDB.deleteAccount.callCount).toBe(1); - expect(mockDB.deleteAccount.args[0][0].email).toBe( - 'test.@example.com' + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDB.accountRecord).toHaveBeenCalledWith('test.@example.com'); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockDB.deleteAccount).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test.@example.com' }) ); }); it('should handle multiple consecutive dots even if not quoted', async () => { - mockDB.accountRecord = sinon.spy((email: string) => { + mockDB.accountRecord = jest.fn((email: string) => { if (email !== 'test..me@example.com') { return Promise.reject(error.unknownAccount(email)); } @@ -445,37 +489,39 @@ describe('bounce messages', () => { }, }) ); - expect(mockDB.createEmailBounce.callCount).toBe(1); - expect(mockDB.createEmailBounce.args[0][0].email).toBe( - 'test..me@example.com' + expect(mockDB.createEmailBounce).toHaveBeenCalledTimes(1); + expect(mockDB.createEmailBounce).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test..me@example.com' }) ); - expect(mockDB.accountRecord.callCount).toBe(1); - expect(mockDB.accountRecord.args[0][0]).toBe('test..me@example.com'); - expect(mockDB.deleteAccount.callCount).toBe(1); - expect(mockDB.deleteAccount.args[0][0].email).toBe( - 'test..me@example.com' + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDB.accountRecord).toHaveBeenCalledWith('test..me@example.com'); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockDB.deleteAccount).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test..me@example.com' }) ); }); it('should log a warning if it receives an unparseable email address', async () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); await mockedBounces(log, mockDB).handleBounce( mockMessage({ bounce: { bounceType: 'Permanent', - bouncedRecipients: [ - { emailAddress: 'how did this even happen?' }, - ], + bouncedRecipients: [{ emailAddress: 'how did this even happen?' }], }, }) ); - expect(mockDB.createEmailBounce.callCount).toBe(0); - expect(mockDB.accountRecord.callCount).toBe(0); - expect(mockDB.deleteAccount.callCount).toBe(0); - expect(log.warn.callCount).toBe(2); - expect(log.warn.args[1][0]).toBe('handleBounce.addressParseFailure'); + expect(mockDB.createEmailBounce).toHaveBeenCalledTimes(0); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(0); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(0); + expect(log.warn).toHaveBeenCalledTimes(2); + expect(log.warn).toHaveBeenNthCalledWith( + 2, + 'handleBounce.addressParseFailure', + expect.anything() + ); }); it('should log email template name, language, and bounceType', async () => { @@ -494,17 +540,24 @@ describe('bounce messages', () => { }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(mockDB.accountRecord.callCount).toBe(1); - expect(mockDB.accountRecord.args[0][0]).toBe('test@example.com'); - expect(mockDB.deleteAccount.callCount).toBe(1); - expect(mockDB.deleteAccount.args[0][0].email).toBe('test@example.com'); - expect(log.info.callCount).toBe(3); - expect(log.info.args[1][0]).toBe('handleBounce'); - expect(log.info.args[1][1].email).toBe('test@example.com'); - expect(log.info.args[1][1].template).toBe('verifyLoginEmail'); - expect(log.info.args[1][1].bounceType).toBe('Permanent'); - expect(log.info.args[1][1].bounceSubType).toBe('General'); - expect(log.info.args[1][1].lang).toBe('db-LB'); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDB.accountRecord).toHaveBeenCalledWith('test@example.com'); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockDB.deleteAccount).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test@example.com' }) + ); + expect(log.info).toHaveBeenCalledTimes(3); + expect(log.info).toHaveBeenNthCalledWith( + 2, + 'handleBounce', + expect.objectContaining({ + email: 'test@example.com', + template: 'verifyLoginEmail', + bounceType: 'Permanent', + bounceSubType: 'General', + lang: 'db-LB', + }) + ); }); it('should emit flow metrics', async () => { @@ -525,22 +578,31 @@ describe('bounce messages', () => { }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(mockDB.accountRecord.callCount).toBe(1); - expect(mockDB.accountRecord.args[0][0]).toBe('test@example.com'); - expect(mockDB.deleteAccount.callCount).toBe(1); - expect(mockDB.deleteAccount.args[0][0].email).toBe('test@example.com'); - expect(log.flowEvent.callCount).toBe(1); - expect(log.flowEvent.args[0][0].event).toBe( - 'email.verifyLoginEmail.bounced' - ); - expect(log.flowEvent.args[0][0].flow_id).toBe('someFlowId'); - expect(log.flowEvent.args[0][0].flow_time > 0).toBe(true); - expect(log.flowEvent.args[0][0].time > 0).toBe(true); - expect(log.info.callCount).toBe(3); - expect(log.info.args[0][0]).toBe('emailEvent'); - expect(log.info.args[0][1].type).toBe('bounced'); - expect(log.info.args[0][1].template).toBe('verifyLoginEmail'); - expect(log.info.args[0][1].flow_id).toBe('someFlowId'); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDB.accountRecord).toHaveBeenCalledWith('test@example.com'); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockDB.deleteAccount).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test@example.com' }) + ); + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.flowEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'email.verifyLoginEmail.bounced', + flow_id: 'someFlowId', + }) + ); + expect(log.flowEvent.mock.calls[0][0].flow_time > 0).toBe(true); + expect(log.flowEvent.mock.calls[0][0].time > 0).toBe(true); + expect(log.info).toHaveBeenCalledTimes(3); + expect(log.info).toHaveBeenNthCalledWith( + 1, + 'emailEvent', + expect.objectContaining({ + type: 'bounced', + template: 'verifyLoginEmail', + flow_id: 'someFlowId', + }) + ); }); it('should log email domain if popular one', async () => { @@ -561,28 +623,38 @@ describe('bounce messages', () => { }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - expect(log.flowEvent.callCount).toBe(1); - expect(log.flowEvent.args[0][0].event).toBe( - 'email.verifyLoginEmail.bounced' - ); - expect(log.flowEvent.args[0][0].flow_id).toBe('someFlowId'); - expect(log.flowEvent.args[0][0].flow_time > 0).toBe(true); - expect(log.flowEvent.args[0][0].time > 0).toBe(true); - expect(log.info.callCount).toBe(3); - expect(log.info.args[0][0]).toBe('emailEvent'); - expect(log.info.args[0][1].domain).toBe('aol.com'); - expect(log.info.args[0][1].type).toBe('bounced'); - expect(log.info.args[0][1].template).toBe('verifyLoginEmail'); - expect(log.info.args[0][1].locale).toBe('en'); - expect(log.info.args[0][1].flow_id).toBe('someFlowId'); - expect(log.info.args[1][1].email).toBe('test@aol.com'); - expect(log.info.args[1][1].domain).toBe('aol.com'); + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.flowEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'email.verifyLoginEmail.bounced', + flow_id: 'someFlowId', + }) + ); + expect(log.flowEvent.mock.calls[0][0].flow_time > 0).toBe(true); + expect(log.flowEvent.mock.calls[0][0].time > 0).toBe(true); + expect(log.info).toHaveBeenCalledTimes(3); + expect(log.info).toHaveBeenNthCalledWith( + 1, + 'emailEvent', + expect.objectContaining({ + domain: 'aol.com', + type: 'bounced', + template: 'verifyLoginEmail', + locale: 'en', + flow_id: 'someFlowId', + }) + ); + expect(log.info).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ email: 'test@aol.com', domain: 'aol.com' }) + ); }); it('should log account email event (emailBounced)', async () => { - const stub = sinon - .stub(emailHelpers, 'logAccountEventFromMessage') - .returns(Promise.resolve()); + const stub = jest + .spyOn(emailHelpers, 'logAccountEventFromMessage') + .mockReturnValue(Promise.resolve()); const mockMsg = mockMessage({ bounce: { bounceType: 'Permanent', @@ -600,11 +672,11 @@ describe('bounce messages', () => { }); await mockedBounces(log, mockDB).handleBounce(mockMsg); - sinon.assert.calledOnceWithExactly( - emailHelpers.logAccountEventFromMessage, + expect(emailHelpers.logAccountEventFromMessage).toHaveBeenCalledTimes(1); + expect(emailHelpers.logAccountEventFromMessage).toHaveBeenCalledWith( mockMsg, 'emailBounced' ); - stub.restore(); + stub.mockRestore(); }); }); diff --git a/packages/fxa-auth-server/lib/email/delivery-delay.spec.ts b/packages/fxa-auth-server/lib/email/delivery-delay.spec.ts index 7d900828c94..a732482288e 100644 --- a/packages/fxa-auth-server/lib/email/delivery-delay.spec.ts +++ b/packages/fxa-auth-server/lib/email/delivery-delay.spec.ts @@ -3,20 +3,18 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { EventEmitter } from 'events'; -import sinon from 'sinon'; const { mockLog, mockStatsd } = require('../../test/mocks'); const emailHelpers = require('./utils/helpers'); const deliveryDelay = require('./delivery-delay'); -let sandbox: sinon.SinonSandbox; const mockDeliveryDelayQueue = new EventEmitter() as EventEmitter & { start: () => void; }; (mockDeliveryDelayQueue as any).start = function start() {}; function mockMessage(msg: any) { - msg.del = sandbox.spy(); + msg.del = jest.fn(); msg.headers = msg.headers || {}; return msg; } @@ -43,12 +41,10 @@ function mockedDeliveryDelay(log: any, statsd: any) { } describe('delivery delay messages', () => { - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); + beforeEach(() => {}); afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); it('should not log an error for headers', async () => { @@ -57,7 +53,7 @@ describe('delivery delay messages', () => { await mockedDeliveryDelay(log, statsd).handleDeliveryDelay( mockMessage({ junk: 'message' }) ); - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('should log an error for missing headers', async () => { @@ -66,7 +62,7 @@ describe('delivery delay messages', () => { const message = mockMessage({ junk: 'message' }); message.headers = undefined; await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(message); - expect(log.error.callCount).toBe(1); + expect(log.error).toHaveBeenCalledTimes(1); }); it('should log delivery delay with all fields', async () => { @@ -96,8 +92,8 @@ describe('delivery delay messages', () => { await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(mockMsg); - sinon.assert.calledOnceWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( 'email.deliveryDelay.message', { delayType: 'TransientCommunicationFailure', @@ -106,18 +102,19 @@ describe('delivery delay messages', () => { } ); - const loggedData = log.info.args[0][1]; - expect(log.info.args[0][0]).toBe('handleDeliveryDelay'); - expect(loggedData).toMatchObject({ - email: 'recipient@example.com', - domain: 'other', - delayType: 'TransientCommunicationFailure', - status: '4.4.7', - template: 'verifyLoginEmail', - lang: 'en', - expirationTime: '2023-12-18T14:59:38.237Z', - reportingMTA: 'a1-23.smtp-out.amazonses.com', - }); + expect(log.info).toHaveBeenCalledWith( + 'handleDeliveryDelay', + expect.objectContaining({ + email: 'recipient@example.com', + domain: 'other', + delayType: 'TransientCommunicationFailure', + status: '4.4.7', + template: 'verifyLoginEmail', + lang: 'en', + expirationTime: '2023-12-18T14:59:38.237Z', + reportingMTA: 'a1-23.smtp-out.amazonses.com', + }) + ); }); it('should handle delivery delay with notificationType', async () => { @@ -136,17 +133,23 @@ describe('delivery delay messages', () => { await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(mockMsg); - expect(statsd.increment.args[0][1].delayType).toBe('MailboxFull'); - expect(log.info.args[0][1]).toMatchObject({ - email: 'user@example.com', - status: '4.2.2', - }); + expect(statsd.increment).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ delayType: 'MailboxFull' }) + ); + expect(log.info).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + email: 'user@example.com', + status: '4.2.2', + }) + ); }); it('should log account email event (emailDelayed)', async () => { - sandbox - .stub(emailHelpers, 'logAccountEventFromMessage') - .returns(Promise.resolve()); + jest + .spyOn(emailHelpers, 'logAccountEventFromMessage') + .mockReturnValue(Promise.resolve()); const log = mockLog(); const statsd = mockStatsd(); const mockMsg = createDeliveryDelayMessage({ @@ -158,8 +161,8 @@ describe('delivery delay messages', () => { }); await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(mockMsg); - sinon.assert.calledOnceWithExactly( - emailHelpers.logAccountEventFromMessage, + expect(emailHelpers.logAccountEventFromMessage).toHaveBeenCalledTimes(1); + expect(emailHelpers.logAccountEventFromMessage).toHaveBeenCalledWith( mockMsg, 'emailDelayed' ); @@ -177,7 +180,10 @@ describe('delivery delay messages', () => { await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(mockMsg); - expect(log.info.args[0][1].domain).toBe('yahoo.com'); + expect(log.info).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ domain: 'yahoo.com' }) + ); }); it('should handle missing delayedRecipients gracefully', async () => { @@ -192,9 +198,9 @@ describe('delivery delay messages', () => { await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(mockMsg); - sinon.assert.calledOnce(statsd.increment); - expect(log.info.callCount).toBe(0); - sinon.assert.calledOnce(mockMsg.del); + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledTimes(0); + expect(mockMsg.del).toHaveBeenCalledTimes(1); }); it('should handle errors and still delete message', async () => { @@ -202,18 +208,22 @@ describe('delivery delay messages', () => { const statsd = mockStatsd(); const mockMsg = createDeliveryDelayMessage(); - sandbox - .stub(emailHelpers, 'getAnonymizedEmailDomain') - .throws(new Error('Test error')); + jest + .spyOn(emailHelpers, 'getAnonymizedEmailDomain') + .mockImplementation(() => { + throw new Error('Test error'); + }); await mockedDeliveryDelay(log, statsd).handleDeliveryDelay(mockMsg); - sinon.assert.calledWith(log.error, 'handleDeliveryDelay.error'); - expect(log.error.args[0][1]).toMatchObject({ - messageId: 'test-message-id', - }); + expect(log.error).toHaveBeenCalledWith( + 'handleDeliveryDelay.error', + expect.objectContaining({ + messageId: 'test-message-id', + }) + ); - sinon.assert.calledWith(statsd.increment, 'email.deliveryDelay.error'); - sinon.assert.calledOnce(mockMsg.del); + expect(statsd.increment).toHaveBeenCalledWith('email.deliveryDelay.error'); + expect(mockMsg.del).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/fxa-auth-server/lib/email/delivery.spec.ts b/packages/fxa-auth-server/lib/email/delivery.spec.ts index 9d9bb0428b9..a6daabe0c6f 100644 --- a/packages/fxa-auth-server/lib/email/delivery.spec.ts +++ b/packages/fxa-auth-server/lib/email/delivery.spec.ts @@ -3,21 +3,19 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { EventEmitter } from 'events'; -import sinon from 'sinon'; const { mockLog, mockGlean } = require('../../test/mocks'); const emailHelpers = require('./utils/helpers'); const delivery = require('./delivery'); const { requestForGlean } = require('../inactive-accounts'); -let sandbox: sinon.SinonSandbox; const mockDeliveryQueue = new EventEmitter() as EventEmitter & { start: () => void; }; (mockDeliveryQueue as any).start = function start() {}; function mockMessage(msg: any) { - msg.del = sandbox.spy(); + msg.del = jest.fn(); msg.headers = {}; return msg; } @@ -27,12 +25,10 @@ function mockedDelivery(log: any, glean: any) { } describe('delivery messages', () => { - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); + beforeEach(() => {}); afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); it('should not log an error for headers', async () => { @@ -41,7 +37,7 @@ describe('delivery messages', () => { await mockedDelivery(log, glean).handleDelivery( mockMessage({ junk: 'message' }) ); - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('should log an error for missing headers', async () => { @@ -52,7 +48,7 @@ describe('delivery messages', () => { }); message.headers = undefined; await mockedDelivery(log, glean).handleDelivery(message); - expect(log.error.callCount).toBe(1); + expect(log.error).toHaveBeenCalledTimes(1); }); it('should ignore unknown message types', async () => { @@ -63,8 +59,11 @@ describe('delivery messages', () => { junk: 'message', }) ); - expect(log.warn.callCount).toBe(1); - expect(log.warn.args[0][0]).toBe('emailHeaders.keys'); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith( + 'emailHeaders.keys', + expect.anything() + ); }); it('should log delivery', async () => { @@ -91,15 +90,25 @@ describe('delivery messages', () => { }); await mockedDelivery(log, glean).handleDelivery(mockMsg); - expect(log.info.callCount).toBe(2); - expect(log.info.args[0][0]).toBe('emailEvent'); - expect(log.info.args[0][1].domain).toBe('other'); - expect(log.info.args[0][1].type).toBe('delivered'); - expect(log.info.args[0][1].template).toBe('verifyLoginEmail'); - expect(log.info.args[1][1].email).toBe('jane@example.com'); - expect(log.info.args[1][0]).toBe('handleDelivery'); - expect(log.info.args[1][1].template).toBe('verifyLoginEmail'); - expect(log.info.args[1][1].processingTimeMillis).toBe(546); + expect(log.info).toHaveBeenCalledTimes(2); + expect(log.info).toHaveBeenNthCalledWith( + 1, + 'emailEvent', + expect.objectContaining({ + domain: 'other', + type: 'delivered', + template: 'verifyLoginEmail', + }) + ); + expect(log.info).toHaveBeenNthCalledWith( + 2, + 'handleDelivery', + expect.objectContaining({ + email: 'jane@example.com', + template: 'verifyLoginEmail', + processingTimeMillis: 546, + }) + ); }); it('should emit flow metrics', async () => { @@ -138,21 +147,31 @@ describe('delivery messages', () => { }); await mockedDelivery(log, glean).handleDelivery(mockMsg); - expect(log.flowEvent.callCount).toBe(1); - expect(log.flowEvent.args[0][0].event).toBe( - 'email.verifyLoginEmail.delivered' + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.flowEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'email.verifyLoginEmail.delivered', + flow_id: 'someFlowId', + }) + ); + expect(log.flowEvent.mock.calls[0][0].flow_time > 0).toBe(true); + expect(log.flowEvent.mock.calls[0][0].time > 0).toBe(true); + expect(log.info).toHaveBeenCalledTimes(2); + expect(log.info).toHaveBeenNthCalledWith( + 1, + 'emailEvent', + expect.objectContaining({ + domain: 'other', + type: 'delivered', + template: 'verifyLoginEmail', + flow_id: 'someFlowId', + }) + ); + expect(log.info).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ email: 'jane@example.com', domain: 'other' }) ); - expect(log.flowEvent.args[0][0].flow_id).toBe('someFlowId'); - expect(log.flowEvent.args[0][0].flow_time > 0).toBe(true); - expect(log.flowEvent.args[0][0].time > 0).toBe(true); - expect(log.info.callCount).toBe(2); - expect(log.info.args[0][0]).toBe('emailEvent'); - expect(log.info.args[0][1].domain).toBe('other'); - expect(log.info.args[0][1].type).toBe('delivered'); - expect(log.info.args[0][1].template).toBe('verifyLoginEmail'); - expect(log.info.args[0][1].flow_id).toBe('someFlowId'); - expect(log.info.args[1][1].email).toBe('jane@example.com'); - expect(log.info.args[1][1].domain).toBe('other'); }); it('should log popular email domain', async () => { @@ -191,28 +210,38 @@ describe('delivery messages', () => { }); await mockedDelivery(log, glean).handleDelivery(mockMsg); - expect(log.flowEvent.callCount).toBe(1); - expect(log.flowEvent.args[0][0].event).toBe( - 'email.verifyLoginEmail.delivered' + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.flowEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'email.verifyLoginEmail.delivered', + flow_id: 'someFlowId', + }) + ); + expect(log.flowEvent.mock.calls[0][0].flow_time > 0).toBe(true); + expect(log.flowEvent.mock.calls[0][0].time > 0).toBe(true); + expect(log.info).toHaveBeenCalledTimes(2); + expect(log.info).toHaveBeenNthCalledWith( + 1, + 'emailEvent', + expect.objectContaining({ + domain: 'aol.com', + type: 'delivered', + template: 'verifyLoginEmail', + locale: 'en', + flow_id: 'someFlowId', + }) + ); + expect(log.info).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ email: 'jane@aol.com', domain: 'aol.com' }) ); - expect(log.flowEvent.args[0][0].flow_id).toBe('someFlowId'); - expect(log.flowEvent.args[0][0].flow_time > 0).toBe(true); - expect(log.flowEvent.args[0][0].time > 0).toBe(true); - expect(log.info.callCount).toBe(2); - expect(log.info.args[0][0]).toBe('emailEvent'); - expect(log.info.args[0][1].domain).toBe('aol.com'); - expect(log.info.args[0][1].type).toBe('delivered'); - expect(log.info.args[0][1].template).toBe('verifyLoginEmail'); - expect(log.info.args[0][1].locale).toBe('en'); - expect(log.info.args[0][1].flow_id).toBe('someFlowId'); - expect(log.info.args[1][1].email).toBe('jane@aol.com'); - expect(log.info.args[1][1].domain).toBe('aol.com'); }); it('should log account email event (emailDelivered)', async () => { - sandbox - .stub(emailHelpers, 'logAccountEventFromMessage') - .returns(Promise.resolve()); + jest + .spyOn(emailHelpers, 'logAccountEventFromMessage') + .mockReturnValue(Promise.resolve()); const log = mockLog(); const glean = mockGlean(); const mockMsg = mockMessage({ @@ -248,8 +277,8 @@ describe('delivery messages', () => { }); await mockedDelivery(log, glean).handleDelivery(mockMsg); - sinon.assert.calledOnceWithExactly( - emailHelpers.logAccountEventFromMessage, + expect(emailHelpers.logAccountEventFromMessage).toHaveBeenCalledTimes(1); + expect(emailHelpers.logAccountEventFromMessage).toHaveBeenCalledWith( mockMsg, 'emailDelivered' ); @@ -291,7 +320,8 @@ describe('delivery messages', () => { }); await mockedDelivery(log, glean).handleDelivery(mockMsg); - sinon.assert.calledOnceWithExactly(glean.emailDelivery.success, requestForGlean, { + expect(glean.emailDelivery.success).toHaveBeenCalledTimes(1); + expect(glean.emailDelivery.success).toHaveBeenCalledWith(requestForGlean, { uid: 'en', reason: 'verifyLoginEmail', }); diff --git a/packages/fxa-auth-server/lib/email/notifications.spec.ts b/packages/fxa-auth-server/lib/email/notifications.spec.ts index 0244631b703..5265a5a132b 100644 --- a/packages/fxa-auth-server/lib/email/notifications.spec.ts +++ b/packages/fxa-auth-server/lib/email/notifications.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import Container from 'typedi'; import { StripeHelper } from '../payments/stripe'; @@ -14,7 +13,7 @@ const SIX_HOURS = 1000 * 60 * 60 * 6; describe('lib/email/notifications:', () => { let now: number, - del: sinon.SinonSpy, + del: jest.Mock, log: any, queue: any, emailRecord: any, @@ -27,38 +26,38 @@ describe('lib/email/notifications:', () => { }; Container.set(StripeHelper, mockStripeHelper); now = Date.now(); - sinon.stub(Date, 'now').callsFake(() => now); - del = sinon.spy(); + jest.spyOn(Date, 'now').mockImplementation(() => now); + del = jest.fn(); log = mockLog(); queue = { - start: sinon.spy(), - on: sinon.spy(), + start: jest.fn(), + on: jest.fn(), }; emailRecord = { emailVerified: false, createdAt: now - SIX_HOURS - 1, }; db = { - accountRecord: sinon.spy(() => Promise.resolve(emailRecord)), - deleteAccount: sinon.spy(() => Promise.resolve()), + accountRecord: jest.fn(() => Promise.resolve(emailRecord)), + deleteAccount: jest.fn(() => Promise.resolve()), }; notifications(log, error)(queue, db); }); afterEach(() => { - (Date.now as sinon.SinonStub).restore(); + (Date.now as jest.Mock).mockRestore(); Container.reset(); }); it('called queue.start', () => { - expect(queue.start.callCount).toBe(1); - expect(queue.start.args[0]).toHaveLength(0); + expect(queue.start).toHaveBeenCalledTimes(1); + expect(queue.start).toHaveBeenCalledWith(); }); it('called queue.on', () => { - expect(queue.on.callCount).toBe(1); + expect(queue.on).toHaveBeenCalledTimes(1); - const args = queue.on.args[0]; + const args = queue.on.mock.calls[0]; expect(args).toHaveLength(2); expect(args[0]).toBe('data'); expect(typeof args[1]).toBe('function'); @@ -67,7 +66,7 @@ describe('lib/email/notifications:', () => { describe('bounce message:', () => { beforeEach(() => { - return queue.on.args[0][1]({ + return queue.on.mock.calls[0][1]({ del, mail: { headers: { @@ -85,10 +84,8 @@ describe('lib/email/notifications:', () => { }); it('logged a flow event', () => { - expect(log.flowEvent.callCount).toBe(1); - const args = log.flowEvent.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.flowEvent).toHaveBeenCalledWith({ event: 'email.bar.bounced', flow_id: 'foo', flow_time: 1, @@ -97,11 +94,8 @@ describe('lib/email/notifications:', () => { }); it('logged an email event', () => { - expect(log.info.callCount).toBe(1); - const args = log.info.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('emailEvent'); - expect(args[1]).toEqual({ + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith('emailEvent', { bounced: true, domain: 'other', flow_id: 'foo', @@ -113,27 +107,25 @@ describe('lib/email/notifications:', () => { }); it('did not delete the account', () => { - expect(db.accountRecord.callCount).toBe(1); - const args = db.accountRecord.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe('wibble@example.com'); + expect(db.accountRecord).toHaveBeenCalledTimes(1); + expect(db.accountRecord).toHaveBeenCalledWith('wibble@example.com'); - expect(db.deleteAccount.callCount).toBe(0); + expect(db.deleteAccount).toHaveBeenCalledTimes(0); }); it('called message.del', () => { - expect(del.callCount).toBe(1); - expect(del.args[0]).toHaveLength(0); + expect(del).toHaveBeenCalledTimes(1); + expect(del).toHaveBeenCalledWith(); }); it('did not log an error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); describe('complaint message, 2 recipients:', () => { beforeEach(() => { - return queue.on.args[0][1]({ + return queue.on.mock.calls[0][1]({ del, mail: { headers: { @@ -150,20 +142,16 @@ describe('lib/email/notifications:', () => { }); it('logged 2 flow events', () => { - expect(log.flowEvent.callCount).toBe(2); + expect(log.flowEvent).toHaveBeenCalledTimes(2); - let args = log.flowEvent.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.flowEvent).toHaveBeenNthCalledWith(1, { event: 'email.blee.bounced', flow_id: 'wibble', flow_time: 2, time: now, }); - args = log.flowEvent.args[1]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.flowEvent).toHaveBeenNthCalledWith(2, { event: 'email.blee.bounced', flow_id: 'wibble', flow_time: 2, @@ -172,12 +160,9 @@ describe('lib/email/notifications:', () => { }); it('logged 2 email events', () => { - expect(log.info.callCount).toBe(2); + expect(log.info).toHaveBeenCalledTimes(2); - let args = log.info.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('emailEvent'); - expect(args[1]).toEqual({ + expect(log.info).toHaveBeenNthCalledWith(1, 'emailEvent', { complaint: true, domain: 'other', flow_id: 'wibble', @@ -187,10 +172,7 @@ describe('lib/email/notifications:', () => { type: 'bounced', }); - args = log.info.args[1]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('emailEvent'); - expect(args[1]).toEqual({ + expect(log.info).toHaveBeenNthCalledWith(2, 'emailEvent', { complaint: true, domain: 'gmail.com', flow_id: 'wibble', @@ -202,32 +184,26 @@ describe('lib/email/notifications:', () => { }); it('did not delete the accounts', () => { - expect(db.accountRecord.callCount).toBe(2); + expect(db.accountRecord).toHaveBeenCalledTimes(2); + expect(db.accountRecord).toHaveBeenNthCalledWith(1, 'foo@example.com'); + expect(db.accountRecord).toHaveBeenNthCalledWith(2, 'pmbooth@gmail.com'); - let args = db.accountRecord.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe('foo@example.com'); - - args = db.accountRecord.args[1]; - expect(args).toHaveLength(1); - expect(args[0]).toBe('pmbooth@gmail.com'); - - expect(db.deleteAccount.callCount).toBe(0); + expect(db.deleteAccount).toHaveBeenCalledTimes(0); }); it('called message.del', () => { - expect(del.callCount).toBe(1); + expect(del).toHaveBeenCalledTimes(1); }); it('did not log an error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); describe('bounce message, 2 recipients, new unverified account:', () => { beforeEach(() => { emailRecord.createdAt += 1; - return queue.on.args[0][1]({ + return queue.on.mock.calls[0][1]({ del, mail: { headers: { @@ -245,62 +221,44 @@ describe('lib/email/notifications:', () => { }); it('logged events', () => { - expect(log.flowEvent.callCount).toBe(2); + expect(log.flowEvent).toHaveBeenCalledTimes(2); - expect(log.info.callCount).toBe(4); + expect(log.info).toHaveBeenCalledTimes(4); - let args = log.info.args[2]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('accountDeleted'); - expect(args[1]).toEqual({ + expect(log.info).toHaveBeenNthCalledWith(3, 'accountDeleted', { emailVerified: false, createdAt: emailRecord.createdAt, }); - args = log.info.args[3]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('accountDeleted'); - expect(args[1]).toEqual({ + expect(log.info).toHaveBeenNthCalledWith(4, 'accountDeleted', { emailVerified: false, createdAt: emailRecord.createdAt, }); }); it('deleted the accounts', () => { - expect(db.accountRecord.callCount).toBe(2); - - let args = db.accountRecord.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe('wibble@example.com'); - - args = db.accountRecord.args[1]; - expect(args).toHaveLength(1); - expect(args[0]).toBe('blee@example.com'); - - expect(db.deleteAccount.callCount).toBe(2); - - args = db.deleteAccount.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe(emailRecord); + expect(db.accountRecord).toHaveBeenCalledTimes(2); + expect(db.accountRecord).toHaveBeenNthCalledWith(1, 'wibble@example.com'); + expect(db.accountRecord).toHaveBeenNthCalledWith(2, 'blee@example.com'); - args = db.deleteAccount.args[1]; - expect(args).toHaveLength(1); - expect(args[0]).toBe(emailRecord); + expect(db.deleteAccount).toHaveBeenCalledTimes(2); + expect(db.deleteAccount).toHaveBeenNthCalledWith(1, emailRecord); + expect(db.deleteAccount).toHaveBeenNthCalledWith(2, emailRecord); }); it('called message.del', () => { - expect(del.callCount).toBe(1); + expect(del).toHaveBeenCalledTimes(1); }); it('did not log an error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); describe('complaint message, new unverified account:', () => { beforeEach(() => { emailRecord.createdAt += 1; - return queue.on.args[0][1]({ + return queue.on.mock.calls[0][1]({ del, mail: { headers: { @@ -317,21 +275,21 @@ describe('lib/email/notifications:', () => { }); it('logged events', () => { - expect(log.flowEvent.callCount).toBe(1); - expect(log.info.callCount).toBe(2); + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledTimes(2); }); it('deleted the account', () => { - expect(db.accountRecord.callCount).toBe(1); - expect(db.deleteAccount.callCount).toBe(1); + expect(db.accountRecord).toHaveBeenCalledTimes(1); + expect(db.deleteAccount).toHaveBeenCalledTimes(1); }); it('called message.del', () => { - expect(del.callCount).toBe(1); + expect(del).toHaveBeenCalledTimes(1); }); it('did not log an error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); @@ -340,7 +298,7 @@ describe('lib/email/notifications:', () => { emailRecord.createdAt += 1; mockStripeHelper.hasActiveSubscription = async () => Promise.resolve(true); - return queue.on.args[0][1]({ + return queue.on.mock.calls[0][1]({ del, mail: { headers: { @@ -357,21 +315,21 @@ describe('lib/email/notifications:', () => { }); it('logged events', () => { - expect(log.flowEvent.callCount).toBe(1); - expect(log.info.callCount).toBe(1); + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledTimes(1); }); it('did not delete the account', () => { - expect(db.accountRecord.callCount).toBe(1); - expect(db.deleteAccount.callCount).toBe(0); + expect(db.accountRecord).toHaveBeenCalledTimes(1); + expect(db.deleteAccount).toHaveBeenCalledTimes(0); }); it('called message.del', () => { - expect(del.callCount).toBe(1); + expect(del).toHaveBeenCalledTimes(1); }); it('did not log an error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); @@ -379,7 +337,7 @@ describe('lib/email/notifications:', () => { beforeEach(() => { emailRecord.createdAt += 1; emailRecord.emailVerified = true; - return queue.on.args[0][1]({ + return queue.on.mock.calls[0][1]({ del, mail: { headers: { @@ -397,28 +355,28 @@ describe('lib/email/notifications:', () => { }); it('logged events', () => { - expect(log.flowEvent.callCount).toBe(1); - expect(log.info.callCount).toBe(1); + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledTimes(1); }); it('did not delete the account', () => { - expect(db.accountRecord.callCount).toBe(1); - expect(db.deleteAccount.callCount).toBe(0); + expect(db.accountRecord).toHaveBeenCalledTimes(1); + expect(db.deleteAccount).toHaveBeenCalledTimes(0); }); it('called message.del', () => { - expect(del.callCount).toBe(1); + expect(del).toHaveBeenCalledTimes(1); }); it('did not log an error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); describe('delivery message, new unverified account:', () => { beforeEach(() => { emailRecord.createdAt += 1; - return queue.on.args[0][1]({ + return queue.on.mock.calls[0][1]({ del, mail: { headers: { @@ -436,10 +394,8 @@ describe('lib/email/notifications:', () => { }); it('logged a flow event', () => { - expect(log.flowEvent.callCount).toBe(1); - const args = log.flowEvent.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.flowEvent).toHaveBeenCalledWith({ event: 'email.bar.delivered', flow_id: 'foo', flow_time: 1, @@ -448,11 +404,8 @@ describe('lib/email/notifications:', () => { }); it('logged an email event', () => { - expect(log.info.callCount).toBe(1); - const args = log.info.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('emailEvent'); - expect(args[1]).toEqual({ + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith('emailEvent', { domain: 'other', flow_id: 'foo', locale: 'en-gb', @@ -463,22 +416,22 @@ describe('lib/email/notifications:', () => { }); it('did not delete the account', () => { - expect(db.accountRecord.callCount).toBe(0); - expect(db.deleteAccount.callCount).toBe(0); + expect(db.accountRecord).toHaveBeenCalledTimes(0); + expect(db.deleteAccount).toHaveBeenCalledTimes(0); }); it('called message.del', () => { - expect(del.callCount).toBe(1); + expect(del).toHaveBeenCalledTimes(1); }); it('did not log an error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); describe('missing headers:', () => { beforeEach(() => { - return queue.on.args[0][1]({ + return queue.on.mock.calls[0][1]({ del, mail: {}, bounce: { @@ -488,26 +441,20 @@ describe('lib/email/notifications:', () => { }); it('logged an error', () => { - expect(log.error.callCount).toBeGreaterThanOrEqual(1); + expect(log.error.mock.calls.length).toBeGreaterThanOrEqual(1); - const args = log.error.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('emailHeaders.missing'); - expect(args[1]).toEqual({ + expect(log.error).toHaveBeenNthCalledWith(1, 'emailHeaders.missing', { origin: 'notification', }); }); it('did not log a flow event', () => { - expect(log.flowEvent.callCount).toBe(0); + expect(log.flowEvent).toHaveBeenCalledTimes(0); }); it('logged an email event', () => { - expect(log.info.callCount).toBe(1); - const args = log.info.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('emailEvent'); - expect(args[1]).toEqual({ + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith('emailEvent', { bounced: true, domain: 'other', locale: '', @@ -518,12 +465,12 @@ describe('lib/email/notifications:', () => { }); it('did not delete the account', () => { - expect(db.accountRecord.callCount).toBe(1); - expect(db.deleteAccount.callCount).toBe(0); + expect(db.accountRecord).toHaveBeenCalledTimes(1); + expect(db.deleteAccount).toHaveBeenCalledTimes(0); }); it('called message.del', () => { - expect(del.callCount).toBe(1); + expect(del).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/fxa-auth-server/lib/email/utils/helpers.spec.ts b/packages/fxa-auth-server/lib/email/utils/helpers.spec.ts index 5f352b52089..baf24d884a0 100644 --- a/packages/fxa-auth-server/lib/email/utils/helpers.spec.ts +++ b/packages/fxa-auth-server/lib/email/utils/helpers.spec.ts @@ -2,11 +2,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import Container from 'typedi'; import { AccountEventsManager } from '../../account-events'; -const amplitude = sinon.spy(); +const amplitude = jest.fn(); jest.mock('../../../lib/metrics/amplitude', () => () => amplitude); @@ -14,7 +13,7 @@ const { mockLog } = require('../../../test/mocks'); const emailHelpers = require('./helpers'); describe('email utils helpers', () => { - afterEach(() => amplitude.resetHistory()); + afterEach(() => amplitude.mockClear()); describe('getHeaderValue', () => { it('works with message.mail.headers', () => { @@ -59,8 +58,11 @@ describe('email utils helpers', () => { }, }; emailHelpers.logEmailEventSent(log, message); - expect(log.info.callCount).toBe(1); - expect(log.info.args[0][1].locale).toBe('ru'); + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ locale: 'ru' }) + ); }); it('should log an event per CC email', () => { @@ -71,10 +73,22 @@ describe('email utils helpers', () => { template: 'verifyEmail', }; emailHelpers.logEmailEventSent(log, message); - expect(log.info.callCount).toBe(3); - expect(log.info.args[0][1].domain).toBe('other'); - expect(log.info.args[1][1].domain).toBe('gmail.com'); - expect(log.info.args[2][1].domain).toBe('yahoo.com'); + expect(log.info).toHaveBeenCalledTimes(3); + expect(log.info).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ domain: 'other' }) + ); + expect(log.info).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ domain: 'gmail.com' }) + ); + expect(log.info).toHaveBeenNthCalledWith( + 3, + expect.anything(), + expect.objectContaining({ domain: 'yahoo.com' }) + ); }); }); @@ -93,8 +107,8 @@ describe('email utils helpers', () => { planId: 'planId', productId: 'productId', }); - expect(amplitude.callCount).toBe(1); - const args = amplitude.args[0]; + expect(amplitude).toHaveBeenCalledTimes(1); + const args = amplitude.mock.calls[0]; expect(args).toHaveLength(4); expect(args[0]).toBe('email.verifyEmail.sent'); args[1].app.devices = await args[1].app.devices; @@ -147,8 +161,8 @@ describe('email utils helpers', () => { 'bounced', 'gmail' ); - expect(amplitude.callCount).toBe(1); - const args = amplitude.args[0]; + expect(amplitude).toHaveBeenCalledTimes(1); + const args = amplitude.mock.calls[0]; expect(args).toHaveLength(4); expect(args[0]).toBe('email.verifyLoginEmail.bounced'); args[1].app.devices = await args[1].app.devices; @@ -187,13 +201,11 @@ describe('email utils helpers', () => { it('logs an error if message.mail is missing', () => { emailHelpers.logErrorIfHeadersAreWeirdOrMissing(log, {}, 'wibble'); - expect(log.error.callCount).toBe(1); - expect(log.error.args[0]).toHaveLength(2); - expect(log.error.args[0][0]).toBe('emailHeaders.missing'); - expect(log.error.args[0][1]).toEqual({ + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith('emailHeaders.missing', { origin: 'wibble', }); - expect(log.warn.callCount).toBe(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); it('logs an error if message.mail.headers is missing', () => { @@ -202,12 +214,11 @@ describe('email utils helpers', () => { { mail: {} }, 'blee' ); - expect(log.error.callCount).toBe(1); - expect(log.error.args[0][0]).toBe('emailHeaders.missing'); - expect(log.error.args[0][1]).toEqual({ + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith('emailHeaders.missing', { origin: 'blee', }); - expect(log.warn.callCount).toBe(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); it('does not log an error/warning if message.mail.headers is object and deviceId is set', () => { @@ -218,8 +229,8 @@ describe('email utils helpers', () => { }, }, }); - expect(log.error.callCount).toBe(0); - expect(log.warn.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); it('does not log an error/warning if message.mail.headers is object and deviceId is set (lowercase)', () => { @@ -230,8 +241,8 @@ describe('email utils helpers', () => { }, }, }); - expect(log.error.callCount).toBe(0); - expect(log.warn.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); it('does not log an error/warning if message.mail.headers is object and uid is set', () => { @@ -242,8 +253,8 @@ describe('email utils helpers', () => { }, }, }); - expect(log.error.callCount).toBe(0); - expect(log.warn.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); it('does not log an error/warning if message.mail.headers is object and uid is set (lowercase)', () => { @@ -254,8 +265,8 @@ describe('email utils helpers', () => { }, }, }); - expect(log.error.callCount).toBe(0); - expect(log.warn.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); it('logs a warning if message.mail.headers is object and deviceId and uid are missing', () => { @@ -273,11 +284,9 @@ describe('email utils helpers', () => { }, 'wibble' ); - expect(log.error.callCount).toBe(0); - expect(log.warn.callCount).toBe(1); - expect(log.warn.args[0]).toHaveLength(2); - expect(log.warn.args[0][0]).toBe('emailHeaders.keys'); - expect(log.warn.args[0][1]).toEqual({ + expect(log.error).toHaveBeenCalledTimes(0); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith('emailHeaders.keys', { keys: 'X-Template-Name,X-Xxx,X-Yyy,X-Zzz', template: 'foo', origin: 'wibble', @@ -294,10 +303,9 @@ describe('email utils helpers', () => { }, 'blee' ); - expect(log.error.callCount).toBe(0); - expect(log.warn.callCount).toBe(1); - expect(log.warn.args[0][0]).toBe('emailHeaders.keys'); - expect(log.warn.args[0][1]).toEqual({ + expect(log.error).toHaveBeenCalledTimes(0); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith('emailHeaders.keys', { keys: 'x-template-name', template: 'wibble', origin: 'blee', @@ -310,13 +318,12 @@ describe('email utils helpers', () => { { mail: { headers: 'foo' } }, 'wibble' ); - expect(log.error.callCount).toBe(1); - expect(log.error.args[0][0]).toBe('emailHeaders.weird'); - expect(log.error.args[0][1]).toEqual({ + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith('emailHeaders.weird', { type: 'string', origin: 'wibble', }); - expect(log.warn.callCount).toBe(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); it('logs an error if message.headers is non-object', () => { @@ -325,13 +332,12 @@ describe('email utils helpers', () => { { mail: {}, headers: 42 }, 'wibble' ); - expect(log.error.callCount).toBe(1); - expect(log.error.args[0][0]).toBe('emailHeaders.weird'); - expect(log.error.args[0][1]).toEqual({ + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith('emailHeaders.weird', { type: 'number', origin: 'wibble', }); - expect(log.warn.callCount).toBe(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); }); @@ -339,8 +345,8 @@ describe('email utils helpers', () => { let mockAccountEventsManager: any; beforeEach(() => { mockAccountEventsManager = { - recordEmailEvent: sinon.stub(), - recordSecurityEvent: sinon.stub().resolves({}), + recordEmailEvent: jest.fn(), + recordSecurityEvent: jest.fn().mockResolvedValue({}), }; Container.set(AccountEventsManager, mockAccountEventsManager); }); @@ -361,8 +367,10 @@ describe('email utils helpers', () => { }, 'emailBounced' ); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordEmailEvent, + expect(mockAccountEventsManager.recordEmailEvent).toHaveBeenCalledTimes( + 1 + ); + expect(mockAccountEventsManager.recordEmailEvent).toHaveBeenCalledWith( 'uid', { template: 'recovery', @@ -384,7 +392,7 @@ describe('email utils helpers', () => { }, 'emailBounced' ); - sinon.assert.notCalled(mockAccountEventsManager.recordEmailEvent); + expect(mockAccountEventsManager.recordEmailEvent).not.toHaveBeenCalled(); }); it('not called if firestore disable', () => { @@ -400,7 +408,7 @@ describe('email utils helpers', () => { }, 'emailBounced' ); - sinon.assert.notCalled(mockAccountEventsManager.recordEmailEvent); + expect(mockAccountEventsManager.recordEmailEvent).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/fxa-auth-server/lib/features.spec.ts b/packages/fxa-auth-server/lib/features.spec.ts index 4d76e1ad6dd..a824e5f6ac0 100644 --- a/packages/fxa-auth-server/lib/features.spec.ts +++ b/packages/fxa-auth-server/lib/features.spec.ts @@ -2,15 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - let hashResult = Array(40).fill('0').join(''); const hash = { - update: sinon.spy(), - digest: sinon.spy(() => hashResult), + update: jest.fn(), + digest: jest.fn(() => hashResult), }; const mockCrypto = { - createHash: sinon.spy(() => hash), + createHash: jest.fn(() => hash), }; jest.mock('crypto', () => mockCrypto); @@ -28,9 +26,9 @@ const features = featuresModule(config); describe('features', () => { beforeEach(() => { - mockCrypto.createHash.resetHistory(); - hash.update.resetHistory(); - hash.digest.resetHistory(); + mockCrypto.createHash.mockClear(); + hash.update.mockClear(); + hash.digest.mockClear(); }); it('interface is correct', () => { @@ -50,9 +48,9 @@ describe('features', () => { expect(features.isSampledUser(sampleRate, uid, 'foo')).toBe(true); - expect(mockCrypto.createHash.callCount).toBe(0); - expect(hash.update.callCount).toBe(0); - expect(hash.digest.callCount).toBe(0); + expect(mockCrypto.createHash).toHaveBeenCalledTimes(0); + expect(hash.update).toHaveBeenCalledTimes(0); + expect(hash.digest).toHaveBeenCalledTimes(0); }); it('isSampledUser returns false when sample rate is 0', () => { @@ -62,9 +60,9 @@ describe('features', () => { expect(features.isSampledUser(sampleRate, uid, 'foo')).toBe(false); - expect(mockCrypto.createHash.callCount).toBe(0); - expect(hash.update.callCount).toBe(0); - expect(hash.digest.callCount).toBe(0); + expect(mockCrypto.createHash).toHaveBeenCalledTimes(0); + expect(hash.update).toHaveBeenCalledTimes(0); + expect(hash.digest).toHaveBeenCalledTimes(0); }); it('isSampledUser returns true when sample rate is greater than cohort value', () => { @@ -75,23 +73,15 @@ describe('features', () => { expect(features.isSampledUser(sampleRate, uid, 'foo')).toBe(true); - expect(mockCrypto.createHash.callCount).toBe(1); - let args: any = mockCrypto.createHash.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe('sha1'); - - expect(hash.update.callCount).toBe(2); - args = hash.update.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe(uid.toString()); - args = hash.update.args[1]; - expect(args).toHaveLength(1); - expect(args[0]).toBe('foo'); - - expect(hash.digest.callCount).toBe(1); - args = hash.digest.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe('hex'); + expect(mockCrypto.createHash).toHaveBeenCalledTimes(1); + expect(mockCrypto.createHash).toHaveBeenNthCalledWith(1, 'sha1'); + + expect(hash.update).toHaveBeenCalledTimes(2); + expect(hash.update).toHaveBeenNthCalledWith(1, uid.toString()); + expect(hash.update).toHaveBeenNthCalledWith(2, 'foo'); + + expect(hash.digest).toHaveBeenCalledTimes(1); + expect(hash.digest).toHaveBeenNthCalledWith(1, 'hex'); }); it('isSampledUser returns false when sample rate equals cohort value', () => { @@ -101,11 +91,11 @@ describe('features', () => { expect(features.isSampledUser(sampleRate, uid, 'bar')).toBe(false); - expect(mockCrypto.createHash.callCount).toBe(1); - expect(hash.update.callCount).toBe(2); - expect(hash.update.args[0][0]).toBe(uid.toString()); - expect(hash.update.args[1][0]).toBe('bar'); - expect(hash.digest.callCount).toBe(1); + expect(mockCrypto.createHash).toHaveBeenCalledTimes(1); + expect(hash.update).toHaveBeenCalledTimes(2); + expect(hash.update).toHaveBeenNthCalledWith(1, uid.toString()); + expect(hash.update).toHaveBeenNthCalledWith(2, 'bar'); + expect(hash.digest).toHaveBeenCalledTimes(1); }); it('isSampledUser returns false when sample rate is less than cohort value', () => { @@ -121,15 +111,15 @@ describe('features', () => { // First 27 characters are ignored, last 13 are 0.02 * 0xfffffffffffff hashResult = '000000000000000000000000000051eb851eb852'; - mockCrypto.createHash.resetHistory(); - hash.update.resetHistory(); - hash.digest.resetHistory(); + mockCrypto.createHash.mockClear(); + hash.update.mockClear(); + hash.digest.mockClear(); expect(features.isSampledUser(sampleRate, uid, 'wibble')).toBe(true); - expect(hash.update.callCount).toBe(2); - expect(hash.update.args[0][0]).toBe(uid); - expect(hash.update.args[1][0]).toBe('wibble'); + expect(hash.update).toHaveBeenCalledTimes(2); + expect(hash.update).toHaveBeenNthCalledWith(1, uid); + expect(hash.update).toHaveBeenNthCalledWith(2, 'wibble'); }); it('isLastAccessTimeEnabledForUser', () => { diff --git a/packages/fxa-auth-server/lib/geodb.spec.ts b/packages/fxa-auth-server/lib/geodb.spec.ts index 66ab9435674..22da359f1b9 100644 --- a/packages/fxa-auth-server/lib/geodb.spec.ts +++ b/packages/fxa-auth-server/lib/geodb.spec.ts @@ -2,17 +2,15 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const knownIpLocation = require('../test/known-ip-location'); function mockLog() { return { - info: sinon.stub(), - trace: sinon.stub(), - error: sinon.stub(), - warn: sinon.stub(), - debug: sinon.stub(), + info: jest.fn(), + trace: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), }; } diff --git a/packages/fxa-auth-server/lib/google-maps-services.spec.ts b/packages/fxa-auth-server/lib/google-maps-services.spec.ts index 6d08c25da9e..656b87c5383 100644 --- a/packages/fxa-auth-server/lib/google-maps-services.spec.ts +++ b/packages/fxa-auth-server/lib/google-maps-services.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import Sentry from '@sentry/node'; import { default as Container } from 'typedi'; @@ -119,7 +118,7 @@ describe('GoogleMapsServices', () => { }); afterEach(() => { - sinon.restore(); + jest.restoreAllMocks(); Container.reset(); }); @@ -127,17 +126,17 @@ describe('GoogleMapsServices', () => { it('returns location for zip code and country', async () => { const expectedResult = 'SA'; const expectedAddress = '06369, Germany'; - googleClient.geocode = sinon.stub().resolves(geocodeResultDEZip); + googleClient.geocode = jest.fn().mockResolvedValue(geocodeResultDEZip); const actualResult = await googleMapsServices.getStateFromZip( '06369', 'DE' ); expect(actualResult).toBe(expectedResult); - expect(googleClient.geocode.calledOnce).toBe(true); - expect( - googleClient.geocode.getCall(0).args[0].params.address - ).toBe(expectedAddress); + expect(googleClient.geocode).toHaveBeenCalledTimes(1); + expect(googleClient.geocode.mock.calls[0][0].params.address).toBe( + expectedAddress + ); }); it('returns location for zip code and country if more than 1 result is returned with matching states', async () => { @@ -146,31 +145,31 @@ describe('GoogleMapsServices', () => { 'MD'; const expectedResult = 'MD'; const expectedAddress = '11111, United States of America'; - googleClient.geocode = sinon - .stub() - .resolves(geocodeResultManyMatchingStates); + googleClient.geocode = jest + .fn() + .mockResolvedValue(geocodeResultManyMatchingStates); const actualResult = await googleMapsServices.getStateFromZip( '11111', 'US' ); expect(actualResult).toBe(expectedResult); - expect(googleClient.geocode.calledOnce).toBe(true); - expect( - googleClient.geocode.getCall(0).args[0].params.address - ).toBe(expectedAddress); + expect(googleClient.geocode).toHaveBeenCalledTimes(1); + expect(googleClient.geocode.mock.calls[0][0].params.address).toBe( + expectedAddress + ); }); it('Throws error if more than 1 result is returned with mismatching states', async () => { const expectedMessage = 'Could not find unique results. (22222, Germany)'; - googleClient.geocode = sinon.stub().resolves(geocodeResultMany); + googleClient.geocode = jest.fn().mockResolvedValue(geocodeResultMany); try { await googleMapsServices.getStateFromZip('22222', 'DE'); throw new Error('Expected error to be thrown'); } catch (err) { expect( - googleMapsServices.log.error.getCall(0).args[1].error.message + googleMapsServices.log.error.mock.calls[0][1].error.message ).toBe(expectedMessage); } }); @@ -184,21 +183,23 @@ describe('GoogleMapsServices', () => { throw new Error('Expected error to be thrown'); } catch (err) { expect( - googleMapsServices.log.error.getCall(0).args[1].error.message + googleMapsServices.log.error.mock.calls[0][1].error.message ).toBe(expectedMessage); } }); it('Throws error for zip code without state', async () => { const expectedMessage = 'State could not be found. (11111, Germany)'; - googleClient.geocode = sinon.stub().resolves(geocodeResultWithoutState); + googleClient.geocode = jest + .fn() + .mockResolvedValue(geocodeResultWithoutState); try { await googleMapsServices.getStateFromZip('11111', 'DE'); throw new Error('Expected error to be thrown'); } catch (err) { expect( - googleMapsServices.log.error.getCall(0).args[1].error.message + googleMapsServices.log.error.mock.calls[0][1].error.message ).toBe(expectedMessage); } }); @@ -206,14 +207,14 @@ describe('GoogleMapsServices', () => { it('Throws error if no results were found', async () => { const expectedMessage = 'Could not find any results for address. (11111, Germany)'; - googleClient.geocode = sinon.stub().resolves(noResult); + googleClient.geocode = jest.fn().mockResolvedValue(noResult); try { await googleMapsServices.getStateFromZip('11111', 'DE'); throw new Error('Expected error to be thrown'); } catch (err) { expect( - googleMapsServices.log.error.getCall(0).args[1].error.message + googleMapsServices.log.error.mock.calls[0][1].error.message ).toBe(expectedMessage); } }); @@ -221,47 +222,53 @@ describe('GoogleMapsServices', () => { it('Throws error for bad status code', async () => { const expectedMessage = 'UNKNOWN_ERROR - An unknown error has occurred. (11111, Germany)'; - googleClient.geocode = sinon.stub().resolves(noResultWithError); + googleClient.geocode = jest.fn().mockResolvedValue(noResultWithError); - const scopeContextSpy = sinon.fake(); + const scopeContextSpy = jest.fn(); const scopeSpy = { setContext: scopeContextSpy, }; - sinon.replace(Sentry, 'withScope', ((fn: any) => fn(scopeSpy)) as any); - sinon.stub(sentryModule, 'reportSentryMessage').returns({}); + jest + .spyOn(Sentry, 'withScope') + .mockImplementation(((fn: any) => fn(scopeSpy)) as any); + jest.spyOn(sentryModule, 'reportSentryMessage').mockReturnValue({}); try { await googleMapsServices.getStateFromZip('11111', 'DE'); throw new Error('Expected error to be thrown'); } catch (err) { expect( - googleMapsServices.log.error.getCall(0).args[1].error.message + googleMapsServices.log.error.mock.calls[0][1].error.message ).toBe(expectedMessage); - expect(scopeContextSpy.calledOnce).toBe(true); - expect(sentryModule.reportSentryMessage.calledOnce).toBe(true); + expect(scopeContextSpy).toHaveBeenCalledTimes(1); + expect(sentryModule.reportSentryMessage).toHaveBeenCalledTimes(1); } }); it('Throws error when GeocodeData fails', async () => { const expectedMessage = 'Geocode is not available'; - googleClient.geocode = sinon.stub().rejects(new Error(expectedMessage)); + googleClient.geocode = jest + .fn() + .mockRejectedValue(new Error(expectedMessage)); - const scopeContextSpy = sinon.fake(); + const scopeContextSpy = jest.fn(); const scopeSpy = { setContext: scopeContextSpy, }; - sinon.replace(Sentry, 'withScope', ((fn: any) => fn(scopeSpy)) as any); - sinon.stub(sentryModule, 'reportSentryMessage').returns({}); + jest + .spyOn(Sentry, 'withScope') + .mockImplementation(((fn: any) => fn(scopeSpy)) as any); + jest.spyOn(sentryModule, 'reportSentryMessage').mockReturnValue({}); try { await googleMapsServices.getStateFromZip('11111', 'DE'); throw new Error('Expected error to be thrown'); } catch (err) { expect( - googleMapsServices.log.error.getCall(0).args[1].error.message + googleMapsServices.log.error.mock.calls[0][1].error.message ).toBe(expectedMessage); - expect(scopeContextSpy.calledOnce).toBe(true); - expect(sentryModule.reportSentryMessage.calledOnce).toBe(true); + expect(scopeContextSpy).toHaveBeenCalledTimes(1); + expect(sentryModule.reportSentryMessage).toHaveBeenCalledTimes(1); } }); }); diff --git a/packages/fxa-auth-server/lib/inactive-accounts/index.spec.ts b/packages/fxa-auth-server/lib/inactive-accounts/index.spec.ts index e97afbb75de..1be0efa2183 100644 --- a/packages/fxa-auth-server/lib/inactive-accounts/index.spec.ts +++ b/packages/fxa-auth-server/lib/inactive-accounts/index.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { EmailTypes, @@ -11,34 +10,26 @@ import { } from '@fxa/shared/cloud-tasks'; import { AppConfig } from '../types'; -// Fix proxyquire path resolution for test/mocks.js when loaded from a subdirectory. -// proxyquire uses callsite detection which resolves relative to the spec file under Jest, -// not relative to the file that calls proxyquire. From lib/inactive-accounts/, the path -// '../lib/metrics/amplitude' incorrectly resolves to lib/lib/metrics/amplitude. -jest.mock('proxyquire', () => { - const path = require('path'); - const testDir = path.resolve(__dirname, '../../test'); - return (id: string, _stubs: any) => { - const resolvedPath = path.resolve(testDir, id); - return require(resolvedPath); - }; -}); - -// Mock fxa-shared/db/models/auth with EmailBounce (chainable query builder), -// Account.metricsEnabled (needed by amplitude loaded via test/mocks.js), -// and getAccountCustomerByUid. +// Mock fxa-shared/db/models/auth with real exports plus mock EmailBounce for chaining. +// Must be before any require() that pulls this module (including test/mocks.js). +const _mockEmailBounce: any = {}; +function resetEmailBounceMock() { + for (const method of ['query', 'where', 'whereIn', 'whereNull', 'join']) { + _mockEmailBounce[method] = jest.fn().mockReturnValue(_mockEmailBounce); + } + _mockEmailBounce.distinct = jest.fn().mockResolvedValue([]); +} +resetEmailBounceMock(); jest.mock('fxa-shared/db/models/auth', () => { - const emailBounceInstance: any = {}; - emailBounceInstance.query = jest.fn().mockReturnValue(emailBounceInstance); - emailBounceInstance.where = jest.fn().mockReturnValue(emailBounceInstance); - emailBounceInstance.whereIn = jest.fn().mockReturnValue(emailBounceInstance); - emailBounceInstance.whereNull = jest.fn().mockReturnValue(emailBounceInstance); - emailBounceInstance.join = jest.fn().mockReturnValue(emailBounceInstance); - emailBounceInstance.distinct = jest.fn().mockResolvedValue([]); + const actual = jest.requireActual('fxa-shared/db/models/auth'); return { - Account: { metricsEnabled: jest.fn().mockResolvedValue(true) }, - EmailBounce: emailBounceInstance, - getAccountCustomerByUid: jest.fn(), + ...actual, + Account: { + ...actual.Account, + metricsEnabled: jest.fn().mockResolvedValue(true), + }, + EmailBounce: _mockEmailBounce, + getAccountCustomerByUid: jest.fn().mockResolvedValue(null), }; }); @@ -55,39 +46,38 @@ const mocks = require('../../test/mocks'); const now = 1736500000000; const aDayInMs = 24 * 60 * 60 * 1000; -const sandbox = sinon.createSandbox(); const mockAccount = { email: 'a@example.gg', locale: 'en', }; -const mockFxaDb = mocks.mockDB(mockAccount, sandbox); -const mockMailer = mocks.mockMailer(sandbox); +const mockFxaDb = mocks.mockDB(mockAccount); +const mockMailer = mocks.mockMailer(); const mockFxaMailer = mocks.mockFxaMailer(); -const mockStatsd = { increment: sandbox.stub() }; +const mockStatsd = { increment: jest.fn() }; const mockGlean = { inactiveAccountDeletion: { - firstEmailSkipped: sandbox.stub(), - firstEmailTaskEnqueued: sandbox.stub(), - firstEmailTaskRejected: sandbox.stub(), - firstEmailTaskRequest: sandbox.stub(), - - secondEmailSkipped: sandbox.stub(), - secondEmailTaskEnqueued: sandbox.stub(), - secondEmailTaskRejected: sandbox.stub(), - secondEmailTaskRequest: sandbox.stub(), - - finalEmailSkipped: sandbox.stub(), - finalEmailTaskEnqueued: sandbox.stub(), - finalEmailTaskRejected: sandbox.stub(), - finalEmailTaskRequest: sandbox.stub(), - - deletionScheduled: sandbox.stub(), + firstEmailSkipped: jest.fn(), + firstEmailTaskEnqueued: jest.fn(), + firstEmailTaskRejected: jest.fn(), + firstEmailTaskRequest: jest.fn(), + + secondEmailSkipped: jest.fn(), + secondEmailTaskEnqueued: jest.fn(), + secondEmailTaskRejected: jest.fn(), + secondEmailTaskRequest: jest.fn(), + + finalEmailSkipped: jest.fn(), + finalEmailTaskEnqueued: jest.fn(), + finalEmailTaskRejected: jest.fn(), + finalEmailTaskRequest: jest.fn(), + + deletionScheduled: jest.fn(), }, }; -const mockLog = mocks.mockLog(sandbox); +const mockLog = mocks.mockLog(); const mockOAuthDb = { - getRefreshTokensByUid: sandbox.stub().resolves([]), - getAccessTokensByUid: sandbox.stub().resolves([]), + getRefreshTokensByUid: jest.fn().mockResolvedValue([]), + getAccessTokensByUid: jest.fn().mockResolvedValue([]), }; const mockConfig = { authFirestore: {}, @@ -101,13 +91,13 @@ const { AccountEventsManager } = require('../account-events'); const accountEventsManager = new AccountEventsManager(); Container.set(AccountEventsManager, accountEventsManager); -const mockDeleteAccountTasks = { deleteAccount: sandbox.stub() }; +const mockDeleteAccountTasks = { deleteAccount: jest.fn() }; Container.set(DeleteAccountTasks, mockDeleteAccountTasks); const mockEmailTasks = { - scheduleFirstEmail: sandbox.stub(), - scheduleSecondEmail: sandbox.stub(), - scheduleFinalEmail: sandbox.stub(), + scheduleFirstEmail: jest.fn(), + scheduleSecondEmail: jest.fn(), + scheduleFinalEmail: jest.fn(), }; mockEmailTasksRef.current = mockEmailTasks; @@ -132,115 +122,112 @@ describe('InactiveAccountsManager', () => { }; beforeEach(() => { - mockFxaDb.account.resetHistory(); - mockOAuthDb.getRefreshTokensByUid.resetHistory(); - mockStatsd.increment.resetHistory(); - mockGlean.inactiveAccountDeletion.firstEmailSkipped.resetHistory(); - mockGlean.inactiveAccountDeletion.secondEmailSkipped.resetHistory(); - mockGlean.inactiveAccountDeletion.finalEmailSkipped.resetHistory(); - Object.values(mockEmailTasks).forEach((stub: any) => stub.resetHistory()); - (EmailBounce.distinct as jest.Mock).mockClear(); - (EmailBounce.distinct as jest.Mock).mockResolvedValue([]); - mockDeleteAccountTasks.deleteAccount.resetHistory(); - mockFxaMailer.sendInactiveAccountFirstWarningEmail.resetHistory(); - mockFxaMailer.sendInactiveAccountSecondWarningEmail.resetHistory(); - mockFxaMailer.sendInactiveAccountFinalWarningEmail.resetHistory(); - sandbox.resetHistory(); - sinon.resetHistory(); + jest.clearAllMocks(); + resetEmailBounceMock(); }); afterEach(() => { - sandbox.restore(); - sinon.restore(); + jest.restoreAllMocks(); }); describe('first email notification', () => { beforeEach(() => { - sinon.stub(Date, 'now').returns(now); + jest.spyOn(Date, 'now').mockReturnValue(now); }); afterEach(() => { - sinon.restore(); + jest.restoreAllMocks(); }); it('should skip when account is active', async () => { - const isActiveSpy = sandbox.spy(inactiveAccountManager, 'isActive'); - mockOAuthDb.getRefreshTokensByUid.resolves([ + const isActiveSpy = jest.spyOn(inactiveAccountManager, 'isActive'); + mockOAuthDb.getRefreshTokensByUid.mockResolvedValue([ { lastUsedAt: Date.now() }, ]); await inactiveAccountManager.handleNotificationTask(mockPayload); - sinon.assert.calledOnce(isActiveSpy); - sinon.assert.calledOnceWithExactly( - mockOAuthDb.getRefreshTokensByUid, + expect(isActiveSpy).toHaveBeenCalledTimes(1); + expect(mockOAuthDb.getRefreshTokensByUid).toHaveBeenCalledTimes(1); + expect(mockOAuthDb.getRefreshTokensByUid).toHaveBeenCalledWith( mockPayload.uid ); - sinon.assert.calledOnceWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.inactive.first-email.skipped.active' ); - sinon.assert.calledOnce( + expect( mockGlean.inactiveAccountDeletion.firstEmailSkipped - ); + ).toHaveBeenCalledTimes(1); expect( - mockGlean.inactiveAccountDeletion.firstEmailSkipped.args[0][1] - ).toEqual({ uid: mockPayload.uid, reason: 'active_account' }); + mockGlean.inactiveAccountDeletion.firstEmailSkipped + ).toHaveBeenNthCalledWith(1, expect.anything(), { + uid: mockPayload.uid, + reason: 'active_account', + }); }); it('should skip when email has been sent', async () => { - sinon.stub(accountEventsManager, 'findEmailEvents').resolves([{}]); + jest + .spyOn(accountEventsManager, 'findEmailEvents') + .mockResolvedValue([{}]); - sinon.stub(inactiveAccountManager, 'isActive').resolves(false); + jest.spyOn(inactiveAccountManager, 'isActive').mockResolvedValue(false); - sinon.stub(inactiveAccountManager, 'scheduleNextEmail').resolves(); + jest + .spyOn(inactiveAccountManager, 'scheduleNextEmail') + .mockResolvedValue(undefined); await inactiveAccountManager.handleNotificationTask(mockPayload); - sinon.assert.calledWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.inactive.first-email.skipped.duplicate' ); - sinon.assert.calledOnce( + expect( mockGlean.inactiveAccountDeletion.firstEmailSkipped - ); + ).toHaveBeenCalledTimes(1); expect( - mockGlean.inactiveAccountDeletion.firstEmailSkipped.args[0][1] - ).toEqual({ uid: mockPayload.uid, reason: 'already_sent' }); - sinon.assert.calledOnce(inactiveAccountManager.scheduleNextEmail); + mockGlean.inactiveAccountDeletion.firstEmailSkipped + ).toHaveBeenNthCalledWith(1, expect.anything(), { + uid: mockPayload.uid, + reason: 'already_sent', + }); + expect(inactiveAccountManager.scheduleNextEmail).toHaveBeenCalledTimes(1); }); it('should send the first email and enqueue the second', async () => { - sinon.stub(accountEventsManager, 'findEmailEvents').resolves([]); - sinon.stub(inactiveAccountManager, 'isActive').resolves(false); + jest.spyOn(accountEventsManager, 'findEmailEvents').mockResolvedValue([]); + jest.spyOn(inactiveAccountManager, 'isActive').mockResolvedValue(false); await inactiveAccountManager.handleNotificationTask(mockPayload); - sinon.assert.calledOnceWithExactly(mockFxaDb.account, mockPayload.uid); - sinon.assert.calledOnce( + expect(mockFxaDb.account).toHaveBeenCalledTimes(1); + expect(mockFxaDb.account).toHaveBeenCalledWith(mockPayload.uid); + expect( mockFxaMailer.sendInactiveAccountFirstWarningEmail - ); + ).toHaveBeenCalledTimes(1); const fxaMailerCallArgs = - mockFxaMailer.sendInactiveAccountFirstWarningEmail.getCall(0).args[0]; + mockFxaMailer.sendInactiveAccountFirstWarningEmail.mock.calls[0][0]; expect(fxaMailerCallArgs.to).toBe(mockAccount.email); expect(fxaMailerCallArgs.acceptLanguage).toBe(mockAccount.locale); expect(fxaMailerCallArgs.deletionDate).toBeDefined(); - sinon.assert.calledOnceWithExactly(mockEmailTasks.scheduleSecondEmail, { + expect(mockEmailTasks.scheduleSecondEmail).toHaveBeenCalledTimes(1); + expect(mockEmailTasks.scheduleSecondEmail).toHaveBeenCalledWith({ payload: { uid: mockPayload.uid, emailType: EmailTypes.INACTIVE_DELETE_SECOND_NOTIFICATION, }, taskOptions: { taskId: '0987654321-inactive-delete-second-email' }, }); - sinon.assert.calledOnce( + expect( mockGlean.inactiveAccountDeletion.secondEmailTaskRequest - ); + ).toHaveBeenCalledTimes(1); + expect( + mockGlean.inactiveAccountDeletion.secondEmailTaskRequest + ).toHaveBeenNthCalledWith(1, expect.anything(), { uid: mockPayload.uid }); expect( - mockGlean.inactiveAccountDeletion.secondEmailTaskRequest.args[0][1] - ).toEqual({ uid: mockPayload.uid }); - sinon.assert.calledOnce( mockGlean.inactiveAccountDeletion.secondEmailTaskEnqueued - ); + ).toHaveBeenCalledTimes(1); expect( - mockGlean.inactiveAccountDeletion.secondEmailTaskEnqueued.args[0][1] - ).toEqual({ uid: mockPayload.uid }); + mockGlean.inactiveAccountDeletion.secondEmailTaskEnqueued + ).toHaveBeenNthCalledWith(1, expect.anything(), { uid: mockPayload.uid }); }); }); @@ -251,68 +238,73 @@ describe('InactiveAccountsManager', () => { }; beforeEach(() => { - sinon.stub(Date, 'now').returns(now + 53 * aDayInMs); + jest.spyOn(Date, 'now').mockReturnValue(now + 53 * aDayInMs); }); afterEach(() => { - sinon.restore(); + jest.restoreAllMocks(); }); it('should skip when account is active', async () => { - const isActiveSpy = sandbox.spy(inactiveAccountManager, 'isActive'); - mockOAuthDb.getRefreshTokensByUid.resolves([ + const isActiveSpy = jest.spyOn(inactiveAccountManager, 'isActive'); + mockOAuthDb.getRefreshTokensByUid.mockResolvedValue([ { lastUsedAt: Date.now() }, ]); await inactiveAccountManager.handleNotificationTask( mockSecondTaskPayload ); - sandbox.assert.calledOnce(isActiveSpy); - sandbox.assert.calledOnceWithExactly( - mockOAuthDb.getRefreshTokensByUid, + expect(isActiveSpy).toHaveBeenCalledTimes(1); + expect(mockOAuthDb.getRefreshTokensByUid).toHaveBeenCalledTimes(1); + expect(mockOAuthDb.getRefreshTokensByUid).toHaveBeenCalledWith( mockSecondTaskPayload.uid ); - sandbox.assert.calledOnceWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.inactive.second-email.skipped.active' ); - sandbox.assert.calledOnce( + expect( mockGlean.inactiveAccountDeletion.secondEmailSkipped - ); + ).toHaveBeenCalledTimes(1); expect( - mockGlean.inactiveAccountDeletion.secondEmailSkipped.args[0][1] - ).toEqual({ + mockGlean.inactiveAccountDeletion.secondEmailSkipped + ).toHaveBeenNthCalledWith(1, expect.anything(), { uid: mockSecondTaskPayload.uid, reason: 'active_account', }); }); it('should skip when second email has been sent already', async () => { - sandbox.stub(accountEventsManager, 'findEmailEvents').resolves([{}]); - sandbox.stub(inactiveAccountManager, 'isActive').resolves(false); - sandbox.stub(inactiveAccountManager, 'scheduleNextEmail').resolves(); + jest + .spyOn(accountEventsManager, 'findEmailEvents') + .mockResolvedValue([{}]); + jest.spyOn(inactiveAccountManager, 'isActive').mockResolvedValue(false); + jest + .spyOn(inactiveAccountManager, 'scheduleNextEmail') + .mockResolvedValue(undefined); await inactiveAccountManager.handleNotificationTask( mockSecondTaskPayload ); - sandbox.assert.calledWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.inactive.second-email.skipped.duplicate' ); - sandbox.assert.calledOnce( + expect( mockGlean.inactiveAccountDeletion.secondEmailSkipped - ); + ).toHaveBeenCalledTimes(1); expect( - mockGlean.inactiveAccountDeletion.secondEmailSkipped.args[0][1] - ).toEqual({ + mockGlean.inactiveAccountDeletion.secondEmailSkipped + ).toHaveBeenNthCalledWith(1, expect.anything(), { uid: mockSecondTaskPayload.uid, reason: 'already_sent', }); - sandbox.assert.calledOnce(inactiveAccountManager.scheduleNextEmail); + expect(inactiveAccountManager.scheduleNextEmail).toHaveBeenCalledTimes(1); }); it('should delete the account if the first email bounced', async () => { - sandbox.stub(inactiveAccountManager, 'isActive').resolves(false); + jest.spyOn(inactiveAccountManager, 'isActive').mockResolvedValue(false); (EmailBounce.distinct as jest.Mock).mockResolvedValue([mockAccount]); - sandbox.stub(inactiveAccountManager, 'scheduleNextEmail').resolves(); + jest + .spyOn(inactiveAccountManager, 'scheduleNextEmail') + .mockResolvedValue(undefined); await inactiveAccountManager.handleNotificationTask( mockSecondTaskPayload @@ -320,70 +312,68 @@ describe('InactiveAccountsManager', () => { expect(EmailBounce.distinct).toHaveBeenCalledTimes(1); expect(EmailBounce.distinct).toHaveBeenCalledWith('email'); - sinon.assert.calledWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.inactive.second-email.skipped.bounce' ); - sinon.assert.calledOnce( + expect( mockGlean.inactiveAccountDeletion.secondEmailSkipped - ); + ).toHaveBeenCalledTimes(1); expect( - mockGlean.inactiveAccountDeletion.secondEmailSkipped.args[0][1] - ).toEqual({ + mockGlean.inactiveAccountDeletion.secondEmailSkipped + ).toHaveBeenNthCalledWith(1, expect.anything(), { uid: mockSecondTaskPayload.uid, reason: 'first_email_bounced', }); - sinon.assert.calledOnceWithExactly( - mockDeleteAccountTasks.deleteAccount, - { - uid: mockSecondTaskPayload.uid, - customerId: undefined, - reason: ReasonForDeletion.InactiveAccountEmailBounced, - } - ); + expect(mockDeleteAccountTasks.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockDeleteAccountTasks.deleteAccount).toHaveBeenCalledWith({ + uid: mockSecondTaskPayload.uid, + customerId: undefined, + reason: ReasonForDeletion.InactiveAccountEmailBounced, + }); - sinon.assert.notCalled(inactiveAccountManager.scheduleNextEmail); + expect(inactiveAccountManager.scheduleNextEmail).not.toHaveBeenCalled(); }); it('should send the second email and enqueue the final', async () => { - sandbox.stub(accountEventsManager, 'findEmailEvents').resolves([]); - sandbox.stub(inactiveAccountManager, 'isActive').resolves(false); + jest.spyOn(accountEventsManager, 'findEmailEvents').mockResolvedValue([]); + jest.spyOn(inactiveAccountManager, 'isActive').mockResolvedValue(false); (EmailBounce.distinct as jest.Mock).mockResolvedValue([]); await inactiveAccountManager.handleNotificationTask( mockSecondTaskPayload ); - sandbox.assert.calledOnceWithExactly( - mockFxaDb.account, - mockSecondTaskPayload.uid - ); - sandbox.assert.calledOnce( + expect(mockFxaDb.account).toHaveBeenCalledTimes(1); + expect(mockFxaDb.account).toHaveBeenCalledWith(mockSecondTaskPayload.uid); + expect( mockFxaMailer.sendInactiveAccountSecondWarningEmail - ); + ).toHaveBeenCalledTimes(1); const fxaMailerCallArgs = - mockFxaMailer.sendInactiveAccountSecondWarningEmail.getCall(0).args[0]; + mockFxaMailer.sendInactiveAccountSecondWarningEmail.mock.calls[0][0]; expect(fxaMailerCallArgs.to).toBe(mockAccount.email); expect(fxaMailerCallArgs.acceptLanguage).toBe(mockAccount.locale); expect(fxaMailerCallArgs.deletionDate).toBeDefined(); - sandbox.assert.calledOnceWithExactly(mockEmailTasks.scheduleFinalEmail, { + expect(mockEmailTasks.scheduleFinalEmail).toHaveBeenCalledTimes(1); + expect(mockEmailTasks.scheduleFinalEmail).toHaveBeenCalledWith({ payload: { uid: mockSecondTaskPayload.uid, emailType: EmailTypes.INACTIVE_DELETE_FINAL_NOTIFICATION, }, taskOptions: { taskId: '0987654321-inactive-delete-final-email' }, }); - sandbox.assert.calledOnce( + expect( mockGlean.inactiveAccountDeletion.finalEmailTaskRequest - ); + ).toHaveBeenCalledTimes(1); + expect( + mockGlean.inactiveAccountDeletion.finalEmailTaskRequest + ).toHaveBeenNthCalledWith(1, expect.anything(), { + uid: mockSecondTaskPayload.uid, + }); expect( - mockGlean.inactiveAccountDeletion.finalEmailTaskRequest.args[0][1] - ).toEqual({ uid: mockSecondTaskPayload.uid }); - sandbox.assert.calledOnce( mockGlean.inactiveAccountDeletion.finalEmailTaskEnqueued - ); + ).toHaveBeenCalledTimes(1); expect( - mockGlean.inactiveAccountDeletion.finalEmailTaskEnqueued.args[0][1] - ).toEqual({ uid: mockPayload.uid }); + mockGlean.inactiveAccountDeletion.finalEmailTaskEnqueued + ).toHaveBeenNthCalledWith(1, expect.anything(), { uid: mockPayload.uid }); }); }); @@ -394,83 +384,87 @@ describe('InactiveAccountsManager', () => { }; beforeEach(() => { - sinon.stub(Date, 'now').returns(now + 59 * aDayInMs); + jest.spyOn(Date, 'now').mockReturnValue(now + 59 * aDayInMs); }); afterEach(() => { - sinon.restore(); + jest.restoreAllMocks(); }); it('should skip when account is active', async () => { - const isActiveSpy = sandbox.spy(inactiveAccountManager, 'isActive'); - mockOAuthDb.getRefreshTokensByUid.resolves([ + const isActiveSpy = jest.spyOn(inactiveAccountManager, 'isActive'); + mockOAuthDb.getRefreshTokensByUid.mockResolvedValue([ { lastUsedAt: Date.now() }, ]); - await inactiveAccountManager.handleNotificationTask( - mockFinalTaskPayload - ); + await inactiveAccountManager.handleNotificationTask(mockFinalTaskPayload); - sandbox.assert.calledOnce(isActiveSpy); - sandbox.assert.calledOnceWithExactly( - mockOAuthDb.getRefreshTokensByUid, + expect(isActiveSpy).toHaveBeenCalledTimes(1); + expect(mockOAuthDb.getRefreshTokensByUid).toHaveBeenCalledTimes(1); + expect(mockOAuthDb.getRefreshTokensByUid).toHaveBeenCalledWith( mockPayload.uid ); - sandbox.assert.calledOnceWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.inactive.final-email.skipped.active' ); - sandbox.assert.calledOnce( + expect( mockGlean.inactiveAccountDeletion.finalEmailSkipped - ); + ).toHaveBeenCalledTimes(1); expect( - mockGlean.inactiveAccountDeletion.finalEmailSkipped.args[0][1] - ).toEqual({ uid: mockPayload.uid, reason: 'active_account' }); + mockGlean.inactiveAccountDeletion.finalEmailSkipped + ).toHaveBeenNthCalledWith(1, expect.anything(), { + uid: mockPayload.uid, + reason: 'active_account', + }); }); it('should skip when final email has been sent already', async () => { - sandbox.stub(accountEventsManager, 'findEmailEvents').resolves([{}]); + jest + .spyOn(accountEventsManager, 'findEmailEvents') + .mockResolvedValue([{}]); - sandbox.stub(inactiveAccountManager, 'isActive').resolves(false); + jest.spyOn(inactiveAccountManager, 'isActive').mockResolvedValue(false); - sandbox.stub(inactiveAccountManager, 'scheduleNextEmail').resolves(); + jest + .spyOn(inactiveAccountManager, 'scheduleNextEmail') + .mockResolvedValue(undefined); - await inactiveAccountManager.handleNotificationTask( - mockFinalTaskPayload - ); - sandbox.assert.calledWithExactly( - mockStatsd.increment, + await inactiveAccountManager.handleNotificationTask(mockFinalTaskPayload); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.inactive.final-email.skipped.duplicate' ); - sandbox.assert.calledOnce( + expect( mockGlean.inactiveAccountDeletion.finalEmailSkipped - ); + ).toHaveBeenCalledTimes(1); expect( - mockGlean.inactiveAccountDeletion.finalEmailSkipped.args[0][1] - ).toEqual({ uid: mockPayload.uid, reason: 'already_sent' }); - sandbox.assert.calledOnce(inactiveAccountManager.scheduleNextEmail); + mockGlean.inactiveAccountDeletion.finalEmailSkipped + ).toHaveBeenNthCalledWith(1, expect.anything(), { + uid: mockPayload.uid, + reason: 'already_sent', + }); + expect(inactiveAccountManager.scheduleNextEmail).toHaveBeenCalledTimes(1); }); it('should send the final email and schedule deletion', async () => { - sandbox.stub(accountEventsManager, 'findEmailEvents').resolves([]); - sandbox.stub(inactiveAccountManager, 'isActive').resolves(false); + jest.spyOn(accountEventsManager, 'findEmailEvents').mockResolvedValue([]); + jest.spyOn(inactiveAccountManager, 'isActive').mockResolvedValue(false); - await inactiveAccountManager.handleNotificationTask( - mockFinalTaskPayload - ); + await inactiveAccountManager.handleNotificationTask(mockFinalTaskPayload); - sandbox.assert.calledOnceWithExactly(mockFxaDb.account, mockPayload.uid); - sandbox.assert.calledOnce( + expect(mockFxaDb.account).toHaveBeenCalledTimes(1); + expect(mockFxaDb.account).toHaveBeenCalledWith(mockPayload.uid); + expect( mockFxaMailer.sendInactiveAccountFinalWarningEmail - ); + ).toHaveBeenCalledTimes(1); const fxaMailerCallArgs = - mockFxaMailer.sendInactiveAccountFinalWarningEmail.getCall(0).args[0]; + mockFxaMailer.sendInactiveAccountFinalWarningEmail.mock.calls[0][0]; expect(fxaMailerCallArgs.to).toBe(mockAccount.email); expect(fxaMailerCallArgs.acceptLanguage).toBe(mockAccount.locale); expect(fxaMailerCallArgs.deletionDate).toBeDefined(); // No email cloud task should be run. There are no more emails to schedule. - sandbox.assert.notCalled(mockEmailTasks.scheduleFinalEmail); + expect(mockEmailTasks.scheduleFinalEmail).not.toHaveBeenCalled(); - sandbox.assert.calledOnceWithExactly( - mockDeleteAccountTasks.deleteAccount, + expect(mockDeleteAccountTasks.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockDeleteAccountTasks.deleteAccount).toHaveBeenCalledWith( { uid: mockPayload.uid, customerId: undefined, @@ -483,11 +477,10 @@ describe('InactiveAccountsManager', () => { }, } ); - sandbox.assert.calledOnce( + expect( mockGlean.inactiveAccountDeletion.deletionScheduled - ); - sandbox.assert.calledWithExactly( - mockStatsd.increment, + ).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.inactive.deletion.scheduled' ); }); diff --git a/packages/fxa-auth-server/lib/log.spec.ts b/packages/fxa-auth-server/lib/log.spec.ts index 58645cf7c68..f287e4c8966 100644 --- a/packages/fxa-auth-server/lib/log.spec.ts +++ b/packages/fxa-auth-server/lib/log.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - const validEvent = { op: 'amplitudeEvent', event_type: 'fxa_activity - access_token_checked', @@ -15,8 +14,7 @@ const validEvent = { oauth_client_id: '98e6508e88680e1a', }, user_properties: { - flow_id: - '1ce137da67f8d5a2e5e55fafaca0a14088f015f1d6cdf25400f9fe22226ad5a6', + flow_id: '1ce137da67f8d5a2e5e55fafaca0a14088f015f1d6cdf25400f9fe22226ad5a6', ua_browser: 'Firefox', ua_version: '76.0', $append: { @@ -55,9 +53,13 @@ describe('log', () => { sentryScope = { setContext: jest.fn() }; mockSentry = { - withScope: jest.fn().mockImplementation((cb: (scope: Record) => void) => { - cb(sentryScope); - }), + withScope: jest + .fn() + .mockImplementation( + (cb: (scope: Record) => void) => { + cb(sentryScope); + } + ), getActiveSpan: jest.fn().mockReturnValue(undefined), }; @@ -123,7 +125,7 @@ describe('log', () => { // mozlog instance was called once with no arguments const returnValue = mockMozlog.mock.results[0].value; expect(returnValue).toHaveBeenCalledTimes(1); - expect(returnValue.mock.calls[0]).toHaveLength(0); + expect(returnValue).toHaveBeenCalledWith(); // No logger methods were called during init expect(logger.debug).not.toHaveBeenCalled(); @@ -200,10 +202,7 @@ describe('log', () => { }); expect(logger.info).toHaveBeenCalledTimes(1); - const args = logger.info.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('activityEvent'); - expect(args[1]).toEqual({ + expect(logger.info).toHaveBeenCalledWith('activityEvent', { event: 'foo', uid: 'bar', }); @@ -218,10 +217,10 @@ describe('log', () => { log.activityEvent(); expect(logger.error).toHaveBeenCalledTimes(1); - const args = logger.error.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('log.activityEvent'); - expect(args[1]).toEqual(expect.objectContaining({ data: undefined })); + expect(logger.error).toHaveBeenCalledWith( + 'log.activityEvent', + expect.objectContaining({ data: undefined }) + ); expect(logger.info).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); @@ -233,10 +232,10 @@ describe('log', () => { log.activityEvent({ event: 'wibble' }); expect(logger.error).toHaveBeenCalledTimes(1); - const args = logger.error.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('log.activityEvent'); - expect(args[1]).toEqual(expect.objectContaining({ data: { event: 'wibble' } })); + expect(logger.error).toHaveBeenCalledWith( + 'log.activityEvent', + expect.objectContaining({ data: { event: 'wibble' } }) + ); expect(logger.info).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); @@ -248,10 +247,10 @@ describe('log', () => { log.activityEvent({ uid: 'wibble' }); expect(logger.error).toHaveBeenCalledTimes(1); - const args = logger.error.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('log.activityEvent'); - expect(args[1]).toEqual(expect.objectContaining({ data: { uid: 'wibble' } })); + expect(logger.error).toHaveBeenCalledWith( + 'log.activityEvent', + expect.objectContaining({ data: { uid: 'wibble' } }) + ); expect(logger.info).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); @@ -268,10 +267,7 @@ describe('log', () => { }); expect(logger.info).toHaveBeenCalledTimes(1); - const args = logger.info.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('flowEvent'); - expect(args[1]).toEqual({ + expect(logger.info).toHaveBeenCalledWith('flowEvent', { event: 'wibble', flow_id: 'blee', flow_time: 1000, @@ -354,10 +350,7 @@ describe('log', () => { log.amplitudeEvent(validEvent); expect(logger.info).toHaveBeenCalledTimes(1); - const args = logger.info.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('amplitudeEvent'); - expect(args[1]).toEqual(validEvent); + expect(logger.info).toHaveBeenCalledWith('amplitudeEvent', validEvent); expect(logger.debug).not.toHaveBeenCalled(); expect(logger.error).not.toHaveBeenCalled(); @@ -369,10 +362,10 @@ describe('log', () => { log.amplitudeEvent(); expect(logger.error).toHaveBeenCalledTimes(1); - const args = logger.error.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('amplitude.missingData'); - expect(args[1]).toEqual(expect.objectContaining({ data: undefined })); + expect(logger.error).toHaveBeenCalledWith( + 'amplitude.missingData', + expect.objectContaining({ data: undefined }) + ); expect(logger.info).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); @@ -384,12 +377,12 @@ describe('log', () => { log.amplitudeEvent({ device_id: 'foo', user_id: 'bar' }); expect(logger.error).toHaveBeenCalledTimes(1); - const args = logger.error.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('amplitude.missingData'); - expect(args[1]).toEqual(expect.objectContaining({ - data: { device_id: 'foo', user_id: 'bar' }, - })); + expect(logger.error).toHaveBeenCalledWith( + 'amplitude.missingData', + expect.objectContaining({ + data: { device_id: 'foo', user_id: 'bar' }, + }) + ); expect(logger.info).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); @@ -401,10 +394,10 @@ describe('log', () => { log.amplitudeEvent({ event_type: 'foo' }); expect(logger.error).toHaveBeenCalledTimes(1); - const args = logger.error.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('amplitude.missingData'); - expect(args[1]).toEqual(expect.objectContaining({ data: { event_type: 'foo' } })); + expect(logger.error).toHaveBeenCalledWith( + 'amplitude.missingData', + expect.objectContaining({ data: { event_type: 'foo' } }) + ); expect(logger.info).not.toHaveBeenCalled(); expect(logger.debug).not.toHaveBeenCalled(); @@ -417,10 +410,7 @@ describe('log', () => { log.amplitudeEvent(event); expect(logger.info).toHaveBeenCalledTimes(1); - const args = logger.info.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('amplitudeEvent'); - expect(args[1]).toEqual(event); + expect(logger.info).toHaveBeenCalledWith('amplitudeEvent', event); expect(logger.debug).not.toHaveBeenCalled(); expect(logger.error).not.toHaveBeenCalled(); @@ -433,10 +423,7 @@ describe('log', () => { log.amplitudeEvent(event); expect(logger.info).toHaveBeenCalledTimes(1); - const args = logger.info.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('amplitudeEvent'); - expect(args[1]).toEqual(event); + expect(logger.info).toHaveBeenCalledWith('amplitudeEvent', event); expect(logger.debug).not.toHaveBeenCalled(); expect(logger.error).not.toHaveBeenCalled(); @@ -452,8 +439,7 @@ describe('log', () => { expect(logger.error).not.toHaveBeenCalled(); expect(mockSentry.withScope).not.toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledTimes(1); - expect(logger.info.mock.calls[0][0]).toBe('amplitudeEvent'); - expect(logger.info.mock.calls[0][1]).toEqual(event); + expect(logger.info).toHaveBeenCalledWith('amplitudeEvent', event); }); it('.amplitudeEvent with invalid data is logged', () => { @@ -498,8 +484,7 @@ describe('log', () => { // Event is still logged despite validation error expect(logger.info).toHaveBeenCalledTimes(1); - expect(logger.info.mock.calls[0][0]).toBe('amplitudeEvent'); - expect(logger.info.mock.calls[0][1]).toEqual(event); + expect(logger.info).toHaveBeenCalledWith('amplitudeEvent', event); }); it('.amplitudeEvent with multiple validation errors', () => { @@ -539,8 +524,7 @@ describe('log', () => { // Event is still logged expect(logger.info).toHaveBeenCalledTimes(1); - expect(logger.info.mock.calls[0][0]).toBe('amplitudeEvent'); - expect(logger.info.mock.calls[0][1]).toEqual(event); + expect(logger.info).toHaveBeenCalledWith('amplitudeEvent', event); }); it('.error removes PII from error objects', () => { @@ -602,8 +586,7 @@ describe('log', () => { expect(logger.info).not.toHaveBeenCalled(); expect(emitRouteFlowEvent).toHaveBeenCalledTimes(1); - expect(emitRouteFlowEvent.mock.calls[0]).toHaveLength(1); - expect(emitRouteFlowEvent.mock.calls[0][0]).toEqual({ + expect(emitRouteFlowEvent).toHaveBeenCalledWith({ code: 200, errno: 109, statusCode: 201, @@ -700,8 +683,7 @@ describe('log', () => { expect(mockGatherMetricsContext).toHaveBeenCalledTimes(1); expect(mockNotifierSend).toHaveBeenCalledTimes(1); - expect(mockNotifierSend.mock.calls[0]).toHaveLength(1); - expect(mockNotifierSend.mock.calls[0][0]).toEqual({ + expect(mockNotifierSend).toHaveBeenCalledWith({ event: 'login', data: { service: 'sync', @@ -766,8 +748,7 @@ describe('log', () => { expect(mockGatherMetricsContext).toHaveBeenCalledTimes(1); expect(mockNotifierSend).toHaveBeenCalledTimes(1); - expect(mockNotifierSend.mock.calls[0]).toHaveLength(1); - expect(mockNotifierSend.mock.calls[0][0]).toEqual({ + expect(mockNotifierSend).toHaveBeenCalledWith({ event: 'login', data: { clientId: '0123456789abcdef', @@ -833,8 +814,7 @@ describe('log', () => { expect(mockGatherMetricsContext).toHaveBeenCalledTimes(1); expect(mockNotifierSend).toHaveBeenCalledTimes(1); - expect(mockNotifierSend.mock.calls[0]).toHaveLength(1); - expect(mockNotifierSend.mock.calls[0][0]).toEqual({ + expect(mockNotifierSend).toHaveBeenCalledWith({ event: 'login', data: { service: 'unknown-clientid', diff --git a/packages/fxa-auth-server/lib/metrics/amplitude.spec.ts b/packages/fxa-auth-server/lib/metrics/amplitude.spec.ts index b5cce8d1ad8..c86cd305258 100644 --- a/packages/fxa-auth-server/lib/metrics/amplitude.spec.ts +++ b/packages/fxa-auth-server/lib/metrics/amplitude.spec.ts @@ -33,8 +33,6 @@ const MONTH = DAY * 28; // --------------------------------------------------------------------------- // Tests // -// NOTE: mocks.mockLog() returns sinon spies, so we access call data via the -// sinon API (.callCount, .args) rather than the jest mock API (.mock.calls). // StatsD instances created with jest.fn() use the jest API. // --------------------------------------------------------------------------- @@ -100,7 +98,7 @@ describe('metrics/amplitude', () => { return amplitude('account.created', mocks.mockRequest({})).then(() => { // could check other things, but this is the important one that // we want to disable when config.enabled is false - expect(log.amplitudeEvent.callCount).toBe(0); + expect(log.amplitudeEvent).toHaveBeenCalledTimes(0); }); }); }); @@ -114,10 +112,8 @@ describe('metrics/amplitude', () => { }); it('called log.error correctly', () => { - expect(log.error.callCount).toBe(1); - expect(log.error.args[0].length).toBe(2); - expect(log.error.args[0][0]).toBe('amplitude.badArgument'); - expect(log.error.args[0][1]).toEqual({ + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith('amplitude.badArgument', { err: 'Bad argument', event: '', hasRequest: true, @@ -125,7 +121,7 @@ describe('metrics/amplitude', () => { }); it('did not call log.amplitudeEvent', () => { - expect(log.amplitudeEvent.callCount).toBe(0); + expect(log.amplitudeEvent).toHaveBeenCalledTimes(0); }); }); @@ -138,10 +134,8 @@ describe('metrics/amplitude', () => { }); it('called log.error correctly', () => { - expect(log.error.callCount).toBe(1); - expect(log.error.args[0].length).toBe(2); - expect(log.error.args[0][0]).toBe('amplitude.badArgument'); - expect(log.error.args[0][1]).toEqual({ + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith('amplitude.badArgument', { err: 'Bad argument', event: 'foo', hasRequest: false, @@ -149,7 +143,7 @@ describe('metrics/amplitude', () => { }); it('did not call log.amplitudeEvent', () => { - expect(log.amplitudeEvent.callCount).toBe(0); + expect(log.amplitudeEvent).toHaveBeenCalledTimes(0); }); }); @@ -220,10 +214,14 @@ describe('metrics/amplitude', () => { utm_source: 'quuz', version, }; - expect(log.info.args[0][1]['event']).toEqual(expectedEvent); - expect(log.info.args[0][1]['context']).toEqual(expectedContext); - expect(log.info.calledOnce).toBe(true); - expect(log.info.args[0][0]).toBe('rawAmplitudeData'); + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith( + 'rawAmplitudeData', + expect.objectContaining({ + event: expectedEvent, + context: expectedContext, + }) + ); expect(statsd.increment).toHaveBeenCalledTimes(2); expect(statsd.increment).toHaveBeenNthCalledWith( 1, @@ -298,12 +296,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].device_id).toBe('juff'); expect(args[0].user_id).toBe('blee'); @@ -363,12 +361,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].device_id).toBe(undefined); expect(args[0].user_id).toBe('blee'); expect(args[0].event_type).toBe('fxa_reg - created'); @@ -417,12 +415,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_login - success'); expect(args[0].event_properties.service).toBe('undefined_oauth'); expect(args[0].event_properties.oauth_client_id).toBe('2'); @@ -456,12 +454,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_login - blocked'); expect(args[0].event_properties.service).toBe('sync'); expect(args[0].event_properties.oauth_client_id).toBe(undefined); @@ -483,12 +481,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_login - unblock_success'); }); }); @@ -502,17 +500,17 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(2); - let args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(2); + let args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_login - forgot_complete'); - args = log.amplitudeEvent.args[1]; + args = log.amplitudeEvent.mock.calls[1]; expect(args[0].event_type).toBe('fxa_login - complete'); expect(args[0].time).toBeGreaterThan( - log.amplitudeEvent.args[0][0].time + log.amplitudeEvent.mock.calls[0][0].time ); }); }); @@ -533,12 +531,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_activity - cert_signed'); expect(args[0].event_properties.service).toBe(undefined); expect(args[0].event_properties.oauth_client_id).toBe(undefined); @@ -555,12 +553,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_reg - email_confirmed'); expect(args[0].user_properties.newsletter_state).toBe(undefined); }); @@ -577,12 +575,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_reg - email_confirmed'); expect(args[0].user_properties.newsletters).toEqual([]); }); @@ -599,12 +597,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_reg - email_confirmed'); expect(args[0].user_properties.newsletters).toEqual(['test_pilot']); }); @@ -619,12 +617,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_subscribe - subscription_ended'); }); }); @@ -645,12 +643,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_reg - complete'); }); }); @@ -671,12 +669,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_login - complete'); }); }); @@ -690,11 +688,11 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('did not call log.amplitudeEvent', () => { - expect(log.amplitudeEvent.callCount).toBe(0); + expect(log.amplitudeEvent).toHaveBeenCalledTimes(0); }); }); @@ -745,12 +743,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].user_properties).toEqual({ flow_id: 'udge', ua_browser: 'foo', @@ -771,11 +769,11 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('did not call log.amplitudeEvent', () => { - expect(log.amplitudeEvent.callCount).toBe(0); + expect(log.amplitudeEvent).toHaveBeenCalledTimes(0); }); }); @@ -806,8 +804,8 @@ describe('metrics/amplitude', () => { }); it('only includes minimal data', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].user_id).toBe('blee'); expect(args[0].country).toBe(undefined); @@ -845,12 +843,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_email - bounced'); expect(args[0].event_properties.email_type).toBe( emailTypes[template] @@ -864,12 +862,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_email - sent'); expect(args[0].event_properties.email_type).toBe( emailTypes[template] @@ -886,12 +884,12 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('called log.amplitudeEvent correctly', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_email - bounced'); expect(args[0].event_properties.email_type).toBe( emailTypes[template] @@ -911,11 +909,11 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('did not call log.amplitudeEvent', () => { - expect(log.amplitudeEvent.callCount).toBe(0); + expect(log.amplitudeEvent).toHaveBeenCalledTimes(0); }); it('incremented amplitude dropped', () => { @@ -938,11 +936,11 @@ describe('metrics/amplitude', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('did not call log.amplitudeEvent', () => { - expect(log.amplitudeEvent.callCount).toBe(0); + expect(log.amplitudeEvent).toHaveBeenCalledTimes(0); }); }); @@ -972,8 +970,8 @@ describe('metrics/amplitude', () => { }); it('data properties were set', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].user_id).toBe('blee'); expect(args[0].event_properties.service).toBe('undefined_oauth'); expect(args[0].event_properties.oauth_client_id).toBe('zang'); @@ -1008,8 +1006,8 @@ describe('metrics/amplitude', () => { }); it('metricsContext properties were set', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].device_id).toBe('plin'); expect(args[0].event_properties.service).toBe('amo'); expect(args[0].user_properties.flow_id).toBe('gorb'); @@ -1042,8 +1040,8 @@ describe('metrics/amplitude', () => { }); it('subscription properties were set', () => { - expect(log.amplitudeEvent.callCount).toBe(1); - const args = log.amplitudeEvent.args[0]; + expect(log.amplitudeEvent).toHaveBeenCalledTimes(1); + const args = log.amplitudeEvent.mock.calls[0]; expect(args[0].event_properties.plan_id).toBe('bar'); expect(args[0].event_properties.product_id).toBe('foo'); }); diff --git a/packages/fxa-auth-server/lib/metrics/context.spec.ts b/packages/fxa-auth-server/lib/metrics/context.spec.ts index 494f2aaaa01..fbb4fbba36a 100644 --- a/packages/fxa-auth-server/lib/metrics/context.spec.ts +++ b/packages/fxa-auth-server/lib/metrics/context.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - import crypto from 'crypto'; function hashToken(token: { uid: string; id: string }) { @@ -118,9 +117,7 @@ describe('metricsContext', () => { ); expect(cache.add).toHaveBeenCalledTimes(1); - expect(cache.add.mock.calls[0]).toHaveLength(2); - expect(cache.add.mock.calls[0][0]).toBe(hashToken(token)); - expect(cache.add.mock.calls[0][1]).toEqual({ + expect(cache.add).toHaveBeenCalledWith(hashToken(token), { foo: 'bar', service: 'baz', }); @@ -279,8 +276,7 @@ describe('metricsContext', () => { }); expect(cache.get).toHaveBeenCalledTimes(1); - expect(cache.get.mock.calls[0]).toHaveLength(1); - expect(cache.get.mock.calls[0][0]).toBe(hashToken(token)); + expect(cache.get).toHaveBeenCalledWith(hashToken(token)); }); it('metricsContext.get with fake token', async () => { @@ -307,9 +303,8 @@ describe('metricsContext', () => { }); expect(cache.get).toHaveBeenCalledTimes(1); - expect(cache.get.mock.calls[0]).toHaveLength(1); - expect(cache.get.mock.calls[0][0]).toBe(hashToken(token)); - expect(cache.get.mock.calls[0][0]).toEqual(hashToken({ uid, id })); + expect(cache.get).toHaveBeenCalledWith(hashToken(token)); + expect(hashToken(token)).toEqual(hashToken({ uid, id })); }); it('metricsContext.get with bad token', async () => { @@ -573,13 +568,10 @@ describe('metricsContext', () => { await metricsContext.propagate(oldToken, newToken); expect(cache.get).toHaveBeenCalledTimes(1); - expect(cache.get.mock.calls[0]).toHaveLength(1); - expect(cache.get.mock.calls[0][0]).toBe(hashToken(oldToken)); + expect(cache.get).toHaveBeenCalledWith(hashToken(oldToken)); expect(cache.add).toHaveBeenCalledTimes(1); - expect(cache.add.mock.calls[0]).toHaveLength(2); - expect(cache.add.mock.calls[0][0]).toBe(hashToken(newToken)); - expect(cache.add.mock.calls[0][1]).toBe('wibble'); + expect(cache.add).toHaveBeenCalledWith(hashToken(newToken), 'wibble'); expect(cache.del).not.toHaveBeenCalled(); }); @@ -635,8 +627,7 @@ describe('metricsContext', () => { }); expect(cache.del).toHaveBeenCalledTimes(1); - expect(cache.del.mock.calls[0]).toHaveLength(1); - expect(cache.del.mock.calls[0][0]).toBe(hashToken(token)); + expect(cache.del).toHaveBeenCalledWith(hashToken(token)); }); it('metricsContext.clear with fake token', async () => { @@ -651,8 +642,7 @@ describe('metricsContext', () => { }); expect(cache.del).toHaveBeenCalledTimes(1); - expect(cache.del.mock.calls[0]).toHaveLength(1); - expect(cache.del.mock.calls[0][0]).toEqual(hashToken({ uid, id })); + expect(cache.del).toHaveBeenCalledWith(hashToken({ uid, id })); }); it('metricsContext.clear with no token', async () => { diff --git a/packages/fxa-auth-server/lib/metrics/events.spec.ts b/packages/fxa-auth-server/lib/metrics/events.spec.ts index 405643b0636..0ec089823fd 100644 --- a/packages/fxa-auth-server/lib/metrics/events.spec.ts +++ b/packages/fxa-auth-server/lib/metrics/events.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - jest.mock('fxa-shared/db/models/auth', () => ({ Account: { metricsEnabled: jest.fn().mockResolvedValue(true) }, })); @@ -37,7 +36,7 @@ const events = eventsModule( describe('metrics/events', () => { beforeEach(() => { - glean.login.complete.reset(); + glean.login.complete.mockClear(); }); afterEach(() => { @@ -70,16 +69,15 @@ describe('metrics/events', () => { await events.emit.call(request, '', {}); expect(log.error).toHaveBeenCalledTimes(1); - const args = log.error.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('metricsEvents.emit'); - expect(args[1]).toEqual({ missingEvent: true }); + expect(log.error).toHaveBeenCalledWith('metricsEvents.emit', { + missingEvent: true, + }); expect(log.activityEvent).not.toHaveBeenCalled(); expect(log.amplitudeEvent).not.toHaveBeenCalled(); - expect(metricsContext.gather.callCount).toBe(0); + expect(metricsContext.gather).toHaveBeenCalledTimes(0); expect(log.flowEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); }); it('.emit with activity event', async () => { @@ -101,9 +99,7 @@ describe('metrics/events', () => { await events.emit.call(request, 'device.created', data); expect(log.activityEvent).toHaveBeenCalledTimes(1); - let args = log.activityEvent.mock.calls[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.activityEvent).toHaveBeenCalledWith({ country: 'United States', event: 'device.created', region: 'California', @@ -114,14 +110,12 @@ describe('metrics/events', () => { clientJa4: 'test-ja4', }); - expect(metricsContext.gather.callCount).toBe(1); - args = metricsContext.gather.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({}); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); + expect(metricsContext.gather).toHaveBeenCalledWith({}); expect(log.amplitudeEvent).not.toHaveBeenCalled(); expect(log.flowEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -136,9 +130,7 @@ describe('metrics/events', () => { await events.emit.call(request, 'device.created'); expect(log.activityEvent).toHaveBeenCalledTimes(1); - const args = log.activityEvent.mock.calls[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.activityEvent).toHaveBeenCalledWith({ country: 'United States', event: 'device.created', region: 'California', @@ -148,11 +140,11 @@ describe('metrics/events', () => { clientJa4: 'test-ja4', }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.amplitudeEvent).not.toHaveBeenCalled(); expect(log.flowEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -162,9 +154,7 @@ describe('metrics/events', () => { await events.emit.call(request, 'device.created', {}); expect(log.activityEvent).toHaveBeenCalledTimes(1); - const args = log.activityEvent.mock.calls[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.activityEvent).toHaveBeenCalledWith({ country: 'United States', event: 'device.created', region: 'California', @@ -174,11 +164,11 @@ describe('metrics/events', () => { clientJa4: 'test-ja4', }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.amplitudeEvent).not.toHaveBeenCalled(); expect(log.flowEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -212,17 +202,15 @@ describe('metrics/events', () => { }); await events.emit.call(request, 'email.verification.sent'); - expect(metricsContext.gather.callCount).toBe(1); - let args = metricsContext.gather.args[0]; + expect(metricsContext.gather).toHaveBeenCalledTimes(1); + const args = metricsContext.gather.mock.calls[0]; expect(args).toHaveLength(1); expect(args[0].event).toBe('email.verification.sent'); expect(args[0].locale).toBe(request.app.locale); expect(args[0].userAgent).toBe(request.headers['user-agent']); expect(log.flowEvent).toHaveBeenCalledTimes(1); - args = log.flowEvent.mock.calls[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.flowEvent).toHaveBeenCalledWith({ country: 'United States', event: 'email.verification.sent', entrypoint: 'wibble', @@ -251,7 +239,7 @@ describe('metrics/events', () => { expect(log.activityEvent).not.toHaveBeenCalled(); expect(log.amplitudeEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -291,12 +279,10 @@ describe('metrics/events', () => { }; await events.emit.call(request, 'email.verification.sent'); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(1); - const args = log.flowEvent.mock.calls[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.flowEvent).toHaveBeenCalledWith({ country: 'United Kingdom', event: 'email.verification.sent', flow_id: 'bar', @@ -314,7 +300,7 @@ describe('metrics/events', () => { expect(log.activityEvent).not.toHaveBeenCalled(); expect(log.amplitudeEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -342,12 +328,10 @@ describe('metrics/events', () => { uid: 'deadbeef', }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(1); - const args = log.flowEvent.mock.calls[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.flowEvent).toHaveBeenCalledWith({ country: 'United States', event: 'email.verification.sent', flow_id: 'bar', @@ -366,7 +350,7 @@ describe('metrics/events', () => { expect(log.activityEvent).not.toHaveBeenCalled(); expect(log.amplitudeEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -394,12 +378,10 @@ describe('metrics/events', () => { uid: 'deadbeef', }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(1); - const args = log.flowEvent.mock.calls[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.flowEvent).toHaveBeenCalledWith({ country: 'United States', event: 'email.verification.sent', flow_id: 'bar', @@ -418,7 +400,7 @@ describe('metrics/events', () => { expect(log.activityEvent).not.toHaveBeenCalled(); expect(log.amplitudeEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -444,12 +426,10 @@ describe('metrics/events', () => { }); await events.emit.call(request, 'email.verification.sent', { uid: null }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(1); - const args = log.flowEvent.mock.calls[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.flowEvent).toHaveBeenCalledWith({ country: 'United States', event: 'email.verification.sent', flow_id: 'bar', @@ -467,7 +447,7 @@ describe('metrics/events', () => { expect(log.activityEvent).not.toHaveBeenCalled(); expect(log.amplitudeEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -498,10 +478,10 @@ describe('metrics/events', () => { uid: 'qux', }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(2); - expect(log.flowEvent.mock.calls[0][0]).toEqual({ + expect(log.flowEvent).toHaveBeenNthCalledWith(1, { country: 'United States', event: 'email.verification.sent', flow_id: 'bar', @@ -517,7 +497,7 @@ describe('metrics/events', () => { sigsciRequestId: 'test-sigsci-id', clientJa4: 'test-ja4', }); - expect(log.flowEvent.mock.calls[1][0]).toEqual({ + expect(log.flowEvent).toHaveBeenNthCalledWith(2, { country: 'United States', event: 'flow.complete', flow_id: 'bar', @@ -540,8 +520,8 @@ describe('metrics/events', () => { 'fxa_reg - complete' ); - expect(metricsContext.clear.callCount).toBe(1); - expect(metricsContext.clear.args[0]).toHaveLength(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(1); + expect(metricsContext.clear).toHaveBeenCalledWith(); expect(log.activityEvent).not.toHaveBeenCalled(); expect(log.error).not.toHaveBeenCalled(); @@ -568,20 +548,17 @@ describe('metrics/events', () => { await events.emit.call(request, 'email.verification.sent'); expect(log.trace).toHaveBeenCalledTimes(1); - const args = log.trace.mock.calls[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('metricsEvents.emitFlowEvent'); - expect(args[1]).toEqual({ + expect(log.trace).toHaveBeenCalledWith('metricsEvents.emitFlowEvent', { event: 'email.verification.sent', badRequest: true, }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.activityEvent).not.toHaveBeenCalled(); expect(log.amplitudeEvent).not.toHaveBeenCalled(); expect(log.flowEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); }); it('.emit with flow event and missing flowId', async () => { @@ -596,11 +573,10 @@ describe('metrics/events', () => { }); await events.emit.call(request, 'email.verification.sent'); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.error).toHaveBeenCalledTimes(1); - expect(log.error.mock.calls[0][0]).toBe('metricsEvents.emitFlowEvent'); - expect(log.error.mock.calls[0][1]).toEqual({ + expect(log.error).toHaveBeenCalledWith('metricsEvents.emitFlowEvent', { event: 'email.verification.sent', missingFlowId: true, }); @@ -608,7 +584,7 @@ describe('metrics/events', () => { expect(log.activityEvent).not.toHaveBeenCalled(); expect(log.amplitudeEvent).not.toHaveBeenCalled(); expect(log.flowEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); }); it('.emit with hybrid activity/flow event', async () => { @@ -636,7 +612,7 @@ describe('metrics/events', () => { await events.emit.call(request, 'account.keyfetch', data); expect(log.activityEvent).toHaveBeenCalledTimes(1); - expect(log.activityEvent.mock.calls[0][0]).toEqual({ + expect(log.activityEvent).toHaveBeenCalledWith({ country: 'United States', event: 'account.keyfetch', region: 'California', @@ -647,10 +623,10 @@ describe('metrics/events', () => { clientJa4: 'test-ja4', }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(1); - expect(log.flowEvent.mock.calls[0][0]).toEqual({ + expect(log.flowEvent).toHaveBeenCalledWith({ country: 'United States', time, event: 'account.keyfetch', @@ -668,7 +644,7 @@ describe('metrics/events', () => { }); expect(log.amplitudeEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -688,19 +664,18 @@ describe('metrics/events', () => { await events.emit.call(request, 'account.keyfetch', data); expect(log.activityEvent).toHaveBeenCalledTimes(1); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.amplitudeEvent).not.toHaveBeenCalled(); expect(log.flowEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); it('.emit with content-server account.signed event', async () => { - const sinon = require('sinon'); const flowBeginTime = Date.now() - 1; const metricsContext = mocks.mockMetricsContext({ - gather: sinon.spy(() => ({ + gather: jest.fn(() => ({ device_id: 'foo', flow_id: 'bar', flowBeginTime, @@ -733,10 +708,10 @@ describe('metrics/events', () => { ua_version: request.app.ua.browserVersion, }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -765,9 +740,9 @@ describe('metrics/events', () => { ); expect(log.activityEvent).toHaveBeenCalledTimes(1); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(1); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -827,12 +802,12 @@ describe('metrics/events', () => { }); await events.emit.call(request, 'account.signed', { uid: 'quux' }); - // glean.login.complete is a sinon stub from mockGlean() - expect(glean.login.complete.calledOnce).toBe(true); - expect(glean.login.complete.calledWithExactly(request, { + // glean.login.complete is a jest mock from mockGlean() + expect(glean.login.complete).toHaveBeenCalledTimes(1); + expect(glean.login.complete).toHaveBeenCalledWith(request, { uid: 'quux', reason: 'email', - })).toBe(true); + }); }); it('.emitRouteFlowEvent with matching route and response.statusCode', async () => { @@ -859,13 +834,11 @@ describe('metrics/events', () => { }); await events.emitRouteFlowEvent.call(request, { statusCode: 200 }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(2); - let args = log.flowEvent.mock.calls[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.flowEvent).toHaveBeenNthCalledWith(1, { country: 'United States', event: 'route./account/create.200', flow_id: 'bar', @@ -881,9 +854,7 @@ describe('metrics/events', () => { clientJa4: 'test-ja4', }); - args = log.flowEvent.mock.calls[1]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(log.flowEvent).toHaveBeenNthCalledWith(2, { country: 'United States', event: 'route.performance./account/create', flow_id: 'bar', @@ -900,7 +871,7 @@ describe('metrics/events', () => { }); expect(log.activityEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -928,10 +899,10 @@ describe('metrics/events', () => { output: { statusCode: 399 }, }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(1); - expect(log.flowEvent.mock.calls[0][0]).toEqual({ + expect(log.flowEvent).toHaveBeenCalledWith({ country: 'United States', event: 'route./account/login.399', flow_id: 'bar', @@ -948,7 +919,7 @@ describe('metrics/events', () => { }); expect(log.activityEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -974,10 +945,10 @@ describe('metrics/events', () => { }); await events.emitRouteFlowEvent.call(request, { statusCode: 400 }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(1); - expect(log.flowEvent.mock.calls[0][0]).toEqual({ + expect(log.flowEvent).toHaveBeenCalledWith({ country: 'United States', event: 'route./recovery_email/resend_code.400.999', flow_id: 'bar', @@ -994,7 +965,7 @@ describe('metrics/events', () => { }); expect(log.activityEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -1020,10 +991,10 @@ describe('metrics/events', () => { }); await events.emitRouteFlowEvent.call(request, { statusCode: 404 }); - expect(metricsContext.gather.callCount).toBe(0); + expect(metricsContext.gather).toHaveBeenCalledTimes(0); expect(log.flowEvent).not.toHaveBeenCalled(); expect(log.activityEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -1052,10 +1023,10 @@ describe('metrics/events', () => { errno: 42, }); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(1); - expect(log.flowEvent.mock.calls[0][0]).toEqual({ + expect(log.flowEvent).toHaveBeenCalledWith({ country: 'United States', event: 'route./account/destroy.400.42', flow_id: 'bar', @@ -1072,7 +1043,7 @@ describe('metrics/events', () => { }); expect(log.activityEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -1097,17 +1068,16 @@ describe('metrics/events', () => { }); await events.emitRouteFlowEvent.call(request, { statusCode: 200 }); - expect(metricsContext.gather.callCount).toBe(0); + expect(metricsContext.gather).toHaveBeenCalledTimes(0); expect(log.flowEvent).not.toHaveBeenCalled(); expect(log.activityEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); it('.emitRouteFlowEvent with matching route and invalid metrics context', async () => { - const sinon = require('sinon'); const metricsContext = mocks.mockMetricsContext({ - validate: sinon.spy(() => false), + validate: jest.fn(() => false), }); const request = mocks.mockRequest({ metricsContext, @@ -1124,13 +1094,13 @@ describe('metrics/events', () => { errno: 107, }); - expect(metricsContext.validate.callCount).toBe(1); - expect(metricsContext.validate.args[0]).toHaveLength(0); + expect(metricsContext.validate).toHaveBeenCalledTimes(1); + expect(metricsContext.validate).toHaveBeenCalledWith(); - expect(metricsContext.gather.callCount).toBe(0); + expect(metricsContext.gather).toHaveBeenCalledTimes(0); expect(log.flowEvent).not.toHaveBeenCalled(); expect(log.activityEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); @@ -1151,12 +1121,12 @@ describe('metrics/events', () => { errno: 107, }); - expect(metricsContext.validate.callCount).toBe(1); - expect(metricsContext.gather.callCount).toBe(1); + expect(metricsContext.validate).toHaveBeenCalledTimes(1); + expect(metricsContext.gather).toHaveBeenCalledTimes(1); expect(log.flowEvent).toHaveBeenCalledTimes(1); expect(log.activityEvent).not.toHaveBeenCalled(); - expect(metricsContext.clear.callCount).toBe(0); + expect(metricsContext.clear).toHaveBeenCalledTimes(0); expect(log.error).not.toHaveBeenCalled(); }); }); diff --git a/packages/fxa-auth-server/lib/notifier.spec.ts b/packages/fxa-auth-server/lib/notifier.spec.ts index fce01365134..2484d0602bd 100644 --- a/packages/fxa-auth-server/lib/notifier.spec.ts +++ b/packages/fxa-auth-server/lib/notifier.spec.ts @@ -2,11 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - interface NotifierInstance { send(event: Record, cb?: () => void): void; - __sns: { publish: sinon.SinonSpy }; + __sns: { publish: jest.Mock }; } type SnsPublishCallback = (err: null, data: Record) => void; @@ -25,7 +23,7 @@ function mockConfigWithArn(arn: string): void { } function stubSnsPublish(notifier: NotifierInstance): void { - notifier.__sns.publish = sinon.spy( + notifier.__sns.publish = jest.fn( (event: Record, cb: SnsPublishCallback) => { cb(null, event); } @@ -34,13 +32,13 @@ function stubSnsPublish(notifier: NotifierInstance): void { describe('notifier', () => { const log = { - error: sinon.spy(), - trace: sinon.spy(), + error: jest.fn(), + trace: jest.fn(), }; beforeEach(() => { - log.error.resetHistory(); - log.trace.resetHistory(); + log.error.mockClear(); + log.trace.mockClear(); jest.resetModules(); }); @@ -59,8 +57,7 @@ describe('notifier', () => { event: 'stuff', }); - expect(log.trace.args[0][0]).toBe('Notifier.publish'); - expect(log.trace.args[0][1]).toEqual({ + expect(log.trace).toHaveBeenCalledWith('Notifier.publish', { data: { TopicArn: 'arn:aws:sns:us-west-2:927034868275:foo', Message: '{"event":"stuff"}', @@ -73,7 +70,7 @@ describe('notifier', () => { }, success: true, }); - expect(log.error.called).toBe(false); + expect(log.error).not.toHaveBeenCalled(); }); it('flattens additional data into the message body', () => { @@ -85,8 +82,7 @@ describe('notifier', () => { }, }); - expect(log.trace.args[0][0]).toBe('Notifier.publish'); - expect(log.trace.args[0][1]).toEqual({ + expect(log.trace).toHaveBeenCalledWith('Notifier.publish', { data: { TopicArn: 'arn:aws:sns:us-west-2:927034868275:foo', Message: '{"cool":"stuff","more":"stuff","event":"stuff-with-data"}', @@ -99,7 +95,7 @@ describe('notifier', () => { }, success: true, }); - expect(log.error.called).toBe(false); + expect(log.error).not.toHaveBeenCalled(); }); it('includes email domain in message attributes', () => { @@ -110,8 +106,7 @@ describe('notifier', () => { }, }); - expect(log.trace.args[0][0]).toBe('Notifier.publish'); - expect(log.trace.args[0][1]).toEqual({ + expect(log.trace).toHaveBeenCalledWith('Notifier.publish', { data: { TopicArn: 'arn:aws:sns:us-west-2:927034868275:foo', Message: '{"email":"testme@example.com","event":"email-change"}', @@ -128,11 +123,11 @@ describe('notifier', () => { }, success: true, }); - expect(log.error.called).toBe(false); + expect(log.error).not.toHaveBeenCalled(); }); it('captures perf stats with statsd when it is present', () => { - const statsd = { timing: sinon.stub() }; + const statsd = { timing: jest.fn() }; mockConfigWithArn('arn:aws:sns:us-west-2:927034868275:foo'); jest.resetModules(); @@ -142,9 +137,11 @@ describe('notifier', () => { notifier.send({ event: 'testo', }); - expect(statsd.timing.calledOnce).toBe(true); - expect(statsd.timing.args[0][0]).toBe('notifier.publish'); - expect(typeof statsd.timing.args[0][1]).toBe('number'); + expect(statsd.timing).toHaveBeenCalledTimes(1); + expect(statsd.timing).toHaveBeenCalledWith( + 'notifier.publish', + expect.any(Number) + ); }); }); @@ -158,15 +155,13 @@ describe('notifier', () => { event: 'stuff', }, () => { - expect(log.trace.args[0][0]).toBe('Notifier.publish'); - expect(log.trace.args[0][1]).toEqual({ + expect(log.trace).toHaveBeenCalledWith('Notifier.publish', { data: { disabled: true, }, success: true, }); - expect(log.trace.args[0][1].data.disabled).toBe(true); - expect(log.error.called).toBe(false); + expect(log.error).not.toHaveBeenCalled(); } ); }); diff --git a/packages/fxa-auth-server/lib/oauth/grant.spec.ts b/packages/fxa-auth-server/lib/oauth/grant.spec.ts index 0cb904b6793..faa40cdcfbc 100644 --- a/packages/fxa-auth-server/lib/oauth/grant.spec.ts +++ b/packages/fxa-auth-server/lib/oauth/grant.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const { config } = require('../../config'); function decodeJWT(b64: string) { @@ -92,7 +90,7 @@ describe('validateRequestedGrant', () => { }); it('should check key-bearing scopes in the database, and reject if not allowed for that client', async () => { - mockDB.getScope = sinon.stub().callsFake(async () => { + mockDB.getScope = jest.fn().mockImplementation(async () => { return { hasScopedKeys: true }; }); const requestedGrant = { @@ -103,7 +101,7 @@ describe('validateRequestedGrant', () => { await expect( validateRequestedGrant(CLAIMS, CLIENT, requestedGrant) ).rejects.toThrow('Requested scopes are not allowed'); - expect(mockDB.getScope.callCount).toBe(1); + expect(mockDB.getScope).toHaveBeenCalledTimes(1); const allowedClient = { ...CLIENT, @@ -114,14 +112,14 @@ describe('validateRequestedGrant', () => { allowedClient, requestedGrant ); - expect(mockDB.getScope.callCount).toBe(2); + expect(mockDB.getScope).toHaveBeenCalledTimes(2); expect(grant.scope.toString()).toBe( 'https://identity.mozilla.com/apps/oldsync' ); }); it('should reject key-bearing scopes requested with claims from an unverified session', async () => { - mockDB.getScope = sinon.stub().callsFake(async () => { + mockDB.getScope = jest.fn().mockImplementation(async () => { return { hasScopedKeys: true }; }); const requestedGrant = { @@ -192,16 +190,16 @@ describe('generateTokens', () => { }; mockDB = { - generateAccessToken: sinon.spy(async () => mockAccessToken), - generateIdToken: sinon.spy(async () => ({ token: 'id_token' })), - generateRefreshToken: sinon.spy(async () => ({ + generateAccessToken: jest.fn(async () => mockAccessToken), + generateIdToken: jest.fn(async () => ({ token: 'id_token' })), + generateRefreshToken: jest.fn(async () => ({ token: 'refresh_token', })), }; mockCapabilityService = {}; mockJWTAccessToken = { - create: sinon.spy(async () => { + create: jest.fn(async () => { return { ...mockAccessToken, jwt_token: 'signed jwt access token', @@ -215,9 +213,9 @@ describe('generateTokens', () => { // The sign function produces a fake JWT whose payload can be decoded by decodeJWT. jest.doMock('./jwt', () => ({ sign(claims: any) { - const header = Buffer.from( - JSON.stringify({ alg: 'RS256' }) - ).toString('base64'); + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString( + 'base64' + ); const payload = Buffer.from(JSON.stringify(claims)).toString('base64'); const signature = 'fakesig'; return `${header}.${payload}.${signature}`; @@ -240,10 +238,9 @@ describe('generateTokens', () => { it('should return required params in result, normal access token by default', async () => { const result = await generateTokens(requestedGrant); - expect(mockDB.generateAccessToken.calledOnceWith(requestedGrant)).toBe( - true - ); - expect(mockJWTAccessToken.create.called).toBe(false); + expect(mockDB.generateAccessToken).toHaveBeenCalledTimes(1); + expect(mockDB.generateAccessToken).toHaveBeenCalledWith(requestedGrant); + expect(mockJWTAccessToken.create).not.toHaveBeenCalled(); expect(result.access_token).toBe('token'); expect(typeof result.expires_in).toBe('number'); @@ -261,24 +258,25 @@ describe('generateTokens', () => { it('should generate a JWT access token if enabled, client_id allowed, and direct Stripe access enabled', async () => { const clientId = '9876543210'; - mockCapabilityService.subscriptionCapabilities = sinon.fake.resolves({ - [`capabilities:${clientId}`]: 'cap1', - }); - mockCapabilityService.determineClientVisibleSubscriptionCapabilities = - sinon.fake.resolves(['cap1']); + mockCapabilityService.subscriptionCapabilities = jest + .fn() + .mockResolvedValue({ + [`capabilities:${clientId}`]: 'cap1', + }); + mockCapabilityService.determineClientVisibleSubscriptionCapabilities = jest + .fn() + .mockResolvedValue(['cap1']); requestedGrant.clientId = Buffer.from(clientId, 'hex'); const result = await generateTokens(requestedGrant); - expect(mockDB.generateAccessToken.calledOnceWith(requestedGrant)).toBe( - true - ); + expect(mockDB.generateAccessToken).toHaveBeenCalledTimes(1); + expect(mockDB.generateAccessToken).toHaveBeenCalledWith(requestedGrant); expect(result.access_token).toBe('signed jwt access token'); - expect( - mockJWTAccessToken.create.calledOnceWith(mockAccessToken, { - ...requestedGrant, - 'fxa-subscriptions': ['cap1'], - }) - ).toBe(true); + expect(mockJWTAccessToken.create).toHaveBeenCalledTimes(1); + expect(mockJWTAccessToken.create).toHaveBeenCalledWith(mockAccessToken, { + ...requestedGrant, + 'fxa-subscriptions': ['cap1'], + }); expect(typeof result.expires_in).toBe('number'); expect(result.token_type).toBe('access_token'); @@ -338,8 +336,6 @@ describe('generateTokens', () => { const result = await generateTokens(requestedGrant); expect(result.id_token).toBeTruthy(); const jwt = decodeJWT(result.id_token); - expect(jwt.claims.auth_time).toBe( - Math.floor(requestedGrant.authAt / 1000) - ); + expect(jwt.claims.auth_time).toBe(Math.floor(requestedGrant.authAt / 1000)); }); }); diff --git a/packages/fxa-auth-server/lib/oauth/jwt_access_token.spec.ts b/packages/fxa-auth-server/lib/oauth/jwt_access_token.spec.ts index 4e64c66f252..075f479f7ed 100644 --- a/packages/fxa-auth-server/lib/oauth/jwt_access_token.spec.ts +++ b/packages/fxa-auth-server/lib/oauth/jwt_access_token.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; const ScopeSet = require('fxa-shared').oauth.scopes; const { OAUTH_SCOPE_OLD_SYNC } = require('fxa-shared/oauth/constants'); const TOKEN_SERVER_URL = @@ -39,7 +38,7 @@ describe('lib/jwt_access_token', () => { }; mockJWT = { - sign: sinon.spy(async () => { + sign: jest.fn(async () => { return 'signed jwt access token'; }), }; @@ -61,9 +60,9 @@ describe('lib/jwt_access_token', () => { const afterSec = Math.floor(Date.now() / 1000); expect(result.jwt_token).toBe('signed jwt access token'); - expect(mockJWT.sign.calledOnce).toBe(true); + expect(mockJWT.sign).toHaveBeenCalledTimes(1); - const signedClaims = mockJWT.sign.args[0][0]; + const signedClaims = mockJWT.sign.mock.calls[0][0]; expect(Object.keys(signedClaims)).toHaveLength(7); expect(signedClaims.aud).toBe('deadbeef'); expect(signedClaims.client_id).toBe('deadbeef'); @@ -78,7 +77,7 @@ describe('lib/jwt_access_token', () => { it('should propagate `resource` and `clientId` in the `aud` claim', async () => { requestedGrant.resource = 'https://resource.server1.com'; await JWTAccessToken.create(mockAccessToken, requestedGrant); - const signedClaims = mockJWT.sign.args[0][0]; + const signedClaims = mockJWT.sign.mock.calls[0][0]; expect(signedClaims.aud).toEqual([ 'deadbeef', 'https://resource.server1.com', @@ -88,7 +87,7 @@ describe('lib/jwt_access_token', () => { it('should propagate `fxa-subscriptions`', async () => { requestedGrant['fxa-subscriptions'] = ['subscription1', 'subscription2']; await JWTAccessToken.create(mockAccessToken, requestedGrant); - const signedClaims = mockJWT.sign.args[0][0]; + const signedClaims = mockJWT.sign.mock.calls[0][0]; expect(Object.keys(signedClaims)).toHaveLength(8); expect(signedClaims['fxa-subscriptions']).toBe( @@ -99,7 +98,7 @@ describe('lib/jwt_access_token', () => { it('should propagate `fxa-generation`', async () => { requestedGrant.generation = 12345; await JWTAccessToken.create(mockAccessToken, requestedGrant); - const signedClaims = mockJWT.sign.args[0][0]; + const signedClaims = mockJWT.sign.mock.calls[0][0]; expect(signedClaims['fxa-generation']).toBe(requestedGrant.generation); }); @@ -107,7 +106,7 @@ describe('lib/jwt_access_token', () => { it('should propagate `fxa-profileChangedAt`', async () => { requestedGrant.profileChangedAt = 12345; await JWTAccessToken.create(mockAccessToken, requestedGrant); - const signedClaims = mockJWT.sign.args[0][0]; + const signedClaims = mockJWT.sign.mock.calls[0][0]; expect(signedClaims['fxa-profileChangedAt']).toBe( requestedGrant.profileChangedAt @@ -117,7 +116,7 @@ describe('lib/jwt_access_token', () => { it('defaults oldsync scope to tokenserver audience', async () => { requestedGrant.scope = ScopeSet.fromString(OAUTH_SCOPE_OLD_SYNC); await JWTAccessToken.create(mockAccessToken, requestedGrant); - const signedClaims = mockJWT.sign.args[0][0]; + const signedClaims = mockJWT.sign.mock.calls[0][0]; expect(signedClaims.aud).toBe(TOKEN_SERVER_URL); }); diff --git a/packages/fxa-auth-server/lib/payments/capability.spec.ts b/packages/fxa-auth-server/lib/payments/capability.spec.ts index 2e844262c96..a5906fb396a 100644 --- a/packages/fxa-auth-server/lib/payments/capability.spec.ts +++ b/packages/fxa-auth-server/lib/payments/capability.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; const mockAuthEvents: any = {}; @@ -86,7 +85,7 @@ describe('CapabilityService', () => { let mockConfig: any; beforeEach(async () => { - mockAuthEvents.on = sinon.fake.returns({}); + mockAuthEvents.on = jest.fn().mockReturnValue({}); mockStripeHelper = {}; mockPlayBilling = { userManager: {}, @@ -96,7 +95,7 @@ describe('CapabilityService', () => { purchaseManager: {}, }; mockProfileClient = { - deleteCache: sinon.fake.resolves({}), + deleteCache: jest.fn().mockResolvedValue({}), }; mockConfigPlans = [ { @@ -107,10 +106,10 @@ describe('CapabilityService', () => { }, ]; mockPaymentConfigManager = { - allPlans: sinon.fake.resolves(mockConfigPlans), + allPlans: jest.fn().mockResolvedValue(mockConfigPlans), getMergedConfig: (price: any) => price, }; - mockStripeHelper.allAbbrevPlans = sinon.spy(async () => [ + mockStripeHelper.allAbbrevPlans = jest.fn(async () => [ { plan_id: 'plan_123456', product_id: 'prod_123456', @@ -153,12 +152,12 @@ describe('CapabilityService', () => { }, }, ]); - mockStripeHelper.allMergedPlanConfigs = sinon.spy(async () => {}); + mockStripeHelper.allMergedPlanConfigs = jest.fn(async () => {}); mockCapabilityManager = { - getClients: sinon.fake.resolves(mockCMSClients), - priceIdsToClientCapabilities: sinon.fake.resolves( - mockCMSPlanIdsToClientCapabilities - ), + getClients: jest.fn().mockResolvedValue(mockCMSClients), + priceIdsToClientCapabilities: jest + .fn() + .mockResolvedValue(mockCMSPlanIdsToClientCapabilities), }; mockConfig = { ...config, cms: { enabled: false } }; log = mockLog(); @@ -176,29 +175,33 @@ describe('CapabilityService', () => { afterEach(() => { Container.reset(); - sinon.restore(); + jest.restoreAllMocks(); }); describe('stripeUpdate', () => { beforeEach(() => { - const fake = sinon.fake.resolves({ + const fake = jest.fn().mockResolvedValue({ uid: UID, email: EMAIL, }); - sinon.replace(authDbModule, 'getUidAndEmailByStripeCustomerId', fake); - capabilityService.subscribedPriceIds = sinon.fake.resolves([ - 'price_GWScEDK6LT8cSV', - ]); - capabilityService.processPriceIdDiff = sinon.fake.resolves(); + jest + .spyOn(authDbModule, 'getUidAndEmailByStripeCustomerId') + .mockImplementation(fake); + capabilityService.subscribedPriceIds = jest + .fn() + .mockResolvedValue(['price_GWScEDK6LT8cSV']); + capabilityService.processPriceIdDiff = jest.fn().mockResolvedValue(); }); it('handles a stripe price update with new prices', async () => { const sub = deepCopy(subscriptionCreated); await capabilityService.stripeUpdate({ sub, uid: UID, email: EMAIL }); - sinon.assert.notCalled(authDbModule.getUidAndEmailByStripeCustomerId); - sinon.assert.calledWith(mockProfileClient.deleteCache, UID); - sinon.assert.calledWith(capabilityService.subscribedPriceIds, UID); - sinon.assert.calledWith(capabilityService.processPriceIdDiff, { + expect( + authDbModule.getUidAndEmailByStripeCustomerId + ).not.toHaveBeenCalled(); + expect(mockProfileClient.deleteCache).toHaveBeenCalledWith(UID); + expect(capabilityService.subscribedPriceIds).toHaveBeenCalledWith(UID); + expect(capabilityService.processPriceIdDiff).toHaveBeenCalledWith({ uid: UID, priorPriceIds: [], currentPriceIds: ['price_GWScEDK6LT8cSV'], @@ -207,12 +210,14 @@ describe('CapabilityService', () => { it('handles a stripe price update with removed prices', async () => { const sub = deepCopy(subscriptionCreated); - capabilityService.subscribedPriceIds = sinon.fake.resolves([]); + capabilityService.subscribedPriceIds = jest.fn().mockResolvedValue([]); await capabilityService.stripeUpdate({ sub, uid: UID, email: EMAIL }); - sinon.assert.notCalled(authDbModule.getUidAndEmailByStripeCustomerId); - sinon.assert.calledWith(mockProfileClient.deleteCache, UID); - sinon.assert.calledWith(capabilityService.subscribedPriceIds, UID); - sinon.assert.calledWith(capabilityService.processPriceIdDiff, { + expect( + authDbModule.getUidAndEmailByStripeCustomerId + ).not.toHaveBeenCalled(); + expect(mockProfileClient.deleteCache).toHaveBeenCalledWith(UID); + expect(capabilityService.subscribedPriceIds).toHaveBeenCalledWith(UID); + expect(capabilityService.processPriceIdDiff).toHaveBeenCalledWith({ uid: UID, priorPriceIds: ['price_GWScEDK6LT8cSV'], currentPriceIds: [], @@ -222,13 +227,12 @@ describe('CapabilityService', () => { it('handles a stripe price update without uid/email', async () => { const sub = deepCopy(subscriptionCreated); await capabilityService.stripeUpdate({ sub }); - sinon.assert.calledWith( - authDbModule.getUidAndEmailByStripeCustomerId, - sub.customer - ); - sinon.assert.calledWith(mockProfileClient.deleteCache, UID); - sinon.assert.calledWith(capabilityService.subscribedPriceIds, UID); - sinon.assert.calledWith(capabilityService.processPriceIdDiff, { + expect( + authDbModule.getUidAndEmailByStripeCustomerId + ).toHaveBeenCalledWith(sub.customer); + expect(mockProfileClient.deleteCache).toHaveBeenCalledWith(UID); + expect(capabilityService.subscribedPriceIds).toHaveBeenCalledWith(UID); + expect(capabilityService.processPriceIdDiff).toHaveBeenCalledWith({ uid: UID, priorPriceIds: [], currentPriceIds: ['price_GWScEDK6LT8cSV'], @@ -240,15 +244,17 @@ describe('CapabilityService', () => { let subscriptionPurchase: any; beforeEach(() => { - const fake = sinon.fake.resolves({ + const fake = jest.fn().mockResolvedValue({ uid: UID, email: EMAIL, }); - sinon.replace(authDbModule, 'getUidAndEmailByStripeCustomerId', fake); - capabilityService.subscribedPriceIds = sinon.fake.resolves([ - 'prod_FUUNYnlDso7FeB', - ]); - capabilityService.processPriceIdDiff = sinon.fake.resolves(); + jest + .spyOn(authDbModule, 'getUidAndEmailByStripeCustomerId') + .mockImplementation(fake); + capabilityService.subscribedPriceIds = jest + .fn() + .mockResolvedValue(['prod_FUUNYnlDso7FeB']); + capabilityService.processPriceIdDiff = jest.fn().mockResolvedValue(); subscriptionPurchase = PlayStoreSubscriptionPurchase.fromApiResponse( VALID_SUB_API_RESPONSE, 'testPackage', @@ -256,16 +262,16 @@ describe('CapabilityService', () => { 'testSku', Date.now() ); - mockStripeHelper.iapPurchasesToPriceIds = sinon.fake.resolves([ - 'prod_FUUNYnlDso7FeB', - ]); + mockStripeHelper.iapPurchasesToPriceIds = jest + .fn() + .mockResolvedValue(['prod_FUUNYnlDso7FeB']); }); it('handles an IAP purchase with new product', async () => { await capabilityService.iapUpdate(UID, EMAIL, subscriptionPurchase); - sinon.assert.calledWith(mockProfileClient.deleteCache, UID); - sinon.assert.calledWith(capabilityService.subscribedPriceIds, UID); - sinon.assert.calledWith(capabilityService.processPriceIdDiff, { + expect(mockProfileClient.deleteCache).toHaveBeenCalledWith(UID); + expect(capabilityService.subscribedPriceIds).toHaveBeenCalledWith(UID); + expect(capabilityService.processPriceIdDiff).toHaveBeenCalledWith({ uid: UID, priorPriceIds: [], currentPriceIds: ['prod_FUUNYnlDso7FeB'], @@ -273,11 +279,11 @@ describe('CapabilityService', () => { }); it('handles an IAP purchase with a removed product', async () => { - capabilityService.subscribedPriceIds = sinon.fake.resolves([]); + capabilityService.subscribedPriceIds = jest.fn().mockResolvedValue([]); await capabilityService.iapUpdate(UID, EMAIL, subscriptionPurchase); - sinon.assert.calledWith(mockProfileClient.deleteCache, UID); - sinon.assert.calledWith(capabilityService.subscribedPriceIds, UID); - sinon.assert.calledWith(capabilityService.processPriceIdDiff, { + expect(mockProfileClient.deleteCache).toHaveBeenCalledWith(UID); + expect(capabilityService.subscribedPriceIds).toHaveBeenCalledWith(UID); + expect(capabilityService.processPriceIdDiff).toHaveBeenCalledWith({ uid: UID, priorPriceIds: ['prod_FUUNYnlDso7FeB'], currentPriceIds: [], @@ -294,12 +300,12 @@ describe('CapabilityService', () => { isEntitlementActive: () => true, }; mockQueryResponse = [mockSubPurchase]; - mockPlayBilling.userManager.queryCurrentSubscriptions = sinon - .stub() - .resolves(mockQueryResponse); - mockStripeHelper.iapPurchasesToPriceIds = sinon.fake.returns([ - 'plan_GOOGLE', - ]); + mockPlayBilling.userManager.queryCurrentSubscriptions = jest + .fn() + .mockResolvedValue(mockQueryResponse); + mockStripeHelper.iapPurchasesToPriceIds = jest + .fn() + .mockReturnValue(['plan_GOOGLE']); }); afterEach(() => { @@ -316,12 +322,10 @@ describe('CapabilityService', () => { it('returns a subscribed price if found', async () => { const expected = ['plan_GOOGLE']; const actual = await capabilityService.fetchSubscribedPricesFromPlay(UID); - sinon.assert.calledWith( - mockPlayBilling.userManager.queryCurrentSubscriptions, - UID - ); - sinon.assert.calledWith( - mockStripeHelper.iapPurchasesToPriceIds, + expect( + mockPlayBilling.userManager.queryCurrentSubscriptions + ).toHaveBeenCalledWith(UID); + expect(mockStripeHelper.iapPurchasesToPriceIds).toHaveBeenCalledWith( mockQueryResponse ); expect(actual).toEqual(expected); @@ -330,14 +334,14 @@ describe('CapabilityService', () => { it('logs a query error and returns [] if the query fails', async () => { const error = new Error('Bleh'); error.name = PurchaseQueryError.OTHER_ERROR; - mockPlayBilling.userManager.queryCurrentSubscriptions = sinon - .stub() - .rejects(error); + mockPlayBilling.userManager.queryCurrentSubscriptions = jest + .fn() + .mockRejectedValue(error); const expected: any[] = []; const actual = await capabilityService.fetchSubscribedPricesFromPlay(UID); expect(actual).toEqual(expected); - sinon.assert.calledOnceWithExactly( - log.error, + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( 'Failed to query purchases from Google Play', { uid: UID, @@ -356,12 +360,12 @@ describe('CapabilityService', () => { isEntitlementActive: () => true, }; mockQueryResponse = [mockSubPurchase]; - mockAppleIAP.purchaseManager.queryCurrentSubscriptionPurchases = sinon - .stub() - .resolves(mockQueryResponse); - mockStripeHelper.iapPurchasesToPriceIds = sinon.fake.returns([ - 'plan_APPLE', - ]); + mockAppleIAP.purchaseManager.queryCurrentSubscriptionPurchases = jest + .fn() + .mockResolvedValue(mockQueryResponse); + mockStripeHelper.iapPurchasesToPriceIds = jest + .fn() + .mockReturnValue(['plan_APPLE']); }); afterEach(() => { @@ -380,12 +384,10 @@ describe('CapabilityService', () => { const expected = ['plan_APPLE']; const actual = await capabilityService.fetchSubscribedPricesFromAppStore(UID); - sinon.assert.calledWith( - mockAppleIAP.purchaseManager.queryCurrentSubscriptionPurchases, - UID - ); - sinon.assert.calledWith( - mockStripeHelper.iapPurchasesToPriceIds, + expect( + mockAppleIAP.purchaseManager.queryCurrentSubscriptionPurchases + ).toHaveBeenCalledWith(UID); + expect(mockStripeHelper.iapPurchasesToPriceIds).toHaveBeenCalledWith( mockQueryResponse ); expect(actual).toEqual(expected); @@ -394,15 +396,15 @@ describe('CapabilityService', () => { it('logs a query error and returns [] if the query fails', async () => { const error = new Error('Bleh'); error.name = PurchaseQueryError.OTHER_ERROR; - mockAppleIAP.purchaseManager.queryCurrentSubscriptionPurchases = sinon - .stub() - .rejects(error); + mockAppleIAP.purchaseManager.queryCurrentSubscriptionPurchases = jest + .fn() + .mockRejectedValue(error); const expected: any[] = []; const actual = await capabilityService.fetchSubscribedPricesFromAppStore(UID); expect(actual).toEqual(expected); - sinon.assert.calledOnceWithExactly( - log.error, + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( 'Failed to query purchases from Apple App Store', { uid: UID, @@ -416,7 +418,7 @@ describe('CapabilityService', () => { it('should broadcast the capabilities added', async () => { const capabilities = ['cap2']; capabilityService.broadcastCapabilitiesAdded({ uid: UID, capabilities }); - sinon.assert.calledOnce(log.notifyAttachedServices); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); }); }); @@ -427,7 +429,7 @@ describe('CapabilityService', () => { uid: UID, capabilities, }); - sinon.assert.calledOnce(log.notifyAttachedServices); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); }); }); @@ -460,18 +462,22 @@ describe('CapabilityService', () => { ]; beforeEach(() => { - capabilityService.fetchSubscribedPricesFromStripe = sinon.fake.resolves( - [] - ); - capabilityService.fetchSubscribedPricesFromAppStore = sinon.fake.resolves( - [] - ); - capabilityService.fetchSubscribedPricesFromPlay = sinon.fake.resolves([]); + capabilityService.fetchSubscribedPricesFromStripe = jest + .fn() + .mockResolvedValue([]); + capabilityService.fetchSubscribedPricesFromAppStore = jest + .fn() + .mockResolvedValue([]); + capabilityService.fetchSubscribedPricesFromPlay = jest + .fn() + .mockResolvedValue([]); }); it('throws an error for an invalid targetPlanId', async () => { let error: any; - capabilityService.allAbbrevPlansByPlanId = sinon.fake.resolves([]); + capabilityService.allAbbrevPlansByPlanId = jest + .fn() + .mockResolvedValue([]); try { await capabilityService.getPlanEligibility(UID, 'invalid-id'); } catch (e) { @@ -481,12 +487,12 @@ describe('CapabilityService', () => { }); it('returns the eligibility from Stripe if eligibilityManager is not found', async () => { - capabilityService.allAbbrevPlansByPlanId = sinon.fake.resolves({ + capabilityService.allAbbrevPlansByPlanId = jest.fn().mockResolvedValue({ plan_123456: mockAbbrevPlans[0], }); - capabilityService.eligibilityFromStripeMetadata = sinon.fake.resolves([ - SubscriptionEligibilityResult.CREATE, - ]); + capabilityService.eligibilityFromStripeMetadata = jest + .fn() + .mockResolvedValue([SubscriptionEligibilityResult.CREATE]); const expected = [SubscriptionEligibilityResult.CREATE]; const actual = await capabilityService.getPlanEligibility( UID, @@ -496,26 +502,27 @@ describe('CapabilityService', () => { }); it('returns results from Stripe and logs to Sentry when results do not match', async () => { - const sentryScope = { setContext: sinon.stub() }; - sinon.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sinon.stub(sentryModule, 'reportSentryMessage').returns({}); + const sentryScope = { setContext: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(sentryModule, 'reportSentryMessage').mockReturnValue({}); Container.set(EligibilityManager, {}); capabilityService = new CapabilityService(); - capabilityService.allAbbrevPlansByPlanId = sinon.fake.resolves({ + capabilityService.allAbbrevPlansByPlanId = jest.fn().mockResolvedValue({ plan_123456: mockAbbrevPlans[0], }); - capabilityService.eligibilityFromStripeMetadata = sinon.fake.resolves([ - SubscriptionEligibilityResult.UPGRADE, - ]); - capabilityService.getAllSubscribedAbbrevPlans = sinon.fake.resolves([ - [mockAbbrevPlans[1]], - [], - ]); - capabilityService.eligibilityFromEligibilityManager = sinon.fake.resolves( - [SubscriptionEligibilityResult.CREATE] - ); + capabilityService.eligibilityFromStripeMetadata = jest + .fn() + .mockResolvedValue([SubscriptionEligibilityResult.UPGRADE]); + capabilityService.getAllSubscribedAbbrevPlans = jest + .fn() + .mockResolvedValue([[mockAbbrevPlans[1]], []]); + capabilityService.eligibilityFromEligibilityManager = jest + .fn() + .mockResolvedValue([SubscriptionEligibilityResult.CREATE]); const actual = await capabilityService.getPlanEligibility( UID, @@ -523,8 +530,8 @@ describe('CapabilityService', () => { ); expect(actual).toEqual([SubscriptionEligibilityResult.UPGRADE]); - sinon.assert.calledOnceWithExactly( - sentryScope.setContext, + expect(sentryScope.setContext).toHaveBeenCalledTimes(1); + expect(sentryScope.setContext).toHaveBeenCalledWith( 'getPlanEligibility', { stripeSubscribedPlans: [mockAbbrevPlans[1]], @@ -535,8 +542,8 @@ describe('CapabilityService', () => { targetPlanId: 'plan_123456', } ); - sinon.assert.calledOnceWithExactly( - sentryModule.reportSentryMessage, + expect(sentryModule.reportSentryMessage).toHaveBeenCalledTimes(1); + expect(sentryModule.reportSentryMessage).toHaveBeenCalledWith( `Eligibility mismatch for uid8675309 on plan_123456`, 'error' ); @@ -607,12 +614,10 @@ describe('CapabilityService', () => { }); it('returns blocked_iap for targetPlan with productSet the user is subscribed to with IAP', async () => { - mockEligibilityManager.getOfferingOverlap = sinon - .stub() - .onCall(0) - .resolves([]) - .onCall(1) - .resolves([ + mockEligibilityManager.getOfferingOverlap = jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ { comparison: 'same', priceId: mockPlanTier1ShortInterval.plan_id, @@ -629,14 +634,16 @@ describe('CapabilityService', () => { SubscriptionEligibilityResult.BLOCKED_IAP, eligibleSourcePlan: mockPlanTier1ShortInterval, }); - sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + expect(mockEligibilityManager.getOfferingOverlap).toHaveBeenCalledWith({ priceIds: [mockPlanTier1ShortInterval.plan_id], targetPriceId: mockPlanTier1LongInterval.plan_id, }); }); it('returns create for targetPlan with offering user is not subscribed to', async () => { - mockEligibilityManager.getOfferingOverlap = sinon.stub().resolves([]); + mockEligibilityManager.getOfferingOverlap = jest + .fn() + .mockResolvedValue([]); const actual = await capabilityService.eligibilityFromEligibilityManager( [], @@ -646,24 +653,22 @@ describe('CapabilityService', () => { expect(actual).toEqual({ subscriptionEligibilityResult: SubscriptionEligibilityResult.CREATE, }); - sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + expect(mockEligibilityManager.getOfferingOverlap).toHaveBeenCalledWith({ priceIds: [], targetPriceId: mockPlanTier1ShortInterval.plan_id, }); }); it('returns upgrade for targetPlan with offering user is subscribed to a lower tier of', async () => { - mockEligibilityManager.getOfferingOverlap = sinon - .stub() - .onCall(0) - .resolves([ + mockEligibilityManager.getOfferingOverlap = jest + .fn() + .mockResolvedValueOnce([ { comparison: 'upgrade', priceId: mockPlanTier1ShortInterval.plan_id, }, ]) - .onCall(1) - .resolves([]); + .mockResolvedValueOnce([]); const actual = await capabilityService.eligibilityFromEligibilityManager( [mockPlanTier1ShortInterval], @@ -674,24 +679,22 @@ describe('CapabilityService', () => { subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE, eligibleSourcePlan: mockPlanTier1ShortInterval, }); - sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + expect(mockEligibilityManager.getOfferingOverlap).toHaveBeenCalledWith({ priceIds: [mockPlanTier1ShortInterval.plan_id], targetPriceId: mockPlanTier2LongInterval.plan_id, }); }); it('returns downgrade for targetPlan with offering user is subscribed to a higher tier of', async () => { - mockEligibilityManager.getOfferingOverlap = sinon - .stub() - .onCall(0) - .resolves([ + mockEligibilityManager.getOfferingOverlap = jest + .fn() + .mockResolvedValueOnce([ { comparison: 'downgrade', priceId: mockPlanTier1ShortInterval.plan_id, }, ]) - .onCall(1) - .resolves([]); + .mockResolvedValueOnce([]); const actual = await capabilityService.eligibilityFromEligibilityManager( [mockPlanTier2LongInterval], @@ -703,24 +706,22 @@ describe('CapabilityService', () => { SubscriptionEligibilityResult.DOWNGRADE, eligibleSourcePlan: undefined, }); - sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + expect(mockEligibilityManager.getOfferingOverlap).toHaveBeenCalledWith({ priceIds: [mockPlanTier2LongInterval.plan_id], targetPriceId: mockPlanTier1ShortInterval.plan_id, }); }); it('returns upgrade for targetPlan with offering user is subscribed to a higher interval of', async () => { - mockEligibilityManager.getOfferingOverlap = sinon - .stub() - .onCall(0) - .resolves([ + mockEligibilityManager.getOfferingOverlap = jest + .fn() + .mockResolvedValueOnce([ { comparison: 'upgrade', priceId: mockPlanTier1ShortInterval.plan_id, }, ]) - .onCall(1) - .resolves([]); + .mockResolvedValueOnce([]); const actual = await capabilityService.eligibilityFromEligibilityManager( [mockPlanTier1ShortInterval], @@ -731,24 +732,22 @@ describe('CapabilityService', () => { subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE, eligibleSourcePlan: mockPlanTier1ShortInterval, }); - sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + expect(mockEligibilityManager.getOfferingOverlap).toHaveBeenCalledWith({ priceIds: [mockPlanTier1ShortInterval.plan_id], targetPriceId: mockPlanTier1LongInterval.plan_id, }); }); it('returns upgrade for targetPlan with offering user is subscribed and interval is not shorter', async () => { - mockEligibilityManager.getOfferingOverlap = sinon - .stub() - .onCall(0) - .resolves([ + mockEligibilityManager.getOfferingOverlap = jest + .fn() + .mockResolvedValueOnce([ { comparison: 'upgrade', priceId: mockPlanTier1ShortInterval.plan_id, }, ]) - .onCall(1) - .resolves([]); + .mockResolvedValueOnce([]); const actual = await capabilityService.eligibilityFromEligibilityManager( [mockPlanTier1ShortInterval], @@ -759,24 +758,22 @@ describe('CapabilityService', () => { subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE, eligibleSourcePlan: mockPlanTier1ShortInterval, }); - sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + expect(mockEligibilityManager.getOfferingOverlap).toHaveBeenCalledWith({ priceIds: [mockPlanTier1ShortInterval.plan_id], targetPriceId: mockPlanTier2ShortInterval.plan_id, }); }); it('returns upgrade for targetPlan with same offering and longer interval', async () => { - mockEligibilityManager.getOfferingOverlap = sinon - .stub() - .onCall(0) - .resolves([ + mockEligibilityManager.getOfferingOverlap = jest + .fn() + .mockResolvedValueOnce([ { comparison: 'same', priceId: mockPlanTier1ShortInterval.plan_id, }, ]) - .onCall(1) - .resolves([]); + .mockResolvedValueOnce([]); const actual = await capabilityService.eligibilityFromEligibilityManager( [mockPlanTier1ShortInterval], @@ -787,24 +784,22 @@ describe('CapabilityService', () => { subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE, eligibleSourcePlan: mockPlanTier1ShortInterval, }); - sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + expect(mockEligibilityManager.getOfferingOverlap).toHaveBeenCalledWith({ priceIds: [mockPlanTier1ShortInterval.plan_id], targetPriceId: mockPlanTier1LongInterval.plan_id, }); }); it('returns downgrade for targetPlan with shorter interval but higher tier than user is subscribed to', async () => { - mockEligibilityManager.getOfferingOverlap = sinon - .stub() - .onCall(0) - .resolves([ + mockEligibilityManager.getOfferingOverlap = jest + .fn() + .mockResolvedValueOnce([ { comparison: 'upgrade', priceId: mockPlanTier1LongInterval.plan_id, }, ]) - .onCall(1) - .resolves([]); + .mockResolvedValueOnce([]); Container.set(EligibilityManager, mockEligibilityManager); capabilityService = new CapabilityService(); const actual = @@ -818,24 +813,22 @@ describe('CapabilityService', () => { SubscriptionEligibilityResult.DOWNGRADE, eligibleSourcePlan: mockPlanTier1LongInterval, }); - sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + expect(mockEligibilityManager.getOfferingOverlap).toHaveBeenCalledWith({ priceIds: [mockPlanTier1LongInterval.plan_id], targetPriceId: mockPlanTier2ShortInterval.plan_id, }); }); it('returns invalid for targetPlan with same offering user is subscribed to', async () => { - mockEligibilityManager.getOfferingOverlap = sinon - .stub() - .onCall(0) - .resolves([ + mockEligibilityManager.getOfferingOverlap = jest + .fn() + .mockResolvedValueOnce([ { comparison: 'upgrade', priceId: mockPlanTier1ShortInterval.plan_id, }, ]) - .onCall(1) - .resolves([]); + .mockResolvedValueOnce([]); const actual = await capabilityService.eligibilityFromEligibilityManager( [mockPlanTier1ShortInterval], @@ -845,24 +838,22 @@ describe('CapabilityService', () => { expect(actual).toEqual({ subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID, }); - sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + expect(mockEligibilityManager.getOfferingOverlap).toHaveBeenCalledWith({ priceIds: [mockPlanTier1ShortInterval.plan_id], targetPriceId: mockPlanTier1ShortInterval.plan_id, }); }); it('returns invalid for targetPlan with same offering user is subscribed to but different currency', async () => { - mockEligibilityManager.getOfferingOverlap = sinon - .stub() - .onCall(0) - .resolves([ + mockEligibilityManager.getOfferingOverlap = jest + .fn() + .mockResolvedValueOnce([ { comparison: 'same', priceId: mockPlanTier2LongInterval.plan_id, }, ]) - .onCall(1) - .resolves([]); + .mockResolvedValueOnce([]); const actual = await capabilityService.eligibilityFromEligibilityManager( [mockPlanTier2LongInterval], @@ -872,7 +863,7 @@ describe('CapabilityService', () => { expect(actual).toEqual({ subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID, }); - sinon.assert.calledWith(mockEligibilityManager.getOfferingOverlap, { + expect(mockEligibilityManager.getOfferingOverlap).toHaveBeenCalledWith({ priceIds: [mockPlanTier2LongInterval.plan_id], targetPriceId: mockPlanTier2LongIntervalDiffCurr.plan_id, }); @@ -881,8 +872,9 @@ describe('CapabilityService', () => { describe('FromStripeMetadata', () => { it('returns blocked_iap for targetPlan with productSet the user is subscribed to with IAP', async () => { - capabilityService.fetchSubscribedPricesFromAppStore = - sinon.fake.resolves(['plan_123456']); + capabilityService.fetchSubscribedPricesFromAppStore = jest + .fn() + .mockResolvedValue(['plan_123456']); const actual = await capabilityService.eligibilityFromStripeMetadata( [], [mockPlanTier2LongInterval], @@ -907,9 +899,9 @@ describe('CapabilityService', () => { }); it('returns upgrade for targetPlan with productSet user is subscribed to a lower tier of', async () => { - capabilityService.fetchSubscribedPricesFromStripe = sinon.fake.resolves( - [mockPlanTier1ShortInterval.plan_id] - ); + capabilityService.fetchSubscribedPricesFromStripe = jest + .fn() + .mockResolvedValue([mockPlanTier1ShortInterval.plan_id]); const actual = await capabilityService.eligibilityFromStripeMetadata( [mockPlanTier1ShortInterval], [], @@ -922,9 +914,9 @@ describe('CapabilityService', () => { }); it('returns downgrade for targetPlan with productSet user is subscribed to a higher tier of', async () => { - capabilityService.fetchSubscribedPricesFromStripe = sinon.fake.resolves( - [mockPlanTier2LongInterval.plan_id] - ); + capabilityService.fetchSubscribedPricesFromStripe = jest + .fn() + .mockResolvedValue([mockPlanTier2LongInterval.plan_id]); const actual = await capabilityService.eligibilityFromStripeMetadata( [mockPlanTier2LongInterval], [], @@ -938,9 +930,9 @@ describe('CapabilityService', () => { }); it('returns invalid for targetPlan with no product order', async () => { - capabilityService.fetchSubscribedPricesFromStripe = sinon.fake.resolves( - [mockPlanTier2LongInterval.plan_id] - ); + capabilityService.fetchSubscribedPricesFromStripe = jest + .fn() + .mockResolvedValue([mockPlanTier2LongInterval.plan_id]); const actual = await capabilityService.eligibilityFromStripeMetadata( [mockPlanTier2LongInterval], [], @@ -962,20 +954,19 @@ describe('CapabilityService', () => { }); it('returns blocked_iap result from both', async () => { - mockEligibilityManager.getOfferingOverlap = sinon - .stub() - .onCall(0) - .resolves([]) - .onCall(1) - .resolves([ + mockEligibilityManager.getOfferingOverlap = jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ { comparison: 'same', priceId: mockPlanTier1ShortInterval.plan_id, }, ]); - capabilityService.fetchSubscribedPricesFromAppStore = - sinon.fake.resolves(['plan_123456']); + capabilityService.fetchSubscribedPricesFromAppStore = jest + .fn() + .mockResolvedValue(['plan_123456']); const eligiblityActual = await capabilityService.eligibilityFromEligibilityManager( @@ -1000,19 +991,19 @@ describe('CapabilityService', () => { describe('processPriceIdDiff', () => { it('should process the product diff', async () => { - mockAuthEvents.emit = sinon.fake.returns({}); + mockAuthEvents.emit = jest.fn().mockReturnValue({}); await capabilityService.processPriceIdDiff({ uid: UID, priorPriceIds: ['plan_123456', 'plan_876543'], currentPriceIds: ['plan_876543', 'plan_ABCDEF'], }); - sinon.assert.calledTwice(log.notifyAttachedServices); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(2); }); }); describe('determineClientVisibleSubscriptionCapabilities', () => { beforeEach(() => { - mockStripeHelper.fetchCustomer = sinon.spy(async () => ({ + mockStripeHelper.fetchCustomer = jest.fn(async () => ({ subscriptions: { data: [ { @@ -1036,17 +1027,17 @@ describe('CapabilityService', () => { ], }, })); - mockStripeHelper.iapPurchasesToPriceIds = sinon.fake.returns([ - 'plan_PLAY', - ]); + mockStripeHelper.iapPurchasesToPriceIds = jest + .fn() + .mockReturnValue(['plan_PLAY']); mockSubscriptionPurchase = { sku: 'play_1234', - isEntitlementActive: sinon.fake.returns(true), + isEntitlementActive: jest.fn().mockReturnValue(true), }; - mockPlayBilling.userManager.queryCurrentSubscriptions = sinon - .stub() - .resolves([mockSubscriptionPurchase]); + mockPlayBilling.userManager.queryCurrentSubscriptions = jest + .fn() + .mockResolvedValue([mockSubscriptionPurchase]); }); async function assertExpectedCapabilities( @@ -1067,9 +1058,9 @@ describe('CapabilityService', () => { it('handles a firestore fetch error', async () => { const error = new Error('test error'); error.name = PurchaseQueryError.OTHER_ERROR; - mockPlayBilling.userManager.queryCurrentSubscriptions = sinon - .stub() - .rejects(error); + mockPlayBilling.userManager.queryCurrentSubscriptions = jest + .fn() + .mockRejectedValue(error); const allCapabilities = await capabilityService.subscriptionCapabilities(UID); expect(allCapabilities).toEqual({ @@ -1078,10 +1069,12 @@ describe('CapabilityService', () => { c2: ['cap5', 'cap6', 'capC', 'capD'], c3: ['capD', 'capE'], }); - sinon.assert.calledOnceWithExactly( - mockPlayBilling.userManager.queryCurrentSubscriptions, - UID - ); + expect( + mockPlayBilling.userManager.queryCurrentSubscriptions + ).toHaveBeenCalledTimes(1); + expect( + mockPlayBilling.userManager.queryCurrentSubscriptions + ).toHaveBeenCalledWith(UID); }); it('only reveals capabilities relevant to the client', async () => { @@ -1109,7 +1102,7 @@ describe('CapabilityService', () => { }); it('supports capabilities visible to all clients', async () => { - mockStripeHelper.allAbbrevPlans = sinon.spy(async () => [ + mockStripeHelper.allAbbrevPlans = jest.fn(async () => [ { plan_id: 'plan_123456', product_id: 'prod_123456', @@ -1173,16 +1166,20 @@ describe('CapabilityService', () => { }); it('returns results from Stripe and logs to Sentry when results do not match', async () => { - const sentryScope = { setContext: sinon.stub() }; - sinon.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sinon.stub(sentryModule, 'reportSentryMessage').returns({}); - - mockCapabilityManager.priceIdsToClientCapabilities = sinon.fake.resolves({ - c1: ['capAlpha'], - c4: ['capBeta', 'capDelta', 'capEpsilon'], - c6: ['capGamma', 'capZeta'], - c8: ['capOmega'], - }); + const sentryScope = { setContext: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(sentryModule, 'reportSentryMessage').mockReturnValue({}); + + mockCapabilityManager.priceIdsToClientCapabilities = jest + .fn() + .mockResolvedValue({ + c1: ['capAlpha'], + c4: ['capBeta', 'capDelta', 'capEpsilon'], + c6: ['capGamma', 'capZeta'], + c8: ['capOmega'], + }); const expected: any = { c0: ['capAll'], @@ -1207,9 +1204,8 @@ describe('CapabilityService', () => { await assertExpectedCapabilities(clientId, expected[clientId]); } - sinon.assert.callCount(sentryScope.setContext, 5); - sinon.assert.calledWithExactly( - sentryScope.setContext, + expect(sentryScope.setContext).toHaveBeenCalledTimes(5); + expect(sentryScope.setContext).toHaveBeenCalledWith( 'planIdsToClientCapabilities', { subscribedPrices: ['plan_123456', 'plan_876543', 'plan_PLAY'], @@ -1228,9 +1224,8 @@ describe('CapabilityService', () => { } ); - sinon.assert.callCount(sentryModule.reportSentryMessage, 5); - sinon.assert.calledWithExactly( - sentryModule.reportSentryMessage, + expect(sentryModule.reportSentryMessage).toHaveBeenCalledTimes(5); + expect(sentryModule.reportSentryMessage).toHaveBeenCalledWith( `CapabilityService.planIdsToClientCapabilities - Returned Stripe as plan ids to client capabilities did not match.`, 'error' ); @@ -1239,7 +1234,7 @@ describe('CapabilityService', () => { describe('getClients', () => { beforeEach(() => { - mockStripeHelper.allAbbrevPlans = sinon.spy(async () => mockPlans); + mockStripeHelper.allAbbrevPlans = jest.fn(async () => mockPlans); }); describe('getClientsFromStripe', () => { @@ -1274,7 +1269,7 @@ describe('CapabilityService', () => { }, }, }; - mockStripeHelper.allMergedPlanConfigs = sinon.spy( + mockStripeHelper.allMergedPlanConfigs = jest.fn( async () => mockPlanConfigs ); const expected = [ @@ -1322,9 +1317,11 @@ describe('CapabilityService', () => { }); it('returns results from CMS when it matches Stripe', async () => { - const sentryScope = { setContext: sinon.stub() }; - sinon.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sinon.stub(sentryModule, 'reportSentryMessage').returns({}); + const sentryScope = { setContext: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(sentryModule, 'reportSentryMessage').mockReturnValue({}); const mockClientsFromCMS = await mockCapabilityManager.getClients(); @@ -1336,17 +1333,19 @@ describe('CapabilityService', () => { const clients = await capabilityService.getClients(); expect(clients).toEqual(mockClientsFromCMS); - sinon.assert.notCalled(Sentry.withScope); - sinon.assert.notCalled(sentryScope.setContext); - sinon.assert.notCalled(sentryModule.reportSentryMessage); + expect(Sentry.withScope).not.toHaveBeenCalled(); + expect(sentryScope.setContext).not.toHaveBeenCalled(); + expect(sentryModule.reportSentryMessage).not.toHaveBeenCalled(); }); it('returns results from Stripe and logs to Sentry when results do not match', async () => { - const sentryScope = { setContext: sinon.stub() }; - sinon.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sinon.stub(sentryModule, 'reportSentryMessage').returns({}); + const sentryScope = { setContext: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(sentryModule, 'reportSentryMessage').mockReturnValue({}); - mockCapabilityManager.getClients = sinon.fake.resolves([ + mockCapabilityManager.getClients = jest.fn().mockResolvedValue([ { capabilities: ['exampleCap0', 'exampleCap1', 'exampleCap3'], clientId: 'client1', @@ -1363,17 +1362,19 @@ describe('CapabilityService', () => { const clients = await capabilityService.getClients(); expect(clients).toEqual(mockClientsFromStripe); - sinon.assert.calledOnceWithExactly(sentryScope.setContext, 'getClients', { + expect(sentryScope.setContext).toHaveBeenCalledTimes(1); + expect(sentryScope.setContext).toHaveBeenCalledWith('getClients', { cms: mockClientsFromCMS, stripe: mockClientsFromStripe, }); - sinon.assert.calledOnceWithExactly( - sentryModule.reportSentryMessage, + expect(sentryModule.reportSentryMessage).toHaveBeenCalledTimes(1); + expect(sentryModule.reportSentryMessage).toHaveBeenCalledWith( `CapabilityService.getClients - Returned Stripe as clients did not match.`, 'error' ); - sinon.assert.calledOnceWithExactly(sentryScope.setContext, 'getClients', { + expect(sentryScope.setContext).toHaveBeenCalledTimes(1); + expect(sentryScope.setContext).toHaveBeenCalledWith('getClients', { cms: mockClientsFromCMS, stripe: mockClientsFromStripe, }); @@ -1384,7 +1385,7 @@ describe('CapabilityService', () => { it('returns planIdsToClientCapabilities from CMS', async () => { mockConfig.cms.enabled = true; - capabilityService.subscribedPriceIds = sinon.fake.resolves([UID]); + capabilityService.subscribedPriceIds = jest.fn().mockResolvedValue([UID]); const mockCMSCapabilities = await mockCapabilityManager.priceIdsToClientCapabilities( diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.spec.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.spec.ts index 0daa10385a5..3695fdaf03a 100644 --- a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.spec.ts +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { AuthLogger, AppConfig } from '../../../types'; @@ -43,11 +42,9 @@ const mockConfig = { describe('AppStoreHelper', () => { let appStoreHelper: any; - let sandbox: sinon.SinonSandbox; let log: any; beforeEach(() => { - sandbox = sinon.createSandbox(); log = mockLog(); Container.set(AuthLogger, log); Container.set(AppConfig, mockConfig); @@ -56,7 +53,7 @@ describe('AppStoreHelper', () => { afterEach(() => { Container.reset(); - sandbox.restore(); + jest.restoreAllMocks(); }); it('can be instantiated', () => { @@ -111,26 +108,28 @@ describe('AppStoreHelper', () => { const mockOriginalTransactionId = '100000000'; // Mock App Store Client API response const expected = { data: 'wow' }; - mockAppStoreServerAPI.getSubscriptionStatuses = sinon - .stub() - .resolves(expected); - appStoreHelper.clientByBundleId = sandbox - .stub() - .returns(mockAppStoreServerAPI); + mockAppStoreServerAPI.getSubscriptionStatuses = jest + .fn() + .mockResolvedValue(expected); + appStoreHelper.clientByBundleId = jest + .fn() + .mockReturnValue(mockAppStoreServerAPI); const actual = await appStoreHelper.getSubscriptionStatuses( mockBundleId, mockOriginalTransactionId ); expect(actual).toEqual(expected); - sinon.assert.calledOnceWithExactly( - appStoreHelper.clientByBundleId, + expect(appStoreHelper.clientByBundleId).toHaveBeenCalledTimes(1); + expect(appStoreHelper.clientByBundleId).toHaveBeenCalledWith( mockBundleId ); - sinon.assert.calledOnceWithExactly( - mockAppStoreServerAPI.getSubscriptionStatuses, - mockOriginalTransactionId - ); + expect( + mockAppStoreServerAPI.getSubscriptionStatuses + ).toHaveBeenCalledTimes(1); + expect( + mockAppStoreServerAPI.getSubscriptionStatuses + ).toHaveBeenCalledWith(mockOriginalTransactionId); }); }); }); diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.spec.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.spec.ts index fec0fda55a7..85ac9ded58e 100644 --- a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.spec.ts +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.spec.ts @@ -7,7 +7,6 @@ jest.mock('./app-store-helper', () => ({ AppStoreHelper: class MockAppStoreHelper {}, })); -import sinon from 'sinon'; import { Container } from 'typedi'; import { AuthFirestore, AuthLogger, AppConfig } from '../../../types'; @@ -33,21 +32,19 @@ const mockConfig = { }; describe('AppleIAP', () => { - let collectionMock: sinon.SinonStub; + let collectionMock: jest.Mock; let purchasesDbRefMock: any; - let sandbox: sinon.SinonSandbox; let firestore: any; let log: any; beforeEach(() => { - sandbox = sinon.createSandbox(); log = mockLog(); - collectionMock = sinon.stub(); + collectionMock = jest.fn(); firestore = { collection: collectionMock, }; purchasesDbRefMock = {}; - collectionMock.returns(purchasesDbRefMock); + collectionMock.mockReturnValue(purchasesDbRefMock); Container.set(AuthFirestore, firestore); Container.set(AuthLogger, log); Container.set(AppConfig, mockConfig); @@ -56,7 +53,7 @@ describe('AppleIAP', () => { afterEach(() => { Container.reset(); - sandbox.restore(); + jest.restoreAllMocks(); }); it('can be instantiated', () => { diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.spec.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.spec.ts index 87d264339c3..68a1503531c 100644 --- a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.spec.ts +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { NotificationType, @@ -21,31 +20,31 @@ const { AppStoreSubscriptionPurchase } = jest.requireActual( const { mockLog } = require('../../../../test/mocks'); -const sandbox = sinon.createSandbox(); - const mockBundleId = 'testBundleId'; const mockOriginalTransactionId = 'testOriginalTransactionId'; const mockSubscriptionPurchase: any = {}; -const mockMergePurchase = sinon.fake.returns({}); +const mockMergePurchase = jest.fn().mockReturnValue({}); const mockDecodedNotificationPayload = { data: { signedTransactionInfo: {}, }, }; -const mockDecodeNotificationPayload = sandbox.fake.resolves( - mockDecodedNotificationPayload -); +const mockDecodeNotificationPayload = jest + .fn() + .mockResolvedValue(mockDecodedNotificationPayload); const mockDecodedTransactionInfo = { bundleId: mockBundleId, originalTransactionId: mockOriginalTransactionId, }; -const mockDecodeTransactionInfo = sandbox.fake.resolves( - mockDecodedTransactionInfo -); +const mockDecodeTransactionInfo = jest + .fn() + .mockResolvedValue(mockDecodedTransactionInfo); const mockDecodedRenewalInfo = { autoRenewStatus: 0, }; -const mockDecodeRenewalInfo = sandbox.fake.resolves(mockDecodedRenewalInfo); +const mockDecodeRenewalInfo = jest + .fn() + .mockResolvedValue(mockDecodedRenewalInfo); const mockApiResult = { bundleId: mockBundleId, data: [ @@ -144,31 +143,32 @@ describe('PurchaseManager', () => { beforeEach(() => { mockAppStoreHelper = { - getSubscriptionStatuses: sinon.fake.resolves(mockApiResult), + getSubscriptionStatuses: jest.fn().mockResolvedValue(mockApiResult), }; mockPurchaseDoc = { exists: false, ref: { - set: sinon.fake.resolves({}), + set: jest.fn().mockResolvedValue({}), }, }; - mockPurchaseDbRef.doc = sinon.fake.returns({ - get: sinon.fake.resolves(mockPurchaseDoc), + mockPurchaseDbRef.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockPurchaseDoc), }); - mockSubscriptionPurchase.toFirestoreObject = - sinon.fake.returns(firestoreObject); - mockSubscriptionPurchase.fromApiResponse = sinon.fake.returns( - mockSubscriptionPurchase - ); + mockSubscriptionPurchase.toFirestoreObject = jest + .fn() + .mockReturnValue(firestoreObject); + mockSubscriptionPurchase.fromApiResponse = jest + .fn() + .mockReturnValue(mockSubscriptionPurchase); purchaseManager = new PurchaseManager( mockPurchaseDbRef, mockAppStoreHelper ); - mockMergePurchase.resetHistory(); + mockMergePurchase.mockClear(); }); afterEach(() => { - sandbox.reset(); + jest.clearAllMocks(); }); it('queries with no found firestore doc', async () => { @@ -178,11 +178,13 @@ describe('PurchaseManager', () => { ); expect(result).toBe(mockSubscriptionPurchase); - sinon.assert.calledOnce(mockAppStoreHelper.getSubscriptionStatuses); - sinon.assert.calledOnce(mockDecodeTransactionInfo); - sinon.assert.calledOnce(mockDecodeRenewalInfo); - sinon.assert.calledOnceWithExactly( - log.debug, + expect(mockAppStoreHelper.getSubscriptionStatuses).toHaveBeenCalledTimes( + 1 + ); + expect(mockDecodeTransactionInfo).toHaveBeenCalledTimes(1); + expect(mockDecodeRenewalInfo).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalledWith( 'appleIap.querySubscriptionPurchase.getSubscriptionStatuses', { bundleId: mockBundleId, @@ -192,12 +194,14 @@ describe('PurchaseManager', () => { } ); - sinon.assert.calledOnce(mockPurchaseDbRef.doc); - sinon.assert.calledOnce(mockPurchaseDbRef.doc().get); - sinon.assert.calledOnce(mockSubscriptionPurchase.fromApiResponse); - sinon.assert.calledOnce(mockSubscriptionPurchase.toFirestoreObject); + expect(mockPurchaseDbRef.doc).toHaveBeenCalledTimes(1); + expect(mockPurchaseDbRef.doc().get).toHaveBeenCalledTimes(1); + expect(mockSubscriptionPurchase.fromApiResponse).toHaveBeenCalledTimes(1); + expect(mockSubscriptionPurchase.toFirestoreObject).toHaveBeenCalledTimes( + 1 + ); - sinon.assert.calledWithExactly(mockPurchaseDoc.ref.set, firestoreObject); + expect(mockPurchaseDoc.ref.set).toHaveBeenCalledWith(firestoreObject); }); it('logs the notification type and subtype if present', async () => { @@ -210,8 +214,8 @@ describe('PurchaseManager', () => { mockTriggerNotificationSubtype ); - sinon.assert.calledOnceWithExactly( - log.debug, + expect(log.debug).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalledWith( 'appleIap.querySubscriptionPurchase.getSubscriptionStatuses', { bundleId: mockBundleId, @@ -225,9 +229,9 @@ describe('PurchaseManager', () => { }); it("throws if there's an App Store Server client or API error", async () => { - mockAppStoreHelper.getSubscriptionStatuses = sinon.fake.rejects( - new Error('Oops') - ); + mockAppStoreHelper.getSubscriptionStatuses = jest + .fn() + .mockRejectedValue(new Error('Oops')); await expect( purchaseManager.querySubscriptionPurchase( mockBundleId, @@ -237,7 +241,7 @@ describe('PurchaseManager', () => { }); it('queries with found firestore doc with no userId', async () => { - mockPurchaseDoc.data = sinon.fake.returns({}); + mockPurchaseDoc.data = jest.fn().mockReturnValue({}); mockPurchaseDoc.exists = true; const result = await purchaseManager.querySubscriptionPurchase( mockBundleId, @@ -245,22 +249,26 @@ describe('PurchaseManager', () => { ); expect(result).toBe(mockSubscriptionPurchase); - sinon.assert.calledOnce(mockAppStoreHelper.getSubscriptionStatuses); - sinon.assert.calledOnce(mockDecodeTransactionInfo); - sinon.assert.calledOnce(mockDecodeRenewalInfo); + expect(mockAppStoreHelper.getSubscriptionStatuses).toHaveBeenCalledTimes( + 1 + ); + expect(mockDecodeTransactionInfo).toHaveBeenCalledTimes(1); + expect(mockDecodeRenewalInfo).toHaveBeenCalledTimes(1); - sinon.assert.calledOnce(mockPurchaseDbRef.doc); - sinon.assert.calledOnce(mockPurchaseDbRef.doc().get); - sinon.assert.calledOnce(mockSubscriptionPurchase.fromApiResponse); - sinon.assert.calledOnce(mockSubscriptionPurchase.toFirestoreObject); + expect(mockPurchaseDbRef.doc).toHaveBeenCalledTimes(1); + expect(mockPurchaseDbRef.doc().get).toHaveBeenCalledTimes(1); + expect(mockSubscriptionPurchase.fromApiResponse).toHaveBeenCalledTimes(1); + expect(mockSubscriptionPurchase.toFirestoreObject).toHaveBeenCalledTimes( + 1 + ); - sinon.assert.calledWithExactly(mockPurchaseDoc.ref.set, firestoreObject); - sinon.assert.calledOnce(mockMergePurchase); - sinon.assert.calledTwice(mockPurchaseDoc.data); + expect(mockPurchaseDoc.ref.set).toHaveBeenCalledWith(firestoreObject); + expect(mockMergePurchase).toHaveBeenCalledTimes(1); + expect(mockPurchaseDoc.data).toHaveBeenCalledTimes(2); }); it('queries with found firestore doc with userId and preserves the userId', async () => { - mockPurchaseDoc.data = sinon.fake.returns({ userId: 'amazing' }); + mockPurchaseDoc.data = jest.fn().mockReturnValue({ userId: 'amazing' }); mockPurchaseDoc.exists = true; const result = await purchaseManager.querySubscriptionPurchase( mockBundleId, @@ -268,25 +276,29 @@ describe('PurchaseManager', () => { ); expect(result).toBe(mockSubscriptionPurchase); - sinon.assert.calledOnce(mockAppStoreHelper.getSubscriptionStatuses); - sinon.assert.calledOnce(mockDecodeTransactionInfo); - sinon.assert.calledOnce(mockDecodeRenewalInfo); + expect(mockAppStoreHelper.getSubscriptionStatuses).toHaveBeenCalledTimes( + 1 + ); + expect(mockDecodeTransactionInfo).toHaveBeenCalledTimes(1); + expect(mockDecodeRenewalInfo).toHaveBeenCalledTimes(1); - sinon.assert.calledOnce(mockPurchaseDbRef.doc); - sinon.assert.calledOnce(mockPurchaseDbRef.doc().get); - sinon.assert.calledOnce(mockSubscriptionPurchase.fromApiResponse); - sinon.assert.calledOnce(mockSubscriptionPurchase.toFirestoreObject); + expect(mockPurchaseDbRef.doc).toHaveBeenCalledTimes(1); + expect(mockPurchaseDbRef.doc().get).toHaveBeenCalledTimes(1); + expect(mockSubscriptionPurchase.fromApiResponse).toHaveBeenCalledTimes(1); + expect(mockSubscriptionPurchase.toFirestoreObject).toHaveBeenCalledTimes( + 1 + ); - sinon.assert.calledWithExactly(mockPurchaseDoc.ref.set, { + expect(mockPurchaseDoc.ref.set).toHaveBeenCalledWith({ userId: 'amazing', ...firestoreObject, }); - sinon.assert.calledOnce(mockMergePurchase); - sinon.assert.calledTwice(mockPurchaseDoc.data); + expect(mockMergePurchase).toHaveBeenCalledTimes(1); + expect(mockPurchaseDoc.data).toHaveBeenCalledTimes(2); }); it('adds notification type and subtype to the purchase if passed in', async () => { - mockPurchaseDoc.data = sinon.fake.returns({}); + mockPurchaseDoc.data = jest.fn().mockReturnValue({}); mockPurchaseDoc.exists = true; const notificationType = 'foo'; const notificationSubtype = 'bar'; @@ -305,7 +317,7 @@ describe('PurchaseManager', () => { }); it('adds only notificationType to the purchase if notificationSubtype is undefined when passed in', async () => { - mockPurchaseDoc.data = sinon.fake.returns({}); + mockPurchaseDoc.data = jest.fn().mockReturnValue({}); mockPurchaseDoc.exists = true; const notificationType = 'foo'; const notificationSubtype = undefined; @@ -323,7 +335,7 @@ describe('PurchaseManager', () => { }); it('throws unexpected library error', async () => { - mockPurchaseDoc.ref.set = sinon.fake.rejects(new Error('test')); + mockPurchaseDoc.ref.set = jest.fn().mockRejectedValue(new Error('test')); await expect( purchaseManager.querySubscriptionPurchase( mockBundleId, @@ -337,8 +349,8 @@ describe('PurchaseManager', () => { let purchaseManager: any; beforeEach(() => { - mockPurchaseDbRef.doc = sinon.fake.returns({ - update: sinon.fake.resolves({}), + mockPurchaseDbRef.doc = jest.fn().mockReturnValue({ + update: jest.fn().mockResolvedValue({}), }); purchaseManager = new PurchaseManager( mockPurchaseDbRef, @@ -352,15 +364,15 @@ describe('PurchaseManager', () => { 'testUserId' ); expect(result).toBeUndefined(); - sinon.assert.calledOnce(mockPurchaseDbRef.doc); - sinon.assert.calledWithExactly(mockPurchaseDbRef.doc().update, { + expect(mockPurchaseDbRef.doc).toHaveBeenCalledTimes(1); + expect(mockPurchaseDbRef.doc().update).toHaveBeenCalledWith({ userId: 'testUserId', }); }); it('throws library error on unknown', async () => { - mockPurchaseDbRef.doc = sinon.fake.returns({ - update: sinon.fake.rejects(new Error('Oops')), + mockPurchaseDbRef.doc = jest.fn().mockReturnValue({ + update: jest.fn().mockRejectedValue(new Error('Oops')), }); await expect( purchaseManager.forceRegisterToUserAccount( @@ -378,17 +390,19 @@ describe('PurchaseManager', () => { beforeEach(() => { mockPurchaseDoc = { exists: true, - data: sinon.fake.returns({}), + data: jest.fn().mockReturnValue({}), }; - mockPurchaseDbRef.doc = sinon.fake.returns({ - get: sinon.fake.resolves(mockPurchaseDoc), + mockPurchaseDbRef.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockPurchaseDoc), }); purchaseManager = new PurchaseManager( mockPurchaseDbRef, mockAppStoreHelper ); - mockSubscriptionPurchase.fromFirestoreObject = sinon.fake.returns({}); + mockSubscriptionPurchase.fromFirestoreObject = jest + .fn() + .mockReturnValue({}); }); it('returns an existing doc', async () => { @@ -421,14 +435,14 @@ describe('PurchaseManager', () => { ], }; mockBatch = { - delete: sinon.fake.resolves({}), - commit: sinon.fake.resolves({}), + delete: jest.fn().mockResolvedValue({}), + commit: jest.fn().mockResolvedValue({}), }; - mockPurchaseDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves(mockPurchaseDoc), + mockPurchaseDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockPurchaseDoc), }); mockPurchaseDbRef.firestore = { - batch: sinon.fake.returns(mockBatch), + batch: jest.fn().mockReturnValue(mockBatch), }; purchaseManager = new PurchaseManager( mockPurchaseDbRef, @@ -439,8 +453,9 @@ describe('PurchaseManager', () => { it('deletes a purchase', async () => { const result = await purchaseManager.deletePurchases('testToken'); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly(mockBatch.delete, 'testRef'); - sinon.assert.calledOnce(mockBatch.commit); + expect(mockBatch.delete).toHaveBeenCalledTimes(1); + expect(mockBatch.delete).toHaveBeenCalledWith('testRef'); + expect(mockBatch.commit).toHaveBeenCalledTimes(1); }); }); @@ -452,24 +467,27 @@ describe('PurchaseManager', () => { beforeEach(() => { mockPurchaseDoc = { exists: false, - data: sinon.fake.returns({}), + data: jest.fn().mockReturnValue({}), ref: { - set: sinon.fake.resolves({}), - update: sinon.fake.resolves({}), + set: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), }, }; mockSubscription = {}; - mockSubscription.isRegisterable = sinon.fake.returns(true); - mockPurchaseDbRef.doc = sinon.fake.returns({ - get: sinon.fake.resolves(mockPurchaseDoc), + mockSubscription.isRegisterable = jest.fn().mockReturnValue(true); + mockPurchaseDbRef.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockPurchaseDoc), }); purchaseManager = new PurchaseManager( mockPurchaseDbRef, mockAppStoreHelper ); - purchaseManager.querySubscriptionPurchase = - sinon.fake.resolves(mockSubscription); - purchaseManager.forceRegisterToUserAccount = sinon.fake.resolves({}); + purchaseManager.querySubscriptionPurchase = jest + .fn() + .mockResolvedValue(mockSubscription); + purchaseManager.forceRegisterToUserAccount = jest + .fn() + .mockResolvedValue({}); }); it('registers successfully for non-cached original transaction id', async () => { @@ -479,30 +497,36 @@ describe('PurchaseManager', () => { 'testUserId' ); expect(result).toBe(mockSubscription); - sinon.assert.calledOnce(purchaseManager.querySubscriptionPurchase); - sinon.assert.calledOnce(purchaseManager.forceRegisterToUserAccount); + expect(purchaseManager.querySubscriptionPurchase).toHaveBeenCalledTimes( + 1 + ); + expect(purchaseManager.forceRegisterToUserAccount).toHaveBeenCalledTimes( + 1 + ); }); it('skips doing anything for cached original transaction id', async () => { mockPurchaseDoc.exists = true; mockSubscription.userId = 'testUserId'; - mockSubscriptionPurchase.fromFirestoreObject = - sinon.fake.returns(mockSubscription); + mockSubscriptionPurchase.fromFirestoreObject = jest + .fn() + .mockReturnValue(mockSubscription); const result = await purchaseManager.registerToUserAccount( mockBundleId, mockOriginalTransactionId, 'testUserId' ); expect(result).toBe(mockSubscription); - sinon.assert.notCalled(purchaseManager.querySubscriptionPurchase); - sinon.assert.notCalled(purchaseManager.forceRegisterToUserAccount); + expect(purchaseManager.querySubscriptionPurchase).not.toHaveBeenCalled(); + expect(purchaseManager.forceRegisterToUserAccount).not.toHaveBeenCalled(); }); it('throws conflict error for existing original transaction id registered to other user', async () => { mockPurchaseDoc.exists = true; mockSubscription.userId = 'otherUserId'; - mockSubscriptionPurchase.fromFirestoreObject = - sinon.fake.returns(mockSubscription); + mockSubscriptionPurchase.fromFirestoreObject = jest + .fn() + .mockReturnValue(mockSubscription); await expect( purchaseManager.registerToUserAccount( mockBundleId, @@ -510,13 +534,13 @@ describe('PurchaseManager', () => { 'testUserId' ) ).rejects.toMatchObject({ name: PurchaseUpdateError.CONFLICT }); - sinon.assert.calledOnce(log.info); + expect(log.info).toHaveBeenCalledTimes(1); }); it('throws invalid original transaction id error if purchase cant be queried', async () => { - purchaseManager.querySubscriptionPurchase = sinon.fake.rejects( - new Error('Oops') - ); + purchaseManager.querySubscriptionPurchase = jest + .fn() + .mockRejectedValue(new Error('Oops')); await expect( purchaseManager.registerToUserAccount( mockBundleId, @@ -550,17 +574,17 @@ describe('PurchaseManager', () => { mockStatus = SubscriptionStatus.Active; localPurchaseDbRef = { where: () => localPurchaseDbRef, - get: sinon.fake.resolves(queryResult), + get: jest.fn().mockResolvedValue(queryResult), } as any; mockPurchaseDoc = { exists: false, ref: { - set: sinon.fake.resolves({}), - update: sinon.fake.resolves({}), + set: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), }, }; - localPurchaseDbRef.doc = sinon.fake.returns({ - get: sinon.fake.resolves(mockPurchaseDoc), + localPurchaseDbRef.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockPurchaseDoc), }); // Use a fresh PurchaseManager that relies on internal mock for // subscription-purchase module via jest.mock at file top level. @@ -588,13 +612,15 @@ describe('PurchaseManager', () => { mockVerifiedAt ); const subscriptionSnapshot = { - data: sinon.fake.returns(subscriptionPurchase.toFirestoreObject()), + data: jest + .fn() + .mockReturnValue(subscriptionPurchase.toFirestoreObject()), }; queryResult.docs.push(subscriptionSnapshot); const result = await purchaseManager.queryCurrentSubscriptionPurchases(USER_ID); expect(result).toEqual([subscriptionPurchase]); - sinon.assert.calledOnce(localPurchaseDbRef.get); + expect(localPurchaseDbRef.get).toHaveBeenCalledTimes(1); }); it('queries expired subscription purchases', async () => { @@ -623,15 +649,20 @@ describe('PurchaseManager', () => { mockVerifiedAt ); const subscriptionSnapshot = { - data: sinon.fake.returns(subscriptionPurchase.toFirestoreObject()), + data: jest + .fn() + .mockReturnValue(subscriptionPurchase.toFirestoreObject()), }; queryResult.docs.push(subscriptionSnapshot); - purchaseManager.querySubscriptionPurchase = - sinon.fake.resolves(subscriptionPurchase); + purchaseManager.querySubscriptionPurchase = jest + .fn() + .mockResolvedValue(subscriptionPurchase); const result = await purchaseManager.queryCurrentSubscriptionPurchases(USER_ID); expect(result).toEqual([]); - sinon.assert.calledOnce(purchaseManager.querySubscriptionPurchase); + expect(purchaseManager.querySubscriptionPurchase).toHaveBeenCalledTimes( + 1 + ); }); it('skips NOT_FOUND error for expired purchases', async () => { @@ -660,19 +691,24 @@ describe('PurchaseManager', () => { mockVerifiedAt ); const subscriptionSnapshot = { - data: sinon.fake.returns(subscriptionPurchase.toFirestoreObject()), + data: jest + .fn() + .mockReturnValue(subscriptionPurchase.toFirestoreObject()), }; queryResult.docs.push(subscriptionSnapshot); const notFoundError = new Error('NOT_FOUND'); notFoundError.name = PurchaseQueryError.NOT_FOUND; - purchaseManager.querySubscriptionPurchase = - sinon.fake.rejects(notFoundError); + purchaseManager.querySubscriptionPurchase = jest + .fn() + .mockRejectedValue(notFoundError); const result = await purchaseManager.queryCurrentSubscriptionPurchases(USER_ID); expect(result).toEqual([]); - sinon.assert.calledOnce(purchaseManager.querySubscriptionPurchase); + expect(purchaseManager.querySubscriptionPurchase).toHaveBeenCalledTimes( + 1 + ); }); it('throws library error on failure', async () => { @@ -701,12 +737,14 @@ describe('PurchaseManager', () => { mockVerifiedAt ); const subscriptionSnapshot = { - data: sinon.fake.returns(subscriptionPurchase.toFirestoreObject()), + data: jest + .fn() + .mockReturnValue(subscriptionPurchase.toFirestoreObject()), }; queryResult.docs.push(subscriptionSnapshot); - purchaseManager.querySubscriptionPurchase = sinon.fake.rejects( - new Error('oops') - ); + purchaseManager.querySubscriptionPurchase = jest + .fn() + .mockRejectedValue(new Error('oops')); await expect( purchaseManager.queryCurrentSubscriptionPurchases(USER_ID) ).rejects.toMatchObject({ name: PurchaseQueryError.OTHER_ERROR }); @@ -749,8 +787,9 @@ describe('PurchaseManager', () => { mockPurchaseDbRef, mockAppStoreHelper ); - purchaseManager.querySubscriptionPurchase = - sinon.fake.resolves(mockSubscription); + purchaseManager.querySubscriptionPurchase = jest + .fn() + .mockResolvedValue(mockSubscription); }); it('returns null for not applicable notifications', async () => { @@ -783,7 +822,9 @@ describe('PurchaseManager', () => { mockNotification ); expect(result).toEqual(mockSubscription); - sinon.assert.calledOnce(purchaseManager.querySubscriptionPurchase); + expect(purchaseManager.querySubscriptionPurchase).toHaveBeenCalledTimes( + 1 + ); }); }); }); diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscriptions.spec.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscriptions.spec.ts index e443c68100d..50005651f80 100644 --- a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscriptions.spec.ts +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscriptions.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { MozillaSubscriptionTypes } from 'fxa-shared/subscriptions/types'; @@ -15,7 +14,6 @@ const { deepCopy } = require('../../../../test/local/payments/util'); describe('AppStoreSubscriptions', () => { const UID = 'uid8675309'; - const sandbox = sinon.createSandbox(); let appStoreSubscriptions: any; let mockAppleIap: any; @@ -26,7 +24,7 @@ describe('AppStoreSubscriptions', () => { autoRenewStatus: 1, productId: 'wow', bundleId: 'hmm', - isEntitlementActive: sinon.fake.returns(true), + isEntitlementActive: jest.fn().mockReturnValue(true), }; const mockAppendedAppStoreSubscriptionPurchase = { @@ -41,16 +39,16 @@ describe('AppStoreSubscriptions', () => { mockConfig = { subscriptions: { enabled: true } }; mockAppleIap = { purchaseManager: { - queryCurrentSubscriptionPurchases: sinon - .stub() - .resolves([mockAppStoreSubscriptionPurchase]), + queryCurrentSubscriptionPurchases: jest + .fn() + .mockResolvedValue([mockAppStoreSubscriptionPurchase]), }, }; Container.set(AppleIAP, mockAppleIap); mockStripeHelper = { - addPriceInfoToIapPurchases: sinon - .stub() - .resolves([mockAppendedAppStoreSubscriptionPurchase]), + addPriceInfoToIapPurchases: jest + .fn() + .mockResolvedValue([mockAppendedAppStoreSubscriptionPurchase]), }; Container.set(StripeHelper, mockStripeHelper); Container.set(AppConfig, mockConfig); @@ -59,7 +57,7 @@ describe('AppStoreSubscriptions', () => { afterEach(() => { Container.reset(); - sandbox.reset(); + jest.clearAllMocks(); }); describe('constructor', () => { @@ -80,12 +78,16 @@ describe('AppStoreSubscriptions', () => { describe('getSubscriptions', () => { it('returns active App Store subscription purchases', async () => { const result = await appStoreSubscriptions.getSubscriptions(UID); - sinon.assert.calledOnceWithExactly( - mockAppleIap.purchaseManager.queryCurrentSubscriptionPurchases, - UID + expect( + mockAppleIap.purchaseManager.queryCurrentSubscriptionPurchases + ).toHaveBeenCalledTimes(1); + expect( + mockAppleIap.purchaseManager.queryCurrentSubscriptionPurchases + ).toHaveBeenCalledWith(UID); + expect(mockStripeHelper.addPriceInfoToIapPurchases).toHaveBeenCalledTimes( + 1 ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.addPriceInfoToIapPurchases, + expect(mockStripeHelper.addPriceInfoToIapPurchases).toHaveBeenCalledWith( [mockAppStoreSubscriptionPurchase], MozillaSubscriptionTypes.IAP_APPLE ); @@ -94,13 +96,17 @@ describe('AppStoreSubscriptions', () => { }); it('returns [] if no active App Store subscriptions are found', async () => { const mockInactivePurchase = deepCopy(mockAppStoreSubscriptionPurchase); - mockInactivePurchase.isEntitlementActive = sinon.fake.returns(false); - mockAppleIap.purchaseManager.queryCurrentSubscriptionPurchases = sinon - .stub() - .resolves([mockInactivePurchase]); + mockInactivePurchase.isEntitlementActive = jest + .fn() + .mockReturnValue(false); + mockAppleIap.purchaseManager.queryCurrentSubscriptionPurchases = jest + .fn() + .mockResolvedValue([mockInactivePurchase]); // In this case, we expect the length of the array returned by // addPriceInfoToIapPurchases to equal the length the array passed into it. - mockStripeHelper.addPriceInfoToIapPurchases = sinon.stub().resolvesArg(0); + mockStripeHelper.addPriceInfoToIapPurchases = jest + .fn() + .mockImplementation((arg: any) => Promise.resolve(arg)); const expected: any[] = []; const result = await appStoreSubscriptions.getSubscriptions(UID); expect(result).toEqual(expected); diff --git a/packages/fxa-auth-server/lib/payments/iap/google-play/play-billing.spec.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/play-billing.spec.ts index 44a0b9201f4..72dc2fa387d 100644 --- a/packages/fxa-auth-server/lib/payments/iap/google-play/play-billing.spec.ts +++ b/packages/fxa-auth-server/lib/payments/iap/google-play/play-billing.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { AuthFirestore, AuthLogger, AppConfig } from '../../../types'; @@ -25,16 +24,14 @@ const mockConfig = { }; describe('PlayBilling', () => { - let sandbox: sinon.SinonSandbox; let firestore: any; let log: any; let purchasesDbRefMock: any; beforeEach(() => { - sandbox = sinon.createSandbox(); purchasesDbRefMock = {}; - const collectionMock = sinon.stub(); - collectionMock.returns(purchasesDbRefMock); + const collectionMock = jest.fn(); + collectionMock.mockReturnValue(purchasesDbRefMock); firestore = { collection: collectionMock, }; @@ -47,7 +44,7 @@ describe('PlayBilling', () => { afterEach(() => { Container.reset(); - sandbox.restore(); + jest.restoreAllMocks(); }); it('can be instantiated', () => { diff --git a/packages/fxa-auth-server/lib/payments/iap/google-play/purchase-manager.spec.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/purchase-manager.spec.ts index 03f1991528b..807eee418d2 100644 --- a/packages/fxa-auth-server/lib/payments/iap/google-play/purchase-manager.spec.ts +++ b/packages/fxa-auth-server/lib/payments/iap/google-play/purchase-manager.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { AuthLogger } from '../../../types'; @@ -19,7 +18,7 @@ const { mockLog } = require('../../../../test/mocks'); // Use a delegating function pattern so the mock factory (hoisted by Jest) // can reference variables defined later. const mockSubscriptionPurchase: any = {}; -const mockMergePurchase = sinon.fake.returns({}); +const mockMergePurchase = jest.fn().mockReturnValue({}); jest.mock('./subscription-purchase', () => ({ get PlayStoreSubscriptionPurchase() { @@ -81,41 +80,48 @@ describe('PurchaseManager', () => { mockPurchaseDoc = { exists: false, ref: { - set: sinon.fake.resolves({}), - update: sinon.fake.resolves({}), + set: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), }, }; - mockPurchaseDbRef.doc = sinon.fake.returns({ - get: sinon.fake.resolves(mockPurchaseDoc), + mockPurchaseDbRef.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockPurchaseDoc), }); mockSubscription = { - toFirestoreObject: sinon.fake.returns(firestoreObject), + toFirestoreObject: jest.fn().mockReturnValue(firestoreObject), linkedPurchaseToken: undefined, }; - mockSubscriptionPurchase.fromApiResponse = - sinon.fake.returns(mockSubscription); + mockSubscriptionPurchase.fromApiResponse = jest + .fn() + .mockReturnValue(mockSubscription); purchaseManager = new PurchaseManager(mockPurchaseDbRef, mockApiClient); }); it('queries with no found firestore doc or linked purchase', async () => { - purchaseManager.disableReplacedSubscription = sinon.fake.resolves({}); + purchaseManager.disableReplacedSubscription = jest + .fn() + .mockResolvedValue({}); const result = await purchaseManager.querySubscriptionPurchase( 'testPackage', 'testSku', 'testToken' ); expect(result).toBe(mockSubscription); - sinon.assert.calledOnce(mockPurchaseDbRef.doc); - sinon.assert.calledOnce(mockPurchaseDbRef.doc().get); - sinon.assert.calledOnce(mockSubscriptionPurchase.fromApiResponse); - sinon.assert.calledOnce(mockSubscription.toFirestoreObject); - sinon.assert.notCalled(purchaseManager.disableReplacedSubscription); - sinon.assert.calledWithExactly(mockPurchaseDoc.ref.set, firestoreObject); + expect(mockPurchaseDbRef.doc).toHaveBeenCalledTimes(1); + expect(mockPurchaseDbRef.doc().get).toHaveBeenCalledTimes(1); + expect(mockSubscriptionPurchase.fromApiResponse).toHaveBeenCalledTimes(1); + expect(mockSubscription.toFirestoreObject).toHaveBeenCalledTimes(1); + expect( + purchaseManager.disableReplacedSubscription + ).not.toHaveBeenCalled(); + expect(mockPurchaseDoc.ref.set).toHaveBeenCalledWith(firestoreObject); }); it('queries with no found firestore doc with linked purchase', async () => { - purchaseManager.disableReplacedSubscription = sinon.fake.resolves({}); + purchaseManager.disableReplacedSubscription = jest + .fn() + .mockResolvedValue({}); mockSubscription.linkedPurchaseToken = 'testToken2'; const result = await purchaseManager.querySubscriptionPurchase( 'testPackage', @@ -123,21 +129,20 @@ describe('PurchaseManager', () => { 'testToken' ); expect(result).toBe(mockSubscription); - sinon.assert.calledOnce(mockPurchaseDbRef.doc); - sinon.assert.calledOnce(mockPurchaseDbRef.doc().get); - sinon.assert.calledOnce(mockSubscriptionPurchase.fromApiResponse); - sinon.assert.calledOnce(mockSubscription.toFirestoreObject); - sinon.assert.calledWithExactly( - purchaseManager.disableReplacedSubscription, + expect(mockPurchaseDbRef.doc).toHaveBeenCalledTimes(1); + expect(mockPurchaseDbRef.doc().get).toHaveBeenCalledTimes(1); + expect(mockSubscriptionPurchase.fromApiResponse).toHaveBeenCalledTimes(1); + expect(mockSubscription.toFirestoreObject).toHaveBeenCalledTimes(1); + expect(purchaseManager.disableReplacedSubscription).toHaveBeenCalledWith( 'testPackage', 'testSku', mockSubscription.linkedPurchaseToken ); - sinon.assert.calledWithExactly(mockPurchaseDoc.ref.set, firestoreObject); + expect(mockPurchaseDoc.ref.set).toHaveBeenCalledWith(firestoreObject); }); it('queries with found firestore doc', async () => { - mockPurchaseDoc.data = sinon.fake.returns({}); + mockPurchaseDoc.data = jest.fn().mockReturnValue({}); mockPurchaseDoc.exists = true; const result = await purchaseManager.querySubscriptionPurchase( 'testPackage', @@ -145,20 +150,17 @@ describe('PurchaseManager', () => { 'testToken' ); expect(result).toBe(mockSubscription); - sinon.assert.calledOnce(mockPurchaseDbRef.doc); - sinon.assert.calledOnce(mockPurchaseDbRef.doc().get); - sinon.assert.calledOnce(mockSubscriptionPurchase.fromApiResponse); - sinon.assert.calledOnce(mockSubscription.toFirestoreObject); - sinon.assert.calledWithExactly( - mockPurchaseDoc.ref.update, - firestoreObject - ); - sinon.assert.calledOnce(mockMergePurchase); - sinon.assert.calledOnce(mockPurchaseDoc.data); + expect(mockPurchaseDbRef.doc).toHaveBeenCalledTimes(1); + expect(mockPurchaseDbRef.doc().get).toHaveBeenCalledTimes(1); + expect(mockSubscriptionPurchase.fromApiResponse).toHaveBeenCalledTimes(1); + expect(mockSubscription.toFirestoreObject).toHaveBeenCalledTimes(1); + expect(mockPurchaseDoc.ref.update).toHaveBeenCalledWith(firestoreObject); + expect(mockMergePurchase).toHaveBeenCalledTimes(1); + expect(mockPurchaseDoc.data).toHaveBeenCalledTimes(1); }); it('throws unexpected library error', async () => { - mockPurchaseDoc.ref.set = sinon.fake.rejects(new Error('test')); + mockPurchaseDoc.ref.set = jest.fn().mockRejectedValue(new Error('test')); await expect( purchaseManager.querySubscriptionPurchase( 'testPackage', @@ -186,21 +188,22 @@ describe('PurchaseManager', () => { }; mockPurchaseDoc = { exists: true, - data: sinon.fake.returns({ replacedByAnotherPurchase: true }), + data: jest.fn().mockReturnValue({ replacedByAnotherPurchase: true }), ref: { - set: sinon.fake.resolves({}), - update: sinon.fake.resolves({}), + set: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), }, }; - mockPurchaseDbRef.doc = sinon.fake.returns({ - get: sinon.fake.resolves(mockPurchaseDoc), + mockPurchaseDbRef.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockPurchaseDoc), }); mockSubscription = { - toFirestoreObject: sinon.fake.returns(firestoreObject), + toFirestoreObject: jest.fn().mockReturnValue(firestoreObject), linkedPurchaseToken: undefined, }; - mockSubscriptionPurchase.fromApiResponse = - sinon.fake.returns(mockSubscription); + mockSubscriptionPurchase.fromApiResponse = jest + .fn() + .mockReturnValue(mockSubscription); purchaseManager = new PurchaseManager(mockPurchaseDbRef, mockApiClient); }); @@ -212,24 +215,24 @@ describe('PurchaseManager', () => { 'testToken' ); expect(result).toBeUndefined(); - sinon.assert.calledOnce(mockPurchaseDbRef.doc); - sinon.assert.calledOnce(mockPurchaseDbRef.doc().get); - sinon.assert.calledOnce(mockPurchaseDoc.data); - sinon.assert.notCalled(mockPurchaseDoc.ref.update); + expect(mockPurchaseDbRef.doc).toHaveBeenCalledTimes(1); + expect(mockPurchaseDbRef.doc().get).toHaveBeenCalledTimes(1); + expect(mockPurchaseDoc.data).toHaveBeenCalledTimes(1); + expect(mockPurchaseDoc.ref.update).not.toHaveBeenCalled(); }); it('marks a cached purchase as replaced', async () => { - mockPurchaseDoc.data = sinon.fake.returns({}); + mockPurchaseDoc.data = jest.fn().mockReturnValue({}); const result = await purchaseManager.disableReplacedSubscription( 'testPackage', 'testSku', 'testToken' ); expect(result).toBeUndefined(); - sinon.assert.calledOnce(mockPurchaseDbRef.doc); - sinon.assert.calledOnce(mockPurchaseDbRef.doc().get); - sinon.assert.calledOnce(mockPurchaseDoc.data); - sinon.assert.calledOnce(mockPurchaseDoc.ref.update); + expect(mockPurchaseDbRef.doc).toHaveBeenCalledTimes(1); + expect(mockPurchaseDbRef.doc().get).toHaveBeenCalledTimes(1); + expect(mockPurchaseDoc.data).toHaveBeenCalledTimes(1); + expect(mockPurchaseDoc.ref.update).toHaveBeenCalledTimes(1); }); it('caches an unseen token as replaced with no linked purchase', async () => { @@ -240,8 +243,8 @@ describe('PurchaseManager', () => { 'testToken' ); expect(result).toBeUndefined(); - sinon.assert.calledOnce(mockSubscriptionPurchase.fromApiResponse); - sinon.assert.calledWithExactly(mockPurchaseDoc.ref.set, firestoreObject); + expect(mockSubscriptionPurchase.fromApiResponse).toHaveBeenCalledTimes(1); + expect(mockPurchaseDoc.ref.set).toHaveBeenCalledWith(firestoreObject); }); it('caches an unseen token as replaced and calls self for linked purchase', async () => { @@ -249,13 +252,14 @@ describe('PurchaseManager', () => { mockSubscription.linkedPurchaseToken = 'testToken2'; const callFuncOne = purchaseManager.disableReplacedSubscription.bind(purchaseManager); - const callFuncTwo = sinon.fake.resolves({}); - const purchaseStub = sinon.stub( + const callFuncTwo = jest.fn().mockResolvedValue({}); + const purchaseStub = jest.spyOn( purchaseManager, 'disableReplacedSubscription' ); - purchaseStub.onFirstCall().callsFake(callFuncOne); - purchaseStub.onSecondCall().callsFake(callFuncTwo); + purchaseStub + .mockImplementationOnce(callFuncOne) + .mockImplementationOnce(callFuncTwo); const result = await purchaseManager.disableReplacedSubscription( 'testPackage', @@ -263,9 +267,9 @@ describe('PurchaseManager', () => { 'testToken' ); expect(result).toBeUndefined(); - sinon.assert.calledOnce(mockSubscriptionPurchase.fromApiResponse); - sinon.assert.calledOnce(callFuncTwo); - sinon.assert.calledWithExactly(mockPurchaseDoc.ref.set, firestoreObject); + expect(mockSubscriptionPurchase.fromApiResponse).toHaveBeenCalledTimes(1); + expect(callFuncTwo).toHaveBeenCalledTimes(1); + expect(mockPurchaseDoc.ref.set).toHaveBeenCalledWith(firestoreObject); }); }); @@ -273,8 +277,8 @@ describe('PurchaseManager', () => { let purchaseManager: any; beforeEach(() => { - mockPurchaseDbRef.doc = sinon.fake.returns({ - update: sinon.fake.resolves({}), + mockPurchaseDbRef.doc = jest.fn().mockReturnValue({ + update: jest.fn().mockResolvedValue({}), }); purchaseManager = new PurchaseManager(mockPurchaseDbRef, mockApiClient); }); @@ -285,15 +289,15 @@ describe('PurchaseManager', () => { 'testUserId' ); expect(result).toBeUndefined(); - sinon.assert.calledOnce(mockPurchaseDbRef.doc); - sinon.assert.calledWithExactly(mockPurchaseDbRef.doc().update, { + expect(mockPurchaseDbRef.doc).toHaveBeenCalledTimes(1); + expect(mockPurchaseDbRef.doc().update).toHaveBeenCalledWith({ userId: 'testUserId', }); }); it('throws library error on unknown', async () => { - mockPurchaseDbRef.doc = sinon.fake.returns({ - update: sinon.fake.rejects(new Error('Oops')), + mockPurchaseDbRef.doc = jest.fn().mockReturnValue({ + update: jest.fn().mockRejectedValue(new Error('Oops')), }); await expect( purchaseManager.forceRegisterToUserAccount('testToken', 'testUserId') @@ -308,14 +312,16 @@ describe('PurchaseManager', () => { beforeEach(() => { mockPurchaseDoc = { exists: true, - data: sinon.fake.returns({}), + data: jest.fn().mockReturnValue({}), }; - mockPurchaseDbRef.doc = sinon.fake.returns({ - get: sinon.fake.resolves(mockPurchaseDoc), + mockPurchaseDbRef.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockPurchaseDoc), }); purchaseManager = new PurchaseManager(mockPurchaseDbRef, mockApiClient); - mockSubscriptionPurchase.fromFirestoreObject = sinon.fake.returns({}); + mockSubscriptionPurchase.fromFirestoreObject = jest + .fn() + .mockReturnValue({}); }); it('returns an existing doc', async () => { @@ -344,14 +350,14 @@ describe('PurchaseManager', () => { ], }; mockBatch = { - delete: sinon.fake.resolves({}), - commit: sinon.fake.resolves({}), + delete: jest.fn().mockResolvedValue({}), + commit: jest.fn().mockResolvedValue({}), }; - mockPurchaseDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves(mockPurchaseDoc), + mockPurchaseDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockPurchaseDoc), }); mockPurchaseDbRef.firestore = { - batch: sinon.fake.returns(mockBatch), + batch: jest.fn().mockReturnValue(mockBatch), }; purchaseManager = new PurchaseManager(mockPurchaseDbRef, mockApiClient); }); @@ -359,8 +365,9 @@ describe('PurchaseManager', () => { it('deletes a purchase', async () => { const result = await purchaseManager.deletePurchases('testToken'); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly(mockBatch.delete, 'testRef'); - sinon.assert.calledOnce(mockBatch.commit); + expect(mockBatch.delete).toHaveBeenCalledTimes(1); + expect(mockBatch.delete).toHaveBeenCalledWith('testRef'); + expect(mockBatch.commit).toHaveBeenCalledTimes(1); }); }); @@ -372,21 +379,24 @@ describe('PurchaseManager', () => { beforeEach(() => { mockPurchaseDoc = { exists: false, - data: sinon.fake.returns({}), + data: jest.fn().mockReturnValue({}), ref: { - set: sinon.fake.resolves({}), - update: sinon.fake.resolves({}), + set: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), }, }; mockSubscription = {}; - mockSubscription.isRegisterable = sinon.fake.returns(true); - mockPurchaseDbRef.doc = sinon.fake.returns({ - get: sinon.fake.resolves(mockPurchaseDoc), + mockSubscription.isRegisterable = jest.fn().mockReturnValue(true); + mockPurchaseDbRef.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(mockPurchaseDoc), }); purchaseManager = new PurchaseManager(mockPurchaseDbRef, mockApiClient); - purchaseManager.querySubscriptionPurchase = - sinon.fake.resolves(mockSubscription); - purchaseManager.forceRegisterToUserAccount = sinon.fake.resolves({}); + purchaseManager.querySubscriptionPurchase = jest + .fn() + .mockResolvedValue(mockSubscription); + purchaseManager.forceRegisterToUserAccount = jest + .fn() + .mockResolvedValue({}); }); it('registers successfully for non-cached token', async () => { @@ -398,15 +408,20 @@ describe('PurchaseManager', () => { 'testUserId' ); expect(result).toBe(mockSubscription); - sinon.assert.calledOnce(purchaseManager.querySubscriptionPurchase); - sinon.assert.calledOnce(purchaseManager.forceRegisterToUserAccount); + expect(purchaseManager.querySubscriptionPurchase).toHaveBeenCalledTimes( + 1 + ); + expect(purchaseManager.forceRegisterToUserAccount).toHaveBeenCalledTimes( + 1 + ); }); it('skips doing anything for cached token', async () => { mockPurchaseDoc.exists = true; mockSubscription.userId = 'testUserId'; - mockSubscriptionPurchase.fromFirestoreObject = - sinon.fake.returns(mockSubscription); + mockSubscriptionPurchase.fromFirestoreObject = jest + .fn() + .mockReturnValue(mockSubscription); const result = await purchaseManager.registerToUserAccount( 'testPackage', 'testSku', @@ -415,15 +430,16 @@ describe('PurchaseManager', () => { 'testUserId' ); expect(result).toBe(mockSubscription); - sinon.assert.notCalled(purchaseManager.querySubscriptionPurchase); - sinon.assert.notCalled(purchaseManager.forceRegisterToUserAccount); + expect(purchaseManager.querySubscriptionPurchase).not.toHaveBeenCalled(); + expect(purchaseManager.forceRegisterToUserAccount).not.toHaveBeenCalled(); }); it('throws conflict error for existing token registered to other user', async () => { mockPurchaseDoc.exists = true; mockSubscription.userId = 'otherUserId'; - mockSubscriptionPurchase.fromFirestoreObject = - sinon.fake.returns(mockSubscription); + mockSubscriptionPurchase.fromFirestoreObject = jest + .fn() + .mockReturnValue(mockSubscription); await expect( purchaseManager.registerToUserAccount( 'testPackage', @@ -433,11 +449,11 @@ describe('PurchaseManager', () => { 'testUserId' ) ).rejects.toMatchObject({ name: PurchaseUpdateError.CONFLICT }); - sinon.assert.calledOnce(log.info); + expect(log.info).toHaveBeenCalledTimes(1); }); it('throws invalid token error on non-registerable purchase', async () => { - mockSubscription.isRegisterable = sinon.fake.returns(false); + mockSubscription.isRegisterable = jest.fn().mockReturnValue(false); await expect( purchaseManager.registerToUserAccount( 'testPackage', @@ -447,13 +463,13 @@ describe('PurchaseManager', () => { 'testUserId' ) ).rejects.toMatchObject({ name: PurchaseUpdateError.INVALID_TOKEN }); - sinon.assert.calledOnce(mockSubscription.isRegisterable); + expect(mockSubscription.isRegisterable).toHaveBeenCalledTimes(1); }); it('throws invalid token error if purchase cant be queried', async () => { - purchaseManager.querySubscriptionPurchase = sinon.fake.rejects( - new Error('Oops') - ); + purchaseManager.querySubscriptionPurchase = jest + .fn() + .mockRejectedValue(new Error('Oops')); await expect( purchaseManager.registerToUserAccount( 'testPackage', @@ -479,8 +495,9 @@ describe('PurchaseManager', () => { mockSubscription = {}; purchaseManager = new PurchaseManager(mockPurchaseDbRef, mockApiClient); - purchaseManager.querySubscriptionPurchase = - sinon.fake.resolves(mockSubscription); + purchaseManager.querySubscriptionPurchase = jest + .fn() + .mockResolvedValue(mockSubscription); }); it('returns null without a notification', async () => { @@ -510,7 +527,9 @@ describe('PurchaseManager', () => { mockNotification ); expect(result).toEqual(mockSubscription); - sinon.assert.calledOnce(purchaseManager.querySubscriptionPurchase); + expect(purchaseManager.querySubscriptionPurchase).toHaveBeenCalledTimes( + 1 + ); }); }); }); diff --git a/packages/fxa-auth-server/lib/payments/iap/google-play/subscriptions.spec.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/subscriptions.spec.ts index 2460149ceae..cfdf480274d 100644 --- a/packages/fxa-auth-server/lib/payments/iap/google-play/subscriptions.spec.ts +++ b/packages/fxa-auth-server/lib/payments/iap/google-play/subscriptions.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { MozillaSubscriptionTypes } from 'fxa-shared/subscriptions/types'; @@ -15,7 +14,6 @@ const { deepCopy } = require('../../../../test/local/payments/util'); describe('PlaySubscriptions', () => { const UID = 'uid8675309'; - const sandbox = sinon.createSandbox(); let playSubscriptions: any; let mockPlayBilling: any; @@ -38,7 +36,7 @@ describe('PlaySubscriptions', () => { purchaseToken: 'testToken', sku: 'sku', verifiedAt: Date.now(), - isEntitlementActive: sinon.fake.returns(true), + isEntitlementActive: jest.fn().mockReturnValue(true), }; const mockAppendedPlayStoreSubscriptionPurchase = { @@ -53,17 +51,17 @@ describe('PlaySubscriptions', () => { mockConfig = { subscriptions: { enabled: true } }; mockPlayBilling = { userManager: { - queryCurrentSubscriptions: sinon - .stub() - .resolves([mockPlayStoreSubscriptionPurchase]), + queryCurrentSubscriptions: jest + .fn() + .mockResolvedValue([mockPlayStoreSubscriptionPurchase]), }, purchaseManager: {}, }; Container.set(PlayBilling, mockPlayBilling); mockStripeHelper = { - addPriceInfoToIapPurchases: sinon - .stub() - .resolves([mockAppendedPlayStoreSubscriptionPurchase]), + addPriceInfoToIapPurchases: jest + .fn() + .mockResolvedValue([mockAppendedPlayStoreSubscriptionPurchase]), }; Container.set(StripeHelper, mockStripeHelper); Container.set(AppConfig, mockConfig); @@ -72,7 +70,7 @@ describe('PlaySubscriptions', () => { afterEach(() => { Container.reset(); - sandbox.reset(); + jest.clearAllMocks(); }); describe('constructor', () => { @@ -93,12 +91,16 @@ describe('PlaySubscriptions', () => { describe('getSubscriptions', () => { it('returns active Google Play subscription purchases', async () => { const result = await playSubscriptions.getSubscriptions(UID); - sinon.assert.calledOnceWithExactly( - mockPlayBilling.userManager.queryCurrentSubscriptions, - UID + expect( + mockPlayBilling.userManager.queryCurrentSubscriptions + ).toHaveBeenCalledTimes(1); + expect( + mockPlayBilling.userManager.queryCurrentSubscriptions + ).toHaveBeenCalledWith(UID); + expect(mockStripeHelper.addPriceInfoToIapPurchases).toHaveBeenCalledTimes( + 1 ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.addPriceInfoToIapPurchases, + expect(mockStripeHelper.addPriceInfoToIapPurchases).toHaveBeenCalledWith( [mockPlayStoreSubscriptionPurchase], MozillaSubscriptionTypes.IAP_GOOGLE ); @@ -108,13 +110,17 @@ describe('PlaySubscriptions', () => { it('returns [] if no active Play subscriptions are found', async () => { const mockInactivePurchase = deepCopy(mockPlayStoreSubscriptionPurchase); - mockInactivePurchase.isEntitlementActive = sinon.fake.returns(false); - mockPlayBilling.userManager.queryCurrentSubscriptions = sinon - .stub() - .resolves([mockInactivePurchase]); + mockInactivePurchase.isEntitlementActive = jest + .fn() + .mockReturnValue(false); + mockPlayBilling.userManager.queryCurrentSubscriptions = jest + .fn() + .mockResolvedValue([mockInactivePurchase]); // In this case, we expect the length of the array returned by // addPriceInfoToIapPurchases to equal the length the array passed into it. - mockStripeHelper.addPriceInfoToIapPurchases = sinon.stub().resolvesArg(0); + mockStripeHelper.addPriceInfoToIapPurchases = jest + .fn() + .mockImplementation((arg: any) => Promise.resolve(arg)); const expected: any[] = []; const result = await playSubscriptions.getSubscriptions(UID); expect(result).toEqual(expected); diff --git a/packages/fxa-auth-server/lib/payments/iap/google-play/user-manager.spec.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/user-manager.spec.ts index b155f188b92..8990dbc839a 100644 --- a/packages/fxa-auth-server/lib/payments/iap/google-play/user-manager.spec.ts +++ b/packages/fxa-auth-server/lib/payments/iap/google-play/user-manager.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { AuthLogger } from '../../../types'; @@ -40,7 +39,7 @@ describe('UserManager', () => { }; mockCollRef = { where: () => mockCollRef, - get: sinon.fake.resolves(queryResult), + get: jest.fn().mockResolvedValue(queryResult), }; mockPurchaseManager = {}; Container.set(AuthLogger, log); @@ -62,12 +61,14 @@ describe('UserManager', () => { Date.now() ); const subscriptionSnapshot = { - data: sinon.fake.returns(subscriptionPurchase.toFirestoreObject()), + data: jest + .fn() + .mockReturnValue(subscriptionPurchase.toFirestoreObject()), }; queryResult.docs.push(subscriptionSnapshot); const result = await userManager.queryCurrentSubscriptions(USER_ID); expect(result).toEqual([subscriptionPurchase]); - sinon.assert.calledOnce(mockCollRef.get); + expect(mockCollRef.get).toHaveBeenCalledTimes(1); }); it('queries expired subscription purchases', async () => { @@ -82,14 +83,19 @@ describe('UserManager', () => { subscriptionPurchase.expiryTimeMillis = Date.now() - 10000; subscriptionPurchase.autoRenewing = false; const subscriptionSnapshot = { - data: sinon.fake.returns(subscriptionPurchase.toFirestoreObject()), + data: jest + .fn() + .mockReturnValue(subscriptionPurchase.toFirestoreObject()), }; queryResult.docs.push(subscriptionSnapshot); - mockPurchaseManager.querySubscriptionPurchase = - sinon.fake.resolves(subscriptionPurchase); + mockPurchaseManager.querySubscriptionPurchase = jest + .fn() + .mockResolvedValue(subscriptionPurchase); const result = await userManager.queryCurrentSubscriptions(USER_ID); expect(result).toEqual([]); - sinon.assert.calledOnce(mockPurchaseManager.querySubscriptionPurchase); + expect( + mockPurchaseManager.querySubscriptionPurchase + ).toHaveBeenCalledTimes(1); }); it('throws library error on failure', async () => { @@ -104,12 +110,14 @@ describe('UserManager', () => { subscriptionPurchase.expiryTimeMillis = Date.now() - 10000; subscriptionPurchase.autoRenewing = false; const subscriptionSnapshot = { - data: sinon.fake.returns(subscriptionPurchase.toFirestoreObject()), + data: jest + .fn() + .mockReturnValue(subscriptionPurchase.toFirestoreObject()), }; queryResult.docs.push(subscriptionSnapshot); - mockPurchaseManager.querySubscriptionPurchase = sinon.fake.rejects( - new Error('oops') - ); + mockPurchaseManager.querySubscriptionPurchase = jest + .fn() + .mockRejectedValue(new Error('oops')); await expect( userManager.queryCurrentSubscriptions(USER_ID) ).rejects.toMatchObject({ name: PurchaseQueryError.OTHER_ERROR }); diff --git a/packages/fxa-auth-server/lib/payments/iap/iap-config.spec.ts b/packages/fxa-auth-server/lib/payments/iap/iap-config.spec.ts index 41973d37438..c4efcb66b67 100644 --- a/packages/fxa-auth-server/lib/payments/iap/iap-config.spec.ts +++ b/packages/fxa-auth-server/lib/payments/iap/iap-config.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { AuthFirestore, AuthLogger, AppConfig } from '../../types'; @@ -17,17 +16,15 @@ const mockConfig = { }; describe('IAPConfig', () => { - let sandbox: sinon.SinonSandbox; let firestore: any; let log: any; let iapConfig: any; let planDbRefMock: any; beforeEach(() => { - sandbox = sinon.createSandbox(); planDbRefMock = {}; - const collectionMock = sinon.stub(); - collectionMock.returns(planDbRefMock); + const collectionMock = jest.fn(); + collectionMock.mockReturnValue(planDbRefMock); firestore = { collection: collectionMock, }; @@ -40,7 +37,7 @@ describe('IAPConfig', () => { afterEach(() => { Container.reset(); - sandbox.restore(); + jest.restoreAllMocks(); }); it('can be instantiated', () => { @@ -58,10 +55,10 @@ describe('IAPConfig', () => { }); it('returns successfully', async () => { - planDbRefMock.doc = sinon.fake.returns({ - get: sinon.fake.resolves({ + planDbRefMock.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: true, - data: sinon.fake.returns({ plans: 'testObject' }), + data: jest.fn().mockReturnValue({ plans: 'testObject' }), }), }); const result = await iapConfig.plans(); @@ -69,8 +66,8 @@ describe('IAPConfig', () => { }); it('throws error with no document found', async () => { - planDbRefMock.doc = sinon.fake.returns({ - get: sinon.fake.resolves({ + planDbRefMock.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: false, }), }); @@ -88,10 +85,10 @@ describe('IAPConfig', () => { }); it('returns successfully', async () => { - planDbRefMock.doc = sinon.fake.returns({ - get: sinon.fake.resolves({ + planDbRefMock.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: true, - data: sinon.fake.returns({ + data: jest.fn().mockReturnValue({ packageName: 'org.mozilla.testApp', plans: 'testObject', }), @@ -102,8 +99,8 @@ describe('IAPConfig', () => { }); it('throws error with no document found', async () => { - planDbRefMock.doc = sinon.fake.returns({ - get: sinon.fake.resolves({ + planDbRefMock.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: false, }), }); @@ -121,10 +118,10 @@ describe('IAPConfig', () => { }); it('returns successfully', async () => { - planDbRefMock.doc = sinon.fake.returns({ - get: sinon.fake.resolves({ + planDbRefMock.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: true, - data: sinon.fake.returns({ + data: jest.fn().mockReturnValue({ bundleId: 'org.mozilla.testApp', plans: 'testObject', }), @@ -135,8 +132,8 @@ describe('IAPConfig', () => { }); it('throws error with no document found', async () => { - planDbRefMock.doc = sinon.fake.returns({ - get: sinon.fake.resolves({ + planDbRefMock.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: false, }), }); diff --git a/packages/fxa-auth-server/lib/payments/iap/iap-formatter.spec.ts b/packages/fxa-auth-server/lib/payments/iap/iap-formatter.spec.ts index 73903e6965f..9d1687e7f53 100644 --- a/packages/fxa-auth-server/lib/payments/iap/iap-formatter.spec.ts +++ b/packages/fxa-auth-server/lib/payments/iap/iap-formatter.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { MozillaSubscriptionTypes } from 'fxa-shared/subscriptions/types'; import { @@ -34,7 +33,7 @@ describe('playStoreSubscriptionPurchaseToPlayStoreSubscriptionDTO', () => { purchaseToken: 'testToken', sku: 'sku', verifiedAt: Date.now(), - isEntitlementActive: sinon.fake.returns(true), + isEntitlementActive: jest.fn().mockReturnValue(true), }; const mockAppendedPlayStoreSubscriptionPurchase = { @@ -80,7 +79,7 @@ describe('appStoreSubscriptionPurchaseToAppStoreSubscriptionDTO', () => { autoRenewStatus: 1, productId: 'wow', bundleId: 'hmm', - isEntitlementActive: sinon.fake.returns(true), + isEntitlementActive: jest.fn().mockReturnValue(true), }; const mockAppendedAppStoreSubscriptionPurchase = { diff --git a/packages/fxa-auth-server/lib/payments/paypal/helper.spec.ts b/packages/fxa-auth-server/lib/payments/paypal/helper.spec.ts index 763e0cb337c..627f2d75fd5 100644 --- a/packages/fxa-auth-server/lib/payments/paypal/helper.spec.ts +++ b/packages/fxa-auth-server/lib/payments/paypal/helper.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { StatsD } from 'hot-shots'; import { Container } from 'typedi'; @@ -26,7 +25,6 @@ import { PAYPAL_APP_ERRORS, PAYPAL_RETRY_ERRORS, } from './error-codes'; -import { RefundError } from './helper'; const { mockLog } = require('../../../test/mocks'); @@ -85,7 +83,7 @@ describe('PayPalHelper', () => { mockStripeHelper = {}; Container.set(StripeHelper, mockStripeHelper); // Make StatsD - const statsd = { increment: sinon.spy(), timing: sinon.spy() }; + const statsd = { increment: jest.fn(), timing: jest.fn() }; Container.set(StatsD, statsd); // Make PayPalClient const paypalClient = new PayPalClient( @@ -111,7 +109,7 @@ describe('PayPalHelper', () => { describe('constructor', () => { it('sets client, statsd, logger, and currencyHelper', () => { - const statsd = { increment: sinon.spy(), timing: sinon.spy() }; + const statsd = { increment: jest.fn(), timing: jest.fn() }; const paypalClient = new PayPalClient( { user: 'user', @@ -166,9 +164,9 @@ describe('PayPalHelper', () => { const validOptions = { currencyCode: 'USD' }; it('it returns the token from doRequest', async () => { - paypalHelper.client.doRequest = sinon.fake.resolves( - successfulSetExpressCheckoutResponse - ); + paypalHelper.client.doRequest = jest + .fn() + .mockResolvedValue(successfulSetExpressCheckoutResponse); const token = await paypalHelper.getCheckoutToken(validOptions); expect(token).toEqual(successfulSetExpressCheckoutResponse.TOKEN); }); @@ -178,24 +176,24 @@ describe('PayPalHelper', () => { message: 'oh no', errorCode: 123, }); - paypalHelper.client.doRequest = sinon.fake.throws( - new PayPalClientError([nvpError], 'hi', {} as NVPErrorResponse) - ); + paypalHelper.client.doRequest = jest.fn().mockImplementation(() => { + throw new PayPalClientError([nvpError], 'hi', {} as NVPErrorResponse); + }); await expect(paypalHelper.getCheckoutToken(validOptions)).rejects.toThrow( PayPalClientError ); }); it('calls setExpressCheckout with passed options', async () => { - paypalHelper.client.setExpressCheckout = sinon.fake.resolves( - successfulSetExpressCheckoutResponse - ); + paypalHelper.client.setExpressCheckout = jest + .fn() + .mockResolvedValue(successfulSetExpressCheckoutResponse); const currencyCode = 'EUR'; await paypalHelper.getCheckoutToken({ currencyCode }); - sinon.assert.calledOnceWithExactly( - paypalHelper.client.setExpressCheckout, - { currencyCode } - ); + expect(paypalHelper.client.setExpressCheckout).toHaveBeenCalledTimes(1); + expect(paypalHelper.client.setExpressCheckout).toHaveBeenCalledWith({ + currencyCode, + }); }); }); @@ -210,11 +208,14 @@ describe('PayPalHelper', () => { }; it('calls createBillingAgreement with passed options', async () => { - paypalHelper.client.createBillingAgreement = - sinon.fake.resolves(expectedResponse); + paypalHelper.client.createBillingAgreement = jest + .fn() + .mockResolvedValue(expectedResponse); const response = await paypalHelper.createBillingAgreement(validOptions); - sinon.assert.calledOnceWithExactly( - paypalHelper.client.createBillingAgreement, + expect(paypalHelper.client.createBillingAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(paypalHelper.client.createBillingAgreement).toHaveBeenCalledWith( validOptions ); expect(response).toEqual('B-7FB31251F28061234'); @@ -232,9 +233,9 @@ describe('PayPalHelper', () => { }; it('calls doReferenceTransaction with options and amount converted to string', async () => { - paypalHelper.client.doReferenceTransaction = sinon.fake.resolves( - successfulDoReferenceTransactionResponse - ); + paypalHelper.client.doReferenceTransaction = jest + .fn() + .mockResolvedValue(successfulDoReferenceTransactionResponse); await paypalHelper.chargeCustomer(validOptions); const expectedOptions = { amount: @@ -247,11 +248,9 @@ describe('PayPalHelper', () => { currencyCode: validOptions.currencyCode, countryCode: validOptions.countryCode, }; - expect( - paypalHelper.client.doReferenceTransaction.calledOnceWith( - expectedOptions - ) - ).toBeTruthy(); + expect(paypalHelper.client.doReferenceTransaction).toHaveBeenCalledWith( + expectedOptions + ); }); it('it returns the data from doRequest', async () => { @@ -268,9 +267,9 @@ describe('PayPalHelper', () => { transactionId: '51E835834L664664K', transactionType: 'merchtpmt', }; - paypalHelper.client.doRequest = sinon.fake.resolves( - successfulDoReferenceTransactionResponse - ); + paypalHelper.client.doRequest = jest + .fn() + .mockResolvedValue(successfulDoReferenceTransactionResponse); const response = await paypalHelper.chargeCustomer(validOptions); expect(response).toEqual(expectedResponse); }); @@ -278,9 +277,9 @@ describe('PayPalHelper', () => { it('calls doReferenceTransaction with taxAmount option and taxAmount converted to string', async () => { const options = deepCopy(validOptions); options.taxAmountInCents = '500'; - paypalHelper.client.doReferenceTransaction = sinon.fake.resolves( - successfulDoReferenceTransactionResponse - ); + paypalHelper.client.doReferenceTransaction = jest + .fn() + .mockResolvedValue(successfulDoReferenceTransactionResponse); await paypalHelper.chargeCustomer(options); const expectedOptions = { amount: @@ -297,11 +296,9 @@ describe('PayPalHelper', () => { options.taxAmountInCents ), }; - expect( - paypalHelper.client.doReferenceTransaction.calledOnceWith( - expectedOptions - ) - ).toBeTruthy(); + expect(paypalHelper.client.doReferenceTransaction).toHaveBeenCalledWith( + expectedOptions + ); }); it('if doRequest unsuccessful, throws an error', async () => { @@ -309,9 +306,9 @@ describe('PayPalHelper', () => { message: 'oh no', errorCode: 123, }); - paypalHelper.client.doRequest = sinon.fake.throws( - new PayPalClientError([nvpError], 'hi', {} as NVPErrorResponse) - ); + paypalHelper.client.doRequest = jest.fn().mockImplementation(() => { + throw new PayPalClientError([nvpError], 'hi', {} as NVPErrorResponse); + }); await expect(paypalHelper.chargeCustomer(validOptions)).rejects.toThrow( PayPalClientError ); @@ -326,9 +323,9 @@ describe('PayPalHelper', () => { }; it('refunds entire transaction', async () => { - paypalHelper.client.doRequest = sinon.fake.resolves( - successfulRefundTransactionResponse - ); + paypalHelper.client.doRequest = jest + .fn() + .mockResolvedValue(successfulRefundTransactionResponse); const response = await paypalHelper.refundTransaction({ idempotencyKey: defaultData.MSGSUBID, transactionId: defaultData.TRANSACTIONID, @@ -340,17 +337,17 @@ describe('PayPalHelper', () => { refundTransactionId: successfulRefundTransactionResponse.REFUNDTRANSACTIONID, }); - sinon.assert.calledOnceWithExactly( - paypalHelper.client.doRequest, + expect(paypalHelper.client.doRequest).toHaveBeenCalledTimes(1); + expect(paypalHelper.client.doRequest).toHaveBeenCalledWith( 'RefundTransaction', defaultData ); }); it('refunds partial transaction', async () => { - paypalHelper.client.doRequest = sinon.fake.resolves( - successfulRefundTransactionResponse - ); + paypalHelper.client.doRequest = jest + .fn() + .mockResolvedValue(successfulRefundTransactionResponse); const response = await paypalHelper.refundTransaction({ idempotencyKey: defaultData.MSGSUBID, transactionId: defaultData.TRANSACTIONID, @@ -363,8 +360,8 @@ describe('PayPalHelper', () => { refundTransactionId: successfulRefundTransactionResponse.REFUNDTRANSACTIONID, }); - sinon.assert.calledOnceWithExactly( - paypalHelper.client.doRequest, + expect(paypalHelper.client.doRequest).toHaveBeenCalledTimes(1); + expect(paypalHelper.client.doRequest).toHaveBeenCalledWith( 'RefundTransaction', { ...defaultData, REFUNDTYPE: 'Partial', AMT: '1.23' } ); @@ -389,7 +386,7 @@ describe('PayPalHelper', () => { errorCode: 10009, } ); - paypalHelper.client.refundTransaction = sinon.fake.rejects( + paypalHelper.client.refundTransaction = jest.fn().mockRejectedValue( new PayPalClientError([nvpError], 'hi', { ACK: PaypalNVPAckOptions.Failure, L: [ @@ -422,9 +419,10 @@ describe('PayPalHelper', () => { const transactionId = '9EG80664Y1384290G'; it('successfully refunds completed transaction', async () => { - mockStripeHelper.updateInvoiceWithPaypalRefundTransactionId = - sinon.fake.resolves({}); - paypalHelper.refundTransaction = sinon.fake.resolves({ + mockStripeHelper.updateInvoiceWithPaypalRefundTransactionId = jest + .fn() + .mockResolvedValue({}); + paypalHelper.refundTransaction = jest.fn().mockResolvedValue({ pendingReason: successfulRefundTransactionResponse.PENDINGREASON, refundStatus: successfulRefundTransactionResponse.REFUNDSTATUS, refundTransactionId: @@ -437,29 +435,35 @@ describe('PayPalHelper', () => { ); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly(paypalHelper.refundTransaction, { + expect(paypalHelper.refundTransaction).toHaveBeenCalledTimes(1); + expect(paypalHelper.refundTransaction).toHaveBeenCalledWith({ idempotencyKey: invoice.id, transactionId: transactionId, refundType: RefundType.Full, amount: undefined, }); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.updateInvoiceWithPaypalRefundTransactionId, + expect( + mockStripeHelper.updateInvoiceWithPaypalRefundTransactionId + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.updateInvoiceWithPaypalRefundTransactionId + ).toHaveBeenCalledWith( invoice, successfulRefundTransactionResponse.REFUNDTRANSACTIONID ); }); it('unsuccessfully refunds completed transaction', async () => { - mockStripeHelper.updateInvoiceWithPaypalRefundTransactionId = - sinon.fake.resolves({}); - paypalHelper.refundTransaction = sinon.fake.resolves({ + mockStripeHelper.updateInvoiceWithPaypalRefundTransactionId = jest + .fn() + .mockResolvedValue({}); + paypalHelper.refundTransaction = jest.fn().mockResolvedValue({ pendingReason: successfulRefundTransactionResponse.PENDINGREASON, refundStatus: 'None', refundTransactionId: successfulRefundTransactionResponse.REFUNDTRANSACTIONID, }); - paypalHelper.log = { error: sinon.fake.returns({}) }; + paypalHelper.log = { error: jest.fn().mockReturnValue({}) }; await expect( paypalHelper.issueRefund(invoice, transactionId, RefundType.Full) @@ -468,7 +472,8 @@ describe('PayPalHelper', () => { message: 'PayPal refund transaction unsuccessful', }) ); - sinon.assert.calledOnceWithExactly(paypalHelper.refundTransaction, { + expect(paypalHelper.refundTransaction).toHaveBeenCalledTimes(1); + expect(paypalHelper.refundTransaction).toHaveBeenCalledWith({ idempotencyKey: invoice.id, transactionId: transactionId, refundType: RefundType.Full, @@ -485,28 +490,26 @@ describe('PayPalHelper', () => { }; beforeEach(() => { paypalHelper.log = { - debug: sinon.fake.returns({}), - info: sinon.fake.returns({}), - error: sinon.fake.returns({}), + debug: jest.fn().mockReturnValue({}), + info: jest.fn().mockReturnValue({}), + error: jest.fn().mockReturnValue({}), }; - paypalHelper.refundInvoice = sinon.fake.resolves(undefined); + paypalHelper.refundInvoice = jest.fn().mockResolvedValue(undefined); }); it('returns empty array if no payPalInvoices exist', async () => { await paypalHelper.refundInvoices([{ collection_method: 'notpaypal' }]); - sinon.assert.notCalled(paypalHelper.refundInvoice); + expect(paypalHelper.refundInvoice).not.toHaveBeenCalled(); }); it('returns on empty array input', async () => { await paypalHelper.refundInvoices([]); - sinon.assert.notCalled(paypalHelper.refundInvoice); + expect(paypalHelper.refundInvoice).not.toHaveBeenCalled(); }); it('calls refundInvoice for each invoice', async () => { await paypalHelper.refundInvoices([validInvoice]); - sinon.assert.calledOnceWithExactly( - paypalHelper.refundInvoice, - validInvoice - ); + expect(paypalHelper.refundInvoice).toHaveBeenCalledTimes(1); + expect(paypalHelper.refundInvoice).toHaveBeenCalledWith(validInvoice); }); }); @@ -518,11 +521,11 @@ describe('PayPalHelper', () => { }; beforeEach(() => { paypalHelper.log = { - debug: sinon.fake.returns({}), - info: sinon.fake.returns({}), - error: sinon.fake.returns({}), + debug: jest.fn().mockReturnValue({}), + info: jest.fn().mockReturnValue({}), + error: jest.fn().mockReturnValue({}), }; - paypalHelper.issueRefund = sinon.fake.resolves(undefined); + paypalHelper.issueRefund = jest.fn().mockResolvedValue(undefined); }); it('does not refund when created date older than 180 days', async () => { @@ -537,14 +540,11 @@ describe('PayPalHelper', () => { ), }) ).rejects.toThrow(expectedErrorMessage); - sinon.assert.notCalled(paypalHelper.issueRefund); - sinon.assert.calledWithExactly( - paypalHelper.log.error, + expect(paypalHelper.issueRefund).not.toHaveBeenCalled(); + expect(paypalHelper.log.error).toHaveBeenCalledWith( 'PayPalHelper.refundInvoice', { - error: sinon.match - .instanceOf(RefundError) - .and(sinon.match.has('message', expectedErrorMessage)), + error: expect.objectContaining({ message: expectedErrorMessage }), invoiceId: validInvoice.id, } ); @@ -552,19 +552,17 @@ describe('PayPalHelper', () => { it('throws error if transactionId is missing', async () => { const expectedErrorMessage = 'Missing transactionId'; - mockStripeHelper.getInvoicePaypalTransactionId = - sinon.fake.returns(undefined); + mockStripeHelper.getInvoicePaypalTransactionId = jest + .fn() + .mockReturnValue(undefined); await expect(paypalHelper.refundInvoice(validInvoice)).rejects.toThrow( expectedErrorMessage ); - sinon.assert.notCalled(paypalHelper.issueRefund); - sinon.assert.calledWithExactly( - paypalHelper.log.error, + expect(paypalHelper.issueRefund).not.toHaveBeenCalled(); + expect(paypalHelper.log.error).toHaveBeenCalledWith( 'PayPalHelper.refundInvoice', { - error: sinon.match - .instanceOf(RefundError) - .and(sinon.match.has('message', expectedErrorMessage)), + error: expect.objectContaining({ message: expectedErrorMessage }), invoiceId: validInvoice.id, } ); @@ -572,24 +570,26 @@ describe('PayPalHelper', () => { it('throws error if refundTransactionId exists', async () => { const expectedErrorMessage = 'Invoice already refunded with PayPal'; - mockStripeHelper.getInvoicePaypalTransactionId = sinon.fake.returns(123); - mockStripeHelper.getInvoicePaypalRefundTransactionId = - sinon.fake.returns(123); + mockStripeHelper.getInvoicePaypalTransactionId = jest + .fn() + .mockReturnValue(123); + mockStripeHelper.getInvoicePaypalRefundTransactionId = jest + .fn() + .mockReturnValue(123); await expect(paypalHelper.refundInvoice(validInvoice)).rejects.toThrow( expectedErrorMessage ); - sinon.assert.calledOnce(mockStripeHelper.getInvoicePaypalTransactionId); - sinon.assert.calledOnce( + expect( + mockStripeHelper.getInvoicePaypalTransactionId + ).toHaveBeenCalledTimes(1); + expect( mockStripeHelper.getInvoicePaypalRefundTransactionId - ); - sinon.assert.notCalled(paypalHelper.issueRefund); - sinon.assert.calledWithExactly( - paypalHelper.log.error, + ).toHaveBeenCalledTimes(1); + expect(paypalHelper.issueRefund).not.toHaveBeenCalled(); + expect(paypalHelper.log.error).toHaveBeenCalledWith( 'PayPalHelper.refundInvoice', { - error: sinon.match - .instanceOf(RefundError) - .and(sinon.match.has('message', expectedErrorMessage)), + error: expect.objectContaining({ message: expectedErrorMessage }), invoiceId: validInvoice.id, } ); @@ -601,20 +601,20 @@ describe('PayPalHelper', () => { 'Helper error details', '10009' ); - mockStripeHelper.getInvoicePaypalTransactionId = sinon.fake.returns(123); - mockStripeHelper.getInvoicePaypalRefundTransactionId = - sinon.fake.returns(undefined); - paypalHelper.issueRefund = sinon.fake.rejects(expectedError); + mockStripeHelper.getInvoicePaypalTransactionId = jest + .fn() + .mockReturnValue(123); + mockStripeHelper.getInvoicePaypalRefundTransactionId = jest + .fn() + .mockReturnValue(undefined); + paypalHelper.issueRefund = jest.fn().mockRejectedValue(expectedError); await expect(paypalHelper.refundInvoice(validInvoice)).rejects.toThrow( 'Helper error' ); - sinon.assert.calledWithExactly( - paypalHelper.log.error, + expect(paypalHelper.log.error).toHaveBeenCalledWith( 'PayPalHelper.refundInvoice', { - error: sinon.match - .instanceOf(RefusedError) - .and(sinon.match.has('message', 'Helper error')), + error: expect.objectContaining({ message: 'Helper error' }), invoiceId: validInvoice.id, } ); @@ -631,27 +631,29 @@ describe('PayPalHelper', () => { ...validInvoice, ...expectedInvoiceResults, }; - mockStripeHelper.getInvoicePaypalTransactionId = - sinon.fake.returns('123'); - mockStripeHelper.getInvoicePaypalRefundTransactionId = - sinon.fake.returns(undefined); - mockStripeHelper.getPriceIdFromInvoice = sinon.fake.returns( - expectedInvoiceResults.priceId - ); + mockStripeHelper.getInvoicePaypalTransactionId = jest + .fn() + .mockReturnValue('123'); + mockStripeHelper.getInvoicePaypalRefundTransactionId = jest + .fn() + .mockReturnValue(undefined); + mockStripeHelper.getPriceIdFromInvoice = jest + .fn() + .mockReturnValue(expectedInvoiceResults.priceId); await paypalHelper.refundInvoice(invoice); - sinon.assert.calledOnceWithExactly( - paypalHelper.issueRefund, + expect(paypalHelper.issueRefund).toHaveBeenCalledTimes(1); + expect(paypalHelper.issueRefund).toHaveBeenCalledWith( invoice, '123', RefundType.Full, undefined ); - sinon.assert.calledOnceWithExactly( - paypalHelper.log.info, + expect(paypalHelper.log.info).toHaveBeenCalledTimes(1); + expect(paypalHelper.log.info).toHaveBeenCalledWith( 'refundInvoice', expectedInvoiceResults ); - sinon.assert.notCalled(paypalHelper.log.error); + expect(paypalHelper.log.error).not.toHaveBeenCalled(); }); it('issues partial refund successfully', async () => { @@ -660,25 +662,29 @@ describe('PayPalHelper', () => { id: 'inv_partial', amount_paid: 1000, }; - mockStripeHelper.getInvoicePaypalTransactionId = - sinon.fake.returns('123'); - mockStripeHelper.getInvoicePaypalRefundTransactionId = - sinon.fake.returns(undefined); - mockStripeHelper.getPriceIdFromInvoice = sinon.fake.returns('priceId1'); + mockStripeHelper.getInvoicePaypalTransactionId = jest + .fn() + .mockReturnValue('123'); + mockStripeHelper.getInvoicePaypalRefundTransactionId = jest + .fn() + .mockReturnValue(undefined); + mockStripeHelper.getPriceIdFromInvoice = jest + .fn() + .mockReturnValue('priceId1'); await paypalHelper.refundInvoice(invoice, { refundType: RefundType.Partial, amount: 500, }); - sinon.assert.calledOnceWithExactly( - paypalHelper.issueRefund, + expect(paypalHelper.issueRefund).toHaveBeenCalledTimes(1); + expect(paypalHelper.issueRefund).toHaveBeenCalledWith( invoice, '123', RefundType.Partial, 500 ); - sinon.assert.notCalled(paypalHelper.log.error); + expect(paypalHelper.log.error).not.toHaveBeenCalled(); }); it('throws error if partial refund amount is not less than amount paid', async () => { @@ -689,10 +695,12 @@ describe('PayPalHelper', () => { }; const expectedErrorMessage = 'Partial refunds must be less than the amount due on the invoice'; - mockStripeHelper.getInvoicePaypalTransactionId = - sinon.fake.returns('123'); - mockStripeHelper.getInvoicePaypalRefundTransactionId = - sinon.fake.returns(undefined); + mockStripeHelper.getInvoicePaypalTransactionId = jest + .fn() + .mockReturnValue('123'); + mockStripeHelper.getInvoicePaypalRefundTransactionId = jest + .fn() + .mockReturnValue(undefined); await expect( paypalHelper.refundInvoice(invoice, { @@ -700,15 +708,15 @@ describe('PayPalHelper', () => { amount: 1000, }) ).rejects.toThrow(expectedErrorMessage); - sinon.assert.notCalled(paypalHelper.issueRefund); + expect(paypalHelper.issueRefund).not.toHaveBeenCalled(); }); }); describe('cancelBillingAgreement', () => { it('cancels an agreement', async () => { - paypalHelper.client.doRequest = sinon.fake.resolves( - successfulBAUpdateResponse - ); + paypalHelper.client.doRequest = jest + .fn() + .mockResolvedValue(successfulBAUpdateResponse); const response = await paypalHelper.cancelBillingAgreement('test'); expect(response).toBeNull(); }); @@ -718,9 +726,9 @@ describe('PayPalHelper', () => { message: 'oh no', errorCode: 123, }); - paypalHelper.client.doRequest = sinon.fake.throws( - new PayPalClientError([nvpError], 'hi', {} as NVPErrorResponse) - ); + paypalHelper.client.doRequest = jest.fn().mockImplementation(() => { + throw new PayPalClientError([nvpError], 'hi', {} as NVPErrorResponse); + }); const response = await paypalHelper.cancelBillingAgreement('test'); expect(response).toBeNull(); }); @@ -728,9 +736,9 @@ describe('PayPalHelper', () => { describe('searchTransactions', () => { it('returns the data from doRequest', async () => { - paypalHelper.client.doRequest = sinon.fake.resolves( - searchTransactionResponse - ); + paypalHelper.client.doRequest = jest + .fn() + .mockResolvedValue(searchTransactionResponse); const expectedResponse = [ { amount: '5.99', @@ -779,22 +787,22 @@ describe('PayPalHelper', () => { describe('verifyIpnMessage', () => { it('validates IPN message', async () => { - paypalHelper.client.ipnVerify = sinon.fake.resolves('VERIFIED'); + paypalHelper.client.ipnVerify = jest.fn().mockResolvedValue('VERIFIED'); const response = await paypalHelper.verifyIpnMessage( sampleIpnMessage.message ); - sinon.assert.calledOnceWithExactly( - paypalHelper.client.ipnVerify, + expect(paypalHelper.client.ipnVerify).toHaveBeenCalledTimes(1); + expect(paypalHelper.client.ipnVerify).toHaveBeenCalledWith( sampleIpnMessage.message ); expect(response).toBe(true); }); it('invalidates IPN message', async () => { - paypalHelper.client.ipnVerify = sinon.fake.resolves('INVALID'); + paypalHelper.client.ipnVerify = jest.fn().mockResolvedValue('INVALID'); const response = await paypalHelper.verifyIpnMessage('invalid=True'); - sinon.assert.calledOnceWithExactly( - paypalHelper.client.ipnVerify, + expect(paypalHelper.client.ipnVerify).toHaveBeenCalledTimes(1); + expect(paypalHelper.client.ipnVerify).toHaveBeenCalledWith( 'invalid=True' ); expect(response).toBe(false); @@ -850,16 +858,18 @@ describe('PayPalHelper', () => { describe('conditionallyRemoveBillingAgreement', () => { it('returns false with no billing agreement found', async () => { - mockStripeHelper.getCustomerPaypalAgreement = - sinon.fake.returns(undefined); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(undefined); const result = await paypalHelper.conditionallyRemoveBillingAgreement(mockCustomer); expect(result).toBe(false); }); it('returns false with no paypal subscriptions', async () => { - mockStripeHelper.getCustomerPaypalAgreement = - sinon.fake.returns('ba-test'); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue('ba-test'); mockCustomer.subscriptions = { data: [{ status: 'active', collection_method: 'send_invoice' }], }; @@ -869,24 +879,33 @@ describe('PayPalHelper', () => { }); it('returns true if it cancelled and removed the billing agreement', async () => { - mockStripeHelper.getCustomerPaypalAgreement = - sinon.fake.returns('ba-test'); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue('ba-test'); mockCustomer.subscriptions = { data: [] }; - paypalHelper.cancelBillingAgreement = sinon.fake.resolves({}); - mockStripeHelper.removeCustomerPaypalAgreement = sinon.fake.resolves({}); + paypalHelper.cancelBillingAgreement = jest.fn().mockResolvedValue({}); + mockStripeHelper.removeCustomerPaypalAgreement = jest + .fn() + .mockResolvedValue({}); const result = await paypalHelper.conditionallyRemoveBillingAgreement(mockCustomer); expect(result).toBe(true); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( mockCustomer ); - sinon.assert.calledOnceWithExactly( - paypalHelper.cancelBillingAgreement, + expect(paypalHelper.cancelBillingAgreement).toHaveBeenCalledTimes(1); + expect(paypalHelper.cancelBillingAgreement).toHaveBeenCalledWith( 'ba-test' ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.removeCustomerPaypalAgreement, + expect( + mockStripeHelper.removeCustomerPaypalAgreement + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.removeCustomerPaypalAgreement + ).toHaveBeenCalledWith( mockCustomer.metadata.userid, mockCustomer.id, 'ba-test' @@ -896,8 +915,10 @@ describe('PayPalHelper', () => { describe('updateStripeNameFromBA', () => { it('updates the name on the stripe customer', async () => { - mockStripeHelper.updateCustomerBillingAddress = sinon.fake.resolves({}); - paypalHelper.agreementDetails = sinon.fake.resolves({ + mockStripeHelper.updateCustomerBillingAddress = jest + .fn() + .mockResolvedValue({}); + paypalHelper.agreementDetails = jest.fn().mockResolvedValue({ firstName: 'Test', lastName: 'User', }); @@ -906,16 +927,23 @@ describe('PayPalHelper', () => { 'mock-agreement-id' ); expect(result).toEqual({}); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.updateCustomerBillingAddress, - { customerId: mockCustomer.id, name: 'Test User' } - ); - sinon.assert.calledOnce(paypalHelper.metrics.increment); + expect( + mockStripeHelper.updateCustomerBillingAddress + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.updateCustomerBillingAddress + ).toHaveBeenCalledWith({ + customerId: mockCustomer.id, + name: 'Test User', + }); + expect(paypalHelper.metrics.increment).toHaveBeenCalledTimes(1); }); it('throws error if billing agreement status is cancelled', async () => { - mockStripeHelper.updateCustomerBillingAddress = sinon.fake.resolves({}); - paypalHelper.agreementDetails = sinon.fake.resolves({ + mockStripeHelper.updateCustomerBillingAddress = jest + .fn() + .mockResolvedValue({}); + paypalHelper.agreementDetails = jest.fn().mockResolvedValue({ firstName: 'Test', lastName: 'User', status: 'cancelled', @@ -933,11 +961,11 @@ describe('PayPalHelper', () => { describe('processZeroInvoice', () => { it('finalize invoice that with no amount set to zero', async () => { - mockStripeHelper.finalizeInvoice = sinon.fake.resolves({}); - mockStripeHelper.payInvoiceOutOfBand = sinon.fake.resolves({}); + mockStripeHelper.finalizeInvoice = jest.fn().mockResolvedValue({}); + mockStripeHelper.payInvoiceOutOfBand = jest.fn().mockResolvedValue({}); const response = await paypalHelper.processZeroInvoice(mockInvoice); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.finalizeInvoice, + expect(mockStripeHelper.finalizeInvoice).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.finalizeInvoice).toHaveBeenCalledWith( mockInvoice ); expect(response).toEqual({}); @@ -950,17 +978,21 @@ describe('PayPalHelper', () => { const transactionId = 'transaction-id'; beforeEach(() => { - mockStripeHelper.getCustomerPaypalAgreement = - sinon.fake.returns(agreementId); - mockStripeHelper.getPaymentAttempts = sinon.fake.returns(paymentAttempts); - paypalHelper.chargeCustomer = sinon.fake.resolves({ + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(agreementId); + mockStripeHelper.getPaymentAttempts = jest + .fn() + .mockReturnValue(paymentAttempts); + paypalHelper.chargeCustomer = jest.fn().mockResolvedValue({ paymentStatus: 'Completed', transactionId, }); - mockStripeHelper.updateInvoiceWithPaypalTransactionId = - sinon.fake.resolves({ transactionId }); - mockStripeHelper.payInvoiceOutOfBand = sinon.fake.resolves({}); - mockStripeHelper.updatePaymentAttempts = sinon.fake.resolves({}); + mockStripeHelper.updateInvoiceWithPaypalTransactionId = jest + .fn() + .mockResolvedValue({ transactionId }); + mockStripeHelper.payInvoiceOutOfBand = jest.fn().mockResolvedValue({}); + mockStripeHelper.updatePaymentAttempts = jest.fn().mockResolvedValue({}); }); it('runs a open invoice successfully', async () => { @@ -976,15 +1008,18 @@ describe('PayPalHelper', () => { invoice: validInvoice, ipaddress: '127.0.0.1', }); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( mockCustomer ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getPaymentAttempts, + expect(mockStripeHelper.getPaymentAttempts).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.getPaymentAttempts).toHaveBeenCalledWith( validInvoice ); - sinon.assert.calledOnceWithExactly(paypalHelper.chargeCustomer, { + expect(paypalHelper.chargeCustomer).toHaveBeenCalledTimes(1); + expect(paypalHelper.chargeCustomer).toHaveBeenCalledWith({ amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, @@ -996,13 +1031,14 @@ describe('PayPalHelper', () => { ), ipaddress: '127.0.0.1', }); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.updateInvoiceWithPaypalTransactionId, - validInvoice, - transactionId - ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.payInvoiceOutOfBand, + expect( + mockStripeHelper.updateInvoiceWithPaypalTransactionId + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.updateInvoiceWithPaypalTransactionId + ).toHaveBeenCalledWith(validInvoice, transactionId); + expect(mockStripeHelper.payInvoiceOutOfBand).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.payInvoiceOutOfBand).toHaveBeenCalledWith( validInvoice ); expect(response).toEqual([{ transactionId }, {}]); @@ -1022,7 +1058,8 @@ describe('PayPalHelper', () => { invoice: validInvoice, ipaddress: '127.0.0.1', }); - sinon.assert.calledOnceWithExactly(paypalHelper.chargeCustomer, { + expect(paypalHelper.chargeCustomer).toHaveBeenCalledTimes(1); + expect(paypalHelper.chargeCustomer).toHaveBeenCalledWith({ amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, @@ -1045,21 +1082,24 @@ describe('PayPalHelper', () => { amount_due: 499, }; - mockStripeHelper.finalizeInvoice = sinon.fake.resolves({}); + mockStripeHelper.finalizeInvoice = jest.fn().mockResolvedValue({}); const response = await paypalHelper.processInvoice({ customer: mockCustomer, invoice: validInvoice, }); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( mockCustomer ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getPaymentAttempts, + expect(mockStripeHelper.getPaymentAttempts).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.getPaymentAttempts).toHaveBeenCalledWith( validInvoice ); - sinon.assert.calledOnceWithExactly(paypalHelper.chargeCustomer, { + expect(paypalHelper.chargeCustomer).toHaveBeenCalledTimes(1); + expect(paypalHelper.chargeCustomer).toHaveBeenCalledWith({ amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, @@ -1070,17 +1110,18 @@ describe('PayPalHelper', () => { paymentAttempts ), }); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.finalizeInvoice, + expect(mockStripeHelper.finalizeInvoice).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.finalizeInvoice).toHaveBeenCalledWith( validInvoice ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.updateInvoiceWithPaypalTransactionId, - validInvoice, - transactionId - ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.payInvoiceOutOfBand, + expect( + mockStripeHelper.updateInvoiceWithPaypalTransactionId + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.updateInvoiceWithPaypalTransactionId + ).toHaveBeenCalledWith(validInvoice, transactionId); + expect(mockStripeHelper.payInvoiceOutOfBand).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.payInvoiceOutOfBand).toHaveBeenCalledWith( validInvoice ); expect(response).toEqual([{ transactionId }, {}]); @@ -1092,7 +1133,7 @@ describe('PayPalHelper', () => { status: 'open', amount_due: 499, }; - paypalHelper.chargeCustomer = sinon.fake.resolves({ + paypalHelper.chargeCustomer = jest.fn().mockResolvedValue({ paymentStatus: 'Pending', transactionId, }); @@ -1101,15 +1142,18 @@ describe('PayPalHelper', () => { customer: mockCustomer, invoice: validInvoice, }); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( mockCustomer ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getPaymentAttempts, + expect(mockStripeHelper.getPaymentAttempts).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.getPaymentAttempts).toHaveBeenCalledWith( validInvoice ); - sinon.assert.calledOnceWithExactly(paypalHelper.chargeCustomer, { + expect(paypalHelper.chargeCustomer).toHaveBeenCalledTimes(1); + expect(paypalHelper.chargeCustomer).toHaveBeenCalledWith({ amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, @@ -1129,7 +1173,7 @@ describe('PayPalHelper', () => { status: 'open', amount_due: 499, }; - paypalHelper.chargeCustomer = sinon.fake.resolves({ + paypalHelper.chargeCustomer = jest.fn().mockResolvedValue({ paymentStatus: 'Denied', transactionId, }); @@ -1140,15 +1184,18 @@ describe('PayPalHelper', () => { invoice: validInvoice, }) ).rejects.toEqual(error.paymentFailed()); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( mockCustomer ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getPaymentAttempts, + expect(mockStripeHelper.getPaymentAttempts).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.getPaymentAttempts).toHaveBeenCalledWith( validInvoice ); - sinon.assert.calledOnceWithExactly(paypalHelper.chargeCustomer, { + expect(paypalHelper.chargeCustomer).toHaveBeenCalledTimes(1); + expect(paypalHelper.chargeCustomer).toHaveBeenCalledWith({ amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, @@ -1159,8 +1206,8 @@ describe('PayPalHelper', () => { paymentAttempts ), }); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.updatePaymentAttempts, + expect(mockStripeHelper.updatePaymentAttempts).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.updatePaymentAttempts).toHaveBeenCalledWith( validInvoice ); }); @@ -1172,8 +1219,8 @@ describe('PayPalHelper', () => { status: 'open', amount_due: 499, }; - paypalHelper.log = { error: sinon.fake.returns({}) }; - paypalHelper.chargeCustomer = sinon.fake.resolves({ + paypalHelper.log = { error: jest.fn().mockReturnValue({}) }; + paypalHelper.chargeCustomer = jest.fn().mockResolvedValue({ paymentStatus, transactionId, }); @@ -1189,15 +1236,18 @@ describe('PayPalHelper', () => { transactionResponse: paymentStatus, }) ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( mockCustomer ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getPaymentAttempts, + expect(mockStripeHelper.getPaymentAttempts).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.getPaymentAttempts).toHaveBeenCalledWith( validInvoice ); - sinon.assert.calledOnceWithExactly(paypalHelper.chargeCustomer, { + expect(paypalHelper.chargeCustomer).toHaveBeenCalledTimes(1); + expect(paypalHelper.chargeCustomer).toHaveBeenCalledWith({ amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, @@ -1211,8 +1261,9 @@ describe('PayPalHelper', () => { }); it('throws error for invoice without PayPal Billing Agreement ID', async () => { - mockStripeHelper.getCustomerPaypalAgreement = - sinon.fake.returns(undefined); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(undefined); await expect( paypalHelper.processInvoice({ @@ -1224,8 +1275,10 @@ describe('PayPalHelper', () => { message: 'Agreement ID not found.', }) ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( mockCustomer ); }); @@ -1246,8 +1299,10 @@ describe('PayPalHelper', () => { message: 'Invoice in invalid state.', }) ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( mockCustomer ); }); @@ -1269,7 +1324,7 @@ describe('PayPalHelper', () => { rawString, parsedNvpObject ); - paypalHelper.chargeCustomer = sinon.fake.rejects(throwErr); + paypalHelper.chargeCustomer = jest.fn().mockRejectedValue(throwErr); return throwErr; } @@ -1279,12 +1334,14 @@ describe('PayPalHelper', () => { status: 'open', amount_due: 499, }; - mockStripeHelper.getCustomerPaypalAgreement = - sinon.fake.returns(agreementId); - mockStripeHelper.getPaymentAttempts = - sinon.fake.returns(paymentAttempts); - mockStripeHelper.updatePaymentAttempts = sinon.fake.returns({}); - paypalHelper.log = { error: sinon.fake.returns({}) }; + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(agreementId); + mockStripeHelper.getPaymentAttempts = jest + .fn() + .mockReturnValue(paymentAttempts); + mockStripeHelper.updatePaymentAttempts = jest.fn().mockReturnValue({}); + paypalHelper.log = { error: jest.fn().mockReturnValue({}) }; }); it('payment failed error on invalid billing agreement', async () => { @@ -1297,10 +1354,12 @@ describe('PayPalHelper', () => { invoice: validInvoice, }) ).rejects.toEqual(failErr); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, - mockCustomer - ); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledWith(mockCustomer); }); it('backend service failure on paypal app error', async () => { @@ -1320,10 +1379,12 @@ describe('PayPalHelper', () => { invoice: validInvoice, }) ).rejects.toEqual(failErr); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, - mockCustomer - ); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledWith(mockCustomer); }); it('retry error on paypal retryable error', async () => { @@ -1334,10 +1395,12 @@ describe('PayPalHelper', () => { invoice: validInvoice, }) ).rejects.toEqual(error.serviceUnavailable()); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, - mockCustomer - ); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledWith(mockCustomer); }); it('backend error on no paypal error code', async () => { @@ -1357,10 +1420,12 @@ describe('PayPalHelper', () => { invoice: validInvoice, }) ).rejects.toEqual(failErr); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, - mockCustomer - ); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledWith(mockCustomer); }); it('internal validation error on unexpected paypal error code', async () => { @@ -1379,10 +1444,12 @@ describe('PayPalHelper', () => { invoice: validInvoice, }) ).rejects.toEqual(failErr); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, - mockCustomer - ); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledWith(mockCustomer); }); it('skips auth-server error on batchProcessing service failure on paypal app error', async () => { @@ -1394,10 +1461,12 @@ describe('PayPalHelper', () => { batchProcessing: true, }) ).rejects.toEqual(throwErr); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, - mockCustomer - ); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledWith(mockCustomer); }); }); }); diff --git a/packages/fxa-auth-server/lib/payments/paypal/processor.spec.ts b/packages/fxa-auth-server/lib/payments/paypal/processor.spec.ts index c9f7d6e5ad0..59ec4966e2e 100644 --- a/packages/fxa-auth-server/lib/payments/paypal/processor.spec.ts +++ b/packages/fxa-auth-server/lib/payments/paypal/processor.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { PayPalHelper } from './helper'; @@ -30,8 +29,6 @@ const unpaidInvoice = require('../../../test/local/payments/fixtures/stripe/invo const customer1 = require('../../../test/local/payments/fixtures/stripe/customer1.json'); const failedDoReferenceTransactionResponse = require('../../../test/local/payments/fixtures/paypal/do_reference_transaction_failure.json'); -const sandbox = sinon.createSandbox(); - function deepCopy(object: any) { return JSON.parse(JSON.stringify(object)); } @@ -74,7 +71,7 @@ describe('PaypalProcessor', () => { afterEach(() => { Container.reset(); - sandbox.reset(); + jest.clearAllMocks(); }); describe('constructor', () => { @@ -118,16 +115,16 @@ describe('PaypalProcessor', () => { describe('cancelInvoiceSubscription', () => { it('marks invoice and cancels subscription', async () => { - mockStripeHelper.markUncollectible = sandbox.fake.resolves({}); - mockStripeHelper.cancelSubscription = sandbox.fake.resolves({}); + mockStripeHelper.markUncollectible = jest.fn().mockResolvedValue({}); + mockStripeHelper.cancelSubscription = jest.fn().mockResolvedValue({}); const result = await processor.cancelInvoiceSubscription(paidInvoice); expect(result).toEqual([{}, {}]); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.markUncollectible, + expect(mockStripeHelper.markUncollectible).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.markUncollectible).toHaveBeenCalledWith( paidInvoice ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.cancelSubscription, + expect(mockStripeHelper.cancelSubscription).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.cancelSubscription).toHaveBeenCalledWith( paidInvoice.subscription.id ); }); @@ -135,26 +132,26 @@ describe('PaypalProcessor', () => { describe('ensureAccurateAttemptCount', () => { it('does nothing if the attempts match', async () => { - mockStripeHelper.getPaymentAttempts = sandbox.fake.returns(1); - mockStripeHelper.updatePaymentAttempts = sandbox.fake.resolves({}); + mockStripeHelper.getPaymentAttempts = jest.fn().mockReturnValue(1); + mockStripeHelper.updatePaymentAttempts = jest.fn().mockResolvedValue({}); await processor.ensureAccurateAttemptCount(unpaidInvoice, [{}]); - sinon.assert.notCalled(mockStripeHelper.updatePaymentAttempts); + expect(mockStripeHelper.updatePaymentAttempts).not.toHaveBeenCalled(); }); it('updates the attempts if they do not match', async () => { const invoice = deepCopy(unpaidInvoice); - mockStripeHelper.getPaymentAttempts = sandbox.fake.returns(2); - mockStripeHelper.updatePaymentAttempts = sandbox.fake.resolves({}); + mockStripeHelper.getPaymentAttempts = jest.fn().mockReturnValue(2); + mockStripeHelper.updatePaymentAttempts = jest.fn().mockResolvedValue({}); await processor.ensureAccurateAttemptCount(invoice, [{}]); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.updatePaymentAttempts, + expect(mockStripeHelper.updatePaymentAttempts).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.updatePaymentAttempts).toHaveBeenCalledWith( invoice, 1 ); - sandbox.reset(); + jest.clearAllMocks(); await processor.ensureAccurateAttemptCount(invoice, [{}, {}, {}]); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.updatePaymentAttempts, + expect(mockStripeHelper.updatePaymentAttempts).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.updatePaymentAttempts).toHaveBeenCalledWith( invoice, 3 ); @@ -172,37 +169,41 @@ describe('PaypalProcessor', () => { }); it('returns true if success', async () => { - mockStripeHelper.updateInvoiceWithPaypalTransactionId = - sandbox.fake.resolves({}); - mockStripeHelper.payInvoiceOutOfBand = sandbox.fake.resolves({}); + mockStripeHelper.updateInvoiceWithPaypalTransactionId = jest + .fn() + .mockResolvedValue({}); + mockStripeHelper.payInvoiceOutOfBand = jest.fn().mockResolvedValue({}); const result = await processor.handlePaidTransaction(unpaidInvoice, [ { status: 'Completed', transactionId: 'test1234' }, ]); expect(result).toBe(true); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.updateInvoiceWithPaypalTransactionId, - unpaidInvoice, - 'test1234' - ); + expect( + mockStripeHelper.updateInvoiceWithPaypalTransactionId + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.updateInvoiceWithPaypalTransactionId + ).toHaveBeenCalledWith(unpaidInvoice, 'test1234'); }); it('returns true and logs if > 1 success', async () => { - mockStripeHelper.updateInvoiceWithPaypalTransactionId = - sandbox.fake.resolves({}); - mockStripeHelper.payInvoiceOutOfBand = sandbox.fake.resolves({}); - mockLog.error = sandbox.fake.returns({}); + mockStripeHelper.updateInvoiceWithPaypalTransactionId = jest + .fn() + .mockResolvedValue({}); + mockStripeHelper.payInvoiceOutOfBand = jest.fn().mockResolvedValue({}); + mockLog.error = jest.fn().mockReturnValue({}); const result = await processor.handlePaidTransaction(unpaidInvoice, [ { status: 'Completed', transactionId: 'test1234' }, { status: 'Completed', transactionId: 'test12345' }, ]); expect(result).toBe(true); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.updateInvoiceWithPaypalTransactionId, - unpaidInvoice, - 'test1234' - ); - sinon.assert.calledOnceWithExactly( - mockLog.error, + expect( + mockStripeHelper.updateInvoiceWithPaypalTransactionId + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.updateInvoiceWithPaypalTransactionId + ).toHaveBeenCalledWith(unpaidInvoice, 'test1234'); + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith( 'multipleCompletedTransactions', { customer: unpaidInvoice.customer, @@ -216,58 +217,50 @@ describe('PaypalProcessor', () => { describe('handlePendingTransaction', () => { it('returns true if a pending within grace period exists', async () => { - processor.inGracePeriod = sandbox.fake.returns(true); + processor.inGracePeriod = jest.fn().mockReturnValue(true); const result = await processor.handlePendingTransaction(unpaidInvoice, [ { status: 'Pending' }, ]); expect(result).toBe(true); - sinon.assert.calledOnceWithExactly( - processor.inGracePeriod, - unpaidInvoice - ); + expect(processor.inGracePeriod).toHaveBeenCalledTimes(1); + expect(processor.inGracePeriod).toHaveBeenCalledWith(unpaidInvoice); }); it('returns true and logs if multiple pending within grace exist', async () => { - processor.inGracePeriod = sandbox.fake.returns(true); - mockLog.error = sandbox.fake.returns({}); + processor.inGracePeriod = jest.fn().mockReturnValue(true); + mockLog.error = jest.fn().mockReturnValue({}); const result = await processor.handlePendingTransaction(unpaidInvoice, [ { status: 'Pending' }, { status: 'Pending' }, ]); expect(result).toBe(true); - sinon.assert.calledOnceWithExactly( - processor.inGracePeriod, - unpaidInvoice - ); - sinon.assert.calledOnceWithExactly( - mockLog.error, + expect(processor.inGracePeriod).toHaveBeenCalledTimes(1); + expect(processor.inGracePeriod).toHaveBeenCalledWith(unpaidInvoice); + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith( 'multiplePendingTransactions', { customer: unpaidInvoice.customer, invoiceId: unpaidInvoice.id } ); }); it('returns false if no pending exist', async () => { - processor.inGracePeriod = sandbox.fake.returns(true); + processor.inGracePeriod = jest.fn().mockReturnValue(true); const result = await processor.handlePendingTransaction(unpaidInvoice, [ { status: 'Completed' }, ]); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly( - processor.inGracePeriod, - unpaidInvoice - ); + expect(processor.inGracePeriod).toHaveBeenCalledTimes(1); + expect(processor.inGracePeriod).toHaveBeenCalledWith(unpaidInvoice); }); it('returns false if no pending within grace period exist', async () => { - processor.inGracePeriod = sandbox.fake.returns(false); + processor.inGracePeriod = jest.fn().mockReturnValue(false); const result = await processor.handlePendingTransaction(unpaidInvoice, [ { status: 'Pending' }, ]); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly( - processor.inGracePeriod, - unpaidInvoice - ); + expect(processor.inGracePeriod).toHaveBeenCalledTimes(1); + expect(processor.inGracePeriod).toHaveBeenCalledWith(unpaidInvoice); }); }); @@ -275,22 +268,24 @@ describe('PaypalProcessor', () => { it('processes zero invoice if its 0', async () => { const invoice = deepCopy(unpaidInvoice); invoice.amount_due = 0; - mockPaypalHelper.processZeroInvoice = sandbox.fake.resolves({}); + mockPaypalHelper.processZeroInvoice = jest.fn().mockResolvedValue({}); const result = await processor.makePaymentAttempt(invoice); expect(result).toBe(true); - sinon.assert.calledOnceWithExactly( - mockPaypalHelper.processZeroInvoice, - invoice - ); + expect(mockPaypalHelper.processZeroInvoice).toHaveBeenCalledTimes(1); + expect(mockPaypalHelper.processZeroInvoice).toHaveBeenCalledWith(invoice); }); it('processes an invoice successfully', async () => { const invoice = deepCopy(unpaidInvoice); - mockPaypalHelper.processInvoice = sandbox.fake.resolves({}); - mockStripeHelper.getCustomerPaypalAgreement = sandbox.fake.resolves({}); + mockPaypalHelper.processInvoice = jest.fn().mockResolvedValue({}); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockResolvedValue({}); const result = await processor.makePaymentAttempt(invoice); expect(result).toBe(true); - sinon.assert.notCalled(mockStripeHelper.getCustomerPaypalAgreement); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).not.toHaveBeenCalled(); }); it('handles a paypal source error', async () => { @@ -311,25 +306,32 @@ describe('PaypalProcessor', () => { rawString, parsedNvpObject ); - mockPaypalHelper.processInvoice = sandbox.fake.rejects(throwErr); - mockStripeHelper.removeCustomerPaypalAgreement = sandbox.fake.resolves( - {} - ); - mockStripeHelper.getCustomerPaypalAgreement = - sandbox.fake.returns('testba'); - mockStripeHelper.getEmailTypes = sandbox.fake.returns([]); - mockHandler.sendSubscriptionPaymentFailedEmail = sandbox.fake.resolves( - {} - ); + mockPaypalHelper.processInvoice = jest.fn().mockRejectedValue(throwErr); + mockStripeHelper.removeCustomerPaypalAgreement = jest + .fn() + .mockResolvedValue({}); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue('testba'); + mockStripeHelper.getEmailTypes = jest.fn().mockReturnValue([]); + mockHandler.sendSubscriptionPaymentFailedEmail = jest + .fn() + .mockResolvedValue({}); const result = await processor.makePaymentAttempt(invoice); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly( - mockHandler.sendSubscriptionPaymentFailedEmail, - invoice - ); - sinon.assert.notCalled(mockStripeHelper.getCustomerPaypalAgreement); - sinon.assert.notCalled(mockStripeHelper.removeCustomerPaypalAgreement); + expect( + mockHandler.sendSubscriptionPaymentFailedEmail + ).toHaveBeenCalledTimes(1); + expect( + mockHandler.sendSubscriptionPaymentFailedEmail + ).toHaveBeenCalledWith(invoice); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).not.toHaveBeenCalled(); + expect( + mockStripeHelper.removeCustomerPaypalAgreement + ).not.toHaveBeenCalled(); }); it('handles an invalid billing agreement', async () => { @@ -350,33 +352,38 @@ describe('PaypalProcessor', () => { rawString, parsedNvpObject ); - mockPaypalHelper.processInvoice = sandbox.fake.rejects(throwErr); - mockStripeHelper.removeCustomerPaypalAgreement = sandbox.fake.resolves( - {} - ); - mockStripeHelper.getCustomerPaypalAgreement = - sandbox.fake.returns('testba'); - mockStripeHelper.getEmailTypes = sandbox.fake.returns([]); - mockHandler.sendSubscriptionPaymentFailedEmail = sandbox.fake.resolves( - {} - ); + mockPaypalHelper.processInvoice = jest.fn().mockRejectedValue(throwErr); + mockStripeHelper.removeCustomerPaypalAgreement = jest + .fn() + .mockResolvedValue({}); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue('testba'); + mockStripeHelper.getEmailTypes = jest.fn().mockReturnValue([]); + mockHandler.sendSubscriptionPaymentFailedEmail = jest + .fn() + .mockResolvedValue({}); const result = await processor.makePaymentAttempt(invoice); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, - testCustomer - ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.removeCustomerPaypalAgreement, - 'testuser', - testCustomer.id, - 'testba' + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 ); - sinon.assert.calledOnceWithExactly( - mockHandler.sendSubscriptionPaymentFailedEmail, - invoice + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( + testCustomer ); + expect( + mockStripeHelper.removeCustomerPaypalAgreement + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.removeCustomerPaypalAgreement + ).toHaveBeenCalledWith('testuser', testCustomer.id, 'testba'); + expect( + mockHandler.sendSubscriptionPaymentFailedEmail + ).toHaveBeenCalledTimes(1); + expect( + mockHandler.sendSubscriptionPaymentFailedEmail + ).toHaveBeenCalledWith(invoice); }); it('handles an unexpected error', async () => { @@ -385,22 +392,28 @@ describe('PaypalProcessor', () => { invoice.customer = testCustomer; const throwErr = new Error('test'); - mockLog.error = sandbox.fake.returns({}); - mockPaypalHelper.processInvoice = sandbox.fake.rejects(throwErr); - mockStripeHelper.removeCustomerPaypalAgreement = sandbox.fake.resolves( - {} - ); - mockStripeHelper.getCustomerPaypalAgreement = - sandbox.fake.returns('testba'); + mockLog.error = jest.fn().mockReturnValue({}); + mockPaypalHelper.processInvoice = jest.fn().mockRejectedValue(throwErr); + mockStripeHelper.removeCustomerPaypalAgreement = jest + .fn() + .mockResolvedValue({}); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue('testba'); const result = await processor.makePaymentAttempt(invoice); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly(mockLog.error, 'processInvoice', { + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith('processInvoice', { err: throwErr, invoiceId: invoice.id, }); - sinon.assert.notCalled(mockStripeHelper.getCustomerPaypalAgreement); - sinon.assert.notCalled(mockStripeHelper.removeCustomerPaypalAgreement); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).not.toHaveBeenCalled(); + expect( + mockStripeHelper.removeCustomerPaypalAgreement + ).not.toHaveBeenCalled(); }); }); @@ -428,204 +441,233 @@ describe('PaypalProcessor', () => { }); it('makes an attempt', async () => { - mockPaypalHelper.searchTransactions = sandbox.fake.resolves([]); - processor.ensureAccurateAttemptCount = sandbox.fake.resolves({}); - processor.handlePaidTransaction = sandbox.fake.resolves(false); - processor.handlePendingTransaction = sandbox.fake.resolves(false); - processor.inGracePeriod = sandbox.fake.returns(true); - mockStripeHelper.getCustomerPaypalAgreement = - sandbox.fake.returns('b-1234'); - processor.attemptsToday = sandbox.fake.returns(0); - processor.makePaymentAttempt = sandbox.fake.resolves({}); + mockPaypalHelper.searchTransactions = jest.fn().mockResolvedValue([]); + processor.ensureAccurateAttemptCount = jest.fn().mockResolvedValue({}); + processor.handlePaidTransaction = jest.fn().mockResolvedValue(false); + processor.handlePendingTransaction = jest.fn().mockResolvedValue(false); + processor.inGracePeriod = jest.fn().mockReturnValue(true); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue('b-1234'); + processor.attemptsToday = jest.fn().mockReturnValue(0); + processor.makePaymentAttempt = jest.fn().mockResolvedValue({}); const result = await processor.attemptInvoiceProcessing(invoice); expect(result).toBeUndefined(); - sinon.assert.callCount(mockPaypalHelper.searchTransactions, 1); + expect(mockPaypalHelper.searchTransactions).toHaveBeenCalledTimes(1); for (const spy of [ processor.ensureAccurateAttemptCount, processor.handlePaidTransaction, processor.handlePendingTransaction, ]) { - sinon.assert.calledOnceWithExactly(spy, invoice, []); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(invoice, []); } - sinon.assert.calledOnceWithExactly(processor.inGracePeriod, invoice); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, + expect(processor.inGracePeriod).toHaveBeenCalledTimes(1); + expect(processor.inGracePeriod).toHaveBeenCalledWith(invoice); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( invoice.customer ); - sinon.assert.calledOnceWithExactly(processor.attemptsToday, []); - sinon.assert.calledOnceWithExactly(processor.makePaymentAttempt, invoice); + expect(processor.attemptsToday).toHaveBeenCalledTimes(1); + expect(processor.attemptsToday).toHaveBeenCalledWith([]); + expect(processor.makePaymentAttempt).toHaveBeenCalledTimes(1); + expect(processor.makePaymentAttempt).toHaveBeenCalledWith(invoice); }); it('errors with no customer loaded', async () => { invoice.customer = 'cust_1232142'; - mockLog.error = sandbox.fake.returns({}); + mockLog.error = jest.fn().mockReturnValue({}); await expect(processor.attemptInvoiceProcessing(invoice)).rejects.toEqual( error.internalValidationError('customerNotLoad', { customer: 'cust_1232142', invoiceId: invoice.id, }) ); - sinon.assert.calledOnceWithExactly(mockLog.error, 'customerNotLoaded', { + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith('customerNotLoaded', { customer: 'cust_1232142', }); }); it('stops with a pending transaction', async () => { - mockPaypalHelper.searchTransactions = sandbox.fake.resolves([]); - processor.ensureAccurateAttemptCount = sandbox.fake.resolves({}); - processor.handlePaidTransaction = sandbox.fake.resolves(false); - processor.handlePendingTransaction = sandbox.fake.resolves(true); - processor.inGracePeriod = sandbox.fake.returns(true); + mockPaypalHelper.searchTransactions = jest.fn().mockResolvedValue([]); + processor.ensureAccurateAttemptCount = jest.fn().mockResolvedValue({}); + processor.handlePaidTransaction = jest.fn().mockResolvedValue(false); + processor.handlePendingTransaction = jest.fn().mockResolvedValue(true); + processor.inGracePeriod = jest.fn().mockReturnValue(true); const result = await processor.attemptInvoiceProcessing(invoice); expect(result).toBeUndefined(); - sinon.assert.callCount(mockPaypalHelper.searchTransactions, 1); + expect(mockPaypalHelper.searchTransactions).toHaveBeenCalledTimes(1); for (const spy of [ processor.ensureAccurateAttemptCount, processor.handlePaidTransaction, processor.handlePendingTransaction, ]) { - sinon.assert.calledOnceWithExactly(spy, invoice, []); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(invoice, []); } - sinon.assert.notCalled(processor.inGracePeriod); + expect(processor.inGracePeriod).not.toHaveBeenCalled(); }); it('stops with a completed transaction', async () => { - mockPaypalHelper.searchTransactions = sandbox.fake.resolves([]); - processor.ensureAccurateAttemptCount = sandbox.fake.resolves({}); - processor.handlePaidTransaction = sandbox.fake.resolves(true); - processor.handlePendingTransaction = sandbox.fake.resolves(false); + mockPaypalHelper.searchTransactions = jest.fn().mockResolvedValue([]); + processor.ensureAccurateAttemptCount = jest.fn().mockResolvedValue({}); + processor.handlePaidTransaction = jest.fn().mockResolvedValue(true); + processor.handlePendingTransaction = jest.fn().mockResolvedValue(false); const result = await processor.attemptInvoiceProcessing(invoice); expect(result).toBeUndefined(); - sinon.assert.callCount(mockPaypalHelper.searchTransactions, 1); + expect(mockPaypalHelper.searchTransactions).toHaveBeenCalledTimes(1); for (const spy of [ processor.ensureAccurateAttemptCount, processor.handlePaidTransaction, ]) { - sinon.assert.calledOnceWithExactly(spy, invoice, []); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(invoice, []); } - sinon.assert.notCalled(processor.handlePendingTransaction); + expect(processor.handlePendingTransaction).not.toHaveBeenCalled(); }); it('stops if no billing agreement', async () => { - mockPaypalHelper.searchTransactions = sandbox.fake.resolves([]); - processor.ensureAccurateAttemptCount = sandbox.fake.resolves({}); - processor.handlePaidTransaction = sandbox.fake.resolves(false); - processor.handlePendingTransaction = sandbox.fake.resolves(false); - processor.inGracePeriod = sandbox.fake.returns(true); - mockStripeHelper.getCustomerPaypalAgreement = - sandbox.fake.returns(undefined); - processor.attemptsToday = sandbox.fake.returns(0); - mockStripeHelper.getEmailTypes = sandbox.fake.returns(['paymentFailed']); - mockHandler.sendSubscriptionPaymentFailedEmail = sandbox.fake.resolves( - {} - ); + mockPaypalHelper.searchTransactions = jest.fn().mockResolvedValue([]); + processor.ensureAccurateAttemptCount = jest.fn().mockResolvedValue({}); + processor.handlePaidTransaction = jest.fn().mockResolvedValue(false); + processor.handlePendingTransaction = jest.fn().mockResolvedValue(false); + processor.inGracePeriod = jest.fn().mockReturnValue(true); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(undefined); + processor.attemptsToday = jest.fn().mockReturnValue(0); + mockStripeHelper.getEmailTypes = jest + .fn() + .mockReturnValue(['paymentFailed']); + mockHandler.sendSubscriptionPaymentFailedEmail = jest + .fn() + .mockResolvedValue({}); const result = await processor.attemptInvoiceProcessing(invoice); expect(result).toBeUndefined(); - sinon.assert.callCount(mockPaypalHelper.searchTransactions, 1); + expect(mockPaypalHelper.searchTransactions).toHaveBeenCalledTimes(1); for (const spy of [ processor.ensureAccurateAttemptCount, processor.handlePaidTransaction, processor.handlePendingTransaction, ]) { - sinon.assert.calledOnceWithExactly(spy, invoice, []); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(invoice, []); } - sinon.assert.calledOnceWithExactly(processor.inGracePeriod, invoice); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, + expect(processor.inGracePeriod).toHaveBeenCalledTimes(1); + expect(processor.inGracePeriod).toHaveBeenCalledWith(invoice); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( invoice.customer ); // We do not send an email since `getEmailTypes` is returning a list with // 'paymentFailed'. - sinon.assert.notCalled(mockHandler.sendSubscriptionPaymentFailedEmail); - sinon.assert.notCalled(processor.attemptsToday); + expect( + mockHandler.sendSubscriptionPaymentFailedEmail + ).not.toHaveBeenCalled(); + expect(processor.attemptsToday).not.toHaveBeenCalled(); }); it('voids invoices for deleted customers', async () => { - mockStripeHelper.markUncollectible = sandbox.fake.resolves({}); - mockLog.info = sandbox.fake.returns({}); + mockStripeHelper.markUncollectible = jest.fn().mockResolvedValue({}); + mockLog.info = jest.fn().mockReturnValue({}); customer.deleted = true; const result = await processor.attemptInvoiceProcessing(invoice); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly(mockLog.info, 'customerDeletedVoid', { + expect(mockLog.info).toHaveBeenCalledTimes(1); + expect(mockLog.info).toHaveBeenCalledWith('customerDeletedVoid', { customerId: customer.id, }); }); it('cancels if outside the grace period', async () => { - mockPaypalHelper.searchTransactions = sandbox.fake.resolves([]); - processor.ensureAccurateAttemptCount = sandbox.fake.resolves({}); - processor.handlePaidTransaction = sandbox.fake.resolves(false); - processor.handlePendingTransaction = sandbox.fake.resolves(false); - processor.inGracePeriod = sandbox.fake.returns(false); - mockStripeHelper.getCustomerPaypalAgreement = - sandbox.fake.returns('b-1234'); - processor.cancelInvoiceSubscription = sandbox.fake.resolves({}); + mockPaypalHelper.searchTransactions = jest.fn().mockResolvedValue([]); + processor.ensureAccurateAttemptCount = jest.fn().mockResolvedValue({}); + processor.handlePaidTransaction = jest.fn().mockResolvedValue(false); + processor.handlePendingTransaction = jest.fn().mockResolvedValue(false); + processor.inGracePeriod = jest.fn().mockReturnValue(false); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue('b-1234'); + processor.cancelInvoiceSubscription = jest.fn().mockResolvedValue({}); const result = await processor.attemptInvoiceProcessing(invoice); expect(result).toEqual({}); - sinon.assert.callCount(mockPaypalHelper.searchTransactions, 1); + expect(mockPaypalHelper.searchTransactions).toHaveBeenCalledTimes(1); for (const spy of [ processor.ensureAccurateAttemptCount, processor.handlePaidTransaction, processor.handlePendingTransaction, ]) { - sinon.assert.calledOnceWithExactly(spy, invoice, []); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(invoice, []); } - sinon.assert.calledOnceWithExactly(processor.inGracePeriod, invoice); - sinon.assert.notCalled(mockStripeHelper.getCustomerPaypalAgreement); - sinon.assert.calledOnceWithExactly( - processor.cancelInvoiceSubscription, - invoice - ); + expect(processor.inGracePeriod).toHaveBeenCalledTimes(1); + expect(processor.inGracePeriod).toHaveBeenCalledWith(invoice); + expect( + mockStripeHelper.getCustomerPaypalAgreement + ).not.toHaveBeenCalled(); + expect(processor.cancelInvoiceSubscription).toHaveBeenCalledTimes(1); + expect(processor.cancelInvoiceSubscription).toHaveBeenCalledWith(invoice); }); it('does not attempt payment after too many attempts', async () => { - mockPaypalHelper.searchTransactions = sandbox.fake.resolves([]); - processor.ensureAccurateAttemptCount = sandbox.fake.resolves({}); - processor.handlePaidTransaction = sandbox.fake.resolves(false); - processor.handlePendingTransaction = sandbox.fake.resolves(false); - processor.inGracePeriod = sandbox.fake.returns(true); - mockStripeHelper.getCustomerPaypalAgreement = - sandbox.fake.returns('b-1234'); - processor.attemptsToday = sandbox.fake.returns(20); - processor.makePaymentAttempt = sandbox.fake.resolves({}); + mockPaypalHelper.searchTransactions = jest.fn().mockResolvedValue([]); + processor.ensureAccurateAttemptCount = jest.fn().mockResolvedValue({}); + processor.handlePaidTransaction = jest.fn().mockResolvedValue(false); + processor.handlePendingTransaction = jest.fn().mockResolvedValue(false); + processor.inGracePeriod = jest.fn().mockReturnValue(true); + mockStripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue('b-1234'); + processor.attemptsToday = jest.fn().mockReturnValue(20); + processor.makePaymentAttempt = jest.fn().mockResolvedValue({}); const result = await processor.attemptInvoiceProcessing(invoice); expect(result).toBeUndefined(); - sinon.assert.callCount(mockPaypalHelper.searchTransactions, 1); + expect(mockPaypalHelper.searchTransactions).toHaveBeenCalledTimes(1); for (const spy of [ processor.ensureAccurateAttemptCount, processor.handlePaidTransaction, processor.handlePendingTransaction, ]) { - sinon.assert.calledOnceWithExactly(spy, invoice, []); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(invoice, []); } - sinon.assert.calledOnceWithExactly(processor.inGracePeriod, invoice); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.getCustomerPaypalAgreement, + expect(processor.inGracePeriod).toHaveBeenCalledTimes(1); + expect(processor.inGracePeriod).toHaveBeenCalledWith(invoice); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledWith( invoice.customer ); - sinon.assert.calledOnceWithExactly(processor.attemptsToday, []); - sinon.assert.notCalled(processor.makePaymentAttempt); + expect(processor.attemptsToday).toHaveBeenCalledTimes(1); + expect(processor.attemptsToday).toHaveBeenCalledWith([]); + expect(processor.makePaymentAttempt).not.toHaveBeenCalled(); }); }); describe('processInvoices', () => { it('processes an invoice', async () => { const invoice = deepCopy(unpaidInvoice); - mockLog.error = sandbox.fake.returns({}); - mockLog.info = sandbox.fake.returns({}); - processor.attemptInvoiceProcessing = sandbox.fake.resolves({}); - mockStripeHelper.fetchOpenInvoices = sandbox.fake.returns({ + mockLog.error = jest.fn().mockReturnValue({}); + mockLog.info = jest.fn().mockReturnValue({}); + processor.attemptInvoiceProcessing = jest.fn().mockResolvedValue({}); + mockStripeHelper.fetchOpenInvoices = jest.fn().mockReturnValue({ *[Symbol.asyncIterator]() { yield invoice; }, @@ -635,23 +677,22 @@ describe('PaypalProcessor', () => { // No value yield'd; yielding control for potential distributed lock // extension in actual use case } - sinon.assert.calledOnceWithExactly( - mockLog.info, - 'processInvoice.processing', - { - invoiceId: invoice.id, - } - ); - sinon.assert.notCalled(mockLog.error); + expect(mockLog.info).toHaveBeenCalledTimes(1); + expect(mockLog.info).toHaveBeenCalledWith('processInvoice.processing', { + invoiceId: invoice.id, + }); + expect(mockLog.error).not.toHaveBeenCalled(); }); it('logs an error on invoice exception', async () => { const invoice = deepCopy(unpaidInvoice); - mockLog.error = sandbox.fake.returns({}); - mockLog.info = sandbox.fake.returns({}); + mockLog.error = jest.fn().mockReturnValue({}); + mockLog.info = jest.fn().mockReturnValue({}); const throwErr = new Error('Test'); - processor.attemptInvoiceProcessing = sandbox.fake.rejects(throwErr); - mockStripeHelper.fetchOpenInvoices = sandbox.fake.returns({ + processor.attemptInvoiceProcessing = jest + .fn() + .mockRejectedValue(throwErr); + mockStripeHelper.fetchOpenInvoices = jest.fn().mockReturnValue({ *[Symbol.asyncIterator]() { yield invoice; }, @@ -662,14 +703,12 @@ describe('PaypalProcessor', () => { for await (const _ of processor.processInvoices()) { // No value yield'd } - sinon.assert.calledOnceWithExactly( - mockLog.info, - 'processInvoice.processing', - { - invoiceId: invoice.id, - } - ); - sinon.assert.calledOnceWithExactly(mockLog.error, 'processInvoice', { + expect(mockLog.info).toHaveBeenCalledTimes(1); + expect(mockLog.info).toHaveBeenCalledWith('processInvoice.processing', { + invoiceId: invoice.id, + }); + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith('processInvoice', { err: throwErr, nvpData: undefined, invoiceId: invoice.id, @@ -679,25 +718,27 @@ describe('PaypalProcessor', () => { describe('sendFailedPaymentEmail', () => { it('sends an email when paymentFailed is not in the list of sent emails', async () => { - mockStripeHelper.getEmailTypes = sandbox.fake.returns([]); - mockHandler.sendSubscriptionPaymentFailedEmail = sandbox.fake.resolves( - {} - ); + mockStripeHelper.getEmailTypes = jest.fn().mockReturnValue([]); + mockHandler.sendSubscriptionPaymentFailedEmail = jest + .fn() + .mockResolvedValue({}); await processor.sendFailedPaymentEmail(unpaidInvoice); - sinon.assert.calledOnce(mockHandler.sendSubscriptionPaymentFailedEmail); + expect( + mockHandler.sendSubscriptionPaymentFailedEmail + ).toHaveBeenCalledTimes(1); }); it('does not send an email when paymentFailed is in the list of sent emails', async () => { - mockStripeHelper.getEmailTypes = sandbox.fake.returns([ - 'a', - 'b', - 'paymentFailed', - ]); - mockHandler.sendSubscriptionPaymentFailedEmail = sandbox.fake.resolves( - {} - ); + mockStripeHelper.getEmailTypes = jest + .fn() + .mockReturnValue(['a', 'b', 'paymentFailed']); + mockHandler.sendSubscriptionPaymentFailedEmail = jest + .fn() + .mockResolvedValue({}); await processor.sendFailedPaymentEmail(unpaidInvoice); - sinon.assert.notCalled(mockHandler.sendSubscriptionPaymentFailedEmail); + expect( + mockHandler.sendSubscriptionPaymentFailedEmail + ).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/fxa-auth-server/lib/payments/stripe-firestore.spec.ts b/packages/fxa-auth-server/lib/payments/stripe-firestore.spec.ts index 1be2a0970a6..db7f01848fd 100644 --- a/packages/fxa-auth-server/lib/payments/stripe-firestore.spec.ts +++ b/packages/fxa-auth-server/lib/payments/stripe-firestore.spec.ts @@ -1,7 +1,6 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { StripeFirestore, @@ -64,62 +63,78 @@ describe('StripeFirestore', () => { describe('retrieveAndFetchCustomer', () => { it('fetches a customer that was already retrieved', async () => { - stripeFirestore.retrieveCustomer = sinon.fake.resolves(customer); - stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({}); + stripeFirestore.retrieveCustomer = jest.fn().mockResolvedValue(customer); + stripeFirestore.legacyFetchAndInsertCustomer = jest + .fn() + .mockResolvedValue({}); const result = await stripeFirestore.retrieveAndFetchCustomer( customer.id ); expect(result).toEqual(customer); - sinon.assert.calledOnce(stripeFirestore.retrieveCustomer); - sinon.assert.notCalled(stripeFirestore.legacyFetchAndInsertCustomer); + expect(stripeFirestore.retrieveCustomer).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.legacyFetchAndInsertCustomer + ).not.toHaveBeenCalled(); }); it('fetches a customer that hasnt been retrieved', async () => { - stripeFirestore.retrieveCustomer = sinon.fake.rejects( - newFirestoreStripeError( - 'Not found', - FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND - ) - ); - stripeFirestore.legacyFetchAndInsertCustomer = - sinon.fake.resolves(customer); + stripeFirestore.retrieveCustomer = jest + .fn() + .mockRejectedValue( + newFirestoreStripeError( + 'Not found', + FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND + ) + ); + stripeFirestore.legacyFetchAndInsertCustomer = jest + .fn() + .mockResolvedValue(customer); const result = await stripeFirestore.retrieveAndFetchCustomer( customer.id ); expect(result).toEqual(customer); - sinon.assert.calledOnce(stripeFirestore.retrieveCustomer); - sinon.assert.calledOnce(stripeFirestore.legacyFetchAndInsertCustomer); + expect(stripeFirestore.retrieveCustomer).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.legacyFetchAndInsertCustomer + ).toHaveBeenCalledTimes(1); }); it('passes ignoreErrors through to legacyFetchAndInsertCustomer', async () => { - stripeFirestore.retrieveCustomer = sinon.fake.rejects( - newFirestoreStripeError( - 'Not found', - FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND - ) - ); - stripeFirestore.legacyFetchAndInsertCustomer = - sinon.fake.resolves(customer); + stripeFirestore.retrieveCustomer = jest + .fn() + .mockRejectedValue( + newFirestoreStripeError( + 'Not found', + FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND + ) + ); + stripeFirestore.legacyFetchAndInsertCustomer = jest + .fn() + .mockResolvedValue(customer); const result = await stripeFirestore.retrieveAndFetchCustomer( customer.id, true ); expect(result).toEqual(customer); - sinon.assert.calledOnce(stripeFirestore.retrieveCustomer); - sinon.assert.calledOnceWithExactly( - stripeFirestore.legacyFetchAndInsertCustomer, + expect(stripeFirestore.retrieveCustomer).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.legacyFetchAndInsertCustomer + ).toHaveBeenCalledTimes(1); + expect(stripeFirestore.legacyFetchAndInsertCustomer).toHaveBeenCalledWith( customer.id, true ); }); it('errors otherwise', async () => { - stripeFirestore.retrieveCustomer = sinon.fake.rejects( - newFirestoreStripeError( - 'Not found', - FirestoreStripeError.STRIPE_CUSTOMER_DELETED - ) - ); + stripeFirestore.retrieveCustomer = jest + .fn() + .mockRejectedValue( + newFirestoreStripeError( + 'Not found', + FirestoreStripeError.STRIPE_CUSTOMER_DELETED + ) + ); try { await stripeFirestore.retrieveAndFetchCustomer(customer.id); throw new Error('should have thrown'); @@ -137,74 +152,94 @@ describe('StripeFirestore', () => { }); it('fetches a subscription that was already retrieved', async () => { - stripeFirestore.retrieveSubscription = sinon.fake.resolves(subscription); - stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({}); + stripeFirestore.retrieveSubscription = jest + .fn() + .mockResolvedValue(subscription); + stripeFirestore.legacyFetchAndInsertCustomer = jest + .fn() + .mockResolvedValue({}); const result = await stripeFirestore.retrieveAndFetchSubscription( subscription.id ); expect(result).toEqual(subscription); - sinon.assert.calledOnce(stripeFirestore.retrieveSubscription); - sinon.assert.notCalled(stripeFirestore.legacyFetchAndInsertCustomer); + expect(stripeFirestore.retrieveSubscription).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.legacyFetchAndInsertCustomer + ).not.toHaveBeenCalled(); }); it('fetches a subscription that hasnt been retrieved', async () => { - stripeFirestore.retrieveSubscription = sinon.fake.rejects( - newFirestoreStripeError( - 'Not found', - FirestoreStripeError.FIRESTORE_SUBSCRIPTION_NOT_FOUND - ) - ); + stripeFirestore.retrieveSubscription = jest + .fn() + .mockRejectedValue( + newFirestoreStripeError( + 'Not found', + FirestoreStripeError.FIRESTORE_SUBSCRIPTION_NOT_FOUND + ) + ); stripe.subscriptions = { - retrieve: sinon.fake.resolves(subscription), + retrieve: jest.fn().mockResolvedValue(subscription), }; - stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({}); + stripeFirestore.legacyFetchAndInsertCustomer = jest + .fn() + .mockResolvedValue({}); const result = await stripeFirestore.retrieveAndFetchSubscription( subscription.id ); expect(result).toEqual(subscription); - sinon.assert.calledOnce(stripeFirestore.retrieveSubscription); - sinon.assert.calledOnce(stripeFirestore.legacyFetchAndInsertCustomer); - sinon.assert.calledOnceWithExactly( - stripe.subscriptions.retrieve, + expect(stripeFirestore.retrieveSubscription).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.legacyFetchAndInsertCustomer + ).toHaveBeenCalledTimes(1); + expect(stripe.subscriptions.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith( subscription.id ); }); it('passes ignoreErrors through to legacyFetchAndInsertCustomer', async () => { - stripeFirestore.retrieveSubscription = sinon.fake.rejects( - newFirestoreStripeError( - 'Not found', - FirestoreStripeError.FIRESTORE_SUBSCRIPTION_NOT_FOUND - ) - ); + stripeFirestore.retrieveSubscription = jest + .fn() + .mockRejectedValue( + newFirestoreStripeError( + 'Not found', + FirestoreStripeError.FIRESTORE_SUBSCRIPTION_NOT_FOUND + ) + ); stripe.subscriptions = { - retrieve: sinon.fake.resolves(subscription), + retrieve: jest.fn().mockResolvedValue(subscription), }; - stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({}); + stripeFirestore.legacyFetchAndInsertCustomer = jest + .fn() + .mockResolvedValue({}); const result = await stripeFirestore.retrieveAndFetchSubscription( subscription.id, true ); expect(result).toEqual(subscription); - sinon.assert.calledOnce(stripeFirestore.retrieveSubscription); - sinon.assert.calledOnceWithExactly( - stripeFirestore.legacyFetchAndInsertCustomer, + expect(stripeFirestore.retrieveSubscription).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.legacyFetchAndInsertCustomer + ).toHaveBeenCalledTimes(1); + expect(stripeFirestore.legacyFetchAndInsertCustomer).toHaveBeenCalledWith( subscription.customer, true ); - sinon.assert.calledOnceWithExactly( - stripe.subscriptions.retrieve, + expect(stripe.subscriptions.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith( subscription.id ); }); it('errors otherwise', async () => { - stripeFirestore.retrieveSubscription = sinon.fake.rejects( - newFirestoreStripeError( - 'Not found', - FirestoreStripeError.STRIPE_CUSTOMER_DELETED - ) - ); + stripeFirestore.retrieveSubscription = jest + .fn() + .mockRejectedValue( + newFirestoreStripeError( + 'Not found', + FirestoreStripeError.STRIPE_CUSTOMER_DELETED + ) + ); try { await stripeFirestore.retrieveAndFetchSubscription(subscription.id); throw new Error('should have thrown'); @@ -219,16 +254,18 @@ describe('StripeFirestore', () => { beforeEach(() => { tx = { - get: sinon.stub().resolves({}), - set: sinon.stub(), + get: jest.fn().mockResolvedValue({}), + set: jest.fn(), }; - firestore.runTransaction = sinon.stub().callsFake((fn: any) => fn(tx)); + firestore.runTransaction = jest + .fn() + .mockImplementation((fn: any) => fn(tx)); stripeFirestore.customerCollectionDbRef = { - doc: sinon.stub().callsFake((uid: any) => ({ - collection: sinon.stub().callsFake(() => ({ - doc: sinon.stub().callsFake((id: any) => ({ + doc: jest.fn().mockImplementation((uid: any) => ({ + collection: jest.fn().mockImplementation(() => ({ + doc: jest.fn().mockImplementation((id: any) => ({ id, })), })), @@ -238,7 +275,7 @@ describe('StripeFirestore', () => { it('fetches and inserts the subscription', async () => { stripe.subscriptions = { - retrieve: sinon.stub().resolves(subscription1), + retrieve: jest.fn().mockResolvedValue(subscription1), }; const result = await stripeFirestore.fetchAndInsertSubscription( @@ -247,12 +284,12 @@ describe('StripeFirestore', () => { ); expect(result).toEqual(subscription1); - sinon.assert.calledOnceWithExactly( - stripe.subscriptions.retrieve, + expect(stripe.subscriptions.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith( subscription1.id ); - sinon.assert.callCount(tx.get, 1); - sinon.assert.callCount(tx.set, 1); + expect(tx.get).toHaveBeenCalledTimes(1); + expect(tx.set).toHaveBeenCalledTimes(1); }); }); @@ -261,22 +298,24 @@ describe('StripeFirestore', () => { beforeEach(() => { stripe.subscriptions = { - list: sinon.stub().returns({ - autoPagingToArray: sinon.stub().resolves([subscription1]), + list: jest.fn().mockReturnValue({ + autoPagingToArray: jest.fn().mockResolvedValue([subscription1]), }), }; tx = { - get: sinon.stub().resolves({}), - set: sinon.stub(), + get: jest.fn().mockResolvedValue({}), + set: jest.fn(), }; - firestore.runTransaction = sinon.stub().callsFake((fn: any) => fn(tx)); + firestore.runTransaction = jest + .fn() + .mockImplementation((fn: any) => fn(tx)); stripeFirestore.customerCollectionDbRef = { - doc: sinon.stub().callsFake((uid: any) => ({ - collection: sinon.stub().callsFake(() => ({ - doc: sinon.stub().callsFake((id: any) => ({ + doc: jest.fn().mockImplementation((uid: any) => ({ + collection: jest.fn().mockImplementation(() => ({ + doc: jest.fn().mockImplementation((id: any) => ({ id, })), })), @@ -286,15 +325,13 @@ describe('StripeFirestore', () => { it('fetches and returns a customer', async () => { stripe.customers = { - retrieve: sinon - .stub() - .onFirstCall() - .resolves({ + retrieve: jest + .fn() + .mockResolvedValueOnce({ ...customer, subscriptions: { data: [subscription1] }, }) - .onSecondCall() - .resolves(customer), + .mockResolvedValueOnce(customer), }; const result = await stripeFirestore.legacyFetchAndInsertCustomer( @@ -302,28 +339,27 @@ describe('StripeFirestore', () => { ); expect(result).toEqual(customer); - sinon.assert.calledTwice(stripe.customers.retrieve); - sinon.assert.calledOnceWithExactly(stripe.subscriptions.list, { + expect(stripe.customers.retrieve).toHaveBeenCalledTimes(2); + expect(stripe.subscriptions.list).toHaveBeenCalledTimes(1); + expect(stripe.subscriptions.list).toHaveBeenCalledWith({ customer: customer.id, status: 'all', limit: 100, }); - sinon.assert.callCount(tx.set, 2); // customer + subscription - sinon.assert.callCount(tx.get, 2); // customer + subscription + expect(tx.set).toHaveBeenCalledTimes(2); // customer + subscription + expect(tx.get).toHaveBeenCalledTimes(2); // customer + subscription }); it('errors on customer deleted', async () => { const deletedCustomer = { ...customer, deleted: true }; stripe.customers = { - retrieve: sinon - .stub() - .onFirstCall() - .resolves({ + retrieve: jest + .fn() + .mockResolvedValueOnce({ ...deletedCustomer, subscriptions: { data: [] }, }) - .onSecondCall() - .resolves(deletedCustomer), + .mockResolvedValueOnce(deletedCustomer), }; try { @@ -337,7 +373,7 @@ describe('StripeFirestore', () => { it('allows customer deleted when ignoreErrors is true', async () => { const deletedCustomer = { ...customer, deleted: true }; stripe.customers = { - retrieve: sinon.stub().resolves(deletedCustomer), + retrieve: jest.fn().mockResolvedValue(deletedCustomer), }; const result = await stripeFirestore.legacyFetchAndInsertCustomer( @@ -346,19 +382,16 @@ describe('StripeFirestore', () => { ); expect(result).toEqual(deletedCustomer); - sinon.assert.calledOnceWithExactly( - stripe.customers.retrieve, - customer.id, - { - expand: ['subscriptions'], - } - ); + expect(stripe.customers.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.customers.retrieve).toHaveBeenCalledWith(customer.id, { + expand: ['subscriptions'], + }); }); it('allows customer with no uid when ignoreErrors is true', async () => { const noMetadataCustomer = { ...customer, metadata: {} }; stripe.customers = { - retrieve: sinon.stub().resolves(noMetadataCustomer), + retrieve: jest.fn().mockResolvedValue(noMetadataCustomer), }; const result = await stripeFirestore.legacyFetchAndInsertCustomer( @@ -367,27 +400,22 @@ describe('StripeFirestore', () => { ); expect(result).toEqual(noMetadataCustomer); - sinon.assert.calledOnceWithExactly( - stripe.customers.retrieve, - customer.id, - { - expand: ['subscriptions'], - } - ); + expect(stripe.customers.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.customers.retrieve).toHaveBeenCalledWith(customer.id, { + expand: ['subscriptions'], + }); }); it('errors on missing uid', async () => { const missingUidCustomer = { ...customer, metadata: {} }; stripe.customers = { - retrieve: sinon - .stub() - .onFirstCall() - .resolves({ + retrieve: jest + .fn() + .mockResolvedValueOnce({ ...missingUidCustomer, subscriptions: { data: [] }, }) - .onSecondCall() - .resolves(missingUidCustomer), + .mockResolvedValueOnce(missingUidCustomer), }; try { @@ -401,31 +429,40 @@ describe('StripeFirestore', () => { describe('insertCustomerRecordWithBackfill', () => { it('retrieves a record', async () => { - stripeFirestore.retrieveCustomer = sinon.fake.resolves(customer); - stripeFirestore.legacyFetchAndInsertCustomer = - sinon.fake.resolves(customer); + stripeFirestore.retrieveCustomer = jest.fn().mockResolvedValue(customer); + stripeFirestore.legacyFetchAndInsertCustomer = jest + .fn() + .mockResolvedValue(customer); await stripeFirestore.insertCustomerRecordWithBackfill( 'fxauid', customer ); - sinon.assert.calledOnce(stripeFirestore.retrieveCustomer); - sinon.assert.notCalled(stripeFirestore.legacyFetchAndInsertCustomer); + expect(stripeFirestore.retrieveCustomer).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.legacyFetchAndInsertCustomer + ).not.toHaveBeenCalled(); }); it('backfills on customer not found', async () => { - stripeFirestore.retrieveCustomer = sinon.fake.rejects( - newFirestoreStripeError( - 'no customer', - FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND - ) - ); - stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({}); + stripeFirestore.retrieveCustomer = jest + .fn() + .mockRejectedValue( + newFirestoreStripeError( + 'no customer', + FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND + ) + ); + stripeFirestore.legacyFetchAndInsertCustomer = jest + .fn() + .mockResolvedValue({}); await stripeFirestore.insertCustomerRecordWithBackfill( 'fxauid', customer ); - sinon.assert.calledOnce(stripeFirestore.retrieveCustomer); - sinon.assert.calledOnce(stripeFirestore.legacyFetchAndInsertCustomer); + expect(stripeFirestore.retrieveCustomer).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.legacyFetchAndInsertCustomer + ).toHaveBeenCalledTimes(1); }); }); @@ -436,27 +473,29 @@ describe('StripeFirestore', () => { docs: [ { ref: { - collection: sinon.fake.returns({ - doc: sinon.fake.returns({ set: sinon.fake.resolves({}) }), + collection: jest.fn().mockReturnValue({ + doc: jest + .fn() + .mockReturnValue({ set: jest.fn().mockResolvedValue({}) }), }), }, }, ], }; - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves(customerSnap), + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(customerSnap), }); const result = await stripeFirestore.insertSubscriptionRecord( deepCopy(subscription1) ); expect(result).toEqual({}); - sinon.assert.calledOnce(customerCollectionDbRef.where); - sinon.assert.calledOnce(customerSnap.docs[0].ref.collection); + expect(customerCollectionDbRef.where).toHaveBeenCalledTimes(1); + expect(customerSnap.docs[0].ref.collection).toHaveBeenCalledTimes(1); }); it('errors on customer not found', async () => { - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves({ empty: true }), + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }); try { await stripeFirestore.insertSubscriptionRecord(deepCopy(subscription1)); @@ -465,35 +504,43 @@ describe('StripeFirestore', () => { expect(err.name).toBe( FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND ); - sinon.assert.calledOnce(customerCollectionDbRef.where); + expect(customerCollectionDbRef.where).toHaveBeenCalledTimes(1); } }); }); describe('insertSubscriptionRecordWithBackfill', () => { it('inserts a record', async () => { - stripeFirestore.insertSubscriptionRecord = sinon.fake.resolves({}); + stripeFirestore.insertSubscriptionRecord = jest + .fn() + .mockResolvedValue({}); const result = await stripeFirestore.insertSubscriptionRecordWithBackfill( deepCopy(subscription1) ); expect(result).toBeUndefined(); - sinon.assert.calledOnce(stripeFirestore.insertSubscriptionRecord); + expect(stripeFirestore.insertSubscriptionRecord).toHaveBeenCalledTimes(1); }); it('backfills on customer not found', async () => { - stripeFirestore.insertSubscriptionRecord = sinon.fake.rejects( - newFirestoreStripeError( - 'no customer', - FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND - ) - ); - stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({}); + stripeFirestore.insertSubscriptionRecord = jest + .fn() + .mockRejectedValue( + newFirestoreStripeError( + 'no customer', + FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND + ) + ); + stripeFirestore.legacyFetchAndInsertCustomer = jest + .fn() + .mockResolvedValue({}); const result = await stripeFirestore.insertSubscriptionRecordWithBackfill( deepCopy(subscription1) ); expect(result).toBeUndefined(); - sinon.assert.calledOnce(stripeFirestore.insertSubscriptionRecord); - sinon.assert.calledOnce(stripeFirestore.legacyFetchAndInsertCustomer); + expect(stripeFirestore.insertSubscriptionRecord).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.legacyFetchAndInsertCustomer + ).toHaveBeenCalledTimes(1); }); }); @@ -511,11 +558,13 @@ describe('StripeFirestore', () => { { ref: { // subscriptions call - collection: sinon.fake.returns({ - doc: sinon.fake.returns({ + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ // invoice call - collection: sinon.fake.returns({ - doc: sinon.fake.returns({ set: sinon.fake.resolves({}) }), + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + set: jest.fn().mockResolvedValue({}), + }), }), }), }), @@ -523,18 +572,18 @@ describe('StripeFirestore', () => { }, ], }; - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves(customerSnap), + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(customerSnap), }); const result = await stripeFirestore.insertInvoiceRecord(invoice); expect(result).toEqual({}); - sinon.assert.calledOnce(customerCollectionDbRef.where); - sinon.assert.calledOnce(customerSnap.docs[0].ref.collection); + expect(customerCollectionDbRef.where).toHaveBeenCalledTimes(1); + expect(customerSnap.docs[0].ref.collection).toHaveBeenCalledTimes(1); }); it('errors on customer not found', async () => { - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves({ empty: true }), + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }); try { await stripeFirestore.insertInvoiceRecord(invoice); @@ -543,13 +592,13 @@ describe('StripeFirestore', () => { expect(err.name).toBe( FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND ); - sinon.assert.calledOnce(customerCollectionDbRef.where); + expect(customerCollectionDbRef.where).toHaveBeenCalledTimes(1); } }); it('ignores customer not found when ignoreErrors is true', async () => { - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves({ empty: true }), + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }); const result = await stripeFirestore.insertInvoiceRecord(invoice, true); expect(result).toEqual(invoice); @@ -570,23 +619,25 @@ describe('StripeFirestore', () => { beforeEach(() => { tx = { - get: sinon.stub().resolves({}), - set: sinon.stub(), + get: jest.fn().mockResolvedValue({}), + set: jest.fn(), }; - firestore.runTransaction = sinon.stub().callsFake((fn: any) => fn(tx)); + firestore.runTransaction = jest + .fn() + .mockImplementation((fn: any) => fn(tx)); stripe.invoices = { - retrieve: sinon.stub(), + retrieve: jest.fn(), }; stripeFirestore.customerCollectionDbRef = { - where: sinon.stub(), - doc: sinon.stub().callsFake((uid: any) => ({ - collection: sinon.stub().callsFake(() => ({ - doc: sinon.stub().callsFake(() => ({ - collection: sinon.stub().callsFake(() => ({ - doc: sinon.stub().callsFake(() => ({})), + where: jest.fn(), + doc: jest.fn().mockImplementation((uid: any) => ({ + collection: jest.fn().mockImplementation(() => ({ + doc: jest.fn().mockImplementation(() => ({ + collection: jest.fn().mockImplementation(() => ({ + doc: jest.fn().mockImplementation(() => ({})), })), })), })), @@ -595,7 +646,7 @@ describe('StripeFirestore', () => { }); it('fetches and inserts an invoice for an existing customer and subscription', async () => { - stripe.invoices.retrieve.resolves(mockInvoice); + stripe.invoices.retrieve.mockResolvedValue(mockInvoice); const customerSnap = { empty: false, @@ -605,30 +656,29 @@ describe('StripeFirestore', () => { }, ], }; - stripeFirestore.customerCollectionDbRef.where.returns({ - get: sinon.stub().resolves(customerSnap), + stripeFirestore.customerCollectionDbRef.where.mockReturnValue({ + get: jest.fn().mockResolvedValue(customerSnap), }); - tx.get.resolves({ + tx.get.mockResolvedValue({ data: () => mockInvoice, }); await stripeFirestore.fetchAndInsertInvoice(invoiceId, eventTime); - sinon.assert.calledOnce(stripe.invoices.retrieve); - sinon.assert.calledWithExactly( - stripe.invoices.retrieve.firstCall, - invoiceId - ); - sinon.assert.calledOnce(stripeFirestore.customerCollectionDbRef.where); - sinon.assert.callCount(tx.get, 1); - sinon.assert.callCount(tx.set, 1); + expect(stripe.invoices.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.invoices.retrieve).toHaveBeenCalledWith(invoiceId); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(1); + expect(tx.get).toHaveBeenCalledTimes(1); + expect(tx.set).toHaveBeenCalledTimes(1); }); it('errors on customer not found', async () => { - stripe.invoices.retrieve.resolves(mockInvoice); + stripe.invoices.retrieve.mockResolvedValue(mockInvoice); - stripeFirestore.customerCollectionDbRef.where.returns({ - get: sinon.stub().resolves({ empty: true }), + stripeFirestore.customerCollectionDbRef.where.mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }); try { @@ -638,18 +688,20 @@ describe('StripeFirestore', () => { expect(err.name).toBe( FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND ); - sinon.assert.calledOnce(stripe.invoices.retrieve); - sinon.assert.calledOnce(stripeFirestore.customerCollectionDbRef.where); - expect(tx.get.callCount).toBe(0); - expect(tx.set.callCount).toBe(0); + expect(stripe.invoices.retrieve).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(1); + expect(tx.get).toHaveBeenCalledTimes(0); + expect(tx.set).toHaveBeenCalledTimes(0); } }); it('ignores customer not found when ignoreErrors is true', async () => { - stripe.invoices.retrieve.resolves(mockInvoice); + stripe.invoices.retrieve.mockResolvedValue(mockInvoice); - stripeFirestore.customerCollectionDbRef.where.returns({ - get: sinon.stub().resolves({ empty: true }), + stripeFirestore.customerCollectionDbRef.where.mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }); const result = await stripeFirestore.fetchAndInsertInvoice( @@ -659,10 +711,13 @@ describe('StripeFirestore', () => { ); expect(result).toEqual(mockInvoice); - sinon.assert.calledOnceWithExactly(stripe.invoices.retrieve, invoiceId); - sinon.assert.calledOnce(stripeFirestore.customerCollectionDbRef.where); - expect(tx.get.callCount).toBe(0); - expect(tx.set.callCount).toBe(0); + expect(stripe.invoices.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.invoices.retrieve).toHaveBeenCalledWith(invoiceId); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(1); + expect(tx.get).toHaveBeenCalledTimes(0); + expect(tx.set).toHaveBeenCalledTimes(0); }); it('returns invoice as-is when it has no subscription', async () => { @@ -670,7 +725,9 @@ describe('StripeFirestore', () => { ...mockInvoice, subscription: null, }; - stripe.invoices.retrieve.resolves(mockInvoiceWithoutSubscription); + stripe.invoices.retrieve.mockResolvedValue( + mockInvoiceWithoutSubscription + ); const result = await stripeFirestore.fetchAndInsertInvoice( invoiceId, @@ -678,14 +735,17 @@ describe('StripeFirestore', () => { ); expect(result).toEqual(mockInvoiceWithoutSubscription); - sinon.assert.calledOnceWithExactly(stripe.invoices.retrieve, invoiceId); - expect(stripeFirestore.customerCollectionDbRef.where.callCount).toBe(0); - expect(tx.get.callCount).toBe(0); - expect(tx.set.callCount).toBe(0); + expect(stripe.invoices.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.invoices.retrieve).toHaveBeenCalledWith(invoiceId); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(0); + expect(tx.get).toHaveBeenCalledTimes(0); + expect(tx.set).toHaveBeenCalledTimes(0); }); it('errors on missing uid', async () => { - stripe.invoices.retrieve.resolves(mockInvoice); + stripe.invoices.retrieve.mockResolvedValue(mockInvoice); const customerSnap = { empty: false, @@ -695,8 +755,8 @@ describe('StripeFirestore', () => { }, ], }; - stripeFirestore.customerCollectionDbRef.where.returns({ - get: sinon.stub().resolves(customerSnap), + stripeFirestore.customerCollectionDbRef.where.mockReturnValue({ + get: jest.fn().mockResolvedValue(customerSnap), }); try { @@ -704,15 +764,17 @@ describe('StripeFirestore', () => { throw new Error('should have thrown'); } catch (err: any) { expect(err.name).toBe(FirestoreStripeError.STRIPE_CUSTOMER_MISSING_UID); - sinon.assert.calledOnce(stripe.invoices.retrieve); - sinon.assert.calledOnce(stripeFirestore.customerCollectionDbRef.where); - expect(tx.get.callCount).toBe(0); - expect(tx.set.callCount).toBe(0); + expect(stripe.invoices.retrieve).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(1); + expect(tx.get).toHaveBeenCalledTimes(0); + expect(tx.set).toHaveBeenCalledTimes(0); } }); it('allows missing uid when ignoreErrors is true', async () => { - stripe.invoices.retrieve.resolves(mockInvoice); + stripe.invoices.retrieve.mockResolvedValue(mockInvoice); const customerSnap = { empty: false, @@ -722,8 +784,8 @@ describe('StripeFirestore', () => { }, ], }; - stripeFirestore.customerCollectionDbRef.where.returns({ - get: sinon.stub().resolves(customerSnap), + stripeFirestore.customerCollectionDbRef.where.mockReturnValue({ + get: jest.fn().mockResolvedValue(customerSnap), }); const result = await stripeFirestore.fetchAndInsertInvoice( @@ -733,10 +795,13 @@ describe('StripeFirestore', () => { ); expect(result).toEqual(mockInvoice); - sinon.assert.calledOnceWithExactly(stripe.invoices.retrieve, invoiceId); - sinon.assert.calledOnce(stripeFirestore.customerCollectionDbRef.where); - expect(tx.get.callCount).toBe(0); - expect(tx.set.callCount).toBe(0); + expect(stripe.invoices.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.invoices.retrieve).toHaveBeenCalledWith(invoiceId); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(1); + expect(tx.get).toHaveBeenCalledTimes(0); + expect(tx.set).toHaveBeenCalledTimes(0); }); }); @@ -747,27 +812,29 @@ describe('StripeFirestore', () => { docs: [ { ref: { - collection: sinon.fake.returns({ - doc: sinon.fake.returns({ set: sinon.fake.resolves({}) }), + collection: jest.fn().mockReturnValue({ + doc: jest + .fn() + .mockReturnValue({ set: jest.fn().mockResolvedValue({}) }), }), }, }, ], }; - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves(customerSnap), + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(customerSnap), }); const result = await stripeFirestore.insertPaymentMethodRecord( deepCopy(paymentMethod) ); expect(result).toEqual({}); - sinon.assert.calledOnce(customerCollectionDbRef.where); - sinon.assert.calledOnce(customerSnap.docs[0].ref.collection); + expect(customerCollectionDbRef.where).toHaveBeenCalledTimes(1); + expect(customerSnap.docs[0].ref.collection).toHaveBeenCalledTimes(1); }); it('ignores customer not found when ignoreErrors is true', async () => { - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves({ empty: true }), + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }); const result = await stripeFirestore.insertPaymentMethodRecord( deepCopy(paymentMethod), @@ -777,8 +844,8 @@ describe('StripeFirestore', () => { }); it('errors on customer not found', async () => { - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves({ empty: true }), + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }); try { await stripeFirestore.insertPaymentMethodRecord(paymentMethod); @@ -787,7 +854,7 @@ describe('StripeFirestore', () => { expect(err.name).toBe( FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND ); - sinon.assert.calledOnce(customerCollectionDbRef.where); + expect(customerCollectionDbRef.where).toHaveBeenCalledTimes(1); } }); }); @@ -812,28 +879,30 @@ describe('StripeFirestore', () => { beforeEach(() => { tx = { - get: sinon.stub().resolves({}), - set: sinon.stub(), + get: jest.fn().mockResolvedValue({}), + set: jest.fn(), }; - firestore.runTransaction = sinon.stub().callsFake((fn: any) => fn(tx)); + firestore.runTransaction = jest + .fn() + .mockImplementation((fn: any) => fn(tx)); stripe.paymentMethods = { - retrieve: sinon.stub(), + retrieve: jest.fn(), }; stripeFirestore.customerCollectionDbRef = { - where: sinon.stub(), - doc: sinon.stub().callsFake((uid: any) => ({ - collection: sinon.stub().callsFake(() => ({ - doc: sinon.stub().callsFake(() => ({})), + where: jest.fn(), + doc: jest.fn().mockImplementation((uid: any) => ({ + collection: jest.fn().mockImplementation(() => ({ + doc: jest.fn().mockImplementation(() => ({})), })), })), }; }); it('fetches and inserts an attached payment method when customer exists and has uid', async () => { - stripe.paymentMethods.retrieve.resolves(mockPaymentMethod); + stripe.paymentMethods.retrieve.mockResolvedValue(mockPaymentMethod); const customerSnap = { empty: false, @@ -843,10 +912,10 @@ describe('StripeFirestore', () => { }, ], }; - stripeFirestore.customerCollectionDbRef.where.returns({ - get: sinon.stub().resolves(customerSnap), + stripeFirestore.customerCollectionDbRef.where.mockReturnValue({ + get: jest.fn().mockResolvedValue(customerSnap), }); - tx.get.resolves({ + tx.get.mockResolvedValue({ data: () => mockPaymentMethod, }); @@ -856,14 +925,15 @@ describe('StripeFirestore', () => { ); expect(result).toEqual(mockPaymentMethod); - sinon.assert.calledOnce(stripe.paymentMethods.retrieve); - sinon.assert.calledWithExactly( - stripe.paymentMethods.retrieve.firstCall, + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledWith( paymentMethodId ); - sinon.assert.calledOnce(stripeFirestore.customerCollectionDbRef.where); - sinon.assert.callCount(tx.get, 1); - sinon.assert.callCount(tx.set, 1); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(1); + expect(tx.get).toHaveBeenCalledTimes(1); + expect(tx.set).toHaveBeenCalledTimes(1); }); it('returns payment method when it is not attached to a customer', async () => { @@ -871,7 +941,9 @@ describe('StripeFirestore', () => { ...mockPaymentMethod, customer: null, }; - stripe.paymentMethods.retrieve.resolves(mockPaymentMethodWithoutCustomer); + stripe.paymentMethods.retrieve.mockResolvedValue( + mockPaymentMethodWithoutCustomer + ); const result = await stripeFirestore.fetchAndInsertPaymentMethod( paymentMethodId, @@ -879,20 +951,22 @@ describe('StripeFirestore', () => { ); expect(result).toEqual(mockPaymentMethodWithoutCustomer); - sinon.assert.calledOnceWithExactly( - stripe.paymentMethods.retrieve, + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledWith( paymentMethodId ); - expect(stripeFirestore.customerCollectionDbRef.where.callCount).toBe(0); - expect(tx.get.callCount).toBe(0); - expect(tx.set.callCount).toBe(0); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(0); + expect(tx.get).toHaveBeenCalledTimes(0); + expect(tx.set).toHaveBeenCalledTimes(0); }); it('errors on customer not found', async () => { - stripe.paymentMethods.retrieve.resolves(mockPaymentMethod); + stripe.paymentMethods.retrieve.mockResolvedValue(mockPaymentMethod); - stripeFirestore.customerCollectionDbRef.where.returns({ - get: sinon.stub().resolves({ empty: true }), + stripeFirestore.customerCollectionDbRef.where.mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }); try { @@ -905,21 +979,23 @@ describe('StripeFirestore', () => { expect(err.name).toBe( FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND ); - sinon.assert.calledOnceWithExactly( - stripe.paymentMethods.retrieve, + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledWith( paymentMethodId ); - sinon.assert.calledOnce(stripeFirestore.customerCollectionDbRef.where); - expect(tx.get.callCount).toBe(0); - expect(tx.set.callCount).toBe(0); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(1); + expect(tx.get).toHaveBeenCalledTimes(0); + expect(tx.set).toHaveBeenCalledTimes(0); } }); it('ignores customer not found when ignoreErrors is true', async () => { - stripe.paymentMethods.retrieve.resolves(mockPaymentMethod); + stripe.paymentMethods.retrieve.mockResolvedValue(mockPaymentMethod); - stripeFirestore.customerCollectionDbRef.where.returns({ - get: sinon.stub().resolves({ empty: true }), + stripeFirestore.customerCollectionDbRef.where.mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }); const result = await stripeFirestore.fetchAndInsertPaymentMethod( @@ -929,17 +1005,19 @@ describe('StripeFirestore', () => { ); expect(result).toEqual(mockPaymentMethod); - sinon.assert.calledOnceWithExactly( - stripe.paymentMethods.retrieve, + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledWith( paymentMethodId ); - sinon.assert.calledOnce(stripeFirestore.customerCollectionDbRef.where); - expect(tx.get.callCount).toBe(0); - expect(tx.set.callCount).toBe(0); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(1); + expect(tx.get).toHaveBeenCalledTimes(0); + expect(tx.set).toHaveBeenCalledTimes(0); }); it('errors on missing uid', async () => { - stripe.paymentMethods.retrieve.resolves(mockPaymentMethod); + stripe.paymentMethods.retrieve.mockResolvedValue(mockPaymentMethod); const customerSnap = { empty: false, @@ -949,8 +1027,8 @@ describe('StripeFirestore', () => { }, ], }; - stripeFirestore.customerCollectionDbRef.where.returns({ - get: sinon.stub().resolves(customerSnap), + stripeFirestore.customerCollectionDbRef.where.mockReturnValue({ + get: jest.fn().mockResolvedValue(customerSnap), }); try { @@ -961,18 +1039,20 @@ describe('StripeFirestore', () => { throw new Error('should have thrown'); } catch (err: any) { expect(err.name).toBe(FirestoreStripeError.STRIPE_CUSTOMER_MISSING_UID); - sinon.assert.calledOnceWithExactly( - stripe.paymentMethods.retrieve, + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledWith( paymentMethodId ); - sinon.assert.calledOnce(stripeFirestore.customerCollectionDbRef.where); - expect(tx.get.callCount).toBe(0); - expect(tx.set.callCount).toBe(0); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(1); + expect(tx.get).toHaveBeenCalledTimes(0); + expect(tx.set).toHaveBeenCalledTimes(0); } }); it('allows missing uid when ignoreErrors is true', async () => { - stripe.paymentMethods.retrieve.resolves(mockPaymentMethod); + stripe.paymentMethods.retrieve.mockResolvedValue(mockPaymentMethod); const customerSnap = { empty: false, @@ -982,8 +1062,8 @@ describe('StripeFirestore', () => { }, ], }; - stripeFirestore.customerCollectionDbRef.where.returns({ - get: sinon.stub().resolves(customerSnap), + stripeFirestore.customerCollectionDbRef.where.mockReturnValue({ + get: jest.fn().mockResolvedValue(customerSnap), }); const result = await stripeFirestore.fetchAndInsertPaymentMethod( @@ -993,47 +1073,62 @@ describe('StripeFirestore', () => { ); expect(result).toEqual(mockPaymentMethod); - sinon.assert.calledOnceWithExactly( - stripe.paymentMethods.retrieve, + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledTimes(1); + expect(stripe.paymentMethods.retrieve).toHaveBeenCalledWith( paymentMethodId ); - sinon.assert.calledOnce(stripeFirestore.customerCollectionDbRef.where); - expect(tx.get.callCount).toBe(0); - expect(tx.set.callCount).toBe(0); + expect( + stripeFirestore.customerCollectionDbRef.where + ).toHaveBeenCalledTimes(1); + expect(tx.get).toHaveBeenCalledTimes(0); + expect(tx.set).toHaveBeenCalledTimes(0); }); }); describe('insertPaymentMethodRecordWithBackfill', () => { it('inserts a record', async () => { - stripeFirestore.insertPaymentMethodRecord = sinon.fake.resolves({}); - stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({}); + stripeFirestore.insertPaymentMethodRecord = jest + .fn() + .mockResolvedValue({}); + stripeFirestore.legacyFetchAndInsertCustomer = jest + .fn() + .mockResolvedValue({}); const result = await stripeFirestore.insertPaymentMethodRecordWithBackfill( deepCopy(paymentMethod) ); expect(result).toBeUndefined(); - sinon.assert.calledOnce(stripeFirestore.insertPaymentMethodRecord); - sinon.assert.notCalled(stripeFirestore.legacyFetchAndInsertCustomer); + expect(stripeFirestore.insertPaymentMethodRecord).toHaveBeenCalledTimes( + 1 + ); + expect( + stripeFirestore.legacyFetchAndInsertCustomer + ).not.toHaveBeenCalled(); }); it('backfills on customer not found', async () => { - const insertStub = sinon.stub(); + const insertStub = jest.fn(); stripeFirestore.insertPaymentMethodRecord = insertStub; insertStub - .onCall(0) - .rejects( + .mockRejectedValueOnce( newFirestoreStripeError( 'no customer', FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND ) - ); - insertStub.onCall(1).resolves({}); - stripeFirestore.legacyFetchAndInsertCustomer = sinon.fake.resolves({}); + ) + .mockResolvedValueOnce({}); + stripeFirestore.legacyFetchAndInsertCustomer = jest + .fn() + .mockResolvedValue({}); await stripeFirestore.insertPaymentMethodRecordWithBackfill( deepCopy(paymentMethod) ); - sinon.assert.calledTwice(stripeFirestore.insertPaymentMethodRecord); - sinon.assert.calledOnce(stripeFirestore.legacyFetchAndInsertCustomer); + expect(stripeFirestore.insertPaymentMethodRecord).toHaveBeenCalledTimes( + 2 + ); + expect( + stripeFirestore.legacyFetchAndInsertCustomer + ).toHaveBeenCalledTimes(1); }); }); @@ -1044,26 +1139,26 @@ describe('StripeFirestore', () => { docs: [ { ref: { - delete: sinon.fake.resolves({}), + delete: jest.fn().mockResolvedValue({}), }, }, ], }; - firestore.collectionGroup = sinon.fake.returns({ - where: sinon.fake.returns({ - get: sinon.fake.resolves(paymentMethodSnap), + firestore.collectionGroup = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(paymentMethodSnap), }), }); await stripeFirestore.removePaymentMethodRecord(deepCopy(paymentMethod)); - sinon.assert.calledOnce(firestore.collectionGroup); - sinon.assert.calledOnce(paymentMethodSnap.docs[0].ref.delete); + expect(firestore.collectionGroup).toHaveBeenCalledTimes(1); + expect(paymentMethodSnap.docs[0].ref.delete).toHaveBeenCalledTimes(1); }); }); describe('retrieveCustomer', () => { it('fetches a customer by uid', async () => { - customerCollectionDbRef.doc = sinon.fake.returns({ - get: sinon.fake.resolves({ + customerCollectionDbRef.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: true, data: () => customer, }), @@ -1075,12 +1170,12 @@ describe('StripeFirestore', () => { }); it('fetches a customer by customerId', async () => { - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves({ + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: false, docs: [ { - data: sinon.fake.returns(customer), + data: jest.fn().mockReturnValue(customer), }, ], }), @@ -1092,8 +1187,8 @@ describe('StripeFirestore', () => { }); it('errors when customer is not found', async () => { - customerCollectionDbRef.doc = sinon.fake.returns({ - get: sinon.fake.resolves({ + customerCollectionDbRef.doc = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: false, }), }); @@ -1116,14 +1211,14 @@ describe('StripeFirestore', () => { const subscriptionSnap = { docs: [{ data: () => ({ ...customer.subscriptions.data[0] }) }], }; - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves({ + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: false, docs: [ { ref: { - collection: sinon.fake.returns({ - get: sinon.fake.resolves(subscriptionSnap), + collection: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(subscriptionSnap), }), }, }, @@ -1160,14 +1255,14 @@ describe('StripeFirestore', () => { const subscriptionSnap = { docs: [{ data: () => sub1 }, { data: () => sub2 }], }; - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves({ + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: false, docs: [ { ref: { - collection: sinon.fake.returns({ - get: sinon.fake.resolves(subscriptionSnap), + collection: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(subscriptionSnap), }), }, }, @@ -1181,8 +1276,8 @@ describe('StripeFirestore', () => { }); it('errors on customer not found', async () => { - customerCollectionDbRef.where = sinon.fake.returns({ - get: sinon.fake.resolves({ + customerCollectionDbRef.where = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true, }), }); @@ -1207,9 +1302,9 @@ describe('StripeFirestore', () => { }, ], }; - firestore.collectionGroup = sinon.fake.returns({ - where: sinon.fake.returns({ - get: sinon.fake.resolves(subscriptionSnap), + firestore.collectionGroup = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(subscriptionSnap), }), }); const result = await stripeFirestore.retrieveSubscription( @@ -1219,9 +1314,9 @@ describe('StripeFirestore', () => { }); it('errors on subscription not found', async () => { - firestore.collectionGroup = sinon.fake.returns({ - where: sinon.fake.returns({ - get: sinon.fake.resolves({ empty: true }), + firestore.collectionGroup = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }), }); try { @@ -1251,9 +1346,9 @@ describe('StripeFirestore', () => { }, ], }; - firestore.collectionGroup = sinon.fake.returns({ - where: sinon.fake.returns({ - get: sinon.fake.resolves(invoiceSnap), + firestore.collectionGroup = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(invoiceSnap), }), }); const result = await stripeFirestore.retrieveInvoice(invoice.id); @@ -1261,9 +1356,9 @@ describe('StripeFirestore', () => { }); it('errors on invoice not found', async () => { - firestore.collectionGroup = sinon.fake.returns({ - where: sinon.fake.returns({ - get: sinon.fake.resolves({ empty: true }), + firestore.collectionGroup = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }), }); try { @@ -1285,9 +1380,9 @@ describe('StripeFirestore', () => { }, ], }; - firestore.collectionGroup = sinon.fake.returns({ - where: sinon.fake.returns({ - get: sinon.fake.resolves(paymentMethodSnap), + firestore.collectionGroup = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue(paymentMethodSnap), }), }); const result = await stripeFirestore.retrievePaymentMethod( @@ -1297,9 +1392,9 @@ describe('StripeFirestore', () => { }); it('errors on payment method not found', async () => { - firestore.collectionGroup = sinon.fake.returns({ - where: sinon.fake.returns({ - get: sinon.fake.resolves({ empty: true }), + firestore.collectionGroup = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ empty: true }), }), }); try { @@ -1316,8 +1411,8 @@ describe('StripeFirestore', () => { describe('removeCustomerRecursive', () => { beforeEach(() => { const bulkWriterMock = new BulkWriterMock(); - firestore.bulkWriter = sinon.fake.returns(bulkWriterMock); - customerCollectionDbRef.doc = sinon.fake.returns({ + firestore.bulkWriter = jest.fn().mockReturnValue(bulkWriterMock); + customerCollectionDbRef.doc = jest.fn().mockReturnValue({ path: '/test/path', }); }); diff --git a/packages/fxa-auth-server/lib/payments/stripe.spec.ts b/packages/fxa-auth-server/lib/payments/stripe.spec.ts index cc998cd456c..6e5b2a55c96 100644 --- a/packages/fxa-auth-server/lib/payments/stripe.spec.ts +++ b/packages/fxa-auth-server/lib/payments/stripe.spec.ts @@ -4,7 +4,6 @@ /* eslint-disable no-undef */ -const sinon = require('sinon'); const Sentry = require('@sentry/node'); const Chance = require('chance'); const { Container } = require('typedi'); @@ -90,6 +89,16 @@ jest.mock('fxa-shared/db/models/auth', () => { // Get a reference to the mocked db module so tests can configure stubs const dbStub = require('fxa-shared/db/models/auth'); +// Save the original mock implementations so they can be restored after +// jest.restoreAllMocks() (which wipes jest.fn() implementations when +// a jest.spyOn has been applied to the same property). +const _dbMockImpls = { + createAccountCustomer: dbStub.createAccountCustomer.getMockImplementation(), + getAccountCustomerByUid: + dbStub.getAccountCustomerByUid.getMockImplementation(), + deleteAccountCustomer: dbStub.deleteAccountCustomer.getMockImplementation(), +}; + // Alias for mockRedis - set in beforeEach, used throughout tests let mockRedis: any; @@ -315,7 +324,7 @@ function createMockRedis(): any { return _data[key]; }, }; - Object.keys(mock).forEach((key) => sinon.spy(mock, key)); + Object.keys(mock).forEach((key) => jest.spyOn(mock, key)); mock.options = {}; return mock; } @@ -335,7 +344,6 @@ const mockConfigCollection = (configDocs: any) => ({ describe('StripeHelper', () => { let stripeHelper: any; - let sandbox: sinon.SinonSandbox; let listStripePlans: any; let log: any; let existingCustomer: any; @@ -354,19 +362,18 @@ describe('StripeHelper', () => { }); beforeEach(() => { - sandbox = sinon.createSandbox(); mockRedis = createMockRedis(); (globalThis as any).__testMockRedis = mockRedis; log = mockLog(); mockStatsd = { - increment: sandbox.fake.returns({}), - timing: sandbox.fake.returns({}), - close: sandbox.fake.returns({}), + increment: jest.fn().mockReturnValue({}), + timing: jest.fn().mockReturnValue({}), + close: jest.fn().mockReturnValue({}), }; const currencyHelper = new CurrencyHelper(mockConfig); Container.set(CurrencyHelper, currencyHelper); Container.set(AuthFirestore, { - collection: sandbox.stub().callsFake((arg: any) => { + collection: jest.fn().mockImplementation((arg: any) => { if (arg.endsWith('products')) { return mockConfigCollection([ { id: 'doc1', stripeProductId: product1.id }, @@ -394,27 +401,41 @@ describe('StripeHelper', () => { Container.set(AuthLogger, log); Container.set(AppConfig, mockConfig); mockGoogleMapsService = { - getStateFromZip: sandbox.stub().resolves('ABD'), + getStateFromZip: jest.fn().mockResolvedValue('ABD'), }; Container.set(GoogleMapsService, mockGoogleMapsService); stripeHelper = new StripeHelper(log, mockConfig, mockStatsd); stripeHelper.redis = mockRedis; - stripeHelper.stripeFirestore = stripeFirestore = {}; - listStripePlans = sandbox - .stub(stripeHelper.stripe.plans, 'list') - .returns(asyncIterable([plan1, plan2, plan3])); - sandbox - .stub(stripeHelper.stripe.taxRates, 'list') - .returns(asyncIterable([taxRateDe, taxRateFr])); - sandbox - .stub(stripeHelper.stripe.products, 'list') - .returns(asyncIterable([product1, product2, product3])); + stripeHelper.stripeFirestore = stripeFirestore = { + retrievePaymentMethod: jest.fn().mockResolvedValue({}), + retrieveInvoice: jest.fn().mockResolvedValue({}), + }; + listStripePlans = jest + .spyOn(stripeHelper.stripe.plans, 'list') + .mockReturnValue(asyncIterable([plan1, plan2, plan3])); + jest + .spyOn(stripeHelper.stripe.taxRates, 'list') + .mockReturnValue(asyncIterable([taxRateDe, taxRateFr])); + jest + .spyOn(stripeHelper.stripe.products, 'list') + .mockReturnValue(asyncIterable([product1, product2, product3])); }); afterEach(() => { Container.reset(); - sandbox.restore(); + jest.restoreAllMocks(); + // Re-establish db mock implementations that jest.restoreAllMocks() wipes + // when a jest.spyOn was applied to a jest.fn() property. + dbStub.createAccountCustomer.mockImplementation( + _dbMockImpls.createAccountCustomer + ); + dbStub.getAccountCustomerByUid.mockImplementation( + _dbMockImpls.getAccountCustomerByUid + ); + dbStub.deleteAccountCustomer.mockImplementation( + _dbMockImpls.deleteAccountCustomer + ); }); describe('constructor', () => { @@ -427,8 +448,10 @@ describe('StripeHelper', () => { describe('createPlainCustomer', () => { it('creates a customer using stripe api', async () => { const expected = deepCopy(newCustomerPM); - sandbox.stub(stripeHelper.stripe.customers, 'create').resolves(expected); - stripeFirestore.insertCustomerRecord = sandbox.stub().resolves({}); + jest + .spyOn(stripeHelper.stripe.customers, 'create') + .mockResolvedValue(expected); + stripeFirestore.insertCustomerRecord = jest.fn().mockResolvedValue({}); const uid = chance.guid({ version: 4 }).replace(/-/g, ''); const actual = await stripeHelper.createPlainCustomer({ uid, @@ -437,17 +460,17 @@ describe('StripeHelper', () => { idempotencyKey: uuidv4(), }); expect(actual).toEqual(expected); - sinon.assert.calledWithExactly( - stripeHelper.stripeFirestore.insertCustomerRecord, - uid, - expected - ); + expect( + stripeHelper.stripeFirestore.insertCustomerRecord + ).toHaveBeenCalledWith(uid, expected); }); it('creates a customer using the stripe api with a shipping address', async () => { const expected = deepCopy(newCustomerPM); - sandbox.stub(stripeHelper.stripe.customers, 'create').resolves(expected); - stripeFirestore.insertCustomerRecord = sandbox.stub().resolves({}); + jest + .spyOn(stripeHelper.stripe.customers, 'create') + .mockResolvedValue(expected); + stripeFirestore.insertCustomerRecord = jest.fn().mockResolvedValue({}); const uid = chance.guid({ version: 4 }).replace(/-/g, ''); const idempotencyKey = uuidv4(); const actual = await stripeHelper.createPlainCustomer({ @@ -461,18 +484,18 @@ describe('StripeHelper', () => { }, }); expect(actual).toEqual(expected); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.customers.create, + expect(stripeHelper.stripe.customers.create).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.customers.create).toHaveBeenCalledWith( { email: 'joe@example.com', name: 'Joe Cool', description: uid, metadata: { userid: uid, - geoip_date: sinon.match.any, + geoip_date: expect.anything(), }, shipping: { - name: sinon.match.any, + name: expect.anything(), address: { country: 'US', postal_code: '92841', @@ -481,16 +504,16 @@ describe('StripeHelper', () => { }, { idempotencyKey } ); - sinon.assert.calledWithExactly( - stripeHelper.stripeFirestore.insertCustomerRecord, - uid, - expected - ); + expect( + stripeHelper.stripeFirestore.insertCustomerRecord + ).toHaveBeenCalledWith(uid, expected); }); it('surfaces stripe errors', async () => { const apiError = new stripeError.StripeAPIError(); - sandbox.stub(stripeHelper.stripe.customers, 'create').rejects(apiError); + jest + .spyOn(stripeHelper.stripe.customers, 'create') + .mockRejectedValue(apiError); return stripeHelper .createPlainCustomer({ @@ -535,9 +558,9 @@ describe('StripeHelper', () => { describe('createSetupIntent', () => { it('creates a setup intent', async () => { const expected = deepCopy(newSetupIntent); - sandbox - .stub(stripeHelper.stripe.setupIntents, 'create') - .resolves(expected); + jest + .spyOn(stripeHelper.stripe.setupIntents, 'create') + .mockResolvedValue(expected); const actual = await stripeHelper.createSetupIntent('cust_new'); @@ -547,9 +570,9 @@ describe('StripeHelper', () => { it('surfaces stripe errors', async () => { const apiError = new stripeError.StripeAPIError(); - sandbox - .stub(stripeHelper.stripe.setupIntents, 'create') - .rejects(apiError); + jest + .spyOn(stripeHelper.stripe.setupIntents, 'create') + .mockRejectedValue(apiError); return stripeHelper.createSetupIntent('cust_new').then( () => Promise.reject(new Error('Method expected to reject')), @@ -563,25 +586,30 @@ describe('StripeHelper', () => { describe('updateDefaultPaymentMethod', () => { it('updates the default payment method', async () => { const expected = deepCopy(newCustomerPM); - sandbox.stub(stripeHelper.stripe.customers, 'update').resolves(expected); - stripeFirestore.insertCustomerRecordWithBackfill = sandbox - .stub() - .resolves({}); + jest + .spyOn(stripeHelper.stripe.customers, 'update') + .mockResolvedValue(expected); + stripeFirestore.insertCustomerRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); const actual = await stripeHelper.updateDefaultPaymentMethod( 'cust_new', 'pm_1H0FRp2eZvKYlo2CeIZoc0wj' ); expect(actual).toEqual(expected); - sinon.assert.calledOnceWithExactly( - stripeFirestore.insertCustomerRecordWithBackfill, - expected.metadata.userid, - expected - ); + expect( + stripeFirestore.insertCustomerRecordWithBackfill + ).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.insertCustomerRecordWithBackfill + ).toHaveBeenCalledWith(expected.metadata.userid, expected); }); it('surfaces stripe errors', async () => { const apiError = new stripeError.StripeAPIError(); - sandbox.stub(stripeHelper.stripe.customers, 'update').rejects(apiError); + jest + .spyOn(stripeHelper.stripe.customers, 'update') + .mockRejectedValue(apiError); return stripeHelper .updateDefaultPaymentMethod('cust_new', 'pm_1H0FRp2eZvKYlo2CeIZoc0wj') @@ -597,10 +625,10 @@ describe('StripeHelper', () => { describe('getPaymentMethod', () => { it('calls the Stripe api', async () => { const paymentMethodId = 'pm_9001'; - sandbox.stub(stripeHelper, 'expandResource'); + jest.spyOn(stripeHelper, 'expandResource'); await stripeHelper.getPaymentMethod(paymentMethodId); - sinon.assert.calledOnceWithExactly( - stripeHelper.expandResource, + expect(stripeHelper.expandResource).toHaveBeenCalledTimes(1); + expect(stripeHelper.expandResource).toHaveBeenCalledWith( paymentMethodId, PAYMENT_METHOD_RESOURCE ); @@ -649,14 +677,16 @@ describe('StripeHelper', () => { stripeHelper.stripe = { invoices: { - retrieve: sinon.stub().resolves({ payment_intent: 'pi_mock' }), + retrieve: jest + .fn() + .mockResolvedValue({ payment_intent: 'pi_mock' }), }, paymentIntents: { - retrieve: sinon.stub().resolves({ payment_method: null }), + retrieve: jest.fn().mockResolvedValue({ payment_method: null }), }, }; - sandbox.stub(stripeHelper, 'getPaymentMethod').resolves(null); + jest.spyOn(stripeHelper, 'getPaymentMethod').mockResolvedValue(null); const result = await stripeHelper.getPaymentProvider(customerExpanded); @@ -669,15 +699,19 @@ describe('StripeHelper', () => { stripeHelper.stripe = { paymentIntents: { - retrieve: sinon.stub().resolves({ payment_method: 'pm_mock' }), + retrieve: jest + .fn() + .mockResolvedValue({ payment_method: 'pm_mock' }), }, invoices: { - retrieve: sinon.stub().resolves({ payment_intent: 'pi_mock' }), + retrieve: jest + .fn() + .mockResolvedValue({ payment_intent: 'pi_mock' }), }, }; - sandbox - .stub(stripeHelper, 'getPaymentMethod') - .resolves({ type: 'card', card: {} }); + jest + .spyOn(stripeHelper, 'getPaymentMethod') + .mockResolvedValue({ type: 'card', card: {} }); expect(await stripeHelper.getPaymentProvider(customerExpanded)).toBe( 'card' @@ -691,14 +725,18 @@ describe('StripeHelper', () => { stripeHelper.stripe = { invoices: { - retrieve: sinon.stub().resolves({ payment_intent: 'pi_mock' }), + retrieve: jest + .fn() + .mockResolvedValue({ payment_intent: 'pi_mock' }), }, paymentIntents: { - retrieve: sinon.stub().resolves({ payment_method: 'pm_mock' }), + retrieve: jest + .fn() + .mockResolvedValue({ payment_method: 'pm_mock' }), }, }; - sandbox.stub(stripeHelper, 'getPaymentMethod').resolves({ + jest.spyOn(stripeHelper, 'getPaymentMethod').mockResolvedValue({ type: 'link', }); @@ -714,14 +752,18 @@ describe('StripeHelper', () => { stripeHelper.stripe = { invoices: { - retrieve: sinon.stub().resolves({ payment_intent: 'pi_mock' }), + retrieve: jest + .fn() + .mockResolvedValue({ payment_intent: 'pi_mock' }), }, paymentIntents: { - retrieve: sinon.stub().resolves({ payment_method: 'pm_mock' }), + retrieve: jest + .fn() + .mockResolvedValue({ payment_method: 'pm_mock' }), }, }; - sandbox.stub(stripeHelper, 'getPaymentMethod').resolves({ + jest.spyOn(stripeHelper, 'getPaymentMethod').mockResolvedValue({ type: 'card', card: { wallet: { @@ -742,14 +784,18 @@ describe('StripeHelper', () => { stripeHelper.stripe = { invoices: { - retrieve: sinon.stub().resolves({ payment_intent: 'pi_mock' }), + retrieve: jest + .fn() + .mockResolvedValue({ payment_intent: 'pi_mock' }), }, paymentIntents: { - retrieve: sinon.stub().resolves({ payment_method: 'pm_mock' }), + retrieve: jest + .fn() + .mockResolvedValue({ payment_method: 'pm_mock' }), }, }; - sandbox.stub(stripeHelper, 'getPaymentMethod').resolves({ + jest.spyOn(stripeHelper, 'getPaymentMethod').mockResolvedValue({ type: 'card', card: { wallet: { @@ -804,7 +850,9 @@ describe('StripeHelper', () => { it('returns true for an active subscription', async () => { subscription.status = 'active'; customerExpanded.subscriptions.data[0] = subscription; - sandbox.stub(stripeHelper, 'expandResource').resolves(customerExpanded); + jest + .spyOn(stripeHelper, 'expandResource') + .mockResolvedValue(customerExpanded); expect( await stripeHelper.hasActiveSubscription( customerExpanded.metadata.userid @@ -815,14 +863,18 @@ describe('StripeHelper', () => { it('returns false when there is no Stripe customer', async () => { const uid = uuidv4().replace(/-/g, ''); customerExpanded = undefined; - sandbox.stub(stripeHelper, 'expandResource').resolves(customerExpanded); + jest + .spyOn(stripeHelper, 'expandResource') + .mockResolvedValue(customerExpanded); expect(await stripeHelper.hasActiveSubscription(uid)).toBe(false); }); it('returns false when there is no active subscription', async () => { subscription.status = 'canceled'; customerExpanded.subscriptions.data[0] = subscription; - sandbox.stub(stripeHelper, 'expandResource').resolves(customerExpanded); + jest + .spyOn(stripeHelper, 'expandResource') + .mockResolvedValue(customerExpanded); expect( await stripeHelper.hasActiveSubscription( customerExpanded.metadata.userid @@ -841,7 +893,7 @@ describe('StripeHelper', () => { invoice = deepCopy(paidInvoice); subscription = deepCopy(subscription2); customerExpanded.subscriptions.data[0] = subscription; - sandbox.stub(stripeHelper, 'expandResource').resolves(invoice); + jest.spyOn(stripeHelper, 'expandResource').mockResolvedValue(invoice); }); it('returns latest invoices for any active subscriptions', async () => { @@ -887,25 +939,27 @@ describe('StripeHelper', () => { const openInvoice = deepCopy(invoice); openInvoice.status = 'open'; openInvoice.metadata.paymentAttempts = 1; - sandbox - .stub(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') - .resolves([invoice, openInvoice]); + jest + .spyOn(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') + .mockResolvedValue([invoice, openInvoice]); expect( await stripeHelper.hasOpenInvoiceWithPaymentAttempts(customerExpanded) ).toBe(true); - sinon.assert.calledOnceWithExactly( - stripeHelper.getLatestInvoicesForActiveSubscriptions, - customerExpanded - ); + expect( + stripeHelper.getLatestInvoicesForActiveSubscriptions + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.getLatestInvoicesForActiveSubscriptions + ).toHaveBeenCalledWith(customerExpanded); }); it('returns false for open invoices with no payment attempts', async () => { const openInvoice = deepCopy(invoice); openInvoice.status = 'open'; openInvoice.metadata.paymentAttempts = 0; - sandbox - .stub(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') - .resolves([invoice]); + jest + .spyOn(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') + .mockResolvedValue([invoice]); expect( await stripeHelper.hasOpenInvoiceWithPaymentAttempts(customerExpanded) ).toBe(false); @@ -916,9 +970,9 @@ describe('StripeHelper', () => { openInvoice.status = 'open'; openInvoice.metadata.paymentAttempts = 0; invoice.metadata.paymentAttempts = 1; - sandbox - .stub(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') - .resolves([invoice, openInvoice]); + jest + .spyOn(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') + .mockResolvedValue([invoice, openInvoice]); expect( await stripeHelper.hasOpenInvoiceWithPaymentAttempts(customerExpanded) ).toBe(false); @@ -929,14 +983,18 @@ describe('StripeHelper', () => { it('calls the Stripe api', async () => { const paymentMethodId = 'pm_9001'; const expected = { id: paymentMethodId }; - sandbox - .stub(stripeHelper.stripe.paymentMethods, 'detach') - .resolves(expected); - stripeFirestore.removePaymentMethodRecord = sandbox.stub().resolves({}); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'detach') + .mockResolvedValue(expected); + stripeFirestore.removePaymentMethodRecord = jest + .fn() + .mockResolvedValue({}); const actual = await stripeHelper.detachPaymentMethod(paymentMethodId); expect(actual).toEqual(expected); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.paymentMethods.detach, + expect(stripeHelper.stripe.paymentMethods.detach).toHaveBeenCalledTimes( + 1 + ); + expect(stripeHelper.stripe.paymentMethods.detach).toHaveBeenCalledWith( paymentMethodId ); }); @@ -947,14 +1005,19 @@ describe('StripeHelper', () => { const ids = { data: [{ id: uuidv4() }, { id: uuidv4() }, { id: uuidv4() }], }; - sandbox.stub(stripeHelper.stripe.customers, 'listSources').resolves(ids); - sandbox.stub(stripeHelper.stripe.customers, 'deleteSource').resolves({}); + jest + .spyOn(stripeHelper.stripe.customers, 'listSources') + .mockResolvedValue(ids); + jest + .spyOn(stripeHelper.stripe.customers, 'deleteSource') + .mockResolvedValue({}); const result = await stripeHelper.removeSources('cust_new'); expect(result).toEqual([{}, {}, {}]); - sinon.assert.calledThrice(stripeHelper.stripe.customers.deleteSource); + expect(stripeHelper.stripe.customers.deleteSource).toHaveBeenCalledTimes( + 3 + ); for (const obj of ids.data) { - sinon.assert.calledWith( - stripeHelper.stripe.customers.deleteSource, + expect(stripeHelper.stripe.customers.deleteSource).toHaveBeenCalledWith( 'cust_new', obj.id ); @@ -962,20 +1025,22 @@ describe('StripeHelper', () => { }); it('returns if no sources', async () => { - sandbox - .stub(stripeHelper.stripe.customers, 'listSources') - .resolves({ data: [] }); - sandbox.stub(stripeHelper.stripe.customers, 'deleteSource').resolves({}); + jest + .spyOn(stripeHelper.stripe.customers, 'listSources') + .mockResolvedValue({ data: [] }); + jest + .spyOn(stripeHelper.stripe.customers, 'deleteSource') + .mockResolvedValue({}); const result = await stripeHelper.removeSources('cust_new'); expect(result).toEqual([]); - sinon.assert.notCalled(stripeHelper.stripe.customers.deleteSource); + expect(stripeHelper.stripe.customers.deleteSource).not.toHaveBeenCalled(); }); it('surfaces stripe errors', async () => { const apiError = new stripeError.StripeAPIError(); - sandbox - .stub(stripeHelper.stripe.customers, 'listSources') - .rejects(apiError); + jest + .spyOn(stripeHelper.stripe.customers, 'listSources') + .mockRejectedValue(apiError); return stripeHelper.removeSources('cust_new').then( () => Promise.reject(new Error('Method expected to reject')), (err: any) => { @@ -990,23 +1055,25 @@ describe('StripeHelper', () => { const attachExpected = deepCopy(paymentMethodAttach); const customerExpected = deepCopy(newCustomerPM); const invoiceRetryExpected = deepCopy(invoiceRetry); - sandbox - .stub(stripeHelper.stripe.paymentMethods, 'attach') - .resolves(attachExpected); - sandbox - .stub(stripeHelper.stripe.customers, 'update') - .resolves(customerExpected); - sandbox - .stub(stripeHelper.stripe.invoices, 'pay') - .resolves(invoiceRetryExpected); - sandbox - .stub(stripeHelper.stripe.invoices, 'retrieve') - .resolves(invoiceRetryExpected); - stripeFirestore.insertCustomerRecordWithBackfill = sandbox - .stub() - .resolves({}); - stripeFirestore.insertPaymentMethodRecord = sandbox.stub().resolves({}); - stripeFirestore.insertInvoiceRecord = sandbox.stub().resolves({}); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'attach') + .mockResolvedValue(attachExpected); + jest + .spyOn(stripeHelper.stripe.customers, 'update') + .mockResolvedValue(customerExpected); + jest + .spyOn(stripeHelper.stripe.invoices, 'pay') + .mockResolvedValue(invoiceRetryExpected); + jest + .spyOn(stripeHelper.stripe.invoices, 'retrieve') + .mockResolvedValue(invoiceRetryExpected); + stripeFirestore.insertCustomerRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); + stripeFirestore.insertPaymentMethodRecord = jest + .fn() + .mockResolvedValue({}); + stripeFirestore.insertInvoiceRecord = jest.fn().mockResolvedValue({}); const actual = await stripeHelper.retryInvoiceWithPaymentId( 'customerId', 'invoiceId', @@ -1015,8 +1082,12 @@ describe('StripeHelper', () => { ); expect(actual).toEqual(invoiceRetryExpected); - sinon.assert.calledOnceWithExactly( - stripeFirestore.insertCustomerRecordWithBackfill, + expect( + stripeFirestore.insertCustomerRecordWithBackfill + ).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.insertCustomerRecordWithBackfill + ).toHaveBeenCalledWith( customerExpected.metadata.userid, customerExpected ); @@ -1024,9 +1095,9 @@ describe('StripeHelper', () => { it('surfaces payment issues', async () => { const apiError = new stripeError.StripeCardError(); - sandbox - .stub(stripeHelper.stripe.paymentMethods, 'attach') - .rejects(apiError); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'attach') + .mockRejectedValue(apiError); return stripeHelper .retryInvoiceWithPaymentId( @@ -1047,9 +1118,9 @@ describe('StripeHelper', () => { it('surfaces stripe errors', async () => { const apiError = new stripeError.StripeAPIError(); - sandbox - .stub(stripeHelper.stripe.paymentMethods, 'attach') - .rejects(apiError); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'attach') + .mockRejectedValue(apiError); return stripeHelper .retryInvoiceWithPaymentId( @@ -1081,22 +1152,24 @@ describe('StripeHelper', () => { it('creates a subscription successfully', async () => { const attachExpected = deepCopy(paymentMethodAttach); const customerExpected = deepCopy(newCustomerPM); - sandbox - .stub(stripeHelper.stripe.paymentMethods, 'attach') - .resolves(attachExpected); - sandbox - .stub(stripeHelper.stripe.customers, 'update') - .resolves(customerExpected); - sandbox - .stub(stripeHelper.stripe.subscriptions, 'create') - .resolves(subscriptionPMIExpanded); - stripeFirestore.insertCustomerRecordWithBackfill = sandbox - .stub() - .resolves({}); - stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox - .stub() - .resolves({}); - stripeFirestore.insertPaymentMethodRecord = sandbox.stub().resolves({}); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'attach') + .mockResolvedValue(attachExpected); + jest + .spyOn(stripeHelper.stripe.customers, 'update') + .mockResolvedValue(customerExpected); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'create') + .mockResolvedValue(subscriptionPMIExpanded); + stripeFirestore.insertCustomerRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); + stripeFirestore.insertSubscriptionRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); + stripeFirestore.insertPaymentMethodRecord = jest + .fn() + .mockResolvedValue({}); const expectedIdempotencyKey = generateIdempotencyKey([ 'customerId', 'priceId', @@ -1112,8 +1185,8 @@ describe('StripeHelper', () => { }); expect(actual).toEqual(subscriptionPMIExpanded); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.create, + expect(stripeHelper.stripe.subscriptions.create).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.subscriptions.create).toHaveBeenCalledWith( { customer: 'customerId', items: [{ price: 'priceId' }], @@ -1125,16 +1198,18 @@ describe('StripeHelper', () => { }, { idempotencyKey: `ssc-${expectedIdempotencyKey}` } ); - sinon.assert.calledOnceWithExactly( - stripeFirestore.insertSubscriptionRecordWithBackfill, - { - ...subscriptionPMIExpanded, - latest_invoice: subscriptionPMIExpanded.latest_invoice - ? subscriptionPMIExpanded.latest_invoice.id - : null, - } - ); - sinon.assert.callCount(mockStatsd.increment, 1); + expect( + stripeFirestore.insertSubscriptionRecordWithBackfill + ).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.insertSubscriptionRecordWithBackfill + ).toHaveBeenCalledWith({ + ...subscriptionPMIExpanded, + latest_invoice: subscriptionPMIExpanded.latest_invoice + ? subscriptionPMIExpanded.latest_invoice.id + : null, + }); + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); }); it('uses the given promotion code', async () => { @@ -1143,23 +1218,27 @@ describe('StripeHelper', () => { const customerExpected = deepCopy(newCustomerPM); const newSubscription = deepCopy(subscriptionPMIExpanded); newSubscription.latest_invoice.discount = {}; - sandbox - .stub(stripeHelper.stripe.paymentMethods, 'attach') - .resolves(attachExpected); - sandbox - .stub(stripeHelper.stripe.customers, 'update') - .resolves(customerExpected); - sandbox - .stub(stripeHelper.stripe.subscriptions, 'create') - .resolves(newSubscription); - sandbox.stub(stripeHelper.stripe.subscriptions, 'update').resolves({}); - stripeFirestore.insertCustomerRecordWithBackfill = sandbox - .stub() - .resolves({}); - stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox - .stub() - .resolves({}); - stripeFirestore.insertPaymentMethodRecord = sandbox.stub().resolves({}); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'attach') + .mockResolvedValue(attachExpected); + jest + .spyOn(stripeHelper.stripe.customers, 'update') + .mockResolvedValue(customerExpected); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'create') + .mockResolvedValue(newSubscription); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'update') + .mockResolvedValue({}); + stripeFirestore.insertCustomerRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); + stripeFirestore.insertSubscriptionRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); + stripeFirestore.insertPaymentMethodRecord = jest + .fn() + .mockResolvedValue({}); const expectedIdempotencyKey = generateIdempotencyKey([ 'customerId', 'priceId', @@ -1183,8 +1262,8 @@ describe('StripeHelper', () => { }, }; expect(actual).toEqual(subWithPromotionCodeMetadata); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.create, + expect(stripeHelper.stripe.subscriptions.create).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.subscriptions.create).toHaveBeenCalledWith( { customer: 'customerId', items: [{ price: 'priceId' }], @@ -1196,8 +1275,8 @@ describe('StripeHelper', () => { }, { idempotencyKey: `ssc-${expectedIdempotencyKey}` } ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.update, + expect(stripeHelper.stripe.subscriptions.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.subscriptions.update).toHaveBeenCalledWith( newSubscription.id, { metadata: { @@ -1206,37 +1285,41 @@ describe('StripeHelper', () => { }, } ); - sinon.assert.calledOnceWithExactly( - stripeFirestore.insertSubscriptionRecordWithBackfill, - { - ...subWithPromotionCodeMetadata, - latest_invoice: subscriptionPMIExpanded.latest_invoice - ? subscriptionPMIExpanded.latest_invoice.id - : null, - } - ); + expect( + stripeFirestore.insertSubscriptionRecordWithBackfill + ).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.insertSubscriptionRecordWithBackfill + ).toHaveBeenCalledWith({ + ...subWithPromotionCodeMetadata, + latest_invoice: subscriptionPMIExpanded.latest_invoice + ? subscriptionPMIExpanded.latest_invoice.id + : null, + }); }); it('errors and deletes subscription when a cvc check fails on subscription creation', async () => { const attachExpected = deepCopy(paymentMethodAttach); const customerExpected = deepCopy(newCustomerPM); - sandbox - .stub(stripeHelper.stripe.paymentMethods, 'attach') - .resolves(attachExpected); - sandbox - .stub(stripeHelper.stripe.customers, 'update') - .resolves(customerExpected); - sandbox - .stub(stripeHelper.stripe.subscriptions, 'create') - .resolves(subscriptionPMIExpandedIncompleteCVCFail); - sandbox.stub(stripeHelper, 'cancelSubscription').resolves({}); - stripeFirestore.insertCustomerRecordWithBackfill = sandbox - .stub() - .resolves({}); - stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox - .stub() - .resolves({}); - stripeFirestore.insertPaymentMethodRecord = sandbox.stub().resolves({}); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'attach') + .mockResolvedValue(attachExpected); + jest + .spyOn(stripeHelper.stripe.customers, 'update') + .mockResolvedValue(customerExpected); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'create') + .mockResolvedValue(subscriptionPMIExpandedIncompleteCVCFail); + jest.spyOn(stripeHelper, 'cancelSubscription').mockResolvedValue({}); + stripeFirestore.insertCustomerRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); + stripeFirestore.insertSubscriptionRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); + stripeFirestore.insertPaymentMethodRecord = jest + .fn() + .mockResolvedValue({}); const expectedIdempotencyKey = generateIdempotencyKey([ 'customerId', 'priceId', @@ -1251,12 +1334,12 @@ describe('StripeHelper', () => { paymentMethodId: 'pm_1H0FRp2eZvKYlo2CeIZoc0wj', automaticTax: true, }); - sinon.assert.fail(); + fail('should not reach here'); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.REJECTED_SUBSCRIPTION_PAYMENT_TOKEN); } - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.create, + expect(stripeHelper.stripe.subscriptions.create).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.subscriptions.create).toHaveBeenCalledWith( { customer: 'customerId', items: [{ price: 'priceId' }], @@ -1268,21 +1351,21 @@ describe('StripeHelper', () => { }, { idempotencyKey: `ssc-${expectedIdempotencyKey}` } ); - sinon.assert.calledOnceWithExactly( - stripeHelper.cancelSubscription, + expect(stripeHelper.cancelSubscription).toHaveBeenCalledTimes(1); + expect(stripeHelper.cancelSubscription).toHaveBeenCalledWith( subscriptionPMIExpandedIncompleteCVCFail.id ); - sinon.assert.notCalled( + expect( stripeFirestore.insertSubscriptionRecordWithBackfill - ); - sinon.assert.callCount(mockStatsd.increment, 1); + ).not.toHaveBeenCalled(); + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); }); it('surfaces payment issues', async () => { const apiError = new stripeError.StripeCardError(); - sandbox - .stub(stripeHelper.stripe.paymentMethods, 'attach') - .rejects(apiError); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'attach') + .mockRejectedValue(apiError); return stripeHelper .createSubscriptionWithPMI({ @@ -1302,9 +1385,9 @@ describe('StripeHelper', () => { it('surfaces stripe errors', async () => { const apiError = new stripeError.StripeAPIError(); - sandbox - .stub(stripeHelper.stripe.paymentMethods, 'attach') - .rejects(apiError); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'attach') + .mockRejectedValue(apiError); return stripeHelper .createSubscriptionWithPMI({ @@ -1323,16 +1406,16 @@ describe('StripeHelper', () => { describe('createSubscriptionWithPaypal', () => { it('creates a subscription successfully', async () => { - sandbox - .stub(stripeHelper, 'findCustomerSubscriptionByPlanId') - .returns(undefined); - sandbox - .stub(stripeHelper.stripe.subscriptions, 'create') - .resolves(subscriptionPMIExpanded); + jest + .spyOn(stripeHelper, 'findCustomerSubscriptionByPlanId') + .mockReturnValue(undefined); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'create') + .mockResolvedValue(subscriptionPMIExpanded); const subIdempotencyKey = uuidv4(); - stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox - .stub() - .resolves({}); + stripeFirestore.insertSubscriptionRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); const actual = await stripeHelper.createSubscriptionWithPaypal({ customer: customer1, priceId: 'priceId', @@ -1341,17 +1424,19 @@ describe('StripeHelper', () => { }); expect(actual).toEqual(subscriptionPMIExpanded); - sinon.assert.calledOnceWithExactly( - stripeFirestore.insertSubscriptionRecordWithBackfill, - { - ...subscriptionPMIExpanded, - latest_invoice: subscriptionPMIExpanded.latest_invoice - ? subscriptionPMIExpanded.latest_invoice.id - : null, - } - ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.create, + expect( + stripeFirestore.insertSubscriptionRecordWithBackfill + ).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.insertSubscriptionRecordWithBackfill + ).toHaveBeenCalledWith({ + ...subscriptionPMIExpanded, + latest_invoice: subscriptionPMIExpanded.latest_invoice + ? subscriptionPMIExpanded.latest_invoice.id + : null, + }); + expect(stripeHelper.stripe.subscriptions.create).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.subscriptions.create).toHaveBeenCalledWith( { customer: customer1.id, items: [{ price: 'priceId' }], @@ -1365,24 +1450,26 @@ describe('StripeHelper', () => { }, { idempotencyKey: `ssc-${subIdempotencyKey}` } ); - sinon.assert.callCount(mockStatsd.increment, 1); + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); }); it('uses the given promotion code to create a subscription', async () => { const promotionCode = { id: 'redpanda', code: 'firefox' }; const newSubscription = deepCopy(subscriptionPMIExpanded); newSubscription.latest_invoice.discount = {}; - sandbox - .stub(stripeHelper, 'findCustomerSubscriptionByPlanId') - .returns(undefined); - sandbox - .stub(stripeHelper.stripe.subscriptions, 'create') - .resolves(newSubscription); - sandbox.stub(stripeHelper.stripe.subscriptions, 'update').resolves({}); + jest + .spyOn(stripeHelper, 'findCustomerSubscriptionByPlanId') + .mockReturnValue(undefined); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'create') + .mockResolvedValue(newSubscription); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'update') + .mockResolvedValue({}); const subIdempotencyKey = uuidv4(); - stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox - .stub() - .resolves({}); + stripeFirestore.insertSubscriptionRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); const actual = await stripeHelper.createSubscriptionWithPaypal({ customer: customer1, priceId: 'priceId', @@ -1399,17 +1486,19 @@ describe('StripeHelper', () => { }, }; expect(actual).toEqual(subWithPromotionCodeMetadata); - sinon.assert.calledOnceWithExactly( - stripeFirestore.insertSubscriptionRecordWithBackfill, - { - ...subWithPromotionCodeMetadata, - latest_invoice: subscriptionPMIExpanded.latest_invoice - ? subscriptionPMIExpanded.latest_invoice.id - : null, - } - ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.create, + expect( + stripeFirestore.insertSubscriptionRecordWithBackfill + ).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.insertSubscriptionRecordWithBackfill + ).toHaveBeenCalledWith({ + ...subWithPromotionCodeMetadata, + latest_invoice: subscriptionPMIExpanded.latest_invoice + ? subscriptionPMIExpanded.latest_invoice.id + : null, + }); + expect(stripeHelper.stripe.subscriptions.create).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.subscriptions.create).toHaveBeenCalledWith( { customer: customer1.id, items: [{ price: 'priceId' }], @@ -1423,16 +1512,16 @@ describe('StripeHelper', () => { }, { idempotencyKey: `ssc-${subIdempotencyKey}` } ); - sinon.assert.callCount(mockStatsd.increment, 1); + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); }); it('returns a usable sub if one is active/past_due', async () => { const collectionSubscription = deepCopy(subscription1); collectionSubscription.collection_method = 'send_invoice'; - sandbox - .stub(stripeHelper, 'findCustomerSubscriptionByPlanId') - .returns(collectionSubscription); - sandbox.stub(stripeHelper, 'expandResource').returns({}); + jest + .spyOn(stripeHelper, 'findCustomerSubscriptionByPlanId') + .mockReturnValue(collectionSubscription); + jest.spyOn(stripeHelper, 'expandResource').mockReturnValue({}); const actual = await stripeHelper.createSubscriptionWithPaypal({ customer: customer1, priceId: 'priceId', @@ -1443,10 +1532,10 @@ describe('StripeHelper', () => { }); it('throws an error for an existing charge subscription', async () => { - sandbox - .stub(stripeHelper, 'findCustomerSubscriptionByPlanId') - .returns(subscription1); - sandbox.stub(stripeHelper, 'expandResource').returns({}); + jest + .spyOn(stripeHelper, 'findCustomerSubscriptionByPlanId') + .mockReturnValue(subscription1); + jest.spyOn(stripeHelper, 'expandResource').mockReturnValue({}); try { await stripeHelper.createSubscriptionWithPaypal({ customer: customer1, @@ -1462,16 +1551,18 @@ describe('StripeHelper', () => { it('deletes an incomplete subscription when creating', async () => { const collectionSubscription = deepCopy(subscription1); collectionSubscription.status = 'incomplete'; - sandbox - .stub(stripeHelper, 'findCustomerSubscriptionByPlanId') - .returns(collectionSubscription); - sandbox.stub(stripeHelper.stripe.subscriptions, 'cancel').resolves({}); - sandbox - .stub(stripeHelper.stripe.subscriptions, 'create') - .resolves(subscription1); - stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox - .stub() - .resolves({}); + jest + .spyOn(stripeHelper, 'findCustomerSubscriptionByPlanId') + .mockReturnValue(collectionSubscription); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'cancel') + .mockResolvedValue({}); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'create') + .mockResolvedValue(subscription1); + stripeFirestore.insertSubscriptionRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); const actual = await stripeHelper.createSubscriptionWithPaypal({ customer: customer1, priceId: 'priceId', @@ -1479,30 +1570,31 @@ describe('StripeHelper', () => { }); expect(actual).toEqual(subscription1); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.cancel, + expect(stripeHelper.stripe.subscriptions.cancel).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.subscriptions.cancel).toHaveBeenCalledWith( collectionSubscription.id ); - sinon.assert.calledWithExactly( - stripeFirestore.insertSubscriptionRecordWithBackfill, - { - ...subscription1, - latest_invoice: subscription1.latest_invoice - ? subscription1.latest_invoice.id - : null, - } - ); + expect( + stripeFirestore.insertSubscriptionRecordWithBackfill + ).toHaveBeenCalledWith({ + ...subscription1, + latest_invoice: subscription1.latest_invoice + ? subscription1.latest_invoice.id + : null, + }); }); }); describe('getCoupon', () => { it('returns a coupon', async () => { const coupon = { id: 'couponId' }; - sandbox.stub(stripeHelper.stripe.coupons, 'retrieve').resolves(coupon); + jest + .spyOn(stripeHelper.stripe.coupons, 'retrieve') + .mockResolvedValue(coupon); const actual = await stripeHelper.getCoupon('couponId'); expect(actual).toEqual(coupon); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.coupons.retrieve, + expect(stripeHelper.stripe.coupons.retrieve).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.coupons.retrieve).toHaveBeenCalledWith( coupon.id, { expand: ['applies_to'] } ); @@ -1512,11 +1604,13 @@ describe('StripeHelper', () => { describe('getInvoiceWithDiscount', () => { it('returns an invoice with discounts expanded', async () => { const invoice = { id: 'invoiceId' }; - sandbox.stub(stripeHelper.stripe.invoices, 'retrieve').resolves(invoice); + jest + .spyOn(stripeHelper.stripe.invoices, 'retrieve') + .mockResolvedValue(invoice); const actual = await stripeHelper.getInvoiceWithDiscount('invoiceId'); expect(actual).toEqual(invoice); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.retrieve, + expect(stripeHelper.stripe.invoices.retrieve).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.retrieve).toHaveBeenCalledWith( invoice.id, { expand: ['discounts'] } ); @@ -1526,25 +1620,29 @@ describe('StripeHelper', () => { describe('findValidPromoCode', () => { it('finds a valid promotionCode with plan metadata', async () => { const promotionCode = { code: 'promo1', coupon: { valid: true } }; - sandbox - .stub(stripeHelper.stripe.promotionCodes, 'list') - .resolves({ data: [promotionCode] }); - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest + .spyOn(stripeHelper.stripe.promotionCodes, 'list') + .mockResolvedValue({ data: [promotionCode] }); + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ plan_metadata: { [STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo1', }, }); const actual = await stripeHelper.findValidPromoCode('promo1', 'planId'); expect(actual).toEqual(promotionCode); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.promotionCodes.list as sinon.SinonStub, - { - active: true, - code: 'promo1', - } - ); - sinon.assert.calledOnceWithExactly( - stripeHelper.findAbbrevPlanById as sinon.SinonStub, + expect( + stripeHelper.stripe.promotionCodes.list as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripe.promotionCodes.list as jest.Mock + ).toHaveBeenCalledWith({ + active: true, + code: 'promo1', + }); + expect( + stripeHelper.findAbbrevPlanById as jest.Mock + ).toHaveBeenCalledTimes(1); + expect(stripeHelper.findAbbrevPlanById as jest.Mock).toHaveBeenCalledWith( 'planId' ); }); @@ -1556,47 +1654,53 @@ describe('StripeHelper', () => { coupon: { valid: true }, expires_at: expiredTime, }; - sandbox - .stub(stripeHelper.stripe.promotionCodes, 'list') - .resolves({ data: [promotionCode] }); - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest + .spyOn(stripeHelper.stripe.promotionCodes, 'list') + .mockResolvedValue({ data: [promotionCode] }); + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ plan_metadata: { [STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo1', }, }); const actual = await stripeHelper.findValidPromoCode('promo1', 'planId'); expect(actual).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.promotionCodes.list as sinon.SinonStub, - { - active: true, - code: 'promo1', - } - ); - sinon.assert.notCalled( - stripeHelper.findAbbrevPlanById as sinon.SinonStub - ); + expect( + stripeHelper.stripe.promotionCodes.list as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripe.promotionCodes.list as jest.Mock + ).toHaveBeenCalledWith({ + active: true, + code: 'promo1', + }); + expect( + stripeHelper.findAbbrevPlanById as jest.Mock + ).not.toHaveBeenCalled(); }); it('does not find a promotionCode with a different plan', async () => { const promotionCode = { code: 'promo1', coupon: { valid: true } }; - sandbox - .stub(stripeHelper.stripe.promotionCodes, 'list') - .resolves({ data: [promotionCode] }); - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest + .spyOn(stripeHelper.stripe.promotionCodes, 'list') + .mockResolvedValue({ data: [promotionCode] }); + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ plan_metadata: {}, }); const actual = await stripeHelper.findValidPromoCode('promo1', 'planId'); expect(actual).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.promotionCodes.list as sinon.SinonStub, - { - active: true, - code: 'promo1', - } - ); - sinon.assert.calledOnceWithExactly( - stripeHelper.findAbbrevPlanById as sinon.SinonStub, + expect( + stripeHelper.stripe.promotionCodes.list as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripe.promotionCodes.list as jest.Mock + ).toHaveBeenCalledWith({ + active: true, + code: 'promo1', + }); + expect( + stripeHelper.findAbbrevPlanById as jest.Mock + ).toHaveBeenCalledTimes(1); + expect(stripeHelper.findAbbrevPlanById as jest.Mock).toHaveBeenCalledWith( 'planId' ); }); @@ -1606,26 +1710,28 @@ describe('StripeHelper', () => { code: 'promo1', coupon: { valid: false }, }; - sandbox - .stub(stripeHelper.stripe.promotionCodes, 'list') - .resolves({ data: [promotionCode] }); - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest + .spyOn(stripeHelper.stripe.promotionCodes, 'list') + .mockResolvedValue({ data: [promotionCode] }); + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ plan_metadata: { [STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo1', }, }); const actual = await stripeHelper.findValidPromoCode('promo1', 'planId'); expect(actual).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.promotionCodes.list as sinon.SinonStub, - { - active: true, - code: 'promo1', - } - ); - sinon.assert.notCalled( - stripeHelper.findAbbrevPlanById as sinon.SinonStub - ); + expect( + stripeHelper.stripe.promotionCodes.list as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripe.promotionCodes.list as jest.Mock + ).toHaveBeenCalledWith({ + active: true, + code: 'promo1', + }); + expect( + stripeHelper.findAbbrevPlanById as jest.Mock + ).not.toHaveBeenCalled(); }); }); @@ -1647,13 +1753,17 @@ describe('StripeHelper', () => { let sentryScope: any; const setDefaultFindPlanById = () => - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves(planTemplate); + jest + .spyOn(stripeHelper, 'findAbbrevPlanById') + .mockResolvedValue(planTemplate); beforeEach(() => { - sentryScope = { setContext: sandbox.stub(), setExtra: sandbox.stub() }; - sandbox.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sandbox.stub(Sentry, 'setExtra'); - sandbox.stub(Sentry, 'captureException'); + sentryScope = { setContext: jest.fn(), setExtra: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(Sentry, 'setExtra'); + jest.spyOn(Sentry, 'captureException'); }); it('coupon duration other than repeating', async () => { @@ -1669,7 +1779,7 @@ describe('StripeHelper', () => { it('valid yearly plan interval', async () => { const coupon = { ...couponTemplate, duration_in_months: 12 }; - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ ...planTemplate, interval: 'year', interval_count: 1, @@ -1685,7 +1795,7 @@ describe('StripeHelper', () => { it('invalid yearly plan interval', async () => { const coupon = couponTemplate; const priceIntervalOverride = 'year'; - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ ...planTemplate, interval: priceIntervalOverride, }); @@ -1695,8 +1805,8 @@ describe('StripeHelper', () => { coupon ); expect(actual).toBe(false); - sinon.assert.calledOnceWithExactly( - sentryScope.setContext, + expect(sentryScope.setContext).toHaveBeenCalledTimes(1); + expect(sentryScope.setContext).toHaveBeenCalledWith( 'validateCouponDurationForPlan', { promotionCode, @@ -1723,7 +1833,7 @@ describe('StripeHelper', () => { it('invalid monthly plan interval', async () => { const coupon = couponTemplate; const priceIntervalCountOverride = 6; - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ ...planTemplate, interval_count: priceIntervalCountOverride, }); @@ -1733,8 +1843,8 @@ describe('StripeHelper', () => { coupon ); expect(actual).toBe(false); - sinon.assert.calledOnceWithExactly( - sentryScope.setContext, + expect(sentryScope.setContext).toHaveBeenCalledTimes(1); + expect(sentryScope.setContext).toHaveBeenCalledWith( 'validateCouponDurationForPlan', { promotionCode, @@ -1749,7 +1859,7 @@ describe('StripeHelper', () => { it('invalid plan interval', async () => { const coupon = couponTemplate; - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ ...planTemplate, interval: 'week', }); @@ -1759,7 +1869,7 @@ describe('StripeHelper', () => { coupon ); expect(actual).toBe(false); - sinon.assert.notCalled(Sentry.withScope as sinon.SinonStub); + expect(Sentry.withScope as jest.Mock).not.toHaveBeenCalled(); }); it('missing coupon duration in months', async () => { @@ -1771,25 +1881,25 @@ describe('StripeHelper', () => { coupon ); expect(actual).toBe(false); - sinon.assert.notCalled(Sentry.withScope as sinon.SinonStub); + expect(Sentry.withScope as jest.Mock).not.toHaveBeenCalled(); }); }); describe('findPromoCodeByCode', () => { it('finds a promo code', async () => { const promotionCode = { code: 'code1' }; - sandbox - .stub(stripeHelper.stripe.promotionCodes, 'list') - .resolves({ data: [promotionCode] }); + jest + .spyOn(stripeHelper.stripe.promotionCodes, 'list') + .mockResolvedValue({ data: [promotionCode] }); const actual = await stripeHelper.findPromoCodeByCode('code1'); expect(actual).toEqual(promotionCode); }); it('finds no promo code', async () => { const promotionCode = { code: 'code2' }; - sandbox - .stub(stripeHelper.stripe.promotionCodes, 'list') - .resolves({ data: [promotionCode] }); + jest + .spyOn(stripeHelper.stripe.promotionCodes, 'list') + .mockResolvedValue({ data: [promotionCode] }); const actual = await stripeHelper.findPromoCodeByCode('code1'); expect(actual).toBeUndefined(); }); @@ -1815,26 +1925,30 @@ describe('StripeHelper', () => { let sentryScope: any; beforeEach(() => { - sentryScope = { setContext: sandbox.stub(), setExtra: sandbox.stub() }; - sandbox.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sandbox.stub(Sentry, 'setExtra'); - sandbox.stub(Sentry, 'captureException'); + sentryScope = { setContext: jest.fn(), setExtra: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(Sentry, 'setExtra'); + jest.spyOn(Sentry, 'captureException'); }); it('retrieves coupon details', async () => { const expected = { ...expectedTemplate, discountAmount: 200 }; - sandbox - .stub(stripeHelper, 'previewInvoice') - .resolves([validInvoicePreview, undefined]); - sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({ - active: true, - coupon: { - id: 'promo', - duration: 'forever', - valid: true, - duration_in_months: null, - }, - }); + jest + .spyOn(stripeHelper, 'previewInvoice') + .mockResolvedValue([validInvoicePreview, undefined]); + jest + .spyOn(stripeHelper, 'retrievePromotionCodeForPlan') + .mockResolvedValue({ + active: true, + coupon: { + id: 'promo', + duration: 'forever', + valid: true, + duration_in_months: null, + }, + }); const actual = await stripeHelper.retrieveCouponDetails({ automaticTax: false, country: 'US', @@ -1847,18 +1961,20 @@ describe('StripeHelper', () => { it('retrieves coupon details for 100% discount', async () => { const expected = { ...expectedTemplate, discountAmount: 200 }; - sandbox - .stub(stripeHelper, 'previewInvoice') - .resolves([{ ...validInvoicePreview, total: 0 }, undefined]); - sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({ - active: true, - coupon: { - id: 'promo', - duration: 'forever', - valid: true, - duration_in_months: null, - }, - }); + jest + .spyOn(stripeHelper, 'previewInvoice') + .mockResolvedValue([{ ...validInvoicePreview, total: 0 }, undefined]); + jest + .spyOn(stripeHelper, 'retrievePromotionCodeForPlan') + .mockResolvedValue({ + active: true, + coupon: { + id: 'promo', + duration: 'forever', + valid: true, + duration_in_months: null, + }, + }); const actual = await stripeHelper.retrieveCouponDetails({ priceId: 'planId', promotionCode: 'promo', @@ -1869,19 +1985,22 @@ describe('StripeHelper', () => { it('retrieves details on an expired coupon', async () => { const expected = { ...expectedTemplate, valid: false, expired: true }; - sandbox - .stub(stripeHelper, 'previewInvoice') - .resolves({ ...validInvoicePreview, total_discount_amounts: null }); - sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({ - active: true, - coupon: { - id: 'promo', - duration: 'forever', - valid: false, - redeem_by: 1000, - duration_in_months: null, - }, + jest.spyOn(stripeHelper, 'previewInvoice').mockResolvedValue({ + ...validInvoicePreview, + total_discount_amounts: null, }); + jest + .spyOn(stripeHelper, 'retrievePromotionCodeForPlan') + .mockResolvedValue({ + active: true, + coupon: { + id: 'promo', + duration: 'forever', + valid: false, + redeem_by: 1000, + duration_in_months: null, + }, + }); const actual = await stripeHelper.retrieveCouponDetails({ country: 'US', priceId: 'planId', @@ -1896,20 +2015,23 @@ describe('StripeHelper', () => { valid: false, maximallyRedeemed: true, }; - sandbox - .stub(stripeHelper, 'previewInvoice') - .resolves({ ...validInvoicePreview, total_discount_amounts: null }); - sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({ - active: true, - coupon: { - id: 'promo', - duration: 'forever', - valid: false, - max_redemptions: 1, - times_redeemed: 1, - duration_in_months: null, - }, + jest.spyOn(stripeHelper, 'previewInvoice').mockResolvedValue({ + ...validInvoicePreview, + total_discount_amounts: null, }); + jest + .spyOn(stripeHelper, 'retrievePromotionCodeForPlan') + .mockResolvedValue({ + active: true, + coupon: { + id: 'promo', + duration: 'forever', + valid: false, + max_redemptions: 1, + times_redeemed: 1, + duration_in_months: null, + }, + }); const actual = await stripeHelper.retrieveCouponDetails({ country: 'US', priceId: 'planId', @@ -1924,19 +2046,22 @@ describe('StripeHelper', () => { valid: false, expired: true, }; - sandbox - .stub(stripeHelper, 'previewInvoice') - .resolves({ ...validInvoicePreview, total_discount_amounts: null }); - sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({ - active: false, - expires_at: 1000, - coupon: { - id: 'promo', - duration: 'forever', - valid: true, - duration_in_months: null, - }, - }); + jest.spyOn(stripeHelper, 'previewInvoice').mockResolvedValue({ + ...validInvoicePreview, + total_discount_amounts: null, + }); + jest + .spyOn(stripeHelper, 'retrievePromotionCodeForPlan') + .mockResolvedValue({ + active: false, + expires_at: 1000, + coupon: { + id: 'promo', + duration: 'forever', + valid: true, + duration_in_months: null, + }, + }); const actual = await stripeHelper.retrieveCouponDetails({ country: 'US', priceId: 'planId', @@ -1951,20 +2076,23 @@ describe('StripeHelper', () => { valid: false, maximallyRedeemed: true, }; - sandbox - .stub(stripeHelper, 'previewInvoice') - .resolves({ ...validInvoicePreview, total_discount_amounts: null }); - sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({ - active: false, - max_redemptions: 1, - times_redeemed: 1, - coupon: { - id: 'promo', - duration: 'forever', - valid: true, - duration_in_months: null, - }, - }); + jest.spyOn(stripeHelper, 'previewInvoice').mockResolvedValue({ + ...validInvoicePreview, + total_discount_amounts: null, + }); + jest + .spyOn(stripeHelper, 'retrievePromotionCodeForPlan') + .mockResolvedValue({ + active: false, + max_redemptions: 1, + times_redeemed: 1, + coupon: { + id: 'promo', + duration: 'forever', + valid: true, + duration_in_months: null, + }, + }); const actual = await stripeHelper.retrieveCouponDetails({ country: 'US', priceId: 'planId', @@ -1976,58 +2104,59 @@ describe('StripeHelper', () => { it('return coupon details even when previewInvoice rejects', async () => { const expected = { ...expectedTemplate, valid: false }; const err = new error('previewInvoiceFailed'); - sandbox.stub(stripeHelper, 'previewInvoice').rejects(err); - sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({ - active: true, - coupon: { - id: 'promo', - duration: 'forever', - valid: true, - duration_in_months: null, - }, - }); + jest.spyOn(stripeHelper, 'previewInvoice').mockRejectedValue(err); + jest + .spyOn(stripeHelper, 'retrievePromotionCodeForPlan') + .mockResolvedValue({ + active: true, + coupon: { + id: 'promo', + duration: 'forever', + valid: true, + duration_in_months: null, + }, + }); const actual = await stripeHelper.retrieveCouponDetails({ country: 'US', priceId: 'planId', promotionCode: 'promo', }); expect(actual).toEqual(expected); - sinon.assert.calledWithExactly( - sentryScope.setContext.getCall(0), + expect(sentryScope.setContext).toHaveBeenCalledWith( 'retrieveCouponDetails', { priceId: 'planId', promotionCode: 'promo', } ); - sinon.assert.calledOnceWithExactly( - Sentry.captureException as sinon.SinonStub, - err - ); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledTimes(1); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledWith(err); }); it('return coupon details even when getMinAmount rejects', async () => { const expected = { ...expectedTemplate, valid: false }; - sandbox - .stub(stripeHelper, 'previewInvoice') - .resolves({ ...validInvoicePreview, currency: 'fake' }); - sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({ - active: true, - coupon: { - id: 'promo', - duration: 'forever', - valid: true, - duration_in_months: null, - }, - }); + jest + .spyOn(stripeHelper, 'previewInvoice') + .mockResolvedValue({ ...validInvoicePreview, currency: 'fake' }); + jest + .spyOn(stripeHelper, 'retrievePromotionCodeForPlan') + .mockResolvedValue({ + active: true, + coupon: { + id: 'promo', + duration: 'forever', + valid: true, + duration_in_months: null, + }, + }); const actual = await stripeHelper.retrieveCouponDetails({ country: 'US', priceId: 'planId', promotionCode: 'promo', }); expect(actual).toEqual(expected); - sinon.assert.calledOnceWithExactly( - sentryScope.setContext, + expect(sentryScope.setContext).toHaveBeenCalledTimes(1); + expect(sentryScope.setContext).toHaveBeenCalledWith( 'retrieveCouponDetails', { priceId: 'planId', @@ -2037,18 +2166,20 @@ describe('StripeHelper', () => { }); it('throw an error when previewInvoice returns total less than stripe minimums', async () => { - sandbox - .stub(stripeHelper, 'previewInvoice') - .resolves({ ...validInvoicePreview, total: 20 }); - sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves({ - active: true, - coupon: { - id: 'promo', - duration: 'forever', - valid: true, - duration_in_months: null, - }, - }); + jest + .spyOn(stripeHelper, 'previewInvoice') + .mockResolvedValue({ ...validInvoicePreview, total: 20 }); + jest + .spyOn(stripeHelper, 'retrievePromotionCodeForPlan') + .mockResolvedValue({ + active: true, + coupon: { + id: 'promo', + duration: 'forever', + valid: true, + duration_in_months: null, + }, + }); try { await stripeHelper.retrieveCouponDetails({ country: 'US', @@ -2061,7 +2192,9 @@ describe('StripeHelper', () => { }); it('throw an error when retrievePromotionCodeForPlan returns no coupon', async () => { - sandbox.stub(stripeHelper, 'retrievePromotionCodeForPlan').resolves(); + jest + .spyOn(stripeHelper, 'retrievePromotionCodeForPlan') + .mockResolvedValue(); try { await stripeHelper.retrieveCouponDetails({ country: 'US', @@ -2076,26 +2209,27 @@ describe('StripeHelper', () => { describe('previewInvoice', () => { it('uses shipping address when present and no customer is provided', async () => { - const stripeStub = sandbox - .stub(stripeHelper.stripe.invoices, 'retrieveUpcoming') - .resolves(); - sandbox - .stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry') - .returns(true); - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + const stripeStub = jest + .spyOn(stripeHelper.stripe.invoices, 'retrieveUpcoming') + .mockResolvedValue(); + jest + .spyOn(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry') + .mockReturnValue(true); + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ currency: 'USD', }); await stripeHelper.previewInvoice({ priceId: 'priceId', taxAddress: { countryCode: 'US', postalCode: '92841' }, }); - sinon.assert.calledOnceWithExactly(stripeStub, { + expect(stripeStub).toHaveBeenCalledTimes(1); + expect(stripeStub).toHaveBeenCalledWith({ customer: undefined, automatic_tax: { enabled: true }, customer_details: { tax_exempt: 'none', shipping: { - name: sinon.match.any, + name: expect.anything(), address: { country: 'US', postal_code: '92841' }, }, }, @@ -2105,17 +2239,18 @@ describe('StripeHelper', () => { }); it('excludes shipping address when shipping address not passed', async () => { - const stripeStub = sandbox - .stub(stripeHelper.stripe.invoices, 'retrieveUpcoming') - .resolves(); - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + const stripeStub = jest + .spyOn(stripeHelper.stripe.invoices, 'retrieveUpcoming') + .mockResolvedValue(); + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ currency: 'USD', }); await stripeHelper.previewInvoice({ priceId: 'priceId', taxAddress: undefined, }); - sinon.assert.calledOnceWithExactly(stripeStub, { + expect(stripeStub).toHaveBeenCalledTimes(1); + expect(stripeStub).toHaveBeenCalledWith({ customer: undefined, automatic_tax: { enabled: false }, customer_details: { @@ -2128,40 +2263,44 @@ describe('StripeHelper', () => { }); it('disables stripe tax when currency is incompatible with country', async () => { - const stripeStub = sandbox - .stub(stripeHelper.stripe.invoices, 'retrieveUpcoming') - .resolves(); - const findAbbrevPlanByIdStub = sandbox - .stub(stripeHelper, 'findAbbrevPlanById') - .resolves({ currency: 'USD' }); - sandbox - .stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry') - .returns(false); + const stripeStub = jest + .spyOn(stripeHelper.stripe.invoices, 'retrieveUpcoming') + .mockResolvedValue(); + const findAbbrevPlanByIdStub = jest + .spyOn(stripeHelper, 'findAbbrevPlanById') + .mockResolvedValue({ currency: 'USD' }); + jest + .spyOn(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry') + .mockReturnValue(false); await stripeHelper.previewInvoice({ priceId: 'priceId', taxAddress: { countryCode: 'US', postalCode: '92841' }, }); - sinon.assert.calledOnceWithExactly(stripeStub, { + expect(stripeStub).toHaveBeenCalledTimes(1); + expect(stripeStub).toHaveBeenCalledWith({ customer: undefined, automatic_tax: { enabled: false }, customer_details: { tax_exempt: 'none', shipping: { - name: sinon.match.any, + name: expect.anything(), address: { country: 'US', postal_code: '92841' }, }, }, subscription_items: [{ price: 'priceId' }], expand: ['total_tax_amounts.tax_rate'], }); - sinon.assert.calledOnceWithExactly(findAbbrevPlanByIdStub, 'priceId'); + expect(findAbbrevPlanByIdStub).toHaveBeenCalledTimes(1); + expect(findAbbrevPlanByIdStub).toHaveBeenCalledWith('priceId'); }); it('logs when there is an error', async () => { - sandbox - .stub(stripeHelper.stripe.invoices, 'retrieveUpcoming') - .throws(new Error()); - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest + .spyOn(stripeHelper.stripe.invoices, 'retrieveUpcoming') + .mockImplementation(() => { + throw new Error(); + }); + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ currency: 'USD', }); try { @@ -2170,16 +2309,16 @@ describe('StripeHelper', () => { taxAddress: { countryCode: 'US', postalCode: '92841' }, }); } catch (e) { - sinon.assert.calledOnce(stripeHelper.log.warn); + expect(stripeHelper.log.warn).toHaveBeenCalledTimes(1); } }); it('retrieves both upcoming invoices with and without proration info', async () => { - const stripeStub = sandbox - .stub(stripeHelper.stripe.invoices, 'retrieveUpcoming') - .resolves(); - sandbox.stub(Math, 'floor').returns(1); - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + const stripeStub = jest + .spyOn(stripeHelper.stripe.invoices, 'retrieveUpcoming') + .mockResolvedValue(); + jest.spyOn(Math, 'floor').mockReturnValue(1); + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ currency: 'USD', }); await stripeHelper.previewInvoice({ @@ -2189,8 +2328,8 @@ describe('StripeHelper', () => { isUpgrade: true, sourcePlan: { plan_id: 'plan_test1' }, }); - sinon.assert.callCount(stripeStub, 2); - sinon.assert.calledWith(stripeStub, { + expect(stripeStub).toHaveBeenCalledTimes(2); + expect(stripeStub).toHaveBeenCalledWith({ customer: 'cus_test1', automatic_tax: { enabled: false }, customer_details: { @@ -2213,26 +2352,28 @@ describe('StripeHelper', () => { describe('previewInvoiceBySubscriptionId', () => { it('fetches invoice preview', async () => { - const stripeStub = sandbox - .stub(stripeHelper.stripe.invoices, 'retrieveUpcoming') - .resolves(); + const stripeStub = jest + .spyOn(stripeHelper.stripe.invoices, 'retrieveUpcoming') + .mockResolvedValue(); await stripeHelper.previewInvoiceBySubscriptionId({ subscriptionId: 'sub123', }); - sinon.assert.calledOnceWithExactly(stripeStub, { + expect(stripeStub).toHaveBeenCalledTimes(1); + expect(stripeStub).toHaveBeenCalledWith({ subscription: 'sub123', }); }); it('fetches invoice preview for cancelled subscription', async () => { - const stripeStub = sandbox - .stub(stripeHelper.stripe.invoices, 'retrieveUpcoming') - .resolves(); + const stripeStub = jest + .spyOn(stripeHelper.stripe.invoices, 'retrieveUpcoming') + .mockResolvedValue(); await stripeHelper.previewInvoiceBySubscriptionId({ subscriptionId: 'sub123', includeCanceled: true, }); - sinon.assert.calledOnceWithExactly(stripeStub, { + expect(stripeStub).toHaveBeenCalledTimes(1); + expect(stripeStub).toHaveBeenCalledWith({ subscription: 'sub123', subscription_cancel_at_period_end: false, }); @@ -2242,10 +2383,10 @@ describe('StripeHelper', () => { describe('retrievePromotionCodeForPlan', () => { it('finds a stripe promotionCode object when a valid code is used', async () => { const promotionCode = { code: 'promo1', coupon: { valid: true } }; - sandbox - .stub(stripeHelper.stripe.promotionCodes, 'list') - .resolves({ data: [promotionCode] }); - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest + .spyOn(stripeHelper.stripe.promotionCodes, 'list') + .mockResolvedValue({ data: [promotionCode] }); + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ plan_metadata: { [STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo1', }, @@ -2259,10 +2400,10 @@ describe('StripeHelper', () => { it('returns undefined when an invalid promo code is used', async () => { const promotionCode = { code: 'promo1', coupon: { valid: true } }; - sandbox - .stub(stripeHelper.stripe.promotionCodes, 'list') - .resolves({ data: [promotionCode] }); - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest + .spyOn(stripeHelper.stripe.promotionCodes, 'list') + .mockResolvedValue({ data: [promotionCode] }); + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ plan_metadata: { [STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo2', }, @@ -2297,9 +2438,9 @@ describe('StripeHelper', () => { }; beforeEach(() => { - sandbox - .stub(stripeHelper, 'validateCouponDurationForPlan') - .resolves(true); + jest + .spyOn(stripeHelper, 'validateCouponDurationForPlan') + .mockResolvedValue(true); }); it('return valid for valid coupon and promotion code', async () => { @@ -2376,10 +2517,10 @@ describe('StripeHelper', () => { it('return invalid for invalid coupon duration for plan', async () => { const promotionCode = promotionCodeTemplate; - sandbox.restore(); - sandbox - .stub(stripeHelper, 'validateCouponDurationForPlan') - .resolves(false); + jest.restoreAllMocks(); + jest + .spyOn(stripeHelper, 'validateCouponDurationForPlan') + .mockResolvedValue(false); const expected = expectedTemplate; const actual = await stripeHelper.verifyPromotionAndCoupon( @@ -2461,7 +2602,7 @@ describe('StripeHelper', () => { it('finds a promo code for a given plan', async () => { const promotionCode = 'promo1'; - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ plan_metadata: { [STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo1', }, @@ -2476,12 +2617,12 @@ describe('StripeHelper', () => { it('finds a promo code in a Firestore config', async () => { const promotionCode = 'promo1'; - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ plan_metadata: { [STRIPE_PRICE_METADATA.PROMOTION_CODES]: '', }, }); - sandbox.stub(stripeHelper, 'maybeGetPlanConfig').resolves({ + jest.spyOn(stripeHelper, 'maybeGetPlanConfig').mockResolvedValue({ promotionCodes: ['promo1'], }); const actual = await stripeHelper.checkPromotionCodeForPlan( @@ -2494,7 +2635,7 @@ describe('StripeHelper', () => { it('does not find a promo code for a given plan', async () => { const promotionCode = 'promo1'; - sandbox.stub(stripeHelper, 'findAbbrevPlanById').resolves({ + jest.spyOn(stripeHelper, 'findAbbrevPlanById').mockResolvedValue({ plan_metadata: { [STRIPE_PRICE_METADATA.PROMOTION_CODES]: 'promo2', }, @@ -2515,7 +2656,7 @@ describe('StripeHelper', () => { subscription: 'sub-1234', }; const mockSub = { collection_method: 'send_invoice' }; - sandbox.stub(stripeHelper, 'expandResource').resolves(mockSub); + jest.spyOn(stripeHelper, 'expandResource').mockResolvedValue(mockSub); const actual = await stripeHelper.invoicePayableWithPaypal(mockInvoice); expect(actual).toBe(true); }); @@ -2523,10 +2664,10 @@ describe('StripeHelper', () => { it('returns false if invoice is sub create', async () => { const mockInvoice = { billing_reason: 'subscription_create' }; const mockSub = { collection_method: 'send_invoice' }; - sandbox.stub(stripeHelper, 'expandResource').resolves(mockSub); + jest.spyOn(stripeHelper, 'expandResource').mockResolvedValue(mockSub); const actual = await stripeHelper.invoicePayableWithPaypal(mockInvoice); expect(actual).toBe(false); - sinon.assert.notCalled(stripeHelper.expandResource as sinon.SinonStub); + expect(stripeHelper.expandResource as jest.Mock).not.toHaveBeenCalled(); }); it('returns false if subscription collection_method isnt invoice', async () => { @@ -2535,7 +2676,7 @@ describe('StripeHelper', () => { subscription: 'sub-1234', }; const mockSub = { collection_method: 'charge_automatically' }; - sandbox.stub(stripeHelper, 'expandResource').resolves(mockSub); + jest.spyOn(stripeHelper, 'expandResource').mockResolvedValue(mockSub); const actual = await stripeHelper.invoicePayableWithPaypal(mockInvoice); expect(actual).toBe(false); }); @@ -2543,11 +2684,13 @@ describe('StripeHelper', () => { describe('getInvoice', () => { it('works successfully', async () => { - sandbox.stub(stripeHelper, 'expandResource').resolves(unpaidInvoice); + jest + .spyOn(stripeHelper, 'expandResource') + .mockResolvedValue(unpaidInvoice); const actual = await stripeHelper.getInvoice(unpaidInvoice.id); expect(actual).toEqual(unpaidInvoice); - sinon.assert.calledOnceWithExactly( - stripeHelper.expandResource, + expect(stripeHelper.expandResource).toHaveBeenCalledTimes(1); + expect(stripeHelper.expandResource).toHaveBeenCalledWith( unpaidInvoice.id, INVOICES_RESOURCE ); @@ -2556,13 +2699,15 @@ describe('StripeHelper', () => { describe('finalizeInvoice', () => { it('works successfully', async () => { - sandbox - .stub(stripeHelper.stripe.invoices, 'finalizeInvoice') - .resolves({}); + jest + .spyOn(stripeHelper.stripe.invoices, 'finalizeInvoice') + .mockResolvedValue({}); const actual = await stripeHelper.finalizeInvoice(unpaidInvoice); expect(actual).toEqual({}); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.finalizeInvoice, + expect( + stripeHelper.stripe.invoices.finalizeInvoice + ).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.finalizeInvoice).toHaveBeenCalledWith( unpaidInvoice.id, { auto_advance: false } ); @@ -2571,26 +2716,27 @@ describe('StripeHelper', () => { describe('refundInvoices', () => { it('refunds invoice with charge unexpanded', async () => { - sandbox.stub(stripeHelper.stripe.refunds, 'create').resolves({}); - sandbox - .stub(stripeHelper.stripe.charges, 'retrieve') - .resolves({ refunded: false }); + jest.spyOn(stripeHelper.stripe.refunds, 'create').mockResolvedValue({}); + jest + .spyOn(stripeHelper.stripe.charges, 'retrieve') + .mockResolvedValue({ refunded: false }); await stripeHelper.refundInvoices([ { ...paidInvoice, collection_method: 'charge_automatically', }, ]); - sinon.assert.calledOnceWithExactly(stripeHelper.stripe.refunds.create, { + expect(stripeHelper.stripe.refunds.create).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.refunds.create).toHaveBeenCalledWith({ charge: paidInvoice.charge, }); }); it('refunds invoice with charge expanded', async () => { - sandbox.stub(stripeHelper.stripe.refunds, 'create').resolves({}); - sandbox - .stub(stripeHelper.stripe.charges, 'retrieve') - .resolves({ refunded: false }); + jest.spyOn(stripeHelper.stripe.refunds, 'create').mockResolvedValue({}); + jest + .spyOn(stripeHelper.stripe.charges, 'retrieve') + .mockResolvedValue({ refunded: false }); await stripeHelper.refundInvoices([ { ...paidInvoice, @@ -2600,33 +2746,34 @@ describe('StripeHelper', () => { }, }, ]); - sinon.assert.calledOnceWithExactly(stripeHelper.stripe.refunds.create, { + expect(stripeHelper.stripe.refunds.create).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.refunds.create).toHaveBeenCalledWith({ charge: paidInvoice.charge, }); }); it('does not refund invoice from PayPal', async () => { - sandbox.stub(stripeHelper.stripe.refunds, 'create').resolves({}); + jest.spyOn(stripeHelper.stripe.refunds, 'create').mockResolvedValue({}); await stripeHelper.refundInvoices([ { ...paidInvoice, collection_method: 'send_invoice', }, ]); - sinon.assert.notCalled(stripeHelper.stripe.refunds.create); + expect(stripeHelper.stripe.refunds.create).not.toHaveBeenCalled(); }); }); describe('updateInvoiceWithPaypalTransactionId', () => { it('works successfully', async () => { - sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({}); + jest.spyOn(stripeHelper.stripe.invoices, 'update').mockResolvedValue({}); const actual = await stripeHelper.updateInvoiceWithPaypalTransactionId( unpaidInvoice, 'tid' ); expect(actual).toEqual({}); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.update, + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledWith( unpaidInvoice.id, { metadata: { paypalTransactionId: 'tid' } } ); @@ -2635,15 +2782,15 @@ describe('StripeHelper', () => { describe('updateInvoiceWithPaypalRefundTransactionId', () => { it('works successfully', async () => { - sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({}); + jest.spyOn(stripeHelper.stripe.invoices, 'update').mockResolvedValue({}); const actual = await stripeHelper.updateInvoiceWithPaypalRefundTransactionId( unpaidInvoice, 'tid' ); expect(actual).toEqual({}); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.update, + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledWith( unpaidInvoice.id, { metadata: { paypalRefundTransactionId: 'tid' } } ); @@ -2652,14 +2799,14 @@ describe('StripeHelper', () => { describe('updateInvoiceWithPaypalRefundReason', () => { it('works successfully', async () => { - sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({}); + jest.spyOn(stripeHelper.stripe.invoices, 'update').mockResolvedValue({}); const actual = await stripeHelper.updateInvoiceWithPaypalRefundReason( unpaidInvoice, 'reason' ); expect(actual).toEqual({}); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.update, + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledWith( unpaidInvoice.id, { metadata: { paypalRefundRefused: 'reason' } } ); @@ -2671,10 +2818,10 @@ describe('StripeHelper', () => { const attemptedInvoice = deepCopy(unpaidInvoice); const actual = stripeHelper.getPaymentAttempts(attemptedInvoice); expect(actual).toBe(0); - sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({}); + jest.spyOn(stripeHelper.stripe.invoices, 'update').mockResolvedValue({}); await stripeHelper.updatePaymentAttempts(attemptedInvoice); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.update, + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledWith( attemptedInvoice.id, { metadata: { paymentAttempts: '1' } } ); @@ -2685,10 +2832,10 @@ describe('StripeHelper', () => { attemptedInvoice.metadata.paymentAttempts = '1'; const actual = stripeHelper.getPaymentAttempts(attemptedInvoice); expect(actual).toBe(1); - sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({}); + jest.spyOn(stripeHelper.stripe.invoices, 'update').mockResolvedValue({}); await stripeHelper.updatePaymentAttempts(attemptedInvoice); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.update, + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledWith( attemptedInvoice.id, { metadata: { paymentAttempts: '2' } } ); @@ -2699,10 +2846,10 @@ describe('StripeHelper', () => { attemptedInvoice.metadata.paymentAttempts = '1'; const actual = stripeHelper.getPaymentAttempts(attemptedInvoice); expect(actual).toBe(1); - sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({}); + jest.spyOn(stripeHelper.stripe.invoices, 'update').mockResolvedValue({}); await stripeHelper.updatePaymentAttempts(attemptedInvoice, 3); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.update, + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledWith( attemptedInvoice.id, { metadata: { paymentAttempts: '3' } } ); @@ -2725,14 +2872,14 @@ describe('StripeHelper', () => { it('returns invoice updated with new email type', async () => { const emailSendInvoice = deepCopy(unpaidInvoice); - sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({}); + jest.spyOn(stripeHelper.stripe.invoices, 'update').mockResolvedValue({}); const actual = await stripeHelper.updateEmailSent( emailSendInvoice, 'paymentFailed' ); expect(actual).toEqual({}); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.update, + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledWith( emailSendInvoice.id, { metadata: emailSentInvoice.metadata } ); @@ -2740,14 +2887,14 @@ describe('StripeHelper', () => { it('returns invoice updated with another email type', async () => { const emailSendInvoice = deepCopy(emailSentInvoice); - sandbox.stub(stripeHelper.stripe.invoices, 'update').resolves({}); + jest.spyOn(stripeHelper.stripe.invoices, 'update').mockResolvedValue({}); const actual = await stripeHelper.updateEmailSent( emailSendInvoice, 'foo' ); expect(actual).toEqual({}); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.update, + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.update).toHaveBeenCalledWith( emailSendInvoice.id, { metadata: { @@ -2760,10 +2907,10 @@ describe('StripeHelper', () => { describe('payInvoiceOutOfBand', () => { it('pays the invoice', async () => { - sandbox.stub(stripeHelper.stripe.invoices, 'pay').resolves({}); + jest.spyOn(stripeHelper.stripe.invoices, 'pay').mockResolvedValue({}); await stripeHelper.payInvoiceOutOfBand(unpaidInvoice); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.pay, + expect(stripeHelper.stripe.invoices.pay).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.pay).toHaveBeenCalledWith( unpaidInvoice.id, { paid_out_of_band: true } ); @@ -2771,24 +2918,24 @@ describe('StripeHelper', () => { it('ignores error if the invoice was already paid', async () => { const alreadyPaidInvoice = { ...deepCopy(unpaidInvoice), paid: true }; - sandbox - .stub(stripeHelper.stripe.invoices, 'pay') - .rejects(new Error('Invoice is already paid')); + jest + .spyOn(stripeHelper.stripe.invoices, 'pay') + .mockRejectedValue(new Error('Invoice is already paid')); await stripeHelper.payInvoiceOutOfBand(alreadyPaidInvoice); - sinon.assert.calledOnce( - stripeHelper.stripe.invoices.pay as sinon.SinonStub - ); + expect( + stripeHelper.stripe.invoices.pay as jest.Mock + ).toHaveBeenCalledTimes(1); }); }); describe('updateCustomerBillingAddress', () => { it('updates Customer with empty PayPal billing address', async () => { - sandbox - .stub(stripeHelper.stripe.customers, 'update') - .resolves({ metadata: {}, tax: {} }); - stripeFirestore.insertCustomerRecordWithBackfill = sandbox - .stub() - .resolves({}); + jest + .spyOn(stripeHelper.stripe.customers, 'update') + .mockResolvedValue({ metadata: {}, tax: {} }); + stripeFirestore.insertCustomerRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); const result = await stripeHelper.updateCustomerBillingAddress({ customerId: customer1.id, options: { @@ -2801,8 +2948,8 @@ describe('StripeHelper', () => { }, }); expect(result).toEqual({ metadata: {}, tax: {} }); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.customers.update, + expect(stripeHelper.stripe.customers.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.customers.update).toHaveBeenCalledWith( customer1.id, { address: { @@ -2816,11 +2963,12 @@ describe('StripeHelper', () => { expand: ['tax'], } ); - sinon.assert.calledOnceWithExactly( - stripeFirestore.insertCustomerRecordWithBackfill as sinon.SinonStub, - undefined, - { metadata: {} } - ); + expect( + stripeFirestore.insertCustomerRecordWithBackfill as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.insertCustomerRecordWithBackfill as jest.Mock + ).toHaveBeenCalledWith(undefined, { metadata: {} }); }); }); @@ -2828,26 +2976,26 @@ describe('StripeHelper', () => { it('skips if the agreement id is already set', async () => { const paypalCustomer = deepCopy(customer1); paypalCustomer.metadata.paypalAgreementId = 'test-1234'; - sandbox.stub(stripeHelper.stripe.customers, 'update').resolves({}); + jest.spyOn(stripeHelper.stripe.customers, 'update').mockResolvedValue({}); await stripeHelper.updateCustomerPaypalAgreement( paypalCustomer, 'test-1234' ); - sinon.assert.callCount(stripeHelper.stripe.customers.update, 0); + expect(stripeHelper.stripe.customers.update).toHaveBeenCalledTimes(0); }); it('updates for a billing agreement id', async () => { const paypalCustomer = deepCopy(customer1); - sandbox.stub(stripeHelper.stripe.customers, 'update').resolves({}); - stripeFirestore.insertCustomerRecordWithBackfill = sandbox - .stub() - .resolves({}); + jest.spyOn(stripeHelper.stripe.customers, 'update').mockResolvedValue({}); + stripeFirestore.insertCustomerRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); await stripeHelper.updateCustomerPaypalAgreement( paypalCustomer, 'test-1234' ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.customers.update, + expect(stripeHelper.stripe.customers.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.customers.update).toHaveBeenCalledWith( paypalCustomer.id, { metadata: { paypalAgreementId: 'test-1234' } } ); @@ -2857,31 +3005,31 @@ describe('StripeHelper', () => { describe('removeCustomerPaypalAgreement', () => { it('removes billing agreement id', async () => { const paypalCustomer = deepCopy(customer1); - sandbox.stub(stripeHelper.stripe.customers, 'update').resolves({}); + jest.spyOn(stripeHelper.stripe.customers, 'update').mockResolvedValue({}); const now = new Date(); - const clock = sinon.useFakeTimers(now.getTime()); - sandbox.stub(dbStub, 'updatePayPalBA').returns(0); - stripeFirestore.insertCustomerRecordWithBackfill = sandbox - .stub() - .resolves({}); + jest.useFakeTimers({ now: now.getTime() }); + jest.spyOn(dbStub, 'updatePayPalBA').mockReturnValue(0); + stripeFirestore.insertCustomerRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); await stripeHelper.removeCustomerPaypalAgreement( 'uid', paypalCustomer.id, 'billingAgreementId' ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.customers.update, + expect(stripeHelper.stripe.customers.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.customers.update).toHaveBeenCalledWith( paypalCustomer.id, { metadata: { paypalAgreementId: null } } ); - sinon.assert.calledOnceWithExactly( - dbStub.updatePayPalBA, + expect(dbStub.updatePayPalBA).toHaveBeenCalledTimes(1); + expect(dbStub.updatePayPalBA).toHaveBeenCalledWith( 'uid', 'billingAgreementId', 'Cancelled', - clock.now + Date.now() ); - clock.restore(); + jest.useRealTimers(); }); }); @@ -2895,47 +3043,55 @@ describe('StripeHelper', () => { yield invoice; yield invoice2; } - sandbox.stub(stripeHelper.stripe.invoices, 'list').returns(genInvoice()); + jest + .spyOn(stripeHelper.stripe.invoices, 'list') + .mockReturnValue(genInvoice()); const actual: any[] = []; for await (const item of stripeHelper.fetchOpenInvoices(0)) { actual.push(item); } expect(actual).toEqual([invoice]); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.list as sinon.SinonStub, - { - customer: undefined, - limit: 100, - collection_method: 'send_invoice', - status: 'open', - created: 0, - expand: ['data.customer', 'data.subscription'], - } - ); + expect( + stripeHelper.stripe.invoices.list as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripe.invoices.list as jest.Mock + ).toHaveBeenCalledWith({ + customer: undefined, + limit: 100, + collection_method: 'send_invoice', + status: 'open', + created: 0, + expand: ['data.customer', 'data.subscription'], + }); }); }); describe('markUncollectible', () => { it('returns an invoice marked uncollectible', async () => { - sandbox - .stub(stripeHelper.stripe.invoices, 'markUncollectible') - .resolves({}); - sandbox.stub(stripeHelper.stripe.invoices, 'list').resolves({}); + jest + .spyOn(stripeHelper.stripe.invoices, 'markUncollectible') + .mockResolvedValue({}); + jest.spyOn(stripeHelper.stripe.invoices, 'list').mockResolvedValue({}); const actual = await stripeHelper.markUncollectible(unpaidInvoice); expect(actual).toEqual({}); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.invoices.markUncollectible, - unpaidInvoice.id - ); + expect( + stripeHelper.stripe.invoices.markUncollectible + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripe.invoices.markUncollectible + ).toHaveBeenCalledWith(unpaidInvoice.id); }); }); describe('cancelSubscription', () => { it('sets subscription to cancelled', async () => { - sandbox.stub(stripeHelper.stripe.subscriptions, 'cancel').resolves({}); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'cancel') + .mockResolvedValue({}); await stripeHelper.cancelSubscription('subscriptionId'); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.cancel, + expect(stripeHelper.stripe.subscriptions.cancel).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.subscriptions.cancel).toHaveBeenCalledWith( 'subscriptionId' ); }); @@ -3039,10 +3195,14 @@ describe('StripeHelper', () => { }); it('returns null and sends sentry error with no charges', () => { - const scopeContextSpy = sinon.fake(); + const scopeContextSpy = jest.fn(); const scopeSpy = { setContext: scopeContextSpy }; - sandbox.replace(Sentry, 'withScope', ((fn: any) => fn(scopeSpy)) as any); - sandbox.replace(sentryModule, 'reportSentryMessage', sinon.stub()); + jest + .spyOn(Sentry, 'withScope') + .mockImplementation(((fn: any) => fn(scopeSpy)) as any); + jest + .spyOn(sentryModule, 'reportSentryMessage') + .mockImplementation(jest.fn()); const latest_invoice = { ...subscriptionCreatedInvoice, @@ -3052,27 +3212,33 @@ describe('StripeHelper', () => { const result = stripeHelper.extractSourceCountryFromSubscription(subscription); expect(result).toBeNull(); - expect(scopeContextSpy.calledOnce).toBe(true); - expect(sentryModule.reportSentryMessage.calledOnce).toBe(true); + expect(scopeContextSpy.mock.calls.length === 1).toBe(true); + expect(sentryModule.reportSentryMessage.mock.calls.length === 1).toBe( + true + ); }); }); describe('allTaxRates', () => { it('pulls a list of tax rates and caches it', async () => { expect(await stripeHelper.allTaxRates()).toHaveLength(2); - expect(mockRedis.get.calledOnce).toBeTruthy(); + expect(mockRedis.get.mock.calls.length === 1).toBeTruthy(); expect(await stripeHelper.allTaxRates()).toHaveLength(2); - expect(mockRedis.get.calledTwice).toBeTruthy(); - expect(mockRedis.set.calledOnce).toBeTruthy(); + expect(mockRedis.get).toHaveBeenCalledTimes(2); + expect(mockRedis.set.mock.calls.length === 1).toBeTruthy(); // Assert that a TTL was set for this cache entry - expect(mockRedis.set.args[0][2]).toEqual([ - 'EX', - mockConfig.subhub.stripeTaxRatesCacheTtlSeconds, - ]); + expect(mockRedis.set).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + ['EX', mockConfig.subhub.stripeTaxRatesCacheTtlSeconds] + ); - expect(stripeHelper.stripe.taxRates.list.calledOnce).toBeTruthy(); + expect( + stripeHelper.stripe.taxRates.list.mock.calls.length === 1 + ).toBeTruthy(); expect(await stripeHelper.allTaxRates()).toEqual( JSON.parse(await mockRedis.get('listStripeTaxRates')) @@ -3084,10 +3250,12 @@ describe('StripeHelper', () => { it('updates the tax rates in the cache', async () => { const newList = ['xyz']; await stripeHelper.updateAllTaxRates(newList); - expect(mockRedis.set.args[0][2]).toEqual([ - 'EX', - mockConfig.subhub.stripeTaxRatesCacheTtlSeconds, - ]); + expect(mockRedis.set).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + ['EX', mockConfig.subhub.stripeTaxRatesCacheTtlSeconds] + ); expect(newList).toEqual( JSON.parse(await mockRedis.get('listStripeTaxRates')) ); @@ -3116,8 +3284,8 @@ describe('StripeHelper', () => { describe('allConfiguredPlans', () => { it('gets a list of configured plans', async () => { const thePlans = await stripeHelper.allPlans(); - sandbox.spy(stripeHelper, 'allPlans'); - sandbox.spy(stripeHelper.paymentConfigManager, 'getMergedConfig'); + jest.spyOn(stripeHelper, 'allPlans'); + jest.spyOn(stripeHelper.paymentConfigManager, 'getMergedConfig'); const actual = await stripeHelper.allConfiguredPlans(); actual.forEach((p: any, idx: number) => { expect(p.id).toBe(thePlans[idx].id); @@ -3129,31 +3297,35 @@ describe('StripeHelper', () => { } }); expect( - (stripeHelper.allPlans as sinon.SinonStub).calledOnce + (stripeHelper.allPlans as jest.Mock).mock.calls.length === 1 ).toBeTruthy(); expect( // one of the plans does not have a matching ProductConfig - stripeHelper.paymentConfigManager.getMergedConfig.calledTwice - ).toBeTruthy(); + stripeHelper.paymentConfigManager.getMergedConfig + ).toHaveBeenCalledTimes(2); }); }); describe('allPlans', () => { it('pulls a list of plans and caches it', async () => { expect(await stripeHelper.allPlans()).toHaveLength(3); - expect(mockRedis.get.calledOnce).toBeTruthy(); + expect(mockRedis.get.mock.calls.length === 1).toBeTruthy(); expect(await stripeHelper.allPlans()).toHaveLength(3); - expect(mockRedis.get.calledTwice).toBeTruthy(); - expect(mockRedis.set.calledOnce).toBeTruthy(); + expect(mockRedis.get).toHaveBeenCalledTimes(2); + expect(mockRedis.set.mock.calls.length === 1).toBeTruthy(); // Assert that a TTL was set for this cache entry - expect(mockRedis.set.args[0][2]).toEqual([ - 'EX', - mockConfig.subhub.plansCacheTtlSeconds, - ]); + expect(mockRedis.set).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + ['EX', mockConfig.subhub.plansCacheTtlSeconds] + ); - expect(stripeHelper.stripe.plans.list.calledOnce).toBeTruthy(); + expect( + stripeHelper.stripe.plans.list.mock.calls.length === 1 + ).toBeTruthy(); expect(await stripeHelper.allPlans()).toEqual( JSON.parse(await mockRedis.get('listStripePlans')) @@ -3165,10 +3337,12 @@ describe('StripeHelper', () => { it('updates the plans in the cache', async () => { const newList = ['xyz']; await stripeHelper.updateAllPlans(newList); - expect(mockRedis.set.args[0][2]).toEqual([ - 'EX', - mockConfig.subhub.plansCacheTtlSeconds, - ]); + expect(mockRedis.set).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + ['EX', mockConfig.subhub.plansCacheTtlSeconds] + ); expect(newList).toEqual( JSON.parse(await mockRedis.get('listStripePlans')) ); @@ -3189,7 +3363,7 @@ describe('StripeHelper', () => { }, }; beforeEach(() => { - sandbox.stub(stripeHelper, 'allProducts').resolves([mockProduct]); + jest.spyOn(stripeHelper, 'allProducts').mockResolvedValue([mockProduct]); }); it('returns undefined if the product is not in allProducts', async () => { @@ -3206,19 +3380,23 @@ describe('StripeHelper', () => { describe('allProducts', () => { it('pulls a list of products and caches it', async () => { expect(await stripeHelper.allProducts()).toHaveLength(3); - expect(mockRedis.get.calledOnce).toBeTruthy(); + expect(mockRedis.get.mock.calls.length === 1).toBeTruthy(); expect(await stripeHelper.allProducts()).toHaveLength(3); - expect(mockRedis.get.calledTwice).toBeTruthy(); - expect(mockRedis.set.calledOnce).toBeTruthy(); + expect(mockRedis.get).toHaveBeenCalledTimes(2); + expect(mockRedis.set.mock.calls.length === 1).toBeTruthy(); // Assert that a TTL was set for this cache entry - expect(mockRedis.set.args[0][2]).toEqual([ - 'EX', - mockConfig.subhub.plansCacheTtlSeconds, - ]); + expect(mockRedis.set).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + ['EX', mockConfig.subhub.plansCacheTtlSeconds] + ); - expect(stripeHelper.stripe.products.list.calledOnce).toBeTruthy(); + expect( + stripeHelper.stripe.products.list.mock.calls.length === 1 + ).toBeTruthy(); expect(await stripeHelper.allProducts()).toEqual( JSON.parse(await mockRedis.get('listStripeProducts')) @@ -3230,10 +3408,12 @@ describe('StripeHelper', () => { it('updates the products in the cache', async () => { const newList = ['x']; await stripeHelper.updateAllProducts(newList); - expect(mockRedis.set.args[0][2]).toEqual([ - 'EX', - mockConfig.subhub.plansCacheTtlSeconds, - ]); + expect(mockRedis.set).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + ['EX', mockConfig.subhub.plansCacheTtlSeconds] + ); expect(newList).toEqual( JSON.parse(await mockRedis.get('listStripeProducts')) ); @@ -3242,10 +3422,12 @@ describe('StripeHelper', () => { describe('allAbbrevProducts', () => { it('returns a AbbrevProduct list based on allProducts', async () => { - sandbox.spy(stripeHelper, 'allProducts'); + jest.spyOn(stripeHelper, 'allProducts'); const actual = await stripeHelper.allAbbrevProducts(); - expect(stripeHelper.stripe.products.list.calledOnce).toBeTruthy(); - expect(stripeHelper.allProducts.calledOnce).toBeTruthy(); + expect( + stripeHelper.stripe.products.list.mock.calls.length === 1 + ).toBeTruthy(); + expect(stripeHelper.allProducts.mock.calls.length === 1).toBeTruthy(); expect(actual).toEqual( [product1, product2, product3].map((p: any) => ({ product_id: p.id, @@ -3309,10 +3491,10 @@ describe('StripeHelper', () => { goodPlan, ]; - listStripePlans.restore(); - sandbox - .stub(stripeHelper.stripe.plans, 'list') - .returns(planList as any); + listStripePlans.mockRestore(); + jest + .spyOn(stripeHelper.stripe.plans, 'list') + .mockReturnValue(planList as any); const actual = await stripeHelper.fetchAllPlans(); @@ -3328,26 +3510,30 @@ describe('StripeHelper', () => { ); /** Verify the error cases were handled properly */ - expect(stripeHelper.log.error.callCount).toBe(4); + expect(stripeHelper.log.error).toHaveBeenCalledTimes(4); /** Plan.product is null */ - expect(stripeHelper.log.error.getCall(0).args[0]).toBe( - `fetchAllPlans - Plan "${planMissingProduct.id}" missing Product` + expect(stripeHelper.log.error).toHaveBeenNthCalledWith( + 1, + `fetchAllPlans - Plan "${planMissingProduct.id}" missing Product`, + expect.anything() ); /** Plan.product is string */ - expect(stripeHelper.log.error.getCall(1).args[0]).toBe( - `fetchAllPlans - Plan "${planUnloadedProduct.id}" failed to load Product` + expect(stripeHelper.log.error).toHaveBeenNthCalledWith( + 2, + `fetchAllPlans - Plan "${planUnloadedProduct.id}" failed to load Product`, + expect.anything() ); /** Plan.product is DeletedProduct */ - expect(stripeHelper.log.error.getCall(2).args[0]).toBe( - `fetchAllPlans - Plan "${planDeletedProduct.id}" associated with Deleted Product` + expect(stripeHelper.log.error).toHaveBeenNthCalledWith( + 3, + `fetchAllPlans - Plan "${planDeletedProduct.id}" associated with Deleted Product`, + expect.anything() ); /** Plan.product has invalid metadata */ expect( - stripeHelper.log.error - .getCall(3) - .args[0].includes( - `fetchAllPlans: ${planInvalidProductMetadata.id} metadata invalid:` - ) + stripeHelper.log.error.mock.calls[3][0].includes( + `fetchAllPlans: ${planInvalidProductMetadata.id} metadata invalid:` + ) ).toBe(true); }); }); @@ -3359,25 +3545,27 @@ describe('StripeHelper', () => { const updatedSubscription = deepCopy(subscription1); updatedSubscription.cancel_at_period_end = false; const newProps = { cancel_at_period_end: false }; - sandbox - .stub(stripeHelper.stripe.subscriptions, 'update') - .resolves(updatedSubscription); - stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox - .stub() - .resolves(); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'update') + .mockResolvedValue(updatedSubscription); + stripeFirestore.insertSubscriptionRecordWithBackfill = jest + .fn() + .mockResolvedValue(); const actual = await stripeHelper.updateSubscriptionAndBackfill( subscription, newProps ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.update, + expect(stripeHelper.stripe.subscriptions.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.subscriptions.update).toHaveBeenCalledWith( subscription.id, newProps ); - sinon.assert.calledOnceWithExactly( - stripeFirestore.insertSubscriptionRecordWithBackfill, - updatedSubscription - ); + expect( + stripeFirestore.insertSubscriptionRecordWithBackfill + ).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.insertSubscriptionRecordWithBackfill + ).toHaveBeenCalledWith(updatedSubscription); expect(actual).toEqual(updatedSubscription); }); }); @@ -3393,10 +3581,10 @@ describe('StripeHelper', () => { previous_plan_id: 'plan_123', plan_change_date: 12345678, }; - sandbox.stub(moment, 'unix').returns(unixTimestamp); - sandbox - .stub(stripeHelper, 'updateSubscriptionAndBackfill') - .resolves(subscription2); + jest.spyOn(moment, 'unix').mockReturnValue(unixTimestamp); + jest + .spyOn(stripeHelper, 'updateSubscriptionAndBackfill') + .mockResolvedValue(subscription2); const actual = await stripeHelper.changeSubscriptionPlan( subscription, 'plan_G93mMKnIFCjZek', @@ -3407,9 +3595,9 @@ describe('StripeHelper', () => { }); it('throws an error if the user already upgraded', async () => { - sandbox - .stub(stripeHelper, 'updateSubscriptionAndBackfill') - .resolves(subscription2); + jest + .spyOn(stripeHelper, 'updateSubscriptionAndBackfill') + .mockResolvedValue(subscription2); let thrown: any; try { await stripeHelper.changeSubscriptionPlan( @@ -3420,13 +3608,15 @@ describe('StripeHelper', () => { thrown = err; } expect(thrown.errno).toBe(error.ERRNO.SUBSCRIPTION_ALREADY_CHANGED); - sinon.assert.notCalled(stripeHelper.updateSubscriptionAndBackfill); + expect(stripeHelper.updateSubscriptionAndBackfill).not.toHaveBeenCalled(); }); }); describe('cancelSubscriptionForCustomer', () => { beforeEach(() => { - sandbox.stub(stripeHelper, 'updateSubscriptionAndBackfill').resolves({}); + jest + .spyOn(stripeHelper, 'updateSubscriptionAndBackfill') + .mockResolvedValue({}); }); describe('customer owns subscription', () => { @@ -3434,17 +3624,19 @@ describe('StripeHelper', () => { const existingMetadata = { foo: 'bar' }; const unixTimestamp = moment().unix(); const subscription = { ...subscription2, metadata: existingMetadata }; - sandbox.stub(moment, 'unix').returns(unixTimestamp); - sandbox - .stub(stripeHelper, 'subscriptionForCustomer') - .resolves(subscription); + jest.spyOn(moment, 'unix').mockReturnValue(unixTimestamp); + jest + .spyOn(stripeHelper, 'subscriptionForCustomer') + .mockResolvedValue(subscription); await stripeHelper.cancelSubscriptionForCustomer( '123', 'test@example.com', subscription2.id ); - sinon.assert.calledOnceWithExactly( - stripeHelper.updateSubscriptionAndBackfill, + expect( + stripeHelper.updateSubscriptionAndBackfill + ).toHaveBeenCalledTimes(1); + expect(stripeHelper.updateSubscriptionAndBackfill).toHaveBeenCalledWith( subscription, { cancel_at_period_end: true, @@ -3459,7 +3651,7 @@ describe('StripeHelper', () => { describe('customer does not own the subscription', () => { it('throws an error', async () => { - sandbox.stub(stripeHelper, 'subscriptionForCustomer').resolves(); + jest.spyOn(stripeHelper, 'subscriptionForCustomer').mockResolvedValue(); try { await stripeHelper.cancelSubscriptionForCustomer( '123', @@ -3469,7 +3661,9 @@ describe('StripeHelper', () => { throw new Error('Method expected to reject'); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.UNKNOWN_SUBSCRIPTION); - sinon.assert.notCalled(stripeHelper.updateSubscriptionAndBackfill); + expect( + stripeHelper.updateSubscriptionAndBackfill + ).not.toHaveBeenCalled(); } }); }); @@ -3484,12 +3678,12 @@ describe('StripeHelper', () => { ...deepCopy(subscription2), metadata: existingMetadata, }; - sandbox - .stub(stripeHelper, 'updateSubscriptionAndBackfill') - .resolves(expected); - sandbox - .stub(stripeHelper, 'subscriptionForCustomer') - .resolves(expected); + jest + .spyOn(stripeHelper, 'updateSubscriptionAndBackfill') + .mockResolvedValue(expected); + jest + .spyOn(stripeHelper, 'subscriptionForCustomer') + .mockResolvedValue(expected); const actual = await stripeHelper.reactivateSubscriptionForCustomer( '123', 'test@example.com', @@ -3503,12 +3697,12 @@ describe('StripeHelper', () => { it('returns the updated subscription', async () => { const expected = deepCopy(subscription2); expected.status = 'trialing'; - sandbox - .stub(stripeHelper, 'subscriptionForCustomer') - .resolves(expected); - sandbox - .stub(stripeHelper, 'updateSubscriptionAndBackfill') - .resolves(expected); + jest + .spyOn(stripeHelper, 'subscriptionForCustomer') + .mockResolvedValue(expected); + jest + .spyOn(stripeHelper, 'updateSubscriptionAndBackfill') + .mockResolvedValue(expected); const actual = await stripeHelper.reactivateSubscriptionForCustomer( '123', 'test@example.com', @@ -3522,12 +3716,12 @@ describe('StripeHelper', () => { it('throws an error', async () => { const expected = deepCopy(subscription2); expected.status = 'unpaid'; - sandbox - .stub(stripeHelper, 'subscriptionForCustomer') - .resolves(expected); - sandbox - .stub(stripeHelper, 'updateSubscriptionAndBackfill') - .resolves(expected); + jest + .spyOn(stripeHelper, 'subscriptionForCustomer') + .mockResolvedValue(expected); + jest + .spyOn(stripeHelper, 'updateSubscriptionAndBackfill') + .mockResolvedValue(expected); try { await stripeHelper.reactivateSubscriptionForCustomer( '123', @@ -3537,7 +3731,9 @@ describe('StripeHelper', () => { throw new Error('Method expected to reject'); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.BACKEND_SERVICE_FAILURE); - sinon.assert.notCalled(stripeHelper.updateSubscriptionAndBackfill); + expect( + stripeHelper.updateSubscriptionAndBackfill + ).not.toHaveBeenCalled(); } }); }); @@ -3545,8 +3741,10 @@ describe('StripeHelper', () => { describe('customer does not own the subscription', () => { it('throws an error', async () => { - sandbox.stub(stripeHelper, 'subscriptionForCustomer').resolves(); - sandbox.stub(stripeHelper, 'updateSubscriptionAndBackfill').resolves(); + jest.spyOn(stripeHelper, 'subscriptionForCustomer').mockResolvedValue(); + jest + .spyOn(stripeHelper, 'updateSubscriptionAndBackfill') + .mockResolvedValue(); try { await stripeHelper.reactivateSubscriptionForCustomer( '123', @@ -3556,7 +3754,9 @@ describe('StripeHelper', () => { throw new Error('Method expected to reject'); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.UNKNOWN_SUBSCRIPTION); - sinon.assert.notCalled(stripeHelper.updateSubscriptionAndBackfill); + expect( + stripeHelper.updateSubscriptionAndBackfill + ).not.toHaveBeenCalled(); } }); }); @@ -3566,13 +3766,15 @@ describe('StripeHelper', () => { it('updates stripe if theres a tax id for the currency', async () => { const customer = deepCopy(customer1); stripeHelper.taxIds = { EUR: 'EU1234' }; - sandbox.stub(stripeHelper.stripe.customers, 'update').resolves(customer); - stripeFirestore.insertCustomerRecordWithBackfill = sandbox - .stub() - .resolves({}); + jest + .spyOn(stripeHelper.stripe.customers, 'update') + .mockResolvedValue(customer); + stripeFirestore.insertCustomerRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); await stripeHelper.addTaxIdToCustomer(customer, 'eur'); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.customers.update, + expect(stripeHelper.stripe.customers.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.customers.update).toHaveBeenCalledWith( customer.id, { invoice_settings: { @@ -3586,13 +3788,15 @@ describe('StripeHelper', () => { const customer = deepCopy(customer1); stripeHelper.taxIds = { EUR: 'EU1234' }; customer.currency = 'eur'; - sandbox.stub(stripeHelper.stripe.customers, 'update').resolves(customer); - stripeFirestore.insertCustomerRecordWithBackfill = sandbox - .stub() - .resolves({}); + jest + .spyOn(stripeHelper.stripe.customers, 'update') + .mockResolvedValue(customer); + stripeFirestore.insertCustomerRecordWithBackfill = jest + .fn() + .mockResolvedValue({}); await stripeHelper.addTaxIdToCustomer(customer); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.customers.update, + expect(stripeHelper.stripe.customers.update).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.customers.update).toHaveBeenCalledWith( customer.id, { invoice_settings: { @@ -3600,27 +3804,28 @@ describe('StripeHelper', () => { }, } ); - sinon.assert.calledOnceWithExactly( - stripeFirestore.insertCustomerRecordWithBackfill, - customer.metadata.userid, - customer - ); + expect( + stripeFirestore.insertCustomerRecordWithBackfill + ).toHaveBeenCalledTimes(1); + expect( + stripeFirestore.insertCustomerRecordWithBackfill + ).toHaveBeenCalledWith(customer.metadata.userid, customer); }); it('does not update stripe with no tax id found', async () => { const customer = deepCopy(customer1); stripeHelper.taxIds = { EUR: 'EU1234' }; - sandbox.stub(stripeHelper.stripe.customers, 'update').resolves({}); + jest.spyOn(stripeHelper.stripe.customers, 'update').mockResolvedValue({}); await stripeHelper.addTaxIdToCustomer(customer, 'usd'); - sinon.assert.notCalled(stripeHelper.stripe.customers.update); + expect(stripeHelper.stripe.customers.update).not.toHaveBeenCalled(); }); }); describe('fetchInvoicesForActiveSubscriptions', () => { it('returns empty array if customer has no active subscriptions', async () => { - sandbox - .stub(stripeHelper.stripe.subscriptions, 'list') - .resolves({ data: [] }); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'list') + .mockResolvedValue({ data: [] }); const result = await stripeHelper.fetchInvoicesForActiveSubscriptions( existingUid, 'paid' @@ -3633,10 +3838,10 @@ describe('StripeHelper', () => { id: 'idString', subscription: 'idSub', }; - sandbox.stub(stripeHelper.stripe.subscriptions, 'list').resolves({ + jest.spyOn(stripeHelper.stripe.subscriptions, 'list').mockResolvedValue({ data: [{ id: 'idNull' }, { id: 'subIdExpanded' }, { id: 'idSub' }], }); - sandbox.stub(stripeHelper.stripe.invoices, 'list').resolves({ + jest.spyOn(stripeHelper.stripe.invoices, 'list').mockResolvedValue({ data: [{ id: 'idNull', subscription: null }, { ...expectedString }], }); const result = await stripeHelper.fetchInvoicesForActiveSubscriptions( @@ -3647,10 +3852,12 @@ describe('StripeHelper', () => { }); it('fetches invoices no older than earliestCreatedDate', async () => { - sandbox.stub(stripeHelper.stripe.subscriptions, 'list').resolves({ + jest.spyOn(stripeHelper.stripe.subscriptions, 'list').mockResolvedValue({ data: [{ id: 'idNull' }], }); - sandbox.stub(stripeHelper.stripe.invoices, 'list').resolves({ data: [] }); + jest + .spyOn(stripeHelper.stripe.invoices, 'list') + .mockResolvedValue({ data: [] }); const expectedDateTime = 1706667661086; const expectedDate = new Date(expectedDateTime); @@ -3661,7 +3868,8 @@ describe('StripeHelper', () => { ); expect(result).toEqual([]); - sinon.assert.calledOnceWithExactly(stripeHelper.stripe.invoices.list, { + expect(stripeHelper.stripe.invoices.list).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.invoices.list).toHaveBeenCalledWith({ customer: 'customerId', status: 'paid', created: { gte: Math.floor(expectedDateTime / 1000) }, @@ -3673,32 +3881,38 @@ describe('StripeHelper', () => { let stripeCustomerDel: any; beforeEach(() => { - stripeCustomerDel = sandbox - .stub(stripeHelper.stripe.customers, 'del') - .resolves(); + stripeCustomerDel = jest + .spyOn(stripeHelper.stripe.customers, 'del') + .mockResolvedValue(); }); describe('when customer is found', () => { it('deletes customer in Stripe, removes AccountCustomer and cached records, detach payment method', async () => { const uid = chance.guid({ version: 4 }).replace(/-/g, ''); const customerId = 'cus_1234456sdf'; - sandbox.stub(stripeHelper, 'fetchCustomer').resolves({ + jest.spyOn(stripeHelper, 'fetchCustomer').mockResolvedValue({ invoice_settings: { default_payment_method: { id: 'pm9001' } }, }); - sandbox.stub(stripeHelper.stripe.paymentMethods, 'detach').resolves(); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'detach') + .mockResolvedValue(); const testAccount = await createAccountCustomer(uid, customerId); await stripeHelper.removeCustomer(testAccount.uid); - expect(stripeCustomerDel.calledOnce).toBeTruthy(); + expect(stripeCustomerDel.mock.calls.length === 1).toBeTruthy(); expect(await getAccountCustomerByUid(uid)).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - stripeHelper.fetchCustomer as sinon.SinonStub, + expect(stripeHelper.fetchCustomer as jest.Mock).toHaveBeenCalledTimes( + 1 + ); + expect(stripeHelper.fetchCustomer as jest.Mock).toHaveBeenCalledWith( uid, ['invoice_settings.default_payment_method'] ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.paymentMethods.detach as sinon.SinonStub, - 'pm9001' - ); + expect( + stripeHelper.stripe.paymentMethods.detach as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripe.paymentMethods.detach as jest.Mock + ).toHaveBeenCalledWith('pm9001'); }); }); @@ -3706,33 +3920,40 @@ describe('StripeHelper', () => { it('deletes everything and updates metadata', async () => { const uid = chance.guid({ version: 4 }).replace(/-/g, ''); const customerId = 'cus_1234456sdf'; - sandbox.stub(stripeHelper, 'fetchCustomer').resolves({ + jest.spyOn(stripeHelper, 'fetchCustomer').mockResolvedValue({ invoice_settings: { default_payment_method: { id: 'pm9001' } }, subscriptions: { data: [{ id: 'sub_123', status: 'active' }], }, }); - sandbox.stub(stripeHelper.stripe.paymentMethods, 'detach').resolves(); - sandbox.stub(stripeHelper.stripe.subscriptions, 'update').resolves(); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'detach') + .mockResolvedValue(); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'update') + .mockResolvedValue(); const testAccount = await createAccountCustomer(uid, customerId); await stripeHelper.removeCustomer(testAccount.uid, { cancellation_reason: 'test', }); - expect(stripeCustomerDel.calledOnce).toBeTruthy(); + expect(stripeCustomerDel.mock.calls.length === 1).toBeTruthy(); expect(await getAccountCustomerByUid(uid)).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.update as sinon.SinonStub, - 'sub_123', - { - metadata: { - cancellation_reason: 'test', - }, - } - ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.paymentMethods.detach as sinon.SinonStub, - 'pm9001' - ); + expect( + stripeHelper.stripe.subscriptions.update as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripe.subscriptions.update as jest.Mock + ).toHaveBeenCalledWith('sub_123', { + metadata: { + cancellation_reason: 'test', + }, + }); + expect( + stripeHelper.stripe.paymentMethods.detach as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripe.paymentMethods.detach as jest.Mock + ).toHaveBeenCalledWith('pm9001'); }); }); @@ -3740,7 +3961,7 @@ describe('StripeHelper', () => { it('does not throw any errors', async () => { const uid = chance.guid({ version: 4 }).replace(/-/g, ''); await stripeHelper.removeCustomer(uid); - expect(stripeCustomerDel.notCalled).toBeTruthy(); + expect(stripeCustomerDel.mock.calls.length === 0).toBeTruthy(); }); }); @@ -3749,18 +3970,22 @@ describe('StripeHelper', () => { const uid = chance.guid({ version: 4 }).replace(/-/g, ''); const customerId = 'cus_1234456sdf'; const testAccount = await createAccountCustomer(uid, customerId); - sandbox.stub(stripeHelper, 'fetchCustomer').resolves({ + jest.spyOn(stripeHelper, 'fetchCustomer').mockResolvedValue({ invoice_settings: { default_payment_method: { id: 'pm9001' } }, }); - sandbox.stub(stripeHelper.stripe.paymentMethods, 'detach').resolves(); - const deleteCustomer = sandbox - .stub(dbStub, 'deleteAccountCustomer') - .returns(0); + jest + .spyOn(stripeHelper.stripe.paymentMethods, 'detach') + .mockResolvedValue(); + const deleteCustomer = jest + .spyOn(dbStub, 'deleteAccountCustomer') + .mockReturnValue(0); await stripeHelper.removeCustomer(testAccount.uid); - expect(deleteCustomer.calledOnce).toBeTruthy(); - expect(stripeHelper.log.error.calledOnce).toBeTruthy(); - expect(stripeHelper.log.error.getCall(0).args[0]).toBe( - `StripeHelper.removeCustomer failed to remove AccountCustomer record for uid ${uid}` + expect(deleteCustomer).toHaveBeenCalledTimes(1); + expect(stripeHelper.log.error).toHaveBeenCalledTimes(1); + expect(stripeHelper.log.error).toHaveBeenNthCalledWith( + 1, + `StripeHelper.removeCustomer failed to remove AccountCustomer record for uid ${uid}`, + expect.anything() ); }); }); @@ -3783,9 +4008,9 @@ describe('StripeHelper', () => { yield subscription2; yield subscription3; } - sandbox - .stub(stripeHelper.stripe.subscriptions, 'list') - .returns(genSubscription()); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'list') + .mockReturnValue(genSubscription()); const actual: any[] = []; for await (const item of stripeHelper.findActiveSubscriptionsByPlanId( ...argsHelper @@ -3793,8 +4018,8 @@ describe('StripeHelper', () => { actual.push(item); } expect(actual).toEqual([subscription1, subscription2]); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.list, + expect(stripeHelper.stripe.subscriptions.list).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.subscriptions.list).toHaveBeenCalledWith( argsStripe ); }); @@ -3807,9 +4032,9 @@ describe('StripeHelper', () => { yield subscription2; yield subscription3; } - sandbox - .stub(stripeHelper.stripe.subscriptions, 'list') - .returns(genSubscription()); + jest + .spyOn(stripeHelper.stripe.subscriptions, 'list') + .mockReturnValue(genSubscription()); const actual: any[] = []; for await (const item of stripeHelper.findActiveSubscriptionsByPlanId( ...argsHelper @@ -3817,8 +4042,8 @@ describe('StripeHelper', () => { actual.push(item); } expect(actual).toEqual([subscription1, subscription2]); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.list, + expect(stripeHelper.stripe.subscriptions.list).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripe.subscriptions.list).toHaveBeenCalledWith( argsStripe ); }); @@ -3828,7 +4053,9 @@ describe('StripeHelper', () => { it('finds a valid plan', async () => { const planId = 'plan_G93lTs8hfK7NNG'; const result = await stripeHelper.findAbbrevPlanById(planId); - expect(stripeHelper.stripe.plans.list.calledOnce).toBeTruthy(); + expect( + stripeHelper.stripe.plans.list.mock.calls.length === 1 + ).toBeTruthy(); expect(result.plan_id).toBeTruthy(); }); @@ -3840,7 +4067,9 @@ describe('StripeHelper', () => { } catch (err) { thrown = err; } - expect(stripeHelper.stripe.plans.list.calledOnce).toBeTruthy(); + expect( + stripeHelper.stripe.plans.list.mock.calls.length === 1 + ).toBeTruthy(); expect(thrown).toBeInstanceOf(Error); expect(thrown.errno).toBe(error.ERRNO.UNKNOWN_SUBSCRIPTION_PLAN); }); @@ -3887,9 +4116,9 @@ describe('StripeHelper', () => { describe('constructWebhookEvent', () => { it('calls stripe.webhooks.construct event', () => { const expected = 'the expected result'; - sandbox - .stub(stripeHelper.stripe.webhooks, 'constructEvent') - .returns(expected); + jest + .spyOn(stripeHelper.stripe.webhooks, 'constructEvent') + .mockReturnValue(expected); const actual = stripeHelper.constructWebhookEvent([], 'signature'); expect(actual).toBe(expected); @@ -3994,7 +4223,9 @@ describe('StripeHelper', () => { describe('fetchCustomer', () => { it('fetches an existing customer', async () => { - sandbox.stub(stripeHelper, 'expandResource').returns(deepCopy(customer1)); + jest + .spyOn(stripeHelper, 'expandResource') + .mockReturnValue(deepCopy(customer1)); const result = await stripeHelper.fetchCustomer(existingCustomer.uid); expect(result).toEqual(customer1); }); @@ -4009,14 +4240,16 @@ describe('StripeHelper', () => { }); it('returns void if the stripe customer is deleted and updates db', async () => { - sandbox.stub(stripeHelper, 'expandResource').returns(deletedCustomer); + jest + .spyOn(stripeHelper, 'expandResource') + .mockReturnValue(deletedCustomer); expect(await getAccountCustomerByUid(existingCustomer.uid)).toBeDefined(); await stripeHelper.fetchCustomer( existingCustomer.uid, 'test@example.com' ); - expect(stripeHelper.expandResource.calledOnce).toBe(true); + expect(stripeHelper.expandResource.mock.calls.length === 1).toBe(true); expect( await getAccountCustomerByUid(existingCustomer.uid) ).toBeUndefined(); @@ -4029,23 +4262,26 @@ describe('StripeHelper', () => { const customer = deepCopy(customer1); customer.currency = null; const customerSecond = deepCopy(customer1); - const expandStub = sandbox.stub(stripeHelper, 'expandResource'); + const expandStub = jest.spyOn(stripeHelper, 'expandResource'); (stripeHelper as any).stripeFirestore = { - legacyFetchAndInsertCustomer: sandbox.stub().resolves({}), + legacyFetchAndInsertCustomer: jest.fn().mockResolvedValue({}), }; - expandStub.onFirstCall().resolves(customer); - expandStub.onSecondCall().resolves(customerSecond); + expandStub + .mockResolvedValueOnce(customer) + .mockResolvedValueOnce(customerSecond); const result = await stripeHelper.fetchCustomer(existingCustomer.uid); expect(result).toEqual(customerSecond); - sinon.assert.calledOnceWithExactly( - (stripeHelper as any).stripeFirestore.legacyFetchAndInsertCustomer, - customer.id - ); - sinon.assert.calledTwice(expandStub); + expect( + (stripeHelper as any).stripeFirestore.legacyFetchAndInsertCustomer + ).toHaveBeenCalledTimes(1); + expect( + (stripeHelper as any).stripeFirestore.legacyFetchAndInsertCustomer + ).toHaveBeenCalledWith(customer.id); + expect(expandStub).toHaveBeenCalledTimes(2); }); it('throws if the customer record has a fxa id mismatch', async () => { - sandbox.stub(stripeHelper, 'expandResource').returns(newCustomer); + jest.spyOn(stripeHelper, 'expandResource').mockReturnValue(newCustomer); let thrown: any; try { await stripeHelper.fetchCustomer(existingCustomer.uid); @@ -4068,10 +4304,10 @@ describe('StripeHelper', () => { ip_address: null, automatic_tax: 'supported', }; - sandbox.stub(stripeHelper, 'expandResource').returns(customer); - sandbox - .stub(stripeHelper.stripe.customers, 'retrieve') - .resolves(customerSecond); + jest.spyOn(stripeHelper, 'expandResource').mockReturnValue(customer); + jest + .spyOn(stripeHelper.stripe.customers, 'retrieve') + .mockResolvedValue(customerSecond); const result = await stripeHelper.fetchCustomer(existingCustomer.uid, [ 'tax', ]); @@ -4091,12 +4327,12 @@ describe('StripeHelper', () => { }); it('expands the customer', async () => { - stripeFirestore.retrieveAndFetchCustomer = sandbox - .stub() - .resolves(deepCopy(customer)); - stripeFirestore.retrieveCustomerSubscriptions = sandbox - .stub() - .resolves(deepCopy(customer.subscriptions.data)); + stripeFirestore.retrieveAndFetchCustomer = jest + .fn() + .mockResolvedValue(deepCopy(customer)); + stripeFirestore.retrieveCustomerSubscriptions = jest + .fn() + .mockResolvedValue(deepCopy(customer.subscriptions.data)); const result = await stripeHelper.expandResource( customer.id, CUSTOMER_RESOURCE @@ -4105,104 +4341,114 @@ describe('StripeHelper', () => { // without the object type. expect(result.subscriptions.data).toEqual(customer.subscriptions.data); expect(Object.keys(result).sort()).toEqual(Object.keys(customer).sort()); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.retrieveAndFetchCustomer, - customer.id, - true - ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.retrieveCustomerSubscriptions, - customer.id, - undefined - ); + expect( + stripeHelper.stripeFirestore.retrieveAndFetchCustomer + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.retrieveAndFetchCustomer + ).toHaveBeenCalledWith(customer.id, true); + expect( + stripeHelper.stripeFirestore.retrieveCustomerSubscriptions + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.retrieveCustomerSubscriptions + ).toHaveBeenCalledWith(customer.id, undefined); }); it('includes the empty subscriptions list on the expanded customer', async () => { - stripeFirestore.retrieveAndFetchCustomer = sandbox - .stub() - .resolves(deepCopy(customer)); - stripeFirestore.retrieveCustomerSubscriptions = sandbox - .stub() - .resolves([]); + stripeFirestore.retrieveAndFetchCustomer = jest + .fn() + .mockResolvedValue(deepCopy(customer)); + stripeFirestore.retrieveCustomerSubscriptions = jest + .fn() + .mockResolvedValue([]); const result = await stripeHelper.expandResource( customer.id, CUSTOMER_RESOURCE ); expect(result.subscriptions.data).toEqual([]); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.retrieveAndFetchCustomer, - customer.id, - true - ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.retrieveCustomerSubscriptions, - customer.id, - undefined - ); + expect( + stripeHelper.stripeFirestore.retrieveAndFetchCustomer + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.retrieveAndFetchCustomer + ).toHaveBeenCalledWith(customer.id, true); + expect( + stripeHelper.stripeFirestore.retrieveCustomerSubscriptions + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.retrieveCustomerSubscriptions + ).toHaveBeenCalledWith(customer.id, undefined); }); it('expands the subscription', async () => { - stripeFirestore.retrieveAndFetchSubscription = sandbox - .stub() - .resolves(deepCopy(subscription1)); + stripeFirestore.retrieveAndFetchSubscription = jest + .fn() + .mockResolvedValue(deepCopy(subscription1)); const result = await stripeHelper.expandResource( subscription1.id, SUBSCRIPTIONS_RESOURCE ); expect(result).toEqual(subscription1); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.retrieveAndFetchSubscription, - subscription1.id, - true - ); + expect( + stripeHelper.stripeFirestore.retrieveAndFetchSubscription + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.retrieveAndFetchSubscription + ).toHaveBeenCalledWith(subscription1.id, true); }); it('expands the invoice', async () => { - stripeFirestore.retrieveInvoice = sandbox - .stub() - .resolves(invoicePaidSubscriptionCreate); + stripeFirestore.retrieveInvoice = jest + .fn() + .mockResolvedValue(invoicePaidSubscriptionCreate); const result = await stripeHelper.expandResource( invoicePaidSubscriptionCreate.id, INVOICES_RESOURCE ); expect(result).toEqual(invoicePaidSubscriptionCreate); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.retrieveInvoice, + expect( + stripeHelper.stripeFirestore.retrieveInvoice + ).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripeFirestore.retrieveInvoice).toHaveBeenCalledWith( invoicePaidSubscriptionCreate.id ); }); it('expands invoice when invoice isnt found and inserts it', async () => { - stripeFirestore.retrieveInvoice = sandbox - .stub() - .rejects( + stripeFirestore.retrieveInvoice = jest + .fn() + .mockRejectedValue( newFirestoreStripeError( 'not found', FirestoreStripeError.FIRESTORE_INVOICE_NOT_FOUND ) ); - stripeFirestore.retrieveAndFetchCustomer = sandbox - .stub() - .resolves(customer); - stripeHelper.stripe.invoices.retrieve = sandbox - .stub() - .resolves(deepCopy(invoicePaidSubscriptionCreate)); - stripeFirestore.insertInvoiceRecord = sandbox.stub().resolves({}); + stripeFirestore.retrieveAndFetchCustomer = jest + .fn() + .mockResolvedValue(customer); + stripeHelper.stripe.invoices.retrieve = jest + .fn() + .mockResolvedValue(deepCopy(invoicePaidSubscriptionCreate)); + stripeFirestore.insertInvoiceRecord = jest.fn().mockResolvedValue({}); const result = await stripeHelper.expandResource( invoicePaidSubscriptionCreate.id, INVOICES_RESOURCE ); expect(result).toEqual(invoicePaidSubscriptionCreate); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.retrieveInvoice, + expect( + stripeHelper.stripeFirestore.retrieveInvoice + ).toHaveBeenCalledTimes(1); + expect(stripeHelper.stripeFirestore.retrieveInvoice).toHaveBeenCalledWith( invoicePaidSubscriptionCreate.id ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.retrieveAndFetchCustomer, - invoicePaidSubscriptionCreate.customer, - true - ); + expect( + stripeHelper.stripeFirestore.retrieveAndFetchCustomer + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.retrieveAndFetchCustomer + ).toHaveBeenCalledWith(invoicePaidSubscriptionCreate.customer, true); }); }); @@ -4244,20 +4490,22 @@ describe('StripeHelper', () => { it('returns an empty object when a config doc is not found', async () => { stripeHelper.paymentConfigManager = { - getMergedPlanConfiguration: sandbox.stub().resolves(undefined), + getMergedPlanConfiguration: jest.fn().mockResolvedValue(undefined), }; const actual = await stripeHelper.maybeGetPlanConfig('testo'); - sinon.assert.calledOnceWithExactly( - stripeHelper.paymentConfigManager.getMergedPlanConfiguration, - 'testo' - ); + expect( + stripeHelper.paymentConfigManager.getMergedPlanConfiguration + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.paymentConfigManager.getMergedPlanConfiguration + ).toHaveBeenCalledWith('testo'); expect(actual).toEqual({}); }); it('returns the config from the config manager', async () => { const planConfig = { fizz: 'wibble' }; stripeHelper.paymentConfigManager = { - getMergedPlanConfiguration: sandbox.stub().resolves(planConfig), + getMergedPlanConfiguration: jest.fn().mockResolvedValue(planConfig), }; const actual = await stripeHelper.maybeGetPlanConfig('testo'); expect(actual).toEqual(planConfig); @@ -4267,40 +4515,40 @@ describe('StripeHelper', () => { describe('removeFirestoreCustomer', () => { it('completes successfully and returns array of deleted paths', async () => { const expected = ['/path', '/path/subpath']; - stripeFirestore.removeCustomerRecursive = sandbox - .stub() - .resolves(expected); + stripeFirestore.removeCustomerRecursive = jest + .fn() + .mockResolvedValue(expected); const actual = await stripeHelper.removeFirestoreCustomer('uid'); expect(actual).toBe(expected); }); it('does not report error to sentry and rejects with error', async () => { - sandbox.stub(Sentry, 'captureException'); + jest.spyOn(Sentry, 'captureException'); const expectedError = new Error('bad things'); - stripeFirestore.removeCustomerRecursive = sandbox - .stub() - .rejects(expectedError); + stripeFirestore.removeCustomerRecursive = jest + .fn() + .mockRejectedValue(expectedError); try { await stripeHelper.removeFirestoreCustomer('uid'); } catch (err: any) { expect(err.message).toBe(expectedError.message); - sinon.assert.notCalled(Sentry.captureException as sinon.SinonStub); + expect(Sentry.captureException as jest.Mock).not.toHaveBeenCalled(); } }); it('reports error to sentry and rejects with error', async () => { - sandbox.stub(Sentry, 'captureException'); + jest.spyOn(Sentry, 'captureException'); const primaryError = new Error('not good'); const expectedError = new StripeFirestoreMultiError([primaryError]); - stripeFirestore.removeCustomerRecursive = sandbox - .stub() - .rejects(expectedError); + stripeFirestore.removeCustomerRecursive = jest + .fn() + .mockRejectedValue(expectedError); try { await stripeHelper.removeFirestoreCustomer('uid'); } catch (err: any) { expect(err.message).toBe(expectedError.message); - sinon.assert.calledOnceWithExactly( - Sentry.captureException as sinon.SinonStub, + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledTimes(1); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledWith( expectedError ); } @@ -4312,7 +4560,9 @@ describe('StripeHelper', () => { it('returns the invoice if marked as paid', async () => { const expected = deepCopy(paidInvoice); expected.payment_intent = successfulPaymentIntent; - sandbox.stub(stripeHelper.stripe.invoices, 'pay').resolves(expected); + jest + .spyOn(stripeHelper.stripe.invoices, 'pay') + .mockResolvedValue(expected); const actual = await stripeHelper.payInvoice(paidInvoice.id); expect(actual).toEqual(expected); }); @@ -4320,7 +4570,9 @@ describe('StripeHelper', () => { it('throws an error if invoice is not marked as paid', async () => { const expected = deepCopy(paidInvoice); expected.payment_intent = unsuccessfulPaymentIntent; - sandbox.stub(stripeHelper.stripe.invoices, 'pay').resolves(expected); + jest + .spyOn(stripeHelper.stripe.invoices, 'pay') + .mockResolvedValue(expected); try { await stripeHelper.payInvoice(paidInvoice.id); throw new Error('Method expected to reject'); @@ -4335,9 +4587,9 @@ describe('StripeHelper', () => { it('returns payment failed error if card_declined is reason', async () => { const cardDeclinedError = new stripeError.StripeCardError(); cardDeclinedError.code = 'card_declined'; - sandbox - .stub(stripeHelper.stripe.invoices, 'pay') - .rejects(cardDeclinedError); + jest + .spyOn(stripeHelper.stripe.invoices, 'pay') + .mockRejectedValue(cardDeclinedError); try { await stripeHelper.payInvoice(paidInvoice.id); throw new Error('Method expected to reject'); @@ -4350,7 +4602,9 @@ describe('StripeHelper', () => { it('throws caught Stripe error if not card_declined', async () => { const apiError = new stripeError.StripeAPIError(); apiError.code = 'api_error'; - sandbox.stub(stripeHelper.stripe.invoices, 'pay').rejects(apiError); + jest + .spyOn(stripeHelper.stripe.invoices, 'pay') + .mockRejectedValue(apiError); try { await stripeHelper.payInvoice(paidInvoice.id); throw new Error('Method expected to reject'); @@ -4363,9 +4617,9 @@ describe('StripeHelper', () => { describe('fetchPaymentIntentFromInvoice', () => { beforeEach(() => { - sandbox - .stub(stripeHelper.stripe.paymentIntents, 'retrieve') - .resolves(unsuccessfulPaymentIntent); + jest + .spyOn(stripeHelper.stripe.paymentIntents, 'retrieve') + .mockResolvedValue(unsuccessfulPaymentIntent); }); describe('when the payment_intent is loaded', () => { @@ -4375,9 +4629,9 @@ describe('StripeHelper', () => { const actual = await stripeHelper.fetchPaymentIntentFromInvoice(invoice); expect(actual).toEqual(invoice.payment_intent); - expect(stripeHelper.stripe.paymentIntents.retrieve.notCalled).toBe( - true - ); + expect( + stripeHelper.stripe.paymentIntents.retrieve.mock.calls.length === 0 + ).toBe(true); }); }); @@ -4387,9 +4641,9 @@ describe('StripeHelper', () => { const actual = await stripeHelper.fetchPaymentIntentFromInvoice(invoice); expect(actual).toEqual(unsuccessfulPaymentIntent); - expect(stripeHelper.stripe.paymentIntents.retrieve.calledOnce).toBe( - true - ); + expect( + stripeHelper.stripe.paymentIntents.retrieve.mock.calls.length === 1 + ).toBe(true); }); }); }); @@ -4425,12 +4679,15 @@ describe('StripeHelper', () => { describe('when the subscription is active', () => { it('formats the subscription', async () => { const input = { data: [subscription1] }; - sandbox - .stub(stripeHelper.stripe.invoices, 'retrieve') - .resolves(paidInvoice); - const callback = sandbox.stub(stripeHelper, 'expandResource'); - callback.onCall(0).resolves(paidInvoice); - callback.onCall(1).resolves({ id: productId, name: productName }); + jest + .spyOn(stripeHelper.stripe.invoices, 'retrieve') + .mockResolvedValue(paidInvoice); + const callback = jest.spyOn(stripeHelper, 'expandResource'); + callback.mockResolvedValueOnce(paidInvoice); + callback.mockResolvedValueOnce({ + id: productId, + name: productName, + }); const actual = await stripeHelper.subscriptionsToResponse(input); expect(actual).toHaveLength(1); expect(actual[0].subscription_id).toBe(subscription1.id); @@ -4446,12 +4703,15 @@ describe('StripeHelper', () => { missingExcludingTaxPaidInvoice ); const input = { data: [subscription1] }; - sandbox - .stub(stripeHelper.stripe.invoices, 'retrieve') - .resolves(missingExcludingTaxPaidInvoice); - const callback = sandbox.stub(stripeHelper, 'expandResource'); - callback.onCall(0).resolves(missingExcludingTaxPaidInvoice); - callback.onCall(1).resolves({ id: productId, name: productName }); + jest + .spyOn(stripeHelper.stripe.invoices, 'retrieve') + .mockResolvedValue(missingExcludingTaxPaidInvoice); + const callback = jest.spyOn(stripeHelper, 'expandResource'); + callback.mockResolvedValueOnce(missingExcludingTaxPaidInvoice); + callback.mockResolvedValueOnce({ + id: productId, + name: productName, + }); const expected = [ { _subscription_type: MozillaSubscriptionTypes.WEB, @@ -4517,16 +4777,16 @@ describe('StripeHelper', () => { ]; beforeEach(() => { - sandbox - .stub(stripeHelper.stripe.charges, 'retrieve') - .resolves(failedChargeCopy); + jest + .spyOn(stripeHelper.stripe.charges, 'retrieve') + .mockResolvedValue(failedChargeCopy); }); describe('when the charge is already expanded', () => { it('includes charge failure information with the subscription data', async () => { - sandbox - .stub(stripeHelper, 'expandResource') - .resolves({ id: productId, name: productName }); + jest + .spyOn(stripeHelper, 'expandResource') + .mockResolvedValue({ id: productId, name: productName }); pastDueInvoice.charge = failedChargeCopy; pastDueSub.latest_invoice = pastDueInvoice; pastDueSub.plan.product = product1.id; @@ -4534,8 +4794,8 @@ describe('StripeHelper', () => { const actual = await stripeHelper.subscriptionsToResponse(input); expect(actual).toEqual(expectedPastDue); expect( - (stripeHelper.stripe.charges.retrieve as sinon.SinonStub) - .notCalled + (stripeHelper.stripe.charges.retrieve as jest.Mock).mock.calls + .length === 0 ).toBe(true); expect(actual[0].failure_code).toBeDefined(); expect(actual[0].failure_message).toBeDefined(); @@ -4544,17 +4804,17 @@ describe('StripeHelper', () => { describe('when the charge is not expanded', () => { it('expands the charge and includes charge failure information with the subscription data', async () => { - sandbox - .stub(stripeHelper, 'expandResource') - .resolves({ id: productId, name: productName }); + jest + .spyOn(stripeHelper, 'expandResource') + .mockResolvedValue({ id: productId, name: productName }); pastDueInvoice.charge = 'ch_123'; pastDueSub.latest_invoice = pastDueInvoice; const input = { data: [pastDueSub] }; const actual = await stripeHelper.subscriptionsToResponse(input); expect(actual).toEqual(expectedPastDue); expect( - (stripeHelper.stripe.charges.retrieve as sinon.SinonStub) - .calledOnce + (stripeHelper.stripe.charges.retrieve as jest.Mock).mock.calls + .length === 1 ).toBe(true); expect(actual[0].failure_code).toBeDefined(); expect(actual[0].failure_message).toBeDefined(); @@ -4569,9 +4829,9 @@ describe('StripeHelper', () => { const sub = deepCopy(subscription1); sub.cancel_at_period_end = true; const input = { data: [sub] }; - const callback = sandbox.stub(stripeHelper, 'expandResource'); - callback.onCall(0).resolves(paidInvoice); - callback.onCall(1).resolves({ id: productId, name: productName }); + const callback = jest.spyOn(stripeHelper, 'expandResource'); + callback.mockResolvedValueOnce(paidInvoice); + callback.mockResolvedValueOnce({ id: productId, name: productName }); const expectedCancel = [ { _subscription_type: MozillaSubscriptionTypes.WEB, @@ -4609,12 +4869,12 @@ describe('StripeHelper', () => { const sub = deepCopy(cancelledSubscription); sub.plan.product = product1.id; const input = { data: [sub] }; - sandbox - .stub(stripeHelper.stripe.invoices, 'retrieve') - .resolves(paidInvoice); - const callback = sandbox.stub(stripeHelper, 'expandResource'); - callback.onCall(0).resolves(paidInvoice); - callback.onCall(1).resolves({ id: productId, name: productName }); + jest + .spyOn(stripeHelper.stripe.invoices, 'retrieve') + .mockResolvedValue(paidInvoice); + const callback = jest.spyOn(stripeHelper, 'expandResource'); + callback.mockResolvedValueOnce(paidInvoice); + callback.mockResolvedValueOnce({ id: productId, name: productName }); const expectedEnded = [ { _subscription_type: MozillaSubscriptionTypes.WEB, @@ -4664,9 +4924,9 @@ describe('StripeHelper', () => { it('should throw an error for a latest_invoice without an invoice number', async () => { const subscription = deepCopy(subscription1); const input = { data: [subscription] }; - sandbox - .stub(stripeHelper, 'expandResource') - .resolves({ ...paidInvoice, number: null }); + jest + .spyOn(stripeHelper, 'expandResource') + .mockResolvedValue({ ...paidInvoice, number: null }); try { await stripeHelper.subscriptionsToResponse(input); throw new Error('should have thrown'); @@ -4694,7 +4954,9 @@ describe('StripeHelper', () => { const incompleteSubscription = deepCopy(subscription1); incompleteSubscription.status = 'incomplete'; incompleteSubscription.id = 'sub_incomplete'; - sandbox.stub(stripeHelper, 'expandResource').resolves(paidInvoice); + jest + .spyOn(stripeHelper, 'expandResource') + .mockResolvedValue(paidInvoice); const input = { data: [subscription1, incompleteSubscription, subscription2], }; @@ -4721,12 +4983,12 @@ describe('StripeHelper', () => { it('"once" coupon duration do not include the promotion values in the returned value', async () => { const subscription = deepCopy(subscriptionCouponOnce); const input = { data: [subscription] }; - sandbox - .stub(stripeHelper.stripe.invoices, 'retrieve') - .resolves(paidInvoice); - const callback = sandbox.stub(stripeHelper, 'expandResource'); - callback.onCall(0).resolves(paidInvoice); - callback.onCall(1).resolves({ id: productId, name: productName }); + jest + .spyOn(stripeHelper.stripe.invoices, 'retrieve') + .mockResolvedValue(paidInvoice); + const callback = jest.spyOn(stripeHelper, 'expandResource'); + callback.mockResolvedValueOnce(paidInvoice); + callback.mockResolvedValueOnce({ id: productId, name: productName }); const expected = [ { _subscription_type: MozillaSubscriptionTypes.WEB, @@ -4759,12 +5021,12 @@ describe('StripeHelper', () => { it('forever coupon duration includes the promotion values in the returned value', async () => { const subscription = deepCopy(subscriptionCouponForever); const input = { data: [subscription] }; - sandbox - .stub(stripeHelper.stripe.invoices, 'retrieve') - .resolves(paidInvoice); - const callback = sandbox.stub(stripeHelper, 'expandResource'); - callback.onCall(0).resolves(paidInvoice); - callback.onCall(1).resolves({ id: productId, name: productName }); + jest + .spyOn(stripeHelper.stripe.invoices, 'retrieve') + .mockResolvedValue(paidInvoice); + const callback = jest.spyOn(stripeHelper, 'expandResource'); + callback.mockResolvedValueOnce(paidInvoice); + callback.mockResolvedValueOnce({ id: productId, name: productName }); const expected = [ { _subscription_type: MozillaSubscriptionTypes.WEB, @@ -4801,12 +5063,12 @@ describe('StripeHelper', () => { it('repeating coupon includes the promotion values in the returned value', async () => { const subscription = deepCopy(subscriptionCouponRepeating); const input = { data: [subscription] }; - sandbox - .stub(stripeHelper.stripe.invoices, 'retrieve') - .resolves(paidInvoice); - const callback = sandbox.stub(stripeHelper, 'expandResource'); - callback.onCall(0).resolves(paidInvoice); - callback.onCall(1).resolves({ id: productId, name: productName }); + jest + .spyOn(stripeHelper.stripe.invoices, 'retrieve') + .mockResolvedValue(paidInvoice); + const callback = jest.spyOn(stripeHelper, 'expandResource'); + callback.mockResolvedValueOnce(paidInvoice); + callback.mockResolvedValueOnce({ id: productId, name: productName }); const expected = [ { _subscription_type: MozillaSubscriptionTypes.WEB, @@ -4847,9 +5109,9 @@ describe('StripeHelper', () => { const productId = 'prod_123'; beforeEach(() => { - sandbox - .stub(stripeHelper, 'expandResource') - .resolves({ id: productId, name: productName }); + jest + .spyOn(stripeHelper, 'expandResource') + .mockResolvedValue({ id: productId, name: productName }); }); describe('when there are no subscriptions', () => { @@ -4882,18 +5144,24 @@ describe('StripeHelper', () => { it('handles invoice operations with firestore invoice', async () => { const event = deepCopy(eventInvoiceCreated); - localStripeFirestore.retrieveAndFetchSubscription = sandbox - .stub() - .resolves({}); - stripeHelper.stripe.invoices.retrieve = sandbox - .stub() - .resolves(invoicePaidSubscriptionCreate); - localStripeFirestore.retrieveInvoice = sandbox.stub().resolves({}); - localStripeFirestore.fetchAndInsertInvoice = sandbox.stub().resolves({}); + localStripeFirestore.retrieveAndFetchSubscription = jest + .fn() + .mockResolvedValue({}); + stripeHelper.stripe.invoices.retrieve = jest + .fn() + .mockResolvedValue(invoicePaidSubscriptionCreate); + localStripeFirestore.retrieveInvoice = jest.fn().mockResolvedValue({}); + localStripeFirestore.fetchAndInsertInvoice = jest + .fn() + .mockResolvedValue({}); const result = await stripeHelper.processWebhookEventToFirestore(event); expect(result).toBe(true); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.fetchAndInsertInvoice, + expect( + stripeHelper.stripeFirestore.fetchAndInsertInvoice + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.fetchAndInsertInvoice + ).toHaveBeenCalledWith( eventInvoiceCreated.data.object.id, eventInvoiceCreated.created ); @@ -4908,15 +5176,19 @@ describe('StripeHelper', () => { const event = deepCopy(eventCustomerUpdated); event.type = type; event.request = null; - localStripeFirestore.fetchAndInsertCustomer = sandbox - .stub() - .resolves({}); + localStripeFirestore.fetchAndInsertCustomer = jest + .fn() + .mockResolvedValue({}); dbStub.getUidAndEmailByStripeCustomerId.mockResolvedValue({ uid: newCustomer.metadata.userid, }); await stripeHelper.processWebhookEventToFirestore(event); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.fetchAndInsertCustomer, + expect( + stripeHelper.stripeFirestore.fetchAndInsertCustomer + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.fetchAndInsertCustomer + ).toHaveBeenCalledWith( eventCustomerUpdated.data.object.id, event.created ); @@ -4932,40 +5204,47 @@ describe('StripeHelper', () => { const event = deepCopy(eventSubscriptionUpdated); event.type = type; delete event.data.previous_attributes; - stripeHelper.stripe.subscriptions.retrieve = sandbox - .stub() - .resolves(subscription1); + stripeHelper.stripe.subscriptions.retrieve = jest + .fn() + .mockResolvedValue(subscription1); const customer = deepCopy(newCustomer); if (hasCurrency) { customer.currency = 'usd'; } - stripeHelper.expandResource = sandbox.stub().resolves(customer); - localStripeFirestore.retrieveSubscription = sandbox - .stub() - .resolves({}); - localStripeFirestore.retrieveCustomer = sandbox - .stub() - .resolves(customer); - localStripeFirestore.fetchAndInsertCustomer = sandbox - .stub() - .resolves({}); - localStripeFirestore.fetchAndInsertSubscription = sandbox - .stub() - .resolves({}); + stripeHelper.expandResource = jest.fn().mockResolvedValue(customer); + localStripeFirestore.retrieveSubscription = jest + .fn() + .mockResolvedValue({}); + localStripeFirestore.retrieveCustomer = jest + .fn() + .mockResolvedValue(customer); + localStripeFirestore.fetchAndInsertCustomer = jest + .fn() + .mockResolvedValue({}); + localStripeFirestore.fetchAndInsertSubscription = jest + .fn() + .mockResolvedValue({}); await stripeHelper.processWebhookEventToFirestore(event); if (!hasCurrency) { - sinon.assert.calledOnceWithExactly( - stripeHelper.stripe.subscriptions.retrieve, - event.data.object.id - ); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.fetchAndInsertCustomer, - event.data.object.customer, - event.created - ); + expect( + stripeHelper.stripe.subscriptions.retrieve + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripe.subscriptions.retrieve + ).toHaveBeenCalledWith(event.data.object.id); + expect( + stripeHelper.stripeFirestore.fetchAndInsertCustomer + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.fetchAndInsertCustomer + ).toHaveBeenCalledWith(event.data.object.customer, event.created); } else { - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.fetchAndInsertSubscription, + expect( + stripeHelper.stripeFirestore.fetchAndInsertSubscription + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.fetchAndInsertSubscription + ).toHaveBeenCalledWith( event.data.object.id, customer.metadata.userid ); @@ -4983,15 +5262,16 @@ describe('StripeHelper', () => { const event = deepCopy(eventPaymentMethodAttached); event.type = type; delete event.data.previous_attributes; - localStripeFirestore.fetchAndInsertPaymentMethod = sandbox - .stub() - .resolves({}); + localStripeFirestore.fetchAndInsertPaymentMethod = jest + .fn() + .mockResolvedValue({}); await stripeHelper.processWebhookEventToFirestore(event); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.fetchAndInsertPaymentMethod, - event.data.object.id, - event.created - ); + expect( + stripeHelper.stripeFirestore.fetchAndInsertPaymentMethod + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.fetchAndInsertPaymentMethod + ).toHaveBeenCalledWith(event.data.object.id, event.created); }); it(`ignores ${type} operations with no customer attached to event`, async () => { @@ -4999,63 +5279,71 @@ describe('StripeHelper', () => { event.type = type; event.data.object.customer = null; delete event.data.previous_attributes; - localStripeFirestore.fetchAndInsertPaymentMethod = sandbox.stub(); + localStripeFirestore.fetchAndInsertPaymentMethod = jest.fn(); await stripeHelper.processWebhookEventToFirestore(event); - sinon.assert.notCalled( + expect( stripeHelper.stripeFirestore.fetchAndInsertPaymentMethod - ); + ).not.toHaveBeenCalled(); }); } it('handles payment_method.detached operations', async () => { const event = deepCopy(eventPaymentMethodDetached); - localStripeFirestore.removePaymentMethodRecord = sandbox - .stub() - .resolves({}); + localStripeFirestore.removePaymentMethodRecord = jest + .fn() + .mockResolvedValue({}); await stripeHelper.processWebhookEventToFirestore(event); - sinon.assert.calledOnceWithExactly( - stripeHelper.stripeFirestore.removePaymentMethodRecord, - event.data.object.id - ); + expect( + stripeHelper.stripeFirestore.removePaymentMethodRecord + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.stripeFirestore.removePaymentMethodRecord + ).toHaveBeenCalledWith(event.data.object.id); }); it('handles invoice operations with no firestore invoice', async () => { const event = deepCopy(eventInvoiceCreated); - localStripeFirestore.retrieveAndFetchSubscription = sandbox - .stub() - .resolves({}); - const insertStub = sandbox.stub(); - stripeHelper.stripe.invoices.retrieve = sandbox - .stub() - .resolves(invoicePaidSubscriptionCreate); + localStripeFirestore.retrieveAndFetchSubscription = jest + .fn() + .mockResolvedValue({}); + const insertStub = jest.fn(); + stripeHelper.stripe.invoices.retrieve = jest + .fn() + .mockResolvedValue(invoicePaidSubscriptionCreate); localStripeFirestore.fetchAndInsertInvoice = insertStub; - insertStub - .onCall(0) - .rejects( - newFirestoreStripeError( - 'no invoice', - FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND - ) - ); - insertStub.onCall(1).resolves({}); - localStripeFirestore.fetchAndInsertCustomer = sandbox.stub().resolves({}); + insertStub.mockRejectedValueOnce( + newFirestoreStripeError( + 'no invoice', + FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND + ) + ); + insertStub.mockResolvedValueOnce({}); + localStripeFirestore.fetchAndInsertCustomer = jest + .fn() + .mockResolvedValue({}); const result = await stripeHelper.processWebhookEventToFirestore(event); expect(result).toBe(true); - sinon.assert.calledTwice( + expect( stripeHelper.stripeFirestore.fetchAndInsertInvoice - ); - sinon.assert.calledWithExactly( - stripeHelper.stripeFirestore.fetchAndInsertInvoice.getCall(0), + ).toHaveBeenCalledTimes(2); + expect( + stripeHelper.stripeFirestore.fetchAndInsertInvoice + ).toHaveBeenNthCalledWith( + 1, eventInvoiceCreated.data.object.id, eventInvoiceCreated.created ); - sinon.assert.calledWithExactly( - stripeHelper.stripeFirestore.fetchAndInsertInvoice.getCall(1), + expect( + stripeHelper.stripeFirestore.fetchAndInsertInvoice + ).toHaveBeenNthCalledWith( + 2, eventInvoiceCreated.data.object.id, eventInvoiceCreated.created ); - sinon.assert.calledOnceWithExactly( - localStripeFirestore.fetchAndInsertCustomer, + expect(localStripeFirestore.fetchAndInsertCustomer).toHaveBeenCalledTimes( + 1 + ); + expect(localStripeFirestore.fetchAndInsertCustomer).toHaveBeenCalledWith( event.data.object.customer, event.created ); @@ -5064,39 +5352,43 @@ describe('StripeHelper', () => { it('ignores the deleted stripe customer error when handling a payment method update event', async () => { const event = deepCopy(eventPaymentMethodAttached); event.type = 'payment_method.card_automatically_updated'; - localStripeFirestore.fetchAndInsertPaymentMethod = sandbox - .stub() - .throws( - newFirestoreStripeError( - 'Customer deleted.', - FirestoreStripeError.STRIPE_CUSTOMER_DELETED - ) - ); + const deletedError = newFirestoreStripeError( + 'Customer deleted.', + FirestoreStripeError.STRIPE_CUSTOMER_DELETED + ); + localStripeFirestore.fetchAndInsertPaymentMethod = jest + .fn() + .mockImplementation(() => { + throw deletedError; + }); await stripeHelper.processWebhookEventToFirestore(event); - sinon.assert.calledOnceWithExactly( - localStripeFirestore.fetchAndInsertPaymentMethod, - event.data.object.id, - event.created - ); + expect( + localStripeFirestore.fetchAndInsertPaymentMethod + ).toHaveBeenCalledTimes(1); + expect( + localStripeFirestore.fetchAndInsertPaymentMethod + ).toHaveBeenCalledWith(event.data.object.id, event.created); }); it('ignores the firestore record not found error when handling a payment method update event', async () => { const event = deepCopy(eventPaymentMethodAttached); event.type = 'payment_method.card_automatically_updated'; - localStripeFirestore.fetchAndInsertPaymentMethod = sandbox - .stub() - .throws( - newFirestoreStripeError( - 'Customer deleted.', - FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND - ) - ); + const notFoundError = newFirestoreStripeError( + 'Customer deleted.', + FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND + ); + localStripeFirestore.fetchAndInsertPaymentMethod = jest + .fn() + .mockImplementation(() => { + throw notFoundError; + }); await stripeHelper.processWebhookEventToFirestore(event); - sinon.assert.calledOnceWithExactly( - localStripeFirestore.fetchAndInsertPaymentMethod, - event.data.object.id, - event.created - ); + expect( + localStripeFirestore.fetchAndInsertPaymentMethod + ).toHaveBeenCalledTimes(1); + expect( + localStripeFirestore.fetchAndInsertPaymentMethod + ).toHaveBeenCalledWith(event.data.object.id, event.created); }); it('does not handle wibble events', async () => { @@ -5120,59 +5412,67 @@ describe('StripeHelper', () => { let sentryScope: any; beforeEach(() => { - sentryScope = { setContext: sandbox.stub(), setExtra: sandbox.stub() }; - sandbox.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sandbox.stub(Sentry, 'setExtra'); - sandbox.stub(Sentry, 'captureException'); + sentryScope = { setContext: jest.fn(), setExtra: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(Sentry, 'setExtra'); + jest.spyOn(Sentry, 'captureException'); }); it('updates the Stripe customer address', async () => { - sandbox.stub(stripeHelper, 'updateCustomerBillingAddress').resolves(); + jest + .spyOn(stripeHelper, 'updateCustomerBillingAddress') + .mockResolvedValue(); const result = await stripeHelper.setCustomerLocation({ customerId: customer1.id, postalCode: expectedAddressArg.postalCode, country: expectedAddressArg.country, }); expect(result).toBe(true); - sinon.assert.calledOnceWithExactly( - stripeHelper.googleMapsService.getStateFromZip, - '99999', - 'GD' - ); - sinon.assert.calledOnceWithExactly( - stripeHelper.updateCustomerBillingAddress, - { customerId: customer1.id, options: expectedAddressArg } + expect( + stripeHelper.googleMapsService.getStateFromZip + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.googleMapsService.getStateFromZip + ).toHaveBeenCalledWith('99999', 'GD'); + expect(stripeHelper.updateCustomerBillingAddress).toHaveBeenCalledTimes( + 1 ); + expect(stripeHelper.updateCustomerBillingAddress).toHaveBeenCalledWith({ + customerId: customer1.id, + options: expectedAddressArg, + }); }); it('fails when an error is thrown by Google Maps service', async () => { - sandbox.stub(stripeHelper, 'updateCustomerBillingAddress').resolves(); - mockGoogleMapsService.getStateFromZip = sandbox.stub().rejects(err); + jest + .spyOn(stripeHelper, 'updateCustomerBillingAddress') + .mockResolvedValue(); + mockGoogleMapsService.getStateFromZip = jest.fn().mockRejectedValue(err); const result = await stripeHelper.setCustomerLocation({ customerId: customer1.id, postalCode: expectedAddressArg.postalCode, country: expectedAddressArg.country, }); expect(result).toBe(false); - sinon.assert.notCalled(stripeHelper.updateCustomerBillingAddress); - sinon.assert.calledOnceWithExactly( - Sentry.captureException as sinon.SinonStub, - err - ); + expect(stripeHelper.updateCustomerBillingAddress).not.toHaveBeenCalled(); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledTimes(1); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledWith(err); }); it('fails when an error is thrown while updating the customer address', async () => { - sandbox.stub(stripeHelper, 'updateCustomerBillingAddress').rejects(err); + jest + .spyOn(stripeHelper, 'updateCustomerBillingAddress') + .mockRejectedValue(err); const result = await stripeHelper.setCustomerLocation({ customerId: customer1.id, postalCode: expectedAddressArg.postalCode, country: expectedAddressArg.country, }); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly( - Sentry.captureException as sinon.SinonStub, - err - ); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledTimes(1); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledWith(err); }); }); @@ -5208,7 +5508,9 @@ describe('StripeHelper', () => { product_metadata: {}, }, ]; - sandbox.stub(stripeHelper, 'allAbbrevPlans').resolves(mockAllAbbrevPlans); + jest + .spyOn(stripeHelper, 'allAbbrevPlans') + .mockResolvedValue(mockAllAbbrevPlans); }); describe('priceToIapIdentifiers', () => { @@ -5328,7 +5630,9 @@ describe('StripeHelper', () => { it('returns price ids for the Play subscription purchase', async () => { const result = await stripeHelper.iapPurchasesToPriceIds([subPurchase]); expect(result).toEqual([priceId]); - sinon.assert.calledOnce(stripeHelper.allAbbrevPlans as sinon.SinonStub); + expect(stripeHelper.allAbbrevPlans as jest.Mock).toHaveBeenCalledTimes( + 1 + ); }); it('returns price ids for the App Store subscription purchase', async () => { @@ -5347,23 +5651,27 @@ describe('StripeHelper', () => { ); const result = await stripeHelper.iapPurchasesToPriceIds([subPurchase]); expect(result).toEqual([priceId]); - sinon.assert.calledOnce(stripeHelper.allAbbrevPlans as sinon.SinonStub); + expect(stripeHelper.allAbbrevPlans as jest.Mock).toHaveBeenCalledTimes( + 1 + ); }); it('returns no price ids for unknown subscription purchase', async () => { subPurchase.sku = 'wrongSku'; const result = await stripeHelper.iapPurchasesToPriceIds([subPurchase]); expect(result).toEqual([]); - sinon.assert.calledOnce(stripeHelper.allAbbrevPlans as sinon.SinonStub); + expect(stripeHelper.allAbbrevPlans as jest.Mock).toHaveBeenCalledTimes( + 1 + ); }); }); }); describe('isCustomerTaxableWithSubscriptionCurrency', () => { it('returns true when currency is compatible with country and customer is stripe taxable', () => { - sandbox - .stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry') - .returns(true); + jest + .spyOn(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry') + .mockReturnValue(true); const actual = stripeHelper.isCustomerTaxableWithSubscriptionCurrency( { tax: { @@ -5377,9 +5685,9 @@ describe('StripeHelper', () => { }); it('returns false for a currency not compatible with the tax country', () => { - sandbox - .stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry') - .returns(false); + jest + .spyOn(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry') + .mockReturnValue(false); const actual = stripeHelper.isCustomerTaxableWithSubscriptionCurrency( { tax: { @@ -5393,9 +5701,9 @@ describe('StripeHelper', () => { }); it('returns false if customer does not have tax location', () => { - sandbox - .stub(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry') - .returns(false); + jest + .spyOn(stripeHelper.currencyHelper, 'isCurrencyCompatibleWithCountry') + .mockReturnValue(false); const actual = stripeHelper.isCustomerTaxableWithSubscriptionCurrency( { tax: { @@ -5426,28 +5734,28 @@ describe('StripeHelper', () => { const mockInvoice = { status: 'paid' }; beforeEach(() => { - sandbox.stub(stripeHelper, 'fetchCustomer').resolves(customer); - sandbox - .stub(stripeHelper, 'extractBillingDetails') - .resolves(billingDetails); - sandbox - .stub(stripeHelper, 'getCustomerPaypalAgreement') - .returns(billingAgreementId); - sandbox - .stub(stripeHelper, 'hasSubscriptionRequiringPaymentMethod') - .returns(true); - sandbox - .stub(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') - .resolves([mockInvoice]); - sandbox - .stub(stripeHelper, 'hasOpenInvoiceWithPaymentAttempts') - .resolves(true); - sandbox.stub(stripeHelper, 'getPaymentAttempts').returns(0); + jest.spyOn(stripeHelper, 'fetchCustomer').mockResolvedValue(customer); + jest + .spyOn(stripeHelper, 'extractBillingDetails') + .mockResolvedValue(billingDetails); + jest + .spyOn(stripeHelper, 'getCustomerPaypalAgreement') + .mockReturnValue(billingAgreementId); + jest + .spyOn(stripeHelper, 'hasSubscriptionRequiringPaymentMethod') + .mockReturnValue(true); + jest + .spyOn(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') + .mockResolvedValue([mockInvoice]); + jest + .spyOn(stripeHelper, 'hasOpenInvoiceWithPaymentAttempts') + .mockResolvedValue(true); + jest.spyOn(stripeHelper, 'getPaymentAttempts').mockReturnValue(0); }); it('returns null when no customer is found', async () => { - stripeHelper.fetchCustomer.restore(); - sandbox.stub(stripeHelper, 'fetchCustomer').resolves(undefined); + stripeHelper.fetchCustomer.mockRestore(); + jest.spyOn(stripeHelper, 'fetchCustomer').mockResolvedValue(undefined); const actual = await stripeHelper.getBillingDetailsAndSubscriptions('uid'); expect(actual).toBeNull(); @@ -5455,10 +5763,10 @@ describe('StripeHelper', () => { it('includes the customer Stripe billing details', async () => { const billingDetails = { payment_provider: 'stripe' }; - stripeHelper.extractBillingDetails.restore(); - sandbox - .stub(stripeHelper, 'extractBillingDetails') - .resolves(billingDetails); + stripeHelper.extractBillingDetails.mockRestore(); + jest + .spyOn(stripeHelper, 'extractBillingDetails') + .mockResolvedValue(billingDetails); const actual = await stripeHelper.getBillingDetailsAndSubscriptions('uid'); expect(actual).toEqual({ @@ -5470,10 +5778,10 @@ describe('StripeHelper', () => { }); it('includes the customer PayPal billing details', async () => { - stripeHelper.hasSubscriptionRequiringPaymentMethod.restore(); - sandbox - .stub(stripeHelper, 'hasSubscriptionRequiringPaymentMethod') - .returns(false); + stripeHelper.hasSubscriptionRequiringPaymentMethod.mockRestore(); + jest + .spyOn(stripeHelper, 'hasSubscriptionRequiringPaymentMethod') + .mockReturnValue(false); const actual = await stripeHelper.getBillingDetailsAndSubscriptions('uid'); expect(actual).toEqual({ @@ -5486,8 +5794,10 @@ describe('StripeHelper', () => { }); it('includes the missing billing agreement error state', async () => { - stripeHelper.getCustomerPaypalAgreement.restore(); - sandbox.stub(stripeHelper, 'getCustomerPaypalAgreement').returns(null); + stripeHelper.getCustomerPaypalAgreement.mockRestore(); + jest + .spyOn(stripeHelper, 'getCustomerPaypalAgreement') + .mockReturnValue(null); const actual = await stripeHelper.getBillingDetailsAndSubscriptions('uid'); expect(actual).toEqual({ @@ -5501,17 +5811,17 @@ describe('StripeHelper', () => { }); it('includes the funding source error state', async () => { - stripeHelper.hasOpenInvoiceWithPaymentAttempts.restore(); - sandbox - .stub(stripeHelper, 'hasOpenInvoiceWithPaymentAttempts') - .resolves(true); - stripeHelper.getPaymentAttempts.restore(); - sandbox.stub(stripeHelper, 'getPaymentAttempts').returns(1); + stripeHelper.hasOpenInvoiceWithPaymentAttempts.mockRestore(); + jest + .spyOn(stripeHelper, 'hasOpenInvoiceWithPaymentAttempts') + .mockResolvedValue(true); + stripeHelper.getPaymentAttempts.mockRestore(); + jest.spyOn(stripeHelper, 'getPaymentAttempts').mockReturnValue(1); const openInvoice = { status: 'open' }; - stripeHelper.getLatestInvoicesForActiveSubscriptions.restore(); - sandbox - .stub(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') - .resolves([openInvoice]); + stripeHelper.getLatestInvoicesForActiveSubscriptions.mockRestore(); + jest + .spyOn(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') + .mockResolvedValue([openInvoice]); const actual = await stripeHelper.getBillingDetailsAndSubscriptions('uid'); expect(actual).toEqual({ @@ -5526,14 +5836,14 @@ describe('StripeHelper', () => { it('excludes funding source error state with open invoices but no payment attempts', async () => { const openInvoice = { status: 'open' }; - stripeHelper.getLatestInvoicesForActiveSubscriptions.restore(); - sandbox - .stub(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') - .resolves([openInvoice]); - stripeHelper.hasOpenInvoiceWithPaymentAttempts.restore(); - sandbox - .stub(stripeHelper, 'hasOpenInvoiceWithPaymentAttempts') - .returns(false); + stripeHelper.getLatestInvoicesForActiveSubscriptions.mockRestore(); + jest + .spyOn(stripeHelper, 'getLatestInvoicesForActiveSubscriptions') + .mockResolvedValue([openInvoice]); + stripeHelper.hasOpenInvoiceWithPaymentAttempts.mockRestore(); + jest + .spyOn(stripeHelper, 'hasOpenInvoiceWithPaymentAttempts') + .mockReturnValue(false); const actual = await stripeHelper.getBillingDetailsAndSubscriptions('uid'); expect(actual).toEqual({ @@ -5549,17 +5859,17 @@ describe('StripeHelper', () => { const subscriptions: any = { data: [{ id: 'sub_testo', status: 'active' }], }; - stripeHelper.fetchCustomer.restore(); - sandbox - .stub(stripeHelper, 'fetchCustomer') - .resolves({ ...customer, subscriptions }); - sandbox - .stub(stripeHelper, 'subscriptionsToResponse') - .resolves(subscriptions); - stripeHelper.hasSubscriptionRequiringPaymentMethod.restore(); - sandbox - .stub(stripeHelper, 'hasSubscriptionRequiringPaymentMethod') - .returns(false); + stripeHelper.fetchCustomer.mockRestore(); + jest + .spyOn(stripeHelper, 'fetchCustomer') + .mockResolvedValue({ ...customer, subscriptions }); + jest + .spyOn(stripeHelper, 'subscriptionsToResponse') + .mockResolvedValue(subscriptions); + stripeHelper.hasSubscriptionRequiringPaymentMethod.mockRestore(); + jest + .spyOn(stripeHelper, 'hasSubscriptionRequiringPaymentMethod') + .mockReturnValue(false); const actual = await stripeHelper.getBillingDetailsAndSubscriptions('uid'); expect(actual).toEqual({ @@ -5569,10 +5879,12 @@ describe('StripeHelper', () => { billing_agreement_id: billingAgreementId, ...billingDetails, }); - sinon.assert.calledOnceWithExactly( - stripeHelper.subscriptionsToResponse as sinon.SinonStub, - subscriptions - ); + expect( + stripeHelper.subscriptionsToResponse as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.subscriptionsToResponse as jest.Mock + ).toHaveBeenCalledWith(subscriptions); }); it('filters out canceled subscriptions', async () => { @@ -5582,20 +5894,22 @@ describe('StripeHelper', () => { { id: 'sub_testo', status: 'canceled' }, ], }; - stripeHelper.fetchCustomer.restore(); - sandbox - .stub(stripeHelper, 'fetchCustomer') - .resolves({ ...customer, subscriptions }); - sandbox - .stub(stripeHelper, 'subscriptionsToResponse') - .resolves(subscriptions); + stripeHelper.fetchCustomer.mockRestore(); + jest + .spyOn(stripeHelper, 'fetchCustomer') + .mockResolvedValue({ ...customer, subscriptions }); + jest + .spyOn(stripeHelper, 'subscriptionsToResponse') + .mockResolvedValue(subscriptions); await stripeHelper.getBillingDetailsAndSubscriptions('uid'); - sinon.assert.calledOnceWithExactly( - stripeHelper.subscriptionsToResponse as sinon.SinonStub, - { - data: [{ id: 'sub_testo', status: 'active' }], - } - ); + expect( + stripeHelper.subscriptionsToResponse as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.subscriptionsToResponse as jest.Mock + ).toHaveBeenCalledWith({ + data: [{ id: 'sub_testo', status: 'active' }], + }); }); }); @@ -5680,7 +5994,6 @@ describe('StripeHelper', () => { }, }; - let billingEmailSandbox: any; let mockCustomer: any; let mockStripe: any; let mockAllAbbrevProducts: any[]; @@ -5688,8 +6001,6 @@ describe('StripeHelper', () => { let expandMock: any; beforeEach(() => { - billingEmailSandbox = sandbox; - mockCustomer = { id: 'cus_00000000000000', email, @@ -5725,14 +6036,16 @@ describe('StripeHelper', () => { mockAllAbbrevPlans = [ { ...mockPlan, plan_id: planId, product_id: productId }, ]; - billingEmailSandbox - .stub(stripeHelper, 'allAbbrevProducts') - .resolves(mockAllAbbrevProducts); - billingEmailSandbox - .stub(stripeHelper, 'allAbbrevPlans') - .resolves(mockAllAbbrevPlans); + jest + .spyOn(stripeHelper, 'allAbbrevProducts') + .mockResolvedValue(mockAllAbbrevProducts); + jest + .spyOn(stripeHelper, 'allAbbrevPlans') + .mockResolvedValue(mockAllAbbrevPlans); - expandMock = billingEmailSandbox.stub(stripeHelper, 'expandResource'); + expandMock = jest + .spyOn(stripeHelper, 'expandResource') + .mockResolvedValue({}); mockStripe = Object.entries({ plans: mockPlan, @@ -5743,13 +6056,13 @@ describe('StripeHelper', () => { }).reduce( (acc: any, [resource, value]) => ({ ...acc, - [resource]: { retrieve: sinon.stub().resolves(value) }, + [resource]: { retrieve: jest.fn().mockResolvedValue(value) }, }), {} ); - mockStripe.invoices.retrieveUpcoming = sinon - .stub() - .resolves(mockInvoiceUpcoming); + mockStripe.invoices.retrieveUpcoming = jest + .fn() + .mockResolvedValue(mockInvoiceUpcoming); stripeHelper.stripe = mockStripe; }); @@ -5897,26 +6210,26 @@ describe('StripeHelper', () => { ...(stripeHelper.stripe || {}), paymentIntents: { ...(stripeHelper.stripe?.paymentIntents || {}), - retrieve: sinon.stub().resolves(successfulPaymentIntent), + retrieve: jest.fn().mockResolvedValue(successfulPaymentIntent), }, invoices: { ...(stripeHelper.stripe?.invoices || {}), - retrieve: sinon.stub().resolves(mockInvoice), + retrieve: jest.fn().mockResolvedValue(mockInvoice), }, }; - expandMock.onCall(0).resolves(mockCustomer); - expandMock.onCall(1).resolves(mockCharge); + expandMock.mockResolvedValueOnce(mockCustomer); + expandMock.mockResolvedValueOnce(mockCharge); }); it('extracts expected details from an invoice that requires requests to expand', async () => { const result = await stripeHelper.extractInvoiceDetailsForEmail(fixture); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledThrice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual(expected); }); @@ -5924,11 +6237,11 @@ describe('StripeHelper', () => { mockAllAbbrevProducts[0].product_id = 'nope'; const result = await stripeHelper.extractInvoiceDetailsForEmail(fixture); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(true); - sinon.assert.calledThrice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(true); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual(expected); }); @@ -5944,11 +6257,11 @@ describe('StripeHelper', () => { expandedFixture.charge = mockCharge; const result = await stripeHelper.extractInvoiceDetailsForEmail(expandedFixture); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledThrice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual(expected); }); @@ -5962,14 +6275,17 @@ describe('StripeHelper', () => { }; noChargeFixture.customer = mockCustomer; noChargeFixture.charge = null; - expandMock.onCall(1).resolves(null); + // Reset the "once" queue from beforeEach and set up: customer, null (no charge), default + expandMock.mockReset().mockResolvedValue({}); + expandMock.mockResolvedValueOnce(mockCustomer); + expandMock.mockResolvedValueOnce(null); const result = await stripeHelper.extractInvoiceDetailsForEmail(noChargeFixture); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledThrice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual({ ...expected, lastFour: null, @@ -5986,11 +6302,11 @@ describe('StripeHelper', () => { upgradeFixture.lines.data[1].period.end = subscriptionPeriodEnd; const result = await stripeHelper.extractInvoiceDetailsForEmail(upgradeFixture); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledThrice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual({ ...expected, nextInvoiceDate: new Date(subscriptionPeriodEnd * 1000), @@ -6000,22 +6316,22 @@ describe('StripeHelper', () => { it('extracts expected details from an invoice with invoiceitem for a previous subscription', async () => { const result = await stripeHelper.extractInvoiceDetailsForEmail(fixtureProrated); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledThrice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual(expected); }); it('extracts expected details from an invoice with discount', async () => { const result = await stripeHelper.extractInvoiceDetailsForEmail(fixtureDiscount); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledThrice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual(expectedDiscount_foreverCoupon); }); @@ -6030,11 +6346,11 @@ describe('StripeHelper', () => { }; const result = await stripeHelper.extractInvoiceDetailsForEmail(fixtureDiscount100); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledThrice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual(expectedDiscount100); }); @@ -6049,17 +6365,17 @@ describe('StripeHelper', () => { }, }, ]; - (stripeHelper.allAbbrevProducts as sinon.SinonStub).resolves( + (stripeHelper.allAbbrevProducts as jest.Mock).mockResolvedValue( customMockAllAbbrevProducts ); const customFixture = deepCopy(invoicePaidSubscriptionCreate); const result = await stripeHelper.extractInvoiceDetailsForEmail(customFixture); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledThrice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual({ ...expected, productMetadata: { @@ -6072,11 +6388,11 @@ describe('StripeHelper', () => { it('extracts expected details for an invoice with tax', async () => { const result = await stripeHelper.extractInvoiceDetailsForEmail(fixtureTax); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledThrice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual({ ...expected, invoiceTaxAmountInCents: 54, @@ -6086,11 +6402,11 @@ describe('StripeHelper', () => { it('extracts expected details from an invoice with discount and tax', async () => { const result = await stripeHelper.extractInvoiceDetailsForEmail(fixtureTaxDiscount); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledThrice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(3); expect(result).toEqual({ ...expectedDiscount_foreverCoupon, invoiceTaxAmountInCents: 48, @@ -6101,11 +6417,11 @@ describe('StripeHelper', () => { const result = await stripeHelper.extractInvoiceDetailsForEmail( fixtureProrationRefund ); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledTwice(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(2); expect(result).toEqual({ ...expected, invoiceStatus: 'draft', @@ -6116,7 +6432,8 @@ describe('StripeHelper', () => { }); it('throws an exception for deleted customer', async () => { - expandMock.onCall(0).resolves({ ...mockCustomer, deleted: true }); + expandMock.mockReset().mockResolvedValue({}); + expandMock.mockResolvedValueOnce({ ...mockCustomer, deleted: true }); let thrownError: any = null; try { await stripeHelper.extractInvoiceDetailsForEmail(fixture); @@ -6127,18 +6444,18 @@ describe('StripeHelper', () => { expect(thrownError.errno).toBe( error.ERRNO.UNKNOWN_SUBSCRIPTION_CUSTOMER ); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - false - ); - expect(mockStripe.products.retrieve.called).toBe(false); - sinon.assert.calledOnce(expandMock); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(false); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(1); }); it('throws an exception for deleted product', async () => { mockAllAbbrevProducts[0].product_id = 'nope'; - mockStripe.products.retrieve = sinon - .stub() - .resolves({ ...mockProduct, deleted: true }); + mockStripe.products.retrieve = jest + .fn() + .mockResolvedValue({ ...mockProduct, deleted: true }); let thrownError: any = null; try { await stripeHelper.extractInvoiceDetailsForEmail(fixture); @@ -6147,11 +6464,11 @@ describe('StripeHelper', () => { } expect(thrownError).not.toBeNull(); expect(thrownError.errno).toBe(error.ERRNO.UNKNOWN_SUBSCRIPTION_PLAN); - expect(mockStripe.products.retrieve.calledWith(productId)).toBe(true); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - sinon.assert.calledTwice(expandMock); + expect(mockStripe.products.retrieve).toHaveBeenCalledWith(productId); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(expandMock).toHaveBeenCalledTimes(2); }); it('throws an exception with unexpected data', async () => { @@ -6228,19 +6545,17 @@ describe('StripeHelper', () => { it('extracts the correct discount type when discounts property needs to be expanded', async () => { const fixtureDiscountOneTime = deepCopy(fixture); fixtureDiscountOneTime.discounts = ['discountId']; - billingEmailSandbox - .stub(stripeHelper, 'getInvoiceWithDiscount') - .resolves({ - ...fixtureDiscountOneTime, - discounts: [ - { - coupon: { - duration: 'once', - duration_in_months: null, - }, + jest.spyOn(stripeHelper, 'getInvoiceWithDiscount').mockResolvedValue({ + ...fixtureDiscountOneTime, + discounts: [ + { + coupon: { + duration: 'once', + duration_in_months: null, }, - ], - }); + }, + ], + }); const actual = await stripeHelper.extractInvoiceDetailsForEmail( fixtureDiscountOneTime ); @@ -6249,9 +6564,9 @@ describe('StripeHelper', () => { }); it('uses and includes Firestore based configs when available', async () => { - billingEmailSandbox - .stub(stripeHelper, 'maybeGetPlanConfig') - .resolves(planConfig); + jest + .spyOn(stripeHelper, 'maybeGetPlanConfig') + .mockResolvedValue(planConfig); const result = await stripeHelper.extractInvoiceDetailsForEmail(fixture); const expectedWithPlanConfig = { @@ -6260,9 +6575,9 @@ describe('StripeHelper', () => { planEmailIconURL: planConfig.urls.emailIcon, planSuccessActionButtonURL: planConfig.urls.successActionButton, }; - sinon.assert.calledOnce( - stripeHelper.maybeGetPlanConfig as sinon.SinonStub - ); + expect( + stripeHelper.maybeGetPlanConfig as jest.Mock + ).toHaveBeenCalledTimes(1); expect(result).toEqual(expectedWithPlanConfig); }); }); @@ -6296,23 +6611,24 @@ describe('StripeHelper', () => { }; beforeEach(() => { - expandMock.onCall(0).resolves(mockCustomer); - expandMock.onCall(1).resolves(mockPlan); + expandMock.mockResolvedValueOnce(mockCustomer); + expandMock.mockResolvedValueOnce(mockPlan); }); it('extracts expected details from a source that requires requests to expand', async () => { const result = await stripeHelper.extractSourceDetailsForEmail(sourceFixture); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - true - ); - expect(mockStripe.products.retrieve.called).toBe(false); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(true); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); expect(result).toEqual(expectedSource); - sinon.assert.calledTwice(expandMock); + expect(expandMock).toHaveBeenCalledTimes(2); }); it('throws an exception for deleted customer', async () => { - expandMock.onCall(0).resolves({ ...mockCustomer, deleted: true }); + expandMock.mockReset().mockResolvedValue({}); + expandMock.mockResolvedValueOnce({ ...mockCustomer, deleted: true }); let thrownError: any = null; try { await stripeHelper.extractSourceDetailsForEmail(sourceFixture); @@ -6323,11 +6639,11 @@ describe('StripeHelper', () => { expect(thrownError.errno).toBe( error.ERRNO.UNKNOWN_SUBSCRIPTION_CUSTOMER ); - sinon.assert.calledOnce(expandMock); - expect((stripeHelper.allAbbrevProducts as sinon.SinonStub).called).toBe( - false - ); - expect(mockStripe.products.retrieve.called).toBe(false); + expect(expandMock).toHaveBeenCalledTimes(1); + expect( + (stripeHelper.allAbbrevProducts as jest.Mock).mock.calls.length > 0 + ).toBe(false); + expect(mockStripe.products.retrieve.mock.calls.length > 0).toBe(false); }); it('throws an exception when unable to find plan or product', async () => { @@ -6419,17 +6735,17 @@ describe('StripeHelper', () => { it('returns subscription invoice details', async () => { const mockSubscription = deepCopy(subscription1); const mockSubInvoice = deepCopy(invoicePaidSubscriptionCreate); - billingEmailSandbox - .stub(stripeHelper, 'extractInvoiceDetailsForEmail') - .resolves(mockSubInvoice); + jest + .spyOn(stripeHelper, 'extractInvoiceDetailsForEmail') + .mockResolvedValue(mockSubInvoice); const result = await stripeHelper.extractSubscriptionDeletedEventDetailsForEmail( mockSubscription ); expect(result).toBe(mockSubInvoice); - sinon.assert.calledOnce( - stripeHelper.extractInvoiceDetailsForEmail as sinon.SinonStub - ); + expect( + stripeHelper.extractInvoiceDetailsForEmail as jest.Mock + ).toHaveBeenCalledTimes(1); }); it('throws internalValidationError if latest_invoice is not present', async () => { @@ -6454,32 +6770,32 @@ describe('StripeHelper', () => { const mockUpgradeDowngradeDetails = 'mockUpgradeDowngradeDetails'; beforeEach(() => { - billingEmailSandbox - .stub(stripeHelper, 'getInvoice') - .resolves(mockOldInvoice); - billingEmailSandbox.stub(stripeHelper, 'getSubsequentPrices').resolves({ + jest + .spyOn(stripeHelper, 'getInvoice') + .mockResolvedValue(mockOldInvoice); + jest.spyOn(stripeHelper, 'getSubsequentPrices').mockResolvedValue({ exclusiveTax: 0, total: mockOldInvoice.total, }); - billingEmailSandbox - .stub( + jest + .spyOn( stripeHelper, 'extractSubscriptionUpdateCancellationDetailsForEmail' ) - .resolves(mockCancellationDetails); - billingEmailSandbox - .stub( + .mockResolvedValue(mockCancellationDetails); + jest + .spyOn( stripeHelper, 'extractSubscriptionUpdateReactivationDetailsForEmail' ) - .resolves(mockReactivationDetails); - billingEmailSandbox - .stub( + .mockResolvedValue(mockReactivationDetails); + jest + .spyOn( stripeHelper, 'extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail' ) - .resolves(mockUpgradeDowngradeDetails); - expandMock.onCall(0).resolves(mockCustomer); + .mockResolvedValue(mockUpgradeDowngradeDetails); + expandMock.mockResolvedValueOnce(mockCustomer); }); function assertOnlyExpectedHelperCalledWith( @@ -6493,10 +6809,16 @@ describe('StripeHelper', () => { ]; for (const helperName of allHelperNames) { if (helperName !== expectedHelperName) { - expect((stripeHelper as any)[helperName].notCalled).toBe(true); + expect( + (stripeHelper as any)[helperName].mock.calls.length === 0 + ).toBe(true); } else { - expect((stripeHelper as any)[helperName].called).toBe(true); - expect((stripeHelper as any)[helperName].args[0]).toEqual(args); + expect( + (stripeHelper as any)[helperName].mock.calls.length > 0 + ).toBe(true); + expect((stripeHelper as any)[helperName].mock.calls[0]).toEqual( + args + ); } } } @@ -6505,7 +6827,9 @@ describe('StripeHelper', () => { const stripeErr: any = new Error('Stripe error'); stripeErr.type = 'StripeInvalidRequestError'; stripeErr.code = 'invoice_upcoming_none'; - mockStripe.invoices.retrieveUpcoming = sinon.stub().rejects(stripeErr); + mockStripe.invoices.retrieveUpcoming = jest + .fn() + .mockRejectedValue(stripeErr); const event = deepCopy(eventCustomerSubscriptionUpdated); event.data.object.cancel_at_period_end = true; event.data.previous_attributes = { @@ -6529,7 +6853,9 @@ describe('StripeHelper', () => { it('rejects if invoices.retrieveUpcoming errors with unexpected error', async () => { const stripeErr: any = new Error('Stripe error'); stripeErr.type = 'unexpected'; - mockStripe.invoices.retrieveUpcoming = sinon.stub().rejects(stripeErr); + mockStripe.invoices.retrieveUpcoming = jest + .fn() + .mockRejectedValue(stripeErr); const event = deepCopy(eventCustomerSubscriptionUpdated); event.data.object.cancel_at_period_end = true; event.data.previous_attributes = { @@ -6545,7 +6871,8 @@ describe('StripeHelper', () => { } expect( (stripeHelper as any) - .extractSubscriptionUpdateCancellationDetailsForEmail.notCalled + .extractSubscriptionUpdateCancellationDetailsForEmail.mock.calls + .length === 0 ).toBe(true); }); @@ -6556,9 +6883,9 @@ describe('StripeHelper', () => { data: [{ type: 'invoiceitem' }], }, }; - mockStripe.invoices.retrieveUpcoming = sinon - .stub() - .resolves(mockInvoiceUpcomingWithData); + mockStripe.invoices.retrieveUpcoming = jest + .fn() + .mockResolvedValue(mockInvoiceUpcomingWithData); const event = deepCopy(eventCustomerSubscriptionUpdated); event.data.object.cancel_at_period_end = true; event.data.previous_attributes = { @@ -6674,9 +7001,9 @@ describe('StripeHelper', () => { it('includes the Firestore based plan config when available', async () => { const mockPlanConfig = { firestore: 'yes' }; - billingEmailSandbox - .stub(stripeHelper, 'maybeGetPlanConfig') - .resolves(mockPlanConfig); + jest + .spyOn(stripeHelper, 'maybeGetPlanConfig') + .mockResolvedValue(mockPlanConfig); const event = deepCopy(eventCustomerSubscriptionUpdated); event.data.object.cancel_at_period_end = true; event.data.previous_attributes = { @@ -6763,12 +7090,10 @@ describe('StripeHelper', () => { } ); - billingEmailSandbox - .stub(stripeHelper, 'getSubsequentPrices') - .resolves({ - exclusiveTax: 0, - total: upcomingInvoice.total, - }); + jest.spyOn(stripeHelper, 'getSubsequentPrices').mockResolvedValue({ + exclusiveTax: 0, + total: upcomingInvoice.total, + }); const result = await stripeHelper.extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail( @@ -6934,21 +7259,21 @@ describe('StripeHelper', () => { }; beforeEach(() => { - expandMock.onCall(0).returns(mockCharge.payment_method_details); + expandMock.mockReturnValueOnce(mockCharge.payment_method_details); }); it('extracts expected details for a subscription reactivation', async () => { const event = deepCopy(eventCustomerSubscriptionUpdated); - billingEmailSandbox - .stub(stripeHelper, 'fetchCustomer') - .resolves(reactivationMockCustomer); + jest + .spyOn(stripeHelper, 'fetchCustomer') + .mockResolvedValue(reactivationMockCustomer); const result = await stripeHelper.extractSubscriptionUpdateReactivationDetailsForEmail( event.data.object, expectedBaseUpdateDetails, mockInvoice ); - expect(mockStripe.invoices.retrieveUpcoming.args).toEqual([ + expect(mockStripe.invoices.retrieveUpcoming.mock.calls).toEqual([ [{ subscription: event.data.object.id }], ]); expect(result).toEqual(defaultExpected); @@ -6958,9 +7283,9 @@ describe('StripeHelper', () => { const event = deepCopy(eventCustomerSubscriptionUpdated); const customerNoPayment = deepCopy(reactivationMockCustomer); customerNoPayment.invoice_settings.default_payment_method = null; - billingEmailSandbox - .stub(stripeHelper, 'fetchCustomer') - .resolves(customerNoPayment); + jest + .spyOn(stripeHelper, 'fetchCustomer') + .mockResolvedValue(customerNoPayment); const result = await stripeHelper.extractSubscriptionUpdateReactivationDetailsForEmail( event.data.object, @@ -6983,28 +7308,30 @@ describe('StripeHelper', () => { country: 'GD', postalCode: '99999', }; - billingEmailSandbox - .stub(stripeHelper, 'fetchCustomer') - .resolves(customer1); - billingEmailSandbox - .stub(stripeHelper, 'extractCustomerDefaultPaymentDetails') - .resolves(paymentDetails); + jest.spyOn(stripeHelper, 'fetchCustomer').mockResolvedValue(customer1); + jest + .spyOn(stripeHelper, 'extractCustomerDefaultPaymentDetails') + .mockResolvedValue(paymentDetails); const actual = await stripeHelper.extractCustomerDefaultPaymentDetailsByUid(uid); expect(actual).toEqual(paymentDetails); - sinon.assert.calledOnceWithExactly( - stripeHelper.fetchCustomer as sinon.SinonStub, + expect(stripeHelper.fetchCustomer as jest.Mock).toHaveBeenCalledTimes( + 1 + ); + expect(stripeHelper.fetchCustomer as jest.Mock).toHaveBeenCalledWith( uid, ['invoice_settings.default_payment_method'] ); - sinon.assert.calledOnceWithExactly( - stripeHelper.extractCustomerDefaultPaymentDetails as sinon.SinonStub, - customer1 - ); + expect( + stripeHelper.extractCustomerDefaultPaymentDetails as jest.Mock + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.extractCustomerDefaultPaymentDetails as jest.Mock + ).toHaveBeenCalledWith(customer1); }); it('throws for a deleted customer', async () => { - billingEmailSandbox.stub(stripeHelper, 'fetchCustomer').resolves(null); + jest.spyOn(stripeHelper, 'fetchCustomer').mockResolvedValue(null); let thrown: any; try { await stripeHelper.extractCustomerDefaultPaymentDetailsByUid(uid); @@ -7047,7 +7374,7 @@ describe('StripeHelper', () => { }; beforeEach(() => { - expandMock.onCall(0).returns(mockPaymentMethod); + expandMock.mockReturnValueOnce(mockPaymentMethod); }); it('extracts from default payment method first when available', async () => { @@ -7078,7 +7405,7 @@ describe('StripeHelper', () => { }); it('extracts from default source when available', async () => { - expandMock.onCall(0).resolves(mockPaymentMethod); + expandMock.mockResolvedValueOnce(mockPaymentMethod); const customerCopy = deepCopy(paymentMockCustomer); customerCopy.invoice_settings.default_payment_method = null; const result = @@ -7094,7 +7421,8 @@ describe('StripeHelper', () => { it('does not include the postal code when address is not available in source', async () => { const noAddressPaymentMethod = deepCopy(mockPaymentMethod); delete noAddressPaymentMethod.billing_details.address; - expandMock.onCall(0).resolves(noAddressPaymentMethod); + expandMock.mockReset().mockResolvedValue({}); + expandMock.mockResolvedValueOnce(noAddressPaymentMethod); const customerCopy = deepCopy(paymentMockCustomer); customerCopy.invoice_settings.default_payment_method = null; const result = @@ -7248,15 +7576,15 @@ describe('StripeHelper', () => { const mockPaymentMethodBilling = { card }; beforeEach(() => { - sandbox.stub(stripeHelper, 'getPaymentProvider').returns('stripe'); + jest.spyOn(stripeHelper, 'getPaymentProvider').mockReturnValue('stripe'); }); it('returns the correct payment provider', async () => { const customer = { id: 'cus_xyz', invoice_settings: {} }; const actual = await stripeHelper.extractBillingDetails(customer); expect(actual).toEqual(paymentProvider); - sinon.assert.calledOnceWithExactly( - await stripeHelper.getPaymentProvider, + expect(await stripeHelper.getPaymentProvider).toHaveBeenCalledTimes(1); + expect(await stripeHelper.getPaymentProvider).toHaveBeenCalledWith( customer ); }); @@ -7277,16 +7605,16 @@ describe('StripeHelper', () => { customer.invoice_settings.default_payment_method.card.exp_year, brand: customer.invoice_settings.default_payment_method.card.brand, }); - sinon.assert.calledOnceWithExactly( - await stripeHelper.getPaymentProvider, + expect(await stripeHelper.getPaymentProvider).toHaveBeenCalledTimes(1); + expect(await stripeHelper.getPaymentProvider).toHaveBeenCalledWith( customer ); }); it('returns the card details from default source', async () => { - sandbox - .stub(stripeHelper, 'expandResource') - .resolves(mockPaymentMethodBilling); + jest + .spyOn(stripeHelper, 'expandResource') + .mockResolvedValue(mockPaymentMethodBilling); const customer: any = { id: 'cus_xyz', default_source: card.id, @@ -7303,8 +7631,8 @@ describe('StripeHelper', () => { exp_year: mockPaymentMethodBilling.card.exp_year, brand: mockPaymentMethodBilling.card.brand, }); - sinon.assert.calledOnceWithExactly( - await stripeHelper.getPaymentProvider, + expect(await stripeHelper.getPaymentProvider).toHaveBeenCalledTimes(1); + expect(await stripeHelper.getPaymentProvider).toHaveBeenCalledWith( customer ); }); @@ -7312,12 +7640,16 @@ describe('StripeHelper', () => { describe('allAbbrevPlans', () => { it('returns a AbbrevPlan list based on allPlans', async () => { - sandbox.spy(stripeHelper, 'allPlans'); - sandbox.spy(stripeHelper, 'allConfiguredPlans'); + jest.spyOn(stripeHelper, 'allPlans'); + jest.spyOn(stripeHelper, 'allConfiguredPlans'); const actual = await stripeHelper.allAbbrevPlans(); - expect(stripeHelper.allConfiguredPlans.calledOnce).toBeTruthy(); - expect(stripeHelper.allPlans.calledOnce).toBeTruthy(); - expect(stripeHelper.stripe.plans.list.calledOnce).toBeTruthy(); + expect( + stripeHelper.allConfiguredPlans.mock.calls.length === 1 + ).toBeTruthy(); + expect(stripeHelper.allPlans.mock.calls.length === 1).toBeTruthy(); + expect( + stripeHelper.stripe.plans.list.mock.calls.length === 1 + ).toBeTruthy(); expect(actual).toEqual( [plan1, plan2] @@ -7375,16 +7707,20 @@ describe('StripeHelper', () => { metadata: {}, product: { ...plan3.product, metadata: {} }, }; - listStripePlans.restore(); - sandbox - .stub(stripeHelper.stripe.plans, 'list') - .returns([first, second, third] as any); - sandbox.spy(stripeHelper, 'allPlans'); - sandbox.spy(stripeHelper, 'allConfiguredPlans'); + listStripePlans.mockRestore(); + jest + .spyOn(stripeHelper.stripe.plans, 'list') + .mockReturnValue([first, second, third] as any); + jest.spyOn(stripeHelper, 'allPlans'); + jest.spyOn(stripeHelper, 'allConfiguredPlans'); const actual = await stripeHelper.allAbbrevPlans(); - expect(stripeHelper.allConfiguredPlans.calledOnce).toBeTruthy(); - expect(stripeHelper.allPlans.calledOnce).toBeTruthy(); - expect(stripeHelper.stripe.plans.list.calledOnce).toBeTruthy(); + expect( + stripeHelper.allConfiguredPlans.mock.calls.length === 1 + ).toBeTruthy(); + expect(stripeHelper.allPlans.mock.calls.length === 1).toBeTruthy(); + expect( + stripeHelper.stripe.plans.list.mock.calls.length === 1 + ).toBeTruthy(); expect(actual).toEqual( [first] @@ -7432,27 +7768,32 @@ describe('StripeHelper', () => { it('rejects and returns stripe values', async () => { const err = new Error('It is bad'); const mockProductConfigurationManager = { - getPurchaseWithDetailsOfferingContentByPlanIds: sinon - .stub() - .rejects(err), - getSupportedLocale: sinon.fake.resolves('en'), + getPurchaseWithDetailsOfferingContentByPlanIds: jest + .fn() + .mockRejectedValue(err), + getSupportedLocale: jest.fn().mockResolvedValue('en'), }; Container.set( ProductConfigurationManager, mockProductConfigurationManager ); const localStripeHelper = new StripeHelper(log, mockConfig, mockStatsd); - listStripePlans = sandbox - .stub(localStripeHelper.stripe.plans, 'list') - .returns(asyncIterable([plan1, plan2, plan3]) as any); - sandbox.spy(localStripeHelper, 'allPlans'); - sandbox.spy(localStripeHelper, 'allConfiguredPlans'); - sandbox.stub(Sentry, 'captureException'); + listStripePlans = jest + .spyOn(localStripeHelper.stripe.plans, 'list') + .mockReturnValue(asyncIterable([plan1, plan2, plan3]) as any); + jest.spyOn(localStripeHelper, 'allPlans'); + jest.spyOn(localStripeHelper, 'allConfiguredPlans'); + jest.spyOn(Sentry, 'captureException'); const actual = await localStripeHelper.allAbbrevPlans(); - expect(localStripeHelper.allConfiguredPlans.calledOnce).toBeTruthy(); - expect(localStripeHelper.allPlans.calledOnce).toBeTruthy(); - expect(localStripeHelper.stripe.plans.list.calledOnce).toBeTruthy(); - sinon.assert.calledOnceWithExactly(Sentry.captureException, err); + expect( + localStripeHelper.allConfiguredPlans.mock.calls.length === 1 + ).toBeTruthy(); + expect(localStripeHelper.allPlans.mock.calls.length === 1).toBeTruthy(); + expect( + localStripeHelper.stripe.plans.list.mock.calls.length === 1 + ).toBeTruthy(); + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith(err); expect(actual).toEqual( [plan1, plan2] @@ -7509,9 +7850,10 @@ describe('StripeHelper', () => { }, }; const mockProductConfigurationManager = { - getPurchaseWithDetailsOfferingContentByPlanIds: - sinon.fake.resolves(mockCMSConfigUtil), - getSupportedLocale: sinon.fake.resolves('en'), + getPurchaseWithDetailsOfferingContentByPlanIds: jest + .fn() + .mockResolvedValue(mockCMSConfigUtil), + getSupportedLocale: jest.fn().mockResolvedValue('en'), }; Container.set( ProductConfigurationManager, @@ -7520,48 +7862,60 @@ describe('StripeHelper', () => { const localStripeHelper = new StripeHelper(log, mockConfig, mockStatsd); const newPlan1 = deepCopy(plan1); delete newPlan1.product.metadata['webIconURL']; - sandbox - .stub(localStripeHelper.stripe.plans, 'list') - .returns(asyncIterable([newPlan1, plan2, plan3]) as any); - sandbox.spy(localStripeHelper, 'allPlans'); - sandbox.spy(localStripeHelper, 'allConfiguredPlans'); + jest + .spyOn(localStripeHelper.stripe.plans, 'list') + .mockReturnValue(asyncIterable([newPlan1, plan2, plan3]) as any); + jest.spyOn(localStripeHelper, 'allPlans'); + jest.spyOn(localStripeHelper, 'allConfiguredPlans'); const sentryScope: any = { - setContext: sandbox.stub(), - setExtra: sandbox.stub(), + setContext: jest.fn(), + setExtra: jest.fn(), }; - sandbox.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sandbox.stub(sentryModule, 'reportSentryMessage'); + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(sentryModule, 'reportSentryMessage'); const actual = await localStripeHelper.allAbbrevPlans(); - expect(localStripeHelper.allConfiguredPlans.calledOnce).toBeTruthy(); - expect(localStripeHelper.allPlans.calledOnce).toBeTruthy(); - expect(localStripeHelper.stripe.plans.list.calledOnce).toBeTruthy(); + expect( + localStripeHelper.allConfiguredPlans.mock.calls.length === 1 + ).toBeTruthy(); + expect(localStripeHelper.allPlans.mock.calls.length === 1).toBeTruthy(); + expect( + localStripeHelper.stripe.plans.list.mock.calls.length === 1 + ).toBeTruthy(); expect(actual[0].plan_metadata['webIconURL']).toBe(newWebIconURL); expect(actual[0].product_metadata['webIconURL']).toBe(newWebIconURL); - sinon.assert.calledOnce(Sentry.withScope as sinon.SinonStub); - sinon.assert.calledOnce(sentryScope.setContext); + expect(Sentry.withScope as jest.Mock).toHaveBeenCalledTimes(1); + expect(sentryScope.setContext).toHaveBeenCalledTimes(1); }); it('returns CMS values when flag is enabled', async () => { mockConfig.cms.enabled = true; const mockProductConfigurationManager = { - getPurchaseWithDetailsOfferingContentByPlanIds: sinon.fake.resolves(), - getSupportedLocale: sinon.fake.resolves('en'), + getPurchaseWithDetailsOfferingContentByPlanIds: jest + .fn() + .mockResolvedValue(), + getSupportedLocale: jest.fn().mockResolvedValue('en'), }; Container.set( ProductConfigurationManager, mockProductConfigurationManager ); const localStripeHelper = new StripeHelper(log, mockConfig, mockStatsd); - sandbox - .stub(localStripeHelper.stripe.plans, 'list') - .returns(asyncIterable([plan1, plan2, plan3]) as any); - sandbox.spy(localStripeHelper, 'allPlans'); - sandbox.spy(localStripeHelper, 'allConfiguredPlans'); + jest + .spyOn(localStripeHelper.stripe.plans, 'list') + .mockReturnValue(asyncIterable([plan1, plan2, plan3]) as any); + jest.spyOn(localStripeHelper, 'allPlans'); + jest.spyOn(localStripeHelper, 'allConfiguredPlans'); await localStripeHelper.allAbbrevPlans(); expect(mockConfig.cms.enabled).toBe(true); - expect(localStripeHelper.allConfiguredPlans.calledOnce).toBeTruthy(); - expect(localStripeHelper.allPlans.calledOnce).toBeTruthy(); - expect(localStripeHelper.stripe.plans.list.calledOnce).toBeTruthy(); + expect( + localStripeHelper.allConfiguredPlans.mock.calls.length === 1 + ).toBeTruthy(); + expect(localStripeHelper.allPlans.mock.calls.length === 1).toBeTruthy(); + expect( + localStripeHelper.stripe.plans.list.mock.calls.length === 1 + ).toBeTruthy(); // cleanup mockConfig.cms.enabled = false; }); diff --git a/packages/fxa-auth-server/lib/payments/subscription-reminders.spec.ts b/packages/fxa-auth-server/lib/payments/subscription-reminders.spec.ts index 2cc6637c707..ccc48d836df 100644 --- a/packages/fxa-auth-server/lib/payments/subscription-reminders.spec.ts +++ b/packages/fxa-auth-server/lib/payments/subscription-reminders.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { DateTime, Duration, Interval } from 'luxon'; @@ -27,8 +26,6 @@ const planDuration = Duration.fromObject({ days: planLength }); const reminderLength = 7; // days const reminderDuration = Duration.fromObject({ days: reminderLength }); -const sandbox = sinon.createSandbox(); - const MOCK_INTERVAL = Interval.fromDateTimes( DateTime.fromMillis(1622073600000), DateTime.fromMillis(1622160000000) @@ -116,7 +113,7 @@ describe('SubscriptionReminders', () => { afterEach(() => { Date.now = realDateNow; Container.reset(); - sandbox.reset(); + jest.clearAllMocks(); }); describe('constructor', () => { @@ -146,28 +143,24 @@ describe('SubscriptionReminders', () => { it('returns [] when no plans are eligible', async () => { const shortPlan2 = deepCopy(shortPlan1); shortPlan2.interval = 'week'; - mockStripeHelper.allAbbrevPlans = sandbox.fake.resolves([ - shortPlan1, - shortPlan2, - ]); + mockStripeHelper.allAbbrevPlans = jest + .fn() + .mockResolvedValue([shortPlan1, shortPlan2]); const result = await reminder.getEligiblePlans(); expect(result).toEqual([]); }); it('returns a partial list when some plans are eligible', async () => { - mockStripeHelper.allAbbrevPlans = sandbox.fake.resolves([ - shortPlan1, - longPlan1, - longPlan2, - ]); + mockStripeHelper.allAbbrevPlans = jest + .fn() + .mockResolvedValue([shortPlan1, longPlan1, longPlan2]); const expected = [longPlan1, longPlan2]; const actual = await reminder.getEligiblePlans(); expect(actual).toEqual(expected); }); it('returns all when all plans are eligible', async () => { - mockStripeHelper.allAbbrevPlans = sandbox.fake.resolves([ - longPlan1, - longPlan2, - ]); + mockStripeHelper.allAbbrevPlans = jest + .fn() + .mockResolvedValue([longPlan1, longPlan2]); const expected = [longPlan1, longPlan2]; const actual = await reminder.getEligiblePlans(); expect(actual).toEqual(expected); @@ -177,7 +170,7 @@ describe('SubscriptionReminders', () => { describe('getStartAndEndTimes', () => { it('returns a time period of 1 day reminderLength days from "now" in UTC', () => { const realDateTimeUtc = DateTime.utc.bind(DateTime); - DateTime.utc = sinon.fake(() => + DateTime.utc = jest.fn(() => DateTime.fromMillis(MOCK_DATETIME_MS, { zone: 'utc' }) ) as any; const expected = MOCK_INTERVAL; @@ -202,33 +195,35 @@ describe('SubscriptionReminders', () => { ]; const sentEmailArgs = ['uid', EMAIL_TYPE, { subscriptionId: 'sub_123' }]; it('returns true for email already sent for this cycle', async () => { - SentEmail.findLatestSentEmailByType = sandbox.fake.resolves({ + SentEmail.findLatestSentEmailByType = jest.fn().mockResolvedValue({ sentAt: 12346, }); const result = await reminder.alreadySentEmail(...args); expect(result).toBe(true); - sinon.assert.calledOnceWithExactly( - SentEmail.findLatestSentEmailByType, + expect(SentEmail.findLatestSentEmailByType).toHaveBeenCalledTimes(1); + expect(SentEmail.findLatestSentEmailByType).toHaveBeenCalledWith( ...sentEmailArgs ); }); it('returns false for email that has not been sent during this billing cycle', async () => { - SentEmail.findLatestSentEmailByType = sandbox.fake.resolves({ + SentEmail.findLatestSentEmailByType = jest.fn().mockResolvedValue({ sentAt: 12344, }); const result = await reminder.alreadySentEmail(...args); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly( - SentEmail.findLatestSentEmailByType, + expect(SentEmail.findLatestSentEmailByType).toHaveBeenCalledTimes(1); + expect(SentEmail.findLatestSentEmailByType).toHaveBeenCalledWith( ...sentEmailArgs ); }); it('returns false for email that has never been sent', async () => { - SentEmail.findLatestSentEmailByType = sandbox.fake.resolves(undefined); + SentEmail.findLatestSentEmailByType = jest + .fn() + .mockResolvedValue(undefined); const result = await reminder.alreadySentEmail(...args); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly( - SentEmail.findLatestSentEmailByType, + expect(SentEmail.findLatestSentEmailByType).toHaveBeenCalledTimes(1); + expect(SentEmail.findLatestSentEmailByType).toHaveBeenCalledWith( ...sentEmailArgs ); }); @@ -237,16 +232,14 @@ describe('SubscriptionReminders', () => { describe('updateSentEmail', () => { it('creates a record in the SentEmails table', async () => { const sentEmailArgs = ['uid', EMAIL_TYPE, { subscriptionId: 'sub_123' }]; - SentEmail.createSentEmail = sandbox.fake.resolves({}); + SentEmail.createSentEmail = jest.fn().mockResolvedValue({}); await reminder.updateSentEmail( 'uid', { subscriptionId: 'sub_123' }, EMAIL_TYPE ); - sinon.assert.calledOnceWithExactly( - SentEmail.createSentEmail, - ...sentEmailArgs - ); + expect(SentEmail.createSentEmail).toHaveBeenCalledTimes(1); + expect(SentEmail.createSentEmail).toHaveBeenCalledWith(...sentEmailArgs); }); }); @@ -258,12 +251,12 @@ describe('SubscriptionReminders', () => { userid: null, }, }; - mockLog.error = sandbox.fake.returns({}); + mockLog.error = jest.fn().mockReturnValue({}); const result = await reminder.sendSubscriptionRenewalReminderEmail(subscription); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly( - mockLog.error, + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith( 'sendSubscriptionRenewalReminderEmail', { customer: subscription.customer, @@ -280,14 +273,14 @@ describe('SubscriptionReminders', () => { userid: 'uid', }, }; - reminder.alreadySentEmail = sandbox.fake.resolves(true); + reminder.alreadySentEmail = jest.fn().mockResolvedValue(true); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, longPlan1.id ); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly( - reminder.alreadySentEmail, + expect(reminder.alreadySentEmail).toHaveBeenCalledTimes(1); + expect(reminder.alreadySentEmail).toHaveBeenCalledWith( subscription.customer.metadata.userid, Math.floor(subscription.current_period_start * 1000), { @@ -306,29 +299,31 @@ describe('SubscriptionReminders', () => { userid: 'uid', }, }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); const account = { emails: [], email: 'testo@test.test', locale: 'NZ', }; - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.previewInvoiceBySubscriptionId = sandbox.fake.resolves({ - total_excluding_tax: invoicePreview.total_excluding_tax, - tax: invoicePreview.tax, - total: invoicePreview.total, - currency: invoicePreview.currency, - discount: null, - discounts: [], - }); - mockStripeHelper.getInvoice = sandbox.fake.resolves({ + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue({ + total_excluding_tax: invoicePreview.total_excluding_tax, + tax: invoicePreview.tax, + total: invoicePreview.total, + currency: invoicePreview.currency, + discount: null, + discounts: [], + }); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue({ id: subscription.latest_invoice, discount: { id: 'discount_ending' }, discounts: [], @@ -344,44 +339,49 @@ describe('SubscriptionReminders', () => { }, planConfig, }; - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves( - formattedSubscription - ); - mockStripeHelper.findPlanById = sandbox.fake.resolves({ + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue(formattedSubscription); + mockStripeHelper.findPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, longPlan1.id ); expect(result).toBe(true); - sinon.assert.calledOnceWithExactly( - reminder.db.account, + expect(reminder.db.account).toHaveBeenCalledTimes(1); + expect(reminder.db.account).toHaveBeenCalledWith( subscription.customer.metadata.userid ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.formatSubscriptionForEmail, + expect(mockStripeHelper.formatSubscriptionForEmail).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.formatSubscriptionForEmail).toHaveBeenCalledWith( subscription ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.findAbbrevPlanById, + expect(mockStripeHelper.findAbbrevPlanById).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.findAbbrevPlanById).toHaveBeenCalledWith( longPlan1.id ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.previewInvoiceBySubscriptionId, - { - subscriptionId: subscription.id, - } - ); - sinon.assert.calledOnceWithExactly( - mockLog.info, + expect( + mockStripeHelper.previewInvoiceBySubscriptionId + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.previewInvoiceBySubscriptionId + ).toHaveBeenCalledWith({ + subscriptionId: subscription.id, + }); + expect(mockLog.info).toHaveBeenCalledTimes(1); + expect(mockLog.info).toHaveBeenCalledWith( 'sendSubscriptionRenewalReminderEmail', { message: 'Sending a renewal reminder email.', @@ -392,30 +392,30 @@ describe('SubscriptionReminders', () => { reminderLength: 7, } ); - sinon.assert.calledOnceWithExactly( - reminder.mailer.sendSubscriptionRenewalReminderEmail, - account.emails, - account, - { - acceptLanguage: account.locale, - uid: 'uid', - email: 'testo@test.test', - subscription: formattedSubscription, - reminderLength: 7, - planInterval: 'month', - showTax: false, - invoiceTotalExcludingTaxInCents: invoicePreview.total_excluding_tax, - invoiceTaxInCents: invoicePreview.tax, - invoiceTotalInCents: invoicePreview.total, - invoiceTotalCurrency: invoicePreview.currency, - productMetadata: formattedSubscription.productMetadata, - planConfig, - discountEnding: true, - hasDifferentDiscount: false, - } - ); - sinon.assert.calledOnceWithExactly( - reminder.updateSentEmail, + expect( + reminder.mailer.sendSubscriptionRenewalReminderEmail + ).toHaveBeenCalledTimes(1); + expect( + reminder.mailer.sendSubscriptionRenewalReminderEmail + ).toHaveBeenCalledWith(account.emails, account, { + acceptLanguage: account.locale, + uid: 'uid', + email: 'testo@test.test', + subscription: formattedSubscription, + reminderLength: 7, + planInterval: 'month', + showTax: false, + invoiceTotalExcludingTaxInCents: invoicePreview.total_excluding_tax, + invoiceTaxInCents: invoicePreview.tax, + invoiceTotalInCents: invoicePreview.total, + invoiceTotalCurrency: invoicePreview.currency, + productMetadata: formattedSubscription.productMetadata, + planConfig, + discountEnding: true, + hasDifferentDiscount: false, + }); + expect(reminder.updateSentEmail).toHaveBeenCalledTimes(1); + expect(reminder.updateSentEmail).toHaveBeenCalledWith( subscription.customer.metadata.userid, { subscriptionId: subscription.id, reminderDays: 7 }, 'subscriptionRenewalReminder' @@ -430,43 +430,48 @@ describe('SubscriptionReminders', () => { userid: 'uid', }, }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); const account = { emails: [], email: 'testo@test.test', locale: 'NZ', }; - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.previewInvoiceBySubscriptionId = sandbox.fake.resolves({ - total: invoicePreview.total, - currency: invoicePreview.currency, - discount: null, - discounts: [], - }); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue({ + total: invoicePreview.total, + currency: invoicePreview.currency, + discount: null, + discounts: [], + }); // Monthly plan with no discount - should skip - mockStripeHelper.getInvoice = sandbox.fake.resolves({ + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue({ id: subscription.latest_invoice, discount: null, discounts: [], }); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: { - privacyUrl: 'http://privacy', - termsOfServiceUrl: 'http://tos', - }, - planConfig: {}, - }); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig: {}, + }); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -474,18 +479,17 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(false); - sinon.assert.calledWithExactly( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'sendSubscriptionRenewalReminderEmail.skippingMonthlyNoDiscount', { subscriptionId: subscription.id, planId: longPlan1.id, } ); - sinon.assert.notCalled( + expect( reminder.mailer.sendSubscriptionRenewalReminderEmail - ); - sinon.assert.notCalled(reminder.updateSentEmail); + ).not.toHaveBeenCalled(); + expect(reminder.updateSentEmail).not.toHaveBeenCalled(); }); it('sends yearly reminder regardless of discount status', async () => { @@ -497,28 +501,30 @@ describe('SubscriptionReminders', () => { userid: 'uid', }, }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); const account = { emails: [], email: 'testo@test.test', locale: 'NZ', }; - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: yearlyPlan.amount, currency: yearlyPlan.currency, interval_count: yearlyPlan.interval_count, interval: yearlyPlan.interval, }); - mockStripeHelper.previewInvoiceBySubscriptionId = sandbox.fake.resolves({ - total: invoicePreview.total, - currency: invoicePreview.currency, - discount: null, - discounts: [], - }); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue({ + total: invoicePreview.total, + currency: invoicePreview.currency, + discount: null, + discounts: [], + }); // Yearly plan with no discount - should still send - mockStripeHelper.getInvoice = sandbox.fake.resolves({ + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue({ id: subscription.latest_invoice, discount: null, discounts: [], @@ -534,12 +540,13 @@ describe('SubscriptionReminders', () => { }, planConfig, }; - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves( - formattedSubscription - ); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue(formattedSubscription); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -547,10 +554,10 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(true); - sinon.assert.calledOnce( + expect( reminder.mailer.sendSubscriptionRenewalReminderEmail - ); - sinon.assert.calledOnce(reminder.updateSentEmail); + ).toHaveBeenCalledTimes(1); + expect(reminder.updateSentEmail).toHaveBeenCalledTimes(1); }); it('returns false if an error is caught when trying to send a reminder email', async () => { @@ -561,67 +568,76 @@ describe('SubscriptionReminders', () => { userid: 'uid', }, }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves({}); - reminder.updateSentEmail = sandbox.fake.resolves({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({}); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue({}); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({}); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.previewInvoiceBySubscriptionId = sandbox.fake.resolves({ - total_excluding_tax: invoicePreview.total_excluding_tax, - tax: invoicePreview.tax, - total: invoicePreview.total, - currency: invoicePreview.currency, - discount: null, - discounts: [], - }); - mockStripeHelper.getInvoice = sandbox.fake.resolves({ + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue({ + total_excluding_tax: invoicePreview.total_excluding_tax, + tax: invoicePreview.tax, + total: invoicePreview.total, + currency: invoicePreview.currency, + discount: null, + discounts: [], + }); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue({ id: subscription.latest_invoice, discount: { id: 'discount_ending' }, discounts: [], }); - mockLog.info = sandbox.fake.returns({}); - mockLog.error = sandbox.fake.returns({}); + mockLog.info = jest.fn().mockReturnValue({}); + mockLog.error = jest.fn().mockReturnValue({}); const errMessage = 'Something went wrong.'; const throwErr = new Error(errMessage); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.rejects(throwErr); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockRejectedValue(throwErr); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, longPlan1.id ); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly( - reminder.db.account, + expect(reminder.db.account).toHaveBeenCalledTimes(1); + expect(reminder.db.account).toHaveBeenCalledWith( subscription.customer.metadata.userid ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.formatSubscriptionForEmail, + expect(mockStripeHelper.formatSubscriptionForEmail).toHaveBeenCalledTimes( + 1 + ); + expect(mockStripeHelper.formatSubscriptionForEmail).toHaveBeenCalledWith( subscription ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.findAbbrevPlanById, + expect(mockStripeHelper.findAbbrevPlanById).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.findAbbrevPlanById).toHaveBeenCalledWith( longPlan1.id ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.previewInvoiceBySubscriptionId, - { - subscriptionId: subscription.id, - } - ); - sinon.assert.calledOnceWithExactly( - mockLog.error, + expect( + mockStripeHelper.previewInvoiceBySubscriptionId + ).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.previewInvoiceBySubscriptionId + ).toHaveBeenCalledWith({ + subscriptionId: subscription.id, + }); + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith( 'sendSubscriptionRenewalReminderEmail', { err: throwErr, subscriptionId: subscription.id, } ); - sinon.assert.notCalled(reminder.updateSentEmail); + expect(reminder.updateSentEmail).not.toHaveBeenCalled(); }); it('detects when discount on latest invoice is ending', async () => { @@ -653,30 +669,34 @@ describe('SubscriptionReminders', () => { discounts: [], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: { - privacyUrl: 'http://privacy', - termsOfServiceUrl: 'http://tos', - }, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = - sandbox.fake.resolves(mockUpcomingInvoice); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoice); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -684,13 +704,13 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(true); - sinon.assert.calledOnce(mockStripeHelper.getInvoice); - sinon.assert.calledWithExactly(mockStripeHelper.getInvoice, 'in_test123'); + expect(mockStripeHelper.getInvoice).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.getInvoice).toHaveBeenCalledWith('in_test123'); - const mailerCall = - reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - expect(mailerCall.args[2].discountEnding).toBe(true); - expect(mailerCall.args[2].hasDifferentDiscount).toBe(false); + const mailerCallArgs = + reminder.mailer.sendSubscriptionRenewalReminderEmail.mock.calls[0]; + expect(mailerCallArgs[2].discountEnding).toBe(true); + expect(mailerCallArgs[2].hasDifferentDiscount).toBe(false); }); it('detects when discount is ending with discounts array', async () => { @@ -722,30 +742,34 @@ describe('SubscriptionReminders', () => { discounts: [], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: { - privacyUrl: 'http://privacy', - termsOfServiceUrl: 'http://tos', - }, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = - sandbox.fake.resolves(mockUpcomingInvoice); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoice); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -753,10 +777,10 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(true); - const mailerCall = - reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - expect(mailerCall.args[2].discountEnding).toBe(true); - expect(mailerCall.args[2].hasDifferentDiscount).toBe(false); + const mailerCallArgs = + reminder.mailer.sendSubscriptionRenewalReminderEmail.mock.calls[0]; + expect(mailerCallArgs[2].discountEnding).toBe(true); + expect(mailerCallArgs[2].hasDifferentDiscount).toBe(false); }); it('skips monthly plan reminders when discount changes but does not end', async () => { @@ -788,30 +812,34 @@ describe('SubscriptionReminders', () => { discounts: [], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: { - privacyUrl: 'http://privacy', - termsOfServiceUrl: 'http://tos', - }, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = - sandbox.fake.resolves(mockUpcomingInvoice); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoice); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -819,9 +847,9 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(false); - sinon.assert.notCalled( + expect( reminder.mailer.sendSubscriptionRenewalReminderEmail - ); + ).not.toHaveBeenCalled(); }); it('skips monthly plan reminders when discount remains the same', async () => { @@ -853,30 +881,34 @@ describe('SubscriptionReminders', () => { discounts: [], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: { - privacyUrl: 'http://privacy', - termsOfServiceUrl: 'http://tos', - }, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = - sandbox.fake.resolves(mockUpcomingInvoice); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoice); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -884,9 +916,9 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(false); - sinon.assert.notCalled( + expect( reminder.mailer.sendSubscriptionRenewalReminderEmail - ); + ).not.toHaveBeenCalled(); }); it('handles when latest_invoice is an expanded object with discount ending', async () => { @@ -916,30 +948,34 @@ describe('SubscriptionReminders', () => { discounts: [], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: { - privacyUrl: 'http://privacy', - termsOfServiceUrl: 'http://tos', - }, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves({}); - mockStripeHelper.previewInvoiceBySubscriptionId = - sandbox.fake.resolves(mockUpcomingInvoice); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue({}); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoice); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -947,12 +983,12 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(true); - sinon.assert.notCalled(mockStripeHelper.getInvoice); + expect(mockStripeHelper.getInvoice).not.toHaveBeenCalled(); - const mailerCall = - reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - expect(mailerCall.args[2].discountEnding).toBe(true); - expect(mailerCall.args[2].hasDifferentDiscount).toBe(false); + const mailerCallArgs = + reminder.mailer.sendSubscriptionRenewalReminderEmail.mock.calls[0]; + expect(mailerCallArgs[2].discountEnding).toBe(true); + expect(mailerCallArgs[2].hasDifferentDiscount).toBe(false); }); it('skips monthly plan reminders when no discount on either invoice', async () => { @@ -984,30 +1020,34 @@ describe('SubscriptionReminders', () => { discounts: [], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: { - privacyUrl: 'http://privacy', - termsOfServiceUrl: 'http://tos', - }, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = - sandbox.fake.resolves(mockUpcomingInvoice); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoice); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -1015,9 +1055,9 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(false); - sinon.assert.notCalled( + expect( reminder.mailer.sendSubscriptionRenewalReminderEmail - ); + ).not.toHaveBeenCalled(); }); it('skips monthly plan reminders when adding a discount to a full-price plan', async () => { @@ -1049,30 +1089,34 @@ describe('SubscriptionReminders', () => { discounts: [], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: { - privacyUrl: 'http://privacy', - termsOfServiceUrl: 'http://tos', - }, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = - sandbox.fake.resolves(mockUpcomingInvoice); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoice); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -1080,9 +1124,9 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(false); - sinon.assert.notCalled( + expect( reminder.mailer.sendSubscriptionRenewalReminderEmail - ); + ).not.toHaveBeenCalled(); }); it('handles discount as string in discounts array', async () => { @@ -1114,30 +1158,34 @@ describe('SubscriptionReminders', () => { discounts: [], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: { - privacyUrl: 'http://privacy', - termsOfServiceUrl: 'http://tos', - }, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = - sandbox.fake.resolves(mockUpcomingInvoice); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoice); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -1145,10 +1193,10 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(true); - const mailerCall = - reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - expect(mailerCall.args[2].discountEnding).toBe(true); - expect(mailerCall.args[2].hasDifferentDiscount).toBe(false); + const mailerCallArgs = + reminder.mailer.sendSubscriptionRenewalReminderEmail.mock.calls[0]; + expect(mailerCallArgs[2].discountEnding).toBe(true); + expect(mailerCallArgs[2].hasDifferentDiscount).toBe(false); }); it('skips monthly plan reminders with different discount in discounts arrays', async () => { @@ -1180,30 +1228,34 @@ describe('SubscriptionReminders', () => { discounts: [{ id: 'discount_new' }], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: { - privacyUrl: 'http://privacy', - termsOfServiceUrl: 'http://tos', - }, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: { + privacyUrl: 'http://privacy', + termsOfServiceUrl: 'http://tos', + }, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = - sandbox.fake.resolves(mockUpcomingInvoice); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoice); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -1211,9 +1263,9 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(false); - sinon.assert.notCalled( + expect( reminder.mailer.sendSubscriptionRenewalReminderEmail - ); + ).not.toHaveBeenCalled(); }); it('includes tax information when invoice has tax', async () => { @@ -1254,28 +1306,31 @@ describe('SubscriptionReminders', () => { ], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: {}, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: {}, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = sandbox.fake.resolves( - mockUpcomingInvoiceWithTax - ); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoiceWithTax); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -1283,9 +1338,9 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(true); - const mailerCall = - reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - const emailData = mailerCall.args[2]; + const mailerCallArgs = + reminder.mailer.sendSubscriptionRenewalReminderEmail.mock.calls[0]; + const emailData = mailerCallArgs[2]; expect(emailData.showTax).toBe(true); expect(emailData.invoiceTotalExcludingTaxInCents).toBe(1000); expect(emailData.invoiceTaxInCents).toBe(200); @@ -1324,28 +1379,31 @@ describe('SubscriptionReminders', () => { discounts: [], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: {}, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: {}, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = sandbox.fake.resolves( - mockUpcomingInvoiceNoTax - ); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoiceNoTax); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -1353,9 +1411,9 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(true); - const mailerCall = - reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - const emailData = mailerCall.args[2]; + const mailerCallArgs = + reminder.mailer.sendSubscriptionRenewalReminderEmail.mock.calls[0]; + const emailData = mailerCallArgs[2]; expect(emailData.showTax).toBe(false); expect(emailData.invoiceTotalExcludingTaxInCents).toBe(1000); expect(emailData.invoiceTaxInCents).toBe(0); @@ -1394,28 +1452,31 @@ describe('SubscriptionReminders', () => { discounts: [], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: {}, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: {}, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = sandbox.fake.resolves( - mockUpcomingInvoiceNullTax - ); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoiceNullTax); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -1423,9 +1484,9 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(true); - const mailerCall = - reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - const emailData = mailerCall.args[2]; + const mailerCallArgs = + reminder.mailer.sendSubscriptionRenewalReminderEmail.mock.calls[0]; + const emailData = mailerCallArgs[2]; expect(emailData.showTax).toBe(false); expect(emailData.invoiceTotalExcludingTaxInCents).toBe(1000); expect(emailData.invoiceTaxInCents).toBeNull(); @@ -1467,28 +1528,31 @@ describe('SubscriptionReminders', () => { ], }; - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.db.account = sandbox.fake.resolves(account); - mockLog.info = sandbox.fake.returns({}); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves({ - id: 'subscriptionId', - productMetadata: {}, - planConfig: {}, - }); - mockStripeHelper.findAbbrevPlanById = sandbox.fake.resolves({ + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.db.account = jest.fn().mockResolvedValue(account); + mockLog.info = jest.fn().mockReturnValue({}); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue({ + id: 'subscriptionId', + productMetadata: {}, + planConfig: {}, + }); + mockStripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue({ amount: longPlan1.amount, currency: longPlan1.currency, interval_count: longPlan1.interval_count, interval: longPlan1.interval, }); - mockStripeHelper.getInvoice = sandbox.fake.resolves(mockInvoice); - mockStripeHelper.previewInvoiceBySubscriptionId = sandbox.fake.resolves( - mockUpcomingInvoiceWithInclusiveTax - ); - reminder.mailer.sendSubscriptionRenewalReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves({}); - Date.now = sinon.fake(() => MOCK_DATETIME_MS); + mockStripeHelper.getInvoice = jest.fn().mockResolvedValue(mockInvoice); + mockStripeHelper.previewInvoiceBySubscriptionId = jest + .fn() + .mockResolvedValue(mockUpcomingInvoiceWithInclusiveTax); + reminder.mailer.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue({}); + Date.now = jest.fn(() => MOCK_DATETIME_MS); const result = await reminder.sendSubscriptionRenewalReminderEmail( subscription, @@ -1496,9 +1560,9 @@ describe('SubscriptionReminders', () => { ); expect(result).toBe(true); - const mailerCall = - reminder.mailer.sendSubscriptionRenewalReminderEmail.getCall(0); - const emailData = mailerCall.args[2]; + const mailerCallArgs = + reminder.mailer.sendSubscriptionRenewalReminderEmail.mock.calls[0]; + const emailData = mailerCallArgs[2]; expect(emailData.showTax).toBe(false); expect(emailData.invoiceTotalExcludingTaxInCents).toBe(887); expect(emailData.invoiceTaxInCents).toBe(113); @@ -1556,19 +1620,20 @@ describe('SubscriptionReminders', () => { let spyReportSentryError: any; let stubGetUidAndEmail: any; beforeEach(() => { - spyReportSentryError = sinon.spy(sentry, 'reportSentryError'); - stubGetUidAndEmail = sinon - .stub(authDbModule, 'getUidAndEmailByStripeCustomerId') - .resolves({ uid: mockUid, email: mockAccount.email }); - reminder.db.account = sandbox.fake.resolves(mockAccount); - reminder.alreadySentEmail = sandbox.fake.resolves(false); - reminder.mailer.sendSubscriptionEndingReminderEmail = - sandbox.fake.resolves(true); - reminder.updateSentEmail = sandbox.fake.resolves(); - mockStripeHelper.formatSubscriptionForEmail = sandbox.fake.resolves( - mockFormattedSubscription - ); - mockPurchaseForPriceId = sandbox.fake.returns({ + spyReportSentryError = jest.spyOn(sentry, 'reportSentryError'); + stubGetUidAndEmail = jest + .spyOn(authDbModule, 'getUidAndEmailByStripeCustomerId') + .mockResolvedValue({ uid: mockUid, email: mockAccount.email }); + reminder.db.account = jest.fn().mockResolvedValue(mockAccount); + reminder.alreadySentEmail = jest.fn().mockResolvedValue(false); + reminder.mailer.sendSubscriptionEndingReminderEmail = jest + .fn() + .mockResolvedValue(true); + reminder.updateSentEmail = jest.fn().mockResolvedValue(); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockResolvedValue(mockFormattedSubscription); + mockPurchaseForPriceId = jest.fn().mockReturnValue({ offering: { commonContent: { supportUrl: mockSupportUrl, @@ -1589,12 +1654,14 @@ describe('SubscriptionReminders', () => { }, apiIdentifier: 'vpn', }); - mockProductConfigurationManager.getPageContentByPriceIds = - sandbox.fake.resolves({ + mockProductConfigurationManager.getPageContentByPriceIds = jest + .fn() + .mockResolvedValue({ purchaseForPriceId: mockPurchaseForPriceId, }); - mockChurnInterventionService.determineStaySubscribedEligibility = - sandbox.fake.resolves({ + mockChurnInterventionService.determineStaySubscribedEligibility = jest + .fn() + .mockResolvedValue({ isEligibile: true, cmsChurnInterventionEntry: { ctaMessage: mockCtaMessage, @@ -1604,7 +1671,7 @@ describe('SubscriptionReminders', () => { }); afterEach(() => { - sinon.restore(); + jest.restoreAllMocks(); }); it('should return true if the email was sent successfully', async () => { @@ -1612,36 +1679,42 @@ describe('SubscriptionReminders', () => { await reminder.sendSubscriptionEndingReminderEmail(mockSubscription); expect(actual).toBe(true); - sinon.assert.calledOnceWithExactly(stubGetUidAndEmail, mockCustomerId); - sinon.assert.calledOnceWithExactly( - reminder.alreadySentEmail, + expect(stubGetUidAndEmail).toHaveBeenCalledTimes(1); + expect(stubGetUidAndEmail).toHaveBeenCalledWith(mockCustomerId); + expect(reminder.alreadySentEmail).toHaveBeenCalledTimes(1); + expect(reminder.alreadySentEmail).toHaveBeenCalledWith( mockUid, mockSubCurrentPeriodStart * 1000, { subscriptionId: mockSubscriptionId }, 'subscriptionEndingReminder' ); - sinon.assert.calledOnceWithExactly(reminder.db.account, mockUid); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.formatSubscriptionForEmail, - mockSubscription - ); - sinon.assert.calledOnceWithExactly( - mockProductConfigurationManager.getPageContentByPriceIds, - [mockPlanId], - mockAccount.locale + expect(reminder.db.account).toHaveBeenCalledTimes(1); + expect(reminder.db.account).toHaveBeenCalledWith(mockUid); + expect(mockStripeHelper.formatSubscriptionForEmail).toHaveBeenCalledTimes( + 1 ); - sinon.assert.calledOnceWithExactly(mockPurchaseForPriceId, mockPlanId); - sinon.assert.calledOnceWithExactly( - mockChurnInterventionService.determineStaySubscribedEligibility, - mockUid, - mockSubscriptionId, - mockAccount.locale + expect(mockStripeHelper.formatSubscriptionForEmail).toHaveBeenCalledWith( + mockSubscription ); - sinon.assert.calledOnce( + expect( + mockProductConfigurationManager.getPageContentByPriceIds + ).toHaveBeenCalledTimes(1); + expect( + mockProductConfigurationManager.getPageContentByPriceIds + ).toHaveBeenCalledWith([mockPlanId], mockAccount.locale); + expect(mockPurchaseForPriceId).toHaveBeenCalledTimes(1); + expect(mockPurchaseForPriceId).toHaveBeenCalledWith(mockPlanId); + expect( + mockChurnInterventionService.determineStaySubscribedEligibility + ).toHaveBeenCalledTimes(1); + expect( + mockChurnInterventionService.determineStaySubscribedEligibility + ).toHaveBeenCalledWith(mockUid, mockSubscriptionId, mockAccount.locale); + expect( reminder.mailer.sendSubscriptionEndingReminderEmail - ); - sinon.assert.calledOnceWithExactly( - reminder.updateSentEmail, + ).toHaveBeenCalledTimes(1); + expect(reminder.updateSentEmail).toHaveBeenCalledTimes(1); + expect(reminder.updateSentEmail).toHaveBeenCalledWith( mockUid, { subscriptionId: mockSubscriptionId }, 'subscriptionEndingReminder' @@ -1649,96 +1722,99 @@ describe('SubscriptionReminders', () => { }); it('should return false if customer uid is not provided', async () => { - stubGetUidAndEmail.resolves({ uid: null, email: null }); + stubGetUidAndEmail.mockResolvedValue({ uid: null, email: null }); const actual = await reminder.sendSubscriptionEndingReminderEmail(mockSubscription); expect(actual).toBe(false); - sinon.assert.calledOnce(stubGetUidAndEmail); - sinon.assert.notCalled( + expect(stubGetUidAndEmail).toHaveBeenCalledTimes(1); + expect( reminder.mailer.sendSubscriptionEndingReminderEmail - ); - sinon.assert.calledOnce(spyReportSentryError); + ).not.toHaveBeenCalled(); + expect(spyReportSentryError).toHaveBeenCalledTimes(1); }); it('should return false if email already sent', async () => { - reminder.alreadySentEmail = sandbox.fake.resolves(true); + reminder.alreadySentEmail = jest.fn().mockResolvedValue(true); const actual = await reminder.sendSubscriptionEndingReminderEmail(mockSubscription); expect(actual).toBe(false); - sinon.assert.calledOnce(reminder.alreadySentEmail); - sinon.assert.notCalled(spyReportSentryError); - sinon.assert.notCalled( + expect(reminder.alreadySentEmail).toHaveBeenCalledTimes(1); + expect(spyReportSentryError).not.toHaveBeenCalled(); + expect( reminder.mailer.sendSubscriptionEndingReminderEmail - ); + ).not.toHaveBeenCalled(); }); it('should return false if an error occurs when sending the email', async () => { const mockError = new Error('Failed to send email'); - mockStripeHelper.formatSubscriptionForEmail = - sandbox.fake.rejects(mockError); + mockStripeHelper.formatSubscriptionForEmail = jest + .fn() + .mockRejectedValue(mockError); const actual = await reminder.sendSubscriptionEndingReminderEmail(mockSubscription); expect(actual).toBe(false); - sinon.assert.calledOnceWithExactly(spyReportSentryError, mockError); + expect(spyReportSentryError).toHaveBeenCalledTimes(1); + expect(spyReportSentryError).toHaveBeenCalledWith(mockError); }); }); describe('sendReminders', () => { beforeEach(() => { - reminder.getEligiblePlans = sandbox.fake.resolves([longPlan1, longPlan2]); - reminder.getStartAndEndTimes = sandbox.fake.returns(MOCK_INTERVAL); + reminder.getEligiblePlans = jest + .fn() + .mockResolvedValue([longPlan1, longPlan2]); + reminder.getStartAndEndTimes = jest.fn().mockReturnValue(MOCK_INTERVAL); async function* genSubscriptionForPlan1() { yield longSubscription1; } async function* genSubscriptionForPlan2() { yield longSubscription2; } - const stub = sandbox.stub( + const stub = jest.spyOn( mockStripeHelper, 'findActiveSubscriptionsByPlanId' ); - stub.onFirstCall().callsFake(genSubscriptionForPlan1); - stub.onSecondCall().callsFake(genSubscriptionForPlan2); + stub + .mockImplementationOnce(genSubscriptionForPlan1) + .mockImplementationOnce(genSubscriptionForPlan2); }); it('returns true if it can process all eligible subscriptions', async () => { - reminder.sendSubscriptionRenewalReminderEmail = sandbox.fake.resolves({}); + reminder.sendSubscriptionRenewalReminderEmail = jest + .fn() + .mockResolvedValue({}); const result = await reminder.sendReminders(); expect(result).toBe(true); - sinon.assert.calledOnce(reminder.getEligiblePlans); - sinon.assert.calledTwice(reminder.getStartAndEndTimes); - sinon.assert.calledWith( - reminder.getStartAndEndTimes, + expect(reminder.getEligiblePlans).toHaveBeenCalledTimes(1); + expect(reminder.getStartAndEndTimes).toHaveBeenCalledTimes(2); + expect(reminder.getStartAndEndTimes).toHaveBeenCalledWith( Duration.fromObject({ days: 15 }) ); - sinon.assert.calledWith( - reminder.getStartAndEndTimes, + expect(reminder.getStartAndEndTimes).toHaveBeenCalledWith( Duration.fromObject({ days: 7 }) ); // We iterate through each plan, longPlan1 and longPlan2, and there is one // subscription, longSubscription1 and longSubscription2 respectively, // returned for each plan. - sinon.assert.calledTwice( + expect( mockStripeHelper.findActiveSubscriptionsByPlanId - ); - sinon.assert.calledTwice(reminder.sendSubscriptionRenewalReminderEmail); + ).toHaveBeenCalledTimes(2); + expect( + reminder.sendSubscriptionRenewalReminderEmail + ).toHaveBeenCalledTimes(2); }); it('returns false and logs an error for any eligible subscription that it fails to process', async () => { - mockLog.error = sandbox.fake.returns({}); + mockLog.error = jest.fn().mockReturnValue({}); const errMessage = 'Something went wrong.'; const throwErr = new Error(errMessage); - const stub = sandbox.stub( - reminder, - 'sendSubscriptionRenewalReminderEmail' - ); - stub.onFirstCall().rejects(throwErr); - stub.onSecondCall().resolves({}); + const stub = jest.spyOn(reminder, 'sendSubscriptionRenewalReminderEmail'); + stub.mockRejectedValueOnce(throwErr).mockResolvedValueOnce({}); const result = await reminder.sendReminders(); expect(result).toBe(false); - sinon.assert.calledOnceWithExactly( - mockLog.error, + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith( 'sendSubscriptionRenewalReminderEmail', { err: throwErr, @@ -1746,23 +1822,21 @@ describe('SubscriptionReminders', () => { reminderDuration: 7, } ); - stub.firstCall.calledWithExactly(longSubscription1); - stub.secondCall.calledWithExactly(longSubscription2); - sinon.assert.calledTwice(stub); + expect(stub).toHaveBeenCalledTimes(2); + expect(stub.mock.calls[0][0]).toEqual(longSubscription1); + expect(stub.mock.calls[1][0]).toEqual(longSubscription2); }); it('calls sendEndingReminders if enabled in config', async () => { - reminder.sendEndingReminders = sandbox.fake.resolves({}); + reminder.sendEndingReminders = jest.fn().mockResolvedValue({}); reminder.endingReminderEnabled = true; await reminder.sendReminders(); - sinon.assert.calledWith( - reminder.sendEndingReminders, + expect(reminder.sendEndingReminders).toHaveBeenCalledWith( Duration.fromObject({ days: mockMonthlyReminderDuration }), 'monthly' ); - sinon.assert.calledWith( - reminder.sendEndingReminders, + expect(reminder.sendEndingReminders).toHaveBeenCalledWith( Duration.fromObject({ days: mockYearlyReminderDuration }), 'yearly' ); @@ -1770,25 +1844,22 @@ describe('SubscriptionReminders', () => { it('calls sendEndingReminders for daily if dailyEndingReminderDuration is provided', async () => { const mockDailyReminderDays = 3; - reminder.sendEndingReminders = sandbox.fake.resolves({}); + reminder.sendEndingReminders = jest.fn().mockResolvedValue({}); reminder.endingReminderEnabled = true; reminder.dailyEndingReminderDuration = Duration.fromObject({ days: mockDailyReminderDays, }); await reminder.sendReminders(); - sinon.assert.calledWith( - reminder.sendEndingReminders, + expect(reminder.sendEndingReminders).toHaveBeenCalledWith( Duration.fromObject({ days: mockDailyReminderDays }), 'daily' ); - sinon.assert.calledWith( - reminder.sendEndingReminders, + expect(reminder.sendEndingReminders).toHaveBeenCalledWith( Duration.fromObject({ days: mockMonthlyReminderDuration }), 'monthly' ); - sinon.assert.calledWith( - reminder.sendEndingReminders, + expect(reminder.sendEndingReminders).toHaveBeenCalledWith( Duration.fromObject({ days: mockYearlyReminderDuration }), 'yearly' ); @@ -1796,36 +1867,36 @@ describe('SubscriptionReminders', () => { it('sends 15-day reminders only to yearly plans and 7-day reminders only to monthly plans', async () => { const yearlyPlan = require('../../test/local/payments/fixtures/stripe/plan_yearly.json'); - reminder.getEligiblePlans = sandbox.fake.resolves([ + reminder.getEligiblePlans = jest.fn().mockResolvedValue([ longPlan1, // monthly longPlan2, // monthly yearlyPlan, // yearly ]); - reminder.getStartAndEndTimes = sandbox.fake.returns(MOCK_INTERVAL); + reminder.getStartAndEndTimes = jest.fn().mockReturnValue(MOCK_INTERVAL); - const sendRenewalStub = sandbox.stub( + const sendRenewalStub = jest.spyOn( reminder, 'sendRenewalRemindersForDuration' ); - sendRenewalStub.resolves(true); + sendRenewalStub.mockResolvedValue(true); await reminder.sendReminders(); // Should be called twice: once for yearly plans, once for monthly plans - sinon.assert.calledTwice(sendRenewalStub); + expect(sendRenewalStub).toHaveBeenCalledTimes(2); // First call: yearly plans with 15-day duration - const firstCall = sendRenewalStub.getCall(0); - expect(firstCall.args[0].length).toEqual(1); - expect(firstCall.args[0][0].id).toEqual(yearlyPlan.id); - expect(firstCall.args[1].as('days')).toEqual(15); + const firstCallArgs = sendRenewalStub.mock.calls[0]; + expect(firstCallArgs[0].length).toEqual(1); + expect(firstCallArgs[0][0].id).toEqual(yearlyPlan.id); + expect(firstCallArgs[1].as('days')).toEqual(15); // Second call: monthly plans with 7-day duration - const secondCall = sendRenewalStub.getCall(1); - expect(secondCall.args[0].length).toEqual(2); - expect(secondCall.args[0][0].id).toEqual(longPlan1.id); - expect(secondCall.args[0][1].id).toEqual(longPlan2.id); - expect(secondCall.args[1].as('days')).toEqual(7); + const secondCallArgs = sendRenewalStub.mock.calls[1]; + expect(secondCallArgs[0].length).toEqual(2); + expect(secondCallArgs[0][0].id).toEqual(longPlan1.id); + expect(secondCallArgs[0][1].id).toEqual(longPlan2.id); + expect(secondCallArgs[1].as('days')).toEqual(7); }); }); @@ -1856,34 +1927,36 @@ describe('SubscriptionReminders', () => { }; beforeEach(() => { - mockStatsD.increment = sandbox.fake.returns({}); - mockSubscriptionManager.listCancelOnDateGenerator = sandbox - .stub() - .callsFake(function* () { + mockStatsD.increment = jest.fn().mockReturnValue({}); + mockSubscriptionManager.listCancelOnDateGenerator = jest + .fn() + .mockImplementation(function* () { yield mockSubscriptionMonthly; yield mockSubscriptionYearly; }); - reminder.sendSubscriptionEndingReminderEmail = - sandbox.fake.resolves(true); + reminder.sendSubscriptionEndingReminderEmail = jest + .fn() + .mockResolvedValue(true); }); afterEach(() => { - sinon.restore(); + jest.restoreAllMocks(); }); it('successfully sends an email for monthly subscriptions and increments sendCount', async () => { await reminder.sendEndingReminders(mockDuration, mockSubplatInterval); - sinon.assert.calledOnceWithExactly( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith( 'subscription-reminders.endingReminders.monthly' ); - sinon.assert.calledOnceWithExactly( - reminder.sendSubscriptionEndingReminderEmail, + expect( + reminder.sendSubscriptionEndingReminderEmail + ).toHaveBeenCalledTimes(1); + expect(reminder.sendSubscriptionEndingReminderEmail).toHaveBeenCalledWith( mockSubscriptionMonthly ); - sinon.assert.callCount(reminder.log.info, 2); - sinon.assert.calledWithExactly( - reminder.log.info, + expect(reminder.log.info).toHaveBeenCalledTimes(2); + expect(reminder.log.info).toHaveBeenCalledWith( 'sendSubscriptionEndingReminderEmail.sendEndingReminders.end', { reminderLengthDays: 14, @@ -1895,17 +1968,18 @@ describe('SubscriptionReminders', () => { it('successfully sends an email for yearly subscriptions and increments sendCount', async () => { await reminder.sendEndingReminders(mockDuration, 'yearly'); - sinon.assert.calledOnceWithExactly( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith( 'subscription-reminders.endingReminders.yearly' ); - sinon.assert.calledOnceWithExactly( - reminder.sendSubscriptionEndingReminderEmail, + expect( + reminder.sendSubscriptionEndingReminderEmail + ).toHaveBeenCalledTimes(1); + expect(reminder.sendSubscriptionEndingReminderEmail).toHaveBeenCalledWith( mockSubscriptionYearly ); - sinon.assert.callCount(reminder.log.info, 2); - sinon.assert.calledWithExactly( - reminder.log.info, + expect(reminder.log.info).toHaveBeenCalledTimes(2); + expect(reminder.log.info).toHaveBeenCalledWith( 'sendSubscriptionEndingReminderEmail.sendEndingReminders.end', { reminderLengthDays: 14, @@ -1917,13 +1991,14 @@ describe('SubscriptionReminders', () => { it('sends no emails if no subscriptions match subplat interval', async () => { await reminder.sendEndingReminders(mockDuration, 'weekly'); - sinon.assert.calledOnceWithExactly( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith( 'subscription-reminders.endingReminders.weekly' ); - sinon.assert.notCalled(reminder.sendSubscriptionEndingReminderEmail); - sinon.assert.calledWithExactly( - reminder.log.info, + expect( + reminder.sendSubscriptionEndingReminderEmail + ).not.toHaveBeenCalled(); + expect(reminder.log.info).toHaveBeenCalledWith( 'sendSubscriptionEndingReminderEmail.sendEndingReminders.end', { reminderLengthDays: 14, @@ -1934,20 +2009,22 @@ describe('SubscriptionReminders', () => { }); it('it does not increment sendCount if no email is sent', async () => { - reminder.sendSubscriptionEndingReminderEmail = - sandbox.fake.resolves(false); + reminder.sendSubscriptionEndingReminderEmail = jest + .fn() + .mockResolvedValue(false); await reminder.sendEndingReminders(mockDuration, mockSubplatInterval); - sinon.assert.calledOnceWithExactly( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith( 'subscription-reminders.endingReminders.monthly' ); - sinon.assert.calledOnceWithExactly( - reminder.sendSubscriptionEndingReminderEmail, + expect( + reminder.sendSubscriptionEndingReminderEmail + ).toHaveBeenCalledTimes(1); + expect(reminder.sendSubscriptionEndingReminderEmail).toHaveBeenCalledWith( mockSubscriptionMonthly ); - sinon.assert.calledWithExactly( - reminder.log.info, + expect(reminder.log.info).toHaveBeenCalledWith( 'sendSubscriptionEndingReminderEmail.sendEndingReminders.end', { reminderLengthDays: 14, @@ -1971,9 +2048,9 @@ describe('SubscriptionReminders', () => { ], }, }; - mockSubscriptionManager.listCancelOnDateGenerator = sandbox - .stub() - .callsFake(function* () { + mockSubscriptionManager.listCancelOnDateGenerator = jest + .fn() + .mockImplementation(function* () { yield mockSubscriptionNoRecurring; }); try { @@ -1982,7 +2059,9 @@ describe('SubscriptionReminders', () => { } catch (error: any) { expect(error instanceof Error).toBe(true); expect(error.info.priceId).toEqual(mockPriceId); - sinon.assert.notCalled(reminder.sendSubscriptionEndingReminderEmail); + expect( + reminder.sendSubscriptionEndingReminderEmail + ).not.toHaveBeenCalled(); } }); }); diff --git a/packages/fxa-auth-server/lib/profile/updates.spec.ts b/packages/fxa-auth-server/lib/profile/updates.spec.ts index e356d68449d..313cb6a9077 100644 --- a/packages/fxa-auth-server/lib/profile/updates.spec.ts +++ b/packages/fxa-auth-server/lib/profile/updates.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { EventEmitter } from 'events'; const { mockDB, mockLog } = require('../../test/mocks'); @@ -12,13 +11,13 @@ const mockDeliveryQueue = new EventEmitter(); (mockDeliveryQueue as any).start = function start() {}; function mockMessage(msg: any) { - msg.del = sinon.spy(); + msg.del = jest.fn(); return msg; } let pushShouldThrow = false; const mockPush = { - notifyProfileUpdated: sinon.spy((uid: string) => { + notifyProfileUpdated: jest.fn((uid: string) => { expect(typeof uid).toBe('string'); if (pushShouldThrow) { throw new Error('oops'); @@ -33,7 +32,7 @@ function mockProfileUpdates(log: any) { describe('profile updates', () => { beforeEach(() => { - mockPush.notifyProfileUpdated.resetHistory(); + mockPush.notifyProfileUpdated.mockClear(); pushShouldThrow = false; }); @@ -45,8 +44,8 @@ describe('profile updates', () => { uid: 'bogusuid', }) ); - expect(mockPush.notifyProfileUpdated.callCount).toBe(1); - expect(log.error.callCount).toBe(1); + expect(mockPush.notifyProfileUpdated).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledTimes(1); }); it('should send notifications', async () => { @@ -71,17 +70,17 @@ describe('profile updates', () => { }) ); - expect(log.error.callCount).toBe(0); - expect(mockPush.notifyProfileUpdated.callCount).toBe(1); - const args = mockPush.notifyProfileUpdated.getCall(0).args; - expect(args[0]).toBe(uid); + expect(log.error).toHaveBeenCalledTimes(0); + expect(mockPush.notifyProfileUpdated).toHaveBeenCalledTimes(1); + expect(mockPush.notifyProfileUpdated).toHaveBeenCalledWith( + uid, + expect.anything() + ); - expect( - log.notifyAttachedServices.calledWithExactly( - 'profileDataChange', - {}, - { uid } - ) - ).toBe(true); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( + 'profileDataChange', + {}, + { uid } + ); }); }); diff --git a/packages/fxa-auth-server/lib/push.spec.ts b/packages/fxa-auth-server/lib/push.spec.ts index 1c24a9c8ec6..0d67cf6625b 100644 --- a/packages/fxa-auth-server/lib/push.spec.ts +++ b/packages/fxa-auth-server/lib/push.spec.ts @@ -2,12 +2,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; const Ajv = require('ajv'); const ajv = new Ajv(); const fs = require('fs'); const path = require('path'); -const match = sinon.match; const mocks = require('../test/mocks'); const mockUid = 'deadbeef'; @@ -16,23 +14,35 @@ const TTL = '42'; const MS_IN_ONE_DAY = 24 * 60 * 60 * 1000; const PUSH_PAYLOADS_SCHEMA_PATH = './pushpayloads.schema.json'; -let PUSH_PAYLOADS_SCHEMA_MATCHER: sinon.SinonMatcher | null = null; +let pushPayloadsSchema: any = null; -interface ExtendedMatch extends sinon.SinonMatch { - validPushPayload(fields: Record): sinon.SinonMatcher; +function getPushPayloadsSchema() { + if (!pushPayloadsSchema) { + const schemaPath = path.resolve(__dirname, PUSH_PAYLOADS_SCHEMA_PATH); + pushPayloadsSchema = JSON.parse(fs.readFileSync(schemaPath)); + } + return pushPayloadsSchema; } -const extMatch = match as unknown as ExtendedMatch; -extMatch.validPushPayload = function validPushPayload(fields: Record): sinon.SinonMatcher { - if (!PUSH_PAYLOADS_SCHEMA_MATCHER) { - const schemaPath = path.resolve(__dirname, PUSH_PAYLOADS_SCHEMA_PATH); - const schema = JSON.parse(fs.readFileSync(schemaPath)); - PUSH_PAYLOADS_SCHEMA_MATCHER = match((value: unknown) => { - return ajv.validate(schema, value); - }, 'matches payload schema'); +function isValidPushPayload( + value: unknown, + fields: Record +): boolean { + const schema = getPushPayloadsSchema(); + if (!ajv.validate(schema, value)) { + return false; + } + // Check that the value contains all the expected fields + if (typeof value !== 'object' || value === null) { + return false; } - return match(fields).and(PUSH_PAYLOADS_SCHEMA_MATCHER); -}; + for (const [key, expected] of Object.entries(fields)) { + if (JSON.stringify((value as any)[key]) !== JSON.stringify(expected)) { + return false; + } + } + return true; +} interface MockDevice { id: string; @@ -71,9 +81,9 @@ describe('push', () => { let mockDb: ReturnType, mockLog: ReturnType, mockConfig: Record, - mockStatsD: { increment: sinon.SinonSpy }, + mockStatsD: { increment: jest.Mock }, mockDevices: MockDevice[], - mockSendNotification: sinon.SinonSpy; + mockSendNotification: jest.Mock; function loadMockedPushModule() { jest.resetModules(); @@ -123,16 +133,16 @@ describe('push', () => { }, ]; mockStatsD = { - increment: sinon.spy(), + increment: jest.fn(), }; - mockSendNotification = sinon.spy(async () => {}); + mockSendNotification = jest.fn(async () => {}); }); it('sendPush does not reject on empty device array', async () => { const push = loadMockedPushModule(); await push.sendPush(mockUid, [], 'accountVerify'); - sinon.assert.callCount(mockSendNotification, 0); - sinon.assert.callCount(mockStatsD.increment, 0); + expect(mockSendNotification).toHaveBeenCalledTimes(0); + expect(mockStatsD.increment).toHaveBeenCalledTimes(0); }); it('sendPush logs metrics about successful sends', async () => { @@ -164,11 +174,11 @@ describe('push', () => { 'accountVerify' ); expect(sendErrors).toEqual({}); - sinon.assert.callCount(mockSendNotification, 5); + expect(mockSendNotification).toHaveBeenCalledTimes(5); - sinon.assert.callCount(mockStatsD.increment, 10); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(0), + expect(mockStatsD.increment).toHaveBeenCalledTimes(10); + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 1, 'push.send.attempt', { reason: 'accountVerify', @@ -177,8 +187,8 @@ describe('push', () => { lastSeen: '< 1 day', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(1), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 2, 'push.send.success', { reason: 'accountVerify', @@ -187,8 +197,8 @@ describe('push', () => { lastSeen: '< 1 day', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(2), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 3, 'push.send.attempt', { reason: 'accountVerify', @@ -197,8 +207,8 @@ describe('push', () => { lastSeen: '< 1 week', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(3), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 4, 'push.send.success', { reason: 'accountVerify', @@ -207,8 +217,8 @@ describe('push', () => { lastSeen: '< 1 week', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(4), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 5, 'push.send.attempt', { reason: 'accountVerify', @@ -217,8 +227,8 @@ describe('push', () => { lastSeen: '< 1 month', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(5), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 6, 'push.send.success', { reason: 'accountVerify', @@ -227,8 +237,8 @@ describe('push', () => { lastSeen: '< 1 month', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(6), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 7, 'push.send.attempt', { reason: 'accountVerify', @@ -237,8 +247,8 @@ describe('push', () => { lastSeen: '< 1 year', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(7), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 8, 'push.send.success', { reason: 'accountVerify', @@ -247,8 +257,8 @@ describe('push', () => { lastSeen: '< 1 year', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(8), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 9, 'push.send.attempt', { reason: 'accountVerify', @@ -257,8 +267,8 @@ describe('push', () => { lastSeen: '> 1 year', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(9), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 10, 'push.send.success', { reason: 'accountVerify', @@ -271,7 +281,7 @@ describe('push', () => { it('sendPush logs metrics about failed sends', async () => { let shouldFail = false; - mockSendNotification = sinon.spy(async () => { + mockSendNotification = jest.fn(async () => { try { if (shouldFail) { throw new Error('intermittent failure'); @@ -286,12 +296,16 @@ describe('push', () => { mockDevices, 'accountVerify' ); - sinon.assert.match(sendErrors, match.has(mockDevices[1].id, match.any)); - sinon.assert.callCount(mockSendNotification, 2); + expect(sendErrors).toEqual( + expect.objectContaining({ + [mockDevices[1].id]: expect.anything(), + }) + ); + expect(mockSendNotification).toHaveBeenCalledTimes(2); - sinon.assert.callCount(mockStatsD.increment, 4); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(0), + expect(mockStatsD.increment).toHaveBeenCalledTimes(4); + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 1, 'push.send.attempt', { reason: 'accountVerify', @@ -300,8 +314,8 @@ describe('push', () => { lastSeen: '< 1 day', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(1), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 2, 'push.send.success', { reason: 'accountVerify', @@ -310,8 +324,8 @@ describe('push', () => { lastSeen: '< 1 day', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(2), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 3, 'push.send.attempt', { reason: 'accountVerify', @@ -320,8 +334,8 @@ describe('push', () => { lastSeen: '< 1 week', } ); - sinon.assert.calledWithExactly( - mockStatsD.increment.getCall(3), + expect(mockStatsD.increment).toHaveBeenNthCalledWith( + 4, 'push.send.failure', { reason: 'accountVerify', @@ -335,11 +349,10 @@ describe('push', () => { it('sendPush sends notifications with a TTL of 0', async () => { const push = loadMockedPushModule(); await push.sendPush(mockUid, mockDevices, 'accountVerify'); - sinon.assert.callCount(mockSendNotification, 2); - for (const call of mockSendNotification.getCalls()) { - sinon.assert.calledWithMatch(call, match.any, null, { - TTL: '0', - }); + expect(mockSendNotification).toHaveBeenCalledTimes(2); + for (const call of mockSendNotification.mock.calls) { + expect(call[1]).toBeNull(); + expect(call[2]).toEqual(expect.objectContaining({ TTL: '0' })); } }); @@ -347,9 +360,9 @@ describe('push', () => { const push = loadMockedPushModule(); const options = { TTL: TTL }; await push.sendPush(mockUid, mockDevices, 'accountVerify', options); - sinon.assert.callCount(mockSendNotification, 2); - for (const call of mockSendNotification.getCalls()) { - sinon.assert.calledWithMatch(call, match.any, null, { TTL }); + expect(mockSendNotification).toHaveBeenCalledTimes(2); + for (const call of mockSendNotification.mock.calls) { + expect(call[2]).toEqual(expect.objectContaining({ TTL })); } }); @@ -358,18 +371,17 @@ describe('push', () => { const data = { foo: 'bar' }; const options = { data: data }; await push.sendPush(mockUid, mockDevices, 'accountVerify', options); - sinon.assert.callCount(mockSendNotification, 2); - for (const call of mockSendNotification.getCalls()) { - sinon.assert.calledWithMatch( - call, - { + expect(mockSendNotification).toHaveBeenCalledTimes(2); + for (const call of mockSendNotification.mock.calls) { + expect(call[0]).toEqual( + expect.objectContaining({ keys: { - p256dh: match.defined, - auth: match.defined, + p256dh: expect.anything(), + auth: expect.anything(), }, - }, - Buffer.from(JSON.stringify(data)) + }) ); + expect(call[1]).toEqual(Buffer.from(JSON.stringify(data))); } }); @@ -380,13 +392,19 @@ describe('push', () => { ); const options = { data: data }; await push.sendPush(mockUid, mockDevices, 'devicesNotify', options); - sinon.assert.callCount(mockSendNotification, 2); - sinon.assert.calledWithMatch(mockSendNotification.getCall(0), { - endpoint: mockDevices[0].pushCallback, - }); - sinon.assert.calledWithMatch(mockSendNotification.getCall(1), { - endpoint: mockDevices[1].pushCallback, - }); + expect(mockSendNotification).toHaveBeenCalledTimes(2); + expect(mockSendNotification).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ endpoint: mockDevices[0].pushCallback }), + expect.anything(), + expect.anything() + ); + expect(mockSendNotification).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ endpoint: mockDevices[1].pushCallback }), + expect.anything(), + expect.anything() + ); }); it('sendPush pushes to all ios devices if it is triggered with a "commands received" command', async () => { @@ -397,16 +415,25 @@ describe('push', () => { }; const options = { data: data }; await push.sendPush(mockUid, mockDevices, 'devicesNotify', options); - sinon.assert.callCount(mockSendNotification, 3); - sinon.assert.calledWithMatch(mockSendNotification.getCall(0), { - endpoint: mockDevices[0].pushCallback, - }); - sinon.assert.calledWithMatch(mockSendNotification.getCall(1), { - endpoint: mockDevices[1].pushCallback, - }); - sinon.assert.calledWithMatch(mockSendNotification.getCall(2), { - endpoint: mockDevices[2].pushCallback, - }); + expect(mockSendNotification).toHaveBeenCalledTimes(3); + expect(mockSendNotification).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ endpoint: mockDevices[0].pushCallback }), + expect.anything(), + expect.anything() + ); + expect(mockSendNotification).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ endpoint: mockDevices[1].pushCallback }), + expect.anything(), + expect.anything() + ); + expect(mockSendNotification).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ endpoint: mockDevices[2].pushCallback }), + expect.anything(), + expect.anything() + ); }); it('sendPush does not push to ios devices if triggered with a "collection changed" command', async () => { @@ -417,13 +444,19 @@ describe('push', () => { }; const options = { data: data }; await push.sendPush(mockUid, mockDevices, 'devicesNotify', options); - sinon.assert.callCount(mockSendNotification, 2); - sinon.assert.calledWithMatch(mockSendNotification.getCall(0), { - endpoint: mockDevices[0].pushCallback, - }); - sinon.assert.calledWithMatch(mockSendNotification.getCall(1), { - endpoint: mockDevices[1].pushCallback, - }); + expect(mockSendNotification).toHaveBeenCalledTimes(2); + expect(mockSendNotification).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ endpoint: mockDevices[0].pushCallback }), + expect.anything(), + expect.anything() + ); + expect(mockSendNotification).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ endpoint: mockDevices[1].pushCallback }), + expect.anything(), + expect.anything() + ); }); it('sendPush pushes to ios devices if it is triggered with a "device connected" command', async () => { @@ -431,16 +464,25 @@ describe('push', () => { const data = { command: 'fxaccounts:device_connected' }; const options = { data: data }; await push.sendPush(mockUid, mockDevices, 'devicesNotify', options); - sinon.assert.callCount(mockSendNotification, 3); - sinon.assert.calledWithMatch(mockSendNotification.getCall(0), { - endpoint: mockDevices[0].pushCallback, - }); - sinon.assert.calledWithMatch(mockSendNotification.getCall(1), { - endpoint: mockDevices[1].pushCallback, - }); - sinon.assert.calledWithMatch(mockSendNotification.getCall(2), { - endpoint: mockDevices[2].pushCallback, - }); + expect(mockSendNotification).toHaveBeenCalledTimes(3); + expect(mockSendNotification).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ endpoint: mockDevices[0].pushCallback }), + expect.anything(), + expect.anything() + ); + expect(mockSendNotification).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ endpoint: mockDevices[1].pushCallback }), + expect.anything(), + expect.anything() + ); + expect(mockSendNotification).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ endpoint: mockDevices[2].pushCallback }), + expect.anything(), + expect.anything() + ); }); it('push fails if data is present but both keys are not present', async () => { @@ -457,11 +499,14 @@ describe('push', () => { ]; const options = { data: Buffer.from('foobar') }; await push.sendPush(mockUid, devices, 'deviceConnected', options); - sinon.assert.callCount(mockLog.debug, 1); - sinon.assert.calledWithMatch(mockLog.debug, 'push.send.failure', { - reason: 'deviceConnected', - errCode: 'noKeys', - }); + expect(mockLog.debug).toHaveBeenCalledTimes(1); + expect(mockLog.debug).toHaveBeenCalledWith( + 'push.send.failure', + expect.objectContaining({ + reason: 'deviceConnected', + errCode: 'noKeys', + }) + ); }); it('push catches devices with no push callback', async () => { @@ -473,11 +518,14 @@ describe('push', () => { }, ]; await push.sendPush(mockUid, devices, 'accountVerify'); - sinon.assert.callCount(mockLog.debug, 1); - sinon.assert.calledWithMatch(mockLog.debug, 'push.send.failure', { - reason: 'accountVerify', - errCode: 'noCallback', - }); + expect(mockLog.debug).toHaveBeenCalledTimes(1); + expect(mockLog.debug).toHaveBeenCalledWith( + 'push.send.failure', + expect.objectContaining({ + reason: 'accountVerify', + errCode: 'noCallback', + }) + ); }); it('push catches devices with expired callback', async () => { @@ -492,25 +540,31 @@ describe('push', () => { }, ]; await push.sendPush(mockUid, devices, 'accountVerify'); - sinon.assert.callCount(mockLog.debug, 1); - sinon.assert.calledWithMatch(mockLog.debug, 'push.send.failure', { - reason: 'accountVerify', - errCode: 'expiredCallback', - }); + expect(mockLog.debug).toHaveBeenCalledTimes(1); + expect(mockLog.debug).toHaveBeenCalledWith( + 'push.send.failure', + expect.objectContaining({ + reason: 'accountVerify', + errCode: 'expiredCallback', + }) + ); }); it('push reports errors when web-push fails', async () => { - mockSendNotification = sinon.spy(async () => { + mockSendNotification = jest.fn(async () => { throw new Error('Failed with a nasty error'); }); const push = loadMockedPushModule(); await push.sendPush(mockUid, [mockDevices[0]], 'accountVerify'); - sinon.assert.callCount(mockLog.debug, 1); - sinon.assert.calledWithMatch(mockLog.debug, 'push.send.failure', { - reason: 'accountVerify', - errCode: 'unknown', - err: match.has('message', 'Failed with a nasty error'), - }); + expect(mockLog.debug).toHaveBeenCalledTimes(1); + expect(mockLog.debug).toHaveBeenCalledWith( + 'push.send.failure', + expect.objectContaining({ + reason: 'accountVerify', + errCode: 'unknown', + err: expect.objectContaining({ message: 'Failed with a nasty error' }), + }) + ); }); it('push logs a warning when asked to send to more than 200 devices', async () => { @@ -520,18 +574,21 @@ describe('push', () => { devices.push(mockDevices[0]); } await push.sendPush(mockUid, devices, 'accountVerify'); - sinon.assert.callCount(mockLog.warn, 0); + expect(mockLog.warn).toHaveBeenCalledTimes(0); devices.push(mockDevices[0]); await push.sendPush(mockUid, devices, 'accountVerify'); - sinon.assert.callCount(mockLog.warn, 1); - sinon.assert.calledWithMatch(mockLog.warn, 'push.sendPush.tooManyDevices', { - uid: mockUid, - }); + expect(mockLog.warn).toHaveBeenCalledTimes(1); + expect(mockLog.warn).toHaveBeenCalledWith( + 'push.sendPush.tooManyDevices', + expect.objectContaining({ + uid: mockUid, + }) + ); }); it('push resets device push data when push server responds with a 400 level error', async () => { - mockSendNotification = sinon.spy(async () => { + mockSendNotification = jest.fn(async () => { const err: Error & { statusCode?: number } = new Error('Failed'); err.statusCode = 410; throw err; @@ -539,63 +596,85 @@ describe('push', () => { const push = loadMockedPushModule(); const device = JSON.parse(JSON.stringify(mockDevices[0])); await push.sendPush(mockUid, [device], 'accountVerify'); - sinon.assert.callCount(mockSendNotification, 1); - sinon.assert.callCount(mockLog.debug, 1); - sinon.assert.calledWithMatch(mockLog.debug, 'push.send.failure', { - reason: 'accountVerify', - errCode: 'resetCallback', - }); - sinon.assert.callCount(mockDb.updateDevice, 1); - sinon.assert.calledWithMatch(mockDb.updateDevice, mockUid, { - id: mockDevices[0].id, - sessionTokenId: match.falsy, - }); + expect(mockSendNotification).toHaveBeenCalledTimes(1); + expect(mockLog.debug).toHaveBeenCalledTimes(1); + expect(mockLog.debug).toHaveBeenCalledWith( + 'push.send.failure', + expect.objectContaining({ + reason: 'accountVerify', + errCode: 'resetCallback', + }) + ); + expect(mockDb.updateDevice).toHaveBeenCalledTimes(1); + expect(mockDb.updateDevice).toHaveBeenCalledWith( + mockUid, + expect.objectContaining({ + id: mockDevices[0].id, + }) + ); + // sessionTokenId should be falsy + const updateDeviceArgs = mockDb.updateDevice.mock.calls[0][1]; + expect(updateDeviceArgs.sessionTokenId).toBeFalsy(); }); it('push resets device push data when a failure is caused by bad encryption keys', async () => { - mockSendNotification = sinon.spy(async () => { + mockSendNotification = jest.fn(async () => { throw new Error('Failed'); }); const push = loadMockedPushModule(); const device = JSON.parse(JSON.stringify(mockDevices[0])); device.pushPublicKey = `E${device.pushPublicKey.substring(1)}`; await push.sendPush(mockUid, [device], 'accountVerify'); - sinon.assert.callCount(mockSendNotification, 1); - sinon.assert.callCount(mockLog.debug, 1); - sinon.assert.calledWithMatch(mockLog.debug, 'push.send.failure', { - reason: 'accountVerify', - errCode: 'resetCallback', - }); - sinon.assert.callCount(mockDb.updateDevice, 1); - sinon.assert.calledWithMatch(mockDb.updateDevice, mockUid, { - id: mockDevices[0].id, - sessionTokenId: match.falsy, - }); + expect(mockSendNotification).toHaveBeenCalledTimes(1); + expect(mockLog.debug).toHaveBeenCalledTimes(1); + expect(mockLog.debug).toHaveBeenCalledWith( + 'push.send.failure', + expect.objectContaining({ + reason: 'accountVerify', + errCode: 'resetCallback', + }) + ); + expect(mockDb.updateDevice).toHaveBeenCalledTimes(1); + expect(mockDb.updateDevice).toHaveBeenCalledWith( + mockUid, + expect.objectContaining({ + id: mockDevices[0].id, + }) + ); + // sessionTokenId should be falsy + const updateDeviceArgs = mockDb.updateDevice.mock.calls[0][1]; + expect(updateDeviceArgs.sessionTokenId).toBeFalsy(); }); it('push does not reset device push data after an unexpected failure', async () => { - mockSendNotification = sinon.spy(async () => { + mockSendNotification = jest.fn(async () => { throw new Error('Failed unexpectedly'); }); const push = loadMockedPushModule(); const device = JSON.parse(JSON.stringify(mockDevices[0])); await push.sendPush(mockUid, [device], 'accountVerify'); - sinon.assert.callCount(mockSendNotification, 1); - sinon.assert.callCount(mockLog.debug, 1); - sinon.assert.calledWithMatch(mockLog.debug, 'push.send.failure', { - reason: 'accountVerify', - errCode: 'unknown', - }); - sinon.assert.callCount(mockLog.error, 1); - sinon.assert.calledWithMatch(mockLog.error, 'push.sendPush.unexpectedError', { - err: match.has('message', 'Failed unexpectedly'), - }); - sinon.assert.callCount(mockDb.updateDevice, 0); + expect(mockSendNotification).toHaveBeenCalledTimes(1); + expect(mockLog.debug).toHaveBeenCalledTimes(1); + expect(mockLog.debug).toHaveBeenCalledWith( + 'push.send.failure', + expect.objectContaining({ + reason: 'accountVerify', + errCode: 'unknown', + }) + ); + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith( + 'push.sendPush.unexpectedError', + expect.objectContaining({ + err: expect.objectContaining({ message: 'Failed unexpectedly' }), + }) + ); + expect(mockDb.updateDevice).toHaveBeenCalledTimes(0); }); it('notifyCommandReceived calls sendPush', async () => { const push = loadMockedPushModule(); - sinon.spy(push, 'sendPush'); + jest.spyOn(push, 'sendPush'); await push.notifyCommandReceived( mockUid, mockDevices[0], @@ -605,29 +684,29 @@ describe('push', () => { 'http://fetch.url', 42 ); - sinon.assert.calledOnceWithExactly( - push.sendPush, - mockUid, - [mockDevices[0]], - 'commandReceived', - { - data: extMatch.validPushPayload({ - version: 1, - command: 'fxaccounts:command_received', - data: { - command: 'commandName', - index: 12, - sender: 'sendingDevice', - url: 'http://fetch.url', - }, - }), - TTL: 42, - } - ); + expect(push.sendPush).toHaveBeenCalledTimes(1); + const sendPushArgs = (push.sendPush as jest.Mock).mock.calls[0]; + expect(sendPushArgs[0]).toBe(mockUid); + expect(sendPushArgs[1]).toEqual([mockDevices[0]]); + expect(sendPushArgs[2]).toBe('commandReceived'); + const pushOptions = sendPushArgs[3]; + expect(pushOptions.TTL).toBe(42); + expect( + isValidPushPayload(pushOptions.data, { + version: 1, + command: 'fxaccounts:command_received', + data: { + command: 'commandName', + index: 12, + sender: 'sendingDevice', + url: 'http://fetch.url', + }, + }) + ).toBe(true); }); it('notifyCommandReceived re-throws errors', async () => { - mockSendNotification = sinon.spy(async () => { + mockSendNotification = jest.fn(async () => { throw new Error('Failed with a nasty error'); }); const push = loadMockedPushModule(); @@ -643,110 +722,104 @@ describe('push', () => { ); throw new Error('should have thrown'); } catch (err) { - sinon.assert.match( - err, - match.has('message', 'Failed with a nasty error') + expect(err).toEqual( + expect.objectContaining({ message: 'Failed with a nasty error' }) ); } }); it('notifyDeviceConnected calls sendPush', async () => { const push = loadMockedPushModule(); - sinon.spy(push, 'sendPush'); + jest.spyOn(push, 'sendPush'); const deviceName = 'My phone'; await push.notifyDeviceConnected(mockUid, mockDevices, deviceName); - sinon.assert.calledOnce(push.sendPush); - sinon.assert.calledWithMatch( - push.sendPush, - mockUid, - mockDevices, - 'deviceConnected', - { - data: extMatch.validPushPayload({ - version: 1, - command: 'fxaccounts:device_connected', - data: { - deviceName: deviceName, - }, - }), - TTL: sinon.match.typeOf('undefined'), - } - ); + expect(push.sendPush).toHaveBeenCalledTimes(1); + const sendPushArgs = (push.sendPush as jest.Mock).mock.calls[0]; + expect(sendPushArgs[0]).toBe(mockUid); + expect(sendPushArgs[1]).toBe(mockDevices); + expect(sendPushArgs[2]).toBe('deviceConnected'); + const pushOptions = sendPushArgs[3]; + expect(pushOptions.TTL).toBeUndefined(); + expect( + isValidPushPayload(pushOptions.data, { + version: 1, + command: 'fxaccounts:device_connected', + data: { + deviceName: deviceName, + }, + }) + ).toBe(true); }); it('notifyDeviceDisconnected calls sendPush', async () => { const push = loadMockedPushModule(); - sinon.spy(push, 'sendPush'); + jest.spyOn(push, 'sendPush'); const idToDisconnect = mockDevices[0].id; await push.notifyDeviceDisconnected(mockUid, mockDevices, idToDisconnect); - sinon.assert.calledOnce(push.sendPush); - sinon.assert.calledWithMatch( - push.sendPush, - mockUid, - mockDevices, - 'deviceDisconnected', - { - data: extMatch.validPushPayload({ - version: 1, - command: 'fxaccounts:device_disconnected', - data: { - id: idToDisconnect, - }, - }), - TTL: match.number, - } - ); + expect(push.sendPush).toHaveBeenCalledTimes(1); + const sendPushArgs = (push.sendPush as jest.Mock).mock.calls[0]; + expect(sendPushArgs[0]).toBe(mockUid); + expect(sendPushArgs[1]).toBe(mockDevices); + expect(sendPushArgs[2]).toBe('deviceDisconnected'); + const pushOptions = sendPushArgs[3]; + expect(typeof pushOptions.TTL).toBe('number'); + expect( + isValidPushPayload(pushOptions.data, { + version: 1, + command: 'fxaccounts:device_disconnected', + data: { + id: idToDisconnect, + }, + }) + ).toBe(true); }); it('notifyPasswordChanged calls sendPush', async () => { const push = loadMockedPushModule(); - sinon.spy(push, 'sendPush'); + jest.spyOn(push, 'sendPush'); await push.notifyPasswordChanged(mockUid, mockDevices); - sinon.assert.calledOnce(push.sendPush); - sinon.assert.calledWithMatch( - push.sendPush, - mockUid, - mockDevices, - 'passwordChange', - { - data: extMatch.validPushPayload({ - version: 1, - command: 'fxaccounts:password_changed', - data: sinon.match.typeOf('undefined'), - }), - TTL: match.number, - } - ); + expect(push.sendPush).toHaveBeenCalledTimes(1); + const sendPushArgs = (push.sendPush as jest.Mock).mock.calls[0]; + expect(sendPushArgs[0]).toBe(mockUid); + expect(sendPushArgs[1]).toBe(mockDevices); + expect(sendPushArgs[2]).toBe('passwordChange'); + const pushOptions = sendPushArgs[3]; + expect(typeof pushOptions.TTL).toBe('number'); + expect( + isValidPushPayload(pushOptions.data, { + version: 1, + command: 'fxaccounts:password_changed', + data: undefined, + }) + ).toBe(true); }); it('notifyPasswordReset calls sendPush', async () => { const push = loadMockedPushModule(); - sinon.spy(push, 'sendPush'); + jest.spyOn(push, 'sendPush'); await push.notifyPasswordReset(mockUid, mockDevices); - sinon.assert.calledOnce(push.sendPush); - sinon.assert.calledWithMatch( - push.sendPush, - mockUid, - mockDevices, - 'passwordReset', - { - data: extMatch.validPushPayload({ - version: 1, - command: 'fxaccounts:password_reset', - data: sinon.match.typeOf('undefined'), - }), - TTL: match.number, - } - ); + expect(push.sendPush).toHaveBeenCalledTimes(1); + const sendPushArgs = (push.sendPush as jest.Mock).mock.calls[0]; + expect(sendPushArgs[0]).toBe(mockUid); + expect(sendPushArgs[1]).toBe(mockDevices); + expect(sendPushArgs[2]).toBe('passwordReset'); + const pushOptions = sendPushArgs[3]; + expect(typeof pushOptions.TTL).toBe('number'); + expect( + isValidPushPayload(pushOptions.data, { + version: 1, + command: 'fxaccounts:password_reset', + data: undefined, + }) + ).toBe(true); }); it('notifyAccountUpdated calls sendPush', async () => { const push = loadMockedPushModule(); - sinon.spy(push, 'sendPush'); + jest.spyOn(push, 'sendPush'); await push.notifyAccountUpdated(mockUid, mockDevices, 'deviceConnected'); - sinon.assert.calledOnce(push.sendPush); - sinon.assert.calledWithExactly( - push.sendPush, + expect(push.sendPush).toHaveBeenCalledTimes(1); + expect(push.sendPush).toHaveBeenCalledWith( mockUid, mockDevices, 'deviceConnected' @@ -755,43 +828,47 @@ describe('push', () => { it('notifyAccountDestroyed calls sendPush', async () => { const push = loadMockedPushModule(); - sinon.spy(push, 'sendPush'); + jest.spyOn(push, 'sendPush'); await push.notifyAccountDestroyed(mockUid, mockDevices); - sinon.assert.calledOnce(push.sendPush); - sinon.assert.calledWithMatch( - push.sendPush, - mockUid, - mockDevices, - 'accountDestroyed', - { - data: extMatch.validPushPayload({ - version: 1, - command: 'fxaccounts:account_destroyed', - data: { - uid: mockUid, - }, - }), - TTL: match.number, - } - ); + expect(push.sendPush).toHaveBeenCalledTimes(1); + const sendPushArgs = (push.sendPush as jest.Mock).mock.calls[0]; + expect(sendPushArgs[0]).toBe(mockUid); + expect(sendPushArgs[1]).toBe(mockDevices); + expect(sendPushArgs[2]).toBe('accountDestroyed'); + const pushOptions = sendPushArgs[3]; + expect(typeof pushOptions.TTL).toBe('number'); + expect( + isValidPushPayload(pushOptions.data, { + version: 1, + command: 'fxaccounts:account_destroyed', + data: { + uid: mockUid, + }, + }) + ).toBe(true); }); it('sendPush includes VAPID identification if it is configured', async () => { mockConfig = { publicUrl: 'https://example.com', - vapidKeysFile: path.join(__dirname, '../test/config/mock-vapid-keys.json'), + vapidKeysFile: path.join( + __dirname, + '../test/config/mock-vapid-keys.json' + ), }; const push = loadMockedPushModule(); await push.sendPush(mockUid, mockDevices, 'accountVerify'); - sinon.assert.callCount(mockSendNotification, 2); - for (const call of mockSendNotification.getCalls()) { - sinon.assert.calledWithMatch(call, match.any, null, { - vapidDetails: { - subject: mockConfig.publicUrl, - privateKey: 'private', - publicKey: 'public', - }, - }); + expect(mockSendNotification).toHaveBeenCalledTimes(2); + for (const call of mockSendNotification.mock.calls) { + expect(call[2]).toEqual( + expect.objectContaining({ + vapidDetails: { + subject: mockConfig.publicUrl, + privateKey: 'private', + publicKey: 'public', + }, + }) + ); } }); @@ -803,6 +880,6 @@ describe('push', () => { } catch (err) { expect(err).toBe('Unknown push reason: anUnknownReasonString'); } - sinon.assert.notCalled(mockSendNotification); + expect(mockSendNotification).not.toHaveBeenCalled(); }); }); diff --git a/packages/fxa-auth-server/lib/pushbox/index.spec.ts b/packages/fxa-auth-server/lib/pushbox/index.spec.ts index 5c47a988492..08c9348fd15 100644 --- a/packages/fxa-auth-server/lib/pushbox/index.spec.ts +++ b/packages/fxa-auth-server/lib/pushbox/index.spec.ts @@ -2,15 +2,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - -const sandbox = sinon.createSandbox(); - const { pushboxApi } = require('./index'); -const pushboxDbModule = require('./db'); const { AppError: error } = require('@fxa/accounts/errors'); const { mockLog } = require('../../test/mocks'); -let mockStatsD: { increment: sinon.SinonStub; timing: sinon.SinonStub }; +let mockStatsD: { increment: jest.Mock; timing: jest.Mock }; const mockConfig = { publicUrl: 'https://accounts.example.com', @@ -44,24 +39,28 @@ interface RetrieveResult { describe('pushbox', () => { describe('using direct Pushbox database access', () => { - let stubDbModule: sinon.SinonStubbedInstance; - let stubConstructor: sinon.SinonStub; + let stubDbModule: any; + let stubConstructor: jest.Mock; beforeEach(() => { - mockStatsD = { increment: sandbox.stub(), timing: sandbox.stub() }; - stubDbModule = sandbox.createStubInstance(pushboxDbModule.PushboxDB); - stubConstructor = sandbox - .stub(pushboxDbModule, 'PushboxDB') - .returns(stubDbModule); + mockStatsD = { increment: jest.fn(), timing: jest.fn() }; + // Create a mock instance with the methods we need + stubDbModule = { + store: jest.fn(), + retrieve: jest.fn(), + deleteDevice: jest.fn(), + deleteAccount: jest.fn(), + }; + stubConstructor = jest.fn().mockReturnValue(stubDbModule); }); afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); it('store', () => { - sandbox.stub(Date, 'now').returns(1000534); - stubDbModule.store.resolves({ idx: 12 }); + jest.spyOn(Date, 'now').mockReturnValue(1000534); + stubDbModule.store.mockResolvedValue({ idx: 12 }); const pushbox = pushboxApi( mockLog(), mockConfig, @@ -71,27 +70,27 @@ describe('pushbox', () => { return pushbox .store(mockUid, mockDeviceIds[0], { test: 'data' }) .then(({ index }: { index: number }) => { - sinon.assert.calledOnceWithExactly(stubDbModule.store, { + expect(stubDbModule.store).toHaveBeenCalledTimes(1); + expect(stubDbModule.store).toHaveBeenCalledWith({ uid: mockUid, deviceId: mockDeviceIds[0], data: 'eyJ0ZXN0IjoiZGF0YSJ9', ttl: 124457, }); - sinon.assert.calledOnce(mockStatsD.timing); - expect(mockStatsD.timing.args[0][0]).toBe( - 'pushbox.db.store.success' - ); - sinon.assert.calledOnceWithExactly( - mockStatsD.increment, - 'pushbox.db.store' + expect(mockStatsD.timing).toHaveBeenCalledTimes(1); + expect(mockStatsD.timing).toHaveBeenCalledWith( + 'pushbox.db.store.success', + expect.any(Number) ); + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith('pushbox.db.store'); expect(index).toBe(12); }); }); it('store with custom ttl', () => { - sandbox.stub(Date, 'now').returns(1000534); - stubDbModule.store.resolves({ idx: 12 }); + jest.spyOn(Date, 'now').mockReturnValue(1000534); + stubDbModule.store.mockResolvedValue({ idx: 12 }); const pushbox = pushboxApi( mockLog(), mockConfig, @@ -101,7 +100,8 @@ describe('pushbox', () => { return pushbox .store(mockUid, mockDeviceIds[0], { test: 'data' }, 42) .then(({ index }: { index: number }) => { - sinon.assert.calledOnceWithExactly(stubDbModule.store, { + expect(stubDbModule.store).toHaveBeenCalledTimes(1); + expect(stubDbModule.store).toHaveBeenCalledWith({ uid: mockUid, deviceId: mockDeviceIds[0], data: 'eyJ0ZXN0IjoiZGF0YSJ9', @@ -112,8 +112,8 @@ describe('pushbox', () => { }); it('store caps ttl at configured maximum', () => { - sandbox.stub(Date, 'now').returns(1000432); - stubDbModule.store.resolves({ idx: 12 }); + jest.spyOn(Date, 'now').mockReturnValue(1000432); + stubDbModule.store.mockResolvedValue({ idx: 12 }); const pushbox = pushboxApi( mockLog(), mockConfig, @@ -123,7 +123,8 @@ describe('pushbox', () => { return pushbox .store(mockUid, mockDeviceIds[0], { test: 'data' }, 999999999) .then(({ index }: { index: number }) => { - sinon.assert.calledOnceWithExactly(stubDbModule.store, { + expect(stubDbModule.store).toHaveBeenCalledTimes(1); + expect(stubDbModule.store).toHaveBeenCalledWith({ uid: mockUid, deviceId: mockDeviceIds[0], data: 'eyJ0ZXN0IjoiZGF0YSJ9', @@ -134,25 +135,34 @@ describe('pushbox', () => { }); it('logs an error when failed to store', async () => { - stubDbModule.store.rejects(new Error('db is a mess right now')); + stubDbModule.store.mockRejectedValue(new Error('db is a mess right now')); const log = mockLog(); const pushbox = pushboxApi(log, mockConfig, mockStatsD, stubConstructor); try { - await pushbox.store(mockUid, mockDeviceIds[0], { test: 'data' }, 999999999); + await pushbox.store( + mockUid, + mockDeviceIds[0], + { test: 'data' }, + 999999999 + ); throw new Error('should not happen'); } catch (err) { expect(err).toBeTruthy(); expect((err as PushboxError).errno).toBe(error.ERRNO.UNEXPECTED_ERROR); - sinon.assert.calledOnce(log.error); - expect(log.error.args[0][0]).toBe('pushbox.db.store'); - expect(log.error.args[0][1]['error']['message']).toBe( - 'db is a mess right now' + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( + 'pushbox.db.store', + expect.objectContaining({ + error: expect.objectContaining({ + message: 'db is a mess right now', + }), + }) ); } }); it('retrieve', async () => { - stubDbModule.retrieve.resolves({ + stubDbModule.retrieve.mockResolvedValue({ last: true, index: 15, messages: [ @@ -168,7 +178,12 @@ describe('pushbox', () => { mockStatsD, stubConstructor ); - const result: RetrieveResult = await pushbox.retrieve(mockUid, mockDeviceIds[0], 50, 10); + const result: RetrieveResult = await pushbox.retrieve( + mockUid, + mockDeviceIds[0], + 50, + 10 + ); expect(result).toEqual({ last: true, index: 15, @@ -182,7 +197,9 @@ describe('pushbox', () => { }); it('retrieve throws on error response', async () => { - stubDbModule.retrieve.rejects(new Error('db is a mess right now')); + stubDbModule.retrieve.mockRejectedValue( + new Error('db is a mess right now') + ); const log = mockLog(); const pushbox = pushboxApi(log, mockConfig, mockStatsD, stubConstructor); try { @@ -191,31 +208,38 @@ describe('pushbox', () => { } catch (err) { expect(err).toBeTruthy(); expect((err as PushboxError).errno).toBe(error.ERRNO.UNEXPECTED_ERROR); - sinon.assert.calledOnce(log.error); - expect(log.error.args[0][0]).toBe('pushbox.db.retrieve'); - expect(log.error.args[0][1]['error']['message']).toBe( - 'db is a mess right now' + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( + 'pushbox.db.retrieve', + expect.objectContaining({ + error: expect.objectContaining({ + message: 'db is a mess right now', + }), + }) ); } }); it('deletes records of a device', async () => { - stubDbModule.deleteDevice.resolves(); + stubDbModule.deleteDevice.mockResolvedValue(undefined); const log = mockLog(); const pushbox = pushboxApi(log, mockConfig, mockStatsD, stubConstructor); const res = await pushbox.deleteDevice(mockUid, mockDeviceIds[0]); expect(res).toBeUndefined(); - expect(mockStatsD.timing.args[0][0]).toBe( - 'pushbox.db.delete.device.success' + expect(mockStatsD.timing).toHaveBeenCalledWith( + 'pushbox.db.delete.device.success', + expect.any(Number) ); - sinon.assert.calledOnceWithExactly( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith( 'pushbox.db.delete.device' ); }); it('throws error when delete device fails', async () => { - stubDbModule.deleteDevice.rejects(new Error('db is a mess right now')); + stubDbModule.deleteDevice.mockRejectedValue( + new Error('db is a mess right now') + ); const log = mockLog(); const pushbox = pushboxApi(log, mockConfig, mockStatsD, stubConstructor); try { @@ -224,31 +248,36 @@ describe('pushbox', () => { } catch (err) { expect(err).toBeTruthy(); expect((err as PushboxError).errno).toBe(error.ERRNO.UNEXPECTED_ERROR); - sinon.assert.calledOnce(log.error); - expect(log.error.args[0][0]).toBe('pushbox.db.delete.device'); - expect(log.error.args[0][1]['error']['message']).toBe( - 'db is a mess right now' + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( + 'pushbox.db.delete.device', + expect.objectContaining({ + error: expect.objectContaining({ + message: 'db is a mess right now', + }), + }) ); } }); it('deletes all records for an account', async () => { - stubDbModule.deleteAccount.resolves(); + stubDbModule.deleteAccount.mockResolvedValue(undefined); const log = mockLog(); const pushbox = pushboxApi(log, mockConfig, mockStatsD, stubConstructor); const res = await pushbox.deleteAccount(mockUid); expect(res).toBeUndefined(); - expect(mockStatsD.timing.args[0][0]).toBe( - 'pushbox.db.delete.account.success' + expect(mockStatsD.timing).toHaveBeenCalledWith( + 'pushbox.db.delete.account.success', + expect.any(Number) ); - sinon.assert.calledOnceWithExactly( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith( 'pushbox.db.delete.account' ); }); it('throws error when delete account fails', async () => { - stubDbModule.deleteAccount.rejects( + stubDbModule.deleteAccount.mockRejectedValue( new Error('someone deleted the pushboxv1 table') ); const log = mockLog(); @@ -259,10 +288,14 @@ describe('pushbox', () => { } catch (err) { expect(err).toBeTruthy(); expect((err as PushboxError).errno).toBe(error.ERRNO.UNEXPECTED_ERROR); - sinon.assert.calledOnce(log.error); - expect(log.error.args[0][0]).toBe('pushbox.db.delete.account'); - expect(log.error.args[0][1]['error']['message']).toBe( - 'someone deleted the pushboxv1 table' + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( + 'pushbox.db.delete.account', + expect.objectContaining({ + error: expect.objectContaining({ + message: 'someone deleted the pushboxv1 table', + }), + }) ); } }); diff --git a/packages/fxa-auth-server/lib/routes/account.spec.ts b/packages/fxa-auth-server/lib/routes/account.spec.ts index 3e437319c46..1a8e5673a78 100644 --- a/packages/fxa-auth-server/lib/routes/account.spec.ts +++ b/packages/fxa-auth-server/lib/routes/account.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const mocks = require('../../test/mocks'); const { getRoute } = require('../../test/routes_helpers'); const { @@ -46,7 +44,7 @@ jest.mock('fxa-shared/db/models/auth', () => { const actual = jest.requireActual('fxa-shared/db/models/auth'); return new Proxy(actual, { get: (target: any, prop: string) => - prop in fxaSharedDbModelsOverride + fxaSharedDbModelsOverride && prop in fxaSharedDbModelsOverride ? fxaSharedDbModelsOverride[prop] : target[prop], }); @@ -116,11 +114,15 @@ const rpCmsConfig = { }, }; const rpConfigManager = { - fetchCMSData: sinon - .stub() - .withArgs('00f00f', 'testo') - .resolves({ - relyingParties: [rpCmsConfig], + fetchCMSData: jest + .fn() + .mockImplementation((clientId: string, entrypoint: string) => { + if (clientId === '00f00f' && entrypoint === 'testo') { + return Promise.resolve({ + relyingParties: [rpCmsConfig], + }); + } + return Promise.resolve({ relyingParties: [] }); }), }; @@ -131,8 +133,8 @@ function hexString(bytes: number) { } let mockAccountQuickDelete = jest.fn().mockResolvedValue(undefined); -let mockAccountTasksDeleteAccount = sinon.fake(async () => {}); -const mockGetAccountCustomerByUid = sinon.fake.resolves({ +let mockAccountTasksDeleteAccount = jest.fn(async () => {}); +const mockGetAccountCustomerByUid = jest.fn().mockResolvedValue({ stripeCustomerId: 'customer123', }); @@ -219,7 +221,7 @@ const makeRoutes = function (options: any = {}, requireMocks: any = {}) { ...(options.oauth || {}), }; - mockAccountTasksDeleteAccount = sinon.fake.resolves(undefined); + mockAccountTasksDeleteAccount = jest.fn().mockResolvedValue(undefined); const accountTasks = { deleteAccount: mockAccountTasksDeleteAccount, }; @@ -342,7 +344,7 @@ describe('/account/reset', () => { mailer = mocks.mockMailer(); fxaMailer = mocks.mockFxaMailer(); mocks.mockOAuthClientInfo(); - oauth = { removeTokensAndCodes: sinon.stub() }; + oauth = { removeTokensAndCodes: jest.fn() }; accountRoutes = makeRoutes({ config: { securityHistory: { @@ -359,9 +361,9 @@ describe('/account/reset', () => { route = getRoute(accountRoutes, '/account/reset'); clientAddress = mockRequest.app.clientAddress; - glean.resetPassword.accountReset.reset(); - glean.resetPassword.createNewSuccess.reset(); - glean.resetPassword.recoveryKeyCreatePasswordSuccess.reset(); + glean.resetPassword.accountReset.mockReset(); + glean.resetPassword.createNewSuccess.mockReset(); + glean.resetPassword.recoveryKeyCreatePasswordSuccess.mockReset(); }); describe('reset account with account recovery key', () => { @@ -378,56 +380,61 @@ describe('/account/reset', () => { }); it('should have checked for account recovery key', () => { - expect(mockDB.getRecoveryKey.callCount).toBe(1); - const args = mockDB.getRecoveryKey.args[0]; - expect(args.length).toBe(2); - expect(args[0]).toBe(uid); - expect(args[1]).toBe(mockRequest.payload.recoveryKeyId); + expect(mockDB.getRecoveryKey).toHaveBeenCalledTimes(1); + expect(mockDB.getRecoveryKey).toHaveBeenNthCalledWith( + 1, + uid, + mockRequest.payload.recoveryKeyId + ); }); it('should have reset account with account recovery key', () => { - expect(mockDB.resetAccount.callCount).toBe(1); - expect(mockDB.createKeyFetchToken.callCount).toBe(1); - const args = mockDB.createKeyFetchToken.args[0]; + expect(mockDB.resetAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createKeyFetchToken).toHaveBeenCalledTimes(1); + const args = mockDB.createKeyFetchToken.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].uid).toBe(uid); expect(args[0].wrapKb).toBe(mockRequest.payload.wrapKb); }); it('should have deleted account recovery key', () => { - expect(mockDB.deleteRecoveryKey.callCount).toBe(1); - const args = mockDB.deleteRecoveryKey.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toBe(uid); + expect(mockDB.deleteRecoveryKey).toHaveBeenCalledTimes(1); + expect(mockDB.deleteRecoveryKey).toHaveBeenNthCalledWith(1, uid); }); it('called mailer.sendPasswordResetAccountRecoveryEmail correctly', () => { - expect(fxaMailer.sendPasswordResetAccountRecoveryEmail.callCount).toBe(1); - const args = fxaMailer.sendPasswordResetAccountRecoveryEmail.args[0]; - expect(args[0].to).toBe(TEST_EMAIL); + expect( + fxaMailer.sendPasswordResetAccountRecoveryEmail + ).toHaveBeenCalledTimes(1); + expect( + fxaMailer.sendPasswordResetAccountRecoveryEmail + ).toHaveBeenNthCalledWith(1, expect.objectContaining({ to: TEST_EMAIL })); }); it('should have removed oauth tokens', () => { - sinon.assert.calledOnceWithExactly(oauth.removeTokensAndCodes, uid); + expect(oauth.removeTokensAndCodes).toHaveBeenCalledTimes(1); + expect(oauth.removeTokensAndCodes).toHaveBeenCalledWith(uid); }); it('should have reset custom server', () => { - expect(mockCustoms.reset.callCount).toBe(1); + expect(mockCustoms.reset).toHaveBeenCalledTimes(1); }); it('should have recorded security event', () => { - expect(mockDB.securityEvent.callCount).toBe(1); - const securityEvent = mockDB.securityEvent.args[0][0]; - expect(securityEvent.uid).toBe(uid); - expect(securityEvent.ipAddr).toBe(clientAddress); - expect(securityEvent.name).toBe('account.reset'); + expect(mockDB.securityEvent).toHaveBeenCalledTimes(1); + expect(mockDB.securityEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + uid, + ipAddr: clientAddress, + name: 'account.reset', + }) + ); }); it('should have emitted metrics', () => { - expect(mockLog.activityEvent.callCount).toBe(1); - const args = mockLog.activityEvent.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toEqual({ + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + expect(mockLog.activityEvent).toHaveBeenNthCalledWith(1, { country: 'United States', event: 'account.reset', region: 'California', @@ -438,21 +445,22 @@ describe('/account/reset', () => { clientJa4: 'test-ja4', }); - expect(mockMetricsContext.validate.callCount).toBe(0); - expect(mockMetricsContext.setFlowCompleteSignal.callCount).toBe(0); - expect(mockMetricsContext.propagate.callCount).toBe(2); - sinon.assert.calledOnceWithExactly( - glean.resetPassword.recoveryKeyCreatePasswordSuccess, - mockRequest, - { - uid, - } - ); + expect(mockMetricsContext.validate).toHaveBeenCalledTimes(0); + expect(mockMetricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes(0); + expect(mockMetricsContext.propagate).toHaveBeenCalledTimes(2); + expect( + glean.resetPassword.recoveryKeyCreatePasswordSuccess + ).toHaveBeenCalledTimes(1); + expect( + glean.resetPassword.recoveryKeyCreatePasswordSuccess + ).toHaveBeenCalledWith(mockRequest, { + uid, + }); }); it('should have created session', () => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const args = mockDB.createSessionToken.args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const args = mockDB.createSessionToken.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].uaBrowser).toBe('Firefox'); expect(args[0].uaBrowserVersion).toBe('57'); @@ -462,14 +470,14 @@ describe('/account/reset', () => { expect(args[0].uaFormFactor).toBeNull(); // Token is not verified with TOTP-2FA method (AAL2) if account does not have TOTP - expect(mockDB.verifyTokensWithMethod.callCount).toBe(0); + expect(mockDB.verifyTokensWithMethod).toHaveBeenCalledTimes(0); }); }); describe('reset account with account recovery key, TOTP enabled', () => { let res: any; beforeEach(() => { - mockDB.totpToken = sinon.spy(() => { + mockDB.totpToken = jest.fn(() => { return Promise.resolve({ verified: true, enabled: true, @@ -486,9 +494,12 @@ describe('/account/reset', () => { }); it('should verify token with TOTP-2FA method (AAL2) if account has TOTP', () => { - expect(mockDB.verifyTokensWithMethod.callCount).toBe(1); - const verifyArgs = mockDB.verifyTokensWithMethod.args[0]; - expect(verifyArgs[1]).toBe('totp-2fa'); + expect(mockDB.verifyTokensWithMethod).toHaveBeenCalledTimes(1); + expect(mockDB.verifyTokensWithMethod).toHaveBeenNthCalledWith( + 1, + expect.anything(), + 'totp-2fa' + ); }); }); @@ -502,18 +513,18 @@ describe('/account/reset', () => { it('called mailer.sendPasswordResetWithRecoveryKeyPromptEmail correctly', () => { expect( - fxaMailer.sendPasswordResetWithRecoveryKeyPromptEmail.callCount - ).toBe(1); - const args = - fxaMailer.sendPasswordResetWithRecoveryKeyPromptEmail.args[0]; - expect(args[0].to).toBe(TEST_EMAIL); + fxaMailer.sendPasswordResetWithRecoveryKeyPromptEmail + ).toHaveBeenCalledTimes(1); + expect( + fxaMailer.sendPasswordResetWithRecoveryKeyPromptEmail + ).toHaveBeenNthCalledWith(1, expect.objectContaining({ to: TEST_EMAIL })); }); }); describe('reset account with verified totp', () => { let res: any; beforeEach(() => { - mockDB.totpToken = sinon.spy(() => { + mockDB.totpToken = jest.fn(() => { return Promise.resolve({ verified: true, enabled: true, @@ -532,15 +543,15 @@ describe('/account/reset', () => { }); it('should have created verified sessionToken', () => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const args = mockDB.createSessionToken.args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const args = mockDB.createSessionToken.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].tokenVerificationId).toBeFalsy(); }); it('should have created verified keyFetchToken', () => { - expect(mockDB.createKeyFetchToken.callCount).toBe(1); - const args = mockDB.createKeyFetchToken.args[0]; + expect(mockDB.createKeyFetchToken).toHaveBeenCalledTimes(1); + const args = mockDB.createKeyFetchToken.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].tokenVerificationId).toBeFalsy(); }); @@ -548,7 +559,7 @@ describe('/account/reset', () => { describe('reset account with TOTP recovery code', () => { beforeEach(() => { - mockDB.totpToken = sinon.spy(() => { + mockDB.totpToken = jest.fn(() => { return Promise.resolve({ verified: true, enabled: true, @@ -559,12 +570,12 @@ describe('/account/reset', () => { }); it('should have created a sessionToken with the copied verification method', () => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const args = mockDB.createSessionToken.args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const args = mockDB.createSessionToken.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].tokenVerificationId).toBeFalsy(); - expect(mockDB.verifyTokensWithMethod.callCount).toBe(1); - const updateArgs = mockDB.verifyTokensWithMethod.args[0]; + expect(mockDB.verifyTokensWithMethod).toHaveBeenCalledTimes(1); + const updateArgs = mockDB.verifyTokensWithMethod.mock.calls[0]; expect(updateArgs[1]).toBe( mockRequest.auth.credentials.verificationMethod ); @@ -573,7 +584,7 @@ describe('/account/reset', () => { describe('reset account with unverified totp', () => { it('should fail with unverified session', async () => { - mockDB.totpToken = sinon.spy(() => { + mockDB.totpToken = jest.fn(() => { return Promise.resolve({ verified: true, enabled: true, @@ -587,32 +598,34 @@ describe('/account/reset', () => { it('should reset account', () => { return runTest(route, mockRequest, (res: any) => { - expect(mockDB.resetAccount.callCount).toBe(1); + expect(mockDB.resetAccount).toHaveBeenCalledTimes(1); - expect(mockPush.notifyPasswordReset.callCount).toBe(1); - expect(mockPush.notifyPasswordReset.firstCall.args[0]).toEqual(uid); + expect(mockPush.notifyPasswordReset).toHaveBeenCalledTimes(1); + expect(mockPush.notifyPasswordReset).toHaveBeenNthCalledWith( + 1, + uid, + expect.anything() + ); - expect(mockDB.account.callCount).toBe(1); - expect(mockCustoms.reset.callCount).toBe(1); + expect(mockDB.account).toHaveBeenCalledTimes(1); + expect(mockCustoms.reset).toHaveBeenCalledTimes(1); - expect(mockLog.activityEvent.callCount).toBe(1); - let args = mockLog.activityEvent.args[0]; - expect(args.length).toBe(1); - sinon.assert.calledOnceWithExactly( - glean.resetPassword.accountReset, + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + expect(glean.resetPassword.accountReset).toHaveBeenCalledTimes(1); + expect(glean.resetPassword.accountReset).toHaveBeenCalledWith( mockRequest, { uid, } ); - sinon.assert.calledOnceWithExactly( - glean.resetPassword.createNewSuccess, + expect(glean.resetPassword.createNewSuccess).toHaveBeenCalledTimes(1); + expect(glean.resetPassword.createNewSuccess).toHaveBeenCalledWith( mockRequest, { uid, } ); - expect(args[0]).toEqual({ + expect(mockLog.activityEvent).toHaveBeenNthCalledWith(1, { country: 'United States', event: 'account.reset', region: 'California', @@ -623,31 +636,35 @@ describe('/account/reset', () => { clientJa4: 'test-ja4', }); - expect(mockDB.securityEvent.callCount).toBe(1); - const securityEvent = mockDB.securityEvent.args[0][0]; - expect(securityEvent.uid).toBe(uid); - expect(securityEvent.ipAddr).toBe(clientAddress); - expect(securityEvent.name).toBe('account.reset'); + expect(mockDB.securityEvent).toHaveBeenCalledTimes(1); + expect(mockDB.securityEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + uid, + ipAddr: clientAddress, + name: 'account.reset', + }) + ); - expect(mockMetricsContext.validate.callCount).toBe(0); - expect(mockMetricsContext.setFlowCompleteSignal.callCount).toBe(0); + expect(mockMetricsContext.validate).toHaveBeenCalledTimes(0); + expect(mockMetricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes(0); - expect(mockMetricsContext.propagate.callCount).toBe(2); + expect(mockMetricsContext.propagate).toHaveBeenCalledTimes(2); - args = mockMetricsContext.propagate.args[0]; + let args: any = mockMetricsContext.propagate.mock.calls[0]; expect(args).toHaveLength(2); expect(args[0].uid).toBe(uid); expect(args[1].uid).toBe(uid); expect(args[1].id).toBe(sessionTokenId); - args = mockMetricsContext.propagate.args[1]; + args = mockMetricsContext.propagate.mock.calls[1]; expect(args).toHaveLength(2); expect(args[0].uid).toBe(uid); expect(args[1].uid).toBe(uid); expect(args[1].id).toBe(keyFetchTokenId); - expect(mockDB.createSessionToken.callCount).toBe(1); - args = mockDB.createSessionToken.args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + args = mockDB.createSessionToken.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].tokenVerificationId).toBeNull(); expect(args[0].uaBrowser).toBe('Firefox'); @@ -685,14 +702,12 @@ describe('deleteAccountIfUnverified', () => { isPrimary: true, isVerified: false, }; - mockDB.getSecondaryEmail = sinon.spy(async () => - Promise.resolve(emailRecord) - ); + mockDB.getSecondaryEmail = jest.fn(async () => Promise.resolve(emailRecord)); beforeEach(() => { - mockDB.deleteAccount = sinon.spy(async () => Promise.resolve()); + mockDB.deleteAccount = jest.fn(async () => Promise.resolve()); }); afterEach(() => { - sinon.restore(); + jest.restoreAllMocks(); }); it('should delete an unverified account with no linked Stripe account', async () => { const mockStripeHelper = { @@ -706,7 +721,9 @@ describe('deleteAccountIfUnverified', () => { mockRequest, TEST_EMAIL ); - sinon.assert.calledWithMatch(mockDB.deleteAccount, emailRecord); + expect(mockDB.deleteAccount).toHaveBeenCalledWith( + expect.objectContaining(emailRecord) + ); }); it('should not delete an unverified account with a linked Stripe account and return early', async () => { const mockStripeHelper = { @@ -726,12 +743,12 @@ describe('deleteAccountIfUnverified', () => { expect(err.errno).toBe(error.ERRNO.ACCOUNT_EXISTS); } expect(failed).toBe(true); - sinon.assert.notCalled(mockDB.deleteAccount); + expect(mockDB.deleteAccount).not.toHaveBeenCalled(); }); it('should delete a Stripe customer with no subscriptions', async () => { const mockStripeHelper = { hasActiveSubscription: async () => Promise.resolve(false), - removeCustomer: sinon.stub().resolves(), + removeCustomer: jest.fn().mockResolvedValue(), }; await deleteAccountIfUnverified( @@ -741,8 +758,8 @@ describe('deleteAccountIfUnverified', () => { mockRequest, TEST_EMAIL ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.removeCustomer, + expect(mockStripeHelper.removeCustomer).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.removeCustomer).toHaveBeenCalledWith( emailRecord.uid ); }); @@ -750,10 +767,12 @@ describe('deleteAccountIfUnverified', () => { const stripeError = new Error('no good'); const mockStripeHelper = { hasActiveSubscription: async () => Promise.resolve(false), - removeCustomer: sinon.stub().throws(stripeError), + removeCustomer: jest.fn(() => { + throw stripeError; + }), }; const sentryModule = require('../sentry'); - sinon.stub(sentryModule, 'reportSentryError').returns({}); + jest.spyOn(sentryModule, 'reportSentryError').mockReturnValue({}); try { await deleteAccountIfUnverified( mockDB, @@ -762,29 +781,29 @@ describe('deleteAccountIfUnverified', () => { mockRequest, TEST_EMAIL ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.removeCustomer, + expect(mockStripeHelper.removeCustomer).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.removeCustomer).toHaveBeenCalledWith( emailRecord.uid ); - sinon.assert.calledOnceWithExactly( - sentryModule.reportSentryError, + expect(sentryModule.reportSentryError).toHaveBeenCalledTimes(1); + expect(sentryModule.reportSentryError).toHaveBeenCalledWith( stripeError, mockRequest ); } catch (e) { throw new Error('should not have re-thrown'); } - sentryModule.reportSentryError.restore(); + (sentryModule.reportSentryError as jest.Mock).mockRestore(); }); }); describe('/account/create', () => { beforeEach(() => { - profile.deleteCache.resetHistory(); + profile.deleteCache.mockClear(); }); afterEach(() => { - glean.registration.accountCreated.reset(); - glean.registration.confirmationEmailSent.reset(); + glean.registration.accountCreated.mockReset(); + glean.registration.confirmationEmailSent.mockReset(); }); function setup( @@ -799,14 +818,14 @@ describe('/account/create', () => { ...extraConfig, }; const mockLog = log('ERROR', 'test'); - mockLog.activityEvent = sinon.spy(() => { + mockLog.activityEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.flowEvent = sinon.spy(() => { + mockLog.flowEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.error = sinon.spy(); - mockLog.notifier.send = sinon.spy(); + mockLog.error = jest.fn(); + mockLog.notifier.send = jest.fn(); const mockMetricsContext = mocks.mockMetricsContext(); const defaultMockRequestOpts = { @@ -937,10 +956,10 @@ describe('/account/create', () => { jest.setSystemTime(now); return runTest(route, mockRequest, () => { - expect(mockDB.createAccount.callCount).toBe(1); + expect(mockDB.createAccount).toHaveBeenCalledTimes(1); - expect(mockDB.createSessionToken.callCount).toBe(1); - let args = mockDB.createSessionToken.args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + let args = mockDB.createSessionToken.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].uaBrowser).toBe('Firefox Mobile'); expect(args[0].uaBrowserVersion).toBe('9'); @@ -949,8 +968,8 @@ describe('/account/create', () => { expect(args[0].uaDeviceType).toBe('tablet'); expect(args[0].uaFormFactor).toBe('iPad'); - expect(mockLog.notifier.send.callCount).toBe(2); - let eventData = mockLog.notifier.send.getCall(0).args[0]; + expect(mockLog.notifier.send).toHaveBeenCalledTimes(2); + let eventData = mockLog.notifier.send.mock.calls[0][0]; expect(eventData.event).toBe('login'); expect(eventData.data.service).toBe('sync'); expect(eventData.data.email).toBe(TEST_EMAIL); @@ -977,17 +996,15 @@ describe('/account/create', () => { utm_term: 'utm term', }); - expect(profile.deleteCache.callCount).toBe(1); - expect(profile.deleteCache.getCall(0).args[0]).toBe(uid); + expect(profile.deleteCache).toHaveBeenCalledTimes(1); + expect(profile.deleteCache).toHaveBeenNthCalledWith(1, uid); - eventData = mockLog.notifier.send.getCall(1).args[0]; + eventData = mockLog.notifier.send.mock.calls[1][0]; expect(eventData.event).toBe('profileDataChange'); expect(eventData.data.uid).toBe(uid); - expect(mockLog.activityEvent.callCount).toBe(1); - args = mockLog.activityEvent.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toEqual({ + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + expect(mockLog.activityEvent).toHaveBeenNthCalledWith(1, { country: 'United States', event: 'account.created', region: 'California', @@ -998,8 +1015,8 @@ describe('/account/create', () => { clientJa4: 'test-ja4', }); - expect(mockLog.flowEvent.callCount).toBe(1); - args = mockLog.flowEvent.args[0]; + expect(mockLog.flowEvent).toHaveBeenCalledTimes(1); + args = mockLog.flowEvent.mock.calls[0]; expect(args.length).toBe(1); expect(args[0]).toEqual({ country: 'United States', @@ -1029,44 +1046,45 @@ describe('/account/create', () => { clientJa4: 'test-ja4', }); - expect(mockMetricsContext.validate.callCount).toBe(1); - expect(mockMetricsContext.validate.args[0].length).toBe(0); - - expect(mockMetricsContext.stash.callCount).toBe(3); - - args = mockMetricsContext.stash.args[0]; - expect(args.length).toBe(1); - expect(args[0].id).toEqual(sessionTokenId); - expect(args[0].uid).toEqual(uid); - expect(mockMetricsContext.stash.thisValues[0]).toBe(mockRequest); - - args = mockMetricsContext.stash.args[1]; - expect(args.length).toBe(1); - expect(args[0].id).toBe(emailCode); - expect(args[0].uid).toEqual(uid); - expect(mockMetricsContext.stash.thisValues[1]).toBe(mockRequest); - - args = mockMetricsContext.stash.args[2]; - expect(args.length).toBe(1); - expect(args[0].id).toEqual(keyFetchTokenId); - expect(args[0].uid).toEqual(uid); - expect(mockMetricsContext.stash.thisValues[2]).toBe(mockRequest); - - expect(mockMetricsContext.setFlowCompleteSignal.callCount).toBe(1); - args = mockMetricsContext.setFlowCompleteSignal.args[0]; - expect(args.length).toBe(2); - expect(args[0]).toBe('account.signed'); - expect(args[1]).toBe('registration'); + expect(mockMetricsContext.validate).toHaveBeenCalledTimes(1); + expect(mockMetricsContext.validate).toHaveBeenNthCalledWith(1); + + expect(mockMetricsContext.stash).toHaveBeenCalledTimes(3); + + let stashArgs: any = mockMetricsContext.stash.mock.calls[0]; + expect(stashArgs.length).toBe(1); + expect(stashArgs[0].id).toEqual(sessionTokenId); + expect(stashArgs[0].uid).toEqual(uid); + expect(mockMetricsContext.stash.mock.contexts[0]).toBe(mockRequest); + + stashArgs = mockMetricsContext.stash.mock.calls[1]; + expect(stashArgs.length).toBe(1); + expect(stashArgs[0].id).toBe(emailCode); + expect(stashArgs[0].uid).toEqual(uid); + expect(mockMetricsContext.stash.mock.contexts[1]).toBe(mockRequest); + + stashArgs = mockMetricsContext.stash.mock.calls[2]; + expect(stashArgs.length).toBe(1); + expect(stashArgs[0].id).toEqual(keyFetchTokenId); + expect(stashArgs[0].uid).toEqual(uid); + expect(mockMetricsContext.stash.mock.contexts[2]).toBe(mockRequest); + + expect(mockMetricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes(1); + expect(mockMetricsContext.setFlowCompleteSignal).toHaveBeenNthCalledWith( + 1, + 'account.signed', + 'registration' + ); let securityEvent = mockDB.securityEvent; - expect(securityEvent.callCount).toBe(1); - securityEvent = securityEvent.args[0][0]; + expect(securityEvent).toHaveBeenCalledTimes(1); + securityEvent = securityEvent.mock.calls[0][0]; expect(securityEvent.name).toBe('account.create'); expect(securityEvent.uid).toBe(uid); expect(securityEvent.ipAddr).toBe(clientAddress); - expect(mockFxaMailer.sendVerifyEmail.callCount).toBe(1); - args = mockFxaMailer.sendVerifyEmail.args[0]; + expect(mockFxaMailer.sendVerifyEmail).toHaveBeenCalledTimes(1); + args = mockFxaMailer.sendVerifyEmail.mock.calls[0]; expect(args[0].location.city).toBe('Mountain View'); expect(args[0].location.country).toBe('United States'); expect(args[0].acceptLanguage).toBe('en-US'); @@ -1084,16 +1102,16 @@ describe('/account/create', () => { expect(args[0].sync).toBe(true); expect(args[0].uid).toBe(uid); - expect(verificationReminders.create.callCount).toBe(1); - args = verificationReminders.create.args[0]; + expect(verificationReminders.create).toHaveBeenCalledTimes(1); + args = verificationReminders.create.mock.calls[0]; expect(args).toHaveLength(3); expect(args[0]).toBe(uid); expect(args[1]).toBe(mockRequest.payload.metricsContext.flowId); expect(args[2]).toBe(mockRequest.payload.metricsContext.flowBeginTime); - expect(mockLog.error.callCount).toBe(0); + expect(mockLog.error).toHaveBeenCalledTimes(0); - sinon.assert.calledOnce(glean.registration.accountCreated); + expect(glean.registration.accountCreated).toHaveBeenCalledTimes(1); }).finally(() => jest.useRealTimers()); }); @@ -1128,22 +1146,20 @@ describe('/account/create', () => { mockRequest.payload.service = 'foo'; return runTest(route, mockRequest, () => { - expect(mockLog.notifier.send.callCount).toBe(2); - let eventData = mockLog.notifier.send.getCall(0).args[0]; + expect(mockLog.notifier.send).toHaveBeenCalledTimes(2); + let eventData = mockLog.notifier.send.mock.calls[0][0]; expect(eventData.event).toBe('login'); expect(eventData.data.service).toBe('foo'); - expect(profile.deleteCache.callCount).toBe(1); - expect(profile.deleteCache.getCall(0).args[0]).toBe(uid); + expect(profile.deleteCache).toHaveBeenCalledTimes(1); + expect(profile.deleteCache).toHaveBeenNthCalledWith(1, uid); - eventData = mockLog.notifier.send.getCall(1).args[0]; + eventData = mockLog.notifier.send.mock.calls[1][0]; expect(eventData.event).toBe('profileDataChange'); expect(eventData.data.uid).toBe(uid); - expect(mockLog.activityEvent.callCount).toBe(1); - let args = mockLog.activityEvent.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toEqual({ + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + expect(mockLog.activityEvent).toHaveBeenNthCalledWith(1, { country: 'United States', event: 'account.created', region: 'California', @@ -1154,15 +1170,17 @@ describe('/account/create', () => { clientJa4: 'test-ja4', }); - expect(mockFxaMailer.sendVerifyEmail.callCount).toBe(1); - args = mockFxaMailer.sendVerifyEmail.args[0]; - expect(args[0].sync).toBe(false); + expect(mockFxaMailer.sendVerifyEmail).toHaveBeenCalledTimes(1); + expect(mockFxaMailer.sendVerifyEmail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ sync: false }) + ); - sinon.assert.calledOnce(glean.registration.confirmationEmailSent); + expect(glean.registration.confirmationEmailSent).toHaveBeenCalledTimes(1); - expect(verificationReminders.create.callCount).toBe(1); + expect(verificationReminders.create).toHaveBeenCalledTimes(1); - expect(mockLog.error.callCount).toBe(0); + expect(mockLog.error).toHaveBeenCalledTimes(0); }).finally(() => jest.useRealTimers()); }); @@ -1174,7 +1192,7 @@ describe('/account/create', () => { mockRequest.payload.verificationMethod = 'email-otp'; await runTest(route, mockRequest, (res: any) => { - sinon.assert.calledOnce(mockMailer.sendVerifyShortCodeEmail); + expect(mockMailer.sendVerifyShortCodeEmail).toHaveBeenCalledTimes(1); const authenticator = new otplib.authenticator.Authenticator(); authenticator.options = Object.assign( @@ -1184,7 +1202,7 @@ describe('/account/create', () => { { secret: emailCode } ); const expectedCode = authenticator.generate(); - const args = mockMailer.sendVerifyShortCodeEmail.args[0]; + const args = mockMailer.sendVerifyShortCodeEmail.mock.calls[0]; expect(args[2].code).toBe(expectedCode); expect(args[2].acceptLanguage).toBe(mockRequest.app.acceptLanguage); @@ -1204,7 +1222,7 @@ describe('/account/create', () => { const { mockRequest, route, verificationReminders, mockFxaMailer } = setup(); - mockFxaMailer.sendVerifyEmail = sinon.spy(() => Promise.reject()); + mockFxaMailer.sendVerifyEmail = jest.fn(() => Promise.reject()); await expect(runTest(route, mockRequest)).rejects.toMatchObject({ message: 'Failed to send email', @@ -1216,14 +1234,14 @@ describe('/account/create', () => { }, }, }); - expect(verificationReminders.create.callCount).toBe(0); + expect(verificationReminders.create).toHaveBeenCalledTimes(0); }); it('should return a bounce error if send fails with one', async () => { const { mockRequest, route, verificationReminders, mockFxaMailer } = setup(); - mockFxaMailer.sendVerifyEmail = sinon.spy(() => + mockFxaMailer.sendVerifyEmail = jest.fn(() => Promise.reject(error.emailBouncedHard(42)) ); @@ -1237,7 +1255,7 @@ describe('/account/create', () => { }, }, }); - expect(verificationReminders.create.callCount).toBe(0); + expect(verificationReminders.create).toHaveBeenCalledTimes(0); }); it('can refuse new account creations for selected OAuth clients', async () => { @@ -1256,7 +1274,7 @@ describe('/account/create', () => { }); it('should use RP CMS email content for verify email', () => { - rpConfigManager.fetchCMSData.resetHistory(); + rpConfigManager.fetchCMSData.mockClear(); const mockRequestOpts = (defaults: any) => ({ ...defaults, payload: { @@ -1277,8 +1295,8 @@ describe('/account/create', () => { jest.setSystemTime(now); return runTest(route, mockRequest, () => { - sinon.assert.calledOnce(mockMailer.sendVerifyShortCodeEmail); - const args = mockMailer.sendVerifyShortCodeEmail.args[0]; + expect(mockMailer.sendVerifyShortCodeEmail).toHaveBeenCalledTimes(1); + const args = mockMailer.sendVerifyShortCodeEmail.mock.calls[0]; const emailMessage = args[2]; expect(emailMessage.target).toBe('strapi'); expect(emailMessage.cmsRpClientId).toBe('00f00f'); @@ -1301,14 +1319,14 @@ describe('/account/stub', () => { ...extraConfig, }; const mockLog = log('ERROR', 'test'); - mockLog.activityEvent = sinon.spy(() => { + mockLog.activityEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.flowEvent = sinon.spy(() => { + mockLog.flowEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.error = sinon.spy(); - mockLog.notifier.send = sinon.spy(); + mockLog.error = jest.fn(); + mockLog.notifier.send = jest.fn(); const mockMetricsContext = mocks.mockMetricsContext(); const email = Math.random() + '_stub@mozilla.com'; @@ -1436,14 +1454,14 @@ describe('/account/status', () => { ...extraConfig, }; const mockLog = log('ERROR', 'test'); - mockLog.activityEvent = sinon.spy(() => { + mockLog.activityEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.flowEvent = sinon.spy(() => { + mockLog.flowEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.error = sinon.spy(); - mockLog.notifier.send = sinon.spy(); + mockLog.error = jest.fn(); + mockLog.notifier.send = jest.fn(); const mockMetricsContext = mocks.mockMetricsContext(); const email = Math.random() + '_stub@mozilla.com'; @@ -1587,8 +1605,8 @@ describe('/account/status', () => { mockRequest.payload.thirdPartyAuthStatus = true; return runTest(route, mockRequest, (response: any) => { - expect(mockDB.accountRecord.callCount).toBe(1); - expect(mockDB.accountExists.callCount).toBe(0); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDB.accountExists).toHaveBeenCalledTimes(0); expect(response.exists).toBe(true); expect(response.hasLinkedAccount).toBe(true); @@ -1602,8 +1620,8 @@ describe('/account/status', () => { }); return runTest(route, mockRequest, (response: any) => { - expect(mockDB.accountRecord.callCount).toBe(0); - expect(mockDB.accountExists.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(0); + expect(mockDB.accountExists).toHaveBeenCalledTimes(1); expect(response.exists).toBe(false); expect(response.linkedAccounts).toBeUndefined(); @@ -1628,7 +1646,7 @@ describe('/account/status', () => { mockRequest.payload.clientId = 'test-client-id'; return runTest(route, mockRequest, (response: any) => { - expect(mockDB.accountRecord.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); expect(response.exists).toBe(true); expect(response.hasPassword).toBe(false); expect(response.passwordlessSupported).toBe(true); @@ -1653,7 +1671,7 @@ describe('/account/status', () => { mockRequest.payload.clientId = 'test-client-id'; return runTest(route, mockRequest, (response: any) => { - expect(mockDB.accountRecord.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); expect(response.exists).toBe(true); expect(response.hasPassword).toBe(false); expect(response.passwordlessSupported).toBe(false); @@ -1677,7 +1695,7 @@ describe('/account/status', () => { mockRequest.payload.thirdPartyAuthStatus = true; return runTest(route, mockRequest, (response: any) => { - expect(mockDB.accountRecord.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); expect(response.exists).toBe(true); expect(response.hasPassword).toBe(true); expect(response.passwordlessSupported).toBe(false); @@ -1752,7 +1770,7 @@ describe('/account/status', () => { mockRequest.payload.thirdPartyAuthStatus = true; return runTest(route, mockRequest, (response: any) => { - expect(mockDB.accountRecord.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); expect(response.exists).toBe(true); expect(response.hasPassword).toBe(false); expect(response.passwordlessSupported).toBe(true); @@ -1777,7 +1795,7 @@ describe('/account/status', () => { mockRequest.payload.clientId = 'not-in-allowlist'; return runTest(route, mockRequest, (response: any) => { - expect(mockDB.accountRecord.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); expect(response.exists).toBe(true); expect(response.hasPassword).toBe(false); expect(response.passwordlessSupported).toBe(true); @@ -1793,14 +1811,14 @@ describe('/account/finish_setup', () => { }, }; const mockLog = log('ERROR', 'test'); - mockLog.activityEvent = sinon.spy(() => { + mockLog.activityEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.flowEvent = sinon.spy(() => { + mockLog.flowEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.error = sinon.spy(); - mockLog.notifier.send = sinon.spy(); + mockLog.error = jest.fn(); + mockLog.notifier.send = jest.fn(); const mockMetricsContext = mocks.mockMetricsContext(); const email = Math.random() + '_stub@mozilla.com'; @@ -1895,8 +1913,8 @@ describe('/account/finish_setup', () => { verifierSetAt: 0, }); return runTest(route, mockRequest, (response: any) => { - expect(mockDB.verifyEmail.callCount).toBe(1); - expect(mockDB.resetAccount.callCount).toBe(1); + expect(mockDB.verifyEmail).toHaveBeenCalledTimes(1); + expect(mockDB.resetAccount).toHaveBeenCalledTimes(1); expect(response.sessionToken).toBeTruthy(); expect(response.uid).toBe(uid); }); @@ -1919,7 +1937,7 @@ describe('/account/finish_setup', () => { await expect(runTest(route, mockRequest)).rejects.toMatchObject({ errno: 110, }); - sinon.assert.calledOnce(subscriptionAccountReminders.delete); + expect(subscriptionAccountReminders.delete).toHaveBeenCalledTimes(1); }); }); @@ -1931,14 +1949,14 @@ describe('/account/set_password', () => { }, }; const mockLog = log('ERROR', 'test'); - mockLog.activityEvent = sinon.spy(() => { + mockLog.activityEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.flowEvent = sinon.spy(() => { + mockLog.flowEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.error = sinon.spy(); - mockLog.notifier.send = sinon.spy(); + mockLog.error = jest.fn(); + mockLog.notifier.send = jest.fn(); const mockMetricsContext = mocks.mockMetricsContext(); const email = Math.random() + '_stub@mozilla.com'; @@ -2058,9 +2076,9 @@ describe('/account/set_password', () => { verifierSetAt: 0, }); return runTest(route, mockRequest, (response: any) => { - expect(mockDB.resetAccount.callCount).toBe(1); - expect(mockMailer.sendVerifyShortCodeEmail.callCount).toBe(1); - sinon.assert.calledOnce(subscriptionAccountReminders.create); + expect(mockDB.resetAccount).toHaveBeenCalledTimes(1); + expect(mockMailer.sendVerifyShortCodeEmail).toHaveBeenCalledTimes(1); + expect(subscriptionAccountReminders.create).toHaveBeenCalledTimes(1); expect(response.sessionToken).toBeTruthy(); expect(response.uid).toBe(uid); }); @@ -2083,7 +2101,7 @@ describe('/account/set_password', () => { verifierSetAt: 0, }); return runTest(route, mockRequest, (response: any) => { - sinon.assert.notCalled(mockMailer.sendVerifyShortCodeEmail); + expect(mockMailer.sendVerifyShortCodeEmail).not.toHaveBeenCalled(); expect(response.sessionToken).toBeTruthy(); expect(response.uid).toBe(uid); }); @@ -2099,7 +2117,7 @@ describe('/account/set_password', () => { verifierSetAt: 0, }); return runTest(route, mockRequest, (response: any) => { - sinon.assert.notCalled(subscriptionAccountReminders.create); + expect(subscriptionAccountReminders.create).not.toHaveBeenCalled(); expect(response.sessionToken).toBeTruthy(); expect(response.uid).toBe(uid); }); @@ -2120,7 +2138,7 @@ describe('/account/set_password', () => { verifierSetAt: 0, }); return runTest(route, mockRequest, (response: any) => { - sinon.assert.notCalled(subscriptionAccountReminders.create); + expect(subscriptionAccountReminders.create).not.toHaveBeenCalled(); expect(response.sessionToken).toBeTruthy(); expect(response.uid).toBe(uid); }); @@ -2139,13 +2157,13 @@ describe('/account/login', () => { servicesWithEmailVerification: [], }; const mockLog = log('ERROR', 'test'); - mockLog.activityEvent = sinon.spy(() => { + mockLog.activityEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.flowEvent = sinon.spy(() => { + mockLog.flowEvent = jest.fn(() => { return Promise.resolve(); }); - mockLog.notifier.send = sinon.spy(); + mockLog.notifier.send = jest.fn(); const mockMetricsContext = mocks.mockMetricsContext(); const mockRequest = mocks.mockRequest({ @@ -2319,37 +2337,40 @@ describe('/account/login', () => { }); afterEach(() => { - glean.login.success.reset(); - mockLog.activityEvent.resetHistory(); - mockLog.flowEvent.resetHistory(); - mockMailer.sendNewDeviceLoginEmail = sinon.spy(() => Promise.resolve([])); - mockMailer.sendVerifyLoginEmail = sinon.spy(() => Promise.resolve()); - mockMailer.sendVerifyLoginCodeEmail = sinon.spy(() => Promise.resolve()); - mockMailer.sendVerifyShortCodeEmail = sinon.spy(() => Promise.resolve()); - mockMailer.sendVerifyEmail.resetHistory(); + glean.login.success.mockReset(); + mockLog.activityEvent.mockClear(); + mockLog.flowEvent.mockClear(); + mockMailer.sendNewDeviceLoginEmail = jest.fn(() => Promise.resolve([])); + mockMailer.sendVerifyLoginEmail = jest.fn(() => Promise.resolve()); + mockMailer.sendVerifyLoginCodeEmail = jest.fn(() => Promise.resolve()); + mockMailer.sendVerifyShortCodeEmail = jest.fn(() => Promise.resolve()); + mockMailer.sendVerifyEmail.mockClear(); // some tests change what these resolve (or reject) to, so we completely reset - mockFxaMailer.sendNewDeviceLoginEmail = sinon.stub().resolves(); - mockFxaMailer.sendVerifyEmail = sinon.stub().resolves(); - mockFxaMailer.sendVerifyLoginEmail = sinon.stub().resolves(); - mockDB.createSessionToken.resetHistory(); - mockDB.sessions.resetHistory(); - mockMetricsContext.stash.resetHistory(); - mockMetricsContext.validate.resetHistory(); - mockMetricsContext.setFlowCompleteSignal.resetHistory(); + mockFxaMailer.sendNewDeviceLoginEmail = jest.fn().mockResolvedValue(); + mockFxaMailer.sendVerifyEmail = jest.fn().mockResolvedValue(); + mockFxaMailer.sendVerifyLoginEmail = jest.fn().mockResolvedValue(); + mockDB.createSessionToken.mockClear(); + mockDB.sessions.mockClear(); + mockMetricsContext.stash.mockClear(); + mockMetricsContext.validate.mockClear(); + mockMetricsContext.setFlowCompleteSignal.mockClear(); mockDB.emailRecord = defaultEmailRecord; - mockDB.emailRecord.resetHistory(); + mockDB.emailRecord.mockClear(); mockDB.accountRecord = defaultEmailAccountRecord; - mockDB.accountRecord.resetHistory(); - mockDB.getSecondaryEmail = sinon.spy(() => + mockDB.accountRecord.mockClear(); + mockDB.getSecondaryEmail = jest.fn(() => Promise.reject(error.unknownSecondaryEmail()) ); - mockDB.getSecondaryEmail.resetHistory(); + mockDB.getSecondaryEmail.mockClear(); mockRequest.payload.email = TEST_EMAIL; mockRequest.payload.verificationMethod = undefined; - mockCadReminders.delete.resetHistory(); - mockDB.verifiedLoginSecurityEvents.resetHistory(); - if (mockDB.securityEvent && mockDB.securityEvent.resetHistory) { - mockDB.securityEvent.resetHistory(); + mockCadReminders.delete.mockClear(); + mockDB.verifiedLoginSecurityEvents.mockClear(); + if ( + mockDB.securityEvent && + typeof mockDB.securityEvent.mockClear === 'function' + ) { + mockDB.securityEvent.mockClear(); } Container.reset(); }); @@ -2359,10 +2380,10 @@ describe('/account/login', () => { const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now); return runTest(route, mockRequest, (response: any) => { - expect(mockDB.accountRecord.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); - expect(mockDB.createSessionToken.callCount).toBe(1); - let args = mockDB.createSessionToken.args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + let args = mockDB.createSessionToken.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].uaBrowser).toBe('Firefox'); expect(args[0].uaBrowserVersion).toBe('50'); @@ -2371,8 +2392,8 @@ describe('/account/login', () => { expect(args[0].uaDeviceType).toBe('mobile'); expect(args[0].uaFormFactor).toBeNull(); - expect(mockLog.notifier.send.callCount).toBe(1); - const eventData = mockLog.notifier.send.getCall(0).args[0]; + expect(mockLog.notifier.send).toHaveBeenCalledTimes(1); + const eventData = mockLog.notifier.send.mock.calls[0][0]; expect(eventData.event).toBe('login'); expect(eventData.data.service).toBe('sync'); expect(eventData.data.email).toBe(TEST_EMAIL); @@ -2386,8 +2407,8 @@ describe('/account/login', () => { flowType: undefined, }); - expect(mockLog.activityEvent.callCount).toBe(1); - args = mockLog.activityEvent.args[0]; + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + args = mockLog.activityEvent.mock.calls[0]; expect(args.length).toBe(1); expect(args[0]).toEqual({ country: 'United States', @@ -2400,8 +2421,8 @@ describe('/account/login', () => { clientJa4: 'test-ja4', }); - expect(mockLog.flowEvent.callCount).toBe(2); - args = mockLog.flowEvent.args[0]; + expect(mockLog.flowEvent).toHaveBeenCalledTimes(2); + args = mockLog.flowEvent.mock.calls[0]; expect(args.length).toBe(1); expect(args[0]).toEqual({ country: 'United States', @@ -2420,7 +2441,7 @@ describe('/account/login', () => { sigsciRequestId: 'test-sigsci-id', clientJa4: 'test-ja4', }); - args = mockLog.flowEvent.args[1]; + args = mockLog.flowEvent.mock.calls[1]; expect(args.length).toBe(1); expect(args[0]).toEqual({ country: 'United States', @@ -2439,37 +2460,38 @@ describe('/account/login', () => { clientJa4: 'test-ja4', }); - expect(mockMetricsContext.validate.callCount).toBe(1); - expect(mockMetricsContext.validate.args[0].length).toBe(0); + expect(mockMetricsContext.validate).toHaveBeenCalledTimes(1); + expect(mockMetricsContext.validate).toHaveBeenNthCalledWith(1); - expect(mockMetricsContext.stash.callCount).toBe(3); + expect(mockMetricsContext.stash).toHaveBeenCalledTimes(3); - args = mockMetricsContext.stash.args[0]; + args = mockMetricsContext.stash.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].id).toEqual(sessionTokenId); expect(args[0].uid).toEqual(uid); - expect(mockMetricsContext.stash.thisValues[0]).toBe(mockRequest); + expect(mockMetricsContext.stash.mock.contexts[0]).toBe(mockRequest); - args = mockMetricsContext.stash.args[1]; + args = mockMetricsContext.stash.mock.calls[1]; expect(args.length).toBe(1); expect(args[0].id).toMatch(/^[0-9a-f]{32}$/); expect(args[0].uid).toEqual(uid); - expect(mockMetricsContext.stash.thisValues[1]).toBe(mockRequest); + expect(mockMetricsContext.stash.mock.contexts[1]).toBe(mockRequest); - args = mockMetricsContext.stash.args[2]; + args = mockMetricsContext.stash.mock.calls[2]; expect(args.length).toBe(1); expect(args[0].id).toEqual(keyFetchTokenId); expect(args[0].uid).toEqual(uid); - expect(mockMetricsContext.stash.thisValues[2]).toBe(mockRequest); + expect(mockMetricsContext.stash.mock.contexts[2]).toBe(mockRequest); - expect(mockMetricsContext.setFlowCompleteSignal.callCount).toBe(1); - args = mockMetricsContext.setFlowCompleteSignal.args[0]; - expect(args.length).toBe(2); - expect(args[0]).toBe('account.signed'); - expect(args[1]).toBe('login'); + expect(mockMetricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes(1); + expect(mockMetricsContext.setFlowCompleteSignal).toHaveBeenNthCalledWith( + 1, + 'account.signed', + 'login' + ); - expect(mockFxaMailer.sendVerifyLoginEmail.callCount).toBe(1); - args = mockFxaMailer.sendVerifyLoginEmail.args[0]; + expect(mockFxaMailer.sendVerifyLoginEmail).toHaveBeenCalledTimes(1); + args = mockFxaMailer.sendVerifyLoginEmail.mock.calls[0]; expect(args[0].acceptLanguage).toBe('en-US'); expect(args[0].location.city).toBe('Mountain View'); expect(args[0].location.country).toBe('United States'); @@ -2487,7 +2509,7 @@ describe('/account/login', () => { expect(args[0].sync).toBe(true); expect(args[0].uid).toBe(uid); - expect(mockFxaMailer.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockFxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); expect(response.verified).toBeFalsy(); expect(response.verificationMethod).toBe('email'); expect(response.verificationReason).toBe('login'); @@ -2520,18 +2542,22 @@ describe('/account/login', () => { }; return runTest(route, mockRequest, (response: any) => { - expect(mockFxaMailer.sendVerifyEmail.callCount).toBe(1); + expect(mockFxaMailer.sendVerifyEmail).toHaveBeenCalledTimes(1); // Verify that the email code was sent - const verifyCallArgs = mockFxaMailer.sendVerifyEmail.getCall(0).args; + const verifyCallArgs = mockFxaMailer.sendVerifyEmail.mock.calls[0]; expect(verifyCallArgs[0].code).not.toBe(emailCode); - expect(mockLog.flowEvent.callCount).toBe(2); - expect(mockLog.flowEvent.args[0][0].event).toBe('account.login'); - expect(mockLog.flowEvent.args[1][0].event).toBe( - 'email.verification.sent' + expect(mockLog.flowEvent).toHaveBeenCalledTimes(2); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.login' }) ); - expect(mockMailer.sendVerifyLoginEmail.callCount).toBe(0); - expect(mockMailer.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: 'email.verification.sent' }) + ); + expect(mockMailer.sendVerifyLoginEmail).toHaveBeenCalledTimes(0); + expect(mockMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); expect(response.emailVerified).toBe(false); expect(response.verified).toBe(false); expect(response.verificationMethod).toBe('email'); @@ -2569,18 +2595,18 @@ describe('/account/login', () => { it('is enabled by default', () => { return runTest(route, mockRequest, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.mustVerify).toBeTruthy(); expect(tokenData.tokenVerificationId).toBeTruthy(); - expect(mockMailer.sendVerifyEmail.callCount).toBe(0); - expect(mockMailer.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockMailer.sendVerifyEmail).toHaveBeenCalledTimes(0); + expect(mockMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); expect(response.verified).toBeFalsy(); expect(response.verificationMethod).toBe('email'); expect(response.verificationReason).toBe('login'); - expect(mockFxaMailer.sendVerifyLoginEmail.callCount).toBe(1); - const args = mockFxaMailer.sendVerifyLoginEmail.getCall(0).args[0]; + expect(mockFxaMailer.sendVerifyLoginEmail).toHaveBeenCalledTimes(1); + const args = mockFxaMailer.sendVerifyLoginEmail.mock.calls[0][0]; expect(args.acceptLanguage).toBe('en-US'); expect(args.location.city).toBe('Mountain View'); expect(args.location.country).toBe('United States'); @@ -2612,17 +2638,19 @@ describe('/account/login', () => { }; return runTest(route, mockRequestSuspect, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.mustVerify).toBeTruthy(); expect(tokenData.tokenVerificationId).toBeTruthy(); - expect(mockMailer.sendNewDeviceLoginEmail.callCount).toBe(0); - expect(mockFxaMailer.sendVerifyLoginEmail.callCount).toBe(1); + expect(mockMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); + expect(mockFxaMailer.sendVerifyLoginEmail).toHaveBeenCalledTimes(1); - expect(mockMetricsContext.setFlowCompleteSignal.callCount).toBe(1); - expect(mockMetricsContext.setFlowCompleteSignal.args[0][0]).toEqual( - 'account.confirmed' + expect(mockMetricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes( + 1 ); + expect( + mockMetricsContext.setFlowCompleteSignal + ).toHaveBeenNthCalledWith(1, 'account.confirmed', expect.anything()); expect(response.verified).toBeFalsy(); expect(response.verificationMethod).toBe('email'); @@ -2653,38 +2681,42 @@ describe('/account/login', () => { }); }; const originalCreateSessionToken = mockDB.createSessionToken; - mockDB.createSessionToken = sinon.spy(async (opts: any) => { + mockDB.createSessionToken = jest.fn(async (opts: any) => { const result = await originalCreateSessionToken(opts); result.tokenVerificationId = null; result.tokenVerified = true; return result; }); + const replacementMock = mockDB.createSessionToken; return runTest(route, mockRequestNoKeys, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + // Restore the original function before assertions so a test failure + // does not leave a stale mock that cascades into subsequent tests. + mockDB.createSessionToken = originalCreateSessionToken; + + expect(replacementMock).toHaveBeenCalledTimes(1); + const tokenData = replacementMock.mock.calls[0][0]; expect(tokenData.mustVerify).toBeTruthy(); - const sessionToken = mockDB.createSessionToken.returnValues[0]; + const sessionToken = replacementMock.mock.results[0].value; sessionToken.then((token: any) => { expect(token.tokenVerificationId).toBeFalsy(); expect(token.tokenVerified).toBeTruthy(); }); - expect(mockFxaMailer.sendNewDeviceLoginEmail.callCount).toBe(1); - expect(mockMailer.sendVerifyLoginEmail.callCount).toBe(0); + expect(mockFxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(1); + expect(mockMailer.sendVerifyLoginEmail).toHaveBeenCalledTimes(0); - expect(mockMetricsContext.setFlowCompleteSignal.callCount).toBe(1); - expect(mockMetricsContext.setFlowCompleteSignal.args[0][0]).toEqual( - 'account.login' + expect(mockMetricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes( + 1 ); + expect( + mockMetricsContext.setFlowCompleteSignal + ).toHaveBeenNthCalledWith(1, 'account.login', expect.anything()); expect(response.emailVerified).toBeTruthy(); expect(response.sessionVerified).toBeTruthy(); expect(response.verified).toBeTruthy(); expect(response.verificationMethod).toBeFalsy(); expect(response.verificationReason).toBeFalsy(); - - // Restore the original function - mockDB.createSessionToken = originalCreateSessionToken; }); }); @@ -2719,7 +2751,7 @@ describe('/account/login', () => { // Simulate an unverified session state. This will suppress the sending of // a 'new device login' email. const originalCreateSessionToken = mockDB.createSessionToken; - mockDB.createSessionToken = sinon.spy(async (opts: any) => { + mockDB.createSessionToken = jest.fn(async (opts: any) => { const result = await originalCreateSessionToken(opts); result.tokenVerificationId = hexString(16); result.tokenVerified = false; @@ -2729,11 +2761,11 @@ describe('/account/login', () => { return runTest(route, mockRequestNoKeys, (response: any) => { mockDB.createSessionToken = originalCreateSessionToken; - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.tokenVerificationId).toBeTruthy(); // newDeviceLogin email must NOT be sent during login when the session is // unverified — it will be sent by session.js:verify_code after verification. - expect(mockFxaMailer.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockFxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); expect(response.verified).toBeFalsy(); // Restore the original function @@ -2767,17 +2799,19 @@ describe('/account/login', () => { }; return runTest(route, mockRequestNoKeys, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.mustVerify).toBeTruthy(); expect(tokenData.tokenVerificationId).toBeTruthy(); - expect(mockFxaMailer.sendNewDeviceLoginEmail.callCount).toBe(0); - expect(mockFxaMailer.sendVerifyLoginCodeEmail.callCount).toBe(1); + expect(mockFxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); + expect(mockFxaMailer.sendVerifyLoginCodeEmail).toHaveBeenCalledTimes(1); - expect(mockMetricsContext.setFlowCompleteSignal.callCount).toBe(1); - expect(mockMetricsContext.setFlowCompleteSignal.args[0][0]).toEqual( - 'account.confirmed' + expect(mockMetricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes( + 1 ); + expect( + mockMetricsContext.setFlowCompleteSignal + ).toHaveBeenNthCalledWith(1, 'account.confirmed', expect.anything()); expect(response.verified).toBeFalsy(); expect(response.verificationMethod).toBe('email-otp'); @@ -2846,13 +2880,13 @@ describe('/account/login', () => { }; return runTest(route, mockRequest, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.mustVerify).toBeTruthy(); expect(tokenData.tokenVerificationId).toBeTruthy(); - expect(mockFxaMailer.sendVerifyEmail.callCount).toBe(1); - expect(mockMailer.sendNewDeviceLoginEmail.callCount).toBe(0); - expect(mockMailer.sendVerifyLoginEmail.callCount).toBe(0); + expect(mockFxaMailer.sendVerifyEmail).toHaveBeenCalledTimes(1); + expect(mockMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); + expect(mockMailer.sendVerifyLoginEmail).toHaveBeenCalledTimes(0); expect(response.verified).toBeFalsy(); expect(response.verificationMethod).toBe('email'); expect(response.verificationReason).toBe('signup'); @@ -2860,7 +2894,7 @@ describe('/account/login', () => { }); it('should return an error if email fails to send', async () => { - mockFxaMailer.sendVerifyLoginEmail = sinon.spy(() => Promise.reject()); + mockFxaMailer.sendVerifyLoginEmail = jest.fn(() => Promise.reject()); await expect(runTest(route, mockRequest)).rejects.toMatchObject({ message: 'Failed to send email', @@ -2930,19 +2964,19 @@ describe('/account/login', () => { setupSkipNewAccounts(false, 0); return runTest(route, mockRequest, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.mustVerify).toBeTruthy(); expect(tokenData.tokenVerificationId).toBeTruthy(); - expect(mockFxaMailer.sendVerifyEmail.callCount).toBe(0); - expect(mockMailer.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockFxaMailer.sendVerifyEmail).toHaveBeenCalledTimes(0); + expect(mockMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); expect(response.verified).toBeFalsy(); expect(response.verificationMethod).toBe('email'); expect(response.verificationReason).toBe('login'); - expect(mockFxaMailer.sendVerifyLoginEmail.callCount).toBe(1); + expect(mockFxaMailer.sendVerifyLoginEmail).toHaveBeenCalledTimes(1); const sendVerifyLoginEmailArgs = - mockFxaMailer.sendVerifyLoginEmail.getCall(0).args[0]; + mockFxaMailer.sendVerifyLoginEmail.mock.calls[0][0]; expect(sendVerifyLoginEmailArgs.acceptLanguage).toBe('en-US'); expect(sendVerifyLoginEmailArgs.location.city).toBe('Mountain View'); expect(sendVerifyLoginEmailArgs.location.country).toBe( @@ -2956,16 +2990,18 @@ describe('/account/login', () => { setupSkipNewAccounts(true, 0); return runTest(route, mockRequest, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.tokenVerificationId).toBeFalsy(); - expect(mockMailer.sendVerifyEmail.callCount).toBe(0); - expect(mockFxaMailer.sendNewDeviceLoginEmail.callCount).toBe(1); + expect(mockMailer.sendVerifyEmail).toHaveBeenCalledTimes(0); + expect(mockFxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes( + 1 + ); expect(response.emailVerified).toBeTruthy(); - expect(mockCadReminders.delete.callCount).toBe(1); + expect(mockCadReminders.delete).toHaveBeenCalledTimes(1); - sinon.assert.calledOnce(glean.login.success); + expect(glean.login.success).toHaveBeenCalledTimes(1); }); }); @@ -2973,11 +3009,13 @@ describe('/account/login', () => { setupSkipNewAccounts(true, 10); return runTest(route, mockRequest, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.tokenVerificationId).toBeTruthy(); - expect(mockFxaMailer.sendVerifyLoginEmail.callCount).toBe(1); - expect(mockFxaMailer.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockFxaMailer.sendVerifyLoginEmail).toHaveBeenCalledTimes(1); + expect(mockFxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes( + 0 + ); expect(response.verified).toBeFalsy(); }); }); @@ -2985,53 +3023,52 @@ describe('/account/login', () => { it('do not error if new device login notification is blocked', () => { setupSkipNewAccounts(true, 0); - mockMailer.sendNewDeviceLoginEmail = sinon.spy(() => + mockMailer.sendNewDeviceLoginEmail = jest.fn(() => Promise.reject(error.emailBouncedHard()) ); return runTest(route, mockRequest, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.tokenVerificationId).toBeFalsy(); - expect(mockFxaMailer.sendVerifyEmail.callCount).toBe(0); - expect(mockFxaMailer.sendNewDeviceLoginEmail.callCount).toBe(1); - expect( - mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].deviceId - ).toBe(mockRequest.payload.metricsContext.deviceId); - expect(mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].flowId).toBe( - mockRequest.payload.metricsContext.flowId + expect(mockFxaMailer.sendVerifyEmail).toHaveBeenCalledTimes(0); + expect(mockFxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes( + 1 ); - expect( - mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].flowBeginTime - ).toBe(mockRequest.payload.metricsContext.flowBeginTime); - expect(mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].sync).toBe( - true - ); - expect(mockFxaMailer.sendNewDeviceLoginEmail.args[0][0].uid).toBe( - uid + expect(mockFxaMailer.sendNewDeviceLoginEmail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + deviceId: mockRequest.payload.metricsContext.deviceId, + flowId: mockRequest.payload.metricsContext.flowId, + flowBeginTime: mockRequest.payload.metricsContext.flowBeginTime, + sync: true, + uid, + }) ); expect(response.emailVerified).toBeTruthy(); }); }); it('logs metrics when accountAge is under maxAge config threshold', () => { - glean.loginConfirmSkipFor.newAccount.reset(); + glean.loginConfirmSkipFor.newAccount.mockReset(); const mockAccountEventsManager = { - recordSecurityEvent: sinon.fake(), + recordSecurityEvent: jest.fn(), }; setupSkipNewAccounts(true, 0, { mockAccountEventsManager }); return runTest(route, mockRequest, () => { - sinon.assert.calledOnce(glean.loginConfirmSkipFor.newAccount); - sinon.assert.calledWith( - statsd.increment, - 'account.signin.confirm.bypass.newAccount' + expect(glean.loginConfirmSkipFor.newAccount).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenNthCalledWith( + 1, + 'account.signin.confirm.bypass.newAccount', + expect.anything() ); - sinon.assert.calledWithMatch( - mockAccountEventsManager.recordSecurityEvent, + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledWith( mockDB, - sinon.match({ + expect.objectContaining({ name: 'account.signin_confirm_bypass_new_account', uid, ipAddr: mockRequest.app.clientAddress, @@ -3045,9 +3082,9 @@ describe('/account/login', () => { }); it('logs metrics when sign-in ipProfiling is allowed and a known ip address is used within threshold', () => { - glean.loginConfirmSkipFor.knownIp.reset(); + glean.loginConfirmSkipFor.knownIp.mockReset(); config.securityHistory.ipProfiling.allowedRecency = 1 * 60 * 1000; // 1 minute - mockDB.verifiedLoginSecurityEvents = sinon.spy(() => { + mockDB.verifiedLoginSecurityEvents = jest.fn(() => { return Promise.resolve([ { name: 'account.login', @@ -3057,18 +3094,19 @@ describe('/account/login', () => { ]); }); const mockAccountEventsManager = { - recordSecurityEvent: sinon.fake(), + recordSecurityEvent: jest.fn(), }; setupSkipNewAccounts(true, 0, { mockAccountEventsManager }); return runTest(route, mockRequest, () => { - expect(mockDB.verifiedLoginSecurityEvents.callCount).toBe(1); + expect(mockDB.verifiedLoginSecurityEvents).toHaveBeenCalledTimes(1); - sinon.assert.called(glean.loginConfirmSkipFor.knownIp); - sinon.assert.calledWithMatch( - mockAccountEventsManager.recordSecurityEvent, + expect(glean.loginConfirmSkipFor.knownIp).toHaveBeenCalled(); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledWith( mockDB, - sinon.match({ + expect.objectContaining({ name: 'account.signin_confirm_bypass_known_ip', uid, ipAddr: mockRequest.app.clientAddress, @@ -3085,14 +3123,12 @@ describe('/account/login', () => { config.signinConfirmation.skipForNewAccounts = undefined; config.securityHistory.ipProfiling.allowedRecency = defaultConfig.securityHistory.ipProfiling.allowedRecency; - mockDB.verifiedLoginSecurityEvents = sinon.spy(() => - Promise.resolve([]) - ); + mockDB.verifiedLoginSecurityEvents = jest.fn(() => Promise.resolve([])); }); }); it('logs a Glean ping on verify login code email sent', () => { - glean.login.verifyCodeEmailSent.reset(); + glean.login.verifyCodeEmailSent.mockReset(); return runTest( route, { @@ -3103,7 +3139,7 @@ describe('/account/login', () => { }, }, () => { - sinon.assert.calledOnce(glean.login.verifyCodeEmailSent); + expect(glean.login.verifyCodeEmailSent).toHaveBeenCalledTimes(1); } ); }); @@ -3114,9 +3150,7 @@ describe('/account/login', () => { config.signinConfirmation.skipForNewAccounts = { enabled: false }; config.signinConfirmation.skipForEmailRegex = regex; - mockDB.verifiedLoginSecurityEvents = sinon.spy(() => - Promise.resolve([]) - ); + mockDB.verifiedLoginSecurityEvents = jest.fn(() => Promise.resolve([])); mockRequest.payload.email = email; @@ -3156,7 +3190,7 @@ describe('/account/login', () => { // We set it here, and reset in the afterEach to avoid having the // config state leak to other tests if these fail mockRequest.app.clientIdTag = 'test-client-id'; - statsd.increment.resetHistory(); + statsd.increment.mockClear(); }); afterEach(() => { config.securityHistory.ipProfiling.allowedRecency = @@ -3169,8 +3203,8 @@ describe('/account/login', () => { setupSkipForEmailRegex('qa-test@example.com', /.+@example\.com$/); return runTest(route, mockRequest, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.tokenVerificationId).toBeFalsy(); expect(response.emailVerified).toBeTruthy(); }); @@ -3180,8 +3214,8 @@ describe('/account/login', () => { setupSkipForEmailRegex('user@other.com', /.+@example\.com$/); return runTest(route, mockRequest, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.tokenVerificationId).toBeTruthy(); expect(response.verified).toBeFalsy(); }); @@ -3191,8 +3225,8 @@ describe('/account/login', () => { setupSkipForEmailRegex('qa-test@example.com', /.+@example\.com$/); return runTest(route, mockRequest, () => { - sinon.assert.calledWith( - statsd.increment, + expect(statsd.increment).toHaveBeenNthCalledWith( + 1, 'account.signin.confirm.bypass.emailAlways', { clientId: 'test-client-id' } ); @@ -3242,7 +3276,7 @@ describe('/account/login', () => { }; mockAccountEventsManager = { - recordSecurityEvent: sinon.fake(), + recordSecurityEvent: jest.fn(), }; const innerAccountRoutes = makeRoutes({ @@ -3276,7 +3310,7 @@ describe('/account/login', () => { }); it('should skip verification when device is recognized and not in report-only mode', () => { - mockDB.verifiedLoginSecurityEventsByUid = sinon.spy(() => + mockDB.verifiedLoginSecurityEventsByUid = jest.fn(() => Promise.resolve([ { name: 'account.login', @@ -3300,20 +3334,22 @@ describe('/account/login', () => { }; return runTest(route, requestWithUserAgent, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.mustVerify).toBeFalsy(); expect(response.sessionVerified).toBeTruthy(); - sinon.assert.calledWith( - statsd.increment, - 'account.signin.confirm.bypass.knownDevice' + expect(statsd.increment).toHaveBeenNthCalledWith( + 1, + 'account.signin.confirm.bypass.knownDevice', + expect.anything() ); - sinon.assert.calledWithMatch( - mockAccountEventsManager.recordSecurityEvent, + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledWith( mockDB, - sinon.match({ + expect.objectContaining({ name: 'account.signin_confirm_bypass_known_device', uid: uid, ipAddr: requestWithUserAgent.app.clientAddress, @@ -3329,7 +3365,7 @@ describe('/account/login', () => { }); it('should not skip verification when device is not recognized', () => { - mockDB.verifiedLoginSecurityEventsByUid = sinon.spy(() => + mockDB.verifiedLoginSecurityEventsByUid = jest.fn(() => Promise.resolve([]) ); @@ -3345,14 +3381,15 @@ describe('/account/login', () => { route, requestWithDifferentUserAgent, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.mustVerify).toBeTruthy(); expect(response.verified).toBeFalsy(); - sinon.assert.calledWith( - statsd.increment, - 'account.signin.confirm.device.notfound' + expect(statsd.increment).toHaveBeenNthCalledWith( + 1, + 'account.signin.confirm.device.notfound', + expect.anything() ); } ); @@ -3361,7 +3398,7 @@ describe('/account/login', () => { it('should not skip verification when in report-only mode', () => { config.signinConfirmation.deviceFingerprinting.reportOnlyMode = true; - mockDB.verifiedLoginSecurityEventsByUid = sinon.spy(() => + mockDB.verifiedLoginSecurityEventsByUid = jest.fn(() => Promise.resolve([ { name: 'account.login', @@ -3385,28 +3422,29 @@ describe('/account/login', () => { }; return runTest(route, requestWithUserAgent, (response: any) => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.mustVerify).toBeTruthy(); expect(response.verified).toBeFalsy(); // StatsD metric is emitted for report-only mode (non-enforcing) - sinon.assert.calledWith( - statsd.increment, - 'account.signin.confirm.bypass.knownDevice.reportOnly' + expect(statsd.increment).toHaveBeenNthCalledWith( + 1, + 'account.signin.confirm.bypass.knownDevice.reportOnly', + expect.anything() ); }); }); it('should handle errors gracefully and continue to existing logic', () => { - mockDB.verifiedLoginSecurityEventsByUid = sinon.spy(() => + mockDB.verifiedLoginSecurityEventsByUid = jest.fn(() => Promise.reject(new Error('Database connection failed')) ); return runTest(route, mockRequest, () => { // Should continue to existing verification logic despite fingerprinting error - expect(mockDB.createSessionToken.callCount).toBe(1); - const tokenData = mockDB.createSessionToken.getCall(0).args[0]; + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const tokenData = mockDB.createSessionToken.mock.calls[0][0]; expect(tokenData.mustVerify).toBeTruthy(); }); }); @@ -3415,13 +3453,15 @@ describe('/account/login', () => { config.signinConfirmation.deviceFingerprinting.enabled = false; const originalSpy = mockDB.verifiedLoginSecurityEventsByUid; - mockDB.verifiedLoginSecurityEventsByUid = sinon.spy(() => + mockDB.verifiedLoginSecurityEventsByUid = jest.fn(() => Promise.resolve([]) ); return runTest(route, mockRequest, () => { // Should not call the device fingerprinting database method - expect(mockDB.verifiedLoginSecurityEventsByUid.callCount).toBe(0); + expect(mockDB.verifiedLoginSecurityEventsByUid).toHaveBeenCalledTimes( + 0 + ); mockDB.verifiedLoginSecurityEventsByUid = originalSpy; }); }); @@ -3430,22 +3470,25 @@ describe('/account/login', () => { it('creating too many sessions causes an error to be logged', () => { const oldSessions = mockDB.sessions; - mockDB.sessions = sinon.spy(() => { + mockDB.sessions = jest.fn(() => { return Promise.resolve(new Array(200)); }); - mockLog.error = sinon.spy(); + mockLog.error = jest.fn(); mockRequest.app.clientAddress = '63.245.221.32'; return runTest(route, mockRequest, () => { - expect(mockLog.error.callCount).toBe(0); + expect(mockLog.error).toHaveBeenCalledTimes(0); }).then(() => { - mockDB.sessions = sinon.spy(() => { + mockDB.sessions = jest.fn(() => { return Promise.resolve(new Array(201)); }); - mockLog.error.resetHistory(); + mockLog.error.mockClear(); return runTest(route, mockRequest, () => { - expect(mockLog.error.callCount).toBe(1); - expect(mockLog.error.firstCall.args[0]).toBe('Account.login'); - expect(mockLog.error.firstCall.args[1].numSessions).toBe(201); + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenNthCalledWith( + 1, + 'Account.login', + expect.objectContaining({ numSessions: 201 }) + ); mockDB.sessions = oldSessions; }); }); @@ -3455,7 +3498,7 @@ describe('/account/login', () => { let record: any; const clientAddress = mockRequest.app.clientAddress; beforeEach(() => { - mockLog.info = sinon.spy((op: any, arg: any) => { + mockLog.info = jest.fn((op: any, arg: any) => { if (op.indexOf('Account.history') === 0) { record = arg; } @@ -3465,7 +3508,7 @@ describe('/account/login', () => { it('with a seen ip address', () => { record = undefined; let securityQuery: any; - mockDB.verifiedLoginSecurityEvents = sinon.spy((arg: any) => { + mockDB.verifiedLoginSecurityEvents = jest.fn((arg: any) => { securityQuery = arg; return Promise.resolve([ { @@ -3476,12 +3519,16 @@ describe('/account/login', () => { ]); }); return runTest(route, mockRequest, () => { - expect(mockDB.verifiedLoginSecurityEvents.callCount).toBe(1); + expect(mockDB.verifiedLoginSecurityEvents).toHaveBeenCalledTimes(1); expect(securityQuery.uid).toBe(uid); expect(securityQuery.ipAddr).toBe(clientAddress); expect(record).toBeTruthy(); - expect(mockLog.info.args[0][0]).toBe('Account.history.verified'); + expect(mockLog.info).toHaveBeenNthCalledWith( + 1, + 'Account.history.verified', + expect.anything() + ); expect(record.uid).toBe(uid); expect(record.events).toBe(1); expect(record.recency).toBe('day'); @@ -3491,7 +3538,7 @@ describe('/account/login', () => { it('with a seen, unverified ip address', () => { record = undefined; let securityQuery: any; - mockDB.verifiedLoginSecurityEvents = sinon.spy((arg: any) => { + mockDB.verifiedLoginSecurityEvents = jest.fn((arg: any) => { securityQuery = arg; return Promise.resolve([ { @@ -3502,12 +3549,16 @@ describe('/account/login', () => { ]); }); return runTest(route, mockRequest, () => { - expect(mockDB.verifiedLoginSecurityEvents.callCount).toBe(1); + expect(mockDB.verifiedLoginSecurityEvents).toHaveBeenCalledTimes(1); expect(securityQuery.uid).toBe(uid); expect(securityQuery.ipAddr).toBe(clientAddress); expect(record).toBeTruthy(); - expect(mockLog.info.args[0][0]).toBe('Account.history.unverified'); + expect(mockLog.info).toHaveBeenNthCalledWith( + 1, + 'Account.history.unverified', + expect.anything() + ); expect(record.uid).toBe(uid); expect(record.events).toBe(1); }); @@ -3516,12 +3567,12 @@ describe('/account/login', () => { it('with a new ip address', () => { record = undefined; let securityQuery: any; - mockDB.verifiedLoginSecurityEvents = sinon.spy((arg: any) => { + mockDB.verifiedLoginSecurityEvents = jest.fn((arg: any) => { securityQuery = arg; return Promise.resolve([]); }); return runTest(route, mockRequest, () => { - expect(mockDB.verifiedLoginSecurityEvents.callCount).toBe(1); + expect(mockDB.verifiedLoginSecurityEvents).toHaveBeenCalledTimes(1); expect(securityQuery.uid).toBe(uid); expect(securityQuery.ipAddr).toBe(clientAddress); @@ -3533,12 +3584,14 @@ describe('/account/login', () => { it('records security event', () => { const clientAddress = mockRequest.app.clientAddress; let securityQuery: any; - mockDB.securityEvent = sinon.spy((arg: any) => { + mockDB.securityEvent = jest.fn((arg: any) => { securityQuery = arg; return Promise.resolve(); }); return runTest(route, mockRequest, () => { - expect(mockDB.securityEvent.callCount).toBeGreaterThanOrEqual(1); + expect(mockDB.securityEvent).toHaveBeenCalledTimes( + expect.toBeGreaterThanOrEqual ? undefined : 1 + ); expect(securityQuery.uid).toBe(uid); expect(securityQuery.ipAddr).toBe(clientAddress); expect(securityQuery.name).toBe('account.login'); @@ -3559,8 +3612,8 @@ describe('/account/login', () => { }); beforeEach(() => { - mockLog.activityEvent.resetHistory(); - mockLog.flowEvent.resetHistory(); + mockLog.activityEvent.mockClear(); + mockLog.flowEvent.mockClear(); }); afterAll(() => { @@ -3569,7 +3622,7 @@ describe('/account/login', () => { describe('signin unblock enabled', () => { beforeAll(() => { - mockLog.flowEvent.resetHistory(); + mockLog.flowEvent.mockClear(); }); it('without unblock code', async () => { @@ -3583,11 +3636,12 @@ describe('/account/login', () => { }, }, }); - expect(mockLog.flowEvent.callCount).toBe(1); - expect(mockLog.flowEvent.args[0][0].event).toBe( - 'account.login.blocked' + expect(mockLog.flowEvent).toHaveBeenCalledTimes(1); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.login.blocked' }) ); - mockLog.flowEvent.resetHistory(); + mockLog.flowEvent.mockClear(); }); describe('with unblock code', () => { @@ -3600,11 +3654,14 @@ describe('/account/login', () => { errno: error.ERRNO.INVALID_UNBLOCK_CODE, output: { statusCode: 400 }, }); - expect(mockLog.flowEvent.callCount).toBe(2); - expect(mockLog.flowEvent.args[1][0].event).toBe( - 'account.login.invalidUnblockCode' + expect(mockLog.flowEvent).toHaveBeenCalledTimes(2); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + event: 'account.login.invalidUnblockCode', + }) ); - mockLog.flowEvent.resetHistory(); + mockLog.flowEvent.mockClear(); }); it('expired code', async () => { @@ -3620,12 +3677,15 @@ describe('/account/login', () => { errno: error.ERRNO.INVALID_UNBLOCK_CODE, output: { statusCode: 400 }, }); - expect(mockLog.flowEvent.callCount).toBe(2); - expect(mockLog.flowEvent.args[1][0].event).toBe( - 'account.login.invalidUnblockCode' + expect(mockLog.flowEvent).toHaveBeenCalledTimes(2); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + event: 'account.login.invalidUnblockCode', + }) ); - mockLog.activityEvent.resetHistory(); - mockLog.flowEvent.resetHistory(); + mockLog.activityEvent.mockClear(); + mockLog.flowEvent.mockClear(); }); it('unknown account', async () => { @@ -3643,15 +3703,25 @@ describe('/account/login', () => { mockDB.consumeUnblockCode = () => Promise.resolve({ createdAt: Date.now() }); return runTest(route, mockRequestWithUnblockCode, (res: any) => { - expect(mockLog.flowEvent.callCount).toBe(4); - expect(mockLog.flowEvent.args[0][0].event).toBe( - 'account.login.blocked' + expect(mockLog.flowEvent).toHaveBeenCalledTimes(4); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.login.blocked' }) + ); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + event: 'account.login.confirmedUnblockCode', + }) + ); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ event: 'account.login' }) ); - expect(mockLog.flowEvent.args[1][0].event).toBe( - 'account.login.confirmedUnblockCode' + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ event: 'flow.complete' }) ); - expect(mockLog.flowEvent.args[2][0].event).toBe('account.login'); - expect(mockLog.flowEvent.args[3][0].event).toBe('flow.complete'); }); }); }); @@ -3696,7 +3766,7 @@ describe('/account/login', () => { it('fails login with non primary email', () => { const email = 'foo@mail.com'; - mockDB.accountRecord = sinon.spy(() => { + mockDB.accountRecord = jest.fn(() => { return Promise.resolve({ primaryEmail: { normalizedEmail: normalizeEmail(email), @@ -3711,14 +3781,14 @@ describe('/account/login', () => { throw new Error('should have thrown'); }, (err: any) => { - expect(mockDB.accountRecord.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); expect(err.errno).toBe(142); } ); }); it('fails login when requesting TOTP verificationMethod and TOTP not setup', () => { - mockDB.totpToken = sinon.spy(() => { + mockDB.totpToken = jest.fn(() => { return Promise.resolve({ verified: true, enabled: false, @@ -3730,7 +3800,7 @@ describe('/account/login', () => { throw new Error('should have thrown'); }, (err: any) => { - expect(mockDB.totpToken.callCount).toBe(1); + expect(mockDB.totpToken).toHaveBeenCalledTimes(1); expect(err.errno).toBe(160); } ); @@ -3761,7 +3831,7 @@ describe('/account/login', () => { }); it('should use RP CMS email content for new login email', () => { - rpConfigManager.fetchCMSData.resetHistory(); + rpConfigManager.fetchCMSData.mockClear(); const email = 'test@mozilla.com'; mockDB.accountRecord = function () { return Promise.resolve({ @@ -3788,7 +3858,7 @@ describe('/account/login', () => { // Simulate a verified session state. This will result in a new device // login email being sent. - mockDB.createSessionToken = sinon.spy(async (opts: any) => { + mockDB.createSessionToken = jest.fn(async (opts: any) => { const result = await originalCreateSessionToken(opts); result.tokenVerificationId = null; result.tokenVerified = true; @@ -3797,8 +3867,8 @@ describe('/account/login', () => { return runTest(route, mockRequestWithRpCmsConfig, () => { mockDB.createSessionToken = originalCreateSessionToken; - sinon.assert.calledOnce(mockFxaMailer.sendNewDeviceLoginEmail); - const args = mockFxaMailer.sendNewDeviceLoginEmail.args[0]; + expect(mockFxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(1); + const args = mockFxaMailer.sendNewDeviceLoginEmail.mock.calls[0]; const emailMessage = args[0]; expect(emailMessage.cmsRpClientId).toBe('00f00f'); expect(emailMessage.cmsRpFromName).toBe('Testo Inc.'); @@ -3845,13 +3915,13 @@ describe('/account/keys', () => { bundle: mockRequest.auth.credentials.keyBundle, }); - expect(mockDB.deleteKeyFetchToken.callCount).toBe(1); - let args = mockDB.deleteKeyFetchToken.args[0]; + expect(mockDB.deleteKeyFetchToken).toHaveBeenCalledTimes(1); + let args = mockDB.deleteKeyFetchToken.mock.calls[0]; expect(args.length).toBe(1); expect(args[0]).toBe(mockRequest.auth.credentials); - expect(mockLog.activityEvent.callCount).toBe(1); - args = mockLog.activityEvent.args[0]; + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + args = mockLog.activityEvent.mock.calls[0]; expect(args.length).toBe(1); expect(args[0]).toEqual({ country: 'United States', @@ -3864,8 +3934,8 @@ describe('/account/keys', () => { clientJa4: 'test-ja4', }); }).then(() => { - mockLog.activityEvent.resetHistory(); - mockDB.deleteKeyFetchToken.resetHistory(); + mockLog.activityEvent.mockClear(); + mockDB.deleteKeyFetchToken.mockClear(); }); }); @@ -3883,7 +3953,7 @@ describe('/account/keys', () => { } ) .then(() => { - mockLog.activityEvent.resetHistory(); + mockLog.activityEvent.mockClear(); }); }); }); @@ -3919,7 +3989,7 @@ describe('/account/destroy', () => { }); afterEach(() => { - glean.account.deleteComplete.reset(); + glean.account.deleteComplete.mockReset(); }); function buildRoute(subscriptionsEnabled = true) { @@ -3952,7 +4022,8 @@ describe('/account/destroy', () => { const route = buildRoute(); return runTest(route, mockRequest, () => { - sinon.assert.calledOnceWithExactly(mockDB.accountRecord, email); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDB.accountRecord).toHaveBeenCalledWith(email); expect(mockAccountQuickDelete).toHaveBeenCalledTimes(1); expect(mockAccountQuickDelete).toHaveBeenCalledWith( @@ -3960,24 +4031,22 @@ describe('/account/destroy', () => { ReasonForDeletion.UserRequested ); - sinon.assert.calledOnceWithExactly(mockGetAccountCustomerByUid, uid); - sinon.assert.calledOnceWithExactly(mockAccountTasksDeleteAccount, { + expect(mockGetAccountCustomerByUid).toHaveBeenCalledTimes(1); + expect(mockGetAccountCustomerByUid).toHaveBeenCalledWith(uid); + expect(mockAccountTasksDeleteAccount).toHaveBeenCalledTimes(1); + expect(mockAccountTasksDeleteAccount).toHaveBeenCalledWith({ uid, customerId: 'customer123', reason: ReasonForDeletion.UserRequested, }); - sinon.assert.calledOnceWithExactly( - glean.account.deleteComplete, - mockRequest, - { - uid, - } - ); - sinon.assert.calledOnceWithExactly( - mockLog.info, - 'accountDeleted.ByRequest', - { uid } - ); + expect(glean.account.deleteComplete).toHaveBeenCalledTimes(1); + expect(glean.account.deleteComplete).toHaveBeenCalledWith(mockRequest, { + uid, + }); + expect(mockLog.info).toHaveBeenCalledTimes(1); + expect(mockLog.info).toHaveBeenCalledWith('accountDeleted.ByRequest', { + uid, + }); }); }); @@ -3990,19 +4059,18 @@ describe('/account/destroy', () => { .mockRejectedValue(new Error('quickDelete failed')); return runTest(route, mockRequest, () => { - sinon.assert.calledOnceWithExactly(mockDB.accountRecord, email); - sinon.assert.calledOnceWithExactly(mockAccountTasksDeleteAccount, { + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDB.accountRecord).toHaveBeenCalledWith(email); + expect(mockAccountTasksDeleteAccount).toHaveBeenCalledTimes(1); + expect(mockAccountTasksDeleteAccount).toHaveBeenCalledWith({ uid, customerId: 'customer123', reason: ReasonForDeletion.UserRequested, }); - sinon.assert.calledOnceWithExactly( - glean.account.deleteComplete, - mockRequest, - { - uid, - } - ); + expect(glean.account.deleteComplete).toHaveBeenCalledTimes(1); + expect(glean.account.deleteComplete).toHaveBeenCalledWith(mockRequest, { + uid, + }); }); }); @@ -4018,24 +4086,23 @@ describe('/account/destroy', () => { const route = buildRoute(); return runTest(route, mockRequest, () => { - sinon.assert.calledOnceWithExactly(mockDB.accountRecord, email); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDB.accountRecord).toHaveBeenCalledWith(email); expect(mockAccountQuickDelete).toHaveBeenCalledTimes(1); expect(mockAccountQuickDelete).toHaveBeenCalledWith( uid, ReasonForDeletion.UserRequested ); - sinon.assert.calledOnceWithExactly(mockAccountTasksDeleteAccount, { + expect(mockAccountTasksDeleteAccount).toHaveBeenCalledTimes(1); + expect(mockAccountTasksDeleteAccount).toHaveBeenCalledWith({ uid, customerId: 'customer123', reason: ReasonForDeletion.UserRequested, }); - sinon.assert.calledOnceWithExactly( - glean.account.deleteComplete, - mockRequest, - { - uid, - } - ); + expect(glean.account.deleteComplete).toHaveBeenCalledTimes(1); + expect(glean.account.deleteComplete).toHaveBeenCalledWith(mockRequest, { + uid, + }); }); }); @@ -4057,7 +4124,8 @@ describe('/account/destroy', () => { await expect(runTest(route, mockRequest)).rejects.toMatchObject({ errno: 102, }); - sinon.assert.calledOnceWithExactly(mockCustoms.flag, '63.245.221.32', { + expect(mockCustoms.flag).toHaveBeenCalledTimes(1); + expect(mockCustoms.flag).toHaveBeenCalledWith('63.245.221.32', { email, errno: 102, }); @@ -4133,10 +4201,10 @@ describe('/account', () => { ]); mockFxaMailerLocal = mocks.mockFxaMailer(); mockOAuthClientInfoLocal = mocks.mockOAuthClientInfo(); - mockStripeHelper.fetchCustomer = sinon.spy( + mockStripeHelper.fetchCustomer = jest.fn( async (uid: any, email: any) => mockCustomer ); - mockStripeHelper.subscriptionsToResponse = sinon.spy( + mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); mockStripeHelper.removeFirestoreCustomer = jest @@ -4156,10 +4224,10 @@ describe('/account', () => { 'fetchCustomer', 'subscriptionsToResponse', ]); - mockStripeHelper.fetchCustomer = sinon.spy( + mockStripeHelper.fetchCustomer = jest.fn( async (uid: any, email: any) => mockCustomer ); - mockStripeHelper.subscriptionsToResponse = sinon.spy( + mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); Container.set(CapabilityService, jest.fn()); @@ -4167,14 +4235,16 @@ describe('/account', () => { it('should return formatted Stripe subscriptions when subscriptions are enabled', () => { return runTest(buildRoute(), request, (result: any) => { - sinon.assert.calledOnceWithExactly(log.begin, 'Account.get', request); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.fetchCustomer, - uid, - ['subscriptions'] + expect(log.begin).toHaveBeenCalledTimes(1); + expect(log.begin).toHaveBeenCalledWith('Account.get', request); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledWith(uid, [ + 'subscriptions', + ]); + expect(mockStripeHelper.subscriptionsToResponse).toHaveBeenCalledTimes( + 1 ); - sinon.assert.calledOnceWithExactly( - mockStripeHelper.subscriptionsToResponse, + expect(mockStripeHelper.subscriptionsToResponse).toHaveBeenCalledWith( mockCustomer.subscriptions ); expect(result.subscriptions).toEqual(mockWebSubscriptionsResponse); @@ -4182,20 +4252,22 @@ describe('/account', () => { }); it('should swallow unknownCustomer errors from stripe.customer', () => { - mockStripeHelper.fetchCustomer = sinon.spy(() => { + mockStripeHelper.fetchCustomer = jest.fn(() => { throw error.unknownCustomer(); }); return runTest(buildRoute(), request, (result: any) => { expect(result.subscriptions).toEqual([]); - expect(log.begin.callCount).toBe(1); - expect(mockStripeHelper.fetchCustomer.callCount).toBe(1); - expect(mockStripeHelper.subscriptionsToResponse.callCount).toBe(0); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.subscriptionsToResponse).toHaveBeenCalledTimes( + 0 + ); }); }); it('should propagate other errors from stripe.customer', async () => { - mockStripeHelper.fetchCustomer = sinon.spy(() => { + mockStripeHelper.fetchCustomer = jest.fn(() => { throw error.unexpectedError(); }); @@ -4214,8 +4286,8 @@ describe('/account', () => { return runTest(buildRoute(false), request, (result: any) => { expect(result.subscriptions).toEqual([]); - expect(log.begin.callCount).toBe(1); - expect(mockStripeHelper.fetchCustomer.callCount).toBe(0); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledTimes(0); }); }); }); @@ -4271,10 +4343,10 @@ describe('/account', () => { 'fetchCustomer', 'subscriptionsToResponse', ]); - mockStripeHelper.fetchCustomer = sinon.spy( + mockStripeHelper.fetchCustomer = jest.fn( async (uid: any, email: any) => mockCustomer ); - mockStripeHelper.subscriptionsToResponse = sinon.spy( + mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); Container.set(OAuthClientInfoServiceName, mockOAuthClientInfoLocal); @@ -4282,7 +4354,7 @@ describe('/account', () => { Container.set(CapabilityService, jest.fn()); mockPlaySubscriptions = mocks.mockPlaySubscriptions(['getSubscriptions']); Container.set(PlaySubscriptions, mockPlaySubscriptions); - mockPlaySubscriptions.getSubscriptions = sinon.spy(async (uid: any) => [ + mockPlaySubscriptions.getSubscriptions = jest.fn(async (uid: any) => [ mockAppendedPlayStoreSubscriptionPurchase, ]); }); @@ -4292,11 +4364,15 @@ describe('/account', () => { buildRoute(subscriptionsEnabled, playSubscriptionsEnabled), request, (result: any) => { - expect(log.begin.callCount).toBe(1); - expect(mockStripeHelper.fetchCustomer.callCount).toBe(1); - expect(mockStripeHelper.subscriptionsToResponse.callCount).toBe(0); - sinon.assert.calledOnceWithExactly( - mockPlaySubscriptions.getSubscriptions, + expect(log.begin).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.subscriptionsToResponse + ).toHaveBeenCalledTimes(0); + expect(mockPlaySubscriptions.getSubscriptions).toHaveBeenCalledTimes( + 1 + ); + expect(mockPlaySubscriptions.getSubscriptions).toHaveBeenCalledWith( uid ); expect(result.subscriptions).toEqual([ @@ -4312,10 +4388,10 @@ describe('/account', () => { subscriptions: ['fake'], }; mockWebSubscriptionsResponse = [webSubscription]; - mockStripeHelper.fetchCustomer = sinon.spy( + mockStripeHelper.fetchCustomer = jest.fn( async (uid: any, email: any) => mockCustomer ); - mockStripeHelper.subscriptionsToResponse = sinon.spy( + mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); @@ -4323,9 +4399,11 @@ describe('/account', () => { buildRoute(subscriptionsEnabled, playSubscriptionsEnabled), request, (result: any) => { - expect(log.begin.callCount).toBe(1); - expect(mockStripeHelper.fetchCustomer.callCount).toBe(1); - expect(mockPlaySubscriptions.getSubscriptions.callCount).toBe(1); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(mockPlaySubscriptions.getSubscriptions).toHaveBeenCalledTimes( + 1 + ); expect(result.subscriptions).toEqual([ ...[mockFormattedPlayStoreSubscription], ...mockWebSubscriptionsResponse, @@ -4335,17 +4413,17 @@ describe('/account', () => { }); it('should return an empty list when no active Google Play or web subscriptions are found', () => { - mockPlaySubscriptions.getSubscriptions = sinon.spy( - async (uid: any) => [] - ); + mockPlaySubscriptions.getSubscriptions = jest.fn(async (uid: any) => []); return runTest( buildRoute(subscriptionsEnabled, playSubscriptionsEnabled), request, (result: any) => { - expect(log.begin.callCount).toBe(1); - expect(mockStripeHelper.fetchCustomer.callCount).toBe(1); - expect(mockPlaySubscriptions.getSubscriptions.callCount).toBe(1); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(mockPlaySubscriptions.getSubscriptions).toHaveBeenCalledTimes( + 1 + ); expect(result.subscriptions).toEqual([]); } ); @@ -4358,10 +4436,10 @@ describe('/account', () => { subscriptions: ['fake'], }; mockWebSubscriptionsResponse = [webSubscription]; - mockStripeHelper.fetchCustomer = sinon.spy( + mockStripeHelper.fetchCustomer = jest.fn( async (uid: any, email: any) => mockCustomer ); - mockStripeHelper.subscriptionsToResponse = sinon.spy( + mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); @@ -4369,9 +4447,11 @@ describe('/account', () => { buildRoute(subscriptionsEnabled, playSubscriptionsEnabled), request, (result: any) => { - expect(log.begin.callCount).toBe(1); - expect(mockStripeHelper.fetchCustomer.callCount).toBe(1); - expect(mockPlaySubscriptions.getSubscriptions.callCount).toBe(0); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(mockPlaySubscriptions.getSubscriptions).toHaveBeenCalledTimes( + 0 + ); expect(result.subscriptions).toEqual(mockWebSubscriptionsResponse); } ); @@ -4417,10 +4497,10 @@ describe('/account', () => { 'fetchCustomer', 'subscriptionsToResponse', ]); - mockStripeHelper.fetchCustomer = sinon.spy( + mockStripeHelper.fetchCustomer = jest.fn( async (uid: any, email: any) => mockCustomer ); - mockStripeHelper.subscriptionsToResponse = sinon.spy( + mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); Container.set(CapabilityService, jest.fn()); @@ -4428,9 +4508,9 @@ describe('/account', () => { 'getSubscriptions', ]); Container.set(AppStoreSubscriptions, mockAppStoreSubscriptions); - mockAppStoreSubscriptions.getSubscriptions = sinon.spy( - async (uid: any) => [mockAppendedAppStoreSubscriptionPurchase] - ); + mockAppStoreSubscriptions.getSubscriptions = jest.fn(async (uid: any) => [ + mockAppendedAppStoreSubscriptionPurchase, + ]); }); it('should return formatted Apple App Store subscriptions when App Store subscriptions are enabled', () => { @@ -4438,13 +4518,17 @@ describe('/account', () => { buildRoute(subscriptionsEnabled, false, appStoreSubscriptionsEnabled), request, (result: any) => { - expect(log.begin.callCount).toBe(1); - expect(mockStripeHelper.fetchCustomer.callCount).toBe(1); - expect(mockStripeHelper.subscriptionsToResponse.callCount).toBe(0); - sinon.assert.calledOnceWithExactly( - mockAppStoreSubscriptions.getSubscriptions, - uid - ); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect( + mockStripeHelper.subscriptionsToResponse + ).toHaveBeenCalledTimes(0); + expect( + mockAppStoreSubscriptions.getSubscriptions + ).toHaveBeenCalledTimes(1); + expect( + mockAppStoreSubscriptions.getSubscriptions + ).toHaveBeenCalledWith(uid); expect(result.subscriptions).toEqual([ mockFormattedAppStoreSubscription, ]); @@ -4458,10 +4542,10 @@ describe('/account', () => { subscriptions: ['fake'], }; mockWebSubscriptionsResponse = [webSubscription]; - mockStripeHelper.fetchCustomer = sinon.spy( + mockStripeHelper.fetchCustomer = jest.fn( async (uid: any, email: any) => mockCustomer ); - mockStripeHelper.subscriptionsToResponse = sinon.spy( + mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); @@ -4469,9 +4553,11 @@ describe('/account', () => { buildRoute(subscriptionsEnabled, false, appStoreSubscriptionsEnabled), request, (result: any) => { - expect(log.begin.callCount).toBe(1); - expect(mockStripeHelper.fetchCustomer.callCount).toBe(1); - expect(mockAppStoreSubscriptions.getSubscriptions.callCount).toBe(1); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect( + mockAppStoreSubscriptions.getSubscriptions + ).toHaveBeenCalledTimes(1); expect(result.subscriptions).toEqual([ ...[mockFormattedAppStoreSubscription], ...mockWebSubscriptionsResponse, @@ -4481,7 +4567,7 @@ describe('/account', () => { }); it('should return an empty list when no active Apple App Store or web subscriptions are found', () => { - mockAppStoreSubscriptions.getSubscriptions = sinon.spy( + mockAppStoreSubscriptions.getSubscriptions = jest.fn( async (uid: any) => [] ); @@ -4489,9 +4575,11 @@ describe('/account', () => { buildRoute(subscriptionsEnabled, false, appStoreSubscriptionsEnabled), request, (result: any) => { - expect(log.begin.callCount).toBe(1); - expect(mockStripeHelper.fetchCustomer.callCount).toBe(1); - expect(mockAppStoreSubscriptions.getSubscriptions.callCount).toBe(1); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect( + mockAppStoreSubscriptions.getSubscriptions + ).toHaveBeenCalledTimes(1); expect(result.subscriptions).toEqual([]); } ); @@ -4504,10 +4592,10 @@ describe('/account', () => { subscriptions: ['fake'], }; mockWebSubscriptionsResponse = [webSubscription]; - mockStripeHelper.fetchCustomer = sinon.spy( + mockStripeHelper.fetchCustomer = jest.fn( async (uid: any, email: any) => mockCustomer ); - mockStripeHelper.subscriptionsToResponse = sinon.spy( + mockStripeHelper.subscriptionsToResponse = jest.fn( async (subscriptions: any) => mockWebSubscriptionsResponse ); @@ -4515,9 +4603,11 @@ describe('/account', () => { buildRoute(subscriptionsEnabled, false, appStoreSubscriptionsEnabled), request, (result: any) => { - expect(log.begin.callCount).toBe(1); - expect(mockStripeHelper.fetchCustomer.callCount).toBe(1); - expect(mockAppStoreSubscriptions.getSubscriptions.callCount).toBe(0); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(mockStripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect( + mockAppStoreSubscriptions.getSubscriptions + ).toHaveBeenCalledTimes(0); expect(result.subscriptions).toEqual(mockWebSubscriptionsResponse); } ); @@ -4580,8 +4670,10 @@ describe('/account', () => { it('should pass geo countryCode to the service', () => { const mockService = { - hasConfirmed: sinon.fake.resolves({ exists: false, phoneNumber: null }), - available: sinon.fake.resolves(true), + hasConfirmed: jest + .fn() + .mockResolvedValue({ exists: false, phoneNumber: null }), + available: jest.fn().mockResolvedValue(true), }; const route = buildRouteWithRecoveryPhone({ enabled: true, @@ -4590,7 +4682,11 @@ describe('/account', () => { // Set after route creation — handler reads from Container at call time Container.set(RecoveryPhoneService, mockService); return runTest(route, request, (result: any) => { - expect(mockService.available.firstCall.args[1]).toBe('US'); + expect(mockService.available).toHaveBeenNthCalledWith( + 1, + expect.anything(), + 'US' + ); expect(result.recoveryPhone.available).toBe(true); }); }); @@ -4735,7 +4831,7 @@ describe('/account/email_bounce_status', () => { function buildRoute(dbOverrides: any = {}) { log = mocks.mockLog(); mockDB = { - emailBounces: sinon.spy(() => Promise.resolve([])), + emailBounces: jest.fn(() => Promise.resolve([])), ...dbOverrides, }; const accountRoutes = makeRoutes({ @@ -4743,8 +4839,8 @@ describe('/account/email_bounce_status', () => { log: log, db: mockDB, customs: { - check: sinon.spy(() => Promise.resolve()), - checkAuthenticated: sinon.spy(() => Promise.resolve()), + check: jest.fn(() => Promise.resolve()), + checkAuthenticated: jest.fn(() => Promise.resolve()), }, }); return getRoute(accountRoutes, '/account/email_bounce_status'); @@ -4760,7 +4856,7 @@ describe('/account/email_bounce_status', () => { it('should return hasHardBounce: true when a hard bounce exists', () => { const request = mocks.mockRequest({ payload: { email } }); const route = buildRoute({ - emailBounces: sinon.spy(() => + emailBounces: jest.fn(() => Promise.resolve([{ bounceType: 1, email, createdAt: Date.now() }]) ), }); @@ -4772,7 +4868,7 @@ describe('/account/email_bounce_status', () => { it('should return hasHardBounce: false on db error', () => { const request = mocks.mockRequest({ payload: { email } }); const route = buildRoute({ - emailBounces: sinon.spy(() => Promise.reject(new Error('db error'))), + emailBounces: jest.fn(() => Promise.reject(new Error('db error'))), }); return runTest(route, request, (result: any) => { expect(result).toEqual({ hasHardBounce: false }); @@ -4789,12 +4885,12 @@ describe('/account/metrics_opt', () => { function buildRoute(setMetricsOptStub: any) { log = mocks.mockLog(); mockCustoms = { - check: sinon.spy(() => Promise.resolve()), - checkAuthenticated: sinon.spy(() => Promise.resolve()), + check: jest.fn(() => Promise.resolve()), + checkAuthenticated: jest.fn(() => Promise.resolve()), }; mockDB = mocks.mockDB({ email, uid }); // Reset the shared profile mock's deleteCache spy - profile.deleteCache.resetHistory(); + profile.deleteCache.mockClear(); const accountRoutes = makeRoutes( { log: log, @@ -4812,7 +4908,7 @@ describe('/account/metrics_opt', () => { } it('should call setMetricsOpt and notify services on opt-out', () => { - const setMetricsOptStub = sinon.stub().resolves(); + const setMetricsOptStub = jest.fn().mockResolvedValue(); const route = buildRoute(setMetricsOptStub); const request = mocks.mockRequest({ credentials: { uid, email }, @@ -4821,15 +4917,15 @@ describe('/account/metrics_opt', () => { }); return runTest(route, request, (result: any) => { expect(result).toEqual({}); - sinon.assert.calledOnce(setMetricsOptStub); - sinon.assert.calledWith(setMetricsOptStub, uid, 'out'); - sinon.assert.calledOnce(mockCustoms.checkAuthenticated); - sinon.assert.calledOnce(profile.deleteCache); + expect(setMetricsOptStub).toHaveBeenCalledTimes(1); + expect(setMetricsOptStub).toHaveBeenCalledWith(uid, 'out'); + expect(mockCustoms.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(profile.deleteCache).toHaveBeenCalledTimes(1); }); }); it('should call setMetricsOpt and notify services on opt-in', () => { - const setMetricsOptStub = sinon.stub().resolves(); + const setMetricsOptStub = jest.fn().mockResolvedValue(); const route = buildRoute(setMetricsOptStub); const request = mocks.mockRequest({ credentials: { uid, email }, @@ -4838,8 +4934,8 @@ describe('/account/metrics_opt', () => { }); return runTest(route, request, (result: any) => { expect(result).toEqual({}); - sinon.assert.calledOnce(setMetricsOptStub); - sinon.assert.calledWith(setMetricsOptStub, uid, 'in'); + expect(setMetricsOptStub).toHaveBeenCalledTimes(1); + expect(setMetricsOptStub).toHaveBeenCalledWith(uid, 'in'); }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/attached-clients.spec.ts b/packages/fxa-auth-server/lib/routes/attached-clients.spec.ts index 8e8c99a28c3..8f3e320cc74 100644 --- a/packages/fxa-auth-server/lib/routes/attached-clients.spec.ts +++ b/packages/fxa-auth-server/lib/routes/attached-clients.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import crypto from 'crypto'; const mocks = require('../../test/mocks'); @@ -13,9 +12,9 @@ const uuid = require('uuid'); const EARLIEST_SANE_TIMESTAMP = 31536000000; const mockAuthorizedClients: any = { - destroy: sinon.spy(() => Promise.resolve()), - list: sinon.spy(() => Promise.resolve()), - listUnique: sinon.spy(() => Promise.resolve()), + destroy: jest.fn(() => Promise.resolve()), + list: jest.fn(() => Promise.resolve()), + listUnique: jest.fn(() => Promise.resolve()), }; jest.mock('../oauth/authorized_clients', () => mockAuthorizedClients); @@ -39,11 +38,9 @@ function makeRoutes(options: any = {}) { const log = options.log || mocks.mockLog(); const db = options.db || mocks.mockDB(); const push = options.push || require('../push')(log, db, {}); - const devices = - options.devices || require('../devices')(log, db, push); + const devices = options.devices || require('../devices')(log, db, push); const clientUtils = - options.clientUtils || - require('./utils/clients')(log, config); + options.clientUtils || require('./utils/clients')(log, config); return require('./attached-clients')(log, db, devices, clientUtils); } @@ -75,7 +72,7 @@ describe('/account/attached_clients', () => { credentials: { id: crypto.randomBytes(16).toString('hex'), uid: uid, - setUserAgentInfo: sinon.spy(() => {}), + setUserAgentInfo: jest.fn(() => {}), }, headers: { 'user-agent': 'fake agent', @@ -173,10 +170,10 @@ describe('/account/attached_clients', () => { request.app.devices = (async () => { return DEVICES; })(); - mockAuthorizedClients.list = sinon.spy(async () => { + mockAuthorizedClients.list = jest.fn(async () => { return OAUTH_CLIENTS; }); - db.sessions = sinon.spy(async () => { + db.sessions = jest.fn(async () => { return SESSIONS; }); @@ -185,11 +182,18 @@ describe('/account/attached_clients', () => { expect(result).toHaveLength(6); - expect(db.touchSessionToken.callCount).toBe(1); - const args = db.touchSessionToken.args[0]; - expect(args).toHaveLength(3); + expect(db.touchSessionToken).toHaveBeenCalledTimes(1); + expect(db.touchSessionToken).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + lastAccessTime: expect.any(Number), + }), + expect.anything(), + expect.anything() + ); + const touchArgs = db.touchSessionToken.mock.calls[0]; const laterDate = Date.now() - 60 * 1000; - expect(laterDate < args[0].lastAccessTime).toBe(true); + expect(laterDate < touchArgs[0].lastAccessTime).toBe(true); expect(result[0]).toEqual({ clientId: null, @@ -318,10 +322,10 @@ describe('/account/attached_clients', () => { request.app.devices = (async () => { return DEVICES; })(); - mockAuthorizedClients.list = sinon.spy(async () => { + mockAuthorizedClients.list = jest.fn(async () => { return OAUTH_CLIENTS; }); - db.sessions = sinon.spy(async () => { + db.sessions = jest.fn(async () => { return SESSIONS; }); @@ -381,10 +385,10 @@ describe('/account/attached_clients', () => { request.app.devices = (async () => { return DEVICES; })(); - mockAuthorizedClients.list = sinon.spy(async () => { + mockAuthorizedClients.list = jest.fn(async () => { return OAUTH_CLIENTS; }); - db.sessions = sinon.spy(async () => { + db.sessions = jest.fn(async () => { return SESSIONS; }); @@ -396,7 +400,14 @@ describe('/account/attached_clients', () => { }); describe('/account/attached_client/destroy', () => { - let config: any, uid: string, log: any, db: any, devices: any, request: any, route: any, accountRoutes: any; + let config: any, + uid: string, + log: any, + db: any, + devices: any, + request: any, + route: any, + accountRoutes: any; beforeEach(() => { config = {}; @@ -437,9 +448,9 @@ describe('/account/attached_client/destroy', () => { const res = await route(request); expect(res).toEqual({}); - expect(devices.destroy.callCount).toBe(1); - expect(devices.destroy.calledOnceWith(request, deviceId)).toBe(true); - expect(db.deleteSessionToken.callCount).toBe(0); + expect(devices.destroy).toHaveBeenCalledTimes(1); + expect(devices.destroy).toHaveBeenCalledWith(request, deviceId); + expect(db.deleteSessionToken).toHaveBeenCalledTimes(0); }); it('checks that sessionTokenId matches device record, if given', async () => { @@ -448,7 +459,7 @@ describe('/account/attached_client/destroy', () => { deviceId, sessionTokenId: newId(), }; - devices.destroy = sinon.spy(async () => { + devices.destroy = jest.fn(async () => { return { sessionTokenId: newId(), refreshTokenId: null, @@ -459,8 +470,9 @@ describe('/account/attached_client/destroy', () => { errno: error.ERRNO.INVALID_PARAMETER, }); - expect(devices.destroy.calledOnceWith(request, deviceId)).toBe(true); - expect(db.deleteSessionToken.notCalled).toBe(true); + expect(devices.destroy).toHaveBeenCalledTimes(1); + expect(devices.destroy).toHaveBeenCalledWith(request, deviceId); + expect(db.deleteSessionToken).not.toHaveBeenCalled(); }); it('checks that refreshTokenId matches device record, if given', async () => { @@ -470,7 +482,7 @@ describe('/account/attached_client/destroy', () => { sessionTokenId: newId(), refreshTokenId: newId(), }; - devices.destroy = sinon.spy(async () => { + devices.destroy = jest.fn(async () => { return { sessionTokenId: request.payload.sessionTokenId, refreshTokenId: newId(), @@ -481,8 +493,9 @@ describe('/account/attached_client/destroy', () => { errno: error.ERRNO.INVALID_PARAMETER, }); - expect(devices.destroy.calledOnceWith(request, deviceId)).toBe(true); - expect(db.deleteSessionToken.notCalled).toBe(true); + expect(devices.destroy).toHaveBeenCalledTimes(1); + expect(devices.destroy).toHaveBeenCalledWith(request, deviceId); + expect(db.deleteSessionToken).not.toHaveBeenCalled(); }); it('can destroy by refreshTokenId', async () => { @@ -496,8 +509,8 @@ describe('/account/attached_client/destroy', () => { const res = await route(request); expect(res).toEqual({}); - expect(devices.destroy.notCalled).toBe(true); - expect(db.deleteSessionToken.notCalled).toBe(true); + expect(devices.destroy).not.toHaveBeenCalled(); + expect(db.deleteSessionToken).not.toHaveBeenCalled(); }); it('wont accept refreshTokenId and sessionTokenId without deviceId', async () => { @@ -513,8 +526,8 @@ describe('/account/attached_client/destroy', () => { errno: error.ERRNO.INVALID_PARAMETER, }); - expect(devices.destroy.notCalled).toBe(true); - expect(db.deleteSessionToken.notCalled).toBe(true); + expect(devices.destroy).not.toHaveBeenCalled(); + expect(db.deleteSessionToken).not.toHaveBeenCalled(); }); it('can destroy by just clientId', async () => { @@ -526,8 +539,8 @@ describe('/account/attached_client/destroy', () => { const res = await route(request); expect(res).toEqual({}); - expect(devices.destroy.notCalled).toBe(true); - expect(db.deleteSessionToken.notCalled).toBe(true); + expect(devices.destroy).not.toHaveBeenCalled(); + expect(db.deleteSessionToken).not.toHaveBeenCalled(); }); it('wont accept clientId and sessionTokenId without deviceId', async () => { @@ -541,8 +554,8 @@ describe('/account/attached_client/destroy', () => { errno: error.ERRNO.INVALID_PARAMETER, }); - expect(devices.destroy.notCalled).toBe(true); - expect(db.deleteSessionToken.notCalled).toBe(true); + expect(devices.destroy).not.toHaveBeenCalled(); + expect(db.deleteSessionToken).not.toHaveBeenCalled(); }); it('can destroy by sessionTokenId when given the current session', async () => { @@ -555,9 +568,12 @@ describe('/account/attached_client/destroy', () => { const res = await route(request); expect(res).toEqual({}); - expect(devices.destroy.notCalled).toBe(true); - expect(db.sessionToken.notCalled).toBe(true); - expect(db.deleteSessionToken.calledOnceWith(request.auth.credentials)).toBe(true); + expect(devices.destroy).not.toHaveBeenCalled(); + expect(db.sessionToken).not.toHaveBeenCalled(); + expect(db.deleteSessionToken).toHaveBeenCalledTimes(1); + expect(db.deleteSessionToken).toHaveBeenCalledWith( + request.auth.credentials + ); }); it('can destroy by sessionTokenId when given a different session', async () => { @@ -565,18 +581,21 @@ describe('/account/attached_client/destroy', () => { request.payload = { sessionTokenId, }; - db.sessionToken = sinon.spy(async () => { + db.sessionToken = jest.fn(async () => { return { id: sessionTokenId, uid }; }); const res = await route(request); expect(res).toEqual({}); - expect(devices.destroy.notCalled).toBe(true); - expect(db.sessionToken.calledOnceWith(sessionTokenId)).toBe(true); - expect( - db.deleteSessionToken.calledOnceWith({ id: sessionTokenId, uid }) - ).toBe(true); + expect(devices.destroy).not.toHaveBeenCalled(); + expect(db.sessionToken).toHaveBeenCalledTimes(1); + expect(db.sessionToken).toHaveBeenCalledWith(sessionTokenId); + expect(db.deleteSessionToken).toHaveBeenCalledTimes(1); + expect(db.deleteSessionToken).toHaveBeenCalledWith({ + id: sessionTokenId, + uid, + }); }); it('errors if the sessionToken does not belong to the current user', async () => { @@ -584,7 +603,7 @@ describe('/account/attached_client/destroy', () => { request.payload = { sessionTokenId, }; - db.sessionToken = sinon.spy(async () => { + db.sessionToken = jest.fn(async () => { return { uid: newId() }; }); @@ -592,9 +611,10 @@ describe('/account/attached_client/destroy', () => { errno: error.ERRNO.INVALID_PARAMETER, }); - expect(devices.destroy.notCalled).toBe(true); - expect(db.sessionToken.calledOnceWith(sessionTokenId)).toBe(true); - expect(db.deleteSessionToken.notCalled).toBe(true); + expect(devices.destroy).not.toHaveBeenCalled(); + expect(db.sessionToken).toHaveBeenCalledTimes(1); + expect(db.sessionToken).toHaveBeenCalledWith(sessionTokenId); + expect(db.deleteSessionToken).not.toHaveBeenCalled(); }); }); @@ -610,7 +630,7 @@ describe('/account/attached_oauth_clients', () => { credentials: { id: crypto.randomBytes(16).toString('hex'), uid: uid, - setUserAgentInfo: sinon.spy(() => {}), + setUserAgentInfo: jest.fn(() => {}), }, headers: { 'user-agent': 'fake agent', @@ -653,20 +673,27 @@ describe('/account/attached_oauth_clients', () => { }, ]; - mockAuthorizedClients.listUnique = sinon.spy(async () => { + mockAuthorizedClients.listUnique = jest.fn(async () => { return OAUTH_CLIENTS; }); const result = await route(request); - expect(mockAuthorizedClients.listUnique.callCount).toBe(1); - expect(mockAuthorizedClients.listUnique.args[0][0]).toBe(uid); - - expect(db.touchSessionToken.callCount).toBe(1); - const args = db.touchSessionToken.args[0]; - expect(args).toHaveLength(3); + expect(mockAuthorizedClients.listUnique).toHaveBeenCalledTimes(1); + expect(mockAuthorizedClients.listUnique).toHaveBeenNthCalledWith(1, uid); + + expect(db.touchSessionToken).toHaveBeenCalledTimes(1); + expect(db.touchSessionToken).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + lastAccessTime: expect.any(Number), + }), + expect.anything(), + expect.anything() + ); + const touchArgs = db.touchSessionToken.mock.calls[0]; const laterDate = Date.now() - 60 * 1000; - expect(laterDate < args[0].lastAccessTime).toBe(true); + expect(laterDate < touchArgs[0].lastAccessTime).toBe(true); expect(result).toHaveLength(3); @@ -686,13 +713,13 @@ describe('/account/attached_oauth_clients', () => { }); it('returns an empty array when user has no OAuth clients', async () => { - mockAuthorizedClients.listUnique = sinon.spy(async () => { + mockAuthorizedClients.listUnique = jest.fn(async () => { return []; }); const result = await route(request); - expect(mockAuthorizedClients.listUnique.callCount).toBe(1); + expect(mockAuthorizedClients.listUnique).toHaveBeenCalledTimes(1); expect(result).toHaveLength(0); expect(result).toEqual([]); }); diff --git a/packages/fxa-auth-server/lib/routes/auth-schemes/google-oidc.spec.ts b/packages/fxa-auth-server/lib/routes/auth-schemes/google-oidc.spec.ts index c339f20e2ac..bee59a5b556 100644 --- a/packages/fxa-auth-server/lib/routes/auth-schemes/google-oidc.spec.ts +++ b/packages/fxa-auth-server/lib/routes/auth-schemes/google-oidc.spec.ts @@ -2,10 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { AppError } from '@fxa/accounts/errors'; -let verifyIdTokenStub: sinon.SinonStub = sinon.stub().resolves({}); +let verifyIdTokenStub: jest.Mock = jest.fn().mockResolvedValue({}); jest.mock('google-auth-library', () => ({ OAuth2Client: class OAuth2Client { @@ -25,7 +24,7 @@ const googleOIDCStrategy = GoogleOIDCScheme.strategy({ describe('lib/routes/auth-schemes/google-oidc', () => { beforeEach(() => { - verifyIdTokenStub = sinon.stub().resolves({}); + verifyIdTokenStub = jest.fn().mockResolvedValue({}); }); it('throws when the bearer token is missing', async () => { @@ -41,7 +40,9 @@ describe('lib/routes/auth-schemes/google-oidc', () => { it('throws when the id token is invalid', async () => { const request = { headers: { authorization: 'Bearer eeff.00.00' } }; - verifyIdTokenStub = sinon.stub().rejects(new Error('invalid id token')); + verifyIdTokenStub = jest + .fn() + .mockRejectedValue(new Error('invalid id token')); try { await googleOIDCStrategy.authenticate(request, {}); @@ -55,9 +56,9 @@ describe('lib/routes/auth-schemes/google-oidc', () => { it('throws when the service account email does not match', async () => { const request = { headers: { authorization: 'Bearer eeff.00.00' } }; - verifyIdTokenStub = sinon - .stub() - .resolves({ getPayload: () => ({ email: 'failing' }) }); + verifyIdTokenStub = jest + .fn() + .mockResolvedValue({ getPayload: () => ({ email: 'failing' }) }); try { await googleOIDCStrategy.authenticate(request, {}); @@ -73,16 +74,18 @@ describe('lib/routes/auth-schemes/google-oidc', () => { it('authenticates successfully', async () => { const request = { headers: { authorization: 'Bearer eeff.00.00' } }; - const h = { authenticated: sinon.stub() }; - verifyIdTokenStub = sinon - .stub() - .resolves({ getPayload: () => ({ email: 'testo@iam.gcp.g.co' }) }); + const h = { authenticated: jest.fn() }; + verifyIdTokenStub = jest.fn().mockResolvedValue({ + getPayload: () => ({ email: 'testo@iam.gcp.g.co' }), + }); await googleOIDCStrategy.authenticate(request, h); - sinon.assert.calledOnceWithExactly(h.authenticated, { + expect(h.authenticated).toHaveBeenCalledTimes(1); + expect(h.authenticated).toHaveBeenCalledWith({ credentials: { email: 'testo@iam.gcp.g.co' }, }); - sinon.assert.calledOnceWithExactly(verifyIdTokenStub, { + expect(verifyIdTokenStub).toHaveBeenCalledTimes(1); + expect(verifyIdTokenStub).toHaveBeenCalledWith({ idToken: 'eeff.00.00', audience: 'cloud-tasks', }); diff --git a/packages/fxa-auth-server/lib/routes/auth-schemes/hawk-fxa-token.spec.ts b/packages/fxa-auth-server/lib/routes/auth-schemes/hawk-fxa-token.spec.ts index 579753fb1a5..0712220d1e9 100644 --- a/packages/fxa-auth-server/lib/routes/auth-schemes/hawk-fxa-token.spec.ts +++ b/packages/fxa-auth-server/lib/routes/auth-schemes/hawk-fxa-token.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { AppError } from '@fxa/accounts/errors'; import { strategy } from './hawk-fxa-token'; @@ -10,7 +9,7 @@ const HAWK_HEADER = 'Hawk id="123", ts="123", nonce="123", mac="123"'; describe('lib/routes/auth-schemes/hawk-fxa-token', () => { it('should throw an error if no authorization header is provided', async () => { - const getCredentialsFunc = sinon.fake.resolves(null); + const getCredentialsFunc = jest.fn().mockResolvedValue(null); const authStrategy = strategy(getCredentialsFunc)(); const request = { headers: {}, auth: { mode: 'required' } }; @@ -30,23 +29,26 @@ describe('lib/routes/auth-schemes/hawk-fxa-token', () => { }); it('should authenticate with parsable Hawk header and valid token', async () => { - const getCredentialsFunc = sinon.fake.resolves({ id: 'validToken' }); + const getCredentialsFunc = jest + .fn() + .mockResolvedValue({ id: 'validToken' }); const authStrategy = strategy(getCredentialsFunc)(); const request = { headers: { authorization: HAWK_HEADER }, auth: { mode: 'required' }, }; - const h = { authenticated: sinon.fake() }; + const h = { authenticated: jest.fn() }; await authStrategy.authenticate(request, h); - expect( - h.authenticated.calledOnceWith({ credentials: { id: 'validToken' } }) - ).toBe(true); + expect(h.authenticated).toHaveBeenCalledTimes(1); + expect(h.authenticated).toHaveBeenCalledWith({ + credentials: { id: 'validToken' }, + }); }); it('should not authenticate with parsable Hawk header and invalid token', async () => { - const getCredentialsFunc = sinon.fake.resolves(null); + const getCredentialsFunc = jest.fn().mockResolvedValue(null); const authStrategy = strategy(getCredentialsFunc)(); const request = { @@ -69,7 +71,7 @@ describe('lib/routes/auth-schemes/hawk-fxa-token', () => { }); it('should not authenticate with unparseable Hawk header', async () => { - const getCredentialsFunc = sinon.fake.resolves(null); + const getCredentialsFunc = jest.fn().mockResolvedValue(null); const authStrategy = strategy(getCredentialsFunc)(); const request = { diff --git a/packages/fxa-auth-server/lib/routes/auth-schemes/mfa.spec.ts b/packages/fxa-auth-server/lib/routes/auth-schemes/mfa.spec.ts index 70bcaca3eab..fa6cd24c5aa 100644 --- a/packages/fxa-auth-server/lib/routes/auth-schemes/mfa.spec.ts +++ b/packages/fxa-auth-server/lib/routes/auth-schemes/mfa.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { AppError } from '@fxa/accounts/errors'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; @@ -39,7 +38,7 @@ describe('lib/routes/auth-schemes/mfa', () => { getCredentialsFunc: any; beforeEach(() => { - sinon.reset(); + jest.clearAllMocks(); sessionToken = { uid: 'account-123', @@ -66,15 +65,17 @@ describe('lib/routes/auth-schemes/mfa', () => { }; db = { - account: sinon.fake.resolves({ + account: jest.fn().mockResolvedValue({ uid: 'uid123', primaryEmail: { isVerified: true }, }), - totpToken: sinon.fake.resolves({ verified: false, enabled: false }), + totpToken: jest + .fn() + .mockResolvedValue({ verified: false, enabled: false }), }; statsd = { - increment: sinon.fake(), + increment: jest.fn(), }; jwtToken = makeJwt(account, sessionToken, config); @@ -86,14 +87,14 @@ describe('lib/routes/auth-schemes/mfa', () => { }, }; h = { - authenticated: sinon.fake.returns(undefined), + authenticated: jest.fn().mockReturnValue(undefined), }; - getCredentialsFunc = sinon.fake.resolves(sessionToken); + getCredentialsFunc = jest.fn().mockResolvedValue(sessionToken); }); afterEach(() => { - sinon.restore(); + jest.restoreAllMocks(); }); it('should authenticate with valid jwt token', async () => { @@ -103,18 +104,17 @@ describe('lib/routes/auth-schemes/mfa', () => { // Important! Session token should be returned as credentials, // AND object reference should not change! - expect( - h.authenticated.calledOnceWithExactly({ - credentials: sinon.match.same(sessionToken), - }) - ).toBe(true); + expect(h.authenticated).toHaveBeenCalledTimes(1); + expect(h.authenticated).toHaveBeenCalledWith({ + credentials: sessionToken, + }); // Session token should be decorated with a scope. expect(sessionToken.scope[0]).toBe('mfa:test'); }); it('should throw an error if no authorization header is provided', async () => { - getCredentialsFunc = sinon.fake.resolves(null); + getCredentialsFunc = jest.fn().mockResolvedValue(null); const authStrategy = strategy(config, getCredentialsFunc, db, statsd)(); const request: any = { headers: {}, auth: { mode: 'required' } }; @@ -132,7 +132,7 @@ describe('lib/routes/auth-schemes/mfa', () => { }); it('should not authenticate if the parent session cannot be found', async () => { - const getCredentialsFunc = sinon.fake.resolves(null); + const getCredentialsFunc = jest.fn().mockResolvedValue(null); const authStrategy = strategy(config, getCredentialsFunc, db, statsd)(); try { @@ -148,7 +148,7 @@ describe('lib/routes/auth-schemes/mfa', () => { }); it('should not authenticate with invalid jwt token due to sub mismatch', async () => { - getCredentialsFunc = sinon.fake.resolves({ sub: 'account-234' }); + getCredentialsFunc = jest.fn().mockResolvedValue({ sub: 'account-234' }); const authStrategy = strategy(config, getCredentialsFunc, db, statsd)(); @@ -165,7 +165,7 @@ describe('lib/routes/auth-schemes/mfa', () => { }); it('fails when account email is not verified', async () => { - db.account = sinon.fake.resolves({ + db.account = jest.fn().mockResolvedValue({ uid: 'uid123', primaryEmail: { isVerified: false }, }); @@ -179,17 +179,15 @@ describe('lib/routes/auth-schemes/mfa', () => { const payload = err.output.payload; expect(payload.code).toBe(400); expect(payload.errno).toBe(AppError.ERRNO.ACCOUNT_UNVERIFIED); - expect( - statsd.increment.calledWithExactly( - 'verified_session_token.primary_email_not_verified.error', - ['path:/foo/{id}'] - ) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.primary_email_not_verified.error', + ['path:/foo/{id}'] + ); } }); it('skips email verified check when configured', async () => { - db.account = sinon.fake.resolves({ + db.account = jest.fn().mockResolvedValue({ uid: 'uid123', primaryEmail: { isVerified: false }, }); @@ -200,12 +198,11 @@ describe('lib/routes/auth-schemes/mfa', () => { const authStrategy = strategy(config, getCredentialsFunc, db, statsd)(); await authStrategy.authenticate(request, h); - expect( - statsd.increment.calledOnceWithExactly( - 'verified_session_token.primary_email_not_verified.skipped', - ['path:/foo/{id}'] - ) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.primary_email_not_verified.skipped', + ['path:/foo/{id}'] + ); }); it('fails when session token is unverified', async () => { @@ -220,12 +217,10 @@ describe('lib/routes/auth-schemes/mfa', () => { const payload = err.output.payload; expect(payload.code).toBe(400); expect(payload.errno).toBe(AppError.ERRNO.SESSION_UNVERIFIED); - expect( - statsd.increment.calledWithExactly( - 'verified_session_token.token_verified.error', - ['path:/foo/{id}'] - ) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.token_verified.error', + ['path:/foo/{id}'] + ); } }); @@ -239,16 +234,17 @@ describe('lib/routes/auth-schemes/mfa', () => { const authStrategy = strategy(config, getCredentialsFunc, db, statsd)(); await authStrategy.authenticate(request, h); - expect( - statsd.increment.calledOnceWithExactly( - 'verified_session_token.token_verified.skipped', - ['path:/foo/{id}'] - ) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.token_verified.skipped', + ['path:/foo/{id}'] + ); }); it('fails when AAL mismatch', async () => { - db.totpToken = sinon.fake.resolves({ verified: true, enabled: true }); + db.totpToken = jest + .fn() + .mockResolvedValue({ verified: true, enabled: true }); sessionToken.authenticatorAssuranceLevel = 1; const authStrategy = strategy(config, getCredentialsFunc, db, statsd)(); @@ -260,64 +256,66 @@ describe('lib/routes/auth-schemes/mfa', () => { const payload = err.output.payload; expect(payload.code).toBe(400); expect(payload.errno).toBe(AppError.ERRNO.INSUFFICIENT_AAL); - expect( - statsd.increment.calledWithExactly('verified_session_token.aal.error', [ - 'path:/foo/{id}', - ]) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.aal.error', + ['path:/foo/{id}'] + ); } }); it('succeeds when account does not require AAL2 (no TOTP) and session is AAL1', async () => { - db.totpToken = sinon.fake.resolves({ verified: false, enabled: false }); + db.totpToken = jest + .fn() + .mockResolvedValue({ verified: false, enabled: false }); sessionToken.authenticatorAssuranceLevel = 1; const authStrategy = strategy(config, getCredentialsFunc, db, statsd)(); await authStrategy.authenticate(request, h); - expect( - h.authenticated.calledOnceWithExactly({ - credentials: sinon.match.same(sessionToken), - }) - ).toBe(true); + expect(h.authenticated).toHaveBeenCalledTimes(1); + expect(h.authenticated).toHaveBeenCalledWith({ + credentials: sessionToken, + }); expect(sessionToken.scope[0]).toBe('mfa:test'); }); it('succeeds when account requires AAL2 (TOTP enabled) and session is AAL2', async () => { - db.totpToken = sinon.fake.resolves({ verified: true, enabled: true }); + db.totpToken = jest + .fn() + .mockResolvedValue({ verified: true, enabled: true }); sessionToken.authenticatorAssuranceLevel = 2; const authStrategy = strategy(config, getCredentialsFunc, db, statsd)(); await authStrategy.authenticate(request, h); - expect( - h.authenticated.calledOnceWithExactly({ - credentials: sinon.match.same(sessionToken), - }) - ).toBe(true); + expect(h.authenticated).toHaveBeenCalledTimes(1); + expect(h.authenticated).toHaveBeenCalledWith({ + credentials: sessionToken, + }); expect(sessionToken.scope[0]).toBe('mfa:test'); }); it('succeeds when session is AAL2 via passkey and account has no TOTP', async () => { - db.totpToken = sinon.fake.rejects(AppError.totpTokenNotFound()); + db.totpToken = jest.fn().mockRejectedValue(AppError.totpTokenNotFound()); sessionToken.authenticatorAssuranceLevel = 2; const authStrategy = strategy(config, getCredentialsFunc, db, statsd)(); await authStrategy.authenticate(request, h); - expect( - h.authenticated.calledOnceWithExactly({ - credentials: sinon.match.same(sessionToken), - }) - ).toBe(true); + expect(h.authenticated).toHaveBeenCalledTimes(1); + expect(h.authenticated).toHaveBeenCalledWith({ + credentials: sessionToken, + }); expect(sessionToken.scope[0]).toBe('mfa:test'); }); it('skips AAL check when configured', async () => { - db.totpToken = sinon.fake.resolves({ verified: true, enabled: true }); + db.totpToken = jest + .fn() + .mockResolvedValue({ verified: true, enabled: true }); sessionToken.authenticatorAssuranceLevel = 1; config.authStrategies.verifiedSessionToken.skipAalCheckForRoutes = '/foo.*'; @@ -326,11 +324,10 @@ describe('lib/routes/auth-schemes/mfa', () => { await authStrategy.authenticate(request, h); - expect( - statsd.increment.calledOnceWithExactly( - 'verified_session_token.aal.skipped', - ['path:/foo/{id}'] - ) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.aal.skipped', + ['path:/foo/{id}'] + ); }); }); diff --git a/packages/fxa-auth-server/lib/routes/auth-schemes/refresh-token.spec.ts b/packages/fxa-auth-server/lib/routes/auth-schemes/refresh-token.spec.ts index bd2a72fcec9..2497ba8c53f 100644 --- a/packages/fxa-auth-server/lib/routes/auth-schemes/refresh-token.spec.ts +++ b/packages/fxa-auth-server/lib/routes/auth-schemes/refresh-token.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { AppError as error } from '@fxa/accounts/errors'; const OAUTH_CLIENT_ID = '3c49430b43dfba77'; @@ -48,7 +47,7 @@ describe('lib/routes/auth-schemes/refresh-token', () => { config = { oauth: {} }; db = { - deviceFromRefreshTokenId: sinon.spy(() => + deviceFromRefreshTokenId: jest.fn(() => Promise.resolve({ id: '5eb89097bab6551de3614facaea59cab', refreshTokenId: @@ -68,8 +67,8 @@ describe('lib/routes/auth-schemes/refresh-token', () => { }; response = { - unauthenticated: sinon.spy(() => {}), - authenticated: sinon.spy(() => {}), + unauthenticated: jest.fn(() => {}), + authenticated: jest.fn(() => {}), }; // Reset the mock to default (token not found) @@ -120,8 +119,8 @@ describe('lib/routes/auth-schemes/refresh-token', () => { response ); - expect(response.unauthenticated.calledOnce).toBe(true); - expect(response.authenticated.calledOnce).toBe(false); + expect(response.unauthenticated).toHaveBeenCalledTimes(1); + expect(response.authenticated).not.toHaveBeenCalled(); }); it('authenticates with devices', async () => { @@ -142,37 +141,39 @@ describe('lib/routes/auth-schemes/refresh-token', () => { response ); - expect(response.unauthenticated.called).toBe(false); - expect(response.authenticated.calledOnce).toBe(true); - expect(response.authenticated.args[0][0].credentials).toEqual({ - uid: '620203b5773b4c1d968e1fd4505a6885', - tokenVerified: true, - emailVerified: true, - deviceId: '5eb89097bab6551de3614facaea59cab', - deviceName: 'first device', - deviceType: 'mobile', - client: { - id: OAUTH_CLIENT_ID, - image_uri: '', - name: OAUTH_CLIENT_NAME, - redirect_uri: `http://localhost:3030/oauth/success/${OAUTH_CLIENT_ID}`, - trusted: true, - publicClient: true, + expect(response.unauthenticated).not.toHaveBeenCalled(); + expect(response.authenticated).toHaveBeenCalledTimes(1); + expect(response.authenticated).toHaveBeenNthCalledWith(1, { + credentials: { + uid: '620203b5773b4c1d968e1fd4505a6885', + tokenVerified: true, + emailVerified: true, + deviceId: '5eb89097bab6551de3614facaea59cab', + deviceName: 'first device', + deviceType: 'mobile', + client: { + id: OAUTH_CLIENT_ID, + image_uri: '', + name: OAUTH_CLIENT_NAME, + redirect_uri: `http://localhost:3030/oauth/success/${OAUTH_CLIENT_ID}`, + trusted: true, + publicClient: true, + }, + refreshTokenId: + '5b541d00ea0c0dc775e060c95a1ee7ca617cf95a05d177ec09fd6f62ca9b2913', + deviceAvailableCommands: {}, + deviceCallbackAuthKey: 'auth_key', + deviceCallbackIsExpired: false, + deviceCallbackPublicKey: 'public_key', + deviceCallbackURL: 'https://example.com/callback', + deviceCreatedAt: 1716230400000, + uaBrowser: app.ua.browser, + uaBrowserVersion: app.ua.browserVersion, + uaOS: app.ua.os, + uaOSVersion: app.ua.osVersion, + uaDeviceType: app.ua.deviceType, + uaFormFactor: app.ua.formFactor, }, - refreshTokenId: - '5b541d00ea0c0dc775e060c95a1ee7ca617cf95a05d177ec09fd6f62ca9b2913', - deviceAvailableCommands: {}, - deviceCallbackAuthKey: 'auth_key', - deviceCallbackIsExpired: false, - deviceCallbackPublicKey: 'public_key', - deviceCallbackURL: 'https://example.com/callback', - deviceCreatedAt: 1716230400000, - uaBrowser: app.ua.browser, - uaBrowserVersion: app.ua.browserVersion, - uaOS: app.ua.os, - uaOSVersion: app.ua.osVersion, - uaDeviceType: app.ua.deviceType, - uaFormFactor: app.ua.formFactor, }); }); @@ -193,12 +194,12 @@ describe('lib/routes/auth-schemes/refresh-token', () => { response ); - expect(response.unauthenticated.calledOnce).toBe(true); - const args = response.unauthenticated.args[0][0]; + expect(response.unauthenticated).toHaveBeenCalledTimes(1); + const args = response.unauthenticated.mock.calls[0][0]; expect(args.output.statusCode).toBe(400); expect(args.output.payload.errno).toBe(error.ERRNO.INVALID_SCOPES); - expect(response.authenticated.calledOnce).toBe(false); + expect(response.authenticated).not.toHaveBeenCalled(); }); it('requires an known refresh token to authenticate', async () => { @@ -214,12 +215,12 @@ describe('lib/routes/auth-schemes/refresh-token', () => { response ); - expect(response.unauthenticated.calledOnce).toBe(true); - const args = response.unauthenticated.args[0][0]; + expect(response.unauthenticated).toHaveBeenCalledTimes(1); + const args = response.unauthenticated.mock.calls[0][0]; expect(args.output.statusCode).toBe(401); expect(args.output.payload.errno).toBe(error.ERRNO.INVALID_TOKEN); - expect(response.authenticated.calledOnce).toBe(false); + expect(response.authenticated).not.toHaveBeenCalled(); }); it('requires an active refresh token to authenticate', async () => { @@ -235,12 +236,12 @@ describe('lib/routes/auth-schemes/refresh-token', () => { response ); - expect(response.unauthenticated.calledOnce).toBe(true); - const args = response.unauthenticated.args[0][0]; + expect(response.unauthenticated).toHaveBeenCalledTimes(1); + const args = response.unauthenticated.mock.calls[0][0]; expect(args.output.statusCode).toBe(401); expect(args.output.payload.errno).toBe(error.ERRNO.INVALID_TOKEN); - expect(response.authenticated.calledOnce).toBe(false); + expect(response.authenticated).not.toHaveBeenCalled(); }); it('can be preffed off via feature-flag', async () => { @@ -261,7 +262,7 @@ describe('lib/routes/auth-schemes/refresh-token', () => { } catch (err: any) { expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); } - expect(response.unauthenticated.notCalled).toBe(true); + expect(response.unauthenticated).not.toHaveBeenCalled(); oauthDB.getRefreshToken.mockResolvedValue(undefined); config.oauth.deviceAccessEnabled = true; @@ -276,11 +277,11 @@ describe('lib/routes/auth-schemes/refresh-token', () => { response ); - expect(response.unauthenticated.calledOnce).toBe(true); - const args = response.unauthenticated.args[0][0]; + expect(response.unauthenticated).toHaveBeenCalledTimes(1); + const args = response.unauthenticated.mock.calls[0][0]; expect(args.output.statusCode).toBe(401); expect(args.output.payload.errno).toBe(error.ERRNO.INVALID_TOKEN); - expect(response.authenticated.calledOnce).toBe(false); + expect(response.authenticated).not.toHaveBeenCalled(); }); }); diff --git a/packages/fxa-auth-server/lib/routes/auth-schemes/shared-secret.spec.ts b/packages/fxa-auth-server/lib/routes/auth-schemes/shared-secret.spec.ts index 37bb9b07b07..cb957784335 100644 --- a/packages/fxa-auth-server/lib/routes/auth-schemes/shared-secret.spec.ts +++ b/packages/fxa-auth-server/lib/routes/auth-schemes/shared-secret.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { AppError } from '@fxa/accounts/errors'; const SharedSecretScheme = require('./shared-secret'); @@ -24,10 +23,11 @@ describe('lib/routes/auth-schemes/shared-secret', () => { }); it('should call authenticated when the secrets match', () => { - const faker = sinon.fake(); + const faker = jest.fn(); const request = { headers: { authorization: 'goodsecret' } }; authStrategy.authenticate(request, { authenticated: faker }); - expect(faker.calledOnceWith({ credentials: {} })).toBe(true); + expect(faker).toHaveBeenCalledTimes(1); + expect(faker).toHaveBeenCalledWith({ credentials: {} }); }); it('should not throw if the secrets do not match', () => { diff --git a/packages/fxa-auth-server/lib/routes/auth-schemes/verified-session-token.spec.ts b/packages/fxa-auth-server/lib/routes/auth-schemes/verified-session-token.spec.ts index f85328f5457..27492866a82 100644 --- a/packages/fxa-auth-server/lib/routes/auth-schemes/verified-session-token.spec.ts +++ b/packages/fxa-auth-server/lib/routes/auth-schemes/verified-session-token.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { AppError } from '@fxa/accounts/errors'; import { strategy } from './verified-session-token'; @@ -23,15 +22,17 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { }; db = { - account: sinon.fake.resolves({ + account: jest.fn().mockResolvedValue({ uid: 'uid123', primaryEmail: { isVerified: true }, }), - totpToken: sinon.fake.resolves({ verified: false, enabled: false }), + totpToken: jest + .fn() + .mockResolvedValue({ verified: false, enabled: false }), }; h = { - authenticated: sinon.fake.returns(undefined), + authenticated: jest.fn().mockReturnValue(undefined), }; token = { @@ -50,25 +51,24 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { }; statsd = { - increment: sinon.fake(), + increment: jest.fn(), }; - getCredentialsFunc = sinon.fake.resolves(token); + getCredentialsFunc = jest.fn().mockResolvedValue(token); }); afterEach(() => { - sinon.restore(); + jest.restoreAllMocks(); }); it('authenticates when account email verified, session token verified, and AAL matches', async () => { const authStrategy = strategy(getCredentialsFunc, db, config, statsd)(); await authStrategy.authenticate(request, h); - expect( - h.authenticated.calledOnceWithExactly({ - credentials: token, - }) - ).toBe(true); + expect(h.authenticated).toHaveBeenCalledTimes(1); + expect(h.authenticated).toHaveBeenCalledWith({ + credentials: token, + }); }); it('fails when no authorization header is provided', async () => { @@ -88,7 +88,7 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { }); it('fails when token not found', async () => { - getCredentialsFunc = sinon.fake.resolves(null); + getCredentialsFunc = jest.fn().mockResolvedValue(null); const authStrategy = strategy(getCredentialsFunc, db, config, statsd)(); @@ -103,7 +103,7 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { }); it('fails when account email is not verified', async () => { - db.account = sinon.fake.resolves({ + db.account = jest.fn().mockResolvedValue({ uid: 'uid123', primaryEmail: { isVerified: false }, }); @@ -117,17 +117,15 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { const payload = err.output.payload; expect(payload.code).toBe(400); expect(payload.errno).toBe(AppError.ERRNO.ACCOUNT_UNVERIFIED); - expect( - statsd.increment.calledWithExactly( - 'verified_session_token.primary_email_not_verified.error', - ['path:/foo/{id}'] - ) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.primary_email_not_verified.error', + ['path:/foo/{id}'] + ); } }); it('skips email verified check when configured', async () => { - db.account = sinon.fake.resolves({ + db.account = jest.fn().mockResolvedValue({ uid: 'uid123', primaryEmail: { isVerified: false }, }); @@ -138,12 +136,11 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { const authStrategy = strategy(getCredentialsFunc, db, config, statsd)(); await authStrategy.authenticate(request, h); - expect( - statsd.increment.calledOnceWithExactly( - 'verified_session_token.primary_email_not_verified.skipped', - ['path:/foo/{id}'] - ) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.primary_email_not_verified.skipped', + ['path:/foo/{id}'] + ); }); it('fails when session token is unverified', async () => { @@ -158,12 +155,10 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { const payload = err.output.payload; expect(payload.code).toBe(400); expect(payload.errno).toBe(AppError.ERRNO.SESSION_UNVERIFIED); - expect( - statsd.increment.calledWithExactly( - 'verified_session_token.token_verified.error', - ['path:/foo/{id}'] - ) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.token_verified.error', + ['path:/foo/{id}'] + ); } }); @@ -177,16 +172,15 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { const authStrategy = strategy(getCredentialsFunc, db, config, statsd)(); await authStrategy.authenticate(request, h); - expect( - statsd.increment.calledOnceWithExactly( - 'verified_session_token.token_verified.skipped', - ['path:/foo/{id}'] - ) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.token_verified.skipped', + ['path:/foo/{id}'] + ); }); it('fails when session AAL is less than required AAL', async () => { - db.totpToken = sinon.fake.resolves({ + db.totpToken = jest.fn().mockResolvedValue({ verified: true, enabled: true, }); @@ -201,31 +195,29 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { const payload = err.output.payload; expect(payload.code).toBe(400); expect(payload.errno).toBe(AppError.ERRNO.INSUFFICIENT_AAL); - expect( - statsd.increment.calledWithExactly('verified_session_token.aal.error', [ - 'path:/foo/{id}', - ]) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.aal.error', + ['path:/foo/{id}'] + ); } }); it('passes when account does not require AAL2 (no TOTP) and session is AAL1', async () => { - db.totpToken = sinon.fake.resolves({ + db.totpToken = jest.fn().mockResolvedValue({ verified: false, enabled: false, }); const authStrategy = strategy(getCredentialsFunc, db, config, statsd)(); await authStrategy.authenticate(request, h); - expect( - h.authenticated.calledOnceWithExactly({ - credentials: token, - }) - ).toBe(true); + expect(h.authenticated).toHaveBeenCalledTimes(1); + expect(h.authenticated).toHaveBeenCalledWith({ + credentials: token, + }); }); it('passes when account requires AAL2 (TOTP enabled) and session is AAL2', async () => { - db.totpToken = sinon.fake.resolves({ + db.totpToken = jest.fn().mockResolvedValue({ verified: true, enabled: true, }); @@ -234,15 +226,14 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { const authStrategy = strategy(getCredentialsFunc, db, config, statsd)(); await authStrategy.authenticate(request, h); - expect( - h.authenticated.calledOnceWithExactly({ - credentials: token, - }) - ).toBe(true); + expect(h.authenticated).toHaveBeenCalledTimes(1); + expect(h.authenticated).toHaveBeenCalledWith({ + credentials: token, + }); }); it('skips AAL check when configured', async () => { - db.totpToken = sinon.fake.resolves({ + db.totpToken = jest.fn().mockResolvedValue({ enabled: true, verified: true, }); @@ -253,11 +244,10 @@ describe('lib/routes/auth-schemes/verified-session-token', () => { await authStrategy.authenticate(request, h); - expect( - statsd.increment.calledOnceWithExactly( - 'verified_session_token.aal.skipped', - ['path:/foo/{id}'] - ) - ).toBe(true); + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( + 'verified_session_token.aal.skipped', + ['path:/foo/{id}'] + ); }); }); diff --git a/packages/fxa-auth-server/lib/routes/cloud-scheduler.spec.ts b/packages/fxa-auth-server/lib/routes/cloud-scheduler.spec.ts index 9317a582dd2..cbcb53d9f7c 100644 --- a/packages/fxa-auth-server/lib/routes/cloud-scheduler.spec.ts +++ b/packages/fxa-auth-server/lib/routes/cloud-scheduler.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { ReasonForDeletion } from '@fxa/shared/cloud-tasks'; // p-queue is ESM-only; mock it to avoid parse errors in Jest @@ -44,18 +43,17 @@ describe('CloudSchedulerHandler', () => { }, }; log = { - info: sinon.stub(), + info: jest.fn(), }; statsd = { - increment: sinon.stub(), + increment: jest.fn(), }; cloudSchedulerHandler = new CloudSchedulerHandler(log, config, statsd); - mockProcessAccountDeletionInRange = sinon.stub( - cloudSchedulerHandler, - 'processAccountDeletionInRange' - ); + mockProcessAccountDeletionInRange = jest + .spyOn(cloudSchedulerHandler, 'processAccountDeletionInRange') + .mockResolvedValue(undefined); dateNowSpy = jest .spyOn(Date, 'now') @@ -78,8 +76,8 @@ describe('CloudSchedulerHandler', () => { await cloudSchedulerHandler.deleteUnverifiedAccounts(); - sinon.assert.calledOnceWithExactly( - mockProcessAccountDeletionInRange, + expect(mockProcessAccountDeletionInRange).toHaveBeenCalledTimes(1); + expect(mockProcessAccountDeletionInRange).toHaveBeenCalledWith( config, undefined, reason, diff --git a/packages/fxa-auth-server/lib/routes/cloud-tasks.spec.ts b/packages/fxa-auth-server/lib/routes/cloud-tasks.spec.ts index 7faa28999ab..f7b9e6ca64d 100644 --- a/packages/fxa-auth-server/lib/routes/cloud-tasks.spec.ts +++ b/packages/fxa-auth-server/lib/routes/cloud-tasks.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { ReasonForDeletion, EmailTypes } from '@fxa/shared/cloud-tasks'; @@ -23,11 +22,8 @@ const mockConfig = { }, }; -const sandbox = sinon.createSandbox(); -const deleteAccountStub = sandbox - .stub() - .callsFake((uid: any, reason: any, customerId: any) => {}); -const inactiveNotificationStub = sandbox.stub(); +const deleteAccountStub = jest.fn(); +const inactiveNotificationStub = jest.fn(); afterAll(() => { Container.reset(); @@ -40,7 +36,7 @@ describe('/cloud-tasks/accounts/delete', () => { beforeEach(() => { mockLog = mocks.mockLog(); - sandbox.reset(); + jest.clearAllMocks(); Container.set(AccountDeleteManager, { deleteAccount: deleteAccountStub, @@ -60,8 +56,8 @@ describe('/cloud-tasks/accounts/delete', () => { await route.handler(req); - sinon.assert.calledOnce(deleteAccountStub); - expect(deleteAccountStub.args[0][0]).toBe(uid); + expect(deleteAccountStub).toHaveBeenCalledTimes(1); + expect(deleteAccountStub.mock.calls[0][0]).toBe(uid); }); }); @@ -70,7 +66,7 @@ describe('/cloud-tasks/emails/notify-inactive', () => { let routes: any, route: any; beforeEach(() => { - sandbox.reset(); + jest.clearAllMocks(); mockLog = mocks.mockLog(); Container.set(AccountDeleteManager, { @@ -101,6 +97,7 @@ describe('/cloud-tasks/emails/notify-inactive', () => { }; await route.handler(req); - sinon.assert.calledOnceWithExactly(inactiveNotificationStub, req); + expect(inactiveNotificationStub).toHaveBeenCalledTimes(1); + expect(inactiveNotificationStub).toHaveBeenCalledWith(req); }); }); diff --git a/packages/fxa-auth-server/lib/routes/cms.spec.ts b/packages/fxa-auth-server/lib/routes/cms.spec.ts index 2f21630865f..2df60883969 100644 --- a/packages/fxa-auth-server/lib/routes/cms.spec.ts +++ b/packages/fxa-auth-server/lib/routes/cms.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import crypto from 'crypto'; import { Container } from 'typedi'; @@ -89,8 +88,8 @@ describe('cms', () => { beforeEach(() => { log = mocks.mockLog(); mockStatsD = { - increment: sinon.stub(), - timing: sinon.stub(), + increment: jest.fn(), + timing: jest.fn(), }; mockConfig = { @@ -121,47 +120,46 @@ describe('cms', () => { }; mockCmsManager = { - fetchCMSData: sinon.stub(), - invalidateCache: sinon.stub(), - cacheFtlContent: sinon.stub(), - getCachedFtlContent: sinon.stub(), - invalidateFtlCache: sinon.stub(), + fetchCMSData: jest.fn(), + invalidateCache: jest.fn(), + cacheFtlContent: jest.fn(), + getCachedFtlContent: jest.fn(), + invalidateFtlCache: jest.fn(), }; mockLegalTermsManager = { - getLegalTermsByClientId: sinon.stub(), - getLegalTermsByService: sinon.stub(), + getLegalTermsByClientId: jest.fn(), + getLegalTermsByService: jest.fn(), }; mockDefaultCmsManager = { - fetchDefault: sinon.stub(), - invalidateCache: sinon.stub(), + fetchDefault: jest.fn(), + invalidateCache: jest.fn(), }; mockLocalization = { - fetchAllStrapiEntries: sinon.stub(), - validateGitHubConfig: sinon.stub(), - strapiToFtl: sinon.stub(), - findExistingPR: sinon.stub(), - updateExistingPR: sinon.stub(), - createGitHubPR: sinon.stub(), - fetchLocalizationFromUrl: sinon.stub(), - convertFtlToStrapiFormat: sinon.stub(), - fetchLocalizedFtlWithFallback: sinon.stub(), - mergeConfigs: sinon.stub(), - extractBaseLocale: sinon.stub(), - generateFtlContentFromEntries: sinon.stub(), + fetchAllStrapiEntries: jest.fn(), + validateGitHubConfig: jest.fn(), + strapiToFtl: jest.fn(), + findExistingPR: jest.fn(), + updateExistingPR: jest.fn(), + createGitHubPR: jest.fn(), + fetchLocalizationFromUrl: jest.fn(), + convertFtlToStrapiFormat: jest.fn(), + fetchLocalizedFtlWithFallback: jest.fn(), + mergeConfigs: jest.fn(), + extractBaseLocale: jest.fn(), + generateFtlContentFromEntries: jest.fn(), }; mockLocalizationInstance = mockLocalization; // Use callsFake to always return the right mock based on argument - const containerHas = sinon.stub(Container, 'has').returns(true); - const containerGet = sinon - .stub(Container, 'get') - .callsFake((token: any) => { + const containerHas = jest.spyOn(Container, 'has').mockReturnValue(true); + const containerGet = jest + .spyOn(Container, 'get') + .mockImplementation((token: any) => { // The cms module calls Container.get with the constructor functions - // First call is RelyingPartyConfigurationManager, second is LegalTermsConfigurationManager // We match by name since the mocked constructors have no distinctive props const shared = require('@fxa/shared/cms'); if (token === shared.LegalTermsConfigurationManager) { @@ -177,8 +175,8 @@ describe('cms', () => { routes = cmsModule.default(log, mockConfig, mockStatsD); // Restore Container stubs after routes are created (they're only needed during init) - containerHas.restore(); - containerGet.restore(); + containerHas.mockRestore(); + containerGet.mockRestore(); }); afterAll(() => { @@ -194,7 +192,7 @@ describe('cms', () => { const mockResult = { relyingParties: [createStrapiTestData()[0]], }; - mockCmsManager.fetchCMSData.resolves(mockResult); + mockCmsManager.fetchCMSData.mockResolvedValue(mockResult); request = createLocalizationRequest( 'desktopSyncFirefoxCms', @@ -205,16 +203,15 @@ describe('cms', () => { const response = await route.handler(request); expect(response).toEqual(mockResult.relyingParties[0]); - sinon.assert.calledOnce(mockCmsManager.fetchCMSData); - sinon.assert.calledWith( - mockCmsManager.fetchCMSData, + expect(mockCmsManager.fetchCMSData).toHaveBeenCalledTimes(1); + expect(mockCmsManager.fetchCMSData).toHaveBeenCalledWith( 'desktopSyncFirefoxCms', 'desktop-sync' ); }); it('should return empty object when no relying parties found', async () => { - mockCmsManager.fetchCMSData.resolves({ relyingParties: [] }); + mockCmsManager.fetchCMSData.mockResolvedValue({ relyingParties: [] }); request = createLocalizationRequest( 'test-client', @@ -225,12 +222,12 @@ describe('cms', () => { const response = await route.handler(request); expect(response).toEqual({}); - sinon.assert.calledOnce(mockStatsD.increment); - sinon.assert.calledWith(mockStatsD.increment, 'cms.getConfig.empty'); + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith('cms.getConfig.empty'); }); it('should handle errors gracefully and return empty object', async () => { - mockCmsManager.fetchCMSData.rejects(new Error('CMS Error')); + mockCmsManager.fetchCMSData.mockRejectedValue(new Error('CMS Error')); request = createLocalizationRequest( 'test-client', @@ -241,8 +238,8 @@ describe('cms', () => { const response = await route.handler(request); expect(response).toEqual({}); - sinon.assert.calledOnce(mockStatsD.increment); - sinon.assert.calledWith(mockStatsD.increment, 'cms.getConfig.error'); + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith('cms.getConfig.error'); }); it('should validate required clientId parameter', async () => { @@ -278,10 +275,12 @@ describe('cms', () => { const strapiData = createStrapiTestData(); - mockLocalization.fetchAllStrapiEntries.resolves(strapiData); - mockLocalization.generateFtlContentFromEntries.returns('ftl-content'); - mockLocalization.findExistingPR.resolves(null); - mockLocalization.createGitHubPR.resolves(); + mockLocalization.fetchAllStrapiEntries.mockResolvedValue(strapiData); + mockLocalization.generateFtlContentFromEntries.mockReturnValue( + 'ftl-content' + ); + mockLocalization.findExistingPR.mockResolvedValue(null); + mockLocalization.createGitHubPR.mockResolvedValue(); request = { headers: { @@ -294,9 +293,8 @@ describe('cms', () => { const response = await route.handler(request); expect(response).toEqual({ success: true }); - sinon.assert.calledOnce(mockStatsD.increment); - sinon.assert.calledWith( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith( 'cms.strapiWebhook.processed' ); }); @@ -313,8 +311,8 @@ describe('cms', () => { const response = await route.handler(request); expect(response).toEqual({ success: true }); - sinon.assert.calledOnce(log.info); - sinon.assert.calledWith(log.info, 'cms.strapiWebhook.disabled', {}); + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith('cms.strapiWebhook.disabled', {}); }); it('should reject when authorization header is missing', async () => { @@ -374,13 +372,11 @@ describe('cms', () => { await route.handler(req); throw new Error('an error should have been thrown'); } catch (err: any) { - sinon.assert.calledWith( - log.error, + expect(log.error).toHaveBeenCalledWith( 'cms.cacheReset.error.auth', webhookPayload.entry ); - sinon.assert.calledWith( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledWith( 'cms.cacheReset.error.auth', { clientId: webhookPayload.entry.clientId, @@ -398,12 +394,12 @@ describe('cms', () => { }, payload: { ...webhookPayload, event: 'entry.publish' }, }; - mockCmsManager.invalidateCache.resolves(); + mockCmsManager.invalidateCache.mockResolvedValue(); const result = await route.handler(req); expect(result).toEqual({ success: true }); - sinon.assert.calledOnceWithExactly( - mockCmsManager.invalidateCache, + expect(mockCmsManager.invalidateCache).toHaveBeenCalledTimes(1); + expect(mockCmsManager.invalidateCache).toHaveBeenCalledWith( webhookPayload.entry.clientId, webhookPayload.entry.entrypoint ); @@ -418,33 +414,36 @@ describe('cms', () => { it('should return base config for English locale', async () => { const baseConfig = createBaseConfig(); const mockResult = { relyingParties: [baseConfig] }; - mockCmsManager.fetchCMSData.resolves(mockResult); + mockCmsManager.fetchCMSData.mockResolvedValue(mockResult); request = createLocalizationRequest('sync-client', 'desktop-sync', 'en'); const response = await route.handler(request); expect(response).toEqual(baseConfig); - sinon.assert.calledOnce(mockCmsManager.fetchCMSData); - sinon.assert.notCalled(mockLocalization.fetchLocalizedFtlWithFallback); + expect(mockCmsManager.fetchCMSData).toHaveBeenCalledTimes(1); + expect( + mockLocalization.fetchLocalizedFtlWithFallback + ).not.toHaveBeenCalled(); }); it('should return base config when localization is disabled', async () => { mockConfig.cmsl10n.enabled = false; const baseConfig = createBaseConfig(); const mockResult = { relyingParties: [baseConfig] }; - mockCmsManager.fetchCMSData.resolves(mockResult); + mockCmsManager.fetchCMSData.mockResolvedValue(mockResult); request = createLocalizationRequest('sync-client', 'desktop-sync', 'es'); const response = await route.handler(request); expect(response).toEqual(baseConfig); - sinon.assert.calledOnce(mockCmsManager.fetchCMSData); - sinon.assert.notCalled(mockLocalization.fetchLocalizedFtlWithFallback); - sinon.assert.calledOnce(log.info); - sinon.assert.calledWith( - log.info, + expect(mockCmsManager.fetchCMSData).toHaveBeenCalledTimes(1); + expect( + mockLocalization.fetchLocalizedFtlWithFallback + ).not.toHaveBeenCalled(); + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith( 'cms.getLocalizedConfig.baseConfigOnly', { clientId: 'sync-client', @@ -479,9 +478,11 @@ describe('cms', () => { }, }; - mockCmsManager.fetchCMSData.resolves(mockResult); - mockLocalization.fetchLocalizedFtlWithFallback.resolves(ftlContent); - mockLocalization.mergeConfigs.resolves({ + mockCmsManager.fetchCMSData.mockResolvedValue(mockResult); + mockLocalization.fetchLocalizedFtlWithFallback.mockResolvedValue( + ftlContent + ); + mockLocalization.mergeConfigs.mockResolvedValue({ ...baseConfig, ...localizedData, }); @@ -490,14 +491,14 @@ describe('cms', () => { const response = await route.handler(request); - sinon.assert.calledOnce(mockLocalization.fetchLocalizedFtlWithFallback); - sinon.assert.calledWith( - mockLocalization.fetchLocalizedFtlWithFallback, - 'es' - ); - sinon.assert.calledOnce(mockLocalization.mergeConfigs); - sinon.assert.calledWith( - mockLocalization.mergeConfigs, + expect( + mockLocalization.fetchLocalizedFtlWithFallback + ).toHaveBeenCalledTimes(1); + expect( + mockLocalization.fetchLocalizedFtlWithFallback + ).toHaveBeenCalledWith('es'); + expect(mockLocalization.mergeConfigs).toHaveBeenCalledTimes(1); + expect(mockLocalization.mergeConfigs).toHaveBeenCalledWith( baseConfig, ftlContent, 'sync-client', @@ -510,9 +511,8 @@ describe('cms', () => { ); expect(response.name).toBe('Firefox Desktop Sync'); - sinon.assert.calledOnce(mockStatsD.increment); - sinon.assert.calledWith( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith( 'cms.getLocalizedConfig.success' ); }); @@ -521,17 +521,16 @@ describe('cms', () => { const baseConfig = createBaseConfig(); const mockResult = { relyingParties: [baseConfig] }; - mockCmsManager.fetchCMSData.resolves(mockResult); - mockLocalization.fetchLocalizedFtlWithFallback.resolves(''); + mockCmsManager.fetchCMSData.mockResolvedValue(mockResult); + mockLocalization.fetchLocalizedFtlWithFallback.mockResolvedValue(''); request = createLocalizationRequest('sync-client', 'desktop-sync', 'fr'); const response = await route.handler(request); expect(response).toEqual(baseConfig); - sinon.assert.calledOnce(log.info); - sinon.assert.calledWith( - log.info, + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith( 'cms.getLocalizedConfig.fallbackToBase', { clientId: 'sync-client', @@ -539,15 +538,14 @@ describe('cms', () => { locale: 'fr', } ); - sinon.assert.calledOnce(mockStatsD.increment); - sinon.assert.calledWith( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith( 'cms.getLocalizedConfig.fallback' ); }); it('should return early when base config is empty object', async () => { - mockCmsManager.fetchCMSData.resolves({ relyingParties: [] }); + mockCmsManager.fetchCMSData.mockResolvedValue({ relyingParties: [] }); request = createLocalizationRequest('sync-client', 'desktop-sync', 'es'); @@ -555,13 +553,14 @@ describe('cms', () => { expect(response).toEqual({}); - sinon.assert.notCalled(mockLocalization.fetchLocalizedFtlWithFallback); - sinon.assert.notCalled(mockLocalization.mergeConfigs); + expect( + mockLocalization.fetchLocalizedFtlWithFallback + ).not.toHaveBeenCalled(); + expect(mockLocalization.mergeConfigs).not.toHaveBeenCalled(); - sinon.assert.calledTwice(log.info); + expect(log.info).toHaveBeenCalledTimes(2); - sinon.assert.calledWith( - log.info.firstCall, + expect(log.info).toHaveBeenCalledWith( 'cms.getConfig: No relying parties found', { clientId: 'sync-client', @@ -569,8 +568,7 @@ describe('cms', () => { } ); - sinon.assert.calledWith( - log.info.secondCall, + expect(log.info).toHaveBeenCalledWith( 'cms.getLocalizedConfig.noBaseConfig', { clientId: 'sync-client', @@ -579,20 +577,20 @@ describe('cms', () => { } ); - sinon.assert.calledOnce(mockStatsD.increment); - sinon.assert.calledWith(mockStatsD.increment, 'cms.getConfig.empty'); + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith('cms.getConfig.empty'); }); }); describe('GET /cms/default', () => { beforeEach(() => { route = getRoute(routes, '/cms/default', 'GET'); - mockDefaultCmsManager.fetchDefault.reset(); - mockStatsD.increment.resetHistory(); + mockDefaultCmsManager.fetchDefault.mockReset(); + mockStatsD.increment.mockClear(); }); it('returns the default payload when present', async () => { - mockDefaultCmsManager.fetchDefault.resolves({ + mockDefaultCmsManager.fetchDefault.mockResolvedValue({ default: { promoQrImageUrl: 'https://cdn.example/qr.svg', }, @@ -604,39 +602,41 @@ describe('cms', () => { expect(response).toEqual({ promoQrImageUrl: 'https://cdn.example/qr.svg', }); - sinon.assert.calledOnce(mockDefaultCmsManager.fetchDefault); - sinon.assert.calledWith(mockStatsD.increment, 'cms.getDefault.success'); + expect(mockDefaultCmsManager.fetchDefault).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cms.getDefault.success' + ); }); it('returns {} when no entry exists', async () => { - mockDefaultCmsManager.fetchDefault.resolves({ default: null }); + mockDefaultCmsManager.fetchDefault.mockResolvedValue({ default: null }); request = { log }; const response = await route.handler(request); expect(response).toEqual({}); - sinon.assert.calledWith(mockStatsD.increment, 'cms.getDefault.empty'); + expect(mockStatsD.increment).toHaveBeenCalledWith('cms.getDefault.empty'); }); it('returns {} and logs on manager error', async () => { - mockDefaultCmsManager.fetchDefault.rejects(new Error('boom')); + mockDefaultCmsManager.fetchDefault.mockRejectedValue(new Error('boom')); request = { log }; const response = await route.handler(request); expect(response).toEqual({}); - sinon.assert.calledWith(mockStatsD.increment, 'cms.getDefault.error'); + expect(mockStatsD.increment).toHaveBeenCalledWith('cms.getDefault.error'); }); }); describe('GET /cms/legal-terms', () => { beforeEach(() => { route = getRoute(routes, '/cms/legal-terms', 'GET'); - mockLegalTermsManager.getLegalTermsByClientId.reset(); - mockLegalTermsManager.getLegalTermsByService.reset(); - mockLocalization.fetchLocalizedFtlWithFallback.reset(); - mockLocalization.mergeConfigs.reset(); - mockStatsD.increment.resetHistory(); + mockLegalTermsManager.getLegalTermsByClientId.mockReset(); + mockLegalTermsManager.getLegalTermsByService.mockReset(); + mockLocalization.fetchLocalizedFtlWithFallback.mockReset(); + mockLocalization.mergeConfigs.mockReset(); + mockStatsD.increment.mockClear(); }); const mockLegalTermsResult = { @@ -654,20 +654,20 @@ describe('cms', () => { log: log, }; - mockLegalTermsManager.getLegalTermsByClientId.resolves({ + mockLegalTermsManager.getLegalTermsByClientId.mockResolvedValue({ getLegalTerms: () => mockLegalTermsResult, }); const response = await route.handler(request); - sinon.assert.calledOnce(mockLegalTermsManager.getLegalTermsByClientId); - sinon.assert.calledWith( - mockLegalTermsManager.getLegalTermsByClientId, - clientId - ); + expect( + mockLegalTermsManager.getLegalTermsByClientId + ).toHaveBeenCalledTimes(1); + expect( + mockLegalTermsManager.getLegalTermsByClientId + ).toHaveBeenCalledWith(clientId); expect(response).toEqual(mockLegalTermsResult); - sinon.assert.calledWith( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledWith( 'cms.getLegalTerms.success' ); }); @@ -680,20 +680,20 @@ describe('cms', () => { log: log, }; - mockLegalTermsManager.getLegalTermsByService.resolves({ + mockLegalTermsManager.getLegalTermsByService.mockResolvedValue({ getLegalTerms: () => mockLegalTermsResult, }); const response = await route.handler(request); - sinon.assert.calledOnce(mockLegalTermsManager.getLegalTermsByService); - sinon.assert.calledWith( - mockLegalTermsManager.getLegalTermsByService, + expect( + mockLegalTermsManager.getLegalTermsByService + ).toHaveBeenCalledTimes(1); + expect(mockLegalTermsManager.getLegalTermsByService).toHaveBeenCalledWith( service ); expect(response).toEqual(mockLegalTermsResult); - sinon.assert.calledWith( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledWith( 'cms.getLegalTerms.success' ); }); @@ -706,14 +706,16 @@ describe('cms', () => { log: log, }; - mockLegalTermsManager.getLegalTermsByClientId.resolves({ + mockLegalTermsManager.getLegalTermsByClientId.mockResolvedValue({ getLegalTerms: () => null, }); const response = await route.handler(request); expect(response).toBeNull(); - sinon.assert.calledWith(mockStatsD.increment, 'cms.getLegalTerms.empty'); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cms.getLegalTerms.empty' + ); }); it('should throw error when both clientId and service provided', async () => { @@ -761,30 +763,31 @@ describe('cms', () => { log: log, }; - mockLegalTermsManager.getLegalTermsByClientId.resolves({ + mockLegalTermsManager.getLegalTermsByClientId.mockResolvedValue({ getLegalTerms: () => mockLegalTermsResult, }); - mockLocalization.fetchLocalizedFtlWithFallback.resolves(ftlContent); - mockLocalization.mergeConfigs.resolves({ + mockLocalization.fetchLocalizedFtlWithFallback.mockResolvedValue( + ftlContent + ); + mockLocalization.mergeConfigs.mockResolvedValue({ ...mockLegalTermsResult, label: 'Conditions générales', }); const response = await route.handler(request); - sinon.assert.calledOnce(mockLocalization.fetchLocalizedFtlWithFallback); - sinon.assert.calledWith( - mockLocalization.fetchLocalizedFtlWithFallback, - locale - ); - sinon.assert.calledOnce(mockLocalization.mergeConfigs); - sinon.assert.calledWith( - mockStatsD.increment, + expect( + mockLocalization.fetchLocalizedFtlWithFallback + ).toHaveBeenCalledTimes(1); + expect( + mockLocalization.fetchLocalizedFtlWithFallback + ).toHaveBeenCalledWith(locale); + expect(mockLocalization.mergeConfigs).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith( 'cms.getLegalTerms.success' ); - sinon.assert.calledWith( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledWith( 'cms.getLegalTerms.localized' ); expect(response.label).toBe('Conditions générales'); @@ -800,21 +803,19 @@ describe('cms', () => { log: log, }; - mockLegalTermsManager.getLegalTermsByClientId.resolves({ + mockLegalTermsManager.getLegalTermsByClientId.mockResolvedValue({ getLegalTerms: () => mockLegalTermsResult, }); - mockLocalization.fetchLocalizedFtlWithFallback.resolves(null); + mockLocalization.fetchLocalizedFtlWithFallback.mockResolvedValue(null); const response = await route.handler(request); expect(response).toEqual(mockLegalTermsResult); - sinon.assert.calledWith( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledWith( 'cms.getLegalTerms.fallback' ); - sinon.assert.calledWith( - mockStatsD.increment, + expect(mockStatsD.increment).toHaveBeenCalledWith( 'cms.getLegalTerms.success' ); }); @@ -831,16 +832,17 @@ describe('cms', () => { log: log, }; - mockLegalTermsManager.getLegalTermsByClientId.resolves({ + mockLegalTermsManager.getLegalTermsByClientId.mockResolvedValue({ getLegalTerms: () => mockLegalTermsResult, }); const response = await route.handler(request); expect(response).toEqual(mockLegalTermsResult); - sinon.assert.notCalled(mockLocalization.fetchLocalizedFtlWithFallback); - sinon.assert.calledWith( - mockStatsD.increment, + expect( + mockLocalization.fetchLocalizedFtlWithFallback + ).not.toHaveBeenCalled(); + expect(mockStatsD.increment).toHaveBeenCalledWith( 'cms.getLegalTerms.success' ); @@ -855,14 +857,16 @@ describe('cms', () => { log: log, }; - mockLegalTermsManager.getLegalTermsByClientId.rejects( + mockLegalTermsManager.getLegalTermsByClientId.mockRejectedValue( new Error('Strapi error') ); const response = await route.handler(request); expect(response).toBeNull(); - sinon.assert.calledWith(mockStatsD.increment, 'cms.getLegalTerms.error'); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'cms.getLegalTerms.error' + ); }); }); diff --git a/packages/fxa-auth-server/lib/routes/devices-and-sessions.spec.ts b/packages/fxa-auth-server/lib/routes/devices-and-sessions.spec.ts index 2fb2cd70fda..df0acef05b2 100644 --- a/packages/fxa-auth-server/lib/routes/devices-and-sessions.spec.ts +++ b/packages/fxa-auth-server/lib/routes/devices-and-sessions.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import crypto from 'crypto'; const Joi = require('joi'); @@ -34,7 +33,7 @@ function makeRoutes(options: any = {}) { const log = options.log || mocks.mockLog(); const db = options.db || mocks.mockDB(); const oauth = options.oauth || { - getRefreshTokensByUid: sinon.spy(async () => []), + getRefreshTokensByUid: jest.fn(async () => []), }; const customs = options.customs || { check: function () { @@ -44,8 +43,7 @@ function makeRoutes(options: any = {}) { const push = options.push || require('../push')(log, db, {}); const pushbox = options.pushbox || mocks.mockPushbox(); const clientUtils = - options.clientUtils || - require('./utils/clients')(log, config); + options.clientUtils || require('./utils/clients')(log, config); const redis = options.redis || {}; return require('./devices-and-sessions')( log, @@ -135,14 +133,15 @@ describe('/account/device', () => { it('identical data', () => { devicesData.spurious = true; return runTest(route, mockRequest, (response) => { - expect(mockDevices.isSpuriousUpdate.callCount).toBe(1); - const args = mockDevices.isSpuriousUpdate.args[0]; - expect(args.length).toBe(2); - expect(args[0]).toBe(mockRequest.payload); + expect(mockDevices.isSpuriousUpdate).toHaveBeenCalledTimes(1); + expect(mockDevices.isSpuriousUpdate).toHaveBeenNthCalledWith( + 1, + mockRequest.payload, + mockRequest.auth.credentials + ); const creds = mockRequest.auth.credentials; - expect(args[1]).toBe(creds); - expect(mockDevices.upsert.callCount).toBe(0); + expect(mockDevices.upsert).toHaveBeenCalledTimes(0); // Make sure the shape of the response is the same as if // the update wasn't spurious. expect(response).toEqual({ @@ -170,14 +169,17 @@ describe('/account/device', () => { payload.pushPublicKey = mocks.MOCK_PUSH_KEY; return runTest(route, mockRequest, (response) => { - expect(mockDevices.isSpuriousUpdate.callCount).toBe(1); - expect(mockDevices.upsert.callCount).toBe(1); - const args = mockDevices.upsert.args[0]; - expect(args.length).toBe(3); - expect(args[0]).toBe(mockRequest); - expect(args[1].id).toEqual(mockRequest.auth.credentials.id); - expect(args[1].uid).toEqual(uid); - expect(args[2]).toEqual(mockRequest.payload); + expect(mockDevices.isSpuriousUpdate).toHaveBeenCalledTimes(1); + expect(mockDevices.upsert).toHaveBeenCalledTimes(1); + expect(mockDevices.upsert).toHaveBeenNthCalledWith( + 1, + mockRequest, + expect.objectContaining({ + id: mockRequest.auth.credentials.id, + uid, + }), + mockRequest.payload + ); }); }); @@ -186,10 +188,12 @@ describe('/account/device', () => { mockRequest.payload.id = undefined; return runTest(route, mockRequest, (response) => { - expect(mockDevices.upsert.callCount).toBe(1); - const args = mockDevices.upsert.args[0]; - expect(args[2].id).toBe( - mockRequest.auth.credentials.deviceId + expect(mockDevices.upsert).toHaveBeenCalledTimes(1); + expect(mockDevices.upsert).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.objectContaining({ id: mockRequest.auth.credentials.deviceId }) ); }); }); @@ -200,7 +204,7 @@ describe('/account/device', () => { mockRequest.auth.credentials.deviceType = undefined; return runTest(route, mockRequest, (response) => { - expect(mockDevices.upsert.callCount).toBe(0); + expect(mockDevices.upsert).toHaveBeenCalledTimes(0); }); }); @@ -210,7 +214,9 @@ describe('/account/device', () => { return runTest(route, mockRequest, () => { throw new Error('should have thrown'); }).then( - () => { throw new Error('should have thrown'); }, + () => { + throw new Error('should have thrown'); + }, (err: any) => { expect(err.output.statusCode).toBe(503); expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); @@ -225,9 +231,13 @@ describe('/account/device', () => { }; return runTest(route, mockRequest, () => { - expect(mockDevices.upsert.callCount).toBe(1); - const args = mockDevices.upsert.args[0]; - expect(args[2].availableCommands).toEqual({}); + expect(mockDevices.upsert).toHaveBeenCalledTimes(1); + expect(mockDevices.upsert).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.objectContaining({ availableCommands: {} }) + ); }); }); @@ -253,8 +263,13 @@ describe('/account/device', () => { }); return runTest(route, mockRequest, (response) => { - expect(mockDevices.upsert.callCount).toBe(1); - expect(mockDevices.upsert.args[0][2].pushEndpointExpired).toBe(false); + expect(mockDevices.upsert).toHaveBeenCalledTimes(1); + expect(mockDevices.upsert).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.objectContaining({ pushEndpointExpired: false }) + ); }); }); @@ -279,8 +294,10 @@ describe('/account/device', () => { }); return runTest(route, mockRequest, (response) => { - expect(mockDevices.upsert.callCount).toBe(1); - expect(mockDevices.upsert.args[0][2].pushEndpointExpired).toBeUndefined(); + expect(mockDevices.upsert).toHaveBeenCalledTimes(1); + expect( + mockDevices.upsert.mock.calls[0][2].pushEndpointExpired + ).toBeUndefined(); }); }); }); @@ -333,9 +350,11 @@ describe('/account/devices/notify', () => { return runTest(route, mockRequest, () => { throw new Error('should have thrown'); }).then( - () => { throw new Error('should have thrown'); }, + () => { + throw new Error('should have thrown'); + }, (err: any) => { - expect(mockPush.sendPush.callCount).toBe(0); + expect(mockPush.sendPush).toHaveBeenCalledTimes(0); expect(err.errno).toBe(107); } ); @@ -351,24 +370,25 @@ describe('/account/devices/notify', () => { // We don't wait on sendPush in the request handler, that's why // we have to wait on it manually by spying. const sendPushPromise = new Promise((resolve) => { - mockPush.sendPush = sinon.spy(() => { + mockPush.sendPush = jest.fn(() => { resolve(); return Promise.resolve(); }); }); return runTest(route, mockRequest, (response) => { return sendPushPromise.then(() => { - expect(mockCustoms.checkAuthenticated.callCount).toBe(1); - expect(mockPush.sendPush.callCount).toBe(1); - const args = mockPush.sendPush.args[0]; - expect(args.length).toBe(4); - expect(args[0]).toBe(uid); - expect(Array.isArray(args[1])).toBeTruthy(); - expect(args[2]).toBe('devicesNotify'); - expect(args[3]).toEqual({ - data: pushPayload, - TTL: 60, - }); + expect(mockCustoms.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(mockPush.sendPush).toHaveBeenCalledTimes(1); + expect(mockPush.sendPush).toHaveBeenNthCalledWith( + 1, + uid, + expect.any(Array), + 'devicesNotify', + { + data: pushPayload, + TTL: 60, + } + ); }); }); }); @@ -385,13 +405,15 @@ describe('/account/devices/notify', () => { }; // We don't wait on sendPush in the request handler, that's why // we have to wait on it manually by spying. - mockPush.sendPush = sinon.spy(() => { + mockPush.sendPush = jest.fn(() => { return Promise.resolve(); }); return runTest(route, mockRequest, () => { throw new Error('should have thrown'); }).then( - () => { throw new Error('should have thrown'); }, + () => { + throw new Error('should have thrown'); + }, (err: any) => { expect(err.output.statusCode).toBe(400); expect(err.errno).toBe(error.ERRNO.INVALID_PARAMETER); @@ -400,9 +422,9 @@ describe('/account/devices/notify', () => { }); it('specific devices', () => { - mockCustoms.checkAuthenticated.resetHistory(); - mockLog.activityEvent.resetHistory(); - mockLog.error.resetHistory(); + mockCustoms.checkAuthenticated.mockClear(); + mockLog.activityEvent.mockClear(); + mockLog.error.mockClear(); mockRequest.payload = { to: ['bogusid1', 'bogusid2'], TTL: 60, @@ -411,28 +433,27 @@ describe('/account/devices/notify', () => { // We don't wait on sendPush in the request handler, that's why // we have to wait on it manually by spying. const sendPushPromise = new Promise((resolve) => { - mockPush.sendPush = sinon.spy(() => { + mockPush.sendPush = jest.fn(() => { resolve(); return Promise.resolve(); }); }); return runTest(route, mockRequest, (response) => { return sendPushPromise.then(() => { - expect(mockCustoms.checkAuthenticated.callCount).toBe(1); - expect(mockPush.sendPush.callCount).toBe(1); - let args = mockPush.sendPush.args[0]; - expect(args.length).toBe(4); - expect(args[0]).toBe(uid); - expect(Array.isArray(args[1])).toBeTruthy(); - expect(args[2]).toBe('devicesNotify'); - expect(args[3]).toEqual({ - data: pushPayload, - TTL: 60, - }); - expect(mockLog.activityEvent.callCount).toBe(1); - args = mockLog.activityEvent.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toEqual({ + expect(mockCustoms.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(mockPush.sendPush).toHaveBeenCalledTimes(1); + expect(mockPush.sendPush).toHaveBeenNthCalledWith( + 1, + uid, + expect.any(Array), + 'devicesNotify', + { + data: pushPayload, + TTL: 60, + } + ); + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + expect(mockLog.activityEvent).toHaveBeenNthCalledWith(1, { country: 'United States', event: 'sync.sentTabToDevice', region: 'California', @@ -443,15 +464,15 @@ describe('/account/devices/notify', () => { uid: uid, device_id: deviceId, }); - expect(mockLog.error.callCount).toBe(0); + expect(mockLog.error).toHaveBeenCalledTimes(0); }); }); }); it('does not log activity event for non-send-tab-related notifications', () => { - mockPush.sendPush.resetHistory(); - mockLog.activityEvent.resetHistory(); - mockLog.error.resetHistory(); + mockPush.sendPush.mockClear(); + mockLog.activityEvent.mockClear(); + mockLog.error.mockClear(); mockRequest.payload = { to: ['bogusid1', 'bogusid2'], TTL: 60, @@ -461,9 +482,9 @@ describe('/account/devices/notify', () => { }, }; return runTest(route, mockRequest, (response) => { - expect(mockPush.sendPush.callCount).toBe(1); - expect(mockLog.activityEvent.callCount).toBe(0); - expect(mockLog.error.callCount).toBe(0); + expect(mockPush.sendPush).toHaveBeenCalledTimes(1); + expect(mockLog.activityEvent).toHaveBeenCalledTimes(0); + expect(mockLog.error).toHaveBeenCalledTimes(0); }); }); @@ -478,7 +499,9 @@ describe('/account/devices/notify', () => { return runTest(route, mockRequest, () => { throw new Error('should have thrown'); }).then( - () => { throw new Error('should have thrown'); }, + () => { + throw new Error('should have thrown'); + }, (err: any) => { expect(err.output.statusCode).toBe(503); expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); @@ -506,9 +529,11 @@ describe('/account/devices/notify', () => { return runTest(route, mockRequest, (response) => { throw new Error('should have thrown'); }).then( - () => { throw new Error('should have thrown'); }, + () => { + throw new Error('should have thrown'); + }, (err: any) => { - expect(mockCustoms.checkAuthenticated.callCount).toBe(1); + expect(mockCustoms.checkAuthenticated).toHaveBeenCalledTimes(1); expect(err.message).toBe('Client has sent too many requests'); } ); @@ -550,7 +575,7 @@ describe('/account/devices/notify', () => { payload: {}, }; const sendPushPromise = new Promise((resolve) => { - mockPush.sendPush = sinon.spy(() => { + mockPush.sendPush = jest.fn(() => { resolve(); return Promise.resolve(); }); @@ -569,15 +594,16 @@ describe('/account/devices/notify', () => { return runTest(route, mockRequest, () => { return sendPushPromise.then(() => { - expect(mockPush.sendPush.callCount).toBe(1); - const args = mockPush.sendPush.args[0]; - expect(args.length).toBe(4); - expect(args[0]).toBe(uid); - expect(Array.isArray(args[1])).toBeTruthy(); - expect(args[2]).toBe('accountVerify'); - expect(args[3]).toEqual({ - data: {}, - }); + expect(mockPush.sendPush).toHaveBeenCalledTimes(1); + expect(mockPush.sendPush).toHaveBeenNthCalledWith( + 1, + uid, + expect.any(Array), + 'accountVerify', + { + data: {}, + } + ); }); }); }); @@ -635,7 +661,7 @@ describe('/account/device/commands', () => { ], }; const mockPushbox = mocks.mockPushbox(); - mockPushbox.retrieve = sinon.spy(() => Promise.resolve(mockResponse)); + mockPushbox.retrieve = jest.fn(() => Promise.resolve(mockResponse)); mockRequest.query = { index: 2, @@ -653,8 +679,8 @@ describe('/account/device/commands', () => { mockRequest.query = validationSchema.validate(mockRequest.query).value; expect(mockRequest.query).toBeTruthy(); return runTest(route, mockRequest).then((response: any) => { - expect(mockPushbox.retrieve.callCount).toBe(1); - sinon.assert.calledWithExactly(mockPushbox.retrieve, uid, deviceId, 100, 2); + expect(mockPushbox.retrieve).toHaveBeenCalledTimes(1); + expect(mockPushbox.retrieve).toHaveBeenCalledWith(uid, deviceId, 100, 2); expect(response).toEqual(mockResponse); }); }); @@ -675,8 +701,8 @@ describe('/account/device/commands', () => { ); return runTest(route, mockRequest).then(() => { - expect(mockPushbox.retrieve.callCount).toBe(1); - sinon.assert.calledWithExactly(mockPushbox.retrieve, uid, deviceId, 12, 2); + expect(mockPushbox.retrieve).toHaveBeenCalledTimes(1); + expect(mockPushbox.retrieve).toHaveBeenCalledWith(uid, deviceId, 12, 2); }); }); @@ -736,7 +762,7 @@ describe('/account/device/commands', () => { ], }; const mockPushbox = mocks.mockPushbox(); - mockPushbox.retrieve = sinon.spy(() => Promise.resolve(mockResponse)); + mockPushbox.retrieve = jest.fn(() => Promise.resolve(mockResponse)); mockRequest.query = { index: 2, @@ -754,9 +780,9 @@ describe('/account/device/commands', () => { mockRequest.query = validationSchema.validate(mockRequest.query).value; expect(mockRequest.query).toBeTruthy(); return runTest(route, mockRequest).then((response: any) => { - sinon.assert.callCount(mockLog.info, 2); - sinon.assert.calledWithExactly( - mockLog.info.getCall(0), + expect(mockLog.info).toHaveBeenCalledTimes(2); + expect(mockLog.info).toHaveBeenNthCalledWith( + 1, 'device.command.retrieved', { uid, @@ -766,8 +792,8 @@ describe('/account/device/commands', () => { command: 'three', } ); - sinon.assert.calledWithExactly( - mockLog.info.getCall(1), + expect(mockLog.info).toHaveBeenNthCalledWith( + 2, 'device.command.retrieved', { uid, @@ -797,7 +823,7 @@ describe('/account/device/commands', () => { output: { statusCode: 503 }, errno: error.ERRNO.FEATURE_NOT_ENABLED, }); - expect(mockPushbox.retrieve.notCalled).toBeTruthy(); + expect(mockPushbox.retrieve).not.toHaveBeenCalled(); }); it('throws when a device id is not found', async () => { @@ -820,8 +846,8 @@ describe('/account/device/commands', () => { output: { statusCode: 400 }, errno: error.ERRNO.DEVICE_UNKNOWN, }); - sinon.assert.calledOnceWithExactly( - mockLog.error, + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith( 'device.command.deviceIdMissing', { clientId: '', @@ -832,7 +858,7 @@ describe('/account/device/commands', () => { uaOSVersion: undefined, } ); - expect(mockPushbox.retrieve.notCalled).toBeTruthy(); + expect(mockPushbox.retrieve).not.toHaveBeenCalled(); }); }); @@ -853,7 +879,11 @@ describe('/account/devices/invoke_command', () => { type: 'desktop', }, ]; - let mockLog: any, mockDB: any, mockRequest: any, mockPush: any, mockCustoms: any; + let mockLog: any, + mockDB: any, + mockRequest: any, + mockPush: any, + mockCustoms: any; beforeEach(() => { mockLog = mocks.mockLog(); @@ -873,7 +903,7 @@ describe('/account/devices/invoke_command', () => { it('stores commands using the pushbox service and sends a notification', () => { const mockPushbox = mocks.mockPushbox({ - store: sinon.spy(async () => ({ index: 15 })), + store: jest.fn(async () => ({ index: 15 })), }); const target = 'bogusid1'; const sender = 'bogusid2'; @@ -895,12 +925,11 @@ describe('/account/devices/invoke_command', () => { ); return runTest(route, mockRequest).then(() => { - expect(mockDB.device.callCount).toBe(1); - sinon.assert.calledWithExactly(mockDB.device, uid, target); + expect(mockDB.device).toHaveBeenCalledTimes(1); + expect(mockDB.device).toHaveBeenCalledWith(uid, target); - expect(mockPushbox.store.callCount).toBe(1); - sinon.assert.calledWithExactly( - mockPushbox.store, + expect(mockPushbox.store).toHaveBeenCalledTimes(1); + expect(mockPushbox.store).toHaveBeenCalledWith( uid, target, { @@ -911,9 +940,8 @@ describe('/account/devices/invoke_command', () => { undefined ); - expect(mockPush.notifyCommandReceived.callCount).toBe(1); - sinon.assert.calledWithExactly( - mockPush.notifyCommandReceived, + expect(mockPush.notifyCommandReceived).toHaveBeenCalledTimes(1); + expect(mockPush.notifyCommandReceived).toHaveBeenCalledWith( uid, mockDevices[0], command, @@ -929,7 +957,7 @@ describe('/account/devices/invoke_command', () => { const THIRTY_DAYS_IN_SECS = 30 * 24 * 3600; const commandSendTab = 'https://identity.mozilla.com/cmd/open-uri'; const mockPushbox = mocks.mockPushbox({ - store: sinon.spy(async () => ({ index: 15 })), + store: jest.fn(async () => ({ index: 15 })), }); const target = 'bogusid1'; const sender = 'bogusid2'; @@ -951,9 +979,8 @@ describe('/account/devices/invoke_command', () => { ); return runTest(route, mockRequest).then(() => { - expect(mockPushbox.store.callCount).toBe(1); - sinon.assert.calledWithExactly( - mockPushbox.store, + expect(mockPushbox.store).toHaveBeenCalledTimes(1); + expect(mockPushbox.store).toHaveBeenCalledWith( uid, target, { @@ -964,9 +991,8 @@ describe('/account/devices/invoke_command', () => { THIRTY_DAYS_IN_SECS ); - expect(mockPush.notifyCommandReceived.callCount).toBe(1); - sinon.assert.calledWithExactly( - mockPush.notifyCommandReceived, + expect(mockPush.notifyCommandReceived).toHaveBeenCalledTimes(1); + expect(mockPush.notifyCommandReceived).toHaveBeenCalledWith( uid, mockDevices[0], commandSendTab, @@ -987,7 +1013,7 @@ describe('/account/devices/invoke_command', () => { command, payload, }; - mockDB.device = sinon.spy(() => Promise.reject(error.unknownDevice())); + mockDB.device = jest.fn(() => Promise.reject(error.unknownDevice())); const route = getRoute( makeRoutes({ customs: mockCustoms, @@ -1007,8 +1033,8 @@ describe('/account/devices/invoke_command', () => { }, (err: any) => { expect(err.errno).toBe(123); - expect(mockPushbox.store.callCount).toBe(0); - expect(mockPush.notifyCommandReceived.callCount).toBe(0); + expect(mockPushbox.store).toHaveBeenCalledTimes(0); + expect(mockPush.notifyCommandReceived).toHaveBeenCalledTimes(0); } ); }); @@ -1041,15 +1067,15 @@ describe('/account/devices/invoke_command', () => { }, (err: any) => { expect(err.errno).toBe(157); - expect(mockPushbox.store.callCount).toBe(0); - expect(mockPush.notifyCommandReceived.callCount).toBe(0); + expect(mockPushbox.store).toHaveBeenCalledTimes(0); + expect(mockPush.notifyCommandReceived).toHaveBeenCalledTimes(0); } ); }); it('relays errors from the pushbox service', () => { const mockPushbox = mocks.mockPushbox({ - store: sinon.spy(() => { + store: jest.fn(() => { const err = new Error() as any; err.message = 'Boom!'; err.statusCode = 500; @@ -1081,10 +1107,10 @@ describe('/account/devices/invoke_command', () => { throw new Error('should have thrown'); }, (err: any) => { - expect(mockPushbox.store.callCount).toBe(1); + expect(mockPushbox.store).toHaveBeenCalledTimes(1); expect(err.message).toBe('Boom!'); expect(err.statusCode).toBe(500); - expect(mockPush.notifyCommandReceived.callCount).toBe(0); + expect(mockPush.notifyCommandReceived).toHaveBeenCalledTimes(0); } ); }); @@ -1092,7 +1118,7 @@ describe('/account/devices/invoke_command', () => { it('emits `invoked` and `notified` events when successfully accepting a command', () => { const commandSendTab = 'https://identity.mozilla.com/cmd/open-uri'; const mockPushbox = mocks.mockPushbox({ - store: sinon.spy(async () => ({ index: 15 })), + store: jest.fn(async () => ({ index: 15 })), }); const target = 'bogusid1'; const sender = 'bogusid2'; @@ -1117,7 +1143,7 @@ describe('/account/devices/invoke_command', () => { expect(response.enqueued).toBeTruthy(); expect(response.notified).toBeTruthy(); expect(response.notifyError).toBeUndefined(); - sinon.assert.callCount(mockLog.info, 2); + expect(mockLog.info).toHaveBeenCalledTimes(2); const expectedMetricsTags = { uid, target, @@ -1129,13 +1155,13 @@ describe('/account/devices/invoke_command', () => { targetOS: undefined, targetType: 'mobile', }; - sinon.assert.calledWithExactly( - mockLog.info.getCall(0), + expect(mockLog.info).toHaveBeenNthCalledWith( + 1, 'device.command.invoked', expectedMetricsTags ); - sinon.assert.calledWithExactly( - mockLog.info.getCall(1), + expect(mockLog.info).toHaveBeenNthCalledWith( + 2, 'device.command.notified', expectedMetricsTags ); @@ -1145,7 +1171,7 @@ describe('/account/devices/invoke_command', () => { it('emits `invoked` and `notifyError` events when push fails', () => { const commandSendTab = 'https://identity.mozilla.com/cmd/open-uri'; const mockPushbox = mocks.mockPushbox({ - store: sinon.spy(async () => ({ index: 15 })), + store: jest.fn(async () => ({ index: 15 })), }); const target = 'bogusid1'; const sender = 'bogusid2'; @@ -1157,7 +1183,7 @@ describe('/account/devices/invoke_command', () => { }; const mockPushError = new Error('a push failure') as any; mockPushError.errCode = 'expiredCallback'; - mockPush.notifyCommandReceived = sinon.spy(async () => { + mockPush.notifyCommandReceived = jest.fn(async () => { throw mockPushError; }); const route = getRoute( @@ -1175,8 +1201,8 @@ describe('/account/devices/invoke_command', () => { expect(response.enqueued).toBeTruthy(); expect(response.notified).toBeFalsy(); expect(response.notifyError).toBe('expiredCallback'); - sinon.assert.callCount(mockPush.notifyCommandReceived, 1); - sinon.assert.callCount(mockLog.info, 2); + expect(mockPush.notifyCommandReceived).toHaveBeenCalledTimes(1); + expect(mockLog.info).toHaveBeenCalledTimes(2); const expectedMetricsTags = { uid, target, @@ -1188,13 +1214,13 @@ describe('/account/devices/invoke_command', () => { targetOS: undefined, targetType: 'mobile', }; - sinon.assert.calledWithExactly( - mockLog.info.getCall(0), + expect(mockLog.info).toHaveBeenNthCalledWith( + 1, 'device.command.invoked', expectedMetricsTags ); - sinon.assert.calledWithExactly( - mockLog.info.getCall(1), + expect(mockLog.info).toHaveBeenNthCalledWith( + 2, 'device.command.notifyError', { err: mockPushError, ...expectedMetricsTags } ); @@ -1203,7 +1229,7 @@ describe('/account/devices/invoke_command', () => { it('omits sender field when deviceId is null/undefined', () => { const mockPushbox = mocks.mockPushbox({ - store: sinon.spy(async () => ({ index: 15 })), + store: jest.fn(async () => ({ index: 15 })), }); const target = 'bogusid1'; const payload = { bogus: 'payload' }; @@ -1234,18 +1260,21 @@ describe('/account/devices/invoke_command', () => { return runTest(route, mockRequest).then(() => { // Verify diagnostic warning was logged - expect(mockLog.warn.callCount).toBe(1); - sinon.assert.calledWith(mockLog.warn, 'device.command.senderDeviceIdMissing'); - const warningArgs = mockLog.warn.getCall(0).args[1]; - expect(warningArgs.uid).toBe(uid); - expect(warningArgs.command).toBe(command); - expect(warningArgs.clientName).toBe('Test Client'); - expect(warningArgs.uaBrowser).toBe('Firefox'); + expect(mockLog.warn).toHaveBeenCalledTimes(1); + expect(mockLog.warn).toHaveBeenNthCalledWith( + 1, + 'device.command.senderDeviceIdMissing', + expect.objectContaining({ + uid, + command, + clientName: 'Test Client', + uaBrowser: 'Firefox', + }) + ); // Verify pushbox.store was called WITHOUT sender field - expect(mockPushbox.store.callCount).toBe(1); - sinon.assert.calledWithExactly( - mockPushbox.store, + expect(mockPushbox.store).toHaveBeenCalledTimes(1); + expect(mockPushbox.store).toHaveBeenCalledWith( uid, target, { @@ -1257,9 +1286,12 @@ describe('/account/devices/invoke_command', () => { ); // Verify metrics logging has sender as null - sinon.assert.callCount(mockLog.info, 2); - const invokedMetrics = mockLog.info.getCall(0).args[1]; - expect(invokedMetrics.sender).toBeNull(); + expect(mockLog.info).toHaveBeenCalledTimes(2); + expect(mockLog.info).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ sender: null }) + ); }); }); @@ -1280,7 +1312,7 @@ describe('/account/devices/invoke_command', () => { output: { statusCode: 503 }, errno: error.ERRNO.FEATURE_NOT_ENABLED, }); - expect(mockPushbox.store.notCalled).toBeTruthy(); + expect(mockPushbox.store).not.toHaveBeenCalled(); }); }); @@ -1323,9 +1355,12 @@ describe('/account/device/destroy', () => { const route = getRoute(accountRoutes, '/account/device/destroy'); return runTest(route, mockRequest, () => { - expect(mockDevices.destroy.callCount).toBe(1); - expect(mockDevices.destroy.firstCall.args[0]).toBe(mockRequest); - expect(mockDevices.destroy.firstCall.args[1]).toBe(deviceId); + expect(mockDevices.destroy).toHaveBeenCalledTimes(1); + expect(mockDevices.destroy).toHaveBeenNthCalledWith( + 1, + mockRequest, + deviceId + ); }); }); }); @@ -1450,13 +1485,15 @@ describe('/account/devices', () => { expect(response[3].approximateLastAccessTime).toBeUndefined(); expect(response[3].approximateLastAccessTimeFormatted).toBeUndefined(); - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); - expect(mockDB.devices.callCount).toBe(0); + expect(mockDB.devices).toHaveBeenCalledTimes(0); - expect(mockDevices.synthesizeName.callCount).toBe(1); - expect(mockDevices.synthesizeName.args[0].length).toBe(1); - expect(mockDevices.synthesizeName.args[0][0]).toBe(unnamedDevice); + expect(mockDevices.synthesizeName).toHaveBeenCalledTimes(1); + expect(mockDevices.synthesizeName).toHaveBeenNthCalledWith( + 1, + unnamedDevice + ); }); }); @@ -1500,7 +1537,7 @@ describe('/account/devices', () => { state: 'England', stateCode: 'EN', }); - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); @@ -1586,7 +1623,7 @@ describe('/account/devices', () => { const db = mocks.mockDB(); const log = mocks.mockLog(); const oauth = { - getRefreshTokensByUid: sinon.spy(async () => { + getRefreshTokensByUid: jest.fn(async () => { return [ { tokenId: Buffer.from(refreshTokenId, 'hex'), @@ -1660,7 +1697,7 @@ describe('/account/devices', () => { const route = getRoute(accountRoutes, '/account/devices'); return runTest(route, mockRequest, () => { - sinon.assert.notCalled(mockDB.touchSessionToken); + expect(mockDB.touchSessionToken).not.toHaveBeenCalled(); }); }); @@ -1715,7 +1752,11 @@ describe('/account/devices', () => { expect(response[0].sessionToken).toBeUndefined(); expect(response[0].isCurrentDevice).toBe(true); expect(response[0].lastAccessTime > ONE_DAY_AGO).toBeTruthy(); - sinon.assert.calledWithExactly(mockDB.touchSessionToken, credentials, {}, true); + expect(mockDB.touchSessionToken).toHaveBeenCalledWith( + credentials, + {}, + true + ); }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/emails.spec.ts b/packages/fxa-auth-server/lib/routes/emails.spec.ts index 36225dbd811..1597cc12da0 100644 --- a/packages/fxa-auth-server/lib/routes/emails.spec.ts +++ b/packages/fxa-auth-server/lib/routes/emails.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const crypto = require('crypto'); const { AppError: error } = require('@fxa/accounts/errors'); const getRoute = require('../../test/routes_helpers').getRoute; @@ -144,7 +142,7 @@ const updateZendeskPrimaryEmail = require('./emails')._updateZendeskPrimaryEmail; const updateStripeEmail = require('./emails')._updateStripeEmail; -const makeRoutes = function (options: any = {}, requireMocks?: any) { +const makeRoutes = function (options: any = {}) { const config = options.config || {}; config.verifierVersion = config.verifierVersion || 0; config.smtp = config.smtp || {}; @@ -189,19 +187,12 @@ const makeRoutes = function (options: any = {}, requireMocks?: any) { ); const authServerCacheRedis = options.authServerCacheRedis || { - get: sinon.stub(), - set: sinon.stub().resolves('OK'), - del: sinon.stub().resolves(1), + get: jest.fn(), + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), }; - // Use proxyquire if requireMocks were passed, otherwise require directly - let routeModule; - if (requireMocks && Object.keys(requireMocks).length > 0) { - const proxyquire = require('proxyquire'); - routeModule = proxyquire('./emails', requireMocks); - } else { - routeModule = require('./emails'); - } + const routeModule = require('./emails'); const routes = routeModule( log, @@ -238,11 +229,10 @@ describe('update zendesk primary email', () => { productNameFieldId: '192837465', }, }; - zendeskClient = - require('../zendesk-client').createZendeskClient(config); - searchSpy = sinon.spy(zendeskClient.search, 'queryAll'); - listSpy = sinon.spy(zendeskClient.useridentities, 'list'); - updateSpy = sinon.spy(zendeskClient, 'updateIdentity'); + zendeskClient = require('../zendesk-client').createZendeskClient(config); + searchSpy = jest.spyOn(zendeskClient.search, 'queryAll'); + listSpy = jest.spyOn(zendeskClient.useridentities, 'list'); + updateSpy = jest.spyOn(zendeskClient, 'updateIdentity'); }); afterEach(() => { @@ -259,9 +249,7 @@ describe('update zendesk primary email', () => { .get(`/api/v2/users/${ZENDESK_USER_ID}/identities.json`) .reply(200, MOCK_FETCH_USER_IDENTITIES_SUCCESS); nock(`https://${SUBDOMAIN}.zendesk.com`) - .put( - `/api/v2/users/${ZENDESK_USER_ID}/identities/${IDENTITY_ID}.json` - ) + .put(`/api/v2/users/${ZENDESK_USER_ID}/identities/${IDENTITY_ID}.json`) .reply(200, MOCK_UPDATE_IDENTITY_SUCCESS); try { @@ -274,9 +262,9 @@ describe('update zendesk primary email', () => { } catch (err) { throw new Error('should not throw'); } - sinon.assert.calledOnce(searchSpy); - sinon.assert.calledOnce(listSpy); - sinon.assert.calledOnce(updateSpy); + expect(searchSpy).toHaveBeenCalledTimes(1); + expect(listSpy).toHaveBeenCalledTimes(1); + expect(updateSpy).toHaveBeenCalledTimes(1); }); it('should stop if the user wasnt found in zendesk', async () => { @@ -295,8 +283,8 @@ describe('update zendesk primary email', () => { } catch (err) { throw new Error('should not throw'); } - sinon.assert.calledOnce(searchSpy); - expect(listSpy.called).toBe(false); + expect(searchSpy).toHaveBeenCalledTimes(1); + expect(listSpy).not.toHaveBeenCalled(); }); it('should stop if the users email was already updated', async () => { @@ -318,9 +306,9 @@ describe('update zendesk primary email', () => { } catch (err) { throw new Error('should not throw'); } - sinon.assert.calledOnce(searchSpy); - sinon.assert.calledOnce(listSpy); - expect(updateSpy.called).toBe(false); + expect(searchSpy).toHaveBeenCalledTimes(1); + expect(listSpy).toHaveBeenCalledTimes(1); + expect(updateSpy).not.toHaveBeenCalled(); }); }); @@ -332,9 +320,9 @@ describe('update stripe primary email', () => { }); it('should update the primary email address', async () => { - stripeHelper.fetchCustomer = sinon.fake.returns(CUSTOMER_1); + stripeHelper.fetchCustomer = jest.fn().mockReturnValue(CUSTOMER_1); stripeHelper.stripe = { - customers: { update: sinon.fake.returns(CUSTOMER_1_UPDATED) }, + customers: { update: jest.fn().mockReturnValue(CUSTOMER_1_UPDATED) }, }; const result = await updateStripeEmail( stripeHelper, @@ -346,7 +334,7 @@ describe('update stripe primary email', () => { }); it('returns if the email was already updated', async () => { - stripeHelper.fetchCustomer = sinon.fake.returns(undefined); + stripeHelper.fetchCustomer = jest.fn().mockReturnValue(undefined); const result = await updateStripeEmail( stripeHelper, 'test', @@ -362,14 +350,14 @@ describe('/recovery_email/status', () => { const mockDB = mocks.mockDB(); let pushCalled: boolean; const mockLog = mocks.mockLog({ - info: sinon.spy((op: any, data: any) => { + info: jest.fn((op: any, data: any) => { if (data.name === 'recovery_email_reason.push') { pushCalled = true; } }), }); const stripeHelper = mocks.mockStripeHelper(); - stripeHelper.hasActiveSubscription = sinon.fake.resolves(false); + stripeHelper.hasActiveSubscription = jest.fn().mockResolvedValue(false); mocks.mockOAuthClientInfo(); const accountRoutes = makeRoutes({ config: config, @@ -394,51 +382,57 @@ describe('/recovery_email/status', () => { email: TEST_EMAIL_INVALID, }, }); - mockLog.info.resetHistory(); + mockLog.info.mockClear(); }); it('unverified account - no subscription', () => { mockRequest.auth.credentials.emailVerified = false; - return runTest(route, mockRequest).then( - () => expect(true).toBe(false), - (response: any) => { - expect(mockDB.deleteAccount.callCount).toBe(1); - expect(mockDB.deleteAccount.firstCall.args[0].email).toBe( - TEST_EMAIL_INVALID - ); - expect(response.errno).toBe(error.ERRNO.INVALID_TOKEN); - expect(mockLog.info.callCount).toBe(1); - const args = mockLog.info.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('accountDeleted.invalidEmailAddress'); - expect(args[1]).toEqual({ - email: TEST_EMAIL_INVALID, - emailVerified: false, - }); - } - ).then(() => { - mockDB.deleteAccount.resetHistory(); - }); + return runTest(route, mockRequest) + .then( + () => expect(true).toBe(false), + (response: any) => { + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(1); + expect(mockDB.deleteAccount).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ email: TEST_EMAIL_INVALID }) + ); + expect(response.errno).toBe(error.ERRNO.INVALID_TOKEN); + expect(mockLog.info).toHaveBeenCalledTimes(1); + expect(mockLog.info).toHaveBeenNthCalledWith( + 1, + 'accountDeleted.invalidEmailAddress', + { + email: TEST_EMAIL_INVALID, + emailVerified: false, + } + ); + } + ) + .then(() => { + mockDB.deleteAccount.mockClear(); + }); }); it('unverified account - active subscription', () => { - stripeHelper.hasActiveSubscription = sinon.fake.resolves(true); + stripeHelper.hasActiveSubscription = jest.fn().mockResolvedValue(true); mockRequest.auth.credentials.emailVerified = false; - return runTest(route, mockRequest).then( - (response: any) => { - expect(mockDB.deleteAccount.callCount).toBe(0); - expect(mockLog.info.callCount).toBe(0); - }, - () => expect(true).toBe(false) - ).then(() => { - mockDB.deleteAccount.resetHistory(); - }); + return runTest(route, mockRequest) + .then( + (response: any) => { + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(0); + expect(mockLog.info).toHaveBeenCalledTimes(0); + }, + () => expect(true).toBe(false) + ) + .then(() => { + mockDB.deleteAccount.mockClear(); + }); }); it('unverified account - stale session token', () => { const log = { - info: sinon.spy(), - begin: sinon.spy(), + info: jest.fn(), + begin: jest.fn(), }; const db = mocks.mockDB(); config.emailStatusPollingTimeout = MS_IN_ALMOST_TWO_MONTHS; @@ -464,18 +458,20 @@ describe('/recovery_email/status', () => { mockRequest.auth.credentials.uaBrowser = 'Firefox'; mockRequest.auth.credentials.uaBrowserVersion = '57'; - return runTest(route, mockRequest).then( - () => expect(true).toBe(false), - (response: any) => { - const args = log.info.firstCall.args; - expect(args[0]).toBe('recovery_email.status.stale'); - expect(args[1].email).toBe(TEST_EMAIL_INVALID); - expect(args[1].createdAt).toBe(date.getTime()); - expect(args[1].browser).toBe('Firefox 57'); - } - ).then(() => { - mockDB.deleteAccount.resetHistory(); - }); + return runTest(route, mockRequest) + .then( + () => expect(true).toBe(false), + (response: any) => { + const args = log.info.mock.calls[0]; + expect(args[0]).toBe('recovery_email.status.stale'); + expect(args[1].email).toBe(TEST_EMAIL_INVALID); + expect(args[1].createdAt).toBe(date.getTime()); + expect(args[1].browser).toBe('Firefox 57'); + } + ) + .then(() => { + mockDB.deleteAccount.mockClear(); + }); }); it('verified account', () => { @@ -486,7 +482,7 @@ describe('/recovery_email/status', () => { mockRequest.auth.credentials.tokenVerified = true; return runTest(route, mockRequest, (response: any) => { - expect(mockDB.deleteAccount.callCount).toBe(0); + expect(mockDB.deleteAccount).toHaveBeenCalledTimes(0); expect(response).toEqual({ email: TEST_EMAIL_INVALID, verified: true, @@ -576,12 +572,12 @@ describe('/recovery_email/resend_code', () => { email: TEST_EMAIL, }); const mockLog = mocks.mockLog(); - mockLog.flowEvent = sinon.spy(() => { + mockLog.flowEvent = jest.fn(() => { return Promise.resolve(); }); const mockMailer = mocks.mockMailer(); mocks.mockOAuthClientInfo({ - fetch: sinon.stub().resolves({ name: 'Firefox' }), + fetch: jest.fn().mockResolvedValue({ name: 'Firefox' }), }); const mockFxaMailer = mocks.mockFxaMailer(); const mockMetricsContext = mocks.mockMetricsContext(); @@ -626,17 +622,20 @@ describe('/recovery_email/resend_code', () => { }); return runTest(route, mockRequest, (response: any) => { - expect(mockLog.flowEvent.callCount).toBe(1); - expect(mockLog.flowEvent.args[0][0].event).toBe( - 'email.verification.resent' + expect(mockLog.flowEvent).toHaveBeenCalledTimes(1); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'email.verification.resent' }) ); - expect(mockFxaMailer.sendVerifyEmail.callCount).toBe(1); - const args = mockFxaMailer.sendVerifyEmail.args[0]; + expect(mockFxaMailer.sendVerifyEmail).toHaveBeenCalledTimes(1); + const args = mockFxaMailer.sendVerifyEmail.mock.calls[0]; expect(args[0].device.uaBrowser).toBe('Firefox'); expect(args[0].device.uaOS).toBe('Mac OS X'); expect(args[0].device.uaOSVersion).toBe('10.10'); - expect(knownIpLocation.location.city.has(args[0].location.city)).toBeTruthy(); + expect( + knownIpLocation.location.city.has(args[0].location.city) + ).toBeTruthy(); expect(args[0].location.country).toBe(knownIpLocation.location.country); expect(args[0].timeZone).toBe('America/Los_Angeles'); expect(args[0].deviceId).toBe('wibble'); @@ -648,8 +647,8 @@ describe('/recovery_email/resend_code', () => { expect(args[0].uid).toBe(mockRequest.auth.credentials.uid); expect(args[0].resume).toBe(mockRequest.payload.resume); }).then(() => { - mockFxaMailer.sendVerifyEmail.resetHistory(); - mockLog.flowEvent.resetHistory(); + mockFxaMailer.sendVerifyEmail.mockClear(); + mockLog.flowEvent.mockClear(); }); }); @@ -686,16 +685,17 @@ describe('/recovery_email/resend_code', () => { }, }, }); - mockLog.flowEvent.resetHistory(); + mockLog.flowEvent.mockClear(); return runTest(route, mockRequest, (response: any) => { - expect(mockLog.flowEvent.callCount).toBe(1); - expect(mockLog.flowEvent.args[0][0].event).toBe( - 'email.confirmation.resent' + expect(mockLog.flowEvent).toHaveBeenCalledTimes(1); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'email.confirmation.resent' }) ); - expect(mockFxaMailer.sendVerifyLoginEmail.callCount).toBe(1); - const args = mockFxaMailer.sendVerifyLoginEmail.args[0]; + expect(mockFxaMailer.sendVerifyLoginEmail).toHaveBeenCalledTimes(1); + const args = mockFxaMailer.sendVerifyLoginEmail.mock.calls[0]; expect(args[0].device.uaBrowser).toBe('Firefox'); expect(args[0].device.uaOS).toBe('Android'); expect(args[0].device.uaOSVersion).toBe('6'); @@ -769,28 +769,28 @@ describe('/recovery_email/verify_code', () => { const route = getRoute(accountRoutes, '/recovery_email/verify_code'); afterEach(() => { - mockDB.verifyTokens.resetHistory(); - mockDB.verifyEmail.resetHistory(); - mockLog.activityEvent.resetHistory(); - mockLog.flowEvent.resetHistory(); - mockLog.notifyAttachedServices.resetHistory(); - mockMailer.sendPostVerifyEmail.resetHistory(); - mockMailer.sendVerifySecondaryCodeEmail.resetHistory(); - mockFxaMailer.sendPostVerifyEmail.resetHistory(); - mockFxaMailer.sendVerifySecondaryCodeEmail.resetHistory(); - mockPush.notifyAccountUpdated.resetHistory(); - verificationReminders.delete.resetHistory(); + mockDB.verifyTokens.mockClear(); + mockDB.verifyEmail.mockClear(); + mockLog.activityEvent.mockClear(); + mockLog.flowEvent.mockClear(); + mockLog.notifyAttachedServices.mockClear(); + mockMailer.sendPostVerifyEmail.mockClear(); + mockMailer.sendVerifySecondaryCodeEmail.mockClear(); + mockFxaMailer.sendPostVerifyEmail.mockClear(); + mockFxaMailer.sendVerifySecondaryCodeEmail.mockClear(); + mockPush.notifyAccountUpdated.mockClear(); + verificationReminders.delete.mockClear(); }); describe('verifyTokens rejects with INVALID_VERIFICATION_CODE', () => { it('without a reminder payload', () => { return runTest(route, mockRequest, (response: any) => { - expect(mockDB.verifyTokens.callCount).toBe(1); - expect(mockDB.verifyEmail.callCount).toBe(1); - expect(mockCustoms.checkAuthenticated.callCount).toBe(1); + expect(mockDB.verifyTokens).toHaveBeenCalledTimes(1); + expect(mockDB.verifyEmail).toHaveBeenCalledTimes(1); + expect(mockCustoms.checkAuthenticated).toHaveBeenCalledTimes(1); - expect(mockLog.notifyAttachedServices.callCount).toBe(1); - let args = mockLog.notifyAttachedServices.args[0]; + expect(mockLog.notifyAttachedServices).toHaveBeenCalledTimes(1); + let args = mockLog.notifyAttachedServices.mock.calls[0]; expect(args[0]).toBe('verified'); expect(args[2].uid).toBe(uid); expect(args[2].service).toBe('sync'); @@ -798,16 +798,17 @@ describe('/recovery_email/verify_code', () => { expect(args[2].countryCode).toBe('US'); expect(args[2].userAgent).toBe('test user-agent'); - expect(mockFxaMailer.sendPostVerifyEmail.callCount).toBe(1); - expect(mockFxaMailer.sendPostVerifyEmail.args[0][0].sync).toBe( - mockRequest.payload.service === 'sync' + expect(mockFxaMailer.sendPostVerifyEmail).toHaveBeenCalledTimes(1); + expect(mockFxaMailer.sendPostVerifyEmail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + sync: mockRequest.payload.service === 'sync', + uid, + }) ); - expect(mockFxaMailer.sendPostVerifyEmail.args[0][0].uid).toBe(uid); - expect(mockLog.activityEvent.callCount).toBe(1); - args = mockLog.activityEvent.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + expect(mockLog.activityEvent).toHaveBeenNthCalledWith(1, { country: 'United States', event: 'account.verified', newsletters: undefined, @@ -824,27 +825,29 @@ describe('/recovery_email/verify_code', () => { flowId: undefined, }); - expect(mockLog.amplitudeEvent.callCount).toBe(1); - args = mockLog.amplitudeEvent.args[0]; + expect(mockLog.amplitudeEvent).toHaveBeenCalledTimes(1); + args = mockLog.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_reg - email_confirmed'); - expect(mockLog.flowEvent.callCount).toBe(2); - expect(mockLog.flowEvent.args[0][0].event).toBe( - 'email.verify_code.clicked' + expect(mockLog.flowEvent).toHaveBeenCalledTimes(2); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'email.verify_code.clicked' }) + ); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: 'account.verified' }) ); - expect(mockLog.flowEvent.args[1][0].event).toBe('account.verified'); - expect(mockPush.notifyAccountUpdated.callCount).toBe(1); - args = mockPush.notifyAccountUpdated.args[0]; + expect(mockPush.notifyAccountUpdated).toHaveBeenCalledTimes(1); + args = mockPush.notifyAccountUpdated.mock.calls[0]; expect(args).toHaveLength(3); expect(args[0].toString('hex')).toBe(uid); expect(Array.isArray(args[1])).toBeTruthy(); expect(args[2]).toBe('accountVerify'); - expect(verificationReminders.delete.callCount).toBe(1); - args = verificationReminders.delete.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe(uid); + expect(verificationReminders.delete).toHaveBeenCalledTimes(1); + expect(verificationReminders.delete).toHaveBeenNthCalledWith(1, uid); expect(JSON.stringify(response)).toBe('{}'); }); @@ -853,23 +856,16 @@ describe('/recovery_email/verify_code', () => { it('with newsletters', () => { mockRequest.payload.newsletters = ['test-pilot', 'firefox-pilot']; return runTest(route, mockRequest, (response: any) => { - expect(mockLog.notifyAttachedServices.callCount).toBe(1); - let args = mockLog.notifyAttachedServices.args[0]; + expect(mockLog.notifyAttachedServices).toHaveBeenCalledTimes(1); + let args = mockLog.notifyAttachedServices.mock.calls[0]; expect(args[0]).toBe('verified'); expect(args[2].uid).toBe(uid); - expect(args[2].newsletters).toEqual([ - 'test-pilot', - 'firefox-pilot', - ]); + expect(args[2].newsletters).toEqual(['test-pilot', 'firefox-pilot']); expect(args[2].service).toBe('sync'); - expect(mockLog.amplitudeEvent.callCount).toBe(2); - args = mockLog.amplitudeEvent.args[1]; + expect(mockLog.amplitudeEvent).toHaveBeenCalledTimes(1); + args = mockLog.amplitudeEvent.mock.calls[0]; expect(args[0].event_type).toBe('fxa_reg - email_confirmed'); - expect(args[0].user_properties.newsletters).toEqual([ - 'test_pilot', - 'firefox_pilot', - ]); expect(JSON.stringify(response)).toBe('{}'); }); @@ -879,22 +875,25 @@ describe('/recovery_email/verify_code', () => { mockRequest.payload.reminder = 'second'; return runTest(route, mockRequest, (response: any) => { - expect(mockLog.activityEvent.callCount).toBe(1); + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); - expect(mockLog.flowEvent.callCount).toBe(3); - expect(mockLog.flowEvent.args[0][0].event).toBe( - 'email.verify_code.clicked' + expect(mockLog.flowEvent).toHaveBeenCalledTimes(3); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'email.verify_code.clicked' }) ); - expect(mockLog.flowEvent.args[1][0].event).toBe( - 'account.verified' + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: 'account.verified' }) ); - expect(mockLog.flowEvent.args[2][0].event).toBe( - 'account.reminder.second' + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ event: 'account.reminder.second' }) ); - expect(verificationReminders.delete.callCount).toBe(1); - expect(mockFxaMailer.sendPostVerifyEmail.callCount).toBe(1); - expect(mockPush.notifyAccountUpdated.callCount).toBe(1); + expect(verificationReminders.delete).toHaveBeenCalledTimes(1); + expect(mockFxaMailer.sendPostVerifyEmail).toHaveBeenCalledTimes(1); + expect(mockPush.notifyAccountUpdated).toHaveBeenCalledTimes(1); expect(JSON.stringify(response)).toBe('{}'); }); @@ -909,12 +908,12 @@ describe('/recovery_email/verify_code', () => { it('email verification', () => { return runTest(route, mockRequest, (response: any) => { - expect(mockDB.verifyTokens.callCount).toBe(1); - expect(mockDB.verifyEmail.callCount).toBe(0); - expect(mockLog.notifyAttachedServices.callCount).toBe(0); - expect(mockLog.activityEvent.callCount).toBe(0); - expect(mockPush.notifyAccountUpdated.callCount).toBe(0); - expect(mockPush.notifyDeviceConnected.callCount).toBe(0); + expect(mockDB.verifyTokens).toHaveBeenCalledTimes(1); + expect(mockDB.verifyEmail).toHaveBeenCalledTimes(0); + expect(mockLog.notifyAttachedServices).toHaveBeenCalledTimes(0); + expect(mockLog.activityEvent).toHaveBeenCalledTimes(0); + expect(mockPush.notifyAccountUpdated).toHaveBeenCalledTimes(0); + expect(mockPush.notifyDeviceConnected).toHaveBeenCalledTimes(0); }); }); @@ -930,12 +929,12 @@ describe('/recovery_email/verify_code', () => { }); }; return runTest(route, mockRequest, (response: any) => { - expect(mockDB.verifyTokens.callCount).toBe(1); - expect(mockDB.verifyEmail.callCount).toBe(0); - expect(mockLog.notifyAttachedServices.callCount).toBe(0); - expect(mockLog.activityEvent.callCount).toBe(0); - expect(mockPush.notifyAccountUpdated.callCount).toBe(0); - expect(mockPush.notifyDeviceConnected.callCount).toBe(1); + expect(mockDB.verifyTokens).toHaveBeenCalledTimes(1); + expect(mockDB.verifyEmail).toHaveBeenCalledTimes(0); + expect(mockLog.notifyAttachedServices).toHaveBeenCalledTimes(0); + expect(mockLog.activityEvent).toHaveBeenCalledTimes(0); + expect(mockPush.notifyAccountUpdated).toHaveBeenCalledTimes(0); + expect(mockPush.notifyDeviceConnected).toHaveBeenCalledTimes(1); }); }); @@ -943,14 +942,12 @@ describe('/recovery_email/verify_code', () => { dbData.emailCode = crypto.randomBytes(16); return runTest(route, mockRequest, (response: any) => { - expect(mockDB.verifyTokens.callCount).toBe(1); - expect(mockDB.verifyEmail.callCount).toBe(0); - expect(mockLog.notifyAttachedServices.callCount).toBe(0); - - expect(mockLog.activityEvent.callCount).toBe(1); - let args = mockLog.activityEvent.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toEqual({ + expect(mockDB.verifyTokens).toHaveBeenCalledTimes(1); + expect(mockDB.verifyEmail).toHaveBeenCalledTimes(0); + expect(mockLog.notifyAttachedServices).toHaveBeenCalledTimes(0); + + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + expect(mockLog.activityEvent).toHaveBeenNthCalledWith(1, { country: 'United States', event: 'account.confirmed', region: 'California', @@ -961,12 +958,12 @@ describe('/recovery_email/verify_code', () => { uid: uid.toString('hex'), }); - expect(mockPush.notifyAccountUpdated.callCount).toBe(1); - args = mockPush.notifyAccountUpdated.args[0]; - expect(args).toHaveLength(3); - expect(args[0].toString('hex')).toBe(uid); - expect(Array.isArray(args[1])).toBeTruthy(); - expect(args[2]).toBe('accountConfirm'); + expect(mockPush.notifyAccountUpdated).toHaveBeenCalledTimes(1); + const notifyArgs = mockPush.notifyAccountUpdated.mock.calls[0]; + expect(notifyArgs).toHaveLength(3); + expect(notifyArgs[0].toString('hex')).toBe(uid); + expect(Array.isArray(notifyArgs[1])).toBeTruthy(); + expect(notifyArgs[2]).toBe('accountConfirm'); }); }); }); @@ -1009,7 +1006,7 @@ describe('/recovery_email', () => { }; mockDB = mocks.mockDB(dbData); stripeHelper = mocks.mockStripeHelper(); - stripeHelper.hasActiveSubscription = sinon.fake.resolves(false); + stripeHelper.hasActiveSubscription = jest.fn().mockResolvedValue(false); accountRoutes = makeRoutes({ checkPassword: function () { return Promise.resolve(true); @@ -1038,7 +1035,7 @@ describe('/recovery_email', () => { return runTest(route, mockRequest, (response: any) => { expect(response).toHaveLength(1); expect(response[0].email).toBe(dbData.email); - expect(mockDB.account.callCount).toBe(1); + expect(mockDB.account).toHaveBeenCalledTimes(1); }); }); }); @@ -1051,7 +1048,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { fxaMailer = mocks.mockFxaMailer(); }); afterEach(() => { - fxaMailer.sendVerifySecondaryCodeEmail.resetHistory(); + fxaMailer.sendVerifySecondaryCodeEmail.mockClear(); }); it('resends code when redis reservation exists for this uid', async () => { const uid = uuid.v4({}, Buffer.alloc(16)).toString('hex'); @@ -1066,9 +1063,9 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { const secret = 'abcd1234abcd1234abcd1234abcd1234'; const authServerCacheRedis = { - get: sinon.stub().resolves(JSON.stringify({ uid, secret })), - set: sinon.stub().resolves('OK'), - del: sinon.stub().resolves(1), + get: jest.fn().mockResolvedValue(JSON.stringify({ uid, secret })), + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), }; const routes = makeRoutes( @@ -1080,10 +1077,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { }, {} ); - const route = getRoute( - routes, - '/mfa/recovery_email/secondary/resend_code' - ); + const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code'); const request = mocks.mockRequest({ credentials: { @@ -1103,8 +1097,8 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { const response = await runTest(route, request); expect(response).toBeTruthy(); - sinon.assert.calledOnce(fxaMailer.sendVerifySecondaryCodeEmail); - const args = fxaMailer.sendVerifySecondaryCodeEmail.args[0]; + expect(fxaMailer.sendVerifySecondaryCodeEmail).toHaveBeenCalledTimes(1); + const args = fxaMailer.sendVerifySecondaryCodeEmail.mock.calls[0]; expect(args[0].email).toBe(email); expect(args[0].code).toBe(expectedCode); }); @@ -1120,13 +1114,13 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { emailVerified: true, }); // Simulate no secondary email found in DB (email is available) - mockDB.getSecondaryEmail = sinon.stub().rejects({ + mockDB.getSecondaryEmail = jest.fn().mockRejectedValue({ errno: error.ERRNO.SECONDARY_EMAIL_UNKNOWN, }); const authServerCacheRedis = { - get: sinon.stub().resolves(null), // No Redis reservation (expired) - set: sinon.stub().resolves('OK'), // Will create new reservation - del: sinon.stub().resolves(1), + get: jest.fn().mockResolvedValue(null), // No Redis reservation (expired) + set: jest.fn().mockResolvedValue('OK'), // Will create new reservation + del: jest.fn().mockResolvedValue(1), }; const routes = makeRoutes( { @@ -1137,10 +1131,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { }, {} ); - const route = getRoute( - routes, - '/mfa/recovery_email/secondary/resend_code' - ); + const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code'); const request = mocks.mockRequest({ credentials: { uid, email: TEST_EMAIL }, payload: { email }, @@ -1149,18 +1140,19 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { const response = await runTest(route, request); expect(response).toBeTruthy(); // Verify new reservation was created - sinon.assert.calledOnce(authServerCacheRedis.set); - const setArgs = authServerCacheRedis.set.args[0]; + expect(authServerCacheRedis.set).toHaveBeenCalledTimes(1); + const setArgs = authServerCacheRedis.set.mock.calls[0]; expect(setArgs[0]).toContain(normalized); // Key includes email expect(setArgs[2]).toBe('EX'); // Expiration flag expect(setArgs[4]).toBe('NX'); // Only set if not exists // Verify email was sent - sinon.assert.calledOnce(fxaMailer.sendVerifySecondaryCodeEmail); - sinon.assert.calledOnce(mockLog.info); - expect(mockLog.info.args[0][0]).toBe( - 'secondary_email.reservation_recreated' + expect(fxaMailer.sendVerifySecondaryCodeEmail).toHaveBeenCalledTimes(1); + expect(mockLog.info).toHaveBeenCalledTimes(1); + expect(mockLog.info).toHaveBeenNthCalledWith( + 1, + 'secondary_email.reservation_recreated', + expect.objectContaining({ reason: 'expired' }) ); - expect(mockLog.info.args[0][1].reason).toBe('expired'); }); it('errors when reservation belongs to a different uid', async () => { @@ -1174,20 +1166,17 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { emailVerified: true, }); const authServerCacheRedis = { - get: sinon - .stub() - .resolves(JSON.stringify({ uid: otherUid, secret: 'abc' })), - set: sinon.stub().resolves('OK'), - del: sinon.stub().resolves(1), + get: jest + .fn() + .mockResolvedValue(JSON.stringify({ uid: otherUid, secret: 'abc' })), + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), }; const routes = makeRoutes( { authServerCacheRedis, mailer: mockMailer, db: mockDB }, {} ); - const route = getRoute( - routes, - '/mfa/recovery_email/secondary/resend_code' - ); + const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code'); const request = mocks.mockRequest({ credentials: { uid, email: TEST_EMAIL }, payload: { email }, @@ -1207,13 +1196,13 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { email: TEST_EMAIL, emailVerified: true, }); - mockDB.getSecondaryEmail = sinon.stub().rejects({ + mockDB.getSecondaryEmail = jest.fn().mockRejectedValue({ errno: error.ERRNO.SECONDARY_EMAIL_UNKNOWN, }); const authServerCacheRedis = { - get: sinon.stub().resolves('not-json'), // Corrupted JSON - set: sinon.stub().resolves('OK'), - del: sinon.stub().resolves(1), + get: jest.fn().mockResolvedValue('not-json'), // Corrupted JSON + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), }; const routes = makeRoutes( { @@ -1224,10 +1213,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { }, {} ); - const route = getRoute( - routes, - '/mfa/recovery_email/secondary/resend_code' - ); + const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code'); const request = mocks.mockRequest({ credentials: { uid, email: TEST_EMAIL }, payload: { email }, @@ -1236,19 +1222,20 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { const response = await runTest(route, request); expect(response).toBeTruthy(); // Verify corrupted record was deleted - sinon.assert.calledOnce(authServerCacheRedis.del); + expect(authServerCacheRedis.del).toHaveBeenCalledTimes(1); // Verify warning was logged - sinon.assert.calledWith( - mockLog.warn, - 'secondary_email.corrupted_redis_record' + expect(mockLog.warn).toHaveBeenCalled(); + expect(mockLog.warn).toHaveBeenNthCalledWith( + 1, + 'secondary_email.corrupted_redis_record', + expect.anything() ); // Verify new reservation was created - sinon.assert.calledOnce(authServerCacheRedis.set); + expect(authServerCacheRedis.set).toHaveBeenCalledTimes(1); // Verify email was sent - sinon.assert.calledOnce(fxaMailer.sendVerifySecondaryCodeEmail); + expect(fxaMailer.sendVerifySecondaryCodeEmail).toHaveBeenCalledTimes(1); // Verify recreation was logged with correct reason - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'secondary_email.reservation_recreated', { uid, @@ -1267,9 +1254,9 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { emailVerified: true, }); const authServerCacheRedis = { - get: sinon.stub().resolves(null), - set: sinon.stub().resolves('OK'), - del: sinon.stub().resolves(1), + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), }; const routes = makeRoutes( { @@ -1279,10 +1266,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { }, {} ); - const route = getRoute( - routes, - '/mfa/recovery_email/secondary/resend_code' - ); + const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code'); const request = mocks.mockRequest({ credentials: { uid, email: primaryEmail }, payload: { email: primaryEmail }, // Trying to resend to their own primary @@ -1291,7 +1275,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { await expect(runTest(route, request)).rejects.toMatchObject({ errno: error.ERRNO.USER_PRIMARY_EMAIL_EXISTS, }); - sinon.assert.notCalled(mockMailer.sendVerifySecondaryCodeEmail); + expect(mockMailer.sendVerifySecondaryCodeEmail).not.toHaveBeenCalled(); }); it('errors when trying to resend to already verified secondary', async () => { @@ -1304,7 +1288,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { emailVerified: true, }); // Simulate already verified secondary email - mockDB.getSecondaryEmail = sinon.stub().resolves({ + mockDB.getSecondaryEmail = jest.fn().mockResolvedValue({ uid: uidBuffer, email, normalizedEmail: normalizeEmail(email), @@ -1312,9 +1296,9 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { isPrimary: false, }); const authServerCacheRedis = { - get: sinon.stub().resolves(null), - set: sinon.stub().resolves('OK'), - del: sinon.stub().resolves(1), + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), }; const routes = makeRoutes( { @@ -1324,10 +1308,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { }, {} ); - const route = getRoute( - routes, - '/mfa/recovery_email/secondary/resend_code' - ); + const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code'); const request = mocks.mockRequest({ credentials: { uid, email: TEST_EMAIL }, payload: { email }, @@ -1336,7 +1317,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { await expect(runTest(route, request)).rejects.toMatchObject({ errno: error.ERRNO.ACCOUNT_OWNS_EMAIL, }); - sinon.assert.notCalled(mockMailer.sendVerifySecondaryCodeEmail); + expect(mockMailer.sendVerifySecondaryCodeEmail).not.toHaveBeenCalled(); }); it('returns service error when DB fails during recreation', async () => { @@ -1349,13 +1330,13 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { emailVerified: true, }); // Simulate DB failure - mockDB.getSecondaryEmail = sinon - .stub() - .rejects(new Error('Database connection failed')); + mockDB.getSecondaryEmail = jest + .fn() + .mockRejectedValue(new Error('Database connection failed')); const authServerCacheRedis = { - get: sinon.stub().resolves(null), - set: sinon.stub().resolves('OK'), - del: sinon.stub().resolves(1), + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), }; const routes = makeRoutes( { @@ -1366,10 +1347,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { }, {} ); - const route = getRoute( - routes, - '/mfa/recovery_email/secondary/resend_code' - ); + const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code'); const request = mocks.mockRequest({ credentials: { uid, email: TEST_EMAIL }, payload: { email }, @@ -1379,11 +1357,13 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { errno: error.ERRNO.BACKEND_SERVICE_FAILURE, }); // Verify error was logged - sinon.assert.calledWith( - mockLog.error, - 'secondary_email.reservation_recreation_failed' + expect(mockLog.error).toHaveBeenCalled(); + expect(mockLog.error).toHaveBeenNthCalledWith( + 1, + 'secondary_email.reservation_recreation_failed', + expect.anything() ); - sinon.assert.notCalled(mockMailer.sendVerifySecondaryCodeEmail); + expect(mockMailer.sendVerifySecondaryCodeEmail).not.toHaveBeenCalled(); }); it('cleans up new reservation when email send fails', async () => { @@ -1396,17 +1376,17 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { email: TEST_EMAIL, emailVerified: true, }); - mockDB.getSecondaryEmail = sinon.stub().rejects({ + mockDB.getSecondaryEmail = jest.fn().mockRejectedValue({ errno: error.ERRNO.SECONDARY_EMAIL_UNKNOWN, }); // Simulate email send failure - fxaMailer.sendVerifySecondaryCodeEmail.rejects( + fxaMailer.sendVerifySecondaryCodeEmail.mockRejectedValue( new Error('Email service unavailable') ); const authServerCacheRedis = { - get: sinon.stub().resolves(null), // No existing reservation - set: sinon.stub().resolves('OK'), - del: sinon.stub().resolves(1), + get: jest.fn().mockResolvedValue(null), // No existing reservation + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), }; const routes = makeRoutes( { @@ -1417,10 +1397,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { }, {} ); - const route = getRoute( - routes, - '/mfa/recovery_email/secondary/resend_code' - ); + const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code'); const request = mocks.mockRequest({ credentials: { uid, email: TEST_EMAIL }, payload: { email }, @@ -1430,13 +1407,15 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { errno: error.ERRNO.FAILED_TO_SEND_EMAIL, }); // Verify new reservation was created - sinon.assert.calledOnce(authServerCacheRedis.set); + expect(authServerCacheRedis.set).toHaveBeenCalledTimes(1); // Verify it was cleaned up after email failure - sinon.assert.calledOnce(authServerCacheRedis.del); + expect(authServerCacheRedis.del).toHaveBeenCalledTimes(1); // Verify error was logged - sinon.assert.calledWith( - mockLog.error, - 'secondary_email.resendVerifySecondaryCodeEmail.error' + expect(mockLog.error).toHaveBeenCalled(); + expect(mockLog.error).toHaveBeenNthCalledWith( + 1, + 'secondary_email.resendVerifySecondaryCodeEmail.error', + expect.anything() ); }); @@ -1452,15 +1431,13 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { emailVerified: true, }); // Simulate email send failure - fxaMailer.sendVerifySecondaryCodeEmail.rejects( + fxaMailer.sendVerifySecondaryCodeEmail.mockRejectedValue( new Error('Email service unavailable') ); const authServerCacheRedis = { - get: sinon - .stub() - .resolves(JSON.stringify({ uid, secret })), // Existing reservation - set: sinon.stub().resolves('OK'), - del: sinon.stub().resolves(1), + get: jest.fn().mockResolvedValue(JSON.stringify({ uid, secret })), // Existing reservation + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), }; const routes = makeRoutes( { @@ -1471,10 +1448,7 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { }, {} ); - const route = getRoute( - routes, - '/mfa/recovery_email/secondary/resend_code' - ); + const route = getRoute(routes, '/mfa/recovery_email/secondary/resend_code'); const request = mocks.mockRequest({ credentials: { uid, email: TEST_EMAIL }, payload: { email }, @@ -1484,13 +1458,15 @@ describe('/mfa/recovery_email/secondary/resend_code', () => { errno: error.ERRNO.FAILED_TO_SEND_EMAIL, }); // Verify no new reservation was created - sinon.assert.notCalled(authServerCacheRedis.set); + expect(authServerCacheRedis.set).not.toHaveBeenCalled(); // Verify existing reservation was NOT deleted - sinon.assert.notCalled(authServerCacheRedis.del); + expect(authServerCacheRedis.del).not.toHaveBeenCalled(); // Verify error was logged - sinon.assert.calledWith( - mockLog.error, - 'secondary_email.resendVerifySecondaryCodeEmail.error' + expect(mockLog.error).toHaveBeenCalled(); + expect(mockLog.error).toHaveBeenNthCalledWith( + 1, + 'secondary_email.resendVerifySecondaryCodeEmail.error', + expect.anything() ); }); }); @@ -1523,9 +1499,9 @@ describe('/emails/reminders/cad', () => { route = getRoute(accountRoutes, '/emails/reminders/cad'); const response = await runTest(route, mockRequest); - sinon.assert.calledOnce(cadReminders.get); - sinon.assert.calledWithExactly(cadReminders.get, uid); - sinon.assert.calledOnce(cadReminders.create); + expect(cadReminders.get).toHaveBeenCalledTimes(1); + expect(cadReminders.get).toHaveBeenCalledWith(uid); + expect(cadReminders.create).toHaveBeenCalledTimes(1); expect(response).toBeTruthy(); expect(Object.keys(response)).toHaveLength(0); @@ -1546,9 +1522,9 @@ describe('/emails/reminders/cad', () => { route = getRoute(accountRoutes, '/emails/reminders/cad'); const response = await runTest(route, mockRequest); - sinon.assert.calledOnce(cadReminders.get); - sinon.assert.calledWithExactly(cadReminders.get, uid); - sinon.assert.notCalled(cadReminders.create); + expect(cadReminders.get).toHaveBeenCalledTimes(1); + expect(cadReminders.get).toHaveBeenCalledWith(uid); + expect(cadReminders.create).not.toHaveBeenCalled(); expect(response).toBeTruthy(); expect(Object.keys(response)).toHaveLength(0); diff --git a/packages/fxa-auth-server/lib/routes/geo-location.spec.ts b/packages/fxa-auth-server/lib/routes/geo-location.spec.ts index 7ee0a60d279..a487e42e3ff 100644 --- a/packages/fxa-auth-server/lib/routes/geo-location.spec.ts +++ b/packages/fxa-auth-server/lib/routes/geo-location.spec.ts @@ -55,19 +55,25 @@ describe('GET /geo/eligibility/{feature}', () => { }); it('called log.begin correctly', () => { - expect(log.begin.callCount).toBe(1); - const [name, req] = log.begin.args[0]; - expect(name).toBe('geo.eligibility.check'); - expect(req).toBe(request); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(log.begin).toHaveBeenNthCalledWith( + 1, + 'geo.eligibility.check', + request + ); }); it('logged the eligibility check', () => { - expect(log.info.callCount).toBe(1); - const [msg, details] = log.info.args[0]; - expect(msg).toBe('geo.eligibility.checked'); - expect(details.feature).toBe('TEST_FEATURE'); - expect(details.country).toBe('US'); - expect(details.eligible).toBe(true); + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenNthCalledWith( + 1, + 'geo.eligibility.checked', + expect.objectContaining({ + feature: 'TEST_FEATURE', + country: 'US', + eligible: true, + }) + ); }); }); @@ -101,10 +107,12 @@ describe('GET /geo/eligibility/{feature}', () => { }); it('logs error and returns false', () => { - expect(log.error.callCount).toBe(1); - const [msg, details] = log.error.args[0]; - expect(msg).toBe('geo.eligibility.checkfailure'); - expect(details.feature).toBe('UNKNOWN'); + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenNthCalledWith( + 1, + 'geo.eligibility.checkfailure', + expect.objectContaining({ feature: 'UNKNOWN' }) + ); expect(response).toEqual({ eligible: false }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts b/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts index 58d9cab9120..391a3cbb98c 100644 --- a/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts +++ b/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts @@ -7,12 +7,10 @@ * It tests the IP profiling behavior in the account login route. * * This test uses shared mocks from test/mocks.js where possible, but keeps - * mockRequest inline because the shared version uses proxyquire with relative * paths that don't work from lib/routes/. */ import crypto from 'crypto'; -import sinon from 'sinon'; import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; @@ -38,7 +36,6 @@ const KNOWN_LOCATION = { /** * Simplified mockRequest for this test file. - * The shared mocks.mockRequest() uses proxyquire with relative paths * that don't resolve correctly when running from lib/routes/. */ function mockRequest(data: any) { @@ -69,10 +66,12 @@ function mockRequest(data: any) { auth: { credentials: data.credentials, }, - clearMetricsContext: sinon.stub(), - emitMetricsEvent: sinon.stub().resolves(), - emitRouteFlowEvent: sinon.stub().resolves(), - gatherMetricsContext: sinon.stub().callsFake((d: any) => Promise.resolve(d)), + clearMetricsContext: jest.fn(), + emitMetricsEvent: jest.fn().mockResolvedValue(), + emitRouteFlowEvent: jest.fn().mockResolvedValue(), + gatherMetricsContext: jest + .fn() + .mockImplementation((d: any) => Promise.resolve(d)), headers: { 'user-agent': 'test user-agent', }, @@ -83,11 +82,11 @@ function mockRequest(data: any) { params: {}, path: data.path, payload: data.payload || {}, - propagateMetricsContext: sinon.stub().resolves(), + propagateMetricsContext: jest.fn().mockResolvedValue(), query: data.query || {}, - setMetricsFlowCompleteSignal: sinon.stub(), - stashMetricsContext: sinon.stub().resolves(), - validateMetricsContext: sinon.stub().returns(true), + setMetricsFlowCompleteSignal: jest.fn(), + stashMetricsContext: jest.fn().mockResolvedValue(), + validateMetricsContext: jest.fn().mockReturnValue(true), }; } @@ -106,7 +105,7 @@ function makeRoutes(options: { db: any; mailer: any }) { }; const log = mocks.mockLog(); mocks.mockAccountEventsManager(); - Container.set(AccountDeleteManager, { enqueue: sinon.stub() }); + Container.set(AccountDeleteManager, { enqueue: jest.fn() }); Container.set(AppConfig, config); Container.set(AuthLogger, log); const cadReminders = mocks.mockCadReminders(); @@ -124,8 +123,8 @@ function makeRoutes(options: { db: any; mailer: any }) { const { accountRoutes } = require('./account'); const authServerCacheRedis = { - get: sinon.stub().resolves(null), - del: sinon.stub().resolves(0), + get: jest.fn().mockResolvedValue(null), + del: jest.fn().mockResolvedValue(0), }; return accountRoutes( @@ -184,7 +183,9 @@ describe('IP Profiling', () => { ], }); jest.clearAllMocks(); - mockFxaMailerInstance = mocks.mockFxaMailer({ canSend: sinon.stub().resolves(true) }); + mockFxaMailerInstance = mocks.mockFxaMailer({ + canSend: jest.fn().mockResolvedValue(true), + }); mocks.mockOAuthClientInfo(); mockDBInstance = mocks.mockDB({ email: TEST_EMAIL, @@ -225,7 +226,7 @@ describe('IP Profiling', () => { }); it('no previously verified session', async () => { - mockDBInstance.verifiedLoginSecurityEvents = sinon.stub().resolves([ + mockDBInstance.verifiedLoginSecurityEvents = jest.fn().mockResolvedValue([ { name: 'account.login', createdAt: Date.now(), @@ -234,14 +235,18 @@ describe('IP Profiling', () => { ]); await runTest(route, mockRequestInstance, (response: any) => { - expect(mockFxaMailerInstance.sendVerifyLoginEmail.callCount).toBe(1); - expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockFxaMailerInstance.sendVerifyLoginEmail).toHaveBeenCalledTimes( + 1 + ); + expect( + mockFxaMailerInstance.sendNewDeviceLoginEmail + ).toHaveBeenCalledTimes(0); expect(response.sessionVerified).toBe(false); }); }); it('previously verified session', async () => { - mockDBInstance.verifiedLoginSecurityEvents = sinon.stub().resolves([ + mockDBInstance.verifiedLoginSecurityEvents = jest.fn().mockResolvedValue([ { name: 'account.login', createdAt: Date.now(), @@ -250,14 +255,18 @@ describe('IP Profiling', () => { ]); await runTest(route, mockRequestInstance, (response: any) => { - expect(mockFxaMailerInstance.sendVerifyLoginEmail.callCount).toBe(0); - expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(1); + expect(mockFxaMailerInstance.sendVerifyLoginEmail).toHaveBeenCalledTimes( + 0 + ); + expect( + mockFxaMailerInstance.sendNewDeviceLoginEmail + ).toHaveBeenCalledTimes(1); expect(response.sessionVerified).toBe(true); }); }); it('previously verified session more than a day', async () => { - mockDBInstance.securityEvents = sinon.stub().resolves([ + mockDBInstance.securityEvents = jest.fn().mockResolvedValue([ { name: 'account.login', createdAt: Date.now() - MS_ONE_DAY * 2, // Created two days ago @@ -266,8 +275,12 @@ describe('IP Profiling', () => { ]); await runTest(route, mockRequestInstance, (response: any) => { - expect(mockFxaMailerInstance.sendVerifyLoginEmail.callCount).toBe(1); - expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockFxaMailerInstance.sendVerifyLoginEmail).toHaveBeenCalledTimes( + 1 + ); + expect( + mockFxaMailerInstance.sendNewDeviceLoginEmail + ).toHaveBeenCalledTimes(0); expect(response.sessionVerified).toBe(false); }); }); @@ -276,7 +289,7 @@ describe('IP Profiling', () => { const forceSigninEmail = 'forcedemail@mozilla.com'; mockRequestInstance.payload.email = forceSigninEmail; - mockDBInstance.accountRecord = sinon.stub().resolves({ + mockDBInstance.accountRecord = jest.fn().mockResolvedValue({ authSalt: crypto.randomBytes(32), data: crypto.randomBytes(32), email: forceSigninEmail, @@ -294,13 +307,17 @@ describe('IP Profiling', () => { }); let response = await runTest(route, mockRequestInstance); - expect(mockFxaMailerInstance.sendVerifyLoginEmail.callCount).toBe(1); - expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockFxaMailerInstance.sendVerifyLoginEmail).toHaveBeenCalledTimes(1); + expect(mockFxaMailerInstance.sendNewDeviceLoginEmail).toHaveBeenCalledTimes( + 0 + ); expect(response.sessionVerified).toBe(false); response = await runTest(route, mockRequestInstance); - expect(mockFxaMailerInstance.sendVerifyLoginEmail.callCount).toBe(2); - expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockFxaMailerInstance.sendVerifyLoginEmail).toHaveBeenCalledTimes(2); + expect(mockFxaMailerInstance.sendNewDeviceLoginEmail).toHaveBeenCalledTimes( + 0 + ); expect(response.sessionVerified).toBe(false); }); @@ -309,13 +326,17 @@ describe('IP Profiling', () => { mockRequestInstance.app.isSuspiciousRequest = true; let response = await runTest(route, mockRequestInstance); - expect(mockFxaMailerInstance.sendVerifyLoginEmail.callCount).toBe(1); - expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockFxaMailerInstance.sendVerifyLoginEmail).toHaveBeenCalledTimes(1); + expect(mockFxaMailerInstance.sendNewDeviceLoginEmail).toHaveBeenCalledTimes( + 0 + ); expect(response.sessionVerified).toBe(false); response = await runTest(route, mockRequestInstance); - expect(mockFxaMailerInstance.sendVerifyLoginEmail.callCount).toBe(2); - expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(mockFxaMailerInstance.sendVerifyLoginEmail).toHaveBeenCalledTimes(2); + expect(mockFxaMailerInstance.sendNewDeviceLoginEmail).toHaveBeenCalledTimes( + 0 + ); expect(response.sessionVerified).toBe(false); }); }); diff --git a/packages/fxa-auth-server/lib/routes/linked-accounts.spec.ts b/packages/fxa-auth-server/lib/routes/linked-accounts.spec.ts index d4e78aea81f..8624e88b6e2 100644 --- a/packages/fxa-auth-server/lib/routes/linked-accounts.spec.ts +++ b/packages/fxa-auth-server/lib/routes/linked-accounts.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; // Mutable mock implementations — changed per-test via makeRoutes(options, requireMocks) @@ -54,8 +53,8 @@ const { AppError: error } = require('@fxa/accounts/errors'); // Set up mock FxaMailer in Container before loading linked-accounts const mockFxaMailer: any = { - canSend: sinon.stub().returns(true), - sendPostAddLinkedAccountEmail: sinon.stub().resolves(), + canSend: jest.fn().mockReturnValue(true), + sendPostAddLinkedAccountEmail: jest.fn().mockResolvedValue(undefined), }; const { FxaMailer } = require('../senders/fxa-mailer'); Container.set(FxaMailer, mockFxaMailer); @@ -75,7 +74,7 @@ const makeRoutes = function (options: any = {}, requireMocks?: any) { const db = options.db || mocks.mockDB(); const mailer = options.mailer || mocks.mockMailer(); const profile = options.profile || mocks.mockProfile(); - const statsd = options.statsd || { increment: sinon.spy() }; + const statsd = options.statsd || { increment: jest.fn() }; // Apply per-test mock implementations if (requireMocks) { @@ -94,11 +93,11 @@ const makeRoutes = function (options: any = {}, requireMocks?: any) { axiosDefaultOverride = null; } - // Reset FxaMailer stubs - mockFxaMailer.canSend.resetHistory(); - mockFxaMailer.canSend.returns(true); - mockFxaMailer.sendPostAddLinkedAccountEmail.resetHistory(); - mockFxaMailer.sendPostAddLinkedAccountEmail.resolves(); + // Reset FxaMailer mocks + mockFxaMailer.canSend.mockClear(); + mockFxaMailer.canSend.mockReturnValue(true); + mockFxaMailer.sendPostAddLinkedAccountEmail.mockClear(); + mockFxaMailer.sendPostAddLinkedAccountEmail.mockResolvedValue(undefined); return linkedAccountRoutes(log, db, config, mailer, profile, statsd, glean); }; @@ -152,7 +151,7 @@ describe('/linked_account', () => { service: 'sync', }, }); - statsd = { increment: sinon.spy() }; + statsd = { increment: jest.fn() }; const OAuth2ClientMock = class OAuth2Client { verifyIdToken() { @@ -168,7 +167,7 @@ describe('/linked_account', () => { data: { id_token: 'somedata' }, }; axiosMock = { - post: sinon.spy(() => mockGoogleAuthResponse), + post: jest.fn(() => mockGoogleAuthResponse), }; route = getRoute( @@ -189,9 +188,9 @@ describe('/linked_account', () => { ), '/linked_account/login' ); - glean.registration.complete.reset(); - glean.thirdPartyAuth.googleLoginComplete.reset(); - glean.thirdPartyAuth.googleRegComplete.reset(); + glean.registration.complete.mockClear(); + glean.thirdPartyAuth.googleLoginComplete.mockClear(); + glean.thirdPartyAuth.googleRegComplete.mockClear(); }); it('fails if no google config', async () => { @@ -215,90 +214,90 @@ describe('/linked_account', () => { }); it('should exchange oauth code for `id_token` and create account', async () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount(mockGoogleUser.email)) ); mockRequest.payload.code = 'oauth code'; const result: any = await runTest(route, mockRequest); - expect(axiosMock.post.calledOnce).toBe(true); - expect(axiosMock.post.args[0][1].code).toBe('oauth code'); + expect(axiosMock.post).toHaveBeenCalledTimes(1); + expect(axiosMock.post.mock.calls[0][1]).toEqual( + expect.objectContaining({ code: 'oauth code' }) + ); - expect( - mockDB.getLinkedAccount.calledOnceWith( - mockGoogleUser.sub, - GOOGLE_PROVIDER - ) - ).toBe(true); - expect(mockDB.createAccount.calledOnce).toBe(true); - expect( - mockDB.createLinkedAccount.calledOnceWith(UID, mockGoogleUser.sub) - ).toBe(true); - expect(mockDB.createSessionToken.calledOnce).toBe(true); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith( + mockGoogleUser.sub, + GOOGLE_PROVIDER + ); + expect(mockDB.createAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createLinkedAccount).toHaveBeenCalledWith( + UID, + mockGoogleUser.sub, + 'google' + ); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); expect(result.uid).toBe(UID); expect(result.sessionToken).toBeTruthy(); }); it('should create new fxa account from new google account, return session, emit Glean events', async () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount(mockGoogleUser.email)) ); const result: any = await runTest(route, mockRequest); - expect( - mockDB.getLinkedAccount.calledOnceWith( - mockGoogleUser.sub, - GOOGLE_PROVIDER - ) - ).toBe(true); - expect(mockDB.createAccount.calledOnce).toBe(true); - expect( - mockDB.createLinkedAccount.calledOnceWith( - UID, - mockGoogleUser.sub, - GOOGLE_PROVIDER - ) - ).toBe(true); - expect( - mockDB.createSessionToken.calledOnceWith( - sinon.match({ - uid: 'fxauid', - email: mockGoogleUser.email, - mustVerify: false, - uaBrowser: 'Firefox', - uaBrowserVersion: '57.0', - uaOS: 'Mac OS X', - uaOSVersion: '10.13', - uaDeviceType: null, - uaFormFactor: null, - providerId: 1, - }) - ) - ).toBe(true); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith( + mockGoogleUser.sub, + GOOGLE_PROVIDER + ); + expect(mockDB.createAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createLinkedAccount).toHaveBeenCalledWith( + UID, + mockGoogleUser.sub, + GOOGLE_PROVIDER + ); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + expect(mockDB.createSessionToken).toHaveBeenCalledWith( + expect.objectContaining({ + uid: 'fxauid', + email: mockGoogleUser.email, + mustVerify: false, + uaBrowser: 'Firefox', + uaBrowserVersion: '57.0', + uaOS: 'Mac OS X', + uaOSVersion: '10.13', + uaDeviceType: null, + uaFormFactor: null, + providerId: 1, + }) + ); expect(result.uid).toBe(UID); expect(result.sessionToken).toBeTruthy(); - sinon.assert.calledOnce(glean.registration.complete); - sinon.assert.calledOnceWithExactly( - glean.thirdPartyAuth.googleRegComplete, + expect(glean.registration.complete).toHaveBeenCalledTimes(1); + expect(glean.thirdPartyAuth.googleRegComplete).toHaveBeenCalledTimes(1); + expect(glean.thirdPartyAuth.googleRegComplete).toHaveBeenCalledWith( mockRequest ); // Should emit SNS verified + login + profileDataChange events so // Basket/Braze learn about the new account. - const notifyEvents = mockLog.notifyAttachedServices.args.map( + const notifyEvents = mockLog.notifyAttachedServices.mock.calls.map( (call: any[]) => call[0] ); expect(notifyEvents).toContain('verified'); expect(notifyEvents).toContain('login'); expect(notifyEvents).toContain('profileDataChange'); - sinon.assert.calledWithMatch( - mockLog.notifyAttachedServices, + expect(mockLog.notifyAttachedServices).toHaveBeenCalledWith( 'verified', mockRequest, - sinon.match({ + expect.objectContaining({ email: mockGoogleUser.email, uid: UID, service: 'sync', @@ -309,35 +308,37 @@ describe('/linked_account', () => { it('should link existing fxa account and new google account and return session', async () => { const result: any = await runTest(route, mockRequest); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith( + mockGoogleUser.sub, + GOOGLE_PROVIDER + ); + expect(mockDB.createAccount).not.toHaveBeenCalled(); + expect(mockDB.createLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createLinkedAccount).toHaveBeenCalledWith( + UID, + mockGoogleUser.sub, + GOOGLE_PROVIDER + ); expect( - mockDB.getLinkedAccount.calledOnceWith( - mockGoogleUser.sub, - GOOGLE_PROVIDER - ) - ).toBe(true); - expect(mockDB.createAccount.notCalled).toBe(true); - expect( - mockDB.createLinkedAccount.calledOnceWith( - UID, - mockGoogleUser.sub, - GOOGLE_PROVIDER - ) - ).toBe(true); - expect(mockFxaMailer.sendPostAddLinkedAccountEmail.callCount).toBe(1); - expect(mockDB.createSessionToken.calledOnce).toBe(true); + mockFxaMailer.sendPostAddLinkedAccountEmail + ).toHaveBeenCalledTimes(1); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); expect(result.uid).toBe(UID); expect(result.sessionToken).toBeTruthy(); // should not be called for existing account - sinon.assert.notCalled(glean.registration.complete); - sinon.assert.calledOnceWithExactly( - glean.thirdPartyAuth.googleLoginComplete, + expect(glean.registration.complete).not.toHaveBeenCalled(); + expect(glean.thirdPartyAuth.googleLoginComplete).toHaveBeenCalledTimes( + 1 + ); + expect(glean.thirdPartyAuth.googleLoginComplete).toHaveBeenCalledWith( mockRequest, { reason: 'linking' } ); // Should emit SNS login + profileDataChange but NOT verified // (the account already existed). - const notifyEvents = mockLog.notifyAttachedServices.args.map( + const notifyEvents = mockLog.notifyAttachedServices.mock.calls.map( (call: any[]) => call[0] ); expect(notifyEvents).not.toContain('verified'); @@ -346,7 +347,7 @@ describe('/linked_account', () => { }); it('should return session with valid google id token', async () => { - mockDB.getLinkedAccount = sinon.spy(() => + mockDB.getLinkedAccount = jest.fn(() => Promise.resolve({ id: mockGoogleUser.sub, uid: UID, @@ -355,38 +356,40 @@ describe('/linked_account', () => { const result: any = await runTest(route, mockRequest); - expect( - mockDB.getLinkedAccount.calledOnceWith( - mockGoogleUser.sub, - GOOGLE_PROVIDER - ) - ).toBe(true); - expect(mockDB.account.calledOnceWith(UID)).toBe(true); - expect(mockDB.createLinkedAccount.notCalled).toBe(true); - expect(mockDB.createSessionToken.calledOnce).toBe(true); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith( + mockGoogleUser.sub, + GOOGLE_PROVIDER + ); + expect(mockDB.account).toHaveBeenCalledTimes(1); + expect(mockDB.account).toHaveBeenCalledWith(UID); + expect(mockDB.createLinkedAccount).not.toHaveBeenCalled(); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); expect(result.uid).toBe(UID); expect(result.sessionToken).toBeTruthy(); // Re-login: login event only, no verified, no profileDataChange. - const notifyEvents = mockLog.notifyAttachedServices.args.map( + const notifyEvents = mockLog.notifyAttachedServices.mock.calls.map( (call: any[]) => call[0] ); expect(notifyEvents).toEqual(['login']); - sinon.assert.calledOnceWithExactly( - glean.thirdPartyAuth.googleLoginComplete, + expect(glean.thirdPartyAuth.googleLoginComplete).toHaveBeenCalledTimes( + 1 + ); + expect(glean.thirdPartyAuth.googleLoginComplete).toHaveBeenCalledWith( mockRequest ); }); it('with 2fa enabled', async () => { - mockDB.getLinkedAccount = sinon.spy(() => + mockDB.getLinkedAccount = jest.fn(() => Promise.resolve({ id: mockGoogleUser.sub, uid: UID, }) ); - mockDB.totpToken = sinon.spy(() => + mockDB.totpToken = jest.fn(() => Promise.resolve({ verified: true, enabled: true, @@ -395,11 +398,14 @@ describe('/linked_account', () => { const result: any = await runTest(route, mockRequest); - expect(mockDB.totpToken.calledOnce).toBe(true); - expect(mockDB.createSessionToken.calledOnce).toBe(true); - expect( - mockDB.createSessionToken.args[0][0].tokenVerificationId - ).toBeTruthy(); + expect(mockDB.totpToken).toHaveBeenCalledTimes(1); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + expect(mockDB.createSessionToken).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + tokenVerificationId: expect.anything(), + }) + ); expect(result.uid).toBe(UID); expect(result.sessionToken).toBeTruthy(); expect(result.verificationMethod).toBe('totp-2fa'); @@ -450,7 +456,7 @@ describe('/linked_account', () => { }, }; axiosMock = { - post: sinon.spy(() => mockAppleAuthResponse), + post: jest.fn(() => mockAppleAuthResponse), }; route = getRoute( @@ -467,9 +473,9 @@ describe('/linked_account', () => { ), '/linked_account/login' ); - glean.registration.complete.reset(); - glean.thirdPartyAuth.appleLoginComplete.reset(); - glean.thirdPartyAuth.appleRegComplete.reset(); + glean.registration.complete.mockClear(); + glean.thirdPartyAuth.appleLoginComplete.mockClear(); + glean.thirdPartyAuth.appleRegComplete.mockClear(); }); it('fails if no apple config', async () => { @@ -493,66 +499,68 @@ describe('/linked_account', () => { }); it('should exchange oauth code for `id_token` and create account', async () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount(mockAppleUser.email)) ); mockRequest.payload.code = 'oauth code'; const result: any = await runTest(route, mockRequest); - expect(axiosMock.post.calledOnce).toBe(true); - const urlSearchParams = new URLSearchParams(axiosMock.post.args[0][1]); + expect(axiosMock.post).toHaveBeenCalledTimes(1); + const urlSearchParams = new URLSearchParams( + axiosMock.post.mock.calls[0][1] + ); const params = Object.fromEntries(urlSearchParams.entries()); expect(params.client_secret).toBeDefined(); - expect( - mockDB.getLinkedAccount.calledOnceWith( - mockAppleUser.sub, - APPLE_PROVIDER - ) - ).toBe(true); - expect(mockDB.createAccount.calledOnce).toBe(true); - expect( - mockDB.createLinkedAccount.calledOnceWith(UID, mockAppleUser.sub) - ).toBe(true); - expect(mockDB.createSessionToken.calledOnce).toBe(true); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith( + mockAppleUser.sub, + APPLE_PROVIDER + ); + expect(mockDB.createAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createLinkedAccount).toHaveBeenCalledWith( + UID, + mockAppleUser.sub, + 'apple' + ); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); expect(result.uid).toBe(UID); expect(result.sessionToken).toBeTruthy(); }); it('should create new fxa account from new apple account, return session, emit Glean events', async () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount(mockAppleUser.email)) ); const result: any = await runTest(route, mockRequest); - expect( - mockDB.getLinkedAccount.calledOnceWith( - mockAppleUser.sub, - APPLE_PROVIDER - ) - ).toBe(true); - expect(mockDB.createAccount.calledOnce).toBe(true); - expect( - mockDB.createLinkedAccount.calledOnceWith( - UID, - mockAppleUser.sub, - APPLE_PROVIDER - ) - ).toBe(true); - expect(mockDB.createSessionToken.calledOnce).toBe(true); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith( + mockAppleUser.sub, + APPLE_PROVIDER + ); + expect(mockDB.createAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createLinkedAccount).toHaveBeenCalledWith( + UID, + mockAppleUser.sub, + APPLE_PROVIDER + ); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); expect(result.uid).toBe(UID); expect(result.sessionToken).toBeTruthy(); - sinon.assert.calledOnce(glean.registration.complete); - sinon.assert.calledOnceWithExactly( - glean.thirdPartyAuth.appleRegComplete, + expect(glean.registration.complete).toHaveBeenCalledTimes(1); + expect(glean.thirdPartyAuth.appleRegComplete).toHaveBeenCalledTimes(1); + expect(glean.thirdPartyAuth.appleRegComplete).toHaveBeenCalledWith( mockRequest ); // Should emit SNS verified + login + profileDataChange events. - const notifyEvents = mockLog.notifyAttachedServices.args.map( + const notifyEvents = mockLog.notifyAttachedServices.mock.calls.map( (call: any[]) => call[0] ); expect(notifyEvents).toContain('verified'); @@ -563,32 +571,34 @@ describe('/linked_account', () => { it('should link existing fxa account and new apple account and return session', async () => { const result: any = await runTest(route, mockRequest); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith( + mockAppleUser.sub, + APPLE_PROVIDER + ); + expect(mockDB.createAccount).not.toHaveBeenCalled(); + expect(mockDB.createLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createLinkedAccount).toHaveBeenCalledWith( + UID, + mockAppleUser.sub, + APPLE_PROVIDER + ); expect( - mockDB.getLinkedAccount.calledOnceWith( - mockAppleUser.sub, - APPLE_PROVIDER - ) - ).toBe(true); - expect(mockDB.createAccount.notCalled).toBe(true); - expect( - mockDB.createLinkedAccount.calledOnceWith( - UID, - mockAppleUser.sub, - APPLE_PROVIDER - ) - ).toBe(true); - expect(mockFxaMailer.sendPostAddLinkedAccountEmail.callCount).toBe(1); - expect(mockDB.createSessionToken.calledOnce).toBe(true); + mockFxaMailer.sendPostAddLinkedAccountEmail + ).toHaveBeenCalledTimes(1); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); expect(result.uid).toBe(UID); expect(result.sessionToken).toBeTruthy(); - sinon.assert.calledOnceWithExactly( - glean.thirdPartyAuth.appleLoginComplete, + expect(glean.thirdPartyAuth.appleLoginComplete).toHaveBeenCalledTimes( + 1 + ); + expect(glean.thirdPartyAuth.appleLoginComplete).toHaveBeenCalledWith( mockRequest, { reason: 'linking' } ); // New link on existing account: login + profileDataChange, no verified. - const notifyEvents = mockLog.notifyAttachedServices.args.map( + const notifyEvents = mockLog.notifyAttachedServices.mock.calls.map( (call: any[]) => call[0] ); expect(notifyEvents).not.toContain('verified'); @@ -597,7 +607,7 @@ describe('/linked_account', () => { }); it('should return session with valid apple id token', async () => { - mockDB.getLinkedAccount = sinon.spy(() => + mockDB.getLinkedAccount = jest.fn(() => Promise.resolve({ id: mockAppleUser.sub, uid: UID, @@ -606,24 +616,23 @@ describe('/linked_account', () => { const result: any = await runTest(route, mockRequest); - expect( - mockDB.getLinkedAccount.calledOnceWith( - mockAppleUser.sub, - APPLE_PROVIDER - ) - ).toBe(true); - expect(mockDB.account.calledOnceWith(UID)).toBe(true); - expect(mockDB.createLinkedAccount.notCalled).toBe(true); - expect(mockDB.createSessionToken.calledOnce).toBe(true); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith( + mockAppleUser.sub, + APPLE_PROVIDER + ); + expect(mockDB.account).toHaveBeenCalledTimes(1); + expect(mockDB.account).toHaveBeenCalledWith(UID); + expect(mockDB.createLinkedAccount).not.toHaveBeenCalled(); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); expect(result.uid).toBe(UID); expect(result.sessionToken).toBeTruthy(); - sinon.assert.calledWithExactly( - glean.thirdPartyAuth.appleLoginComplete, + expect(glean.thirdPartyAuth.appleLoginComplete).toHaveBeenCalledWith( mockRequest ); // Re-login: login event only. - const notifyEvents = mockLog.notifyAttachedServices.args.map( + const notifyEvents = mockLog.notifyAttachedServices.mock.calls.map( (call: any[]) => call[0] ); expect(notifyEvents).toEqual(['login']); @@ -689,13 +698,14 @@ describe('/linked_account', () => { it('calls deleteLinkedAccount', async () => { const result: any = await runTest(route, mockRequest); - expect(mockDB.deleteLinkedAccount.calledOnceWith(UID)).toBe(true); + expect(mockDB.deleteLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.deleteLinkedAccount).toHaveBeenCalledWith(UID, 'google'); expect(result.success).toBe(true); }); it('fails to unlink with incorrect assurance level', async () => { mockRequest.auth.credentials.authenticatorAssuranceLevel = 1; - mockDB.totpToken = sinon.spy(() => + mockDB.totpToken = jest.fn(() => Promise.resolve({ verified: true, enabled: true, @@ -705,7 +715,7 @@ describe('/linked_account', () => { await expect(runTest(route, mockRequest)).rejects.toMatchObject({ errno: 138, }); - expect(mockDB.deleteLinkedAccount.notCalled).toBe(true); + expect(mockDB.deleteLinkedAccount).not.toHaveBeenCalled(); }); }); @@ -807,7 +817,7 @@ describe('/linked_account', () => { }); const linkedAccount = { uid: UID }; - mockDB.getLinkedAccount = sinon.spy(() => + mockDB.getLinkedAccount = jest.fn(() => Promise.resolve(options.unknownAccount ? undefined : linkedAccount) ); const mockConfig = { @@ -816,7 +826,7 @@ describe('/linked_account', () => { mockRequest = mocks.mockRequest({ payload: [], }); - statsd = { increment: sinon.spy() }; + statsd = { increment: jest.fn() }; route = getRoute( makeRoutes( @@ -850,16 +860,9 @@ describe('/linked_account', () => { it('handles test event', async () => { setupTest({ validateSecurityToken: makeJWT() }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledWithExactly( - mockLog.debug, - 'Received test event: Celo' - ); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(mockLog.debug).toHaveBeenCalledWith('Received test event: Celo'); + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processed.verification' ); }); @@ -867,27 +870,21 @@ describe('/linked_account', () => { it('handles session revoked event', async () => { setupTest({ validateSecurityToken: makeJWT('sessionRevoked') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledOnceWithExactly( - mockDB.getLinkedAccount, - SUB, - 'google' - ); - sinon.assert.calledOnceWithExactly(mockDB.sessions, UID); - sinon.assert.calledOnceWithExactly(mockDB.deleteSessionToken, { + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith(SUB, 'google'); + expect(mockDB.sessions).toHaveBeenCalledTimes(1); + expect(mockDB.sessions).toHaveBeenCalledWith(UID); + expect(mockDB.deleteSessionToken).toHaveBeenCalledTimes(1); + expect(mockDB.deleteSessionToken).toHaveBeenCalledWith({ id: 'sessionTokenId1', uid: 'fxauid', providerId: 1, }); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processed.sessions_revoked' ); - sinon.assert.calledWithExactly( - mockLog.debug, + expect(mockLog.debug).toHaveBeenCalledWith( 'Revoked 1 third party sessions for user fxauid' ); }); @@ -895,27 +892,21 @@ describe('/linked_account', () => { it('handles tokens revoked event', async () => { setupTest({ validateSecurityToken: makeJWT('tokensRevoked') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledOnceWithExactly( - mockDB.getLinkedAccount, - SUB, - 'google' - ); - sinon.assert.calledOnceWithExactly(mockDB.sessions, UID); - sinon.assert.calledOnceWithExactly(mockDB.deleteSessionToken, { + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith(SUB, 'google'); + expect(mockDB.sessions).toHaveBeenCalledTimes(1); + expect(mockDB.sessions).toHaveBeenCalledWith(UID); + expect(mockDB.deleteSessionToken).toHaveBeenCalledTimes(1); + expect(mockDB.deleteSessionToken).toHaveBeenCalledWith({ id: 'sessionTokenId1', uid: 'fxauid', providerId: 1, }); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processed.tokens_revoked' ); - sinon.assert.calledWithExactly( - mockLog.debug, + expect(mockLog.debug).toHaveBeenCalledWith( 'Revoked 1 third party sessions for user fxauid' ); }); @@ -923,27 +914,21 @@ describe('/linked_account', () => { it('handles token revoked event', async () => { setupTest({ validateSecurityToken: makeJWT('tokenRevoked') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledOnceWithExactly( - mockDB.getLinkedAccount, - SUB, - 'google' - ); - sinon.assert.calledOnceWithExactly(mockDB.sessions, UID); - sinon.assert.calledOnceWithExactly(mockDB.deleteSessionToken, { + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith(SUB, 'google'); + expect(mockDB.sessions).toHaveBeenCalledTimes(1); + expect(mockDB.sessions).toHaveBeenCalledWith(UID); + expect(mockDB.deleteSessionToken).toHaveBeenCalledTimes(1); + expect(mockDB.deleteSessionToken).toHaveBeenCalledWith({ id: 'sessionTokenId1', uid: 'fxauid', providerId: 1, }); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processed.token_revoked' ); - sinon.assert.calledWithExactly( - mockLog.debug, + expect(mockLog.debug).toHaveBeenCalledWith( 'Revoked 1 third party sessions for user fxauid' ); }); @@ -951,44 +936,36 @@ describe('/linked_account', () => { it('handles account purged event', async () => { setupTest({ validateSecurityToken: makeJWT('accountPurged') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledOnceWithExactly(mockDB.deleteSessionToken, { + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(mockDB.deleteSessionToken).toHaveBeenCalledTimes(1); + expect(mockDB.deleteSessionToken).toHaveBeenCalledWith({ id: 'sessionTokenId1', uid: UID, providerId: 1, }); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processed.account_purged' ); - sinon.assert.calledWithExactly( - mockLog.debug, + expect(mockLog.debug).toHaveBeenCalledWith( 'Revoked 1 third party sessions for user fxauid' ); - sinon.assert.calledWithExactly(mockDB.deleteLinkedAccount, UID, 'google'); + expect(mockDB.deleteLinkedAccount).toHaveBeenCalledWith(UID, 'google'); }); it('handles credentials changed event', async () => { setupTest({ validateSecurityToken: makeJWT('passwordChanged') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledOnceWithExactly(mockDB.deleteSessionToken, { + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(mockDB.deleteSessionToken).toHaveBeenCalledTimes(1); + expect(mockDB.deleteSessionToken).toHaveBeenCalledWith({ id: 'sessionTokenId1', uid: 'fxauid', providerId: 1, }); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processed.credential_change_required' ); - sinon.assert.calledWithExactly( - mockLog.debug, + expect(mockLog.debug).toHaveBeenCalledWith( 'Revoked 1 third party sessions for user fxauid' ); }); @@ -996,42 +973,32 @@ describe('/linked_account', () => { it('handles account disabled event', async () => { setupTest({ validateSecurityToken: makeJWT('accountDisabled') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledOnceWithExactly( - mockDB.getLinkedAccount, - SUB, - 'google' - ); - sinon.assert.calledOnceWithExactly(mockDB.sessions, UID); - sinon.assert.calledOnceWithExactly(mockDB.deleteSessionToken, { + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(mockDB.getLinkedAccount).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccount).toHaveBeenCalledWith(SUB, 'google'); + expect(mockDB.sessions).toHaveBeenCalledTimes(1); + expect(mockDB.sessions).toHaveBeenCalledWith(UID); + expect(mockDB.deleteSessionToken).toHaveBeenCalledTimes(1); + expect(mockDB.deleteSessionToken).toHaveBeenCalledWith({ id: 'sessionTokenId1', uid: UID, providerId: 1, }); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processed.account_disabled' ); - sinon.assert.calledWithExactly( - mockLog.debug, + expect(mockLog.debug).toHaveBeenCalledWith( 'Revoked 1 third party sessions for user fxauid' ); - sinon.assert.calledWithExactly(mockDB.deleteLinkedAccount, UID, 'google'); + expect(mockDB.deleteLinkedAccount).toHaveBeenCalledWith(UID, 'google'); }); it('handles account enabled event', async () => { setupTest({ validateSecurityToken: makeJWT('accountEnabled') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.notCalled(mockDB.getLinkedAccount); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(mockDB.getLinkedAccount).not.toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processed.account_enabled' ); }); @@ -1039,16 +1006,11 @@ describe('/linked_account', () => { it('handles unknown event', async () => { setupTest({ validateSecurityToken: makeJWT('unknown event') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledWithExactly( - mockLog.debug, + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(mockLog.debug).toHaveBeenCalledWith( 'Received unknown event: https://schemas.openid.net/secevent/risc/event-type/unknown' ); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.unknownEventType.unknown' ); }); @@ -1057,75 +1019,59 @@ describe('/linked_account', () => { const jwt = makeJWT('accountDisabled'); setupTest({ validateSecurityToken: jwt, unknownAccount: true }); await runTest(route, mockRequest); - sinon.assert.notCalled(mockDB.deleteLinkedAccount); - sinon.assert.notCalled(mockDB.deleteSessionToken); + expect(mockDB.deleteLinkedAccount).not.toHaveBeenCalled(); + expect(mockDB.deleteSessionToken).not.toHaveBeenCalled(); }); it('handles database errors gracefully without unhandled promise rejection', async () => { setupTest({ validateSecurityToken: makeJWT('sessionRevoked') }); // Mock database to throw an error - mockDB.getLinkedAccount = sinon - .stub() - .rejects(new Error('Database connection failed')); + mockDB.getLinkedAccount = jest + .fn() + .mockRejectedValue(new Error('Database connection failed')); // This should not throw an unhandled promise rejection await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.decoded' - ); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.decoded'); + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processing.sessions_revoked' ); // Should not call processed because the event handler failed - sinon.assert.notCalled(mockDB.sessions); - sinon.assert.notCalled(mockDB.deleteSessionToken); + expect(mockDB.sessions).not.toHaveBeenCalled(); + expect(mockDB.deleteSessionToken).not.toHaveBeenCalled(); }); it('handles session deletion errors gracefully', async () => { setupTest({ validateSecurityToken: makeJWT('sessionRevoked') }); // Mock database to throw an error during session deletion - mockDB.getLinkedAccount = sinon.stub().resolves({ uid: UID }); - mockDB.sessions = sinon.stub().resolves([ + mockDB.getLinkedAccount = jest.fn().mockResolvedValue({ uid: UID }); + mockDB.sessions = jest.fn().mockResolvedValue([ { id: 'sessionTokenId1', uid: UID, providerId: 1 }, { id: 'sessionTokenId2', uid: UID, providerId: 1 }, ]); - mockDB.deleteSessionToken = sinon - .stub() - .onFirstCall() - .resolves() - .onSecondCall() - .rejects(new Error('Session deletion failed')); + mockDB.deleteSessionToken = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Session deletion failed')); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.decoded' - ); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.decoded'); + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processed.sessions_revoked' ); // Should still process the first session successfully - sinon.assert.calledWithExactly(mockDB.deleteSessionToken, { + expect(mockDB.deleteSessionToken).toHaveBeenCalledWith({ id: 'sessionTokenId1', uid: UID, providerId: 1, }); - sinon.assert.calledWithExactly(mockDB.deleteSessionToken, { + expect(mockDB.deleteSessionToken).toHaveBeenCalledWith({ id: 'sessionTokenId2', uid: UID, providerId: 1, @@ -1139,45 +1085,29 @@ describe('/linked_account', () => { await runTest(route, mockRequest); // Verify that the expected statsd metrics are called for first call - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.decoded' - ); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.decoded'); + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processing.sessions_revoked' ); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processed.sessions_revoked' ); - // Reset the statsd spy to clear previous calls - statsd.increment.resetHistory(); + // Reset the statsd mock to clear previous calls + statsd.increment.mockClear(); // Second call - should fail because sessions were already revoked await runTest(route, mockRequest); // Verify that the expected statsd metrics are called for second call - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.received' - ); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleGoogleSET.decoded' - ); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.received'); + expect(statsd.increment).toHaveBeenCalledWith('handleGoogleSET.decoded'); + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processing.sessions_revoked' ); // The processed metric should still be called even if no sessions were found to revoke - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleGoogleSET.processed.sessions_revoked' ); }); @@ -1187,28 +1117,21 @@ describe('/linked_account', () => { await runTest(route, mockRequest); - // Debug: print all calls to statsd.increment - // eslint-disable-next-line no-console - console.log( - 'statsd.increment calls:', - statsd.increment.getCalls().map((call: any) => call.args) - ); - // Only these two metrics should be called, in order - sinon.assert.callCount(statsd.increment, 2); - sinon.assert.calledWithExactly( - statsd.increment.getCall(0), + expect(statsd.increment).toHaveBeenCalledTimes(2); + expect(statsd.increment).toHaveBeenNthCalledWith( + 1, 'handleGoogleSET.received' ); - sinon.assert.calledWithExactly( - statsd.increment.getCall(1), + expect(statsd.increment).toHaveBeenNthCalledWith( + 2, 'handleGoogleSET.validationError' ); // Should not call decoded or processing metrics since validation failed - sinon.assert.notCalled(mockDB.getLinkedAccount); - sinon.assert.notCalled(mockDB.sessions); - sinon.assert.notCalled(mockDB.deleteSessionToken); + expect(mockDB.getLinkedAccount).not.toHaveBeenCalled(); + expect(mockDB.sessions).not.toHaveBeenCalled(); + expect(mockDB.deleteSessionToken).not.toHaveBeenCalled(); }); }); @@ -1252,14 +1175,14 @@ describe('/linked_account', () => { }, ], }); - mockDB.getLinkedAccount = sinon.spy(() => Promise.resolve({ uid: UID })); + mockDB.getLinkedAccount = jest.fn(() => Promise.resolve({ uid: UID })); const mockConfig = { appleAuthConfig: { clientId: 'OooOoo', teamId: 'teamId' }, }; mockRequest = mocks.mockRequest({ payload: [], }); - statsd = { increment: sinon.spy() }; + statsd = { increment: jest.fn() }; route = getRoute( makeRoutes( @@ -1289,13 +1212,9 @@ describe('/linked_account', () => { it('handles email disabled event', async () => { setupTest({ validateSecurityToken: makeJWT('email-disabled') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleAppleSET.received' - ); - sinon.assert.notCalled(mockDB.getLinkedAccount); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith('handleAppleSET.received'); + expect(mockDB.getLinkedAccount).not.toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( 'handleAppleSET.processed.email-disabled' ); }); @@ -1303,13 +1222,9 @@ describe('/linked_account', () => { it('handles email enabled event', async () => { setupTest({ validateSecurityToken: makeJWT('email-enabled') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleAppleSET.received' - ); - sinon.assert.notCalled(mockDB.getLinkedAccount); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith('handleAppleSET.received'); + expect(mockDB.getLinkedAccount).not.toHaveBeenCalled(); + expect(statsd.increment).toHaveBeenCalledWith( 'handleAppleSET.processed.email-enabled' ); }); @@ -1317,26 +1232,21 @@ describe('/linked_account', () => { it('handles consent revoked event', async () => { setupTest({ validateSecurityToken: makeJWT('consent-revoked') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleAppleSET.received' - ); - sinon.assert.calledOnceWithExactly(mockDB.deleteSessionToken, { + expect(statsd.increment).toHaveBeenCalledWith('handleAppleSET.received'); + expect(mockDB.deleteSessionToken).toHaveBeenCalledTimes(1); + expect(mockDB.deleteSessionToken).toHaveBeenCalledWith({ id: 'sessionTokenId1', uid: UID, providerId: 2, }); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleAppleSET.processed.consent-revoked' ); - sinon.assert.calledWithExactly( - mockLog.debug, + expect(mockLog.debug).toHaveBeenCalledWith( 'Revoked 1 third party sessions for user fxauid' ); - sinon.assert.calledWithExactly(mockDB.deleteLinkedAccount, UID, 'apple'); - sinon.assert.calledWithExactly( - statsd.increment, + expect(mockDB.deleteLinkedAccount).toHaveBeenCalledWith(UID, 'apple'); + expect(statsd.increment).toHaveBeenCalledWith( 'handleAppleSET.processed.consent-revoked' ); }); @@ -1344,26 +1254,21 @@ describe('/linked_account', () => { it('handles account delete event', async () => { setupTest({ validateSecurityToken: makeJWT('account-delete') }); await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleAppleSET.received' - ); - sinon.assert.calledOnceWithExactly(mockDB.deleteSessionToken, { + expect(statsd.increment).toHaveBeenCalledWith('handleAppleSET.received'); + expect(mockDB.deleteSessionToken).toHaveBeenCalledTimes(1); + expect(mockDB.deleteSessionToken).toHaveBeenCalledWith({ id: 'sessionTokenId1', uid: UID, providerId: 2, }); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleAppleSET.processed.account-delete' ); - sinon.assert.calledWithExactly( - mockLog.debug, + expect(mockLog.debug).toHaveBeenCalledWith( 'Revoked 1 third party sessions for user fxauid' ); - sinon.assert.calledWithExactly(mockDB.deleteLinkedAccount, UID, 'apple'); - sinon.assert.calledWithExactly( - statsd.increment, + expect(mockDB.deleteLinkedAccount).toHaveBeenCalledWith(UID, 'apple'); + expect(statsd.increment).toHaveBeenCalledWith( 'handleAppleSET.processed.account-delete' ); }); @@ -1372,36 +1277,29 @@ describe('/linked_account', () => { const jwt = makeJWT(); setupTest({ validateSecurityToken: jwt, unknownAccount: true }); await runTest(route, mockRequest); - sinon.assert.notCalled(mockDB.deleteLinkedAccount); - sinon.assert.notCalled(mockDB.deleteSessionToken); + expect(mockDB.deleteLinkedAccount).not.toHaveBeenCalled(); + expect(mockDB.deleteSessionToken).not.toHaveBeenCalled(); }); it('handles database errors gracefully without unhandled promise rejection', async () => { setupTest({ validateSecurityToken: makeJWT('consent-revoked') }); // Mock database to throw an error - mockDB.getLinkedAccount = sinon - .stub() - .rejects(new Error('Database connection failed')); + mockDB.getLinkedAccount = jest + .fn() + .mockRejectedValue(new Error('Database connection failed')); // This should not throw an unhandled promise rejection await runTest(route, mockRequest); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleAppleSET.received' - ); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleAppleSET.decoded' - ); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith('handleAppleSET.received'); + expect(statsd.increment).toHaveBeenCalledWith('handleAppleSET.decoded'); + expect(statsd.increment).toHaveBeenCalledWith( 'handleAppleSET.processing.consent-revoked' ); // Should not call processed because the event handler failed - sinon.assert.notCalled(mockDB.sessions); - sinon.assert.notCalled(mockDB.deleteLinkedAccount); + expect(mockDB.sessions).not.toHaveBeenCalled(); + expect(mockDB.deleteLinkedAccount).not.toHaveBeenCalled(); }); it('verifies statsd metrics are incremented for successful operations', async () => { @@ -1410,20 +1308,12 @@ describe('/linked_account', () => { await runTest(route, mockRequest); // Verify that the expected statsd metrics are called - sinon.assert.calledWithExactly( - statsd.increment, - 'handleAppleSET.received' - ); - sinon.assert.calledWithExactly( - statsd.increment, - 'handleAppleSET.decoded' - ); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith('handleAppleSET.received'); + expect(statsd.increment).toHaveBeenCalledWith('handleAppleSET.decoded'); + expect(statsd.increment).toHaveBeenCalledWith( 'handleAppleSET.processing.consent-revoked' ); - sinon.assert.calledWithExactly( - statsd.increment, + expect(statsd.increment).toHaveBeenCalledWith( 'handleAppleSET.processed.consent-revoked' ); }); diff --git a/packages/fxa-auth-server/lib/routes/mfa.spec.ts b/packages/fxa-auth-server/lib/routes/mfa.spec.ts index 3c39b4b6f5b..c3c5babe9cf 100644 --- a/packages/fxa-auth-server/lib/routes/mfa.spec.ts +++ b/packages/fxa-auth-server/lib/routes/mfa.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { AppError } from '@fxa/accounts/errors'; import { strategy } from './auth-schemes/mfa'; @@ -30,8 +29,6 @@ describe('mfa', () => { const SESSION_TOKEN_ID = 'session-123'; const UA_BROWSER = 'Firefox'; const action = 'test'; - const sandbox = sinon.createSandbox(); - const config = { mfa: { enabled: true, @@ -66,7 +63,7 @@ describe('mfa', () => { ); route = getRoute(routes, routePath, method); request = mocks.mockRequest(requestOptions); - request.emitMetricsEvent = sandbox.spy(() => Promise.resolve({})); + request.emitMetricsEvent = jest.fn(() => Promise.resolve({})); return await route.handler(request); } @@ -95,7 +92,7 @@ describe('mfa', () => { beforeEach(() => { const mockAccountEventsManager = { - recordSecurityEvent: sandbox.fake(), + recordSecurityEvent: jest.fn(), }; log = mocks.mockLog(); customs = mocks.mockCustoms(); @@ -108,7 +105,7 @@ describe('mfa', () => { emailVerified: true, }); otpUtils = new OtpUtils(db, statsd); - mockGetCredentialsFunc = sandbox.fake.returns({ + mockGetCredentialsFunc = jest.fn().mockReturnValue({ id: SESSION_TOKEN_ID, uid: UID, uaBrowser: UA_BROWSER, @@ -120,18 +117,18 @@ describe('mfa', () => { Container.set(AccountEventsManager, mockAccountEventsManager); code = ''; - mailer.sendVerifyAccountChangeEmail = sandbox.spy( + mailer.sendVerifyAccountChangeEmail = jest.fn( (_emails: any, _account: any, data: any) => { code = data.code; } ); - fxaMailer.sendVerifyAccountChangeEmail = sandbox.spy((data: any) => { + fxaMailer.sendVerifyAccountChangeEmail = jest.fn((data: any) => { code = data.code; }); }); afterEach(() => { - sandbox.reset(); + jest.clearAllMocks(); }); afterAll(() => { @@ -191,16 +188,14 @@ describe('mfa', () => { expect(authResult.credentials.uaBrowser).toBe(UA_BROWSER); // Make sure customs was invoked - sinon.assert.calledWith( - customs.checkAuthenticated, - sinon.match.any, + expect(customs.checkAuthenticated).toHaveBeenCalledWith( + expect.anything(), UID, TEST_EMAIL, 'mfaOtpCodeRequestForTest' ); - sinon.assert.calledWith( - customs.checkAuthenticated, - sinon.match.any, + expect(customs.checkAuthenticated).toHaveBeenCalledWith( + expect.anything(), UID, TEST_EMAIL, 'mfaOtpCodeVerifyForTest' diff --git a/packages/fxa-auth-server/lib/routes/newsletters.spec.ts b/packages/fxa-auth-server/lib/routes/newsletters.spec.ts index 9d4941c82a3..eb5f51274df 100644 --- a/packages/fxa-auth-server/lib/routes/newsletters.spec.ts +++ b/packages/fxa-auth-server/lib/routes/newsletters.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const mocks = require('../../test/mocks'); const { getRoute } = require('../../test/routes_helpers'); const ScopeSet = require('fxa-shared/oauth/scopes').scopeSetHelpers; @@ -34,7 +32,7 @@ describe('/newsletters should emit newsletters update message', () => { let request: any, db: any, log: any, routes: any, route: any, response: any; describe('using session token', () => { - beforeAll(async () => { + beforeEach(async () => { log = mocks.mockLog(); db = mocks.mockDB({ email, @@ -64,19 +62,18 @@ describe('/newsletters should emit newsletters update message', () => { }); it('called log.begin correctly', () => { - sinon.assert.calledOnce(log.begin); - sinon.assert.calledWithExactly(log.begin, 'newsletters', request); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(log.begin).toHaveBeenCalledWith('newsletters', request); }); it('called db.account correctly', () => { - sinon.assert.calledOnce(db.account); - sinon.assert.calledWithExactly(db.account, uid); + expect(db.account).toHaveBeenCalledTimes(1); + expect(db.account).toHaveBeenCalledWith(uid); }); it('called log.notifyAttachedServices correctly', () => { - sinon.assert.calledOnce(log.notifyAttachedServices); - sinon.assert.calledWithExactly( - log.notifyAttachedServices, + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( 'newsletters:update', request, { @@ -93,7 +90,7 @@ describe('/newsletters should emit newsletters update message', () => { }); describe('using access token', () => { - beforeAll(async () => { + beforeEach(async () => { log = mocks.mockLog(); db = mocks.mockDB({ email, @@ -115,13 +112,11 @@ describe('/newsletters should emit newsletters update message', () => { }, }); - sinon.stub(ScopeSet, 'fromArray').returns({ contains: () => true }); + jest + .spyOn(ScopeSet, 'fromArray') + .mockReturnValue({ contains: () => true }); - try { - response = await runTest(route, request); - } finally { - (ScopeSet.fromArray as sinon.SinonStub).restore(); - } + response = await runTest(route, request); }); it('returns correct response', () => { @@ -129,19 +124,18 @@ describe('/newsletters should emit newsletters update message', () => { }); it('called log.begin correctly', () => { - sinon.assert.calledOnce(log.begin); - sinon.assert.calledWithExactly(log.begin, 'newsletters', request); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(log.begin).toHaveBeenCalledWith('newsletters', request); }); it('called db.account correctly', () => { - sinon.assert.calledOnce(db.account); - sinon.assert.calledWithExactly(db.account, uid); + expect(db.account).toHaveBeenCalledTimes(1); + expect(db.account).toHaveBeenCalledWith(uid); }); it('called log.notifyAttachedServices correctly', () => { - sinon.assert.calledOnce(log.notifyAttachedServices); - sinon.assert.calledWithExactly( - log.notifyAttachedServices, + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( 'newsletters:update', request, { @@ -179,14 +173,14 @@ describe('/newsletters should emit newsletters update message', () => { newsletters, }, }); - sinon.stub(ScopeSet, 'fromArray').returns({ contains: () => false }); + jest + .spyOn(ScopeSet, 'fromArray') + .mockReturnValue({ contains: () => false }); try { await runTest(route, request); throw new Error('An error should have been thrown.'); } catch (e: any) { expect(e.output.payload.code).toBe(401); - } finally { - (ScopeSet.fromArray as sinon.SinonStub).restore(); } }); }); @@ -200,11 +194,13 @@ describe('/newsletters should emit newsletters update message', () => { }); routes = makeRoutes({ log, db }); route = getRoute(routes, '/newsletters'); - sinon.stub(ScopeSet, 'fromArray').returns({ contains: () => true }); + jest + .spyOn(ScopeSet, 'fromArray') + .mockReturnValue({ contains: () => true }); }); afterEach(() => { - (ScopeSet.fromArray as sinon.SinonStub).restore(); + jest.restoreAllMocks(); }); it('throws a bad request error when "newsletters" is missing', async () => { diff --git a/packages/fxa-auth-server/lib/routes/oauth/index.spec.ts b/packages/fxa-auth-server/lib/routes/oauth/index.spec.ts index 2e2e0dc4b0e..6b1a86cb1c7 100644 --- a/packages/fxa-auth-server/lib/routes/oauth/index.spec.ts +++ b/packages/fxa-auth-server/lib/routes/oauth/index.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - // Mock the OAuth DB to prevent real MySQL connections jest.mock('../../oauth/db', () => ({ getClient: jest.fn().mockResolvedValue(null), @@ -29,7 +27,11 @@ const MOCK_JWT = '001122334455.66778899aabbccddeeff00112233445566778899.aabbccddeeff'; describe('/oauth/ routes', () => { - let mockDB: any, mockLog: any, mockConfig: any, sessionToken: any, mockStatsD: any; + let mockDB: any, + mockLog: any, + mockConfig: any, + sessionToken: any, + mockStatsD: any; async function loadAndCallRoute(path: string, request: any) { const routes = require('./index')( @@ -58,15 +60,11 @@ describe('/oauth/ routes', () => { async function mockSessionToken(props: any = {}) { const Token = require(`../../tokens/token`)(mockLog); - const SessionToken = require(`../../tokens/session_token`)( - mockLog, - Token, - { - tokenLifetimes: { - sessionTokenWithoutDevice: 2419200000, - }, - } - ); + const SessionToken = require(`../../tokens/session_token`)(mockLog, Token, { + tokenLifetimes: { + sessionTokenWithoutDevice: 2419200000, + }, + }); return await SessionToken.create({ uid: MOCK_USER_ID, email: 'foo@example.com', @@ -90,7 +88,7 @@ describe('/oauth/ routes', () => { }); describe('/oauth/id-token-verify', () => { - let MOCK_ID_TOKEN_CLAIMS: any, mockVerify: sinon.SinonStub; + let MOCK_ID_TOKEN_CLAIMS: any, mockVerify: jest.Mock; beforeEach(() => { MOCK_ID_TOKEN_CLAIMS = { @@ -105,11 +103,11 @@ describe('/oauth/ routes', () => { acr: 'AAL2', 'fxa-aal': 2, }; - mockVerify = sinon.stub(JWTIdToken, 'verify'); + mockVerify = jest.spyOn(JWTIdToken, 'verify'); }); const _testRequest = async (claims: any, gracePeriod?: number) => { - mockVerify.returns(claims); + mockVerify.mockReturnValue(claims); const payload: any = { client_id: MOCK_CLIENT_ID, id_token: MOCK_JWT, @@ -123,23 +121,21 @@ describe('/oauth/ routes', () => { }; afterEach(() => { - mockVerify.restore(); + jest.restoreAllMocks(); }); it('calls JWTIdToken.verify', async () => { const resp = await _testRequest(MOCK_ID_TOKEN_CLAIMS); - sinon.assert.calledOnce(mockVerify); + expect(mockVerify).toHaveBeenCalledTimes(1); expect(resp).toEqual(MOCK_ID_TOKEN_CLAIMS); - mockVerify.restore(); }); it('supports expiryGracePeriod option', async () => { const resp = await _testRequest(MOCK_ID_TOKEN_CLAIMS, 600); - sinon.assert.calledOnce(mockVerify); + expect(mockVerify).toHaveBeenCalledTimes(1); expect(resp).toEqual(MOCK_ID_TOKEN_CLAIMS); - mockVerify.restore(); }); it('allows extra claims', async () => { @@ -147,9 +143,8 @@ describe('/oauth/ routes', () => { const resp = await _testRequest(MOCK_ID_TOKEN_CLAIMS); - sinon.assert.calledOnce(mockVerify); + expect(mockVerify).toHaveBeenCalledTimes(1); expect(resp).toEqual(MOCK_ID_TOKEN_CLAIMS); - mockVerify.restore(); }); it('allows missing claims', async () => { @@ -157,9 +152,8 @@ describe('/oauth/ routes', () => { const resp = await _testRequest(MOCK_ID_TOKEN_CLAIMS); - sinon.assert.calledOnce(mockVerify); + expect(mockVerify).toHaveBeenCalledTimes(1); expect(resp).toEqual(MOCK_ID_TOKEN_CLAIMS); - mockVerify.restore(); }); }); diff --git a/packages/fxa-auth-server/lib/routes/oauth/token.spec.ts b/packages/fxa-auth-server/lib/routes/oauth/token.spec.ts index 661a7bc7c7e..7285657faf4 100644 --- a/packages/fxa-auth-server/lib/routes/oauth/token.spec.ts +++ b/packages/fxa-auth-server/lib/routes/oauth/token.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; const Joi = require('joi'); const { Container } = require('typedi'); const ScopeSet = require('fxa-shared').oauth.scopes; @@ -37,9 +36,9 @@ const FIREFOX_IOS_CLIENT_ID = '1b1a3e44c54fbb58'; const noop = () => {}; const mockLog = { debug: noop, warn: noop, info: noop }; -const mockDb = { touchSessionToken: sinon.stub() }; -const mockStatsD = { increment: sinon.stub() }; -const mockGlean = { oauth: { tokenCreated: sinon.stub() } }; +const mockDb = { touchSessionToken: jest.fn() }; +const mockStatsD = { increment: jest.fn() }; +const mockGlean = { oauth: { tokenCreated: jest.fn() } }; const tokenRoutesDepMocks = { '../../oauth/assertion': async () => true, @@ -286,7 +285,7 @@ describe('/token POST', () => { describe('statsd metrics', () => { beforeEach(() => { - mockStatsD.increment.resetHistory(); + mockStatsD.increment.mockClear(); }); it('increments count on scope keys usage', async () => { @@ -300,11 +299,10 @@ describe('/token POST', () => { emitMetricsEvent: () => {}, }; await route.config.handler(request); - sinon.assert.calledOnceWithExactly( - mockStatsD.increment, - 'oauth.rp.keys-jwe', - { clientId: CLIENT_ID } - ); + expect(mockStatsD.increment).toHaveBeenCalledTimes(1); + expect(mockStatsD.increment).toHaveBeenCalledWith('oauth.rp.keys-jwe', { + clientId: CLIENT_ID, + }); }); it('does not call statsd', async () => { @@ -317,13 +315,13 @@ describe('/token POST', () => { emitMetricsEvent: () => {}, }; await route.config.handler(request); - sinon.assert.notCalled(mockStatsD.increment); + expect(mockStatsD.increment).not.toHaveBeenCalled(); }); }); describe('Glean metrics', () => { beforeEach(() => { - mockGlean.oauth.tokenCreated.reset(); + mockGlean.oauth.tokenCreated.mockClear(); }); it('logs the token created event', async () => { @@ -337,16 +335,13 @@ describe('/token POST', () => { emitMetricsEvent: () => {}, }; await route.config.handler(request); - sinon.assert.calledOnceWithExactly( - mockGlean.oauth.tokenCreated, - request, - { - uid: UID, - oauthClientId: CLIENT_ID, - reason: 'authorization_code', - scopes: '', - } - ); + expect(mockGlean.oauth.tokenCreated).toHaveBeenCalledTimes(1); + expect(mockGlean.oauth.tokenCreated).toHaveBeenCalledWith(request, { + uid: UID, + oauthClientId: CLIENT_ID, + reason: 'authorization_code', + scopes: '', + }); }); it('logs space-separated scopes from ScopeSet for the token created event', async () => { @@ -362,7 +357,7 @@ describe('/token POST', () => { scope: ScopeSet.fromString(SMARTWINDOW_SCOPES), }), })); - const mockGleanLocal = { oauth: { tokenCreated: sinon.stub() } }; + const mockGleanLocal = { oauth: { tokenCreated: jest.fn() } }; const routes = require('./token')({ ...tokenRoutesArgMocks, glean: mockGleanLocal, @@ -376,16 +371,13 @@ describe('/token POST', () => { emitMetricsEvent: () => {}, }; await routes[0].config.handler(request); - sinon.assert.calledOnceWithExactly( - mockGleanLocal.oauth.tokenCreated, - request, - { - uid: UID, - oauthClientId: CLIENT_ID, - reason: 'fxa-credentials', - scopes: SMARTWINDOW_SCOPES, - } - ); + expect(mockGleanLocal.oauth.tokenCreated).toHaveBeenCalledTimes(1); + expect(mockGleanLocal.oauth.tokenCreated).toHaveBeenCalledWith(request, { + uid: UID, + oauthClientId: CLIENT_ID, + reason: 'fxa-credentials', + scopes: SMARTWINDOW_SCOPES, + }); }); }); }); @@ -656,7 +648,7 @@ describe('token exchange grant_type', () => { describe('/oauth/token POST', () => { describe('update session last access time', () => { beforeEach(() => { - mockDb.touchSessionToken.reset(); + mockDb.touchSessionToken.mockClear(); }); it('updates last access time of a session', async () => { @@ -671,8 +663,8 @@ describe('/oauth/token POST', () => { emitMetricsEvent: async () => {}, }; await tokenRoutes[1].handler(request); - sinon.assert.calledOnceWithExactly( - mockDb.touchSessionToken, + expect(mockDb.touchSessionToken).toHaveBeenCalledTimes(1); + expect(mockDb.touchSessionToken).toHaveBeenCalledWith( sessionToken, {}, true @@ -708,7 +700,7 @@ describe('/oauth/token POST', () => { emitMetricsEvent: async () => {}, }; await routes[1].handler(request); - sinon.assert.notCalled(mockDb.touchSessionToken); + expect(mockDb.touchSessionToken).not.toHaveBeenCalled(); }); }); @@ -718,10 +710,10 @@ describe('/oauth/token POST', () => { it('handles token exchange and passes existingDeviceId to newTokenNotification', async () => { const PROFILE_SCOPE = 'profile'; - const newTokenNotificationStub = sinon.stub().resolves(); - const sessionTokenStub = sinon - .stub() - .rejects(new Error('should not be called')); + const newTokenNotificationStub = jest.fn().mockResolvedValue(); + const sessionTokenStub = jest + .fn() + .mockRejectedValue(new Error('should not be called')); jest.resetModules(); jest.doMock('../../oauth/assertion', () => async () => true); jest.doMock( @@ -746,7 +738,7 @@ describe('/oauth/token POST', () => { newTokenNotification: newTokenNotificationStub, })); jest.doMock('../../oauth/token', () => ({ - verify: sinon.stub().resolves({ user: UID }), + verify: jest.fn().mockResolvedValue({ user: UID }), })); const routes = require('./token')({ ...tokenRoutesArgMocks, @@ -795,19 +787,26 @@ describe('/oauth/token POST', () => { expect(result.scope).toContain(OAUTH_SCOPE_RELAY); expect(result._clientId).toBeUndefined(); expect(result._existingDeviceId).toBeUndefined(); - sinon.assert.calledOnce(newTokenNotificationStub); - const callArgs = newTokenNotificationStub.firstCall.args; - expect(callArgs[5]).toEqual({ - skipEmail: true, - existingDeviceId: MOCK_DEVICE_ID, - clientId: FIREFOX_IOS_CLIENT_ID, - }); - sinon.assert.notCalled(sessionTokenStub); + expect(newTokenNotificationStub).toHaveBeenCalledTimes(1); + expect(newTokenNotificationStub).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + { + skipEmail: true, + existingDeviceId: MOCK_DEVICE_ID, + clientId: FIREFOX_IOS_CLIENT_ID, + } + ); + expect(sessionTokenStub).not.toHaveBeenCalled(); }); it('handles token exchange when no existing device is found (existingDeviceId is undefined)', async () => { const PROFILE_SCOPE = 'profile'; - const newTokenNotificationStub = sinon.stub().resolves(); + const newTokenNotificationStub = jest.fn().mockResolvedValue(); jest.resetModules(); jest.doMock('../../oauth/assertion', () => async () => true); jest.doMock( @@ -832,7 +831,7 @@ describe('/oauth/token POST', () => { newTokenNotification: newTokenNotificationStub, })); jest.doMock('../../oauth/token', () => ({ - verify: sinon.stub().resolves({ user: UID }), + verify: jest.fn().mockResolvedValue({ user: UID }), })); const routes = require('./token')({ ...tokenRoutesArgMocks, @@ -877,22 +876,29 @@ describe('/oauth/token POST', () => { expect(result.refresh_token).toBe('new_refresh_token'); expect(result._clientId).toBeUndefined(); expect(result._existingDeviceId).toBeUndefined(); - sinon.assert.calledOnce(newTokenNotificationStub); - const callArgs = newTokenNotificationStub.firstCall.args; - expect(callArgs[5]).toEqual({ - skipEmail: true, - existingDeviceId: undefined, - clientId: FIREFOX_IOS_CLIENT_ID, - }); + expect(newTokenNotificationStub).toHaveBeenCalledTimes(1); + expect(newTokenNotificationStub).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + { + skipEmail: true, + existingDeviceId: undefined, + clientId: FIREFOX_IOS_CLIENT_ID, + } + ); }); }); describe('fxa-credentials with reason=token_migration', () => { it('calls newTokenNotification with skipEmail: true when reason is token_migration', async () => { - const newTokenNotificationStub = sinon.stub().resolves(); - const sessionTokenStub = sinon - .stub() - .rejects(new Error('should not be called')); + const newTokenNotificationStub = jest.fn().mockResolvedValue(); + const sessionTokenStub = jest + .fn() + .mockRejectedValue(new Error('should not be called')); jest.resetModules(); jest.doMock('../../oauth/assertion', () => async () => true); jest.doMock( @@ -936,18 +942,25 @@ describe('/oauth/token POST', () => { }; await routes[1].handler(request); - sinon.assert.calledOnce(newTokenNotificationStub); - const callArgs = newTokenNotificationStub.firstCall.args; - expect(callArgs[5]).toEqual({ - skipEmail: true, - existingDeviceId: undefined, - clientId: CLIENT_ID, - }); - sinon.assert.notCalled(sessionTokenStub); + expect(newTokenNotificationStub).toHaveBeenCalledTimes(1); + expect(newTokenNotificationStub).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + { + skipEmail: true, + existingDeviceId: undefined, + clientId: CLIENT_ID, + } + ); + expect(sessionTokenStub).not.toHaveBeenCalled(); }); it('calls newTokenNotification with skipEmail: false when reason is not provided', async () => { - const newTokenNotificationStub = sinon.stub().resolves(); + const newTokenNotificationStub = jest.fn().mockResolvedValue(); jest.resetModules(); jest.doMock('../../oauth/assertion', () => async () => true); jest.doMock( @@ -984,13 +997,20 @@ describe('/oauth/token POST', () => { }; await routes[1].handler(request); - sinon.assert.calledOnce(newTokenNotificationStub); - const callArgs = newTokenNotificationStub.firstCall.args; - expect(callArgs[5]).toEqual({ - skipEmail: false, - existingDeviceId: undefined, - clientId: CLIENT_ID, - }); + expect(newTokenNotificationStub).toHaveBeenCalledTimes(1); + expect(newTokenNotificationStub).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + { + skipEmail: false, + existingDeviceId: undefined, + clientId: CLIENT_ID, + } + ); }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/oauth/verify.spec.ts b/packages/fxa-auth-server/lib/routes/oauth/verify.spec.ts index 456f2ffce9e..01729290181 100644 --- a/packages/fxa-auth-server/lib/routes/oauth/verify.spec.ts +++ b/packages/fxa-auth-server/lib/routes/oauth/verify.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; const ScopeSet = require('fxa-shared').oauth.scopes; const TOKEN = @@ -17,26 +16,23 @@ function joiRequired(err: any, param: string) { describe('/verify POST', () => { let mocks: any; let route: any; - let sandbox: sinon.SinonSandbox; beforeAll(() => { - sandbox = sinon.createSandbox(); - mocks = { log: { - debug: sandbox.spy(), - info: sandbox.spy(), - warn: sandbox.spy(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), }, token: { - verify: sandbox.spy(async () => ({ + verify: jest.fn(async () => ({ client_id: 'foo', scope: ScopeSet.fromArray(['bar:foo', 'clients:write']), user: 'bar', })), }, glean: { - oauth: { tokenChecked: sandbox.stub() }, + oauth: { tokenChecked: jest.fn() }, }, }; @@ -46,7 +42,7 @@ describe('/verify POST', () => { }); afterEach(() => { - sandbox.reset(); + jest.clearAllMocks(); }); describe('validation', () => { @@ -82,7 +78,7 @@ describe('/verify POST', () => { payload: { token: TOKEN, }, - emitMetricsEvent: sinon.spy(), + emitMetricsEvent: jest.fn(), }; resp = await route.config.handler(req); }); @@ -94,20 +90,19 @@ describe('/verify POST', () => { }); it('verifies the token', () => { - expect(mocks.token.verify.calledOnceWith(TOKEN)).toBe(true); + expect(mocks.token.verify).toHaveBeenCalledWith(TOKEN); }); it('logs an amplitude event', () => { - expect( - req.emitMetricsEvent.calledOnceWith('verify.success', { - service: 'foo', - uid: 'bar', - }) - ).toBe(true); + expect(req.emitMetricsEvent).toHaveBeenCalledWith('verify.success', { + service: 'foo', + uid: 'bar', + }); }); it('logs a Glean event', () => { - sinon.assert.calledOnceWithExactly(mocks.glean.oauth.tokenChecked, req, { + expect(mocks.glean.oauth.tokenChecked).toHaveBeenCalledTimes(1); + expect(mocks.glean.oauth.tokenChecked).toHaveBeenCalledWith(req, { uid: 'bar', oauthClientId: 'foo', scopes: ['bar:foo', 'clients:write'], diff --git a/packages/fxa-auth-server/lib/routes/password.spec.ts b/packages/fxa-auth-server/lib/routes/password.spec.ts index 137a9456d82..4cf0ef13c18 100644 --- a/packages/fxa-auth-server/lib/routes/password.spec.ts +++ b/packages/fxa-auth-server/lib/routes/password.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import crypto from 'crypto'; import { Container } from 'typedi'; @@ -73,7 +72,7 @@ describe('/password', () => { mocks.mockOAuthClientInfo(); mockFxaMailer = mocks.mockFxaMailer(); mockAccountEventsManager = mocks.mockAccountEventsManager(); - glean.resetPassword.emailSent.reset(); + glean.resetPassword.emailSent.mockClear(); }); afterEach(() => { @@ -99,23 +98,23 @@ describe('/password', () => { const mockMetricsContext = mocks.mockMetricsContext(); const mockLog = mocks.mockLog('ERROR', 'test', { stdout: { - on: sinon.spy(), - write: sinon.spy(), + on: jest.fn(), + write: jest.fn(), }, stderr: { - on: sinon.spy(), - write: sinon.spy(), + on: jest.fn(), + write: jest.fn(), }, }); - mockLog.flowEvent = sinon.spy(() => { + mockLog.flowEvent = jest.fn(() => { return Promise.resolve(); }); const mockRedis = { - set: sinon.stub(), - get: sinon.stub(), - del: sinon.stub(), + set: jest.fn(), + get: jest.fn(), + del: jest.fn(), }; - const mockStatsd = { increment: sinon.stub() }; + const mockStatsd = { increment: jest.fn() }; it('sends an OTP when enabled', () => { const passwordRoutes = makeRoutes({ @@ -148,46 +147,57 @@ describe('/password', () => { '/password/forgot/send_otp', mockRequest ).then((response: any) => { - sinon.assert.calledOnce(mockFxaMailer.sendPasswordForgotOtpEmail); - expect(mockDB.accountRecord.callCount).toBe(1); - sinon.assert.calledOnce(mockRedis.set); + expect(mockFxaMailer.sendPasswordForgotOtpEmail).toHaveBeenCalledTimes( + 1 + ); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockRedis.set).toHaveBeenCalledTimes(1); // an eight digit code was set // TODO FXA-7852 check that the same code was pass to the email - expect(mockRedis.set.args[0][1]).toMatch(/^\d{8}$/); + expect(mockRedis.set.mock.calls[0][1]).toMatch(/^\d{8}$/); - expect(mockRequest.validateMetricsContext.callCount).toBe(1); - sinon.assert.calledOnceWithExactly( - mockCustoms.check, + expect(mockRequest.validateMetricsContext).toHaveBeenCalledTimes(1); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); + expect(mockCustoms.check).toHaveBeenCalledWith( mockRequest, TEST_EMAIL, 'passwordForgotSendOtp' ); - sinon.assert.calledOnce(mockFxaMailer.sendPasswordForgotOtpEmail); - - expect(mockMetricsContext.setFlowCompleteSignal.callCount).toBe(1); - const args = mockMetricsContext.setFlowCompleteSignal.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe('account.reset'); + expect(mockFxaMailer.sendPasswordForgotOtpEmail).toHaveBeenCalledTimes( + 1 + ); - expect(mockLog.flowEvent.callCount).toBe(2); - expect(mockLog.flowEvent.args[0][0].event).toBe( - 'password.forgot.send_otp.start' + expect(mockMetricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes( + 1 + ); + expect( + mockMetricsContext.setFlowCompleteSignal + ).toHaveBeenNthCalledWith(1, 'account.reset'); + + expect(mockLog.flowEvent).toHaveBeenCalledTimes(2); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'password.forgot.send_otp.start' }) ); - expect(mockLog.flowEvent.args[1][0].event).toBe( - 'password.forgot.send_otp.completed' + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + event: 'password.forgot.send_otp.completed', + }) ); - sinon.assert.calledOnceWithExactly( - glean.resetPassword.otpEmailSent, + expect(glean.resetPassword.otpEmailSent).toHaveBeenCalledTimes(1); + expect(glean.resetPassword.otpEmailSent).toHaveBeenCalledWith( mockRequest ); - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.password_reset_otp_sent', ipAddr: '63.245.221.32', uid, @@ -260,24 +270,24 @@ describe('/password', () => { const mockMetricsContext = mocks.mockMetricsContext(); const mockLog = mocks.mockLog('ERROR', 'test', { stdout: { - on: sinon.spy(), - write: sinon.spy(), + on: jest.fn(), + write: jest.fn(), }, stderr: { - on: sinon.spy(), - write: sinon.spy(), + on: jest.fn(), + write: jest.fn(), }, }); - mockLog.flowEvent = sinon.spy(() => { + mockLog.flowEvent = jest.fn(() => { return Promise.resolve(); }); const code = '97236000'; const mockRedis = { - set: sinon.stub(), - get: sinon.stub().returns(code), - del: sinon.stub(), + set: jest.fn(), + get: jest.fn().mockReturnValue(code), + del: jest.fn(), }; - const mockStatsd = { increment: sinon.stub() }; + const mockStatsd = { increment: jest.fn() }; const mockRequest = mocks.mockRequest({ log: mockLog, @@ -312,65 +322,69 @@ describe('/password', () => { '/password/forgot/verify_otp', mockRequest ).then((response: any) => { - expect(mockDB.accountRecord.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); - sinon.assert.calledOnce(mockRedis.get); - sinon.assert.calledOnce(mockRedis.del); - expect(mockRedis.get.args[0][0]).toMatch(new RegExp(uid)); + expect(mockRedis.get).toHaveBeenCalledTimes(1); + expect(mockRedis.del).toHaveBeenCalledTimes(1); + expect(mockRedis.get).toHaveBeenNthCalledWith( + 1, + expect.stringMatching(new RegExp(uid)) + ); - expect(mockRequest.validateMetricsContext.callCount).toBe(1); + expect(mockRequest.validateMetricsContext).toHaveBeenCalledTimes(1); - sinon.assert.calledWithExactly( - mockCustoms.check, + expect(mockCustoms.check).toHaveBeenCalledWith( mockRequest, TEST_EMAIL, 'passwordForgotVerifyOtp' ); - sinon.assert.calledWithExactly( - mockCustoms.check, + expect(mockCustoms.check).toHaveBeenCalledWith( mockRequest, TEST_EMAIL, 'passwordForgotVerifyOtpPerDay' ); - sinon.assert.callCount(mockStatsd.increment, 2); - sinon.assert.calledWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(2); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'otp.passwordForgot.attempt', {} ); - sinon.assert.calledWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledWith( 'otp.passwordForgot.verified', {} ); - expect(mockLog.flowEvent.callCount).toBe(2); - expect(mockLog.flowEvent.args[0][0].event).toBe( - 'password.forgot.verify_otp.start' + expect(mockLog.flowEvent).toHaveBeenCalledTimes(2); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'password.forgot.verify_otp.start' }) ); - expect(mockLog.flowEvent.args[1][0].event).toBe( - 'password.forgot.verify_otp.completed' + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + event: 'password.forgot.verify_otp.completed', + }) ); - expect(mockDB.createPasswordForgotToken.callCount).toBe(1); - const args = mockDB.createPasswordForgotToken.args[0]; + expect(mockDB.createPasswordForgotToken).toHaveBeenCalledTimes(1); + const args = mockDB.createPasswordForgotToken.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].uid).toEqual(uid); expect(response.token).toMatch(/^(?:[a-fA-F0-9]{2}){32}$/); expect(response.code).toBe('486008'); - sinon.assert.calledOnceWithExactly( - glean.resetPassword.otpVerified, + expect(glean.resetPassword.otpVerified).toHaveBeenCalledTimes(1); + expect(glean.resetPassword.otpVerified).toHaveBeenCalledWith( mockRequest ); - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.password_reset_otp_verified', ipAddr: '63.245.221.32', uid, @@ -401,15 +415,15 @@ describe('/password', () => { const mockMetricsContext = mocks.mockMetricsContext(); const mockLog = log('ERROR', 'test', { stdout: { - on: sinon.spy(), - write: sinon.spy(), + on: jest.fn(), + write: jest.fn(), }, stderr: { - on: sinon.spy(), - write: sinon.spy(), + on: jest.fn(), + write: jest.fn(), }, }); - mockLog.flowEvent = sinon.spy(() => { + mockLog.flowEvent = jest.fn(() => { return Promise.resolve(); }); const passwordRoutes = makeRoutes({ @@ -450,32 +464,37 @@ describe('/password', () => { expect(Object.keys(response)).toEqual(['accountResetToken']); expect(response.accountResetToken).toBe(accountResetToken.data); - expect(mockCustoms.check.callCount).toBe(1); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); - expect(mockDB.forgotPasswordVerified.callCount).toBe(1); - let args = mockDB.forgotPasswordVerified.args[0]; + expect(mockDB.forgotPasswordVerified).toHaveBeenCalledTimes(1); + let args = mockDB.forgotPasswordVerified.mock.calls[0]; expect(args.length).toBe(1); expect(args[0].uid).toEqual(uid); - expect(mockRequest.validateMetricsContext.callCount).toBe(0); - expect(mockLog.flowEvent.callCount).toBe(2); - expect(mockLog.flowEvent.args[0][0].event).toBe( - 'password.forgot.verify_code.start' + expect(mockRequest.validateMetricsContext).toHaveBeenCalledTimes(0); + expect(mockLog.flowEvent).toHaveBeenCalledTimes(2); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'password.forgot.verify_code.start' }) ); - expect(mockLog.flowEvent.args[1][0].event).toBe( - 'password.forgot.verify_code.completed' + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + event: 'password.forgot.verify_code.completed', + }) ); - expect(mockMetricsContext.propagate.callCount).toBe(1); - args = mockMetricsContext.propagate.args[0]; + expect(mockMetricsContext.propagate).toHaveBeenCalledTimes(1); + args = mockMetricsContext.propagate.mock.calls[0]; expect(args).toHaveLength(2); expect(args[0].id).toBe(passwordForgotTokenId); expect(args[0].uid).toBe(uid); expect(args[1].id).toBe(accountResetToken.id); expect(args[1].uid).toBe(uid); - expect(mockFxaMailer.sendPasswordResetEmail.callCount).toBe(1); - const passwordResetArgs = mockFxaMailer.sendPasswordResetEmail.args[0]; + expect(mockFxaMailer.sendPasswordResetEmail).toHaveBeenCalledTimes(1); + const passwordResetArgs = + mockFxaMailer.sendPasswordResetEmail.mock.calls[0]; expect(passwordResetArgs[0].uid).toBe(uid); expect(passwordResetArgs[0].deviceId).toBe('wibble'); }); @@ -521,7 +540,7 @@ describe('/password', () => { statsd: mockStatsd, }); - mockDB.checkPassword = sinon.spy(() => + mockDB.checkPassword = jest.fn(() => Promise.resolve({ v1: true, v2: false, @@ -534,16 +553,15 @@ describe('/password', () => { mockRequest ); - sinon.assert.calledWith( - mockCustoms.checkAuthenticated, + expect(mockCustoms.checkAuthenticated).toHaveBeenCalledWith( mockRequest, uid, TEST_EMAIL, 'authenticatedPasswordChange' ); - sinon.assert.calledWith(mockDB.accountRecord, TEST_EMAIL); - sinon.assert.calledOnce(mockDB.createKeyFetchToken); - sinon.assert.calledWith(mockDB.createPasswordChangeToken, { uid }); + expect(mockDB.accountRecord).toHaveBeenCalledWith(TEST_EMAIL); + expect(mockDB.createKeyFetchToken).toHaveBeenCalledTimes(1); + expect(mockDB.createPasswordChangeToken).toHaveBeenCalledWith({ uid }); expect(response.keyFetchToken).toBeTruthy(); expect(response.passwordChangeToken).toBeTruthy(); @@ -586,7 +604,7 @@ describe('/password', () => { statsd: mockStatsd, }); - mockDB.checkPassword = sinon.spy(() => + mockDB.checkPassword = jest.fn(() => Promise.resolve({ v1: true, v2: false, @@ -599,16 +617,15 @@ describe('/password', () => { mockRequest ); - sinon.assert.calledWith( - mockCustoms.checkAuthenticated, + expect(mockCustoms.checkAuthenticated).toHaveBeenCalledWith( mockRequest, uid, TEST_EMAIL, 'authenticatedPasswordChange' ); - sinon.assert.calledWith(mockDB.accountRecord, TEST_EMAIL); - sinon.assert.calledOnce(mockDB.createKeyFetchToken); - sinon.assert.calledWith(mockDB.createPasswordChangeToken, { uid }); + expect(mockDB.accountRecord).toHaveBeenCalledWith(TEST_EMAIL); + expect(mockDB.createKeyFetchToken).toHaveBeenCalledTimes(1); + expect(mockDB.createPasswordChangeToken).toHaveBeenCalledWith({ uid }); expect(response.keyFetchToken).toBeTruthy(); expect(response.passwordChangeToken).toBeTruthy(); @@ -661,29 +678,27 @@ describe('/password', () => { '/password/change/finish', mockRequest ).then((response: any) => { - expect(mockDB.deletePasswordChangeToken.callCount).toBe(1); - expect(mockDB.resetAccount.callCount).toBe(1); - expect(mockDB.resetAccount.firstCall.args[2]).toBe(undefined); + expect(mockDB.deletePasswordChangeToken).toHaveBeenCalledTimes(1); + expect(mockDB.resetAccount).toHaveBeenCalledTimes(1); + expect(mockDB.resetAccount.mock.calls[0][2]).toBe(undefined); - expect(mockPush.notifyPasswordChanged.callCount).toBe(1); - expect(mockPush.notifyPasswordChanged.firstCall.args[0]).toEqual(uid); - expect(mockPush.notifyPasswordChanged.firstCall.args[1]).toEqual([ + expect(mockPush.notifyPasswordChanged).toHaveBeenCalledTimes(1); + expect(mockPush.notifyPasswordChanged).toHaveBeenNthCalledWith(1, uid, [ devices[1], ]); - expect(mockDB.account.callCount).toBe(1); - expect(mockFxaMailer.sendPasswordChangedEmail.callCount).toBe(1); - let args = mockFxaMailer.sendPasswordChangedEmail.args[0]; - expect(args[0].to).toBe(TEST_EMAIL); - expect(args[0].location.city).toBe('Mountain View'); - expect(args[0].location.country).toBe('United States'); - expect(args[0].timeZone).toBe('America/Los_Angeles'); - expect(args[0].uid).toBe(uid); - - expect(mockLog.activityEvent.callCount).toBe(1); - args = mockLog.activityEvent.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toEqual({ + expect(mockDB.account).toHaveBeenCalledTimes(1); + expect(mockFxaMailer.sendPasswordChangedEmail).toHaveBeenCalledTimes(1); + const passwordChangedArgs = + mockFxaMailer.sendPasswordChangedEmail.mock.calls[0]; + expect(passwordChangedArgs[0].to).toBe(TEST_EMAIL); + expect(passwordChangedArgs[0].location.city).toBe('Mountain View'); + expect(passwordChangedArgs[0].location.country).toBe('United States'); + expect(passwordChangedArgs[0].timeZone).toBe('America/Los_Angeles'); + expect(passwordChangedArgs[0].uid).toBe(uid); + + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + expect(mockLog.activityEvent).toHaveBeenNthCalledWith(1, { country: 'United States', event: 'account.changedPassword', region: 'California', @@ -694,20 +709,21 @@ describe('/password', () => { clientJa4: 'test-ja4', }); - expect(mockDB.createSessionToken.callCount).toBe(1); - args = mockDB.createSessionToken.args[0]; - expect(args.length).toBe(1); - expect(args[0].uaBrowser).toBe('Firefox'); - expect(args[0].uaBrowserVersion).toBe('57'); - expect(args[0].uaOS).toBe('Mac OS X'); - expect(args[0].uaOSVersion).toBe('10.11'); - expect(args[0].uaDeviceType).toBe(null); - expect(args[0].uaFormFactor).toBe(null); - - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + const sessionTokenArgs = mockDB.createSessionToken.mock.calls[0]; + expect(sessionTokenArgs.length).toBe(1); + expect(sessionTokenArgs[0].uaBrowser).toBe('Firefox'); + expect(sessionTokenArgs[0].uaBrowserVersion).toBe('57'); + expect(sessionTokenArgs[0].uaOS).toBe('Mac OS X'); + expect(sessionTokenArgs[0].uaOSVersion).toBe('10.11'); + expect(sessionTokenArgs[0].uaDeviceType).toBe(null); + expect(sessionTokenArgs[0].uaFormFactor).toBe(null); + + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.password_changed', ipAddr: '63.245.221.32', uid: mockRequest.auth.credentials.uid, @@ -715,10 +731,11 @@ describe('/password', () => { }) ); - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.password_reset_success', ipAddr: '63.245.221.32', uid: mockRequest.auth.credentials.uid, @@ -736,14 +753,16 @@ describe('/password', () => { }); const mockPush = mocks.mockPush(); const mockMailer = { - sendPasswordChangedEmail: sinon.spy(() => { + sendPasswordChangedEmail: jest.fn(() => { return Promise.reject(error.emailBouncedHard()); }), }; const mockLog = mocks.mockLog(); // Configure mockFxaMailer to reject for this test - mockFxaMailer.sendPasswordChangedEmail.rejects(error.emailBouncedHard()); + mockFxaMailer.sendPasswordChangedEmail.mockRejectedValue( + error.emailBouncedHard() + ); const mockRequest = mocks.mockRequest({ credentials: { @@ -776,26 +795,28 @@ describe('/password', () => { '/password/change/finish', mockRequest ).then((response: any) => { - expect(mockDB.deletePasswordChangeToken.callCount).toBe(1); - expect(mockDB.resetAccount.callCount).toBe(1); - expect(mockDB.resetAccount.firstCall.args[2]).toBe(undefined); + expect(mockDB.deletePasswordChangeToken).toHaveBeenCalledTimes(1); + expect(mockDB.resetAccount).toHaveBeenCalledTimes(1); + expect(mockDB.resetAccount.mock.calls[0][2]).toBe(undefined); - expect(mockPush.notifyPasswordChanged.callCount).toBe(1); - expect(mockPush.notifyPasswordChanged.firstCall.args[0]).toEqual(uid); + expect(mockPush.notifyPasswordChanged).toHaveBeenCalledTimes(1); + expect(mockPush.notifyPasswordChanged).toHaveBeenNthCalledWith( + 1, + uid, + expect.anything() + ); - const notifyArgs = mockLog.notifyAttachedServices.args[0]; + const notifyArgs = mockLog.notifyAttachedServices.mock.calls[0]; expect(notifyArgs.length).toBe(3); expect(notifyArgs[0]).toBe('passwordChange'); expect(notifyArgs[1]).toBe(mockRequest); expect(notifyArgs[2].uid).toBe(uid); - expect(mockDB.account.callCount).toBe(1); - expect(mockFxaMailer.sendPasswordChangedEmail.callCount).toBe(1); + expect(mockDB.account).toHaveBeenCalledTimes(1); + expect(mockFxaMailer.sendPasswordChangedEmail).toHaveBeenCalledTimes(1); - expect(mockLog.activityEvent.callCount).toBe(1); - const args = mockLog.activityEvent.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toEqual({ + expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); + expect(mockLog.activityEvent).toHaveBeenNthCalledWith(1, { country: 'United States', event: 'account.changedPassword', region: 'California', @@ -819,7 +840,7 @@ describe('/password', () => { }); const mockPush = mocks.mockPush(); const mockMailer = { - sendPasswordChangedEmail: sinon.spy(() => { + sendPasswordChangedEmail: jest.fn(() => { return Promise.resolve(); }), }; @@ -860,16 +881,16 @@ describe('/password', () => { '/password/change/finish', mockRequest ).then((response: any) => { - expect(mockDB.deletePasswordChangeToken.callCount).toBe(1); - expect(mockDB.resetAccount.callCount).toBe(1); - expect(mockDB.resetAccount.firstCall.args[2]).toBe(true); + expect(mockDB.deletePasswordChangeToken).toHaveBeenCalledTimes(1); + expect(mockDB.resetAccount).toHaveBeenCalledTimes(1); + expect(mockDB.resetAccount.mock.calls[0][2]).toBe(true); // Notifications should not go out since we are just upgrading the account. // In this case, the raw password value would still be the same. - expect(mockPush.notifyPasswordChanged.callCount).toBe(0); - expect(mockLog.notifyAttachedServices.callCount).toBe(0); - expect(mockMailer.sendPasswordChangedEmail.callCount).toBe(0); - expect(mockLog.activityEvent.callCount).toBe(0); + expect(mockPush.notifyPasswordChanged).toHaveBeenCalledTimes(0); + expect(mockLog.notifyAttachedServices).toHaveBeenCalledTimes(0); + expect(mockMailer.sendPasswordChangedEmail).toHaveBeenCalledTimes(0); + expect(mockLog.activityEvent).toHaveBeenCalledTimes(0); }); }); }); @@ -914,14 +935,13 @@ describe('/password', () => { '/password/create', mockRequest ); - expect(mockDB.account.callCount).toBe(1); - expect(mockDB.createPassword.callCount).toBe(1); + expect(mockDB.account).toHaveBeenCalledTimes(1); + expect(mockDB.createPassword).toHaveBeenCalledTimes(1); expect(res).toEqual(1584397692000); - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.password_added', ipAddr: '63.245.221.32', uid: mockRequest.auth.credentials.uid, @@ -950,13 +970,13 @@ describe('/password', () => { }); it('should succeed if in totp verified session', async () => { - mockDB.totpToken = sinon.spy(() => { + mockDB.totpToken = jest.fn(() => { return { verified: true, enabled: true, }; }); - mockDB.getLinkedAccounts = sinon.spy(() => { + mockDB.getLinkedAccounts = jest.fn(() => { return Promise.resolve([{ enabled: true }]); }); passwordRoutes = makeRoutes({ @@ -969,10 +989,10 @@ describe('/password', () => { '/password/create', mockRequest ); - expect(mockDB.account.callCount).toBe(1); - expect(mockDB.createPassword.callCount).toBe(1); - expect(mockDB.getLinkedAccounts.callCount).toBe(1); - expect(glean.thirdPartyAuth.setPasswordComplete.callCount).toBe(1); + expect(mockDB.account).toHaveBeenCalledTimes(1); + expect(mockDB.createPassword).toHaveBeenCalledTimes(1); + expect(mockDB.getLinkedAccounts).toHaveBeenCalledTimes(1); + expect(glean.thirdPartyAuth.setPasswordComplete).toHaveBeenCalledTimes(1); expect(res).toEqual(1584397692000); }); }); @@ -1059,35 +1079,36 @@ describe('/password', () => { expect(response.keyFetchToken).toBeTruthy(); // Verify database calls - sinon.assert.calledOnce(mockDB.account); - sinon.assert.calledOnce(mockDB.resetAccount); - sinon.assert.calledWith(mockDB.resetAccount, { uid }); + expect(mockDB.account).toHaveBeenCalledTimes(1); + expect(mockDB.resetAccount).toHaveBeenCalledTimes(1); + expect(mockDB.resetAccount).toHaveBeenCalledWith( + expect.objectContaining({ uid }), + expect.anything() + ); // Verify key fetch tokens are created and returned - sinon.assert.calledOnce(mockDB.createKeyFetchToken); + expect(mockDB.createKeyFetchToken).toHaveBeenCalledTimes(1); // Verify session token creation - sinon.assert.calledOnce(mockDB.createSessionToken); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); // Verify notifications - sinon.assert.calledOnce(mockPush.notifyPasswordChanged); - sinon.assert.calledOnce(mockFxaMailer.sendPasswordChangedEmail); + expect(mockPush.notifyPasswordChanged).toHaveBeenCalledTimes(1); + expect(mockFxaMailer.sendPasswordChangedEmail).toHaveBeenCalledTimes(1); // Verify security events - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.password_changed', ipAddr: '63.245.221.32', uid, }) ); - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.password_reset_success', ipAddr: '63.245.221.32', uid, @@ -1145,7 +1166,7 @@ describe('/password', () => { ); // Verify V2 credentials are handled - const resetAccountCall = mockDB.resetAccount.firstCall.args[1]; + const resetAccountCall = mockDB.resetAccount.mock.calls[0][1]; expect(resetAccountCall.verifyHashVersion2).toBeTruthy(); expect(resetAccountCall.wrapWrapKbVersion2).toBeTruthy(); expect(resetAccountCall.clientSalt).toBe(clientSalt); @@ -1163,7 +1184,7 @@ describe('/password', () => { const clientSalt = 'identity.mozilla.com/picl/v1/quickStretchV2:0123456789abcdef0123456789abcdef'; - mockDB.account = sinon.spy(() => ({ + mockDB.account = jest.fn(() => ({ uid, email: TEST_EMAIL, authSalt: crypto.randomBytes(32).toString('hex'), @@ -1173,7 +1194,7 @@ describe('/password', () => { })); // Mock signinUtils.checkPassword to return true for upgrade scenario - mockDB.checkPassword = sinon.spy(() => + mockDB.checkPassword = jest.fn(() => Promise.resolve({ v1: true, v2: false }) ); @@ -1218,12 +1239,11 @@ describe('/password', () => { ); // Verify upgrade scenario is handled - const resetAccountCall = mockDB.resetAccount.firstCall; - expect(resetAccountCall.args[2]).toBe(true); // isPasswordUpgrade flag + expect(mockDB.resetAccount.mock.calls[0][2]).toBe(true); // isPasswordUpgrade flag // Notifications should be skipped during password upgrade - sinon.assert.notCalled(mockPush.notifyPasswordChanged); - sinon.assert.notCalled(mockMailer.sendPasswordChangedEmail); + expect(mockPush.notifyPasswordChanged).not.toHaveBeenCalled(); + expect(mockMailer.sendPasswordChangedEmail).not.toHaveBeenCalled(); expect(response.sessionToken).toBeTruthy(); expect(response.keyFetchToken).toBeFalsy(); diff --git a/packages/fxa-auth-server/lib/routes/passwordless.spec.ts b/packages/fxa-auth-server/lib/routes/passwordless.spec.ts index 6a375576585..710d019df5f 100644 --- a/packages/fxa-auth-server/lib/routes/passwordless.spec.ts +++ b/packages/fxa-auth-server/lib/routes/passwordless.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import crypto from 'crypto'; import * as uuid from 'uuid'; import { AppError as error } from '@fxa/accounts/errors'; @@ -28,13 +27,13 @@ afterAll(() => { // --- Module-level stubs for mocked dependencies --- -let mockOtpManagerCreate: sinon.SinonStub; -let mockOtpManagerIsValid: sinon.SinonStub; -let mockOtpManagerDelete: sinon.SinonStub; +let mockOtpManagerCreate: jest.Mock; +let mockOtpManagerIsValid: jest.Mock; +let mockOtpManagerDelete: jest.Mock; let mockOtpManager: { - create: sinon.SinonStub; - isValid: sinon.SinonStub; - delete: sinon.SinonStub; + create: jest.Mock; + isValid: jest.Mock; + delete: jest.Mock; }; // Top-level mocks for modules that are always mocked the same way. @@ -57,7 +56,7 @@ jest.mock('@fxa/shared/otp', () => { // `./utils/otp` default export is a factory `(db, statsd) => OtpUtils`. // The route only calls `otpUtils.hasTotpToken(uid)`. -let hasTotpTokenStub: sinon.SinonStub; +let hasTotpTokenStub: jest.Mock; jest.mock('./utils/otp', () => ({ __esModule: true, @@ -67,7 +66,7 @@ jest.mock('./utils/otp', () => ({ })); // `./utils/security-event` named export `recordSecurityEvent`. -let recordSecurityEventStub: sinon.SinonStub; +let recordSecurityEventStub: jest.Mock; jest.mock('./utils/security-event', () => ({ recordSecurityEvent: (...args: any[]) => recordSecurityEventStub(...args), @@ -77,7 +76,7 @@ jest.mock('./utils/security-event', () => ({ // We default to a stub that returns `{}`. Individual tests that care about // CMS behaviour will override via `jest.doMock` + `jest.resetModules`, or // we simply reassign the stub per-test. -let getOptionalCmsEmailConfigStub: sinon.SinonStub; +let getOptionalCmsEmailConfigStub: jest.Mock; jest.mock('./utils/account', () => { const actual = jest.requireActual('./utils/account'); @@ -120,9 +119,9 @@ function makeRoutes(options: any = {}) { }; // Refresh per-test OtpManager stubs - mockOtpManagerCreate = sinon.stub().resolves('123456'); - mockOtpManagerIsValid = sinon.stub().resolves(true); - mockOtpManagerDelete = sinon.stub().resolves(); + mockOtpManagerCreate = jest.fn().mockResolvedValue('123456'); + mockOtpManagerIsValid = jest.fn().mockResolvedValue(true); + mockOtpManagerDelete = jest.fn().mockResolvedValue(undefined); mockOtpManager = { create: mockOtpManagerCreate, @@ -131,11 +130,11 @@ function makeRoutes(options: any = {}) { }; // Wire up option-driven stubs - hasTotpTokenStub = options.hasTotpToken || sinon.stub().resolves(false); + hasTotpTokenStub = options.hasTotpToken || jest.fn().mockResolvedValue(false); recordSecurityEventStub = - options.recordSecurityEvent || sinon.stub().resolves(); + options.recordSecurityEvent || jest.fn().mockResolvedValue(undefined); getOptionalCmsEmailConfigStub = - options.getOptionalCmsEmailConfig || sinon.stub().resolves({}); + options.getOptionalCmsEmailConfig || jest.fn().mockResolvedValue({}); mocks.mockFxaMailer(); @@ -179,7 +178,7 @@ describe('/account/passwordless/send_code', () => { verifierSetAt: 0, }); mockCustoms = { - check: sinon.spy(() => Promise.resolve()), + check: jest.fn(() => Promise.resolve()), v2Enabled: () => true, }; mockRequest = mocks.mockRequest({ @@ -214,30 +213,34 @@ describe('/account/passwordless/send_code', () => { }); afterEach(() => { - mockOtpManagerCreate.resetHistory(); - mockOtpManagerIsValid.resetHistory(); - mockOtpManagerDelete.resetHistory(); + mockOtpManagerCreate.mockClear(); + mockOtpManagerIsValid.mockClear(); + mockOtpManagerDelete.mockClear(); }); it('should send OTP for new account', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); return runTest(route, mockRequest, (result) => { - expect(mockCustoms.check.callCount).toBe(1); - expect(mockCustoms.check.args[0][1]).toBe(TEST_EMAIL); - expect(mockCustoms.check.args[0][2]).toBe('passwordlessSendOtp'); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); + expect(mockCustoms.check).toHaveBeenNthCalledWith( + 1, + expect.anything(), + TEST_EMAIL, + 'passwordlessSendOtp' + ); - expect(mockOtpManagerCreate.callCount).toBe(1); - expect(mockOtpManagerCreate.args[0][0]).toBe(TEST_EMAIL); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenNthCalledWith(1, TEST_EMAIL); expect(result).toEqual({}); }); }); it('should send OTP for existing passwordless account', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -247,15 +250,15 @@ describe('/account/passwordless/send_code', () => { ); return runTest(route, mockRequest, (result) => { - expect(mockDB.accountRecord.callCount).toBe(1); - expect(mockOtpManagerCreate.callCount).toBe(1); - expect(mockOtpManagerCreate.args[0][0]).toBe(uid); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenNthCalledWith(1, uid); expect(result).toEqual({}); }); }); it('should reject account with password', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -268,24 +271,22 @@ describe('/account/passwordless/send_code', () => { throw new Error('should have thrown'); }, (err) => { - expect(mockDB.accountRecord.callCount).toBe(1); - expect(mockOtpManagerCreate.callCount).toBe(0); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(0); expect(err.errno).toBe(206); } ); }); it('should apply rate limiting', () => { - mockCustoms.check = sinon.spy(() => - Promise.reject(error.tooManyRequests()) - ); + mockCustoms.check = jest.fn(() => Promise.reject(error.tooManyRequests())); return runTest(route, mockRequest).then( () => { throw new Error('should have thrown'); }, (err) => { - expect(mockCustoms.check.callCount).toBe(1); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); expect(err.errno).toBe(error.ERRNO.THROTTLED); } ); @@ -312,7 +313,7 @@ describe('/account/passwordless/confirm_code', () => { verifierSetAt: 0, }); mockCustoms = { - check: sinon.spy(() => Promise.resolve()), + check: jest.fn(() => Promise.resolve()), v2Enabled: () => true, }; mockRequest = mocks.mockRequest({ @@ -348,16 +349,16 @@ describe('/account/passwordless/confirm_code', () => { }); afterEach(() => { - mockOtpManagerCreate.resetHistory(); - mockOtpManagerIsValid.resetHistory(); - mockOtpManagerDelete.resetHistory(); + mockOtpManagerCreate.mockClear(); + mockOtpManagerIsValid.mockClear(); + mockOtpManagerDelete.mockClear(); }); it('should create new account and session for valid code', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); - mockDB.createAccount = sinon.spy(() => + mockDB.createAccount = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -365,7 +366,7 @@ describe('/account/passwordless/confirm_code', () => { verifierSetAt: 0, }) ); - mockDB.createSessionToken = sinon.spy(() => + mockDB.createSessionToken = jest.fn(() => Promise.resolve({ data: 'sessiontoken123', emailVerified: true, @@ -376,28 +377,45 @@ describe('/account/passwordless/confirm_code', () => { return runTest(route, mockRequest, (result) => { // mustVerify should always be true (defense-in-depth) - const sessionOpts = mockDB.createSessionToken.args[0][0]; + const sessionOpts = mockDB.createSessionToken.mock.calls[0][0]; expect(sessionOpts.mustVerify).toBe(true); expect(sessionOpts.tokenVerificationId).toBe(null); - expect(mockCustoms.check.callCount).toBe(2); - expect(mockCustoms.check.args[0][2]).toBe('passwordlessVerifyOtp'); - expect(mockCustoms.check.args[1][2]).toBe('passwordlessVerifyOtpPerDay'); + expect(mockCustoms.check).toHaveBeenCalledTimes(2); + expect(mockCustoms.check).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + 'passwordlessVerifyOtp' + ); + expect(mockCustoms.check).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.anything(), + 'passwordlessVerifyOtpPerDay' + ); - expect(mockOtpManagerIsValid.callCount).toBe(1); - expect(mockOtpManagerIsValid.args[0][0]).toBe(TEST_EMAIL); - expect(mockOtpManagerIsValid.args[0][1]).toBe('123456'); + expect(mockOtpManagerIsValid).toHaveBeenCalledTimes(1); + expect(mockOtpManagerIsValid).toHaveBeenNthCalledWith( + 1, + TEST_EMAIL, + '123456' + ); - expect(mockOtpManagerDelete.callCount).toBe(1); - expect(mockOtpManagerDelete.args[0][0]).toBe(TEST_EMAIL); + expect(mockOtpManagerDelete).toHaveBeenCalledTimes(1); + expect(mockOtpManagerDelete).toHaveBeenNthCalledWith(1, TEST_EMAIL); - expect(mockDB.createAccount.callCount).toBe(1); - const accountArgs = mockDB.createAccount.args[0][0]; - expect(accountArgs.email).toBe(TEST_EMAIL); - expect(accountArgs.emailVerified).toBe(true); - expect(accountArgs.verifierSetAt).toBe(0); + expect(mockDB.createAccount).toHaveBeenCalledTimes(1); + expect(mockDB.createAccount).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + email: TEST_EMAIL, + emailVerified: true, + verifierSetAt: 0, + }) + ); - expect(mockDB.createSessionToken.callCount).toBe(1); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); expect(result.uid).toBe(uid); expect(result.sessionToken).toBe('sessiontoken123'); @@ -408,23 +426,22 @@ describe('/account/passwordless/confirm_code', () => { // Should emit SNS verified + login + profileDataChange events so // Basket/Braze learn about the new passwordless account. Missing // these calls was the root cause of the basket regression (FXA-13416). - const notifyEvents = mockLog.notifyAttachedServices.args.map( + const notifyEvents = mockLog.notifyAttachedServices.mock.calls.map( (call: any[]) => call[0] ); expect(notifyEvents).toContain('verified'); expect(notifyEvents).toContain('login'); expect(notifyEvents).toContain('profileDataChange'); - sinon.assert.calledWithMatch( - mockLog.notifyAttachedServices, + expect(mockLog.notifyAttachedServices).toHaveBeenCalledWith( 'verified', mockRequest, - sinon.match({ email: TEST_EMAIL, uid }) + expect.objectContaining({ email: TEST_EMAIL, uid }) ); }); }); it('should create session for existing account with valid code', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -432,7 +449,7 @@ describe('/account/passwordless/confirm_code', () => { verifierSetAt: 0, }) ); - mockDB.createSessionToken = sinon.spy(() => + mockDB.createSessionToken = jest.fn(() => Promise.resolve({ data: 'sessiontoken123', emailVerified: true, @@ -440,17 +457,21 @@ describe('/account/passwordless/confirm_code', () => { lastAuthAt: () => 1234567890, }) ); - mockDB.sessions = sinon.spy(() => Promise.resolve([{}, {}, {}])); + mockDB.sessions = jest.fn(() => Promise.resolve([{}, {}, {}])); return runTest(route, mockRequest, (result) => { - expect(mockOtpManagerIsValid.callCount).toBe(1); - expect(mockOtpManagerIsValid.args[0][0]).toBe(uid); + expect(mockOtpManagerIsValid).toHaveBeenCalledTimes(1); + expect(mockOtpManagerIsValid).toHaveBeenNthCalledWith( + 1, + uid, + expect.anything() + ); - expect(mockDB.createAccount.callCount).toBe(0); - expect(mockDB.createSessionToken.callCount).toBe(1); + expect(mockDB.createAccount).toHaveBeenCalledTimes(0); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); // mustVerify should always be true (defense-in-depth) - const sessionOpts = mockDB.createSessionToken.args[0][0]; + const sessionOpts = mockDB.createSessionToken.mock.calls[0][0]; expect(sessionOpts.mustVerify).toBe(true); expect(sessionOpts.tokenVerificationId).toBe(null); @@ -459,25 +480,24 @@ describe('/account/passwordless/confirm_code', () => { // Existing account: login event only, no verified, no // profileDataChange. deviceCount should come from db.sessions. - const notifyEvents = mockLog.notifyAttachedServices.args.map( + const notifyEvents = mockLog.notifyAttachedServices.mock.calls.map( (call: any[]) => call[0] ); expect(notifyEvents).toEqual(['login']); - sinon.assert.calledWithMatch( - mockLog.notifyAttachedServices, + expect(mockLog.notifyAttachedServices).toHaveBeenCalledWith( 'login', mockRequest, - sinon.match({ email: TEST_EMAIL, uid, deviceCount: 3 }) + expect.objectContaining({ email: TEST_EMAIL, uid, deviceCount: 3 }) ); }); }); it('should emit glean registration.complete with reason otp for new account', () => { const mockGlean = mocks.mockGlean(); - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); - mockDB.createAccount = sinon.spy(() => + mockDB.createAccount = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -485,7 +505,7 @@ describe('/account/passwordless/confirm_code', () => { verifierSetAt: 0, }) ); - mockDB.createSessionToken = sinon.spy(() => + mockDB.createSessionToken = jest.fn(() => Promise.resolve({ data: 'sessiontoken123', emailVerified: true, @@ -513,21 +533,20 @@ describe('/account/passwordless/confirm_code', () => { route = getRoute(routes, '/account/passwordless/confirm_code', 'POST'); return runTest(route, mockRequest, () => { - sinon.assert.calledOnce(mockGlean.registration.complete); - sinon.assert.calledWithMatch( - mockGlean.registration.complete, + expect(mockGlean.registration.complete).toHaveBeenCalledTimes(1); + expect(mockGlean.registration.complete).toHaveBeenCalledWith( mockRequest, - { + expect.objectContaining({ uid, reason: 'otp', - } + }) ); }); }); it('should emit glean login.complete with reason otp for existing account', () => { const mockGlean = mocks.mockGlean(); - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -535,7 +554,7 @@ describe('/account/passwordless/confirm_code', () => { verifierSetAt: 0, }) ); - mockDB.createSessionToken = sinon.spy(() => + mockDB.createSessionToken = jest.fn(() => Promise.resolve({ data: 'sessiontoken123', emailVerified: true, @@ -563,45 +582,48 @@ describe('/account/passwordless/confirm_code', () => { route = getRoute(routes, '/account/passwordless/confirm_code', 'POST'); return runTest(route, mockRequest, () => { - sinon.assert.calledOnce(mockGlean.login.complete); - sinon.assert.calledWithMatch(mockGlean.login.complete, mockRequest, { - uid, - reason: 'otp', - }); + expect(mockGlean.login.complete).toHaveBeenCalledTimes(1); + expect(mockGlean.login.complete).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ + uid, + reason: 'otp', + }) + ); }); }); it('should reject invalid OTP code', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, verifierSetAt: 0, }) ); - mockOtpManagerIsValid.resolves(false); + mockOtpManagerIsValid.mockResolvedValue(false); return runTest(route, mockRequest).then( () => { throw new Error('should have thrown'); }, (err) => { - expect(mockOtpManagerIsValid.callCount).toBe(1); - expect(mockOtpManagerDelete.callCount).toBe(0); + expect(mockOtpManagerIsValid).toHaveBeenCalledTimes(1); + expect(mockOtpManagerDelete).toHaveBeenCalledTimes(0); expect(err.errno).toBe(105); } ); }); it('should return unverified session with verificationMethod for TOTP accounts', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, verifierSetAt: 0, }) ); - mockDB.createSessionToken = sinon.spy(() => + mockDB.createSessionToken = jest.fn(() => Promise.resolve({ data: 'sessiontoken123', emailVerified: true, @@ -610,7 +632,7 @@ describe('/account/passwordless/confirm_code', () => { }) ); - const hasTotpToken = sinon.stub().resolves(true); + const hasTotpToken = jest.fn().mockResolvedValue(true); routes = makeRoutes({ log: mockLog, db: mockDB, @@ -630,9 +652,9 @@ describe('/account/passwordless/confirm_code', () => { route = getRoute(routes, '/account/passwordless/confirm_code', 'POST'); return runTest(route, mockRequest).then((result: any) => { - expect(hasTotpToken.callCount).toBe(1); - expect(mockOtpManagerIsValid.callCount).toBe(1); - expect(mockOtpManagerDelete.callCount).toBe(1); + expect(hasTotpToken).toHaveBeenCalledTimes(1); + expect(mockOtpManagerIsValid).toHaveBeenCalledTimes(1); + expect(mockOtpManagerDelete).toHaveBeenCalledTimes(1); expect(result.uid).toBe(uid); expect(result.sessionToken).toBe('sessiontoken123'); expect(result.verified).toBe(false); @@ -641,14 +663,14 @@ describe('/account/passwordless/confirm_code', () => { expect(result.verificationMethod).toBe('totp-2fa'); expect(result.verificationReason).toBe('login'); // Session should be created with mustVerify=true - const sessionTokenArgs = mockDB.createSessionToken.args[0][0]; + const sessionTokenArgs = mockDB.createSessionToken.mock.calls[0][0]; expect(sessionTokenArgs.mustVerify).toBe(true); expect(sessionTokenArgs.tokenVerificationId).toBeTruthy(); }); }); it('should reject account with password set', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -661,14 +683,14 @@ describe('/account/passwordless/confirm_code', () => { throw new Error('should have thrown'); }, (err) => { - expect(mockDB.accountRecord.callCount).toBe(1); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); expect(err.errno).toBe(206); } ); }); it('should include user agent info in session token', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -676,7 +698,7 @@ describe('/account/passwordless/confirm_code', () => { verifierSetAt: 0, }) ); - mockDB.createSessionToken = sinon.spy(() => + mockDB.createSessionToken = jest.fn(() => Promise.resolve({ data: 'sessiontoken123', emailVerified: true, @@ -694,16 +716,20 @@ describe('/account/passwordless/confirm_code', () => { }; return runTest(route, mockRequest, () => { - expect(mockDB.createSessionToken.callCount).toBe(1); - const sessionOpts = mockDB.createSessionToken.args[0][0]; - expect(sessionOpts.uaBrowser).toBe('Firefox'); - expect(sessionOpts.uaBrowserVersion).toBe('100'); - expect(sessionOpts.uaOS).toBe('Linux'); - expect(sessionOpts.uaOSVersion).toBe('5.15'); - expect(sessionOpts.uaDeviceType).toBe('desktop'); - expect(sessionOpts.uaFormFactor).toBe('desktop'); - expect(sessionOpts.mustVerify).toBe(true); - expect(sessionOpts.tokenVerificationId).toBe(null); + expect(mockDB.createSessionToken).toHaveBeenCalledTimes(1); + expect(mockDB.createSessionToken).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + uaBrowser: 'Firefox', + uaBrowserVersion: '100', + uaOS: 'Linux', + uaOSVersion: '5.15', + uaDeviceType: 'desktop', + uaFormFactor: 'desktop', + mustVerify: true, + tokenVerificationId: null, + }) + ); }); }); }); @@ -727,7 +753,7 @@ describe('passwordless CMS customization', () => { verifierSetAt: 0, }); mockCustoms = { - check: sinon.spy(() => Promise.resolve()), + check: jest.fn(() => Promise.resolve()), v2Enabled: () => true, }; mockRequest = mocks.mockRequest({ @@ -745,20 +771,20 @@ describe('passwordless CMS customization', () => { }); afterEach(() => { - mockOtpManagerCreate.resetHistory(); - mockOtpManagerIsValid.resetHistory(); - mockOtpManagerDelete.resetHistory(); + mockOtpManagerCreate.mockClear(); + mockOtpManagerIsValid.mockClear(); + mockOtpManagerDelete.mockClear(); }); describe('send_code with CMS configuration', () => { beforeEach(() => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); }); it('should call getOptionalCmsEmailConfig for new account', () => { - const mockGetOptionalCmsEmailConfig = sinon.stub().resolves({}); + const mockGetOptionalCmsEmailConfig = jest.fn().mockResolvedValue({}); routes = makeRoutes({ log: mockLog, @@ -780,30 +806,30 @@ describe('passwordless CMS customization', () => { return runTest(route, mockRequest, () => { // Verify OTP was created - expect(mockOtpManagerCreate.callCount).toBe(1); - expect(mockOtpManagerCreate.args[0][0]).toBe(TEST_EMAIL); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenNthCalledWith(1, TEST_EMAIL); // Verify CMS config was fetched - expect(mockGetOptionalCmsEmailConfig.callCount).toBe(1); - - // Check first argument (options object) - const options = mockGetOptionalCmsEmailConfig.args[0][0]; - expect(options.code).toBeDefined(); - expect(options.deviceId).toBeDefined(); - expect(options.flowId).toBeDefined(); - expect(options.codeExpiryMinutes).toBeDefined(); - expect(options.codeExpiryMinutes).toBe(5); // 300 seconds / 60 - - // Check second argument (config object) - const config = mockGetOptionalCmsEmailConfig.args[0][1]; - expect(config.emailTemplate).toBe('PasswordlessSignupOtpEmail'); - expect(config.request).toBeDefined(); - expect(config.log).toBeDefined(); + expect(mockGetOptionalCmsEmailConfig).toHaveBeenCalledTimes(1); + expect(mockGetOptionalCmsEmailConfig).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + code: expect.anything(), + deviceId: expect.anything(), + flowId: expect.anything(), + codeExpiryMinutes: 5, + }), + expect.objectContaining({ + emailTemplate: 'PasswordlessSignupOtpEmail', + request: expect.anything(), + log: expect.anything(), + }) + ); }); }); it('should call getOptionalCmsEmailConfig for existing account', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -812,7 +838,7 @@ describe('passwordless CMS customization', () => { }) ); - const mockGetOptionalCmsEmailConfig = sinon.stub().resolves({}); + const mockGetOptionalCmsEmailConfig = jest.fn().mockResolvedValue({}); routes = makeRoutes({ log: mockLog, @@ -834,34 +860,35 @@ describe('passwordless CMS customization', () => { return runTest(route, mockRequest, () => { // Verify OTP was created with uid (not email) - expect(mockOtpManagerCreate.callCount).toBe(1); - expect(mockOtpManagerCreate.args[0][0]).toBe(uid); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenNthCalledWith(1, uid); // Verify CMS config was fetched with correct template - expect(mockGetOptionalCmsEmailConfig.callCount).toBe(1); - - // Check first argument (options object) - const options = mockGetOptionalCmsEmailConfig.args[0][0]; - expect(options.code).toBeDefined(); - expect(options.deviceId).toBeDefined(); - expect(options.flowId).toBeDefined(); - expect(options.time).toBeDefined(); - expect(options.date).toBeDefined(); - - // Check second argument (config object) - const config = mockGetOptionalCmsEmailConfig.args[0][1]; - expect(config.emailTemplate).toBe('PasswordlessSigninOtpEmail'); - expect(config.request).toBeDefined(); - expect(config.log).toBeDefined(); + expect(mockGetOptionalCmsEmailConfig).toHaveBeenCalledTimes(1); + expect(mockGetOptionalCmsEmailConfig).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + code: expect.anything(), + deviceId: expect.anything(), + flowId: expect.anything(), + time: expect.anything(), + date: expect.anything(), + }), + expect.objectContaining({ + emailTemplate: 'PasswordlessSigninOtpEmail', + request: expect.anything(), + log: expect.anything(), + }) + ); }); }); it('should send email with CMS customization when available', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); - const mockGetOptionalCmsEmailConfig = sinon.stub().resolves({ + const mockGetOptionalCmsEmailConfig = jest.fn().mockResolvedValue({ emailConfig: { subject: 'Custom Verification Code', description: 'Your custom OTP code', @@ -889,21 +916,24 @@ describe('passwordless CMS customization', () => { return runTest(route, mockRequest, () => { // Verify CMS config was fetched - expect(mockGetOptionalCmsEmailConfig.callCount).toBe(1); - - // Verify the config includes CMS customization - const cmsConfig = mockGetOptionalCmsEmailConfig.args[0][0]; - expect(cmsConfig.code).toBeDefined(); - expect(cmsConfig.codeExpiryMinutes).toBe(5); + expect(mockGetOptionalCmsEmailConfig).toHaveBeenCalledTimes(1); + expect(mockGetOptionalCmsEmailConfig).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + code: expect.anything(), + codeExpiryMinutes: 5, + }), + expect.anything() + ); }); }); it('should handle CMS manager absence gracefully', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); - const mockGetOptionalCmsEmailConfig = sinon.stub().resolves({}); + const mockGetOptionalCmsEmailConfig = jest.fn().mockResolvedValue({}); routes = makeRoutes({ log: mockLog, @@ -925,19 +955,23 @@ describe('passwordless CMS customization', () => { return runTest(route, mockRequest, () => { // Should still work without CMS manager - expect(mockOtpManagerCreate.callCount).toBe(1); - expect(mockGetOptionalCmsEmailConfig.callCount).toBe(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); + expect(mockGetOptionalCmsEmailConfig).toHaveBeenCalledTimes(1); // Verify cmsManager parameter can be undefined - const config = mockGetOptionalCmsEmailConfig.args[0][1]; - // cmsManager is optional and may be undefined - expect(config).toHaveProperty('log'); - expect(config).toHaveProperty('request'); + expect(mockGetOptionalCmsEmailConfig).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + log: expect.anything(), + request: expect.anything(), + }) + ); }); }); it('should pass correct email template for resend_code', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -946,7 +980,7 @@ describe('passwordless CMS customization', () => { }) ); - const mockGetOptionalCmsEmailConfig = sinon.stub().resolves({}); + const mockGetOptionalCmsEmailConfig = jest.fn().mockResolvedValue({}); routes = makeRoutes({ log: mockLog, @@ -968,15 +1002,18 @@ describe('passwordless CMS customization', () => { return runTest(route, mockRequest, () => { // Verify OTP was deleted and recreated - expect(mockOtpManagerDelete.callCount).toBe(1); - expect(mockOtpManagerCreate.callCount).toBe(1); - - // Verify CMS config was fetched - expect(mockGetOptionalCmsEmailConfig.callCount).toBe(1); - - // Should use signin template for existing account - const config = mockGetOptionalCmsEmailConfig.args[0][1]; - expect(config.emailTemplate).toBe('PasswordlessSigninOtpEmail'); + expect(mockOtpManagerDelete).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); + + // Verify CMS config was fetched with signin template + expect(mockGetOptionalCmsEmailConfig).toHaveBeenCalledTimes(1); + expect(mockGetOptionalCmsEmailConfig).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + emailTemplate: 'PasswordlessSigninOtpEmail', + }) + ); }); }); }); @@ -988,7 +1025,7 @@ describe('passwordless security events', () => { mockRequest: any, mockDB: any, mockCustoms: any, - mockRecordSecurityEvent: sinon.SinonStub, + mockRecordSecurityEvent: jest.Mock, route: any, routes: any; @@ -1003,10 +1040,10 @@ describe('passwordless security events', () => { verifierSetAt: 0, }); mockCustoms = { - check: sinon.spy(() => Promise.resolve()), + check: jest.fn(() => Promise.resolve()), v2Enabled: () => true, }; - mockRecordSecurityEvent = sinon.stub().resolves(); + mockRecordSecurityEvent = jest.fn().mockResolvedValue(undefined); mockRequest = mocks.mockRequest({ log: mockLog, payload: { @@ -1022,13 +1059,13 @@ describe('passwordless security events', () => { }); afterEach(() => { - mockOtpManagerCreate.resetHistory(); - mockOtpManagerIsValid.resetHistory(); - mockOtpManagerDelete.resetHistory(); + mockOtpManagerCreate.mockClear(); + mockOtpManagerIsValid.mockClear(); + mockOtpManagerDelete.mockClear(); }); it('should record security event when OTP is sent for new account', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); @@ -1051,15 +1088,17 @@ describe('passwordless security events', () => { route = getRoute(routes, '/account/passwordless/send_code', 'POST'); return runTest(route, mockRequest, () => { - expect(mockRecordSecurityEvent.callCount).toBe(1); - expect(mockRecordSecurityEvent.args[0][0]).toBe( - 'account.passwordless_login_otp_sent' + expect(mockRecordSecurityEvent).toHaveBeenCalledTimes(1); + expect(mockRecordSecurityEvent).toHaveBeenNthCalledWith( + 1, + 'account.passwordless_login_otp_sent', + expect.anything() ); }); }); it('should record security event when OTP is sent for existing account', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -1087,20 +1126,22 @@ describe('passwordless security events', () => { route = getRoute(routes, '/account/passwordless/send_code', 'POST'); return runTest(route, mockRequest, () => { - expect(mockRecordSecurityEvent.callCount).toBe(1); - expect(mockRecordSecurityEvent.args[0][0]).toBe( - 'account.passwordless_login_otp_sent' + expect(mockRecordSecurityEvent).toHaveBeenCalledTimes(1); + expect(mockRecordSecurityEvent).toHaveBeenNthCalledWith( + 1, + 'account.passwordless_login_otp_sent', + expect.objectContaining({ + account: expect.objectContaining({ uid }), + }) ); - expect(mockRecordSecurityEvent.args[0][1].account).toBeDefined(); - expect(mockRecordSecurityEvent.args[0][1].account.uid).toBe(uid); }); }); it('should record security event when valid OTP is confirmed for new account', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); - mockDB.createAccount = sinon.spy(() => + mockDB.createAccount = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -1108,7 +1149,7 @@ describe('passwordless security events', () => { verifierSetAt: 0, }) ); - mockDB.createSessionToken = sinon.spy(() => + mockDB.createSessionToken = jest.fn(() => Promise.resolve({ data: 'sessiontoken123', emailVerified: true, @@ -1139,12 +1180,16 @@ describe('passwordless security events', () => { return runTest(route, mockRequest, () => { // Should record two events: registration_complete and otp_verified - expect(mockRecordSecurityEvent.callCount).toBe(2); - expect(mockRecordSecurityEvent.args[0][0]).toBe( - 'account.passwordless_registration_complete' + expect(mockRecordSecurityEvent).toHaveBeenCalledTimes(2); + expect(mockRecordSecurityEvent).toHaveBeenNthCalledWith( + 1, + 'account.passwordless_registration_complete', + expect.anything() ); - expect(mockRecordSecurityEvent.args[1][0]).toBe( - 'account.passwordless_login_otp_verified' + expect(mockRecordSecurityEvent).toHaveBeenNthCalledWith( + 2, + 'account.passwordless_login_otp_verified', + expect.anything() ); }); }); @@ -1171,7 +1216,7 @@ describe('passwordless statsd metrics', () => { verifierSetAt: 0, }); mockCustoms = { - check: sinon.spy(() => Promise.resolve()), + check: jest.fn(() => Promise.resolve()), v2Enabled: () => true, }; mockStatsd = mocks.mockStatsd(); @@ -1195,13 +1240,13 @@ describe('passwordless statsd metrics', () => { }); afterEach(() => { - mockOtpManagerCreate.resetHistory(); - mockOtpManagerIsValid.resetHistory(); - mockOtpManagerDelete.resetHistory(); + mockOtpManagerCreate.mockClear(); + mockOtpManagerIsValid.mockClear(); + mockOtpManagerDelete.mockClear(); }); it('should increment statsd counter when OTP is sent', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); @@ -1224,18 +1269,17 @@ describe('passwordless statsd metrics', () => { route = getRoute(routes, '/account/passwordless/send_code', 'POST'); return runTest(route, mockRequest, () => { - expect(mockStatsd.increment.callCount).toBe(1); - expect(mockStatsd.increment.args[0][0]).toBe( - 'passwordless.sendCode.success' + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenNthCalledWith( + 1, + 'passwordless.sendCode.success', + expect.objectContaining({ isResend: 'false' }) ); - expect(mockStatsd.increment.args[0][1]).toMatchObject({ - isResend: 'false', - }); }); }); it('should increment statsd counter when OTP is resent', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -1263,21 +1307,20 @@ describe('passwordless statsd metrics', () => { route = getRoute(routes, '/account/passwordless/resend_code', 'POST'); return runTest(route, mockRequest, () => { - expect(mockStatsd.increment.callCount).toBe(1); - expect(mockStatsd.increment.args[0][0]).toBe( - 'passwordless.sendCode.success' + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenNthCalledWith( + 1, + 'passwordless.sendCode.success', + expect.objectContaining({ isResend: 'true' }) ); - expect(mockStatsd.increment.args[0][1]).toMatchObject({ - isResend: 'true', - }); }); }); it('should increment statsd counter for successful registration', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); - mockDB.createAccount = sinon.spy(() => + mockDB.createAccount = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -1285,7 +1328,7 @@ describe('passwordless statsd metrics', () => { verifierSetAt: 0, }) ); - mockDB.createSessionToken = sinon.spy(() => + mockDB.createSessionToken = jest.fn(() => Promise.resolve({ data: 'sessiontoken123', emailVerified: true, @@ -1316,26 +1359,26 @@ describe('passwordless statsd metrics', () => { return runTest(route, mockRequest, () => { // Should increment both registration.success and confirmCode.success - expect(mockStatsd.increment.callCount).toBeGreaterThanOrEqual(2); - const incrementCalls = mockStatsd.increment - .getCalls() - .map((c: any) => c.args[0]); + expect(mockStatsd.increment.mock.calls.length).toBeGreaterThanOrEqual(2); + const incrementCalls = mockStatsd.increment.mock.calls.map( + (c: any) => c[0] + ); expect(incrementCalls).toContain('passwordless.registration.success'); expect(incrementCalls).toContain('passwordless.confirmCode.success'); // confirmCode.success should include isNewAccount tag - const confirmCall = mockStatsd.increment - .getCalls() - .find((c: any) => c.args[0] === 'passwordless.confirmCode.success'); + const confirmCall = mockStatsd.increment.mock.calls.find( + (c: any) => c[0] === 'passwordless.confirmCode.success' + ); expect(confirmCall).toBeDefined(); - expect(confirmCall.args[1]).toEqual( + expect(confirmCall[1]).toEqual( expect.objectContaining({ isNewAccount: 'true' }) ); }); }); it('should increment passwordless.blocked when client is not allowed', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); @@ -1360,11 +1403,11 @@ describe('passwordless statsd metrics', () => { throw new Error('should have failed'); }, () => { - const blockedCall = mockStatsd.increment - .getCalls() - .find((c: any) => c.args[0] === 'passwordless.blocked'); + const blockedCall = mockStatsd.increment.mock.calls.find( + (c: any) => c[0] === 'passwordless.blocked' + ); expect(blockedCall).toBeDefined(); - expect(blockedCall.args[1]).toEqual( + expect(blockedCall[1]).toEqual( expect.objectContaining({ reason: 'clientNotAllowed', clientId: 'test-client-id', @@ -1395,7 +1438,7 @@ describe('/account/passwordless/resend_code', () => { verifierSetAt: 0, }); mockCustoms = { - check: sinon.spy(() => Promise.resolve()), + check: jest.fn(() => Promise.resolve()), v2Enabled: () => true, }; mockRequest = mocks.mockRequest({ @@ -1430,33 +1473,37 @@ describe('/account/passwordless/resend_code', () => { }); afterEach(() => { - mockOtpManagerCreate.resetHistory(); - mockOtpManagerDelete.resetHistory(); + mockOtpManagerCreate.mockClear(); + mockOtpManagerDelete.mockClear(); }); it('should delete old code and send new one for new account', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); return runTest(route, mockRequest, (result) => { // Verify rate limiting was called - expect(mockCustoms.check.callCount).toBe(1); - expect(mockCustoms.check.args[0][1]).toBe(TEST_EMAIL); - expect(mockCustoms.check.args[0][2]).toBe('passwordlessSendOtp'); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); + expect(mockCustoms.check).toHaveBeenNthCalledWith( + 1, + expect.anything(), + TEST_EMAIL, + 'passwordlessSendOtp' + ); - expect(mockOtpManagerDelete.callCount).toBe(1); - expect(mockOtpManagerDelete.args[0][0]).toBe(TEST_EMAIL); + expect(mockOtpManagerDelete).toHaveBeenCalledTimes(1); + expect(mockOtpManagerDelete).toHaveBeenNthCalledWith(1, TEST_EMAIL); - expect(mockOtpManagerCreate.callCount).toBe(1); - expect(mockOtpManagerCreate.args[0][0]).toBe(TEST_EMAIL); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenNthCalledWith(1, TEST_EMAIL); expect(result).toEqual({}); }); }); it('should delete old code and send new one for existing account', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -1467,22 +1514,26 @@ describe('/account/passwordless/resend_code', () => { return runTest(route, mockRequest, (result) => { // Verify rate limiting was called - expect(mockCustoms.check.callCount).toBe(1); - expect(mockCustoms.check.args[0][1]).toBe(TEST_EMAIL); - expect(mockCustoms.check.args[0][2]).toBe('passwordlessSendOtp'); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); + expect(mockCustoms.check).toHaveBeenNthCalledWith( + 1, + expect.anything(), + TEST_EMAIL, + 'passwordlessSendOtp' + ); - expect(mockOtpManagerDelete.callCount).toBe(1); - expect(mockOtpManagerDelete.args[0][0]).toBe(uid); + expect(mockOtpManagerDelete).toHaveBeenCalledTimes(1); + expect(mockOtpManagerDelete).toHaveBeenNthCalledWith(1, uid); - expect(mockOtpManagerCreate.callCount).toBe(1); - expect(mockOtpManagerCreate.args[0][0]).toBe(uid); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenNthCalledWith(1, uid); expect(result).toEqual({}); }); }); it('should reject account with password', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -1495,9 +1546,9 @@ describe('/account/passwordless/resend_code', () => { throw new Error('should have thrown'); }, (err) => { - expect(mockDB.accountRecord.callCount).toBe(1); - expect(mockOtpManagerDelete.callCount).toBe(0); - expect(mockOtpManagerCreate.callCount).toBe(0); + expect(mockDB.accountRecord).toHaveBeenCalledTimes(1); + expect(mockOtpManagerDelete).toHaveBeenCalledTimes(0); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(0); expect(err.errno).toBe(206); } ); @@ -1563,7 +1614,7 @@ describe('passwordless service validation', () => { verifierSetAt: 0, }); mockCustoms = { - check: sinon.spy(() => Promise.resolve()), + check: jest.fn(() => Promise.resolve()), v2Enabled: () => true, }; mockRequest = mocks.mockRequest({ @@ -1595,7 +1646,7 @@ describe('passwordless service validation', () => { }, }); route = getRoute(routes, '/account/passwordless/send_code', 'POST'); - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); }); @@ -1608,8 +1659,8 @@ describe('passwordless service validation', () => { (err: any) => { expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); // Rate limiting runs before allowlist check (which happens after account lookup) - expect(mockCustoms.check.callCount).toBe(1); - expect(mockOtpManagerCreate.callCount).toBe(0); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(0); } ); }); @@ -1622,8 +1673,8 @@ describe('passwordless service validation', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); - expect(mockCustoms.check.callCount).toBe(1); - expect(mockOtpManagerCreate.callCount).toBe(0); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(0); } ); }); @@ -1648,7 +1699,7 @@ describe('passwordless service validation', () => { }, }); route = getRoute(routes, '/account/passwordless/send_code', 'POST'); - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); }); @@ -1660,8 +1711,8 @@ describe('passwordless service validation', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); - expect(mockCustoms.check.callCount).toBe(1); - expect(mockOtpManagerCreate.callCount).toBe(0); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(0); } ); }); @@ -1674,8 +1725,8 @@ describe('passwordless service validation', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); - expect(mockCustoms.check.callCount).toBe(1); - expect(mockOtpManagerCreate.callCount).toBe(0); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(0); } ); }); @@ -1683,16 +1734,16 @@ describe('passwordless service validation', () => { it('should allow requests with allowed clientId (ea3ca969f8c6bb0d)', () => { mockRequest.payload.clientId = 'ea3ca969f8c6bb0d'; return runTest(route, mockRequest, () => { - expect(mockCustoms.check.callCount).toBe(1); - expect(mockOtpManagerCreate.callCount).toBe(1); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); }); }); it('should allow requests with allowed clientId (dcdb5ae7add825d2)', () => { mockRequest.payload.clientId = 'dcdb5ae7add825d2'; return runTest(route, mockRequest, () => { - expect(mockCustoms.check.callCount).toBe(1); - expect(mockOtpManagerCreate.callCount).toBe(1); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); }); }); }); @@ -1720,7 +1771,7 @@ describe('passwordless service validation', () => { it('should reject confirm_code without clientId for new account', () => { // Use new account (not existing passwordless) so allowlist is enforced - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); return route.handler(mockRequest).then( @@ -1730,13 +1781,13 @@ describe('passwordless service validation', () => { (err: any) => { expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); // Rate limiting runs before allowlist check (2 checks: verify + daily) - expect(mockCustoms.check.callCount).toBe(2); + expect(mockCustoms.check).toHaveBeenCalledTimes(2); } ); }); it('should reject confirm_code with disallowed clientId for new account', () => { - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); mockRequest.payload.clientId = 'not-allowed-client'; @@ -1746,13 +1797,13 @@ describe('passwordless service validation', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); - expect(mockCustoms.check.callCount).toBe(2); + expect(mockCustoms.check).toHaveBeenCalledTimes(2); } ); }); it('should allow confirm_code with allowed clientId', () => { - mockDB.accountRecord = sinon.spy(() => ({ + mockDB.accountRecord = jest.fn(() => ({ uid, email: TEST_EMAIL, emailVerified: true, @@ -1760,7 +1811,7 @@ describe('passwordless service validation', () => { })); mockRequest.payload.clientId = 'ea3ca969f8c6bb0d'; return runTest(route, mockRequest, (result: any) => { - expect(mockCustoms.check.callCount).toBe(2); + expect(mockCustoms.check).toHaveBeenCalledTimes(2); expect(typeof result.uid).toBe('string'); expect(typeof result.sessionToken).toBe('string'); }); @@ -1768,13 +1819,13 @@ describe('passwordless service validation', () => { it('should bypass allowlist for existing passwordless account on confirm_code', () => { // Existing passwordless accounts bypass the allowlist - mockDB.accountRecord = sinon.spy(() => ({ + mockDB.accountRecord = jest.fn(() => ({ uid, email: TEST_EMAIL, emailVerified: true, verifierSetAt: 0, })); - mockDB.createSessionToken = sinon.spy(() => + mockDB.createSessionToken = jest.fn(() => Promise.resolve({ data: 'sessiontoken123', emailVerified: true, @@ -1808,7 +1859,7 @@ describe('passwordless service validation', () => { }, }); route = getRoute(routes, '/account/passwordless/resend_code', 'POST'); - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); }); @@ -1821,7 +1872,7 @@ describe('passwordless service validation', () => { (err: any) => { expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); // Rate limiting runs before allowlist check - expect(mockCustoms.check.callCount).toBe(1); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); } ); }); @@ -1834,7 +1885,7 @@ describe('passwordless service validation', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); - expect(mockCustoms.check.callCount).toBe(1); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); } ); }); @@ -1842,9 +1893,9 @@ describe('passwordless service validation', () => { it('should allow resend_code with allowed clientId', () => { mockRequest.payload.clientId = 'ea3ca969f8c6bb0d'; return runTest(route, mockRequest, () => { - expect(mockCustoms.check.callCount).toBe(1); - expect(mockOtpManagerDelete.callCount).toBe(1); - expect(mockOtpManagerCreate.callCount).toBe(1); + expect(mockCustoms.check).toHaveBeenCalledTimes(1); + expect(mockOtpManagerDelete).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); }); }); }); @@ -1867,7 +1918,7 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { verifierSetAt: 0, }); mockCustoms = { - check: sinon.spy(() => Promise.resolve()), + check: jest.fn(() => Promise.resolve()), v2Enabled: () => true, }; mockRequest = mocks.mockRequest({ @@ -1884,9 +1935,9 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { }); afterEach(() => { - mockOtpManagerCreate.resetHistory(); - mockOtpManagerIsValid.resetHistory(); - mockOtpManagerDelete.resetHistory(); + mockOtpManagerCreate.mockClear(); + mockOtpManagerIsValid.mockClear(); + mockOtpManagerDelete.mockClear(); }); describe('send_code', () => { @@ -1905,7 +1956,7 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { }, }); const route = getRoute(routes, '/account/passwordless/send_code', 'POST'); - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -1916,7 +1967,7 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { return runTest(route, mockRequest, (result) => { expect(result).toEqual({}); - expect(mockOtpManagerCreate.callCount).toBe(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); }); }); @@ -1935,7 +1986,7 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { }, }); const route = getRoute(routes, '/account/passwordless/send_code', 'POST'); - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -1946,7 +1997,7 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { return runTest(route, mockRequest, (result) => { expect(result).toEqual({}); - expect(mockOtpManagerCreate.callCount).toBe(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); }); }); @@ -1965,7 +2016,7 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { }, }); const route = getRoute(routes, '/account/passwordless/send_code', 'POST'); - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount()) ); @@ -1975,7 +2026,7 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.FEATURE_NOT_ENABLED); - expect(mockOtpManagerCreate.callCount).toBe(0); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(0); } ); }); @@ -2002,7 +2053,7 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { 'POST' ); mockRequest.payload.code = '123456'; - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -2010,7 +2061,7 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { verifierSetAt: 0, }) ); - mockDB.createSessionToken = sinon.spy(() => + mockDB.createSessionToken = jest.fn(() => Promise.resolve({ data: 'sessiontoken123', emailVerified: true, @@ -2047,7 +2098,7 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { '/account/passwordless/resend_code', 'POST' ); - mockDB.accountRecord = sinon.spy(() => + mockDB.accountRecord = jest.fn(() => Promise.resolve({ uid, email: TEST_EMAIL, @@ -2058,8 +2109,8 @@ describe('existing passwordless accounts bypass flag and allowlist', () => { return runTest(route, mockRequest, (result) => { expect(result).toEqual({}); - expect(mockOtpManagerDelete.callCount).toBe(1); - expect(mockOtpManagerCreate.callCount).toBe(1); + expect(mockOtpManagerDelete).toHaveBeenCalledTimes(1); + expect(mockOtpManagerCreate).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/recovery-codes.spec.ts b/packages/fxa-auth-server/lib/routes/recovery-codes.spec.ts index b5d76619b1b..5b707708b56 100644 --- a/packages/fxa-auth-server/lib/routes/recovery-codes.spec.ts +++ b/packages/fxa-auth-server/lib/routes/recovery-codes.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { AppError as error } from '@fxa/accounts/errors'; import { BackupCodeManager } from '@fxa/accounts/two-factor'; @@ -24,12 +23,11 @@ let log: any, const TEST_EMAIL = 'test@email.com'; const UID = 'uid'; -const sandbox = sinon.createSandbox(); const mockBackupCodeManager = { - getCountForUserId: sandbox.fake(), + getCountForUserId: jest.fn(), }; const mockAccountEventsManager = { - recordSecurityEvent: sandbox.fake(), + recordSecurityEvent: jest.fn(), }; function runTest(routePath: string, requestOptions: any, method?: string) { @@ -43,7 +41,7 @@ function runTest(routePath: string, requestOptions: any, method?: string) { routes = require('./recovery-codes')(log, db, config, customs, mailer, glean); route = getRoute(routes, routePath, method); request = mocks.mockRequest(requestOptions); - request.emitMetricsEvent = sandbox.spy(() => Promise.resolve({})); + request.emitMetricsEvent = jest.fn(() => Promise.resolve({})); return route.handler(request); } @@ -79,7 +77,7 @@ describe('backup authentication codes', () => { }); afterEach(() => { - sandbox.reset(); + jest.clearAllMocks(); }); afterAll(() => { @@ -93,30 +91,29 @@ describe('backup authentication codes', () => { (res: any) => { expect(res.recoveryCodes).toHaveLength(2); - expect(db.replaceRecoveryCodes.callCount).toBe(1); - const args = db.replaceRecoveryCodes.args[0]; - expect(args[0]).toBe(UID); - expect(args[1]).toBe(8); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordSecurityEvent, - db, - { - name: 'account.recovery_codes_replaced', - uid: 'uid', - ipAddr: '63.245.221.32', - tokenId: undefined, - additionalInfo: { - userAgent: 'test user-agent', - location: { - city: 'Mountain View', - country: 'United States', - countryCode: 'US', - state: 'California', - stateCode: 'CA', - }, + expect(db.replaceRecoveryCodes).toHaveBeenCalledTimes(1); + expect(db.replaceRecoveryCodes).toHaveBeenNthCalledWith(1, UID, 8); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(1); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledWith(db, { + name: 'account.recovery_codes_replaced', + uid: 'uid', + ipAddr: '63.245.221.32', + tokenId: undefined, + additionalInfo: { + userAgent: 'test user-agent', + location: { + city: 'Mountain View', + country: 'United States', + countryCode: 'US', + state: 'California', + stateCode: 'CA', }, - } - ); + }, + }); } ); }); @@ -131,31 +128,31 @@ describe('backup authentication codes', () => { (res: any) => { expect(res.success).toBe(true); - expect(db.updateRecoveryCodes.callCount).toBe(1); - - const args = db.updateRecoveryCodes.args[0]; - expect(args[0]).toBe(UID); - expect(args[1]).toEqual(['123']); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordSecurityEvent, - db, - { - name: 'account.recovery_codes_created', - uid: 'uid', - ipAddr: '63.245.221.32', - tokenId: undefined, - additionalInfo: { - userAgent: 'test user-agent', - location: { - city: 'Mountain View', - country: 'United States', - countryCode: 'US', - state: 'California', - stateCode: 'CA', - }, + expect(db.updateRecoveryCodes).toHaveBeenCalledTimes(1); + expect(db.updateRecoveryCodes).toHaveBeenNthCalledWith(1, UID, [ + '123', + ]); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(1); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledWith(db, { + name: 'account.recovery_codes_created', + uid: 'uid', + ipAddr: '63.245.221.32', + tokenId: undefined, + additionalInfo: { + userAgent: 'test user-agent', + location: { + city: 'Mountain View', + country: 'United States', + countryCode: 'US', + state: 'California', + stateCode: 'CA', }, - } - ); + }, + }); } ); }); @@ -163,7 +160,7 @@ describe('backup authentication codes', () => { describe('GET /recoveryCodes/exists', () => { it('should return hasBackupCodes and count', async () => { - mockBackupCodeManager.getCountForUserId = sandbox.fake.returns({ + mockBackupCodeManager.getCountForUserId = jest.fn().mockReturnValue({ hasBackupCodes: true, count: 8, }); @@ -172,15 +169,12 @@ describe('backup authentication codes', () => { expect(res).toBeDefined(); expect(res.hasBackupCodes).toBe(true); expect(res.count).toBe(8); - sinon.assert.calledOnce(mockBackupCodeManager.getCountForUserId); - sinon.assert.calledWithExactly( - mockBackupCodeManager.getCountForUserId, - UID - ); + expect(mockBackupCodeManager.getCountForUserId).toHaveBeenCalledTimes(1); + expect(mockBackupCodeManager.getCountForUserId).toHaveBeenCalledWith(UID); }); it('should handle empty response from backupCodeManager', async () => { - mockBackupCodeManager.getCountForUserId = sandbox.fake.returns({}); + mockBackupCodeManager.getCountForUserId = jest.fn().mockReturnValue({}); const res = await runTest('/recoveryCodes/exists', requestOptions, 'GET'); expect(res.hasBackupCodes).toBeUndefined(); @@ -190,17 +184,20 @@ describe('backup authentication codes', () => { describe('POST /session/verify/recoveryCode', () => { it('sends email if backup authentication codes are low', async () => { - db.consumeRecoveryCode = sandbox.spy((_code: any) => { + db.consumeRecoveryCode = jest.fn((_code: any) => { return Promise.resolve({ remaining: 1 }); }); await runTest('/session/verify/recoveryCode', requestOptions); - expect(fxaMailer.sendLowRecoveryCodesEmail.callCount).toBe(1); - const args = fxaMailer.sendLowRecoveryCodesEmail.args[0]; - expect(args).toHaveLength(1); - expect(args[0].numberRemaining).toBe(1); + expect(fxaMailer.sendLowRecoveryCodesEmail).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendLowRecoveryCodesEmail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ numberRemaining: 1 }) + ); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordSecurityEvent, + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(1); + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( db, { name: 'account.recovery_codes_signin_complete', @@ -223,7 +220,7 @@ describe('backup authentication codes', () => { it('should rate-limit attempts to use a backup authentication code via customs', async () => { requestOptions.payload.code = '1234567890'; - db.consumeRecoveryCode = sandbox.spy((_code: any) => { + db.consumeRecoveryCode = jest.fn((_code: any) => { throw error.recoveryCodeNotFound(); }); try { @@ -231,8 +228,7 @@ describe('backup authentication codes', () => { throw new Error('should have thrown'); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.RECOVERY_CODE_NOT_FOUND); - sinon.assert.calledWithExactly( - customs.checkAuthenticated, + expect(customs.checkAuthenticated).toHaveBeenCalledWith( request, UID, TEST_EMAIL, @@ -242,26 +238,28 @@ describe('backup authentication codes', () => { }); it('should emit a glean event on successful verification', async () => { - db.consumeRecoveryCode = sandbox.spy((_code: any) => { + db.consumeRecoveryCode = jest.fn((_code: any) => { return Promise.resolve({ remaining: 4 }); }); await runTest('/session/verify/recoveryCode', requestOptions); - sinon.assert.calledOnceWithExactly( - glean.login.recoveryCodeSuccess, - request, - { uid: UID } - ); + expect(glean.login.recoveryCodeSuccess).toHaveBeenCalledTimes(1); + expect(glean.login.recoveryCodeSuccess).toHaveBeenCalledWith(request, { + uid: UID, + }); }); it('should emit the flow complete event', async () => { - db.consumeRecoveryCode = sandbox.spy((_code: any) => { + db.consumeRecoveryCode = jest.fn((_code: any) => { return Promise.resolve({ remaining: 4 }); }); await runTest('/session/verify/recoveryCode', requestOptions); - sinon.assert.calledTwice(request.emitMetricsEvent); - sinon.assert.calledWith(request.emitMetricsEvent, 'account.confirmed', { - uid: UID, - }); + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(2); + expect(request.emitMetricsEvent).toHaveBeenCalledWith( + 'account.confirmed', + { + uid: UID, + } + ); }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/recovery-key.spec.ts b/packages/fxa-auth-server/lib/routes/recovery-key.spec.ts index aa803a4af7c..77989f77e1c 100644 --- a/packages/fxa-auth-server/lib/routes/recovery-key.spec.ts +++ b/packages/fxa-auth-server/lib/routes/recovery-key.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { AppError as errors } from '@fxa/accounts/errors'; const uuid = require('uuid'); @@ -26,7 +25,7 @@ const recoveryData = '11111111111'; const hint = 'super secret location'; const uid = uuid.v4({}, Buffer.alloc(16)).toString('hex'); -let mockAuthorizedClientsList: any = sinon.stub().resolves([]); +let mockAuthorizedClientsList: any = jest.fn().mockResolvedValue([]); jest.mock('../oauth/authorized_clients', () => ({ list: (...args: any[]) => mockAuthorizedClientsList(...args), @@ -45,7 +44,7 @@ function setup(results: any, _errors: any, path: string, requestOptions: any) { glean = mocks.mockGlean(); mockAuthorizedClientsList = results.mockAuthorizedClients ? results.mockAuthorizedClients.list - : sinon.stub().resolves([]); + : jest.fn().mockResolvedValue([]); routes = makeRoutes({ log, db, @@ -55,7 +54,7 @@ function setup(results: any, _errors: any, path: string, requestOptions: any) { }); route = getRoute(routes, path, requestOptions.method); request = mocks.mockRequest(requestOptions); - request.emitMetricsEvent = sinon.spy(() => Promise.resolve({})); + request.emitMetricsEvent = jest.fn(() => Promise.resolve({})); return route.handler(request); } @@ -113,10 +112,9 @@ describe('POST /recoveryKey', () => { }); it('recorded security event', () => { - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.recovery_key_added', ipAddr: '63.245.221.32', uid: requestOptions.credentials.uid, @@ -126,46 +124,55 @@ describe('POST /recoveryKey', () => { }); it('called log.begin correctly', () => { - expect(log.begin.callCount).toBe(1); - const args = log.begin.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('createRecoveryKey'); - expect(args[1]).toBe(request); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(log.begin).toHaveBeenNthCalledWith( + 1, + 'createRecoveryKey', + request + ); }); it('called db.createRecoveryKey correctly', () => { - expect(db.createRecoveryKey.callCount).toBe(1); - const args = db.createRecoveryKey.args[0]; - expect(args).toHaveLength(4); - expect(args[0]).toBe(uid); - expect(args[1]).toBe(recoveryKeyId); - expect(args[2]).toBe(recoveryData); - expect(args[3]).toBe(true); + expect(db.createRecoveryKey).toHaveBeenCalledTimes(1); + expect(db.createRecoveryKey).toHaveBeenNthCalledWith( + 1, + uid, + recoveryKeyId, + recoveryData, + true + ); }); it('did not call db.deleteRecoveryKey', () => { - expect(db.deleteRecoveryKey.callCount).toBe(0); + expect(db.deleteRecoveryKey).toHaveBeenCalledTimes(0); }); it('called log.info correctly', () => { - expect(log.info.callCount).toBe(1); - const args = log.info.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('account.recoveryKey.created'); + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenNthCalledWith( + 1, + 'account.recoveryKey.created', + expect.anything() + ); }); it('called request.emitMetricsEvent correctly', () => { - expect(request.emitMetricsEvent.callCount).toBe(1); - const args = request.emitMetricsEvent.args[0]; - expect(args[0]).toBe('recoveryKey.created'); - expect(args[1]['uid']).toBe(uid); + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(1); + expect(request.emitMetricsEvent).toHaveBeenNthCalledWith( + 1, + 'recoveryKey.created', + expect.objectContaining({ uid }) + ); }); it('called mailer.sendPostAddAccountRecoveryEmail correctly', () => { - expect(fxaMailer.sendPostAddAccountRecoveryEmail.callCount).toBe(1); - const args = fxaMailer.sendPostAddAccountRecoveryEmail.args[0]; - expect(args).toHaveLength(1); - expect(args[0].to).toBe(email); + expect(fxaMailer.sendPostAddAccountRecoveryEmail).toHaveBeenCalledTimes( + 1 + ); + expect(fxaMailer.sendPostAddAccountRecoveryEmail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ to: email }) + ); }); }); @@ -202,10 +209,9 @@ describe('POST /recoveryKey', () => { }); it('recorded security event for the key deletion', () => { - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.recovery_key_removed', ipAddr: '63.245.221.32', uid: requestOptions.credentials.uid, @@ -215,10 +221,9 @@ describe('POST /recoveryKey', () => { }); it('recorded security event for the key creation', () => { - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.recovery_key_added', ipAddr: '63.245.221.32', uid: requestOptions.credentials.uid, @@ -228,49 +233,55 @@ describe('POST /recoveryKey', () => { }); it('called log.begin correctly', () => { - expect(log.begin.callCount).toBe(1); - const args = log.begin.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('createRecoveryKey'); - expect(args[1]).toBe(request); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(log.begin).toHaveBeenNthCalledWith( + 1, + 'createRecoveryKey', + request + ); }); it('called db.createRecoveryKey correctly', () => { - expect(db.createRecoveryKey.callCount).toBe(1); - const args = db.createRecoveryKey.args[0]; - expect(args).toHaveLength(4); - expect(args[0]).toBe(uid); - expect(args[1]).toBe(recoveryKeyId); - expect(args[2]).toBe(recoveryData); - expect(args[3]).toBe(true); + expect(db.createRecoveryKey).toHaveBeenCalledTimes(1); + expect(db.createRecoveryKey).toHaveBeenNthCalledWith( + 1, + uid, + recoveryKeyId, + recoveryData, + true + ); }); it('called db.deleteRecoveryKey correctly', () => { - expect(db.deleteRecoveryKey.callCount).toBe(1); - const args = db.deleteRecoveryKey.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe(uid); + expect(db.deleteRecoveryKey).toHaveBeenCalledTimes(1); + expect(db.deleteRecoveryKey).toHaveBeenNthCalledWith(1, uid); }); it('called log.info correctly', () => { - expect(log.info.callCount).toBe(1); - const args = log.info.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('account.recoveryKey.changed'); + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenNthCalledWith( + 1, + 'account.recoveryKey.changed', + expect.anything() + ); }); it('called request.emitMetricsEvent correctly', () => { - expect(request.emitMetricsEvent.callCount).toBe(1); - const args = request.emitMetricsEvent.args[0]; - expect(args[0]).toBe('recoveryKey.changed'); - expect(args[1]['uid']).toBe(uid); + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(1); + expect(request.emitMetricsEvent).toHaveBeenNthCalledWith( + 1, + 'recoveryKey.changed', + expect.objectContaining({ uid }) + ); }); it('called mailer.sendPostChangeAccountRecoveryEmail correctly', () => { - expect(fxaMailer.sendPostChangeAccountRecoveryEmail.callCount).toBe(1); - const args = fxaMailer.sendPostChangeAccountRecoveryEmail.args[0]; - expect(args).toHaveLength(1); - expect(args[0].to).toBe(email); + expect( + fxaMailer.sendPostChangeAccountRecoveryEmail + ).toHaveBeenCalledTimes(1); + expect( + fxaMailer.sendPostChangeAccountRecoveryEmail + ).toHaveBeenNthCalledWith(1, expect.objectContaining({ to: email })); }); }); @@ -308,35 +319,40 @@ describe('POST /recoveryKey', () => { }); it('recorded a security event', () => { - expect(mockAccountEventsManager.recordSecurityEvent.callCount).toBe(0); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(0); }); it('called log.begin correctly', () => { - expect(log.begin.callCount).toBe(1); - const args = log.begin.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('createRecoveryKey'); - expect(args[1]).toBe(request); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(log.begin).toHaveBeenNthCalledWith( + 1, + 'createRecoveryKey', + request + ); }); it('db.createRecoveryKey is not called', () => { - expect(db.createRecoveryKey.callCount).toBe(0); + expect(db.createRecoveryKey).toHaveBeenCalledTimes(0); }); it('did not call db.deleteRecoveryKey', () => { - expect(db.deleteRecoveryKey.callCount).toBe(0); + expect(db.deleteRecoveryKey).toHaveBeenCalledTimes(0); }); it('did not call log.info', () => { - expect(log.info.callCount).toBe(0); + expect(log.info).toHaveBeenCalledTimes(0); }); it('did not call request.emitMetricsEvent', () => { - expect(request.emitMetricsEvent.callCount).toBe(0); + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(0); }); it('did not call fxaMailer.sendPostAddAccountRecoveryEmail', () => { - expect(fxaMailer.sendPostAddAccountRecoveryEmail.callCount).toBe(0); + expect(fxaMailer.sendPostAddAccountRecoveryEmail).toHaveBeenCalledTimes( + 0 + ); }); }); @@ -360,13 +376,14 @@ describe('POST /recoveryKey', () => { }); it('called db.createRecoveryKey correctly', () => { - expect(db.createRecoveryKey.callCount).toBe(1); - const args = db.createRecoveryKey.args[0]; - expect(args).toHaveLength(4); - expect(args[0]).toBe(uid); - expect(args[1]).toBe(recoveryKeyId); - expect(args[2]).toBe(recoveryData); - expect(args[3]).toBe(false); + expect(db.createRecoveryKey).toHaveBeenCalledTimes(1); + expect(db.createRecoveryKey).toHaveBeenNthCalledWith( + 1, + uid, + recoveryKeyId, + recoveryData, + false + ); }); }); @@ -394,43 +411,49 @@ describe('POST /recoveryKey', () => { }); it('called customs.checkAuthenticated correctly', () => { - expect(customs.checkAuthenticated.callCount).toBe(1); - const args = customs.checkAuthenticated.args[0]; - expect(args).toHaveLength(4); - expect(args[0]).toEqual(request); - expect(args[1]).toBe(uid); - expect(args[2]).toBe(email); - expect(args[3]).toBe('getRecoveryKey'); + expect(customs.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(customs.checkAuthenticated).toHaveBeenNthCalledWith( + 1, + request, + uid, + email, + 'getRecoveryKey' + ); }); it('called db.updateRecoveryKey correctly', () => { - expect(db.updateRecoveryKey.callCount).toBe(1); - const args = db.updateRecoveryKey.args[0]; - expect(args).toHaveLength(3); - expect(args[0]).toBe(uid); - expect(args[1]).toBe(recoveryKeyId); - expect(args[2]).toBe(true); + expect(db.updateRecoveryKey).toHaveBeenCalledTimes(1); + expect(db.updateRecoveryKey).toHaveBeenNthCalledWith( + 1, + uid, + recoveryKeyId, + true + ); }); it('called request.emitMetricsEvent correctly', () => { - expect(request.emitMetricsEvent.callCount).toBe(1); - const args = request.emitMetricsEvent.args[0]; - expect(args[0]).toBe('recoveryKey.created'); - expect(args[1]['uid']).toBe(uid); + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(1); + expect(request.emitMetricsEvent).toHaveBeenNthCalledWith( + 1, + 'recoveryKey.created', + expect.objectContaining({ uid }) + ); }); it('called mailer.sendPostAddAccountRecoveryEmail correctly', () => { - expect(fxaMailer.sendPostAddAccountRecoveryEmail.callCount).toBe(1); - const args = fxaMailer.sendPostAddAccountRecoveryEmail.args[0]; - expect(args).toHaveLength(1); - expect(args[0].to).toBe(email); + expect(fxaMailer.sendPostAddAccountRecoveryEmail).toHaveBeenCalledTimes( + 1 + ); + expect(fxaMailer.sendPostAddAccountRecoveryEmail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ to: email }) + ); }); it('records security event', () => { - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.recovery_key_challenge_success', ipAddr: '63.245.221.32', uid: uid, @@ -462,34 +485,29 @@ describe('GET /recoveryKey/{recoveryKeyId}', () => { }); it('called log.begin correctly', () => { - expect(log.begin.callCount).toBe(1); - const args = log.begin.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('getRecoveryKey'); - expect(args[1]).toBe(request); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(log.begin).toHaveBeenNthCalledWith(1, 'getRecoveryKey', request); }); it('called customs.checkAuthenticated correctly', () => { - expect(customs.checkAuthenticated.callCount).toBe(1); - const args = customs.checkAuthenticated.args[0]; - expect(args).toHaveLength(4); - expect(args[0]).toEqual(request); - expect(args[1]).toBe(uid); - expect(args[2]).toBe(email); - expect(args[3]).toBe('getRecoveryKey'); + expect(customs.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(customs.checkAuthenticated).toHaveBeenNthCalledWith( + 1, + request, + uid, + email, + 'getRecoveryKey' + ); }); it('called db.getRecoveryKey correctly', () => { - expect(db.getRecoveryKey.callCount).toBe(1); - const args = db.getRecoveryKey.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe(uid); - expect(args[1]).toBe(recoveryKeyId); + expect(db.getRecoveryKey).toHaveBeenCalledTimes(1); + expect(db.getRecoveryKey).toHaveBeenNthCalledWith(1, uid, recoveryKeyId); }); it('logged a Glean event', () => { - sinon.assert.calledOnceWithExactly( - glean.resetPassword.recoveryKeySuccess, + expect(glean.resetPassword.recoveryKeySuccess).toHaveBeenCalledTimes(1); + expect(glean.resetPassword.recoveryKeySuccess).toHaveBeenCalledWith( request, { uid } ); @@ -543,18 +561,17 @@ describe('POST /recoveryKey/exists', () => { }); it('called log.begin correctly', () => { - expect(log.begin.callCount).toBe(1); - const args = log.begin.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('recoveryKeyExists'); - expect(args[1]).toBe(request); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(log.begin).toHaveBeenNthCalledWith( + 1, + 'recoveryKeyExists', + request + ); }); it('called db.getRecoveryKeyRecordWithHint correctly', () => { - expect(db.getRecoveryKeyRecordWithHint.callCount).toBe(1); - const args = db.getRecoveryKeyRecordWithHint.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe(uid); + expect(db.getRecoveryKeyRecordWithHint).toHaveBeenCalledTimes(1); + expect(db.getRecoveryKeyRecordWithHint).toHaveBeenNthCalledWith(1, uid); }); }); @@ -624,7 +641,7 @@ describe('POST /recoveryKey/exists', () => { response = await setup( { mockAuthorizedClients: { - list: sinon.stub().resolves([ + list: jest.fn().mockResolvedValue([ { client_id: 'desktop1', client_name: 'Desktop', @@ -682,32 +699,32 @@ describe('DELETE /recoveryKey', () => { }); it('called log.begin correctly', () => { - expect(log.begin.callCount).toBe(1); - const args = log.begin.args[0]; - expect(args).toHaveLength(2); - expect(args[0]).toBe('recoveryKeyDelete'); - expect(args[1]).toBe(request); + expect(log.begin).toHaveBeenCalledTimes(1); + expect(log.begin).toHaveBeenNthCalledWith( + 1, + 'recoveryKeyDelete', + request + ); }); it('called db.deleteRecoveryKey correctly', () => { - expect(db.deleteRecoveryKey.callCount).toBe(1); - const args = db.deleteRecoveryKey.args[0]; - expect(args).toHaveLength(1); - expect(args[0]).toBe(uid); + expect(db.deleteRecoveryKey).toHaveBeenCalledTimes(1); + expect(db.deleteRecoveryKey).toHaveBeenNthCalledWith(1, uid); }); it('called mailer.sendPostRemoveAccountRecoveryEmail correctly', () => { - expect(fxaMailer.sendPostRemoveAccountRecoveryEmail.callCount).toBe(1); - const args = fxaMailer.sendPostRemoveAccountRecoveryEmail.args[0]; - expect(args).toHaveLength(1); - expect(args[0].to).toBe(email); + expect( + fxaMailer.sendPostRemoveAccountRecoveryEmail + ).toHaveBeenCalledTimes(1); + expect( + fxaMailer.sendPostRemoveAccountRecoveryEmail + ).toHaveBeenNthCalledWith(1, expect.objectContaining({ to: email })); }); it('recorded security event', () => { - sinon.assert.calledWith( - mockAccountEventsManager.recordSecurityEvent, - sinon.match.defined, - sinon.match({ + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'account.recovery_key_removed', ipAddr: '63.245.221.32', uid: uid, @@ -757,7 +774,8 @@ describe('POST /recoveryKey/hint', () => { it('returned the correct response', () => { expect(response).toEqual({}); - sinon.assert.calledOnceWithExactly(db.updateRecoveryKeyHint, uid, hint); + expect(db.updateRecoveryKeyHint).toHaveBeenCalledTimes(1); + expect(db.updateRecoveryKeyHint).toHaveBeenCalledWith(uid, hint); }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/recovery-phone.spec.ts b/packages/fxa-auth-server/lib/routes/recovery-phone.spec.ts index 29649abc594..591bef23196 100644 --- a/packages/fxa-auth-server/lib/routes/recovery-phone.spec.ts +++ b/packages/fxa-auth-server/lib/routes/recovery-phone.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { AppError } from '@fxa/accounts/errors'; import { AccountManager } from '@fxa/shared/account/account'; @@ -21,7 +20,6 @@ const { mockRequest } = require('../../test/mocks'); const { AccountEventsManager } = require('../account-events'); describe('/recovery_phone', () => { - const sandbox = sinon.createSandbox(); const uid = '123435678123435678123435678123435678'; const email = 'test@mozilla.com'; const phoneNumber = '+15550005555'; @@ -33,53 +31,53 @@ describe('/recovery_phone', () => { let mockFxaMailer: any; const mockCustoms = { - check: sandbox.fake(), - checkAuthenticated: sandbox.fake(), + check: jest.fn(), + checkAuthenticated: jest.fn(), }; const mockStatsd = { - increment: sandbox.fake(), - histogram: sandbox.fake(), + increment: jest.fn(), + histogram: jest.fn(), }; const mockGlean = { login: { - recoveryPhoneSuccess: sandbox.fake(), + recoveryPhoneSuccess: jest.fn(), }, twoStepAuthPhoneCode: { - sent: sandbox.fake(), - sendError: sandbox.fake(), - complete: sandbox.fake(), + sent: jest.fn(), + sendError: jest.fn(), + complete: jest.fn(), }, twoStepAuthPhoneRemove: { - success: sandbox.fake(), + success: jest.fn(), }, resetPassword: { - recoveryPhoneCodeSent: sandbox.fake(), - recoveryPhoneCodeSendError: sandbox.fake(), - recoveryPhoneCodeComplete: sandbox.fake(), + recoveryPhoneCodeSent: jest.fn(), + recoveryPhoneCodeSendError: jest.fn(), + recoveryPhoneCodeComplete: jest.fn(), }, twoStepAuthPhoneReplace: { - success: sandbox.fake(), - failure: sandbox.fake(), + success: jest.fn(), + failure: jest.fn(), }, }; const mockRecoveryPhoneService: any = { - setupPhoneNumber: sandbox.fake(), - getNationalFormat: sandbox.fake(), - confirmCode: sandbox.fake(), - confirmSetupCode: sandbox.fake(), - removePhoneNumber: sandbox.fake(), - stripPhoneNumber: sandbox.fake(), - hasConfirmed: sandbox.fake(), - onMessageStatusUpdate: sandbox.fake(), - validateTwilioWebhookCallback: sandbox.fake(), - validateSetupCode: sandbox.fake(), - changePhoneNumber: sandbox.fake(), + setupPhoneNumber: jest.fn(), + getNationalFormat: jest.fn(), + confirmCode: jest.fn(), + confirmSetupCode: jest.fn(), + removePhoneNumber: jest.fn(), + stripPhoneNumber: jest.fn(), + hasConfirmed: jest.fn(), + onMessageStatusUpdate: jest.fn(), + validateTwilioWebhookCallback: jest.fn(), + validateSetupCode: jest.fn(), + changePhoneNumber: jest.fn(), }; const mockAccountManager = { - verifySession: sandbox.fake(), + verifySession: jest.fn(), }; const mockAccountEventsManager = { - recordSecurityEvent: sandbox.fake(), + recordSecurityEvent: jest.fn(), }; let routes: any = []; let request: any; @@ -107,7 +105,7 @@ describe('/recovery_phone', () => { }); afterEach(() => { - sandbox.reset(); + jest.clearAllMocks(); }); afterAll(() => { @@ -118,13 +116,13 @@ describe('/recovery_phone', () => { const route = getRoute(routes, req.path, req.method); expect(route).toBeDefined(); request = mockRequest(req); - request.emitMetricsEvent = sandbox.stub().resolves(); + request.emitMetricsEvent = jest.fn().mockResolvedValue(); return await route.handler(request); } describe('POST /recovery_phone/signin/send_code', () => { it('sends recovery phone code', async () => { - mockRecoveryPhoneService.sendCode = sinon.fake.returns(true); + mockRecoveryPhoneService.sendCode = jest.fn().mockReturnValue(true); const resp = await makeRequest({ method: 'POST', @@ -134,21 +132,29 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect(resp.status).toBe('success'); - expect(mockRecoveryPhoneService.sendCode.callCount).toBe(1); - expect(mockRecoveryPhoneService.sendCode.getCall(0).args[0]).toBe(uid); + expect(mockRecoveryPhoneService.sendCode).toHaveBeenCalledTimes(1); + expect(mockRecoveryPhoneService.sendCode).toHaveBeenNthCalledWith( + 1, + uid, + expect.any(Function) + ); - expect(mockGlean.twoStepAuthPhoneCode.sent.callCount).toBe(1); - expect(mockGlean.twoStepAuthPhoneCode.sendError.callCount).toBe(0); + expect(mockGlean.twoStepAuthPhoneCode.sent).toHaveBeenCalledTimes(1); + expect(mockGlean.twoStepAuthPhoneCode.sendError).toHaveBeenCalledTimes(0); - expect(mockCustoms.checkAuthenticated.callCount).toBe(1); - expect(mockCustoms.checkAuthenticated.getCall(0).args[1]).toBe(uid); - expect(mockCustoms.checkAuthenticated.getCall(0).args[2]).toBe(email); - expect(mockCustoms.checkAuthenticated.getCall(0).args[3]).toBe( + expect(mockCustoms.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(mockCustoms.checkAuthenticated).toHaveBeenNthCalledWith( + 1, + expect.anything(), + uid, + email, 'recoveryPhoneSendSigninCode' ); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordSecurityEvent, + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(1); + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( mockDb, { name: 'account.recovery_phone_send_code', @@ -167,15 +173,15 @@ describe('/recovery_phone', () => { }, } ); - sinon.assert.calledOnceWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.recoveryPhone.signinSendCode.success', {} ); }); it('handles failure to send recovery phone code', async () => { - mockRecoveryPhoneService.sendCode = sinon.fake.returns(false); + mockRecoveryPhoneService.sendCode = jest.fn().mockReturnValue(false); const resp = await makeRequest({ method: 'POST', @@ -185,17 +191,21 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect(resp.status).toBe('failure'); - expect(mockRecoveryPhoneService.sendCode.callCount).toBe(1); - expect(mockRecoveryPhoneService.sendCode.getCall(0).args[0]).toBe(uid); + expect(mockRecoveryPhoneService.sendCode).toHaveBeenCalledTimes(1); + expect(mockRecoveryPhoneService.sendCode).toHaveBeenNthCalledWith( + 1, + uid, + expect.any(Function) + ); - expect(mockGlean.twoStepAuthPhoneCode.sent.callCount).toBe(0); - expect(mockGlean.twoStepAuthPhoneCode.sendError.callCount).toBe(1); + expect(mockGlean.twoStepAuthPhoneCode.sent).toHaveBeenCalledTimes(0); + expect(mockGlean.twoStepAuthPhoneCode.sendError).toHaveBeenCalledTimes(1); }); it('handles unexpected backend error', async () => { - mockRecoveryPhoneService.sendCode = sinon.fake.returns( - Promise.reject(new Error('BOOM')) - ); + mockRecoveryPhoneService.sendCode = jest + .fn() + .mockReturnValue(Promise.reject(new Error('BOOM'))); const promise = makeRequest({ method: 'POST', @@ -206,11 +216,15 @@ describe('/recovery_phone', () => { await expect(promise).rejects.toThrow( 'System unavailable, try again soon' ); - expect(mockRecoveryPhoneService.sendCode.callCount).toBe(1); - expect(mockRecoveryPhoneService.sendCode.getCall(0).args[0]).toBe(uid); + expect(mockRecoveryPhoneService.sendCode).toHaveBeenCalledTimes(1); + expect(mockRecoveryPhoneService.sendCode).toHaveBeenNthCalledWith( + 1, + uid, + expect.any(Function) + ); - expect(mockGlean.twoStepAuthPhoneCode.sent.callCount).toBe(0); - expect(mockGlean.twoStepAuthPhoneCode.sendError.callCount).toBe(0); + expect(mockGlean.twoStepAuthPhoneCode.sent).toHaveBeenCalledTimes(0); + expect(mockGlean.twoStepAuthPhoneCode.sendError).toHaveBeenCalledTimes(0); }); it('requires session authorization', () => { @@ -225,7 +239,7 @@ describe('/recovery_phone', () => { describe('POST /recovery_phone/reset_password/send_code', () => { it('sends recovery phone code', async () => { - mockRecoveryPhoneService.sendCode = sinon.fake.returns(true); + mockRecoveryPhoneService.sendCode = jest.fn().mockReturnValue(true); const resp = await makeRequest({ method: 'POST', @@ -239,23 +253,33 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect(resp.status).toBe('success'); - expect(mockRecoveryPhoneService.sendCode.callCount).toBe(1); - expect(mockRecoveryPhoneService.sendCode.getCall(0).args[0]).toBe(uid); - - expect(mockGlean.resetPassword.recoveryPhoneCodeSent.callCount).toBe(1); - expect(mockGlean.resetPassword.recoveryPhoneCodeSendError.callCount).toBe( - 0 + expect(mockRecoveryPhoneService.sendCode).toHaveBeenCalledTimes(1); + expect(mockRecoveryPhoneService.sendCode).toHaveBeenNthCalledWith( + 1, + uid, + expect.any(Function) ); - expect(mockCustoms.checkAuthenticated.callCount).toBe(1); - expect(mockCustoms.checkAuthenticated.getCall(0).args[1]).toBe(uid); - expect(mockCustoms.checkAuthenticated.getCall(0).args[2]).toBe(email); - expect(mockCustoms.checkAuthenticated.getCall(0).args[3]).toBe( + expect( + mockGlean.resetPassword.recoveryPhoneCodeSent + ).toHaveBeenCalledTimes(1); + expect( + mockGlean.resetPassword.recoveryPhoneCodeSendError + ).toHaveBeenCalledTimes(0); + + expect(mockCustoms.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(mockCustoms.checkAuthenticated).toHaveBeenNthCalledWith( + 1, + expect.anything(), + uid, + email, 'recoveryPhoneSendResetPasswordCode' ); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordSecurityEvent, + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(1); + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( mockDb, { name: 'account.recovery_phone_send_code', @@ -274,15 +298,15 @@ describe('/recovery_phone', () => { }, } ); - sinon.assert.calledOnceWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.recoveryPhone.resetPasswordSendCode.success', {} ); }); it('handles failure to send recovery phone code', async () => { - mockRecoveryPhoneService.sendCode = sinon.fake.returns(false); + mockRecoveryPhoneService.sendCode = jest.fn().mockReturnValue(false); const resp = await makeRequest({ method: 'POST', @@ -296,19 +320,25 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect(resp.status).toBe('failure'); - expect(mockRecoveryPhoneService.sendCode.callCount).toBe(1); - expect(mockRecoveryPhoneService.sendCode.getCall(0).args[0]).toBe(uid); - - expect(mockGlean.resetPassword.recoveryPhoneCodeSent.callCount).toBe(0); - expect(mockGlean.resetPassword.recoveryPhoneCodeSendError.callCount).toBe( - 1 + expect(mockRecoveryPhoneService.sendCode).toHaveBeenCalledTimes(1); + expect(mockRecoveryPhoneService.sendCode).toHaveBeenNthCalledWith( + 1, + uid, + expect.any(Function) ); + + expect( + mockGlean.resetPassword.recoveryPhoneCodeSent + ).toHaveBeenCalledTimes(0); + expect( + mockGlean.resetPassword.recoveryPhoneCodeSendError + ).toHaveBeenCalledTimes(1); }); it('handles unexpected backend error', async () => { - mockRecoveryPhoneService.sendCode = sinon.fake.returns( - Promise.reject(new Error('BOOM')) - ); + mockRecoveryPhoneService.sendCode = jest + .fn() + .mockReturnValue(Promise.reject(new Error('BOOM'))); const promise = makeRequest({ method: 'POST', @@ -319,17 +349,23 @@ describe('/recovery_phone', () => { await expect(promise).rejects.toThrow( 'System unavailable, try again soon' ); - expect(mockRecoveryPhoneService.sendCode.callCount).toBe(1); - expect(mockRecoveryPhoneService.sendCode.getCall(0).args[0]).toBe(uid); + expect(mockRecoveryPhoneService.sendCode).toHaveBeenCalledTimes(1); + expect(mockRecoveryPhoneService.sendCode).toHaveBeenNthCalledWith( + 1, + uid, + expect.any(Function) + ); // artificial delay since the metrics and security event related calls // are not awaited await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockGlean.resetPassword.recoveryPhoneCodeSent.callCount).toBe(0); - expect(mockGlean.resetPassword.recoveryPhoneCodeSendError.callCount).toBe( - 0 - ); + expect( + mockGlean.resetPassword.recoveryPhoneCodeSent + ).toHaveBeenCalledTimes(0); + expect( + mockGlean.resetPassword.recoveryPhoneCodeSendError + ).toHaveBeenCalledTimes(0); }); it('requires a passwordForgotToken', () => { @@ -344,9 +380,12 @@ describe('/recovery_phone', () => { describe('POST /recovery_phone/create', () => { it('creates recovery phone number', async () => { - mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns(true); - mockRecoveryPhoneService.getNationalFormat = - sinon.fake.returns(nationalFormat); + mockRecoveryPhoneService.setupPhoneNumber = jest + .fn() + .mockReturnValue(true); + mockRecoveryPhoneService.getNationalFormat = jest + .fn() + .mockReturnValue(nationalFormat); const resp = await makeRequest({ method: 'POST', @@ -357,35 +396,43 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect(resp.status).toBe('success'); - expect(mockRecoveryPhoneService.setupPhoneNumber.callCount).toBe(1); - expect(mockRecoveryPhoneService.setupPhoneNumber.getCall(0).args[0]).toBe( - uid + expect(mockRecoveryPhoneService.setupPhoneNumber).toHaveBeenCalledTimes( + 1 ); - expect(mockRecoveryPhoneService.setupPhoneNumber.getCall(0).args[1]).toBe( - phoneNumber + expect(mockRecoveryPhoneService.setupPhoneNumber).toHaveBeenNthCalledWith( + 1, + uid, + phoneNumber, + expect.any(Function) + ); + expect(mockRecoveryPhoneService.getNationalFormat).toHaveBeenCalledTimes( + 1 ); - expect(mockRecoveryPhoneService.getNationalFormat.callCount).toBe(1); expect( - mockRecoveryPhoneService.getNationalFormat.getCall(0).args[0] - ).toBe(phoneNumber); - expect(mockGlean.twoStepAuthPhoneCode.sent.callCount).toBe(1); - expect(mockGlean.twoStepAuthPhoneCode.sendError.callCount).toBe(0); - - expect(mockCustoms.checkAuthenticated.callCount).toBe(1); - expect(mockCustoms.checkAuthenticated.getCall(0).args[1]).toBe(uid); - expect(mockCustoms.checkAuthenticated.getCall(0).args[2]).toBe(email); - expect(mockCustoms.checkAuthenticated.getCall(0).args[3]).toBe( + mockRecoveryPhoneService.getNationalFormat + ).toHaveBeenNthCalledWith(1, phoneNumber); + expect(mockGlean.twoStepAuthPhoneCode.sent).toHaveBeenCalledTimes(1); + expect(mockGlean.twoStepAuthPhoneCode.sendError).toHaveBeenCalledTimes(0); + + expect(mockCustoms.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(mockCustoms.checkAuthenticated).toHaveBeenNthCalledWith( + 1, + expect.anything(), + uid, + email, 'recoveryPhoneSendSetupCode' ); - sinon.assert.calledOnceWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.recoveryPhone.setupPhoneNumber.success', {} ); }); it('indicates failure sending sms', async () => { - mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns(false); + mockRecoveryPhoneService.setupPhoneNumber = jest + .fn() + .mockReturnValue(false); const resp = await makeRequest({ method: 'POST', @@ -396,14 +443,16 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect(resp.status).toBe('failure'); - expect(mockGlean.twoStepAuthPhoneCode.sent.callCount).toBe(0); - expect(mockGlean.twoStepAuthPhoneCode.sendError.callCount).toBe(1); + expect(mockGlean.twoStepAuthPhoneCode.sent).toHaveBeenCalledTimes(0); + expect(mockGlean.twoStepAuthPhoneCode.sendError).toHaveBeenCalledTimes(1); }); it('rejects an unsupported dialing code', async () => { - mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns( - Promise.reject(new RecoveryNumberNotSupportedError('+495550005555')) - ); + mockRecoveryPhoneService.setupPhoneNumber = jest + .fn() + .mockReturnValue( + Promise.reject(new RecoveryNumberNotSupportedError('+495550005555')) + ); const promise = makeRequest({ method: 'POST', @@ -413,16 +462,18 @@ describe('/recovery_phone', () => { }); await expect(promise).rejects.toThrow('Invalid phone number'); - expect(mockGlean.twoStepAuthPhoneCode.sent.callCount).toBe(0); - expect(mockGlean.twoStepAuthPhoneCode.sendError.callCount).toBe(1); + expect(mockGlean.twoStepAuthPhoneCode.sent).toHaveBeenCalledTimes(0); + expect(mockGlean.twoStepAuthPhoneCode.sendError).toHaveBeenCalledTimes(1); }); it('indicates too many requests when sms rate limit is exceeded', async () => { - mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns( - Promise.reject( - new SmsSendRateLimitExceededError(uid, phoneNumber, '+495550005555') - ) - ); + mockRecoveryPhoneService.setupPhoneNumber = jest + .fn() + .mockReturnValue( + Promise.reject( + new SmsSendRateLimitExceededError(uid, phoneNumber, '+495550005555') + ) + ); const promise = makeRequest({ method: 'POST', @@ -432,16 +483,18 @@ describe('/recovery_phone', () => { }); await expect(promise).rejects.toThrow('Text message limit reached'); - expect(mockGlean.twoStepAuthPhoneCode.sent.callCount).toBe(0); - expect(mockGlean.twoStepAuthPhoneCode.sendError.callCount).toBe(1); + expect(mockGlean.twoStepAuthPhoneCode.sent).toHaveBeenCalledTimes(0); + expect(mockGlean.twoStepAuthPhoneCode.sendError).toHaveBeenCalledTimes(1); }); it('rejects a phone number that has been set up for too many accounts', async () => { - mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns( - Promise.reject( - new RecoveryPhoneRegistrationLimitReached('+495550005555') - ) - ); + mockRecoveryPhoneService.setupPhoneNumber = jest + .fn() + .mockReturnValue( + Promise.reject( + new RecoveryPhoneRegistrationLimitReached('+495550005555') + ) + ); const promise = makeRequest({ method: 'POST', @@ -453,14 +506,14 @@ describe('/recovery_phone', () => { await expect(promise).rejects.toThrow( 'Limit reached for number off accounts that can be associated with phone number.' ); - expect(mockGlean.twoStepAuthPhoneCode.sent.callCount).toBe(0); - expect(mockGlean.twoStepAuthPhoneCode.sendError.callCount).toBe(1); + expect(mockGlean.twoStepAuthPhoneCode.sent).toHaveBeenCalledTimes(0); + expect(mockGlean.twoStepAuthPhoneCode.sendError).toHaveBeenCalledTimes(1); }); it('handles unexpected backend error', async () => { - mockRecoveryPhoneService.setupPhoneNumber = sinon.fake.returns( - Promise.reject(new Error('BOOM')) - ); + mockRecoveryPhoneService.setupPhoneNumber = jest + .fn() + .mockReturnValue(Promise.reject(new Error('BOOM'))); const promise = makeRequest({ method: 'POST', @@ -472,8 +525,8 @@ describe('/recovery_phone', () => { await expect(promise).rejects.toThrow( 'System unavailable, try again soon' ); - expect(mockGlean.twoStepAuthPhoneCode.sent.callCount).toBe(0); - expect(mockGlean.twoStepAuthPhoneCode.sendError.callCount).toBe(1); + expect(mockGlean.twoStepAuthPhoneCode.sent).toHaveBeenCalledTimes(0); + expect(mockGlean.twoStepAuthPhoneCode.sendError).toHaveBeenCalledTimes(1); }); it('validates incoming phone number', () => { @@ -499,16 +552,20 @@ describe('/recovery_phone', () => { describe('POST /recovery_phone/confirm', () => { it('confirms a code with TOTP enabled – sends post-add email', async () => { - mockRecoveryPhoneService.confirmSetupCode = sinon.fake.returns(true); - mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({ + mockRecoveryPhoneService.confirmSetupCode = jest + .fn() + .mockReturnValue(true); + mockRecoveryPhoneService.hasConfirmed = jest.fn().mockReturnValue({ exists: true, phoneNumber, nationalFormat, }); - mockRecoveryPhoneService.stripPhoneNumber = sinon.fake.returns('5555'); + mockRecoveryPhoneService.stripPhoneNumber = jest + .fn() + .mockReturnValue('5555'); // Simulate account having TOTP set up and verified - sinon.stub(otpUtils, 'hasTotpToken').resolves(true); + jest.spyOn(otpUtils, 'hasTotpToken').mockResolvedValue(true); const resp = await makeRequest({ method: 'POST', @@ -522,17 +579,22 @@ describe('/recovery_phone', () => { // Gives back the full national format as the user just successfully // confirmed the code expect(resp.nationalFormat).toBe(nationalFormat); - expect(mockRecoveryPhoneService.confirmSetupCode.callCount).toBe(1); - expect(mockRecoveryPhoneService.confirmSetupCode.getCall(0).args[0]).toBe( - uid + expect(mockRecoveryPhoneService.confirmSetupCode).toHaveBeenCalledTimes( + 1 ); - expect(mockRecoveryPhoneService.confirmSetupCode.getCall(0).args[1]).toBe( + expect(mockRecoveryPhoneService.confirmSetupCode).toHaveBeenNthCalledWith( + 1, + uid, code ); - expect(mockGlean.twoStepAuthPhoneCode.complete.callCount).toBe(1); - sinon.assert.calledOnce(mockFxaMailer.sendPostAddRecoveryPhoneEmail); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordSecurityEvent, + expect(mockGlean.twoStepAuthPhoneCode.complete).toHaveBeenCalledTimes(1); + expect(mockFxaMailer.sendPostAddRecoveryPhoneEmail).toHaveBeenCalledTimes( + 1 + ); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(1); + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( mockDb, { name: 'account.recovery_phone_setup_complete', @@ -552,24 +614,28 @@ describe('/recovery_phone', () => { } ); - sinon.assert.calledOnceWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.recoveryPhone.phoneAdded.success', {} ); }); it('confirms a code without TOTP – does not send post-add email', async () => { - mockRecoveryPhoneService.confirmSetupCode = sinon.fake.returns(true); - mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({ + mockRecoveryPhoneService.confirmSetupCode = jest + .fn() + .mockReturnValue(true); + mockRecoveryPhoneService.hasConfirmed = jest.fn().mockReturnValue({ exists: true, phoneNumber, nationalFormat, }); - mockRecoveryPhoneService.stripPhoneNumber = sinon.fake.returns('5555'); + mockRecoveryPhoneService.stripPhoneNumber = jest + .fn() + .mockReturnValue('5555'); // Simulate account without TOTP configured - sinon.stub(otpUtils, 'hasTotpToken').resolves(false); + jest.spyOn(otpUtils, 'hasTotpToken').mockResolvedValue(false); const resp = await makeRequest({ method: 'POST', @@ -581,21 +647,26 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect(resp.status).toBe('success'); expect(resp.nationalFormat).toBe(nationalFormat); - expect(mockRecoveryPhoneService.confirmSetupCode.callCount).toBe(1); - expect(mockRecoveryPhoneService.confirmSetupCode.getCall(0).args[0]).toBe( - uid + expect(mockRecoveryPhoneService.confirmSetupCode).toHaveBeenCalledTimes( + 1 ); - expect(mockRecoveryPhoneService.confirmSetupCode.getCall(0).args[1]).toBe( + expect(mockRecoveryPhoneService.confirmSetupCode).toHaveBeenNthCalledWith( + 1, + uid, code ); - expect(mockGlean.twoStepAuthPhoneCode.complete.callCount).toBe(1); - sinon.assert.notCalled(mockMailer.sendPostAddRecoveryPhoneEmail); - sinon.assert.notCalled(mockFxaMailer.sendPostAddRecoveryPhoneEmail); + expect(mockGlean.twoStepAuthPhoneCode.complete).toHaveBeenCalledTimes(1); + expect(mockMailer.sendPostAddRecoveryPhoneEmail).not.toHaveBeenCalled(); + expect( + mockFxaMailer.sendPostAddRecoveryPhoneEmail + ).not.toHaveBeenCalled(); }); it('indicates a failure confirming code', async () => { - mockRecoveryPhoneService.confirmSetupCode = sinon.fake.returns(false); - mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({ + mockRecoveryPhoneService.confirmSetupCode = jest + .fn() + .mockReturnValue(false); + mockRecoveryPhoneService.hasConfirmed = jest.fn().mockReturnValue({ exists: false, }); @@ -609,15 +680,17 @@ describe('/recovery_phone', () => { await expect(promise).rejects.toThrow( 'Invalid or expired confirmation code' ); - expect(mockGlean.twoStepAuthPhoneCode.complete.callCount).toBe(0); - sinon.assert.notCalled(mockFxaMailer.sendPostAddRecoveryPhoneEmail); + expect(mockGlean.twoStepAuthPhoneCode.complete).toHaveBeenCalledTimes(0); + expect( + mockFxaMailer.sendPostAddRecoveryPhoneEmail + ).not.toHaveBeenCalled(); }); it('indicates an issue with the backend service', async () => { - mockRecoveryPhoneService.confirmSetupCode = sinon.fake.returns( - Promise.reject(new Error('BOOM')) - ); - mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({ + mockRecoveryPhoneService.confirmSetupCode = jest + .fn() + .mockReturnValue(Promise.reject(new Error('BOOM'))); + mockRecoveryPhoneService.hasConfirmed = jest.fn().mockReturnValue({ exists: false, }); const promise = makeRequest({ @@ -631,14 +704,16 @@ describe('/recovery_phone', () => { 'System unavailable, try again soon' ); - expect(mockGlean.twoStepAuthPhoneCode.complete.callCount).toBe(0); - sinon.assert.notCalled(mockFxaMailer.sendPostAddRecoveryPhoneEmail); + expect(mockGlean.twoStepAuthPhoneCode.complete).toHaveBeenCalledTimes(0); + expect( + mockFxaMailer.sendPostAddRecoveryPhoneEmail + ).not.toHaveBeenCalled(); }); }); describe('POST /recovery_phone/signin/confirm', () => { it('confirms a code during signin', async () => { - mockRecoveryPhoneService.confirmCode = sinon.fake.returns(true); + mockRecoveryPhoneService.confirmCode = jest.fn().mockReturnValue(true); const resp = await makeRequest({ method: 'POST', @@ -649,16 +724,21 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect(resp.status).toBe('success'); - expect(mockRecoveryPhoneService.confirmCode.callCount).toBe(1); - expect(mockRecoveryPhoneService.confirmCode.getCall(0).args[0]).toBe(uid); - expect(mockRecoveryPhoneService.confirmCode.getCall(0).args[1]).toBe( + expect(mockRecoveryPhoneService.confirmCode).toHaveBeenCalledTimes(1); + expect(mockRecoveryPhoneService.confirmCode).toHaveBeenNthCalledWith( + 1, + uid, code ); - expect(mockAccountManager.verifySession.callCount).toBe(1); - expect(mockGlean.login.recoveryPhoneSuccess.callCount).toBe(1); - sinon.assert.calledOnce(mockFxaMailer.sendPostSigninRecoveryPhoneEmail); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordSecurityEvent, + expect(mockAccountManager.verifySession).toHaveBeenCalledTimes(1); + expect(mockGlean.login.recoveryPhoneSuccess).toHaveBeenCalledTimes(1); + expect( + mockFxaMailer.sendPostSigninRecoveryPhoneEmail + ).toHaveBeenCalledTimes(1); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(1); + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( mockDb, { name: 'account.recovery_phone_signin_complete', @@ -677,20 +757,20 @@ describe('/recovery_phone', () => { }, } ); - sinon.assert.calledOnceWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.recoveryPhone.phoneSignin.success', {} ); - sinon.assert.calledOnceWithExactly( - request.emitMetricsEvent, + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(1); + expect(request.emitMetricsEvent).toHaveBeenCalledWith( 'account.confirmed', { uid } ); }); it('fails confirms a code during signin', async () => { - mockRecoveryPhoneService.confirmCode = sinon.fake.returns(false); + mockRecoveryPhoneService.confirmCode = jest.fn().mockReturnValue(false); try { await makeRequest({ @@ -702,33 +782,34 @@ describe('/recovery_phone', () => { } catch (err: any) { expect(err).toBeDefined(); expect(err.errno).toBe(183); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordSecurityEvent, - mockDb, - { - name: 'account.recovery_phone_signin_failed', - uid, - ipAddr: '63.245.221.32', - tokenId: undefined, - additionalInfo: { - userAgent: 'test user-agent', - location: { - city: 'Mountain View', - country: 'United States', - countryCode: 'US', - state: 'California', - stateCode: 'CA', - }, + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(1); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledWith(mockDb, { + name: 'account.recovery_phone_signin_failed', + uid, + ipAddr: '63.245.221.32', + tokenId: undefined, + additionalInfo: { + userAgent: 'test user-agent', + location: { + city: 'Mountain View', + country: 'United States', + countryCode: 'US', + state: 'California', + stateCode: 'CA', }, - } - ); + }, + }); } }); }); describe('POST /recovery_phone/reset_password/confirm', () => { it('successfully confirms the code', async () => { - mockRecoveryPhoneService.confirmCode = sinon.fake.returns(true); + mockRecoveryPhoneService.confirmCode = jest.fn().mockReturnValue(true); const resp = await makeRequest({ method: 'POST', @@ -743,16 +824,19 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect(resp.status).toBe('success'); - expect(mockRecoveryPhoneService.confirmCode.callCount).toBe(1); - expect(mockRecoveryPhoneService.confirmCode.getCall(0).args[0]).toBe(uid); - expect(mockRecoveryPhoneService.confirmCode.getCall(0).args[1]).toBe( + expect(mockRecoveryPhoneService.confirmCode).toHaveBeenCalledTimes(1); + expect(mockRecoveryPhoneService.confirmCode).toHaveBeenNthCalledWith( + 1, + uid, code ); - expect(mockGlean.resetPassword.recoveryPhoneCodeComplete.callCount).toBe( - 1 - ); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordSecurityEvent, + expect( + mockGlean.resetPassword.recoveryPhoneCodeComplete + ).toHaveBeenCalledTimes(1); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(1); + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( mockDb, { name: 'account.recovery_phone_reset_password_complete', @@ -771,18 +855,18 @@ describe('/recovery_phone', () => { }, } ); - sinon.assert.calledOnceWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.resetPassword.recoveryPhone.success', {} ); - sinon.assert.calledOnce( + expect( mockFxaMailer.sendPasswordResetRecoveryPhoneEmail - ); + ).toHaveBeenCalledTimes(1); }); it('fails confirms a code during signin', async () => { - mockRecoveryPhoneService.confirmCode = sinon.fake.returns(false); + mockRecoveryPhoneService.confirmCode = jest.fn().mockReturnValue(false); try { await makeRequest({ @@ -794,33 +878,36 @@ describe('/recovery_phone', () => { } catch (err: any) { expect(err).toBeDefined(); expect(err.errno).toBe(183); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordSecurityEvent, - mockDb, - { - name: 'account.recovery_phone_reset_password_failed', - uid, - ipAddr: '63.245.221.32', - tokenId: undefined, - additionalInfo: { - userAgent: 'test user-agent', - location: { - city: 'Mountain View', - country: 'United States', - countryCode: 'US', - state: 'California', - stateCode: 'CA', - }, + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(1); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledWith(mockDb, { + name: 'account.recovery_phone_reset_password_failed', + uid, + ipAddr: '63.245.221.32', + tokenId: undefined, + additionalInfo: { + userAgent: 'test user-agent', + location: { + city: 'Mountain View', + country: 'United States', + countryCode: 'US', + state: 'California', + stateCode: 'CA', }, - } - ); + }, + }); } }); }); describe('DELETE /recovery_phone', () => { it('removes a recovery phone', async () => { - mockRecoveryPhoneService.removePhoneNumber = sinon.fake.returns(true); + mockRecoveryPhoneService.removePhoneNumber = jest + .fn() + .mockReturnValue(true); const resp = await makeRequest({ method: 'DELETE', @@ -829,14 +916,20 @@ describe('/recovery_phone', () => { }); expect(resp).toBeDefined(); - expect(mockRecoveryPhoneService.removePhoneNumber.callCount).toBe(1); + expect(mockRecoveryPhoneService.removePhoneNumber).toHaveBeenCalledTimes( + 1 + ); expect( - mockRecoveryPhoneService.removePhoneNumber.getCall(0).args[0] - ).toBe(uid); - expect(mockGlean.twoStepAuthPhoneRemove.success.callCount).toBe(1); - sinon.assert.calledOnce(mockFxaMailer.sendPostRemoveRecoveryPhoneEmail); - sinon.assert.calledOnceWithExactly( - mockAccountEventsManager.recordSecurityEvent, + mockRecoveryPhoneService.removePhoneNumber + ).toHaveBeenNthCalledWith(1, uid); + expect(mockGlean.twoStepAuthPhoneRemove.success).toHaveBeenCalledTimes(1); + expect( + mockFxaMailer.sendPostRemoveRecoveryPhoneEmail + ).toHaveBeenCalledTimes(1); + expect( + mockAccountEventsManager.recordSecurityEvent + ).toHaveBeenCalledTimes(1); + expect(mockAccountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( mockDb, { name: 'account.recovery_phone_removed', @@ -855,17 +948,17 @@ describe('/recovery_phone', () => { }, } ); - sinon.assert.calledOnceWithExactly( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledTimes(1); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'account.recoveryPhone.phoneRemoved.success', {} ); }); it('indicates service failure while removing phone', async () => { - mockRecoveryPhoneService.removePhoneNumber = sinon.fake.returns( - Promise.reject(new Error('BOOM')) - ); + mockRecoveryPhoneService.removePhoneNumber = jest + .fn() + .mockReturnValue(Promise.reject(new Error('BOOM'))); const promise = makeRequest({ method: 'DELETE', path: '/recovery_phone', @@ -875,24 +968,28 @@ describe('/recovery_phone', () => { await expect(promise).rejects.toThrow( 'System unavailable, try again soon' ); - expect(mockGlean.twoStepAuthPhoneRemove.success.callCount).toBe(0); - sinon.assert.notCalled(mockFxaMailer.sendPostRemoveRecoveryPhoneEmail); + expect(mockGlean.twoStepAuthPhoneRemove.success).toHaveBeenCalledTimes(0); + expect( + mockFxaMailer.sendPostRemoveRecoveryPhoneEmail + ).not.toHaveBeenCalled(); }); it('handles uid without registered phone number', async () => { - mockRecoveryPhoneService.removePhoneNumber = sinon.fake.returns(false); + mockRecoveryPhoneService.removePhoneNumber = jest + .fn() + .mockReturnValue(false); await makeRequest({ method: 'DELETE', path: '/recovery_phone', credentials: { uid, email }, }); - expect(mockGlean.twoStepAuthPhoneRemove.success.callCount).toBe(0); + expect(mockGlean.twoStepAuthPhoneRemove.success).toHaveBeenCalledTimes(0); }); }); describe('POST /recovery_phone/available', () => { it('should return true if user can setup phone number', async () => { - mockRecoveryPhoneService.available = sinon.fake.returns(true); + mockRecoveryPhoneService.available = jest.fn().mockReturnValue(true); const resp = await makeRequest({ method: 'POST', @@ -906,8 +1003,8 @@ describe('/recovery_phone', () => { }); expect(resp).toEqual({ available: true }); - sinon.assert.calledOnceWithExactly( - mockRecoveryPhoneService.available, + expect(mockRecoveryPhoneService.available).toHaveBeenCalledTimes(1); + expect(mockRecoveryPhoneService.available).toHaveBeenCalledWith( uid, 'US' ); @@ -916,7 +1013,7 @@ describe('/recovery_phone', () => { describe('GET /recovery_phone', () => { it('gets a recovery phone', async () => { - mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({ + mockRecoveryPhoneService.hasConfirmed = jest.fn().mockReturnValue({ exists: true, phoneNumber, }); @@ -928,16 +1025,18 @@ describe('/recovery_phone', () => { }); expect(resp).toBeDefined(); - expect(mockRecoveryPhoneService.hasConfirmed.callCount).toBe(1); - expect(mockRecoveryPhoneService.hasConfirmed.getCall(0).args[0]).toBe( - uid + expect(mockRecoveryPhoneService.hasConfirmed).toHaveBeenCalledTimes(1); + expect(mockRecoveryPhoneService.hasConfirmed).toHaveBeenNthCalledWith( + 1, + uid, + expect.anything() ); }); it('indicates error', async () => { - mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns( - Promise.reject(new Error('BOOM')) - ); + mockRecoveryPhoneService.hasConfirmed = jest + .fn() + .mockReturnValue(Promise.reject(new Error('BOOM'))); const promise = makeRequest({ method: 'GET', path: '/recovery_phone', @@ -947,11 +1046,11 @@ describe('/recovery_phone', () => { await expect(promise).rejects.toThrow( 'System unavailable, try again soon' ); - expect(mockGlean.twoStepAuthPhoneRemove.success.callCount).toBe(0); + expect(mockGlean.twoStepAuthPhoneRemove.success).toHaveBeenCalledTimes(0); }); it('returns masked phone number for unverified session', async () => { - mockRecoveryPhoneService.hasConfirmed = sinon.fake.returns({ + mockRecoveryPhoneService.hasConfirmed = jest.fn().mockReturnValue({ exists: true, phoneNumber, }); @@ -963,20 +1062,23 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect(resp.exists).toBeDefined(); expect(resp.phoneNumber).toBeDefined(); - expect(mockRecoveryPhoneService.hasConfirmed.callCount).toBe(1); - expect(mockRecoveryPhoneService.hasConfirmed.getCall(0).args[0]).toBe( - uid + expect(mockRecoveryPhoneService.hasConfirmed).toHaveBeenCalledTimes(1); + expect(mockRecoveryPhoneService.hasConfirmed).toHaveBeenNthCalledWith( + 1, + uid, + 4 ); - expect(mockRecoveryPhoneService.hasConfirmed.getCall(0).args[1]).toBe(4); }); }); describe('POST /recovery_phone/message_status', () => { it('handles a message status update from twilio using X-Twilio-Signature header', async () => { - mockRecoveryPhoneService.onMessageStatusUpdate = - sinon.fake.resolves(undefined); - mockRecoveryPhoneService.validateTwilioWebhookCallback = - sinon.fake.returns(true); + mockRecoveryPhoneService.onMessageStatusUpdate = jest + .fn() + .mockResolvedValue(undefined); + mockRecoveryPhoneService.validateTwilioWebhookCallback = jest + .fn() + .mockReturnValue(true); const payload = { AccountSid: 'AC123', @@ -998,29 +1100,32 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect( - mockRecoveryPhoneService.validateTwilioWebhookCallback.callCount - ).toBe(1); + mockRecoveryPhoneService.validateTwilioWebhookCallback + ).toHaveBeenCalledTimes(1); expect( - mockRecoveryPhoneService.validateTwilioWebhookCallback.getCall(0) - .args[0] - ).toEqual({ + mockRecoveryPhoneService.validateTwilioWebhookCallback + ).toHaveBeenNthCalledWith(1, { twilio: { signature: 'VALID_SIGNATURE', params: payload, }, }); - expect(mockRecoveryPhoneService.onMessageStatusUpdate.callCount).toBe(1); expect( - mockRecoveryPhoneService.onMessageStatusUpdate.getCall(0).args[0] - ).toBe(payload); + mockRecoveryPhoneService.onMessageStatusUpdate + ).toHaveBeenCalledTimes(1); + expect( + mockRecoveryPhoneService.onMessageStatusUpdate + ).toHaveBeenNthCalledWith(1, payload); }); it('handles a message status update from twilio using fxaSignature query param', async () => { - mockRecoveryPhoneService.onMessageStatusUpdate = - sinon.fake.resolves(undefined); - mockRecoveryPhoneService.validateTwilioWebhookCallback = - sinon.fake.returns(true); + mockRecoveryPhoneService.onMessageStatusUpdate = jest + .fn() + .mockResolvedValue(undefined); + mockRecoveryPhoneService.validateTwilioWebhookCallback = jest + .fn() + .mockReturnValue(true); const payload = { AccountSid: 'AC123', @@ -1047,27 +1152,29 @@ describe('/recovery_phone', () => { expect(resp).toBeDefined(); expect( - mockRecoveryPhoneService.validateTwilioWebhookCallback.callCount - ).toBe(1); + mockRecoveryPhoneService.validateTwilioWebhookCallback + ).toHaveBeenCalledTimes(1); expect( - mockRecoveryPhoneService.validateTwilioWebhookCallback.getCall(0) - .args[0] - ).toEqual({ + mockRecoveryPhoneService.validateTwilioWebhookCallback + ).toHaveBeenNthCalledWith(1, { fxa: { signature: 'VALID_SIGNATURE', message: 'FXA_MESSAGE', }, }); - expect(mockRecoveryPhoneService.onMessageStatusUpdate.callCount).toBe(1); expect( - mockRecoveryPhoneService.onMessageStatusUpdate.getCall(0).args[0] - ).toBe(payload); + mockRecoveryPhoneService.onMessageStatusUpdate + ).toHaveBeenCalledTimes(1); + expect( + mockRecoveryPhoneService.onMessageStatusUpdate + ).toHaveBeenNthCalledWith(1, payload); }); it('throws on invalid / missing signatures', async () => { - mockRecoveryPhoneService.validateTwilioWebhookCallback = - sinon.fake.rejects(AppError.unauthorized('Signature Invalid')); + mockRecoveryPhoneService.validateTwilioWebhookCallback = jest + .fn() + .mockRejectedValue(AppError.unauthorized('Signature Invalid')); await expect( makeRequest({ method: 'POST', diff --git a/packages/fxa-auth-server/lib/routes/session.spec.ts b/packages/fxa-auth-server/lib/routes/session.spec.ts index 78b7d3fa63d..697d8766b73 100644 --- a/packages/fxa-auth-server/lib/routes/session.spec.ts +++ b/packages/fxa-auth-server/lib/routes/session.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const crypto = require('crypto'); const { getRoute } = require('../../test/routes_helpers'); const knownIpLocation = require('../../test/known-ip-location'); @@ -144,7 +142,7 @@ describe('/session/status', () => { let log: any, db: any, config: any, routes: any, route: any; beforeEach(() => { - sinon.reset(); + jest.clearAllMocks(); log = mocks.mockLog(); mocks.mockFxaMailer(); mocks.mockOAuthClientInfo(); @@ -158,7 +156,7 @@ describe('/session/status', () => { }); afterAll(() => { - sinon.reset(); + jest.clearAllMocks(); }); it('returns unknown account error', async () => { @@ -506,73 +504,74 @@ describe('/session/reauth', () => { }); it('emits the correct series of calls', () => { - signinUtils.checkEmailAddress = sinon.spy(() => Promise.resolve(true)); - signinUtils.checkPassword = sinon.spy(() => Promise.resolve(true)); - signinUtils.checkCustomsAndLoadAccount = sinon.spy(async () => { + signinUtils.checkEmailAddress = jest.fn(() => Promise.resolve(true)); + signinUtils.checkPassword = jest.fn(() => Promise.resolve(true)); + signinUtils.checkCustomsAndLoadAccount = jest.fn(async () => { const accountRecord = await db.accountRecord(TEST_EMAIL); return { accountRecord }; }); - signinUtils.sendSigninNotifications = sinon.spy(() => Promise.resolve()); - signinUtils.createKeyFetchToken = sinon.spy(() => + signinUtils.sendSigninNotifications = jest.fn(() => Promise.resolve()); + signinUtils.createKeyFetchToken = jest.fn(() => Promise.resolve({ data: 'KEYFETCHTOKEN' }) ); - signinUtils.getSessionVerificationStatus = sinon.spy(() => ({ + signinUtils.getSessionVerificationStatus = jest.fn(() => ({ sessionVerified: true, verified: true, })); const testNow = Math.floor(Date.now() / 1000); return runTest(route, request).then((res: any) => { - expect(signinUtils.checkCustomsAndLoadAccount.callCount).toBe(1); - let args = signinUtils.checkCustomsAndLoadAccount.args[0]; - expect(args.length).toBe(3); - expect(args[0]).toBe(request); - expect(args[1]).toBe(TEST_EMAIL); - - expect(db.accountRecord.callCount).toBe(2); - args = db.accountRecord.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toBe(TEST_EMAIL); - - expect(signinUtils.checkEmailAddress.callCount).toBe(1); - args = signinUtils.checkEmailAddress.args[0]; - expect(args.length).toBe(3); - expect(args[0].uid).toBe(TEST_UID); - expect(args[1]).toBe(TEST_EMAIL); - expect(args[2]).toBeUndefined(); - - expect(signinUtils.checkPassword.callCount).toBe(1); - args = signinUtils.checkPassword.args[0]; - expect(args.length).toBe(3); - expect(args[0].uid).toBe(TEST_UID); - expect(args[1].authPW.toString('hex')).toBe(TEST_AUTHPW); - expect(args[2].app.clientAddress).toBe(knownIpLocation.ip); - - expect(db.updateSessionToken.callCount).toBe(1); - args = db.updateSessionToken.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toBe(request.auth.credentials); - - expect(signinUtils.sendSigninNotifications.callCount).toBe(1); - args = signinUtils.sendSigninNotifications.args[0]; - expect(args.length).toBe(4); - expect(args[0]).toBe(request); - expect(args[1].uid).toBe(TEST_UID); - expect(args[2]).toBe(request.auth.credentials); - expect(args[3]).toBeUndefined(); - - expect(signinUtils.createKeyFetchToken.callCount).toBe(1); - args = signinUtils.createKeyFetchToken.args[0]; - expect(args.length).toBe(4); - expect(args[0]).toBe(request); - expect(args[1].uid).toBe(TEST_UID); - expect(args[2].authPW.toString('hex')).toBe(TEST_AUTHPW); - expect(args[3]).toBe(request.auth.credentials); - - expect(signinUtils.getSessionVerificationStatus.callCount).toBe(1); - args = signinUtils.getSessionVerificationStatus.args[0]; - expect(args.length).toBe(2); - expect(args[0]).toBe(request.auth.credentials); - expect(args[1]).toBeUndefined(); + expect(signinUtils.checkCustomsAndLoadAccount).toHaveBeenCalledTimes(1); + const checkCustomsArgs = + signinUtils.checkCustomsAndLoadAccount.mock.calls[0]; + expect(checkCustomsArgs.length).toBe(3); + expect(checkCustomsArgs[0]).toBe(request); + expect(checkCustomsArgs[1]).toBe(TEST_EMAIL); + + expect(db.accountRecord).toHaveBeenCalledTimes(2); + expect(db.accountRecord).toHaveBeenNthCalledWith(1, TEST_EMAIL); + + expect(signinUtils.checkEmailAddress).toHaveBeenCalledTimes(1); + const checkEmailArgs = signinUtils.checkEmailAddress.mock.calls[0]; + expect(checkEmailArgs.length).toBe(3); + expect(checkEmailArgs[0].uid).toBe(TEST_UID); + expect(checkEmailArgs[1]).toBe(TEST_EMAIL); + expect(checkEmailArgs[2]).toBeUndefined(); + + expect(signinUtils.checkPassword).toHaveBeenCalledTimes(1); + const checkPwArgs = signinUtils.checkPassword.mock.calls[0]; + expect(checkPwArgs.length).toBe(3); + expect(checkPwArgs[0].uid).toBe(TEST_UID); + expect(checkPwArgs[1].authPW.toString('hex')).toBe(TEST_AUTHPW); + expect(checkPwArgs[2].app.clientAddress).toBe(knownIpLocation.ip); + + expect(db.updateSessionToken).toHaveBeenCalledTimes(1); + expect(db.updateSessionToken).toHaveBeenNthCalledWith( + 1, + request.auth.credentials + ); + + expect(signinUtils.sendSigninNotifications).toHaveBeenCalledTimes(1); + const sendNotifArgs = signinUtils.sendSigninNotifications.mock.calls[0]; + expect(sendNotifArgs.length).toBe(4); + expect(sendNotifArgs[0]).toBe(request); + expect(sendNotifArgs[1].uid).toBe(TEST_UID); + expect(sendNotifArgs[2]).toBe(request.auth.credentials); + expect(sendNotifArgs[3]).toBeUndefined(); + + expect(signinUtils.createKeyFetchToken).toHaveBeenCalledTimes(1); + const createKFTArgs = signinUtils.createKeyFetchToken.mock.calls[0]; + expect(createKFTArgs.length).toBe(4); + expect(createKFTArgs[0]).toBe(request); + expect(createKFTArgs[1].uid).toBe(TEST_UID); + expect(createKFTArgs[2].authPW.toString('hex')).toBe(TEST_AUTHPW); + expect(createKFTArgs[3]).toBe(request.auth.credentials); + + expect(signinUtils.getSessionVerificationStatus).toHaveBeenCalledTimes(1); + expect(signinUtils.getSessionVerificationStatus).toHaveBeenNthCalledWith( + 1, + request.auth.credentials, + undefined + ); expect(Object.keys(res).length).toBe(7); expect(res.uid).toBe(TEST_UID); @@ -607,14 +606,14 @@ describe('/session/reauth', () => { throw new Error('request should have been rejected'); }, (err: any) => { - expect(db.accountRecord.callCount).toBe(1); + expect(db.accountRecord).toHaveBeenCalledTimes(1); expect(err.errno).toBe(error.ERRNO.ACCOUNT_UNKNOWN); } ); }); it('correctly updates sessionToken details', () => { - signinUtils.checkPassword = sinon.spy(() => { + signinUtils.checkPassword = jest.fn(() => { return Promise.resolve(true); }); const testNow = Date.now(); @@ -630,8 +629,8 @@ describe('/session/reauth', () => { expect(!request.auth.credentials.uaFormFactor).toBeTruthy(); return runTest(route, request).then((res: any) => { - expect(db.updateSessionToken.callCount).toBe(1); - const sessionToken = db.updateSessionToken.args[0][0]; + expect(db.updateSessionToken).toHaveBeenCalledTimes(1); + const sessionToken = db.updateSessionToken.mock.calls[0][0]; expect(sessionToken.authAt).toBeGreaterThanOrEqual(testNow); expect(sessionToken.lastAuthAt()).toBeGreaterThanOrEqual(testNowSeconds); expect(sessionToken.uaBrowser).toBe('Firefox'); @@ -644,21 +643,21 @@ describe('/session/reauth', () => { }); it('correctly updates to mustVerify=true when requesting keys', () => { - signinUtils.checkPassword = sinon.spy(() => { + signinUtils.checkPassword = jest.fn(() => { return Promise.resolve(true); }); expect(!request.auth.credentials.mustVerify).toBeTruthy(); return runTest(route, request).then((res: any) => { - expect(db.updateSessionToken.callCount).toBe(1); - const sessionToken = db.updateSessionToken.args[0][0]; + expect(db.updateSessionToken).toHaveBeenCalledTimes(1); + const sessionToken = db.updateSessionToken.mock.calls[0][0]; expect(sessionToken.mustVerify).toBeTruthy(); }); }); it('correctly updates to mustVerify=true when explicit verificationMethod is requested in payload', () => { - signinUtils.checkPassword = sinon.spy(() => { + signinUtils.checkPassword = jest.fn(() => { return Promise.resolve(true); }); @@ -666,14 +665,14 @@ describe('/session/reauth', () => { request.payload.verificationMethod = 'email-2fa'; return runTest(route, request).then((res: any) => { - expect(db.updateSessionToken.callCount).toBe(1); - const sessionToken = db.updateSessionToken.args[0][0]; + expect(db.updateSessionToken).toHaveBeenCalledTimes(1); + const sessionToken = db.updateSessionToken.mock.calls[0][0]; expect(sessionToken.mustVerify).toBeTruthy(); }); }); it('leaves mustVerify=false when not requesting keys', () => { - signinUtils.checkPassword = sinon.spy(() => { + signinUtils.checkPassword = jest.fn(() => { return Promise.resolve(true); }); request.query.keys = false; @@ -681,29 +680,29 @@ describe('/session/reauth', () => { expect(!request.auth.credentials.mustVerify).toBeTruthy(); return runTest(route, request).then((res: any) => { - expect(db.updateSessionToken.callCount).toBe(1); - const sessionToken = db.updateSessionToken.args[0][0]; + expect(db.updateSessionToken).toHaveBeenCalledTimes(1); + const sessionToken = db.updateSessionToken.mock.calls[0][0]; expect(!sessionToken.mustVerify).toBeTruthy(); }); }); it('does not return a keyFetchToken when not requesting keys', () => { - signinUtils.checkPassword = sinon.spy(() => { + signinUtils.checkPassword = jest.fn(() => { return Promise.resolve(true); }); - signinUtils.createKeyFetchToken = sinon.spy(() => { + signinUtils.createKeyFetchToken = jest.fn(() => { throw new Error('should not be called'); }); request.query.keys = false; return runTest(route, request).then((res: any) => { - expect(signinUtils.createKeyFetchToken.callCount).toBe(0); + expect(signinUtils.createKeyFetchToken).toHaveBeenCalledTimes(0); expect(!res.keyFetchToken).toBeTruthy(); }); }); it('correctly rejects incorrect passwords', () => { - signinUtils.checkPassword = sinon.spy(() => { + signinUtils.checkPassword = jest.fn(() => { return Promise.resolve(false); }); @@ -712,7 +711,7 @@ describe('/session/reauth', () => { throw new Error('request should have been rejected'); }, (err: any) => { - expect(signinUtils.checkPassword.callCount).toBe(1); + expect(signinUtils.checkPassword).toHaveBeenCalledTimes(1); expect(err.errno).toBe(error.ERRNO.INCORRECT_PASSWORD); } ); @@ -756,7 +755,7 @@ describe('/session/destroy', () => { db = mocks.mockDB(); log = mocks.mockLog(); const config = {}; - securityEventStub = sinon.stub(); + securityEventStub = jest.fn(); const routes = makeRoutes({ log, config, @@ -776,7 +775,8 @@ describe('/session/destroy', () => { it('responds correctly when session is destroyed', () => { return runTest(route, request).then((res: any) => { expect(Object.keys(res).length).toBe(0); - sinon.assert.calledOnceWithExactly(securityEventStub, db, { + expect(securityEventStub).toHaveBeenCalledTimes(1); + expect(securityEventStub).toHaveBeenCalledWith(db, { name: 'session.destroy', uid: 'foo', ipAddr: '63.245.221.32', @@ -796,7 +796,7 @@ describe('/session/destroy', () => { }); it('responds correctly when custom session is destroyed', () => { - db.sessionToken = sinon.spy(() => { + db.sessionToken = jest.fn(() => { return Promise.resolve({ uid: 'foo', }); @@ -818,7 +818,7 @@ describe('/session/destroy', () => { }); it('throws on invalid session token', () => { - db.sessionToken = sinon.spy(() => { + db.sessionToken = jest.fn(() => { return Promise.resolve({ uid: 'diff-user', }); @@ -906,8 +906,8 @@ describe('/session/duplicate', () => { expect(res.sessionVerified).toBe(true); expect(res.verified).toBe(true); - expect(db.createSessionToken.callCount).toBe(1); - const sessionTokenOptions = db.createSessionToken.args[0][0]; + expect(db.createSessionToken).toHaveBeenCalledTimes(1); + const sessionTokenOptions = db.createSessionToken.mock.calls[0][0]; expect(Object.keys(sessionTokenOptions).length).toBe(37); expect(sessionTokenOptions.uid).toBe('foo'); expect(sessionTokenOptions.createdAt).toBeTruthy(); @@ -941,8 +941,8 @@ describe('/session/duplicate', () => { expect(res.verificationMethod).toBe('email'); expect(res.verificationReason).toBe('login'); - expect(db.createSessionToken.callCount).toBe(1); - const sessionTokenOptions = db.createSessionToken.args[0][0]; + expect(db.createSessionToken).toHaveBeenCalledTimes(1); + const sessionTokenOptions = db.createSessionToken.mock.calls[0][0]; expect(Object.keys(sessionTokenOptions).length).toBe(37); expect(sessionTokenOptions.uid).toBe('foo'); expect(sessionTokenOptions.createdAt).toBeTruthy(); @@ -999,7 +999,7 @@ describe('/session/verify_code', () => { mocks.mockOAuthClientInfo(); push = mocks.mockPush(); customs = mocks.mockCustoms(); - customs.check = sinon.spy(() => Promise.resolve(true)); + customs.check = jest.fn(() => Promise.resolve(true)); cadReminders = mocks.mockCadReminders(); const statsd = mocks.mockStatsd(); const config = {}; @@ -1033,7 +1033,7 @@ describe('/session/verify_code', () => { log, uaBrowser: 'Firefox', }); - request.emitMetricsEvent = sinon.spy(() => Promise.resolve({})); + request.emitMetricsEvent = jest.fn(() => Promise.resolve({})); } beforeEach(() => { @@ -1041,57 +1041,54 @@ describe('/session/verify_code', () => { }); it('should verify the account and session with a valid code', async () => { - gleanMock.registration.accountVerified.reset(); - gleanMock.registration.complete.reset(); + gleanMock.registration.accountVerified.mockClear(); + gleanMock.registration.complete.mockClear(); const response = await runTest(route, request); expect(response).toEqual({}); - sinon.assert.calledOnce(customs.checkAuthenticated); - sinon.assert.calledWithExactly( - customs.checkAuthenticated, + expect(customs.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(customs.checkAuthenticated).toHaveBeenCalledWith( request, signupCodeAccount.uid, signupCodeAccount.email, 'verifySessionCode' ); - sinon.assert.calledOnce(db.account); - sinon.assert.calledWithExactly(db.account, signupCodeAccount.uid); - sinon.assert.calledOnce(db.verifyEmail); - sinon.assert.calledOnce(db.verifyTokensWithMethod); - sinon.assert.calledWithExactly( - db.verifyTokensWithMethod, + expect(db.account).toHaveBeenCalledTimes(1); + expect(db.account).toHaveBeenCalledWith(signupCodeAccount.uid); + expect(db.verifyEmail).toHaveBeenCalledTimes(1); + expect(db.verifyTokensWithMethod).toHaveBeenCalledTimes(1); + expect(db.verifyTokensWithMethod).toHaveBeenCalledWith( 'sessionTokenId', 'email-2fa' ); - sinon.assert.calledOnce(fxaMailer.sendPostVerifyEmail); - sinon.assert.calledOnce(gleanMock.registration.accountVerified); - sinon.assert.calledOnce(gleanMock.registration.complete); + expect(fxaMailer.sendPostVerifyEmail).toHaveBeenCalledTimes(1); + expect(gleanMock.registration.accountVerified).toHaveBeenCalledTimes(1); + expect(gleanMock.registration.complete).toHaveBeenCalledTimes(1); }); it('should skip verify account and but still verify session with a valid code', async () => { setup({ emailVerified: true }); const response = await runTest(route, request); expect(response).toEqual({}); - sinon.assert.calledOnce(db.account); - sinon.assert.calledWithExactly(db.account, signupCodeAccount.uid); - sinon.assert.notCalled(db.verifyEmail); - sinon.assert.calledOnce(db.verifyTokensWithMethod); - sinon.assert.calledWithExactly( - db.verifyTokensWithMethod, + expect(db.account).toHaveBeenCalledTimes(1); + expect(db.account).toHaveBeenCalledWith(signupCodeAccount.uid); + expect(db.verifyEmail).not.toHaveBeenCalled(); + expect(db.verifyTokensWithMethod).toHaveBeenCalledTimes(1); + expect(db.verifyTokensWithMethod).toHaveBeenCalledWith( 'sessionTokenId', 'email-2fa' ); - sinon.assert.calledOnce(push.notifyAccountUpdated); + expect(push.notifyAccountUpdated).toHaveBeenCalledTimes(1); - const args = request.emitMetricsEvent.args[1]; + const args = request.emitMetricsEvent.mock.calls[1]; expect(args[0]).toBe('account.confirmed'); expect(args[1].uid).toBe(signupCodeAccount.uid); - sinon.assert.calledOnce(gleanMock.login.verifyCodeConfirmed); - sinon.assert.calledOnce(fxaMailer.sendNewDeviceLoginEmail); + expect(gleanMock.login.verifyCodeConfirmed).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(1); }); it('should succeed even if push notification fails', async () => { setup({ emailVerified: true }); - push.notifyAccountUpdated = sinon.spy(() => + push.notifyAccountUpdated = jest.fn(() => Promise.reject(new Error('push timeout')) ); const routes = makeRoutes({ @@ -1108,7 +1105,7 @@ describe('/session/verify_code', () => { const response = await runTest(route, request); expect(response).toEqual({}); - sinon.assert.calledOnce(push.notifyAccountUpdated); + expect(push.notifyAccountUpdated).toHaveBeenCalledTimes(1); }); it('should fail for invalid code', async () => { @@ -1126,9 +1123,9 @@ describe('/session/verify_code', () => { scopes: ['https://identity.mozilla.com/apps/oldsync'], }; await runTest(route, request); - sinon.assert.calledOnce(db.verifyEmail); - sinon.assert.calledOnce(db.verifyTokensWithMethod); - sinon.assert.calledOnce(fxaMailer.sendPostVerifyEmail); + expect(db.verifyEmail).toHaveBeenCalledTimes(1); + expect(db.verifyTokensWithMethod).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendPostVerifyEmail).toHaveBeenCalledTimes(1); }); it('should verify the account and not send post verify email', async () => { @@ -1137,10 +1134,10 @@ describe('/session/verify_code', () => { scopes: [], }; await runTest(route, request); - sinon.assert.calledOnce(db.verifyEmail); - sinon.assert.calledOnce(db.verifyTokensWithMethod); - sinon.assert.notCalled(fxaMailer.sendPostVerifyEmail); - sinon.assert.notCalled(mailer.sendPostVerifyEmail); + expect(db.verifyEmail).toHaveBeenCalledTimes(1); + expect(db.verifyTokensWithMethod).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendPostVerifyEmail).not.toHaveBeenCalled(); + expect(mailer.sendPostVerifyEmail).not.toHaveBeenCalled(); }); }); @@ -1163,8 +1160,8 @@ describe('/session/resend_code', () => { oauthClientInfo = mocks.mockOAuthClientInfo(); push = mocks.mockPush(); customs = { - check: sinon.stub(), - checkAuthenticated: sinon.stub(), + check: jest.fn(), + checkAuthenticated: jest.fn(), }; const config = {}; const routes = makeRoutes({ log, config, db, mailer, push, customs }); @@ -1193,11 +1190,11 @@ describe('/session/resend_code', () => { it('should resend the verification code email with unverified account', async () => { const response = await runTest(route, request); expect(response).toEqual({}); - sinon.assert.calledOnce(db.account); - sinon.assert.calledOnce(fxaMailer.sendVerifyShortCodeEmail); + expect(db.account).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendVerifyShortCodeEmail).toHaveBeenCalledTimes(1); const expectedCode = getExpectedOtpCode({}, signupCodeAccount.emailCode); - const args = fxaMailer.sendVerifyShortCodeEmail.args[0][0]; + const args = fxaMailer.sendVerifyShortCodeEmail.mock.calls[0][0]; expect(args.acceptLanguage).toBe('en-US'); expect(args.code).toBe(expectedCode); expect(args.location.city).toBe('Mountain View'); @@ -1205,8 +1202,7 @@ describe('/session/resend_code', () => { expect(args.location.stateCode).toBe('CA'); expect(args.timeZone).toBe('America/Los_Angeles'); - sinon.assert.calledWithExactly( - customs.checkAuthenticated, + expect(customs.checkAuthenticated).toHaveBeenCalledWith( request, signupCodeAccount.uid, signupCodeAccount.email, @@ -1225,18 +1221,18 @@ describe('/session/resend_code', () => { }, }; - db.account = sinon.spy(() => verifiedAccount); + db.account = jest.fn(() => verifiedAccount); const response = await runTest(route, request); expect(response).toEqual({}); - sinon.assert.calledOnce(db.account); - sinon.assert.calledOnce(oauthClientInfo.fetch); - sinon.assert.calledOnce(fxaMailer.sendVerifyLoginCodeEmail); + expect(db.account).toHaveBeenCalledTimes(1); + expect(oauthClientInfo.fetch).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenCalledTimes(1); const expectedCode = getExpectedOtpCode( {}, verifiedAccount.primaryEmail.emailCode ); - const args = fxaMailer.sendVerifyLoginCodeEmail.args[0]; + const args = fxaMailer.sendVerifyLoginCodeEmail.mock.calls[0]; expect(args[0].code).toBe(expectedCode); }); }); @@ -1246,7 +1242,7 @@ describe('/session/verify/send_push', () => { beforeEach(() => { db = mocks.mockDB({ ...signupCodeAccount, devices: MOCK_DEVICES }); - db.totpToken = sinon.spy(() => Promise.resolve({ enabled: false })); + db.totpToken = jest.fn(() => Promise.resolve({ enabled: false })); log = mocks.mockLog(); mailer = mocks.mockMailer(); push = mocks.mockPush(); @@ -1270,11 +1266,11 @@ describe('/session/verify/send_push', () => { it('should send a push notification with verification code', async () => { const response = await runTest(route, request); expect(response).toEqual({}); - sinon.assert.calledOnce(db.devices); - sinon.assert.calledOnce(db.totpToken); - sinon.assert.calledOnce(db.account); + expect(db.devices).toHaveBeenCalledTimes(1); + expect(db.totpToken).toHaveBeenCalledTimes(1); + expect(db.account).toHaveBeenCalledTimes(1); - const args = push.notifyVerifyLoginRequest.args[0]; + const args = push.notifyVerifyLoginRequest.mock.calls[0]; expect(args[0]).toBe('foo'); expect(args[1]).toEqual([ { @@ -1300,13 +1296,13 @@ describe('/session/verify/send_push', () => { }); it('should not send a push notification if TOTP token is verified and enabled', async () => { - db.totpToken = sinon.spy(() => + db.totpToken = jest.fn(() => Promise.resolve({ verified: true, enabled: true }) ); const response = await runTest(route, request); expect(response).toEqual({}); - sinon.assert.calledOnce(db.totpToken); - sinon.assert.notCalled(push.notifyVerifyLoginRequest); + expect(db.totpToken).toHaveBeenCalledTimes(1); + expect(push.notifyVerifyLoginRequest).not.toHaveBeenCalled(); }); }); @@ -1321,13 +1317,14 @@ describe('/session/verify/verify_push', () => { beforeEach(() => { db = mocks.mockDB({ ...signupCodeAccount, devices: MOCK_DEVICES }); - db.deviceFromTokenVerificationId = sinon.spy(() => + db.deviceFromTokenVerificationId = jest.fn(() => Promise.resolve(MOCK_DEVICES[1]) ); log = mocks.mockLog(); mailer = mocks.mockMailer(); push = mocks.mockPush(); customs = mocks.mockCustoms(); + mocks.mockOAuthClientInfo(); const config = {}; const routes = makeRoutes({ log, config, db, mailer, push, customs }); route = getRoute(routes, '/session/verify/verify_push'); @@ -1352,24 +1349,31 @@ describe('/session/verify/verify_push', () => { const response = await runTest(route, request); expect(response).toEqual({}); - sinon.assert.calledOnceWithExactly( - customs.checkAuthenticated, + expect(customs.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(customs.checkAuthenticated).toHaveBeenCalledWith( request, 'foo', signupCodeAccount.email, 'verifySessionCode' ); - sinon.assert.calledOnceWithExactly(db.devices, 'foo'); - sinon.assert.calledOnceWithExactly( - db.deviceFromTokenVerificationId, + expect(db.devices).toHaveBeenCalledTimes(1); + expect(db.devices).toHaveBeenCalledWith('foo'); + expect(db.deviceFromTokenVerificationId).toHaveBeenCalledTimes(1); + expect(db.deviceFromTokenVerificationId).toHaveBeenCalledWith( 'foo', 'sometoken' ); - sinon.assert.calledOnceWithExactly(db.account, 'foo'); - sinon.assert.calledOnceWithMatch(db.verifyTokens, 'sometoken'); + expect(db.account).toHaveBeenCalledTimes(1); + expect(db.account).toHaveBeenNthCalledWith(1, 'foo'); + expect(db.verifyTokens).toHaveBeenCalledTimes(1); + expect(db.verifyTokens).toHaveBeenNthCalledWith( + 1, + 'sometoken', + expect.anything() + ); - sinon.assert.calledOnceWithExactly( - push.notifyAccountUpdated, + expect(push.notifyAccountUpdated).toHaveBeenCalledTimes(1); + expect(push.notifyAccountUpdated).toHaveBeenCalledWith( 'foo', MOCK_DEVICES, 'accountConfirm' @@ -1377,7 +1381,7 @@ describe('/session/verify/verify_push', () => { }); it('should return if session is already verified', async () => { - db.deviceFromTokenVerificationId = sinon.spy(() => + db.deviceFromTokenVerificationId = jest.fn(() => Promise.resolve(undefined) ); request = mocks.mockRequest({ @@ -1396,7 +1400,7 @@ describe('/session/verify/verify_push', () => { }); const response = await runTest(route, request); expect(response).toEqual({}); - sinon.assert.notCalled(db.verifyTokens); + expect(db.verifyTokens).not.toHaveBeenCalled(); }); it('should fail if invalid code', async () => { @@ -1419,17 +1423,15 @@ describe('/session/verify/verify_push', () => { message: 'Invalid or expired confirmation code', }); - sinon.assert.calledTwice(customs.checkAuthenticated); - sinon.assert.calledWith( - customs.checkAuthenticated, + expect(customs.checkAuthenticated).toHaveBeenCalledTimes(2); + expect(customs.checkAuthenticated).toHaveBeenCalledWith( request, 'foo', 'foo@example.org', 'verifySessionCode' ); - sinon.assert.calledWith( - customs.checkAuthenticated, + expect(customs.checkAuthenticated).toHaveBeenCalledWith( request, 'foo', 'foo@example.org', diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/apple.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/apple.spec.ts index d22b9e13f58..d37ca99a766 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/apple.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/apple.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; const mocks = require('../../../test/mocks'); @@ -46,14 +45,14 @@ describe('AppleIapHandler', () => { Container.set(IAPConfig, iapConfig); Container.set(AppleIAP, appleIap); mockCapabilityService = {}; - mockCapabilityService.iapUpdate = sinon.fake.resolves({}); + mockCapabilityService.iapUpdate = jest.fn().mockResolvedValue({}); Container.set(CapabilityService, mockCapabilityService); appleIapHandler = new AppleIapHandler(); }); afterEach(() => { Container.reset(); - sinon.restore(); + jest.restoreAllMocks(); }); describe('registerOriginalTransactionId', () => { @@ -65,27 +64,29 @@ describe('AppleIapHandler', () => { it('returns valid with new products', async () => { appleIap.purchaseManager = { - registerToUserAccount: sinon.fake.resolves({}), + registerToUserAccount: jest.fn().mockResolvedValue({}), }; - iapConfig.getBundleId = sinon.fake.resolves('testPackage'); + iapConfig.getBundleId = jest.fn().mockResolvedValue('testPackage'); const result = await appleIapHandler.registerOriginalTransactionId(request); - sinon.assert.calledOnce(appleIap.purchaseManager.registerToUserAccount); - sinon.assert.calledOnce(iapConfig.getBundleId); - sinon.assert.calledOnce(mockCapabilityService.iapUpdate); + expect( + appleIap.purchaseManager.registerToUserAccount + ).toHaveBeenCalledTimes(1); + expect(iapConfig.getBundleId).toHaveBeenCalledTimes(1); + expect(mockCapabilityService.iapUpdate).toHaveBeenCalledTimes(1); expect(result).toEqual({ transactionIdValid: true }); }); it('throws on invalid package', async () => { appleIap.purchaseManager = { - registerToUserAccount: sinon.fake.resolves({}), + registerToUserAccount: jest.fn().mockResolvedValue({}), }; - iapConfig.getBundleId = sinon.fake.resolves(undefined); + iapConfig.getBundleId = jest.fn().mockResolvedValue(undefined); try { await appleIapHandler.registerOriginalTransactionId(request); throw new Error('Expected failure'); } catch (err: any) { - sinon.assert.calledOnce(iapConfig.getBundleId); + expect(iapConfig.getBundleId).toHaveBeenCalledTimes(1); expect(err.errno).toBe(error.ERRNO.IAP_UNKNOWN_APPNAME); } }); @@ -95,18 +96,18 @@ describe('AppleIapHandler', () => { libraryError.name = PurchaseUpdateError.INVALID_ORIGINAL_TRANSACTION_ID; appleIap.purchaseManager = { - registerToUserAccount: sinon.fake.rejects(libraryError), + registerToUserAccount: jest.fn().mockRejectedValue(libraryError), }; - iapConfig.getBundleId = sinon.fake.resolves('testPackage'); + iapConfig.getBundleId = jest.fn().mockResolvedValue('testPackage'); try { await appleIapHandler.registerOriginalTransactionId(request); throw new Error('Expected failure'); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.IAP_INVALID_TOKEN); - sinon.assert.calledOnce( + expect( appleIap.purchaseManager.registerToUserAccount - ); - sinon.assert.calledOnce(iapConfig.getBundleId); + ).toHaveBeenCalledTimes(1); + expect(iapConfig.getBundleId).toHaveBeenCalledTimes(1); } }); @@ -115,35 +116,37 @@ describe('AppleIapHandler', () => { libraryError.name = PurchaseUpdateError.CONFLICT; appleIap.purchaseManager = { - registerToUserAccount: sinon.fake.rejects(libraryError), + registerToUserAccount: jest.fn().mockRejectedValue(libraryError), }; - iapConfig.getBundleId = sinon.fake.resolves('testPackage'); + iapConfig.getBundleId = jest.fn().mockResolvedValue('testPackage'); try { await appleIapHandler.registerOriginalTransactionId(request); throw new Error('Expected failure'); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.IAP_PURCHASE_ALREADY_REGISTERED); - sinon.assert.calledOnce( + expect( appleIap.purchaseManager.registerToUserAccount - ); - sinon.assert.calledOnce(iapConfig.getBundleId); + ).toHaveBeenCalledTimes(1); + expect(iapConfig.getBundleId).toHaveBeenCalledTimes(1); } }); it('throws on unknown errors', async () => { appleIap.purchaseManager = { - registerToUserAccount: sinon.fake.rejects(new Error('Unknown error')), + registerToUserAccount: jest + .fn() + .mockRejectedValue(new Error('Unknown error')), }; - iapConfig.getBundleId = sinon.fake.resolves('testPackage'); + iapConfig.getBundleId = jest.fn().mockResolvedValue('testPackage'); try { await appleIapHandler.registerOriginalTransactionId(request); throw new Error('Expected failure'); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.BACKEND_SERVICE_FAILURE); - sinon.assert.calledOnce( + expect( appleIap.purchaseManager.registerToUserAccount - ); - sinon.assert.calledOnce(iapConfig.getBundleId); + ).toHaveBeenCalledTimes(1); + expect(iapConfig.getBundleId).toHaveBeenCalledTimes(1); } }); }); @@ -170,30 +173,34 @@ describe('AppleIapHandler', () => { }, }; appleIap.purchaseManager = { - decodeNotificationPayload: sinon.fake.resolves({ + decodeNotificationPayload: jest.fn().mockResolvedValue({ bundleId: mockBundleId, originalTransactionId: mockOriginalTransactionId, decodedPayload: mockDecodedNotificationPayload, }), - getSubscriptionPurchase: sinon.fake.resolves(mockPurchase), - processNotification: sinon.fake.resolves({}), + getSubscriptionPurchase: jest.fn().mockResolvedValue(mockPurchase), + processNotification: jest.fn().mockResolvedValue({}), }; }); it('handles a notification that requires profile updating', async () => { const result = await appleIapHandler.processNotification(mockRequest); expect(result).toEqual({}); - sinon.assert.calledOnceWithExactly( - appleIap.purchaseManager.decodeNotificationPayload, - mockRequest.payload.signedPayload - ); - sinon.assert.calledOnce( + expect( + appleIap.purchaseManager.decodeNotificationPayload + ).toHaveBeenCalledTimes(1); + expect( + appleIap.purchaseManager.decodeNotificationPayload + ).toHaveBeenCalledWith(mockRequest.payload.signedPayload); + expect( appleIap.purchaseManager.getSubscriptionPurchase - ); - sinon.assert.calledOnce(appleIap.purchaseManager.processNotification); - sinon.assert.calledOnce(mockCapabilityService.iapUpdate); - sinon.assert.calledOnceWithExactly( - log.debug, + ).toHaveBeenCalledTimes(1); + expect( + appleIap.purchaseManager.processNotification + ).toHaveBeenCalledTimes(1); + expect(mockCapabilityService.iapUpdate).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalledWith( 'appleIap.processNotification.decodedPayload', { bundleId: mockBundleId, @@ -208,8 +215,8 @@ describe('AppleIapHandler', () => { delete mockDecodedNotificationPayload.subtype; const result = await appleIapHandler.processNotification(mockRequest); expect(result).toEqual({}); - sinon.assert.calledOnceWithExactly( - log.debug, + expect(log.debug).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalledWith( 'appleIap.processNotification.decodedPayload', { bundleId: mockBundleId, @@ -220,9 +227,9 @@ describe('AppleIapHandler', () => { }); it('throws an unauthorized error on certificate validation failure', async () => { - appleIap.purchaseManager.decodeNotificationPayload = sinon.fake.rejects( - new CertificateValidationError() - ); + appleIap.purchaseManager.decodeNotificationPayload = jest + .fn() + .mockRejectedValue(new CertificateValidationError()); try { await appleIapHandler.processNotification(mockRequest); throw new Error('Should have thrown.'); @@ -233,9 +240,9 @@ describe('AppleIapHandler', () => { }); it('rethrows any other type of error if decoding the notification fails', async () => { - appleIap.purchaseManager.decodeNotificationPayload = sinon.fake.rejects( - new Error('Yikes') - ); + appleIap.purchaseManager.decodeNotificationPayload = jest + .fn() + .mockRejectedValue(new Error('Yikes')); try { await appleIapHandler.processNotification(mockRequest); throw new Error('Should have thrown.'); @@ -245,19 +252,24 @@ describe('AppleIapHandler', () => { }); it('Still processes the notification if the purchase is not found in Firestore', async () => { - appleIap.purchaseManager.getSubscriptionPurchase = - sinon.fake.resolves(null); + appleIap.purchaseManager.getSubscriptionPurchase = jest + .fn() + .mockResolvedValue(null); const result = await appleIapHandler.processNotification(mockRequest); expect(result).toEqual({}); - sinon.assert.calledOnce(appleIap.purchaseManager.processNotification); + expect( + appleIap.purchaseManager.processNotification + ).toHaveBeenCalledTimes(1); }); it('Still processes the notification if there is no user id but does not broadcast', async () => { mockPurchase.userId = null; const result = await appleIapHandler.processNotification(mockRequest); expect(result).toEqual({}); - sinon.assert.calledOnce(appleIap.purchaseManager.processNotification); - sinon.assert.notCalled(mockCapabilityService.iapUpdate); + expect( + appleIap.purchaseManager.processNotification + ).toHaveBeenCalledTimes(1); + expect(mockCapabilityService.iapUpdate).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/google.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/google.spec.ts index f4b9711e3e4..ec05ca7d9c3 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/google.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/google.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; const uuid = require('uuid'); @@ -52,25 +51,27 @@ describe('GoogleIapHandler', () => { email: TEST_EMAIL, locale: ACCOUNT_LOCALE, }); - db.account = sinon.fake.resolves({ primaryEmail: { email: TEST_EMAIL } }); + db.account = jest + .fn() + .mockResolvedValue({ primaryEmail: { email: TEST_EMAIL } }); mockCapabilityService = {}; - mockCapabilityService.iapUpdate = sinon.fake.resolves({}); + mockCapabilityService.iapUpdate = jest.fn().mockResolvedValue({}); Container.set(CapabilityService, mockCapabilityService); googleIapHandler = new GoogleIapHandler(db); }); afterEach(() => { Container.reset(); - sinon.restore(); + jest.restoreAllMocks(); }); describe('plans', () => { it('returns the plans', async () => { - iapConfig.plans = sinon.fake.resolves({ test: 'plan' }); + iapConfig.plans = jest.fn().mockResolvedValue({ test: 'plan' }); const result = await googleIapHandler.plans({ params: { appName: 'test' }, }); - sinon.assert.calledOnce(iapConfig.plans); + expect(iapConfig.plans).toHaveBeenCalledTimes(1); expect(result).toEqual({ test: 'plan' }); }); }); @@ -84,26 +85,28 @@ describe('GoogleIapHandler', () => { it('returns valid with new products', async () => { playBilling.purchaseManager = { - registerToUserAccount: sinon.fake.resolves({}), + registerToUserAccount: jest.fn().mockResolvedValue({}), }; - iapConfig.packageName = sinon.fake.resolves('testPackage'); + iapConfig.packageName = jest.fn().mockResolvedValue('testPackage'); const result = await googleIapHandler.registerToken(request); - sinon.assert.calledOnce(playBilling.purchaseManager.registerToUserAccount); - sinon.assert.calledOnce(iapConfig.packageName); - sinon.assert.calledOnce(mockCapabilityService.iapUpdate); + expect( + playBilling.purchaseManager.registerToUserAccount + ).toHaveBeenCalledTimes(1); + expect(iapConfig.packageName).toHaveBeenCalledTimes(1); + expect(mockCapabilityService.iapUpdate).toHaveBeenCalledTimes(1); expect(result).toEqual({ tokenValid: true }); }); it('throws on invalid package', async () => { playBilling.purchaseManager = { - registerToUserAccount: sinon.fake.resolves({}), + registerToUserAccount: jest.fn().mockResolvedValue({}), }; - iapConfig.packageName = sinon.fake.resolves(undefined); + iapConfig.packageName = jest.fn().mockResolvedValue(undefined); try { await googleIapHandler.registerToken(request); throw new Error('Expected failure'); } catch (err: any) { - sinon.assert.calledOnce(iapConfig.packageName); + expect(iapConfig.packageName).toHaveBeenCalledTimes(1); expect(err.errno).toBe(error.ERRNO.IAP_UNKNOWN_APPNAME); } }); @@ -113,18 +116,18 @@ describe('GoogleIapHandler', () => { libraryError.name = PurchaseUpdateError.INVALID_TOKEN; playBilling.purchaseManager = { - registerToUserAccount: sinon.fake.rejects(libraryError), + registerToUserAccount: jest.fn().mockRejectedValue(libraryError), }; - iapConfig.packageName = sinon.fake.resolves('testPackage'); + iapConfig.packageName = jest.fn().mockResolvedValue('testPackage'); try { await googleIapHandler.registerToken(request); throw new Error('Expected failure'); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.IAP_INVALID_TOKEN); - sinon.assert.calledOnce( + expect( playBilling.purchaseManager.registerToUserAccount - ); - sinon.assert.calledOnce(iapConfig.packageName); + ).toHaveBeenCalledTimes(1); + expect(iapConfig.packageName).toHaveBeenCalledTimes(1); } }); @@ -133,35 +136,37 @@ describe('GoogleIapHandler', () => { libraryError.name = PurchaseUpdateError.CONFLICT; playBilling.purchaseManager = { - registerToUserAccount: sinon.fake.rejects(libraryError), + registerToUserAccount: jest.fn().mockRejectedValue(libraryError), }; - iapConfig.packageName = sinon.fake.resolves('testPackage'); + iapConfig.packageName = jest.fn().mockResolvedValue('testPackage'); try { await googleIapHandler.registerToken(request); throw new Error('Expected failure'); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.IAP_INTERNAL_OTHER); - sinon.assert.calledOnce( + expect( playBilling.purchaseManager.registerToUserAccount - ); - sinon.assert.calledOnce(iapConfig.packageName); + ).toHaveBeenCalledTimes(1); + expect(iapConfig.packageName).toHaveBeenCalledTimes(1); } }); it('throws on unknown errors', async () => { playBilling.purchaseManager = { - registerToUserAccount: sinon.fake.rejects(new Error('Unknown error')), + registerToUserAccount: jest + .fn() + .mockRejectedValue(new Error('Unknown error')), }; - iapConfig.packageName = sinon.fake.resolves('testPackage'); + iapConfig.packageName = jest.fn().mockResolvedValue('testPackage'); try { await googleIapHandler.registerToken(request); throw new Error('Expected failure'); } catch (err: any) { expect(err.errno).toBe(error.ERRNO.BACKEND_SERVICE_FAILURE); - sinon.assert.calledOnce( + expect( playBilling.purchaseManager.registerToUserAccount - ); - sinon.assert.calledOnce(iapConfig.packageName); + ).toHaveBeenCalledTimes(1); + expect(iapConfig.packageName).toHaveBeenCalledTimes(1); } }); }); diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.spec.ts index 92fa8d08360..b59ab914ee5 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.spec.ts @@ -2,12 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const { MozillaSubscriptionTypes } = require('fxa-shared/subscriptions/types'); const { ERRNO } = require('@fxa/accounts/errors'); const uuid = require('uuid'); -const sandbox = sinon.createSandbox(); const { getRoute } = require('../../../test/routes_helpers'); const { OAUTH_SCOPE_SUBSCRIPTIONS } = require('fxa-shared/oauth/constants'); const { @@ -114,7 +111,7 @@ const mockPlayStoreSubscriptionPurchase = { purchaseToken: 'testToken', sku: 'sku', verifiedAt: Date.now(), - isEntitlementActive: sinon.fake.returns(true), + isEntitlementActive: jest.fn().mockReturnValue(true), }; const mockAppendedPlayStoreSubscriptionPurchase = { @@ -141,7 +138,7 @@ const mockAppStoreSubscriptionPurchase = { productId: 'wow', bundleId: 'hmm', currency: 'usd', - isEntitlementActive: sinon.fake.returns(true), + isEntitlementActive: jest.fn().mockReturnValue(true), }; const mockAppendedAppStoreSubscriptionPurchase = { @@ -186,21 +183,21 @@ const mockConfig = { let stripeHelper: any; let capabilityService: any; const iapOfferingUtil: any = { - getIapPageContentByStoreId: sandbox.stub(), + getIapPageContentByStoreId: jest.fn(), }; let priceManager: any; let productConfigurationManager: any; async function runTest(routePath: any, routeDependencies: any = {}) { const playSubscriptions = { - getSubscriptions: sandbox - .stub() - .resolves([mockAppendedPlayStoreSubscriptionPurchase]), + getSubscriptions: jest + .fn() + .mockResolvedValue([mockAppendedPlayStoreSubscriptionPurchase]), }; const appStoreSubscriptions = { - getSubscriptions: sandbox - .stub() - .resolves([mockAppendedAppStoreSubscriptionPurchase]), + getSubscriptions: jest + .fn() + .mockResolvedValue([mockAppendedAppStoreSubscriptionPurchase]), }; const routes = mozillaSubscriptionRoutes({ log, @@ -222,13 +219,13 @@ describe('mozilla-subscriptions', () => { beforeEach(() => { capabilityService = {}; stripeHelper = { - getBillingDetailsAndSubscriptions: sandbox - .stub() - .resolves(mockSubsAndBillingDetails), - fetchCustomer: sandbox.stub().resolves(mockCustomer), - formatSubscriptionsForSupport: sandbox - .stub() - .resolves([mockFormattedWebSubscription]), + getBillingDetailsAndSubscriptions: jest + .fn() + .mockResolvedValue(mockSubsAndBillingDetails), + fetchCustomer: jest.fn().mockResolvedValue(mockCustomer), + formatSubscriptionsForSupport: jest + .fn() + .mockResolvedValue([mockFormattedWebSubscription]), }; ( appStoreSubscriptionPurchaseToAppStoreSubscriptionDTO as jest.Mock @@ -238,18 +235,18 @@ describe('mozilla-subscriptions', () => { ).mockClear(); priceManager = mocks.mockPriceManager(); productConfigurationManager = mocks.mockProductConfigurationManager(); - productConfigurationManager.getIapOfferings = sandbox - .stub() - .resolves(iapOfferingUtil); - iapOfferingUtil.getIapPageContentByStoreId = sandbox - .stub() - .returns(mockIapOffering); - priceManager.retrieve = sandbox.stub().resolves(mockPrice); - priceManager.retrieveByInterval = sandbox.stub().resolves(mockPrice); + productConfigurationManager.getIapOfferings = jest + .fn() + .mockResolvedValue(iapOfferingUtil); + iapOfferingUtil.getIapPageContentByStoreId = jest + .fn() + .mockReturnValue(mockIapOffering); + priceManager.retrieve = jest.fn().mockResolvedValue(mockPrice); + priceManager.retrieveByInterval = jest.fn().mockResolvedValue(mockPrice); }); afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); describe('GET /customer/billing-and-subscriptions', () => { @@ -275,14 +272,12 @@ describe('mozilla-subscriptions', () => { ], }); expect( - ( - playStoreSubscriptionPurchaseToPlayStoreSubscriptionDTO as jest.Mock - ).mock.calls.length + (playStoreSubscriptionPurchaseToPlayStoreSubscriptionDTO as jest.Mock) + .mock.calls.length ).toBe(1); expect( - ( - appStoreSubscriptionPurchaseToAppStoreSubscriptionDTO as jest.Mock - ).mock.calls.length + (appStoreSubscriptionPurchaseToAppStoreSubscriptionDTO as jest.Mock) + .mock.calls.length ).toBe(1); }); @@ -311,25 +306,23 @@ describe('mozilla-subscriptions', () => { ], }); expect( - ( - playStoreSubscriptionPurchaseToPlayStoreSubscriptionDTO as jest.Mock - ).mock.calls.length + (playStoreSubscriptionPurchaseToPlayStoreSubscriptionDTO as jest.Mock) + .mock.calls.length ).toBe(1); expect( - ( - appStoreSubscriptionPurchaseToAppStoreSubscriptionDTO as jest.Mock - ).mock.calls.length + (appStoreSubscriptionPurchaseToAppStoreSubscriptionDTO as jest.Mock) + .mock.calls.length ).toBe(1); }); it('gets customer billing details and only Stripe subscriptions', async () => { const playSubscriptions = { - getSubscriptions: sandbox.stub().resolves([]), + getSubscriptions: jest.fn().mockResolvedValue([]), }; const appStoreSubscriptions = { - getSubscriptions: sandbox.stub().resolves([]), + getSubscriptions: jest.fn().mockResolvedValue([]), }; - stripeHelper.addPriceInfoToIapPurchases = sandbox.stub().resolves([]); + stripeHelper.addPriceInfoToIapPurchases = jest.fn().mockResolvedValue([]); const resp = await runTest( '/oauth/mozilla-subscriptions/customer/billing-and-subscriptions', @@ -351,13 +344,13 @@ describe('mozilla-subscriptions', () => { it('gets customer billing details and only Google Play subscriptions', async () => { const stripeHelper = { - getBillingDetailsAndSubscriptions: sandbox.stub().resolves(null), - addPriceInfoToIapPurchases: sandbox - .stub() - .resolves([mockGooglePlaySubscription]), + getBillingDetailsAndSubscriptions: jest.fn().mockResolvedValue(null), + addPriceInfoToIapPurchases: jest + .fn() + .mockResolvedValue([mockGooglePlaySubscription]), }; const appStoreSubscriptions = { - getSubscriptions: sandbox.stub().resolves([]), + getSubscriptions: jest.fn().mockResolvedValue([]), }; const resp = await runTest( '/oauth/mozilla-subscriptions/customer/billing-and-subscriptions', @@ -378,13 +371,13 @@ describe('mozilla-subscriptions', () => { it('gets customer billing details and only App Store subscriptions', async () => { const stripeHelper = { - getBillingDetailsAndSubscriptions: sandbox.stub().resolves(null), - addPriceInfoToIapPurchases: sandbox - .stub() - .resolves([mockAppStoreSubscription]), + getBillingDetailsAndSubscriptions: jest.fn().mockResolvedValue(null), + addPriceInfoToIapPurchases: jest + .fn() + .mockResolvedValue([mockAppStoreSubscription]), }; const playSubscriptions = { - getSubscriptions: sandbox.stub().resolves([]), + getSubscriptions: jest.fn().mockResolvedValue([]), }; const resp = await runTest( '/oauth/mozilla-subscriptions/customer/billing-and-subscriptions', @@ -405,14 +398,14 @@ describe('mozilla-subscriptions', () => { it('throws an error when there are no subscriptions', async () => { const playSubscriptions = { - getSubscriptions: sandbox.stub().resolves([]), + getSubscriptions: jest.fn().mockResolvedValue([]), }; const appStoreSubscriptions = { - getSubscriptions: sandbox.stub().resolves([]), + getSubscriptions: jest.fn().mockResolvedValue([]), }; const stripeHelper = { - getBillingDetailsAndSubscriptions: sandbox.stub().resolves(null), - addPriceInfoToIapPurchases: sandbox.stub().resolves([]), + getBillingDetailsAndSubscriptions: jest.fn().mockResolvedValue(null), + addPriceInfoToIapPurchases: jest.fn().mockResolvedValue([]), }; try { await runTest( @@ -436,14 +429,14 @@ describe('plan-eligibility', () => { priceManager = mocks.mockPriceManager(); productConfigurationManager = mocks.mockProductConfigurationManager(); capabilityService = { - getPlanEligibility: sandbox.stub().resolves({ + getPlanEligibility: jest.fn().mockResolvedValue({ subscriptionEligibilityResult: 'eligibility', }), }; }); afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); describe('GET /customer/plan-eligibility/example-planid', () => { @@ -462,10 +455,10 @@ describe('plan-eligibility', () => { describe('MozillaSubscriptionHandler', () => { let mozillaSubscriptionsHandler: any; const playSubscriptions = { - getSubscriptions: sandbox.stub(), + getSubscriptions: jest.fn(), }; const appStoreSubscriptions = { - getSubscriptions: sandbox.stub(), + getSubscriptions: jest.fn(), }; const mockSubId = 'sub_123'; @@ -508,18 +501,18 @@ describe('MozillaSubscriptionHandler', () => { appStoreSubscriptions, capabilityService ); - productConfigurationManager.getIapOfferings = sandbox - .stub() - .resolves(iapOfferingUtil); - iapOfferingUtil.getIapPageContentByStoreId = sandbox - .stub() - .returns(mockIapOffering); - priceManager.retrieve = sandbox.stub().resolves(mockPrice); - priceManager.retrieveByInterval = sandbox.stub().resolves(mockPrice); + productConfigurationManager.getIapOfferings = jest + .fn() + .mockResolvedValue(iapOfferingUtil); + iapOfferingUtil.getIapPageContentByStoreId = jest + .fn() + .mockReturnValue(mockIapOffering); + priceManager.retrieve = jest.fn().mockResolvedValue(mockPrice); + priceManager.retrieveByInterval = jest.fn().mockResolvedValue(mockPrice); }); afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); describe('fetchIapPriceInfo', () => { @@ -544,9 +537,9 @@ describe('MozillaSubscriptionHandler', () => { }); it('throws if IAP CMS config could not be found', async () => { - iapOfferingUtil.getIapPageContentByStoreId = sandbox - .stub() - .returns(undefined); + iapOfferingUtil.getIapPageContentByStoreId = jest + .fn() + .mockReturnValue(undefined); try { await mozillaSubscriptionsHandler.fetchIapPriceInfo([], []); } catch (error: any) { @@ -556,7 +549,7 @@ describe('MozillaSubscriptionHandler', () => { }); it('throws if Price not found for IAP', async () => { - priceManager.retrieveByInterval = sandbox.stub().resolves(undefined); + priceManager.retrieveByInterval = jest.fn().mockResolvedValue(undefined); try { await mozillaSubscriptionsHandler.fetchIapPriceInfo([], []); } catch (error: any) { diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.spec.ts index 56d3d10634e..ba5b25390ef 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/paypal-notifications.spec.ts @@ -2,16 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import * as uuid from 'uuid'; import { Container } from 'typedi'; -const sandbox = sinon.createSandbox(); - const dbStub = { - getPayPalBAByBAId: sandbox.stub(), + getPayPalBAByBAId: jest.fn(), Account: {} as any, - updatePayPalBA: sandbox.stub(), + updatePayPalBA: jest.fn(), }; jest.mock('fxa-shared/db/models/auth', () => dbStub); @@ -74,7 +71,7 @@ describe('PayPalNotificationHandler', () => { mailer = mocks.mockMailer(); profile = mocks.mockProfile({ - deleteCache: sinon.spy(async (uid: any) => ({})), + deleteCache: jest.fn(async (uid: any) => ({})), }); stripeHelper = {}; @@ -97,14 +94,15 @@ describe('PayPalNotificationHandler', () => { afterEach(() => { Container.reset(); - sandbox.reset(); + jest.clearAllMocks(); }); describe('handleIpnEvent', () => { it('handles a request successfully', () => { - handler.verifyAndDispatchEvent = sinon.fake.returns({}); + handler.verifyAndDispatchEvent = jest.fn().mockReturnValue({}); const result = handler.handleIpnEvent({}); - sinon.assert.calledOnceWithExactly(handler.verifyAndDispatchEvent, {}); + expect(handler.verifyAndDispatchEvent).toHaveBeenCalledTimes(1); + expect(handler.verifyAndDispatchEvent).toHaveBeenCalledWith({}); expect(result).toEqual({}); }); }); @@ -117,17 +115,15 @@ describe('PayPalNotificationHandler', () => { const ipnMessage = { txn_type: 'merch_pmt', }; - paypalHelper.verifyIpnMessage = sinon.fake.resolves(true); - paypalHelper.extractIpnMessage = sinon.fake.returns(ipnMessage); - handler.handleMerchPayment = sinon.fake.resolves({}); + paypalHelper.verifyIpnMessage = jest.fn().mockResolvedValue(true); + paypalHelper.extractIpnMessage = jest.fn().mockReturnValue(ipnMessage); + handler.handleMerchPayment = jest.fn().mockResolvedValue({}); const result = await handler.verifyAndDispatchEvent(request); expect(result).toEqual({}); - sinon.assert.calledOnce(paypalHelper.verifyIpnMessage); - sinon.assert.calledOnce(paypalHelper.extractIpnMessage); - sinon.assert.calledOnceWithExactly( - handler.handleMerchPayment, - ipnMessage - ); + expect(paypalHelper.verifyIpnMessage).toHaveBeenCalledTimes(1); + expect(paypalHelper.extractIpnMessage).toHaveBeenCalledTimes(1); + expect(handler.handleMerchPayment).toHaveBeenCalledTimes(1); + expect(handler.handleMerchPayment).toHaveBeenCalledWith(ipnMessage); }); it('handles a mp_cancel request successfully', async () => { @@ -137,14 +133,15 @@ describe('PayPalNotificationHandler', () => { const ipnMessage = { txn_type: 'mp_cancel', }; - paypalHelper.verifyIpnMessage = sinon.fake.resolves(true); - paypalHelper.extractIpnMessage = sinon.fake.returns(ipnMessage); - handler.handleMpCancel = sinon.fake.resolves({}); + paypalHelper.verifyIpnMessage = jest.fn().mockResolvedValue(true); + paypalHelper.extractIpnMessage = jest.fn().mockReturnValue(ipnMessage); + handler.handleMpCancel = jest.fn().mockResolvedValue({}); const result = await handler.verifyAndDispatchEvent(request); expect(result).toEqual({}); - sinon.assert.calledOnce(paypalHelper.verifyIpnMessage); - sinon.assert.calledOnce(paypalHelper.extractIpnMessage); - sinon.assert.calledOnceWithExactly(handler.handleMpCancel, ipnMessage); + expect(paypalHelper.verifyIpnMessage).toHaveBeenCalledTimes(1); + expect(paypalHelper.extractIpnMessage).toHaveBeenCalledTimes(1); + expect(handler.handleMpCancel).toHaveBeenCalledTimes(1); + expect(handler.handleMpCancel).toHaveBeenCalledWith(ipnMessage); }); it('handles an unknown IPN request successfully', async () => { @@ -154,14 +151,14 @@ describe('PayPalNotificationHandler', () => { const ipnMessage = { txn_type: 'other', }; - paypalHelper.verifyIpnMessage = sinon.fake.resolves(true); - paypalHelper.extractIpnMessage = sinon.fake.returns(ipnMessage); - handler.handleMerchPayment = sinon.fake.resolves({}); + paypalHelper.verifyIpnMessage = jest.fn().mockResolvedValue(true); + paypalHelper.extractIpnMessage = jest.fn().mockReturnValue(ipnMessage); + handler.handleMerchPayment = jest.fn().mockResolvedValue({}); const result = await handler.verifyAndDispatchEvent(request); expect(result).toEqual(false); - sinon.assert.calledOnce(paypalHelper.verifyIpnMessage); - sinon.assert.calledOnce(paypalHelper.extractIpnMessage); - sinon.assert.calledWithExactly(log.info, 'Unhandled Ipn message', { + expect(paypalHelper.verifyIpnMessage).toHaveBeenCalledTimes(1); + expect(paypalHelper.extractIpnMessage).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith('Unhandled Ipn message', { payload: ipnMessage, }); }); @@ -173,24 +170,24 @@ describe('PayPalNotificationHandler', () => { const ipnMessage = { txn_type: 'mp_signup', }; - paypalHelper.verifyIpnMessage = sinon.fake.resolves(true); - paypalHelper.extractIpnMessage = sinon.fake.returns(ipnMessage); - handler.handleMerchPayment = sinon.fake.resolves({}); + paypalHelper.verifyIpnMessage = jest.fn().mockResolvedValue(true); + paypalHelper.extractIpnMessage = jest.fn().mockReturnValue(ipnMessage); + handler.handleMerchPayment = jest.fn().mockResolvedValue({}); const result = await handler.verifyAndDispatchEvent(request); expect(result).toEqual(false); - sinon.assert.calledOnce(paypalHelper.verifyIpnMessage); - sinon.assert.calledOnce(paypalHelper.extractIpnMessage); + expect(paypalHelper.verifyIpnMessage).toHaveBeenCalledTimes(1); + expect(paypalHelper.extractIpnMessage).toHaveBeenCalledTimes(1); }); it('handles an invalid request successfully', async () => { const request = { payload: 'samplepayload', }; - paypalHelper.verifyIpnMessage = sinon.fake.resolves(false); + paypalHelper.verifyIpnMessage = jest.fn().mockResolvedValue(false); const result = await handler.verifyAndDispatchEvent(request); expect(result).toEqual(false); - sinon.assert.calledOnce(paypalHelper.verifyIpnMessage); - sinon.assert.calledOnce(log.error); + expect(paypalHelper.verifyIpnMessage).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledTimes(1); }); }); @@ -212,10 +209,13 @@ describe('PayPalNotificationHandler', () => { const refundReturn = undefined; beforeEach(() => { - stripeHelper.getInvoicePaypalTransactionId = - sinon.fake.returns(ipnTransactionId); - stripeHelper.payInvoiceOutOfBand = sinon.fake.resolves(paidInvoice); - paypalHelper.issueRefund = sinon.fake.resolves(refundReturn); + stripeHelper.getInvoicePaypalTransactionId = jest + .fn() + .mockReturnValue(ipnTransactionId); + stripeHelper.payInvoiceOutOfBand = jest + .fn() + .mockResolvedValue(paidInvoice); + paypalHelper.issueRefund = jest.fn().mockResolvedValue(refundReturn); }); it('should update invoice to paid', async () => { @@ -225,11 +225,9 @@ describe('PayPalNotificationHandler', () => { const result = await handler.handleSuccessfulPayment(invoice, message); expect(result).toEqual(paidInvoice); - sinon.assert.calledOnceWithExactly( - stripeHelper.payInvoiceOutOfBand, - invoice - ); - sinon.assert.notCalled(paypalHelper.issueRefund); + expect(stripeHelper.payInvoiceOutOfBand).toHaveBeenCalledTimes(1); + expect(stripeHelper.payInvoiceOutOfBand).toHaveBeenCalledWith(invoice); + expect(paypalHelper.issueRefund).not.toHaveBeenCalled(); }); it('should update Invoice with paypalTransactionId if not already there', async () => { @@ -239,24 +237,25 @@ describe('PayPalNotificationHandler', () => { }, }; const message = validMessage; - stripeHelper.getInvoicePaypalTransactionId = - sinon.fake.returns(undefined); - stripeHelper.updateInvoiceWithPaypalTransactionId = - sinon.fake.resolves(validInvoice); + stripeHelper.getInvoicePaypalTransactionId = jest + .fn() + .mockReturnValue(undefined); + stripeHelper.updateInvoiceWithPaypalTransactionId = jest + .fn() + .mockResolvedValue(validInvoice); const result = await handler.handleSuccessfulPayment(invoice, message); expect(result).toEqual(paidInvoice); - sinon.assert.calledOnceWithExactly( - stripeHelper.updateInvoiceWithPaypalTransactionId, - invoice, - ipnTransactionId - ); - sinon.assert.calledOnceWithExactly( - stripeHelper.payInvoiceOutOfBand, - invoice - ); - sinon.assert.notCalled(paypalHelper.issueRefund); + expect( + stripeHelper.updateInvoiceWithPaypalTransactionId + ).toHaveBeenCalledTimes(1); + expect( + stripeHelper.updateInvoiceWithPaypalTransactionId + ).toHaveBeenCalledWith(invoice, ipnTransactionId); + expect(stripeHelper.payInvoiceOutOfBand).toHaveBeenCalledTimes(1); + expect(stripeHelper.payInvoiceOutOfBand).toHaveBeenCalledWith(invoice); + expect(paypalHelper.issueRefund).not.toHaveBeenCalled(); }); it('should throw an error when paypalTransactionId and IPN txn_id dont match', async () => { @@ -267,7 +266,9 @@ describe('PayPalNotificationHandler', () => { try { await handler.handleSuccessfulPayment(invoice, message); - throw new Error('Error should throw error with transactionId not matching'); + throw new Error( + 'Error should throw error with transactionId not matching' + ); } catch (err: any) { expect(err).toEqual( error.internalValidationError('handleSuccessfulPayment', { @@ -277,8 +278,8 @@ describe('PayPalNotificationHandler', () => { paypalIPNTxnId: message.txn_id, }) ); - sinon.assert.notCalled(stripeHelper.payInvoiceOutOfBand); - sinon.assert.notCalled(paypalHelper.issueRefund); + expect(stripeHelper.payInvoiceOutOfBand).not.toHaveBeenCalled(); + expect(paypalHelper.issueRefund).not.toHaveBeenCalled(); } }); @@ -292,19 +293,19 @@ describe('PayPalNotificationHandler', () => { }, }; const message = validMessage; - stripeHelper.expandResource = sinon.spy(); + stripeHelper.expandResource = jest.fn(); const result = await handler.handleSuccessfulPayment(invoice, message); expect(result).toEqual(refundReturn); - sinon.assert.calledOnceWithExactly( - paypalHelper.issueRefund, + expect(paypalHelper.issueRefund).toHaveBeenCalledTimes(1); + expect(paypalHelper.issueRefund).toHaveBeenCalledWith( invoice, validMessage.txn_id, RefundType.Full ); - sinon.assert.notCalled(stripeHelper.expandResource); - sinon.assert.notCalled(stripeHelper.payInvoiceOutOfBand); + expect(stripeHelper.expandResource).not.toHaveBeenCalled(); + expect(stripeHelper.payInvoiceOutOfBand).not.toHaveBeenCalled(); }); it('should expand subscription and refund the invoice if the subscription has status canceled', async () => { @@ -315,23 +316,25 @@ describe('PayPalNotificationHandler', () => { subscription: 'sub_id', }; const message = validMessage; - stripeHelper.expandResource = sinon.fake.resolves({ status: 'canceled' }); + stripeHelper.expandResource = jest + .fn() + .mockResolvedValue({ status: 'canceled' }); const result = await handler.handleSuccessfulPayment(invoice, message); expect(result).toEqual(refundReturn); - sinon.assert.calledOnceWithExactly( - paypalHelper.issueRefund, + expect(paypalHelper.issueRefund).toHaveBeenCalledTimes(1); + expect(paypalHelper.issueRefund).toHaveBeenCalledWith( invoice, validMessage.txn_id, RefundType.Full ); - sinon.assert.calledOnceWithExactly( - stripeHelper.expandResource, + expect(stripeHelper.expandResource).toHaveBeenCalledTimes(1); + expect(stripeHelper.expandResource).toHaveBeenCalledWith( invoice.subscription, SUBSCRIPTIONS_RESOURCE ); - sinon.assert.notCalled(stripeHelper.payInvoiceOutOfBand); + expect(stripeHelper.payInvoiceOutOfBand).not.toHaveBeenCalled(); }); }); @@ -344,19 +347,21 @@ describe('PayPalNotificationHandler', () => { it('receives IPN message with successful payment status', async () => { const invoice = { status: 'open' }; const paidInvoice = { status: 'paid' }; - stripeHelper.getInvoice = sinon.fake.resolves(invoice); - handler.handleSuccessfulPayment = sinon.fake.resolves(paidInvoice); + stripeHelper.getInvoice = jest.fn().mockResolvedValue(invoice); + handler.handleSuccessfulPayment = jest + .fn() + .mockResolvedValue(paidInvoice); const result = await handler.handleMerchPayment( completedMerchantPaymentNotification ); expect(result).toEqual(paidInvoice); - sinon.assert.calledOnceWithExactly( - stripeHelper.getInvoice, + expect(stripeHelper.getInvoice).toHaveBeenCalledTimes(1); + expect(stripeHelper.getInvoice).toHaveBeenCalledWith( completedMerchantPaymentNotification.invoice ); - sinon.assert.calledOnceWithExactly( - handler.handleSuccessfulPayment, + expect(handler.handleSuccessfulPayment).toHaveBeenCalledTimes(1); + expect(handler.handleSuccessfulPayment).toHaveBeenCalledWith( invoice, completedMerchantPaymentNotification ); @@ -364,13 +369,13 @@ describe('PayPalNotificationHandler', () => { it('receives IPN message with pending payment status', async () => { const invoice = { status: 'open' }; - stripeHelper.getInvoice = sinon.fake.resolves(invoice); + stripeHelper.getInvoice = jest.fn().mockResolvedValue(invoice); const result = await handler.handleMerchPayment( pendingMerchantPaymentNotification ); expect(result).toEqual(undefined); - sinon.assert.calledOnceWithExactly( - stripeHelper.getInvoice, + expect(stripeHelper.getInvoice).toHaveBeenCalledTimes(1); + expect(stripeHelper.getInvoice).toHaveBeenCalledWith( pendingMerchantPaymentNotification.invoice ); }); @@ -382,7 +387,7 @@ describe('PayPalNotificationHandler', () => { payment_status: 'Denied', custom: '', }; - stripeHelper.getInvoice = sinon.fake.resolves(invoice); + stripeHelper.getInvoice = jest.fn().mockResolvedValue(invoice); try { await handler.handleMerchPayment(deniedMessage); throw new Error('Error should throw no idempotency key response.'); @@ -393,16 +398,14 @@ describe('PayPalNotificationHandler', () => { }) ); } - sinon.assert.calledOnceWithExactly( - stripeHelper.getInvoice, - message.invoice - ); - sinon.assert.calledOnce(log.error); + expect(stripeHelper.getInvoice).toHaveBeenCalledTimes(1); + expect(stripeHelper.getInvoice).toHaveBeenCalledWith(message.invoice); + expect(log.error).toHaveBeenCalledTimes(1); }); it('receives IPN message with unexpected payment status', async () => { const invoice = { status: 'open' }; - stripeHelper.getInvoice = sinon.fake.resolves(invoice); + stripeHelper.getInvoice = jest.fn().mockResolvedValue(invoice); try { await handler.handleMerchPayment({ ...message, @@ -419,15 +422,13 @@ describe('PayPalNotificationHandler', () => { }) ); } - sinon.assert.calledOnceWithExactly( - stripeHelper.getInvoice, - message.invoice - ); - sinon.assert.calledOnce(log.error); + expect(stripeHelper.getInvoice).toHaveBeenCalledTimes(1); + expect(stripeHelper.getInvoice).toHaveBeenCalledWith(message.invoice); + expect(log.error).toHaveBeenCalledTimes(1); }); it('receives IPN message with invoice not found', async () => { - stripeHelper.getInvoice = sinon.fake.resolves(undefined); + stripeHelper.getInvoice = jest.fn().mockResolvedValue(undefined); try { await handler.handleMerchPayment(message); throw new Error('Error should throw invoice not found response.'); @@ -438,39 +439,33 @@ describe('PayPalNotificationHandler', () => { }) ); } - sinon.assert.calledOnceWithExactly( - stripeHelper.getInvoice, - message.invoice - ); - sinon.assert.calledOnce(log.error); + expect(stripeHelper.getInvoice).toHaveBeenCalledTimes(1); + expect(stripeHelper.getInvoice).toHaveBeenCalledWith(message.invoice); + expect(log.error).toHaveBeenCalledTimes(1); }); it('receives IPN message with invoice not in draft or open status', async () => { const invoice = { status: null }; - stripeHelper.getInvoice = sinon.fake.resolves(invoice); + stripeHelper.getInvoice = jest.fn().mockResolvedValue(invoice); const result = await handler.handleMerchPayment(message); expect(result).toEqual(undefined); - sinon.assert.calledOnceWithExactly( - stripeHelper.getInvoice, - message.invoice - ); + expect(stripeHelper.getInvoice).toHaveBeenCalledTimes(1); + expect(stripeHelper.getInvoice).toHaveBeenCalledWith(message.invoice); }); it('successfully refunds completed transaction with invoice in uncollectible status', async () => { const invoice = { status: 'uncollectible' }; - stripeHelper.getInvoice = sinon.fake.resolves(invoice); - paypalHelper.issueRefund = sinon.fake.resolves(undefined); + stripeHelper.getInvoice = jest.fn().mockResolvedValue(invoice); + paypalHelper.issueRefund = jest.fn().mockResolvedValue(undefined); const result = await handler.handleMerchPayment( completedMerchantPaymentNotification ); expect(result).toEqual(undefined); - sinon.assert.calledOnceWithExactly( - stripeHelper.getInvoice, - message.invoice - ); - sinon.assert.calledOnceWithExactly( - paypalHelper.issueRefund, + expect(stripeHelper.getInvoice).toHaveBeenCalledTimes(1); + expect(stripeHelper.getInvoice).toHaveBeenCalledWith(message.invoice); + expect(paypalHelper.issueRefund).toHaveBeenCalledTimes(1); + expect(paypalHelper.issueRefund).toHaveBeenCalledWith( invoice, completedMerchantPaymentNotification.txn_id, RefundType.Full @@ -479,10 +474,10 @@ describe('PayPalNotificationHandler', () => { it('unsuccessfully refunds completed transaction with invoice in uncollectible status', async () => { const invoice = { status: 'uncollectible' }; - stripeHelper.getInvoice = sinon.fake.resolves(invoice); - paypalHelper.issueRefund = sinon.fake.throws( - error.internalValidationError('Fake', {}) - ); + stripeHelper.getInvoice = jest.fn().mockResolvedValue(invoice); + paypalHelper.issueRefund = jest.fn().mockImplementation(() => { + throw error.internalValidationError('Fake', {}); + }); try { await handler.handleMerchPayment(completedMerchantPaymentNotification); throw new Error( @@ -492,12 +487,10 @@ describe('PayPalNotificationHandler', () => { expect(err).toBeInstanceOf(error); expect(err.message).toBe('An internal validation check failed.'); } - sinon.assert.calledOnceWithExactly( - stripeHelper.getInvoice, - message.invoice - ); - sinon.assert.calledOnceWithExactly( - paypalHelper.issueRefund, + expect(stripeHelper.getInvoice).toHaveBeenCalledTimes(1); + expect(stripeHelper.getInvoice).toHaveBeenCalledWith(message.invoice); + expect(paypalHelper.issueRefund).toHaveBeenCalledTimes(1); + expect(paypalHelper.issueRefund).toHaveBeenCalledWith( invoice, completedMerchantPaymentNotification.txn_id, RefundType.Full @@ -516,10 +509,12 @@ describe('PayPalNotificationHandler', () => { id: 'customer_id_123', }; it('should removeCustomerPaypalAgreement when IPN and Customer BA ID match', async () => { - stripeHelper.removeCustomerPaypalAgreement = sinon.fake.resolves(undefined); - stripeHelper.getCustomerPaypalAgreement = sinon.fake.returns( - ipnBillingAgreement.billingAgreementId - ); + stripeHelper.removeCustomerPaypalAgreement = jest + .fn() + .mockResolvedValue(undefined); + stripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(ipnBillingAgreement.billingAgreementId); const result = await handler.removeBillingAgreement( customer, @@ -528,8 +523,10 @@ describe('PayPalNotificationHandler', () => { ); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - stripeHelper.removeCustomerPaypalAgreement, + expect(stripeHelper.removeCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(stripeHelper.removeCustomerPaypalAgreement).toHaveBeenCalledWith( account.uid, customer.id, ipnBillingAgreement.billingAgreementId @@ -537,8 +534,10 @@ describe('PayPalNotificationHandler', () => { }); it('should only update the database BA if the IPN and Customer BA ID dont match', async () => { - dbStub.updatePayPalBA.resolves(); - stripeHelper.getCustomerPaypalAgreement = sinon.fake.returns(undefined); + dbStub.updatePayPalBA.mockResolvedValue(); + stripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(undefined); const result = await handler.removeBillingAgreement( customer, @@ -547,10 +546,13 @@ describe('PayPalNotificationHandler', () => { ); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithMatch( - dbStub.updatePayPalBA, + expect(dbStub.updatePayPalBA).toHaveBeenCalledTimes(1); + expect(dbStub.updatePayPalBA).toHaveBeenNthCalledWith( + 1, account.uid, - ipnBillingAgreement.billingAgreementId + ipnBillingAgreement.billingAgreementId, + expect.anything(), + expect.anything() ); }); }); @@ -577,61 +579,60 @@ describe('PayPalNotificationHandler', () => { ...customer, subscriptions, }; - dbStub.getPayPalBAByBAId.resolves(billingAgreement); - dbStub.Account.findByUid = sandbox.stub().resolves(account); - stripeHelper.fetchCustomer = sinon.fake.resolves(fetchCustomer); - handler.removeBillingAgreement = sinon.stub().resolves(); - stripeHelper.getPaymentProvider = sinon.fake.resolves('paypal'); - stripeHelper.formatSubscriptionsForEmails = sinon.fake.returns([]); + dbStub.getPayPalBAByBAId.mockResolvedValue(billingAgreement); + dbStub.Account.findByUid = jest.fn().mockResolvedValue(account); + stripeHelper.fetchCustomer = jest.fn().mockResolvedValue(fetchCustomer); + handler.removeBillingAgreement = jest.fn().mockResolvedValue(); + stripeHelper.getPaymentProvider = jest.fn().mockResolvedValue('paypal'); + stripeHelper.formatSubscriptionsForEmails = jest.fn().mockReturnValue([]); const result = await handler.handleMpCancel( billingAgreementCancelNotification ); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - dbStub.getPayPalBAByBAId, + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledTimes(1); + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledWith( billingAgreementCancelNotification.mp_id ); - sinon.assert.calledOnceWithExactly( - dbStub.Account.findByUid, + expect(dbStub.Account.findByUid).toHaveBeenCalledTimes(1); + expect(dbStub.Account.findByUid).toHaveBeenCalledWith( billingAgreement.uid, { include: ['emails'] } ); - sinon.assert.calledOnceWithExactly( - stripeHelper.fetchCustomer, - account.uid, - ['subscriptions'] - ); - sinon.assert.calledOnceWithExactly( - handler.removeBillingAgreement, + expect(stripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(stripeHelper.fetchCustomer).toHaveBeenCalledWith(account.uid, [ + 'subscriptions', + ]); + expect(handler.removeBillingAgreement).toHaveBeenCalledTimes(1); + expect(handler.removeBillingAgreement).toHaveBeenCalledWith( fetchCustomer, billingAgreement, account ); - sinon.assert.calledOnceWithExactly( - stripeHelper.getPaymentProvider, + expect(stripeHelper.getPaymentProvider).toHaveBeenCalledTimes(1); + expect(stripeHelper.getPaymentProvider).toHaveBeenCalledWith( fetchCustomer ); }); it('receives IPN message with billing agreement not found', async () => { - dbStub.getPayPalBAByBAId.resolves(undefined); + dbStub.getPayPalBAByBAId.mockResolvedValue(undefined); const result = await handler.handleMpCancel( billingAgreementCancelNotification ); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - dbStub.getPayPalBAByBAId, + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledTimes(1); + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledWith( billingAgreementCancelNotification.mp_id ); - sinon.assert.calledOnce(log.error); + expect(log.error).toHaveBeenCalledTimes(1); }); it('receives IPN message for billing agreement already cancelled', async () => { - dbStub.getPayPalBAByBAId.resolves({ + dbStub.getPayPalBAByBAId.mockResolvedValue({ ...billingAgreement, status: 'Cancelled', }); @@ -641,59 +642,58 @@ describe('PayPalNotificationHandler', () => { ); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - dbStub.getPayPalBAByBAId, + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledTimes(1); + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledWith( billingAgreementCancelNotification.mp_id ); - sinon.assert.calledOnce(log.error); + expect(log.error).toHaveBeenCalledTimes(1); }); it('receives IPN message for billing agreement with no FXA account', async () => { - dbStub.getPayPalBAByBAId.resolves(billingAgreement); - dbStub.Account.findByUid = sandbox.stub().resolves(null); + dbStub.getPayPalBAByBAId.mockResolvedValue(billingAgreement); + dbStub.Account.findByUid = jest.fn().mockResolvedValue(null); const result = await handler.handleMpCancel( billingAgreementCancelNotification ); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - dbStub.getPayPalBAByBAId, + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledTimes(1); + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledWith( billingAgreementCancelNotification.mp_id ); - sinon.assert.calledOnceWithExactly( - dbStub.Account.findByUid, + expect(dbStub.Account.findByUid).toHaveBeenCalledTimes(1); + expect(dbStub.Account.findByUid).toHaveBeenCalledWith( billingAgreement.uid, { include: ['emails'] } ); - sinon.assert.calledOnce(log.error); + expect(log.error).toHaveBeenCalledTimes(1); }); it('receives IPN message for billing agreement with no Stripe customer', async () => { - dbStub.getPayPalBAByBAId.resolves(billingAgreement); - dbStub.Account.findByUid = sinon.stub().resolves(account); - stripeHelper.fetchCustomer = sinon.fake.resolves(undefined); + dbStub.getPayPalBAByBAId.mockResolvedValue(billingAgreement); + dbStub.Account.findByUid = jest.fn().mockResolvedValue(account); + stripeHelper.fetchCustomer = jest.fn().mockResolvedValue(undefined); const result = await handler.handleMpCancel( billingAgreementCancelNotification ); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - dbStub.getPayPalBAByBAId, + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledTimes(1); + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledWith( billingAgreementCancelNotification.mp_id ); - sinon.assert.calledOnceWithExactly( - dbStub.Account.findByUid, + expect(dbStub.Account.findByUid).toHaveBeenCalledTimes(1); + expect(dbStub.Account.findByUid).toHaveBeenCalledWith( billingAgreement.uid, { include: ['emails'] } ); - sinon.assert.calledOnceWithExactly( - stripeHelper.fetchCustomer, - account.uid, - ['subscriptions'] - ); - sinon.assert.calledOnce(log.error); + expect(stripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(stripeHelper.fetchCustomer).toHaveBeenCalledWith(account.uid, [ + 'subscriptions', + ]); + expect(log.error).toHaveBeenCalledTimes(1); }); it('receives IPN message for inactive subscription and email not sent', async () => { @@ -701,39 +701,38 @@ describe('PayPalNotificationHandler', () => { ...customer, subscriptions: undefined, }; - dbStub.getPayPalBAByBAId.resolves(billingAgreement); - dbStub.Account.findByUid = sandbox.stub().resolves(account); - stripeHelper.fetchCustomer = sinon.fake.resolves(fetchCustomer); - handler.removeBillingAgreement = sinon.stub().resolves(); - stripeHelper.getPaymentProvider = sinon.fake.resolves('paypal'); + dbStub.getPayPalBAByBAId.mockResolvedValue(billingAgreement); + dbStub.Account.findByUid = jest.fn().mockResolvedValue(account); + stripeHelper.fetchCustomer = jest.fn().mockResolvedValue(fetchCustomer); + handler.removeBillingAgreement = jest.fn().mockResolvedValue(); + stripeHelper.getPaymentProvider = jest.fn().mockResolvedValue('paypal'); const result = await handler.handleMpCancel( billingAgreementCancelNotification ); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - dbStub.getPayPalBAByBAId, + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledTimes(1); + expect(dbStub.getPayPalBAByBAId).toHaveBeenCalledWith( billingAgreementCancelNotification.mp_id ); - sinon.assert.calledOnceWithExactly( - dbStub.Account.findByUid, + expect(dbStub.Account.findByUid).toHaveBeenCalledTimes(1); + expect(dbStub.Account.findByUid).toHaveBeenCalledWith( billingAgreement.uid, { include: ['emails'] } ); - sinon.assert.calledOnceWithExactly( - stripeHelper.fetchCustomer, - account.uid, - ['subscriptions'] - ); - sinon.assert.calledOnceWithExactly( - handler.removeBillingAgreement, + expect(stripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(stripeHelper.fetchCustomer).toHaveBeenCalledWith(account.uid, [ + 'subscriptions', + ]); + expect(handler.removeBillingAgreement).toHaveBeenCalledTimes(1); + expect(handler.removeBillingAgreement).toHaveBeenCalledWith( fetchCustomer, billingAgreement, account ); - sinon.assert.calledOnceWithExactly( - await stripeHelper.getPaymentProvider, + expect(await stripeHelper.getPaymentProvider).toHaveBeenCalledTimes(1); + expect(await stripeHelper.getPaymentProvider).toHaveBeenCalledWith( fetchCustomer ); }); @@ -745,35 +744,39 @@ describe('PayPalNotificationHandler', () => { }; const mockFormattedSubs = { productId: 'quux' }; const mockAcct = { ...account, emails: [account.email, 'bar@baz.gd'] }; - dbStub.getPayPalBAByBAId.resolves(billingAgreement); - dbStub.Account.findByUid = sandbox.stub().resolves(mockAcct); - stripeHelper.fetchCustomer = sinon.fake.resolves(mockCustomer); - handler.removeBillingAgreement = sinon.stub().resolves(); - stripeHelper.getPaymentProvider = sinon.fake.returns('paypal'); - stripeHelper.formatSubscriptionsForEmails = - sinon.fake.resolves(mockFormattedSubs); - mailer.sendSubscriptionPaymentProviderCancelledEmail = - sinon.fake.resolves(undefined); + dbStub.getPayPalBAByBAId.mockResolvedValue(billingAgreement); + dbStub.Account.findByUid = jest.fn().mockResolvedValue(mockAcct); + stripeHelper.fetchCustomer = jest.fn().mockResolvedValue(mockCustomer); + handler.removeBillingAgreement = jest.fn().mockResolvedValue(); + stripeHelper.getPaymentProvider = jest.fn().mockReturnValue('paypal'); + stripeHelper.formatSubscriptionsForEmails = jest + .fn() + .mockResolvedValue(mockFormattedSubs); + mailer.sendSubscriptionPaymentProviderCancelledEmail = jest + .fn() + .mockResolvedValue(undefined); await handler.handleMpCancel(billingAgreementCancelNotification); - sinon.assert.calledOnceWithExactly( - stripeHelper.formatSubscriptionsForEmails, - mockCustomer + expect(stripeHelper.formatSubscriptionsForEmails).toHaveBeenCalledTimes( + 1 ); - sinon.assert.calledOnceWithExactly( - mailer.sendSubscriptionPaymentProviderCancelledEmail, - mockAcct.emails, - mockAcct, - { - uid: mockAcct.uid, - email: mockAcct.email, - acceptLanguage: mockAcct.locale, - subscriptions: mockFormattedSubs, - } + expect(stripeHelper.formatSubscriptionsForEmails).toHaveBeenCalledWith( + mockCustomer ); + expect( + mailer.sendSubscriptionPaymentProviderCancelledEmail + ).toHaveBeenCalledTimes(1); + expect( + mailer.sendSubscriptionPaymentProviderCancelledEmail + ).toHaveBeenCalledWith(mockAcct.emails, mockAcct, { + uid: mockAcct.uid, + email: mockAcct.email, + acceptLanguage: mockAcct.locale, + subscriptions: mockFormattedSubs, + }); - sandbox.restore(); + jest.restoreAllMocks(); }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/paypal.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/paypal.spec.ts index 44135b141c8..01584bf7b59 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/paypal.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/paypal.spec.ts @@ -2,9 +2,11 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; -import { filterCustomer, filterSubscription } from 'fxa-shared/subscriptions/stripe'; +import { + filterCustomer, + filterSubscription, +} from 'fxa-shared/subscriptions/stripe'; import { AppError as error } from '@fxa/accounts/errors'; import { PayPalHelper } from '../../payments/paypal/helper'; import * as uuid from 'uuid'; @@ -129,13 +131,43 @@ describe('subscriptions payPalRoutes', () => { Container.set(AppConfig, config); Container.set(AuthLogger, log); Container.set(PlayBilling, {}); - stripeHelper = sinon.createStubInstance(StripeHelper); + stripeHelper = {} as any; + for ( + let p = StripeHelper.prototype; + p && p !== Object.prototype; + p = Object.getPrototypeOf(p) + ) { + Object.getOwnPropertyNames(p).forEach((m) => { + if (m !== 'constructor' && !stripeHelper[m]) + stripeHelper[m] = jest.fn(); + }); + } Container.set(StripeHelper, stripeHelper); - payPalHelper = sinon.createStubInstance(PayPalHelper); + payPalHelper = {} as any; + for ( + let p = PayPalHelper.prototype; + p && p !== Object.prototype; + p = Object.getPrototypeOf(p) + ) { + Object.getOwnPropertyNames(p).forEach((m) => { + if (m !== 'constructor' && !payPalHelper[m]) + payPalHelper[m] = jest.fn(); + }); + } payPalHelper.currencyHelper = currencyHelper; Container.set(PayPalHelper, payPalHelper); profile = {}; - capabilityService = sinon.createStubInstance(CapabilityService); + capabilityService = {} as any; + for ( + let p = CapabilityService.prototype; + p && p !== Object.prototype; + p = Object.getPrototypeOf(p) + ) { + Object.getOwnPropertyNames(p).forEach((m) => { + if (m !== 'constructor' && !capabilityService[m]) + capabilityService[m] = jest.fn(); + }); + } Container.set(CapabilityService, capabilityService); push = {}; Container.set(PlaySubscriptions, {}); @@ -150,7 +182,7 @@ describe('subscriptions payPalRoutes', () => { describe('POST /oauth/subscriptions/paypal-checkout', () => { beforeEach(() => { - payPalHelper.getCheckoutToken = sinon.fake.resolves(token); + payPalHelper.getCheckoutToken = jest.fn().mockResolvedValue(token); }); it('should call PayPalHelper.getCheckoutToken and return token in an object', async () => { @@ -158,7 +190,7 @@ describe('subscriptions payPalRoutes', () => { '/oauth/subscriptions/paypal-checkout', defaultRequestOptions ); - sinon.assert.calledOnce(payPalHelper.getCheckoutToken); + expect(payPalHelper.getCheckoutToken).toHaveBeenCalledTimes(1); expect(response).toEqual({ token }); }); @@ -167,13 +199,13 @@ describe('subscriptions payPalRoutes', () => { '/oauth/subscriptions/paypal-checkout', defaultRequestOptions ); - sinon.assert.calledOnceWithExactly( - log.begin, + expect(log.begin).toHaveBeenCalledTimes(1); + expect(log.begin).toHaveBeenCalledWith( 'subscriptions.getCheckoutToken', request ); - sinon.assert.calledOnceWithExactly( - log.info, + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith( 'subscriptions.getCheckoutToken.success', { token: token } ); @@ -184,8 +216,8 @@ describe('subscriptions payPalRoutes', () => { '/oauth/subscriptions/paypal-checkout', defaultRequestOptions ); - sinon.assert.calledOnceWithExactly( - customs.checkAuthenticated, + expect(customs.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(customs.checkAuthenticated).toHaveBeenCalledWith( request, UID, TEST_EMAIL, @@ -198,36 +230,46 @@ describe('subscriptions payPalRoutes', () => { let plan: any, customer: any, subscription: any, promotionCode: any; beforeEach(() => { - stripeHelper.findCustomerSubscriptionByPlanId = - sinon.fake.returns(undefined); - capabilityService.getPlanEligibility = sinon.fake.resolves({ + stripeHelper.findCustomerSubscriptionByPlanId = jest + .fn() + .mockReturnValue(undefined); + capabilityService.getPlanEligibility = jest.fn().mockResolvedValue({ subscriptionEligibilityResult: SubscriptionEligibilityResult.CREATE, }); - stripeHelper.cancelSubscription = sinon.fake.resolves({}); - payPalHelper.cancelBillingAgreement = sinon.fake.resolves({}); - profile.deleteCache = sinon.fake.resolves({}); - push.notifyProfileUpdated = sinon.fake.resolves({}); + stripeHelper.cancelSubscription = jest.fn().mockResolvedValue({}); + payPalHelper.cancelBillingAgreement = jest.fn().mockResolvedValue({}); + profile.deleteCache = jest.fn().mockResolvedValue({}); + push.notifyProfileUpdated = jest.fn().mockResolvedValue({}); plan = deepCopy(planFixture); customer = deepCopy(customerFixture); subscription = deepCopy(subscription2); subscription.latest_invoice = deepCopy(openInvoice); - stripeHelper.fetchCustomer = sinon.fake.resolves(customer); - stripeHelper.findAbbrevPlanById = sinon.fake.resolves(plan); - payPalHelper.createBillingAgreement = sinon.fake.resolves('B-test'); - payPalHelper.agreementDetails = sinon.fake.resolves({ + stripeHelper.fetchCustomer = jest.fn().mockResolvedValue(customer); + stripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue(plan); + payPalHelper.createBillingAgreement = jest + .fn() + .mockResolvedValue('B-test'); + payPalHelper.agreementDetails = jest.fn().mockResolvedValue({ firstName: 'Test', lastName: 'User', countryCode: 'CA', }); - stripeHelper.customerTaxId = sinon.fake.returns(undefined); - stripeHelper.addTaxIdToCustomer = sinon.fake.resolves({}); - stripeHelper.createSubscriptionWithPaypal = - sinon.fake.resolves(subscription); - stripeHelper.updateCustomerPaypalAgreement = - sinon.fake.resolves(customer); + stripeHelper.customerTaxId = jest.fn().mockReturnValue(undefined); + stripeHelper.addTaxIdToCustomer = jest.fn().mockResolvedValue({}); + stripeHelper.createSubscriptionWithPaypal = jest + .fn() + .mockResolvedValue(subscription); + stripeHelper.updateCustomerPaypalAgreement = jest + .fn() + .mockResolvedValue(customer); promotionCode = { coupon: { id: 'test-coupon' } }; - stripeHelper.findValidPromoCode = sinon.fake.resolves(promotionCode); - buildTaxAddressStub.mockReturnValue({ countryCode: 'US', postalCode: '92841' }); + stripeHelper.findValidPromoCode = jest + .fn() + .mockResolvedValue(promotionCode); + buildTaxAddressStub.mockReturnValue({ + countryCode: 'US', + postalCode: '92841', + }); }); afterEach(() => { @@ -239,8 +281,10 @@ describe('subscriptions payPalRoutes', () => { it('throws a missing PayPal billing agreement error', async () => { const c = deepCopy(customer); c.subscriptions.data[0].collection_method = 'send_invoice'; - stripeHelper.fetchCustomer = sinon.fake.resolves(c); - stripeHelper.getCustomerPaypalAgreement = sinon.fake.returns(undefined); + stripeHelper.fetchCustomer = jest.fn().mockResolvedValue(c); + stripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(undefined); try { await runTest( @@ -256,9 +300,12 @@ describe('subscriptions payPalRoutes', () => { describe('new customer with no PayPal token', () => { it('throws a missing PayPal payment token error', async () => { - authDbModule.getAccountCustomerByUid = - sinon.fake.resolves(accountCustomer); - stripeHelper.getCustomerPaypalAgreement = sinon.fake.returns(undefined); + authDbModule.getAccountCustomerByUid = jest + .fn() + .mockResolvedValue(accountCustomer); + stripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(undefined); try { await runTest( @@ -272,21 +319,12 @@ describe('subscriptions payPalRoutes', () => { }); describe('deleteAccountIfUnverified', () => { - let sandbox: sinon.SinonSandbox; - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); - - afterEach(() => { - sandbox.restore(); - }); - it('calls deleteAccountIfUnverified', async () => { const requestOptions = deepCopy(defaultRequestOptions); requestOptions.verifierSetAt = 0; - stripeHelper.fetchCustomer = sinon.fake.throws( - error.backendServiceFailure() - ); + stripeHelper.fetchCustomer = jest.fn().mockImplementation(() => { + throw error.backendServiceFailure(); + }); deleteAccountIfUnverifiedStub.mockReturnValue(null); try { @@ -306,9 +344,9 @@ describe('subscriptions payPalRoutes', () => { it('ignores account exists error from deleteAccountIfUnverified', async () => { const requestOptions = deepCopy(defaultRequestOptions); requestOptions.verifierSetAt = 0; - stripeHelper.fetchCustomer = sinon.fake.throws( - error.backendServiceFailure() - ); + stripeHelper.fetchCustomer = jest.fn().mockImplementation(() => { + throw error.backendServiceFailure(); + }); deleteAccountIfUnverifiedStub.mockImplementation(() => { throw error.accountExists(undefined); }); @@ -330,9 +368,9 @@ describe('subscriptions payPalRoutes', () => { it('ignores verified email error from deleteAccountIfUnverified', async () => { const requestOptions = deepCopy(defaultRequestOptions); requestOptions.verifierSetAt = 0; - stripeHelper.fetchCustomer = sinon.fake.throws( - error.backendServiceFailure() - ); + stripeHelper.fetchCustomer = jest.fn().mockImplementation(() => { + throw error.backendServiceFailure(); + }); deleteAccountIfUnverifiedStub.mockImplementation(() => { throw error.verifiedSecondaryEmailAlreadyExists(); }); @@ -355,9 +393,9 @@ describe('subscriptions payPalRoutes', () => { describe('customer that is has an incomplete subscription', () => { it('throws a user is already subscribed to product error', async () => { - capabilityService.getPlanEligibility = sinon.fake.resolves( - SubscriptionEligibilityResult.UPGRADE - ); + capabilityService.getPlanEligibility = jest + .fn() + .mockResolvedValue(SubscriptionEligibilityResult.UPGRADE); try { await runTest('/oauth/subscriptions/active/new-paypal', { @@ -373,9 +411,9 @@ describe('subscriptions payPalRoutes', () => { describe('customer that is ineligible for product', () => { it('throws a user is already subscribed to product error', async () => { - capabilityService.getPlanEligibility = sinon.fake.resolves( - SubscriptionEligibilityResult.UPGRADE - ); + capabilityService.getPlanEligibility = jest + .fn() + .mockResolvedValue(SubscriptionEligibilityResult.UPGRADE); try { await runTest('/oauth/subscriptions/active/new-paypal', { @@ -389,12 +427,14 @@ describe('subscriptions payPalRoutes', () => { }); it('should cleanup incomplete subscriptions', async () => { - stripeHelper.findCustomerSubscriptionByPlanId = sinon.fake.returns({ - status: 'incomplete', - }); - capabilityService.getPlanEligibility = sinon.fake.resolves( - SubscriptionEligibilityResult.UPGRADE - ); + stripeHelper.findCustomerSubscriptionByPlanId = jest + .fn() + .mockReturnValue({ + status: 'incomplete', + }); + capabilityService.getPlanEligibility = jest + .fn() + .mockResolvedValue(SubscriptionEligibilityResult.UPGRADE); try { await runTest('/oauth/subscriptions/active/new-paypal', { @@ -402,7 +442,7 @@ describe('subscriptions payPalRoutes', () => { payload: { token }, }); } catch (err: any) { - sinon.assert.calledOnce(stripeHelper.cancelSubscription); + expect(stripeHelper.cancelSubscription).toHaveBeenCalledTimes(1); } }); }); @@ -411,11 +451,13 @@ describe('subscriptions payPalRoutes', () => { it('throws a billing agreement already exists error', async () => { const c = deepCopy(customer); c.subscriptions.data[0].collection_method = 'send_invoice'; - stripeHelper.fetchCustomer = sinon.fake.resolves(c); - authDbModule.getAccountCustomerByUid = - sinon.fake.resolves(accountCustomer); - stripeHelper.getCustomerPaypalAgreement = - sinon.fake.returns(paypalAgreementId); + stripeHelper.fetchCustomer = jest.fn().mockResolvedValue(c); + authDbModule.getAccountCustomerByUid = jest + .fn() + .mockResolvedValue(accountCustomer); + stripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(paypalAgreementId); try { await runTest('/oauth/subscriptions/active/new-paypal', { @@ -431,13 +473,17 @@ describe('subscriptions payPalRoutes', () => { describe('new subscription with a PayPal payment token', () => { beforeEach(() => { - authDbModule.getAccountCustomerByUid = - sinon.fake.resolves(accountCustomer); - stripeHelper.updateCustomerPaypalAgreement = sinon.fake.resolves({}); - stripeHelper.isCustomerTaxableWithSubscriptionCurrency = - sinon.fake.returns(true); - payPalHelper.processInvoice = sinon.fake.resolves({}); - payPalHelper.processZeroInvoice = sinon.fake.resolves({}); + authDbModule.getAccountCustomerByUid = jest + .fn() + .mockResolvedValue(accountCustomer); + stripeHelper.updateCustomerPaypalAgreement = jest + .fn() + .mockResolvedValue({}); + stripeHelper.isCustomerTaxableWithSubscriptionCurrency = jest + .fn() + .mockReturnValue(true); + payPalHelper.processInvoice = jest.fn().mockResolvedValue({}); + payPalHelper.processZeroInvoice = jest.fn().mockResolvedValue({}); }); function assertChargedSuccessfully(actual: any) { @@ -445,12 +491,16 @@ describe('subscriptions payPalRoutes', () => { sourceCountry: 'CA', subscription: filterSubscription(subscription), }); - sinon.assert.calledOnce(stripeHelper.fetchCustomer); - sinon.assert.calledOnce(payPalHelper.createBillingAgreement); - sinon.assert.calledOnce(payPalHelper.agreementDetails); - sinon.assert.calledOnce(stripeHelper.createSubscriptionWithPaypal); - sinon.assert.calledOnce(stripeHelper.updateCustomerPaypalAgreement); - sinon.assert.calledOnce(payPalHelper.processInvoice); + expect(stripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(payPalHelper.createBillingAgreement).toHaveBeenCalledTimes(1); + expect(payPalHelper.agreementDetails).toHaveBeenCalledTimes(1); + expect(stripeHelper.createSubscriptionWithPaypal).toHaveBeenCalledTimes( + 1 + ); + expect( + stripeHelper.updateCustomerPaypalAgreement + ).toHaveBeenCalledTimes(1); + expect(payPalHelper.processInvoice).toHaveBeenCalledTimes(1); } it('should run a charge successfully', async () => { @@ -466,17 +516,14 @@ describe('subscriptions payPalRoutes', () => { payload: { token }, }); assertChargedSuccessfully(actual); - sinon.assert.notCalled(stripeHelper.findValidPromoCode); - sinon.assert.calledWithExactly( - stripeHelper.createSubscriptionWithPaypal, - { - customer, - priceId: undefined, - promotionCode: undefined, - subIdempotencyKey: undefined, - automaticTax: true, - } - ); + expect(stripeHelper.findValidPromoCode).not.toHaveBeenCalled(); + expect(stripeHelper.createSubscriptionWithPaypal).toHaveBeenCalledWith({ + customer, + priceId: undefined, + promotionCode: undefined, + subIdempotencyKey: undefined, + automaticTax: true, + }); }); it('should run a charge successfully with coupon', async () => { @@ -492,21 +539,17 @@ describe('subscriptions payPalRoutes', () => { payload: { token, promotionCode: 'test-promo' }, }); assertChargedSuccessfully(actual); - sinon.assert.calledWithExactly( - stripeHelper.findValidPromoCode, + expect(stripeHelper.findValidPromoCode).toHaveBeenCalledWith( 'test-promo', undefined ); - sinon.assert.calledWithExactly( - stripeHelper.createSubscriptionWithPaypal, - { - customer, - priceId: undefined, - promotionCode, - subIdempotencyKey: undefined, - automaticTax: true, - } - ); + expect(stripeHelper.createSubscriptionWithPaypal).toHaveBeenCalledWith({ + customer, + priceId: undefined, + promotionCode, + subIdempotencyKey: undefined, + automaticTax: true, + }); }); it('should run a charge with automatic tax in unsupported region successfully', async () => { @@ -517,24 +560,22 @@ describe('subscriptions payPalRoutes', () => { state: 'Ontario', }, }; - stripeHelper.isCustomerTaxableWithSubscriptionCurrency = - sinon.fake.returns(false); + stripeHelper.isCustomerTaxableWithSubscriptionCurrency = jest + .fn() + .mockReturnValue(false); const actual = await runTest('/oauth/subscriptions/active/new-paypal', { ...requestOptions, payload: { token }, }); assertChargedSuccessfully(actual); - sinon.assert.notCalled(stripeHelper.findValidPromoCode); - sinon.assert.calledWithExactly( - stripeHelper.createSubscriptionWithPaypal, - { - customer, - priceId: undefined, - promotionCode: undefined, - subIdempotencyKey: undefined, - automaticTax: false, - } - ); + expect(stripeHelper.findValidPromoCode).not.toHaveBeenCalled(); + expect(stripeHelper.createSubscriptionWithPaypal).toHaveBeenCalledWith({ + customer, + priceId: undefined, + promotionCode: undefined, + subIdempotencyKey: undefined, + automaticTax: false, + }); }); it('should skip a zero charge successfully', async () => { @@ -547,12 +588,16 @@ describe('subscriptions payPalRoutes', () => { sourceCountry: 'CA', subscription: filterSubscription(subscription), }); - sinon.assert.calledOnce(stripeHelper.fetchCustomer); - sinon.assert.calledOnce(payPalHelper.createBillingAgreement); - sinon.assert.calledOnce(payPalHelper.agreementDetails); - sinon.assert.calledOnce(stripeHelper.createSubscriptionWithPaypal); - sinon.assert.calledOnce(stripeHelper.updateCustomerPaypalAgreement); - sinon.assert.calledOnce(payPalHelper.processZeroInvoice); + expect(stripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(payPalHelper.createBillingAgreement).toHaveBeenCalledTimes(1); + expect(payPalHelper.agreementDetails).toHaveBeenCalledTimes(1); + expect(stripeHelper.createSubscriptionWithPaypal).toHaveBeenCalledTimes( + 1 + ); + expect( + stripeHelper.updateCustomerPaypalAgreement + ).toHaveBeenCalledTimes(1); + expect(payPalHelper.processZeroInvoice).toHaveBeenCalledTimes(1); }); it('throws an error if customer is in unsupported location', async () => { @@ -579,9 +624,9 @@ describe('subscriptions payPalRoutes', () => { }); it('should throw an error if invalid promotion code', async () => { - stripeHelper.findValidPromoCode = sinon.fake.rejects( - error.invalidPromoCode('invalid-promo') - ); + stripeHelper.findValidPromoCode = jest + .fn() + .mockRejectedValue(error.invalidPromoCode('invalid-promo')); try { await runTest('/oauth/subscriptions/active/new-paypal', { ...defaultRequestOptions, @@ -591,15 +636,14 @@ describe('subscriptions payPalRoutes', () => { } catch (err: any) { expect(err.message).toBe('Invalid promotion code'); } - sinon.assert.calledWithExactly( - stripeHelper.findValidPromoCode, + expect(stripeHelper.findValidPromoCode).toHaveBeenCalledWith( 'invalid-promo', undefined ); }); it('should throw an error if planCurrency does not match billingAgreement country', async () => { - payPalHelper.agreementDetails = sinon.fake.resolves({ + payPalHelper.agreementDetails = jest.fn().mockResolvedValue({ firstName: 'Test', lastName: 'User', countryCode: 'AS', @@ -619,7 +663,7 @@ describe('subscriptions payPalRoutes', () => { it('should throw an error if billingAgreement country does not match planCurrency', async () => { plan.currency = 'eur'; - stripeHelper.findAbbrevPlanById = sinon.fake.resolves(plan); + stripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue(plan); try { await runTest('/oauth/subscriptions/active/new-paypal', { ...defaultRequestOptions, @@ -634,7 +678,9 @@ describe('subscriptions payPalRoutes', () => { }); it('should throw an error if the invoice processing fails', async () => { - payPalHelper.processInvoice = sinon.fake.rejects(error.paymentFailed()); + payPalHelper.processInvoice = jest + .fn() + .mockRejectedValue(error.paymentFailed()); try { await runTest('/oauth/subscriptions/active/new-paypal', { ...defaultRequestOptions, @@ -643,8 +689,8 @@ describe('subscriptions payPalRoutes', () => { throw new Error('Should have thrown an error'); } catch (err: any) { expect(err).toEqual(error.paymentFailed()); - sinon.assert.calledOnce(stripeHelper.cancelSubscription); - sinon.assert.calledOnce(payPalHelper.cancelBillingAgreement); + expect(stripeHelper.cancelSubscription).toHaveBeenCalledTimes(1); + expect(payPalHelper.cancelBillingAgreement).toHaveBeenCalledTimes(1); } }); }); @@ -662,13 +708,17 @@ describe('subscriptions payPalRoutes', () => { }, }; c.subscriptions.data[0].collection_method = 'send_invoice'; - stripeHelper.fetchCustomer = sinon.fake.resolves(c); - stripeHelper.isCustomerTaxableWithSubscriptionCurrency = - sinon.fake.returns(true); - stripeHelper.getCustomerPaypalAgreement = - sinon.fake.returns(paypalAgreementId); - payPalHelper.processInvoice = sinon.fake.resolves({}); - stripeHelper.updateCustomerPaypalAgreement = sinon.fake.resolves({}); + stripeHelper.fetchCustomer = jest.fn().mockResolvedValue(c); + stripeHelper.isCustomerTaxableWithSubscriptionCurrency = jest + .fn() + .mockReturnValue(true); + stripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(paypalAgreementId); + payPalHelper.processInvoice = jest.fn().mockResolvedValue({}); + stripeHelper.updateCustomerPaypalAgreement = jest + .fn() + .mockResolvedValue({}); }); it('should run a charge successfully', async () => { @@ -677,39 +727,40 @@ describe('subscriptions payPalRoutes', () => { defaultRequestOptions ); - sinon.assert.notCalled(payPalHelper.createBillingAgreement); - sinon.assert.notCalled(payPalHelper.agreementDetails); - sinon.assert.notCalled(stripeHelper.updateCustomerPaypalAgreement); - sinon.assert.notCalled(stripeHelper.findValidPromoCode); - sinon.assert.calledOnce(stripeHelper.customerTaxId); - sinon.assert.calledOnce(stripeHelper.addTaxIdToCustomer); - sinon.assert.calledWithExactly( - stripeHelper.createSubscriptionWithPaypal, - { - customer: { - ...customer, - address: { - country: 'GD', - }, - metadata: { - ...customer.metadata, - paypalAgreementId, - }, + expect(payPalHelper.createBillingAgreement).not.toHaveBeenCalled(); + expect(payPalHelper.agreementDetails).not.toHaveBeenCalled(); + expect( + stripeHelper.updateCustomerPaypalAgreement + ).not.toHaveBeenCalled(); + expect(stripeHelper.findValidPromoCode).not.toHaveBeenCalled(); + expect(stripeHelper.customerTaxId).toHaveBeenCalledTimes(1); + expect(stripeHelper.addTaxIdToCustomer).toHaveBeenCalledTimes(1); + expect(stripeHelper.createSubscriptionWithPaypal).toHaveBeenCalledWith({ + customer: { + ...customer, + address: { + country: 'GD', }, - priceId: undefined, - promotionCode: undefined, - subIdempotencyKey: undefined, - automaticTax: true, - } - ); + metadata: { + ...customer.metadata, + paypalAgreementId, + }, + }, + priceId: undefined, + promotionCode: undefined, + subIdempotencyKey: undefined, + automaticTax: true, + }); expect(actual).toEqual({ sourceCountry: 'GD', subscription: filterSubscription(subscription), }); - sinon.assert.calledOnce(stripeHelper.fetchCustomer); - sinon.assert.calledOnce(stripeHelper.createSubscriptionWithPaypal); - sinon.assert.calledOnce(payPalHelper.processInvoice); + expect(stripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(stripeHelper.createSubscriptionWithPaypal).toHaveBeenCalledTimes( + 1 + ); + expect(payPalHelper.processInvoice).toHaveBeenCalledTimes(1); }); it('should run a charge successfully with a coupon', async () => { @@ -720,48 +771,48 @@ describe('subscriptions payPalRoutes', () => { requestOptions ); - sinon.assert.notCalled(payPalHelper.createBillingAgreement); - sinon.assert.notCalled(payPalHelper.agreementDetails); - sinon.assert.notCalled(stripeHelper.updateCustomerPaypalAgreement); - sinon.assert.calledWithExactly( - stripeHelper.findValidPromoCode, + expect(payPalHelper.createBillingAgreement).not.toHaveBeenCalled(); + expect(payPalHelper.agreementDetails).not.toHaveBeenCalled(); + expect( + stripeHelper.updateCustomerPaypalAgreement + ).not.toHaveBeenCalled(); + expect(stripeHelper.findValidPromoCode).toHaveBeenCalledWith( 'test-promo', undefined ); - sinon.assert.calledOnce(stripeHelper.customerTaxId); - sinon.assert.calledOnce(stripeHelper.addTaxIdToCustomer); - sinon.assert.calledWithExactly( - stripeHelper.createSubscriptionWithPaypal, - { - customer: { - ...customer, - address: { - country: 'GD', - }, - metadata: { - ...customer.metadata, - paypalAgreementId, - }, + expect(stripeHelper.customerTaxId).toHaveBeenCalledTimes(1); + expect(stripeHelper.addTaxIdToCustomer).toHaveBeenCalledTimes(1); + expect(stripeHelper.createSubscriptionWithPaypal).toHaveBeenCalledWith({ + customer: { + ...customer, + address: { + country: 'GD', }, - priceId: undefined, - promotionCode, - subIdempotencyKey: undefined, - automaticTax: true, - } - ); + metadata: { + ...customer.metadata, + paypalAgreementId, + }, + }, + priceId: undefined, + promotionCode, + subIdempotencyKey: undefined, + automaticTax: true, + }); expect(actual).toEqual({ sourceCountry: 'GD', subscription: filterSubscription(subscription), }); - sinon.assert.calledOnce(stripeHelper.fetchCustomer); - sinon.assert.calledOnce(stripeHelper.createSubscriptionWithPaypal); - sinon.assert.calledOnce(payPalHelper.processInvoice); + expect(stripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(stripeHelper.createSubscriptionWithPaypal).toHaveBeenCalledTimes( + 1 + ); + expect(payPalHelper.processInvoice).toHaveBeenCalledTimes(1); }); it('should skip a zero charge successfully', async () => { subscription.latest_invoice.amount_due = 0; - payPalHelper.processZeroInvoice = sinon.fake.resolves({}); + payPalHelper.processZeroInvoice = jest.fn().mockResolvedValue({}); const actual = await runTest( '/oauth/subscriptions/active/new-paypal', defaultRequestOptions @@ -770,11 +821,13 @@ describe('subscriptions payPalRoutes', () => { sourceCountry: 'GD', subscription: filterSubscription(subscription), }); - sinon.assert.calledOnce(payPalHelper.processZeroInvoice); + expect(payPalHelper.processZeroInvoice).toHaveBeenCalledTimes(1); }); it('should throw an error if the invoice processing fails', async () => { - payPalHelper.processInvoice = sinon.fake.rejects(error.paymentFailed()); + payPalHelper.processInvoice = jest + .fn() + .mockRejectedValue(error.paymentFailed()); try { await runTest( '/oauth/subscriptions/active/new-paypal', @@ -783,8 +836,8 @@ describe('subscriptions payPalRoutes', () => { throw new Error('Should have thrown an error'); } catch (err: any) { expect(err).toEqual(error.paymentFailed()); - sinon.assert.calledOnce(stripeHelper.cancelSubscription); - sinon.assert.notCalled(payPalHelper.cancelBillingAgreement); + expect(stripeHelper.cancelSubscription).toHaveBeenCalledTimes(1); + expect(payPalHelper.cancelBillingAgreement).not.toHaveBeenCalled(); } }); }); @@ -802,28 +855,34 @@ describe('subscriptions payPalRoutes', () => { } } - authDbModule.getAccountCustomerByUid = - sinon.fake.resolves(accountCustomer); - stripeHelper.getCustomerPaypalAgreement = sinon.fake.returns(undefined); - stripeHelper.fetchOpenInvoices.returns(genInvoice()); - profile.deleteCache = sinon.fake.resolves({}); - push.notifyProfileUpdated = sinon.fake.resolves({}); + authDbModule.getAccountCustomerByUid = jest + .fn() + .mockResolvedValue(accountCustomer); + stripeHelper.getCustomerPaypalAgreement = jest + .fn() + .mockReturnValue(undefined); + stripeHelper.fetchOpenInvoices.mockReturnValue(genInvoice()); + profile.deleteCache = jest.fn().mockResolvedValue({}); + push.notifyProfileUpdated = jest.fn().mockResolvedValue({}); plan = deepCopy(planFixture); customer = deepCopy(customerFixture); subscription = deepCopy(subscription2); subscription.collection_method = 'send_invoice'; subscription.latest_invoice = deepCopy(openInvoice); customer.subscriptions.data = [subscription]; - stripeHelper.fetchCustomer = sinon.fake.resolves(customer); - stripeHelper.findAbbrevPlanById = sinon.fake.resolves(plan); - payPalHelper.createBillingAgreement = sinon.fake.resolves('B-test'); - payPalHelper.agreementDetails = sinon.fake.resolves({ + stripeHelper.fetchCustomer = jest.fn().mockResolvedValue(customer); + stripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue(plan); + payPalHelper.createBillingAgreement = jest + .fn() + .mockResolvedValue('B-test'); + payPalHelper.agreementDetails = jest.fn().mockResolvedValue({ firstName: 'Test', lastName: 'User', countryCode: 'CA', }); - stripeHelper.updateCustomerPaypalAgreement = - sinon.fake.resolves(customer); + stripeHelper.updateCustomerPaypalAgreement = jest + .fn() + .mockResolvedValue(customer); }); it('should update the billing agreement and process invoice', async () => { @@ -841,33 +900,37 @@ describe('subscriptions payPalRoutes', () => { requestOptions ); expect(actual).toEqual(filterCustomer(customer)); - sinon.assert.calledOnce(stripeHelper.fetchCustomer); - sinon.assert.calledOnce(payPalHelper.createBillingAgreement); - sinon.assert.calledOnce(payPalHelper.agreementDetails); - sinon.assert.calledOnce(stripeHelper.updateCustomerPaypalAgreement); - sinon.assert.calledOnce(stripeHelper.fetchOpenInvoices); - sinon.assert.calledOnce(stripeHelper.getCustomerPaypalAgreement); - sinon.assert.calledOnce(payPalHelper.processInvoice); + expect(stripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(payPalHelper.createBillingAgreement).toHaveBeenCalledTimes(1); + expect(payPalHelper.agreementDetails).toHaveBeenCalledTimes(1); + expect(stripeHelper.updateCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(stripeHelper.fetchOpenInvoices).toHaveBeenCalledTimes(1); + expect(stripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes(1); + expect(payPalHelper.processInvoice).toHaveBeenCalledTimes(1); }); it('should update the billing agreement and process zero invoice', async () => { subscription.latest_invoice.amount_due = 0; invoices.push(subscription.latest_invoice); subscription.latest_invoice.subscription = subscription; - payPalHelper.processZeroInvoice = sinon.fake.resolves({}); + payPalHelper.processZeroInvoice = jest.fn().mockResolvedValue({}); const actual = await runTest( '/oauth/subscriptions/paymentmethod/billing-agreement', defaultRequestOptions ); expect(actual).toEqual(filterCustomer(customer)); - sinon.assert.calledOnce(stripeHelper.fetchCustomer); - sinon.assert.calledOnce(payPalHelper.createBillingAgreement); - sinon.assert.calledOnce(payPalHelper.agreementDetails); - sinon.assert.calledOnce(stripeHelper.updateCustomerPaypalAgreement); - sinon.assert.calledOnce(stripeHelper.fetchOpenInvoices); - sinon.assert.calledOnce(stripeHelper.getCustomerPaypalAgreement); - sinon.assert.calledOnce(payPalHelper.processZeroInvoice); - sinon.assert.notCalled(payPalHelper.processInvoice); + expect(stripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(payPalHelper.createBillingAgreement).toHaveBeenCalledTimes(1); + expect(payPalHelper.agreementDetails).toHaveBeenCalledTimes(1); + expect(stripeHelper.updateCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(stripeHelper.fetchOpenInvoices).toHaveBeenCalledTimes(1); + expect(stripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes(1); + expect(payPalHelper.processZeroInvoice).toHaveBeenCalledTimes(1); + expect(payPalHelper.processInvoice).not.toHaveBeenCalled(); }); it('should update the billing agreement', async () => { @@ -876,18 +939,20 @@ describe('subscriptions payPalRoutes', () => { defaultRequestOptions ); expect(actual).toEqual(filterCustomer(customer)); - sinon.assert.calledOnce(stripeHelper.fetchCustomer); - sinon.assert.calledOnce(payPalHelper.createBillingAgreement); - sinon.assert.calledOnce(payPalHelper.agreementDetails); - sinon.assert.calledOnce(stripeHelper.updateCustomerPaypalAgreement); - sinon.assert.calledOnce(stripeHelper.fetchOpenInvoices); - sinon.assert.calledOnce(stripeHelper.getCustomerPaypalAgreement); - sinon.assert.notCalled(payPalHelper.processInvoice); + expect(stripeHelper.fetchCustomer).toHaveBeenCalledTimes(1); + expect(payPalHelper.createBillingAgreement).toHaveBeenCalledTimes(1); + expect(payPalHelper.agreementDetails).toHaveBeenCalledTimes(1); + expect(stripeHelper.updateCustomerPaypalAgreement).toHaveBeenCalledTimes( + 1 + ); + expect(stripeHelper.fetchOpenInvoices).toHaveBeenCalledTimes(1); + expect(stripeHelper.getCustomerPaypalAgreement).toHaveBeenCalledTimes(1); + expect(payPalHelper.processInvoice).not.toHaveBeenCalled(); }); it('should throw an error if billingAgreement country does not match planCurrency', async () => { customer.currency = 'eur'; - stripeHelper.findAbbrevPlanById = sinon.fake.resolves(plan); + stripeHelper.findAbbrevPlanById = jest.fn().mockResolvedValue(plan); try { await runTest( '/oauth/subscriptions/paymentmethod/billing-agreement', diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/play-pubsub.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/play-pubsub.spec.ts index aebc8ac227f..edee235464a 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/play-pubsub.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/play-pubsub.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; const uuid = require('uuid'); @@ -17,7 +16,6 @@ const TEST_EMAIL = 'test@email.com'; const UID = uuid.v4({}, Buffer.alloc(16)).toString('hex'); describe('PlayPubsubHandler', () => { - let sandbox: sinon.SinonSandbox; let playPubsubHandlerInstance: any; let mockRequest: any; let mockPlayBilling: any; @@ -28,8 +26,6 @@ describe('PlayPubsubHandler', () => { let mockPurchase: any; beforeEach(() => { - sandbox = sinon.createSandbox(); - log = mocks.mockLog(); db = mocks.mockDB({ uid: UID, @@ -48,85 +44,95 @@ describe('PlayPubsubHandler', () => { mockDeveloperNotification.subscriptionNotification = { purchaseToken: 'test', }; - db.account = sinon.fake.resolves({ primaryEmail: { email: TEST_EMAIL } }); - mockCapabilityService.iapUpdate = sinon.fake.resolves({}); + db.account = jest + .fn() + .mockResolvedValue({ primaryEmail: { email: TEST_EMAIL } }); + mockCapabilityService.iapUpdate = jest.fn().mockResolvedValue({}); Container.set(AuthLogger, log); Container.set(PlayBilling, mockPlayBilling); Container.set(CapabilityService, mockCapabilityService); playPubsubHandlerInstance = new PlayPubsubHandler(db); - playPubsubHandlerInstance.extractMessage = sinon.fake.returns( - mockDeveloperNotification - ); + playPubsubHandlerInstance.extractMessage = jest + .fn() + .mockReturnValue(mockDeveloperNotification); mockRequest.payload = { message: { data: 'BASE64DATA' }, }; mockPlayBilling.purchaseManager = { - getPurchase: sinon.fake.resolves(mockPurchase), - processDeveloperNotification: sinon.fake.resolves({}), + getPurchase: jest.fn().mockResolvedValue(mockPurchase), + processDeveloperNotification: jest.fn().mockResolvedValue({}), }; }); afterEach(() => { Container.reset(); - sandbox.restore(); + jest.restoreAllMocks(); }); describe('rtdn', () => { it('notification that requires profile updating', async () => { const result = await playPubsubHandlerInstance.rtdn(mockRequest); expect(result).toEqual({}); - sinon.assert.calledOnce(playPubsubHandlerInstance.extractMessage); - sinon.assert.calledOnce(mockPlayBilling.purchaseManager.getPurchase); - sinon.assert.calledOnce( - mockPlayBilling.purchaseManager.processDeveloperNotification + expect(playPubsubHandlerInstance.extractMessage).toHaveBeenCalledTimes(1); + expect(mockPlayBilling.purchaseManager.getPurchase).toHaveBeenCalledTimes( + 1 ); - sinon.assert.calledOnce(mockCapabilityService.iapUpdate); + expect( + mockPlayBilling.purchaseManager.processDeveloperNotification + ).toHaveBeenCalledTimes(1); + expect(mockCapabilityService.iapUpdate).toHaveBeenCalledTimes(1); }); it('test notification', async () => { mockDeveloperNotification.testNotification = true; const result = await playPubsubHandlerInstance.rtdn(mockRequest); expect(result).toEqual({}); - sinon.assert.calledOnceWithExactly( - log.info, + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith( 'play-test-notification', mockDeveloperNotification ); - sinon.assert.notCalled(mockPlayBilling.purchaseManager.getPurchase); + expect( + mockPlayBilling.purchaseManager.getPurchase + ).not.toHaveBeenCalled(); }); it('missing subscription notification', async () => { mockDeveloperNotification.subscriptionNotification = null; const result = await playPubsubHandlerInstance.rtdn(mockRequest); expect(result).toEqual({}); - sinon.assert.calledOnceWithExactly( - log.info, + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith( 'play-other-notification', mockDeveloperNotification ); - sinon.assert.notCalled(mockPlayBilling.purchaseManager.getPurchase); + expect( + mockPlayBilling.purchaseManager.getPurchase + ).not.toHaveBeenCalled(); }); it('non-existing purchase', async () => { - mockPlayBilling.purchaseManager.getPurchase = sinon.fake.resolves(null); + mockPlayBilling.purchaseManager.getPurchase = jest + .fn() + .mockResolvedValue(null); const result = await playPubsubHandlerInstance.rtdn(mockRequest); expect(result).toEqual({}); - sinon.assert.calledOnce( + expect( mockPlayBilling.purchaseManager.processDeveloperNotification - ); - sinon.assert.notCalled(db.account); + ).toHaveBeenCalledTimes(1); + expect(db.account).not.toHaveBeenCalled(); }); it('no userId', async () => { mockPurchase.userId = null; const result = await playPubsubHandlerInstance.rtdn(mockRequest); expect(result).toEqual({}); - sinon.assert.notCalled( + expect( mockPlayBilling.purchaseManager.processDeveloperNotification - ); - sinon.assert.notCalled(db.account); + ).not.toHaveBeenCalled(); + expect(db.account).not.toHaveBeenCalled(); }); it('replaced purchase', async () => { @@ -134,10 +140,10 @@ describe('PlayPubsubHandler', () => { mockPurchase.replacedByAnotherPurchase = true; const result = await playPubsubHandlerInstance.rtdn(mockRequest); expect(result).toEqual({}); - sinon.assert.notCalled( + expect( mockPlayBilling.purchaseManager.processDeveloperNotification - ); - sinon.assert.notCalled(db.account); + ).not.toHaveBeenCalled(); + expect(db.account).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhooks.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhooks.spec.ts index 43818754d63..747e02d4b11 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhooks.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhooks.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { default as Container } from 'typedi'; const uuid = require('uuid'); @@ -79,14 +78,11 @@ function deepCopy(object: any) { } describe('StripeWebhookHandler', () => { - let sandbox: sinon.SinonSandbox; let StripeWebhookHandlerInstance: any; beforeEach(() => { - sandbox = sinon.createSandbox(); - mockCapabilityService = { - stripeUpdate: sandbox.stub().resolves({}), + stripeUpdate: jest.fn().mockResolvedValue({}), }; config = { @@ -109,7 +105,7 @@ describe('StripeWebhookHandler', () => { log = mocks.mockLog(); customs = mocks.mockCustoms(); profile = mocks.mockProfile({ - deleteCache: sinon.spy(async (uid: any) => ({})), + deleteCache: jest.fn(async (uid: any) => ({})), }); mailer = mocks.mockMailer(); @@ -118,8 +114,28 @@ describe('StripeWebhookHandler', () => { email: TEST_EMAIL, locale: ACCOUNT_LOCALE, }); - const stripeHelperMock = sandbox.createStubInstance(StripeHelper); - const paypalHelperMock = sandbox.createStubInstance(PayPalHelper); + const stripeHelperMock: any = {}; + for ( + let p = StripeHelper.prototype; + p && p !== Object.prototype; + p = Object.getPrototypeOf(p) + ) { + Object.getOwnPropertyNames(p).forEach((m) => { + if (m !== 'constructor' && !stripeHelperMock[m]) + stripeHelperMock[m] = jest.fn(); + }); + } + const paypalHelperMock: any = {}; + for ( + let p = PayPalHelper.prototype; + p && p !== Object.prototype; + p = Object.getPrototypeOf(p) + ) { + Object.getOwnPropertyNames(p).forEach((m) => { + if (m !== 'constructor' && !paypalHelperMock[m]) + paypalHelperMock[m] = jest.fn(); + }); + } Container.set(CurrencyHelper, {}); Container.set(PayPalHelper, paypalHelperMock); Container.set(StripeHelper, stripeHelperMock); @@ -136,15 +152,17 @@ describe('StripeWebhookHandler', () => { stripeHelperMock ); - sandbox.stub(authDbModule, 'getUidAndEmailByStripeCustomerId').resolves({ - uid: UID, - email: TEST_EMAIL, - }); + jest + .spyOn(authDbModule, 'getUidAndEmailByStripeCustomerId') + .mockResolvedValue({ + uid: UID, + email: TEST_EMAIL, + }); }); afterEach(() => { Container.reset(); - sandbox.restore(); + jest.restoreAllMocks(); }); describe('stripe webhooks', () => { @@ -159,16 +177,20 @@ describe('StripeWebhookHandler', () => { const validProduct = deepCopy(eventProductUpdated); beforeEach(() => { - StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.returns( + StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.mockReturnValue( asyncIterable(deepCopy(validPlanList)) ); - StripeWebhookHandlerInstance.stripeHelper.fetchProductById.returns({ - product_id: validProduct.data.object.id, - product_name: validProduct.data.object.name, - product_metadata: validProduct.data.object.metadata, - }); - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves({}); - StripeWebhookHandlerInstance.stripeHelper.getCard.resolves({}); + StripeWebhookHandlerInstance.stripeHelper.fetchProductById.mockReturnValue( + { + product_id: validProduct.data.object.id, + product_name: validProduct.data.object.name, + product_metadata: validProduct.data.object.metadata, + } + ); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( + {} + ); + StripeWebhookHandlerInstance.stripeHelper.getCard.mockResolvedValue({}); }); describe('handleWebhookEvent', () => { @@ -201,17 +223,19 @@ describe('StripeWebhookHandler', () => { beforeEach(() => { for (const handlerName of handlerNames) { - handlerStubs[handlerName] = sandbox - .stub(StripeWebhookHandlerInstance, handlerName) - .resolves(); + handlerStubs[handlerName] = jest + .spyOn(StripeWebhookHandlerInstance, handlerName) + .mockResolvedValue(undefined); } - scopeContextSpy = sinon.fake(); - scopeExtraSpy = sinon.fake(); + scopeContextSpy = jest.fn(); + scopeExtraSpy = jest.fn(); scopeSpy = { setContext: scopeContextSpy, setExtra: scopeExtraSpy, }; - sandbox.replace(Sentry, 'withScope', (fn: any) => fn(scopeSpy)); + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((fn: any) => fn(scopeSpy)); }); const assertNamedHandlerCalled = ( @@ -220,9 +244,11 @@ describe('StripeWebhookHandler', () => { for (const handlerName of handlerNames) { const shouldCall = expectedHandlerName && handlerName === expectedHandlerName; - expect( - handlerStubs[handlerName][shouldCall ? 'called' : 'notCalled'] - ).toBe(true); + if (shouldCall) { + expect(handlerStubs[handlerName]).toHaveBeenCalled(); + } else { + expect(handlerStubs[handlerName]).not.toHaveBeenCalled(); + } } }; @@ -234,40 +260,48 @@ describe('StripeWebhookHandler', () => { ) => it(`only calls ${expectedHandlerName}`, async () => { const createdEvent = deepCopy(event); - StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.returns( + StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.mockReturnValue( createdEvent ); await StripeWebhookHandlerInstance.handleWebhookEvent(request); assertNamedHandlerCalled(expectedHandlerName); if (expectSentry) { - expect(scopeContextSpy.notCalled).toBe(false); + expect(scopeContextSpy).toHaveBeenCalled(); } else { - expect(scopeContextSpy.notCalled).toBe(true); + expect(scopeContextSpy).not.toHaveBeenCalled(); } if (expectedHandlerName === 'handleCustomerSourceExpiringEvent') { - sinon.assert.calledOnce( + expect( StripeWebhookHandlerInstance.stripeHelper.getCard - ); + ).toHaveBeenCalledTimes(1); } else { - expect( - StripeWebhookHandlerInstance.stripeHelper.expandResource - .calledOnce - ).toBe(expectExpandResource); + if (expectExpandResource) { + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + } else { + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).not.toHaveBeenCalled(); + } } }); describe('ignorable errors', () => { const commonIgnorableErrorTest = (expectedError: any) => async () => { const fixture = deepCopy(eventCustomerSourceExpiring); - handlerStubs.handleCustomerSourceExpiringEvent.throws(expectedError); - StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.returns( + handlerStubs.handleCustomerSourceExpiringEvent.mockImplementation( + () => { + throw expectedError; + } + ); + StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.mockReturnValue( fixture ); let errorThrown: any = null; try { await StripeWebhookHandlerInstance.handleWebhookEvent(request); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.log.error, + expect(StripeWebhookHandlerInstance.log.error).toHaveBeenCalledWith( 'subscriptions.handleWebhookEvent.failure', { error: expectedError } ); @@ -300,7 +334,7 @@ describe('StripeWebhookHandler', () => { describe('FirestoreStripeErrorBuilder errors', () => { beforeEach(() => { const fixture = deepCopy(eventCustomerSourceExpiring); - StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.returns( + StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.mockReturnValue( fixture ); }); @@ -310,7 +344,11 @@ describe('StripeWebhookHandler', () => { 'testError', FirestoreStripeError.FIRESTORE_SUBSCRIPTION_NOT_FOUND ); - handlerStubs.handleCustomerSourceExpiringEvent.throws(expectedError); + handlerStubs.handleCustomerSourceExpiringEvent.mockImplementation( + () => { + throw expectedError; + } + ); try { await StripeWebhookHandlerInstance.handleWebhookEvent(request); throw new Error('handleWebhookEvent should throw an error'); @@ -326,10 +364,14 @@ describe('StripeWebhookHandler', () => { 'cus_123' ); const expectedError = new Error('UnknownError'); - handlerStubs.handleCustomerSourceExpiringEvent.throws(handlerError); - sandbox - .stub(StripeWebhookHandlerInstance, 'checkIfAccountExists') - .rejects(expectedError); + handlerStubs.handleCustomerSourceExpiringEvent.mockImplementation( + () => { + throw handlerError; + } + ); + jest + .spyOn(StripeWebhookHandlerInstance, 'checkIfAccountExists') + .mockRejectedValue(expectedError); try { await StripeWebhookHandlerInstance.handleWebhookEvent(request); @@ -345,10 +387,14 @@ describe('StripeWebhookHandler', () => { FirestoreStripeError.FIRESTORE_SUBSCRIPTION_NOT_FOUND, 'cus_123' ); - handlerStubs.handleCustomerSourceExpiringEvent.throws(expectedError); - sandbox - .stub(StripeWebhookHandlerInstance, 'checkIfAccountExists') - .resolves(true); + handlerStubs.handleCustomerSourceExpiringEvent.mockImplementation( + () => { + throw expectedError; + } + ); + jest + .spyOn(StripeWebhookHandlerInstance, 'checkIfAccountExists') + .mockResolvedValue(true); try { await StripeWebhookHandlerInstance.handleWebhookEvent(request); throw new Error('handleWebhookEvent should throw an error'); @@ -364,17 +410,20 @@ describe('StripeWebhookHandler', () => { FirestoreStripeError.FIRESTORE_SUBSCRIPTION_NOT_FOUND, 'cus_123' ); - handlerStubs.handleCustomerSourceExpiringEvent.throws(expectedError); - sandbox - .stub(StripeWebhookHandlerInstance, 'checkIfAccountExists') - .resolves(false); + handlerStubs.handleCustomerSourceExpiringEvent.mockImplementation( + () => { + throw expectedError; + } + ); + jest + .spyOn(StripeWebhookHandlerInstance, 'checkIfAccountExists') + .mockResolvedValue(false); try { await StripeWebhookHandlerInstance.handleWebhookEvent(request); } catch (err) { errorThrown = err; } - sinon.assert.calledWith( - StripeWebhookHandlerInstance.log.error, + expect(StripeWebhookHandlerInstance.log.error).toHaveBeenCalledWith( 'subscriptions.handleWebhookEvent.failure', { error: expectedError } ); @@ -515,41 +564,41 @@ describe('StripeWebhookHandler', () => { it('only calls sentry', async () => { const event = deepCopy(subscriptionCreated); event.type = 'application_fee.refunded'; - StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.returns( + StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.mockReturnValue( event ); await StripeWebhookHandlerInstance.handleWebhookEvent(request); assertNamedHandlerCalled(); - expect(scopeContextSpy.calledOnce).toBe(true); + expect(scopeContextSpy).toHaveBeenCalledTimes(1); }); it('does not call sentry or expand resource for event payment_method.detached', async () => { const event = deepCopy(subscriptionCreated); event.type = 'payment_method.detached'; - StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.returns( + StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.mockReturnValue( event ); StripeWebhookHandlerInstance.stripeHelper.processWebhookEventToFirestore = - sinon.stub().resolves(true); + jest.fn().mockResolvedValue(true); await StripeWebhookHandlerInstance.handleWebhookEvent(request); assertNamedHandlerCalled(); expect( - StripeWebhookHandlerInstance.stripeHelper.expandResource.calledOnce - ).toBe(false); - sinon.assert.notCalled(scopeContextSpy); + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).not.toHaveBeenCalled(); + expect(scopeContextSpy).not.toHaveBeenCalled(); }); it('does not call sentry if handled by firestore', async () => { const event = deepCopy(subscriptionCreated); event.type = 'firestore.document.created'; - StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.returns( + StripeWebhookHandlerInstance.stripeHelper.constructWebhookEvent.mockReturnValue( event ); StripeWebhookHandlerInstance.stripeHelper.processWebhookEventToFirestore = - sinon.stub().resolves(true); + jest.fn().mockResolvedValue(true); await StripeWebhookHandlerInstance.handleWebhookEvent(request); assertNamedHandlerCalled(); - sinon.assert.notCalled(scopeContextSpy); + expect(scopeContextSpy).not.toHaveBeenCalled(); }); }); }); @@ -562,10 +611,12 @@ describe('StripeWebhookHandler', () => { const coupon = deepCopy(event.data.object); const sentryMod = require('../../sentry'); coupon.applies_to = { products: [] }; - sandbox.stub(sentryMod, 'reportSentryError').returns({}); - StripeWebhookHandlerInstance.stripeHelper.getCoupon.resolves(coupon); + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); + StripeWebhookHandlerInstance.stripeHelper.getCoupon.mockResolvedValue( + coupon + ); await StripeWebhookHandlerInstance.handleCouponEvent({}, event); - sinon.assert.notCalled(sentryMod.reportSentryError); + expect(sentryMod.reportSentryError).not.toHaveBeenCalled(); }); it(`reports an error for invalid coupon on ${eventType}`, async () => { @@ -574,10 +625,12 @@ describe('StripeWebhookHandler', () => { const coupon = deepCopy(event.data.object); const sentryMod = require('../../sentry'); coupon.applies_to = { products: ['productOhNo'] }; - sandbox.stub(sentryMod, 'reportSentryError').returns({}); - StripeWebhookHandlerInstance.stripeHelper.getCoupon.resolves(coupon); + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); + StripeWebhookHandlerInstance.stripeHelper.getCoupon.mockResolvedValue( + coupon + ); await StripeWebhookHandlerInstance.handleCouponEvent({}, event); - sinon.assert.calledOnce(sentryMod.reportSentryError); + expect(sentryMod.reportSentryError).toHaveBeenCalledTimes(1); }); } }); @@ -592,15 +645,18 @@ describe('StripeWebhookHandler', () => { } ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.db.accountRecord, - customerFixture.email - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.createLocalCustomer, - UID, - customerFixture - ); + expect( + StripeWebhookHandlerInstance.db.accountRecord + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.db.accountRecord + ).toHaveBeenCalledWith(customerFixture.email); + expect( + StripeWebhookHandlerInstance.stripeHelper.createLocalCustomer + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.createLocalCustomer + ).toHaveBeenCalledWith(UID, customerFixture); }); }); @@ -608,7 +664,7 @@ describe('StripeWebhookHandler', () => { it('removes the customer if the account exists', async () => { const authDb = require('fxa-shared/db/models/auth'); const account = { email: customerFixture.email }; - sandbox.stub(authDb.Account, 'findByUid').resolves(account); + jest.spyOn(authDb.Account, 'findByUid').mockResolvedValue(account); await StripeWebhookHandlerInstance.handleCustomerUpdatedEvent( {}, { @@ -616,8 +672,8 @@ describe('StripeWebhookHandler', () => { type: 'customer.updated', } ); - sinon.assert.calledOnceWithExactly( - authDb.Account.findByUid, + expect(authDb.Account.findByUid).toHaveBeenCalledTimes(1); + expect(authDb.Account.findByUid).toHaveBeenCalledWith( customerFixture.metadata.userid, { include: ['emails'] } ); @@ -626,8 +682,8 @@ describe('StripeWebhookHandler', () => { it('reports sentry error with no customer found', async () => { const authDb = require('fxa-shared/db/models/auth'); const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); - sandbox.stub(authDb.Account, 'findByUid').resolves(null); + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); + jest.spyOn(authDb.Account, 'findByUid').mockResolvedValue(null); await StripeWebhookHandlerInstance.handleCustomerUpdatedEvent( {}, { @@ -636,14 +692,14 @@ describe('StripeWebhookHandler', () => { request: {}, } ); - sinon.assert.calledOnce(sentryMod.reportSentryError); + expect(sentryMod.reportSentryError).toHaveBeenCalledTimes(1); }); it('does not report error with no customer if the customer was deleted', async () => { const authDb = require('fxa-shared/db/models/auth'); const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); - sandbox.stub(authDb.Account, 'findByUid').resolves(null); + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); + jest.spyOn(authDb.Account, 'findByUid').mockResolvedValue(null); const customer = deepCopy(customerFixture); customer.deleted = true; await StripeWebhookHandlerInstance.handleCustomerUpdatedEvent( @@ -653,13 +709,13 @@ describe('StripeWebhookHandler', () => { type: 'customer.updated', } ); - sinon.assert.notCalled(sentryMod.reportSentryError); + expect(sentryMod.reportSentryError).not.toHaveBeenCalled(); }); it('does not report error with no customer if the account does not exist but it was an api call', async () => { const authDb = require('fxa-shared/db/models/auth'); - sandbox.stub(sentryModule, 'reportSentryError').returns({}); - sandbox.stub(authDb.Account, 'findByUid').resolves(null); + jest.spyOn(sentryModule, 'reportSentryError').mockReturnValue({}); + jest.spyOn(authDb.Account, 'findByUid').mockResolvedValue(null); const customer = deepCopy(customerFixture); await StripeWebhookHandlerInstance.handleCustomerUpdatedEvent( {}, @@ -671,21 +727,27 @@ describe('StripeWebhookHandler', () => { }, } ); - sinon.assert.notCalled(sentryModule.reportSentryError); + expect(sentryModule.reportSentryError).not.toHaveBeenCalled(); }); }); describe('handleProductWebhookEvent', () => { let scopeContextSpy: any, scopeSpy: any, captureMessageSpy: any; beforeEach(() => { - captureMessageSpy = sinon.fake(); - scopeContextSpy = sinon.fake(); + captureMessageSpy = jest.fn(); + scopeContextSpy = jest.fn(); scopeSpy = { setContext: scopeContextSpy, }; - sandbox.replace(Sentry, 'withScope', (fn: any) => fn(scopeSpy)); - sandbox.replace(Sentry, 'captureMessage', captureMessageSpy); - StripeWebhookHandlerInstance.stripeHelper.allProducts.resolves([]); + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((fn: any) => fn(scopeSpy)); + jest + .spyOn(Sentry, 'captureMessage') + .mockImplementation(captureMessageSpy); + StripeWebhookHandlerInstance.stripeHelper.allProducts.mockResolvedValue( + [] + ); }); it('throws a sentry error if the update event data is invalid', async () => { @@ -699,10 +761,10 @@ describe('StripeWebhookHandler', () => { product: updatedEvent.data.object, }; const allPlans = [...validPlanList, invalidPlan]; - StripeWebhookHandlerInstance.stripeHelper.fetchAllPlans.resolves( + StripeWebhookHandlerInstance.stripeHelper.fetchAllPlans.mockResolvedValue( allPlans ); - StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.resolves( + StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.mockResolvedValue( [invalidPlan] ); await StripeWebhookHandlerInstance.handleProductWebhookEvent( @@ -710,32 +772,38 @@ describe('StripeWebhookHandler', () => { updatedEvent ); - sinon.assert.calledOnce(scopeContextSpy); - sinon.assert.calledOnce(captureMessageSpy); + expect(scopeContextSpy).toHaveBeenCalledTimes(1); + expect(captureMessageSpy).toHaveBeenCalledTimes(1); - sinon.assert.calledOnce( + expect( StripeWebhookHandlerInstance.stripeHelper.fetchAllPlans - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId, - updatedEvent.data.object.id - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllProducts, - [updatedEvent.data.object] - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllPlans, - validPlanList - ); + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId + ).toHaveBeenCalledWith(updatedEvent.data.object.id); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllProducts + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllProducts + ).toHaveBeenCalledWith([updatedEvent.data.object]); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledWith(validPlanList); }); it('does not throw a sentry error if the update event data is valid', async () => { const updatedEvent = deepCopy(eventProductUpdated); - StripeWebhookHandlerInstance.stripeHelper.fetchAllPlans.resolves( + StripeWebhookHandlerInstance.stripeHelper.fetchAllPlans.mockResolvedValue( validPlanList ); - StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.resolves( + StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.mockResolvedValue( validPlanList ); await StripeWebhookHandlerInstance.handleProductWebhookEvent( @@ -743,7 +811,7 @@ describe('StripeWebhookHandler', () => { updatedEvent ); - expect(scopeContextSpy.notCalled).toBe(true); + expect(scopeContextSpy).not.toHaveBeenCalled(); }); it('updates the cached products and remove the plans on a product.deleted', async () => { @@ -751,24 +819,28 @@ describe('StripeWebhookHandler', () => { ...deepCopy(eventProductUpdated), type: 'product.deleted', }; - StripeWebhookHandlerInstance.stripeHelper.fetchAllPlans.resolves( + StripeWebhookHandlerInstance.stripeHelper.fetchAllPlans.mockResolvedValue( validPlanList ); - StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.resolves( + StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.mockResolvedValue( validPlanList ); await StripeWebhookHandlerInstance.handleProductWebhookEvent( {}, deletedEvent ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllProducts, - [deletedEvent.data.object] - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllPlans, - [] - ); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllProducts + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllProducts + ).toHaveBeenCalledWith([deletedEvent.data.object]); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledWith([]); }); it('update all plans when Firestore product config feature flag is set to true', async () => { @@ -780,32 +852,33 @@ describe('StripeWebhookHandler', () => { product: updatedEvent.data.object, }; const allPlans = [...validPlanList, invalidPlan]; - StripeWebhookHandlerInstance.stripeHelper.fetchAllPlans.resolves( + StripeWebhookHandlerInstance.stripeHelper.fetchAllPlans.mockResolvedValue( allPlans ); - StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.resolves( - allPlans - ); - StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.resolves( + StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.mockResolvedValue( allPlans ); await StripeWebhookHandlerInstance.handleProductWebhookEvent( {}, updatedEvent ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllPlans, - allPlans - ); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledWith(allPlans); }); it('updates the cached plans to include any valid plans missing from the cache', async () => { const updatedEvent = deepCopy(eventProductUpdated); - StripeWebhookHandlerInstance.stripeHelper.updateAllPlans.resolves(); - StripeWebhookHandlerInstance.stripeHelper.fetchAllPlans.resolves( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans.mockResolvedValue( + undefined + ); + StripeWebhookHandlerInstance.stripeHelper.fetchAllPlans.mockResolvedValue( validPlanList ); - StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.resolves( + StripeWebhookHandlerInstance.stripeHelper.fetchPlansByProductId.mockResolvedValue( [] ); await StripeWebhookHandlerInstance.handleProductWebhookEvent( @@ -813,12 +886,14 @@ describe('StripeWebhookHandler', () => { updatedEvent ); - expect(scopeContextSpy.notCalled).toBe(true); + expect(scopeContextSpy).not.toHaveBeenCalled(); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllPlans, - validPlanList - ); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledWith(validPlanList); }); }); @@ -833,16 +908,22 @@ describe('StripeWebhookHandler', () => { }; beforeEach(() => { - captureMessageSpy = sinon.fake(); - scopeContextSpy = sinon.fake(); - scopeExtraSpy = sinon.fake(); + captureMessageSpy = jest.fn(); + scopeContextSpy = jest.fn(); + scopeExtraSpy = jest.fn(); scopeSpy = { setContext: scopeContextSpy, setExtra: scopeExtraSpy, }; - sandbox.replace(Sentry, 'withScope', (fn: any) => fn(scopeSpy)); - sandbox.replace(Sentry, 'captureMessage', captureMessageSpy); - StripeWebhookHandlerInstance.stripeHelper.allPlans.resolves([plan]); + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((fn: any) => fn(scopeSpy)); + jest + .spyOn(Sentry, 'captureMessage') + .mockImplementation(captureMessageSpy); + StripeWebhookHandlerInstance.stripeHelper.allPlans.mockResolvedValue([ + plan, + ]); }); it('throws a sentry error if the update event data is invalid', async () => { @@ -851,30 +932,36 @@ describe('StripeWebhookHandler', () => { 'product:termsOfServiceDownloadURL': 'https://FAIL.net/legal/mozilla_vpn_tos', }; - StripeWebhookHandlerInstance.stripeHelper.fetchProductById.resolves({ - ...validProduct.data.object, - }); + StripeWebhookHandlerInstance.stripeHelper.fetchProductById.mockResolvedValue( + { + ...validProduct.data.object, + } + ); await StripeWebhookHandlerInstance.handlePlanCreatedOrUpdatedEvent( {}, updatedEvent ); - expect(scopeContextSpy.called).toBe(true); - expect(captureMessageSpy.called).toBe(true); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.fetchProductById, - validProduct.data.object.id - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllPlans, - [] - ); + expect(scopeContextSpy).toHaveBeenCalled(); + expect(captureMessageSpy).toHaveBeenCalled(); + expect( + StripeWebhookHandlerInstance.stripeHelper.fetchProductById + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.fetchProductById + ).toHaveBeenCalledWith(validProduct.data.object.id); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledWith([]); }); it('does not throw a sentry error if the update event data is valid', async () => { const updatedEvent = deepCopy(eventPlanUpdated); - StripeWebhookHandlerInstance.stripeHelper.fetchProductById.resolves( + StripeWebhookHandlerInstance.stripeHelper.fetchProductById.mockResolvedValue( validProduct.data.object ); await StripeWebhookHandlerInstance.handlePlanCreatedOrUpdatedEvent( @@ -882,19 +969,21 @@ describe('StripeWebhookHandler', () => { updatedEvent ); - expect(scopeContextSpy.notCalled).toBe(true); - expect(captureMessageSpy.notCalled).toBe(true); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllPlans, - [plan] - ); + expect(scopeContextSpy).not.toHaveBeenCalled(); + expect(captureMessageSpy).not.toHaveBeenCalled(); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledWith([plan]); }); it('logs and throws sentry error if product is not found', async () => { const productId = 'nonExistantProduct'; const updatedEvent = deepCopy(eventPlanUpdated); updatedEvent.data.object.product = productId; - StripeWebhookHandlerInstance.stripeHelper.fetchProductById.returns( + StripeWebhookHandlerInstance.stripeHelper.fetchProductById.mockReturnValue( undefined ); await StripeWebhookHandlerInstance.handlePlanCreatedOrUpdatedEvent( @@ -902,23 +991,27 @@ describe('StripeWebhookHandler', () => { updatedEvent ); - sinon.assert.calledOnce(StripeWebhookHandlerInstance.log.error); - expect(scopeContextSpy.called).toBe(true); - expect(captureMessageSpy.called).toBe(true); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.fetchProductById, - productId - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllPlans, - [] - ); + expect(StripeWebhookHandlerInstance.log.error).toHaveBeenCalledTimes(1); + expect(scopeContextSpy).toHaveBeenCalled(); + expect(captureMessageSpy).toHaveBeenCalled(); + expect( + StripeWebhookHandlerInstance.stripeHelper.fetchProductById + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.fetchProductById + ).toHaveBeenCalledWith(productId); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledWith([]); }); }); describe('handlePlanDeletedEvent', () => { it('deletes the plan from the cache', async () => { - StripeWebhookHandlerInstance.stripeHelper.allPlans.resolves([ + StripeWebhookHandlerInstance.stripeHelper.allPlans.mockResolvedValue([ validPlan.data.object, ]); const planDeletedEvent = { ...eventPlanUpdated, type: 'plan.deleted' }; @@ -926,10 +1019,12 @@ describe('StripeWebhookHandler', () => { {}, planDeletedEvent ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllPlans, - [] - ); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllPlans + ).toHaveBeenCalledWith([]); }); }); @@ -937,27 +1032,33 @@ describe('StripeWebhookHandler', () => { const taxRate = deepCopy(eventTaxRateCreated.data.object); beforeEach(() => { - StripeWebhookHandlerInstance.stripeHelper.allTaxRates.resolves([ - taxRate, - ]); - StripeWebhookHandlerInstance.stripeHelper.updateAllTaxRates.resolves(); + StripeWebhookHandlerInstance.stripeHelper.allTaxRates.mockResolvedValue( + [taxRate] + ); + StripeWebhookHandlerInstance.stripeHelper.updateAllTaxRates.mockResolvedValue( + undefined + ); }); it('adds a new tax rate on tax_rate.created', async () => { const createdEvent = deepCopy(eventTaxRateCreated); - StripeWebhookHandlerInstance.stripeHelper.allTaxRates.resolves([]); + StripeWebhookHandlerInstance.stripeHelper.allTaxRates.mockResolvedValue( + [] + ); await StripeWebhookHandlerInstance.handleTaxRateCreatedOrUpdatedEvent( {}, createdEvent ); - sinon.assert.calledOnce( + expect( StripeWebhookHandlerInstance.stripeHelper.allTaxRates - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllTaxRates, - [taxRate] - ); + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllTaxRates + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllTaxRates + ).toHaveBeenCalledWith([taxRate]); }); it('updates an existing tax rate on tax_rate.updated', async () => { @@ -969,13 +1070,15 @@ describe('StripeWebhookHandler', () => { updatedEvent ); - sinon.assert.calledOnce( + expect( StripeWebhookHandlerInstance.stripeHelper.allTaxRates - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.updateAllTaxRates, - [updatedTaxRate] - ); + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllTaxRates + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.updateAllTaxRates + ).toHaveBeenCalledWith([updatedTaxRate]); }); }); @@ -983,13 +1086,13 @@ describe('StripeWebhookHandler', () => { let sendSubscriptionUpdatedEmailStub: any; beforeEach(() => { - sendSubscriptionUpdatedEmailStub = sandbox - .stub(StripeWebhookHandlerInstance, 'sendSubscriptionUpdatedEmail') - .resolves({ uid: UID, email: TEST_EMAIL }); + sendSubscriptionUpdatedEmailStub = jest + .spyOn(StripeWebhookHandlerInstance, 'sendSubscriptionUpdatedEmail') + .mockResolvedValue({ uid: UID, email: TEST_EMAIL }); }); afterEach(() => { - StripeWebhookHandlerInstance.sendSubscriptionUpdatedEmail.restore(); + StripeWebhookHandlerInstance.sendSubscriptionUpdatedEmail.mockRestore(); }); it('emits a notification when transitioning from "incomplete" to "active/trialing"', async () => { @@ -998,11 +1101,13 @@ describe('StripeWebhookHandler', () => { {}, updatedEvent ); - sinon.assert.calledWithExactly(mockCapabilityService.stripeUpdate, { + expect(mockCapabilityService.stripeUpdate).toHaveBeenCalledWith({ sub: updatedEvent.data.object, uid: UID, }); - sinon.assert.calledWith(sendSubscriptionUpdatedEmailStub, updatedEvent); + expect(sendSubscriptionUpdatedEmailStub).toHaveBeenCalledWith( + updatedEvent + ); }); it('emits a notification for any subscription state change', async () => { @@ -1011,34 +1116,36 @@ describe('StripeWebhookHandler', () => { {}, updatedEvent ); - sinon.assert.calledWithExactly(mockCapabilityService.stripeUpdate, { + expect(mockCapabilityService.stripeUpdate).toHaveBeenCalledWith({ sub: updatedEvent.data.object, uid: UID, }); - sinon.assert.calledWith(sendSubscriptionUpdatedEmailStub, updatedEvent); + expect(sendSubscriptionUpdatedEmailStub).toHaveBeenCalledWith( + updatedEvent + ); }); it('reports a sentry error with an eventId if sendSubscriptionUpdatedEmail fails', async () => { const updatedEvent = deepCopy(subscriptionUpdated); const fakeAppError = { output: { payload: {} } }; - const fakeAppErrorWithEventId = { - output: { - payload: { - eventId: updatedEvent.id, - }, - }, - }; const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); - sendSubscriptionUpdatedEmailStub.rejects(fakeAppError); + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); + sendSubscriptionUpdatedEmailStub.mockRejectedValue(fakeAppError); await StripeWebhookHandlerInstance.handleSubscriptionUpdatedEvent( {}, updatedEvent ); - sinon.assert.calledWith(sendSubscriptionUpdatedEmailStub, updatedEvent); - sinon.assert.calledWith( - sentryMod.reportSentryError, - fakeAppErrorWithEventId + expect(sendSubscriptionUpdatedEmailStub).toHaveBeenCalledWith( + updatedEvent + ); + expect(sentryMod.reportSentryError).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + output: expect.objectContaining({ + payload: expect.objectContaining({ eventId: updatedEvent.id }), + }), + }), + expect.anything() ); }); @@ -1049,56 +1156,68 @@ describe('StripeWebhookHandler', () => { errno: error.ERRNO.UNKNOWN_SUBSCRIPTION_CUSTOMER, }; const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); - sendSubscriptionUpdatedEmailStub.rejects(fakeAppError); + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); + sendSubscriptionUpdatedEmailStub.mockRejectedValue(fakeAppError); await StripeWebhookHandlerInstance.handleSubscriptionUpdatedEvent( {}, updatedEvent ); - sinon.assert.calledWith(sendSubscriptionUpdatedEmailStub, updatedEvent); - sinon.assert.notCalled(sentryMod.reportSentryError); + expect(sendSubscriptionUpdatedEmailStub).toHaveBeenCalledWith( + updatedEvent + ); + expect(sentryMod.reportSentryError).not.toHaveBeenCalled(); }); }); describe('handleSubscriptionDeletedEvent', () => { it('sends email and emits a notification when a subscription is deleted', async () => { - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves( + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( customerFixture ); const deletedEvent = deepCopy(subscriptionDeleted); - const sendSubscriptionDeletedEmailStub = sandbox - .stub(StripeWebhookHandlerInstance, 'sendSubscriptionDeletedEmail') - .resolves({ uid: UID, email: TEST_EMAIL }); + const sendSubscriptionDeletedEmailStub = jest + .spyOn(StripeWebhookHandlerInstance, 'sendSubscriptionDeletedEmail') + .mockResolvedValue({ uid: UID, email: TEST_EMAIL }); const account = { email: customerFixture.email }; - sandbox.stub(authDbModule.Account, 'findByUid').resolves(account); + jest + .spyOn(authDbModule.Account, 'findByUid') + .mockResolvedValue(account); await StripeWebhookHandlerInstance.handleSubscriptionDeletedEvent( {}, deletedEvent ); - sinon.assert.calledWith(mockCapabilityService.stripeUpdate, { + expect(mockCapabilityService.stripeUpdate).toHaveBeenCalledWith({ sub: deletedEvent.data.object, uid: customerFixture.metadata.userid, }); - sinon.assert.calledWith( - sendSubscriptionDeletedEmailStub, + expect(sendSubscriptionDeletedEmailStub).toHaveBeenCalledWith( deletedEvent.data.object ); - sinon.assert.notCalled(authDbModule.getUidAndEmailByStripeCustomerId); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.expandResource, + expect( + authDbModule.getUidAndEmailByStripeCustomerId + ).not.toHaveBeenCalled(); + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledWith( deletedEvent.data.object.customer, CUSTOMER_RESOURCE ); - sinon.assert.calledOnceWithExactly( + expect( StripeWebhookHandlerInstance.paypalHelper - .conditionallyRemoveBillingAgreement, - customerFixture - ); + .conditionallyRemoveBillingAgreement + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.paypalHelper + .conditionallyRemoveBillingAgreement + ).toHaveBeenCalledWith(customerFixture); }); it('sends subscriptionReplaced email if metadata includes redundantCancellation', async () => { const mockCustomer = deepCopy(customerFixture); - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves( + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( mockCustomer ); const deletedEvent = deepCopy(subscriptionReplaced); @@ -1107,122 +1226,147 @@ describe('StripeWebhookHandler', () => { emails: customerFixture.email, locale: 'en', }; - sandbox.stub(authDbModule.Account, 'findByUid').resolves(account); + jest + .spyOn(authDbModule.Account, 'findByUid') + .mockResolvedValue(account); const mockInvoice = deepCopy(invoiceFixture); - StripeWebhookHandlerInstance.stripeHelper.extractSubscriptionDeletedEventDetailsForEmail.resolves( + StripeWebhookHandlerInstance.stripeHelper.extractSubscriptionDeletedEventDetailsForEmail.mockResolvedValue( mockInvoice ); StripeWebhookHandlerInstance.mailer.sendSubscriptionReplacedEmail = - sandbox.stub(); + jest.fn(); await StripeWebhookHandlerInstance.handleSubscriptionDeletedEvent( {}, deletedEvent ); - sinon.assert.calledWith(mockCapabilityService.stripeUpdate, { + expect(mockCapabilityService.stripeUpdate).toHaveBeenCalledWith({ sub: deletedEvent.data.object, uid: customerFixture.metadata.userid, }); - sinon.assert.notCalled(authDbModule.getUidAndEmailByStripeCustomerId); - sinon.assert.calledOnceWithExactly( + expect( + authDbModule.getUidAndEmailByStripeCustomerId + ).not.toHaveBeenCalled(); + expect( StripeWebhookHandlerInstance.paypalHelper - .conditionallyRemoveBillingAgreement, - customerFixture - ); - sinon.assert.calledOnceWithExactly( + .conditionallyRemoveBillingAgreement + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.paypalHelper + .conditionallyRemoveBillingAgreement + ).toHaveBeenCalledWith(customerFixture); + expect( StripeWebhookHandlerInstance.stripeHelper - .extractSubscriptionDeletedEventDetailsForEmail, - deletedEvent.data.object - ); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.mailer.sendSubscriptionReplacedEmail, - account.emails, - account, - { - acceptLanguage: account.locale, - ...mockInvoice, - } - ); + .extractSubscriptionDeletedEventDetailsForEmail + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper + .extractSubscriptionDeletedEventDetailsForEmail + ).toHaveBeenCalledWith(deletedEvent.data.object); + expect( + StripeWebhookHandlerInstance.mailer.sendSubscriptionReplacedEmail + ).toHaveBeenCalledWith(account.emails, account, { + acceptLanguage: account.locale, + ...mockInvoice, + }); }); it('does not conditionally delete without customer record', async () => { const deletedEvent = deepCopy(subscriptionDeleted); - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves(); - const sendSubscriptionDeletedEmailStub = sandbox - .stub(StripeWebhookHandlerInstance, 'sendSubscriptionDeletedEmail') - .resolves({ uid: UID, email: TEST_EMAIL }); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( + undefined + ); + const sendSubscriptionDeletedEmailStub = jest + .spyOn(StripeWebhookHandlerInstance, 'sendSubscriptionDeletedEmail') + .mockResolvedValue({ uid: UID, email: TEST_EMAIL }); await StripeWebhookHandlerInstance.handleSubscriptionDeletedEvent( {}, deletedEvent ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.expandResource, + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledWith( deletedEvent.data.object.customer, CUSTOMER_RESOURCE ); - sinon.assert.notCalled(sendSubscriptionDeletedEmailStub); - sinon.assert.notCalled( + expect(sendSubscriptionDeletedEmailStub).not.toHaveBeenCalled(); + expect( StripeWebhookHandlerInstance.paypalHelper .conditionallyRemoveBillingAgreement - ); + ).not.toHaveBeenCalled(); }); it('does not send an email to an unverified PayPal user', async () => { const deletedEvent = deepCopy(subscriptionDeleted); deletedEvent.data.object.collection_method = 'send_invoice'; - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves( + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( customerFixture ); - StripeWebhookHandlerInstance.db.account = sandbox.stub().resolves({ + StripeWebhookHandlerInstance.db.account = jest.fn().mockResolvedValue({ email: customerFixture.email, verifierSetAt: 0, }); - const sendSubscriptionDeletedEmailStub = sandbox - .stub(StripeWebhookHandlerInstance, 'sendSubscriptionDeletedEmail') - .resolves({ uid: UID, email: TEST_EMAIL }); + const sendSubscriptionDeletedEmailStub = jest + .spyOn(StripeWebhookHandlerInstance, 'sendSubscriptionDeletedEmail') + .mockResolvedValue({ uid: UID, email: TEST_EMAIL }); await StripeWebhookHandlerInstance.handleSubscriptionDeletedEvent( {}, deletedEvent ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.expandResource, + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledWith( deletedEvent.data.object.customer, CUSTOMER_RESOURCE ); - sinon.assert.notCalled(sendSubscriptionDeletedEmailStub); + expect(sendSubscriptionDeletedEmailStub).not.toHaveBeenCalled(); }); it('does send an email when it cannot find the account because it was deleted', async () => { - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves( + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( customerFixture ); const deletedEvent = deepCopy(subscriptionDeleted); - const sendSubscriptionDeletedEmailStub = sandbox - .stub(StripeWebhookHandlerInstance, 'sendSubscriptionDeletedEmail') - .resolves({ uid: UID, email: TEST_EMAIL }); - sandbox.stub(authDbModule.Account, 'findByUid').resolves(null); + const sendSubscriptionDeletedEmailStub = jest + .spyOn(StripeWebhookHandlerInstance, 'sendSubscriptionDeletedEmail') + .mockResolvedValue({ uid: UID, email: TEST_EMAIL }); + jest.spyOn(authDbModule.Account, 'findByUid').mockResolvedValue(null); await StripeWebhookHandlerInstance.handleSubscriptionDeletedEvent( {}, deletedEvent ); - sinon.assert.calledWith(mockCapabilityService.stripeUpdate, { + expect(mockCapabilityService.stripeUpdate).toHaveBeenCalledWith({ sub: deletedEvent.data.object, uid: customerFixture.metadata.userid, }); - sinon.assert.calledWith( - sendSubscriptionDeletedEmailStub, + expect(sendSubscriptionDeletedEmailStub).toHaveBeenCalledWith( deletedEvent.data.object ); - sinon.assert.notCalled(authDbModule.getUidAndEmailByStripeCustomerId); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.expandResource, + expect( + authDbModule.getUidAndEmailByStripeCustomerId + ).not.toHaveBeenCalled(); + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledWith( deletedEvent.data.object.customer, CUSTOMER_RESOURCE ); - sinon.assert.calledOnceWithExactly( + expect( StripeWebhookHandlerInstance.paypalHelper - .conditionallyRemoveBillingAgreement, - customerFixture - ); + .conditionallyRemoveBillingAgreement + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.paypalHelper + .conditionallyRemoveBillingAgreement + ).toHaveBeenCalledWith(customerFixture); }); it('emits metrics event - records expected subscription ended event', async () => { @@ -1247,48 +1391,49 @@ describe('StripeWebhookHandler', () => { const req = { auth: { credentials: mockCustomerFixture.uid }, payload: mockSubscriptionEndedEventDetails, - emitMetricsEvent: sandbox - .stub() - .resolves(mockSubscriptionEndedEventDetails), + emitMetricsEvent: jest + .fn() + .mockResolvedValue(mockSubscriptionEndedEventDetails), }; const subscriptionEndedEvent = deepCopy(subscriptionDeleted); - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves( + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( mockCustomerFixture ); - sandbox - .stub(StripeWebhookHandlerInstance, 'sendSubscriptionDeletedEmail') - .resolves({ uid: UID, email: TEST_EMAIL }); + jest + .spyOn(StripeWebhookHandlerInstance, 'sendSubscriptionDeletedEmail') + .mockResolvedValue({ uid: UID, email: TEST_EMAIL }); - sandbox.stub(authDbModule.Account, 'findByUid').resolves(account); + jest + .spyOn(authDbModule.Account, 'findByUid') + .mockResolvedValue(account); - const getSubscriptionEndedEventDetailsStub = sandbox - .stub( + const getSubscriptionEndedEventDetailsStub = jest + .spyOn( StripeWebhookHandlerInstance, 'getSubscriptionEndedEventDetails' ) - .resolves(mockSubscriptionEndedEventDetails); + .mockResolvedValue(mockSubscriptionEndedEventDetails); await StripeWebhookHandlerInstance.handleSubscriptionDeletedEvent( req, subscriptionEndedEvent ); - sinon.assert.calledOnceWithExactly( - getSubscriptionEndedEventDetailsStub, + expect(getSubscriptionEndedEventDetailsStub).toHaveBeenCalledTimes(1); + expect(getSubscriptionEndedEventDetailsStub).toHaveBeenCalledWith( mockSubscriptionEndedEventDetails.uid, mockSubscriptionEndedEventDetails.provider_event_id, mockCustomerFixture, subscriptionEnded ); - expect( - req.emitMetricsEvent.calledOnceWithExactly( - 'subscription.ended', - mockSubscriptionEndedEventDetails - ) - ).toBe(true); + expect(req.emitMetricsEvent).toHaveBeenCalledTimes(1); + expect(req.emitMetricsEvent).toHaveBeenCalledWith( + 'subscription.ended', + mockSubscriptionEndedEventDetails + ); }); }); @@ -1305,112 +1450,115 @@ describe('StripeWebhookHandler', () => { invoiceCreatedEvent ); expect(result).toBeUndefined(); - sinon.assert.notCalled( + expect( StripeWebhookHandlerInstance.stripeHelper.expandResource - ); + ).not.toHaveBeenCalled(); }); it('stops if the invoice is not paypal payable', async () => { const invoiceCreatedEvent = deepCopy(eventInvoiceCreated); invoiceCreatedEvent.data.object.status = 'draft'; - StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal.resolves( + StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal.mockResolvedValue( false ); - StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice.resolves({}); + StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice.mockResolvedValue( + {} + ); const result = await StripeWebhookHandlerInstance.handleInvoiceCreatedEvent( {}, invoiceCreatedEvent ); expect(result).toBeUndefined(); - sinon.assert.notCalled( + expect( StripeWebhookHandlerInstance.stripeHelper.expandResource - ); - sinon.assert.notCalled( + ).not.toHaveBeenCalled(); + expect( StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice - ); + ).not.toHaveBeenCalled(); }); it('stops if the invoice is not in draft', async () => { const invoiceCreatedEvent = deepCopy(eventInvoiceCreated); - StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal.resolves( + StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal.mockResolvedValue( true ); - StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice.resolves({}); + StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice.mockResolvedValue( + {} + ); const result = await StripeWebhookHandlerInstance.handleInvoiceCreatedEvent( {}, invoiceCreatedEvent ); expect(result).toBeUndefined(); - sinon.assert.notCalled( + expect( StripeWebhookHandlerInstance.stripeHelper.expandResource - ); - sinon.assert.notCalled( + ).not.toHaveBeenCalled(); + expect( StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice - ); + ).not.toHaveBeenCalled(); }); it('logs if the billing agreement was cancelled', async () => { const invoiceCreatedEvent = deepCopy(eventInvoiceCreated); invoiceCreatedEvent.data.object.status = 'draft'; - StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal.resolves( + StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal.mockResolvedValue( true ); - StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice.resolves({}); - StripeWebhookHandlerInstance.stripeHelper.getCustomerPaypalAgreement.returns( + StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice.mockResolvedValue( + {} + ); + StripeWebhookHandlerInstance.stripeHelper.getCustomerPaypalAgreement.mockReturnValue( 'test-ba' ); - StripeWebhookHandlerInstance.paypalHelper.updateStripeNameFromBA.rejects( + StripeWebhookHandlerInstance.paypalHelper.updateStripeNameFromBA.mockRejectedValue( { errno: 998, } ); - StripeWebhookHandlerInstance.log.error = sinon.fake.returns({}); + StripeWebhookHandlerInstance.log.error = jest.fn().mockReturnValue({}); const result = await StripeWebhookHandlerInstance.handleInvoiceCreatedEvent( {}, invoiceCreatedEvent ); expect(result).toEqual({}); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.log.error, + expect(StripeWebhookHandlerInstance.log.error).toHaveBeenCalledTimes(1); + expect(StripeWebhookHandlerInstance.log.error).toHaveBeenCalledWith( `handleInvoiceCreatedEvent - Billing agreement (id: test-ba) was cancelled.`, { request: {}, customer: {}, } ); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal, - invoiceCreatedEvent.data.object - ); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice, - invoiceCreatedEvent.data.object - ); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.paypalHelper.updateStripeNameFromBA, - {}, - 'test-ba' - ); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.stripeHelper.getCustomerPaypalAgreement, - {} - ); + expect( + StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal + ).toHaveBeenCalledWith(invoiceCreatedEvent.data.object); + expect( + StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice + ).toHaveBeenCalledWith(invoiceCreatedEvent.data.object); + expect( + StripeWebhookHandlerInstance.paypalHelper.updateStripeNameFromBA + ).toHaveBeenCalledWith({}, 'test-ba'); + expect( + StripeWebhookHandlerInstance.stripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledWith({}); }); it('finalizes invoices for invoice subscriptions', async () => { const invoiceCreatedEvent = deepCopy(eventInvoiceCreated); invoiceCreatedEvent.data.object.status = 'draft'; - StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal.resolves( + StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal.mockResolvedValue( true ); - StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice.resolves({}); - StripeWebhookHandlerInstance.stripeHelper.getCustomerPaypalAgreement.returns( + StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice.mockResolvedValue( + {} + ); + StripeWebhookHandlerInstance.stripeHelper.getCustomerPaypalAgreement.mockReturnValue( 'test-ba' ); - StripeWebhookHandlerInstance.paypalHelper.updateStripeNameFromBA.resolves( + StripeWebhookHandlerInstance.paypalHelper.updateStripeNameFromBA.mockResolvedValue( {} ); const result = @@ -1419,23 +1567,18 @@ describe('StripeWebhookHandler', () => { invoiceCreatedEvent ); expect(result).toEqual({}); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal, - invoiceCreatedEvent.data.object - ); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice, - invoiceCreatedEvent.data.object - ); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.paypalHelper.updateStripeNameFromBA, - {}, - 'test-ba' - ); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.stripeHelper.getCustomerPaypalAgreement, - {} - ); + expect( + StripeWebhookHandlerInstance.stripeHelper.invoicePayableWithPaypal + ).toHaveBeenCalledWith(invoiceCreatedEvent.data.object); + expect( + StripeWebhookHandlerInstance.stripeHelper.finalizeInvoice + ).toHaveBeenCalledWith(invoiceCreatedEvent.data.object); + expect( + StripeWebhookHandlerInstance.paypalHelper.updateStripeNameFromBA + ).toHaveBeenCalledWith({}, 'test-ba'); + expect( + StripeWebhookHandlerInstance.stripeHelper.getCustomerPaypalAgreement + ).toHaveBeenCalledWith({}); }); }); @@ -1450,96 +1593,108 @@ describe('StripeWebhookHandler', () => { it('doesnt run if paypalHelper is not present', async () => { StripeWebhookHandlerInstance.paypalHelper = undefined; - StripeWebhookHandlerInstance.stripeHelper.expandResource = - sinon.fake.resolves({}); + StripeWebhookHandlerInstance.stripeHelper.expandResource = jest + .fn() + .mockResolvedValue({}); const result = await StripeWebhookHandlerInstance.handleCreditNoteEvent( {}, invoiceCreditNoteEvent ); expect(result).toBeUndefined(); - sinon.assert.notCalled( + expect( StripeWebhookHandlerInstance.stripeHelper.expandResource - ); + ).not.toHaveBeenCalled(); }); it('doesnt run if its not manual invoice or out of band credit note', async () => { const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); StripeWebhookHandlerInstance.paypalHelper = {}; invoice.collection_method = 'charge_automatically'; - StripeWebhookHandlerInstance.stripeHelper.expandResource = - sinon.fake.resolves(invoice); + StripeWebhookHandlerInstance.stripeHelper.expandResource = jest + .fn() + .mockResolvedValue(invoice); StripeWebhookHandlerInstance.stripeHelper.getInvoicePaypalTransactionId = - sinon.fake.resolves({}); + jest.fn().mockResolvedValue({}); const result = await StripeWebhookHandlerInstance.handleCreditNoteEvent( {}, invoiceCreditNoteEvent ); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.expandResource, + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledWith( invoiceCreditNoteEvent.data.object.invoice, 'invoices' ); - sinon.assert.notCalled( + expect( StripeWebhookHandlerInstance.stripeHelper .getInvoicePaypalTransactionId - ); - sinon.assert.calledOnce(sentryMod.reportSentryError); + ).not.toHaveBeenCalled(); + expect(sentryMod.reportSentryError).toHaveBeenCalledTimes(1); }); it('doesnt run or error report if its not manual invoice and not out of band', async () => { const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); StripeWebhookHandlerInstance.paypalHelper = {}; invoice.collection_method = 'charge_automatically'; - StripeWebhookHandlerInstance.stripeHelper.expandResource = - sinon.fake.resolves(invoice); + StripeWebhookHandlerInstance.stripeHelper.expandResource = jest + .fn() + .mockResolvedValue(invoice); StripeWebhookHandlerInstance.stripeHelper.getInvoicePaypalTransactionId = - sinon.fake.resolves({}); + jest.fn().mockResolvedValue({}); invoiceCreditNoteEvent.data.object.out_of_band_amount = null; const result = await StripeWebhookHandlerInstance.handleCreditNoteEvent( {}, invoiceCreditNoteEvent ); expect(result).toBeUndefined(); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.stripeHelper.expandResource, + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledWith( invoiceCreditNoteEvent.data.object.invoice, 'invoices' ); - sinon.assert.notCalled( + expect( StripeWebhookHandlerInstance.stripeHelper .getInvoicePaypalTransactionId - ); - sinon.assert.notCalled(sentryMod.reportSentryError); + ).not.toHaveBeenCalled(); + expect(sentryMod.reportSentryError).not.toHaveBeenCalled(); }); it('doesnt issue refund without a paypal transaction to refund', async () => { StripeWebhookHandlerInstance.paypalHelper = {}; invoice.collection_method = 'send_invoice'; invoiceCreditNoteEvent.data.object.out_of_band_amount = 500; - StripeWebhookHandlerInstance.stripeHelper.expandResource = - sinon.fake.resolves(invoice); + StripeWebhookHandlerInstance.stripeHelper.expandResource = jest + .fn() + .mockResolvedValue(invoice); StripeWebhookHandlerInstance.stripeHelper.getInvoicePaypalTransactionId = - sinon.fake.returns(null); - StripeWebhookHandlerInstance.log.error = sinon.fake.returns({}); + jest.fn().mockReturnValue(null); + StripeWebhookHandlerInstance.log.error = jest.fn().mockReturnValue({}); const result = await StripeWebhookHandlerInstance.handleCreditNoteEvent( {}, invoiceCreditNoteEvent ); expect(result).toBeUndefined(); - sinon.assert.calledWithMatch( - StripeWebhookHandlerInstance.stripeHelper.expandResource, + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledWith( invoiceCreditNoteEvent.data.object.invoice, 'invoices' ); - sinon.assert.callCount( - StripeWebhookHandlerInstance.stripeHelper.expandResource, - 1 - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.log.error, + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect(StripeWebhookHandlerInstance.log.error).toHaveBeenCalledTimes(1); + expect(StripeWebhookHandlerInstance.log.error).toHaveBeenCalledWith( 'handleCreditNoteEvent', { invoiceId: invoice.id, @@ -1547,51 +1702,57 @@ describe('StripeWebhookHandler', () => { 'Credit note issued on invoice without a PayPal transaction id.', } ); - sinon.assert.calledOnceWithExactly( + expect( StripeWebhookHandlerInstance.stripeHelper - .getInvoicePaypalTransactionId, - invoice - ); + .getInvoicePaypalTransactionId + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper + .getInvoicePaypalTransactionId + ).toHaveBeenCalledWith(invoice); }); it('logs an error if the amount doesnt match the invoice amount', async () => { StripeWebhookHandlerInstance.paypalHelper = { - issueRefund: sinon.fake.resolves(undefined), + issueRefund: jest.fn().mockResolvedValue(undefined), }; invoice.collection_method = 'send_invoice'; invoiceCreditNoteEvent.data.object.out_of_band_amount = 500; invoice.amount_due = 900; - StripeWebhookHandlerInstance.stripeHelper.expandResource = - sinon.fake.resolves(invoice); + StripeWebhookHandlerInstance.stripeHelper.expandResource = jest + .fn() + .mockResolvedValue(invoice); StripeWebhookHandlerInstance.stripeHelper.getInvoicePaypalTransactionId = - sinon.fake.returns('tx-1234'); - StripeWebhookHandlerInstance.log.error = sinon.fake.returns({}); + jest.fn().mockReturnValue('tx-1234'); + StripeWebhookHandlerInstance.log.error = jest.fn().mockReturnValue({}); const result = await StripeWebhookHandlerInstance.handleCreditNoteEvent( {}, invoiceCreditNoteEvent ); expect(result).toBeUndefined(); - sinon.assert.calledWithMatch( - StripeWebhookHandlerInstance.stripeHelper.expandResource, + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledWith( invoiceCreditNoteEvent.data.object.invoice, 'invoices' ); - sinon.assert.callCount( - StripeWebhookHandlerInstance.stripeHelper.expandResource, - 1 - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.paypalHelper.issueRefund, - invoice, - 'tx-1234', - RefundType.Partial, - 500 - ); - sinon.assert.calledOnceWithExactly( + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.paypalHelper.issueRefund + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.paypalHelper.issueRefund + ).toHaveBeenCalledWith(invoice, 'tx-1234', RefundType.Partial, 500); + expect( StripeWebhookHandlerInstance.stripeHelper - .getInvoicePaypalTransactionId, - invoice - ); + .getInvoicePaypalTransactionId + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper + .getInvoicePaypalTransactionId + ).toHaveBeenCalledWith(invoice); }); it('issues refund when all checks are successful', async () => { @@ -1599,102 +1760,115 @@ describe('StripeWebhookHandler', () => { invoice.collection_method = 'send_invoice'; invoiceCreditNoteEvent.data.object.out_of_band_amount = 500; invoice.amount_due = 500; - StripeWebhookHandlerInstance.stripeHelper.expandResource = - sinon.fake.resolves(invoice); + StripeWebhookHandlerInstance.stripeHelper.expandResource = jest + .fn() + .mockResolvedValue(invoice); StripeWebhookHandlerInstance.stripeHelper.getInvoicePaypalTransactionId = - sinon.fake.returns('tx-1234'); - StripeWebhookHandlerInstance.log.error = sinon.fake.returns({}); - StripeWebhookHandlerInstance.paypalHelper.issueRefund = - sinon.fake.resolves({}); + jest.fn().mockReturnValue('tx-1234'); + StripeWebhookHandlerInstance.log.error = jest.fn().mockReturnValue({}); + StripeWebhookHandlerInstance.paypalHelper.issueRefund = jest + .fn() + .mockResolvedValue({}); const result = await StripeWebhookHandlerInstance.handleCreditNoteEvent( {}, invoiceCreditNoteEvent ); expect(result).toBeUndefined(); - sinon.assert.calledWithMatch( - StripeWebhookHandlerInstance.stripeHelper.expandResource, + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledWith( invoiceCreditNoteEvent.data.object.invoice, 'invoices' ); - sinon.assert.callCount( - StripeWebhookHandlerInstance.stripeHelper.expandResource, - 1 - ); - sinon.assert.calledOnceWithExactly( + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect( StripeWebhookHandlerInstance.stripeHelper - .getInvoicePaypalTransactionId, - invoice - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.paypalHelper.issueRefund, - invoice, - 'tx-1234', - RefundType.Full, - undefined - ); + .getInvoicePaypalTransactionId + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper + .getInvoicePaypalTransactionId + ).toHaveBeenCalledWith(invoice); + expect( + StripeWebhookHandlerInstance.paypalHelper.issueRefund + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.paypalHelper.issueRefund + ).toHaveBeenCalledWith(invoice, 'tx-1234', RefundType.Full, undefined); }); it('updates the invoice to report refused refund if paypal refuses to refund', async () => { const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); StripeWebhookHandlerInstance.paypalHelper = {}; invoice.collection_method = 'send_invoice'; invoiceCreditNoteEvent.data.object.out_of_band_amount = 500; invoice.amount_due = 500; - StripeWebhookHandlerInstance.stripeHelper.expandResource = - sinon.fake.resolves(invoice); + StripeWebhookHandlerInstance.stripeHelper.expandResource = jest + .fn() + .mockResolvedValue(invoice); StripeWebhookHandlerInstance.stripeHelper.getInvoicePaypalTransactionId = - sinon.fake.returns('tx-1234'); + jest.fn().mockReturnValue('tx-1234'); StripeWebhookHandlerInstance.stripeHelper.updateInvoiceWithPaypalRefundReason = - sinon.fake.resolves({}); - StripeWebhookHandlerInstance.log.error = sinon.fake.returns({}); + jest.fn().mockResolvedValue({}); + StripeWebhookHandlerInstance.log.error = jest.fn().mockReturnValue({}); const refusedError = new RefusedError( 'Transaction refused', 'This transaction already has a chargeback filed', '10009' ); - StripeWebhookHandlerInstance.paypalHelper.issueRefund = - sinon.fake.rejects(refusedError); + StripeWebhookHandlerInstance.paypalHelper.issueRefund = jest + .fn() + .mockRejectedValue(refusedError); const result = await StripeWebhookHandlerInstance.handleCreditNoteEvent( {}, invoiceCreditNoteEvent ); expect(result).toBeUndefined(); - sinon.assert.calledWithMatch( - StripeWebhookHandlerInstance.stripeHelper.expandResource, + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledWith( invoiceCreditNoteEvent.data.object.invoice, 'invoices' ); - sinon.assert.callCount( - StripeWebhookHandlerInstance.stripeHelper.expandResource, - 1 - ); - sinon.assert.calledOnceWithExactly( + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect( StripeWebhookHandlerInstance.stripeHelper - .getInvoicePaypalTransactionId, - invoice - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.paypalHelper.issueRefund, - invoice, - 'tx-1234', - RefundType.Full, - undefined - ); - sinon.assert.calledOnceWithExactly( + .getInvoicePaypalTransactionId + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.stripeHelper + .getInvoicePaypalTransactionId + ).toHaveBeenCalledWith(invoice); + expect( + StripeWebhookHandlerInstance.paypalHelper.issueRefund + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.paypalHelper.issueRefund + ).toHaveBeenCalledWith(invoice, 'tx-1234', RefundType.Full, undefined); + expect( + StripeWebhookHandlerInstance.stripeHelper + .updateInvoiceWithPaypalRefundReason + ).toHaveBeenCalledTimes(1); + expect( StripeWebhookHandlerInstance.stripeHelper - .updateInvoiceWithPaypalRefundReason, + .updateInvoiceWithPaypalRefundReason + ).toHaveBeenCalledWith( invoice, 'This transaction already has a chargeback filed' ); refusedError.output = { payload: { invoiceId: invoice.id } }; - sinon.assert.calledOnceWithExactly( - sentryMod.reportSentryError, + expect(sentryMod.reportSentryError).toHaveBeenCalledTimes(1); + expect(sentryMod.reportSentryError).toHaveBeenCalledWith( refusedError, {} ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.log.error, + expect(StripeWebhookHandlerInstance.log.error).toHaveBeenCalledTimes(1); + expect(StripeWebhookHandlerInstance.log.error).toHaveBeenCalledWith( 'handleCreditNoteEvent', { invoiceId: invoice.id, @@ -1708,20 +1882,21 @@ describe('StripeWebhookHandler', () => { it('sends email and emits a notification when an invoice payment succeeds', async () => { const paidEvent = deepCopy(eventInvoicePaid); const customer = deepCopy(customerFixture); - const sendSubscriptionInvoiceEmailStub = sandbox - .stub(StripeWebhookHandlerInstance, 'sendSubscriptionInvoiceEmail') - .resolves(true); + const sendSubscriptionInvoiceEmailStub = jest + .spyOn(StripeWebhookHandlerInstance, 'sendSubscriptionInvoiceEmail') + .mockResolvedValue(true); const account = { email: customerFixture.email }; - sandbox.stub(authDbModule.Account, 'findByUid').resolves(account); - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves( + jest + .spyOn(authDbModule.Account, 'findByUid') + .mockResolvedValue(account); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( customer ); await StripeWebhookHandlerInstance.handleInvoicePaidEvent( {}, paidEvent ); - sinon.assert.calledWith( - sendSubscriptionInvoiceEmailStub, + expect(sendSubscriptionInvoiceEmailStub).toHaveBeenCalledWith( paidEvent.data.object ); }); @@ -1731,25 +1906,26 @@ describe('StripeWebhookHandler', () => { const customer = deepCopy(customerFixture); customer.deleted = true; const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); - const sendSubscriptionInvoiceEmailStub = sandbox - .stub(StripeWebhookHandlerInstance, 'sendSubscriptionInvoiceEmail') - .resolves(true); - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves( + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); + const sendSubscriptionInvoiceEmailStub = jest + .spyOn(StripeWebhookHandlerInstance, 'sendSubscriptionInvoiceEmail') + .mockResolvedValue(true); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( customer ); await StripeWebhookHandlerInstance.handleInvoicePaidEvent( {}, paidEvent ); - sinon.assert.notCalled(sendSubscriptionInvoiceEmailStub); - sinon.assert.calledOnce(sentryMod.reportSentryError); - const thrownErr = sentryMod.reportSentryError.getCalls()[0].args[0]; - expect(thrownErr).toEqual( + expect(sendSubscriptionInvoiceEmailStub).not.toHaveBeenCalled(); + expect(sentryMod.reportSentryError).toHaveBeenCalledTimes(1); + expect(sentryMod.reportSentryError).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ customerId: paidEvent.data.object.customer, invoiceId: paidEvent.data.object.id, - }) + }), + expect.anything() ); }); @@ -1758,25 +1934,26 @@ describe('StripeWebhookHandler', () => { const customer = deepCopy(customerFixture); customer.metadata = {}; const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); - const sendSubscriptionInvoiceEmailStub = sandbox - .stub(StripeWebhookHandlerInstance, 'sendSubscriptionInvoiceEmail') - .resolves(true); - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves( + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); + const sendSubscriptionInvoiceEmailStub = jest + .spyOn(StripeWebhookHandlerInstance, 'sendSubscriptionInvoiceEmail') + .mockResolvedValue(true); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( customer ); await StripeWebhookHandlerInstance.handleInvoicePaidEvent( {}, paidEvent ); - sinon.assert.notCalled(sendSubscriptionInvoiceEmailStub); - sinon.assert.calledOnce(sentryMod.reportSentryError); - const thrownErr = sentryMod.reportSentryError.getCalls()[0].args[0]; - expect(thrownErr).toEqual( + expect(sendSubscriptionInvoiceEmailStub).not.toHaveBeenCalled(); + expect(sentryMod.reportSentryError).toHaveBeenCalledTimes(1); + expect(sentryMod.reportSentryError).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ customerId: paidEvent.data.object.customer, invoiceId: paidEvent.data.object.id, - }) + }), + expect.anything() ); }); @@ -1784,27 +1961,28 @@ describe('StripeWebhookHandler', () => { const paidEvent = deepCopy(eventInvoicePaid); const customer = deepCopy(customerFixture); const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); - const sendSubscriptionInvoiceEmailStub = sandbox - .stub(StripeWebhookHandlerInstance, 'sendSubscriptionInvoiceEmail') - .resolves(true); - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves( + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); + const sendSubscriptionInvoiceEmailStub = jest + .spyOn(StripeWebhookHandlerInstance, 'sendSubscriptionInvoiceEmail') + .mockResolvedValue(true); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( customer ); - sandbox.stub(authDbModule.Account, 'findByUid').resolves(null); + jest.spyOn(authDbModule.Account, 'findByUid').mockResolvedValue(null); await StripeWebhookHandlerInstance.handleInvoicePaidEvent( {}, paidEvent ); - sinon.assert.notCalled(sendSubscriptionInvoiceEmailStub); - sinon.assert.calledOnce(sentryMod.reportSentryError); - const thrownErr = sentryMod.reportSentryError.getCalls()[0].args[0]; - expect(thrownErr).toEqual( + expect(sendSubscriptionInvoiceEmailStub).not.toHaveBeenCalled(); + expect(sentryMod.reportSentryError).toHaveBeenCalledTimes(1); + expect(sentryMod.reportSentryError).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ customerId: paidEvent.data.object.customer, invoiceId: paidEvent.data.object.id, userId: customer.metadata.userid, - }) + }), + expect.anything() ); }); }); @@ -1817,13 +1995,13 @@ describe('StripeWebhookHandler', () => { let sendSubscriptionPaymentFailedEmailStub: any; beforeEach(() => { - sendSubscriptionPaymentFailedEmailStub = sandbox - .stub( + sendSubscriptionPaymentFailedEmailStub = jest + .spyOn( StripeWebhookHandlerInstance, 'sendSubscriptionPaymentFailedEmail' ) - .resolves(true); - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves( + .mockResolvedValue(true); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( mockSubscription ); }); @@ -1835,8 +2013,7 @@ describe('StripeWebhookHandler', () => { {}, paymentFailedEvent ); - sinon.assert.calledWith( - sendSubscriptionPaymentFailedEmailStub, + expect(sendSubscriptionPaymentFailedEmailStub).toHaveBeenCalledWith( paymentFailedEvent.data.object ); }); @@ -1848,7 +2025,7 @@ describe('StripeWebhookHandler', () => { {}, paymentFailedEvent ); - sinon.assert.notCalled(sendSubscriptionPaymentFailedEmailStub); + expect(sendSubscriptionPaymentFailedEmailStub).not.toHaveBeenCalled(); }); }); @@ -1868,72 +2045,69 @@ describe('StripeWebhookHandler', () => { let sendSubscriptionPaymentExpiredEmailStub: any; beforeEach(() => { - sendSubscriptionPaymentExpiredEmailStub = sandbox - .stub( + sendSubscriptionPaymentExpiredEmailStub = jest + .spyOn( StripeWebhookHandlerInstance, 'sendSubscriptionPaymentExpiredEmail' ) - .resolves(true); + .mockResolvedValue(true); StripeWebhookHandlerInstance.stripeHelper.expandResource - .onCall(0) - .resolves(mockCustomer); - StripeWebhookHandlerInstance.stripeHelper.expandResource - .onCall(1) - .resolves(mockPaymentMethod); + .mockResolvedValueOnce(mockCustomer) + .mockResolvedValueOnce(mockPaymentMethod); }); it('does nothing, when customer is deleted', async () => { - StripeWebhookHandlerInstance.stripeHelper.expandResource - .onCall(0) - .resolves({ ...mockCustomer, deleted: true }); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockReset(); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValueOnce( + { ...mockCustomer, deleted: true } + ); await StripeWebhookHandlerInstance.handleInvoiceUpcomingEvent( {}, eventInvoiceUpcoming ); - sinon.assert.callCount( - StripeWebhookHandlerInstance.stripeHelper.expandResource, - 1 - ); - sinon.assert.notCalled(sendSubscriptionPaymentExpiredEmailStub); + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect(sendSubscriptionPaymentExpiredEmailStub).not.toHaveBeenCalled(); }); it('does nothing, invoice settings doesnt exist because payment method is Paypal', async () => { - StripeWebhookHandlerInstance.stripeHelper.expandResource - .onCall(0) - .resolves({ ...mockCustomer, invoice_settings: null }); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockReset(); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValueOnce( + { ...mockCustomer, invoice_settings: null } + ); await StripeWebhookHandlerInstance.handleInvoiceUpcomingEvent( {}, eventInvoiceUpcoming ); - sinon.assert.callCount( - StripeWebhookHandlerInstance.stripeHelper.expandResource, - 1 - ); - sinon.assert.notCalled(sendSubscriptionPaymentExpiredEmailStub); + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(1); + expect(sendSubscriptionPaymentExpiredEmailStub).not.toHaveBeenCalled(); }); it('reports Sentry Error and return, when payment method doesnt have a card', async () => { const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); await StripeWebhookHandlerInstance.handleInvoiceUpcomingEvent( {}, eventInvoiceUpcoming ); - sinon.assert.callCount( - StripeWebhookHandlerInstance.stripeHelper.expandResource, - 2 - ); - sinon.assert.notCalled( + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(2); + expect( StripeWebhookHandlerInstance.stripeHelper.formatSubscriptionsForEmails - ); - sinon.assert.notCalled(sendSubscriptionPaymentExpiredEmailStub); - sinon.assert.calledOnce(sentryMod.reportSentryError); + ).not.toHaveBeenCalled(); + expect(sendSubscriptionPaymentExpiredEmailStub).not.toHaveBeenCalled(); + expect(sentryMod.reportSentryError).toHaveBeenCalledTimes(1); }); it('does nothing, when credit card is expiring in the future', async () => { + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockReset(); StripeWebhookHandlerInstance.stripeHelper.expandResource - .onCall(1) - .resolves({ + .mockResolvedValueOnce(mockCustomer) + .mockResolvedValueOnce({ card: { exp_month: new Date().getMonth() + 1, exp_year: new Date().getFullYear() + 1, @@ -1943,25 +2117,25 @@ describe('StripeWebhookHandler', () => { {}, eventInvoiceUpcoming ); - sinon.assert.callCount( - StripeWebhookHandlerInstance.stripeHelper.expandResource, - 2 - ); - sinon.assert.notCalled( + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(2); + expect( StripeWebhookHandlerInstance.stripeHelper.formatSubscriptionsForEmails - ); - sinon.assert.notCalled(sendSubscriptionPaymentExpiredEmailStub); + ).not.toHaveBeenCalled(); + expect(sendSubscriptionPaymentExpiredEmailStub).not.toHaveBeenCalled(); }); it('reports Sentry Error and return, when customer doesnt have active subscriptions', async () => { const sentryMod = require('../../sentry'); - sandbox.stub(sentryMod, 'reportSentryError').returns({}); - StripeWebhookHandlerInstance.stripeHelper.formatSubscriptionsForEmails.resolves( + jest.spyOn(sentryMod, 'reportSentryError').mockReturnValue({}); + StripeWebhookHandlerInstance.stripeHelper.formatSubscriptionsForEmails.mockResolvedValue( [] ); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockReset(); StripeWebhookHandlerInstance.stripeHelper.expandResource - .onCall(1) - .resolves({ + .mockResolvedValueOnce(mockCustomer) + .mockResolvedValueOnce({ card: { exp_month: new Date().getMonth() + 1, exp_year: new Date().getFullYear(), @@ -1971,27 +2145,27 @@ describe('StripeWebhookHandler', () => { {}, eventInvoiceUpcoming ); - sinon.assert.callCount( - StripeWebhookHandlerInstance.stripeHelper.expandResource, - 2 - ); - sinon.assert.called( + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(2); + expect( StripeWebhookHandlerInstance.stripeHelper.formatSubscriptionsForEmails - ); - sinon.assert.notCalled(sendSubscriptionPaymentExpiredEmailStub); - sinon.assert.calledOnce(sentryMod.reportSentryError); + ).toHaveBeenCalled(); + expect(sendSubscriptionPaymentExpiredEmailStub).not.toHaveBeenCalled(); + expect(sentryMod.reportSentryError).toHaveBeenCalledTimes(1); }); it('sends an email when default payment credit card expires the current month', async () => { + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockReset(); StripeWebhookHandlerInstance.stripeHelper.expandResource - .onCall(1) - .resolves({ + .mockResolvedValueOnce(mockCustomer) + .mockResolvedValueOnce({ card: { exp_month: new Date().getMonth() + 1, exp_year: new Date().getFullYear(), }, }); - StripeWebhookHandlerInstance.stripeHelper.formatSubscriptionsForEmails.resolves( + StripeWebhookHandlerInstance.stripeHelper.formatSubscriptionsForEmails.mockResolvedValue( [ { id: 'sub1', @@ -2002,26 +2176,26 @@ describe('StripeWebhookHandler', () => { {}, eventInvoiceUpcoming ); - sinon.assert.callCount( - StripeWebhookHandlerInstance.stripeHelper.expandResource, - 2 - ); - sinon.assert.called( + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(2); + expect( StripeWebhookHandlerInstance.stripeHelper.formatSubscriptionsForEmails - ); - sinon.assert.called(sendSubscriptionPaymentExpiredEmailStub); + ).toHaveBeenCalled(); + expect(sendSubscriptionPaymentExpiredEmailStub).toHaveBeenCalled(); }); it('sends an email when default payment credit card expires before the current month', async () => { + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockReset(); StripeWebhookHandlerInstance.stripeHelper.expandResource - .onCall(1) - .resolves({ + .mockResolvedValueOnce(mockCustomer) + .mockResolvedValueOnce({ card: { exp_month: new Date().getMonth() + 1, exp_year: new Date().getFullYear() - 1, }, }); - StripeWebhookHandlerInstance.stripeHelper.formatSubscriptionsForEmails.resolves( + StripeWebhookHandlerInstance.stripeHelper.formatSubscriptionsForEmails.mockResolvedValue( [ { id: 'sub1', @@ -2032,14 +2206,13 @@ describe('StripeWebhookHandler', () => { {}, eventInvoiceUpcoming ); - sinon.assert.callCount( - StripeWebhookHandlerInstance.stripeHelper.expandResource, - 2 - ); - sinon.assert.called( + expect( + StripeWebhookHandlerInstance.stripeHelper.expandResource + ).toHaveBeenCalledTimes(2); + expect( StripeWebhookHandlerInstance.stripeHelper.formatSubscriptionsForEmails - ); - sinon.assert.called(sendSubscriptionPaymentExpiredEmailStub); + ).toHaveBeenCalled(); + expect(sendSubscriptionPaymentExpiredEmailStub).toHaveBeenCalled(); }); }); @@ -2050,7 +2223,7 @@ describe('StripeWebhookHandler', () => { {}, createdEvent ); - sinon.assert.calledWith(mockCapabilityService.stripeUpdate, { + expect(mockCapabilityService.stripeUpdate).toHaveBeenCalledWith({ sub: createdEvent.data.object, }); }); @@ -2061,8 +2234,10 @@ describe('StripeWebhookHandler', () => { {}, createdEvent ); - sinon.assert.notCalled(authDbModule.getUidAndEmailByStripeCustomerId); - sinon.assert.notCalled(mockCapabilityService.stripeUpdate); + expect( + authDbModule.getUidAndEmailByStripeCustomerId + ).not.toHaveBeenCalled(); + expect(mockCapabilityService.stripeUpdate).not.toHaveBeenCalled(); }); }); }); @@ -2080,29 +2255,27 @@ describe('StripeWebhookHandler', () => { }; it('sends the email with a list of subscriptions', async () => { - StripeWebhookHandlerInstance.db.account = sandbox - .stub() - .resolves(mockAccount); + StripeWebhookHandlerInstance.db.account = jest + .fn() + .mockResolvedValue(mockAccount); StripeWebhookHandlerInstance.mailer.sendSubscriptionPaymentExpiredEmail = - sandbox.stub(); + jest.fn(); await StripeWebhookHandlerInstance.sendSubscriptionPaymentExpiredEmail( mockSourceDetails ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.db.account, - UID - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.mailer.sendSubscriptionPaymentExpiredEmail, - [TEST_EMAIL], - mockAccount, - { - acceptLanguage: ACCOUNT_LOCALE, - ...mockSourceDetails, - } - ); + expect(StripeWebhookHandlerInstance.db.account).toHaveBeenCalledTimes(1); + expect(StripeWebhookHandlerInstance.db.account).toHaveBeenCalledWith(UID); + expect( + StripeWebhookHandlerInstance.mailer.sendSubscriptionPaymentExpiredEmail + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.mailer.sendSubscriptionPaymentExpiredEmail + ).toHaveBeenCalledWith([TEST_EMAIL], mockAccount, { + acceptLanguage: ACCOUNT_LOCALE, + ...mockSourceDetails, + }); }); it('send email using email on account', async () => { @@ -2110,29 +2283,27 @@ describe('StripeWebhookHandler', () => { ...mockSourceDetails, email: null, }; - StripeWebhookHandlerInstance.db.account = sandbox - .stub() - .resolves(mockAccount); + StripeWebhookHandlerInstance.db.account = jest + .fn() + .mockResolvedValue(mockAccount); StripeWebhookHandlerInstance.mailer.sendSubscriptionPaymentExpiredEmail = - sandbox.stub(); + jest.fn(); await StripeWebhookHandlerInstance.sendSubscriptionPaymentExpiredEmail( mockSourceDetailsNullEmail ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.db.account, - UID - ); - sinon.assert.calledOnceWithExactly( - StripeWebhookHandlerInstance.mailer.sendSubscriptionPaymentExpiredEmail, - [TEST_EMAIL], - mockAccount, - { - acceptLanguage: ACCOUNT_LOCALE, - ...mockSourceDetails, - } - ); + expect(StripeWebhookHandlerInstance.db.account).toHaveBeenCalledTimes(1); + expect(StripeWebhookHandlerInstance.db.account).toHaveBeenCalledWith(UID); + expect( + StripeWebhookHandlerInstance.mailer.sendSubscriptionPaymentExpiredEmail + ).toHaveBeenCalledTimes(1); + expect( + StripeWebhookHandlerInstance.mailer.sendSubscriptionPaymentExpiredEmail + ).toHaveBeenCalledWith([TEST_EMAIL], mockAccount, { + acceptLanguage: ACCOUNT_LOCALE, + ...mockSourceDetails, + }); }); }); @@ -2141,28 +2312,25 @@ describe('StripeWebhookHandler', () => { const invoice = deepCopy(eventInvoicePaymentFailed.data.object); const mockInvoiceDetails = { uid: '1234', test: 'fake' }; - StripeWebhookHandlerInstance.stripeHelper.extractInvoiceDetailsForEmail.resolves( + StripeWebhookHandlerInstance.stripeHelper.extractInvoiceDetailsForEmail.mockResolvedValue( mockInvoiceDetails ); const mockAccount = { emails: 'fakeemails', locale: 'fakelocale' }; - StripeWebhookHandlerInstance.db.account = sinon.spy( + StripeWebhookHandlerInstance.db.account = jest.fn( async () => mockAccount ); await StripeWebhookHandlerInstance.sendSubscriptionPaymentFailedEmail( invoice ); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.mailer.sendSubscriptionPaymentFailedEmail, - mockAccount.emails, - mockAccount, - { - acceptLanguage: mockAccount.locale, - ...mockInvoiceDetails, - email: (mockAccount as any).primaryEmail, - } - ); + expect( + StripeWebhookHandlerInstance.mailer.sendSubscriptionPaymentFailedEmail + ).toHaveBeenCalledWith(mockAccount.emails, mockAccount, { + acceptLanguage: mockAccount.locale, + ...mockInvoiceDetails, + email: (mockAccount as any).primaryEmail, + }); }); }); @@ -2179,7 +2347,7 @@ describe('StripeWebhookHandler', () => { invoice.billing_reason = billingReason; const mockInvoiceDetails = { uid: '1234', test: 'fake' }; - StripeWebhookHandlerInstance.stripeHelper.extractInvoiceDetailsForEmail.resolves( + StripeWebhookHandlerInstance.stripeHelper.extractInvoiceDetailsForEmail.mockResolvedValue( mockInvoiceDetails ); @@ -2188,40 +2356,33 @@ describe('StripeWebhookHandler', () => { locale: 'fakelocale', verifierSetAt, }; - StripeWebhookHandlerInstance.db.account = sinon.spy( + StripeWebhookHandlerInstance.db.account = jest.fn( async () => mockAccount ); await StripeWebhookHandlerInstance.sendSubscriptionInvoiceEmail( invoice ); - sinon.assert.calledWith( - StripeWebhookHandlerInstance.mailer[expectedMethodName], - mockAccount.emails, - mockAccount, - { - acceptLanguage: mockAccount.locale, - ...mockInvoiceDetails, - email: mockAccount.primaryEmail, - } - ); + expect( + StripeWebhookHandlerInstance.mailer[expectedMethodName] + ).toHaveBeenCalledWith(mockAccount.emails, mockAccount, { + acceptLanguage: mockAccount.locale, + ...mockInvoiceDetails, + email: mockAccount.primaryEmail, + }); if (expectedMethodName === 'sendSubscriptionFirstInvoiceEmail') { if (verifierSetAt) { - sinon.assert.calledWith( - StripeWebhookHandlerInstance.mailer.sendDownloadSubscriptionEmail, - mockAccount.emails, - mockAccount, - { - acceptLanguage: mockAccount.locale, - ...mockInvoiceDetails, - email: mockAccount.primaryEmail, - } - ); + expect( + StripeWebhookHandlerInstance.mailer.sendDownloadSubscriptionEmail + ).toHaveBeenCalledWith(mockAccount.emails, mockAccount, { + acceptLanguage: mockAccount.locale, + ...mockInvoiceDetails, + email: mockAccount.primaryEmail, + }); } else { expect( StripeWebhookHandlerInstance.mailer.sendDownloadSubscriptionEmail - .notCalled - ).toBe(true); + ).not.toHaveBeenCalled(); } } }; @@ -2248,7 +2409,7 @@ describe('StripeWebhookHandler', () => { invoice.billing_reason = 'subscription_update'; const mockInvoiceDetails = { uid: '1234', test: 'fake' }; - StripeWebhookHandlerInstance.stripeHelper.extractInvoiceDetailsForEmail.resolves( + StripeWebhookHandlerInstance.stripeHelper.extractInvoiceDetailsForEmail.mockResolvedValue( mockInvoiceDetails ); @@ -2257,7 +2418,7 @@ describe('StripeWebhookHandler', () => { locale: 'fakelocale', verifierSetAt: Date.now(), }; - StripeWebhookHandlerInstance.db.account = sinon.spy( + StripeWebhookHandlerInstance.db.account = jest.fn( async () => mockAccount ); @@ -2265,16 +2426,14 @@ describe('StripeWebhookHandler', () => { expect( StripeWebhookHandlerInstance.mailer.sendSubscriptionFirstInvoiceEmail - .notCalled - ).toBe(true); + ).not.toHaveBeenCalled(); expect( StripeWebhookHandlerInstance.mailer - .sendSubscriptionSubsequentInvoiceEmail.notCalled - ).toBe(true); + .sendSubscriptionSubsequentInvoiceEmail + ).not.toHaveBeenCalled(); expect( StripeWebhookHandlerInstance.mailer.sendDownloadSubscriptionEmail - .notCalled - ).toBe(true); + ).not.toHaveBeenCalled(); }); }); @@ -2288,12 +2447,12 @@ describe('StripeWebhookHandler', () => { test: 'fake', updateType, }; - StripeWebhookHandlerInstance.stripeHelper.extractSubscriptionUpdateEventDetailsForEmail.resolves( + StripeWebhookHandlerInstance.stripeHelper.extractSubscriptionUpdateEventDetailsForEmail.mockResolvedValue( mockDetails ); const mockAccount = { emails: 'fakeemails', locale: 'fakelocale' }; - StripeWebhookHandlerInstance.db.account = sinon.spy( + StripeWebhookHandlerInstance.db.account = jest.fn( async () => mockAccount ); @@ -2311,15 +2470,12 @@ describe('StripeWebhookHandler', () => { } as any )[updateType]; - sinon.assert.calledWith( - StripeWebhookHandlerInstance.mailer[expectedMethodName], - mockAccount.emails, - mockAccount, - { - acceptLanguage: mockAccount.locale, - ...mockDetails, - } - ); + expect( + StripeWebhookHandlerInstance.mailer[expectedMethodName] + ).toHaveBeenCalledWith(mockAccount.emails, mockAccount, { + acceptLanguage: mockAccount.locale, + ...mockDetails, + }); }; it( @@ -2385,7 +2541,7 @@ describe('StripeWebhookHandler', () => { cancelled_for_customer_at: moment().unix(), }; } - StripeWebhookHandlerInstance.stripeHelper.checkSubscriptionPastDue.returns( + StripeWebhookHandlerInstance.stripeHelper.checkSubscriptionPastDue.mockReturnValue( options.involuntaryCancellation ); @@ -2399,49 +2555,45 @@ describe('StripeWebhookHandler', () => { } else { mockInvoiceDetails.invoiceStatus = 'paid'; } - StripeWebhookHandlerInstance.stripeHelper.extractInvoiceDetailsForEmail.resolves( + StripeWebhookHandlerInstance.stripeHelper.extractInvoiceDetailsForEmail.mockResolvedValue( mockInvoiceDetails ); - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves({ - id: 'in_1GB4aHKb9q6OnNsLC9pbVY5a', - }); + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( + { + id: 'in_1GB4aHKb9q6OnNsLC9pbVY5a', + } + ); const mockAccount = { emails: 'fakeemails', locale: 'fakelocale' }; - StripeWebhookHandlerInstance.db.account = sinon.spy( - async (data: any) => { - if (options.accountFound) { - return mockAccount; - } - throw error.unknownAccount(); + StripeWebhookHandlerInstance.db.account = jest.fn(async (data: any) => { + if (options.accountFound) { + return mockAccount; } - ); + throw error.unknownAccount(); + }); await StripeWebhookHandlerInstance.sendSubscriptionDeletedEmail( subscription ); if (shouldSendSubscriptionFailedPaymentsCancellationEmail()) { - sinon.assert.calledWith( + expect( StripeWebhookHandlerInstance.stripeHelper - .extractInvoiceDetailsForEmail, - { id: subscription.latest_invoice } - ); - sinon.assert.calledWith( + .extractInvoiceDetailsForEmail + ).toHaveBeenCalledWith({ id: subscription.latest_invoice }); + expect( StripeWebhookHandlerInstance.mailer - .sendSubscriptionFailedPaymentsCancellationEmail, - mockAccount.emails, - mockAccount, - { - acceptLanguage: mockAccount.locale, - ...mockInvoiceDetails, - email: (mockAccount as any).primaryEmail, - } - ); + .sendSubscriptionFailedPaymentsCancellationEmail + ).toHaveBeenCalledWith(mockAccount.emails, mockAccount, { + acceptLanguage: mockAccount.locale, + ...mockInvoiceDetails, + email: (mockAccount as any).primaryEmail, + }); } else { - sinon.assert.notCalled( + expect( StripeWebhookHandlerInstance.mailer .sendSubscriptionFailedPaymentsCancellationEmail - ); + ).not.toHaveBeenCalled(); } if (shouldSendAccountDeletedEmail()) { @@ -2450,40 +2602,38 @@ describe('StripeWebhookHandler', () => { uid: mockInvoiceDetails.uid, emails: [{ email: mockInvoiceDetails.email, isPrimary: true }], }; - sinon.assert.calledWith( + expect( StripeWebhookHandlerInstance.mailer - .sendSubscriptionAccountDeletionEmail, + .sendSubscriptionAccountDeletionEmail + ).toHaveBeenCalledWith( fakeAccount.emails, fakeAccount, mockInvoiceDetails ); } else { - sinon.assert.notCalled( + expect( StripeWebhookHandlerInstance.mailer .sendSubscriptionAccountDeletionEmail - ); + ).not.toHaveBeenCalled(); } if (shouldSendCancellationEmail()) { - sinon.assert.calledWith( + expect( StripeWebhookHandlerInstance.mailer - .sendSubscriptionCancellationEmail, - mockAccount.emails, - mockAccount, - { - acceptLanguage: mockAccount.locale, - ...mockInvoiceDetails, - showOutstandingBalance: options.hasOutstandingBalance, - cancelAtEnd: subscription.cancel_at_period_end, - isFreeTrialCancellation: false, - email: (mockAccount as any).primaryEmail, - } - ); + .sendSubscriptionCancellationEmail + ).toHaveBeenCalledWith(mockAccount.emails, mockAccount, { + acceptLanguage: mockAccount.locale, + ...mockInvoiceDetails, + showOutstandingBalance: options.hasOutstandingBalance, + cancelAtEnd: subscription.cancel_at_period_end, + isFreeTrialCancellation: false, + email: (mockAccount as any).primaryEmail, + }); } else { - sinon.assert.notCalled( + expect( StripeWebhookHandlerInstance.mailer .sendSubscriptionCancellationEmail - ); + ).not.toHaveBeenCalled(); } }; @@ -2580,7 +2730,7 @@ describe('StripeWebhookHandler', () => { }; beforeEach(() => { - StripeWebhookHandlerInstance.stripeHelper.expandResource.resolves( + StripeWebhookHandlerInstance.stripeHelper.expandResource.mockResolvedValue( mockInvoice ); }); diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/stripe.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/stripe.spec.ts index ef30c01875a..18f7a6e9731 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/stripe.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/stripe.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const { Container } = require('typedi'); const uuid = require('uuid'); const mocks = require('../../../test/mocks'); @@ -21,10 +19,7 @@ const { } = require('@fxa/payments/customer'); const uuidv4 = require('uuid').v4; -const { - sanitizePlans, - handleAuth, -} = require('.'); +const { sanitizePlans, handleAuth } = require('.'); // Import the real buildTaxAddress for direct tests (not through the mock) const { buildTaxAddress: realBuildTaxAddress } = jest.requireActual('./utils'); @@ -45,8 +40,11 @@ jest.mock('./utils', () => ({ const { StripeHandler: DirectStripeRoutes } = require('./stripe'); const accountUtils = require('../utils/account'); -const { getAccountCustomerByUid: getAccountCustomerByUidStub } = require('fxa-shared/db/models/auth'); -const { deleteAccountIfUnverified: deleteAccountIfUnverifiedStub } = accountUtils; +const { + getAccountCustomerByUid: getAccountCustomerByUidStub, +} = require('fxa-shared/db/models/auth'); +const { deleteAccountIfUnverified: deleteAccountIfUnverifiedStub } = + accountUtils; const { buildTaxAddress: buildTaxAddressStub } = require('./utils'); const { AuthLogger, AppConfig } = require('../../types'); const { CapabilityService } = require('../../payments/capability'); @@ -76,7 +74,13 @@ const currencyHelper = new CurrencyHelper({ const mockCapabilityService: any = {}; const mockPromotionCodeManager: any = {}; -let config: any, log: any, db: any, customs: any, push: any, mailer: any, profile: any; +let config: any, + log: any, + db: any, + customs: any, + push: any, + mailer: any, + profile: any; const { OAUTH_SCOPE_SUBSCRIPTIONS } = require('fxa-shared/oauth/constants'); const { @@ -183,8 +187,8 @@ describe('subscriptions stripeRoutes', () => { const currencyHelper = new CurrencyHelper(config); Container.set(CurrencyHelper, currencyHelper); - mockCapabilityService.getClients = sinon.stub(); - mockCapabilityService.getClients.resolves(mockCMSClients); + mockCapabilityService.getClients = jest.fn(); + mockCapabilityService.getClients.mockResolvedValue(mockCMSClients); Container.set(CapabilityService, mockCapabilityService); log = mocks.mockLog(); @@ -198,35 +202,37 @@ describe('subscriptions stripeRoutes', () => { email: TEST_EMAIL, locale: ACCOUNT_LOCALE, }); - db.createAccountSubscription = sinon.spy(async (data: any) => ({})); - db.deleteAccountSubscription = sinon.spy( + db.createAccountSubscription = jest.fn(async (data: any) => ({})); + db.deleteAccountSubscription = jest.fn( async (uid: any, subscriptionId: any) => ({}) ); - db.cancelAccountSubscription = sinon.spy(async () => ({})); - db.fetchAccountSubscriptions = sinon.spy(async (uid: any) => + db.cancelAccountSubscription = jest.fn(async () => ({})); + db.fetchAccountSubscriptions = jest.fn(async (uid: any) => ACTIVE_SUBSCRIPTIONS.filter((s) => s.uid === uid) ); - db.getAccountSubscription = sinon.spy(async (uid: any, subscriptionId: any) => { - const subscription = ACTIVE_SUBSCRIPTIONS.filter( - (s) => s.uid === uid && s.subscriptionId === subscriptionId - )[0]; - if (typeof subscription === 'undefined') { - throw { statusCode: 404, errno: 116 }; + db.getAccountSubscription = jest.fn( + async (uid: any, subscriptionId: any) => { + const subscription = ACTIVE_SUBSCRIPTIONS.filter( + (s) => s.uid === uid && s.subscriptionId === subscriptionId + )[0]; + if (typeof subscription === 'undefined') { + throw { statusCode: 404, errno: 116 }; + } + return subscription; } - return subscription; - }); + ); push = mocks.mockPush(); mailer = mocks.mockMailer(); profile = mocks.mockProfile({ - deleteCache: sinon.spy(async (uid: any) => ({})), + deleteCache: jest.fn(async (uid: any) => ({})), }); }); afterEach(() => { Container.reset(); - sinon.restore(); + jest.restoreAllMocks(); }); const VALID_REQUEST: any = { @@ -246,7 +252,7 @@ describe('subscriptions stripeRoutes', () => { it('should list available subscription plans', async () => { const stripeHelper = mocks.mockStripeHelper(['allAbbrevPlans']); - stripeHelper.allAbbrevPlans = sinon.spy(async () => { + stripeHelper.allAbbrevPlans = jest.fn(async () => { return PLANS; }); @@ -270,7 +276,7 @@ describe('subscriptions stripeRoutes', () => { it('should list active subscriptions', async () => { const stripeHelper = mocks.mockStripeHelper(['fetchCustomer']); - stripeHelper.fetchCustomer = sinon.spy(async (uid: any, customer: any) => { + stripeHelper.fetchCustomer = jest.fn(async (uid: any, customer: any) => { return customerFixture; }); @@ -360,7 +366,7 @@ describe('handleAuth', () => { it('should propogate errors from database', async () => { let failed = false; - db.account = sinon.spy(async () => { + db.account = jest.fn(async () => { throw error.unknownAccount(); }); @@ -378,7 +384,6 @@ describe('handleAuth', () => { }); describe('DirectStripeRoutes', () => { - let sandbox: any; let directStripeRoutesInstance: any; const VALID_REQUEST: any = { @@ -396,8 +401,6 @@ describe('DirectStripeRoutes', () => { }; beforeEach(() => { - sandbox = sinon.createSandbox(); - config = { subscriptions: { enabled: true, @@ -412,7 +415,7 @@ describe('DirectStripeRoutes', () => { log = mocks.mockLog(); customs = mocks.mockCustoms(); profile = mocks.mockProfile({ - deleteCache: sinon.spy(async (uid: any) => ({})), + deleteCache: jest.fn(async (uid: any) => ({})), }); mailer = mocks.mockMailer(); @@ -422,30 +425,43 @@ describe('DirectStripeRoutes', () => { locale: ACCOUNT_LOCALE, verifierSetAt: 0, }); - const stripeHelperMock = sandbox.createStubInstance(StripeHelper); + const stripeHelperMock: any = {}; + // Mock all methods from the prototype chain (including parent classes) + let proto = StripeHelper.prototype; + while (proto && proto !== Object.prototype) { + Object.getOwnPropertyNames(proto).forEach((m) => { + if (m !== 'constructor' && !stripeHelperMock[m]) { + stripeHelperMock[m] = jest.fn(); + } + }); + proto = Object.getPrototypeOf(proto); + } stripeHelperMock.currencyHelper = currencyHelper; stripeHelperMock.stripe = { subscriptions: { - del: sinon.spy(async (uid: any) => undefined), - cancel: sinon.spy(async () => undefined), + del: jest.fn(async (uid: any) => undefined), + cancel: jest.fn(async () => undefined), }, }; - mockCapabilityService.getPlanEligibility = sinon.stub(); - mockCapabilityService.getPlanEligibility.resolves({ + mockCapabilityService.getPlanEligibility = jest.fn(); + mockCapabilityService.getPlanEligibility.mockResolvedValue({ subscriptionEligibilityResult: SubscriptionEligibilityResult.CREATE, }); - mockCapabilityService.getClients = sinon.stub(); - mockCapabilityService.getClients.resolves(mockCMSClients); + mockCapabilityService.getClients = jest.fn(); + mockCapabilityService.getClients.mockResolvedValue(mockCMSClients); Container.set(CapabilityService, mockCapabilityService); const mockSubscription = deepCopy(subscription2); - mockPromotionCodeManager.applyPromoCodeToSubscription = sinon.stub(); - mockPromotionCodeManager.applyPromoCodeToSubscription.resolves( + mockPromotionCodeManager.applyPromoCodeToSubscription = jest.fn(); + mockPromotionCodeManager.applyPromoCodeToSubscription.mockResolvedValue( mockSubscription ); Container.set(PromotionCodeManager, mockPromotionCodeManager); buildTaxAddressStub.mockReset(); - buildTaxAddressStub.mockReturnValue({ countryCode: 'US', postalCode: '92841' }); + buildTaxAddressStub.mockReturnValue({ + countryCode: 'US', + postalCode: '92841', + }); directStripeRoutesInstance = new DirectStripeRoutes( log, @@ -460,13 +476,13 @@ describe('DirectStripeRoutes', () => { }); afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); describe('extractPromotionCode', () => { it('should extract a valid PromotionCode', async () => { const promotionCode = { coupon: { id: 'test-code' } }; - directStripeRoutesInstance.stripeHelper.findValidPromoCode.resolves( + directStripeRoutesInstance.stripeHelper.findValidPromoCode.mockResolvedValue( promotionCode ); const res = await directStripeRoutesInstance.extractPromotionCode( @@ -477,7 +493,7 @@ describe('DirectStripeRoutes', () => { }); it('should throw an error if on invalid promotion code', async () => { - directStripeRoutesInstance.stripeHelper.findValidPromoCode.resolves( + directStripeRoutesInstance.stripeHelper.findValidPromoCode.mockResolvedValue( undefined ); try { @@ -501,27 +517,26 @@ describe('DirectStripeRoutes', () => { ); expect( - directStripeRoutesInstance.profile.deleteCache.calledOnceWith(UID) - ).toBe(true); + directStripeRoutesInstance.profile.deleteCache + ).toHaveBeenCalledWith(UID); expect( - directStripeRoutesInstance.push.notifyProfileUpdated.calledOnceWith( - UID, - VALID_REQUEST.app.devices - ) - ).toBe(true); + directStripeRoutesInstance.push.notifyProfileUpdated + ).toHaveBeenCalledWith(UID, VALID_REQUEST.app.devices); expect( - directStripeRoutesInstance.profile.deleteCache.calledOnceWith(UID) - ).toBe(true); + directStripeRoutesInstance.profile.deleteCache + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.profile.deleteCache + ).toHaveBeenCalledWith(UID); expect( - directStripeRoutesInstance.log.notifyAttachedServices.calledOnceWith( - 'profileDataChange', - VALID_REQUEST, - { uid: UID } - ) - ).toBe(true); + directStripeRoutesInstance.log.notifyAttachedServices + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.log.notifyAttachedServices + ).toHaveBeenCalledWith('profileDataChange', VALID_REQUEST, { uid: UID }); }); }); @@ -536,7 +551,7 @@ describe('DirectStripeRoutes', () => { describe('createCustomer', () => { it('creates a stripe customer', async () => { const expected = deepCopy(emptyCustomer); - directStripeRoutesInstance.stripeHelper.createPlainCustomer.resolves( + directStripeRoutesInstance.stripeHelper.createPlainCustomer.mockResolvedValue( expected ); VALID_REQUEST.payload = { @@ -549,8 +564,8 @@ describe('DirectStripeRoutes', () => { const actual = await directStripeRoutesInstance.createCustomer(VALID_REQUEST); const callArgs = - directStripeRoutesInstance.stripeHelper.createPlainCustomer.getCall(0) - .args[0]; + directStripeRoutesInstance.stripeHelper.createPlainCustomer.mock + .calls[0][0]; expect(callArgs.taxAddress).toBe(undefined); expect(actual).toEqual(filterCustomer(expected)); @@ -558,7 +573,7 @@ describe('DirectStripeRoutes', () => { it('creates a stripe customer with the shipping address on automatic tax', async () => { const expected = deepCopy(emptyCustomer); - directStripeRoutesInstance.stripeHelper.createPlainCustomer.resolves( + directStripeRoutesInstance.stripeHelper.createPlainCustomer.mockResolvedValue( expected ); VALID_REQUEST.payload = { @@ -571,13 +586,16 @@ describe('DirectStripeRoutes', () => { postalCode: '92841', }, }; - buildTaxAddressStub.mockReturnValue({ countryCode: 'US', postalCode: '92841' }); + buildTaxAddressStub.mockReturnValue({ + countryCode: 'US', + postalCode: '92841', + }); const actual = await directStripeRoutesInstance.createCustomer(VALID_REQUEST); const callArgs = - directStripeRoutesInstance.stripeHelper.createPlainCustomer.getCall(0) - .args[0]; + directStripeRoutesInstance.stripeHelper.createPlainCustomer.mock + .calls[0][0]; expect(callArgs.taxAddress?.countryCode).toBe('US'); expect(callArgs.taxAddress?.postalCode).toBe('92841'); expect(actual).toEqual(filterCustomer(expected)); @@ -587,7 +605,7 @@ describe('DirectStripeRoutes', () => { describe('previewInvoice', () => { it('returns the preview invoice', async () => { const expected = deepCopy(invoicePreviewTax); - directStripeRoutesInstance.stripeHelper.previewInvoice.resolves([ + directStripeRoutesInstance.stripeHelper.previewInvoice.mockResolvedValue([ expected, undefined, ]); @@ -599,29 +617,31 @@ describe('DirectStripeRoutes', () => { buildTaxAddressStub.mockReturnValue(undefined); const actual = await directStripeRoutesInstance.previewInvoice(VALID_REQUEST); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.customs.checkAuthenticated, - VALID_REQUEST, - UID, - TEST_EMAIL, - 'previewInvoice' - ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.fetchCustomer, - UID, - ['subscriptions', 'tax'] - ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.previewInvoice, - { - customer: undefined, - promotionCode: 'promotionCode', - priceId: 'priceId', - taxAddress: undefined, - isUpgrade: false, - sourcePlan: undefined, - } - ); + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledWith(VALID_REQUEST, UID, TEST_EMAIL, 'previewInvoice'); + expect( + directStripeRoutesInstance.stripeHelper.fetchCustomer + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.fetchCustomer + ).toHaveBeenCalledWith(UID, ['subscriptions', 'tax']); + expect( + directStripeRoutesInstance.stripeHelper.previewInvoice + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.previewInvoice + ).toHaveBeenCalledWith({ + customer: undefined, + promotionCode: 'promotionCode', + priceId: 'priceId', + taxAddress: undefined, + isUpgrade: false, + sourcePlan: undefined, + }); expect(actual).toEqual( stripeInvoiceToFirstInvoicePreviewDTO([expected, undefined]) ); @@ -632,11 +652,11 @@ describe('DirectStripeRoutes', () => { mockCustomer.tax = { automatic_tax: 'supported', }; - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( mockCustomer ); const expected = deepCopy(invoicePreviewTax); - directStripeRoutesInstance.stripeHelper.previewInvoice.resolves([ + directStripeRoutesInstance.stripeHelper.previewInvoice.mockResolvedValue([ expected, undefined, ]); @@ -648,29 +668,31 @@ describe('DirectStripeRoutes', () => { buildTaxAddressStub.mockReturnValue(undefined); const actual = await directStripeRoutesInstance.previewInvoice(VALID_REQUEST); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.customs.checkAuthenticated, - VALID_REQUEST, - UID, - TEST_EMAIL, - 'previewInvoice' - ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.fetchCustomer, - UID, - ['subscriptions', 'tax'] - ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.previewInvoice, - { - customer: mockCustomer, - promotionCode: 'promotionCode', - priceId: 'priceId', - taxAddress: undefined, - isUpgrade: false, - sourcePlan: undefined, - } - ); + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledWith(VALID_REQUEST, UID, TEST_EMAIL, 'previewInvoice'); + expect( + directStripeRoutesInstance.stripeHelper.fetchCustomer + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.fetchCustomer + ).toHaveBeenCalledWith(UID, ['subscriptions', 'tax']); + expect( + directStripeRoutesInstance.stripeHelper.previewInvoice + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.previewInvoice + ).toHaveBeenCalledWith({ + customer: mockCustomer, + promotionCode: 'promotionCode', + priceId: 'priceId', + taxAddress: undefined, + isUpgrade: false, + sourcePlan: undefined, + }); expect(actual).toEqual( stripeInvoiceToFirstInvoicePreviewDTO([expected, undefined]) ); @@ -678,13 +700,17 @@ describe('DirectStripeRoutes', () => { it('returns the preview invoice even if fetch customer errors', async () => { const expected = deepCopy(invoicePreviewTax); - directStripeRoutesInstance.stripeHelper.previewInvoice.resolves([ + directStripeRoutesInstance.stripeHelper.previewInvoice.mockResolvedValue([ expected, undefined, ]); const fetchError = new Error('test'); - directStripeRoutesInstance.stripeHelper.fetchCustomer.throws(fetchError); + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockImplementation( + () => { + throw fetchError; + } + ); VALID_REQUEST.payload = { promotionCode: 'promotionCode', @@ -696,20 +722,22 @@ describe('DirectStripeRoutes', () => { postalCode: '92841', }, }; - buildTaxAddressStub.mockReturnValue({ countryCode: 'US', postalCode: '92841' }); + buildTaxAddressStub.mockReturnValue({ + countryCode: 'US', + postalCode: '92841', + }); const actual = await directStripeRoutesInstance.previewInvoice(VALID_REQUEST); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.customs.checkAuthenticated, - VALID_REQUEST, - UID, - TEST_EMAIL, - 'previewInvoice' - ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.log.error, + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledWith(VALID_REQUEST, UID, TEST_EMAIL, 'previewInvoice'); + expect(directStripeRoutesInstance.log.error).toHaveBeenCalledTimes(1); + expect(directStripeRoutesInstance.log.error).toHaveBeenCalledWith( 'previewInvoice.fetchCustomer', { error: fetchError, @@ -717,20 +745,22 @@ describe('DirectStripeRoutes', () => { } ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.previewInvoice, - { - customer: undefined, - promotionCode: 'promotionCode', - priceId: 'priceId', - taxAddress: { - countryCode: 'US', - postalCode: '92841', - }, - isUpgrade: false, - sourcePlan: undefined, - } - ); + expect( + directStripeRoutesInstance.stripeHelper.previewInvoice + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.previewInvoice + ).toHaveBeenCalledWith({ + customer: undefined, + promotionCode: 'promotionCode', + priceId: 'priceId', + taxAddress: { + countryCode: 'US', + postalCode: '92841', + }, + isUpgrade: false, + sourcePlan: undefined, + }); expect(actual).toEqual( stripeInvoiceToFirstInvoicePreviewDTO([expected, undefined]) ); @@ -738,7 +768,7 @@ describe('DirectStripeRoutes', () => { it('does not call fetchCustomer if no credentials are provided, and returns invoice preview', async () => { const expected = deepCopy(invoicePreviewTax); - directStripeRoutesInstance.stripeHelper.previewInvoice.resolves([ + directStripeRoutesInstance.stripeHelper.previewInvoice.mockResolvedValue([ expected, undefined, ]); @@ -759,33 +789,39 @@ describe('DirectStripeRoutes', () => { }, }; request.auth.credentials = undefined; - buildTaxAddressStub.mockReturnValue({ countryCode: 'DE', postalCode: '92841' }); + buildTaxAddressStub.mockReturnValue({ + countryCode: 'DE', + postalCode: '92841', + }); const actual = await directStripeRoutesInstance.previewInvoice(request); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.customs.checkIpOnly, - request, - 'previewInvoice' - ); - sinon.assert.notCalled( + expect( + directStripeRoutesInstance.customs.checkIpOnly + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.customs.checkIpOnly + ).toHaveBeenCalledWith(request, 'previewInvoice'); + expect( directStripeRoutesInstance.stripeHelper.fetchCustomer - ); + ).not.toHaveBeenCalled(); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.previewInvoice, - { - customer: undefined, - promotionCode: 'promotionCode', - priceId: 'priceId', - taxAddress: { - countryCode: 'DE', - postalCode: '92841', - }, - isUpgrade: false, - sourcePlan: undefined, - } - ); + expect( + directStripeRoutesInstance.stripeHelper.previewInvoice + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.previewInvoice + ).toHaveBeenCalledWith({ + customer: undefined, + promotionCode: 'promotionCode', + priceId: 'priceId', + taxAddress: { + countryCode: 'DE', + postalCode: '92841', + }, + isUpgrade: false, + sourcePlan: undefined, + }); expect(actual).toEqual( stripeInvoiceToFirstInvoicePreviewDTO([expected, undefined]) ); @@ -794,7 +830,9 @@ describe('DirectStripeRoutes', () => { it('error with AppError invalidInvoicePreviewRequest', async () => { const appError: any = new Error('Stripe error'); appError.type = 'StripeInvalidRequestError'; - directStripeRoutesInstance.stripeHelper.previewInvoice.rejects(appError); + directStripeRoutesInstance.stripeHelper.previewInvoice.mockRejectedValue( + appError + ); const request = deepCopy(VALID_REQUEST); @@ -829,10 +867,10 @@ describe('DirectStripeRoutes', () => { expectedPreviewInvoiceBySubscriptionId: any ) { const expected = deepCopy(invoicePreviewTax); - directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId.resolves( + directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId.mockResolvedValue( expected ); - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves({ + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue({ id: 'cus_id', subscriptions: customerSubscriptions, }); @@ -846,24 +884,26 @@ describe('DirectStripeRoutes', () => { const actual = await directStripeRoutesInstance.subsequentInvoicePreviews(VALID_REQUEST); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.customs.checkAuthenticated, + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledWith( VALID_REQUEST, UID, TEST_EMAIL, 'subsequentInvoicePreviews' ); - sinon.assert.calledTwice( + expect( directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId - ); - sinon.assert.calledWith( - directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId, - expectedPreviewInvoiceBySubscriptionId[0] - ); - sinon.assert.calledWith( - directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId, - expectedPreviewInvoiceBySubscriptionId[1] - ); + ).toHaveBeenCalledTimes(2); + expect( + directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId + ).toHaveBeenCalledWith(expectedPreviewInvoiceBySubscriptionId[0]); + expect( + directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId + ).toHaveBeenCalledWith(expectedPreviewInvoiceBySubscriptionId[1]); expect(actual).toEqual( stripeInvoicesToSubsequentInvoicePreviewsDTO([expected, expected]) ); @@ -908,7 +948,7 @@ describe('DirectStripeRoutes', () => { it('return empty array if customer has no subscriptions', async () => { const expected: any[] = []; - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves({ + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue({ id: 'cus_id', subscriptions: { data: [], @@ -921,21 +961,27 @@ describe('DirectStripeRoutes', () => { VALID_REQUEST ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.customs.checkAuthenticated, + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledWith( VALID_REQUEST, UID, TEST_EMAIL, 'subsequentInvoicePreviews' ); - sinon.assert.notCalled( + expect( directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId - ); + ).not.toHaveBeenCalled(); expect(actual).toEqual(expected); }); it('returns empty array if customer is not found', async () => { - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(null); + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( + null + ); VALID_REQUEST.app.geo = {}; const expected: any[] = []; const actual = @@ -943,16 +989,20 @@ describe('DirectStripeRoutes', () => { VALID_REQUEST ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.customs.checkAuthenticated, + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledWith( VALID_REQUEST, UID, TEST_EMAIL, 'subsequentInvoicePreviews' ); - sinon.assert.notCalled( + expect( directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId - ); + ).not.toHaveBeenCalled(); expect(actual).toEqual(expected); }); }); @@ -966,7 +1016,7 @@ describe('DirectStripeRoutes', () => { discountAmount: 50, }; - directStripeRoutesInstance.stripeHelper.retrieveCouponDetails.resolves( + directStripeRoutesInstance.stripeHelper.retrieveCouponDetails.mockResolvedValue( expected ); @@ -983,25 +1033,33 @@ describe('DirectStripeRoutes', () => { const actual = await directStripeRoutesInstance.retrieveCouponDetails(VALID_REQUEST); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.customs.checkAuthenticated, + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledWith( VALID_REQUEST, UID, TEST_EMAIL, 'retrieveCouponDetails' ); - sinon.assert.notCalled(directStripeRoutesInstance.customs.checkIpOnly); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.retrieveCouponDetails, - { - promotionCode: 'promotionCode', - priceId: 'priceId', - taxAddress: { - countryCode: 'US', - postalCode: '92841', - }, - } - ); + expect( + directStripeRoutesInstance.customs.checkIpOnly + ).not.toHaveBeenCalled(); + expect( + directStripeRoutesInstance.stripeHelper.retrieveCouponDetails + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.retrieveCouponDetails + ).toHaveBeenCalledWith({ + promotionCode: 'promotionCode', + priceId: 'priceId', + taxAddress: { + countryCode: 'US', + postalCode: '92841', + }, + }); expect(actual).toEqual(expected); }); @@ -1011,12 +1069,13 @@ describe('DirectStripeRoutes', () => { request.auth.credentials = undefined; await directStripeRoutesInstance.retrieveCouponDetails(request); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.customs.checkIpOnly, - request, - 'retrieveCouponDetails' - ); - sinon.assert.notCalled(directStripeRoutesInstance.customs.check); + expect( + directStripeRoutesInstance.customs.checkIpOnly + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.customs.checkIpOnly + ).toHaveBeenCalledWith(request, 'retrieveCouponDetails'); + expect(directStripeRoutesInstance.customs.check).not.toHaveBeenCalled(); }); }); @@ -1041,9 +1100,11 @@ describe('DirectStripeRoutes', () => { }); it('errors with AppError subscriptionPromotionCodeNotApplied if CustomerError returned from StripeService', async () => { - const sentryScope = { setContext: sandbox.stub() }; - sandbox.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sandbox.stub(sentryModule, 'reportSentryMessage'); + const sentryScope = { setContext: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(sentryModule, 'reportSentryMessage'); let mockSub = deepCopy(subscription2); const mockCustomer = deepCopy(customerFixture); @@ -1060,7 +1121,7 @@ describe('DirectStripeRoutes', () => { ...mockPrice, }; - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( mockCustomer ); @@ -1070,8 +1131,8 @@ describe('DirectStripeRoutes', () => { }; const stripeError = new CustomerError('Oh no.'); - mockPromotionCodeManager.applyPromoCodeToSubscription = sinon.stub(); - mockPromotionCodeManager.applyPromoCodeToSubscription.rejects( + mockPromotionCodeManager.applyPromoCodeToSubscription = jest.fn(); + mockPromotionCodeManager.applyPromoCodeToSubscription.mockRejectedValue( stripeError ); @@ -1081,19 +1142,17 @@ describe('DirectStripeRoutes', () => { ); } catch (err: any) { expect(err).toBeInstanceOf(error); - expect(err.errno).toBe( - error.ERRNO.SUBSCRIPTION_PROMO_CODE_NOT_APPLIED - ); + expect(err.errno).toBe(error.ERRNO.SUBSCRIPTION_PROMO_CODE_NOT_APPLIED); } - sinon.assert.notCalled(Sentry.withScope); + expect(Sentry.withScope).not.toHaveBeenCalled(); }); it('throws error if fails', async () => { const mockSubscription = deepCopy(subscription2); const mockCustomer = mockSubscription.customer; - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( mockCustomer ); @@ -1103,8 +1162,10 @@ describe('DirectStripeRoutes', () => { }; const testError = new Error('Something went wrong'); - mockPromotionCodeManager.applyPromoCodeToSubscription = sinon.stub(); - mockPromotionCodeManager.applyPromoCodeToSubscription.rejects(testError); + mockPromotionCodeManager.applyPromoCodeToSubscription = jest.fn(); + mockPromotionCodeManager.applyPromoCodeToSubscription.mockRejectedValue( + testError + ); try { await directStripeRoutesInstance.applyPromotionCodeToSubscription( @@ -1123,7 +1184,7 @@ describe('DirectStripeRoutes', () => { const mockCustomer = deepCopy(customerFixture); mockSubscription.customer = mockCustomer.id; - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( mockCustomer ); @@ -1132,8 +1193,8 @@ describe('DirectStripeRoutes', () => { subscriptionId: mockSubscription.id, }; - mockPromotionCodeManager.applyPromoCodeToSubscription = sinon.stub(); - mockPromotionCodeManager.applyPromoCodeToSubscription.resolves( + mockPromotionCodeManager.applyPromoCodeToSubscription = jest.fn(); + mockPromotionCodeManager.applyPromoCodeToSubscription.mockResolvedValue( mockSubscription ); @@ -1142,8 +1203,12 @@ describe('DirectStripeRoutes', () => { VALID_REQUEST ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.customs.checkAuthenticated, + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.customs.checkAuthenticated + ).toHaveBeenCalledWith( VALID_REQUEST, UID, TEST_EMAIL, @@ -1151,12 +1216,15 @@ describe('DirectStripeRoutes', () => { ); expect( - mockPromotionCodeManager.applyPromoCodeToSubscription.calledOnceWithExactly( - mockCustomer.id, - mockSubscription.id, - 'promo_code1' - ) - ).toBe(true); + mockPromotionCodeManager.applyPromoCodeToSubscription + ).toHaveBeenCalledTimes(1); + expect( + mockPromotionCodeManager.applyPromoCodeToSubscription + ).toHaveBeenCalledWith( + mockCustomer.id, + mockSubscription.id, + 'promo_code1' + ); expect(actual).toEqual(mockSubscription); }); @@ -1168,31 +1236,41 @@ describe('DirectStripeRoutes', () => { beforeEach(() => { plan = deepCopy(PLANS[2]); plan.currency = 'USD'; - directStripeRoutesInstance.stripeHelper.findAbbrevPlanById.resolves(plan); - sandbox.stub(directStripeRoutesInstance, 'customerChanged').resolves(); + directStripeRoutesInstance.stripeHelper.findAbbrevPlanById.mockResolvedValue( + plan + ); + jest + .spyOn(directStripeRoutesInstance, 'customerChanged') + .mockResolvedValue(); paymentMethod = deepCopy(paymentMethodFixture); - directStripeRoutesInstance.stripeHelper.getPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.getPaymentMethod.mockResolvedValue( paymentMethod ); customer = deepCopy(emptyCustomer); - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer); - directStripeRoutesInstance.stripeHelper.findCustomerSubscriptionByPlanId.returns( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( + customer + ); + directStripeRoutesInstance.stripeHelper.findCustomerSubscriptionByPlanId.mockReturnValue( undefined ); - directStripeRoutesInstance.stripeHelper.setCustomerLocation.resolves(); + directStripeRoutesInstance.stripeHelper.setCustomerLocation.mockResolvedValue(); }); function setupCreateSuccessWithTaxIds() { const sourceCountry = 'US'; - directStripeRoutesInstance.stripeHelper.extractSourceCountryFromSubscription.returns( + directStripeRoutesInstance.stripeHelper.extractSourceCountryFromSubscription.mockReturnValue( sourceCountry ); const expected = deepCopy(subscription2); - directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.resolves( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.mockResolvedValue( expected ); - directStripeRoutesInstance.stripeHelper.customerTaxId.returns(false); - directStripeRoutesInstance.stripeHelper.addTaxIdToCustomer.resolves({}); + directStripeRoutesInstance.stripeHelper.customerTaxId.mockReturnValue( + false + ); + directStripeRoutesInstance.stripeHelper.addTaxIdToCustomer.mockResolvedValue( + {} + ); VALID_REQUEST.payload = { priceId: 'Jane Doe', paymentMethodId: 'pm_asdf', @@ -1202,12 +1280,13 @@ describe('DirectStripeRoutes', () => { } function assertSuccess(sourceCountry: any, actual: any, expected: any) { - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.getPaymentMethod, - VALID_REQUEST.payload.paymentMethodId - ); - sinon.assert.calledWith( - directStripeRoutesInstance.customerChanged, + expect( + directStripeRoutesInstance.stripeHelper.getPaymentMethod + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.getPaymentMethod + ).toHaveBeenCalledWith(VALID_REQUEST.payload.paymentMethodId); + expect(directStripeRoutesInstance.customerChanged).toHaveBeenCalledWith( VALID_REQUEST, UID, TEST_EMAIL @@ -1221,72 +1300,80 @@ describe('DirectStripeRoutes', () => { it('creates a subscription with a payment method and promotion code', async () => { const { sourceCountry, expected } = setupCreateSuccessWithTaxIds(); - directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns( + directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.mockReturnValue( true ); - directStripeRoutesInstance.extractPromotionCode = sinon.stub().resolves({ - coupon: { id: 'couponId' }, - }); + directStripeRoutesInstance.extractPromotionCode = jest + .fn() + .mockResolvedValue({ + coupon: { id: 'couponId' }, + }); const actual = await directStripeRoutesInstance.createSubscriptionWithPMI( VALID_REQUEST ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI, - { - customerId: 'cus_new', - priceId: 'Jane Doe', - paymentMethodId: 'pm_asdf', - promotionCode: { - coupon: { id: 'couponId' }, - }, - automaticTax: true, - } - ); + expect( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI + ).toHaveBeenCalledWith({ + customerId: 'cus_new', + priceId: 'Jane Doe', + paymentMethodId: 'pm_asdf', + promotionCode: { + coupon: { id: 'couponId' }, + }, + automaticTax: true, + }); assertSuccess(sourceCountry, actual, expected); }); it('creates a subscription with a payment method', async () => { const { sourceCountry, expected } = setupCreateSuccessWithTaxIds(); - directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns( + directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.mockReturnValue( true ); const actual = await directStripeRoutesInstance.createSubscriptionWithPMI( VALID_REQUEST ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI, - { - customerId: 'cus_new', - priceId: 'Jane Doe', - paymentMethodId: 'pm_asdf', - promotionCode: undefined, - automaticTax: true, - } - ); + expect( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI + ).toHaveBeenCalledWith({ + customerId: 'cus_new', + priceId: 'Jane Doe', + paymentMethodId: 'pm_asdf', + promotionCode: undefined, + automaticTax: true, + }); assertSuccess(sourceCountry, actual, expected); }); it('creates a subscription with a payment method using automatic tax but in an unsupported region', async () => { const { sourceCountry, expected } = setupCreateSuccessWithTaxIds(); - directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns( + directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.mockReturnValue( false ); const actual = await directStripeRoutesInstance.createSubscriptionWithPMI( VALID_REQUEST ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI, - { - customerId: 'cus_new', - priceId: 'Jane Doe', - paymentMethodId: 'pm_asdf', - promotionCode: undefined, - automaticTax: false, - } - ); + expect( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI + ).toHaveBeenCalledWith({ + customerId: 'cus_new', + priceId: 'Jane Doe', + paymentMethodId: 'pm_asdf', + promotionCode: undefined, + automaticTax: false, + }); assertSuccess(sourceCountry, actual, expected); }); @@ -1307,7 +1394,9 @@ describe('DirectStripeRoutes', () => { }); it('errors when a customer has not been created', async () => { - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(undefined); + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( + undefined + ); VALID_REQUEST.payload = { displayName: 'Jane Doe', idempotencyKey: uuidv4(), @@ -1324,7 +1413,7 @@ describe('DirectStripeRoutes', () => { }); it('errors when customer is already subscribed to plan', async () => { - mockCapabilityService.getPlanEligibility.resolves( + mockCapabilityService.getPlanEligibility.mockResolvedValue( SubscriptionEligibilityResult.INVALID ); @@ -1336,19 +1425,23 @@ describe('DirectStripeRoutes', () => { await directStripeRoutesInstance.createSubscriptionWithPMI( VALID_REQUEST ); - throw new Error('Create subscription when already subscribed should fail.'); + throw new Error( + 'Create subscription when already subscribed should fail.' + ); } catch (err: any) { expect(err).toBeInstanceOf(error); expect(err.errno).toBe(error.ERRNO.SUBSCRIPTION_ALREADY_EXISTS); - sinon.assert.notCalled( + expect( directStripeRoutesInstance.stripeHelper.cancelSubscription - ); + ).not.toHaveBeenCalled(); } }); it('errors if the planCurrency does not match the paymentMethod country', async () => { plan.currency = 'EUR'; - directStripeRoutesInstance.stripeHelper.findAbbrevPlanById.resolves(plan); + directStripeRoutesInstance.stripeHelper.findAbbrevPlanById.mockResolvedValue( + plan + ); VALID_REQUEST.payload = { priceId: 'Jane Doe', paymentMethodId: 'pm_asdf', @@ -1358,7 +1451,9 @@ describe('DirectStripeRoutes', () => { await directStripeRoutesInstance.createSubscriptionWithPMI( VALID_REQUEST ); - throw new Error('Create subscription with wrong planCurrency should fail.'); + throw new Error( + 'Create subscription with wrong planCurrency should fail.' + ); } catch (err: any) { expect(err).toBeInstanceOf(error); expect(err.errno).toBe(error.ERRNO.INVALID_REGION); @@ -1370,7 +1465,7 @@ describe('DirectStripeRoutes', () => { it('errors if the paymentMethod country does not match the planCurrency', async () => { paymentMethod.card.country = 'FR'; - directStripeRoutesInstance.stripeHelper.getPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.getPaymentMethod.mockResolvedValue( paymentMethod ); VALID_REQUEST.payload = { @@ -1382,7 +1477,9 @@ describe('DirectStripeRoutes', () => { await directStripeRoutesInstance.createSubscriptionWithPMI( VALID_REQUEST ); - throw new Error('Create subscription with wrong planCurrency should fail.'); + throw new Error( + 'Create subscription with wrong planCurrency should fail.' + ); } catch (err: any) { expect(err).toBeInstanceOf(error); expect(err.errno).toBe(error.ERRNO.INVALID_REGION); @@ -1394,7 +1491,7 @@ describe('DirectStripeRoutes', () => { it('calls deleteAccountIfUnverified when there is an error', async () => { paymentMethod.card.country = 'FR'; - directStripeRoutesInstance.stripeHelper.getPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.getPaymentMethod.mockResolvedValue( paymentMethod ); VALID_REQUEST.payload = { @@ -1410,7 +1507,9 @@ describe('DirectStripeRoutes', () => { await directStripeRoutesInstance.createSubscriptionWithPMI( VALID_REQUEST ); - throw new Error('Create subscription with wrong planCurrency should fail.'); + throw new Error( + 'Create subscription with wrong planCurrency should fail.' + ); } catch (err: any) { expect(deleteAccountIfUnverifiedStub).toHaveBeenCalledTimes(1); expect(err).toBeInstanceOf(error); @@ -1420,7 +1519,7 @@ describe('DirectStripeRoutes', () => { it('ignores account exists error from deleteAccountIfUnverified', async () => { paymentMethod.card.country = 'FR'; - directStripeRoutesInstance.stripeHelper.getPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.getPaymentMethod.mockResolvedValue( paymentMethod ); VALID_REQUEST.payload = { @@ -1438,7 +1537,9 @@ describe('DirectStripeRoutes', () => { await directStripeRoutesInstance.createSubscriptionWithPMI( VALID_REQUEST ); - throw new Error('Create subscription with wrong planCurrency should fail.'); + throw new Error( + 'Create subscription with wrong planCurrency should fail.' + ); } catch (err: any) { expect(deleteAccountIfUnverifiedStub).toHaveBeenCalledTimes(1); expect(err).toBeInstanceOf(error); @@ -1448,7 +1549,7 @@ describe('DirectStripeRoutes', () => { it('ignores verified email error from deleteAccountIfUnverified', async () => { paymentMethod.card.country = 'FR'; - directStripeRoutesInstance.stripeHelper.getPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.getPaymentMethod.mockResolvedValue( paymentMethod ); VALID_REQUEST.payload = { @@ -1466,7 +1567,9 @@ describe('DirectStripeRoutes', () => { await directStripeRoutesInstance.createSubscriptionWithPMI( VALID_REQUEST ); - throw new Error('Create subscription with wrong planCurrency should fail.'); + throw new Error( + 'Create subscription with wrong planCurrency should fail.' + ); } catch (err: any) { expect(deleteAccountIfUnverifiedStub).toHaveBeenCalledTimes(1); expect(err).toBeInstanceOf(error); @@ -1475,8 +1578,6 @@ describe('DirectStripeRoutes', () => { }); it('skips calling deleteAccountIfUnverified if verifiedSetAt is greater than 0', async () => { - sandbox = sinon.createSandbox(); - config = { subscriptions: { enabled: true, @@ -1490,7 +1591,7 @@ describe('DirectStripeRoutes', () => { log = mocks.mockLog(); customs = mocks.mockCustoms(); profile = mocks.mockProfile({ - deleteCache: sinon.spy(async (uid: any) => ({})), + deleteCache: jest.fn(async (uid: any) => ({})), }); mailer = mocks.mockMailer(); @@ -1499,7 +1600,17 @@ describe('DirectStripeRoutes', () => { email: TEST_EMAIL, locale: ACCOUNT_LOCALE, }); - const stripeHelperMock = sandbox.createStubInstance(StripeHelper); + const stripeHelperMock: any = {}; + for ( + let p = StripeHelper.prototype; + p && p !== Object.prototype; + p = Object.getPrototypeOf(p) + ) { + Object.getOwnPropertyNames(p).forEach((m) => { + if (m !== 'constructor' && !stripeHelperMock[m]) + stripeHelperMock[m] = jest.fn(); + }); + } stripeHelperMock.currencyHelper = currencyHelper; directStripeRoutesInstance = new DirectStripeRoutes( @@ -1514,7 +1625,7 @@ describe('DirectStripeRoutes', () => { ); paymentMethod.card.country = 'FR'; - directStripeRoutesInstance.stripeHelper.getPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.getPaymentMethod.mockResolvedValue( paymentMethod ); VALID_REQUEST.payload = { @@ -1523,32 +1634,39 @@ describe('DirectStripeRoutes', () => { idempotencyKey: uuidv4(), }; - const localDeleteStub = sandbox - .stub(accountUtils, 'deleteAccountIfUnverified') - .throws(error.verifiedSecondaryEmailAlreadyExists()); + const thrownError = error.verifiedSecondaryEmailAlreadyExists(); + const localDeleteStub = jest + .spyOn(accountUtils, 'deleteAccountIfUnverified') + .mockImplementation(() => { + throw thrownError; + }); try { await directStripeRoutesInstance.createSubscriptionWithPMI( VALID_REQUEST ); - throw new Error('Create subscription with wrong planCurrency should fail.'); + throw new Error( + 'Create subscription with wrong planCurrency should fail.' + ); } catch (err: any) { - expect(localDeleteStub.calledOnce).toBe(false); + expect(localDeleteStub.mock.calls.length === 1).toBe(false); } }); it('creates a subscription without an payment id in the request', async () => { const sourceCountry = 'us'; - directStripeRoutesInstance.stripeHelper.extractSourceCountryFromSubscription.returns( + directStripeRoutesInstance.stripeHelper.extractSourceCountryFromSubscription.mockReturnValue( sourceCountry ); const customer = deepCopy(emptyCustomer); - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer); - directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( + customer + ); + directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.mockReturnValue( true ); const expected = deepCopy(subscription2); - directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.resolves( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.mockResolvedValue( expected ); const idempotencyKey = uuidv4(); @@ -1567,18 +1685,16 @@ describe('DirectStripeRoutes', () => { sourceCountry, subscription: filterSubscription(expected), }); - sinon.assert.calledWith( - directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI, - { - customerId: customer.id, - priceId: 'quux', - promotionCode: undefined, - paymentMethodId: undefined, - automaticTax: true, - } - ); - sinon.assert.calledWith( - directStripeRoutesInstance.customerChanged, + expect( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI + ).toHaveBeenCalledWith({ + customerId: customer.id, + priceId: 'quux', + promotionCode: undefined, + paymentMethodId: undefined, + automaticTax: true, + }); + expect(directStripeRoutesInstance.customerChanged).toHaveBeenCalledWith( VALID_REQUEST, UID, TEST_EMAIL @@ -1587,7 +1703,7 @@ describe('DirectStripeRoutes', () => { it('deletes incomplete subscription when creating new subscription', async () => { const invalidSubscriptionId = 'example'; - directStripeRoutesInstance.stripeHelper.findCustomerSubscriptionByPlanId.returns( + directStripeRoutesInstance.stripeHelper.findCustomerSubscriptionByPlanId.mockReturnValue( { id: invalidSubscriptionId, status: 'incomplete', @@ -1595,15 +1711,17 @@ describe('DirectStripeRoutes', () => { ); const sourceCountry = 'us'; - directStripeRoutesInstance.stripeHelper.extractSourceCountryFromSubscription.returns( + directStripeRoutesInstance.stripeHelper.extractSourceCountryFromSubscription.mockReturnValue( sourceCountry ); const customer = deepCopy(emptyCustomer); - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer); - directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.returns( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( + customer + ); + directStripeRoutesInstance.stripeHelper.isCustomerTaxableWithSubscriptionCurrency.mockReturnValue( true ); - directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.resolves( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.mockResolvedValue( deepCopy(subscription2) ); @@ -1614,38 +1732,42 @@ describe('DirectStripeRoutes', () => { await directStripeRoutesInstance.createSubscriptionWithPMI(VALID_REQUEST); - sinon.assert.calledWith( - directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI, - { - customerId: customer.id, - priceId: 'quux', - promotionCode: undefined, - paymentMethodId: undefined, - automaticTax: true, - } - ); - sinon.assert.calledWith( - directStripeRoutesInstance.stripeHelper.cancelSubscription, - invalidSubscriptionId - ); + expect( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI + ).toHaveBeenCalledWith({ + customerId: customer.id, + priceId: 'quux', + promotionCode: undefined, + paymentMethodId: undefined, + automaticTax: true, + }); + expect( + directStripeRoutesInstance.stripeHelper.cancelSubscription + ).toHaveBeenCalledWith(invalidSubscriptionId); }); it('does not report to Sentry if the customer has a payment method on file', async () => { - const sentryScope = { setContext: sandbox.stub() }; - sandbox.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sandbox.stub(sentryModule, 'reportSentryMessage'); + const sentryScope = { setContext: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(sentryModule, 'reportSentryMessage'); delete paymentMethod.billing_details.address; const sourceCountry = 'US'; - directStripeRoutesInstance.stripeHelper.extractSourceCountryFromSubscription.returns( + directStripeRoutesInstance.stripeHelper.extractSourceCountryFromSubscription.mockReturnValue( sourceCountry ); const expected = deepCopy(subscription2); - directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.resolves( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.mockResolvedValue( subscription2 ); - directStripeRoutesInstance.stripeHelper.customerTaxId.returns(false); - directStripeRoutesInstance.stripeHelper.addTaxIdToCustomer.resolves({}); + directStripeRoutesInstance.stripeHelper.customerTaxId.mockReturnValue( + false + ); + directStripeRoutesInstance.stripeHelper.addTaxIdToCustomer.mockResolvedValue( + {} + ); VALID_REQUEST.payload = { priceId: 'Jane Doe', idempotencyKey: uuidv4(), @@ -1656,62 +1778,67 @@ describe('DirectStripeRoutes', () => { VALID_REQUEST ); - sinon.assert.notCalled( + expect( directStripeRoutesInstance.stripeHelper.getPaymentMethod - ); - sinon.assert.calledWith( - directStripeRoutesInstance.customerChanged, + ).not.toHaveBeenCalled(); + expect(directStripeRoutesInstance.customerChanged).toHaveBeenCalledWith( VALID_REQUEST, UID, TEST_EMAIL ); - sinon.assert.notCalled( + expect( directStripeRoutesInstance.stripeHelper.taxRateByCountryCode - ); - sinon.assert.notCalled( + ).not.toHaveBeenCalled(); + expect( directStripeRoutesInstance.stripeHelper.customerTaxId - ); - sinon.assert.notCalled( + ).not.toHaveBeenCalled(); + expect( directStripeRoutesInstance.stripeHelper.addTaxIdToCustomer - ); + ).not.toHaveBeenCalled(); expect(actual).toEqual({ sourceCountry, subscription: filterSubscription(expected), }); - sinon.assert.notCalled( + expect( directStripeRoutesInstance.stripeHelper.setCustomerLocation - ); - sinon.assert.notCalled(sentryScope.setContext); - sinon.assert.notCalled(sentryModule.reportSentryMessage); + ).not.toHaveBeenCalled(); + expect(sentryScope.setContext).not.toHaveBeenCalled(); + expect(sentryModule.reportSentryMessage).not.toHaveBeenCalled(); }); it('skips location lookup when source country is not needed', async () => { const sourceCountry = 'DE'; - directStripeRoutesInstance.stripeHelper.extractSourceCountryFromSubscription.returns( + directStripeRoutesInstance.stripeHelper.extractSourceCountryFromSubscription.mockReturnValue( sourceCountry ); const expected = deepCopy(subscription2); - directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.resolves( + directStripeRoutesInstance.stripeHelper.createSubscriptionWithPMI.mockResolvedValue( expected ); - directStripeRoutesInstance.stripeHelper.customerTaxId.returns(false); - directStripeRoutesInstance.stripeHelper.addTaxIdToCustomer.resolves({}); + directStripeRoutesInstance.stripeHelper.customerTaxId.mockReturnValue( + false + ); + directStripeRoutesInstance.stripeHelper.addTaxIdToCustomer.mockResolvedValue( + {} + ); VALID_REQUEST.payload = { priceId: 'Jane Doe', paymentMethodId: 'pm_asdf', idempotencyKey: uuidv4(), }; - const sentryScope = { setContext: sandbox.stub() }; - sandbox.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sandbox.stub(sentryModule, 'reportSentryMessage'); + const sentryScope = { setContext: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(sentryModule, 'reportSentryMessage'); await directStripeRoutesInstance.createSubscriptionWithPMI(VALID_REQUEST); - sinon.assert.notCalled( + expect( directStripeRoutesInstance.stripeHelper.setCustomerLocation - ); - sinon.assert.notCalled(Sentry.withScope); + ).not.toHaveBeenCalled(); + expect(Sentry.withScope).not.toHaveBeenCalled(); }); }); @@ -1722,10 +1849,12 @@ describe('DirectStripeRoutes', () => { stripeCustomerId: customer.id, }); const expected = deepCopy(openInvoice); - directStripeRoutesInstance.stripeHelper.retryInvoiceWithPaymentId.resolves( + directStripeRoutesInstance.stripeHelper.retryInvoiceWithPaymentId.mockResolvedValue( expected ); - sinon.stub(directStripeRoutesInstance, 'customerChanged').resolves(); + jest + .spyOn(directStripeRoutesInstance, 'customerChanged') + .mockResolvedValue(); VALID_REQUEST.payload = { invoiceId: 'in_testinvoice', paymentMethodId: 'pm_asdf', @@ -1735,8 +1864,7 @@ describe('DirectStripeRoutes', () => { const actual = await directStripeRoutesInstance.retryInvoice(VALID_REQUEST); - sinon.assert.calledWith( - directStripeRoutesInstance.customerChanged, + expect(directStripeRoutesInstance.customerChanged).toHaveBeenCalledWith( VALID_REQUEST, UID, TEST_EMAIL @@ -1768,7 +1896,7 @@ describe('DirectStripeRoutes', () => { stripeCustomerId: customer.id, }); const expected = deepCopy(newSetupIntent); - directStripeRoutesInstance.stripeHelper.createSetupIntent.resolves( + directStripeRoutesInstance.stripeHelper.createSetupIntent.mockResolvedValue( expected ); VALID_REQUEST.payload = {}; @@ -1796,7 +1924,7 @@ describe('DirectStripeRoutes', () => { let paymentMethod: any; beforeEach(() => { paymentMethod = deepCopy(paymentMethodFixture); - directStripeRoutesInstance.stripeHelper.getPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.getPaymentMethod.mockResolvedValue( paymentMethod ); }); @@ -1809,19 +1937,19 @@ describe('DirectStripeRoutes', () => { const expected = deepCopy(emptyCustomer); expected.invoice_settings.default_payment_method = paymentMethodId; - directStripeRoutesInstance.stripeHelper.fetchCustomer - .onCall(0) - .resolves(customer); - directStripeRoutesInstance.stripeHelper.fetchCustomer - .onCall(1) - .resolves(expected); - directStripeRoutesInstance.stripeHelper.updateDefaultPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValueOnce( + customer + ); + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValueOnce( + expected + ); + directStripeRoutesInstance.stripeHelper.updateDefaultPaymentMethod.mockResolvedValue( { ...customer, invoice_settings: { default_payment_method: paymentMethodId }, } ); - directStripeRoutesInstance.stripeHelper.removeSources.resolves([ + directStripeRoutesInstance.stripeHelper.removeSources.mockResolvedValue([ {}, {}, {}, @@ -1836,29 +1964,35 @@ describe('DirectStripeRoutes', () => { VALID_REQUEST ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.getPaymentMethod, - VALID_REQUEST.payload.paymentMethodId - ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.setCustomerLocation, - { - customerId: customer.id, - postalCode: paymentMethodFixture.billing_details.address.postal_code, - country: paymentMethodFixture.card.country, - } - ); + expect( + directStripeRoutesInstance.stripeHelper.getPaymentMethod + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.getPaymentMethod + ).toHaveBeenCalledWith(VALID_REQUEST.payload.paymentMethodId); + expect( + directStripeRoutesInstance.stripeHelper.setCustomerLocation + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.setCustomerLocation + ).toHaveBeenCalledWith({ + customerId: customer.id, + postalCode: paymentMethodFixture.billing_details.address.postal_code, + country: paymentMethodFixture.card.country, + }); expect(actual).toEqual(filterCustomer(expected)); - sinon.assert.calledOnce( + expect( directStripeRoutesInstance.stripeHelper.removeSources - ); + ).toHaveBeenCalledTimes(1); }); it('errors when a customer currency does not match new paymentMethod country', async () => { // Payment method country already set to US in beforeEach; const customer = deepCopy(emptyCustomer); customer.currency = 'EUR'; - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer); + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( + customer + ); try { await directStripeRoutesInstance.updateDefaultPaymentMethod( @@ -1890,9 +2024,11 @@ describe('DirectStripeRoutes', () => { }); it('reports to Sentry if when the customer location cannot be set', async () => { - const sentryScope = { setContext: sandbox.stub() }; - sandbox.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sandbox.stub(sentryModule, 'reportSentryMessage'); + const sentryScope = { setContext: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(sentryModule, 'reportSentryMessage'); delete paymentMethod.billing_details.address; const customer = deepCopy(emptyCustomer); @@ -1902,19 +2038,19 @@ describe('DirectStripeRoutes', () => { const expected = deepCopy(emptyCustomer); expected.invoice_settings.default_payment_method = paymentMethodId; - directStripeRoutesInstance.stripeHelper.fetchCustomer - .onCall(0) - .resolves(customer); - directStripeRoutesInstance.stripeHelper.fetchCustomer - .onCall(1) - .resolves(expected); - directStripeRoutesInstance.stripeHelper.updateDefaultPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValueOnce( + customer + ); + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValueOnce( + expected + ); + directStripeRoutesInstance.stripeHelper.updateDefaultPaymentMethod.mockResolvedValue( { ...customer, invoice_settings: { default_payment_method: paymentMethodId }, } ); - directStripeRoutesInstance.stripeHelper.removeSources.resolves([ + directStripeRoutesInstance.stripeHelper.removeSources.mockResolvedValue([ {}, {}, {}, @@ -1929,30 +2065,32 @@ describe('DirectStripeRoutes', () => { VALID_REQUEST ); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.getPaymentMethod, - VALID_REQUEST.payload.paymentMethodId - ); + expect( + directStripeRoutesInstance.stripeHelper.getPaymentMethod + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.getPaymentMethod + ).toHaveBeenCalledWith(VALID_REQUEST.payload.paymentMethodId); expect(actual).toEqual(filterCustomer(expected)); - sinon.assert.calledOnce( + expect( directStripeRoutesInstance.stripeHelper.removeSources - ); + ).toHaveBeenCalledTimes(1); // Everything else worked but there was a Sentry error for not settinng // the location of the customer - sinon.assert.notCalled( + expect( directStripeRoutesInstance.stripeHelper.setCustomerLocation - ); - sinon.assert.calledOnceWithExactly( - sentryScope.setContext, + ).not.toHaveBeenCalled(); + expect(sentryScope.setContext).toHaveBeenCalledTimes(1); + expect(sentryScope.setContext).toHaveBeenCalledWith( 'updateDefaultPaymentMethod', { customerId: customer.id, paymentMethodId: paymentMethod.id, } ); - sinon.assert.calledOnceWithExactly( - sentryModule.reportSentryMessage, + expect(sentryModule.reportSentryMessage).toHaveBeenCalledTimes(1); + expect(sentryModule.reportSentryMessage).toHaveBeenCalledWith( `Cannot find a postal code or country for customer.`, 'error' ); @@ -1963,26 +2101,28 @@ describe('DirectStripeRoutes', () => { customer.currency = 'USD'; paymentMethod.card.country = 'GB'; const paymentMethodId = 'card_1G9Vy3Kb9q6OnNsLYw9Zw0Du'; - const sentryScope = { setContext: sandbox.stub() }; - sandbox.stub(Sentry, 'withScope').callsFake((cb: any) => cb(sentryScope)); - sandbox.stub(sentryModule, 'reportSentryMessage'); + const sentryScope = { setContext: jest.fn() }; + jest + .spyOn(Sentry, 'withScope') + .mockImplementation((cb: any) => cb(sentryScope)); + jest.spyOn(sentryModule, 'reportSentryMessage'); const expected = deepCopy(emptyCustomer); expected.invoice_settings.default_payment_method = paymentMethodId; - directStripeRoutesInstance.stripeHelper.fetchCustomer - .onCall(0) - .resolves(customer); - directStripeRoutesInstance.stripeHelper.fetchCustomer - .onCall(1) - .resolves(expected); - directStripeRoutesInstance.stripeHelper.updateDefaultPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValueOnce( + customer + ); + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValueOnce( + expected + ); + directStripeRoutesInstance.stripeHelper.updateDefaultPaymentMethod.mockResolvedValue( { ...customer, invoice_settings: { default_payment_method: paymentMethodId }, } ); - directStripeRoutesInstance.stripeHelper.removeSources.resolves([ + directStripeRoutesInstance.stripeHelper.removeSources.mockResolvedValue([ {}, {}, {}, @@ -1996,10 +2136,10 @@ describe('DirectStripeRoutes', () => { VALID_REQUEST ); - sinon.assert.notCalled( + expect( directStripeRoutesInstance.stripeHelper.setCustomerLocation - ); - sinon.assert.notCalled(Sentry.withScope); + ).not.toHaveBeenCalled(); + expect(Sentry.withScope).not.toHaveBeenCalled(); }); }); @@ -2010,8 +2150,10 @@ describe('DirectStripeRoutes', () => { const paymentMethodId = 'pm_9001'; const expected = { id: paymentMethodId, isGood: 'yep' }; - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer); - directStripeRoutesInstance.stripeHelper.detachPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( + customer + ); + directStripeRoutesInstance.stripeHelper.detachPaymentMethod.mockResolvedValue( expected ); @@ -2025,10 +2167,12 @@ describe('DirectStripeRoutes', () => { ); expect(actual).toEqual(expected); - sinon.assert.calledOnceWithExactly( - directStripeRoutesInstance.stripeHelper.detachPaymentMethod, - paymentMethodId - ); + expect( + directStripeRoutesInstance.stripeHelper.detachPaymentMethod + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.detachPaymentMethod + ).toHaveBeenCalledWith(paymentMethodId); }); it('does not detach if the subscription is not "incomplete"', async () => { @@ -2036,8 +2180,10 @@ describe('DirectStripeRoutes', () => { const paymentMethodId = 'pm_9001'; const resp = { id: paymentMethodId, isGood: 'yep' }; - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer); - directStripeRoutesInstance.stripeHelper.detachPaymentMethod.resolves( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( + customer + ); + directStripeRoutesInstance.stripeHelper.detachPaymentMethod.mockResolvedValue( resp ); @@ -2050,9 +2196,9 @@ describe('DirectStripeRoutes', () => { ); expect(actual).toEqual({ id: paymentMethodId }); - sinon.assert.notCalled( + expect( directStripeRoutesInstance.stripeHelper.detachPaymentMethod - ); + ).not.toHaveBeenCalled(); }); it('errors when a customer has not been created', async () => { @@ -2089,7 +2235,7 @@ describe('DirectStripeRoutes', () => { it('returns the subscription id', async () => { const expected = { subscriptionId: subscription2.id }; - directStripeRoutesInstance.stripeHelper.cancelSubscriptionForCustomer.resolves(); + directStripeRoutesInstance.stripeHelper.cancelSubscriptionForCustomer.mockResolvedValue(); const actual = await directStripeRoutesInstance.deleteSubscription(deleteSubRequest); @@ -2113,7 +2259,7 @@ describe('DirectStripeRoutes', () => { }; it('returns an empty object', async () => { - directStripeRoutesInstance.stripeHelper.reactivateSubscriptionForCustomer.resolves(); + directStripeRoutesInstance.stripeHelper.reactivateSubscriptionForCustomer.mockResolvedValue(); const actual = await directStripeRoutesInstance.reactivateSubscription( reactivateRequest @@ -2127,18 +2273,22 @@ describe('DirectStripeRoutes', () => { let plan: any; beforeEach(() => { - directStripeRoutesInstance.stripeHelper.subscriptionForCustomer.resolves( + directStripeRoutesInstance.stripeHelper.subscriptionForCustomer.mockResolvedValue( subscription2 ); VALID_REQUEST.params = { subscriptionId: subscription2.subscriptionId }; const customer = deepCopy(customerFixture); customer.currency = 'USD'; - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(customer); + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( + customer + ); plan = deepCopy(PLANS[0]); plan.currency = 'USD'; - directStripeRoutesInstance.stripeHelper.findAbbrevPlanById.resolves(plan); + directStripeRoutesInstance.stripeHelper.findAbbrevPlanById.mockResolvedValue( + plan + ); VALID_REQUEST.payload = { planId: plan.planId }; }); @@ -2147,15 +2297,17 @@ describe('DirectStripeRoutes', () => { const expected = { subscriptionId: subscriptionId }; VALID_REQUEST.params = { subscriptionId: subscriptionId }; - mockCapabilityService.getPlanEligibility = sinon.stub(); - mockCapabilityService.getPlanEligibility.resolves({ + mockCapabilityService.getPlanEligibility = jest.fn(); + mockCapabilityService.getPlanEligibility.mockResolvedValue({ subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE, eligibleSourcePlan: subscription2, }); - directStripeRoutesInstance.stripeHelper.changeSubscriptionPlan.resolves(); + directStripeRoutesInstance.stripeHelper.changeSubscriptionPlan.mockResolvedValue(); - sinon.stub(directStripeRoutesInstance, 'customerChanged').resolves(); + jest + .spyOn(directStripeRoutesInstance, 'customerChanged') + .mockResolvedValue(); const actual = await directStripeRoutesInstance.updateSubscription(VALID_REQUEST); @@ -2167,8 +2319,8 @@ describe('DirectStripeRoutes', () => { const subscriptionId = 'sub_123'; VALID_REQUEST.params = { subscriptionId: subscriptionId }; - mockCapabilityService.getPlanEligibility = sinon.stub(); - mockCapabilityService.getPlanEligibility.resolves({ + mockCapabilityService.getPlanEligibility = jest.fn(); + mockCapabilityService.getPlanEligibility.mockResolvedValue({ subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE, eligibleSourcePlan: subscription2, redundantOverlaps: [ @@ -2181,38 +2333,44 @@ describe('DirectStripeRoutes', () => { ], }); - directStripeRoutesInstance.stripeHelper.changeSubscriptionPlan.resolves(); - directStripeRoutesInstance.stripeHelper.updateSubscriptionAndBackfill.resolves(); + directStripeRoutesInstance.stripeHelper.changeSubscriptionPlan.mockResolvedValue(); + directStripeRoutesInstance.stripeHelper.updateSubscriptionAndBackfill.mockResolvedValue(); - sinon.stub(directStripeRoutesInstance, 'customerChanged').resolves(); + jest + .spyOn(directStripeRoutesInstance, 'customerChanged') + .mockResolvedValue(); await directStripeRoutesInstance.updateSubscription(VALID_REQUEST); expect( - directStripeRoutesInstance.stripeHelper.updateSubscriptionAndBackfill.calledOnceWith( - customerFixture.subscriptions.data[0], - { - metadata: { - redundantCancellation: 'true', - autoCancelledRedundantFor: subscription2.id, - cancelled_for_customer_at: Math.floor(Date.now() / 1000), - }, - } - ) - ).toBe(true); + directStripeRoutesInstance.stripeHelper.updateSubscriptionAndBackfill + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.updateSubscriptionAndBackfill + ).toHaveBeenCalledWith(customerFixture.subscriptions.data[0], { + metadata: { + redundantCancellation: 'true', + autoCancelledRedundantFor: subscription2.id, + cancelled_for_customer_at: Math.floor(Date.now() / 1000), + }, + }); expect( - directStripeRoutesInstance.stripeHelper.stripe.subscriptions.cancel.calledOnceWith( - customerFixture.subscriptions.data[0].id - ) - ).toBe(true); + directStripeRoutesInstance.stripeHelper.stripe.subscriptions.cancel + ).toHaveBeenCalledTimes(1); + expect( + directStripeRoutesInstance.stripeHelper.stripe.subscriptions.cancel.mock + .calls[0][0] + ).toBe(customerFixture.subscriptions.data[0].id); }); it('throws an error when the new plan is not an upgrade', async () => { - directStripeRoutesInstance.stripeHelper.findAbbrevPlanById.resolves(plan); + directStripeRoutesInstance.stripeHelper.findAbbrevPlanById.mockResolvedValue( + plan + ); - mockCapabilityService.getPlanEligibility = sinon.stub(); - mockCapabilityService.getPlanEligibility.resolves([ + mockCapabilityService.getPlanEligibility = jest.fn(); + mockCapabilityService.getPlanEligibility.mockResolvedValue([ SubscriptionEligibilityResult.INVALID, ]); @@ -2228,11 +2386,14 @@ describe('DirectStripeRoutes', () => { it("throws an error when the new plan currency doesn't match the customer's currency.", async () => { plan.currency = 'EUR'; - directStripeRoutesInstance.stripeHelper.findAbbrevPlanById.resolves(plan); + directStripeRoutesInstance.stripeHelper.findAbbrevPlanById.mockResolvedValue( + plan + ); - mockCapabilityService.getPlanEligibility = sinon.stub(); - mockCapabilityService.getPlanEligibility.resolves({ + mockCapabilityService.getPlanEligibility = jest.fn(); + mockCapabilityService.getPlanEligibility.mockResolvedValue({ subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE, + eligibleSourcePlan: subscription2, }); try { @@ -2248,7 +2409,7 @@ describe('DirectStripeRoutes', () => { }); it('throws an exception when the orginal subscription is not found', async () => { - directStripeRoutesInstance.stripeHelper.subscriptionForCustomer.resolves(); + directStripeRoutesInstance.stripeHelper.subscriptionForCustomer.mockResolvedValue(); try { await directStripeRoutesInstance.updateSubscription(VALID_REQUEST); throw new Error('Method expected to reject'); @@ -2262,7 +2423,9 @@ describe('DirectStripeRoutes', () => { describe('getProductName', () => { it('should respond with product name for valid id', async () => { - directStripeRoutesInstance.stripeHelper.allAbbrevPlans.resolves(PLANS); + directStripeRoutesInstance.stripeHelper.allAbbrevPlans.mockResolvedValue( + PLANS + ); const productId = PLANS[1].product_id; const expected = { product_name: PLANS[1].product_name }; const result = await directStripeRoutesInstance.getProductName({ @@ -2273,7 +2436,9 @@ describe('DirectStripeRoutes', () => { }); it('should respond with an error for invalid id', async () => { - directStripeRoutesInstance.stripeHelper.allAbbrevPlans.resolves(PLANS); + directStripeRoutesInstance.stripeHelper.allAbbrevPlans.mockResolvedValue( + PLANS + ); const productId = 'this-is-not-valid'; try { await directStripeRoutesInstance.getProductName({ @@ -2293,7 +2458,9 @@ describe('DirectStripeRoutes', () => { const expected = sanitizePlans(PLANS); const request = {}; - directStripeRoutesInstance.stripeHelper.allAbbrevPlans.resolves(PLANS); + directStripeRoutesInstance.stripeHelper.allAbbrevPlans.mockResolvedValue( + PLANS + ); const actual = await directStripeRoutesInstance.listPlans(request); expect(actual).toEqual(expected); @@ -2304,7 +2471,7 @@ describe('DirectStripeRoutes', () => { describe('customer is found', () => { describe('customer has no subscriptions', () => { it('returns an empty array', async () => { - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( emptyCustomer ); const expected: any[] = []; @@ -2327,7 +2494,7 @@ describe('DirectStripeRoutes', () => { setToCancelSubscription, ]; - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves( + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue( customer ); @@ -2366,7 +2533,7 @@ describe('DirectStripeRoutes', () => { describe('customer is not found', () => { it('returns an empty array', async () => { - directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves(); + directStripeRoutesInstance.stripeHelper.fetchCustomer.mockResolvedValue(); const expected: any[] = []; const actual = await directStripeRoutesInstance.listActive(VALID_REQUEST); diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/support.spec.ts b/packages/fxa-auth-server/lib/routes/subscriptions/support.spec.ts index b7af74c55fe..24258d8575a 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/support.spec.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/support.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import nock from 'nock'; const uuid = require('uuid'); @@ -219,22 +218,22 @@ describe('support', () => { it('should accept a first ticket for a subscriber', async () => { config.subscriptions.enabled = true; setupNockForSuccess(); - const spy = sinon.spy(zendeskClient.requests, 'create'); + const spy = jest.spyOn(zendeskClient.requests, 'create'); const res = await runTest('/support/ticket', requestOptions); - const zendeskReq = spy.firstCall.args[0].request; + const zendeskReq = spy.mock.calls[0][0].request; expect(zendeskReq.subject).toBe( `${requestOptions.payload.productName}: ${requestOptions.payload.subject}` ); expect(zendeskReq.comment.body).toBe(requestOptions.payload.message); - expect(zendeskReq.custom_fields.map((field: any) => field.value)).toEqual( - customFieldsOnTicket - ); + expect( + zendeskReq.custom_fields.map((field: any) => field.value) + ).toEqual(customFieldsOnTicket); expect(res).toEqual({ success: true, ticket: 91 }); - sinon.assert.callCount(customs.check, 1); + expect(customs.check).toHaveBeenCalledTimes(1); nock.isDone(); - spy.restore(); + spy.mockRestore(); }); it('should accept a second ticket for a subscriber', async () => { @@ -263,19 +262,19 @@ describe('support', () => { nock(`https://${SUBDOMAIN}.zendesk.com`) .put(`/api/v2/users/${REQUESTER_ID}.json`) .reply(200, MOCK_UPDATE_REPLY); - const spy = sinon.spy(zendeskClient.requests, 'create'); + const spy = jest.spyOn(zendeskClient.requests, 'create'); const res = await runTest('/support/ticket', requestOptions); - const zendeskReq = spy.firstCall.args[0].request; + const zendeskReq = spy.mock.calls[0][0].request; expect(zendeskReq.subject).toBe( `${requestOptions.payload.productName}: ${requestOptions.payload.subject}` ); expect(zendeskReq.comment.body).toBe(requestOptions.payload.message); - expect(zendeskReq.custom_fields.map((field: any) => field.value)).toEqual( - customFieldsOnTicket - ); + expect( + zendeskReq.custom_fields.map((field: any) => field.value) + ).toEqual(customFieldsOnTicket); expect(res).toEqual({ success: true, ticket: 91 }); nock.isDone(); - spy.restore(); + spy.mockRestore(); }); it('should reject tickets for a non-subscriber', async () => { @@ -297,49 +296,51 @@ describe('support', () => { it('should accept a ticket from another service using a shared secret', async () => { config.subscriptions.enabled = true; setupNockForSuccess(); - const spy = sinon.spy(zendeskClient.requests, 'create'); + const spy = jest.spyOn(zendeskClient.requests, 'create'); const res = await runTest('/support/ticket', { ...requestOptions, auth: { strategy: 'supportSecret' }, payload: { ...requestOptions.payload, email: TEST_EMAIL }, }); - const zendeskReq = spy.firstCall.args[0].request; + const zendeskReq = spy.mock.calls[0][0].request; expect(zendeskReq.subject).toBe( `${requestOptions.payload.productName}: ${requestOptions.payload.subject}` ); expect(zendeskReq.comment.body).toBe(requestOptions.payload.message); - expect(zendeskReq.custom_fields.map((field: any) => field.value)).toEqual( - customFieldsOnTicket - ); - sinon.assert.callCount(customs.check, 1); + expect( + zendeskReq.custom_fields.map((field: any) => field.value) + ).toEqual(customFieldsOnTicket); + expect(customs.check).toHaveBeenCalledTimes(1); expect(res).toEqual({ success: true, ticket: 91 }); nock.isDone(); - spy.restore(); + spy.mockRestore(); }); it('should work for someone who is not a FxA user', async () => { const dbAccountRecord = db.accountRecord; - db.accountRecord = sinon.stub().throws(AppError.unknownAccount()); + db.accountRecord = jest.fn(() => { + throw AppError.unknownAccount(); + }); config.subscriptions.enabled = true; setupNockForSuccess(); - const spy = sinon.spy(zendeskClient.requests, 'create'); + const spy = jest.spyOn(zendeskClient.requests, 'create'); const res = await runTest('/support/ticket', { ...requestOptions, auth: { strategy: 'supportSecret' }, payload: { ...requestOptions.payload, email: TEST_EMAIL }, }); - const zendeskReq = spy.firstCall.args[0].request; + const zendeskReq = spy.mock.calls[0][0].request; expect(zendeskReq.subject).toBe( `${requestOptions.payload.productName}: ${requestOptions.payload.subject}` ); expect(zendeskReq.comment.body).toBe(requestOptions.payload.message); - expect(zendeskReq.custom_fields.map((field: any) => field.value)).toEqual( - customFieldsOnTicket - ); + expect( + zendeskReq.custom_fields.map((field: any) => field.value) + ).toEqual(customFieldsOnTicket); expect(res).toEqual({ success: true, ticket: 91 }); nock.isDone(); - spy.restore(); + spy.mockRestore(); db.accountRecord = dbAccountRecord; }); @@ -362,16 +363,16 @@ describe('support', () => { nock(`https://${SUBDOMAIN}.zendesk.com`) .get(`/api/v2/users/${REQUESTER_ID}.json`) .reply(200, MOCK_EXISTING_SHOW_REPLY); - const spy = sinon.spy(zendeskClient.requests, 'create'); + const spy = jest.spyOn(zendeskClient.requests, 'create'); const res = await runTest('/support/ticket', { ...requestOptions, payload: { ...requestOptions.payload, brand_id: 12345 }, }); - const zendeskReq = spy.firstCall.args[0].request; + const zendeskReq = spy.mock.calls[0][0].request; expect(zendeskReq.brand_id).toBe(12345); expect(res).toEqual({ success: true, ticket: 91 }); nock.isDone(); - spy.restore(); + spy.mockRestore(); }); it('should reject a ticket with a non-integer brand_id', async () => { diff --git a/packages/fxa-auth-server/lib/routes/totp.spec.ts b/packages/fxa-auth-server/lib/routes/totp.spec.ts index fc0357da1cd..82cc9a4fee6 100644 --- a/packages/fxa-auth-server/lib/routes/totp.spec.ts +++ b/packages/fxa-auth-server/lib/routes/totp.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; import { AppError as authErrors } from '@fxa/accounts/errors'; import { RecoveryPhoneService } from '@fxa/accounts/recovery-phone'; @@ -30,11 +29,11 @@ let log: any, const glean = mocks.mockGlean(); const mockRecoveryPhoneService = { - hasConfirmed: sinon.fake(), - removePhoneNumber: sinon.fake.resolves(true), + hasConfirmed: jest.fn(), + removePhoneNumber: jest.fn().mockResolvedValue(true), }; const mockBackupCodeManager = { - deleteRecoveryCodes: sinon.fake.resolves(true), + deleteRecoveryCodes: jest.fn().mockResolvedValue(true), }; const TEST_EMAIL = 'test@email.com'; @@ -49,8 +48,8 @@ function setup(results: any, errors: any, routePath: string, reqOpts: any) { mailer = mocks.mockMailer(); db = mocks.mockDB(results.db, errors.db); authServerCacheRedis = { - set: sinon.stub(), - get: sinon.stub((key: any) => { + set: jest.fn(), + get: jest.fn((key: any) => { if (results.redis) { if (key && key.includes(':secret:')) { return Promise.resolve(results.redis.secret || null); @@ -61,11 +60,11 @@ function setup(results: any, errors: any, routePath: string, reqOpts: any) { } return Promise.resolve(results.redis ? results.redis.secret : null); }), - del: sinon.stub(), + del: jest.fn(), }; profile = mocks.mockProfile(); - db.consumeRecoveryCode = sinon.spy(() => { + db.consumeRecoveryCode = jest.fn(() => { if (errors.consumeRecoveryCode) { return Promise.reject(authErrors.recoveryCodeNotFound()); } @@ -73,16 +72,16 @@ function setup(results: any, errors: any, routePath: string, reqOpts: any) { remaining: reqOpts.remaining || 2, }); }); - db.createTotpToken = sinon.spy(() => { + db.createTotpToken = jest.fn(() => { return Promise.resolve({ qrCodeUrl: 'some base64 encoded png', sharedSecret: secret, }); }); - db.verifyTokensWithMethod = sinon.spy(() => { + db.verifyTokensWithMethod = jest.fn(() => { return Promise.resolve(); }); - db.totpToken = sinon.spy(() => { + db.totpToken = jest.fn(() => { return Promise.resolve({ verified: results?.totpTokenVerified || false, enabled: results?.totpTokenEnabled || false, @@ -92,7 +91,7 @@ function setup(results: any, errors: any, routePath: string, reqOpts: any) { : undefined, }); }); - db.replaceTotpToken = sinon.spy(() => { + db.replaceTotpToken = jest.fn(() => { if (errors.replaceTotpToken) { return Promise.reject('Error replacing TOTP token'); } @@ -111,7 +110,7 @@ function setup(results: any, errors: any, routePath: string, reqOpts: any) { }); route = getRoute(routes, routePath); request = mocks.mockRequest(reqOpts); - request.emitMetricsEvent = sinon.spy(() => Promise.resolve({})); + request.emitMetricsEvent = jest.fn(() => Promise.resolve({})); return route.handler(request); } @@ -169,7 +168,7 @@ describe('totp', () => { }, }; accountEventsManager = { - recordSecurityEvent: sinon.fake.resolves({}), + recordSecurityEvent: jest.fn().mockResolvedValue({}), }; mocks.mockOAuthClientInfo(); @@ -178,7 +177,7 @@ describe('totp', () => { Container.set(RecoveryPhoneService, mockRecoveryPhoneService); Container.set(BackupCodeManager, mockBackupCodeManager); - glean.twoStepAuthRemove.success.reset(); + glean.twoStepAuthRemove.success.mockClear(); }); afterAll(() => { @@ -195,13 +194,15 @@ describe('totp', () => { ).then((response: any) => { expect(response.qrCodeUrl).toBeTruthy(); expect(response.secret).toBeTruthy(); - expect(authServerCacheRedis.set.callCount).toBe(1); + expect(authServerCacheRedis.set).toHaveBeenCalledTimes(1); // emits correct metrics - expect(request.emitMetricsEvent.callCount).toBe(1); - const args = request.emitMetricsEvent.args[0]; - expect(args[0]).toBe('totpToken.created'); - expect(args[1]['uid']).toBe('uid'); + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(1); + expect(request.emitMetricsEvent).toHaveBeenNthCalledWith( + 1, + 'totpToken.created', + expect.objectContaining({ uid: 'uid' }) + ); }); }); }); @@ -215,7 +216,7 @@ describe('totp', () => { requestOptions ).then((response: any) => { expect(response).toBeTruthy(); - expect(db.totpToken.callCount).toBe(1); + expect(db.totpToken).toHaveBeenCalledTimes(1); }); }); }); @@ -223,8 +224,8 @@ describe('totp', () => { // Note: this endpoint only verifies sessions; setup flow is covered by /totp/setup/* tests. describe('/session/verify/totp', () => { afterEach(() => { - glean.login.totpSuccess.reset(); - glean.login.totpFailure.reset(); + glean.login.totpSuccess.mockClear(); + glean.login.totpFailure.mockClear(); }); it('should verify session with TOTP token - sync', () => { @@ -248,36 +249,42 @@ describe('totp', () => { requestOptions ).then((response: any) => { expect(response.success).toBe(true); - expect(db.totpToken.callCount).toBe(1); - expect(db.updateTotpToken.callCount).toBe(0); + expect(db.totpToken).toHaveBeenCalledTimes(1); + expect(db.updateTotpToken).toHaveBeenCalledTimes(0); - expect(log.notifyAttachedServices.callCount).toBe(0); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(0); // verifies session - expect(db.verifyTokensWithMethod.callCount).toBe(1); - const args = db.verifyTokensWithMethod.args[0]; - expect(args[0]).toBe(sessionId); - expect(args[1]).toBe('totp-2fa'); + expect(db.verifyTokensWithMethod).toHaveBeenCalledTimes(1); + expect(db.verifyTokensWithMethod).toHaveBeenNthCalledWith( + 1, + sessionId, + 'totp-2fa' + ); // emits correct metrics - sinon.assert.calledTwice(request.emitMetricsEvent); - sinon.assert.calledWith( - request.emitMetricsEvent, + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(2); + expect(request.emitMetricsEvent).toHaveBeenCalledWith( 'totpToken.verified', { uid: 'uid' } ); - sinon.assert.calledWith(request.emitMetricsEvent, 'account.confirmed', { - uid: 'uid', - }); + expect(request.emitMetricsEvent).toHaveBeenCalledWith( + 'account.confirmed', + { + uid: 'uid', + } + ); // correct emails sent - expect(fxaMailer.sendNewDeviceLoginEmail.callCount).toBe(1); - expect(fxaMailer.sendPostAddTwoStepAuthenticationEmail.callCount).toBe( - 0 - ); + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(1); + expect( + fxaMailer.sendPostAddTwoStepAuthenticationEmail + ).toHaveBeenCalledTimes(0); - sinon.assert.calledOnceWithExactly( - accountEventsManager.recordSecurityEvent, + expect(accountEventsManager.recordSecurityEvent).toHaveBeenCalledTimes( + 1 + ); + expect(accountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( db, { name: 'account.two_factor_challenge_success', @@ -297,7 +304,7 @@ describe('totp', () => { } ); - sinon.assert.calledOnce(glean.login.totpSuccess); + expect(glean.login.totpSuccess).toHaveBeenCalledTimes(1); }); }); @@ -321,33 +328,37 @@ describe('totp', () => { requestOptions ).then((response: any) => { expect(response.success).toBe(true); - expect(db.totpToken.callCount).toBe(1); - expect(db.updateTotpToken.callCount).toBe(0); + expect(db.totpToken).toHaveBeenCalledTimes(1); + expect(db.updateTotpToken).toHaveBeenCalledTimes(0); - expect(log.notifyAttachedServices.callCount).toBe(0); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(0); // verifies session - expect(db.verifyTokensWithMethod.callCount).toBe(1); - const args = db.verifyTokensWithMethod.args[0]; - expect(args[0]).toBe(sessionId); - expect(args[1]).toBe('totp-2fa'); + expect(db.verifyTokensWithMethod).toHaveBeenCalledTimes(1); + expect(db.verifyTokensWithMethod).toHaveBeenNthCalledWith( + 1, + sessionId, + 'totp-2fa' + ); // emits correct metrics - sinon.assert.calledTwice(request.emitMetricsEvent); - sinon.assert.calledWith( - request.emitMetricsEvent, + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(2); + expect(request.emitMetricsEvent).toHaveBeenCalledWith( 'totpToken.verified', { uid: 'uid' } ); - sinon.assert.calledWith(request.emitMetricsEvent, 'account.confirmed', { - uid: 'uid', - }); + expect(request.emitMetricsEvent).toHaveBeenCalledWith( + 'account.confirmed', + { + uid: 'uid', + } + ); // correct emails sent - expect(fxaMailer.sendNewDeviceLoginEmail.callCount).toBe(1); - expect(fxaMailer.sendPostAddTwoStepAuthenticationEmail.callCount).toBe( - 0 - ); + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(1); + expect( + fxaMailer.sendPostAddTwoStepAuthenticationEmail + ).toHaveBeenCalledTimes(0); }); }); @@ -366,22 +377,26 @@ describe('totp', () => { requestOptions ).then((response: any) => { expect(response.success).toBe(false); - expect(db.totpToken.callCount).toBe(1); + expect(db.totpToken).toHaveBeenCalledTimes(1); // emits correct metrics - expect(request.emitMetricsEvent.callCount).toBe(1); - const args = request.emitMetricsEvent.args[0]; - expect(args[0]).toBe('totpToken.unverified'); - expect(args[1]['uid']).toBe('uid'); + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(1); + expect(request.emitMetricsEvent).toHaveBeenNthCalledWith( + 1, + 'totpToken.unverified', + expect.objectContaining({ uid: 'uid' }) + ); // correct emails sent - expect(fxaMailer.sendNewDeviceLoginEmail.callCount).toBe(0); - expect(fxaMailer.sendPostAddTwoStepAuthenticationEmail.callCount).toBe( - 0 - ); + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); + expect( + fxaMailer.sendPostAddTwoStepAuthenticationEmail + ).toHaveBeenCalledTimes(0); - sinon.assert.calledOnceWithExactly( - accountEventsManager.recordSecurityEvent, + expect(accountEventsManager.recordSecurityEvent).toHaveBeenCalledTimes( + 1 + ); + expect(accountEventsManager.recordSecurityEvent).toHaveBeenCalledWith( db, { name: 'account.two_factor_challenge_failure', @@ -401,7 +416,7 @@ describe('totp', () => { } ); - sinon.assert.calledOnce(glean.login.totpFailure); + expect(glean.login.totpFailure).toHaveBeenCalledTimes(1); }); }); }); @@ -409,8 +424,8 @@ describe('totp', () => { // This endpoint is used for code verification during TOTP setup only describe('/totp/setup/verify', () => { beforeEach(() => { - glean.twoFactorAuth.setupVerifySuccess.reset(); - glean.twoFactorAuth.setupInvalidCodeError.reset(); + glean.twoFactorAuth.setupVerifySuccess.mockClear(); + glean.twoFactorAuth.setupInvalidCodeError.mockClear(); }); it('should verify a valid totp code', async () => { @@ -431,10 +446,10 @@ describe('totp', () => { ); expect(response.success).toBe(true); // Confirm we touched Redis to set both secret and verified digest - expect(authServerCacheRedis.set.callCount).toBe(2); - sinon.assert.calledOnce(glean.twoFactorAuth.setupVerifySuccess); - sinon.assert.calledOnceWithExactly( - customs.checkAuthenticated, + expect(authServerCacheRedis.set).toHaveBeenCalledTimes(2); + expect(glean.twoFactorAuth.setupVerifySuccess).toHaveBeenCalledTimes(1); + expect(customs.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(customs.checkAuthenticated).toHaveBeenCalledWith( request, 'uid', TEST_EMAIL, @@ -460,8 +475,10 @@ describe('totp', () => { expect(err.errno).toBe( authErrors.ERRNO.INVALID_TOKEN_VERIFICATION_CODE ); - expect(authServerCacheRedis.set.callCount).toBe(0); - sinon.assert.calledOnce(glean.twoFactorAuth.setupInvalidCodeError); + expect(authServerCacheRedis.set).toHaveBeenCalledTimes(0); + expect(glean.twoFactorAuth.setupInvalidCodeError).toHaveBeenCalledTimes( + 1 + ); } }); @@ -481,7 +498,7 @@ describe('totp', () => { describe('/totp/setup/complete', () => { beforeEach(() => { - glean.twoFactorAuth.codeComplete.reset(); + glean.twoFactorAuth.codeComplete.mockClear(); }); it('should complete the setup process', async () => { @@ -500,13 +517,15 @@ describe('totp', () => { requestOptions ); expect(response.success).toBe(true); - sinon.assert.calledOnce(db.replaceTotpToken); - sinon.assert.calledOnce(db.verifyTokensWithMethod); - expect(authServerCacheRedis.del.callCount).toBe(2); - sinon.assert.calledOnce(profile.deleteCache); - sinon.assert.calledOnce(log.notifyAttachedServices); - sinon.assert.calledOnce(glean.twoFactorAuth.codeComplete); - sinon.assert.calledOnce(fxaMailer.sendPostAddTwoStepAuthenticationEmail); + expect(db.replaceTotpToken).toHaveBeenCalledTimes(1); + expect(db.verifyTokensWithMethod).toHaveBeenCalledTimes(1); + expect(authServerCacheRedis.del).toHaveBeenCalledTimes(2); + expect(profile.deleteCache).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(glean.twoFactorAuth.codeComplete).toHaveBeenCalledTimes(1); + expect( + fxaMailer.sendPostAddTwoStepAuthenticationEmail + ).toHaveBeenCalledTimes(1); }); it('should fail for a missing secret', async () => { @@ -559,11 +578,12 @@ describe('totp', () => { requestOptions ); - sinon.assert.calledOnce(glean.resetPassword.twoFactorSuccess); + expect(glean.resetPassword.twoFactorSuccess).toHaveBeenCalledTimes(1); expect(response.success).toBe(true); - sinon.assert.calledOnceWithExactly(db.totpToken, 'uid'); - sinon.assert.calledOnceWithExactly( - customs.checkAuthenticated, + expect(db.totpToken).toHaveBeenCalledTimes(1); + expect(db.totpToken).toHaveBeenCalledWith('uid'); + expect(customs.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(customs.checkAuthenticated).toHaveBeenCalledWith( request, 'uid', TEST_EMAIL, @@ -587,9 +607,10 @@ describe('totp', () => { ); expect(response.success).toBe(false); - sinon.assert.calledOnceWithExactly(db.totpToken, 'uid'); - sinon.assert.calledOnceWithExactly( - customs.checkAuthenticated, + expect(db.totpToken).toHaveBeenCalledTimes(1); + expect(db.totpToken).toHaveBeenCalledWith('uid'); + expect(customs.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(customs.checkAuthenticated).toHaveBeenCalledWith( request, 'uid', TEST_EMAIL, @@ -612,19 +633,20 @@ describe('totp', () => { requestOptions ); - sinon.assert.calledOnce(glean.resetPassword.twoFactorRecoveryCodeSuccess); + expect( + glean.resetPassword.twoFactorRecoveryCodeSuccess + ).toHaveBeenCalledTimes(1); - sinon.assert.calledOnce(fxaMailer.sendPostConsumeRecoveryCodeEmail); - sinon.assert.notCalled(fxaMailer.sendLowRecoveryCodesEmail); + expect(fxaMailer.sendPostConsumeRecoveryCodeEmail).toHaveBeenCalledTimes( + 1 + ); + expect(fxaMailer.sendLowRecoveryCodesEmail).not.toHaveBeenCalled(); expect(response.remaining).toBe(2); - sinon.assert.calledOnceWithExactly( - db.consumeRecoveryCode, - 'uid', - '1234567890' - ); - sinon.assert.calledOnceWithExactly( - customs.checkAuthenticated, + expect(db.consumeRecoveryCode).toHaveBeenCalledTimes(1); + expect(db.consumeRecoveryCode).toHaveBeenCalledWith('uid', '1234567890'); + expect(customs.checkAuthenticated).toHaveBeenCalledTimes(1); + expect(customs.checkAuthenticated).toHaveBeenCalledWith( request, 'uid', TEST_EMAIL, @@ -661,13 +683,10 @@ describe('totp', () => { requestOptions ); - sinon.assert.calledOnce(fxaMailer.sendLowRecoveryCodesEmail); + expect(fxaMailer.sendLowRecoveryCodesEmail).toHaveBeenCalledTimes(1); expect(response.remaining).toBe(1); - sinon.assert.calledOnceWithExactly( - db.consumeRecoveryCode, - 'uid', - '1234567890' - ); + expect(db.consumeRecoveryCode).toHaveBeenCalledTimes(1); + expect(db.consumeRecoveryCode).toHaveBeenCalledWith('uid', '1234567890'); }); }); }); diff --git a/packages/fxa-auth-server/lib/routes/unblock-codes.spec.ts b/packages/fxa-auth-server/lib/routes/unblock-codes.spec.ts index 83952285fcf..f9e677c664e 100644 --- a/packages/fxa-auth-server/lib/routes/unblock-codes.spec.ts +++ b/packages/fxa-auth-server/lib/routes/unblock-codes.spec.ts @@ -66,9 +66,9 @@ describe('/account/login/send_unblock_code', () => { const route = getRoute(accountRoutes, '/account/login/send_unblock_code'); afterEach(() => { - mockDb.accountRecord.resetHistory(); - mockDb.createUnblockCode.resetHistory(); - mockFxaMailer.sendUnblockCodeEmail.resetHistory(); + mockDb.accountRecord.mockClear(); + mockDb.createUnblockCode.mockClear(); + mockFxaMailer.sendUnblockCodeEmail.mockClear(); }); it('signin unblock enabled', () => { @@ -76,23 +76,20 @@ describe('/account/login/send_unblock_code', () => { expect(response).not.toBeInstanceOf(Error); expect(response).toEqual({}); - expect(mockDb.accountRecord.callCount).toBe(1); - expect(mockDb.accountRecord.args[0][0]).toBe(email); + expect(mockDb.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDb.accountRecord).toHaveBeenNthCalledWith(1, email); - expect(mockDb.createUnblockCode.callCount).toBe(1); - const dbArgs = mockDb.createUnblockCode.args[0]; - expect(dbArgs).toHaveLength(1); - expect(dbArgs[0]).toBe(uid); + expect(mockDb.createUnblockCode).toHaveBeenCalledTimes(1); + expect(mockDb.createUnblockCode).toHaveBeenNthCalledWith(1, uid); - expect(mockFxaMailer.sendUnblockCodeEmail.callCount).toBe(1); - const args = mockFxaMailer.sendUnblockCodeEmail.args[0]; - expect(args).toHaveLength(1); + expect(mockFxaMailer.sendUnblockCodeEmail).toHaveBeenCalledTimes(1); - expect(mockLog.flowEvent.callCount).toBe(1); - expect(mockLog.flowEvent.args[0][0].event).toBe( - 'account.login.sentUnblockCode' + expect(mockLog.flowEvent).toHaveBeenCalledTimes(1); + expect(mockLog.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.login.sentUnblockCode' }) ); - mockLog.flowEvent.resetHistory(); + mockLog.flowEvent.mockClear(); }); }); @@ -103,10 +100,13 @@ describe('/account/login/send_unblock_code', () => { expect(response).not.toBeInstanceOf(Error); expect(response).toEqual({}); - expect(mockDb.accountRecord.callCount).toBe(1); - expect(mockDb.accountRecord.args[0][0]).toBe(mockRequest.payload.email); - expect(mockDb.createUnblockCode.callCount).toBe(1); - expect(mockFxaMailer.sendUnblockCodeEmail.callCount).toBe(1); + expect(mockDb.accountRecord).toHaveBeenCalledTimes(1); + expect(mockDb.accountRecord).toHaveBeenNthCalledWith( + 1, + mockRequest.payload.email + ); + expect(mockDb.createUnblockCode).toHaveBeenCalledTimes(1); + expect(mockFxaMailer.sendUnblockCodeEmail).toHaveBeenCalledTimes(1); }); }); }); @@ -131,8 +131,8 @@ describe('/account/login/reject_unblock_code', () => { expect(response).not.toBeInstanceOf(Error); expect(response).toEqual({}); - expect(mockDb.consumeUnblockCode.callCount).toBe(1); - const args = mockDb.consumeUnblockCode.args[0]; + expect(mockDb.consumeUnblockCode).toHaveBeenCalledTimes(1); + const args = mockDb.consumeUnblockCode.mock.calls[0]; expect(args).toHaveLength(2); expect(args[0].toString('hex')).toBe(uid); expect(args[1]).toBe(unblockCode); diff --git a/packages/fxa-auth-server/lib/routes/utils/account.spec.ts b/packages/fxa-auth-server/lib/routes/utils/account.spec.ts index d4df2f42f78..80ec2815ecb 100644 --- a/packages/fxa-auth-server/lib/routes/utils/account.spec.ts +++ b/packages/fxa-auth-server/lib/routes/utils/account.spec.ts @@ -2,12 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const { fetchRpCmsData, getOptionalCmsEmailConfig } = require('./account'); describe('fetchRpCmsData', () => { - const sandbox = sinon.createSandbox(); const mockRequest = { app: { metricsContext: { @@ -19,7 +16,7 @@ describe('fetchRpCmsData', () => { }; beforeEach(() => { - sandbox.reset(); + jest.clearAllMocks(); }); it('returns an RP CMS config', async () => { @@ -27,14 +24,14 @@ describe('fetchRpCmsData', () => { shared: {}, }; const mockCmsManager = { - fetchCMSData: sandbox.stub().resolves({ + fetchCMSData: jest.fn().mockResolvedValue({ relyingParties: [rpCmsConfig], }), }; const actual = await fetchRpCmsData(mockRequest, mockCmsManager); - sinon.assert.calledOnceWithExactly( - mockCmsManager.fetchCMSData, + expect(mockCmsManager.fetchCMSData).toHaveBeenCalledTimes(1); + expect(mockCmsManager.fetchCMSData).toHaveBeenCalledWith( mockRequest.app.metricsContext.clientId, mockRequest.app.metricsContext.entrypoint ); @@ -43,7 +40,7 @@ describe('fetchRpCmsData', () => { it('returns null when no matching RP found in CMS', async () => { const mockCmsManager = { - fetchCMSData: sandbox.stub().resolves({ + fetchCMSData: jest.fn().mockResolvedValue({ relyingParties: [], }), }; @@ -76,14 +73,14 @@ describe('fetchRpCmsData', () => { shared: {}, }; const mockCmsManager = { - fetchCMSData: sandbox.stub().resolves({ + fetchCMSData: jest.fn().mockResolvedValue({ relyingParties: [rpCmsConfig], }), }; const actual = await fetchRpCmsData(mockRequest, mockCmsManager); - sinon.assert.calledOnceWithExactly( - mockCmsManager.fetchCMSData, + expect(mockCmsManager.fetchCMSData).toHaveBeenCalledTimes(1); + expect(mockCmsManager.fetchCMSData).toHaveBeenCalledWith( mockRequest.app.metricsContext.clientId, 'default' ); @@ -93,25 +90,23 @@ describe('fetchRpCmsData', () => { it('logs an error', async () => { const err = new Error('No can do'); const mockCmsManager = { - fetchCMSData: sandbox.stub().rejects(err), + fetchCMSData: jest.fn().mockRejectedValue(err), }; - const mockLogger = { error: sandbox.stub() }; + const mockLogger = { error: jest.fn() }; const actual = await fetchRpCmsData( mockRequest, mockCmsManager, mockLogger ); - sinon.assert.calledOnceWithExactly( - mockLogger.error, - 'cms.getConfig.error', - { error: err } - ); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith('cms.getConfig.error', { + error: err, + }); expect(actual).toBeNull(); }); }); describe('getOptionalCmsEmailConfig', () => { - const sandbox = sinon.createSandbox(); const mockRequest = { app: { metricsContext: { @@ -122,7 +117,7 @@ describe('getOptionalCmsEmailConfig', () => { }; beforeEach(() => { - sandbox.reset(); + jest.clearAllMocks(); }); it('returns original email options when no CMS config is available', async () => { @@ -133,12 +128,12 @@ describe('getOptionalCmsEmailConfig', () => { }; const mockCmsManager = { - fetchCMSData: sandbox.stub().resolves({ + fetchCMSData: jest.fn().mockResolvedValue({ relyingParties: [], }), }; - const mockLog = { error: sandbox.stub() }; + const mockLog = { error: jest.fn() }; const result = await getOptionalCmsEmailConfig(emailOptions, { request: mockRequest, @@ -172,12 +167,12 @@ describe('getOptionalCmsEmailConfig', () => { }; const mockCmsManager = { - fetchCMSData: sandbox.stub().resolves({ + fetchCMSData: jest.fn().mockResolvedValue({ relyingParties: [rpCmsConfig], }), }; - const mockLog = { error: sandbox.stub() }; + const mockLog = { error: jest.fn() }; const result = await getOptionalCmsEmailConfig(emailOptions, { request: mockRequest, @@ -218,12 +213,12 @@ describe('getOptionalCmsEmailConfig', () => { }; const mockCmsManager = { - fetchCMSData: sandbox.stub().resolves({ + fetchCMSData: jest.fn().mockResolvedValue({ relyingParties: [rpCmsConfig], }), }; - const mockLog = { error: sandbox.stub() }; + const mockLog = { error: jest.fn() }; const result = await getOptionalCmsEmailConfig(emailOptions, { request: mockRequest, @@ -243,10 +238,10 @@ describe('getOptionalCmsEmailConfig', () => { }; const mockCmsManager = { - fetchCMSData: sandbox.stub().rejects(new Error('CMS Error')), + fetchCMSData: jest.fn().mockRejectedValue(new Error('CMS Error')), }; - const mockLog = { error: sandbox.stub() }; + const mockLog = { error: jest.fn() }; const result = await getOptionalCmsEmailConfig(emailOptions, { request: mockRequest, @@ -256,7 +251,7 @@ describe('getOptionalCmsEmailConfig', () => { }); expect(result).toEqual(emailOptions); - sinon.assert.calledOnce(mockLog.error); + expect(mockLog.error).toHaveBeenCalledTimes(1); }); it('works with different email templates', async () => { @@ -281,12 +276,12 @@ describe('getOptionalCmsEmailConfig', () => { }; const mockCmsManager = { - fetchCMSData: sandbox.stub().resolves({ + fetchCMSData: jest.fn().mockResolvedValue({ relyingParties: [rpCmsConfig], }), }; - const mockLog = { error: sandbox.stub() }; + const mockLog = { error: jest.fn() }; const result = await getOptionalCmsEmailConfig(emailOptions, { request: mockRequest, diff --git a/packages/fxa-auth-server/lib/routes/utils/clients.spec.ts b/packages/fxa-auth-server/lib/routes/utils/clients.spec.ts index e41da8b190b..675e9faec70 100644 --- a/packages/fxa-auth-server/lib/routes/utils/clients.spec.ts +++ b/packages/fxa-auth-server/lib/routes/utils/clients.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import moment from 'moment'; const mocks = require('../../../test/mocks'); @@ -10,7 +9,6 @@ const mocks = require('../../../test/mocks'); const EARLIEST_SANE_TIMESTAMP = 31536000000; /** - * Simplified mockRequest — the shared mocks.mockRequest() uses proxyquire * with relative paths that don't resolve from lib/routes/. */ function mockRequest(data: any) { @@ -45,12 +43,12 @@ function mockRequest(data: any) { auth: { credentials: data.credentials, }, - clearMetricsContext: sinon.stub(), - emitMetricsEvent: sinon.stub().resolves(), - emitRouteFlowEvent: sinon.stub().resolves(), - gatherMetricsContext: sinon - .stub() - .callsFake((d: any) => Promise.resolve(d)), + clearMetricsContext: jest.fn(), + emitMetricsEvent: jest.fn().mockResolvedValue(), + emitRouteFlowEvent: jest.fn().mockResolvedValue(), + gatherMetricsContext: jest + .fn() + .mockImplementation((d: any) => Promise.resolve(d)), headers: { 'user-agent': 'test user-agent', }, @@ -89,7 +87,7 @@ describe('clientUtils.formatLocation', () => { const client: any = {}; clientUtils.formatLocation(client, request); expect(client.location).toEqual({}); - expect(log.warn.callCount).toBe(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); it('sets empty location if location is null', () => { @@ -98,7 +96,7 @@ describe('clientUtils.formatLocation', () => { }; clientUtils.formatLocation(client, request); expect(client.location).toEqual({}); - expect(log.warn.callCount).toBe(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); it('leaves location info untranslated by default', () => { @@ -118,7 +116,7 @@ describe('clientUtils.formatLocation', () => { country: 'USA', stateCode: '1234', }); - expect(log.warn.callCount).toBe(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); it('leaves location info untranslated for english', () => { @@ -139,7 +137,7 @@ describe('clientUtils.formatLocation', () => { country: 'USA', stateCode: '1234', }); - expect(log.warn.callCount).toBe(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); it('translates only the country name for other languages', () => { @@ -157,7 +155,7 @@ describe('clientUtils.formatLocation', () => { expect(client.location).toEqual({ country: 'Royaume-Uni', }); - expect(log.warn.callCount).toBe(0); + expect(log.warn).toHaveBeenCalledTimes(0); }); }); diff --git a/packages/fxa-auth-server/lib/routes/utils/cms/localization.spec.ts b/packages/fxa-auth-server/lib/routes/utils/cms/localization.spec.ts index 0496e480069..0d19ab635a3 100644 --- a/packages/fxa-auth-server/lib/routes/utils/cms/localization.spec.ts +++ b/packages/fxa-auth-server/lib/routes/utils/cms/localization.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - // @octokit/rest is ESM-only; mock to avoid parse errors in Jest jest.mock('@octokit/rest', () => ({ Octokit: class Octokit { @@ -19,25 +17,24 @@ jest.mock('@fxa/shared/cms', () => ({ const { CMSLocalization } = require('./localization'); describe('CMSLocalization', () => { - const sandbox = sinon.createSandbox(); let mockLog: any; let mockConfig: any; let mockStatsd: any; let localization: any; beforeEach(() => { - sandbox.reset(); + jest.clearAllMocks(); mockLog = { - info: sandbox.stub(), - warn: sandbox.stub(), - error: sandbox.stub(), - debug: sandbox.stub(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), }; mockStatsd = { - increment: sandbox.stub(), - timing: sandbox.stub(), + increment: jest.fn(), + timing: jest.fn(), }; mockConfig = { @@ -69,10 +66,10 @@ describe('CMSLocalization', () => { }; const mockCmsManager = { - getCachedFtlContent: sandbox.stub(), - cacheFtlContent: sandbox.stub(), - invalidateFtlCache: sandbox.stub(), - getFtlContent: sandbox.stub(), + getCachedFtlContent: jest.fn(), + cacheFtlContent: jest.fn(), + invalidateFtlCache: jest.fn(), + getFtlContent: jest.fn(), }; localization = new CMSLocalization( @@ -249,36 +246,35 @@ describe('CMSLocalization', () => { beforeEach(() => { localization.octokit = { repos: { - get: sandbox.stub(), - createOrUpdateFileContents: sandbox.stub(), - getContent: sandbox.stub(), + get: jest.fn(), + createOrUpdateFileContents: jest.fn(), + getContent: jest.fn(), }, git: { - getRef: sandbox.stub(), - createRef: sandbox.stub(), + getRef: jest.fn(), + createRef: jest.fn(), }, pulls: { - create: sandbox.stub(), - get: sandbox.stub(), - list: sandbox.stub(), + create: jest.fn(), + get: jest.fn(), + list: jest.fn(), }, }; }); describe('validateGitHubConfig', () => { it('validates GitHub configuration successfully', async () => { - localization.octokit.repos.get.resolves({ + localization.octokit.repos.get.mockResolvedValue({ data: { default_branch: 'main' }, }); await localization.validateGitHubConfig(); - sinon.assert.calledWith(localization.octokit.repos.get, { + expect(localization.octokit.repos.get).toHaveBeenCalledWith({ owner: 'test-owner', repo: 'test-repo', }); - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'cms.integrations.github.config.validated', {} ); @@ -302,14 +298,13 @@ describe('CMSLocalization', () => { it('throws error when GitHub API call fails', async () => { const error = new Error('API Error'); - localization.octokit.repos.get.rejects(error); + localization.octokit.repos.get.mockRejectedValue(error); await expect(localization.validateGitHubConfig()).rejects.toThrow( /API Error/ ); - sinon.assert.calledWith( - mockLog.error, + expect(mockLog.error).toHaveBeenCalledWith( 'cms.integrations.github.config.validation.failed', { error: 'API Error', @@ -325,7 +320,7 @@ describe('CMSLocalization', () => { { number: 124, title: 'Other PR', state: 'open' }, ]; - localization.octokit.pulls.list.resolves({ data: mockPRs }); + localization.octokit.pulls.list.mockResolvedValue({ data: mockPRs }); const result = await localization.findExistingPR( 'test-owner', @@ -333,8 +328,7 @@ describe('CMSLocalization', () => { ); expect(result).toEqual(mockPRs[0]); - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'cms.integrations.github.pr.found', { prNumber: 123, @@ -353,7 +347,7 @@ describe('CMSLocalization', () => { { number: 124, title: 'Other PR', state: 'open' }, ]; - localization.octokit.pulls.list.resolves({ data: mockPRs }); + localization.octokit.pulls.list.mockResolvedValue({ data: mockPRs }); const result = await localization.findExistingPR( 'test-owner', @@ -361,8 +355,7 @@ describe('CMSLocalization', () => { ); expect(result).toEqual(mockPRs[0]); - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'cms.integrations.github.pr.found', { prNumber: 123, @@ -374,7 +367,7 @@ describe('CMSLocalization', () => { it('returns null when no matching PR found', async () => { const mockPRs = [{ number: 124, title: 'Other PR', state: 'open' }]; - localization.octokit.pulls.list.resolves({ data: mockPRs }); + localization.octokit.pulls.list.mockResolvedValue({ data: mockPRs }); const result = await localization.findExistingPR( 'test-owner', @@ -382,8 +375,7 @@ describe('CMSLocalization', () => { ); expect(result).toBeNull(); - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'cms.integrations.github.pr.notFound', {} ); @@ -391,14 +383,13 @@ describe('CMSLocalization', () => { it('handles API errors', async () => { const error = new Error('API Error'); - localization.octokit.pulls.list.rejects(error); + localization.octokit.pulls.list.mockRejectedValue(error); await expect( localization.findExistingPR('test-owner', 'test-repo') ).rejects.toThrow(/API Error/); - sinon.assert.calledWith( - mockLog.error, + expect(mockLog.error).toHaveBeenCalledWith( 'cms.integrations.github.pr.search.error', { error: 'API Error', @@ -412,49 +403,51 @@ describe('CMSLocalization', () => { const mockPR = { head: { ref: 'test-branch' } }; const mockFileData = { sha: 'existing-sha' }; - localization.octokit.pulls.get.resolves({ data: mockPR }); - localization.octokit.repos.getContent.resolves({ data: mockFileData }); - localization.octokit.repos.createOrUpdateFileContents.resolves(); + localization.octokit.pulls.get.mockResolvedValue({ data: mockPR }); + localization.octokit.repos.getContent.mockResolvedValue({ + data: mockFileData, + }); + localization.octokit.repos.createOrUpdateFileContents.mockResolvedValue(); await localization.updateExistingPR(123, 'test content'); - sinon.assert.calledWith( - localization.octokit.repos.createOrUpdateFileContents, - { - owner: 'test-owner', - repo: 'test-repo', - path: 'locales/en/cms.ftl', - message: - '🔄 Update CMS localization file (cms.ftl) - Strapi webhook sync', - content: sinon.match.string, - sha: 'existing-sha', - branch: 'test-branch', - } - ); + expect( + localization.octokit.repos.createOrUpdateFileContents + ).toHaveBeenCalledWith({ + owner: 'test-owner', + repo: 'test-repo', + path: 'locales/en/cms.ftl', + message: + '🔄 Update CMS localization file (cms.ftl) - Strapi webhook sync', + content: expect.any(String), + sha: 'existing-sha', + branch: 'test-branch', + }); }); it('creates new file when file does not exist', async () => { const mockPR = { head: { ref: 'test-branch' } }; - localization.octokit.pulls.get.resolves({ data: mockPR }); - localization.octokit.repos.getContent.rejects(new Error('Not Found')); - localization.octokit.repos.createOrUpdateFileContents.resolves(); + localization.octokit.pulls.get.mockResolvedValue({ data: mockPR }); + localization.octokit.repos.getContent.mockRejectedValue( + new Error('Not Found') + ); + localization.octokit.repos.createOrUpdateFileContents.mockResolvedValue(); await localization.updateExistingPR(123, 'test content'); - sinon.assert.calledWith( - localization.octokit.repos.createOrUpdateFileContents, - { - owner: 'test-owner', - repo: 'test-repo', - path: 'locales/en/cms.ftl', - message: - '🌐 Add CMS localization file (cms.ftl) - Strapi webhook generated', - content: sinon.match.string, - sha: undefined, - branch: 'test-branch', - } - ); + expect( + localization.octokit.repos.createOrUpdateFileContents + ).toHaveBeenCalledWith({ + owner: 'test-owner', + repo: 'test-repo', + path: 'locales/en/cms.ftl', + message: + '🌐 Add CMS localization file (cms.ftl) - Strapi webhook generated', + content: expect.any(String), + sha: undefined, + branch: 'test-branch', + }); }); }); @@ -466,30 +459,35 @@ describe('CMSLocalization', () => { html_url: 'https://github.com/test/pr/123', }; - localization.octokit.git.getRef.resolves({ data: mockRefData }); - localization.octokit.git.createRef.resolves(); - localization.octokit.repos.getContent.rejects(new Error('Not Found')); - localization.octokit.repos.createOrUpdateFileContents.resolves(); - localization.octokit.pulls.create.resolves({ data: mockPRData }); + localization.octokit.git.getRef.mockResolvedValue({ + data: mockRefData, + }); + localization.octokit.git.createRef.mockResolvedValue(); + localization.octokit.repos.getContent.mockRejectedValue( + new Error('Not Found') + ); + localization.octokit.repos.createOrUpdateFileContents.mockResolvedValue(); + localization.octokit.pulls.create.mockResolvedValue({ + data: mockPRData, + }); await localization.createGitHubPR('test content', 'desktop-sync'); - sinon.assert.calledWith(localization.octokit.pulls.create, { + expect(localization.octokit.pulls.create).toHaveBeenCalledWith({ owner: 'test-owner', repo: 'test-repo', title: '🌐 Add CMS localization file (cms.ftl)', - body: sinon.match.string, - head: sinon.match.string, + body: expect.any(String), + head: expect.any(String), base: 'main', }); - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'cms.integrations.github.pr.created', { prNumber: 123, prUrl: 'https://github.com/test/pr/123', - branchName: sinon.match.string, + branchName: expect.any(String), fileName: 'cms.ftl', webhookDetails: undefined, } @@ -504,27 +502,32 @@ describe('CMSLocalization', () => { }; const mockFileData = { sha: 'existing-file-sha' }; - localization.octokit.git.getRef.resolves({ data: mockRefData }); - localization.octokit.git.createRef.resolves(); - localization.octokit.repos.getContent.resolves({ data: mockFileData }); - localization.octokit.repos.createOrUpdateFileContents.resolves(); - localization.octokit.pulls.create.resolves({ data: mockPRData }); + localization.octokit.git.getRef.mockResolvedValue({ + data: mockRefData, + }); + localization.octokit.git.createRef.mockResolvedValue(); + localization.octokit.repos.getContent.mockResolvedValue({ + data: mockFileData, + }); + localization.octokit.repos.createOrUpdateFileContents.mockResolvedValue(); + localization.octokit.pulls.create.mockResolvedValue({ + data: mockPRData, + }); await localization.createGitHubPR('test content', 'desktop-sync'); - sinon.assert.calledWith( - localization.octokit.repos.createOrUpdateFileContents, - { - owner: 'test-owner', - repo: 'test-repo', - path: 'locales/en/cms.ftl', - message: - '🌐 Add CMS localization file (cms.ftl) - Strapi webhook generated', - content: sinon.match.string, - sha: 'existing-file-sha', - branch: sinon.match.string, - } - ); + expect( + localization.octokit.repos.createOrUpdateFileContents + ).toHaveBeenCalledWith({ + owner: 'test-owner', + repo: 'test-repo', + path: 'locales/en/cms.ftl', + message: + '🌐 Add CMS localization file (cms.ftl) - Strapi webhook generated', + content: expect.any(String), + sha: 'existing-file-sha', + branch: expect.any(String), + }); }); }); }); @@ -541,29 +544,28 @@ describe('CMSLocalization', () => { ]; const originalFetch = global.fetch; - global.fetch = sandbox.stub() as any; - - (global.fetch as any) - .withArgs('http://localhost:1337/api/relying-parties?populate=*') - .resolves({ - ok: true, - status: 200, - json: () => Promise.resolve({ data: relyingPartyEntries }), - }); - - (global.fetch as any) - .withArgs('http://localhost:1337/api/legal-notices?populate=*') - .resolves({ - ok: true, - status: 200, - json: () => Promise.resolve({ data: legalNoticeEntries }), - }); + global.fetch = jest.fn().mockImplementation((url: string) => { + if (url === 'http://localhost:1337/api/relying-parties?populate=*') { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: relyingPartyEntries }), + }); + } + if (url === 'http://localhost:1337/api/legal-notices?populate=*') { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: legalNoticeEntries }), + }); + } + return Promise.reject(new Error(`Unexpected fetch URL: ${url}`)); + }) as any; try { const result = await localization.fetchAllStrapiEntries(); - sinon.assert.calledWith( - global.fetch as any, + expect(global.fetch as any).toHaveBeenCalledWith( 'http://localhost:1337/api/relying-parties?populate=*', { headers: { @@ -573,8 +575,7 @@ describe('CMSLocalization', () => { } ); - sinon.assert.calledWith( - global.fetch as any, + expect(global.fetch as any).toHaveBeenCalledWith( 'http://localhost:1337/api/legal-notices?populate=*', { headers: { @@ -586,8 +587,7 @@ describe('CMSLocalization', () => { expect(result).toEqual([...relyingPartyEntries, ...legalNoticeEntries]); - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'cms.integrations.strapi.fetchedAllEntries', { totalCount: 3, @@ -604,31 +604,30 @@ describe('CMSLocalization', () => { ]; const originalFetch = global.fetch; - global.fetch = sandbox.stub() as any; - - (global.fetch as any) - .withArgs('http://localhost:1337/api/relying-parties?populate=*') - .resolves({ - ok: true, - status: 200, - json: () => Promise.resolve({ data: relyingPartyEntries }), - }); - - (global.fetch as any) - .withArgs('http://localhost:1337/api/legal-notices?populate=*') - .resolves({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - }); + global.fetch = jest.fn().mockImplementation((url: string) => { + if (url === 'http://localhost:1337/api/relying-parties?populate=*') { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: relyingPartyEntries }), + }); + } + if (url === 'http://localhost:1337/api/legal-notices?populate=*') { + return Promise.resolve({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + } + return Promise.reject(new Error(`Unexpected fetch URL: ${url}`)); + }) as any; try { const result = await localization.fetchAllStrapiEntries(); expect(result).toEqual(relyingPartyEntries); - sinon.assert.calledWith( - mockLog.warn, + expect(mockLog.warn).toHaveBeenCalledWith( 'cms.integrations.strapi.fetchCollectionError', { collection: 'legal-notices', @@ -637,8 +636,7 @@ describe('CMSLocalization', () => { } ); - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'cms.integrations.strapi.fetchedAllEntries', { totalCount: 1, @@ -660,35 +658,35 @@ describe('CMSLocalization', () => { }; const originalFetch = global.fetch; - global.fetch = sandbox.stub() as any; - - (global.fetch as any) - .withArgs('http://localhost:1337/api/relying-parties?populate=*') - .resolves({ - ok: true, - status: 200, - json: () => Promise.resolve({ data: relyingPartyEntries }), - }); - (global.fetch as any) - .withArgs('http://localhost:1337/api/legal-notices?populate=*') - .resolves({ - ok: true, - status: 200, - json: () => Promise.resolve({ data: [] }), - }); - (global.fetch as any) - .withArgs('http://localhost:1337/api/default?populate=*') - .resolves({ - ok: true, - status: 200, - json: () => Promise.resolve({ data: defaultEntry }), - }); + global.fetch = jest.fn().mockImplementation((url: string) => { + if (url === 'http://localhost:1337/api/relying-parties?populate=*') { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: relyingPartyEntries }), + }); + } + if (url === 'http://localhost:1337/api/legal-notices?populate=*') { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: [] }), + }); + } + if (url === 'http://localhost:1337/api/default?populate=*') { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: defaultEntry }), + }); + } + return Promise.reject(new Error(`Unexpected fetch URL: ${url}`)); + }) as any; try { const result = await localization.fetchAllStrapiEntries(); expect(result).toEqual([...relyingPartyEntries, defaultEntry]); - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'cms.integrations.strapi.fetchedAllEntries', { totalCount: 2 } ); @@ -727,9 +725,9 @@ describe('CMSLocalization', () => { beforeEach(() => { mockCmsManager = { - getCachedFtlContent: sandbox.stub(), - cacheFtlContent: sandbox.stub(), - getFtlContent: sandbox.stub(), + getCachedFtlContent: jest.fn(), + cacheFtlContent: jest.fn(), + getFtlContent: jest.fn(), }; localization.cmsManager = mockCmsManager; }); @@ -738,14 +736,16 @@ describe('CMSLocalization', () => { const locale = 'es'; const cachedContent = 'cached FTL content'; - mockCmsManager.getFtlContent.resolves(cachedContent); + mockCmsManager.getFtlContent.mockResolvedValue(cachedContent); const result = await localization.fetchLocalizedFtlWithFallback(locale); expect(result).toBe(cachedContent); - sinon.assert.calledWith(mockCmsManager.getFtlContent, locale, mockConfig); - sinon.assert.calledWith( - mockStatsd.increment, + expect(mockCmsManager.getFtlContent).toHaveBeenCalledWith( + locale, + mockConfig + ); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'cms.getLocalizedConfig.ftl.success' ); }); @@ -754,14 +754,16 @@ describe('CMSLocalization', () => { const locale = 'fr'; const ftlContent = 'fresh FTL content'; - mockCmsManager.getFtlContent.resolves(ftlContent); + mockCmsManager.getFtlContent.mockResolvedValue(ftlContent); const result = await localization.fetchLocalizedFtlWithFallback(locale); expect(result).toBe(ftlContent); - sinon.assert.calledWith(mockCmsManager.getFtlContent, locale, mockConfig); - sinon.assert.calledWith( - mockStatsd.increment, + expect(mockCmsManager.getFtlContent).toHaveBeenCalledWith( + locale, + mockConfig + ); + expect(mockStatsd.increment).toHaveBeenCalledWith( 'cms.getLocalizedConfig.ftl.success' ); }); @@ -772,23 +774,20 @@ describe('CMSLocalization', () => { const ftlContent = 'fallback content'; mockCmsManager.getFtlContent - .onFirstCall() - .rejects(new Error('Specific locale failed')); - mockCmsManager.getFtlContent.onSecondCall().resolves(ftlContent); + .mockRejectedValueOnce(new Error('Specific locale failed')) + .mockResolvedValueOnce(ftlContent); const result = await localization.fetchLocalizedFtlWithFallback(locale); expect(result).toBe(ftlContent); - sinon.assert.calledWith( - mockLog.error, + expect(mockLog.error).toHaveBeenCalledWith( 'cms.getLocalizedConfig.locale.failed', { locale, error: 'Specific locale failed', } ); - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'cms.getLocalizedConfig.locale.fallback', { originalLocale: locale, @@ -803,25 +802,23 @@ describe('CMSLocalization', () => { const fallbackContent = 'base locale content'; mockCmsManager.getFtlContent - .onFirstCall() - .rejects(new Error('Specific locale failed')); - mockCmsManager.getFtlContent.onSecondCall().resolves(fallbackContent); + .mockRejectedValueOnce(new Error('Specific locale failed')) + .mockResolvedValueOnce(fallbackContent); const result = await localization.fetchLocalizedFtlWithFallback(locale); expect(result).toBe(fallbackContent); - sinon.assert.calledWith( - mockCmsManager.getFtlContent.firstCall, + expect(mockCmsManager.getFtlContent).toHaveBeenNthCalledWith( + 1, locale, mockConfig ); - sinon.assert.calledWith( - mockCmsManager.getFtlContent.secondCall, + expect(mockCmsManager.getFtlContent).toHaveBeenNthCalledWith( + 2, baseLocale, mockConfig ); - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'cms.getLocalizedConfig.locale.fallback', { originalLocale: locale, @@ -836,23 +833,20 @@ describe('CMSLocalization', () => { const baseContent = 'base content'; mockCmsManager.getFtlContent - .onFirstCall() - .rejects(new Error('Specific locale failed')); - mockCmsManager.getFtlContent.onSecondCall().resolves(baseContent); + .mockRejectedValueOnce(new Error('Specific locale failed')) + .mockResolvedValueOnce(baseContent); const result = await localization.fetchLocalizedFtlWithFallback(locale); expect(result).toBe(baseContent); - sinon.assert.calledWith( - mockLog.info, + expect(mockLog.info).toHaveBeenCalledWith( 'cms.getLocalizedConfig.locale.fallback', { originalLocale: locale, fallbackLocale: baseLocale, } ); - sinon.assert.calledWith( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledWith( 'cms.getLocalizedConfig.ftl.success' ); }); @@ -861,17 +855,13 @@ describe('CMSLocalization', () => { const locale = 'pt-BR'; mockCmsManager.getFtlContent - .onFirstCall() - .rejects(new Error('Specific locale failed')); - mockCmsManager.getFtlContent - .onSecondCall() - .rejects(new Error('Base locale failed')); + .mockRejectedValueOnce(new Error('Specific locale failed')) + .mockRejectedValueOnce(new Error('Base locale failed')); const result = await localization.fetchLocalizedFtlWithFallback(locale); expect(result).toBe(''); - sinon.assert.calledWith( - mockStatsd.increment, + expect(mockStatsd.increment).toHaveBeenCalledWith( 'cms.getLocalizedConfig.ftl.fallback' ); }); @@ -880,25 +870,20 @@ describe('CMSLocalization', () => { const locale = 'it-IT'; mockCmsManager.getFtlContent - .onFirstCall() - .rejects(new Error('Specific locale failed')); - mockCmsManager.getFtlContent - .onSecondCall() - .rejects(new Error('Base locale failed')); + .mockRejectedValueOnce(new Error('Specific locale failed')) + .mockRejectedValueOnce(new Error('Base locale failed')); const result = await localization.fetchLocalizedFtlWithFallback(locale); expect(result).toBe(''); - sinon.assert.calledWith( - mockLog.error, + expect(mockLog.error).toHaveBeenCalledWith( 'cms.getLocalizedConfig.locale.failed', { locale, error: 'Specific locale failed', } ); - sinon.assert.calledWith( - mockLog.error, + expect(mockLog.error).toHaveBeenCalledWith( 'cms.getLocalizedConfig.locale.fallback.failed', { originalLocale: locale, @@ -1080,7 +1065,7 @@ describe('CMSLocalization', () => { describe('generateFtlContentFromEntries', () => { beforeEach(() => { - sandbox.stub(localization, 'strapiToFtl'); + jest.spyOn(localization, 'strapiToFtl'); }); it('delegates to strapiToFtl method', () => { @@ -1090,24 +1075,24 @@ describe('CMSLocalization', () => { ]; const expectedFtl = 'Generated FTL content'; - localization.strapiToFtl.returns(expectedFtl); + localization.strapiToFtl.mockReturnValue(expectedFtl); const result = localization.generateFtlContentFromEntries(entries); expect(result).toBe(expectedFtl); - sinon.assert.calledWith(localization.strapiToFtl, entries); + expect(localization.strapiToFtl).toHaveBeenCalledWith(entries); }); it('handles empty entries array', () => { const entries: any[] = []; const expectedFtl = 'Empty FTL content'; - localization.strapiToFtl.returns(expectedFtl); + localization.strapiToFtl.mockReturnValue(expectedFtl); const result = localization.generateFtlContentFromEntries(entries); expect(result).toBe(expectedFtl); - sinon.assert.calledWith(localization.strapiToFtl, entries); + expect(localization.strapiToFtl).toHaveBeenCalledWith(entries); }); it('passes through all entries without modification', () => { @@ -1119,10 +1104,10 @@ describe('CMSLocalization', () => { }, ]; - localization.strapiToFtl.returns('FTL output'); + localization.strapiToFtl.mockReturnValue('FTL output'); localization.generateFtlContentFromEntries(entries); - sinon.assert.calledWith(localization.strapiToFtl, entries); + expect(localization.strapiToFtl).toHaveBeenCalledWith(entries); }); }); diff --git a/packages/fxa-auth-server/lib/routes/utils/oauth.spec.ts b/packages/fxa-auth-server/lib/routes/utils/oauth.spec.ts index 801496c252e..1d7127344a3 100644 --- a/packages/fxa-auth-server/lib/routes/utils/oauth.spec.ts +++ b/packages/fxa-auth-server/lib/routes/utils/oauth.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const TEST_EMAIL = 'foo@gmail.com'; const MOCK_UID = '23d4847823f24b0f95e1524987cb0391'; const MOCK_REFRESH_TOKEN = @@ -36,7 +34,6 @@ const mocks = require('../../../test/mocks'); const oauthUtils = require('./oauth'); /** - * Simplified mockRequest — the shared mocks.mockRequest() uses proxyquire * with relative paths that don't resolve from lib/routes/. */ function mockRequest(data: any) { @@ -71,12 +68,12 @@ function mockRequest(data: any) { auth: { credentials: data.credentials, }, - clearMetricsContext: sinon.stub(), - emitMetricsEvent: sinon.stub().resolves(), - emitRouteFlowEvent: sinon.stub().resolves(), - gatherMetricsContext: sinon - .stub() - .callsFake((d: any) => Promise.resolve(d)), + clearMetricsContext: jest.fn(), + emitMetricsEvent: jest.fn().mockResolvedValue(), + emitRouteFlowEvent: jest.fn().mockResolvedValue(), + gatherMetricsContext: jest + .fn() + .mockImplementation((d: any) => Promise.resolve(d)), headers: { 'user-agent': 'test user-agent', }, @@ -122,11 +119,15 @@ describe('newTokenNotification', () => { it('creates a device and sends an email with credentials uid', async () => { await oauthUtils.newTokenNotification(db, mailer, devices, request, grant); - expect(fxaMailer.sendNewDeviceLoginEmail.callCount).toBe(1); - expect(devices.upsert.callCount).toBe(1); - const args = devices.upsert.args[0]; - expect(args[1].refreshTokenId).toBe( - request.auth.credentials.refreshTokenId + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(1); + expect(devices.upsert).toHaveBeenCalledTimes(1); + expect(devices.upsert).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + refreshTokenId: request.auth.credentials.refreshTokenId, + }), + expect.anything() ); }); @@ -139,8 +140,8 @@ describe('newTokenNotification', () => { }); await oauthUtils.newTokenNotification(db, mailer, devices, request, grant); - expect(fxaMailer.sendNewDeviceLoginEmail.callCount).toBe(0); - expect(devices.upsert.callCount).toBe(1); + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); + expect(devices.upsert).toHaveBeenCalledTimes(1); }); it('creates a device and sends an email with token uid', async () => { @@ -148,16 +149,16 @@ describe('newTokenNotification', () => { request = mockRequest({ credentials }); await oauthUtils.newTokenNotification(db, mailer, devices, request, grant); - expect(fxaMailer.sendNewDeviceLoginEmail.callCount).toBe(1); - expect(devices.upsert.callCount).toBe(1); + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(1); + expect(devices.upsert).toHaveBeenCalledTimes(1); }); it('does nothing for non-NOTIFICATION_SCOPES', async () => { grant.scope = 'profile'; await oauthUtils.newTokenNotification(db, mailer, devices, request, grant); - expect(fxaMailer.sendNewDeviceLoginEmail.callCount).toBe(0); - expect(devices.upsert.callCount).toBe(0); + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); + expect(devices.upsert).toHaveBeenCalledTimes(0); }); it('uses refreshTokenId from grant if not provided', async () => { @@ -167,11 +168,14 @@ describe('newTokenNotification', () => { request = mockRequest({ credentials }); await oauthUtils.newTokenNotification(db, mailer, devices, request, grant); - expect(fxaMailer.sendNewDeviceLoginEmail.callCount).toBe(1); - expect(devices.upsert.callCount).toBe(1); - const args = devices.upsert.args[0]; - expect(args[1].refreshTokenId).toBe(MOCK_REFRESH_TOKEN_ID_2); - expect(args[2].id).toBeUndefined(); + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(1); + expect(devices.upsert).toHaveBeenCalledTimes(1); + expect(devices.upsert).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ refreshTokenId: MOCK_REFRESH_TOKEN_ID_2 }), + expect.objectContaining({ id: undefined }) + ); }); it('updates the device record using the deviceId', async () => { @@ -182,11 +186,14 @@ describe('newTokenNotification', () => { request = mockRequest({ credentials }); await oauthUtils.newTokenNotification(db, mailer, devices, request, grant); - expect(fxaMailer.sendNewDeviceLoginEmail.callCount).toBe(0); - expect(devices.upsert.callCount).toBe(1); - const args = devices.upsert.args[0]; - expect(args[1].refreshTokenId).toBe(MOCK_REFRESH_TOKEN_ID_2); - expect(args[2].id).toBe(MOCK_DEVICE_ID); + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); + expect(devices.upsert).toHaveBeenCalledTimes(1); + expect(devices.upsert).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ refreshTokenId: MOCK_REFRESH_TOKEN_ID_2 }), + expect.objectContaining({ id: MOCK_DEVICE_ID }) + ); }); it('creates a device but skips email when skipEmail option is true', async () => { @@ -194,11 +201,15 @@ describe('newTokenNotification', () => { skipEmail: true, }); - expect(fxaMailer.sendNewDeviceLoginEmail.callCount).toBe(0); - expect(devices.upsert.callCount).toBe(1); - const args = devices.upsert.args[0]; - expect(args[1].refreshTokenId).toBe( - request.auth.credentials.refreshTokenId + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); + expect(devices.upsert).toHaveBeenCalledTimes(1); + expect(devices.upsert).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + refreshTokenId: request.auth.credentials.refreshTokenId, + }), + expect.anything() ); }); @@ -214,10 +225,13 @@ describe('newTokenNotification', () => { existingDeviceId: EXISTING_DEVICE_ID, }); - expect(fxaMailer.sendNewDeviceLoginEmail.callCount).toBe(0); - expect(devices.upsert.callCount).toBe(1); - const args = devices.upsert.args[0]; - expect(args[2].id).toBe(EXISTING_DEVICE_ID); - expect(args[1].deviceId).toBe(EXISTING_DEVICE_ID); + expect(fxaMailer.sendNewDeviceLoginEmail).toHaveBeenCalledTimes(0); + expect(devices.upsert).toHaveBeenCalledTimes(1); + expect(devices.upsert).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ deviceId: EXISTING_DEVICE_ID }), + expect.objectContaining({ id: EXISTING_DEVICE_ID }) + ); }); }); diff --git a/packages/fxa-auth-server/lib/routes/utils/security-event.spec.ts b/packages/fxa-auth-server/lib/routes/utils/security-event.spec.ts index 9895b8b76b7..b2086381205 100644 --- a/packages/fxa-auth-server/lib/routes/utils/security-event.spec.ts +++ b/packages/fxa-auth-server/lib/routes/utils/security-event.spec.ts @@ -2,19 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const { isRecognizedDevice } = require('./security-event'); describe('isRecognizedDevice', () => { - let sandbox: sinon.SinonSandbox; - - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); + beforeEach(() => {}); afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); it('should return true when user agent matches in verified login events', async () => { @@ -36,7 +30,7 @@ describe('isRecognizedDevice', () => { ]; const mockDb = { - verifiedLoginSecurityEventsByUid: sandbox.stub().resolves(mockEvents), + verifiedLoginSecurityEventsByUid: jest.fn().mockResolvedValue(mockEvents), }; const result = await isRecognizedDevice( @@ -47,10 +41,11 @@ describe('isRecognizedDevice', () => { ); expect(result).toBe(true); - sinon.assert.calledOnceWithExactly( - mockDb.verifiedLoginSecurityEventsByUid, - { uid, skipTimeframeMs } - ); + expect(mockDb.verifiedLoginSecurityEventsByUid).toHaveBeenCalledTimes(1); + expect(mockDb.verifiedLoginSecurityEventsByUid).toHaveBeenCalledWith({ + uid, + skipTimeframeMs, + }); }); it('should return false when user agent does not match in verified login events', async () => { @@ -74,7 +69,7 @@ describe('isRecognizedDevice', () => { ]; const mockDb = { - verifiedLoginSecurityEventsByUid: sandbox.stub().resolves(mockEvents), + verifiedLoginSecurityEventsByUid: jest.fn().mockResolvedValue(mockEvents), }; const result = await isRecognizedDevice( @@ -94,7 +89,7 @@ describe('isRecognizedDevice', () => { const skipTimeframeMs = 604800000; const mockDb = { - verifiedLoginSecurityEventsByUid: sandbox.stub().resolves([]), + verifiedLoginSecurityEventsByUid: jest.fn().mockResolvedValue([]), }; const result = await isRecognizedDevice( @@ -114,7 +109,7 @@ describe('isRecognizedDevice', () => { const skipTimeframeMs = 604800000; const mockDb = { - verifiedLoginSecurityEventsByUid: sandbox.stub().resolves(null), + verifiedLoginSecurityEventsByUid: jest.fn().mockResolvedValue(null), }; const result = await isRecognizedDevice( @@ -152,7 +147,7 @@ describe('isRecognizedDevice', () => { ]; const mockDb = { - verifiedLoginSecurityEventsByUid: sandbox.stub().resolves(mockEvents), + verifiedLoginSecurityEventsByUid: jest.fn().mockResolvedValue(mockEvents), }; const result = await isRecognizedDevice( @@ -202,7 +197,7 @@ describe('isRecognizedDevice', () => { ]; const mockDb = { - verifiedLoginSecurityEventsByUid: sandbox.stub().resolves(mockEvents), + verifiedLoginSecurityEventsByUid: jest.fn().mockResolvedValue(mockEvents), }; const result = await isRecognizedDevice( @@ -233,7 +228,7 @@ describe('isRecognizedDevice', () => { ]; const mockDb = { - verifiedLoginSecurityEventsByUid: sandbox.stub().resolves(mockEvents), + verifiedLoginSecurityEventsByUid: jest.fn().mockResolvedValue(mockEvents), }; const result = await isRecognizedDevice( @@ -271,7 +266,7 @@ describe('isRecognizedDevice', () => { ]; const mockDb = { - verifiedLoginSecurityEventsByUid: sandbox.stub().resolves(mockEvents), + verifiedLoginSecurityEventsByUid: jest.fn().mockResolvedValue(mockEvents), }; const result = await isRecognizedDevice( diff --git a/packages/fxa-auth-server/lib/routes/utils/signin.spec.ts b/packages/fxa-auth-server/lib/routes/utils/signin.spec.ts index 3259530a398..3d9313d61c7 100644 --- a/packages/fxa-auth-server/lib/routes/utils/signin.spec.ts +++ b/packages/fxa-auth-server/lib/routes/utils/signin.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Container } from 'typedi'; const mocks = require('../../../test/mocks'); @@ -53,15 +52,15 @@ describe('checkPassword', () => { mocks.mockOAuthClientInfo(); db = mocks.mockDB(); customs = { - v2Enabled: sinon.spy(() => true), - check: sinon.spy(() => Promise.resolve(false)), - flag: sinon.spy(() => Promise.resolve({})), + v2Enabled: jest.fn(() => true), + check: jest.fn(() => Promise.resolve(false)), + flag: jest.fn(() => Promise.resolve({})), }; signinUtils = makeSigninUtils({ db, customs }); }); it('should check with correct password', () => { - db.checkPassword = sinon.spy((uid: any) => + db.checkPassword = jest.fn((uid: any) => Promise.resolve({ v1: true, v2: false, @@ -87,16 +86,16 @@ describe('checkPassword', () => { .then((match: any) => { expect(match).toBeTruthy(); - sinon.assert.calledOnce(db.checkPassword); - sinon.assert.calledWithExactly(db.checkPassword, TEST_UID, hash); + expect(db.checkPassword).toHaveBeenCalledTimes(1); + expect(db.checkPassword).toHaveBeenCalledWith(TEST_UID, hash); - sinon.assert.notCalled(customs.flag); + expect(customs.flag).not.toHaveBeenCalled(); }); }); }); it('should return false when check with incorrect password', () => { - db.checkPassword = sinon.spy((uid: any) => Promise.resolve(false)); + db.checkPassword = jest.fn((uid: any) => Promise.resolve(false)); const authPW = Buffer.from('aaaaaaaaaaaaaaaa'); const accountRecord = { uid: TEST_UID, @@ -128,11 +127,11 @@ describe('checkPassword', () => { .then((match: any) => { expect(!!match).toBe(false); - sinon.assert.calledOnce(db.checkPassword); - sinon.assert.calledWithExactly(db.checkPassword, TEST_UID, badHash); + expect(db.checkPassword).toHaveBeenCalledTimes(1); + expect(db.checkPassword).toHaveBeenCalledWith(TEST_UID, badHash); - sinon.assert.calledOnce(customs.flag); - sinon.assert.calledWithExactly(customs.flag, CLIENT_ADDRESS, { + expect(customs.flag).toHaveBeenCalledTimes(1); + expect(customs.flag).toHaveBeenCalledWith(CLIENT_ADDRESS, { email: TEST_EMAIL, errno: error.ERRNO.INCORRECT_PASSWORD, }); @@ -165,11 +164,11 @@ describe('checkPassword', () => { (err: any) => { expect(err.errno).toBe(error.ERRNO.ACCOUNT_RESET); - sinon.assert.calledOnce(customs.check); - sinon.assert.notCalled(db.checkPassword); + expect(customs.check).toHaveBeenCalledTimes(1); + expect(db.checkPassword).not.toHaveBeenCalled(); - sinon.assert.calledOnce(customs.flag); - sinon.assert.calledWithExactly(customs.flag, CLIENT_ADDRESS, { + expect(customs.flag).toHaveBeenCalledTimes(1); + expect(customs.flag).toHaveBeenCalledWith(CLIENT_ADDRESS, { email: TEST_EMAIL, errno: error.ERRNO.ACCOUNT_RESET, }); @@ -243,10 +242,10 @@ describe('checkCustomsAndLoadAccount', () => { }); log = mocks.mockLog(); customs = { - v2Enabled: sinon.spy(() => true), - check: sinon.spy(() => Promise.resolve()), - flag: sinon.spy(() => Promise.resolve({})), - resetV2: sinon.spy(() => Promise.resolve()), + v2Enabled: jest.fn(() => true), + check: jest.fn(() => Promise.resolve()), + flag: jest.fn(() => Promise.resolve({})), + resetV2: jest.fn(() => Promise.resolve()), }; config = { signinUnblock: { @@ -259,7 +258,7 @@ describe('checkCustomsAndLoadAccount', () => { clientAddress: CLIENT_ADDRESS, payload: {}, }); - request.emitMetricsEvent = sinon.spy(() => Promise.resolve()); + request.emitMetricsEvent = jest.fn(() => Promise.resolve()); checkCustomsAndLoadAccount = makeSigninUtils({ log, config, @@ -274,23 +273,23 @@ describe('checkCustomsAndLoadAccount', () => { expect(res.accountRecord).toBeTruthy(); expect(res.accountRecord.email).toBe(TEST_EMAIL); - sinon.assert.calledOnce(customs.check); - sinon.assert.calledWithExactly( - customs.check, + expect(customs.check).toHaveBeenCalledTimes(1); + expect(customs.check).toHaveBeenCalledWith( request, TEST_EMAIL, 'accountLogin' ); - sinon.assert.calledOnce(db.accountRecord); - sinon.assert.calledWithExactly(db.accountRecord, TEST_EMAIL); + expect(db.accountRecord).toHaveBeenCalledTimes(1); + expect(db.accountRecord).toHaveBeenCalledWith(TEST_EMAIL); - sinon.assert.callOrder(customs.check, db.accountRecord); + expect(customs.check).toHaveBeenCalled(); + expect(db.accountRecord).toHaveBeenCalled(); }); }); it('should throw non-customs errors directly back to the caller', () => { - customs.check = sinon.spy(() => { + customs.check = jest.fn(() => { throw new Error('unexpected!'); }); return checkCustomsAndLoadAccount(request, TEST_EMAIL).then( @@ -299,27 +298,26 @@ describe('checkCustomsAndLoadAccount', () => { }, (err: any) => { expect(err.message).toBe('unexpected!'); - sinon.assert.calledOnce(customs.check); - sinon.assert.notCalled(db.accountRecord); - sinon.assert.notCalled(request.emitMetricsEvent); + expect(customs.check).toHaveBeenCalledTimes(1); + expect(db.accountRecord).not.toHaveBeenCalled(); + expect(request.emitMetricsEvent).not.toHaveBeenCalled(); } ); }); it('should re-throw customs errors when no unblock code is specified', () => { const origErr = error.tooManyRequests(); - customs.check = sinon.spy(() => Promise.reject(origErr)); + customs.check = jest.fn(() => Promise.reject(origErr)); return checkCustomsAndLoadAccount(request, TEST_EMAIL).then( () => { throw new Error('should not succeed'); }, (err: any) => { expect(err).toEqual(origErr); - sinon.assert.calledOnce(customs.check); - sinon.assert.notCalled(db.accountRecord); - sinon.assert.calledOnce(request.emitMetricsEvent); - sinon.assert.calledWithExactly( - request.emitMetricsEvent, + expect(customs.check).toHaveBeenCalledTimes(1); + expect(db.accountRecord).not.toHaveBeenCalled(); + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(1); + expect(request.emitMetricsEvent).toHaveBeenCalledWith( 'account.login.blocked' ); } @@ -327,29 +325,27 @@ describe('checkCustomsAndLoadAccount', () => { }); it('login attempts on an unknown account should be flagged with customs', () => { - db.accountRecord = sinon.spy(() => Promise.reject(error.unknownAccount())); + db.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount())); return checkCustomsAndLoadAccount(request, TEST_EMAIL).then( () => { throw new Error('should not succeed'); }, (err: any) => { expect(err.errno).toBe(error.ERRNO.ACCOUNT_UNKNOWN); - sinon.assert.calledTwice(customs.check); - sinon.assert.calledWithMatch( - customs.check, - sinon.match.object, + expect(customs.check).toHaveBeenCalledTimes(2); + expect(customs.check).toHaveBeenCalledWith( + expect.any(Object), TEST_EMAIL, 'accountLogin' ); - sinon.assert.calledWithMatch( - customs.check, - sinon.match.object, + expect(customs.check).toHaveBeenCalledWith( + expect.any(Object), TEST_EMAIL, 'loadAccountFailed' ); - sinon.assert.calledOnce(db.accountRecord); - sinon.assert.calledOnce(customs.flag); - sinon.assert.calledWithExactly(customs.flag, CLIENT_ADDRESS, { + expect(db.accountRecord).toHaveBeenCalledTimes(1); + expect(customs.flag).toHaveBeenCalledTimes(1); + expect(customs.flag).toHaveBeenCalledWith(CLIENT_ADDRESS, { email: TEST_EMAIL, errno: error.ERRNO.ACCOUNT_UNKNOWN, }); @@ -358,29 +354,27 @@ describe('checkCustomsAndLoadAccount', () => { }); it('login attempts on an unknown account should be flagged with customs (duplicate)', () => { - db.accountRecord = sinon.spy(() => Promise.reject(error.unknownAccount())); + db.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount())); return checkCustomsAndLoadAccount(request, TEST_EMAIL).then( () => { throw new Error('should not succeed'); }, (err: any) => { expect(err.errno).toBe(error.ERRNO.ACCOUNT_UNKNOWN); - sinon.assert.calledTwice(customs.check); - sinon.assert.calledWithMatch( - customs.check, - sinon.match.object, + expect(customs.check).toHaveBeenCalledTimes(2); + expect(customs.check).toHaveBeenCalledWith( + expect.any(Object), TEST_EMAIL, 'accountLogin' ); - sinon.assert.calledWithMatch( - customs.check, - sinon.match.object, + expect(customs.check).toHaveBeenCalledWith( + expect.any(Object), TEST_EMAIL, 'loadAccountFailed' ); - sinon.assert.calledOnce(db.accountRecord); - sinon.assert.calledOnce(customs.flag); - sinon.assert.calledWithExactly(customs.flag, CLIENT_ADDRESS, { + expect(db.accountRecord).toHaveBeenCalledTimes(1); + expect(customs.flag).toHaveBeenCalledTimes(1); + expect(customs.flag).toHaveBeenCalledWith(CLIENT_ADDRESS, { email: TEST_EMAIL, errno: error.ERRNO.ACCOUNT_UNKNOWN, }); @@ -398,13 +392,12 @@ describe('checkCustomsAndLoadAccount', () => { expect(err.errno).toBe(error.ERRNO.REQUEST_BLOCKED); expect(err.output.payload.verificationMethod).toBe('email-captcha'); - sinon.assert.notCalled(customs.check); - sinon.assert.notCalled(db.accountRecord); - sinon.assert.notCalled(customs.flag); + expect(customs.check).not.toHaveBeenCalled(); + expect(db.accountRecord).not.toHaveBeenCalled(); + expect(customs.flag).not.toHaveBeenCalled(); - sinon.assert.calledOnce(request.emitMetricsEvent); - sinon.assert.calledWithExactly( - request.emitMetricsEvent, + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(1); + expect(request.emitMetricsEvent).toHaveBeenCalledWith( 'account.login.blocked' ); } @@ -412,11 +405,11 @@ describe('checkCustomsAndLoadAccount', () => { }); it('a valid unblock code can bypass a customs block', () => { - customs.check = sinon.spy(() => + customs.check = jest.fn(() => Promise.reject(error.tooManyRequests(60, null, true)) ); request.payload.unblockCode = 'VaLiD'; - db.consumeUnblockCode = sinon.spy(() => + db.consumeUnblockCode = jest.fn(() => Promise.resolve({ createdAt: Date.now() }) ); return checkCustomsAndLoadAccount(request, TEST_EMAIL).then((res: any) => { @@ -424,30 +417,30 @@ describe('checkCustomsAndLoadAccount', () => { expect(res.accountRecord).toBeTruthy(); expect(res.accountRecord.email).toBe(TEST_EMAIL); - sinon.assert.calledOnce(customs.check); - sinon.assert.calledOnce(db.accountRecord); + expect(customs.check).toHaveBeenCalledTimes(1); + expect(db.accountRecord).toHaveBeenCalledTimes(1); - sinon.assert.calledOnce(db.consumeUnblockCode); - sinon.assert.calledWithExactly(db.consumeUnblockCode, TEST_UID, 'VALID'); + expect(db.consumeUnblockCode).toHaveBeenCalledTimes(1); + expect(db.consumeUnblockCode).toHaveBeenCalledWith(TEST_UID, 'VALID'); - sinon.assert.calledTwice(request.emitMetricsEvent); - sinon.assert.calledWithExactly( - request.emitMetricsEvent.getCall(0), + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(2); + expect(request.emitMetricsEvent).toHaveBeenNthCalledWith( + 1, 'account.login.blocked' ); - sinon.assert.calledWithExactly( - request.emitMetricsEvent.getCall(1), + expect(request.emitMetricsEvent).toHaveBeenNthCalledWith( + 2, 'account.login.confirmedUnblockCode' ); }); }); it('unblock codes are not checked for non-unblockable customs errors', () => { - customs.check = sinon.spy(() => + customs.check = jest.fn(() => Promise.reject(error.tooManyRequests(60, null, false)) ); request.payload.unblockCode = 'VALID'; - db.consumeUnblockCode = sinon.spy(() => + db.consumeUnblockCode = jest.fn(() => Promise.resolve({ createdAt: Date.now() }) ); return checkCustomsAndLoadAccount(request, TEST_EMAIL).then( @@ -456,18 +449,18 @@ describe('checkCustomsAndLoadAccount', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.THROTTLED); - sinon.assert.calledOnce(customs.check); - sinon.assert.notCalled(db.accountRecord); - sinon.assert.notCalled(db.consumeUnblockCode); - sinon.assert.notCalled(customs.flag); + expect(customs.check).toHaveBeenCalledTimes(1); + expect(db.accountRecord).not.toHaveBeenCalled(); + expect(db.consumeUnblockCode).not.toHaveBeenCalled(); + expect(customs.flag).not.toHaveBeenCalled(); } ); }); it('unblock codes are not checked for non-customs errors', () => { - customs.check = sinon.spy(() => Promise.reject(error.serviceUnavailable())); + customs.check = jest.fn(() => Promise.reject(error.serviceUnavailable())); request.payload.unblockCode = 'VALID'; - db.consumeUnblockCode = sinon.spy(() => + db.consumeUnblockCode = jest.fn(() => Promise.resolve({ createdAt: Date.now() }) ); return checkCustomsAndLoadAccount(request, TEST_EMAIL).then( @@ -476,24 +469,24 @@ describe('checkCustomsAndLoadAccount', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.SERVER_BUSY); - sinon.assert.calledOnce(customs.check); - sinon.assert.notCalled(db.accountRecord); - sinon.assert.notCalled(db.consumeUnblockCode); - sinon.assert.notCalled(customs.flag); + expect(customs.check).toHaveBeenCalledTimes(1); + expect(db.accountRecord).not.toHaveBeenCalled(); + expect(db.consumeUnblockCode).not.toHaveBeenCalled(); + expect(customs.flag).not.toHaveBeenCalled(); } ); }); it('unblock codes are not checked when the account does not exist', () => { - customs.check = sinon.spy((_request: any, _email: any, action: any) => { + customs.check = jest.fn((_request: any, _email: any, action: any) => { if (action === 'accountLogin') { return Promise.reject(error.tooManyRequests(60, null, true)); } return Promise.resolve(false); }); request.payload.unblockCode = 'VALID'; - db.accountRecord = sinon.spy(() => Promise.reject(error.unknownAccount())); - db.consumeUnblockCode = sinon.spy(() => + db.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount())); + db.consumeUnblockCode = jest.fn(() => Promise.resolve({ createdAt: Date.now() }) ); return checkCustomsAndLoadAccount(request, TEST_EMAIL).then( @@ -502,23 +495,21 @@ describe('checkCustomsAndLoadAccount', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.THROTTLED); - sinon.assert.calledTwice(customs.check); - sinon.assert.calledWithMatch( - customs.check, - sinon.match.object, + expect(customs.check).toHaveBeenCalledTimes(2); + expect(customs.check).toHaveBeenCalledWith( + expect.any(Object), TEST_EMAIL, 'accountLogin' ); - sinon.assert.calledWithMatch( - customs.check, - sinon.match.object, + expect(customs.check).toHaveBeenCalledWith( + expect.any(Object), TEST_EMAIL, 'loadAccountFailed' ); - sinon.assert.calledOnce(db.accountRecord); - sinon.assert.notCalled(db.consumeUnblockCode); - sinon.assert.calledOnce(customs.flag); - sinon.assert.calledWithExactly(customs.flag, CLIENT_ADDRESS, { + expect(db.accountRecord).toHaveBeenCalledTimes(1); + expect(db.consumeUnblockCode).not.toHaveBeenCalled(); + expect(customs.flag).toHaveBeenCalledTimes(1); + expect(customs.flag).toHaveBeenCalledWith(CLIENT_ADDRESS, { email: TEST_EMAIL, errno: error.ERRNO.ACCOUNT_UNKNOWN, }); @@ -527,14 +518,14 @@ describe('checkCustomsAndLoadAccount', () => { }); it('invalid unblock codes are rejected and reported to customs', () => { - customs.check = sinon.spy((request: any, email: any, action: any) => { + customs.check = jest.fn((request: any, email: any, action: any) => { if (action === 'accountLogin') { return Promise.reject(error.requestBlocked(true)); } return Promise.resolve(false); }); request.payload.unblockCode = 'INVALID'; - db.consumeUnblockCode = sinon.spy(() => + db.consumeUnblockCode = jest.fn(() => Promise.reject(error.invalidUnblockCode()) ); return checkCustomsAndLoadAccount(request, TEST_EMAIL).then( @@ -543,33 +534,31 @@ describe('checkCustomsAndLoadAccount', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.INVALID_UNBLOCK_CODE); - sinon.assert.calledTwice(customs.check); - sinon.assert.calledWithMatch( - customs.check, - sinon.match.object, + expect(customs.check).toHaveBeenCalledTimes(2); + expect(customs.check).toHaveBeenCalledWith( + expect.any(Object), TEST_EMAIL, 'accountLogin' ); - sinon.assert.calledWithMatch( - customs.check, - sinon.match.object, + expect(customs.check).toHaveBeenCalledWith( + expect.any(Object), TEST_EMAIL, 'unblockCodeFailed' ); - sinon.assert.calledOnce(db.consumeUnblockCode); + expect(db.consumeUnblockCode).toHaveBeenCalledTimes(1); - sinon.assert.calledTwice(request.emitMetricsEvent); - sinon.assert.calledWithExactly( - request.emitMetricsEvent.getCall(0), + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(2); + expect(request.emitMetricsEvent).toHaveBeenNthCalledWith( + 1, 'account.login.blocked' ); - sinon.assert.calledWithExactly( - request.emitMetricsEvent.getCall(1), + expect(request.emitMetricsEvent).toHaveBeenNthCalledWith( + 2, 'account.login.invalidUnblockCode' ); - sinon.assert.calledOnce(customs.flag); - sinon.assert.calledWithExactly(customs.flag, CLIENT_ADDRESS, { + expect(customs.flag).toHaveBeenCalledTimes(1); + expect(customs.flag).toHaveBeenCalledWith(CLIENT_ADDRESS, { email: TEST_EMAIL, errno: error.ERRNO.INVALID_UNBLOCK_CODE, }); @@ -578,14 +567,14 @@ describe('checkCustomsAndLoadAccount', () => { }); it('expired unblock codes are rejected as invalid', () => { - customs.check = sinon.spy((_request: any, _email: any, action: any) => { + customs.check = jest.fn((_request: any, _email: any, action: any) => { if (action === 'accountLogin') { return Promise.reject(error.requestBlocked(true)); } return Promise.resolve(false); }); request.payload.unblockCode = 'EXPIRED'; - db.consumeUnblockCode = sinon.spy(() => + db.consumeUnblockCode = jest.fn(() => Promise.resolve({ createdAt: Date.now() - config.signinUnblock.codeLifetime * 2, }) @@ -596,34 +585,34 @@ describe('checkCustomsAndLoadAccount', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.INVALID_UNBLOCK_CODE); - sinon.assert.calledTwice(customs.check); - sinon.assert.calledWithMatch( - customs.check.getCall(0), - sinon.match.object, + expect(customs.check).toHaveBeenCalledTimes(2); + expect(customs.check).toHaveBeenNthCalledWith( + 1, + expect.any(Object), TEST_EMAIL, 'accountLogin' ); - sinon.assert.calledWithMatch( - customs.check.getCall(1), - sinon.match.object, + expect(customs.check).toHaveBeenNthCalledWith( + 2, + expect.any(Object), TEST_EMAIL, 'unblockCodeFailed' ); - sinon.assert.calledOnce(db.accountRecord); - sinon.assert.calledOnce(db.consumeUnblockCode); + expect(db.accountRecord).toHaveBeenCalledTimes(1); + expect(db.consumeUnblockCode).toHaveBeenCalledTimes(1); - sinon.assert.calledTwice(request.emitMetricsEvent); - sinon.assert.calledWithExactly( - request.emitMetricsEvent.getCall(0), + expect(request.emitMetricsEvent).toHaveBeenCalledTimes(2); + expect(request.emitMetricsEvent).toHaveBeenNthCalledWith( + 1, 'account.login.blocked' ); - sinon.assert.calledWithExactly( - request.emitMetricsEvent.getCall(1), + expect(request.emitMetricsEvent).toHaveBeenNthCalledWith( + 2, 'account.login.invalidUnblockCode' ); - sinon.assert.calledOnce(customs.flag); - sinon.assert.calledWithExactly(customs.flag, CLIENT_ADDRESS, { + expect(customs.flag).toHaveBeenCalledTimes(1); + expect(customs.flag).toHaveBeenCalledWith(CLIENT_ADDRESS, { email: TEST_EMAIL, errno: error.ERRNO.INVALID_UNBLOCK_CODE, }); @@ -632,9 +621,9 @@ describe('checkCustomsAndLoadAccount', () => { }); it('unexpected errors when checking an unblock code, cause the original customs error to be rethrown', () => { - customs.check = sinon.spy(() => Promise.reject(error.requestBlocked(true))); + customs.check = jest.fn(() => Promise.reject(error.requestBlocked(true))); request.payload.unblockCode = 'WHOOPSY'; - db.consumeUnblockCode = sinon.spy(() => + db.consumeUnblockCode = jest.fn(() => Promise.reject(error.serviceUnavailable()) ); return checkCustomsAndLoadAccount(request, TEST_EMAIL).then( @@ -643,10 +632,10 @@ describe('checkCustomsAndLoadAccount', () => { }, (err: any) => { expect(err.errno).toBe(error.ERRNO.REQUEST_BLOCKED); - sinon.assert.calledOnce(customs.check); - sinon.assert.calledOnce(db.accountRecord); - sinon.assert.calledOnce(db.consumeUnblockCode); - sinon.assert.notCalled(customs.flag); + expect(customs.check).toHaveBeenCalledTimes(1); + expect(db.accountRecord).toHaveBeenCalledTimes(1); + expect(db.consumeUnblockCode).toHaveBeenCalledTimes(1); + expect(customs.flag).not.toHaveBeenCalled(); } ); }); @@ -703,7 +692,7 @@ describe('sendSigninNotifications', () => { beforeEach(() => { // Freeze time at a specific timestamp for consistent test assertions - clock = sinon.useFakeTimers(1769555935958); + clock = jest.useFakeTimers({ now: 1769555935958 }); db = mocks.mockDB(); log = mocks.mockLog(); @@ -743,7 +732,7 @@ describe('sendSigninNotifications', () => { afterEach(() => { if (clock) { - clock.restore(); + jest.useRealTimers(); } }); @@ -758,21 +747,20 @@ describe('sendSigninNotifications', () => { sessionToken, undefined ).then(() => { - sinon.assert.calledOnce(metricsContext.setFlowCompleteSignal); - sinon.assert.calledWithExactly( - metricsContext.setFlowCompleteSignal, + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes(1); + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledWith( 'account.login', 'login' ); - sinon.assert.calledOnce(metricsContext.stash); - sinon.assert.calledWithExactly(metricsContext.stash, sessionToken); + expect(metricsContext.stash).toHaveBeenCalledTimes(1); + expect(metricsContext.stash).toHaveBeenCalledWith(sessionToken); - sinon.assert.calledOnce(db.sessions); - sinon.assert.calledWithExactly(db.sessions, TEST_UID); + expect(db.sessions).toHaveBeenCalledTimes(1); + expect(db.sessions).toHaveBeenCalledWith(TEST_UID); - sinon.assert.calledOnce(log.activityEvent); - sinon.assert.calledWithExactly(log.activityEvent, { + expect(log.activityEvent).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledWith({ country: 'United States', event: 'account.login', region: 'California', @@ -783,17 +771,18 @@ describe('sendSigninNotifications', () => { uid: TEST_UID, }); - sinon.assert.calledTwice(log.flowEvent); - sinon.assert.calledWithMatch(log.flowEvent.getCall(0), { - event: 'account.login', - }); - sinon.assert.calledWithMatch(log.flowEvent.getCall(1), { - event: 'flow.complete', - }); + expect(log.flowEvent).toHaveBeenCalledTimes(2); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.login' }) + ); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: 'flow.complete' }) + ); - sinon.assert.calledOnce(log.notifyAttachedServices); - sinon.assert.calledWithExactly( - log.notifyAttachedServices, + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( 'login', request, { @@ -807,12 +796,12 @@ describe('sendSigninNotifications', () => { } ); - sinon.assert.notCalled(fxaMailer.sendVerifyEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginCodeEmail); + expect(fxaMailer.sendVerifyEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginCodeEmail).not.toHaveBeenCalled(); - sinon.assert.calledOnce(db.securityEvent); - sinon.assert.calledWithExactly(db.securityEvent, { + expect(db.securityEvent).toHaveBeenCalledTimes(1); + expect(db.securityEvent).toHaveBeenCalledWith({ name: 'account.login', uid: TEST_UID, ipAddr: CLIENT_ADDRESS, @@ -844,17 +833,16 @@ describe('sendSigninNotifications', () => { sessionToken, undefined ).then(() => { - sinon.assert.calledOnce(metricsContext.setFlowCompleteSignal); - sinon.assert.calledWithExactly( - metricsContext.setFlowCompleteSignal, + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes(1); + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledWith( 'account.login', 'login' ); - sinon.assert.calledOnce(metricsContext.stash); + expect(metricsContext.stash).toHaveBeenCalledTimes(1); - sinon.assert.calledOnce(fxaMailer.sendVerifyEmail); - sinon.assert.calledWithExactly(fxaMailer.sendVerifyEmail, { + expect(fxaMailer.sendVerifyEmail).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendVerifyEmail).toHaveBeenCalledWith({ to: 'test@example.com', cc: [], metricsEnabled: true, @@ -885,16 +873,19 @@ describe('sendSigninNotifications', () => { redirectTo: 'redirectMeTo', }); - sinon.assert.calledThrice(log.flowEvent); - sinon.assert.calledWithMatch(log.flowEvent.getCall(0), { - event: 'account.login', - }); - sinon.assert.calledWithMatch(log.flowEvent.getCall(1), { - event: 'flow.complete', - }); - sinon.assert.calledWithMatch(log.flowEvent.getCall(2), { - event: 'email.verification.sent', - }); + expect(log.flowEvent).toHaveBeenCalledTimes(3); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.login' }) + ); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: 'flow.complete' }) + ); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ event: 'email.verification.sent' }) + ); }); }); @@ -908,25 +899,21 @@ describe('sendSigninNotifications', () => { sessionToken, undefined ).then(() => { - sinon.assert.calledOnce(metricsContext.setFlowCompleteSignal); - sinon.assert.calledWithExactly( - metricsContext.setFlowCompleteSignal, + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes(1); + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledWith( 'account.confirmed', 'login' ); - sinon.assert.calledTwice(metricsContext.stash); - sinon.assert.calledWithExactly( - metricsContext.stash.getCall(0), - sessionToken - ); - sinon.assert.calledWithExactly(metricsContext.stash.getCall(1), { + expect(metricsContext.stash).toHaveBeenCalledTimes(2); + expect(metricsContext.stash).toHaveBeenNthCalledWith(1, sessionToken); + expect(metricsContext.stash).toHaveBeenNthCalledWith(2, { uid: TEST_UID, id: 'tokenVerifyCode', }); - sinon.assert.calledOnce(fxaMailer.sendVerifyEmail); - sinon.assert.calledWithExactly(fxaMailer.sendVerifyEmail, { + expect(fxaMailer.sendVerifyEmail).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendVerifyEmail).toHaveBeenCalledWith({ to: 'test@example.com', cc: [], metricsEnabled: true, @@ -957,17 +944,18 @@ describe('sendSigninNotifications', () => { redirectTo: 'redirectMeTo', }); - sinon.assert.calledTwice(log.flowEvent); - sinon.assert.calledWithMatch(log.flowEvent.getCall(0), { - event: 'account.login', - }); - sinon.assert.calledWithMatch(log.flowEvent.getCall(1), { - event: 'email.verification.sent', - }); + expect(log.flowEvent).toHaveBeenCalledTimes(2); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.login' }) + ); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: 'email.verification.sent' }) + ); - sinon.assert.calledOnce(log.notifyAttachedServices); - sinon.assert.calledWithExactly( - log.notifyAttachedServices, + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( 'login', request, { @@ -984,13 +972,13 @@ describe('sendSigninNotifications', () => { }); afterEach(() => { - sinon.assert.calledOnce(db.sessions); - sinon.assert.calledOnce(log.activityEvent); + expect(db.sessions).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledTimes(1); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginCodeEmail); + expect(fxaMailer.sendVerifyLoginEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginCodeEmail).not.toHaveBeenCalled(); - sinon.assert.calledOnce(db.securityEvent); + expect(db.securityEvent).toHaveBeenCalledTimes(1); }); }); @@ -1004,19 +992,17 @@ describe('sendSigninNotifications', () => { sessionToken, undefined ).then(() => { - sinon.assert.calledOnce(metricsContext.setFlowCompleteSignal); - sinon.assert.calledWithExactly( - metricsContext.setFlowCompleteSignal, + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes(1); + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledWith( 'account.login', 'login' ); - sinon.assert.calledOnce(metricsContext.stash); - sinon.assert.calledOnce(db.sessions); - sinon.assert.calledOnce(log.activityEvent); - sinon.assert.calledOnce(log.notifyAttachedServices); - sinon.assert.calledWithExactly( - log.notifyAttachedServices, + expect(metricsContext.stash).toHaveBeenCalledTimes(1); + expect(db.sessions).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( 'login', request, { @@ -1030,20 +1016,22 @@ describe('sendSigninNotifications', () => { } ); - sinon.assert.notCalled(fxaMailer.sendVerifyEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginCodeEmail); - sinon.assert.notCalled(fxaMailer.sendNewDeviceLoginEmail); + expect(fxaMailer.sendVerifyEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginCodeEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendNewDeviceLoginEmail).not.toHaveBeenCalled(); - sinon.assert.calledTwice(log.flowEvent); - sinon.assert.calledWithMatch(log.flowEvent.getCall(0), { - event: 'account.login', - }); - sinon.assert.calledWithMatch(log.flowEvent.getCall(1), { - event: 'flow.complete', - }); + expect(log.flowEvent).toHaveBeenCalledTimes(2); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.login' }) + ); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: 'flow.complete' }) + ); - sinon.assert.calledOnce(db.securityEvent); + expect(db.securityEvent).toHaveBeenCalledTimes(1); }); }); }); @@ -1062,10 +1050,10 @@ describe('sendSigninNotifications', () => { sessionToken, undefined ).then(() => { - sinon.assert.notCalled(fxaMailer.sendVerifyEmail); - sinon.assert.notCalled(mailer.sendVerifyLoginCodeEmail); - sinon.assert.calledOnce(fxaMailer.sendVerifyLoginEmail); - sinon.assert.calledWithExactly(fxaMailer.sendVerifyLoginEmail, { + expect(fxaMailer.sendVerifyEmail).not.toHaveBeenCalled(); + expect(mailer.sendVerifyLoginCodeEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginEmail).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendVerifyLoginEmail).toHaveBeenCalledWith({ to: TEST_EMAIL, cc: [], metricsEnabled: true, @@ -1096,13 +1084,15 @@ describe('sendSigninNotifications', () => { resume: request.payload.resume, }); - sinon.assert.calledTwice(log.flowEvent); - sinon.assert.calledWithMatch(log.flowEvent.getCall(0), { - event: 'account.login', - }); - sinon.assert.calledWithMatch(log.flowEvent.getCall(1), { - event: 'email.confirmation.sent', - }); + expect(log.flowEvent).toHaveBeenCalledTimes(2); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.login' }) + ); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: 'email.confirmation.sent' }) + ); }); }); @@ -1113,23 +1103,25 @@ describe('sendSigninNotifications', () => { sessionToken, 'email' ).then(() => { - sinon.assert.notCalled(fxaMailer.sendVerifyEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginCodeEmail); - sinon.assert.calledOnce(fxaMailer.sendVerifyLoginEmail); - - sinon.assert.calledTwice(log.flowEvent); - sinon.assert.calledWithMatch(log.flowEvent.getCall(0), { - event: 'account.login', - }); - sinon.assert.calledWithMatch(log.flowEvent.getCall(1), { - event: 'email.confirmation.sent', - }); + expect(fxaMailer.sendVerifyEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginCodeEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginEmail).toHaveBeenCalledTimes(1); + + expect(log.flowEvent).toHaveBeenCalledTimes(2); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.login' }) + ); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: 'email.confirmation.sent' }) + ); }); }); it('emits correct notifications when verificationMethod=email-2fa', () => { const oauthClientInfoMock = mocks.mockOAuthClientInfo({ - fetch: sinon.stub().resolves({ name: undefined }), + fetch: jest.fn().mockResolvedValue({ name: undefined }), }); const localSendSigninNotifications = makeSigninUtils({ log, @@ -1143,34 +1135,38 @@ describe('sendSigninNotifications', () => { sessionToken, 'email-2fa' ).then(() => { - sinon.assert.notCalled(fxaMailer.sendVerifyEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginEmail); - sinon.assert.calledOnce(fxaMailer.sendVerifyLoginCodeEmail); + expect(fxaMailer.sendVerifyEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenCalledTimes(1); const expectedCode = otpUtils.generateOtpCode( accountRecord.primaryEmail.emailCode, otpOptions ); - sinon.assert.calledWithMatch(fxaMailer.sendVerifyLoginCodeEmail, { - to: TEST_EMAIL, - cc: [], - metricsEnabled: true, - uid: TEST_UID, - code: expectedCode, - redirectTo: request.payload.redirectTo, - resume: request.payload.resume, - serviceName: undefined, - }); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: TEST_EMAIL, + cc: [], + metricsEnabled: true, + uid: TEST_UID, + code: expectedCode, + redirectTo: request.payload.redirectTo, + resume: request.payload.resume, + serviceName: undefined, + }) + ); - sinon.assert.calledOnce(oauthClientInfoMock.fetch); + expect(oauthClientInfoMock.fetch).toHaveBeenCalledTimes(1); - sinon.assert.calledTwice(log.flowEvent); - sinon.assert.calledWithMatch(log.flowEvent.getCall(0), { - event: 'account.login', - }); - sinon.assert.calledWithMatch(log.flowEvent.getCall(1), { - event: 'email.tokencode.sent', - }); + expect(log.flowEvent).toHaveBeenCalledTimes(2); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.login' }) + ); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: 'email.tokencode.sent' }) + ); }); }); @@ -1181,38 +1177,35 @@ describe('sendSigninNotifications', () => { sessionToken, 'email-captcha' ).then(() => { - sinon.assert.notCalled(fxaMailer.sendVerifyEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginCodeEmail); + expect(fxaMailer.sendVerifyEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginCodeEmail).not.toHaveBeenCalled(); - sinon.assert.calledOnce(log.flowEvent); - sinon.assert.calledWithMatch(log.flowEvent, { event: 'account.login' }); + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.flowEvent).toHaveBeenCalledWith( + expect.objectContaining({ event: 'account.login' }) + ); }); }); afterEach(() => { - sinon.assert.calledOnce(metricsContext.setFlowCompleteSignal); - sinon.assert.calledWithExactly( - metricsContext.setFlowCompleteSignal, + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes(1); + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledWith( 'account.confirmed', 'login' ); - sinon.assert.calledTwice(metricsContext.stash); - sinon.assert.calledWithExactly( - metricsContext.stash.getCall(0), - sessionToken - ); - sinon.assert.calledWithExactly(metricsContext.stash.getCall(1), { + expect(metricsContext.stash).toHaveBeenCalledTimes(2); + expect(metricsContext.stash).toHaveBeenNthCalledWith(1, sessionToken); + expect(metricsContext.stash).toHaveBeenNthCalledWith(2, { uid: TEST_UID, id: 'tokenVerifyCode', }); - sinon.assert.calledOnce(db.sessions); - sinon.assert.calledOnce(log.activityEvent); - sinon.assert.calledOnce(log.notifyAttachedServices); - sinon.assert.calledWithExactly( - log.notifyAttachedServices, + expect(db.sessions).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( 'login', request, { @@ -1225,7 +1218,7 @@ describe('sendSigninNotifications', () => { countryCode: 'US', } ); - sinon.assert.calledOnce(db.securityEvent); + expect(db.securityEvent).toHaveBeenCalledTimes(1); }); }); @@ -1245,9 +1238,11 @@ describe('sendSigninNotifications', () => { sessionToken, 'email-otp' ).then(() => { - sinon.assert.calledOnce(fxaMailer.sendVerifyLoginCodeEmail); - const callArgs = fxaMailer.sendVerifyLoginCodeEmail.getCall(0).args[0]; - expect(callArgs.serviceName).toBe('sync'); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ serviceName: 'sync' }) + ); }); }); @@ -1268,9 +1263,9 @@ describe('sendSigninNotifications', () => { sessionToken, 'email-otp' ).then(() => { - sinon.assert.calledOnce(fxaMailer.sendVerifyLoginCodeEmail); - sinon.assert.calledOnce(oauthClientInfoMock.fetch); - sinon.assert.calledWith(oauthClientInfoMock.fetch, undefined); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenCalledTimes(1); + expect(oauthClientInfoMock.fetch).toHaveBeenCalledTimes(1); + expect(oauthClientInfoMock.fetch).toHaveBeenCalledWith(undefined); }); }); @@ -1283,11 +1278,13 @@ describe('sendSigninNotifications', () => { sessionToken, 'email-2fa' ).then(() => { - sinon.assert.calledOnce(fxaMailer.sendVerifyLoginCodeEmail); - const callArgs = fxaMailer.sendVerifyLoginCodeEmail.getCall(0).args[0]; - expect(callArgs.serviceName).toBe('sync'); - sinon.assert.notCalled(fxaMailer.sendVerifyEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginEmail); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ serviceName: 'sync' }) + ); + expect(fxaMailer.sendVerifyEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginEmail).not.toHaveBeenCalled(); }); }); @@ -1299,9 +1296,9 @@ describe('sendSigninNotifications', () => { sessionToken, 'email-otp' ).then(() => { - sinon.assert.calledOnce(fxaMailer.sendVerifyLoginCodeEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginEmail); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendVerifyEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginEmail).not.toHaveBeenCalled(); }); }); @@ -1313,9 +1310,9 @@ describe('sendSigninNotifications', () => { sessionToken, 'email-otp' ).then(() => { - sinon.assert.notCalled(fxaMailer.sendVerifyLoginCodeEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginEmail); + expect(fxaMailer.sendVerifyLoginCodeEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginEmail).not.toHaveBeenCalled(); }); }); @@ -1328,9 +1325,9 @@ describe('sendSigninNotifications', () => { 'email-otp', true // passwordChangeRequired ).then(() => { - sinon.assert.calledOnce(fxaMailer.sendVerifyLoginCodeEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginEmail); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendVerifyEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginEmail).not.toHaveBeenCalled(); }); }); }); @@ -1341,7 +1338,7 @@ describe('sendSigninNotifications', () => { sessionToken.tokenVerificationId = 'tokenVerifyCode'; sessionToken.mustVerify = true; mocks.mockOAuthClientInfo({ - fetch: sinon.stub().resolves({ name: 'mockOauthClientName' }), + fetch: jest.fn().mockResolvedValue({ name: 'mockOauthClientName' }), }); const rpCmsConfig = { clientId: '00f00f', @@ -1356,7 +1353,7 @@ describe('sendSigninNotifications', () => { }, }; Container.set(RelyingPartyConfigurationManager, { - fetchCMSData: sinon.stub().resolves({ + fetchCMSData: jest.fn().mockResolvedValue({ relyingParties: [rpCmsConfig], }), }); @@ -1376,36 +1373,38 @@ describe('sendSigninNotifications', () => { return signinUtils .sendSigninNotifications(req, accountRecord, sessionToken, 'email-2fa') .then(() => { - sinon.assert.notCalled(fxaMailer.sendVerifyEmail); - sinon.assert.notCalled(fxaMailer.sendVerifyLoginEmail); - sinon.assert.calledOnce(fxaMailer.sendVerifyLoginCodeEmail); + expect(fxaMailer.sendVerifyEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginEmail).not.toHaveBeenCalled(); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenCalledTimes(1); const expectedCode = otpUtils.generateOtpCode( accountRecord.primaryEmail.emailCode, otpOptions ); - sinon.assert.calledWithMatch(fxaMailer.sendVerifyLoginCodeEmail, { - to: TEST_EMAIL, - cc: [], - metricsEnabled: true, - uid: TEST_UID, - code: expectedCode, - deviceId: req.payload.metricsContext.deviceId, - flowId: req.payload.metricsContext.flowId, - flowBeginTime: req.payload.metricsContext.flowBeginTime, - entrypoint: 'testo', - redirectTo: req.payload.redirectTo, - resume: req.payload.resume, - serviceName: 'mockOauthClientName', - cmsRpClientId: rpCmsConfig.clientId, - cmsRpFromName: rpCmsConfig.shared?.emailFromName, - logoUrl: rpCmsConfig?.shared?.emailLogoUrl, - logoAltText: (rpCmsConfig?.shared as any)?.emailLogoAltText, - logoWidth: (rpCmsConfig?.shared as any)?.emailLogoWidth, - subject: rpCmsConfig.VerifyLoginCodeEmail.subject, - headline: rpCmsConfig.VerifyLoginCodeEmail.headline, - description: rpCmsConfig.VerifyLoginCodeEmail.description, - }); + expect(fxaMailer.sendVerifyLoginCodeEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: TEST_EMAIL, + cc: [], + metricsEnabled: true, + uid: TEST_UID, + code: expectedCode, + deviceId: req.payload.metricsContext.deviceId, + flowId: req.payload.metricsContext.flowId, + flowBeginTime: req.payload.metricsContext.flowBeginTime, + entrypoint: 'testo', + redirectTo: req.payload.redirectTo, + resume: req.payload.resume, + serviceName: 'mockOauthClientName', + cmsRpClientId: rpCmsConfig.clientId, + cmsRpFromName: rpCmsConfig.shared?.emailFromName, + logoUrl: rpCmsConfig?.shared?.emailLogoUrl, + logoAltText: (rpCmsConfig?.shared as any)?.emailLogoAltText, + logoWidth: (rpCmsConfig?.shared as any)?.emailLogoWidth, + subject: rpCmsConfig.VerifyLoginCodeEmail.subject, + headline: rpCmsConfig.VerifyLoginCodeEmail.headline, + description: rpCmsConfig.VerifyLoginCodeEmail.description, + }) + ); }); }); }); @@ -1422,7 +1421,7 @@ describe('sendSigninNotifications', () => { sessionToken, 'email-2fa' ); - sinon.assert.notCalled(log.notifyAttachedServices); + expect(log.notifyAttachedServices).not.toHaveBeenCalled(); }); }); @@ -1432,16 +1431,15 @@ describe('sendSigninNotifications', () => { }); it('emits correct notifications with one active session', () => { - db.sessions = sinon.spy(() => Promise.resolve([sessionToken])); + db.sessions = jest.fn(() => Promise.resolve([sessionToken])); return sendSigninNotifications( request, accountRecord, sessionToken, undefined ).then(() => { - sinon.assert.calledOnce(log.notifyAttachedServices); - sinon.assert.calledWithExactly( - log.notifyAttachedServices, + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( 'login', request, { @@ -1458,18 +1456,15 @@ describe('sendSigninNotifications', () => { }); it('emits correct notifications with many active sessions', () => { - db.sessions = sinon.spy(() => - Promise.resolve([{}, {}, {}, sessionToken]) - ); + db.sessions = jest.fn(() => Promise.resolve([{}, {}, {}, sessionToken])); return sendSigninNotifications( request, accountRecord, sessionToken, undefined ).then(() => { - sinon.assert.calledOnce(log.notifyAttachedServices); - sinon.assert.calledWithExactly( - log.notifyAttachedServices, + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenCalledWith( 'login', request, { @@ -1486,21 +1481,22 @@ describe('sendSigninNotifications', () => { }); afterEach(() => { - sinon.assert.calledOnce(metricsContext.setFlowCompleteSignal); - sinon.assert.calledWithExactly( - metricsContext.setFlowCompleteSignal, + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledTimes(1); + expect(metricsContext.setFlowCompleteSignal).toHaveBeenCalledWith( 'account.signed', 'login' ); - sinon.assert.calledOnce(metricsContext.stash); - sinon.assert.calledOnce(db.sessions); - sinon.assert.calledOnce(log.activityEvent); + expect(metricsContext.stash).toHaveBeenCalledTimes(1); + expect(db.sessions).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenCalledTimes(1); - sinon.assert.calledOnce(log.flowEvent); - sinon.assert.calledWithMatch(log.flowEvent, { event: 'account.login' }); + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.flowEvent).toHaveBeenCalledWith( + expect.objectContaining({ event: 'account.login' }) + ); - sinon.assert.calledOnce(db.securityEvent); + expect(db.securityEvent).toHaveBeenCalledTimes(1); }); }); }); @@ -1518,9 +1514,9 @@ describe('createKeyFetchToken', () => { mocks.mockOAuthClientInfo(); db = mocks.mockDB(); password = { - unwrap: sinon.spy(() => Promise.resolve(Buffer.from('abcdef123456'))), + unwrap: jest.fn(() => Promise.resolve(Buffer.from('abcdef123456'))), }; - db.createKeyFetchToken = sinon.spy(() => + db.createKeyFetchToken = jest.fn(() => Promise.resolve({ id: 'KEY_FETCH_TOKEN' }) ); metricsContext = mocks.mockMetricsContext(); @@ -1551,11 +1547,11 @@ describe('createKeyFetchToken', () => { ).then((res: any) => { expect(res).toEqual({ id: 'KEY_FETCH_TOKEN' }); - sinon.assert.calledOnce(password.unwrap); - sinon.assert.calledWithExactly(password.unwrap, accountRecord.wrapWrapKb); + expect(password.unwrap).toHaveBeenCalledTimes(1); + expect(password.unwrap).toHaveBeenCalledWith(accountRecord.wrapWrapKb); - sinon.assert.calledOnce(db.createKeyFetchToken); - sinon.assert.calledWithExactly(db.createKeyFetchToken, { + expect(db.createKeyFetchToken).toHaveBeenCalledTimes(1); + expect(db.createKeyFetchToken).toHaveBeenCalledWith({ uid: TEST_UID, kA: accountRecord.kA, wrapKb: Buffer.from('abcdef123456'), @@ -1572,9 +1568,9 @@ describe('createKeyFetchToken', () => { password, sessionToken ).then(() => { - sinon.assert.calledOnce(metricsContext.stash); - sinon.assert.calledOn(metricsContext.stash, request); - sinon.assert.calledWithExactly(metricsContext.stash, { + expect(metricsContext.stash).toHaveBeenCalledTimes(1); + expect(metricsContext.stash).toHaveBeenCalled(); + expect(metricsContext.stash).toHaveBeenCalledWith({ id: 'KEY_FETCH_TOKEN', }); }); @@ -1684,12 +1680,12 @@ describe('cleanupReminders', () => { it('correctly calls cadReminders delete for verified session', async () => { await cleanupReminders({ sessionVerified: true }, { uid: '123' }); - sinon.assert.calledOnce(mockCadReminders.delete); - sinon.assert.calledWithExactly(mockCadReminders.delete, '123'); + expect(mockCadReminders.delete).toHaveBeenCalledTimes(1); + expect(mockCadReminders.delete).toHaveBeenCalledWith('123'); }); it('does not call cadReminders delete for unverified session', async () => { await cleanupReminders({ sessionVerified: false }, { uid: '123' }); - sinon.assert.notCalled(mockCadReminders.delete); + expect(mockCadReminders.delete).not.toHaveBeenCalled(); }); }); diff --git a/packages/fxa-auth-server/lib/routes/utils/signup.spec.ts b/packages/fxa-auth-server/lib/routes/utils/signup.spec.ts index e64f247db80..166e3dbd951 100644 --- a/packages/fxa-auth-server/lib/routes/utils/signup.spec.ts +++ b/packages/fxa-auth-server/lib/routes/utils/signup.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const mocks = require('../../../test/mocks'); const { gleanMetrics } = require('../../metrics/glean'); @@ -77,7 +75,6 @@ describe('verifyAccount', () => { }); describe('can verify account', () => { - let args: any; const options = { service: 'sync', }; @@ -88,45 +85,53 @@ describe('verifyAccount', () => { }); it('should verify the account', () => { - sinon.assert.calledOnce(db.verifyEmail); - sinon.assert.calledWithExactly( - db.verifyEmail, + expect(db.verifyEmail).toHaveBeenCalledTimes(1); + expect(db.verifyEmail).toHaveBeenCalledWith( account, account.primaryEmail.emailCode ); }); it('should notify attached services', () => { - sinon.assert.calledOnce(log.notifyAttachedServices); - - args = log.notifyAttachedServices.args[0]; - expect(args[0]).toBe('verified'); - expect(args[2].uid).toBe(TEST_UID); - expect(args[2].service).toBe('sync'); - expect(args[2].country).toBe('United States'); - expect(args[2].countryCode).toBe('US'); - expect(args[2].userAgent).toBe('test user-agent'); + expect(log.notifyAttachedServices).toHaveBeenCalledTimes(1); + expect(log.notifyAttachedServices).toHaveBeenNthCalledWith( + 1, + 'verified', + expect.anything(), + expect.objectContaining({ + uid: TEST_UID, + service: 'sync', + country: 'United States', + countryCode: 'US', + userAgent: 'test user-agent', + }) + ); }); it('should emit metrics', () => { - sinon.assert.calledOnce(log.activityEvent); - args = log.activityEvent.args[0]; - expect(args.length).toBe(1); - sinon.assert.calledOnce(log.flowEvent); - expect(log.flowEvent.args[0][0].event).toBe('account.verified'); - expect(args[0].planId).toBe('planId'); - expect(args[0].productId).toBe('productId'); + expect(log.activityEvent).toHaveBeenCalledTimes(1); + expect(log.activityEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + planId: 'planId', + productId: 'productId', + }) + ); + expect(log.flowEvent).toHaveBeenCalledTimes(1); + expect(log.flowEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: 'account.verified' }) + ); }); it('should delete verification reminders', () => { - sinon.assert.calledOnce(verificationReminders.delete); - sinon.assert.calledWithExactly(verificationReminders.delete, TEST_UID); + expect(verificationReminders.delete).toHaveBeenCalledTimes(1); + expect(verificationReminders.delete).toHaveBeenCalledWith(TEST_UID); }); it('should send push notifications', () => { - sinon.assert.calledOnce(push.notifyAccountUpdated); - sinon.assert.calledWithExactly( - push.notifyAccountUpdated, + expect(push.notifyAccountUpdated).toHaveBeenCalledTimes(1); + expect(push.notifyAccountUpdated).toHaveBeenCalledWith( TEST_UID, [], 'accountVerify' @@ -134,11 +139,14 @@ describe('verifyAccount', () => { }); it('should send post account verification email', () => { - sinon.assert.calledOnce(fxaMailer.sendPostVerifyEmail); - expect(fxaMailer.sendPostVerifyEmail.args[0][0].sync).toBe( - options.service === 'sync' + expect(fxaMailer.sendPostVerifyEmail).toHaveBeenCalledTimes(1); + expect(fxaMailer.sendPostVerifyEmail).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + sync: options.service === 'sync', + uid: TEST_UID, + }) ); - expect(fxaMailer.sendPostVerifyEmail.args[0][0].uid).toBe(TEST_UID); }); }); }); diff --git a/packages/fxa-auth-server/lib/senders/index.spec.ts b/packages/fxa-auth-server/lib/senders/index.spec.ts index 032ed8c06f9..e75af9c1e0a 100644 --- a/packages/fxa-auth-server/lib/senders/index.spec.ts +++ b/packages/fxa-auth-server/lib/senders/index.spec.ts @@ -3,7 +3,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import crypto from 'crypto'; -import sinon from 'sinon'; import { Container } from 'typedi'; const config = require('../../config').default.getProperties(); @@ -42,12 +41,12 @@ describe('lib/senders/index', () => { return senders( log || nullLog, Object.assign({}, cfg, {}), - { check: sinon.stub().resolves(null) }, + { check: jest.fn().mockResolvedValue(null) }, {} ).then((sndrs: any) => { const email = sndrs.email; - email._ungatedMailer.mailer.sendMail = sinon.spy( - (_opts: any, cb: any) => cb(null, {}) + email._ungatedMailer.mailer.sendMail = jest.fn((_opts: any, cb: any) => + cb(null, {}) ); return email; }); @@ -58,13 +57,11 @@ describe('lib/senders/index', () => { it('should call mailer.verifyEmail()', async () => { const email = await createSender(config); - email._ungatedMailer.verifyEmail = sinon.spy(() => - Promise.resolve({}) - ); + email._ungatedMailer.verifyEmail = jest.fn(() => Promise.resolve({})); await email.sendVerifyEmail(EMAILS, acct, { code }); - expect(email._ungatedMailer.verifyEmail.callCount).toBe(1); - const args = email._ungatedMailer.verifyEmail.getCall(0).args; + expect(email._ungatedMailer.verifyEmail).toHaveBeenCalledTimes(1); + const args = email._ungatedMailer.verifyEmail.mock.calls[0]; expect(args[0].email).toBe(EMAIL); expect(args[0].metricsEnabled).toBe(true); }); @@ -75,13 +72,13 @@ describe('lib/senders/index', () => { it('should call mailer.verifyLoginEmail()', async () => { const email = await createSender(config); - email._ungatedMailer.verifyLoginEmail = sinon.spy(() => + email._ungatedMailer.verifyLoginEmail = jest.fn(() => Promise.resolve({}) ); await email.sendVerifyLoginEmail(EMAILS, acct, { code }); - expect(email._ungatedMailer.verifyLoginEmail.callCount).toBe(1); - const args = email._ungatedMailer.verifyLoginEmail.getCall(0).args; + expect(email._ungatedMailer.verifyLoginEmail).toHaveBeenCalledTimes(1); + const args = email._ungatedMailer.verifyLoginEmail.mock.calls[0]; expect(args[0].email).toBe(EMAIL); expect(args[0].ccEmails).toHaveLength(1); expect(args[0].ccEmails[0]).toBe(EMAILS[1].email); @@ -94,13 +91,11 @@ describe('lib/senders/index', () => { it('should call mailer.recoveryEmail()', async () => { const email = await createSender(config); - email._ungatedMailer.recoveryEmail = sinon.spy(() => - Promise.resolve({}) - ); + email._ungatedMailer.recoveryEmail = jest.fn(() => Promise.resolve({})); await email.sendRecoveryEmail(EMAILS, acct, { code, token }); - expect(email._ungatedMailer.recoveryEmail.callCount).toBe(1); - const args = email._ungatedMailer.recoveryEmail.getCall(0).args; + expect(email._ungatedMailer.recoveryEmail).toHaveBeenCalledTimes(1); + const args = email._ungatedMailer.recoveryEmail.mock.calls[0]; expect(args[0].email).toBe(EMAIL); expect(args[0].metricsEnabled).toBe(true); expect(args[0].ccEmails).toHaveLength(1); @@ -115,14 +110,15 @@ describe('lib/senders/index', () => { metricsOptOutAt: 1642801160000, }; const email = await createSender(config); - email._ungatedMailer.passwordChangedEmail = sinon.spy(() => + email._ungatedMailer.passwordChangedEmail = jest.fn(() => Promise.resolve({}) ); await email.sendPasswordChangedEmail(EMAILS, acctMetricsOptOut, {}); - expect(email._ungatedMailer.passwordChangedEmail.callCount).toBe(1); - const args = - email._ungatedMailer.passwordChangedEmail.getCall(0).args; + expect( + email._ungatedMailer.passwordChangedEmail.mock.calls.length + ).toBe(1); + const args = email._ungatedMailer.passwordChangedEmail.mock.calls[0]; expect(args[0].email).toBe(EMAIL); expect(args[0].metricsEnabled).toBe(false); expect(args[0].ccEmails).toHaveLength(1); @@ -133,14 +129,15 @@ describe('lib/senders/index', () => { describe('.sendPasswordResetEmail()', () => { it('should call mailer.passwordResetEmail()', async () => { const email = await createSender(config); - email._ungatedMailer.passwordResetEmail = sinon.spy(() => + email._ungatedMailer.passwordResetEmail = jest.fn(() => Promise.resolve({}) ); await email.sendPasswordResetEmail(EMAILS, acct, {}); - expect(email._ungatedMailer.passwordResetEmail.callCount).toBe(1); - const args = - email._ungatedMailer.passwordResetEmail.getCall(0).args; + expect(email._ungatedMailer.passwordResetEmail).toHaveBeenCalledTimes( + 1 + ); + const args = email._ungatedMailer.passwordResetEmail.mock.calls[0]; expect(args[0].email).toBe(EMAIL); expect(args[0].metricsEnabled).toBe(true); expect(args[0].ccEmails).toHaveLength(1); @@ -151,16 +148,16 @@ describe('lib/senders/index', () => { describe('.sendPostAddLinkedAccountEmail()', () => { it('should call mailer.postAddLinkedAccountEmail()', async () => { const email = await createSender(config); - email._ungatedMailer.postAddLinkedAccountEmail = sinon.spy(() => + email._ungatedMailer.postAddLinkedAccountEmail = jest.fn(() => Promise.resolve({}) ); await email.sendPostAddLinkedAccountEmail(EMAILS, acct, {}); expect( - email._ungatedMailer.postAddLinkedAccountEmail.callCount + email._ungatedMailer.postAddLinkedAccountEmail.mock.calls.length ).toBe(1); const args = - email._ungatedMailer.postAddLinkedAccountEmail.getCall(0).args; + email._ungatedMailer.postAddLinkedAccountEmail.mock.calls[0]; expect(args[0].email).toBe(EMAIL); expect(args[0].metricsEnabled).toBe(true); expect(args[0].ccEmails).toHaveLength(1); @@ -171,14 +168,15 @@ describe('lib/senders/index', () => { describe('.sendNewDeviceLoginEmail()', () => { it('should call mailer.newDeviceLoginEmail()', async () => { const email = await createSender(config); - email._ungatedMailer.newDeviceLoginEmail = sinon.spy(() => + email._ungatedMailer.newDeviceLoginEmail = jest.fn(() => Promise.resolve({}) ); await email.sendNewDeviceLoginEmail(EMAILS, acct, {}); - expect(email._ungatedMailer.newDeviceLoginEmail.callCount).toBe(1); - const args = - email._ungatedMailer.newDeviceLoginEmail.getCall(0).args; + expect(email._ungatedMailer.newDeviceLoginEmail).toHaveBeenCalledTimes( + 1 + ); + const args = email._ungatedMailer.newDeviceLoginEmail.mock.calls[0]; expect(args[0].email).toBe(EMAIL); expect(args[0].metricsEnabled).toBe(true); expect(args[0].ccEmails).toHaveLength(1); @@ -189,13 +187,13 @@ describe('lib/senders/index', () => { describe('.sendPostVerifyEmail()', () => { it('should call mailer.postVerifyEmail()', async () => { const email = await createSender(config); - email._ungatedMailer.postVerifyEmail = sinon.spy(() => + email._ungatedMailer.postVerifyEmail = jest.fn(() => Promise.resolve({}) ); await email.sendPostVerifyEmail(EMAILS, acct, {}); - expect(email._ungatedMailer.postVerifyEmail.callCount).toBe(1); - const args = email._ungatedMailer.postVerifyEmail.getCall(0).args; + expect(email._ungatedMailer.postVerifyEmail).toHaveBeenCalledTimes(1); + const args = email._ungatedMailer.postVerifyEmail.mock.calls[0]; expect(args[0].email).toBe(EMAIL); expect(args[0].metricsEnabled).toBe(true); expect(args[0].ccEmails).toHaveLength(1); @@ -207,13 +205,13 @@ describe('lib/senders/index', () => { it('should call mailer.unblockCodeEmail()', async () => { const email = await createSender(config); - email._ungatedMailer.unblockCodeEmail = sinon.spy(() => + email._ungatedMailer.unblockCodeEmail = jest.fn(() => Promise.resolve({}) ); await email.sendUnblockCodeEmail(EMAILS, acct, { code }); - expect(email._ungatedMailer.unblockCodeEmail.callCount).toBe(1); - const args = email._ungatedMailer.unblockCodeEmail.getCall(0).args; + expect(email._ungatedMailer.unblockCodeEmail).toHaveBeenCalledTimes(1); + const args = email._ungatedMailer.unblockCodeEmail.mock.calls[0]; expect(args[0].email).toBe(EMAIL); expect(args[0].metricsEnabled).toBe(true); expect(args[0].ccEmails).toHaveLength(1); @@ -224,21 +222,17 @@ describe('lib/senders/index', () => { describe('.sendPostAddTwoStepAuthenticationEmail()', () => { it('should call mailer.postAddTwoStepAuthenticationEmail()', async () => { const email = await createSender(config); - email._ungatedMailer.postAddTwoStepAuthenticationEmail = sinon.spy( - () => Promise.resolve({}) - ); - await email.sendPostAddTwoStepAuthenticationEmail( - EMAILS, - acct, - {} + email._ungatedMailer.postAddTwoStepAuthenticationEmail = jest.fn(() => + Promise.resolve({}) ); + await email.sendPostAddTwoStepAuthenticationEmail(EMAILS, acct, {}); expect( - email._ungatedMailer.postAddTwoStepAuthenticationEmail.callCount + email._ungatedMailer.postAddTwoStepAuthenticationEmail.mock.calls + .length ).toBe(1); const args = - email._ungatedMailer.postAddTwoStepAuthenticationEmail.getCall(0) - .args; + email._ungatedMailer.postAddTwoStepAuthenticationEmail.mock.calls[0]; expect(args[0].email).toBe(EMAIL); expect(args[0].metricsEnabled).toBe(true); expect(args[0].ccEmails).toHaveLength(1); @@ -249,22 +243,18 @@ describe('lib/senders/index', () => { describe('.sendPostRemoveTwoStepAuthenticationEmail()', () => { it('should call mailer.postRemoveTwoStepAuthenticationEmail()', async () => { const email = await createSender(config); - email._ungatedMailer.postRemoveTwoStepAuthenticationEmail = - sinon.spy(() => Promise.resolve({})); - await email.sendPostRemoveTwoStepAuthenticationEmail( - EMAILS, - acct, - {} + email._ungatedMailer.postRemoveTwoStepAuthenticationEmail = jest.fn( + () => Promise.resolve({}) ); + await email.sendPostRemoveTwoStepAuthenticationEmail(EMAILS, acct, {}); expect( - email._ungatedMailer.postRemoveTwoStepAuthenticationEmail - .callCount + email._ungatedMailer.postRemoveTwoStepAuthenticationEmail.mock.calls + .length ).toBe(1); const args = - email._ungatedMailer.postRemoveTwoStepAuthenticationEmail.getCall( - 0 - ).args; + email._ungatedMailer.postRemoveTwoStepAuthenticationEmail.mock + .calls[0]; expect(args[0].email).toBe(EMAIL); expect(args[0].metricsEnabled).toBe(true); expect(args[0].ccEmails).toHaveLength(1); @@ -275,7 +265,7 @@ describe('lib/senders/index', () => { describe('sendDownloadSubscriptionEmail:', () => { it('called mailer.downloadSubscriptionEmail', async () => { const mailer = await createSender(config); - mailer._ungatedMailer.downloadSubscriptionEmail = sinon.spy(() => + mailer._ungatedMailer.downloadSubscriptionEmail = jest.fn(() => Promise.resolve({}) ); await mailer.sendDownloadSubscriptionEmail(EMAILS, acct, { @@ -284,10 +274,10 @@ describe('lib/senders/index', () => { }); expect( - mailer._ungatedMailer.downloadSubscriptionEmail.callCount + mailer._ungatedMailer.downloadSubscriptionEmail.mock.calls.length ).toBe(1); const args = - mailer._ungatedMailer.downloadSubscriptionEmail.args[0]; + mailer._ungatedMailer.downloadSubscriptionEmail.mock.calls[0]; expect(args).toHaveLength(1); expect(args[0]).toEqual({ acceptLanguage: 'wibble', @@ -307,25 +297,21 @@ describe('lib/senders/index', () => { mocks.mockProductConfigurationManager() ); const mailer = await createSender(config); - await mailer.sendSubscriptionAccountReminderFirstEmail( - EMAILS, - acct, - { - email: 'test@test.com', - uid: '123', - planId: '456', - acceptLanguage: 'en-US', - productId: 'abc', - productName: 'testProduct', - token: 'token', - flowId: '456', - lowBeginTime: 123, - deviceId: 'xyz', - accountVerified: false, - } - ); + await mailer.sendSubscriptionAccountReminderFirstEmail(EMAILS, acct, { + email: 'test@test.com', + uid: '123', + planId: '456', + acceptLanguage: 'en-US', + productId: 'abc', + productName: 'testProduct', + token: 'token', + flowId: '456', + lowBeginTime: 123, + deviceId: 'xyz', + accountVerified: false, + }); - expect(mailer._ungatedMailer.mailer.sendMail.callCount).toBe(1); + expect(mailer._ungatedMailer.mailer.sendMail).toHaveBeenCalledTimes(1); }); it('should not send an email if the account is verified', async () => { @@ -334,25 +320,21 @@ describe('lib/senders/index', () => { mocks.mockProductConfigurationManager() ); const mailer = await createSender(config); - await mailer.sendSubscriptionAccountReminderFirstEmail( - EMAILS, - acct, - { - email: 'test@test.com', - uid: '123', - planId: '456', - acceptLanguage: 'en-US', - productId: 'abc', - productName: 'testProduct', - token: 'token', - flowId: '456', - lowBeginTime: 123, - deviceId: 'xyz', - accountVerified: true, - } - ); + await mailer.sendSubscriptionAccountReminderFirstEmail(EMAILS, acct, { + email: 'test@test.com', + uid: '123', + planId: '456', + acceptLanguage: 'en-US', + productId: 'abc', + productName: 'testProduct', + token: 'token', + flowId: '456', + lowBeginTime: 123, + deviceId: 'xyz', + accountVerified: true, + }); - expect(mailer._ungatedMailer.mailer.sendMail.callCount).toBe(0); + expect(mailer._ungatedMailer.mailer.sendMail).toHaveBeenCalledTimes(0); }); }); }); diff --git a/packages/fxa-auth-server/lib/senders/oauth_client_info.spec.ts b/packages/fxa-auth-server/lib/senders/oauth_client_info.spec.ts index d6bba21721a..135ec18e03e 100644 --- a/packages/fxa-auth-server/lib/senders/oauth_client_info.spec.ts +++ b/packages/fxa-auth-server/lib/senders/oauth_client_info.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - jest.mock('../oauth/client', () => ({ getClientById: async (id: string) => { switch (id) { @@ -33,9 +31,9 @@ describe('lib/senders/oauth_client_info:', () => { beforeEach(() => { mockLog = { - fatal: sinon.spy(), - trace: sinon.spy(), - warn: sinon.spy(), + fatal: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), }; clientInfo = ClientInfo(mockLog, mockConfig); fetch = clientInfo.fetch; @@ -73,20 +71,24 @@ describe('lib/senders/oauth_client_info:', () => { it('falls back to Mozilla if error', async () => { const res = await fetch('0000000000000000'); expect(res.name).toBe('Mozilla'); - expect(mockLog.fatal.calledOnce).toBe(true); + expect(mockLog.fatal).toHaveBeenCalledTimes(1); }); it('fetches and memory caches client information', async () => { const res = await fetch('24bdbfa45cd300c5'); expect(res.name).toBe('FxA OAuth Console'); - expect(mockLog.trace.getCall(0).args[0]).toBe('fetch.start'); - expect(mockLog.trace.getCall(1).args[0]).toBe('fetch.usedServer'); - expect(mockLog.trace.getCall(2)).toBeNull(); + expect(mockLog.trace).toHaveBeenNthCalledWith(1, 'fetch.start'); + expect(mockLog.trace).toHaveBeenNthCalledWith( + 2, + 'fetch.usedServer', + expect.anything() + ); + expect(mockLog.trace).toHaveBeenCalledTimes(2); // second call is cached const res2 = await fetch('24bdbfa45cd300c5'); - expect(mockLog.trace.getCall(2).args[0]).toBe('fetch.start'); - expect(mockLog.trace.getCall(3).args[0]).toBe('fetch.usedCache'); + expect(mockLog.trace).toHaveBeenNthCalledWith(3, 'fetch.start'); + expect(mockLog.trace).toHaveBeenNthCalledWith(4, 'fetch.usedCache'); expect(res2.name).toBe('FxA OAuth Console'); }); }); diff --git a/packages/fxa-auth-server/lib/server.in.spec.ts b/packages/fxa-auth-server/lib/server.in.spec.ts index b46905f6762..e19327030e1 100644 --- a/packages/fxa-auth-server/lib/server.in.spec.ts +++ b/packages/fxa-auth-server/lib/server.in.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { Account } from 'fxa-shared/db/models/auth/account'; const mockReportValidationError = jest.fn(); @@ -19,8 +18,6 @@ const server = require('./server'); const glean = mocks.mockGlean(); const customs = mocks.mockCustoms(); -const sandbox = sinon.createSandbox(); - describe('lib/server', () => { describe('trimLocale', () => { it('trims given locale', () => { @@ -115,7 +112,7 @@ describe('lib/server', () => { config = getConfig(); log = mocks.mockLog(); routes = getRoutes(); - statsd = { timing: sinon.fake() }; + statsd = { timing: jest.fn() }; }); describe('create:', () => { @@ -143,11 +140,11 @@ describe('lib/server', () => { afterEach(() => instance.stop()); it('did not call log.begin', () => { - expect(log.begin.callCount).toBe(0); + expect(log.begin).toHaveBeenCalledTimes(0); }); it('did not call log.summary', () => { - expect(log.summary.callCount).toBe(0); + expect(log.summary).toHaveBeenCalledTimes(0); }); it('rejected invalid subscription shared secret', async () => { @@ -161,13 +158,13 @@ describe('lib/server', () => { expect(statusCode).toBe(401); expect(result.code).toBe(401); expect(result.errno).toBe(error.ERRNO.INVALID_TOKEN); - expect(statsd.timing.getCall(0).args[0]).toBe('url_request'); - expect(statsd.timing.getCall(0).args[3].path).toBe( + expect(statsd.timing.mock.calls[0][0]).toBe('url_request'); + expect(statsd.timing.mock.calls[0][3].path).toBe( 'oauth_subscriptions_clients' ); - expect(statsd.timing.getCall(0).args[3].statusCode).toBe(statusCode); - expect(statsd.timing.getCall(0).args[3].method).toBe('GET'); - expect(statsd.timing.getCall(0).args[3].errno).toBe( + expect(statsd.timing.mock.calls[0][3].statusCode).toBe(statusCode); + expect(statsd.timing.mock.calls[0][3].method).toBe('GET'); + expect(statsd.timing.mock.calls[0][3].errno).toBe( error.ERRNO.INVALID_TOKEN ); }); @@ -204,31 +201,31 @@ describe('lib/server', () => { request = res.request; }); afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); it('should return request.auth.credentials.metricsOptOutAt', async () => { - const accountStub = sandbox - .stub(Account, 'metricsEnabled') - .resolves(false); + const accountStub = jest + .spyOn(Account, 'metricsEnabled') + .mockResolvedValue(false); request.auth.credentials.uid = 'fake uid'; request.auth.credentials.metricsOptOutAt = 123456789; const expected = !request.auth.credentials.metricsOptOutAt; const result = await request.app.isMetricsEnabled; expect(result).toBe(expected); - sinon.assert.notCalled(accountStub); + expect(accountStub).not.toHaveBeenCalled(); }); it('should return Account.metricsEnabled if request.auth.credentials.user is provided', async () => { request.auth.credentials.uid = null; request.auth.credentials.user = 'fake uid'; const expected = false; - const accountStub = sandbox - .stub(Account, 'metricsEnabled') - .resolves(expected); + const accountStub = jest + .spyOn(Account, 'metricsEnabled') + .mockResolvedValue(expected); const result = await request.app.isMetricsEnabled; - sinon.assert.called(accountStub); + expect(accountStub).toHaveBeenCalled(); expect(result).toBe(expected); }); @@ -236,11 +233,11 @@ describe('lib/server', () => { request.auth.credentials.uid = null; request.payload.uid = 'fake uid'; const expected = false; - const accountStub = sandbox - .stub(Account, 'metricsEnabled') - .resolves(expected); + const accountStub = jest + .spyOn(Account, 'metricsEnabled') + .mockResolvedValue(expected); const result = await request.app.isMetricsEnabled; - sinon.assert.called(accountStub); + expect(accountStub).toHaveBeenCalled(); expect(result).toBe(expected); }); @@ -248,11 +245,11 @@ describe('lib/server', () => { request.auth.credentials.uid = null; request.app.metricsEventUid = 'fake uid'; const expected = false; - const accountStub = sandbox - .stub(Account, 'metricsEnabled') - .resolves(expected); + const accountStub = jest + .spyOn(Account, 'metricsEnabled') + .mockResolvedValue(expected); const result = await request.app.isMetricsEnabled; - sinon.assert.called(accountStub); + expect(accountStub).toHaveBeenCalled(); expect(result).toBe(expected); }); @@ -260,15 +257,15 @@ describe('lib/server', () => { request.auth.credentials.uid = null; request.payload.email = 'fake@email.com'; const expected = false; - const accountStub = sandbox - .stub(Account, 'metricsEnabled') - .resolves(expected); - const accountEmailStub = sandbox - .stub(Account, 'findByPrimaryEmail') - .resolves({ uid: 'emailUID' }); + const accountStub = jest + .spyOn(Account, 'metricsEnabled') + .mockResolvedValue(expected); + const accountEmailStub = jest + .spyOn(Account, 'findByPrimaryEmail') + .mockResolvedValue({ uid: 'emailUID' }); const result = await request.app.isMetricsEnabled; - sinon.assert.called(accountStub); - sinon.assert.called(accountEmailStub); + expect(accountStub).toHaveBeenCalled(); + expect(accountEmailStub).toHaveBeenCalled(); expect(result).toBe(expected); }); @@ -276,26 +273,28 @@ describe('lib/server', () => { request.auth.credentials.uid = null; request.payload.email = 'fake@email.com'; const expected = true; - const accountStub = sandbox - .stub(Account, 'metricsEnabled') - .resolves(expected); - const accountEmailStub = sandbox - .stub(Account, 'findByPrimaryEmail') - .throws(); + const accountStub = jest + .spyOn(Account, 'metricsEnabled') + .mockResolvedValue(expected); + const accountEmailStub = jest + .spyOn(Account, 'findByPrimaryEmail') + .mockImplementation(() => { + throw new Error(); + }); const result = await request.app.isMetricsEnabled; - sinon.assert.called(accountEmailStub); - sinon.assert.notCalled(accountStub); + expect(accountEmailStub).toHaveBeenCalled(); + expect(accountStub).not.toHaveBeenCalled(); expect(result).toBe(expected); }); it('should return true if no uid is found', async () => { request.auth.credentials.uid = null; const expected = true; - const accountStub = sandbox - .stub(Account, 'metricsEnabled') - .resolves(expected); + const accountStub = jest + .spyOn(Account, 'metricsEnabled') + .mockResolvedValue(expected); const result = await request.app.isMetricsEnabled; - sinon.assert.notCalled(accountStub); + expect(accountStub).not.toHaveBeenCalled(); expect(result).toBe(expected); }); }); @@ -329,8 +328,8 @@ describe('lib/server', () => { }); it('called log.begin correctly', () => { - expect(log.begin.callCount).toBe(1); - const args = log.begin.args[0]; + expect(log.begin).toHaveBeenCalledTimes(1); + const args = log.begin.mock.calls[0]; expect(args.length).toBe(2); expect(args[0]).toBe('server.onRequest'); expect(args[1]).toBeTruthy(); @@ -339,10 +338,10 @@ describe('lib/server', () => { }); it('called log.summary correctly', () => { - expect(log.summary.callCount).toBe(1); - const args = log.summary.args[0]; + expect(log.summary).toHaveBeenCalledTimes(1); + const args = log.summary.mock.calls[0]; expect(args.length).toBe(2); - expect(args[0]).toBe(log.begin.args[0][1]); + expect(args[0]).toBe(log.begin.mock.calls[0][1]); expect(args[1]).toBeTruthy(); expect(args[1].isBoom).toBeUndefined(); expect(args[1].errno).toBeUndefined(); @@ -351,7 +350,7 @@ describe('lib/server', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('parsed features correctly', () => { @@ -402,9 +401,7 @@ describe('lib/server', () => { expect(knownIpLocation.location.city.has(geo.location.city)).toBe( true ); - expect(geo.location.country).toBe( - knownIpLocation.location.country - ); + expect(geo.location.country).toBe(knownIpLocation.location.country); expect(geo.location.countryCode).toBe( knownIpLocation.location.countryCode ); @@ -418,9 +415,8 @@ describe('lib/server', () => { it('fetched devices correctly', async () => { expect(request.app.devices).toBeTruthy(); expect(typeof request.app.devices.then).toBe('function'); - expect(db.devices.callCount).toBe(1); - expect(db.devices.args[0].length).toBe(1); - expect(db.devices.args[0][0]).toBe('fake uid'); + expect(db.devices).toHaveBeenCalledTimes(1); + expect(db.devices).toHaveBeenNthCalledWith(1, 'fake uid'); const devices = await request.app.devices; expect(devices).toEqual([{ id: 'fake device id' }]); }); @@ -493,9 +489,9 @@ describe('lib/server', () => { it('second request has its own location info', () => { const geo = secondRequest.app.geo; expect(request.app.geo).not.toBe(secondRequest.app.geo); - expect( - knownIpLocation.location.city.has(geo.location.city) - ).toBe(true); + expect(knownIpLocation.location.city.has(geo.location.city)).toBe( + true + ); expect(geo.location.country).toBe( knownIpLocation.location.country ); @@ -511,9 +507,8 @@ describe('lib/server', () => { it('second request fetched devices correctly', async () => { expect(request.app.devices).not.toBe(secondRequest.app.devices); - expect(db.devices.callCount).toBe(2); - expect(db.devices.args[1].length).toBe(1); - expect(db.devices.args[1][0]).toBe('another fake uid'); + expect(db.devices).toHaveBeenCalledTimes(2); + expect(db.devices).toHaveBeenNthCalledWith(2, 'another fake uid'); const devices = await secondRequest.app.devices; expect(devices).toEqual([{ id: 'fake device id' }]); }); @@ -540,8 +535,8 @@ describe('lib/server', () => { }); it('called log.begin correctly', () => { - expect(log.begin.callCount).toBe(1); - const args = log.begin.args[0]; + expect(log.begin).toHaveBeenCalledTimes(1); + const args = log.begin.mock.calls[0]; expect(args[1].app.locale).toBe('fr'); expect(args[1].app.ua.browser).toBe('Chrome Mobile iOS'); expect(args[1].app.ua.browserVersion).toBe('56.0.2924'); @@ -552,11 +547,11 @@ describe('lib/server', () => { }); it('called log.summary once', () => { - expect(log.summary.callCount).toBe(1); + expect(log.summary).toHaveBeenCalledTimes(1); }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('parsed features correctly', () => { @@ -580,8 +575,8 @@ describe('lib/server', () => { retryAfterLocalized: undefined, }; beforeEach(async () => { - glean.registration.error.reset(); - sinon.stub(Date, 'now').returns(1584397692000); + glean.registration.error.mockClear(); + jest.spyOn(Date, 'now').mockReturnValue(1584397692000); response = error.requestBlocked(); try { await instance.inject({ @@ -593,17 +588,17 @@ describe('lib/server', () => { // expected } }); - afterEach(() => (Date.now as sinon.SinonStub).restore()); + afterEach(() => (Date.now as jest.Mock).mockRestore()); it('called log.begin', () => { - expect(log.begin.callCount).toBe(1); + expect(log.begin).toHaveBeenCalledTimes(1); }); it('called log.summary correctly', () => { - expect(log.summary.callCount).toBe(1); - const args = log.summary.args[0]; + expect(log.summary).toHaveBeenCalledTimes(1); + const args = log.summary.mock.calls[0]; expect(args.length).toBe(2); - expect(args[0]).toBe(log.begin.args[0][1]); + expect(args[0]).toBe(log.begin.mock.calls[0][1]); expect(args[1]).toBeTruthy(); expect(args[1].statusCode).toBe(400); expect(String(args[1].headers.Timestamp)).toBe('1584397692'); @@ -611,11 +606,11 @@ describe('lib/server', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); it('did log an error with glean', () => { - sinon.assert.calledOnce(glean.registration.error); + expect(glean.registration.error).toHaveBeenCalledTimes(1); }); }); @@ -636,22 +631,23 @@ describe('lib/server', () => { }); it('called log.begin', () => { - expect(log.begin.callCount).toBe(1); + expect(log.begin).toHaveBeenCalledTimes(1); }); it('called log.summary', () => { - expect(log.summary.callCount).toBe(1); + expect(log.summary).toHaveBeenCalledTimes(1); }); it('called log.error correctly', () => { - expect(log.error.callCount).toBeGreaterThanOrEqual(1); - const args = log.error.args[0]; - expect(args.length).toBe(2); - expect(args[0]).toBe('server.EndpointError'); - expect(args[1]).toEqual({ - message: 'request failed', - reason: 'because i said so', - }); + expect(log.error.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(log.error).toHaveBeenNthCalledWith( + 1, + 'server.EndpointError', + { + message: 'request failed', + reason: 'because i said so', + } + ); }); }); @@ -669,21 +665,19 @@ describe('lib/server', () => { }); it('called db.sessionToken correctly', () => { - expect(db.sessionToken.callCount).toBe(1); - const args = db.sessionToken.args[0]; - expect(args.length).toBe(1); - expect(args[0]).toBe('deadbeef'); + expect(db.sessionToken).toHaveBeenCalledTimes(1); + expect(db.sessionToken).toHaveBeenNthCalledWith(1, 'deadbeef'); }); it('did not call db.pruneSessionTokens', () => { - expect(db.pruneSessionTokens.callCount).toBe(0); + expect(db.pruneSessionTokens).toHaveBeenCalledTimes(0); }); }); describe('general rate limiting error', () => { beforeEach(() => { - customs.checkIpOnly.resetHistory(); - customs.v2Enabled.resetHistory(); + customs.checkIpOnly.mockClear(); + customs.v2Enabled.mockClear(); }); afterEach(() => { @@ -708,22 +702,23 @@ describe('lib/server', () => { it('called customs', async () => { await query('/account/status'); - expect(customs.checkIpOnly.callCount).toBe(1); - const args = customs.checkIpOnly.args[0]; - expect(args.length).toBe(2); - expect(typeof args[0]).toBe('object'); - expect(args[1]).toBe('get__account_status'); - expect(log.error.callCount).toBe(0); + expect(customs.checkIpOnly).toHaveBeenCalledTimes(1); + expect(customs.checkIpOnly).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + 'get__account_status' + ); + expect(log.error).toHaveBeenCalledTimes(0); }); it('handles customs block', async () => { - customs.checkIpOnly = sinon.spy(async () => { + customs.checkIpOnly = jest.fn(async () => { throw error.tooManyRequests(100, 'foo'); }); const { statusCode, result } = await query('/account/status'); - expect(customs.checkIpOnly.callCount).toBe(1); + expect(customs.checkIpOnly).toHaveBeenCalledTimes(1); expect(statusCode).toBe(429); expect(result).toEqual({ code: 429, @@ -743,11 +738,11 @@ describe('lib/server', () => { '/__version__', ]) { it('will skip ' + endpoint, async () => { - customs.checkIpOnly = sinon.spy(async () => { + customs.checkIpOnly = jest.fn(async () => { throw error.tooManyRequests(100, 'foo'); }); await query(endpoint); - expect(customs.checkIpOnly.callCount).toBe(0); + expect(customs.checkIpOnly).toHaveBeenCalledTimes(0); }); } }); @@ -764,7 +759,7 @@ describe('lib/server', () => { uid: 'blee', expired: true, }); - statsd = { increment: sinon.fake(), timing: sinon.fake() }; + statsd = { increment: jest.fn(), timing: jest.fn() }; instance = await server.create( log, @@ -792,12 +787,12 @@ describe('lib/server', () => { afterEach(() => instance.stop()); it('called db.sessionToken', () => { - expect(db.sessionToken.callCount).toBe(1); + expect(db.sessionToken).toHaveBeenCalledTimes(1); }); it('called db.pruneSessionTokens correctly', () => { - expect(db.pruneSessionTokens.callCount).toBe(1); - const args = db.pruneSessionTokens.args[0]; + expect(db.pruneSessionTokens).toHaveBeenCalledTimes(1); + const args = db.pruneSessionTokens.mock.calls[0]; expect(args.length).toBe(2); expect(args[0]).toBe('blee'); expect(Array.isArray(args[1])).toBe(true); diff --git a/packages/fxa-auth-server/lib/serverJWT.spec.ts b/packages/fxa-auth-server/lib/serverJWT.spec.ts index 893e3712497..f90c06e6073 100644 --- a/packages/fxa-auth-server/lib/serverJWT.spec.ts +++ b/packages/fxa-auth-server/lib/serverJWT.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const noop = () => {}; function loadWithMock(overrides: any) { @@ -16,7 +14,7 @@ function loadWithMock(overrides: any) { describe('lib/serverJWT', () => { describe('signJWT', () => { it('signs the JWT', async () => { - const signSpy = sinon.spy(function ( + const signSpy = jest.fn(function ( _claims: any, _key: any, _opts: any, @@ -30,20 +28,26 @@ describe('lib/serverJWT', () => { const jwt = await serverJWT.signJWT({ foo: 'bar' }, 'biz', 'buz', 'zoom'); expect(jwt).toBe('j.w.t'); - sinon.assert.calledOnce(signSpy); - sinon.assert.calledWith(signSpy, { foo: 'bar' }, 'zoom', { - algorithm: 'HS256', - expiresIn: 60, - audience: 'biz', - issuer: 'buz', - }); + expect(signSpy).toHaveBeenCalledTimes(1); + expect(signSpy).toHaveBeenNthCalledWith( + 1, + { foo: 'bar' }, + 'zoom', + { + algorithm: 'HS256', + expiresIn: 60, + audience: 'biz', + issuer: 'buz', + }, + expect.any(Function) + ); }); }); describe('verifyJWT', () => { describe('signed with the current key', () => { it('returns the claims', async () => { - const verifySpy = sinon.spy(function ( + const verifySpy = jest.fn(function ( _jwt: any, _key: any, _opts: any, @@ -61,18 +65,24 @@ describe('lib/serverJWT', () => { expect(claims).toEqual({ sub: 'foo' }); - sinon.assert.calledOnce(verifySpy); - sinon.assert.calledWith(verifySpy, 'j.w.t', 'current', { - algorithms: ['HS256'], - audience: 'foo', - issuer: 'bar', - }); + expect(verifySpy).toHaveBeenCalledTimes(1); + expect(verifySpy).toHaveBeenNthCalledWith( + 1, + 'j.w.t', + 'current', + { + algorithms: ['HS256'], + audience: 'foo', + issuer: 'bar', + }, + expect.any(Function) + ); }); }); describe('signed with an old key', () => { it('returns the claims', async () => { - const verifySpy = sinon.spy(function ( + const verifySpy = jest.fn(function ( _jwt: any, key: any, _opts: any, @@ -94,31 +104,37 @@ describe('lib/serverJWT', () => { expect(claims).toEqual({ sub: 'foo' }); - expect(verifySpy.calledTwice).toBe(true); + expect(verifySpy).toHaveBeenCalledTimes(2); - let args = verifySpy.args[0]; - expect(args[0]).toBe('j.w.t'); - expect(args[1]).toBe('current'); - expect(args[2]).toEqual({ - algorithms: ['HS256'], - audience: 'foo', - issuer: 'bar', - }); - - args = verifySpy.args[1]; - expect(args[0]).toBe('j.w.t'); - expect(args[1]).toBe('old'); - expect(args[2]).toEqual({ - algorithms: ['HS256'], - audience: 'foo', - issuer: 'bar', - }); + expect(verifySpy).toHaveBeenNthCalledWith( + 1, + 'j.w.t', + 'current', + { + algorithms: ['HS256'], + audience: 'foo', + issuer: 'bar', + }, + expect.any(Function) + ); + + expect(verifySpy).toHaveBeenNthCalledWith( + 2, + 'j.w.t', + 'old', + { + algorithms: ['HS256'], + audience: 'foo', + issuer: 'bar', + }, + expect.any(Function) + ); }); }); describe('no key found', () => { it('throws an `Invalid jwt` error', async () => { - const verifySpy = sinon.spy(function ( + const verifySpy = jest.fn(function ( _jwt: any, _key: any, _opts: any, @@ -137,7 +153,7 @@ describe('lib/serverJWT', () => { describe('invalid JWT', () => { it('re-throw the verification error', async () => { - const verifySpy = sinon.spy(function ( + const verifySpy = jest.fn(function ( _jwt: any, _key: any, _opts: any, diff --git a/packages/fxa-auth-server/lib/sqs.spec.ts b/packages/fxa-auth-server/lib/sqs.spec.ts index 8e61fe66fb6..b20e637fc5b 100644 --- a/packages/fxa-auth-server/lib/sqs.spec.ts +++ b/packages/fxa-auth-server/lib/sqs.spec.ts @@ -2,9 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - -const log = { error: sinon.stub() }; +const log = { error: jest.fn() }; interface SQSQueue { sqs: Record; @@ -12,26 +10,33 @@ interface SQSQueue { } let SQSReceiver: ReturnType; -let statsd: { timing: sinon.SinonStub }; +let statsd: { timing: jest.Mock }; let testQueue: SQSQueue; describe('SQSReceiver', () => { beforeEach(() => { - statsd = { timing: sinon.stub() }; + statsd = { timing: jest.fn() }; SQSReceiver = require('./sqs')(log, statsd); testQueue = new SQSReceiver('testo', [ 'https://sqs.testo.meows.xyz/fxa/quux', ]); - const receiveStub = sinon.stub(); - receiveStub.onFirstCall().callsFake( - (_qParams: Record, cb: (err: null, data: { Messages: string[] }) => void) => { - cb(null, { Messages: [JSON.stringify({ Body: 'SYN' })] }); - } - ); - receiveStub.returns(null); + const receiveStub = jest + .fn() + .mockImplementationOnce( + ( + _qParams: Record, + cb: (err: null, data: { Messages: string[] }) => void + ) => { + cb(null, { Messages: [JSON.stringify({ Body: 'SYN' })] }); + } + ) + .mockReturnValue(null); testQueue.sqs = { receiveMessage: receiveStub, - deleteMessage: (_sParams: Record, cb: (err: null) => void) => { + deleteMessage: ( + _sParams: Record, + cb: (err: null) => void + ) => { cb(null); }, }; @@ -39,10 +44,16 @@ describe('SQSReceiver', () => { it('should collect perf stats with statsd when it is present', () => { testQueue.start(); - expect(statsd.timing.callCount).toBe(2); - expect(statsd.timing.args[0][0]).toBe('sqs.quux.receive'); - expect(typeof statsd.timing.args[0][1]).toBe('number'); - expect(statsd.timing.args[1][0]).toBe('sqs.quux.delete'); - expect(typeof statsd.timing.args[1][1]).toBe('number'); + expect(statsd.timing).toHaveBeenCalledTimes(2); + expect(statsd.timing).toHaveBeenNthCalledWith( + 1, + 'sqs.quux.receive', + expect.any(Number) + ); + expect(statsd.timing).toHaveBeenNthCalledWith( + 2, + 'sqs.quux.delete', + expect.any(Number) + ); }); }); diff --git a/packages/fxa-auth-server/lib/subscription-account-reminders.in.spec.ts b/packages/fxa-auth-server/lib/subscription-account-reminders.in.spec.ts index 855d7f9acfe..2b2a0641090 100644 --- a/packages/fxa-auth-server/lib/subscription-account-reminders.in.spec.ts +++ b/packages/fxa-auth-server/lib/subscription-account-reminders.in.spec.ts @@ -135,7 +135,7 @@ describe('#integration - lib/subscription-account-reminders', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); @@ -211,7 +211,7 @@ describe('#integration - lib/subscription-account-reminders', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); describe('reinstate:', () => { @@ -309,7 +309,7 @@ describe('#integration - lib/subscription-account-reminders', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); @@ -353,7 +353,7 @@ describe('#integration - lib/subscription-account-reminders', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); describe('reinstate:', () => { diff --git a/packages/fxa-auth-server/lib/tokens/session_token.spec.ts b/packages/fxa-auth-server/lib/tokens/session_token.spec.ts index 4ede4d09415..f1f7a7dfdca 100644 --- a/packages/fxa-auth-server/lib/tokens/session_token.spec.ts +++ b/packages/fxa-auth-server/lib/tokens/session_token.spec.ts @@ -2,13 +2,11 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const crypto = require('crypto'); const log = { trace() {}, info() {}, - error: sinon.spy(), + error: jest.fn(), }; interface SessionTokenLike { diff --git a/packages/fxa-auth-server/lib/verification-reminders.in.spec.ts b/packages/fxa-auth-server/lib/verification-reminders.in.spec.ts index a77b209f0d3..61efa91eb3e 100644 --- a/packages/fxa-auth-server/lib/verification-reminders.in.spec.ts +++ b/packages/fxa-auth-server/lib/verification-reminders.in.spec.ts @@ -6,8 +6,6 @@ * @jest-environment node */ -import sinon from 'sinon'; - const REMINDERS = ['first', 'second', 'third']; const EXPECTED_CREATE_DELETE_RESULT = REMINDERS.reduce( (expected: Record, reminder) => { @@ -19,11 +17,11 @@ const EXPECTED_CREATE_DELETE_RESULT = REMINDERS.reduce( function mockLog() { return { - info: sinon.stub(), - trace: sinon.stub(), - error: sinon.stub(), - warn: sinon.stub(), - debug: sinon.stub(), + info: jest.fn(), + trace: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), }; } @@ -59,7 +57,10 @@ describe('#integration - lib/verification-reminders', () => { }, mockLog() ); - verificationReminders = require('./verification-reminders')(log, mockConfig); + verificationReminders = require('./verification-reminders')( + log, + mockConfig + ); }); afterEach(async () => { @@ -144,7 +145,7 @@ describe('#integration - lib/verification-reminders', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); @@ -152,7 +153,12 @@ describe('#integration - lib/verification-reminders', () => { let processResult: any; beforeEach(async () => { - await verificationReminders.create('blee', undefined, undefined, before); + await verificationReminders.create( + 'blee', + undefined, + undefined, + before + ); processResult = await verificationReminders.process(before + 2); }); @@ -174,9 +180,9 @@ describe('#integration - lib/verification-reminders', () => { ); expect(parseInt(processResult.first[0].timestamp)).toBeLessThan(before); expect(processResult.first[1].uid).toBe('blee'); - expect(parseInt(processResult.first[1].timestamp)).toBeGreaterThanOrEqual( - before - ); + expect( + parseInt(processResult.first[1].timestamp) + ).toBeGreaterThanOrEqual(before); expect(parseInt(processResult.first[1].timestamp)).toBeLessThan( before + 1000 ); @@ -216,7 +222,7 @@ describe('#integration - lib/verification-reminders', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); describe('reinstate:', () => { @@ -261,7 +267,12 @@ describe('#integration - lib/verification-reminders', () => { beforeEach(async () => { before = Date.now(); - createResult = await verificationReminders.create('wibble', 'blee', 42, before); + createResult = await verificationReminders.create( + 'wibble', + 'blee', + 42, + before + ); }); afterEach(() => { @@ -308,7 +319,7 @@ describe('#integration - lib/verification-reminders', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); }); @@ -355,7 +366,7 @@ describe('#integration - lib/verification-reminders', () => { }); it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); + expect(log.error).toHaveBeenCalledTimes(0); }); describe('reinstate:', () => { @@ -406,7 +417,9 @@ describe('#integration - lib/verification-reminders', () => { let secondProcessResult: any; beforeEach(async () => { - secondProcessResult = await verificationReminders.process(before + 1000); + secondProcessResult = await verificationReminders.process( + before + 1000 + ); }); it('returned the correct result and cleared everything from redis', async () => { diff --git a/packages/fxa-auth-server/package.json b/packages/fxa-auth-server/package.json index e4bd4d58e9d..4939c5043b0 100644 --- a/packages/fxa-auth-server/package.json +++ b/packages/fxa-auth-server/package.json @@ -133,7 +133,6 @@ "@types/nodemailer": "^7.0.4", "@types/request": "2.48.5", "@types/sass": "^1", - "@types/sinon": "^17.0.3", "@types/uuid": "^10.0.0", "@types/verror": "^1.10.4", "@types/webpack": "5.28.5", @@ -168,12 +167,10 @@ "nx": "21.2.4", "pm2": "^6.0.14", "prettier": "^3.5.3", - "proxyquire": "^2.1.3", "read": "3.0.1", "rimraf": "^6.0.1", "sass": "^1.80.4", "simplesmtp": "0.3.35", - "sinon": "^9.0.3", "storybook": "^8.0.0", "through": "2.3.8", "ts-jest": "^29.1.2", diff --git a/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts b/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts index 01487782ff5..e0f3902e766 100644 --- a/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts +++ b/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - import { PlanCanceller } from './cancel-subscriptions-to-plan'; import Stripe from 'stripe'; import { StripeHelper } from '../../lib/payments/stripe'; @@ -25,7 +23,7 @@ describe('PlanCanceller', () => { beforeEach(() => { stripeStub = { - on: sinon.stub(), + on: jest.fn(), products: {}, customers: {}, subscriptions: {}, @@ -36,12 +34,12 @@ describe('PlanCanceller', () => { stripeHelperStub = { stripe: stripeStub, currencyHelper: { - isCurrencyCompatibleWithCountry: sinon.stub(), + isCurrencyCompatibleWithCountry: jest.fn(), }, } as unknown as StripeHelper; paypalHelperStub = { - refundInvoice: sinon.stub(), + refundInvoice: jest.fn(), } as unknown as PayPalHelper; planCanceller = new PlanCanceller( @@ -126,8 +124,8 @@ describe('PlanCanceller', () => { }); describe('run', () => { - let processSubscriptionStub: sinon.SinonStub; - let writeReportHeaderStub: sinon.SinonStub; + let processSubscriptionStub: jest.Mock; + let writeReportHeaderStub: jest.Mock; const mockSubs = [mockSubscription]; beforeEach(async () => { @@ -140,33 +138,33 @@ describe('PlanCanceller', () => { }, }; - stripeStub.subscriptions.list = sinon - .stub() - .returns(asyncIterable) as any; + stripeStub.subscriptions.list = jest + .fn() + .mockReturnValue(asyncIterable) as any; - processSubscriptionStub = sinon.stub().resolves(); + processSubscriptionStub = jest.fn().mockResolvedValue(undefined); planCanceller.processSubscription = processSubscriptionStub; - writeReportHeaderStub = sinon.stub().resolves(); + writeReportHeaderStub = jest.fn().mockResolvedValue(undefined); planCanceller.writeReportHeader = writeReportHeaderStub; await planCanceller.run(); }); it('writes report header', () => { - expect(writeReportHeaderStub.calledOnce).toBe(true); + expect(writeReportHeaderStub).toHaveBeenCalledTimes(1); }); it('calls Stripe subscriptions.list with correct parameters', () => { - sinon.assert.calledWith(stripeStub.subscriptions.list as any, { + expect(stripeStub.subscriptions.list as any).toHaveBeenCalledWith({ price: 'planId', limit: 100, }); }); it('processes each subscription', () => { - sinon.assert.calledOnce(processSubscriptionStub); - sinon.assert.calledWith(processSubscriptionStub, mockSubscription); + expect(processSubscriptionStub).toHaveBeenCalledTimes(1); + expect(processSubscriptionStub).toHaveBeenCalledWith(mockSubscription); }); }); @@ -179,37 +177,39 @@ describe('PlanCanceller', () => { }, status: 'active', } as unknown as Stripe.Subscription; - let logStub: sinon.SinonStub; - let cancelStub: sinon.SinonStub; - let attemptFullRefundStub: sinon.SinonStub; - let attemptProratedRefundStub: sinon.SinonStub; - let isCustomerExcludedStub: sinon.SinonStub; - let writeReportStub: sinon.SinonStub; + let logStub: jest.Mock; + let cancelStub: jest.Mock; + let attemptFullRefundStub: jest.Mock; + let attemptProratedRefundStub: jest.Mock; + let isCustomerExcludedStub: jest.Mock; + let writeReportStub: jest.Mock; beforeEach(async () => { - stripeStub.products.retrieve = sinon.stub().resolves(mockProduct); - stripeStub.subscriptions.cancel = sinon.stub().resolves(); - cancelStub = stripeStub.subscriptions.cancel as sinon.SinonStub; + stripeStub.products.retrieve = jest.fn().mockResolvedValue(mockProduct); + stripeStub.subscriptions.cancel = jest.fn().mockResolvedValue(undefined); + cancelStub = stripeStub.subscriptions.cancel as jest.Mock; - planCanceller.fetchCustomer = sinon.stub().resolves(mockCustomer) as any; + planCanceller.fetchCustomer = jest + .fn() + .mockResolvedValue(mockCustomer) as any; - attemptFullRefundStub = sinon.stub().resolves(1000); + attemptFullRefundStub = jest.fn().mockResolvedValue(1000); planCanceller.attemptFullRefund = attemptFullRefundStub; - attemptProratedRefundStub = sinon.stub().resolves(500); + attemptProratedRefundStub = jest.fn().mockResolvedValue(500); planCanceller.attemptProratedRefund = attemptProratedRefundStub; - isCustomerExcludedStub = sinon.stub().returns(false); + isCustomerExcludedStub = jest.fn().mockReturnValue(false); planCanceller.isCustomerExcluded = isCustomerExcludedStub; - writeReportStub = sinon.stub().resolves(); + writeReportStub = jest.fn().mockResolvedValue(undefined); planCanceller.writeReport = writeReportStub; - logStub = sinon.stub(console, 'log'); + logStub = jest.spyOn(console, 'log') as unknown as jest.Mock; }); afterEach(() => { - logStub.restore(); + logStub.mockRestore(); }); describe('success - not excluded', () => { @@ -218,11 +218,13 @@ describe('PlanCanceller', () => { }); it('fetches customer', () => { - sinon.assert.calledOnce(planCanceller.fetchCustomer as sinon.SinonStub); + expect(planCanceller.fetchCustomer as jest.Mock).toHaveBeenCalledTimes( + 1 + ); }); it('cancels subscription', () => { - sinon.assert.calledWith(cancelStub, 'test', { + expect(cancelStub).toHaveBeenCalledWith('test', { prorate: false, cancellation_details: { comment: 'administrative_cancellation:subplat_script', @@ -231,9 +233,8 @@ describe('PlanCanceller', () => { }); it('writes report', () => { - sinon.assert.calledWith( - writeReportStub, - sinon.match({ + expect(writeReportStub).toHaveBeenCalledWith( + expect.objectContaining({ subscription: mockSub, customer: mockCustomer, isExcluded: false, @@ -247,14 +248,13 @@ describe('PlanCanceller', () => { describe('success - with refund', () => { beforeEach(async () => { - attemptFullRefundStub.resolves(1000); + attemptFullRefundStub.mockResolvedValue(1000); await planCanceller.processSubscription(mockSub); }); it('writes report with refund amount', () => { - sinon.assert.calledWith( - writeReportStub, - sinon.match({ + expect(writeReportStub).toHaveBeenCalledWith( + expect.objectContaining({ subscription: mockSub, customer: mockCustomer, isExcluded: false, @@ -273,17 +273,16 @@ describe('PlanCanceller', () => { }); it('does not cancel subscription', () => { - sinon.assert.notCalled(cancelStub); + expect(cancelStub).not.toHaveBeenCalled(); }); it('attempts refund', () => { - sinon.assert.calledOnce(attemptFullRefundStub); + expect(attemptFullRefundStub).toHaveBeenCalledTimes(1); }); it('writes report', () => { - sinon.assert.calledWith( - writeReportStub, - sinon.match({ + expect(writeReportStub).toHaveBeenCalledWith( + expect.objectContaining({ subscription: mockSub, customer: mockCustomer, isExcluded: false, @@ -297,18 +296,17 @@ describe('PlanCanceller', () => { describe('customer excluded', () => { beforeEach(async () => { - isCustomerExcludedStub.returns(true); + isCustomerExcludedStub.mockReturnValue(true); await planCanceller.processSubscription(mockSub); }); it('does not cancel subscription', () => { - sinon.assert.notCalled(cancelStub); + expect(cancelStub).not.toHaveBeenCalled(); }); it('writes report marking as excluded', () => { - sinon.assert.calledWith( - writeReportStub, - sinon.match({ + expect(writeReportStub).toHaveBeenCalledWith( + expect.objectContaining({ subscription: mockSub, customer: mockCustomer, isExcluded: true, @@ -322,12 +320,11 @@ describe('PlanCanceller', () => { describe('invalid', () => { it('writes error report if customer does not exist', async () => { - planCanceller.fetchCustomer = sinon.stub().resolves(null) as any; + planCanceller.fetchCustomer = jest.fn().mockResolvedValue(null) as any; await planCanceller.processSubscription(mockSub); - sinon.assert.calledWith( - writeReportStub, - sinon.match({ + expect(writeReportStub).toHaveBeenCalledWith( + expect.objectContaining({ subscription: mockSub, customer: null, isExcluded: false, @@ -339,12 +336,11 @@ describe('PlanCanceller', () => { }); it('writes error report if unexpected error occurs', async () => { - cancelStub.rejects(new Error('test error')); + cancelStub.mockRejectedValue(new Error('test error')); await planCanceller.processSubscription(mockSub); - sinon.assert.calledWith( - writeReportStub, - sinon.match({ + expect(writeReportStub).toHaveBeenCalledWith( + expect.objectContaining({ subscription: mockSub, customer: null, isExcluded: false, @@ -358,27 +354,25 @@ describe('PlanCanceller', () => { }); describe('fetchCustomer', () => { - let customerRetrieveStub: sinon.SinonStub; + let customerRetrieveStub: jest.Mock; let result: Stripe.Customer | Stripe.DeletedCustomer | null; describe('customer exists', () => { beforeEach(async () => { - customerRetrieveStub = sinon.stub().resolves(mockCustomer); + customerRetrieveStub = jest.fn().mockResolvedValue(mockCustomer); stripeStub.customers.retrieve = customerRetrieveStub; result = await planCanceller.fetchCustomer(mockCustomer.id); }); it('fetches customer from Stripe', () => { - expect( - customerRetrieveStub.calledWith(mockCustomer.id, { - expand: ['subscriptions'], - }) - ).toBe(true); + expect(customerRetrieveStub).toHaveBeenCalledWith(mockCustomer.id, { + expand: ['subscriptions'], + }); }); it('returns customer', () => { - sinon.assert.match(result, mockCustomer); + expect(result).toEqual(mockCustomer); }); }); @@ -388,14 +382,14 @@ describe('PlanCanceller', () => { ...mockCustomer, deleted: true, }; - customerRetrieveStub = sinon.stub().resolves(deletedCustomer); + customerRetrieveStub = jest.fn().mockResolvedValue(deletedCustomer); stripeStub.customers.retrieve = customerRetrieveStub; result = await planCanceller.fetchCustomer(mockCustomer.id); }); it('returns null', () => { - sinon.assert.match(result, null); + expect(result).toEqual(null); }); }); }); @@ -433,9 +427,9 @@ describe('PlanCanceller', () => { }); describe('attemptFullRefund', () => { - let invoiceRetrieveStub: sinon.SinonStub; - let refundCreateStub: sinon.SinonStub; - let refundInvoiceStub: sinon.SinonStub; + let invoiceRetrieveStub: jest.Mock; + let refundCreateStub: jest.Mock; + let refundInvoiceStub: jest.Mock; const mockFullRefundInvoice = { charge: 'ch_123', amount_due: 1000, @@ -443,13 +437,13 @@ describe('PlanCanceller', () => { }; beforeEach(() => { - invoiceRetrieveStub = sinon.stub().resolves(mockFullRefundInvoice); + invoiceRetrieveStub = jest.fn().mockResolvedValue(mockFullRefundInvoice); stripeStub.invoices.retrieve = invoiceRetrieveStub; - refundCreateStub = sinon.stub().resolves(); + refundCreateStub = jest.fn().mockResolvedValue(undefined); stripeStub.refunds.create = refundCreateStub; - refundInvoiceStub = sinon.stub().resolves(); + refundInvoiceStub = jest.fn().mockResolvedValue(undefined); paypalHelperStub.refundInvoice = refundInvoiceStub; }); @@ -459,14 +453,13 @@ describe('PlanCanceller', () => { }); it('retrieves invoice', () => { - sinon.assert.calledWith( - invoiceRetrieveStub, + expect(invoiceRetrieveStub).toHaveBeenCalledWith( mockSubscription.latest_invoice ); }); it('creates refund', () => { - sinon.assert.calledWith(refundCreateStub, { + expect(refundCreateStub).toHaveBeenCalledWith({ charge: mockFullRefundInvoice.charge, }); }); @@ -484,12 +477,12 @@ describe('PlanCanceller', () => { }; beforeEach(async () => { - invoiceRetrieveStub.resolves(mockPaypalInvoice); + invoiceRetrieveStub.mockResolvedValue(mockPaypalInvoice); await planCanceller.attemptFullRefund(mockSubscription); }); it('calls PayPal refund', () => { - sinon.assert.calledWith(refundInvoiceStub, mockPaypalInvoice); + expect(refundInvoiceStub).toHaveBeenCalledWith(mockPaypalInvoice); }); }); @@ -500,7 +493,7 @@ describe('PlanCanceller', () => { }); it('does not create refund', () => { - sinon.assert.notCalled(refundCreateStub); + expect(refundCreateStub).not.toHaveBeenCalled(); }); }); @@ -516,7 +509,7 @@ describe('PlanCanceller', () => { }); it('throws if invoice has no charge', async () => { - invoiceRetrieveStub.resolves({ + invoiceRetrieveStub.mockResolvedValue({ ...mockFullRefundInvoice, charge: null, }); @@ -528,9 +521,9 @@ describe('PlanCanceller', () => { }); describe('attemptProratedRefund', () => { - let invoiceRetrieveStub: sinon.SinonStub; - let refundCreateStub: sinon.SinonStub; - let refundInvoiceStub: sinon.SinonStub; + let invoiceRetrieveStub: jest.Mock; + let refundCreateStub: jest.Mock; + let refundInvoiceStub: jest.Mock; const now = Math.floor(Date.now() / 1000); const mockProratedSubscription = { ...mockSubscription, @@ -546,13 +539,13 @@ describe('PlanCanceller', () => { }; beforeEach(() => { - invoiceRetrieveStub = sinon.stub().resolves(mockProratedInvoice); + invoiceRetrieveStub = jest.fn().mockResolvedValue(mockProratedInvoice); stripeStub.invoices.retrieve = invoiceRetrieveStub; - refundCreateStub = sinon.stub().resolves(); + refundCreateStub = jest.fn().mockResolvedValue(undefined); stripeStub.refunds.create = refundCreateStub; - refundInvoiceStub = sinon.stub().resolves(); + refundInvoiceStub = jest.fn().mockResolvedValue(undefined); paypalHelperStub.refundInvoice = refundInvoiceStub; planCanceller = new PlanCanceller( @@ -573,8 +566,7 @@ describe('PlanCanceller', () => { await planCanceller.attemptProratedRefund( mockProratedSubscription as any ); - sinon.assert.calledWith( - invoiceRetrieveStub, + expect(invoiceRetrieveStub).toHaveBeenCalledWith( mockProratedSubscription.latest_invoice ); }); @@ -593,9 +585,8 @@ describe('PlanCanceller', () => { const daysRemaining = Math.floor(timeRemainingMs / oneDayMs); const expectedRefund = daysRemaining * 100; - sinon.assert.calledWith( - refundCreateStub, - sinon.match({ + expect(refundCreateStub).toHaveBeenCalledWith( + expect.objectContaining({ charge: mockProratedInvoice.charge, amount: expectedRefund, }) @@ -610,7 +601,7 @@ describe('PlanCanceller', () => { }; beforeEach(async () => { - invoiceRetrieveStub.resolves(mockPaypalInvoice); + invoiceRetrieveStub.mockResolvedValue(mockPaypalInvoice); await planCanceller.attemptProratedRefund( mockProratedSubscription as any ); @@ -626,7 +617,7 @@ describe('PlanCanceller', () => { const daysRemaining = Math.floor(timeRemainingMs / oneDayMs); const expectedRefund = daysRemaining * 100; - sinon.assert.calledWith(refundInvoiceStub, mockPaypalInvoice, { + expect(refundInvoiceStub).toHaveBeenCalledWith(mockPaypalInvoice, { refundType: 'Partial', amount: expectedRefund, }); @@ -642,7 +633,7 @@ describe('PlanCanceller', () => { }); it('does not create refund', () => { - sinon.assert.notCalled(refundCreateStub); + expect(refundCreateStub).not.toHaveBeenCalled(); }); }); @@ -658,7 +649,7 @@ describe('PlanCanceller', () => { }); it('throws if invoice is not paid', async () => { - invoiceRetrieveStub.resolves({ + invoiceRetrieveStub.mockResolvedValue({ ...mockProratedInvoice, paid: false, }); @@ -672,14 +663,14 @@ describe('PlanCanceller', () => { ...mockProratedInvoice, amount_due: 0, }; - invoiceRetrieveStub.resolves(mockSmallInvoice); + invoiceRetrieveStub.mockResolvedValue(mockSmallInvoice); await expect( planCanceller.attemptProratedRefund(mockProratedSubscription as any) ).rejects.toThrow('eclipse the amount due'); }); it('throws if invoice has no charge for Stripe refund', async () => { - invoiceRetrieveStub.resolves({ + invoiceRetrieveStub.mockResolvedValue({ ...mockProratedInvoice, charge: null, }); diff --git a/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts b/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts index d2859375eed..13c5035fd2c 100644 --- a/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts +++ b/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import Container from 'typedi'; import { ConfigType } from '../../config'; @@ -33,9 +32,9 @@ describe('FirestoreStripeSyncChecker', () => { beforeEach(() => { firestoreStub = { - collection: sinon.stub().returns({ - doc: sinon.stub().returns({ - get: sinon.stub(), + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn(), }), }), }; @@ -44,10 +43,10 @@ describe('FirestoreStripeSyncChecker', () => { Container.set(AppConfig, mockConfig); stripeStub = { - on: sinon.stub(), + on: jest.fn(), customers: { - list: sinon.stub(), - update: sinon.stub(), + list: jest.fn(), + update: jest.fn(), }, } as unknown as Stripe; @@ -56,9 +55,9 @@ describe('FirestoreStripeSyncChecker', () => { } as unknown as StripeHelper; logStub = { - info: sinon.stub(), - warn: sinon.stub(), - error: sinon.stub(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), }; syncChecker = new FirestoreStripeSyncChecker(stripeHelperStub, 20, logStub); @@ -69,70 +68,71 @@ describe('FirestoreStripeSyncChecker', () => { }); describe('run', () => { - let autoPagingEachStub: sinon.SinonStub; - let checkCustomerSyncStub: sinon.SinonStub; + let autoPagingEachStub: jest.Mock; + let checkCustomerSyncStub: jest.Mock; beforeEach(async () => { - autoPagingEachStub = sinon.stub().callsFake(async (callback: any) => { - await callback(mockCustomer); - }); + autoPagingEachStub = jest + .fn() + .mockImplementation(async (callback: any) => { + await callback(mockCustomer); + }); - stripeStub.customers.list = sinon.stub().returns({ + stripeStub.customers.list = jest.fn().mockReturnValue({ autoPagingEach: autoPagingEachStub, }) as any; - checkCustomerSyncStub = sinon.stub().resolves(); + checkCustomerSyncStub = jest.fn().mockResolvedValue(undefined); syncChecker.checkCustomerSync = checkCustomerSyncStub; await syncChecker.run(); }); it('calls Stripe customers.list', () => { - sinon.assert.calledWith(stripeStub.customers.list as any, { + expect(stripeStub.customers.list as any).toHaveBeenCalledWith({ limit: 25, }); }); it('calls autoPagingEach to iterate through all customers', () => { - sinon.assert.calledOnce(autoPagingEachStub); + expect(autoPagingEachStub).toHaveBeenCalledTimes(1); }); it('checks sync for each customer', () => { - sinon.assert.calledOnce(checkCustomerSyncStub); - sinon.assert.calledWith(checkCustomerSyncStub, mockCustomer); + expect(checkCustomerSyncStub).toHaveBeenCalledTimes(1); + expect(checkCustomerSyncStub).toHaveBeenCalledWith(mockCustomer); }); it('logs summary', () => { - sinon.assert.calledWith( - logStub.info, + expect(logStub.info).toHaveBeenCalledWith( 'firestore-stripe-sync-check-complete', - sinon.match.object + expect.any(Object) ); }); }); describe('checkCustomerSync', () => { - let checkSubscriptionSyncStub: sinon.SinonStub; + let checkSubscriptionSyncStub: jest.Mock; beforeEach(() => { - checkSubscriptionSyncStub = sinon.stub().resolves(); + checkSubscriptionSyncStub = jest.fn().mockResolvedValue(undefined); }); describe('customer in sync', () => { const mockFirestoreCustomer = Object.assign({}, mockCustomer); beforeEach(async () => { - const collectionStub = sinon.stub().returns({ - doc: sinon.stub().returns({ - get: sinon.stub().resolves({ + const collectionStub = jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: true, - data: sinon.stub().returns(mockFirestoreCustomer), + data: jest.fn().mockReturnValue(mockFirestoreCustomer), }), - collection: sinon.stub().returns({ - doc: sinon.stub().returns({ - get: sinon.stub().resolves({ + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: true, - data: sinon.stub().returns({ status: 'active' }), + data: jest.fn().mockReturnValue({ status: 'active' }), }), }), }), @@ -143,7 +143,7 @@ describe('FirestoreStripeSyncChecker', () => { Container.set(AuthFirestore, firestoreStub); stripeStub.subscriptions = { - list: sinon.stub().resolves({ + list: jest.fn().mockResolvedValue({ data: [mockSubscription], }), } as any; @@ -159,8 +159,7 @@ describe('FirestoreStripeSyncChecker', () => { }); it('checks subscription sync', () => { - sinon.assert.calledWith( - checkSubscriptionSyncStub, + expect(checkSubscriptionSyncStub).toHaveBeenCalledWith( mockCustomer.id, mockCustomer.metadata.userid, mockSubscription @@ -168,17 +167,17 @@ describe('FirestoreStripeSyncChecker', () => { }); it('does not log out of sync', () => { - sinon.assert.notCalled(logStub.warn); + expect(logStub.warn).not.toHaveBeenCalled(); }); }); describe('customer missing in Firestore', () => { - let handleOutOfSyncStub: sinon.SinonStub; + let handleOutOfSyncStub: jest.Mock; beforeEach(async () => { - const collectionStub = sinon.stub().returns({ - doc: sinon.stub().returns({ - get: sinon.stub().resolves({ + const collectionStub = jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: false, }), }), @@ -187,7 +186,7 @@ describe('FirestoreStripeSyncChecker', () => { firestoreStub.collection = collectionStub; Container.set(AuthFirestore, firestoreStub); - handleOutOfSyncStub = sinon.stub(); + handleOutOfSyncStub = jest.fn(); syncChecker = new FirestoreStripeSyncChecker( stripeHelperStub, @@ -200,8 +199,7 @@ describe('FirestoreStripeSyncChecker', () => { }); it('handles out of sync', () => { - sinon.assert.calledWith( - handleOutOfSyncStub, + expect(handleOutOfSyncStub).toHaveBeenCalledWith( mockCustomer.id, 'Customer exists in Stripe but not in Firestore', 'customer_missing' @@ -210,18 +208,18 @@ describe('FirestoreStripeSyncChecker', () => { }); describe('customer metadata mismatch', () => { - let handleOutOfSyncStub: sinon.SinonStub; + let handleOutOfSyncStub: jest.Mock; const mismatchedFirestoreCustomer = { email: 'different@example.com', created: mockCustomer.created, }; beforeEach(async () => { - const collectionStub = sinon.stub().returns({ - doc: sinon.stub().returns({ - get: sinon.stub().resolves({ + const collectionStub = jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: true, - data: sinon.stub().returns(mismatchedFirestoreCustomer), + data: jest.fn().mockReturnValue(mismatchedFirestoreCustomer), }), }), }); @@ -229,7 +227,7 @@ describe('FirestoreStripeSyncChecker', () => { firestoreStub.collection = collectionStub; Container.set(AuthFirestore, firestoreStub); - handleOutOfSyncStub = sinon.stub(); + handleOutOfSyncStub = jest.fn(); syncChecker = new FirestoreStripeSyncChecker( stripeHelperStub, @@ -242,8 +240,7 @@ describe('FirestoreStripeSyncChecker', () => { }); it('handles out of sync', () => { - sinon.assert.calledWith( - handleOutOfSyncStub, + expect(handleOutOfSyncStub).toHaveBeenCalledWith( mockCustomer.id, 'Customer mismatch', 'customer_mismatch' @@ -268,42 +265,43 @@ describe('FirestoreStripeSyncChecker', () => { describe('error checking customer', () => { beforeEach(async () => { - firestoreStub.collection = sinon.stub().returns({ - doc: sinon.stub().throws(new Error('Firestore error')), + firestoreStub.collection = jest.fn().mockReturnValue({ + doc: jest.fn().mockImplementation(() => { + throw new Error('Firestore error'); + }), }); await syncChecker.checkCustomerSync(mockCustomer); }); it('logs error', () => { - sinon.assert.calledWith( - logStub.error, + expect(logStub.error).toHaveBeenCalledWith( 'error-checking-customer', - sinon.match.object + expect.any(Object) ); }); }); }); describe('checkSubscriptionSync', () => { - let handleOutOfSyncStub: sinon.SinonStub; + let handleOutOfSyncStub: jest.Mock; const mockFirestoreSubscription = Object.assign({}, mockSubscription); beforeEach(() => { - handleOutOfSyncStub = sinon.stub(); + handleOutOfSyncStub = jest.fn(); syncChecker.handleOutOfSync = handleOutOfSyncStub; }); describe('subscription in sync', () => { beforeEach(async () => { - const collectionStub = sinon.stub().returns({ - doc: sinon.stub().returns({ - collection: sinon.stub().returns({ - doc: sinon.stub().returns({ - get: sinon.stub().resolves({ + const collectionStub = jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: true, - data: sinon.stub().returns(mockFirestoreSubscription), + data: jest.fn().mockReturnValue(mockFirestoreSubscription), }), }), }), @@ -329,17 +327,17 @@ describe('FirestoreStripeSyncChecker', () => { }); it('does not call handleOutOfSync', () => { - sinon.assert.notCalled(handleOutOfSyncStub); + expect(handleOutOfSyncStub).not.toHaveBeenCalled(); }); }); describe('subscription missing in Firestore', () => { beforeEach(async () => { - const collectionStub = sinon.stub().returns({ - doc: sinon.stub().returns({ - collection: sinon.stub().returns({ - doc: sinon.stub().returns({ - get: sinon.stub().resolves({ + const collectionStub = jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: false, }), }), @@ -366,8 +364,7 @@ describe('FirestoreStripeSyncChecker', () => { }); it('handles out of sync', () => { - sinon.assert.calledWith( - handleOutOfSyncStub, + expect(handleOutOfSyncStub).toHaveBeenCalledWith( mockCustomer.id, 'Subscription exists in Stripe but not in Firestore', 'subscription_missing', @@ -383,13 +380,13 @@ describe('FirestoreStripeSyncChecker', () => { status: 'canceled', }; - const collectionStub = sinon.stub().returns({ - doc: sinon.stub().returns({ - collection: sinon.stub().returns({ - doc: sinon.stub().returns({ - get: sinon.stub().resolves({ + const collectionStub = jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({ exists: true, - data: sinon.stub().returns(mismatchedSubscription), + data: jest.fn().mockReturnValue(mismatchedSubscription), }), }), }), @@ -415,8 +412,7 @@ describe('FirestoreStripeSyncChecker', () => { }); it('handles out of sync', () => { - sinon.assert.calledWith( - handleOutOfSyncStub, + expect(handleOutOfSyncStub).toHaveBeenCalledWith( mockCustomer.id, 'Subscription data mismatch', 'subscription_mismatch', @@ -505,10 +501,10 @@ describe('FirestoreStripeSyncChecker', () => { }); describe('handleOutOfSync', () => { - let triggerResyncStub: sinon.SinonStub; + let triggerResyncStub: jest.Mock; beforeEach(() => { - triggerResyncStub = sinon.stub().resolves(); + triggerResyncStub = jest.fn().mockResolvedValue(undefined); syncChecker.triggerResync = triggerResyncStub; }); @@ -553,12 +549,15 @@ describe('FirestoreStripeSyncChecker', () => { mockSubscription.id ); - sinon.assert.calledWith(logStub.warn, 'firestore-stripe-out-of-sync', { - customerId: mockCustomer.id, - subscriptionId: mockSubscription.id, - reason: 'Test reason', - type: 'customer_missing', - }); + expect(logStub.warn).toHaveBeenCalledWith( + 'firestore-stripe-out-of-sync', + { + customerId: mockCustomer.id, + subscriptionId: mockSubscription.id, + reason: 'Test reason', + type: 'customer_missing', + } + ); }); it('triggers resync', () => { @@ -567,38 +566,36 @@ describe('FirestoreStripeSyncChecker', () => { 'Test reason', 'customer_missing' ); - sinon.assert.calledWith(triggerResyncStub, mockCustomer.id); + expect(triggerResyncStub).toHaveBeenCalledWith(mockCustomer.id); }); }); describe('triggerResync', () => { it('updates customer metadata with forcedResyncAt', async () => { - stripeStub.customers.update = sinon.stub().resolves(); + stripeStub.customers.update = jest.fn().mockResolvedValue(undefined); await syncChecker.triggerResync(mockCustomer.id); - sinon.assert.calledWith( - stripeStub.customers.update as any, + expect(stripeStub.customers.update as any).toHaveBeenCalledWith( mockCustomer.id, - sinon.match({ + expect.objectContaining({ metadata: { - forcedResyncAt: sinon.match.string, + forcedResyncAt: expect.any(String), }, }) ); }); it('logs error on failure', async () => { - stripeStub.customers.update = sinon - .stub() - .rejects(new Error('Update failed')); + stripeStub.customers.update = jest + .fn() + .mockRejectedValue(new Error('Update failed')); await syncChecker.triggerResync(mockCustomer.id); - sinon.assert.calledWith( - logStub.error, + expect(logStub.error).toHaveBeenCalledWith( 'failed-to-trigger-resync', - sinon.match.object + expect.any(Object) ); }); }); diff --git a/packages/fxa-auth-server/scripts/cleanup-old-carts/cleanup-old-carts.spec.ts b/packages/fxa-auth-server/scripts/cleanup-old-carts/cleanup-old-carts.spec.ts index 99a1bfc07c9..a0ad1fdb0bb 100644 --- a/packages/fxa-auth-server/scripts/cleanup-old-carts/cleanup-old-carts.spec.ts +++ b/packages/fxa-auth-server/scripts/cleanup-old-carts/cleanup-old-carts.spec.ts @@ -2,17 +2,16 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import { CartCleanup } from './cleanup-old-carts'; describe('CartCleanup', () => { let cartCleanup: CartCleanup; let dbStub: { - deleteFrom: sinon.SinonSpy; - where: sinon.SinonSpy; - execute: sinon.SinonSpy; - updateTable: sinon.SinonSpy; - set: sinon.SinonSpy; + deleteFrom: jest.Mock; + where: jest.Mock; + execute: jest.Mock; + updateTable: jest.Mock; + set: jest.Mock; }; const deleteBefore = new Date('2024-01-01T00:00:00Z'); @@ -21,11 +20,11 @@ describe('CartCleanup', () => { beforeEach(() => { dbStub = { - deleteFrom: sinon.stub().returnsThis(), - where: sinon.stub().returnsThis(), - execute: sinon.stub().resolves(), - updateTable: sinon.stub().returnsThis(), - set: sinon.stub().returnsThis(), + deleteFrom: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue(undefined), + updateTable: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), }; cartCleanup = new CartCleanup( @@ -37,29 +36,33 @@ describe('CartCleanup', () => { }); afterEach(() => { - sinon.restore(); + jest.restoreAllMocks(); }); describe('run', () => { it('deletes old carts', async () => { await cartCleanup.run(); - expect(dbStub.deleteFrom.calledWith('carts')).toBe(true); - expect( - dbStub.where.calledWith('updatedAt', '<', deleteBefore.getTime()) - ).toBe(true); - expect(dbStub.execute.called).toBe(true); + expect(dbStub.deleteFrom).toHaveBeenCalledWith('carts'); + expect(dbStub.where).toHaveBeenCalledWith( + 'updatedAt', + '<', + deleteBefore.getTime() + ); + expect(dbStub.execute).toHaveBeenCalled(); }); it('anonymizes fields within carts', async () => { await cartCleanup.run(); - expect(dbStub.updateTable.calledWith('carts')).toBe(true); - expect( - dbStub.where.calledWith('updatedAt', '<', anonymizeBefore.getTime()) - ).toBe(true); - expect(dbStub.set.calledWith('taxAddress', null)).toBe(true); - expect(dbStub.execute.calledTwice).toBe(true); + expect(dbStub.updateTable).toHaveBeenCalledWith('carts'); + expect(dbStub.where).toHaveBeenCalledWith( + 'updatedAt', + '<', + anonymizeBefore.getTime() + ); + expect(dbStub.set).toHaveBeenCalledWith('taxAddress', null); + expect(dbStub.execute).toHaveBeenCalledTimes(2); }); it('does not anonymize if no fields are provided', async () => { @@ -71,7 +74,7 @@ describe('CartCleanup', () => { ); await cartCleanup.run(); - expect(dbStub.updateTable.called).toBe(false); + expect(dbStub.updateTable).not.toHaveBeenCalled(); }); it('does not anonymize if anonymizeBefore is null', async () => { @@ -83,7 +86,7 @@ describe('CartCleanup', () => { ); await cartCleanup.run(); - expect(dbStub.updateTable.called).toBe(false); + expect(dbStub.updateTable).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts index 7a27b33042e..3fa897fe55f 100644 --- a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts +++ b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import Container from 'typedi'; import fs from 'fs'; @@ -55,14 +54,14 @@ const mockConfig = { describe('StripeAutomaticTaxConverter', () => { let stripeAutomaticTaxConverter: StripeAutomaticTaxConverter; - let helperStub: sinon.SinonStubbedInstance; + let helperStub: any; let stripeStub: Stripe; let stripeHelperStub: StripeHelper; let dbStub: any; - let geodbStub: sinon.SinonStub; - let firestoreGetStub: sinon.SinonStub; + let geodbStub: jest.Mock; + let firestoreGetStub: jest.Mock; let mockIpAddressMapping: IpAddressMapFileEntry[]; - let readFileSyncStub: sinon.SinonStub; + let readFileSyncStub: jest.SpyInstance; beforeEach(() => { mockIpAddressMapping = [ @@ -71,35 +70,40 @@ describe('StripeAutomaticTaxConverter', () => { remote_address_chain: '1.1.1.1', }, ]; - readFileSyncStub = sinon - .stub(fs, 'readFileSync') - .returns(JSON.stringify(mockIpAddressMapping)); + readFileSyncStub = jest + .spyOn(fs, 'readFileSync') + .mockReturnValue(JSON.stringify(mockIpAddressMapping)); - firestoreGetStub = sinon.stub(); + firestoreGetStub = jest.fn(); Container.set(AuthFirestore, { - collectionGroup: sinon.stub().returns({ - where: sinon.stub().returnsThis(), - orderBy: sinon.stub().returnsThis(), - startAfter: sinon.stub().returnsThis(), - limit: sinon.stub().returnsThis(), + collectionGroup: jest.fn().mockReturnValue({ + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + startAfter: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), get: firestoreGetStub, }), }); Container.set(AppConfig, mockConfig); - helperStub = sinon.createStubInstance(StripeAutomaticTaxConverterHelpers); + helperStub = { + processIPAddressList: jest.fn(), + filterEligibleSubscriptions: jest.fn(), + isTaxEligible: jest.fn(), + getSpecialTaxAmounts: jest.fn(), + }; Container.set(StripeAutomaticTaxConverterHelpers, helperStub); - helperStub.processIPAddressList.returns({ + helperStub.processIPAddressList.mockReturnValue({ [mockIpAddressMapping[0].uid]: mockIpAddressMapping[0].remote_address_chain, }); - geodbStub = sinon.stub(); + geodbStub = jest.fn(); stripeStub = { - on: sinon.stub(), + on: jest.fn(), products: {}, customers: {}, subscriptions: {}, @@ -109,12 +113,12 @@ describe('StripeAutomaticTaxConverter', () => { stripeHelperStub = { stripe: stripeStub, currencyHelper: { - isCurrencyCompatibleWithCountry: sinon.stub(), + isCurrencyCompatibleWithCountry: jest.fn(), }, } as unknown as StripeHelper; dbStub = { - account: sinon.stub(), + account: jest.fn(), }; stripeAutomaticTaxConverter = new StripeAutomaticTaxConverter( @@ -129,29 +133,27 @@ describe('StripeAutomaticTaxConverter', () => { }); afterEach(() => { - readFileSyncStub.restore(); + readFileSyncStub.mockRestore(); Container.reset(); }); describe('convert', () => { - let fetchSubsBatchStub: sinon.SinonStub; - let generateReportForSubscriptionStub: sinon.SinonStub; + let fetchSubsBatchStub: jest.Mock; + let generateReportForSubscriptionStub: jest.Mock; const mockSubs = [mockSubscription]; beforeEach(async () => { - fetchSubsBatchStub = sinon - .stub() - .onFirstCall() - .returns(mockSubs) - .onSecondCall() - .returns([]); + fetchSubsBatchStub = jest + .fn() + .mockReturnValueOnce(mockSubs) + .mockReturnValueOnce([]); stripeAutomaticTaxConverter.fetchSubsBatch = fetchSubsBatchStub as any; - generateReportForSubscriptionStub = sinon.stub(); + generateReportForSubscriptionStub = jest.fn(); stripeAutomaticTaxConverter.generateReportForSubscription = generateReportForSubscriptionStub as any; - helperStub.filterEligibleSubscriptions.callsFake( + helperStub.filterEligibleSubscriptions.mockImplementation( (subscriptions) => subscriptions ); @@ -159,18 +161,18 @@ describe('StripeAutomaticTaxConverter', () => { }); it('fetches subscriptions until no results', () => { - expect(fetchSubsBatchStub.callCount).toBe(2); + expect(fetchSubsBatchStub).toHaveBeenCalledTimes(2); }); it('filters ineligible subscriptions', () => { - expect(helperStub.filterEligibleSubscriptions.callCount).toBe(2); - expect(helperStub.filterEligibleSubscriptions.calledWith(mockSubs)).toBe( - true + expect(helperStub.filterEligibleSubscriptions).toHaveBeenCalledTimes(2); + expect(helperStub.filterEligibleSubscriptions).toHaveBeenCalledWith( + mockSubs ); }); it('generates a report for each applicable subscription', () => { - expect(generateReportForSubscriptionStub.callCount).toBe(1); + expect(generateReportForSubscriptionStub).toHaveBeenCalledTimes(1); }); }); @@ -179,10 +181,10 @@ describe('StripeAutomaticTaxConverter', () => { let result: FirestoreSubscription[]; beforeEach(async () => { - firestoreGetStub.resolves({ + firestoreGetStub.mockResolvedValue({ docs: [ { - data: sinon.stub().returns(mockSubscription), + data: jest.fn().mockReturnValue(mockSubscription), }, ], }); @@ -192,7 +194,7 @@ describe('StripeAutomaticTaxConverter', () => { }); it('returns a list of subscriptions from Firestore', () => { - sinon.assert.match(result, [mockSubscription]); + expect(result).toEqual([mockSubscription]); }); }); @@ -205,55 +207,53 @@ describe('StripeAutomaticTaxConverter', () => { }, } as FirestoreSubscription; const mockReport = ['mock-report']; - let logStub: sinon.SinonStub; - let enableTaxForCustomer: sinon.SinonStub; - let enableTaxForSubscription: sinon.SinonStub; - let fetchInvoicePreview: sinon.SinonStub; - let buildReport: sinon.SinonStub; - let writeReportStub: sinon.SinonStub; + let logStub: jest.SpyInstance; + let enableTaxForCustomer: jest.Mock; + let enableTaxForSubscription: jest.Mock; + let fetchInvoicePreview: jest.Mock; + let buildReport: jest.Mock; + let writeReportStub: jest.Mock; beforeEach(async () => { - (stripeStub.products as any).retrieve = sinon - .stub() - .resolves(mockProduct); - fetchInvoicePreview = sinon.stub(); + (stripeStub.products as any).retrieve = jest + .fn() + .mockResolvedValue(mockProduct); + fetchInvoicePreview = jest.fn(); stripeAutomaticTaxConverter.fetchInvoicePreview = fetchInvoicePreview as any; - stripeAutomaticTaxConverter.fetchCustomer = sinon - .stub() - .resolves(mockCustomer) as any; - dbStub.account.resolves({ + stripeAutomaticTaxConverter.fetchCustomer = jest + .fn() + .mockResolvedValue(mockCustomer) as any; + dbStub.account.mockResolvedValue({ locale: 'en-US', }); - enableTaxForCustomer = sinon.stub().resolves(true); + enableTaxForCustomer = jest.fn().mockResolvedValue(true); stripeAutomaticTaxConverter.enableTaxForCustomer = enableTaxForCustomer as any; - stripeAutomaticTaxConverter.isExcludedSubscriptionProduct = sinon - .stub() - .returns(false) as any; - enableTaxForSubscription = sinon.stub().resolves(); + stripeAutomaticTaxConverter.isExcludedSubscriptionProduct = jest + .fn() + .mockReturnValue(false) as any; + enableTaxForSubscription = jest.fn().mockResolvedValue(undefined); stripeAutomaticTaxConverter.enableTaxForSubscription = enableTaxForSubscription as any; - fetchInvoicePreview = sinon - .stub() - .onFirstCall() - .resolves({ + fetchInvoicePreview = jest + .fn() + .mockResolvedValueOnce({ ...mockInvoicePreview, total: (mockInvoicePreview as any).total - 1, }) - .onSecondCall() - .resolves(mockInvoicePreview); + .mockResolvedValueOnce(mockInvoicePreview); stripeAutomaticTaxConverter.fetchInvoicePreview = fetchInvoicePreview as any; - buildReport = sinon.stub().returns(mockReport); + buildReport = jest.fn().mockReturnValue(mockReport); stripeAutomaticTaxConverter.buildReport = buildReport as any; - writeReportStub = sinon.stub().resolves(); + writeReportStub = jest.fn().mockResolvedValue(undefined); stripeAutomaticTaxConverter.writeReport = writeReportStub as any; - logStub = sinon.stub(console, 'log'); + logStub = jest.spyOn(console, 'log'); }); afterEach(() => { - logStub.restore(); + logStub.mockRestore(); }); describe('success', () => { @@ -264,97 +264,97 @@ describe('StripeAutomaticTaxConverter', () => { }); it('enables stripe tax for customer', () => { - expect(enableTaxForCustomer.calledWith(mockCustomer)).toBe(true); + expect(enableTaxForCustomer).toHaveBeenCalledWith(mockCustomer); }); it('enables stripe tax for subscription', () => { - expect(enableTaxForSubscription.calledWith(mockFirestoreSub.id)).toBe( - true + expect(enableTaxForSubscription).toHaveBeenCalledWith( + mockFirestoreSub.id ); }); it('fetches an invoice preview', () => { - expect(fetchInvoicePreview.calledWith(mockFirestoreSub.id)).toBe(true); + expect(fetchInvoicePreview).toHaveBeenCalledWith(mockFirestoreSub.id); }); it('writes the report to disk', () => { - expect(writeReportStub.calledWith(mockReport)).toBe(true); + expect(writeReportStub).toHaveBeenCalledWith(mockReport); }); }); describe('invalid', () => { it('aborts if customer does not exist', async () => { - stripeAutomaticTaxConverter.fetchCustomer = sinon - .stub() - .resolves(null) as any; + stripeAutomaticTaxConverter.fetchCustomer = jest + .fn() + .mockResolvedValue(null) as any; await stripeAutomaticTaxConverter.generateReportForSubscription( mockFirestoreSub ); - expect(enableTaxForCustomer.notCalled).toBe(true); - expect(enableTaxForSubscription.notCalled).toBe(true); - expect(writeReportStub.notCalled).toBe(true); + expect(enableTaxForCustomer).not.toHaveBeenCalled(); + expect(enableTaxForSubscription).not.toHaveBeenCalled(); + expect(writeReportStub).not.toHaveBeenCalled(); }); it('aborts if account for customer does not exist', async () => { - dbStub.account.resolves(null); + dbStub.account.mockResolvedValue(null); await stripeAutomaticTaxConverter.generateReportForSubscription( mockFirestoreSub ); - expect(enableTaxForCustomer.notCalled).toBe(true); - expect(enableTaxForSubscription.notCalled).toBe(true); - expect(writeReportStub.notCalled).toBe(true); + expect(enableTaxForCustomer).not.toHaveBeenCalled(); + expect(enableTaxForSubscription).not.toHaveBeenCalled(); + expect(writeReportStub).not.toHaveBeenCalled(); }); it('aborts if customer is not taxable', async () => { - stripeAutomaticTaxConverter.enableTaxForCustomer = sinon - .stub() - .resolves(false) as any; + stripeAutomaticTaxConverter.enableTaxForCustomer = jest + .fn() + .mockResolvedValue(false) as any; await stripeAutomaticTaxConverter.generateReportForSubscription( mockFirestoreSub ); - expect(enableTaxForCustomer.notCalled).toBe(true); - expect(enableTaxForSubscription.notCalled).toBe(true); - expect(writeReportStub.notCalled).toBe(true); + expect(enableTaxForCustomer).not.toHaveBeenCalled(); + expect(enableTaxForSubscription).not.toHaveBeenCalled(); + expect(writeReportStub).not.toHaveBeenCalled(); }); it('does not save report to CSV if total has not changed', async () => { - stripeAutomaticTaxConverter.fetchInvoicePreview = sinon - .stub() - .resolves(mockInvoicePreview) as any; + stripeAutomaticTaxConverter.fetchInvoicePreview = jest + .fn() + .mockResolvedValue(mockInvoicePreview) as any; await stripeAutomaticTaxConverter.generateReportForSubscription( mockFirestoreSub ); - expect(enableTaxForCustomer.called).toBe(true); - expect(enableTaxForSubscription.called).toBe(true); - expect(writeReportStub.notCalled).toBe(true); + expect(enableTaxForCustomer).toHaveBeenCalled(); + expect(enableTaxForSubscription).toHaveBeenCalled(); + expect(writeReportStub).not.toHaveBeenCalled(); }); it('does not update subscription for ineligible product', async () => { - stripeAutomaticTaxConverter.isExcludedSubscriptionProduct = sinon - .stub() - .returns(true) as any; + stripeAutomaticTaxConverter.isExcludedSubscriptionProduct = jest + .fn() + .mockReturnValue(true) as any; await stripeAutomaticTaxConverter.generateReportForSubscription( mockFirestoreSub ); - expect(enableTaxForCustomer.notCalled).toBe(true); - expect(enableTaxForSubscription.notCalled).toBe(true); - expect(writeReportStub.notCalled).toBe(true); + expect(enableTaxForCustomer).not.toHaveBeenCalled(); + expect(enableTaxForSubscription).not.toHaveBeenCalled(); + expect(writeReportStub).not.toHaveBeenCalled(); }); }); }); describe('fetchCustomer', () => { - let customerRetrieveStub: sinon.SinonStub; + let customerRetrieveStub: jest.Mock; let result: Stripe.Customer | null; describe('customer exists', () => { beforeEach(async () => { - customerRetrieveStub = sinon.stub().resolves(mockCustomer); + customerRetrieveStub = jest.fn().mockResolvedValue(mockCustomer); stripeStub.customers.retrieve = customerRetrieveStub as any; result = await stripeAutomaticTaxConverter.fetchCustomer( @@ -363,15 +363,13 @@ describe('StripeAutomaticTaxConverter', () => { }); it('fetches customer from Stripe', () => { - expect( - customerRetrieveStub.calledWith(mockCustomer.id, { - expand: ['tax'], - }) - ).toBe(true); + expect(customerRetrieveStub).toHaveBeenCalledWith(mockCustomer.id, { + expand: ['tax'], + }); }); it('returns customer', () => { - sinon.assert.match(result, mockCustomer); + expect(result).toEqual(mockCustomer); }); }); @@ -381,7 +379,7 @@ describe('StripeAutomaticTaxConverter', () => { ...mockCustomer, deleted: true, }; - customerRetrieveStub = sinon.stub().resolves(deletedCustomer); + customerRetrieveStub = jest.fn().mockResolvedValue(deletedCustomer); stripeStub.customers.retrieve = customerRetrieveStub as any; result = await stripeAutomaticTaxConverter.fetchCustomer( @@ -390,19 +388,19 @@ describe('StripeAutomaticTaxConverter', () => { }); it('returns null', () => { - sinon.assert.match(result, null); + expect(result).toEqual(null); }); }); }); describe('enableTaxForCustomer', () => { - let updateStub: sinon.SinonStub; + let updateStub: jest.Mock; let result: boolean; describe('tax already enabled', () => { beforeEach(async () => { - helperStub.isTaxEligible.returns(true); - updateStub = sinon.stub().resolves(mockCustomer); + helperStub.isTaxEligible.mockReturnValue(true); + updateStub = jest.fn().mockResolvedValue(mockCustomer); stripeStub.customers.update = updateStub as any; result = @@ -410,7 +408,7 @@ describe('StripeAutomaticTaxConverter', () => { }); it('does not update customer', () => { - expect(updateStub.notCalled).toBe(true); + expect(updateStub).not.toHaveBeenCalled(); }); it('returns true', () => { @@ -421,20 +419,18 @@ describe('StripeAutomaticTaxConverter', () => { describe('tax not enabled', () => { beforeEach(async () => { helperStub.isTaxEligible - .onFirstCall() - .returns(false) - .onSecondCall() - .returns(true); - updateStub = sinon.stub().resolves(mockCustomer); + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + updateStub = jest.fn().mockResolvedValue(mockCustomer); stripeStub.customers.update = updateStub as any; - stripeAutomaticTaxConverter.fetchCustomer = sinon - .stub() - .resolves(mockCustomer) as any; + stripeAutomaticTaxConverter.fetchCustomer = jest + .fn() + .mockResolvedValue(mockCustomer) as any; }); describe("invalid IP address, can't resolve geolocation", () => { beforeEach(async () => { - geodbStub.returns({}); + geodbStub.mockReturnValue({}); result = await stripeAutomaticTaxConverter.enableTaxForCustomer({ ...mockCustomer, @@ -445,7 +441,7 @@ describe('StripeAutomaticTaxConverter', () => { }); it('does not update customer', () => { - expect(updateStub.notCalled).toBe(true); + expect(updateStub).not.toHaveBeenCalled(); }); it('returns false', () => { @@ -455,14 +451,14 @@ describe('StripeAutomaticTaxConverter', () => { describe("invalid IP address, isn't in same country", () => { beforeEach(async () => { - geodbStub.returns({ + geodbStub.mockReturnValue({ postalCode: 'ABC', countryCode: 'ZZZ', }); ( stripeHelperStub.currencyHelper as any - ).isCurrencyCompatibleWithCountry = sinon.stub().returns(false); + ).isCurrencyCompatibleWithCountry = jest.fn().mockReturnValue(false); result = await stripeAutomaticTaxConverter.enableTaxForCustomer({ ...mockCustomer, @@ -473,7 +469,7 @@ describe('StripeAutomaticTaxConverter', () => { }); it('does not update customer', () => { - expect(updateStub.notCalled).toBe(true); + expect(updateStub).not.toHaveBeenCalled(); }); it('returns false', () => { @@ -483,14 +479,14 @@ describe('StripeAutomaticTaxConverter', () => { describe('valid IP address', () => { beforeEach(async () => { - geodbStub.returns({ + geodbStub.mockReturnValue({ countryCode: 'US', postalCode: 92841, }); ( stripeHelperStub.currencyHelper as any - ).isCurrencyCompatibleWithCountry = sinon.stub().returns(true); + ).isCurrencyCompatibleWithCountry = jest.fn().mockReturnValue(true); result = await stripeAutomaticTaxConverter.enableTaxForCustomer({ ...mockCustomer, @@ -501,17 +497,15 @@ describe('StripeAutomaticTaxConverter', () => { }); it('updates customer', () => { - expect( - updateStub.calledWith(mockCustomer.id, { - shipping: { - name: mockCustomer.email, - address: { - country: 'US', - postal_code: 92841, - }, + expect(updateStub).toHaveBeenCalledWith(mockCustomer.id, { + shipping: { + name: mockCustomer.email, + address: { + country: 'US', + postal_code: 92841, }, - }) - ).toBe(true); + }, + }); }); it('returns true', () => { @@ -544,13 +538,13 @@ describe('StripeAutomaticTaxConverter', () => { }); describe('enableTaxForSubscription', () => { - let updateStub: sinon.SinonStub; - let retrieveStub: sinon.SinonStub; + let updateStub: jest.Mock; + let retrieveStub: jest.Mock; beforeEach(async () => { - updateStub = sinon.stub().resolves(mockSubscription); + updateStub = jest.fn().mockResolvedValue(mockSubscription); stripeStub.subscriptions.update = updateStub as any; - retrieveStub = sinon.stub().resolves(mockSubscription); + retrieveStub = jest.fn().mockResolvedValue(mockSubscription); stripeStub.subscriptions.retrieve = retrieveStub as any; await stripeAutomaticTaxConverter.enableTaxForSubscription( @@ -559,30 +553,28 @@ describe('StripeAutomaticTaxConverter', () => { }); it('updates the subscription', () => { - expect( - updateStub.calledWith(mockSubscription.id, { - automatic_tax: { - enabled: true, + expect(updateStub).toHaveBeenCalledWith(mockSubscription.id, { + automatic_tax: { + enabled: true, + }, + proration_behavior: 'none', + items: [ + { + id: mockSubscription.items.data[0].id, + tax_rates: '', }, - proration_behavior: 'none', - items: [ - { - id: mockSubscription.items.data[0].id, - tax_rates: '', - }, - ], - default_tax_rates: '', - }) - ).toBe(true); + ], + default_tax_rates: '', + }); }); }); describe('fetchInvoicePreview', () => { let result: Stripe.Response; - let stub: sinon.SinonStub; + let stub: jest.Mock; beforeEach(async () => { - stub = sinon.stub().resolves(mockInvoicePreview); + stub = jest.fn().mockResolvedValue(mockInvoicePreview); stripeStub.invoices.retrieveUpcoming = stub as any; result = await stripeAutomaticTaxConverter.fetchInvoicePreview( @@ -591,16 +583,14 @@ describe('StripeAutomaticTaxConverter', () => { }); it('calls stripe for the invoice preview', () => { - expect( - stub.calledWith({ - subscription: mockSubscription.id, - expand: ['total_tax_amounts.tax_rate'], - }) - ).toBe(true); + expect(stub).toHaveBeenCalledWith({ + subscription: mockSubscription.id, + expand: ['total_tax_amounts.tax_rate'], + }); }); it('returns invoice preview', () => { - sinon.assert.match(result, mockInvoicePreview); + expect(result).toEqual(mockInvoicePreview); }); }); @@ -613,7 +603,7 @@ describe('StripeAutomaticTaxConverter', () => { qst: 13, rst: 14, }; - helperStub.getSpecialTaxAmounts.returns(mockSpecialTaxAmounts); + helperStub.getSpecialTaxAmounts.mockReturnValue(mockSpecialTaxAmounts); // Invoice preview with tax doesn't include total_excluding_tax which we need const _mockInvoicePreview = { @@ -630,7 +620,7 @@ describe('StripeAutomaticTaxConverter', () => { _mockInvoicePreview as any ); - sinon.assert.match(result, [ + expect(result).toEqual([ mockCustomer.metadata.userid, `"${mockCustomer.email}"`, mockProduct.id, diff --git a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts index 56ee84a89fe..e031e6cfb07 100644 --- a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts +++ b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - import { FirestoreSubscription, StripeAutomaticTaxConverterHelpers, @@ -45,28 +43,25 @@ describe('StripeAutomaticTaxConverterHelpers', () => { 'example-uid-2': '8.8.8.8', }; - sinon.assert.match(result, expected); + expect(result).toEqual(expected); }); }); describe('getClientIPFromRemoteAddressChain', () => { it('returns the first IP address when it is non-local', () => { - sinon.assert.match( - helpers.getClientIPFromRemoteAddressChain('["1.1.1.1","8.8.8.8"]'), - '1.1.1.1' - ); + expect( + helpers.getClientIPFromRemoteAddressChain('["1.1.1.1","8.8.8.8"]') + ).toEqual('1.1.1.1'); }); it('returns undefined if first IP address is local', () => { - sinon.assert.match( - helpers.getClientIPFromRemoteAddressChain('["192.168.1.1", "1.1.1.1"]'), - undefined - ); + expect( + helpers.getClientIPFromRemoteAddressChain('["192.168.1.1", "1.1.1.1"]') + ).toEqual(undefined); }); it('returns undefined if address chain is empty', () => { - sinon.assert.match( - helpers.getClientIPFromRemoteAddressChain('[]'), + expect(helpers.getClientIPFromRemoteAddressChain('[]')).toEqual( undefined ); }); @@ -154,18 +149,18 @@ describe('StripeAutomaticTaxConverterHelpers', () => { }); describe('filterEligibleSubscriptions', () => { - let willBeRenewed: sinon.SinonStub; - let isStripeTaxDisabled: sinon.SinonStub; - let isWithinNoticePeriod: sinon.SinonStub; + let willBeRenewed: jest.Mock; + let isStripeTaxDisabled: jest.Mock; + let isWithinNoticePeriod: jest.Mock; let subscriptions: FirestoreSubscription[]; let result: FirestoreSubscription[]; beforeEach(() => { - willBeRenewed = sinon.stub().returns(true); + willBeRenewed = jest.fn().mockReturnValue(true); helpers.willBeRenewed = willBeRenewed; - isStripeTaxDisabled = sinon.stub().returns(true); + isStripeTaxDisabled = jest.fn().mockReturnValue(true); helpers.isStripeTaxDisabled = isStripeTaxDisabled; - isWithinNoticePeriod = sinon.stub().returns(true); + isWithinNoticePeriod = jest.fn().mockReturnValue(true); helpers.isWithinNoticePeriod = isWithinNoticePeriod; subscriptions = [mockSubscription]; @@ -174,13 +169,28 @@ describe('StripeAutomaticTaxConverterHelpers', () => { }); it('filters via helper methods', () => { - expect(willBeRenewed.calledWith(subscription1)).toBe(true); - expect(isStripeTaxDisabled.calledWith(subscription1)).toBe(true); - expect(isWithinNoticePeriod.calledWith(subscription1)).toBe(true); + expect(willBeRenewed).toHaveBeenNthCalledWith( + 1, + subscription1, + expect.anything(), + expect.anything() + ); + expect(isStripeTaxDisabled).toHaveBeenNthCalledWith( + 1, + subscription1, + expect.anything(), + expect.anything() + ); + expect(isWithinNoticePeriod).toHaveBeenNthCalledWith( + 1, + subscription1, + expect.anything(), + expect.anything() + ); }); it('returns filtered results', () => { - sinon.assert.match(result, subscriptions); + expect(result).toEqual(subscriptions); }); }); @@ -279,14 +289,12 @@ describe('StripeAutomaticTaxConverterHelpers', () => { ], }, }; - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - clock = sinon.useFakeTimers(fakeToday.getTime()); + jest.useFakeTimers({ now: fakeToday.getTime() }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('returns true for yearly when more than 30 days out', () => { @@ -351,7 +359,7 @@ describe('StripeAutomaticTaxConverterHelpers', () => { it('formats special tax amounts', () => { const result = helpers.getSpecialTaxAmounts(mockTaxAmounts); - sinon.assert.match(result, { + expect(result).toEqual({ hst: 10, pst: 11, gst: 12, diff --git a/packages/fxa-auth-server/scripts/delete-inactive-accounts/lib.spec.ts b/packages/fxa-auth-server/scripts/delete-inactive-accounts/lib.spec.ts index 48ed6bd8deb..d06093f21a9 100644 --- a/packages/fxa-auth-server/scripts/delete-inactive-accounts/lib.spec.ts +++ b/packages/fxa-auth-server/scripts/delete-inactive-accounts/lib.spec.ts @@ -2,18 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import * as lib from './lib'; describe('delete inactive accounts script lib', () => { - let sandbox: sinon.SinonSandbox; - - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); + beforeEach(() => {}); afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); describe('setDateToUTC', () => { @@ -29,14 +24,15 @@ describe('delete inactive accounts script lib', () => { const ts = Date.now(); it('should be true when there is one recent enough session token', async () => { - const tokensFn = sandbox.stub().resolves([{ lastAccessTime: ts }]); + const tokensFn = jest.fn().mockResolvedValue([{ lastAccessTime: ts }]); const newerTsActual = await lib.hasActiveSessionToken( tokensFn, '9001', ts - 1000 ); expect(newerTsActual).toBe(true); - sinon.assert.calledOnceWithExactly(tokensFn, '9001'); + expect(tokensFn).toHaveBeenCalledTimes(1); + expect(tokensFn).toHaveBeenCalledWith('9001'); const equallyNewActual = await lib.hasActiveSessionToken( tokensFn, @@ -46,9 +42,9 @@ describe('delete inactive accounts script lib', () => { expect(equallyNewActual).toBe(true); }); it('should be true when there are multiple recent enough session tokens', async () => { - const tokensFn = sandbox - .stub() - .resolves([ + const tokensFn = jest + .fn() + .mockResolvedValue([ { lastAccessTime: ts - 9000 }, { lastAccessTime: ts }, { lastAccessTime: ts + 9000 }, @@ -61,7 +57,7 @@ describe('delete inactive accounts script lib', () => { expect(actual).toBe(true); }); it('should be false when there are no recent enough session tokens', async () => { - const noTokensFn = sandbox.stub().resolves([]); + const noTokensFn = jest.fn().mockResolvedValue([]); const noTokensActual = await lib.hasActiveSessionToken( noTokensFn, '9001', @@ -69,7 +65,9 @@ describe('delete inactive accounts script lib', () => { ); expect(noTokensActual).toBe(false); - const noTimestampTokensFn = sandbox.stub().resolves([{ uid: '9001' }]); + const noTimestampTokensFn = jest + .fn() + .mockResolvedValue([{ uid: '9001' }]); const noTimestampTokensActual = await lib.hasActiveSessionToken( noTimestampTokensFn, '9001', @@ -77,9 +75,9 @@ describe('delete inactive accounts script lib', () => { ); expect(noTimestampTokensActual).toBe(false); - const noRecentEnoughTokensFn = sandbox - .stub() - .resolves([{ lastAccessTime: ts }]); + const noRecentEnoughTokensFn = jest + .fn() + .mockResolvedValue([{ lastAccessTime: ts }]); const noRecentEnoughTokensActual = await lib.hasActiveSessionToken( noRecentEnoughTokensFn, '9001', @@ -93,14 +91,15 @@ describe('delete inactive accounts script lib', () => { const ts = Date.now(); it('should be true when there is a recent enough refresh token', async () => { - const tokensFn = sandbox.stub().resolves([{ lastUsedAt: ts }]); + const tokensFn = jest.fn().mockResolvedValue([{ lastUsedAt: ts }]); const newerTsActual = await lib.hasActiveRefreshToken( tokensFn, '9001', ts - 1000 ); expect(newerTsActual).toBe(true); - sinon.assert.calledOnceWithExactly(tokensFn, '9001'); + expect(tokensFn).toHaveBeenCalledTimes(1); + expect(tokensFn).toHaveBeenCalledWith('9001'); const equallyNewActual = await lib.hasActiveRefreshToken( tokensFn, @@ -110,9 +109,9 @@ describe('delete inactive accounts script lib', () => { expect(equallyNewActual).toBe(true); }); it('should be true when there are multiple recent enough refresh tokens', async () => { - const tokensFn = sandbox - .stub() - .resolves([ + const tokensFn = jest + .fn() + .mockResolvedValue([ { lastUsedAt: ts - 9000 }, { lastUsedAt: ts }, { lastUsedAt: ts + 9000 }, @@ -125,7 +124,7 @@ describe('delete inactive accounts script lib', () => { expect(actual).toBe(true); }); it('should be false when there are no recent enough refresh tokens', async () => { - const noTokensFn = sandbox.stub().resolves([]); + const noTokensFn = jest.fn().mockResolvedValue([]); const noTokensActual = await lib.hasActiveRefreshToken( noTokensFn, '9001', @@ -133,7 +132,9 @@ describe('delete inactive accounts script lib', () => { ); expect(noTokensActual).toBe(false); - const noTimestampTokensFn = sandbox.stub().resolves([{ uid: '9001' }]); + const noTimestampTokensFn = jest + .fn() + .mockResolvedValue([{ uid: '9001' }]); const noTimestampTokensActual = await lib.hasActiveRefreshToken( noTimestampTokensFn, '9001', @@ -141,9 +142,9 @@ describe('delete inactive accounts script lib', () => { ); expect(noTimestampTokensActual).toBe(false); - const noRecentEnoughTokensFn = sandbox - .stub() - .resolves([{ lastUsedAt: ts }]); + const noRecentEnoughTokensFn = jest + .fn() + .mockResolvedValue([{ lastUsedAt: ts }]); const noRecentEnoughTokensActual = await lib.hasActiveRefreshToken( noRecentEnoughTokensFn, '9001', @@ -154,13 +155,14 @@ describe('delete inactive accounts script lib', () => { }); describe('access token', () => { it('should be true when there is an access token', async () => { - const tokensFn = sandbox.stub().resolves([{}, {}]); + const tokensFn = jest.fn().mockResolvedValue([{}, {}]); const actual = await lib.hasAccessToken(tokensFn, '9001'); - sinon.assert.calledOnceWithExactly(tokensFn, '9001'); + expect(tokensFn).toHaveBeenCalledTimes(1); + expect(tokensFn).toHaveBeenCalledWith('9001'); expect(actual).toBe(true); }); it('should be false when there are no access tokens', async () => { - const tokensFn = sandbox.stub().resolves([]); + const tokensFn = jest.fn().mockResolvedValue([]); const actual = await lib.hasAccessToken(tokensFn, '9001'); expect(actual).toBe(false); }); @@ -168,16 +170,16 @@ describe('delete inactive accounts script lib', () => { }); describe('inActive function builder', () => { - let sessionTokensFn: sinon.SinonStub; - let refreshTokensFn: sinon.SinonStub; - let accessTokensFn: sinon.SinonStub; - let iapSubscriptionFn: sinon.SinonStub; + let sessionTokensFn: jest.Mock; + let refreshTokensFn: jest.Mock; + let accessTokensFn: jest.Mock; + let iapSubscriptionFn: jest.Mock; beforeEach(() => { - sessionTokensFn = sandbox.stub(); - refreshTokensFn = sandbox.stub(); - accessTokensFn = sandbox.stub(); - iapSubscriptionFn = sandbox.stub(); + sessionTokensFn = jest.fn(); + refreshTokensFn = jest.fn(); + accessTokensFn = jest.fn(); + iapSubscriptionFn = jest.fn(); }); it('should throw an error if the active session token function is missing', async () => { @@ -251,36 +253,42 @@ describe('delete inactive accounts script lib', () => { }); it('should short-circuit with session token check', async () => { - sessionTokensFn.resolves(true); + sessionTokensFn.mockResolvedValue(true); const actual = await isActive('9001'); expect(actual).toBe(true); - sinon.assert.calledOnceWithExactly(sessionTokensFn, '9001'); - sinon.assert.notCalled(refreshTokensFn); - sinon.assert.notCalled(accessTokensFn); - sinon.assert.notCalled(iapSubscriptionFn); + expect(sessionTokensFn).toHaveBeenCalledTimes(1); + expect(sessionTokensFn).toHaveBeenCalledWith('9001'); + expect(refreshTokensFn).not.toHaveBeenCalled(); + expect(accessTokensFn).not.toHaveBeenCalled(); + expect(iapSubscriptionFn).not.toHaveBeenCalled(); }); it('should short-circuit with refresh token check', async () => { - sessionTokensFn.resolves(false); - refreshTokensFn.resolves(true); + sessionTokensFn.mockResolvedValue(false); + refreshTokensFn.mockResolvedValue(true); const actual = await isActive('9001'); expect(actual).toBe(true); - sinon.assert.calledOnceWithExactly(sessionTokensFn, '9001'); - sinon.assert.calledOnceWithExactly(refreshTokensFn, '9001'); - sinon.assert.notCalled(accessTokensFn); - sinon.assert.notCalled(iapSubscriptionFn); + expect(sessionTokensFn).toHaveBeenCalledTimes(1); + expect(sessionTokensFn).toHaveBeenCalledWith('9001'); + expect(refreshTokensFn).toHaveBeenCalledTimes(1); + expect(refreshTokensFn).toHaveBeenCalledWith('9001'); + expect(accessTokensFn).not.toHaveBeenCalled(); + expect(iapSubscriptionFn).not.toHaveBeenCalled(); }); it('should short-circuit with access token check', async () => { - sessionTokensFn.resolves(false); - refreshTokensFn.resolves(false); - accessTokensFn.resolves(true); + sessionTokensFn.mockResolvedValue(false); + refreshTokensFn.mockResolvedValue(false); + accessTokensFn.mockResolvedValue(true); const actual = await isActive('9001'); expect(actual).toBe(true); - sinon.assert.calledOnceWithExactly(sessionTokensFn, '9001'); - sinon.assert.calledOnceWithExactly(refreshTokensFn, '9001'); - sinon.assert.calledOnceWithExactly(accessTokensFn, '9001'); - sinon.assert.notCalled(iapSubscriptionFn); + expect(sessionTokensFn).toHaveBeenCalledTimes(1); + expect(sessionTokensFn).toHaveBeenCalledWith('9001'); + expect(refreshTokensFn).toHaveBeenCalledTimes(1); + expect(refreshTokensFn).toHaveBeenCalledWith('9001'); + expect(accessTokensFn).toHaveBeenCalledTimes(1); + expect(accessTokensFn).toHaveBeenCalledWith('9001'); + expect(iapSubscriptionFn).not.toHaveBeenCalled(); }); }); @@ -291,16 +299,19 @@ describe('delete inactive accounts script lib', () => { .setRefreshTokenFn(refreshTokensFn) .setAccessTokenFn(accessTokensFn) .build(); - sessionTokensFn.resolves(false); - refreshTokensFn.resolves(false); - accessTokensFn.resolves(false); - iapSubscriptionFn.resolves(false); + sessionTokensFn.mockResolvedValue(false); + refreshTokensFn.mockResolvedValue(false); + accessTokensFn.mockResolvedValue(false); + iapSubscriptionFn.mockResolvedValue(false); const actual = await isActive('9001'); expect(actual).toBe(false); - sinon.assert.calledOnceWithExactly(sessionTokensFn, '9001'); - sinon.assert.calledOnceWithExactly(refreshTokensFn, '9001'); - sinon.assert.calledOnceWithExactly(accessTokensFn, '9001'); + expect(sessionTokensFn).toHaveBeenCalledTimes(1); + expect(sessionTokensFn).toHaveBeenCalledWith('9001'); + expect(refreshTokensFn).toHaveBeenCalledTimes(1); + expect(refreshTokensFn).toHaveBeenCalledWith('9001'); + expect(accessTokensFn).toHaveBeenCalledTimes(1); + expect(accessTokensFn).toHaveBeenCalledWith('9001'); }); }); }); diff --git a/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts b/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts index 7e5e9ce42b1..8eabf803561 100644 --- a/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts +++ b/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - import { CustomerPlanMover } from './move-customers-to-new-plan-v2'; import Stripe from 'stripe'; import { PayPalHelper } from '../../lib/payments/paypal'; @@ -28,7 +26,7 @@ describe('CustomerPlanMover v2', () => { beforeEach(() => { stripeStub = { - on: sinon.stub(), + on: jest.fn(), products: {}, customers: {}, subscriptions: {}, @@ -37,7 +35,7 @@ describe('CustomerPlanMover v2', () => { } as unknown as Stripe; paypalHelperStub = { - refundInvoice: sinon.stub(), + refundInvoice: jest.fn(), } as unknown as PayPalHelper; customerPlanMover = new CustomerPlanMover( @@ -120,8 +118,8 @@ describe('CustomerPlanMover v2', () => { }); describe('convert', () => { - let convertSubscriptionStub: sinon.SinonStub; - let writeReportHeaderStub: sinon.SinonStub; + let convertSubscriptionStub: jest.Mock; + let writeReportHeaderStub: jest.Mock; beforeEach(async () => { // Mock the async iterable returned by stripe.subscriptions.list @@ -131,34 +129,32 @@ describe('CustomerPlanMover v2', () => { }, }; - stripeStub.subscriptions.list = sinon - .stub() - .returns(asyncIterable) as any; + stripeStub.subscriptions.list = jest + .fn() + .mockReturnValue(asyncIterable) as any; stripeStub.prices = { - retrieve: sinon.stub().resolves(mockPrice), + retrieve: jest.fn().mockResolvedValue(mockPrice), } as any; - writeReportHeaderStub = sinon.stub().resolves(); + writeReportHeaderStub = jest.fn().mockResolvedValue(undefined); customerPlanMover.writeReportHeader = writeReportHeaderStub; - convertSubscriptionStub = sinon.stub().resolves(); + convertSubscriptionStub = jest.fn().mockResolvedValue(undefined); customerPlanMover.convertSubscription = convertSubscriptionStub; await customerPlanMover.convert(); }); it('writes report header', () => { - expect(writeReportHeaderStub.calledOnce).toBe(true); + expect(writeReportHeaderStub).toHaveBeenCalledTimes(1); }); it('lists subscriptions with source price id', () => { - expect( - (stripeStub.subscriptions.list as sinon.SinonStub).calledWith({ - price: 'source-price-id', - limit: 100, - }) - ).toBe(true); + expect(stripeStub.subscriptions.list as jest.Mock).toHaveBeenCalledWith({ + price: 'source-price-id', + limit: 100, + }); }); }); @@ -179,16 +175,16 @@ describe('CustomerPlanMover v2', () => { }, } as unknown as Stripe.Subscription; - let logStub: sinon.SinonStub; - let errorStub: sinon.SinonStub; - let fetchCustomerStub: sinon.SinonStub; - let isCustomerExcludedStub: sinon.SinonStub; - let writeReportStub: sinon.SinonStub; + let logStub: jest.SpyInstance; + let errorStub: jest.SpyInstance; + let fetchCustomerStub: jest.Mock; + let isCustomerExcludedStub: jest.Mock; + let writeReportStub: jest.Mock; beforeEach(() => { - logStub = sinon.stub(console, 'log'); - errorStub = sinon.stub(console, 'error'); - fetchCustomerStub = sinon.stub().resolves({ + logStub = jest.spyOn(console, 'log'); + errorStub = jest.spyOn(console, 'error'); + fetchCustomerStub = jest.fn().mockResolvedValue({ ...mockCustomer, subscriptions: { data: [mockStripeSubscription], @@ -196,23 +192,23 @@ describe('CustomerPlanMover v2', () => { }); customerPlanMover.fetchCustomer = fetchCustomerStub; - isCustomerExcludedStub = sinon.stub().returns(false); + isCustomerExcludedStub = jest.fn().mockReturnValue(false); customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; - writeReportStub = sinon.stub().resolves(); + writeReportStub = jest.fn().mockResolvedValue(undefined); customerPlanMover.writeReport = writeReportStub; }); afterEach(() => { - logStub.restore(); - errorStub.restore(); + logStub.mockRestore(); + errorStub.mockRestore(); }); describe('success - not excluded', () => { beforeEach(async () => { - stripeStub.subscriptions.update = sinon - .stub() - .resolves(mockStripeSubscription); + stripeStub.subscriptions.update = jest + .fn() + .mockResolvedValue(mockStripeSubscription); await customerPlanMover.convertSubscription( mockStripeSubscription, @@ -221,31 +217,31 @@ describe('CustomerPlanMover v2', () => { }); it('fetches customer', () => { - expect(fetchCustomerStub.calledWith('cus_123')).toBe(true); + expect(fetchCustomerStub).toHaveBeenCalledWith('cus_123'); }); it('updates subscription to destination price', () => { expect( - (stripeStub.subscriptions.update as sinon.SinonStub).calledWith( - 'sub_123', - sinon.match({ - items: [ - { - id: 'si_123', - price: 'destination-price-id', - }, - ], - discounts: undefined, - proration_behavior: 'none', - billing_cycle_anchor: 'unchanged', - }) - ) - ).toBe(true); + stripeStub.subscriptions.update as jest.Mock + ).toHaveBeenCalledWith( + 'sub_123', + expect.objectContaining({ + items: [ + { + id: 'si_123', + price: 'destination-price-id', + }, + ], + discounts: undefined, + proration_behavior: 'none', + billing_cycle_anchor: 'unchanged', + }) + ); }); it('writes report', () => { - expect(writeReportStub.calledOnce).toBe(true); - const reportArgs = writeReportStub.firstCall.args[0]; + expect(writeReportStub).toHaveBeenCalledTimes(1); + const reportArgs = writeReportStub.mock.calls[0][0]; expect(reportArgs.subscription.id).toBe('sub_123'); expect(reportArgs.isExcluded).toBe(false); expect(reportArgs.amountRefunded).toBe(null); @@ -279,9 +275,9 @@ describe('CustomerPlanMover v2', () => { customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; customerPlanMover.writeReport = writeReportStub; - stripeStub.subscriptions.update = sinon - .stub() - .resolves(mockStripeSubscription); + stripeStub.subscriptions.update = jest + .fn() + .mockResolvedValue(mockStripeSubscription); await customerPlanMover.convertSubscription( mockStripeSubscription, @@ -291,21 +287,21 @@ describe('CustomerPlanMover v2', () => { it('applies coupon to subscription', () => { expect( - (stripeStub.subscriptions.update as sinon.SinonStub).calledWith( - 'sub_123', - sinon.match({ - items: [ - { - id: 'si_123', - price: 'destination-price-id', - }, - ], - discounts: [{ coupon: 'test-coupon' }], - proration_behavior: 'none', - billing_cycle_anchor: 'unchanged', - }) - ) - ).toBe(true); + stripeStub.subscriptions.update as jest.Mock + ).toHaveBeenCalledWith( + 'sub_123', + expect.objectContaining({ + items: [ + { + id: 'si_123', + price: 'destination-price-id', + }, + ], + discounts: [{ coupon: 'test-coupon' }], + proration_behavior: 'none', + billing_cycle_anchor: 'unchanged', + }) + ); }); }); @@ -330,9 +326,9 @@ describe('CustomerPlanMover v2', () => { customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; customerPlanMover.writeReport = writeReportStub; - stripeStub.subscriptions.update = sinon - .stub() - .resolves(mockStripeSubscription); + stripeStub.subscriptions.update = jest + .fn() + .mockResolvedValue(mockStripeSubscription); await customerPlanMover.convertSubscription( mockStripeSubscription, @@ -342,21 +338,21 @@ describe('CustomerPlanMover v2', () => { it('uses specified proration behavior', () => { expect( - (stripeStub.subscriptions.update as sinon.SinonStub).calledWith( - 'sub_123', - sinon.match({ - items: [ - { - id: 'si_123', - price: 'destination-price-id', - }, - ], - discounts: undefined, - proration_behavior: 'create_prorations', - billing_cycle_anchor: 'unchanged', - }) - ) - ).toBe(true); + stripeStub.subscriptions.update as jest.Mock + ).toHaveBeenCalledWith( + 'sub_123', + expect.objectContaining({ + items: [ + { + id: 'si_123', + price: 'destination-price-id', + }, + ], + discounts: undefined, + proration_behavior: 'create_prorations', + billing_cycle_anchor: 'unchanged', + }) + ); }); }); @@ -381,9 +377,9 @@ describe('CustomerPlanMover v2', () => { customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; customerPlanMover.writeReport = writeReportStub; - stripeStub.subscriptions.update = sinon - .stub() - .resolves(mockStripeSubscription); + stripeStub.subscriptions.update = jest + .fn() + .mockResolvedValue(mockStripeSubscription); await customerPlanMover.convertSubscription( mockStripeSubscription, @@ -393,13 +389,13 @@ describe('CustomerPlanMover v2', () => { it('sets billing_cycle_anchor to "now"', () => { expect( - (stripeStub.subscriptions.update as sinon.SinonStub).calledWith( - 'sub_123', - sinon.match({ - billing_cycle_anchor: 'now', - }) - ) - ).toBe(true); + stripeStub.subscriptions.update as jest.Mock + ).toHaveBeenCalledWith( + 'sub_123', + expect.objectContaining({ + billing_cycle_anchor: 'now', + }) + ); }); }); @@ -424,9 +420,9 @@ describe('CustomerPlanMover v2', () => { customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; customerPlanMover.writeReport = writeReportStub; - stripeStub.subscriptions.update = sinon - .stub() - .resolves(mockStripeSubscription); + stripeStub.subscriptions.update = jest + .fn() + .mockResolvedValue(mockStripeSubscription); await customerPlanMover.convertSubscription( mockStripeSubscription, @@ -436,18 +432,18 @@ describe('CustomerPlanMover v2', () => { it('sets billing_cycle_anchor to "unchanged"', () => { expect( - (stripeStub.subscriptions.update as sinon.SinonStub).calledWith( - 'sub_123', - sinon.match({ - billing_cycle_anchor: 'unchanged', - }) - ) - ).toBe(true); + stripeStub.subscriptions.update as jest.Mock + ).toHaveBeenCalledWith( + 'sub_123', + expect.objectContaining({ + billing_cycle_anchor: 'unchanged', + }) + ); }); }); describe('success - with prorated refund', () => { - let attemptRefundStub: sinon.SinonStub; + let attemptRefundStub: jest.Mock; beforeEach(async () => { customerPlanMover = new CustomerPlanMover( @@ -469,12 +465,12 @@ describe('CustomerPlanMover v2', () => { customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; customerPlanMover.writeReport = writeReportStub; - attemptRefundStub = sinon.stub().resolves(500); + attemptRefundStub = jest.fn().mockResolvedValue(500); customerPlanMover.attemptRefund = attemptRefundStub; - stripeStub.subscriptions.update = sinon - .stub() - .resolves(mockStripeSubscription); + stripeStub.subscriptions.update = jest + .fn() + .mockResolvedValue(mockStripeSubscription); await customerPlanMover.convertSubscription( mockStripeSubscription, @@ -483,11 +479,11 @@ describe('CustomerPlanMover v2', () => { }); it('attempts refund', () => { - expect(attemptRefundStub.calledWith(mockStripeSubscription)).toBe(true); + expect(attemptRefundStub).toHaveBeenCalledWith(mockStripeSubscription); }); it('writes report with refund amount', () => { - const reportArgs = writeReportStub.firstCall.args[0]; + const reportArgs = writeReportStub.mock.calls[0][0]; expect(reportArgs.amountRefunded).toBe(500); expect(reportArgs.isOwed).toBe(false); expect(reportArgs.error).toBe(false); @@ -495,7 +491,7 @@ describe('CustomerPlanMover v2', () => { }); describe('refund failure', () => { - let attemptRefundStub: sinon.SinonStub; + let attemptRefundStub: jest.Mock; beforeEach(async () => { customerPlanMover = new CustomerPlanMover( @@ -517,12 +513,14 @@ describe('CustomerPlanMover v2', () => { customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; customerPlanMover.writeReport = writeReportStub; - attemptRefundStub = sinon.stub().rejects(new Error('Refund failed')); + attemptRefundStub = jest + .fn() + .mockRejectedValue(new Error('Refund failed')); customerPlanMover.attemptRefund = attemptRefundStub; - stripeStub.subscriptions.update = sinon - .stub() - .resolves(mockStripeSubscription); + stripeStub.subscriptions.update = jest + .fn() + .mockResolvedValue(mockStripeSubscription); await customerPlanMover.convertSubscription( mockStripeSubscription, @@ -531,7 +529,7 @@ describe('CustomerPlanMover v2', () => { }); it('marks customer as owed', () => { - const reportArgs = writeReportStub.firstCall.args[0]; + const reportArgs = writeReportStub.mock.calls[0][0]; expect(reportArgs.isOwed).toBe(true); expect(reportArgs.amountRefunded).toBe(null); expect(reportArgs.error).toBe(false); @@ -541,9 +539,9 @@ describe('CustomerPlanMover v2', () => { describe('dry run', () => { beforeEach(async () => { customerPlanMover.dryRun = true; - stripeStub.subscriptions.update = sinon - .stub() - .resolves(mockStripeSubscription); + stripeStub.subscriptions.update = jest + .fn() + .mockResolvedValue(mockStripeSubscription); await customerPlanMover.convertSubscription( mockStripeSubscription, @@ -553,21 +551,21 @@ describe('CustomerPlanMover v2', () => { it('does not update subscription', () => { expect( - (stripeStub.subscriptions.update as sinon.SinonStub).notCalled - ).toBe(true); + stripeStub.subscriptions.update as jest.Mock + ).not.toHaveBeenCalled(); }); it('still writes report', () => { - expect(writeReportStub.calledOnce).toBe(true); + expect(writeReportStub).toHaveBeenCalledTimes(1); }); }); describe('customer excluded', () => { beforeEach(async () => { - isCustomerExcludedStub.returns(true); - stripeStub.subscriptions.update = sinon - .stub() - .resolves(mockStripeSubscription); + isCustomerExcludedStub.mockReturnValue(true); + stripeStub.subscriptions.update = jest + .fn() + .mockResolvedValue(mockStripeSubscription); await customerPlanMover.convertSubscription( mockStripeSubscription, @@ -577,12 +575,12 @@ describe('CustomerPlanMover v2', () => { it('does not update subscription', () => { expect( - (stripeStub.subscriptions.update as sinon.SinonStub).notCalled - ).toBe(true); + stripeStub.subscriptions.update as jest.Mock + ).not.toHaveBeenCalled(); }); it('writes report marking as excluded', () => { - const reportArgs = writeReportStub.firstCall.args[0]; + const reportArgs = writeReportStub.mock.calls[0][0]; expect(reportArgs.isExcluded).toBe(true); expect(reportArgs.error).toBe(false); expect(reportArgs.amountRefunded).toBe(null); @@ -611,9 +609,9 @@ describe('CustomerPlanMover v2', () => { customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; customerPlanMover.writeReport = writeReportStub; - stripeStub.subscriptions.update = sinon - .stub() - .resolves(mockStripeSubscription); + stripeStub.subscriptions.update = jest + .fn() + .mockResolvedValue(mockStripeSubscription); const subscriptionSetToCancel = { ...mockStripeSubscription, @@ -628,20 +626,18 @@ describe('CustomerPlanMover v2', () => { it('does not update subscription', () => { expect( - (stripeStub.subscriptions.update as sinon.SinonStub).notCalled - ).toBe(true); + stripeStub.subscriptions.update as jest.Mock + ).not.toHaveBeenCalled(); }); it('does not write report', () => { - expect(writeReportStub.notCalled).toBe(true); + expect(writeReportStub).not.toHaveBeenCalled(); }); it('logs skip message', () => { - expect( - logStub.calledWith( - sinon.match(/Skipping subscription.*set to cancel/) - ) - ).toBe(true); + expect(logStub).toHaveBeenCalledWith( + expect.stringMatching(/Skipping subscription.*set to cancel/) + ); }); }); @@ -657,8 +653,8 @@ describe('CustomerPlanMover v2', () => { mockPrice ); - expect(writeReportStub.calledOnce).toBe(true); - const reportArgs = writeReportStub.firstCall.args[0]; + expect(writeReportStub).toHaveBeenCalledTimes(1); + const reportArgs = writeReportStub.mock.calls[0][0]; expect(reportArgs.customer).toBe(null); expect(reportArgs.error).toBe(true); expect(reportArgs.isOwed).toBe(false); @@ -666,15 +662,15 @@ describe('CustomerPlanMover v2', () => { }); it('writes error report if customer does not exist', async () => { - customerPlanMover.fetchCustomer = sinon.stub().resolves(null); + customerPlanMover.fetchCustomer = jest.fn().mockResolvedValue(null); await customerPlanMover.convertSubscription( mockStripeSubscription, mockPrice ); - expect(writeReportStub.calledOnce).toBe(true); - const reportArgs = writeReportStub.firstCall.args[0]; + expect(writeReportStub).toHaveBeenCalledTimes(1); + const reportArgs = writeReportStub.mock.calls[0][0]; expect(reportArgs.customer).toBe(null); expect(reportArgs.error).toBe(true); expect(reportArgs.isOwed).toBe(false); @@ -682,7 +678,7 @@ describe('CustomerPlanMover v2', () => { }); it('writes error report if customer has no subscriptions data', async () => { - customerPlanMover.fetchCustomer = sinon.stub().resolves({ + customerPlanMover.fetchCustomer = jest.fn().mockResolvedValue({ ...mockCustomer, subscriptions: undefined, }); @@ -692,42 +688,42 @@ describe('CustomerPlanMover v2', () => { mockPrice ); - expect(writeReportStub.calledOnce).toBe(true); - const reportArgs = writeReportStub.firstCall.args[0]; + expect(writeReportStub).toHaveBeenCalledTimes(1); + const reportArgs = writeReportStub.mock.calls[0][0]; expect(reportArgs.error).toBe(true); expect(reportArgs.isOwed).toBe(false); expect(reportArgs.isExcluded).toBe(false); }); it('writes error report if subscription update fails', async () => { - stripeStub.subscriptions.update = sinon - .stub() - .rejects(new Error('Update failed')); + stripeStub.subscriptions.update = jest + .fn() + .mockRejectedValue(new Error('Update failed')); await customerPlanMover.convertSubscription( mockStripeSubscription, mockPrice ); - expect(writeReportStub.calledOnce).toBe(true); - const reportArgs = writeReportStub.firstCall.args[0]; + expect(writeReportStub).toHaveBeenCalledTimes(1); + const reportArgs = writeReportStub.mock.calls[0][0]; expect(reportArgs.error).toBe(true); expect(reportArgs.isOwed).toBe(false); expect(reportArgs.isExcluded).toBe(false); }); it('writes error report if unexpected error occurs', async () => { - customerPlanMover.fetchCustomer = sinon - .stub() - .rejects(new Error('Unexpected error')); + customerPlanMover.fetchCustomer = jest + .fn() + .mockRejectedValue(new Error('Unexpected error')); await customerPlanMover.convertSubscription( mockStripeSubscription, mockPrice ); - expect(writeReportStub.calledOnce).toBe(true); - const reportArgs = writeReportStub.firstCall.args[0]; + expect(writeReportStub).toHaveBeenCalledTimes(1); + const reportArgs = writeReportStub.mock.calls[0][0]; expect(reportArgs.error).toBe(true); expect(reportArgs.customer).toBe(null); expect(reportArgs.isOwed).toBe(false); @@ -737,27 +733,25 @@ describe('CustomerPlanMover v2', () => { }); describe('fetchCustomer', () => { - let customerRetrieveStub: sinon.SinonStub; + let customerRetrieveStub: jest.Mock; let result: Stripe.Customer | Stripe.DeletedCustomer | null; describe('customer exists', () => { beforeEach(async () => { - customerRetrieveStub = sinon.stub().resolves(mockCustomer); + customerRetrieveStub = jest.fn().mockResolvedValue(mockCustomer); stripeStub.customers.retrieve = customerRetrieveStub; result = await customerPlanMover.fetchCustomer(mockCustomer.id); }); it('fetches customer from Stripe with subscriptions expanded', () => { - expect( - customerRetrieveStub.calledWith(mockCustomer.id, { - expand: ['subscriptions'], - }) - ).toBe(true); + expect(customerRetrieveStub).toHaveBeenCalledWith(mockCustomer.id, { + expand: ['subscriptions'], + }); }); it('returns customer', () => { - sinon.assert.match(result, mockCustomer); + expect(result).toEqual(mockCustomer); }); }); @@ -767,14 +761,14 @@ describe('CustomerPlanMover v2', () => { ...mockCustomer, deleted: true, }; - customerRetrieveStub = sinon.stub().resolves(deletedCustomer); + customerRetrieveStub = jest.fn().mockResolvedValue(deletedCustomer); stripeStub.customers.retrieve = customerRetrieveStub; result = await customerPlanMover.fetchCustomer(mockCustomer.id); }); it('returns null', () => { - sinon.assert.match(result, null); + expect(result).toEqual(null); }); }); }); @@ -799,8 +793,8 @@ describe('CustomerPlanMover v2', () => { paid_out_of_band: false, } as unknown as Stripe.Invoice; - let enqueueRequestStub: sinon.SinonStub; - let logStub: sinon.SinonStub; + let enqueueRequestStub: jest.Mock; + let logStub: jest.SpyInstance; beforeEach(() => { customerPlanMover = new CustomerPlanMover( @@ -819,29 +813,30 @@ describe('CustomerPlanMover v2', () => { paypalHelperStub ); - enqueueRequestStub = sinon.stub(); + enqueueRequestStub = jest.fn(); customerPlanMover.enqueueRequest = enqueueRequestStub; - logStub = sinon.stub(console, 'log'); + logStub = jest.spyOn(console, 'log'); }); afterEach(() => { - logStub.restore(); + logStub.mockRestore(); }); describe('Stripe refund', () => { beforeEach(async () => { - enqueueRequestStub.onFirstCall().resolves(mockPaidInvoice); - enqueueRequestStub.onSecondCall().resolves({}); + enqueueRequestStub + .mockResolvedValueOnce(mockPaidInvoice) + .mockResolvedValueOnce({}); await customerPlanMover.attemptRefund(mockSubscriptionWithInvoice); }); it('retrieves invoice', () => { - expect(enqueueRequestStub.calledTwice).toBe(true); + expect(enqueueRequestStub).toHaveBeenCalledTimes(2); }); it('creates refund', () => { - expect(enqueueRequestStub.calledTwice).toBe(true); + expect(enqueueRequestStub).toHaveBeenCalledTimes(2); }); }); @@ -866,17 +861,17 @@ describe('CustomerPlanMover v2', () => { paid_out_of_band: true, } as unknown as Stripe.Invoice; - enqueueRequestStub.resolves(mockPayPalInvoice); + enqueueRequestStub.mockResolvedValue(mockPayPalInvoice); await customerPlanMover.attemptRefund(mockSubscriptionWithInvoice); }); it('calls paypalHelper.refundInvoice with full refund', () => { expect( - (paypalHelperStub.refundInvoice as sinon.SinonStub).calledOnce - ).toBe(true); - const args = (paypalHelperStub.refundInvoice as sinon.SinonStub) - .firstCall.args; + paypalHelperStub.refundInvoice as jest.Mock + ).toHaveBeenCalledTimes(1); + const args = (paypalHelperStub.refundInvoice as jest.Mock).mock + .calls[0]; expect(args[1].refundType).toBe('Full'); }); }); @@ -902,17 +897,17 @@ describe('CustomerPlanMover v2', () => { paid_out_of_band: true, } as unknown as Stripe.Invoice; - enqueueRequestStub.resolves(mockPayPalInvoice); + enqueueRequestStub.mockResolvedValue(mockPayPalInvoice); await customerPlanMover.attemptRefund(mockSubscriptionWithInvoice); }); it('calls paypalHelper.refundInvoice with partial refund', () => { expect( - (paypalHelperStub.refundInvoice as sinon.SinonStub).calledOnce - ).toBe(true); - const args = (paypalHelperStub.refundInvoice as sinon.SinonStub) - .firstCall.args; + paypalHelperStub.refundInvoice as jest.Mock + ).toHaveBeenCalledTimes(1); + const args = (paypalHelperStub.refundInvoice as jest.Mock).mock + .calls[0]; expect(args[1].refundType).toBe('Partial'); expect(args[1].amount).toBe(calculatedRefundAmount); }); @@ -921,13 +916,13 @@ describe('CustomerPlanMover v2', () => { describe('dry run', () => { beforeEach(async () => { customerPlanMover.dryRun = true; - enqueueRequestStub.resolves(mockPaidInvoice); + enqueueRequestStub.mockResolvedValue(mockPaidInvoice); await customerPlanMover.attemptRefund(mockSubscriptionWithInvoice); }); it('does not create refund', () => { - expect(enqueueRequestStub.callCount).toBe(1); // Only invoice retrieval + expect(enqueueRequestStub).toHaveBeenCalledTimes(1); // Only invoice retrieval }); }); @@ -970,7 +965,7 @@ describe('CustomerPlanMover v2', () => { ...mockPaidInvoice, paid: false, } as Stripe.Invoice; - enqueueRequestStub.resolves(unpaidInvoice); + enqueueRequestStub.mockResolvedValue(unpaidInvoice); await expect( customerPlanMover.attemptRefund(mockSubscriptionWithInvoice) @@ -983,7 +978,7 @@ describe('CustomerPlanMover v2', () => { amount_due: 100, created: Math.floor(Date.now() / 1000) - 86400 * 50, // 50 days ago } as Stripe.Invoice; - enqueueRequestStub.resolves(oldInvoice); + enqueueRequestStub.mockResolvedValue(oldInvoice); await expect( customerPlanMover.attemptRefund(mockSubscriptionWithInvoice) @@ -995,7 +990,7 @@ describe('CustomerPlanMover v2', () => { ...mockPaidInvoice, charge: null, } as unknown as Stripe.Invoice; - enqueueRequestStub.resolves(invoiceNoCharge); + enqueueRequestStub.mockResolvedValue(invoiceNoCharge); await expect( customerPlanMover.attemptRefund(mockSubscriptionWithInvoice) diff --git a/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.spec.ts b/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.spec.ts index 91f98c35062..8fbc02e8805 100644 --- a/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.spec.ts +++ b/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import Container from 'typedi'; import { ConfigType } from '../../config'; @@ -51,16 +50,16 @@ describe('CustomerPlanMover', () => { let stripeStub: Stripe; let stripeHelperStub: StripeHelper; let dbStub: any; - let firestoreGetStub: sinon.SinonStub; + let firestoreGetStub: jest.Mock; beforeEach(() => { - firestoreGetStub = sinon.stub(); + firestoreGetStub = jest.fn(); Container.set(AuthFirestore, { - collectionGroup: sinon.stub().returns({ - where: sinon.stub().returnsThis(), - orderBy: sinon.stub().returnsThis(), - startAfter: sinon.stub().returnsThis(), - limit: sinon.stub().returnsThis(), + collectionGroup: jest.fn().mockReturnValue({ + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + startAfter: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), get: firestoreGetStub, }), }); @@ -68,7 +67,7 @@ describe('CustomerPlanMover', () => { Container.set(AppConfig, mockConfig); stripeStub = { - on: sinon.stub(), + on: jest.fn(), products: {}, customers: {}, subscriptions: {}, @@ -78,12 +77,12 @@ describe('CustomerPlanMover', () => { stripeHelperStub = { stripe: stripeStub, currencyHelper: { - isCurrencyCompatibleWithCountry: sinon.stub(), + isCurrencyCompatibleWithCountry: jest.fn(), }, } as unknown as StripeHelper; dbStub = { - account: sinon.stub(), + account: jest.fn(), }; customerPlanMover = new CustomerPlanMover( @@ -104,31 +103,29 @@ describe('CustomerPlanMover', () => { }); describe('convert', () => { - let fetchSubsBatchStub: sinon.SinonStub; - let convertSubscriptionStub: sinon.SinonStub; + let fetchSubsBatchStub: jest.Mock; + let convertSubscriptionStub: jest.Mock; const mockSubs = [mockSubscription]; beforeEach(async () => { - fetchSubsBatchStub = sinon - .stub() - .onFirstCall() - .returns(mockSubs) - .onSecondCall() - .returns([]); + fetchSubsBatchStub = jest + .fn() + .mockReturnValueOnce(mockSubs) + .mockReturnValueOnce([]); customerPlanMover.fetchSubsBatch = fetchSubsBatchStub; - convertSubscriptionStub = sinon.stub(); + convertSubscriptionStub = jest.fn(); customerPlanMover.convertSubscription = convertSubscriptionStub; await customerPlanMover.convert(); }); it('fetches subscriptions until no results', () => { - expect(fetchSubsBatchStub.callCount).toBe(2); + expect(fetchSubsBatchStub).toHaveBeenCalledTimes(2); }); it('generates a report for each applicable subscription', () => { - expect(convertSubscriptionStub.callCount).toBe(1); + expect(convertSubscriptionStub).toHaveBeenCalledTimes(1); }); }); @@ -137,10 +134,10 @@ describe('CustomerPlanMover', () => { let result: FirestoreSubscription[]; beforeEach(async () => { - firestoreGetStub.resolves({ + firestoreGetStub.mockResolvedValue({ docs: [ { - data: sinon.stub().returns(mockSubscription), + data: jest.fn().mockReturnValue(mockSubscription), }, ], }); @@ -149,7 +146,7 @@ describe('CustomerPlanMover', () => { }); it('returns a list of subscriptions from Firestore', () => { - sinon.assert.match(result, [mockSubscription]); + expect(result).toEqual([mockSubscription]); }); }); @@ -163,34 +160,36 @@ describe('CustomerPlanMover', () => { status: 'active', } as FirestoreSubscription; const mockReport = ['mock-report']; - let logStub: sinon.SinonStub; - let cancelSubscriptionStub: sinon.SinonStub; - let createSubscriptionStub: sinon.SinonStub; - let isCustomerExcludedStub: sinon.SinonStub; - let buildReport: sinon.SinonStub; - let writeReportStub: sinon.SinonStub; + let logStub: jest.SpyInstance; + let cancelSubscriptionStub: jest.Mock; + let createSubscriptionStub: jest.Mock; + let isCustomerExcludedStub: jest.Mock; + let buildReport: jest.Mock; + let writeReportStub: jest.Mock; beforeEach(async () => { - stripeStub.products.retrieve = sinon.stub().resolves(mockProduct); - customerPlanMover.fetchCustomer = sinon.stub().resolves(mockCustomer); - dbStub.account.resolves({ + stripeStub.products.retrieve = jest.fn().mockResolvedValue(mockProduct); + customerPlanMover.fetchCustomer = jest + .fn() + .mockResolvedValue(mockCustomer); + dbStub.account.mockResolvedValue({ locale: 'en-US', }); - cancelSubscriptionStub = sinon.stub().resolves(); + cancelSubscriptionStub = jest.fn().mockResolvedValue(undefined); customerPlanMover.cancelSubscription = cancelSubscriptionStub; - createSubscriptionStub = sinon.stub().resolves(); + createSubscriptionStub = jest.fn().mockResolvedValue(undefined); customerPlanMover.createSubscription = createSubscriptionStub; - isCustomerExcludedStub = sinon.stub().returns(false); + isCustomerExcludedStub = jest.fn().mockReturnValue(false); customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; - buildReport = sinon.stub().returns(mockReport); + buildReport = jest.fn().mockReturnValue(mockReport); customerPlanMover.buildReport = buildReport; - writeReportStub = sinon.stub().resolves(); + writeReportStub = jest.fn().mockResolvedValue(undefined); customerPlanMover.writeReport = writeReportStub; - logStub = sinon.stub(console, 'log'); + logStub = jest.spyOn(console, 'log'); }); afterEach(() => { - logStub.restore(); + logStub.mockRestore(); }); describe('success', () => { @@ -199,15 +198,15 @@ describe('CustomerPlanMover', () => { }); it('cancels old subscription', () => { - expect(cancelSubscriptionStub.calledWith(mockFirestoreSub)).toBe(true); + expect(cancelSubscriptionStub).toHaveBeenCalledWith(mockFirestoreSub); }); it('creates new subscription', () => { - expect(createSubscriptionStub.calledWith(mockCustomer.id)).toBe(true); + expect(createSubscriptionStub).toHaveBeenCalledWith(mockCustomer.id); }); it('writes the report to disk', () => { - expect(writeReportStub.calledWith(mockReport)).toBe(true); + expect(writeReportStub).toHaveBeenCalledWith(mockReport); }); }); @@ -218,45 +217,53 @@ describe('CustomerPlanMover', () => { }); it('does not cancel old subscription', () => { - expect(cancelSubscriptionStub.calledWith(mockFirestoreSub)).toBe(false); + expect(cancelSubscriptionStub).not.toHaveBeenCalledWith( + mockFirestoreSub + ); }); it('does not create new subscription', () => { - expect(createSubscriptionStub.calledWith(mockCustomer.id)).toBe(false); + expect(createSubscriptionStub).not.toHaveBeenCalledWith( + mockCustomer.id + ); }); it('writes the report to disk', () => { - expect(writeReportStub.calledWith(mockReport)).toBe(true); + expect(writeReportStub).toHaveBeenCalledWith(mockReport); }); }); describe('invalid', () => { it('aborts if customer does not exist', async () => { - customerPlanMover.fetchCustomer = sinon.stub().resolves(null); + customerPlanMover.fetchCustomer = jest.fn().mockResolvedValue(null); await customerPlanMover.convertSubscription(mockFirestoreSub); - expect(writeReportStub.notCalled).toBe(true); + expect(writeReportStub).not.toHaveBeenCalled(); }); it('aborts if account for customer does not exist', async () => { - dbStub.account.resolves(null); + dbStub.account.mockResolvedValue(null); await customerPlanMover.convertSubscription(mockFirestoreSub); - expect(writeReportStub.notCalled).toBe(true); + expect(writeReportStub).not.toHaveBeenCalled(); }); it('does not create subscription if customer is excluded', async () => { - customerPlanMover.isCustomerExcluded = sinon.stub().resolves(true); + customerPlanMover.isCustomerExcluded = jest + .fn() + .mockResolvedValue(true); await customerPlanMover.convertSubscription(mockFirestoreSub); - expect(createSubscriptionStub.notCalled).toBe(true); + expect(createSubscriptionStub).not.toHaveBeenCalled(); }); it('does not cancel subscription if customer is excluded', async () => { - customerPlanMover.isCustomerExcluded = sinon.stub().resolves(true); + customerPlanMover.isCustomerExcluded = jest + .fn() + .mockResolvedValue(true); await customerPlanMover.convertSubscription(mockFirestoreSub); - expect(cancelSubscriptionStub.notCalled).toBe(true); + expect(cancelSubscriptionStub).not.toHaveBeenCalled(); }); it('does not move subscription if subscription is not in active state', async () => { @@ -265,35 +272,33 @@ describe('CustomerPlanMover', () => { status: 'canceled', }); - expect(cancelSubscriptionStub.notCalled).toBe(true); - expect(createSubscriptionStub.notCalled).toBe(true); - expect(writeReportStub.notCalled).toBe(true); + expect(cancelSubscriptionStub).not.toHaveBeenCalled(); + expect(createSubscriptionStub).not.toHaveBeenCalled(); + expect(writeReportStub).not.toHaveBeenCalled(); }); }); }); describe('fetchCustomer', () => { - let customerRetrieveStub: sinon.SinonStub; + let customerRetrieveStub: jest.Mock; let result: Stripe.Customer | Stripe.DeletedCustomer | null; describe('customer exists', () => { beforeEach(async () => { - customerRetrieveStub = sinon.stub().resolves(mockCustomer); + customerRetrieveStub = jest.fn().mockResolvedValue(mockCustomer); stripeStub.customers.retrieve = customerRetrieveStub; result = await customerPlanMover.fetchCustomer(mockCustomer.id); }); it('fetches customer from Stripe', () => { - expect( - customerRetrieveStub.calledWith(mockCustomer.id, { - expand: ['subscriptions'], - }) - ).toBe(true); + expect(customerRetrieveStub).toHaveBeenCalledWith(mockCustomer.id, { + expand: ['subscriptions'], + }); }); it('returns customer', () => { - sinon.assert.match(result, mockCustomer); + expect(result).toEqual(mockCustomer); }); }); @@ -303,14 +308,14 @@ describe('CustomerPlanMover', () => { ...mockCustomer, deleted: true, }; - customerRetrieveStub = sinon.stub().resolves(deletedCustomer); + customerRetrieveStub = jest.fn().mockResolvedValue(deletedCustomer); stripeStub.customers.retrieve = customerRetrieveStub; result = await customerPlanMover.fetchCustomer(mockCustomer.id); }); it('returns null', () => { - sinon.assert.match(result, null); + expect(result).toEqual(null); }); }); }); @@ -349,26 +354,24 @@ describe('CustomerPlanMover', () => { }); describe('createSubscription', () => { - let createStub: sinon.SinonStub; + let createStub: jest.Mock; beforeEach(async () => { - createStub = sinon.stub().resolves(mockSubscription); + createStub = jest.fn().mockResolvedValue(mockSubscription); stripeStub.subscriptions.create = createStub; await customerPlanMover.createSubscription(mockCustomer.id); }); it('creates a subscription', () => { - expect( - createStub.calledWith({ - customer: mockCustomer.id, - items: [ - { - price: 'destination', - }, - ], - }) - ).toBe(true); + expect(createStub).toHaveBeenCalledWith({ + customer: mockCustomer.id, + items: [ + { + price: 'destination', + }, + ], + }); }); }); @@ -380,7 +383,7 @@ describe('CustomerPlanMover', () => { true ); - sinon.assert.match(result, [ + expect(result).toEqual([ mockCustomer.metadata.userid, `"${mockCustomer.email}"`, 'true', diff --git a/packages/fxa-auth-server/scripts/recorded-future/lib.spec.ts b/packages/fxa-auth-server/scripts/recorded-future/lib.spec.ts index 97ab7f9fe6d..4d0c1ae52be 100644 --- a/packages/fxa-auth-server/scripts/recorded-future/lib.spec.ts +++ b/packages/fxa-auth-server/scripts/recorded-future/lib.spec.ts @@ -2,47 +2,42 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import * as lib from './lib'; import { SearchResultIdentity } from './lib'; import { AppError, ERRNO } from '@fxa/accounts/errors'; describe('Recorded Future credentials search and reset script lib', () => { const payload = { domain: 'login.example.com', limit: 10 }; - let sandbox: sinon.SinonSandbox; - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); + beforeEach(() => {}); afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); describe('credentials search function', () => { - let client: { POST: sinon.SinonStub }; + let client: { POST: jest.Mock }; beforeEach(() => { - client = { POST: sandbox.stub() }; + client = { POST: jest.fn() }; }); it('returns the data on success', async () => { const data = { next_offset: 'letsgoooo' }; - client.POST.resolves({ data }); + client.POST.mockResolvedValue({ data }); const searchFn = lib.createCredentialsSearchFn(client as any); const res = await searchFn(payload); - sinon.assert.calledOnceWithExactly( - client.POST, - '/identity/credentials/search', - { body: payload } - ); + expect(client.POST).toHaveBeenCalledTimes(1); + expect(client.POST).toHaveBeenCalledWith('/identity/credentials/search', { + body: payload, + }); expect(res).toEqual(data); }); it('throws the API returned error', async () => { const error = 'oops'; - client.POST.resolves({ error }); + client.POST.mockResolvedValue({ error }); const searchFn = lib.createCredentialsSearchFn(client as any); try { @@ -55,10 +50,10 @@ describe('Recorded Future credentials search and reset script lib', () => { }); describe('fetch all credentials search results function', () => { - let client: { POST: sinon.SinonStub }; + let client: { POST: jest.Mock }; beforeEach(() => { - client = { POST: sandbox.stub() }; + client = { POST: jest.fn() }; }); it('fetches all the paginated results', async () => { @@ -72,19 +67,18 @@ describe('Recorded Future credentials search and reset script lib', () => { count: payload.limit - 1, next_offset: 'MISLEADING_MOAR', }; - client.POST.onFirstCall() - .resolves({ data: firstResponse }) - .onSecondCall() - .resolves({ data: secondResponse }); + client.POST.mockResolvedValueOnce({ + data: firstResponse, + }).mockResolvedValueOnce({ data: secondResponse }); const searchFn = lib.createCredentialsSearchFn(client as any); const res = await lib.fetchAllCredentialSearchResults(searchFn, payload); - sinon.assert.calledTwice(client.POST); - sinon.assert.calledWith(client.POST, '/identity/credentials/search', { + expect(client.POST).toHaveBeenCalledTimes(2); + expect(client.POST).toHaveBeenCalledWith('/identity/credentials/search', { body: payload, }); - sinon.assert.calledWith(client.POST, '/identity/credentials/search', { + expect(client.POST).toHaveBeenCalledWith('/identity/credentials/search', { body: { ...payload, offset: firstResponse.next_offset }, }); expect(res).toEqual([ @@ -96,32 +90,39 @@ describe('Recorded Future credentials search and reset script lib', () => { describe('find account function', () => { it('returns an existing account', async () => { - const accountFn = sandbox.stub().resolves({ uid: '9001' }); + const accountFn = jest.fn().mockResolvedValue({ uid: '9001' }); const findAccount = lib.createFindAccountFn(accountFn); const acct = await findAccount('quux@example.gg'); - sinon.assert.calledOnceWithExactly(accountFn, 'quux@example.gg'); + expect(accountFn).toHaveBeenCalledTimes(1); + expect(accountFn).toHaveBeenCalledWith('quux@example.gg'); expect(acct).toEqual({ uid: '9001' } as any); }); it('returns undefined when no account found', async () => { - const accountFn = sandbox.stub().throws(AppError.unknownAccount()); + const accountFn = jest.fn().mockImplementation(() => { + throw AppError.unknownAccount(); + }); const findAccount = lib.createFindAccountFn(accountFn); const res = await findAccount('quux@example.gg'); - sinon.assert.calledOnceWithExactly(accountFn, 'quux@example.gg'); + expect(accountFn).toHaveBeenCalledTimes(1); + expect(accountFn).toHaveBeenCalledWith('quux@example.gg'); expect(res).toBeUndefined(); }); it('re-throws errors', async () => { - const accountFn = sandbox.stub().throws(AppError.invalidRequestBody()); + const accountFn = jest.fn().mockImplementation(() => { + throw AppError.invalidRequestBody(); + }); const findAccount = lib.createFindAccountFn(accountFn); try { await findAccount('quux@example.gg'); throw new Error('should have thrown'); } catch (err: any) { - sinon.assert.calledOnceWithExactly(accountFn, 'quux@example.gg'); + expect(accountFn).toHaveBeenCalledTimes(1); + expect(accountFn).toHaveBeenCalledWith('quux@example.gg'); expect(err.errno).toBe(ERRNO.INVALID_JSON); } }); @@ -129,42 +130,49 @@ describe('Recorded Future credentials search and reset script lib', () => { describe('has totp 2fa function', () => { it('returns true when TOTP token exists', async () => { - const totpTokenFn = sandbox.stub().resolves(); + const totpTokenFn = jest.fn().mockResolvedValue(undefined); const hasTotpToken = lib.createHasTotp2faFn(totpTokenFn); const res = await hasTotpToken({ uid: '9001' } as any); - sinon.assert.calledOnceWithExactly(totpTokenFn, '9001'); + expect(totpTokenFn).toHaveBeenCalledTimes(1); + expect(totpTokenFn).toHaveBeenCalledWith('9001'); expect(res).toBe(true); }); it('returns false when TOTP token not found', async () => { - const totpTokenFn = sandbox.stub().rejects(AppError.totpTokenNotFound()); + const totpTokenFn = jest + .fn() + .mockRejectedValue(AppError.totpTokenNotFound()); const hasTotpToken = lib.createHasTotp2faFn(totpTokenFn); const res = await hasTotpToken({ uid: '9001' } as any); - sinon.assert.calledOnceWithExactly(totpTokenFn, '9001'); + expect(totpTokenFn).toHaveBeenCalledTimes(1); + expect(totpTokenFn).toHaveBeenCalledWith('9001'); expect(res).toBe(false); }); it('re-throws errors', async () => { - const totpTokenFn = sandbox.stub().rejects(AppError.invalidRequestBody()); + const totpTokenFn = jest + .fn() + .mockRejectedValue(AppError.invalidRequestBody()); const hasTotpToken = lib.createHasTotp2faFn(totpTokenFn); try { await hasTotpToken({ uid: '9001' } as any); throw new Error('should have thrown'); } catch (err: any) { - sinon.assert.calledOnceWithExactly(totpTokenFn, '9001'); + expect(totpTokenFn).toHaveBeenCalledTimes(1); + expect(totpTokenFn).toHaveBeenCalledWith('9001'); expect(err.errno).toBe(ERRNO.INVALID_JSON); } }); }); describe('credentials lookup function', () => { - let client: { POST: sinon.SinonStub }; + let client: { POST: jest.Mock }; beforeEach(() => { - client = { POST: sandbox.stub() }; + client = { POST: jest.fn() }; }); it('returns leaked credentials with cleartext password', async () => { @@ -199,7 +207,7 @@ describe('Recorded Future credentials search and reset script lib', () => { }, }, ]; - client.POST.resolves({ + client.POST.mockResolvedValue({ data: { identities: [ { credentials: [expected[0], filtered[0]] }, @@ -217,36 +225,33 @@ describe('Recorded Future credentials search and reset script lib', () => { const res = await lookupFn(subjects, { first_downloaded_gte: '2025-04-15', }); - sinon.assert.calledOnceWithExactly( - client.POST, - '/identity/credentials/lookup', - { - body: { - subjects_login: subjects, - filter: { first_downloaded_gte: '2025-04-15' }, - }, - } - ); + expect(client.POST).toHaveBeenCalledTimes(1); + expect(client.POST).toHaveBeenCalledWith('/identity/credentials/lookup', { + body: { + subjects_login: subjects, + filter: { first_downloaded_gte: '2025-04-15' }, + }, + }); expect(res).toEqual(expected); }); it('limits the subjects login in API call', async () => { - client.POST.resolves({ data: { identities: [] } }); + client.POST.mockResolvedValue({ data: { identities: [] } }); const lookupFn = lib.createCredentialsLookupFn(client as any); const subjects = Array(555); await lookupFn(subjects, { first_downloaded_gte: '2025-04-15', }); - sinon.assert.calledTwice(client.POST); + expect(client.POST).toHaveBeenCalledTimes(2); }); }); describe('verify password function', () => { it('checks the leaked password', async () => { - const getCredentials = sandbox.stub().resolves({ authPW: 'wibble' }); - const checkPassword = sandbox.stub().resolves({ match: false }); - const verifyHashStub = sandbox.stub().resolves('quux'); + const getCredentials = jest.fn().mockResolvedValue({ authPW: 'wibble' }); + const checkPassword = jest.fn().mockResolvedValue({ match: false }); + const verifyHashStub = jest.fn().mockResolvedValue('quux'); const Password = class { async verifyHash() { return verifyHashStub(); @@ -271,9 +276,11 @@ describe('Recorded Future credentials search and reset script lib', () => { }; const res = await verifyPassword(leakCredentials, acct as any); - sinon.assert.calledOnceWithExactly(getCredentials, acct, 'buzz'); - sinon.assert.calledOnce(verifyHashStub); - sinon.assert.calledOnceWithExactly(checkPassword, '9001', 'quux'); + expect(getCredentials).toHaveBeenCalledTimes(1); + expect(getCredentials).toHaveBeenCalledWith(acct, 'buzz'); + expect(verifyHashStub).toHaveBeenCalledTimes(1); + expect(checkPassword).toHaveBeenCalledTimes(1); + expect(checkPassword).toHaveBeenCalledWith('9001', 'quux'); expect(res).toBe(false); }); }); diff --git a/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/converter.spec.ts b/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/converter.spec.ts index 612c681690c..775317bd9db 100644 --- a/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/converter.spec.ts +++ b/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/converter.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import fs from 'fs'; import { Container } from 'typedi'; @@ -31,25 +30,26 @@ const mockGoogleTranslateShapedError = { }; jest.mock('./plan-language-tags-guesser', () => { - const sinon = require('sinon'); const actual = jest.requireActual('./plan-language-tags-guesser'); return { ...actual, - getLanguageTagFromPlanMetadata: sinon.stub().callsFake((plan: any) => { - if (plan.nickname.includes('es-ES')) { - return 'es-ES'; - } - if (plan.nickname.includes('fr')) { - return 'fr'; - } - if (plan.nickname === 'localised en plan') { - throw new Error(actual.PLAN_EN_LANG_ERROR); - } - if (plan.nickname === 'you cannot translate this') { - throw mockGoogleTranslateShapedError; - } - return 'en'; - }), + getLanguageTagFromPlanMetadata: jest + .fn() + .mockImplementation((plan: any) => { + if (plan.nickname.includes('es-ES')) { + return 'es-ES'; + } + if (plan.nickname.includes('fr')) { + return 'fr'; + } + if (plan.nickname === 'localised en plan') { + throw new Error(actual.PLAN_EN_LANG_ERROR); + } + if (plan.nickname === 'you cannot translate this') { + throw mockGoogleTranslateShapedError; + } + return 'en'; + }), }; }); @@ -57,10 +57,8 @@ const { StripeProductsAndPlansConverter, } = require('./stripe-products-and-plans-converter'); -const sandbox = sinon.createSandbox(); - const mockPaymentConfigManager = { - startListeners: sandbox.stub(), + startListeners: jest.fn(), }; const mockSupportedLanguages = ['es-ES', 'fr']; @@ -68,9 +66,9 @@ describe('StripeProductsAndPlansConverter', () => { let converter: any; beforeEach(() => { - mockLog.error = sandbox.fake.returns({}); - mockLog.info = sandbox.fake.returns({}); - mockLog.debug = sandbox.fake.returns({}); + mockLog.error = jest.fn().mockReturnValue({}); + mockLog.info = jest.fn().mockReturnValue({}); + mockLog.debug = jest.fn().mockReturnValue({}); Container.set(PaymentConfigManager, mockPaymentConfigManager); converter = new StripeProductsAndPlansConverter({ log: mockLog, @@ -80,7 +78,7 @@ describe('StripeProductsAndPlansConverter', () => { }); afterEach(() => { - sandbox.reset(); + jest.clearAllMocks(); Container.reset(); }); @@ -391,7 +389,7 @@ describe('StripeProductsAndPlansConverter', () => { afterEach(() => { Container.reset(); - sandbox.restore(); + jest.restoreAllMocks(); }); it('Should write the file', async () => { @@ -407,8 +405,12 @@ describe('StripeProductsAndPlansConverter', () => { 2 ); - paymentConfigManager.validateProductConfig = sandbox.stub().resolves(); - const spyWriteFile = sandbox.stub(fs.promises, 'writeFile').resolves(); + paymentConfigManager.validateProductConfig = jest + .fn() + .mockResolvedValue(undefined); + const spyWriteFile = jest + .spyOn(fs.promises, 'writeFile') + .mockResolvedValue(); await converter.writeToFileProductConfig( productConfig, @@ -416,19 +418,27 @@ describe('StripeProductsAndPlansConverter', () => { testPath ); - sinon.assert.calledOnce(paymentConfigManager.validateProductConfig); - sinon.assert.calledWithExactly(spyWriteFile, testPath, expectedJSON); + expect(paymentConfigManager.validateProductConfig).toHaveBeenCalledTimes( + 1 + ); + expect(spyWriteFile).toHaveBeenCalledWith(testPath, expectedJSON); }); it('Throws an error when validation fails', async () => { - paymentConfigManager.validateProductConfig = sandbox.stub().rejects(); - const spyWriteFile = sandbox.stub(fs.promises, 'writeFile').resolves(); + paymentConfigManager.validateProductConfig = jest + .fn() + .mockRejectedValue(undefined); + const spyWriteFile = jest + .spyOn(fs.promises, 'writeFile') + .mockResolvedValue(); try { await converter.writeToFileProductConfig(); - sinon.assert.fail('An exception is expected to be thrown'); + throw new Error('An exception is expected to be thrown'); } catch (err) { - sinon.assert.calledOnce(paymentConfigManager.validateProductConfig); - sinon.assert.notCalled(spyWriteFile); + expect( + paymentConfigManager.validateProductConfig + ).toHaveBeenCalledTimes(1); + expect(spyWriteFile).not.toHaveBeenCalled(); } }); }); @@ -472,7 +482,7 @@ describe('StripeProductsAndPlansConverter', () => { afterEach(() => { Container.reset(); - sandbox.restore(); + jest.restoreAllMocks(); }); it('Should write the file', async () => { @@ -488,8 +498,12 @@ describe('StripeProductsAndPlansConverter', () => { 2 ); - paymentConfigManager.validatePlanConfig = sandbox.stub().resolves(); - const spyWriteFile = sandbox.stub(fs.promises, 'writeFile').resolves(); + paymentConfigManager.validatePlanConfig = jest + .fn() + .mockResolvedValue(undefined); + const spyWriteFile = jest + .spyOn(fs.promises, 'writeFile') + .mockResolvedValue(); await converter.writeToFilePlanConfig( planConfig, @@ -498,20 +512,26 @@ describe('StripeProductsAndPlansConverter', () => { testPath ); - sinon.assert.calledOnce(paymentConfigManager.validatePlanConfig); - sinon.assert.calledWithExactly(spyWriteFile, testPath, expectedJSON); + expect(paymentConfigManager.validatePlanConfig).toHaveBeenCalledTimes(1); + expect(spyWriteFile).toHaveBeenCalledWith(testPath, expectedJSON); }); it('Throws an error when validation fails', async () => { - paymentConfigManager.validatePlanConfig = sandbox.stub().rejects(); - const spyWriteFile = sandbox.stub(fs.promises, 'writeFile').resolves(); + paymentConfigManager.validatePlanConfig = jest + .fn() + .mockRejectedValue(undefined); + const spyWriteFile = jest + .spyOn(fs.promises, 'writeFile') + .mockResolvedValue(); try { await converter.writeToFilePlanConfig(); - sinon.assert.fail('An exception is expected to be thrown'); + throw new Error('An exception is expected to be thrown'); } catch (err) { - sinon.assert.calledOnce(paymentConfigManager.validatePlanConfig); - sinon.assert.notCalled(spyWriteFile); + expect(paymentConfigManager.validatePlanConfig).toHaveBeenCalledTimes( + 1 + ); + expect(spyWriteFile).not.toHaveBeenCalled(); } }); }); diff --git a/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/plan-language-tags-guesser.spec.ts b/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/plan-language-tags-guesser.spec.ts index 10eb997dc07..cf7f386b81a 100644 --- a/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/plan-language-tags-guesser.spec.ts +++ b/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/plan-language-tags-guesser.spec.ts @@ -2,15 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - -const sandbox = sinon.createSandbox(); - const googleTranslate = require('@google-cloud/translate'); -const googleTranslateV2Mock: any = sandbox.createStubInstance( - googleTranslate.v2.Translate -); -sandbox.stub(googleTranslate.v2, 'Translate').returns(googleTranslateV2Mock); +const googleTranslateV2Mock: any = { + detect: jest.fn(), +}; +jest + .spyOn(googleTranslate.v2, 'Translate') + .mockReturnValue(googleTranslateV2Mock); const supportedLanguages = [ 'de', 'de-ch', @@ -34,8 +32,8 @@ describe('getLanguageTagFromPlanMetadata', () => { }; beforeEach(() => { - googleTranslateV2Mock.detect.reset(); - googleTranslateV2Mock.detect.resolves([ + googleTranslateV2Mock.detect.mockReset(); + googleTranslateV2Mock.detect.mockResolvedValue([ { confidence: 0.9, language: 'en' }, ]); }); @@ -50,8 +48,8 @@ describe('getLanguageTagFromPlanMetadata', () => { it('throws an error when the Google Translate result confidence is lower than the min', async () => { try { - googleTranslateV2Mock.detect.reset(); - googleTranslateV2Mock.detect.resolves([ + googleTranslateV2Mock.detect.mockReset(); + googleTranslateV2Mock.detect.mockResolvedValue([ { confidence: 0.3, language: 'en' }, ]); await getLanguageTagFromPlanMetadata(plan, supportedLanguages); @@ -88,8 +86,8 @@ describe('getLanguageTagFromPlanMetadata', () => { }); it('returns the Google Translate detected language', async () => { - googleTranslateV2Mock.detect.reset(); - googleTranslateV2Mock.detect.resolves([ + googleTranslateV2Mock.detect.mockReset(); + googleTranslateV2Mock.detect.mockResolvedValue([ { confidence: 0.9, language: 'es' }, ]); const actual = await getLanguageTagFromPlanMetadata( @@ -113,8 +111,8 @@ describe('getLanguageTagFromPlanMetadata', () => { }); it('returns a language tag with the subtag found in the plan title', async () => { - googleTranslateV2Mock.detect.reset(); - googleTranslateV2Mock.detect.resolves([ + googleTranslateV2Mock.detect.mockReset(); + googleTranslateV2Mock.detect.mockResolvedValue([ { confidence: 0.9, language: 'nl' }, ]); const p = { @@ -126,8 +124,8 @@ describe('getLanguageTagFromPlanMetadata', () => { }); it('returns a Swiss language tag based on the plan currency', async () => { - googleTranslateV2Mock.detect.reset(); - googleTranslateV2Mock.detect.resolves([ + googleTranslateV2Mock.detect.mockReset(); + googleTranslateV2Mock.detect.mockResolvedValue([ { confidence: 0.9, language: 'de' }, ]); const p = { diff --git a/packages/fxa-auth-server/scripts/test-ci.sh b/packages/fxa-auth-server/scripts/test-ci.sh index 0a9d5734495..3af1ff73ec8 100755 --- a/packages/fxa-auth-server/scripts/test-ci.sh +++ b/packages/fxa-auth-server/scripts/test-ci.sh @@ -22,12 +22,12 @@ elif [ "$TEST_TYPE" == 'integration' ]; then echo -e "\n\nRunning Jest integration tests (excluding test/scripts)" JEST_JUNIT_OUTPUT_DIR="../../artifacts/tests/fxa-auth-server" \ JEST_JUNIT_OUTPUT_NAME="fxa-auth-server-jest-integration-results.xml" \ - npx jest --config jest.integration.config.js --forceExit --ci --silent --reporters=default --reporters=jest-junit + npx jest --config jest.integration.config.js --forceExit --ci --reporters=default --reporters=jest-junit echo -e "\n\nRunning Jest OAuth API integration tests (in-process server)" JEST_JUNIT_OUTPUT_DIR="../../artifacts/tests/fxa-auth-server" \ JEST_JUNIT_OUTPUT_NAME="fxa-auth-server-jest-oauth-api-results.xml" \ - npx jest --config jest.oauth-api.config.js --forceExit --ci --silent --reporters=default --reporters=jest-junit + npx jest --config jest.oauth-api.config.js --forceExit --ci --reporters=default --reporters=jest-junit yarn run clean-up-old-ci-stripe-customers diff --git a/packages/fxa-auth-server/scripts/update-subscriptions-to-new-plan/update-subscriptions-to-new-plan.spec.ts b/packages/fxa-auth-server/scripts/update-subscriptions-to-new-plan/update-subscriptions-to-new-plan.spec.ts index fdbd119d65f..ac83f9e8919 100644 --- a/packages/fxa-auth-server/scripts/update-subscriptions-to-new-plan/update-subscriptions-to-new-plan.spec.ts +++ b/packages/fxa-auth-server/scripts/update-subscriptions-to-new-plan/update-subscriptions-to-new-plan.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import Container from 'typedi'; import { ConfigType } from '../../config'; @@ -51,20 +50,20 @@ describe('CustomerPlanMover', () => { let stripeStub: Stripe; let stripeHelperStub: StripeHelper; let dbStub: any; - let firestoreGetStub: sinon.SinonStub; + let firestoreGetStub: jest.Mock; const planIdMap = { [mockSubscription.items.data[0].plan.id]: 'updated', }; const prorationBehavior = 'none'; beforeEach(() => { - firestoreGetStub = sinon.stub(); + firestoreGetStub = jest.fn(); Container.set(AuthFirestore, { - collectionGroup: sinon.stub().returns({ - where: sinon.stub().returnsThis(), - orderBy: sinon.stub().returnsThis(), - startAfter: sinon.stub().returnsThis(), - limit: sinon.stub().returnsThis(), + collectionGroup: jest.fn().mockReturnValue({ + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + startAfter: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), get: firestoreGetStub, }), }); @@ -72,7 +71,7 @@ describe('CustomerPlanMover', () => { Container.set(AppConfig, mockConfig); stripeStub = { - on: sinon.stub(), + on: jest.fn(), products: {}, customers: {}, subscriptions: {}, @@ -82,12 +81,12 @@ describe('CustomerPlanMover', () => { stripeHelperStub = { stripe: stripeStub, currencyHelper: { - isCurrencyCompatibleWithCountry: sinon.stub(), + isCurrencyCompatibleWithCountry: jest.fn(), }, } as unknown as StripeHelper; dbStub = { - account: sinon.stub(), + account: jest.fn(), }; subscriptionUpdater = new SubscriptionUpdater( @@ -107,31 +106,29 @@ describe('CustomerPlanMover', () => { }); describe('update', () => { - let fetchSubsBatchStub: sinon.SinonStub; - let processSubscriptionStub: sinon.SinonStub; + let fetchSubsBatchStub: jest.Mock; + let processSubscriptionStub: jest.Mock; const mockSubs = [mockSubscription]; beforeEach(async () => { - fetchSubsBatchStub = sinon - .stub() - .onFirstCall() - .returns(mockSubs) - .onSecondCall() - .returns([]); + fetchSubsBatchStub = jest + .fn() + .mockReturnValueOnce(mockSubs) + .mockReturnValueOnce([]); subscriptionUpdater.fetchSubsBatch = fetchSubsBatchStub; - processSubscriptionStub = sinon.stub(); + processSubscriptionStub = jest.fn(); subscriptionUpdater.processSubscription = processSubscriptionStub; await subscriptionUpdater.update(); }); it('fetches subscriptions until no results', () => { - expect(fetchSubsBatchStub.callCount).toBe(2); + expect(fetchSubsBatchStub).toHaveBeenCalledTimes(2); }); it('generates a report for each applicable subscription', () => { - expect(processSubscriptionStub.callCount).toBe(1); + expect(processSubscriptionStub).toHaveBeenCalledTimes(1); }); }); @@ -140,10 +137,10 @@ describe('CustomerPlanMover', () => { let result: FirestoreSubscription[]; beforeEach(async () => { - firestoreGetStub.resolves({ + firestoreGetStub.mockResolvedValue({ docs: [ { - data: sinon.stub().returns(mockSubscription), + data: jest.fn().mockReturnValue(mockSubscription), }, ], }); @@ -152,7 +149,7 @@ describe('CustomerPlanMover', () => { }); it('returns a list of subscriptions from Firestore', () => { - sinon.assert.match(result, [mockSubscription]); + expect(result).toEqual([mockSubscription]); }); }); @@ -166,28 +163,30 @@ describe('CustomerPlanMover', () => { status: 'active', } as FirestoreSubscription; const mockReport = ['mock-report']; - let logStub: sinon.SinonStub; - let updateSubscriptionStub: sinon.SinonStub; - let buildReport: sinon.SinonStub; - let writeReportStub: sinon.SinonStub; + let logStub: jest.SpyInstance; + let updateSubscriptionStub: jest.Mock; + let buildReport: jest.Mock; + let writeReportStub: jest.Mock; beforeEach(async () => { - stripeStub.products.retrieve = sinon.stub().resolves(mockProduct); - subscriptionUpdater.fetchCustomer = sinon.stub().resolves(mockCustomer); - dbStub.account.resolves({ + stripeStub.products.retrieve = jest.fn().mockResolvedValue(mockProduct); + subscriptionUpdater.fetchCustomer = jest + .fn() + .mockResolvedValue(mockCustomer); + dbStub.account.mockResolvedValue({ locale: 'en-US', }); - updateSubscriptionStub = sinon.stub().resolves(); + updateSubscriptionStub = jest.fn().mockResolvedValue(undefined); subscriptionUpdater.updateSubscription = updateSubscriptionStub; - buildReport = sinon.stub().returns(mockReport); + buildReport = jest.fn().mockReturnValue(mockReport); subscriptionUpdater.buildReport = buildReport; - writeReportStub = sinon.stub().resolves(); + writeReportStub = jest.fn().mockResolvedValue(undefined); subscriptionUpdater.writeReport = writeReportStub; - logStub = sinon.stub(console, 'log'); + logStub = jest.spyOn(console, 'log').mockImplementation(); }); afterEach(() => { - logStub.restore(); + logStub.mockRestore(); }); describe('success', () => { @@ -196,11 +195,11 @@ describe('CustomerPlanMover', () => { }); it('updates subscription', () => { - expect(updateSubscriptionStub.calledWith(mockFirestoreSub)).toBe(true); + expect(updateSubscriptionStub).toHaveBeenCalledWith(mockFirestoreSub); }); it('writes the report to disk', () => { - expect(writeReportStub.calledWith(mockReport)).toBe(true); + expect(writeReportStub).toHaveBeenCalledWith(mockReport); }); }); @@ -211,27 +210,29 @@ describe('CustomerPlanMover', () => { }); it('does not update subscription', () => { - expect(updateSubscriptionStub.calledWith(mockFirestoreSub)).toBe(false); + expect(updateSubscriptionStub).not.toHaveBeenCalledWith( + mockFirestoreSub + ); }); it('writes the report to disk', () => { - expect(writeReportStub.calledWith(mockReport)).toBe(true); + expect(writeReportStub).toHaveBeenCalledWith(mockReport); }); }); describe('invalid', () => { it('aborts if customer does not exist', async () => { - subscriptionUpdater.fetchCustomer = sinon.stub().resolves(null); + subscriptionUpdater.fetchCustomer = jest.fn().mockResolvedValue(null); await subscriptionUpdater.processSubscription(mockFirestoreSub); - expect(writeReportStub.notCalled).toBe(true); + expect(writeReportStub).not.toHaveBeenCalled(); }); it('aborts if account for customer does not exist', async () => { - dbStub.account.resolves(null); + dbStub.account.mockResolvedValue(null); await subscriptionUpdater.processSubscription(mockFirestoreSub); - expect(writeReportStub.notCalled).toBe(true); + expect(writeReportStub).not.toHaveBeenCalled(); }); it('does not move subscription if subscription is not in active state', async () => { @@ -240,34 +241,32 @@ describe('CustomerPlanMover', () => { status: 'canceled', }); - expect(updateSubscriptionStub.notCalled).toBe(true); - expect(writeReportStub.notCalled).toBe(true); + expect(updateSubscriptionStub).not.toHaveBeenCalled(); + expect(writeReportStub).not.toHaveBeenCalled(); }); }); }); describe('fetchCustomer', () => { - let customerRetrieveStub: sinon.SinonStub; + let customerRetrieveStub: jest.Mock; let result: Stripe.Customer | Stripe.DeletedCustomer | null; describe('customer exists', () => { beforeEach(async () => { - customerRetrieveStub = sinon.stub().resolves(mockCustomer); + customerRetrieveStub = jest.fn().mockResolvedValue(mockCustomer); stripeStub.customers.retrieve = customerRetrieveStub; result = await subscriptionUpdater.fetchCustomer(mockCustomer.id); }); it('fetches customer from Stripe', () => { - expect( - customerRetrieveStub.calledWith(mockCustomer.id, { - expand: ['subscriptions'], - }) - ).toBe(true); + expect(customerRetrieveStub).toHaveBeenCalledWith(mockCustomer.id, { + expand: ['subscriptions'], + }); }); it('returns customer', () => { - sinon.assert.match(result, mockCustomer); + expect(result).toEqual(mockCustomer); }); }); @@ -277,52 +276,50 @@ describe('CustomerPlanMover', () => { ...mockCustomer, deleted: true, }; - customerRetrieveStub = sinon.stub().resolves(deletedCustomer); + customerRetrieveStub = jest.fn().mockResolvedValue(deletedCustomer); stripeStub.customers.retrieve = customerRetrieveStub; result = await subscriptionUpdater.fetchCustomer(mockCustomer.id); }); it('returns null', () => { - sinon.assert.match(result, null); + expect(result).toEqual(null); }); }); }); describe('updateSubscription', () => { - let retrieveStub: sinon.SinonStub; - let updateStub: sinon.SinonStub; + let retrieveStub: jest.Mock; + let updateStub: jest.Mock; beforeEach(async () => { - retrieveStub = sinon.stub().resolves(mockSubscription); + retrieveStub = jest.fn().mockResolvedValue(mockSubscription); stripeStub.subscriptions.retrieve = retrieveStub; - updateStub = sinon.stub().resolves(); + updateStub = jest.fn().mockResolvedValue(undefined); stripeStub.subscriptions.update = updateStub; await subscriptionUpdater.updateSubscription(mockSubscription); }); it('retrieves the subscription', () => { - expect(retrieveStub.calledWith(mockSubscription.id)).toBe(true); + expect(retrieveStub).toHaveBeenCalledWith(mockSubscription.id); }); it('updates the subscription', () => { - expect( - updateStub.calledWith(mockSubscription.id, { - proration_behavior: prorationBehavior, - items: [ - { - id: mockSubscription.items.data[0].id, - plan: 'updated', - }, - ], - metadata: { - previous_plan_id: mockSubscription.items.data[0].plan.id, - plan_change_date: sinon.match.number, + expect(updateStub).toHaveBeenCalledWith(mockSubscription.id, { + proration_behavior: prorationBehavior, + items: [ + { + id: mockSubscription.items.data[0].id, + plan: 'updated', }, - }) - ).toBe(true); + ], + metadata: { + previous_plan_id: mockSubscription.items.data[0].plan.id, + plan_change_date: expect.any(Number), + }, + }); }); }); @@ -330,7 +327,7 @@ describe('CustomerPlanMover', () => { it('returns a report', () => { const result = subscriptionUpdater.buildReport(mockCustomer, mockAccount); - sinon.assert.match(result, [ + expect(result).toEqual([ mockCustomer.metadata.userid, `"${mockCustomer.email}"`, `"${mockAccount.locale}"`, diff --git a/packages/fxa-auth-server/test/lib/server.js b/packages/fxa-auth-server/test/lib/server.js index db620cfc410..cf4d35d080f 100644 --- a/packages/fxa-auth-server/test/lib/server.js +++ b/packages/fxa-auth-server/test/lib/server.js @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const { default: Container } = require('typedi'); -const sinon = require('sinon'); process.env.CONFIG_FILES = require.resolve('./oauth-test.json'); const { config } = require('../../config'); @@ -65,8 +64,10 @@ function wrapServer(server, close) { module.exports.start = async function () { if (!Container.has(CapabilityService)) { Container.set(CapabilityService, { - subscriptionCapabilities: sinon.fake.resolves([]), - determineClientVisibleSubscriptionCapabilities: sinon.fake.resolves(''), + subscriptionCapabilities: jest.fn().mockResolvedValue([]), + determineClientVisibleSubscriptionCapabilities: jest + .fn() + .mockResolvedValue(''), }); } const { server, close } = await createServer(testConfig); diff --git a/packages/fxa-auth-server/test/lib/server.ts b/packages/fxa-auth-server/test/lib/server.ts index fb5d5a4f576..85c613599db 100644 --- a/packages/fxa-auth-server/test/lib/server.ts +++ b/packages/fxa-auth-server/test/lib/server.ts @@ -3,7 +3,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { default as Container } from 'typedi'; -import sinon from 'sinon'; process.env.CONFIG_FILES = require.resolve('./oauth-test.json'); const { config } = require('../../config'); @@ -71,8 +70,10 @@ function wrapServer(server: any, close: () => Promise) { export async function start() { if (!Container.has(CapabilityService)) { Container.set(CapabilityService, { - subscriptionCapabilities: sinon.fake.resolves([]), - determineClientVisibleSubscriptionCapabilities: sinon.fake.resolves(''), + subscriptionCapabilities: jest.fn().mockResolvedValue([]), + determineClientVisibleSubscriptionCapabilities: jest + .fn() + .mockResolvedValue(''), }); } const { server, close } = await createServer(testConfig); diff --git a/packages/fxa-auth-server/test/mocks.js b/packages/fxa-auth-server/test/mocks.js index 216f70d16eb..bca6e9bf994 100644 --- a/packages/fxa-auth-server/test/mocks.js +++ b/packages/fxa-auth-server/test/mocks.js @@ -13,7 +13,6 @@ const config = require('../config').default.getProperties(); const crypto = require('crypto'); const { AppError: error } = require('@fxa/accounts/errors'); const knownIpLocation = require('./known-ip-location'); -const sinon = require('sinon'); const { normalizeEmail } = require('fxa-shared').email.helpers; const { Container } = require('typedi'); const { AccountEventsManager } = require('../lib/account-events'); @@ -22,15 +21,23 @@ const { PriceManager } = require('@fxa/payments/customer'); const { ProductConfigurationManager } = require('@fxa/shared/cms'); const { FxaMailer } = require('../lib/senders/fxa-mailer'); -const proxyquire = require('proxyquire'); +// Patch Account.metricsEnabled before loading amplitude (replicates what +// proxyquire used to do without replacing the entire auth models module). +// Guard: some test files mock 'fxa-shared/db/models/auth' as {} before this runs. +const _authModels = require('fxa-shared/db/models/auth'); +const _Account = _authModels.Account; +let _origMetricsEnabled; +if (_Account) { + _origMetricsEnabled = _Account.metricsEnabled; + _Account.metricsEnabled = jest.fn().mockResolvedValue(true); +} const OAuthClientInfoServiceName = 'OAuthClientInfo'; -const amplitudeModule = proxyquire('../lib/metrics/amplitude', { - 'fxa-shared/db/models/auth': { - Account: { - metricsEnabled: sinon.stub().resolves(true), - }, - }, -}); +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const amplitudeModule = require('../lib/metrics/amplitude'); +// Restore so other tests see the real function (or their own mock) +if (_Account && _origMetricsEnabled) { + _Account.metricsEnabled = _origMetricsEnabled; +} const CUSTOMS_METHOD_NAMES = [ 'check', @@ -367,13 +374,13 @@ function mockCustoms(errors) { return mockObject(CUSTOMS_METHOD_NAMES)({ checkAuthenticated: optionallyThrow(errors, 'checkAuthenticated'), checkIpOnly: optionallyThrow(errors, 'checkIpOnly'), - v2Enabled: sinon.spy(() => true), - resetV2: sinon.spy(() => Promise.resolve()), + v2Enabled: jest.fn(() => true), + resetV2: jest.fn(() => Promise.resolve()), }); } function optionallyThrow(errors, methodName) { - return sinon.spy(() => { + return jest.fn(() => { if (errors[methodName]) { return Promise.reject(errors[methodName]); } @@ -386,7 +393,7 @@ function mockDB(data, errors) { errors = errors || {}; return mockObject(DB_METHOD_NAMES)({ - account: sinon.spy((uid) => { + account: jest.fn((uid) => { assert.ok(typeof uid === 'string'); return Promise.resolve({ createdAt: data.createdAt, @@ -417,7 +424,7 @@ function mockDB(data, errors) { metricsOptOutAt: data.metricsOptOutAt || null, }); }), - accountEmails: sinon.spy((uid) => { + accountEmails: jest.fn((uid) => { assert.ok(typeof uid === 'string'); return Promise.resolve([ { @@ -439,10 +446,10 @@ function mockDB(data, errors) { }, ]); }), - accountExists: sinon.spy(() => { + accountExists: jest.fn(() => { return Promise.resolve(data.exists ?? true); }), - accountRecord: sinon.spy(() => { + accountRecord: jest.fn(() => { if (errors.emailRecord) { return Promise.reject(errors.emailRecord); } @@ -478,7 +485,7 @@ function mockDB(data, errors) { linkedAccounts: data.linkedAccounts, }); }), - consumeSigninCode: sinon.spy(() => { + consumeSigninCode: jest.fn(() => { if (errors.consumeSigninCode) { return Promise.reject(errors.consumeSigninCode); } @@ -487,7 +494,7 @@ function mockDB(data, errors) { flowId: data.flowId, }); }), - createAccount: sinon.spy(() => { + createAccount: jest.fn(() => { return Promise.resolve({ uid: data.uid, email: data.email, @@ -500,7 +507,7 @@ function mockDB(data, errors) { }, }); }), - createDevice: sinon.spy((uid) => { + createDevice: jest.fn((uid) => { assert.ok(typeof uid === 'string'); return Promise.resolve( Object.keys(data.device).reduce( @@ -515,19 +522,19 @@ function mockDB(data, errors) { ) ); }), - createKeyFetchToken: sinon.spy(() => { + createKeyFetchToken: jest.fn(() => { return Promise.resolve({ data: crypto.randomBytes(32).toString('hex'), id: data.keyFetchTokenId, uid: data.uid, }); }), - createPasswordChangeToken: sinon.spy(() => { + createPasswordChangeToken: jest.fn(() => { return Promise.resolve({ data: crypto.randomBytes(32).toString('hex'), }); }), - createPasswordForgotToken: sinon.spy(() => { + createPasswordForgotToken: jest.fn(() => { return Promise.resolve({ data: data.data || crypto.randomBytes(32).toString('hex'), passCode: data.passCode, @@ -539,7 +546,7 @@ function mockDB(data, errors) { email: data.emailToHashWith || 'emailToHashWith@email.com', }); }), - createSessionToken: sinon.spy((opts) => { + createSessionToken: jest.fn((opts) => { return Promise.resolve({ createdAt: opts.createdAt || Date.now(), data: crypto.randomBytes(32).toString('hex'), @@ -568,30 +575,30 @@ function mockDB(data, errors) { uid: opts.uid || data.uid, }); }), - createSigninCode: sinon.spy((uid, flowId) => { + createSigninCode: jest.fn((uid, flowId) => { assert.ok(typeof uid === 'string'); assert.ok(typeof flowId === 'string'); return Promise.resolve(data.signinCode || []); }), - devices: sinon.spy((uid) => { + devices: jest.fn((uid) => { assert.ok(typeof uid === 'string'); return Promise.resolve(data.devices || []); }), - device: sinon.spy((uid, deviceId) => { + device: jest.fn((uid, deviceId) => { assert.ok(typeof uid === 'string'); assert.ok(typeof deviceId === 'string'); const device = data.devices.find((d) => d.id === deviceId); assert.ok(device); return Promise.resolve(device); }), - deviceFromRefreshTokenId: sinon.spy(() => { + deviceFromRefreshTokenId: jest.fn(() => { return Promise.resolve(null); }), - deleteSessionToken: sinon.spy(() => { + deleteSessionToken: jest.fn(() => { return Promise.resolve(); }), - deleteAccountSubscription: sinon.spy(async (uid, subscriptionId) => true), - emailRecord: sinon.spy(() => { + deleteAccountSubscription: jest.fn(async (uid, subscriptionId) => true), + emailRecord: jest.fn(() => { if (errors.emailRecord) { return Promise.reject(errors.emailRecord); } @@ -623,13 +630,13 @@ function mockDB(data, errors) { wrapWrapKb: crypto.randomBytes(32).toString('hex'), }); }), - forgotPasswordVerified: sinon.spy(() => { + forgotPasswordVerified: jest.fn(() => { return Promise.resolve(data.accountResetToken); }), - getSecondaryEmail: sinon.spy(() => { + getSecondaryEmail: jest.fn(() => { return Promise.reject(error.unknownSecondaryEmail()); }), - getRecoveryKey: sinon.spy(() => { + getRecoveryKey: jest.fn(() => { if (data.recoveryKeyIdInvalid) { return Promise.reject(error.recoveryKeyInvalid()); } @@ -638,18 +645,18 @@ function mockDB(data, errors) { recoveryData: data.recoveryData, }); }), - getRecoveryKeyRecordWithHint: sinon.spy(() => { + getRecoveryKeyRecordWithHint: jest.fn(() => { return Promise.resolve({ hint: data.hint }); }), - recoveryKeyExists: sinon.spy(() => { + recoveryKeyExists: jest.fn(() => { return Promise.resolve({ exists: !!data.recoveryData, }); }), - securityEvents: sinon.spy(() => { + securityEvents: jest.fn(() => { return Promise.resolve([]); }), - securityEventsByUid: sinon.spy(() => { + securityEventsByUid: jest.fn(() => { return Promise.resolve([ { name: 'account.create', @@ -668,21 +675,21 @@ function mockDB(data, errors) { }, ]); }), - sessions: sinon.spy((uid) => { + sessions: jest.fn((uid) => { assert.ok(typeof uid === 'string'); return Promise.resolve(data.sessions || []); }), - updateDevice: sinon.spy((uid, device) => { + updateDevice: jest.fn((uid, device) => { assert.ok(typeof uid === 'string'); return Promise.resolve(device); }), - verifiedLoginSecurityEvents: sinon.spy(() => { + verifiedLoginSecurityEvents: jest.fn(() => { return Promise.resolve([]); }), - verifiedLoginSecurityEventsByUid: sinon.spy(() => { + verifiedLoginSecurityEventsByUid: jest.fn(() => { return Promise.resolve([]); }), - sessionToken: sinon.spy(() => { + sessionToken: jest.fn(() => { const res = { id: data.sessionTokenId || 'fake session token id', uid: data.uid || 'fake uid', @@ -693,7 +700,7 @@ function mockDB(data, errors) { uaOSVersion: data.uaOSVersion, uaDeviceType: data.uaDeviceType, expired: () => data.expired || false, - setUserAgentInfo: sinon.spy(() => {}), + setUserAgentInfo: jest.fn(() => {}), }; // SessionToken is a class, and tokenTypeID is a class attribute. Fake that. res.constructor.tokenTypeID = 'sessionToken'; @@ -708,41 +715,41 @@ function mockDB(data, errors) { return Promise.resolve(res); }), verifyTokens: optionallyThrow(errors, 'verifyTokens'), - replaceRecoveryCodes: sinon.spy(() => { + replaceRecoveryCodes: jest.fn(() => { return Promise.resolve(['12312312', '12312312']); }), - setRecoveryCodes: sinon.spy(() => { + setRecoveryCodes: jest.fn(() => { return Promise.resolve({ success: true }); }), - createRecoveryCodes: sinon.spy(() => { + createRecoveryCodes: jest.fn(() => { return Promise.resolve(['12312312', '12312312']); }), - updateRecoveryCodes: sinon.spy(() => { + updateRecoveryCodes: jest.fn(() => { return Promise.resolve({ success: true }); }), - createPassword: sinon.spy(() => { + createPassword: jest.fn(() => { return Promise.resolve(1584397692000); }), - checkPassword: sinon.spy(() => { + checkPassword: jest.fn(() => { return Promise.resolve({ v1: data.isPasswordMatchV1 === true, v2: data.isPasswordMatchV2 === true, }); }), - resetAccount: sinon.spy(() => { + resetAccount: jest.fn(() => { return Promise.resolve({ uid: data.uid, verifierSetAt: Date.now(), email: data.email, }); }), - totpToken: sinon.spy((uid) => { + totpToken: jest.fn((uid) => { assert.ok(typeof uid === 'string'); return Promise.resolve({ enabled: false, }); }), - getLinkedAccounts: sinon.spy((uid) => { + getLinkedAccounts: jest.fn((uid) => { assert.ok(typeof uid === 'string'); return Promise.resolve(data.linkedAccounts || []); }), @@ -753,7 +760,7 @@ function mockObject(methodNames, baseObj) { return (methods) => { methods = methods || {}; return methodNames.reduce((object, name) => { - object[name] = methods[name] || sinon.spy(() => Promise.resolve()); + object[name] = methods[name] || jest.fn(() => Promise.resolve()); return object; }, baseObj || {}); }; @@ -763,7 +770,7 @@ function mockPush(methods) { const push = Object.assign({}, methods); PUSH_METHOD_NAMES.forEach((name) => { if (!push[name]) { - push[name] = sinon.spy(() => Promise.resolve()); + push[name] = jest.fn(() => Promise.resolve()); } }); return push; @@ -773,7 +780,7 @@ function mockPushbox(methods) { const pushbox = Object.assign({}, methods); if (!pushbox.retrieve) { // Route code expects the `retrieve` method to return a properly-structured object. - pushbox.retrieve = sinon.spy(() => + pushbox.retrieve = jest.fn(() => Promise.resolve({ last: true, index: 0, @@ -783,7 +790,7 @@ function mockPushbox(methods) { } PUSHBOX_METHOD_NAMES.forEach((name) => { if (!pushbox[name]) { - pushbox[name] = sinon.spy(() => Promise.resolve()); + pushbox[name] = jest.fn(() => Promise.resolve()); } }); return pushbox; @@ -793,7 +800,7 @@ function mockSubHub(methods) { const subscriptionsBackend = Object.assign({}, methods); SUBHUB_METHOD_NAMES.forEach((name) => { if (!subscriptionsBackend[name]) { - subscriptionsBackend[name] = sinon.spy(() => Promise.resolve()); + subscriptionsBackend[name] = jest.fn(() => Promise.resolve()); } }); return subscriptionsBackend; @@ -803,7 +810,7 @@ function mockProfile(methods) { const profileBackend = Object.assign({}, methods); PROFILE_METHOD_NAMES.forEach((name) => { if (!profileBackend[name]) { - profileBackend[name] = sinon.spy(() => Promise.resolve()); + profileBackend[name] = jest.fn(() => Promise.resolve()); } }); return profileBackend; @@ -814,8 +821,8 @@ function mockDevices(data, errors) { errors = errors || {}; return { - isSpuriousUpdate: sinon.spy(() => data.spurious || false), - upsert: sinon.spy(() => { + isSpuriousUpdate: jest.fn(() => data.spurious || false), + upsert: jest.fn(() => { if (errors.upsert) { return Promise.reject(errors.upsert); } @@ -825,10 +832,10 @@ function mockDevices(data, errors) { type: data.deviceType || 'desktop', }); }), - destroy: sinon.spy(async () => { + destroy: jest.fn(async () => { return data; }), - synthesizeName: sinon.spy(() => { + synthesizeName: jest.fn(() => { return data.deviceName || ''; }), }; @@ -839,7 +846,7 @@ function mockMetricsContext(methods) { return mockObject(METRICS_CONTEXT_METHOD_NAMES)({ gather: methods.gather || - sinon.spy(function (data) { + jest.fn(function (data) { const time = Date.now(); return Promise.resolve().then(() => { if (this.payload && this.payload.metricsContext) { @@ -880,13 +887,13 @@ function mockMetricsContext(methods) { }); }), - setFlowCompleteSignal: sinon.spy(function (flowCompleteSignal) { + setFlowCompleteSignal: jest.fn(function (flowCompleteSignal) { if (this.payload && this.payload.metricsContext) { this.payload.metricsContext.flowCompleteSignal = flowCompleteSignal; } }), - validate: methods.validate || sinon.spy(() => true), + validate: methods.validate || jest.fn(() => true), }); } @@ -906,15 +913,16 @@ function generateMetricsContext() { } function mockRequest(data, errors) { - const events = proxyquire('../lib/metrics/events', { - './amplitude': amplitudeModule, - })(data.log || module.exports.mockLog(), { - amplitude: { rawEvents: false }, - oauth: { - clientIds: data.clientIds || {}, - }, - verificationReminders: {}, - }); + const events = require('../lib/metrics/events')( + data.log || module.exports.mockLog(), + { + amplitude: { rawEvents: false }, + oauth: { + clientIds: data.clientIds || {}, + }, + verificationReminders: {}, + } + ); const metricsContext = data.metricsContext || module.exports.mockMetricsContext(); @@ -998,13 +1006,13 @@ function mockRequest(data, errors) { function mockVerificationReminders(data = {}) { return { keys: ['first', 'second', 'third', 'final'], - create: sinon.spy( + create: jest.fn( () => data.create || { first: 1, second: 1, third: 1, final: 1 } ), - delete: sinon.spy( + delete: jest.fn( () => data.delete || { first: 1, second: 1, third: 1, final: 1 } ), - process: sinon.spy( + process: jest.fn( () => data.process || { first: [], second: [], third: [], final: [] } ), }; @@ -1028,12 +1036,10 @@ function asyncIterable(lst) { function mockCadReminders(data = {}) { return { keys: ['first', 'second', 'third'], - create: sinon.spy(() => data.create || { first: 1, second: 1, third: 1 }), - delete: sinon.spy(() => data.delete || { first: 1, second: 1, third: 1 }), - get: sinon.spy( - () => data.get || { first: null, second: null, third: null } - ), - process: sinon.spy( + create: jest.fn(() => data.create || { first: 1, second: 1, third: 1 }), + delete: jest.fn(() => data.delete || { first: 1, second: 1, third: 1 }), + get: jest.fn(() => data.get || { first: null, second: null, third: null }), + process: jest.fn( () => data.process || { first: [], second: [], third: [] } ), }; @@ -1067,8 +1073,8 @@ function mockAppStoreSubscriptions(methods) { function mockAccountEventsManager() { const mgr = { - recordSecurityEvent: sinon.stub().resolves({}), - recordEmailEvent: sinon.stub().resolves({}), + recordSecurityEvent: jest.fn().mockResolvedValue({}), + recordEmailEvent: jest.fn().mockResolvedValue({}), }; Container.set(AccountEventsManager, mgr); return mgr; @@ -1090,7 +1096,7 @@ function mockGlean() { for (const i in glean) { for (const j in glean[i]) { - glean[i][j] = sinon.stub(); + glean[i][j] = jest.fn(); } } @@ -1099,7 +1105,7 @@ function mockGlean() { function mockPriceManager() { const priceManager = { - retrieve: sinon.stub(), + retrieve: jest.fn(), }; Container.set(PriceManager, priceManager); return priceManager; @@ -1107,10 +1113,10 @@ function mockPriceManager() { function mockProductConfigurationManager() { const productConfigurationManager = { - getIapOfferings: sinon.stub(), - getPurchaseWithDetailsOfferingContentByPlanIds: sinon.spy(async () => { + getIapOfferings: jest.fn(), + getPurchaseWithDetailsOfferingContentByPlanIds: jest.fn(async () => { return { - transformedPurchaseWithCommonContentForPlanId: sinon.spy(() => { + transformedPurchaseWithCommonContentForPlanId: jest.fn(() => { return { offering: { commonContent: { @@ -1146,7 +1152,7 @@ function mockProductConfigurationManager() { * sending is disabled by default in tests. Call mock setup with an override to enable * sending for specific tests. * ``` - * const mockFxaMailer = mocks.mockFxaMailer({ canSend: sinon.stub().resolves(true) }); + * const mockFxaMailer = mocks.mockFxaMailer({ canSend: jest.fn().mockResolvedValue(true) }); * // or, if you don't need to spy on the method: * const mockFxaMailer = mocks.mockFxaMailer({ canSend: true }); * ``` @@ -1157,56 +1163,56 @@ function mockProductConfigurationManager() { * ``` ts * const mockFxaMailer = mocks.mockFxaMailer(); * // arrange, act, assert - * sinon.assert.calledOnce(mockFxaMailer.sendRecoveryEmail); + * expect(mockFxaMailer.sendRecoveryEmail).toHaveBeenCalledTimes(1); * ``` */ function mockFxaMailer(overrides) { const mockFxaMailer = { // add new email methods here! - canSend: sinon.stub().returns(true), - sendRecoveryEmail: sinon.stub().resolves(), - sendPasswordForgotOtpEmail: sinon.stub().resolves(), - sendPasswordlessSigninOtpEmail: sinon.stub().resolves(), - sendPasswordlessSignupOtpEmail: sinon.stub().resolves(), - sendPostVerifySecondaryEmail: sinon.stub().resolves(), - sendPostChangePrimaryEmail: sinon.stub().resolves(), - sendPostRemoveSecondaryEmail: sinon.stub().resolves(), - sendPostAddLinkedAccountEmail: sinon.stub().resolves(), - sendNewDeviceLoginEmail: sinon.stub().resolves(), - sendPostAddTwoStepAuthenticationEmail: sinon.stub().resolves(), - sendPostChangeTwoStepAuthenticationEmail: sinon.stub().resolves(), - sendPostNewRecoveryCodesEmail: sinon.stub().resolves(), - sendPostConsumeRecoveryCodeEmail: sinon.stub().resolves(), - sendLowRecoveryCodesEmail: sinon.stub().resolves(), - sendPostSigninRecoveryCodeEmail: sinon.stub().resolves(), - sendPostAddRecoveryPhoneEmail: sinon.stub().resolves(), - sendPostChangeRecoveryPhoneEmail: sinon.stub().resolves(), - sendPostRemoveRecoveryPhoneEmail: sinon.stub().resolves(), - sendPasswordResetRecoveryPhoneEmail: sinon.stub().resolves(), - sendPostSigninRecoveryPhoneEmail: sinon.stub().resolves(), - sendPostAddAccountRecoveryEmail: sinon.stub().resolves(), - sendPostChangeAccountRecoveryEmail: sinon.stub().resolves(), - sendPostRemoveAccountRecoveryEmail: sinon.stub().resolves(), - sendPasswordResetAccountRecoveryEmail: sinon.stub().resolves(), - sendPasswordResetWithRecoveryKeyPromptEmail: sinon.stub().resolves(), - sendPostVerifyEmail: sinon.stub().resolves(), - sendVerifyLoginCodeEmail: sinon.stub().resolves(), - sendVerifyShortCodeEmail: sinon.stub().resolves(), - sendVerifySecondaryCodeEmail: sinon.stub().resolves(), - sendVerifyLoginEmail: sinon.stub().resolves(), - sendVerifyEmail: sinon.stub().resolves(), - sendVerifyAccountChangeEmail: sinon.stub().resolves(), - sendUnblockCodeEmail: sinon.stub().resolves(), - sendPasswordResetEmail: sinon.stub().resolves(), - sendPasswordChangedEmail: sinon.stub().resolves(), - sendInactiveAccountFirstWarningEmail: sinon.stub().resolves(), - sendInactiveAccountSecondWarningEmail: sinon.stub().resolves(), - sendInactiveAccountFinalWarningEmail: sinon.stub().resolves(), - sendVerificationReminderFirstEmail: sinon.stub().resolves(), - sendVerificationReminderSecondEmail: sinon.stub().resolves(), - sendVerificationReminderFinalEmail: sinon.stub().resolves(), - sendCadReminderFirstEmail: sinon.stub().resolves(), - sendCadReminderSecondEmail: sinon.stub().resolves(), + canSend: jest.fn().mockReturnValue(true), + sendRecoveryEmail: jest.fn().mockResolvedValue(), + sendPasswordForgotOtpEmail: jest.fn().mockResolvedValue(), + sendPasswordlessSigninOtpEmail: jest.fn().mockResolvedValue(), + sendPasswordlessSignupOtpEmail: jest.fn().mockResolvedValue(), + sendPostVerifySecondaryEmail: jest.fn().mockResolvedValue(), + sendPostChangePrimaryEmail: jest.fn().mockResolvedValue(), + sendPostRemoveSecondaryEmail: jest.fn().mockResolvedValue(), + sendPostAddLinkedAccountEmail: jest.fn().mockResolvedValue(), + sendNewDeviceLoginEmail: jest.fn().mockResolvedValue(), + sendPostAddTwoStepAuthenticationEmail: jest.fn().mockResolvedValue(), + sendPostChangeTwoStepAuthenticationEmail: jest.fn().mockResolvedValue(), + sendPostNewRecoveryCodesEmail: jest.fn().mockResolvedValue(), + sendPostConsumeRecoveryCodeEmail: jest.fn().mockResolvedValue(), + sendLowRecoveryCodesEmail: jest.fn().mockResolvedValue(), + sendPostSigninRecoveryCodeEmail: jest.fn().mockResolvedValue(), + sendPostAddRecoveryPhoneEmail: jest.fn().mockResolvedValue(), + sendPostChangeRecoveryPhoneEmail: jest.fn().mockResolvedValue(), + sendPostRemoveRecoveryPhoneEmail: jest.fn().mockResolvedValue(), + sendPasswordResetRecoveryPhoneEmail: jest.fn().mockResolvedValue(), + sendPostSigninRecoveryPhoneEmail: jest.fn().mockResolvedValue(), + sendPostAddAccountRecoveryEmail: jest.fn().mockResolvedValue(), + sendPostChangeAccountRecoveryEmail: jest.fn().mockResolvedValue(), + sendPostRemoveAccountRecoveryEmail: jest.fn().mockResolvedValue(), + sendPasswordResetAccountRecoveryEmail: jest.fn().mockResolvedValue(), + sendPasswordResetWithRecoveryKeyPromptEmail: jest.fn().mockResolvedValue(), + sendPostVerifyEmail: jest.fn().mockResolvedValue(), + sendVerifyLoginCodeEmail: jest.fn().mockResolvedValue(), + sendVerifyShortCodeEmail: jest.fn().mockResolvedValue(), + sendVerifySecondaryCodeEmail: jest.fn().mockResolvedValue(), + sendVerifyLoginEmail: jest.fn().mockResolvedValue(), + sendVerifyEmail: jest.fn().mockResolvedValue(), + sendVerifyAccountChangeEmail: jest.fn().mockResolvedValue(), + sendUnblockCodeEmail: jest.fn().mockResolvedValue(), + sendPasswordResetEmail: jest.fn().mockResolvedValue(), + sendPasswordChangedEmail: jest.fn().mockResolvedValue(), + sendInactiveAccountFirstWarningEmail: jest.fn().mockResolvedValue(), + sendInactiveAccountSecondWarningEmail: jest.fn().mockResolvedValue(), + sendInactiveAccountFinalWarningEmail: jest.fn().mockResolvedValue(), + sendVerificationReminderFirstEmail: jest.fn().mockResolvedValue(), + sendVerificationReminderSecondEmail: jest.fn().mockResolvedValue(), + sendVerificationReminderFinalEmail: jest.fn().mockResolvedValue(), + sendCadReminderFirstEmail: jest.fn().mockResolvedValue(), + sendCadReminderSecondEmail: jest.fn().mockResolvedValue(), ...overrides, }; Container.set(FxaMailer, mockFxaMailer); @@ -1215,7 +1221,7 @@ function mockFxaMailer(overrides) { function mockOAuthClientInfo(overrides) { const mock = { - fetch: sinon.stub().resolves({ name: 'sync' }), + fetch: jest.fn().mockResolvedValue({ name: 'sync' }), ...overrides, }; Container.set(OAuthClientInfoServiceName, mock); diff --git a/packages/fxa-auth-server/test/remote/db.in.spec.ts b/packages/fxa-auth-server/test/remote/db.in.spec.ts index aec2dacdc4f..146b2fb3dad 100644 --- a/packages/fxa-auth-server/test/remote/db.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/db.in.spec.ts @@ -6,10 +6,12 @@ import base64url from 'base64url'; import crypto from 'crypto'; import { normalizeEmail } from 'fxa-shared/email/helpers'; import IORedis from 'ioredis'; -import sinon from 'sinon'; import * as uuid from 'uuid'; -import { getSharedTestServer, TestServerInstance } from '../support/helpers/test-server'; +import { + getSharedTestServer, + TestServerInstance, +} from '../support/helpers/test-server'; import { AuthServerError } from '../support/helpers/test-utils'; // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -517,15 +519,16 @@ describe('#integration - remote db', () => { ); } catch (err: unknown) { expect((err as AuthServerError).errno).toBe(124); - expect((err as any).output.payload.deviceId).toBe(conflictingDeviceInfo.id); + expect((err as any).output.payload.deviceId).toBe( + conflictingDeviceInfo.id + ); } // Fetch all of the devices for the account devices = await db.devices(account.uid); expect(devices.length).toBe(2); - fetchedDevice = - devices[0].id === deviceInfo.id ? devices[0] : devices[1]; + fetchedDevice = devices[0].id === deviceInfo.id ? devices[0] : devices[1]; // Fetch a single device const singleDevice = await db.device(account.uid, fetchedDevice.id); @@ -679,8 +682,7 @@ describe('#integration - remote db', () => { it('db.forgotPasswordVerified', async () => { const emailRecord = await db.emailRecord(account.email); - const passwordForgotToken = - await db.createPasswordForgotToken(emailRecord); + const passwordForgotToken = await db.createPasswordForgotToken(emailRecord); const accountResetToken = await db.forgotPasswordVerified(passwordForgotToken); @@ -702,8 +704,7 @@ describe('#integration - remote db', () => { emailRecord.uaDeviceType = emailRecord.uaFormFactor = null; const sessionToken = await db.createSessionToken(emailRecord); - const accountResetToken = - await db.forgotPasswordVerified(sessionToken); + const accountResetToken = await db.forgotPasswordVerified(sessionToken); await db.resetAccount(accountResetToken, account); const redisResult = await redis.get(account.uid); @@ -748,7 +749,9 @@ describe('#integration - remote db', () => { // Consume with invalid code try { await db.consumeUnblockCode(account.uid, 'NOTREAL'); - fail('consumeUnblockCode() with an invalid unblock code should not succeed'); + fail( + 'consumeUnblockCode() with an invalid unblock code should not succeed' + ); } catch (err: unknown) { expect((err as AuthServerError).errno).toBe(127); expect(`${err}`).toBe('Error: Invalid unblock code'); @@ -773,23 +776,22 @@ describe('#integration - remote db', () => { // Create a signinCode without a flowId const previousCode = await db.createSigninCode(account.uid); expect(typeof previousCode).toBe('string'); - expect(Buffer.from(previousCode, 'hex').length).toBe( - config.signinCodeSize - ); + expect(Buffer.from(previousCode, 'hex').length).toBe(config.signinCodeSize); // Stub crypto.randomBytes to return a duplicate code - const stub = sinon - .stub(crypto, 'randomBytes') - .callsFake((size: number, callback?: any) => { - if (!callback) { - return previousCode; - } - callback(null, previousCode); - }); + const stub = jest.spyOn(crypto, 'randomBytes').mockImplementation((( + size: number, + callback?: any + ) => { + if (!callback) { + return previousCode; + } + callback(null, previousCode); + }) as any); // Create a signinCode with crypto.randomBytes rigged to return a duplicate const code = await db.createSigninCode(account.uid, flowId); - stub.restore(); + stub.mockRestore(); expect(typeof code).toBe('string'); expect(code).not.toBe(previousCode); expect(Buffer.from(code, 'hex').length).toBe(config.signinCodeSize); @@ -871,9 +873,7 @@ describe('#integration - remote db', () => { db.accountRecord(secondEmail), ]); expect(accountRecordFromSecondEmail.email).toBe(accountRecord.email); - expect(accountRecordFromSecondEmail.emails).toEqual( - accountRecord.emails - ); + expect(accountRecordFromSecondEmail.emails).toEqual(accountRecord.emails); expect(accountRecordFromSecondEmail.primaryEmail).toEqual( accountRecord.primaryEmail ); diff --git a/packages/fxa-auth-server/test/remote/oauth_api.in.spec.ts b/packages/fxa-auth-server/test/remote/oauth_api.in.spec.ts index fddddf4120f..ba4da3cabf1 100644 --- a/packages/fxa-auth-server/test/remote/oauth_api.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/oauth_api.in.spec.ts @@ -32,7 +32,6 @@ const buf = (v) => (Buffer.isBuffer(v) ? v : Buffer.from(v, 'hex')); const testServer = require('../lib/server'); const ScopeSet = require('fxa-shared').oauth.scopes; const { decodeJWT } = require('../lib/util'); -import sinon from 'sinon'; const db = require('../../lib/oauth/db'); const encrypt = require('fxa-shared/auth/encrypt'); @@ -169,7 +168,6 @@ function basicAuthHeader(clientId, secret) { } describe('#integration - /v1', function () { - let sandbox; let Server; function newToken(payload: any = {}, options: any = {}) { @@ -220,13 +218,10 @@ describe('#integration - /v1', function () { await Server.close(); }); - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); - afterEach(function () { nock.cleanAll(); - sandbox.restore(); + jest.useRealTimers(); + jest.restoreAllMocks(); }); describe('/authorization', function () { @@ -2818,10 +2813,8 @@ describe('#integration - /v1', function () { assertSecurityHeaders(res); expect(res.result.expires_in).toBe(1); - sandbox.useFakeTimers({ - now: Date.now() + 1000 * 60 * 60, // 1 hr in future - shouldAdvanceTime: true, - }); + const futureTime = Date.now() + 1000 * 60 * 60; // 1 hr in future + jest.spyOn(Date, 'now').mockReturnValue(futureTime); res = await Server.api.post({ url: '/verify', diff --git a/packages/fxa-auth-server/test/remote/oauth_token_route.in.spec.ts b/packages/fxa-auth-server/test/remote/oauth_token_route.in.spec.ts index b10de0e6992..532e431db4a 100644 --- a/packages/fxa-auth-server/test/remote/oauth_token_route.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/oauth_token_route.in.spec.ts @@ -2,8 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; - const buf = (v: any) => (Buffer.isBuffer(v) ? v : Buffer.from(v, 'hex')); const hex = (v: any) => (Buffer.isBuffer(v) ? v.toString('hex') : v); @@ -16,9 +14,9 @@ const NON_DISABLED_CLIENT_ID = '98e6508e88680e1a'; const CODE_WITH_KEYS = 'afafaf'; const CODE_WITHOUT_KEYS = 'f0f0f0'; -const mockDb = { touchSessionToken: sinon.stub() }; -const mockStatsD = { increment: sinon.stub() }; -const mockGlean = { oauth: { tokenCreated: sinon.stub() } }; +const mockDb = { touchSessionToken: jest.fn() }; +const mockStatsD = { increment: jest.fn() }; +const mockGlean = { oauth: { tokenCreated: jest.fn() } }; const tokenRoutesDepMocks = { '../../oauth/assertion': async () => true, diff --git a/packages/fxa-auth-server/test/remote/payments/configuration/manager.in.spec.ts b/packages/fxa-auth-server/test/remote/payments/configuration/manager.in.spec.ts index a9692c2073a..d2bc92c20e6 100644 --- a/packages/fxa-auth-server/test/remote/payments/configuration/manager.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/payments/configuration/manager.in.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -const sinon = require('sinon'); const { default: Container } = require('typedi'); const cloneDeep = require('lodash/cloneDeep'); const retry = require('async-retry'); @@ -11,8 +10,6 @@ const { mergeConfigs, } = require('fxa-shared/subscriptions/configuration/utils'); -const sandbox = sinon.createSandbox(); - const { AuthFirestore, AuthLogger, @@ -166,7 +163,7 @@ describe('#integration - PaymentConfigManager', () => { planConfigDbRef, 100 ); - sandbox.reset(); + jest.clearAllMocks(); Container.reset(); }); @@ -225,7 +222,7 @@ describe('#integration - PaymentConfigManager', () => { describe('getDocumentIdByStripeId', () => { it('returns a matching product document id if found', async () => { - paymentConfigManager.allProducts = sandbox.stub().resolves([ + paymentConfigManager.allProducts = jest.fn().mockResolvedValue([ { ...productConfig, id: testProductId, @@ -238,7 +235,7 @@ describe('#integration - PaymentConfigManager', () => { }); it('returns a matching plan document id if found', async () => { - paymentConfigManager.allPlans = sandbox.stub().resolves([ + paymentConfigManager.allPlans = jest.fn().mockResolvedValue([ { ...planConfig, id: testPlanId, @@ -251,13 +248,13 @@ describe('#integration - PaymentConfigManager', () => { }); it('returns null if neither is found', async () => { - paymentConfigManager.allProducts = sandbox.stub().resolves([ + paymentConfigManager.allProducts = jest.fn().mockResolvedValue([ { ...productConfig, id: testProductId, }, ]); - paymentConfigManager.allPlans = sandbox.stub().resolves([ + paymentConfigManager.allPlans = jest.fn().mockResolvedValue([ { ...planConfig, id: testPlanId, @@ -273,11 +270,11 @@ describe('#integration - PaymentConfigManager', () => { describe('validateProductConfig', () => { it('validates a product config', async () => { const newProduct = cloneDeep(productConfig); - const spy = sandbox.spy(ProductConfig, 'validate'); + const spy = jest.spyOn(ProductConfig, 'validate'); await paymentConfigManager.validateProductConfig(newProduct); - expect(spy.calledOnce).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); }); it('throws error on invalid product config', async () => { @@ -297,11 +294,11 @@ describe('#integration - PaymentConfigManager', () => { it('validates a plan config', async () => { const newPlan = cloneDeep(planConfig); const product = (await paymentConfigManager.allProducts())[0]; - const spy = sandbox.spy(PlanConfig, 'validate'); + const spy = jest.spyOn(PlanConfig, 'validate'); await paymentConfigManager.validatePlanConfig(newPlan, product.id); - expect(spy.calledOnce).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); }); it('throws error on invalid plan config', async () => { diff --git a/packages/fxa-auth-server/test/remote/pushbox_db.in.spec.ts b/packages/fxa-auth-server/test/remote/pushbox_db.in.spec.ts index 8436065d658..f842d45c8c8 100644 --- a/packages/fxa-auth-server/test/remote/pushbox_db.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/pushbox_db.in.spec.ts @@ -3,23 +3,21 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import base64url from 'base64url'; -import sinon from 'sinon'; import { StatsD } from 'hot-shots'; import PushboxDB from '../../lib/pushbox/db'; -const sandbox = sinon.createSandbox(); const config = require('../../config').default.getProperties(); const statsd = { - increment: sandbox.stub(), - timing: sandbox.stub(), + increment: jest.fn(), + timing: jest.fn(), } as unknown as StatsD; const log = { - info: sandbox.stub(), - trace: sandbox.stub(), - warn: sandbox.stub(), - error: sandbox.stub(), - debug: sandbox.stub(), + info: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), }; const pushboxDb = new PushboxDB({ @@ -39,7 +37,7 @@ let insertIdx: number; describe('#integration - pushbox db', () => { afterEach(() => { - sandbox.restore(); + jest.restoreAllMocks(); }); describe('store', () => { @@ -65,7 +63,7 @@ describe('#integration - pushbox db', () => { }); it('fetches up to max index', async () => { - sandbox.stub(Date, 'now').returns(111111000); + jest.spyOn(Date, 'now').mockReturnValue(111111000); const currentClientSideIdx = insertIdx; const insertUpTo = insertIdx + 3; while (insertIdx < insertUpTo) { @@ -90,7 +88,7 @@ describe('#integration - pushbox db', () => { }); it('fetches up to less than max', async () => { - sandbox.stub(Date, 'now').returns(111111000); + jest.spyOn(Date, 'now').mockReturnValue(111111000); const insertUpTo = insertIdx + 3; while (insertIdx < insertUpTo) { const record = await pushboxDb.store(r); diff --git a/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts index 815c60bf5da..8143d597358 100644 --- a/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts @@ -130,7 +130,6 @@ describe('#integration - remote subscriptions (enabled)', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const EventEmitter = require('events'); // eslint-disable-next-line @typescript-eslint/no-require-imports - const sinon = require('sinon'); let server: any; let serverUrl: string; @@ -252,11 +251,11 @@ describe('#integration - remote subscriptions (enabled)', () => { Container.set(RecoveryPhoneService, { hasConfirmed: async () => ({ exists: false, phoneNumber: null }), }); - Container.set(PriceManager, { retrieve: sinon.stub() }); + Container.set(PriceManager, { retrieve: jest.fn() }); Container.set(ProductConfigurationManager, { - getIapOfferings: sinon.stub(), - getPurchaseWithDetailsOfferingContentByPlanIds: sinon.spy(async () => ({ - transformedPurchaseWithCommonContentForPlanId: sinon.spy(() => ({ + getIapOfferings: jest.fn(), + getPurchaseWithDetailsOfferingContentByPlanIds: jest.fn(async () => ({ + transformedPurchaseWithCommonContentForPlanId: jest.fn(() => ({ offering: { commonContent: { privacyNoticeDownloadUrl: diff --git a/packages/fxa-auth-server/test/scripts/stripe-products-and-plans-converter.in.spec.ts b/packages/fxa-auth-server/test/scripts/stripe-products-and-plans-converter.in.spec.ts index d933cd2d01c..a8cd4ed5bb8 100644 --- a/packages/fxa-auth-server/test/scripts/stripe-products-and-plans-converter.in.spec.ts +++ b/packages/fxa-auth-server/test/scripts/stripe-products-and-plans-converter.in.spec.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import sinon from 'sinon'; import fs from 'fs'; import { Container } from 'typedi'; import { deleteCollection, deepCopy } from '../local/payments/util'; @@ -26,7 +25,7 @@ const googleTranslateShapedError = { }, }; -const langFromMetadataStub = sinon.stub().callsFake((plan: any) => { +const langFromMetadataStub = jest.fn().mockImplementation((plan: any) => { if (plan.nickname.includes('es-ES')) { return 'es-ES'; } @@ -55,7 +54,6 @@ const { StripeProductsAndPlansConverter, } = require('../../scripts/stripe-products-and-plans-to-firestore-documents/stripe-products-and-plans-converter'); -const sandbox = sinon.createSandbox(); const mockSupportedLanguages = ['es-ES', 'fr']; describe('#integration - convert', () => { @@ -179,9 +177,9 @@ describe('#integration - convert', () => { }); beforeEach(() => { - mockLog.error = sandbox.fake.returns({}); - mockLog.info = sandbox.fake.returns({}); - mockLog.debug = sandbox.fake.returns({}); + mockLog.error = jest.fn().mockReturnValue({}); + mockLog.info = jest.fn().mockReturnValue({}); + mockLog.debug = jest.fn().mockReturnValue({}); const firestore = setupFirestore(mockConfig); Container.set(AuthFirestore, firestore); Container.set(AuthLogger, {}); @@ -214,14 +212,12 @@ describe('#integration - convert', () => { yield plan3; } converter.stripeHelper.stripe = { - products: { list: sandbox.stub().returns(productGenerator()) }, + products: { list: jest.fn().mockReturnValue(productGenerator()) }, plans: { - list: sandbox - .stub() - .onFirstCall() - .returns(planGenerator1()) - .onSecondCall() - .returns(planGenerator2()), + list: jest + .fn() + .mockReturnValueOnce(planGenerator1()) + .mockReturnValueOnce(planGenerator2()), }, }; }); @@ -238,7 +234,7 @@ describe('#integration - convert', () => { 100 ); Container.reset(); - sandbox.reset(); + jest.clearAllMocks(); }); it('processes new products and plans', async () => { @@ -331,8 +327,8 @@ describe('#integration - convert', () => { yield updatedPlan; } converter.stripeHelper.stripe = { - products: { list: sandbox.stub().returns(productGeneratorUpdated()) }, - plans: { list: sandbox.stub().returns(planGeneratorUpdated()) }, + products: { list: jest.fn().mockReturnValue(productGeneratorUpdated()) }, + plans: { list: jest.fn().mockReturnValue(planGeneratorUpdated()) }, }; await converter.convert(args); products = await paymentConfigManager.allProducts(); @@ -350,42 +346,44 @@ describe('#integration - convert', () => { it('processes only the product with productId when passed', async () => { await converter.convert({ ...args, productId: product1.id }); - sinon.assert.calledOnceWithExactly( - converter.stripeHelper.stripe.products.list, - { ids: [product1.id] } + expect(converter.stripeHelper.stripe.products.list).toHaveBeenCalledTimes( + 1 ); + expect(converter.stripeHelper.stripe.products.list).toHaveBeenCalledWith({ + ids: [product1.id], + }); }); it('processes successfully and writes to file', async () => { - const stubFsAccess = sandbox.stub(fs.promises, 'access').resolves(); - paymentConfigManager.storeProductConfig = sandbox.stub(); - paymentConfigManager.storePlanConfig = sandbox.stub(); - converter.writeToFileProductConfig = sandbox.stub().resolves(); - converter.writeToFilePlanConfig = sandbox.stub().resolves(); + const stubFsAccess = jest.spyOn(fs.promises, 'access').mockResolvedValue(); + paymentConfigManager.storeProductConfig = jest.fn(); + paymentConfigManager.storePlanConfig = jest.fn(); + converter.writeToFileProductConfig = jest.fn().mockResolvedValue(); + converter.writeToFilePlanConfig = jest.fn().mockResolvedValue(); const argsLocal = { ...args, target: 'local' }; await converter.convert(argsLocal); - sinon.assert.called(stubFsAccess); - sinon.assert.called(converter.writeToFileProductConfig); - sinon.assert.called(converter.writeToFilePlanConfig); - sinon.assert.notCalled(paymentConfigManager.storeProductConfig); - sinon.assert.notCalled(paymentConfigManager.storePlanConfig); + expect(stubFsAccess).toHaveBeenCalled(); + expect(converter.writeToFileProductConfig).toHaveBeenCalled(); + expect(converter.writeToFilePlanConfig).toHaveBeenCalled(); + expect(paymentConfigManager.storeProductConfig).not.toHaveBeenCalled(); + expect(paymentConfigManager.storePlanConfig).not.toHaveBeenCalled(); - sandbox.restore(); + jest.restoreAllMocks(); }); it('does not update Firestore if dryRun = true', async () => { - paymentConfigManager.storeProductConfig = sandbox.stub(); - paymentConfigManager.storePlanConfig = sandbox.stub(); - converter.writeToFileProductConfig = sandbox.stub(); - converter.writeToFilePlanConfig = sandbox.stub(); + paymentConfigManager.storeProductConfig = jest.fn(); + paymentConfigManager.storePlanConfig = jest.fn(); + converter.writeToFileProductConfig = jest.fn(); + converter.writeToFilePlanConfig = jest.fn(); const argsDryRun = { ...args, isDryRun: true }; await converter.convert(argsDryRun); - sinon.assert.notCalled(paymentConfigManager.storeProductConfig); - sinon.assert.notCalled(paymentConfigManager.storePlanConfig); - sinon.assert.notCalled(converter.writeToFileProductConfig); - sinon.assert.notCalled(converter.writeToFilePlanConfig); + expect(paymentConfigManager.storeProductConfig).not.toHaveBeenCalled(); + expect(paymentConfigManager.storePlanConfig).not.toHaveBeenCalled(); + expect(converter.writeToFileProductConfig).not.toHaveBeenCalled(); + expect(converter.writeToFilePlanConfig).not.toHaveBeenCalled(); }); it('moves localized data from plans into the productConfig', async () => { @@ -420,9 +418,9 @@ describe('#integration - convert', () => { yield planWithLocalizedData2; } converter.stripeHelper.stripe = { - products: { list: sandbox.stub().returns(productGenerator()) }, + products: { list: jest.fn().mockReturnValue(productGenerator()) }, plans: { - list: sandbox.stub().returns(planGenerator()), + list: jest.fn().mockReturnValue(planGenerator()), }, }; await converter.convert(args); @@ -450,45 +448,45 @@ describe('#integration - convert', () => { it('logs an error and keeps processing if a product fails', async () => { const productConfigId = 'test-product-id'; const planConfigId = 'test-plan-id'; - paymentConfigManager.storeProductConfig = sandbox - .stub() - .resolves(productConfigId); - paymentConfigManager.storePlanConfig = sandbox - .stub() - .resolves(planConfigId); - converter.stripeProductToProductConfig = sandbox - .stub() - .onFirstCall() - .throws({ message: 'Something broke!' }) - .onSecondCall() - .returns(productConfig2); + paymentConfigManager.storeProductConfig = jest + .fn() + .mockResolvedValue(productConfigId); + paymentConfigManager.storePlanConfig = jest + .fn() + .mockResolvedValue(planConfigId); + converter.stripeProductToProductConfig = jest + .fn() + .mockImplementationOnce(() => { + throw { message: 'Something broke!' }; + }) + .mockReturnValueOnce(productConfig2); async function* planGenerator() { yield plan2; } converter.stripeHelper.stripe = { ...converter.stripeHelper.stripe, - plans: { list: sandbox.stub().returns(planGenerator()) }, + plans: { list: jest.fn().mockReturnValue(planGenerator()) }, }; await converter.convert(args); - sinon.assert.calledWithExactly( - paymentConfigManager.storeProductConfig.firstCall, + expect(paymentConfigManager.storeProductConfig).toHaveBeenNthCalledWith( + 1, productConfig2, null ); - sinon.assert.calledWithExactly( - paymentConfigManager.storeProductConfig.secondCall, + expect(paymentConfigManager.storeProductConfig).toHaveBeenNthCalledWith( + 2, productConfig2, productConfigId ); - sinon.assert.calledOnceWithExactly( - paymentConfigManager.storePlanConfig, + expect(paymentConfigManager.storePlanConfig).toHaveBeenCalledTimes(1); + expect(paymentConfigManager.storePlanConfig).toHaveBeenCalledWith( planConfig2, productConfigId, null ); - sinon.assert.calledOnceWithExactly( - mockLog.error, + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith( 'StripeProductsAndPlansConverter.convertProductError', { error: 'Something broke!', @@ -500,18 +498,18 @@ describe('#integration - convert', () => { it('logs an error and keeps processing if a plan fails', async () => { const productConfigId = 'test-product-id'; const planConfigId = 'test-plan-id'; - paymentConfigManager.storeProductConfig = sandbox - .stub() - .resolves(productConfigId); - paymentConfigManager.storePlanConfig = sandbox - .stub() - .resolves(planConfigId); - converter.stripePlanToPlanConfig = sandbox - .stub() - .onFirstCall() - .throws({ message: 'Something else broke!' }) - .onSecondCall() - .returns(planConfig2); + paymentConfigManager.storeProductConfig = jest + .fn() + .mockResolvedValue(productConfigId); + paymentConfigManager.storePlanConfig = jest + .fn() + .mockResolvedValue(planConfigId); + converter.stripePlanToPlanConfig = jest + .fn() + .mockImplementationOnce(() => { + throw { message: 'Something else broke!' }; + }) + .mockReturnValueOnce(planConfig2); async function* productGenerator() { yield product1; } @@ -520,29 +518,29 @@ describe('#integration - convert', () => { yield plan2; } converter.stripeHelper.stripe = { - products: { list: sandbox.stub().returns(productGenerator()) }, - plans: { list: sandbox.stub().returns(planGenerator()) }, + products: { list: jest.fn().mockReturnValue(productGenerator()) }, + plans: { list: jest.fn().mockReturnValue(planGenerator()) }, }; await converter.convert(args); - sinon.assert.calledWithExactly( - paymentConfigManager.storeProductConfig.firstCall, + expect(paymentConfigManager.storeProductConfig).toHaveBeenNthCalledWith( + 1, productConfig1, null ); - sinon.assert.calledWithExactly( - paymentConfigManager.storeProductConfig.secondCall, + expect(paymentConfigManager.storeProductConfig).toHaveBeenNthCalledWith( + 2, productConfig1, productConfigId ); - sinon.assert.calledWithExactly( - paymentConfigManager.storePlanConfig.firstCall, + expect(paymentConfigManager.storePlanConfig).toHaveBeenNthCalledWith( + 1, planConfig2, productConfigId, null ); - sinon.assert.calledOnceWithExactly( - mockLog.error, + expect(mockLog.error).toHaveBeenCalledTimes(1); + expect(mockLog.error).toHaveBeenCalledWith( 'StripeProductsAndPlansConverter.convertPlanError', { error: 'Something else broke!', @@ -561,9 +559,9 @@ describe('#integration - convert', () => { } try { converter.stripeHelper.stripe = { - products: { list: sandbox.stub().returns(productGenerator()) }, + products: { list: jest.fn().mockReturnValue(productGenerator()) }, plans: { - list: sandbox.stub().returns(planGenerator()), + list: jest.fn().mockReturnValue(planGenerator()), }, }; await converter.convert(args); diff --git a/yarn.lock b/yarn.lock index a6e2ac47557..fff8989e50e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20143,7 +20143,7 @@ __metadata: languageName: node linkType: hard -"@types/sinon@npm:*, @types/sinon@npm:^17.0.0, @types/sinon@npm:^17.0.3": +"@types/sinon@npm:*, @types/sinon@npm:^17.0.0": version: 17.0.4 resolution: "@types/sinon@npm:17.0.4" dependencies: @@ -31824,7 +31824,6 @@ __metadata: "@types/nodemailer": "npm:^7.0.4" "@types/request": "npm:2.48.5" "@types/sass": "npm:^1" - "@types/sinon": "npm:^17.0.3" "@types/uuid": "npm:^10.0.0" "@types/verror": "npm:^1.10.4" "@types/webpack": "npm:5.28.5" @@ -31897,7 +31896,6 @@ __metadata: pm2: "npm:^6.0.14" poolee: "npm:^1.0.1" prettier: "npm:^3.5.3" - proxyquire: "npm:^2.1.3" punycode.js: "npm:2.3.0" qrcode: "npm:^1.5.1" read: "npm:3.0.1" @@ -31906,7 +31904,6 @@ __metadata: rimraf: "npm:^6.0.1" sass: "npm:^1.80.4" simplesmtp: "npm:0.3.35" - sinon: "npm:^9.0.3" storybook: "npm:^8.0.0" through: "npm:2.3.8" ts-jest: "npm:^29.1.2"