From 93b7274ea6efd5ea069b51ad9c44db304ca6f520 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Wed, 22 Oct 2025 13:52:08 +0100 Subject: [PATCH] Convert tests to ESM GitOrigin-RevId: 03bd4db8cddc548706439edd7f6db0bc3e7ed9d3 --- .../src/Features/User/UserSessionsManager.mjs | 4 +- .../Analytics/AccountMappingHelper.test.mjs | 250 ++-- .../src/Analytics/AnalyticsManager.test.mjs | 494 ++++---- .../src/Analytics/EmailChangeHelpers.test.mjs | 29 +- .../AuthenticationManager.test.mjs | 787 ++++++------ .../Authentication/SessionManager.test.mjs | 103 +- .../unit/src/Chat/ChatApiHandler.test.mjs | 193 ++- .../CollaboratorsInviteGetter.test.mjs | 195 +-- .../CollaboratorsInviteHelper.test.mjs | 14 +- .../unit/src/Contact/ContactManager.test.mjs | 91 +- .../src/Docstore/DocstoreManager.test.mjs | 421 ++++--- .../src/Documents/DocumentHelper.test.mjs | 164 ++- .../Editor/EditorRealTimeController.test.mjs | 139 ++- .../test/unit/src/Email/EmailBuilder.test.mjs | 774 ++++++------ .../test/unit/src/Email/EmailHandler.test.mjs | 143 ++- .../test/unit/src/Email/EmailSender.test.mjs | 151 +-- .../web/test/unit/src/Email/SpamSafe.test.mjs | 9 +- .../unit/src/Errors/HttpErrorHandler.test.mjs | 376 +++--- .../HelperFiles/SafeHTMLSubstitute.test.mjs | 14 +- .../unit/src/HelperFiles/UrlHelper.test.mjs | 40 +- .../unit/src/History/HistoryManager.test.mjs | 253 ++-- .../unit/src/History/RestoreManager.test.mjs | 44 +- .../Institutions/InstitutionHelper.test.mjs | 10 +- .../src/Institutions/InstitutionsAPI.test.mjs | 350 +++--- .../InstitutionsFeatures.test.mjs | 140 ++- .../src/Newsletter/NewsletterManager.test.mjs | 161 +-- .../NotificationsBuilder.test.mjs | 120 +- .../NotificationsHandler.test.mjs | 134 +- .../Project/FolderStructureBuilder.test.mjs | 39 +- .../src/Project/ProjectEditorHandler.test.mjs | 319 +++-- .../unit/src/Project/ProjectHelper.test.mjs | 118 +- .../Project/ProjectListController.test.mjs | 551 ++++----- .../Project/ProjectOptionsHandler.test.mjs | 212 ++-- .../src/Project/ProjectUpdateHandler.test.mjs | 91 +- .../test/unit/src/Project/SafePath.test.mjs | 202 +-- .../src/Publishers/PublishersGetter.test.mjs | 55 +- .../unit/src/Referal/ReferalFeatures.test.mjs | 83 +- .../src/SplitTests/SplitTestHandler.test.mjs | 371 +++--- .../SplitTestSessionHandler.test.mjs | 71 +- .../src/Subscription/FeaturesHelper.test.mjs | 67 +- .../src/Subscription/FeaturesUpdater.test.mjs | 376 +++--- .../PaymentProviderEntities.test.mjs | 321 ++--- .../src/Subscription/PlansLocator.test.mjs | 135 +-- .../src/Subscription/RecurlyClient.test.mjs | 559 ++++----- .../src/Subscription/RecurlyWrapper.test.mjs | 1078 +++++++++-------- .../Subscription/SubscriptionLocator.test.mjs | 129 +- .../Subscription/SubscriptionUpdater.test.mjs | 846 +++++++------ .../Subscription/UserFeaturesUpdater.test.mjs | 53 +- .../V1SusbcriptionManager.test.mjs | 365 +++--- .../test/unit/src/Tags/TagsHandler.test.mjs | 307 +++-- .../TokenAccessController.test.mjs | 20 +- .../TokenAccess/TokenAccessHandler.test.mjs | 686 ++++++----- .../unit/src/Uploads/ArchiveManager.test.mjs | 731 +++++------ .../unit/src/Uploads/FileTypeManager.test.mjs | 254 ++-- .../User/ThirdPartyIdentityManager.test.mjs | 259 ++-- .../src/User/UserAuditLogHandler.test.mjs | 174 +-- .../test/unit/src/User/UserGetter.test.mjs | 589 ++++----- .../src/User/UserSessionsManager.test.mjs | 662 +++++----- .../test/unit/src/User/UserUpdater.test.mjs | 1051 ++++++++-------- .../UserMembershipController.test.mjs | 18 + .../UserMembershipsHandler.test.mjs | 96 +- 61 files changed, 8428 insertions(+), 8063 deletions(-) diff --git a/services/web/app/src/Features/User/UserSessionsManager.mjs b/services/web/app/src/Features/User/UserSessionsManager.mjs index 0f48732bab..c64f716d6d 100644 --- a/services/web/app/src/Features/User/UserSessionsManager.mjs +++ b/services/web/app/src/Features/User/UserSessionsManager.mjs @@ -1,7 +1,7 @@ import Settings from '@overleaf/settings' import logger from '@overleaf/logger' import _ from 'lodash' -import { callbackifyAll } from 'node:util' +import { callbackifyAll } from '@overleaf/promise-utils' import UserSessionsRedis from './UserSessionsRedis.mjs' const rclient = UserSessionsRedis.client() @@ -163,5 +163,5 @@ const UserSessionsManager = { export default { ...callbackifyAll(UserSessionsManager), - promises: UserSessionsManager + promises: UserSessionsManager, } diff --git a/services/web/test/unit/src/Analytics/AccountMappingHelper.test.mjs b/services/web/test/unit/src/Analytics/AccountMappingHelper.test.mjs index 91eb6b9f87..d1ca54f74e 100644 --- a/services/web/test/unit/src/Analytics/AccountMappingHelper.test.mjs +++ b/services/web/test/unit/src/Analytics/AccountMappingHelper.test.mjs @@ -1,121 +1,122 @@ -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const { ObjectId } = require('mongodb-legacy') -const path = require('node:path') +import { expect } from 'vitest' +import mongodb from 'mongodb-legacy' +import path from 'node:path' + +const { ObjectId } = mongodb const MODULE_PATH = path.join( - __dirname, + import.meta.dirname, '../../../../app/src/Features/Analytics/AccountMappingHelper' ) describe('AccountMappingHelper', function () { - beforeEach(function () { - this.AccountMappingHelper = SandboxedModule.require(MODULE_PATH) + beforeEach(async function (ctx) { + ctx.AccountMappingHelper = (await import(MODULE_PATH)).default }) describe('extractAccountMappingsFromSubscription', function () { describe('when the v1 id is the same in the updated subscription and the subscription', function () { describe('when the salesforce id is the same in the updated subscription and the subscription', function () { - beforeEach(function () { - this.subscription = { + beforeEach(function (ctx) { + ctx.subscription = { id: new ObjectId('abc123abc123abc123abc123'), salesforce_id: 'def456def456def456', } - this.updatedSubscription = { salesforce_id: 'def456def456def456' } - this.result = - this.AccountMappingHelper.extractAccountMappingsFromSubscription( - this.subscription, - this.updatedSubscription + ctx.updatedSubscription = { salesforce_id: 'def456def456def456' } + ctx.result = + ctx.AccountMappingHelper.extractAccountMappingsFromSubscription( + ctx.subscription, + ctx.updatedSubscription ) }) - it('returns an empty array', function () { - expect(this.result).to.be.an('array') - expect(this.result).to.have.length(0) + it('returns an empty array', function (ctx) { + expect(ctx.result).to.be.an('array') + expect(ctx.result).to.have.length(0) }) }) describe('when the salesforce id has changed between the subscription and the updated subscription', function () { - beforeEach(function () { - this.subscription = { + beforeEach(function (ctx) { + ctx.subscription = { id: new ObjectId('abc123abc123abc123abc123'), salesforce_id: 'def456def456def456', } - this.updatedSubscription = { salesforce_id: 'ghi789ghi789ghi789' } - this.result = - this.AccountMappingHelper.extractAccountMappingsFromSubscription( - this.subscription, - this.updatedSubscription + ctx.updatedSubscription = { salesforce_id: 'ghi789ghi789ghi789' } + ctx.result = + ctx.AccountMappingHelper.extractAccountMappingsFromSubscription( + ctx.subscription, + ctx.updatedSubscription ) }) - it('returns an array with a single item', function () { - expect(this.result).to.be.an('array') - expect(this.result).to.have.length(1) + it('returns an array with a single item', function (ctx) { + expect(ctx.result).to.be.an('array') + expect(ctx.result).to.have.length(1) }) - it('uses "account" as sourceEntity', function () { - expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'account') + it('uses "account" as sourceEntity', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty('sourceEntity', 'account') }) - it('uses the salesforceId from the updated subscription as sourceEntityId', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses the salesforceId from the updated subscription as sourceEntityId', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'sourceEntityId', - this.updatedSubscription.salesforce_id + ctx.updatedSubscription.salesforce_id ) }) - it('uses "subscription" as targetEntity', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses "subscription" as targetEntity', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'targetEntity', 'subscription' ) }) - it('uses the subscriptionId as targetEntityId', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses the subscriptionId as targetEntityId', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'targetEntityId', - this.subscription.id + ctx.subscription.id ) }) }) describe('when the update subscription has a salesforce id and the subscription has no salesforce_id', function () { - beforeEach(function () { - this.subscription = { id: new ObjectId('abc123abc123abc123abc123') } - this.updatedSubscription = { salesforce_id: 'def456def456def456' } - this.result = - this.AccountMappingHelper.extractAccountMappingsFromSubscription( - this.subscription, - this.updatedSubscription + beforeEach(function (ctx) { + ctx.subscription = { id: new ObjectId('abc123abc123abc123abc123') } + ctx.updatedSubscription = { salesforce_id: 'def456def456def456' } + ctx.result = + ctx.AccountMappingHelper.extractAccountMappingsFromSubscription( + ctx.subscription, + ctx.updatedSubscription ) }) - it('returns an array with a single item', function () { - expect(this.result).to.be.an('array') - expect(this.result).to.have.length(1) + it('returns an array with a single item', function (ctx) { + expect(ctx.result).to.be.an('array') + expect(ctx.result).to.have.length(1) }) - it('uses "account" as sourceEntity', function () { - expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'account') + it('uses "account" as sourceEntity', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty('sourceEntity', 'account') }) - it('uses the salesforceId from the updated subscription as sourceEntityId', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses the salesforceId from the updated subscription as sourceEntityId', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'sourceEntityId', - this.updatedSubscription.salesforce_id + ctx.updatedSubscription.salesforce_id ) }) - it('uses "subscription" as targetEntity', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses "subscription" as targetEntity', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'targetEntity', 'subscription' ) }) - it('uses the subscriptionId as targetEntityId', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses the subscriptionId as targetEntityId', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'targetEntityId', - this.subscription.id + ctx.subscription.id ) }) }) @@ -123,153 +124,150 @@ describe('AccountMappingHelper', function () { describe('when the v1 id has changed between the subscription and the updated subscription', function () { describe('when the salesforce id has not changed between the subscription and the updated subscription', function () { - beforeEach(function () { - this.subscription = { + beforeEach(function (ctx) { + ctx.subscription = { id: new ObjectId('abc123abc123abc123abc123'), v1_id: '1', salesforce_id: '', } - this.updatedSubscription = { v1_id: '2', salesforce_id: '' } - this.result = - this.AccountMappingHelper.extractAccountMappingsFromSubscription( - this.subscription, - this.updatedSubscription + ctx.updatedSubscription = { v1_id: '2', salesforce_id: '' } + ctx.result = + ctx.AccountMappingHelper.extractAccountMappingsFromSubscription( + ctx.subscription, + ctx.updatedSubscription ) }) - it('returns an array with a single item', function () { - expect(this.result).to.be.an('array') - expect(this.result).to.have.length(1) + it('returns an array with a single item', function (ctx) { + expect(ctx.result).to.be.an('array') + expect(ctx.result).to.have.length(1) }) - it('uses "university" as the sourceEntity', function () { - expect(this.result[0]).to.haveOwnProperty( - 'sourceEntity', - 'university' - ) + it('uses "university" as the sourceEntity', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty('sourceEntity', 'university') }) - it('uses the v1_id from the updated subscription as the sourceEntityId', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses the v1_id from the updated subscription as the sourceEntityId', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'sourceEntityId', - this.updatedSubscription.v1_id + ctx.updatedSubscription.v1_id ) }) - it('uses "subscription" as the targetEntity', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses "subscription" as the targetEntity', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'targetEntity', 'subscription' ) }) - it('uses the subscription id as the targetEntityId', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses the subscription id as the targetEntityId', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'targetEntityId', - this.subscription.id + ctx.subscription.id ) }) }) describe('when the salesforce id has changed between the subscription and the updated subscription', function () { - beforeEach(function () { - this.subscription = { + beforeEach(function (ctx) { + ctx.subscription = { id: new ObjectId('abc123abc123abc123abc123'), v1_id: '', salesforce_id: 'def456def456def456', } - this.updatedSubscription = { + ctx.updatedSubscription = { v1_id: '2', salesforce_id: '', } - this.result = - this.AccountMappingHelper.extractAccountMappingsFromSubscription( - this.subscription, - this.updatedSubscription + ctx.result = + ctx.AccountMappingHelper.extractAccountMappingsFromSubscription( + ctx.subscription, + ctx.updatedSubscription ) }) - it('returns an array with two items', function () { - expect(this.result).to.be.an('array') - expect(this.result).to.have.length(2) + it('returns an array with two items', function (ctx) { + expect(ctx.result).to.be.an('array') + expect(ctx.result).to.have.length(2) }) - it('uses the salesforce_id from the updated subscription as the sourceEntityId for the first item', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses the salesforce_id from the updated subscription as the sourceEntityId for the first item', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'sourceEntityId', - this.updatedSubscription.salesforce_id + ctx.updatedSubscription.salesforce_id ) }) - it('uses the subscription id as the targetEntityId for the first item', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses the subscription id as the targetEntityId for the first item', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'targetEntityId', - this.subscription.id + ctx.subscription.id ) }) - it('uses the v1_id from the updated subscription as the sourceEntityId for the second item', function () { - expect(this.result[1]).to.haveOwnProperty( + it('uses the v1_id from the updated subscription as the sourceEntityId for the second item', function (ctx) { + expect(ctx.result[1]).to.haveOwnProperty( 'sourceEntityId', - this.updatedSubscription.v1_id + ctx.updatedSubscription.v1_id ) }) - it('uses the subscription id as the targetEntityId for the second item', function () { - expect(this.result[1]).to.haveOwnProperty( + it('uses the subscription id as the targetEntityId for the second item', function (ctx) { + expect(ctx.result[1]).to.haveOwnProperty( 'targetEntityId', - this.subscription.id + ctx.subscription.id ) }) }) }) }) describe('when the recurlySubscription_id has changed between the subscription and the updated subscription', function () { - beforeEach(function () { - this.subscription = { + beforeEach(function (ctx) { + ctx.subscription = { id: new ObjectId('abc123abc123abc123abc123'), recurlySubscription_id: '', } - this.updatedSubscription = { + ctx.updatedSubscription = { recurlySubscription_id: '1234a5678b90123cd4567e8f901a2b34', } - this.result = - this.AccountMappingHelper.extractAccountMappingsFromSubscription( - this.subscription, - this.updatedSubscription + ctx.result = + ctx.AccountMappingHelper.extractAccountMappingsFromSubscription( + ctx.subscription, + ctx.updatedSubscription ) }) - it('returns an array with one item', function () { - expect(this.result).to.be.an('array') - expect(this.result).to.have.length(1) + it('returns an array with one item', function (ctx) { + expect(ctx.result).to.be.an('array') + expect(ctx.result).to.have.length(1) }) - it('uses "recurly" as the source', function () { - expect(this.result[0]).to.haveOwnProperty('source', 'recurly') + it('uses "recurly" as the source', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty('source', 'recurly') }) - it('uses "subscription" as the sourceEntity', function () { - expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'subscription') + it('uses "subscription" as the sourceEntity', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty('sourceEntity', 'subscription') }) - it('uses the recurlySubscription_id as the sourceEntityId', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses the recurlySubscription_id as the sourceEntityId', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'sourceEntityId', - this.updatedSubscription.recurlySubscription_id + ctx.updatedSubscription.recurlySubscription_id ) }) - it('uses "v2" as the target', function () { - expect(this.result[0]).to.haveOwnProperty('target', 'v2') + it('uses "v2" as the target', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty('target', 'v2') }) - it('uses "subscription" as the targetEntity', function () { - expect(this.result[0]).to.haveOwnProperty('targetEntity', 'subscription') + it('uses "subscription" as the targetEntity', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty('targetEntity', 'subscription') }) - it('uses the subscription id as the targetEntityId', function () { - expect(this.result[0]).to.haveOwnProperty( + it('uses the subscription id as the targetEntityId', function (ctx) { + expect(ctx.result[0]).to.haveOwnProperty( 'targetEntityId', - this.subscription.id + ctx.subscription.id ) }) }) diff --git a/services/web/test/unit/src/Analytics/AnalyticsManager.test.mjs b/services/web/test/unit/src/Analytics/AnalyticsManager.test.mjs index 053f2ee31f..86b95b60c2 100644 --- a/services/web/test/unit/src/Analytics/AnalyticsManager.test.mjs +++ b/services/web/test/unit/src/Analytics/AnalyticsManager.test.mjs @@ -1,186 +1,201 @@ -const SandboxedModule = require('sandboxed-module') -const path = require('path') -const sinon = require('sinon') -const MockRequest = require('../helpers/MockRequest') -const MockResponse = require('../helpers/MockResponse') -const { assert } = require('chai') -const { ObjectId } = require('mongodb-legacy') +import { vi, assert } from 'vitest' +import path from 'path' +import sinon from 'sinon' +import MockRequest from '../helpers/MockRequest.js' +import MockResponse from '../helpers/MockResponse.js' +import mongodb from 'mongodb-legacy' + +const { ObjectId } = mongodb const MODULE_PATH = path.join( - __dirname, + import.meta.dirname, '../../../../app/src/Features/Analytics/AnalyticsManager' ) +vi.mock('../../../../app/src/infrastructure/Metrics.js', () => ({ + default: { + analyticsQueue: { + inc: vi.fn(), + }, + }, +})) describe('AnalyticsManager', function () { - beforeEach(function () { - this.fakeUserId = 'dbfc9438d14996f73dd172fb' - this.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55' - this.Settings = { + beforeEach(async function (ctx) { + ctx.fakeUserId = 'dbfc9438d14996f73dd172fb' + ctx.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55' + ctx.Settings = { analytics: { enabled: true, hashedEmailSalt: 'salt' }, } - this.analyticsEventsQueue = { + ctx.analyticsEventsQueue = { add: sinon.stub().resolves(), process: sinon.stub().resolves(), } - this.analyticsEditingSessionQueue = { + ctx.analyticsEditingSessionQueue = { add: sinon.stub().resolves(), process: sinon.stub().resolves(), } - this.onboardingEmailsQueue = { + ctx.onboardingEmailsQueue = { add: sinon.stub().resolves(), process: sinon.stub().resolves(), } - this.analyticsUserPropertiesQueue = { + ctx.analyticsUserPropertiesQueue = { add: sinon.stub().resolves(), process: sinon.stub().resolves(), } - this.analyticsAccountMappingQueue = { + ctx.analyticsAccountMappingQueue = { add: sinon.stub().resolves(), process: sinon.stub().resolves(), } - this.analyticsEmailChangeQueue = { + ctx.analyticsEmailChangeQueue = { add: sinon.stub().resolves(), process: sinon.stub().resolves(), } - this.Queues = { + ctx.Queues = { getQueue: queueName => { switch (queueName) { case 'analytics-events': - return this.analyticsEventsQueue + return ctx.analyticsEventsQueue case 'analytics-editing-sessions': - return this.analyticsEditingSessionQueue + return ctx.analyticsEditingSessionQueue case 'emails-onboarding': - return this.onboardingEmailsQueue + return ctx.onboardingEmailsQueue case 'analytics-user-properties': - return this.analyticsUserPropertiesQueue + return ctx.analyticsUserPropertiesQueue case 'analytics-account-mapping': - return this.analyticsAccountMappingQueue + return ctx.analyticsAccountMappingQueue case 'analytics-email-change': - return this.analyticsEmailChangeQueue + return ctx.analyticsEmailChangeQueue default: throw new Error('Unexpected queue name') } }, createScheduledJob: sinon.stub().resolves(), } - this.backgroundRequest = sinon.stub().yields() - this.request = sinon.stub().yields() - this.AnalyticsManager = SandboxedModule.require(MODULE_PATH, { - requires: { - '@overleaf/settings': this.Settings, - '../../infrastructure/Queues': this.Queues, - './UserAnalyticsIdCache': (this.UserAnalyticsIdCache = { - get: sinon.stub().resolves(this.analyticsId), + ctx.backgroundRequest = sinon.stub().yields() + ctx.request = sinon.stub().yields() + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + vi.doMock('../../../../app/src/infrastructure/Queues', () => ({ + default: ctx.Queues, + })) + + vi.doMock( + '../../../../app/src/Features/Analytics/UserAnalyticsIdCache', + () => ({ + default: (ctx.UserAnalyticsIdCache = { + get: sinon.stub().resolves(ctx.analyticsId), }), - }, - }) + }) + ) + + ctx.AnalyticsManager = (await import(MODULE_PATH)).default }) describe('ignores when', function () { - it('user is smoke test user', function () { - this.Settings.smokeTest = { userId: this.fakeUserId } - this.AnalyticsManager.identifyUser(this.fakeUserId, '') - sinon.assert.notCalled(this.Queues.createScheduledJob) + it('user is smoke test user', function (ctx) { + ctx.Settings.smokeTest = { userId: ctx.fakeUserId } + ctx.AnalyticsManager.identifyUser(ctx.fakeUserId, '') + sinon.assert.notCalled(ctx.Queues.createScheduledJob) }) - it('analytics service is disabled', function () { - this.Settings.analytics.enabled = false - this.AnalyticsManager.identifyUser(this.fakeUserId, '') - sinon.assert.notCalled(this.Queues.createScheduledJob) + it('analytics service is disabled', function (ctx) { + ctx.Settings.analytics.enabled = false + ctx.AnalyticsManager.identifyUser(ctx.fakeUserId, '') + sinon.assert.notCalled(ctx.Queues.createScheduledJob) }) - it('userId is missing', function () { - this.AnalyticsManager.identifyUser(undefined, this.analyticsId) - sinon.assert.notCalled(this.Queues.createScheduledJob) + it('userId is missing', function (ctx) { + ctx.AnalyticsManager.identifyUser(undefined, ctx.analyticsId) + sinon.assert.notCalled(ctx.Queues.createScheduledJob) }) - it('analyticsId is missing', function () { - this.AnalyticsManager.identifyUser( - new ObjectId(this.fakeUserId), - undefined + it('analyticsId is missing', function (ctx) { + ctx.AnalyticsManager.identifyUser(new ObjectId(ctx.fakeUserId), undefined) + sinon.assert.notCalled(ctx.Queues.createScheduledJob) + }) + + it('analyticsId is not a valid UUID', function (ctx) { + ctx.AnalyticsManager.identifyUser( + new ObjectId(ctx.fakeUserId), + ctx.fakeUserId ) - sinon.assert.notCalled(this.Queues.createScheduledJob) + sinon.assert.notCalled(ctx.Queues.createScheduledJob) }) - it('analyticsId is not a valid UUID', function () { - this.AnalyticsManager.identifyUser( - new ObjectId(this.fakeUserId), - this.fakeUserId + it('userId and analyticsId are the same Mongo ID', function (ctx) { + ctx.AnalyticsManager.identifyUser( + new ObjectId(ctx.fakeUserId), + new ObjectId(ctx.fakeUserId) ) - sinon.assert.notCalled(this.Queues.createScheduledJob) + sinon.assert.notCalled(ctx.Queues.createScheduledJob) }) - it('userId and analyticsId are the same Mongo ID', function () { - this.AnalyticsManager.identifyUser( - new ObjectId(this.fakeUserId), - new ObjectId(this.fakeUserId) - ) - sinon.assert.notCalled(this.Queues.createScheduledJob) - }) - - it('editing session segmentation is not valid', function () { - this.AnalyticsManager.updateEditingSession( - this.fakeUserId, + it('editing session segmentation is not valid', function (ctx) { + ctx.AnalyticsManager.updateEditingSession( + ctx.fakeUserId, '789ghi', 'fr', { '': 'foo' } ) - sinon.assert.called(this.logger.info) - sinon.assert.notCalled(this.analyticsEditingSessionQueue.add) + expect(ctx.logger.info).toHaveBeenCalled() + sinon.assert.notCalled(ctx.analyticsEditingSessionQueue.add) }) - it('event is not valid', async function () { - await this.AnalyticsManager.recordEventForUser( - this.fakeUserId, + it('event is not valid', async function (ctx) { + await ctx.AnalyticsManager.recordEventForUser( + ctx.fakeUserId, 'not an event!' ) - sinon.assert.called(this.logger.info) - sinon.assert.notCalled(this.analyticsEventsQueue.add) + expect(ctx.logger.info).toHaveBeenCalled() + sinon.assert.notCalled(ctx.analyticsEventsQueue.add) }) - it('event segmentation is not valid', async function () { - await this.AnalyticsManager.recordEventForUser( - this.fakeUserId, + it('event segmentation is not valid', async function (ctx) { + await ctx.AnalyticsManager.recordEventForUser( + ctx.fakeUserId, 'an_event', { 'not_a!': 'Valid Segmentation' } ) - sinon.assert.called(this.logger.info) - sinon.assert.notCalled(this.analyticsEventsQueue.add) + expect(ctx.logger.info).toHaveBeenCalled() + sinon.assert.notCalled(ctx.analyticsEventsQueue.add) }) - it('user property name is not valid', async function () { - await this.AnalyticsManager.setUserPropertyForUser( - this.fakeUserId, + it('user property name is not valid', async function (ctx) { + await ctx.AnalyticsManager.setUserPropertyForUser( + ctx.fakeUserId, 'an invalid property', 'a_value' ) - sinon.assert.called(this.logger.info) - sinon.assert.notCalled(this.analyticsUserPropertiesQueue.add) + expect(ctx.logger.info).toHaveBeenCalled() + sinon.assert.notCalled(ctx.analyticsUserPropertiesQueue.add) }) - it('user property value is not valid', async function () { - await this.AnalyticsManager.setUserPropertyForUser( - this.fakeUserId, + it('user property value is not valid', async function (ctx) { + await ctx.AnalyticsManager.setUserPropertyForUser( + ctx.fakeUserId, 'a_property', 'an invalid value' ) - sinon.assert.called(this.logger.info) - sinon.assert.notCalled(this.analyticsUserPropertiesQueue.add) + expect(ctx.logger.info).toHaveBeenCalled() + sinon.assert.notCalled(ctx.analyticsUserPropertiesQueue.add) }) }) describe('queues the appropriate message for', function () { - it('identifyUser', function () { + it('identifyUser', function (ctx) { const analyticsId = 'bd101c4c-722f-4204-9e2d-8303e5d9c120' - this.AnalyticsManager.identifyUser(this.fakeUserId, analyticsId, true) - sinon.assert.notCalled(this.logger.info) + ctx.AnalyticsManager.identifyUser(ctx.fakeUserId, analyticsId, true) + expect(ctx.logger.info).not.toHaveBeenCalled() sinon.assert.calledWithMatch( - this.Queues.createScheduledJob, + ctx.Queues.createScheduledJob, 'analytics-events', { name: 'identify', data: { - userId: this.fakeUserId, + userId: ctx.fakeUserId, analyticsId, isNewUser: true, createdAt: sinon.match.date, @@ -190,38 +205,34 @@ describe('AnalyticsManager', function () { ) }) - it('recordEventForUser', async function () { + it('recordEventForUser', async function (ctx) { const event = 'fake-event' - await this.AnalyticsManager.recordEventForUser( - this.fakeUserId, - event, - null - ) - sinon.assert.notCalled(this.logger.info) - sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', { - analyticsId: this.analyticsId, + await ctx.AnalyticsManager.recordEventForUser(ctx.fakeUserId, event, null) + expect(ctx.logger.info).not.toHaveBeenCalled() + sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', { + analyticsId: ctx.analyticsId, event, segmentation: null, isLoggedIn: true, }) }) - it('updateEditingSession', function () { + it('updateEditingSession', function (ctx) { const projectId = '789ghi' const countryCode = 'fr' const segmentation = { editorType: 'abc' } - this.AnalyticsManager.updateEditingSession( - this.fakeUserId, + ctx.AnalyticsManager.updateEditingSession( + ctx.fakeUserId, projectId, countryCode, segmentation ) - sinon.assert.notCalled(this.logger.info) + expect(ctx.logger.info).not.toHaveBeenCalled() sinon.assert.calledWithMatch( - this.analyticsEditingSessionQueue.add, + ctx.analyticsEditingSessionQueue.add, 'editing-session', { - userId: this.fakeUserId, + userId: ctx.fakeUserId, projectId, countryCode, segmentation, @@ -229,68 +240,68 @@ describe('AnalyticsManager', function () { ) }) - it('empty field in event segmentation', async function () { + it('empty field in event segmentation', async function (ctx) { const timings = null - await this.AnalyticsManager.recordEventForUser( - this.fakeUserId, + await ctx.AnalyticsManager.recordEventForUser( + ctx.fakeUserId, 'an_event', { compileTime: timings?.compileE2E } ) - sinon.assert.notCalled(this.logger.info) - sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', { - analyticsId: this.analyticsId, + expect(ctx.logger.info).not.toHaveBeenCalled() + sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', { + analyticsId: ctx.analyticsId, event: 'an_event', segmentation: { compileTime: undefined }, isLoggedIn: true, }) }) - it('empty space in event segmentation value', async function () { - await this.AnalyticsManager.recordEventForUser( - this.fakeUserId, + it('empty space in event segmentation value', async function (ctx) { + await ctx.AnalyticsManager.recordEventForUser( + ctx.fakeUserId, 'an_event', { segment: 'a value with spaces' } ) - sinon.assert.notCalled(this.logger.info) - sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', { - analyticsId: this.analyticsId, + expect(ctx.logger.info).not.toHaveBeenCalled() + sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', { + analyticsId: ctx.analyticsId, event: 'an_event', segmentation: { segment: 'a value with spaces' }, isLoggedIn: true, }) }) - it('percent sign in event segmentation value', async function () { - await this.AnalyticsManager.recordEventForUser( - this.fakeUserId, + it('percent sign in event segmentation value', async function (ctx) { + await ctx.AnalyticsManager.recordEventForUser( + ctx.fakeUserId, 'an_event', { segment: 'a value with escaped comma %2C' } ) - sinon.assert.notCalled(this.logger.info) - sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', { - analyticsId: this.analyticsId, + expect(ctx.logger.info).not.toHaveBeenCalled() + sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', { + analyticsId: ctx.analyticsId, event: 'an_event', segmentation: { segment: 'a value with escaped comma %2C' }, isLoggedIn: true, }) }) - it('boolean field in event segmentation', async function () { - await this.AnalyticsManager.recordEventForUser( - this.fakeUserId, + it('boolean field in event segmentation', async function (ctx) { + await ctx.AnalyticsManager.recordEventForUser( + ctx.fakeUserId, 'an_event', { isAutoCompile: false } ) - sinon.assert.notCalled(this.logger.info) - sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', { - analyticsId: this.analyticsId, + expect(ctx.logger.info).not.toHaveBeenCalled() + sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', { + analyticsId: ctx.analyticsId, event: 'an_event', segmentation: { isAutoCompile: false }, isLoggedIn: true, }) }) - it('account mapping', async function () { + it('account mapping', async function (ctx) { const message = { source: 'salesforce', sourceEntity: 'account', @@ -300,24 +311,24 @@ describe('AnalyticsManager', function () { targetEntityId: 1, createdAt: '2021-01-01T00:00:00Z', } - await this.AnalyticsManager.registerAccountMapping(message) + await ctx.AnalyticsManager.registerAccountMapping(message) sinon.assert.calledWithMatch( - this.analyticsAccountMappingQueue.add, + ctx.analyticsAccountMappingQueue.add, 'account-mapping', message ) }) - it('email change', async function () { + it('email change', async function (ctx) { const message = { - userId: this.fakeUserId, + userId: ctx.fakeUserId, email: 'test@example.com', createdAt: '2021-01-01T00:00:00Z', action: 'created', emailCreatedAt: '2021-01-01T00:00:00Z', isPrimary: false, } - this.AnalyticsManager.registerEmailChange(message) + ctx.AnalyticsManager.registerEmailChange(message) const convertedMessage = { ...message, emailConfirmedAt: undefined, @@ -326,7 +337,7 @@ describe('AnalyticsManager', function () { '1778d425d64c5259ef7b574a2488647eb51ca739a0b16bfa0e2e3e16fff362db', // sha256 hash of email + salt } sinon.assert.calledWithMatch( - this.analyticsEmailChangeQueue.add, + ctx.analyticsEmailChangeQueue.add, 'email-change', convertedMessage ) @@ -334,123 +345,124 @@ describe('AnalyticsManager', function () { }) describe('AnalyticsIdMiddleware', function () { - beforeEach(function () { - this.userId = '123abc' - this.analyticsId = 'bccd308c-5d72-426e-a106-662e88557795' - this.AnalyticsManager = SandboxedModule.require(MODULE_PATH, { - requires: { - '@overleaf/settings': { - analytics: { hashedEmailSalt: 'test-salt' }, - }, - '../../infrastructure/Queues': { - getQueue: queueName => { - switch (queueName) { - case 'analytics-events': - return this.analyticsEventsQueue - case 'analytics-editing-sessions': - return this.analyticsEditingSessionQueue - case 'emails-onboarding': - return this.onboardingEmailsQueue - case 'analytics-user-properties': - return this.analyticsUserPropertiesQueue - case 'analytics-account-mapping': - return this.analyticsAccountMappingQueue - case 'analytics-email-change': - return this.analyticsEmailChangeQueue - default: - throw new Error('Unexpected queue name') - } - }, - }, + beforeEach(async function (ctx) { + vi.resetModules() + ctx.userId = '123abc' + ctx.analyticsId = 'bccd308c-5d72-426e-a106-662e88557795' - './UserAnalyticsIdCache': (this.UserAnalyticsIdCache = { - get: sinon.stub().resolves(this.analyticsId), - }), - crypto: { - randomUUID: () => this.analyticsId, + vi.doMock('@overleaf/settings', () => ({ + default: { + analytics: { hashedEmailSalt: 'test-salt' }, + }, + })) + + vi.doMock('../../../../app/src/infrastructure/Queues', () => ({ + default: { + getQueue: queueName => { + switch (queueName) { + case 'analytics-events': + return ctx.analyticsEventsQueue + case 'analytics-editing-sessions': + return ctx.analyticsEditingSessionQueue + case 'emails-onboarding': + return ctx.onboardingEmailsQueue + case 'analytics-user-properties': + return ctx.analyticsUserPropertiesQueue + case 'analytics-account-mapping': + return ctx.analyticsAccountMappingQueue + case 'analytics-email-change': + return ctx.analyticsEmailChangeQueue + default: + throw new Error('Unexpected queue name') + } }, }, - }) - this.req = new MockRequest() - this.req.session = {} - this.res = new MockResponse() - this.next = () => {} - }) + })) - it('sets session.analyticsId with no user in session', async function () { - await this.AnalyticsManager.analyticsIdMiddleware( - this.req, - this.res, - this.next + vi.doMock( + '../../../../app/src/Features/Analytics/UserAnalyticsIdCache', + () => ({ + default: (ctx.UserAnalyticsIdCache = { + get: sinon.stub().resolves(ctx.analyticsId), + }), + }) ) - assert.equal(this.analyticsId, this.req.session.analyticsId) + + vi.doMock('node:crypto', () => ({ + default: { + randomUUID: () => ctx.analyticsId, + }, + })) + + ctx.AnalyticsManager = (await import(MODULE_PATH)).default + ctx.req = new MockRequest() + ctx.req.session = {} + ctx.res = new MockResponse() + ctx.next = () => {} }) - it('does not update analyticsId when existing, with no user in session', async function () { - this.req.session.analyticsId = 'foo' - await this.AnalyticsManager.analyticsIdMiddleware( - this.req, - this.res, - this.next + it('sets session.analyticsId with no user in session', async function (ctx) { + await ctx.AnalyticsManager.analyticsIdMiddleware( + ctx.req, + ctx.res, + ctx.next ) - assert.equal('foo', this.req.session.analyticsId) + assert.equal(ctx.analyticsId, ctx.req.session.analyticsId) }) - it('sets session.analyticsId with a logged in user in session having an analyticsId', async function () { - this.req.session.user = { - _id: this.userId, - analyticsId: this.analyticsId, + it('does not update analyticsId when existing, with no user in session', async function (ctx) { + ctx.req.session.analyticsId = 'foo' + await ctx.AnalyticsManager.analyticsIdMiddleware( + ctx.req, + ctx.res, + ctx.next + ) + assert.equal('foo', ctx.req.session.analyticsId) + }) + + it('sets session.analyticsId with a logged in user in session having an analyticsId', async function (ctx) { + ctx.req.session.user = { + _id: ctx.userId, + analyticsId: ctx.analyticsId, } - await this.AnalyticsManager.analyticsIdMiddleware( - this.req, - this.res, - () => { - assert.equal(this.analyticsId, this.req.session.analyticsId) - } - ) - }) - - it('sets session.analyticsId with a legacy user session without an analyticsId', async function () { - this.UserAnalyticsIdCache.get.resolves(this.userId) - this.req.session.user = { - _id: this.userId, - analyticsId: undefined, - } - await this.AnalyticsManager.analyticsIdMiddleware( - this.req, - this.res, - () => { - assert.equal(this.userId, this.req.session.analyticsId) - } - ) - }) - - it('updates session.analyticsId with a legacy user session without an analyticsId if different', async function () { - this.UserAnalyticsIdCache.get.resolves(this.userId) - this.req.session.user = { - _id: this.userId, - analyticsId: undefined, - } - this.req.analyticsId = 'foo' - this.AnalyticsManager.analyticsIdMiddleware(this.req, this.res, () => { - assert.equal(this.userId, this.req.session.analyticsId) + await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => { + assert.equal(ctx.analyticsId, ctx.req.session.analyticsId) }) }) - it('does not update session.analyticsId with a legacy user session without an analyticsId if same', async function () { - this.UserAnalyticsIdCache.get.resolves(this.userId) - this.req.session.user = { - _id: this.userId, + it('sets session.analyticsId with a legacy user session without an analyticsId', async function (ctx) { + ctx.UserAnalyticsIdCache.get.resolves(ctx.userId) + ctx.req.session.user = { + _id: ctx.userId, analyticsId: undefined, } - this.req.analyticsId = this.userId - await this.AnalyticsManager.analyticsIdMiddleware( - this.req, - this.res, - () => { - assert.equal(this.userId, this.req.session.analyticsId) - } - ) + await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => { + assert.equal(ctx.userId, ctx.req.session.analyticsId) + }) + }) + + it('updates session.analyticsId with a legacy user session without an analyticsId if different', async function (ctx) { + ctx.UserAnalyticsIdCache.get.resolves(ctx.userId) + ctx.req.session.user = { + _id: ctx.userId, + analyticsId: undefined, + } + ctx.req.analyticsId = 'foo' + ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => { + assert.equal(ctx.userId, ctx.req.session.analyticsId) + }) + }) + + it('does not update session.analyticsId with a legacy user session without an analyticsId if same', async function (ctx) { + ctx.UserAnalyticsIdCache.get.resolves(ctx.userId) + ctx.req.session.user = { + _id: ctx.userId, + analyticsId: undefined, + } + ctx.req.analyticsId = ctx.userId + await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => { + assert.equal(ctx.userId, ctx.req.session.analyticsId) + }) }) }) }) diff --git a/services/web/test/unit/src/Analytics/EmailChangeHelpers.test.mjs b/services/web/test/unit/src/Analytics/EmailChangeHelpers.test.mjs index f874458667..8fb9371e05 100644 --- a/services/web/test/unit/src/Analytics/EmailChangeHelpers.test.mjs +++ b/services/web/test/unit/src/Analytics/EmailChangeHelpers.test.mjs @@ -1,6 +1,5 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const { expect } = require('chai') +import { vi, expect } from 'vitest' +import sinon from 'sinon' describe('EmailChangeHelper', function () { let AnalyticsManager @@ -8,7 +7,7 @@ describe('EmailChangeHelper', function () { let EmailChangeHelpers const email = 'test@example.com' const userId = '507f1f77bcf86cd799439011' - beforeEach(function () { + beforeEach(async function () { UserGetter = { promises: { getUserFullEmails: sinon.stub().resolves([]), @@ -17,15 +16,21 @@ describe('EmailChangeHelper', function () { AnalyticsManager = { registerEmailChange: sinon.stub(), } - EmailChangeHelpers = SandboxedModule.require( - '../../../../app/src/Features/Analytics/EmailChangeHelper', - { - requires: { - '../User/UserGetter': UserGetter, - './AnalyticsManager': AnalyticsManager, - }, - } + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: UserGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: AnalyticsManager, + }) ) + + EmailChangeHelpers = ( + await import('../../../../app/src/Features/Analytics/EmailChangeHelper') + ).default }) describe('registerEmailUpdate', function () { diff --git a/services/web/test/unit/src/Authentication/AuthenticationManager.test.mjs b/services/web/test/unit/src/Authentication/AuthenticationManager.test.mjs index b2a3a6a22e..2a85092530 100644 --- a/services/web/test/unit/src/Authentication/AuthenticationManager.test.mjs +++ b/services/web/test/unit/src/Authentication/AuthenticationManager.test.mjs @@ -1,52 +1,83 @@ -const sinon = require('sinon') -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const { ObjectId } = require('mongodb-legacy') -const AuthenticationErrors = require('../../../../app/src/Features/Authentication/AuthenticationErrors') -const tk = require('timekeeper') +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' +import AuthenticationErrors from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import tk from 'timekeeper' +import bcrypt from 'bcrypt' + +const { ObjectId } = mongodb const modulePath = - '../../../../app/src/Features/Authentication/AuthenticationManager.js' + '../../../../app/src/Features/Authentication/AuthenticationManager.mjs' describe('AuthenticationManager', function () { - beforeEach(function () { + beforeEach(async function (ctx) { tk.freeze(Date.now()) - this.settings = { security: { bcryptRounds: 4 } } - this.metrics = { inc: sinon.stub().returns() } - this.HaveIBeenPwned = { + ctx.settings = { security: { bcryptRounds: 4 } } + ctx.metrics = { inc: sinon.stub().returns() } + ctx.HaveIBeenPwned = { promises: { checkPasswordForReuse: sinon.stub().resolves(false), }, checkPasswordForReuseInBackground: sinon.stub(), } - this.AuthenticationManager = SandboxedModule.require(modulePath, { - requires: { - '../../models/User': { - User: (this.User = { - updateOne: sinon - .stub() - .returns({ exec: sinon.stub().resolves({ modifiedCount: 1 }) }), - }), + + vi.doMock('../../../../app/src/models/User', () => ({ + User: (ctx.User = { + updateOne: sinon + .stub() + .returns({ exec: sinon.stub().resolves({ modifiedCount: 1 }) }), + }), + })) + + vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({ + db: (ctx.db = { users: {} }), + ObjectId, + })) + + vi.doMock('bcrypt', () => ({ + default: (ctx.bcrypt = { + getRounds: sinon.stub().returns(4), + }), + })) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + ctx.UserGetter = { promises: {} } + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationErrors', + () => ({ + ...AuthenticationErrors, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/HaveIBeenPwned', + () => ({ + default: ctx.HaveIBeenPwned, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({ + default: (ctx.UserAuditLogHandler = { + promises: { + addEntry: sinon.stub().resolves(null), }, - '../../infrastructure/mongodb': { - db: (this.db = { users: {} }), - ObjectId, - }, - bcrypt: (this.bcrypt = { - getRounds: sinon.stub().returns(4), - }), - '@overleaf/settings': this.settings, - '../User/UserGetter': (this.UserGetter = { promises: {} }), - './AuthenticationErrors': AuthenticationErrors, - './HaveIBeenPwned': this.HaveIBeenPwned, - '../User/UserAuditLogHandler': (this.UserAuditLogHandler = { - promises: { - addEntry: sinon.stub().resolves(null), - }, - }), - '@overleaf/metrics': this.metrics, - }, - }) + }), + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: ctx.metrics, + })) + + ctx.AuthenticationManager = (await import(modulePath)).default }) afterEach(function () { @@ -54,51 +85,50 @@ describe('AuthenticationManager', function () { }) describe('with real bcrypt', function () { - beforeEach(function () { - const bcrypt = require('bcrypt') - this.bcrypt.compare = bcrypt.compare - this.bcrypt.getRounds = bcrypt.getRounds - this.bcrypt.genSalt = bcrypt.genSalt - this.bcrypt.hash = bcrypt.hash + beforeEach(function (ctx) { + ctx.bcrypt.compare = bcrypt.compare + ctx.bcrypt.getRounds = bcrypt.getRounds + ctx.bcrypt.genSalt = bcrypt.genSalt + ctx.bcrypt.hash = bcrypt.hash // Hash of 'testpassword' - this.testPassword = + ctx.testPassword = '$2a$04$DcU/3UeJf1PfsWlQL./5H.rGTQL1Z1iyz6r7bN9Do8cy6pVWxpKpK' }) describe('authenticate', function () { - beforeEach(function () { - this.user = { + beforeEach(function (ctx) { + ctx.user = { _id: 'user-id', - email: (this.email = 'USER@overleaf.com'), + email: (ctx.email = 'USER@overleaf.com'), } - this.user.hashedPassword = this.testPassword - this.User.findOne = sinon + ctx.user.hashedPassword = ctx.testPassword + ctx.User.findOne = sinon .stub() - .returns({ exec: sinon.stub().resolves(this.user) }) - this.metrics.inc.reset() + .returns({ exec: sinon.stub().resolves(ctx.user) }) + ctx.metrics.inc.reset() }) describe('when the hashed password matches', function () { - beforeEach(async function () { - this.unencryptedPassword = 'testpassword' - ;({ user: this.result } = - await this.AuthenticationManager.promises.authenticate( - { email: this.email }, - this.unencryptedPassword, + beforeEach(async function (ctx) { + ctx.unencryptedPassword = 'testpassword' + ;({ user: ctx.result } = + await ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, + ctx.unencryptedPassword, null, { enforceHIBPCheck: false } )) }) - it('should look up the correct user in the database', function () { - this.User.findOne.calledWith({ email: this.email }).should.equal(true) + it('should look up the correct user in the database', function (ctx) { + ctx.User.findOne.calledWith({ email: ctx.email }).should.equal(true) }) - it('should bump epoch', function () { - this.User.updateOne.should.have.been.calledWith( + it('should bump epoch', function (ctx) { + ctx.User.updateOne.should.have.been.calledWith( { - _id: this.user._id, - loginEpoch: this.user.loginEpoch, + _id: ctx.user._id, + loginEpoch: ctx.user.loginEpoch, }, { $inc: { loginEpoch: 1 }, @@ -107,33 +137,33 @@ describe('AuthenticationManager', function () { ) }) - it('should return the user', function () { - this.result.should.equal(this.user) + it('should return the user', function (ctx) { + ctx.result.should.equal(ctx.user) }) - it('should send metrics', function () { + it('should send metrics', function (ctx) { expect( - this.metrics.inc.calledWith('check-password', { status: 'success' }) + ctx.metrics.inc.calledWith('check-password', { status: 'success' }) ).to.equal(true) }) }) describe('when the encrypted passwords do not match', function () { - beforeEach(async function () { - ;({ user: this.result } = - await this.AuthenticationManager.promises.authenticate( - { email: this.email }, + beforeEach(async function (ctx) { + ;({ user: ctx.result } = + await ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, 'notthecorrectpassword', null, { enforceHIBPCheck: false } )) }) - it('should persist the login failure and bump epoch', function () { - this.User.updateOne.should.have.been.calledWith( + it('should persist the login failure and bump epoch', function (ctx) { + ctx.User.updateOne.should.have.been.calledWith( { - _id: this.user._id, - loginEpoch: this.user.loginEpoch, + _id: ctx.user._id, + loginEpoch: ctx.user.loginEpoch, }, { $inc: { loginEpoch: 1 }, @@ -142,23 +172,23 @@ describe('AuthenticationManager', function () { ) }) - it('should not return the user', function () { - expect(this.result).to.equal(null) + it('should not return the user', function (ctx) { + expect(ctx.result).to.equal(null) }) }) describe('when another request runs in parallel', function () { - beforeEach(function () { - this.User.updateOne = sinon + beforeEach(function (ctx) { + ctx.User.updateOne = sinon .stub() .returns({ exec: sinon.stub().resolves({ modifiedCount: 0 }) }) }) describe('correct password', function () { - it('should return an error', async function () { + it('should return an error', async function (ctx) { await expect( - this.AuthenticationManager.promises.authenticate( - { email: this.email }, + ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, 'testpassword', null, { enforceHIBPCheck: false } @@ -168,15 +198,15 @@ describe('AuthenticationManager', function () { }) describe('bad password', function () { - beforeEach(function () { - this.User.updateOne = sinon + beforeEach(function (ctx) { + ctx.User.updateOne = sinon .stub() .returns({ exec: sinon.stub().resolves({ modifiedCount: 0 }) }) }) - it('should return an error', async function () { + it('should return an error', async function (ctx) { await expect( - this.AuthenticationManager.promises.authenticate( - { email: this.email }, + ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, 'notthecorrectpassword', null, { enforceHIBPCheck: false } @@ -188,35 +218,35 @@ describe('AuthenticationManager', function () { }) describe('setUserPasswordInV2', function () { - beforeEach(function () { - this.user = { + beforeEach(function (ctx) { + ctx.user = { _id: '5c8791477192a80b5e76ca7e', - email: (this.email = 'USER@overleaf.com'), + email: (ctx.email = 'USER@overleaf.com'), } - this.db.users.updateOne = sinon - this.User.findOne = sinon + ctx.db.users.updateOne = sinon + ctx.User.findOne = sinon .stub() - .returns({ exec: sinon.stub().resolves(this.user) }) - this.bcrypt.compare = sinon.stub().resolves(false) - this.db.users.updateOne = sinon.stub().resolves({ modifiedCount: 1 }) + .returns({ exec: sinon.stub().resolves(ctx.user) }) + ctx.bcrypt.compare = sinon.stub().resolves(false) + ctx.db.users.updateOne = sinon.stub().resolves({ modifiedCount: 1 }) }) - it('should not produce an error', async function () { + it('should not produce an error', async function (ctx) { const updated = - await this.AuthenticationManager.promises.setUserPasswordInV2( - this.user, + await ctx.AuthenticationManager.promises.setUserPasswordInV2( + ctx.user, 'testpassword' ) expect(updated).to.equal(true) }) - it('should set the hashed password', async function () { - await this.AuthenticationManager.promises.setUserPasswordInV2( - this.user, + it('should set the hashed password', async function (ctx) { + await ctx.AuthenticationManager.promises.setUserPasswordInV2( + ctx.user, 'testpassword' ) - const { hashedPassword } = this.db.users.updateOne.lastCall.args[1].$set + const { hashedPassword } = ctx.db.users.updateOne.lastCall.args[1].$set expect(hashedPassword).to.exist expect(hashedPassword.length).to.equal(60) expect(hashedPassword).to.match(/^\$2a\$04\$[a-zA-Z0-9/.]{53}$/) @@ -225,101 +255,101 @@ describe('AuthenticationManager', function () { }) describe('hashPassword', function () { - it('should block too long passwords', async function () { + it('should block too long passwords', async function (ctx) { await expect( - this.AuthenticationManager.promises.hashPassword('x'.repeat(100)) + ctx.AuthenticationManager.promises.hashPassword('x'.repeat(100)) ).to.be.rejectedWith('password is too long') }) }) describe('authenticate', function () { describe('when the user exists in the database', function () { - beforeEach(function () { - this.user = { + beforeEach(function (ctx) { + ctx.user = { _id: 'user-id', - email: (this.email = 'USER@overleaf.com'), + email: (ctx.email = 'USER@overleaf.com'), } - this.unencryptedPassword = 'banana' - this.User.findOne = sinon + ctx.unencryptedPassword = 'banana' + ctx.User.findOne = sinon .stub() - .returns({ exec: sinon.stub().resolves(this.user) }) - this.metrics.inc.reset() + .returns({ exec: sinon.stub().resolves(ctx.user) }) + ctx.metrics.inc.reset() }) describe('when the hashed password matches', function () { - beforeEach(async function () { - this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf' - this.bcrypt.compare = sinon.stub().resolves(true) - this.bcrypt.getRounds = sinon.stub().returns(4) - ;({ user: this.result } = - await this.AuthenticationManager.promises.authenticate( - { email: this.email }, - this.unencryptedPassword, + beforeEach(async function (ctx) { + ctx.user.hashedPassword = ctx.hashedPassword = 'asdfjadflasdf' + ctx.bcrypt.compare = sinon.stub().resolves(true) + ctx.bcrypt.getRounds = sinon.stub().returns(4) + ;({ user: ctx.result } = + await ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, + ctx.unencryptedPassword, null, { enforceHIBPCheck: false } )) }) - it('should look up the correct user in the database', function () { - this.User.findOne.calledWith({ email: this.email }).should.equal(true) + it('should look up the correct user in the database', function (ctx) { + ctx.User.findOne.calledWith({ email: ctx.email }).should.equal(true) }) - it('should check that the passwords match', function () { - this.bcrypt.compare - .calledWith(this.unencryptedPassword, this.hashedPassword) + it('should check that the passwords match', function (ctx) { + ctx.bcrypt.compare + .calledWith(ctx.unencryptedPassword, ctx.hashedPassword) .should.equal(true) }) - it('should send metrics', function () { + it('should send metrics', function (ctx) { expect( - this.metrics.inc.calledWith('check-password', { + ctx.metrics.inc.calledWith('check-password', { status: 'too_short', }) ).to.equal(true) }) - it('should return the user', function () { - this.result.should.equal(this.user) + it('should return the user', function (ctx) { + ctx.result.should.equal(ctx.user) }) describe('HIBP', function () { - it('should enforce HIBP if requested', async function () { - this.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(true) + it('should enforce HIBP if requested', async function (ctx) { + ctx.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(true) await expect( - this.AuthenticationManager.promises.authenticate( - { email: this.email }, - this.unencryptedPassword, + ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, + ctx.unencryptedPassword, null, { enforceHIBPCheck: true } ) ).to.be.rejectedWith(AuthenticationErrors.PasswordReusedError) }) - it('should check but not enforce HIBP if not requested', async function () { - this.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(true) + it('should check but not enforce HIBP if not requested', async function (ctx) { + ctx.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(true) const { user } = - await this.AuthenticationManager.promises.authenticate( - { email: this.email }, - this.unencryptedPassword, + await ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, + ctx.unencryptedPassword, null, { enforceHIBPCheck: false } ) - this.HaveIBeenPwned.promises.checkPasswordForReuse.should.have.been.calledWith( - this.unencryptedPassword + ctx.HaveIBeenPwned.promises.checkPasswordForReuse.should.have.been.calledWith( + ctx.unencryptedPassword ) - expect(user).to.equal(this.user) + expect(user).to.equal(ctx.user) }) - it('should report password reused when check not enforced', async function () { - this.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(true) + it('should report password reused when check not enforced', async function (ctx) { + ctx.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(true) const { isPasswordReused } = - await this.AuthenticationManager.promises.authenticate( - { email: this.email }, - this.unencryptedPassword, + await ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, + ctx.unencryptedPassword, null, { enforceHIBPCheck: false } ) @@ -327,13 +357,13 @@ describe('AuthenticationManager', function () { expect(isPasswordReused).to.equal(true) }) - it('should report password not reused when check not enforced', async function () { - this.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(false) + it('should report password not reused when check not enforced', async function (ctx) { + ctx.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(false) const { isPasswordReused } = - await this.AuthenticationManager.promises.authenticate( - { email: this.email }, - this.unencryptedPassword, + await ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, + ctx.unencryptedPassword, null, { enforceHIBPCheck: false } ) @@ -344,175 +374,175 @@ describe('AuthenticationManager', function () { }) describe('when the encrypted passwords do not match', function () { - beforeEach(async function () { - this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf' - this.bcrypt.compare = sinon.stub().resolves(false) - ;({ user: this.result } = - await this.AuthenticationManager.promises.authenticate( - { email: this.email }, - this.unencryptedPassword, + beforeEach(async function (ctx) { + ctx.user.hashedPassword = ctx.hashedPassword = 'asdfjadflasdf' + ctx.bcrypt.compare = sinon.stub().resolves(false) + ;({ user: ctx.result } = + await ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, + ctx.unencryptedPassword, null, { enforceHIBPCheck: false } )) }) - it('should not return the user', function () { - expect(this.result).to.equal(null) - this.UserAuditLogHandler.promises.addEntry.callCount.should.equal(0) + it('should not return the user', function (ctx) { + expect(ctx.result).to.equal(null) + ctx.UserAuditLogHandler.promises.addEntry.callCount.should.equal(0) }) }) describe('when the encrypted passwords do not match, with auditLog', function () { - beforeEach(async function () { - this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf' - this.bcrypt.compare = sinon.stub().resolves(false) - this.auditLog = { ipAddress: 'ip', info: { method: 'foo' } } - ;({ user: this.result } = - await this.AuthenticationManager.promises.authenticate( - { email: this.email }, - this.unencryptedPassword, - this.auditLog, + beforeEach(async function (ctx) { + ctx.user.hashedPassword = ctx.hashedPassword = 'asdfjadflasdf' + ctx.bcrypt.compare = sinon.stub().resolves(false) + ctx.auditLog = { ipAddress: 'ip', info: { method: 'foo' } } + ;({ user: ctx.result } = + await ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, + ctx.unencryptedPassword, + ctx.auditLog, { enforceHIBPCheck: false } )) }) - it('should not return the user, but add entry to audit log', function () { - expect(this.result).to.equal(null) - this.UserAuditLogHandler.promises.addEntry.callCount.should.equal(1) - this.UserAuditLogHandler.promises.addEntry + it('should not return the user, but add entry to audit log', function (ctx) { + expect(ctx.result).to.equal(null) + ctx.UserAuditLogHandler.promises.addEntry.callCount.should.equal(1) + ctx.UserAuditLogHandler.promises.addEntry .calledWith( - this.user._id, + ctx.user._id, 'failed-password-match', - this.user._id, - this.auditLog.ipAddress, - this.auditLog.info + ctx.user._id, + ctx.auditLog.ipAddress, + ctx.auditLog.info ) .should.equal(true) }) }) describe('when the hashed password matches but the number of rounds is too low', function () { - beforeEach(async function () { - this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf' - this.bcrypt.compare = sinon.stub().resolves(true) - this.bcrypt.getRounds = sinon.stub().returns(1) - this.AuthenticationManager.promises._setUserPasswordInMongo = sinon + beforeEach(async function (ctx) { + ctx.user.hashedPassword = ctx.hashedPassword = 'asdfjadflasdf' + ctx.bcrypt.compare = sinon.stub().resolves(true) + ctx.bcrypt.getRounds = sinon.stub().returns(1) + ctx.AuthenticationManager.promises._setUserPasswordInMongo = sinon .stub() .resolves() - ;({ user: this.result } = - await this.AuthenticationManager.promises.authenticate( - { email: this.email }, - this.unencryptedPassword, + ;({ user: ctx.result } = + await ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, + ctx.unencryptedPassword, null, { enforceHIBPCheck: false } )) }) - it('should look up the correct user in the database', function () { - this.User.findOne.calledWith({ email: this.email }).should.equal(true) + it('should look up the correct user in the database', function (ctx) { + ctx.User.findOne.calledWith({ email: ctx.email }).should.equal(true) }) - it('should check that the passwords match', function () { - this.bcrypt.compare - .calledWith(this.unencryptedPassword, this.hashedPassword) + it('should check that the passwords match', function (ctx) { + ctx.bcrypt.compare + .calledWith(ctx.unencryptedPassword, ctx.hashedPassword) .should.equal(true) }) - it('should check the number of rounds', function () { - expect(this.metrics.inc).to.have.been.calledWith( + it('should check the number of rounds', function (ctx) { + expect(ctx.metrics.inc).to.have.been.calledWith( 'bcrypt_check_rounds', 1, { status: 'upgrade' } ) }) - it('should set the users password (with a higher number of rounds)', function () { - this.AuthenticationManager.promises._setUserPasswordInMongo - .calledWith(this.user, this.unencryptedPassword) + it('should set the users password (with a higher number of rounds)', function (ctx) { + ctx.AuthenticationManager.promises._setUserPasswordInMongo + .calledWith(ctx.user, ctx.unencryptedPassword) .should.equal(true) }) - it('should return the user', function () { - this.result.should.equal(this.user) + it('should return the user', function (ctx) { + ctx.result.should.equal(ctx.user) }) }) describe('when the hashed password matches but the number of rounds is too low, but upgrades disabled', function () { - beforeEach(async function () { - this.settings.security.disableBcryptRoundsUpgrades = true - this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf' - this.bcrypt.compare = sinon.stub().resolves(true) - this.bcrypt.getRounds = sinon.stub().returns(1) - this.AuthenticationManager.promises.setUserPassword = sinon + beforeEach(async function (ctx) { + ctx.settings.security.disableBcryptRoundsUpgrades = true + ctx.user.hashedPassword = ctx.hashedPassword = 'asdfjadflasdf' + ctx.bcrypt.compare = sinon.stub().resolves(true) + ctx.bcrypt.getRounds = sinon.stub().returns(1) + ctx.AuthenticationManager.promises.setUserPassword = sinon .stub() .resolves() - ;({ user: this.result } = - await this.AuthenticationManager.promises.authenticate( - { email: this.email }, - this.unencryptedPassword, + ;({ user: ctx.result } = + await ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, + ctx.unencryptedPassword, null, { enforceHIBPCheck: false } )) }) - it('should not check the number of rounds', function () { - expect(this.metrics.inc).to.have.been.calledWith( + it('should not check the number of rounds', function (ctx) { + expect(ctx.metrics.inc).to.have.been.calledWith( 'bcrypt_check_rounds', 1, { status: 'disabled' } ) }) - it('should not set the users password (with a higher number of rounds)', function () { - this.AuthenticationManager.promises.setUserPassword - .calledWith(this.user, this.unencryptedPassword) + it('should not set the users password (with a higher number of rounds)', function (ctx) { + ctx.AuthenticationManager.promises.setUserPassword + .calledWith(ctx.user, ctx.unencryptedPassword) .should.equal(false) }) - it('should return the user', function () { - this.result.should.equal(this.user) + it('should return the user', function (ctx) { + ctx.result.should.equal(ctx.user) }) }) }) describe('when the user does not exist in the database', function () { - beforeEach(async function () { - this.User.findOne = sinon + beforeEach(async function (ctx) { + ctx.User.findOne = sinon .stub() .returns({ exec: sinon.stub().resolves(null) }) - ;({ user: this.result } = - await this.AuthenticationManager.promises.authenticate( - { email: this.email }, - this.unencrpytedPassword, + ;({ user: ctx.result } = + await ctx.AuthenticationManager.promises.authenticate( + { email: ctx.email }, + ctx.unencrpytedPassword, null, { enforceHIBPCheck: false } )) }) - it('should not return a user', function () { - expect(this.result).to.equal(null) + it('should not return a user', function (ctx) { + expect(ctx.result).to.equal(null) }) }) }) describe('validateEmail', function () { describe('valid', function () { - it('should return null', function () { + it('should return null', function (ctx) { const result = - this.AuthenticationManager.validateEmail('foo@example.com') + ctx.AuthenticationManager.validateEmail('foo@example.com') expect(result).to.equal(null) }) }) describe('invalid', function () { - it('should return validation error object for no email', function () { - const result = this.AuthenticationManager.validateEmail('') + it('should return validation error object for no email', function (ctx) { + const result = ctx.AuthenticationManager.validateEmail('') expect(result).to.an.instanceOf(AuthenticationErrors.InvalidEmailError) expect(result.message).to.equal('email not valid') }) - it('should return validation error object for invalid', function () { - const result = this.AuthenticationManager.validateEmail('notanemail') + it('should return validation error object for invalid', function (ctx) { + const result = ctx.AuthenticationManager.validateEmail('notanemail') expect(result).to.be.an.instanceOf( AuthenticationErrors.InvalidEmailError ) @@ -522,15 +552,15 @@ describe('AuthenticationManager', function () { }) describe('validatePassword', function () { - beforeEach(function () { + beforeEach(function (ctx) { // 73 characters: - this.longPassword = + ctx.longPassword = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678' }) describe('with a null password', function () { - it('should return an error', function () { - const result = this.AuthenticationManager.validatePassword() + it('should return an error', function (ctx) { + const result = ctx.AuthenticationManager.validatePassword() expect(result).to.be.an.instanceOf( AuthenticationErrors.InvalidPasswordError @@ -542,26 +572,26 @@ describe('AuthenticationManager', function () { describe('password length', function () { describe('with the default password length options', function () { - beforeEach(function () { - this.metrics.inc.reset() + beforeEach(function (ctx) { + ctx.metrics.inc.reset() }) - it('should send a metric', function () { - this.AuthenticationManager.validatePassword('foo') - expect(this.metrics.inc.calledWith('try-validate-password')).to.equal( + it('should send a metric', function (ctx) { + ctx.AuthenticationManager.validatePassword('foo') + expect(ctx.metrics.inc.calledWith('try-validate-password')).to.equal( true ) }) - it('should reject passwords that are too short', function () { - const result1 = this.AuthenticationManager.validatePassword('') + it('should reject passwords that are too short', function (ctx) { + const result1 = ctx.AuthenticationManager.validatePassword('') expect(result1).to.be.an.instanceOf( AuthenticationErrors.InvalidPasswordError ) expect(result1.message).to.equal('password is too short') expect(result1.info.code).to.equal('too_short') - const result2 = this.AuthenticationManager.validatePassword('foo') + const result2 = ctx.AuthenticationManager.validatePassword('foo') expect(result2).to.be.an.instanceOf( AuthenticationErrors.InvalidPasswordError ) @@ -569,9 +599,9 @@ describe('AuthenticationManager', function () { expect(result2.info.code).to.equal('too_short') }) - it('should reject passwords that are too long', function () { - const result = this.AuthenticationManager.validatePassword( - this.longPassword + it('should reject passwords that are too long', function (ctx) { + const result = ctx.AuthenticationManager.validatePassword( + ctx.longPassword ) expect(result).to.be.an.instanceOf( @@ -581,16 +611,16 @@ describe('AuthenticationManager', function () { expect(result.info.code).to.equal('too_long') }) - it('should accept passwords that are a good length', function () { + it('should accept passwords that are a good length', function (ctx) { expect( - this.AuthenticationManager.validatePassword('l337h4x0r') + ctx.AuthenticationManager.validatePassword('l337h4x0r') ).to.equal(null) }) }) describe('when the password length is specified in settings', function () { - beforeEach(function () { - this.settings.passwordStrengthOptions = { + beforeEach(function (ctx) { + ctx.settings.passwordStrengthOptions = { length: { min: 10, max: 12, @@ -598,9 +628,8 @@ describe('AuthenticationManager', function () { } }) - it('should reject passwords that are too short', function () { - const result = - this.AuthenticationManager.validatePassword('012345678') + it('should reject passwords that are too short', function (ctx) { + const result = ctx.AuthenticationManager.validatePassword('012345678') expect(result).to.be.an.instanceOf( AuthenticationErrors.InvalidPasswordError @@ -609,15 +638,15 @@ describe('AuthenticationManager', function () { expect(result.info.code).to.equal('too_short') }) - it('should accept passwords of exactly minimum length', function () { + it('should accept passwords of exactly minimum length', function (ctx) { expect( - this.AuthenticationManager.validatePassword('0123456789') + ctx.AuthenticationManager.validatePassword('0123456789') ).to.equal(null) }) - it('should reject passwords that are too long', function () { + it('should reject passwords that are too long', function (ctx) { const result = - this.AuthenticationManager.validatePassword('0123456789abc') + ctx.AuthenticationManager.validatePassword('0123456789abc') expect(result).to.be.an.instanceOf( AuthenticationErrors.InvalidPasswordError @@ -626,25 +655,25 @@ describe('AuthenticationManager', function () { expect(result.info.code).to.equal('too_long') }) - it('should accept passwords of exactly maximum length', function () { + it('should accept passwords of exactly maximum length', function (ctx) { expect( - this.AuthenticationManager.validatePassword('0123456789ab') + ctx.AuthenticationManager.validatePassword('0123456789ab') ).to.equal(null) }) }) describe('when the maximum password length is set to >72 characters in settings', function () { - beforeEach(function () { - this.settings.passwordStrengthOptions = { + beforeEach(function (ctx) { + ctx.settings.passwordStrengthOptions = { length: { max: 128, }, } }) - it('should still reject passwords > 72 characters in length', function () { - const result = this.AuthenticationManager.validatePassword( - this.longPassword + it('should still reject passwords > 72 characters in length', function (ctx) { + const result = ctx.AuthenticationManager.validatePassword( + ctx.longPassword ) expect(result).to.be.an.instanceOf( @@ -658,21 +687,21 @@ describe('AuthenticationManager', function () { describe('allowed characters', function () { describe('with the default settings for allowed characters', function () { - it('should allow passwords with valid characters', function () { + it('should allow passwords with valid characters', function (ctx) { expect( - this.AuthenticationManager.validatePassword( + ctx.AuthenticationManager.validatePassword( 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' ) ).to.equal(null) expect( - this.AuthenticationManager.validatePassword( + ctx.AuthenticationManager.validatePassword( '1234567890@#$%^&*()-_=+[]{};:<>/?!£€.,' ) ).to.equal(null) }) - it('should not allow passwords with invalid characters', function () { - const result = this.AuthenticationManager.validatePassword( + it('should not allow passwords with invalid characters', function (ctx) { + const result = ctx.AuthenticationManager.validatePassword( 'correct horse battery staple' ) @@ -687,24 +716,24 @@ describe('AuthenticationManager', function () { }) describe('when valid characters are overridden in settings', function () { - beforeEach(function () { - this.settings.passwordStrengthOptions = { + beforeEach(function (ctx) { + ctx.settings.passwordStrengthOptions = { chars: { symbols: ' ', }, } }) - it('should allow passwords with valid characters', function () { + it('should allow passwords with valid characters', function (ctx) { expect( - this.AuthenticationManager.validatePassword( + ctx.AuthenticationManager.validatePassword( 'correct horse battery staple' ) ).to.equal(null) }) - it('should disallow passwords with invalid characters', function () { - const result = this.AuthenticationManager.validatePassword( + it('should disallow passwords with invalid characters', function (ctx) { + const result = ctx.AuthenticationManager.validatePassword( '1234567890@#$%^&*()-_=+[]{};:<>/?!£€.,' ) @@ -719,20 +748,20 @@ describe('AuthenticationManager', function () { }) describe('when allowAnyChars is set', function () { - beforeEach(function () { - this.settings.passwordStrengthOptions = { + beforeEach(function (ctx) { + ctx.settings.passwordStrengthOptions = { allowAnyChars: true, } }) - it('should allow any characters', function () { + it('should allow any characters', function (ctx) { expect( - this.AuthenticationManager.validatePassword( + ctx.AuthenticationManager.validatePassword( 'correct horse battery staple' ) ).to.equal(null) expect( - this.AuthenticationManager.validatePassword( + ctx.AuthenticationManager.validatePassword( '1234567890@#$%^&*()-_=+[]{};:<>/?!£€.,' ) ).to.equal(null) @@ -742,21 +771,21 @@ describe('AuthenticationManager', function () { }) describe('_validatePasswordNotTooSimilar', function () { - beforeEach(function () { - this.metrics.inc.reset() + beforeEach(function (ctx) { + ctx.metrics.inc.reset() }) - it('should return an error when the password is too similar to email', function () { + it('should return an error when the password is too similar to email', function (ctx) { const password = '12someuser34' const email = 'someuser@example.com' - const error = this.AuthenticationManager._validatePasswordNotTooSimilar( + const error = ctx.AuthenticationManager._validatePasswordNotTooSimilar( password, email ) expect(error).to.exist }) - it('should return an error when the password is re-arranged elements of the email', function () { + it('should return an error when the password is re-arranged elements of the email', function (ctx) { const badPasswords = [ 'su2oe1em3oolc', 'someone.cool', @@ -770,7 +799,7 @@ describe('AuthenticationManager', function () { ] const email = 'someone.cool@example.com' for (const password of badPasswords) { - const error = this.AuthenticationManager._validatePasswordNotTooSimilar( + const error = ctx.AuthenticationManager._validatePasswordNotTooSimilar( password, email ) @@ -778,20 +807,20 @@ describe('AuthenticationManager', function () { } }) - it('should return nothing when the password different from email', function () { + it('should return nothing when the password different from email', function (ctx) { const password = '58WyLvr' const email = 'someuser@example.com' - const error = this.AuthenticationManager._validatePasswordNotTooSimilar( + const error = ctx.AuthenticationManager._validatePasswordNotTooSimilar( password, email ) expect(error).to.not.exist }) - it('should return nothing when the password is much longer than parts of the email', function () { + it('should return nothing when the password is much longer than parts of the email', function (ctx) { const password = new Array(30).fill('a').join('') const email = 'a@cd.com' - const error = this.AuthenticationManager._validatePasswordNotTooSimilar( + const error = ctx.AuthenticationManager._validatePasswordNotTooSimilar( password, email ) @@ -800,97 +829,97 @@ describe('AuthenticationManager', function () { }) describe('setUserPassword', function () { - beforeEach(function () { - this.user_id = new ObjectId() - this.password = 'bananagram' - this.hashedPassword = 'asdkjfa;osiuvandf' - this.salt = 'saltaasdfasdfasdf' - this.user = { - _id: this.user_id, + beforeEach(function (ctx) { + ctx.user_id = new ObjectId() + ctx.password = 'bananagram' + ctx.hashedPassword = 'asdkjfa;osiuvandf' + ctx.salt = 'saltaasdfasdfasdf' + ctx.user = { + _id: ctx.user_id, email: 'user@example.com', - hashedPassword: this.hashedPassword, + hashedPassword: ctx.hashedPassword, } - this.bcrypt.compare = sinon.stub().resolves(false) - this.bcrypt.genSalt = sinon.stub().resolves(this.salt) - this.bcrypt.hash = sinon.stub().resolves(this.hashedPassword) - this.User.findOne = sinon + ctx.bcrypt.compare = sinon.stub().resolves(false) + ctx.bcrypt.genSalt = sinon.stub().resolves(ctx.salt) + ctx.bcrypt.hash = sinon.stub().resolves(ctx.hashedPassword) + ctx.User.findOne = sinon .stub() - .returns({ exec: sinon.stub().resolves(this.user) }) - this.db.users.updateOne = sinon.stub().resolves() + .returns({ exec: sinon.stub().resolves(ctx.user) }) + ctx.db.users.updateOne = sinon.stub().resolves() }) describe('same as previous password', function () { - beforeEach(function () { - this.bcrypt.compare.resolves(true) + beforeEach(function (ctx) { + ctx.bcrypt.compare.resolves(true) }) - it('should be rejected', async function () { + it('should be rejected', async function (ctx) { await expect( - this.AuthenticationManager.promises.setUserPassword( - this.user, - this.password + ctx.AuthenticationManager.promises.setUserPassword( + ctx.user, + ctx.password ) ).to.be.rejectedWith(AuthenticationErrors.PasswordMustBeDifferentError) }) }) describe('too long', function () { - beforeEach(function () { - this.settings.passwordStrengthOptions = { + beforeEach(function (ctx) { + ctx.settings.passwordStrengthOptions = { length: { max: 10, }, } - this.password = 'dsdsadsadsadsadsadkjsadjsadjsadljs' + ctx.password = 'dsdsadsadsadsadsadkjsadjsadjsadljs' }) - it('should return and error', async function () { + it('should return and error', async function (ctx) { await expect( - this.AuthenticationManager.promises.setUserPassword( - this.user, - this.password + ctx.AuthenticationManager.promises.setUserPassword( + ctx.user, + ctx.password ) ).to.be.rejectedWith('password is too long') }) - it('should not start the bcrypt process', async function () { + it('should not start the bcrypt process', async function (ctx) { await expect( - this.AuthenticationManager.promises.setUserPassword( - this.user, - this.password + ctx.AuthenticationManager.promises.setUserPassword( + ctx.user, + ctx.password ) ).to.be.rejected - this.bcrypt.genSalt.called.should.equal(false) - this.bcrypt.hash.called.should.equal(false) + ctx.bcrypt.genSalt.called.should.equal(false) + ctx.bcrypt.hash.called.should.equal(false) }) }) describe('contains full email', function () { - beforeEach(function () { - this.password = `some${this.user.email}password` + beforeEach(function (ctx) { + ctx.password = `some${ctx.user.email}password` }) - it('should reject the password', async function () { + it('should reject the password', async function (ctx) { await expect( - this.AuthenticationManager.promises.setUserPassword( - this.user, - this.password + ctx.AuthenticationManager.promises.setUserPassword( + ctx.user, + ctx.password ) ).to.be.rejectedWith(AuthenticationErrors.InvalidPasswordError) }) }) describe('contains first part of email', function () { - beforeEach(function () { - this.password = `some${this.user.email.split('@')[0]}password` + beforeEach(function (ctx) { + ctx.password = `some${ctx.user.email.split('@')[0]}password` }) - it('should reject the password', async function () { + it('should reject the password', async function (ctx) { await expect( - this.AuthenticationManager.promises.setUserPassword( - this.user, - this.password + ctx.AuthenticationManager.promises.setUserPassword( + ctx.user, + ctx.password ) ).to.be.rejectedWith(AuthenticationErrors.InvalidPasswordError) }) @@ -903,9 +932,9 @@ describe('AuthenticationManager', function () { user = { _id: 'some-user-id', email: 'someuser@somedomain.com' } }) - it('should reject the password', async function () { + it('should reject the password', async function (ctx) { try { - await this.AuthenticationManager.promises.setUserPassword( + await ctx.AuthenticationManager.promises.setUserPassword( user, password ) @@ -918,50 +947,50 @@ describe('AuthenticationManager', function () { }) describe('too short', function () { - beforeEach(function () { - this.settings.passwordStrengthOptions = { + beforeEach(function (ctx) { + ctx.settings.passwordStrengthOptions = { length: { max: 10, min: 6, }, } - this.password = 'dsd' + ctx.password = 'dsd' }) - it('should return and error', async function () { + it('should return and error', async function (ctx) { await expect( - this.AuthenticationManager.promises.setUserPassword( - this.user, - this.password + ctx.AuthenticationManager.promises.setUserPassword( + ctx.user, + ctx.password ) ).to.be.rejectedWith('password is too short') }) - it('should not start the bcrypt process', async function () { + it('should not start the bcrypt process', async function (ctx) { await expect( - this.AuthenticationManager.promises.setUserPassword( - this.user, - this.password + ctx.AuthenticationManager.promises.setUserPassword( + ctx.user, + ctx.password ) ).to.be.rejected - this.bcrypt.genSalt.called.should.equal(false) - this.bcrypt.hash.called.should.equal(false) + ctx.bcrypt.genSalt.called.should.equal(false) + ctx.bcrypt.hash.called.should.equal(false) }) }) describe('password too similar to email', function () { - beforeEach(function () { - this.user.email = 'foobarbazquux@example.com' - this.password = 'foo21barbaz' - this.metrics.inc.reset() + beforeEach(function (ctx) { + ctx.user.email = 'foobarbazquux@example.com' + ctx.password = 'foo21barbaz' + ctx.metrics.inc.reset() }) - it('should produce an error when the password is too similar to the email', async function () { + it('should produce an error when the password is too similar to the email', async function (ctx) { try { - await this.AuthenticationManager.promises.setUserPassword( - this.user, - this.password + await ctx.AuthenticationManager.promises.setUserPassword( + ctx.user, + ctx.password ) expect.fail('should have thrown') } catch (err) { @@ -972,15 +1001,15 @@ describe('AuthenticationManager', function () { } expect( - this.metrics.inc.calledWith('password-too-similar-to-email') + ctx.metrics.inc.calledWith('password-too-similar-to-email') ).to.equal(true) }) - it('should produce an error when the password is too similar to the email, regardless of case', async function () { + it('should produce an error when the password is too similar to the email, regardless of case', async function (ctx) { try { - await this.AuthenticationManager.promises.setUserPassword( - this.user, - this.password.toUpperCase() + await ctx.AuthenticationManager.promises.setUserPassword( + ctx.user, + ctx.password.toUpperCase() ) expect.fail('should have thrown') } catch (err) { @@ -991,31 +1020,31 @@ describe('AuthenticationManager', function () { } expect( - this.metrics.inc.calledWith('password-too-similar-to-email') + ctx.metrics.inc.calledWith('password-too-similar-to-email') ).to.equal(true) }) }) describe('successful password set attempt', function () { - beforeEach(async function () { - this.metrics.inc.reset() - this.UserGetter.promises.getUser = sinon + beforeEach(async function (ctx) { + ctx.metrics.inc.reset() + ctx.UserGetter.promises.getUser = sinon .stub() .resolves({ overleaf: null }) - await this.AuthenticationManager.promises.setUserPassword( - this.user, - this.password + await ctx.AuthenticationManager.promises.setUserPassword( + ctx.user, + ctx.password ) }) - it("should update the user's password in the database", function () { - const { args } = this.db.users.updateOne.lastCall + it("should update the user's password in the database", function (ctx) { + const { args } = ctx.db.users.updateOne.lastCall expect(args[0]).to.deep.equal({ - _id: new ObjectId(this.user_id.toString()), + _id: new ObjectId(ctx.user_id.toString()), }) expect(args[1]).to.deep.equal({ $set: { - hashedPassword: this.hashedPassword, + hashedPassword: ctx.hashedPassword, }, $unset: { password: true, @@ -1023,14 +1052,14 @@ describe('AuthenticationManager', function () { }) }) - it('should hash the password', function () { - this.bcrypt.genSalt.calledWith(4).should.equal(true) - this.bcrypt.hash.calledWith(this.password, this.salt).should.equal(true) + it('should hash the password', function (ctx) { + ctx.bcrypt.genSalt.calledWith(4).should.equal(true) + ctx.bcrypt.hash.calledWith(ctx.password, ctx.salt).should.equal(true) }) - it('should not send a metric for password-too-similar-to-email', function () { + it('should not send a metric for password-too-similar-to-email', function (ctx) { expect( - this.metrics.inc.calledWith('password-too-similar-to-email') + ctx.metrics.inc.calledWith('password-too-similar-to-email') ).to.equal(false) }) }) diff --git a/services/web/test/unit/src/Authentication/SessionManager.test.mjs b/services/web/test/unit/src/Authentication/SessionManager.test.mjs index a64b2c44f4..fea2b07d30 100644 --- a/services/web/test/unit/src/Authentication/SessionManager.test.mjs +++ b/services/web/test/unit/src/Authentication/SessionManager.test.mjs @@ -1,26 +1,25 @@ -const sinon = require('sinon') -const { expect } = require('chai') +import { expect } from 'vitest' +import sinon from 'sinon' +import tk from 'timekeeper' +import mongodb from 'mongodb-legacy' const modulePath = - '../../../../app/src/Features/Authentication/SessionManager.js' -const SandboxedModule = require('sandboxed-module') -const tk = require('timekeeper') -const { ObjectId } = require('mongodb-legacy') + '../../../../app/src/Features/Authentication/SessionManager.mjs' + +const { ObjectId } = mongodb describe('SessionManager', function () { - beforeEach(function () { - this.UserModel = { findOne: sinon.stub() } - this.SessionManager = SandboxedModule.require(modulePath, { - requires: {}, - }) - this.user = { + beforeEach(async function (ctx) { + ctx.UserModel = { findOne: sinon.stub() } + ctx.SessionManager = (await import(modulePath)).default + ctx.user = { _id: new ObjectId(), - email: (this.email = 'USER@example.com'), + email: (ctx.email = 'USER@example.com'), first_name: 'bob', last_name: 'brown', referal_id: 1234, isAdmin: false, } - this.session = sinon.stub() + ctx.session = sinon.stub() }) afterEach(function () { @@ -28,39 +27,39 @@ describe('SessionManager', function () { }) describe('isUserLoggedIn', function () { - beforeEach(function () { - this.stub = sinon.stub(this.SessionManager, 'getLoggedInUserId') + beforeEach(function (ctx) { + ctx.stub = sinon.stub(ctx.SessionManager, 'getLoggedInUserId') }) - afterEach(function () { - this.stub.restore() + afterEach(function (ctx) { + ctx.stub.restore() }) - it('should do the right thing in all cases', function () { - this.SessionManager.getLoggedInUserId.returns('some_id') - expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(true) - this.SessionManager.getLoggedInUserId.returns(null) - expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(false) - this.SessionManager.getLoggedInUserId.returns(false) - expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(false) - this.SessionManager.getLoggedInUserId.returns(undefined) - expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(false) + it('should do the right thing in all cases', function (ctx) { + ctx.SessionManager.getLoggedInUserId.returns('some_id') + expect(ctx.SessionManager.isUserLoggedIn(ctx.session)).to.equal(true) + ctx.SessionManager.getLoggedInUserId.returns(null) + expect(ctx.SessionManager.isUserLoggedIn(ctx.session)).to.equal(false) + ctx.SessionManager.getLoggedInUserId.returns(false) + expect(ctx.SessionManager.isUserLoggedIn(ctx.session)).to.equal(false) + ctx.SessionManager.getLoggedInUserId.returns(undefined) + expect(ctx.SessionManager.isUserLoggedIn(ctx.session)).to.equal(false) }) }) describe('setInSessionUser', function () { - beforeEach(function () { - this.user = { + beforeEach(function (ctx) { + ctx.user = { _id: 'id', first_name: 'a', last_name: 'b', email: 'c', } - this.SessionManager.getSessionUser = sinon.stub().returns(this.user) + ctx.SessionManager.getSessionUser = sinon.stub().returns(ctx.user) }) - it('should update the right properties', function () { - this.SessionManager.setInSessionUser(this.session, { + it('should update the right properties', function (ctx) { + ctx.SessionManager.setInSessionUser(ctx.session, { first_name: 'new_first_name', email: 'new_email', }) @@ -70,44 +69,44 @@ describe('SessionManager', function () { last_name: 'b', email: 'new_email', } - expect(this.user).to.deep.equal(expectedUser) - expect(this.user).to.deep.equal(expectedUser) + expect(ctx.user).to.deep.equal(expectedUser) + expect(ctx.user).to.deep.equal(expectedUser) }) }) describe('getLoggedInUserId', function () { - beforeEach(function () { - this.req = { session: {} } + beforeEach(function (ctx) { + ctx.req = { session: {} } }) - it('should return the user id from the session', function () { - this.user_id = '2134' - this.session.user = { _id: this.user_id } - const result = this.SessionManager.getLoggedInUserId(this.session) - expect(result).to.equal(this.user_id) + it('should return the user id from the session', function (ctx) { + ctx.user_id = '2134' + ctx.session.user = { _id: ctx.user_id } + const result = ctx.SessionManager.getLoggedInUserId(ctx.session) + expect(result).to.equal(ctx.user_id) }) - it('should return user for passport session', function () { - this.user_id = '2134' - this.session = { + it('should return user for passport session', function (ctx) { + ctx.user_id = '2134' + ctx.session = { passport: { user: { - _id: this.user_id, + _id: ctx.user_id, }, }, } - const result = this.SessionManager.getLoggedInUserId(this.session) - expect(result).to.equal(this.user_id) + const result = ctx.SessionManager.getLoggedInUserId(ctx.session) + expect(result).to.equal(ctx.user_id) }) - it('should return null if there is no user on the session', function () { - this.session = {} - const result = this.SessionManager.getLoggedInUserId(this.session) + it('should return null if there is no user on the session', function (ctx) { + ctx.session = {} + const result = ctx.SessionManager.getLoggedInUserId(ctx.session) expect(result).to.equal(null) }) - it('should return null if there is no session', function () { - const result = this.SessionManager.getLoggedInUserId(undefined) + it('should return null if there is no session', function (ctx) { + const result = ctx.SessionManager.getLoggedInUserId(undefined) expect(result).to.equal(null) }) }) diff --git a/services/web/test/unit/src/Chat/ChatApiHandler.test.mjs b/services/web/test/unit/src/Chat/ChatApiHandler.test.mjs index 561e6f27bb..a204c64dd7 100644 --- a/services/web/test/unit/src/Chat/ChatApiHandler.test.mjs +++ b/services/web/test/unit/src/Chat/ChatApiHandler.test.mjs @@ -1,179 +1,182 @@ -const SandboxedModule = require('sandboxed-module') -const path = require('path') -const sinon = require('sinon') -const { expect } = require('chai') -const { RequestFailedError } = require('@overleaf/fetch-utils') +import { vi, expect } from 'vitest' +import path from 'path' +import sinon from 'sinon' +import { RequestFailedError } from '@overleaf/fetch-utils' const MODULE_PATH = path.join( - __dirname, + import.meta.dirname, '../../../../app/src/Features/Chat/ChatApiHandler' ) describe('ChatApiHandler', function () { - beforeEach(function () { - this.settings = { + beforeEach(async function (ctx) { + ctx.settings = { apis: { chat: { internal_url: 'http://chat.overleaf.env', }, }, } - this.FetchUtils = { + ctx.FetchUtils = { fetchJson: sinon.stub(), fetchNothing: sinon.stub().resolves(), } - this.ChatApiHandler = SandboxedModule.require(MODULE_PATH, { - requires: { - '@overleaf/settings': this.settings, - '@overleaf/fetch-utils': this.FetchUtils, - }, - }) - this.project_id = '3213213kl12j' - this.user_id = '2k3jlkjs9' - this.content = 'my message here' + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('@overleaf/fetch-utils', () => ctx.FetchUtils) + + ctx.ChatApiHandler = (await import(MODULE_PATH)).default + ctx.project_id = '3213213kl12j' + ctx.user_id = '2k3jlkjs9' + ctx.content = 'my message here' }) describe('sendGlobalMessage', function () { describe('successfully', function () { - beforeEach(async function () { - this.message = { mock: 'message' } - this.FetchUtils.fetchJson.resolves(this.message) - this.result = await this.ChatApiHandler.promises.sendGlobalMessage( - this.project_id, - this.user_id, - this.content + beforeEach(async function (ctx) { + ctx.message = { mock: 'message' } + ctx.FetchUtils.fetchJson.resolves(ctx.message) + ctx.result = await ctx.ChatApiHandler.promises.sendGlobalMessage( + ctx.project_id, + ctx.user_id, + ctx.content ) }) - it('should post the data to the chat api', function () { - this.FetchUtils.fetchJson.should.have.been.calledWith( + it('should post the data to the chat api', function (ctx) { + ctx.FetchUtils.fetchJson.should.have.been.calledWith( sinon.match( url => url.toString() === - `${this.settings.apis.chat.internal_url}/project/${this.project_id}/messages` + `${ctx.settings.apis.chat.internal_url}/project/${ctx.project_id}/messages` ), { method: 'POST', json: { - content: this.content, - user_id: this.user_id, + content: ctx.content, + user_id: ctx.user_id, }, } ) }) - it('should return the message from the post', function () { - expect(this.result).to.deep.equal(this.message) + it('should return the message from the post', function (ctx) { + expect(ctx.result).to.deep.equal(ctx.message) }) }) describe('with a non-success status code', function () { - beforeEach(async function () { - this.error = new RequestFailedError('some-url', {}, { status: 500 }) - this.FetchUtils.fetchJson.rejects(this.error) + beforeEach(async function (ctx) { + ctx.error = new RequestFailedError('some-url', {}, { status: 500 }) + ctx.FetchUtils.fetchJson.rejects(ctx.error) + }) + + it('should throw the error', async function (ctx) { await expect( - this.ChatApiHandler.promises.sendGlobalMessage( - this.project_id, - this.user_id, - this.content + ctx.ChatApiHandler.promises.sendGlobalMessage( + ctx.project_id, + ctx.user_id, + ctx.content ) - ).to.be.rejectedWith(this.error) + ).to.be.rejectedWith(ctx.error) }) }) }) describe('getGlobalMessages', function () { - beforeEach(function () { - this.messages = [{ mock: 'message' }] - this.limit = 30 - this.before = '1234' + beforeEach(function (ctx) { + ctx.messages = [{ mock: 'message' }] + ctx.limit = 30 + ctx.before = '1234' }) describe('successfully', function () { - beforeEach(async function () { - this.FetchUtils.fetchJson.resolves(this.messages) - this.result = await this.ChatApiHandler.promises.getGlobalMessages( - this.project_id, - this.limit, - this.before + beforeEach(async function (ctx) { + ctx.FetchUtils.fetchJson.resolves(ctx.messages) + ctx.result = await ctx.ChatApiHandler.promises.getGlobalMessages( + ctx.project_id, + ctx.limit, + ctx.before ) }) - it('should make get request for room to chat api', function () { - this.FetchUtils.fetchJson.should.have.been.calledWith( + it('should make get request for room to chat api', function (ctx) { + ctx.FetchUtils.fetchJson.should.have.been.calledWith( sinon.match( url => url.toString() === - `${this.settings.apis.chat.internal_url}/project/${this.project_id}/messages?limit=${this.limit}&before=${this.before}` + `${ctx.settings.apis.chat.internal_url}/project/${ctx.project_id}/messages?limit=${ctx.limit}&before=${ctx.before}` ) ) }) - it('should return the messages from the request', function () { - expect(this.result).to.deep.equal(this.messages) + it('should return the messages from the request', function (ctx) { + expect(ctx.result).to.deep.equal(ctx.messages) }) }) describe('with failure error code', function () { - beforeEach(async function () { - this.error = new RequestFailedError('some-url', {}, { status: 500 }) - this.FetchUtils.fetchJson.rejects(this.error) + beforeEach(function (ctx) { + ctx.error = new RequestFailedError('some-url', {}, { status: 500 }) + ctx.FetchUtils.fetchJson.rejects(ctx.error) + }) + + it('should throw the error', async function (ctx) { await expect( - this.ChatApiHandler.getGlobalMessages( - this.project_id, - this.limit, - this.before + ctx.ChatApiHandler.promises.getGlobalMessages( + ctx.project_id, + ctx.limit, + ctx.before ) - ).to.be.rejectedWith(this.error) + ).to.be.rejectedWith(ctx.error) }) }) }) describe('duplicateCommentThreads', function () { - beforeEach(async function () { - this.FetchUtils.fetchJson.resolves( - (this.mapping = { + beforeEach(async function (ctx) { + ctx.FetchUtils.fetchJson.resolves( + (ctx.mapping = { 'comment-thread-1': 'comment-thread-1-dup', 'comment-thread-2': 'comment-thread-2-dup', 'comment-thread-3': 'comment-thread-3-dup', }) ) - this.threads = [ - 'comment-thread-1', - 'comment-thread-2', - 'comment-thread-3', - ] - this.result = await this.ChatApiHandler.promises.duplicateCommentThreads( - this.project_id, - this.threads + ctx.threads = ['comment-thread-1', 'comment-thread-2', 'comment-thread-3'] + ctx.result = await ctx.ChatApiHandler.promises.duplicateCommentThreads( + ctx.project_id, + ctx.threads ) }) - it('should make a post request to the chat api', function () { - expect(this.FetchUtils.fetchJson).to.have.been.calledWith( + it('should make a post request to the chat api', function (ctx) { + expect(ctx.FetchUtils.fetchJson).to.have.been.calledWith( sinon.match( url => url.toString() === - `${this.settings.apis.chat.internal_url}/project/${this.project_id}/duplicate-comment-threads` + `${ctx.settings.apis.chat.internal_url}/project/${ctx.project_id}/duplicate-comment-threads` ), { method: 'POST', json: { - threads: this.threads, + threads: ctx.threads, }, } ) }) - it('should return the thread mapping', function () { - expect(this.result).to.deep.equal(this.mapping) + it('should return the thread mapping', function (ctx) { + expect(ctx.result).to.deep.equal(ctx.mapping) }) }) describe('generateThreadData', async function () { - beforeEach(async function () { - this.FetchUtils.fetchJson.resolves( - (this.chatResponse = { + beforeEach(async function (ctx) { + ctx.FetchUtils.fetchJson.resolves( + (ctx.chatResponse = { 'comment-thread-1': { messages: [ { @@ -196,35 +199,31 @@ describe('ChatApiHandler', function () { ) // Chat won't return threads that couldn't be found, so response can have // fewer threads - this.threads = [ - 'comment-thread-1', - 'comment-thread-2', - 'comment-thread-3', - ] - this.result = await this.ChatApiHandler.promises.generateThreadData( - this.project_id, - this.threads + ctx.threads = ['comment-thread-1', 'comment-thread-2', 'comment-thread-3'] + ctx.result = await ctx.ChatApiHandler.promises.generateThreadData( + ctx.project_id, + ctx.threads ) }) - it('should make a post request to the chat api', function () { - expect(this.FetchUtils.fetchJson).to.have.been.calledWith( + it('should make a post request to the chat api', function (ctx) { + expect(ctx.FetchUtils.fetchJson).to.have.been.calledWith( sinon.match( url => url.toString() === - `${this.settings.apis.chat.internal_url}/project/${this.project_id}/generate-thread-data` + `${ctx.settings.apis.chat.internal_url}/project/${ctx.project_id}/generate-thread-data` ), { method: 'POST', json: { - threads: this.threads, + threads: ctx.threads, }, } ) }) - it('should return the thread data', function () { - expect(this.result).to.deep.equal(this.chatResponse) + it('should return the thread data', function (ctx) { + expect(ctx.result).to.deep.equal(ctx.chatResponse) }) }) }) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs index b190713f95..742516757a 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs @@ -1,15 +1,16 @@ -const sinon = require('sinon') -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const { ObjectId } = require('mongodb-legacy') -const Crypto = require('crypto') +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' +import Crypto from 'crypto' + +const { ObjectId } = mongodb const MODULE_PATH = - '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.js' + '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.mjs' describe('CollaboratorsInviteGetter', function () { - beforeEach(function () { - this.ProjectInvite = class ProjectInvite { + beforeEach(async function (ctx) { + ctx.ProjectInvite = class ProjectInvite { constructor(options) { if (options == null) { options = {} @@ -21,95 +22,101 @@ describe('CollaboratorsInviteGetter', function () { } } } - this.ProjectInvite.prototype.save = sinon.stub() - this.ProjectInvite.findOne = sinon.stub() - this.ProjectInvite.find = sinon.stub() - this.ProjectInvite.deleteOne = sinon.stub() - this.ProjectInvite.findOneAndDelete = sinon.stub() - this.ProjectInvite.countDocuments = sinon.stub() + ctx.ProjectInvite.prototype.save = sinon.stub() + ctx.ProjectInvite.findOne = sinon.stub() + ctx.ProjectInvite.find = sinon.stub() + ctx.ProjectInvite.deleteOne = sinon.stub() + ctx.ProjectInvite.findOneAndDelete = sinon.stub() + ctx.ProjectInvite.countDocuments = sinon.stub() - this.Crypto = { + ctx.Crypto = { randomBytes: sinon.stub().callsFake(Crypto.randomBytes), } - this.CollaboratorsInviteHelper = { - generateToken: sinon.stub().returns(this.Crypto.randomBytes(24)), - hashInviteToken: sinon.stub().returns(this.tokenHmac), + ctx.CollaboratorsInviteHelper = { + generateToken: sinon.stub().returns(ctx.Crypto.randomBytes(24)), + hashInviteToken: sinon.stub().returns(ctx.tokenHmac), } - this.CollaboratorsInviteGetter = SandboxedModule.require(MODULE_PATH, { - requires: { - '../../models/ProjectInvite': { ProjectInvite: this.ProjectInvite }, - './CollaboratorsInviteHelper': this.CollaboratorsInviteHelper, - }, - }) + vi.doMock('../../../../app/src/models/ProjectInvite', () => ({ + ProjectInvite: ctx.ProjectInvite, + })) - this.projectId = new ObjectId() - this.sendingUserId = new ObjectId() - this.email = 'user@example.com' - this.userId = new ObjectId() - this.inviteId = new ObjectId() - this.token = 'hnhteaosuhtaeosuahs' - this.privileges = 'readAndWrite' - this.fakeInvite = { - _id: this.inviteId, - email: this.email, - token: this.token, - tokenHmac: this.tokenHmac, - sendingUserId: this.sendingUserId, - projectId: this.projectId, - privileges: this.privileges, + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper', + () => ({ + default: ctx.CollaboratorsInviteHelper, + }) + ) + + ctx.CollaboratorsInviteGetter = (await import(MODULE_PATH)).default + + ctx.projectId = new ObjectId() + ctx.sendingUserId = new ObjectId() + ctx.email = 'user@example.com' + ctx.userId = new ObjectId() + ctx.inviteId = new ObjectId() + ctx.token = 'hnhteaosuhtaeosuahs' + ctx.privileges = 'readAndWrite' + ctx.fakeInvite = { + _id: ctx.inviteId, + email: ctx.email, + token: ctx.token, + tokenHmac: ctx.tokenHmac, + sendingUserId: ctx.sendingUserId, + projectId: ctx.projectId, + privileges: ctx.privileges, createdAt: new Date(), } }) describe('getEditInviteCount', function () { - beforeEach(function () { - this.ProjectInvite.countDocuments.returns({ + beforeEach(function (ctx) { + ctx.ProjectInvite.countDocuments.returns({ exec: sinon.stub().resolves(2), }) - this.call = async () => { - return await this.CollaboratorsInviteGetter.promises.getEditInviteCount( - this.projectId + ctx.call = async () => { + return await ctx.CollaboratorsInviteGetter.promises.getEditInviteCount( + ctx.projectId ) } }) - it('should produce the count of documents', async function () { - const count = await this.call() - expect(this.ProjectInvite.countDocuments).to.be.calledWith({ - projectId: this.projectId, + it('should produce the count of documents', async function (ctx) { + const count = await ctx.call() + expect(ctx.ProjectInvite.countDocuments).to.be.calledWith({ + projectId: ctx.projectId, privileges: { $ne: 'readOnly' }, }) expect(count).to.equal(2) }) describe('when model.countDocuments produces an error', function () { - beforeEach(function () { - this.ProjectInvite.countDocuments.returns({ + beforeEach(function (ctx) { + ctx.ProjectInvite.countDocuments.returns({ exec: sinon.stub().rejects(new Error('woops')), }) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Error) }) }) }) describe('getAllInvites', function () { - beforeEach(function () { - this.fakeInvites = [ + beforeEach(function (ctx) { + ctx.fakeInvites = [ { _id: new ObjectId(), one: 1 }, { _id: new ObjectId(), two: 2 }, ] - this.ProjectInvite.find.returns({ + ctx.ProjectInvite.find.returns({ select: sinon.stub().returnsThis(), - exec: sinon.stub().resolves(this.fakeInvites), + exec: sinon.stub().resolves(ctx.fakeInvites), }) - this.call = async () => { - return await this.CollaboratorsInviteGetter.promises.getAllInvites( - this.projectId + ctx.call = async () => { + return await ctx.CollaboratorsInviteGetter.promises.getAllInvites( + ctx.projectId ) } }) @@ -117,84 +124,84 @@ describe('CollaboratorsInviteGetter', function () { describe('when all goes well', function () { beforeEach(function () {}) - it('should produce a list of invite objects', async function () { - const invites = await this.call() + it('should produce a list of invite objects', async function (ctx) { + const invites = await ctx.call() expect(invites).to.not.be.oneOf([null, undefined]) - expect(invites).to.deep.equal(this.fakeInvites) + expect(invites).to.deep.equal(ctx.fakeInvites) }) - it('should have called ProjectInvite.find', async function () { - await this.call() - this.ProjectInvite.find.callCount.should.equal(1) - this.ProjectInvite.find - .calledWith({ projectId: this.projectId }) + it('should have called ProjectInvite.find', async function (ctx) { + await ctx.call() + ctx.ProjectInvite.find.callCount.should.equal(1) + ctx.ProjectInvite.find + .calledWith({ projectId: ctx.projectId }) .should.equal(true) }) }) describe('when ProjectInvite.find produces an error', function () { - beforeEach(function () { - this.ProjectInvite.find.returns({ + beforeEach(function (ctx) { + ctx.ProjectInvite.find.returns({ select: sinon.stub().returnsThis(), exec: sinon.stub().rejects(new Error('woops')), }) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Error) }) }) }) describe('getInviteByToken', function () { - beforeEach(function () { - this.ProjectInvite.findOne.returns({ - exec: sinon.stub().resolves(this.fakeInvite), + beforeEach(function (ctx) { + ctx.ProjectInvite.findOne.returns({ + exec: sinon.stub().resolves(ctx.fakeInvite), }) - this.call = async () => { - return await this.CollaboratorsInviteGetter.promises.getInviteByToken( - this.projectId, - this.token + ctx.call = async () => { + return await ctx.CollaboratorsInviteGetter.promises.getInviteByToken( + ctx.projectId, + ctx.token ) } }) describe('when all goes well', function () { - it('should produce the invite object', async function () { - const invite = await this.call() - expect(invite).to.deep.equal(this.fakeInvite) + it('should produce the invite object', async function (ctx) { + const invite = await ctx.call() + expect(invite).to.deep.equal(ctx.fakeInvite) }) - it('should call ProjectInvite.findOne', async function () { - await this.call() - this.ProjectInvite.findOne.callCount.should.equal(1) - this.ProjectInvite.findOne - .calledWith({ projectId: this.projectId, tokenHmac: this.tokenHmac }) + it('should call ProjectInvite.findOne', async function (ctx) { + await ctx.call() + ctx.ProjectInvite.findOne.callCount.should.equal(1) + ctx.ProjectInvite.findOne + .calledWith({ projectId: ctx.projectId, tokenHmac: ctx.tokenHmac }) .should.equal(true) }) }) describe('when findOne produces an error', function () { - beforeEach(function () { - this.ProjectInvite.findOne.returns({ + beforeEach(function (ctx) { + ctx.ProjectInvite.findOne.returns({ exec: sinon.stub().rejects(new Error('woops')), }) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Error) }) }) describe('when findOne does not find an invite', function () { - beforeEach(function () { - this.ProjectInvite.findOne.returns({ + beforeEach(function (ctx) { + ctx.ProjectInvite.findOne.returns({ exec: sinon.stub().resolves(null), }) }) - it('should not produce an invite object', async function () { - const invite = await this.call() + it('should not produce an invite object', async function (ctx) { + const invite = await ctx.call() expect(invite).to.be.oneOf([null, undefined]) }) }) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHelper.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHelper.test.mjs index fdd45a073e..f72602c054 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHelper.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHelper.test.mjs @@ -1,13 +1,7 @@ -const sinon = require('sinon') -const { expect } = require('chai') -const path = require('path') -const CollaboratorsInviteHelper = require( - path.join( - __dirname, - '/../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper' - ) -) -const Crypto = require('crypto') +import sinon from 'sinon' +import { expect } from 'vitest' +import CollaboratorsInviteHelper from '../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs' +import Crypto from 'node:crypto' describe('CollaboratorsInviteHelper', function () { it('should generate a HMAC token', function () { diff --git a/services/web/test/unit/src/Contact/ContactManager.test.mjs b/services/web/test/unit/src/Contact/ContactManager.test.mjs index 5d6029c7a2..359b3bc9d8 100644 --- a/services/web/test/unit/src/Contact/ContactManager.test.mjs +++ b/services/web/test/unit/src/Contact/ContactManager.test.mjs @@ -1,69 +1,70 @@ -const { expect } = require('chai') -const sinon = require('sinon') +import { vi, expect } from 'vitest' +import sinon from 'sinon' const modulePath = '../../../../app/src/Features/Contacts/ContactManager' -const SandboxedModule = require('sandboxed-module') describe('ContactManager', function () { - beforeEach(function () { - this.user_id = 'user-id-123' - this.contact_id = 'contact-id-123' - this.contact_ids = ['mock', 'contact_ids'] - this.FetchUtils = { + beforeEach(async function (ctx) { + ctx.user_id = 'user-id-123' + ctx.contact_id = 'contact-id-123' + ctx.contact_ids = ['mock', 'contact_ids'] + ctx.FetchUtils = { fetchJson: sinon.stub(), } - this.ContactManager = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/fetch-utils': this.FetchUtils, - '@overleaf/settings': (this.settings = { - apis: { - contacts: { - url: 'http://contacts.overleaf.com', - }, + + vi.doMock('@overleaf/fetch-utils', () => ctx.FetchUtils) + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = { + apis: { + contacts: { + url: 'http://contacts.overleaf.com', }, - }), - }, - }) + }, + }), + })) + + ctx.ContactManager = (await import(modulePath)).default }) describe('getContacts', function () { describe('with a successful response code', function () { - beforeEach(async function () { - this.FetchUtils.fetchJson.resolves({ contact_ids: this.contact_ids }) + beforeEach(async function (ctx) { + ctx.FetchUtils.fetchJson.resolves({ contact_ids: ctx.contact_ids }) - this.result = await this.ContactManager.promises.getContactIds( - this.user_id, + ctx.result = await ctx.ContactManager.promises.getContactIds( + ctx.user_id, { limit: 42 } ) }) - it('should get the contacts from the contacts api', function () { - this.FetchUtils.fetchJson.should.have.been.calledWithMatch( + it('should get the contacts from the contacts api', function (ctx) { + ctx.FetchUtils.fetchJson.should.have.been.calledWithMatch( sinon.match( url => url.toString() === - `${this.settings.apis.contacts.url}/user/${this.user_id}/contacts?limit=42` + `${ctx.settings.apis.contacts.url}/user/${ctx.user_id}/contacts?limit=42` ) ) }) - it('should return the contacts', function () { - this.result.should.equal(this.contact_ids) + it('should return the contacts', function (ctx) { + ctx.result.should.equal(ctx.contact_ids) }) }) describe('when an error occurs', function () { - beforeEach(async function () { - this.response = { + beforeEach(async function (ctx) { + ctx.response = { ok: false, statusCode: 500, - json: sinon.stub().resolves({ contact_ids: this.contact_ids }), + json: sinon.stub().resolves({ contact_ids: ctx.contact_ids }), } - this.FetchUtils.fetchJson.rejects(new Error('request error')) + ctx.FetchUtils.fetchJson.rejects(new Error('request error')) }) - it('should reject the promise', async function () { + it('should reject the promise', async function (ctx) { await expect( - this.ContactManager.promises.getContactIds(this.user_id, { + ctx.ContactManager.promises.getContactIds(ctx.user_id, { limit: 42, }) ).to.be.rejected @@ -73,31 +74,31 @@ describe('ContactManager', function () { describe('addContact', function () { describe('with a successful response code', function () { - beforeEach(async function () { - this.FetchUtils.fetchJson.resolves({ contact_ids: this.contact_ids }) + beforeEach(async function (ctx) { + ctx.FetchUtils.fetchJson.resolves({ contact_ids: ctx.contact_ids }) - this.result = await this.ContactManager.promises.addContact( - this.user_id, - this.contact_id + ctx.result = await ctx.ContactManager.promises.addContact( + ctx.user_id, + ctx.contact_id ) }) - it('should add the contacts for the user in the contacts api', function () { - this.FetchUtils.fetchJson.should.have.been.calledWithMatch( + it('should add the contacts for the user in the contacts api', function (ctx) { + ctx.FetchUtils.fetchJson.should.have.been.calledWithMatch( sinon.match( url => url.toString() === - `${this.settings.apis.contacts.url}/user/${this.user_id}/contacts` + `${ctx.settings.apis.contacts.url}/user/${ctx.user_id}/contacts` ), sinon.match({ method: 'POST', - json: { contact_id: this.contact_id }, + json: { contact_id: ctx.contact_id }, }) ) }) - it('should call the callback', function () { - this.result.should.equal(this.contact_ids) + it('should call the callback', function (ctx) { + ctx.result.should.equal(ctx.contact_ids) }) }) }) diff --git a/services/web/test/unit/src/Docstore/DocstoreManager.test.mjs b/services/web/test/unit/src/Docstore/DocstoreManager.test.mjs index 2dbe6b6424..a20ac50c5a 100644 --- a/services/web/test/unit/src/Docstore/DocstoreManager.test.mjs +++ b/services/web/test/unit/src/Docstore/DocstoreManager.test.mjs @@ -1,60 +1,67 @@ -const sinon = require('sinon') +import { beforeAll, beforeEach, describe, it, vi, expect } from 'vitest' +import sinon from 'sinon' +import Errors from '../../../../app/src/Features/Errors/Errors.js' +import tk from 'timekeeper' const modulePath = '../../../../app/src/Features/Docstore/DocstoreManager' -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const Errors = require('../../../../app/src/Features/Errors/Errors') -const tk = require('timekeeper') + +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) describe('DocstoreManager', function () { - beforeEach(function () { - this.requestDefaults = sinon.stub().returns((this.request = sinon.stub())) - this.DocstoreManager = SandboxedModule.require(modulePath, { - requires: { - request: { - defaults: this.requestDefaults, - }, - '@overleaf/settings': (this.settings = { - apis: { - docstore: { - url: 'docstore.overleaf.com', - }, - }, - }), + beforeEach(async function (ctx) { + ctx.requestDefaults = sinon.stub().returns((ctx.request = sinon.stub())) + + vi.doMock('request', () => ({ + default: { + defaults: ctx.requestDefaults, }, - }) + })) - this.requestDefaults.calledWith({ jar: false }).should.equal(true) + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = { + apis: { + docstore: { + url: 'docstore.overleaf.com', + }, + }, + }), + })) - this.project_id = 'project-id-123' - this.doc_id = 'doc-id-123' + ctx.DocstoreManager = (await import(modulePath)).default + + ctx.requestDefaults.calledWith({ jar: false }).should.equal(true) + + ctx.project_id = 'project-id-123' + ctx.doc_id = 'doc-id-123' }) describe('deleteDoc', function () { describe('with a successful response code', function () { // for assertions on the deletedAt timestamp, we need to freeze the clock. - before(function () { + beforeAll(function () { tk.freeze(Date.now()) }) - after(function () { + afterAll(function () { tk.reset() }) - beforeEach(async function () { - this.request.patch = sinon + beforeEach(async function (ctx) { + ctx.request.patch = sinon .stub() .callsArgWith(1, null, { statusCode: 204 }, '') - await this.DocstoreManager.promises.deleteDoc( - this.project_id, - this.doc_id, + await ctx.DocstoreManager.promises.deleteDoc( + ctx.project_id, + ctx.doc_id, 'wombat.tex', new Date() ) }) - it('should delete the doc in the docstore api', function () { - this.request.patch + it('should delete the doc in the docstore api', function (ctx) { + ctx.request.patch .calledWith({ - url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`, + url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`, json: { deleted: true, deletedAt: new Date(), name: 'wombat.tex' }, timeout: 30 * 1000, }) @@ -63,19 +70,19 @@ describe('DocstoreManager', function () { }) describe('with a failed response code', function () { - beforeEach(function () { - this.request.patch = sinon + beforeEach(function (ctx) { + ctx.request.patch = sinon .stub() .callsArgWith(1, null, { statusCode: 500 }, '') }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.deleteDoc( - this.project_id, - this.doc_id, + await ctx.DocstoreManager.promises.deleteDoc( + ctx.project_id, + ctx.doc_id, 'main.tex', new Date() ) @@ -92,18 +99,18 @@ describe('DocstoreManager', function () { }) describe('with a missing (404) response code', function () { - beforeEach(function () { - this.request.patch = sinon + beforeEach(function (ctx) { + ctx.request.patch = sinon .stub() .callsArgWith(1, null, { statusCode: 404 }, '') }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.deleteDoc( - this.project_id, - this.doc_id, + await ctx.DocstoreManager.promises.deleteDoc( + ctx.project_id, + ctx.doc_id, 'main.tex', new Date() ) @@ -121,73 +128,73 @@ describe('DocstoreManager', function () { }) describe('updateDoc', function () { - beforeEach(function () { - this.lines = ['mock', 'doc', 'lines'] - this.rev = 5 - this.version = 42 - this.ranges = { mock: 'ranges' } - this.modified = true + beforeEach(function (ctx) { + ctx.lines = ['mock', 'doc', 'lines'] + ctx.rev = 5 + ctx.version = 42 + ctx.ranges = { mock: 'ranges' } + ctx.modified = true }) describe('with a successful response code', async function () { - beforeEach(async function () { - this.request.post = sinon + beforeEach(async function (ctx) { + ctx.request.post = sinon .stub() .callsArgWith( 1, null, { statusCode: 204 }, - { modified: this.modified, rev: this.rev } + { modified: ctx.modified, rev: ctx.rev } ) - this.updateDocResponse = await this.DocstoreManager.promises.updateDoc( - this.project_id, - this.doc_id, - this.lines, - this.version, - this.ranges + ctx.updateDocResponse = await ctx.DocstoreManager.promises.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.lines, + ctx.version, + ctx.ranges ) }) - it('should update the doc in the docstore api', function () { - this.request.post + it('should update the doc in the docstore api', function (ctx) { + ctx.request.post .calledWith({ - url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`, + url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`, timeout: 30 * 1000, json: { - lines: this.lines, - version: this.version, - ranges: this.ranges, + lines: ctx.lines, + version: ctx.version, + ranges: ctx.ranges, }, }) .should.equal(true) }) - it('should return the modified status and revision', function () { - expect(this.updateDocResponse).to.haveOwnProperty( + it('should return the modified status and revision', function (ctx) { + expect(ctx.updateDocResponse).to.haveOwnProperty( 'modified', - this.modified + ctx.modified ) - expect(this.updateDocResponse).to.haveOwnProperty('rev', this.rev) + expect(ctx.updateDocResponse).to.haveOwnProperty('rev', ctx.rev) }) }) describe('with a failed response code', function () { - beforeEach(function () { - this.request.post = sinon + beforeEach(function (ctx) { + ctx.request.post = sinon .stub() .callsArgWith(1, null, { statusCode: 500 }, '') }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.updateDoc( - this.project_id, - this.doc_id, - this.lines, - this.version, - this.ranges + await ctx.DocstoreManager.promises.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.lines, + ctx.version, + ctx.ranges ) } catch (err) { error = err @@ -203,59 +210,56 @@ describe('DocstoreManager', function () { }) describe('getDoc', function () { - beforeEach(function () { - this.doc = { - lines: (this.lines = ['mock', 'doc', 'lines']), - rev: (this.rev = 5), - version: (this.version = 42), - ranges: (this.ranges = { mock: 'ranges' }), + beforeEach(function (ctx) { + ctx.doc = { + lines: (ctx.lines = ['mock', 'doc', 'lines']), + rev: (ctx.rev = 5), + version: (ctx.version = 42), + ranges: (ctx.ranges = { mock: 'ranges' }), } }) describe('with a successful response code', function () { - beforeEach(async function () { - this.request.get = sinon + beforeEach(async function (ctx) { + ctx.request.get = sinon .stub() - .callsArgWith(1, null, { statusCode: 204 }, this.doc) - this.getDocResponse = await this.DocstoreManager.promises.getDoc( - this.project_id, - this.doc_id + .callsArgWith(1, null, { statusCode: 204 }, ctx.doc) + ctx.getDocResponse = await ctx.DocstoreManager.promises.getDoc( + ctx.project_id, + ctx.doc_id ) }) - it('should get the doc from the docstore api', function () { - this.request.get.should.have.been.calledWith({ - url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`, + it('should get the doc from the docstore api', function (ctx) { + ctx.request.get.should.have.been.calledWith({ + url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`, timeout: 30 * 1000, json: true, }) }) - it('should resolve with the lines, version and rev', function () { - expect(this.getDocResponse).to.eql({ - lines: this.lines, - rev: this.rev, - version: this.version, - ranges: this.ranges, + it('should resolve with the lines, version and rev', function (ctx) { + expect(ctx.getDocResponse).to.eql({ + lines: ctx.lines, + rev: ctx.rev, + version: ctx.version, + ranges: ctx.ranges, }) }) }) describe('with a failed response code', function () { - beforeEach(function () { - this.request.get = sinon + beforeEach(function (ctx) { + ctx.request.get = sinon .stub() .callsArgWith(1, null, { statusCode: 500 }, '') }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.getDoc( - this.project_id, - this.doc_id - ) + await ctx.DocstoreManager.promises.getDoc(ctx.project_id, ctx.doc_id) } catch (err) { error = err } @@ -269,53 +273,49 @@ describe('DocstoreManager', function () { }) describe('with include_deleted=true', function () { - beforeEach(async function () { - this.request.get = sinon + beforeEach(async function (ctx) { + ctx.request.get = sinon .stub() - .callsArgWith(1, null, { statusCode: 204 }, this.doc) - this.getDocResponse = await this.DocstoreManager.promises.getDoc( - this.project_id, - this.doc_id, + .callsArgWith(1, null, { statusCode: 204 }, ctx.doc) + ctx.getDocResponse = await ctx.DocstoreManager.promises.getDoc( + ctx.project_id, + ctx.doc_id, { include_deleted: true } ) }) - it('should get the doc from the docstore api (including deleted)', function () { - this.request.get.should.have.been.calledWith({ - url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`, + it('should get the doc from the docstore api (including deleted)', function (ctx) { + ctx.request.get.should.have.been.calledWith({ + url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`, qs: { include_deleted: 'true' }, timeout: 30 * 1000, json: true, }) }) - it('should resolve with the lines, version and rev', function () { - expect(this.getDocResponse).to.eql({ - lines: this.lines, - rev: this.rev, - version: this.version, - ranges: this.ranges, + it('should resolve with the lines, version and rev', function (ctx) { + expect(ctx.getDocResponse).to.eql({ + lines: ctx.lines, + rev: ctx.rev, + version: ctx.version, + ranges: ctx.ranges, }) }) }) describe('with peek=true', function () { - beforeEach(async function () { - this.request.get = sinon + beforeEach(async function (ctx) { + ctx.request.get = sinon .stub() - .callsArgWith(1, null, { statusCode: 204 }, this.doc) - await this.DocstoreManager.promises.getDoc( - this.project_id, - this.doc_id, - { - peek: true, - } - ) + .callsArgWith(1, null, { statusCode: 204 }, ctx.doc) + await ctx.DocstoreManager.promises.getDoc(ctx.project_id, ctx.doc_id, { + peek: true, + }) }) - it('should call the docstore peek url', function () { - this.request.get.should.have.been.calledWith({ - url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}/peek`, + it('should call the docstore peek url', function (ctx) { + ctx.request.get.should.have.been.calledWith({ + url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}/peek`, timeout: 30 * 1000, json: true, }) @@ -323,20 +323,17 @@ describe('DocstoreManager', function () { }) describe('with a missing (404) response code', function () { - beforeEach(function () { - this.request.get = sinon + beforeEach(function (ctx) { + ctx.request.get = sinon .stub() .callsArgWith(1, null, { statusCode: 404 }, '') }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.getDoc( - this.project_id, - this.doc_id - ) + await ctx.DocstoreManager.promises.getDoc(ctx.project_id, ctx.doc_id) } catch (err) { error = err } @@ -350,47 +347,47 @@ describe('DocstoreManager', function () { describe('getAllDocs', function () { describe('with a successful response code', function () { let getAllDocsResult - beforeEach(async function () { - this.request.get = sinon + beforeEach(async function (ctx) { + ctx.request.get = sinon .stub() .callsArgWith( 1, null, { statusCode: 204 }, - (this.docs = [{ _id: 'mock-doc-id' }]) + (ctx.docs = [{ _id: 'mock-doc-id' }]) ) - getAllDocsResult = await this.DocstoreManager.promises.getAllDocs( - this.project_id + getAllDocsResult = await ctx.DocstoreManager.promises.getAllDocs( + ctx.project_id ) }) - it('should get all the project docs in the docstore api', function () { - this.request.get + it('should get all the project docs in the docstore api', function (ctx) { + ctx.request.get .calledWith({ - url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc`, + url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc`, timeout: 30 * 1000, json: true, }) .should.equal(true) }) - it('should return the docs', function () { - expect(getAllDocsResult).to.eql(this.docs) + it('should return the docs', function (ctx) { + expect(getAllDocsResult).to.eql(ctx.docs) }) }) describe('with a failed response code', function () { - beforeEach(function () { - this.request.get = sinon + beforeEach(function (ctx) { + ctx.request.get = sinon .stub() .callsArgWith(1, null, { statusCode: 500 }, '') }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.getAllDocs(this.project_id) + await ctx.DocstoreManager.promises.getAllDocs(ctx.project_id) } catch (err) { error = err } @@ -407,40 +404,40 @@ describe('DocstoreManager', function () { describe('getAllDeletedDocs', function () { describe('with a successful response code', function () { let getAllDeletedDocsResponse - beforeEach(async function () { - this.docs = [{ _id: 'mock-doc-id', name: 'foo.tex' }] - this.request.get = sinon + beforeEach(async function (ctx) { + ctx.docs = [{ _id: 'mock-doc-id', name: 'foo.tex' }] + ctx.request.get = sinon .stub() - .callsArgWith(1, null, { statusCode: 200 }, this.docs) + .callsArgWith(1, null, { statusCode: 200 }, ctx.docs) getAllDeletedDocsResponse = - await this.DocstoreManager.promises.getAllDeletedDocs(this.project_id) + await ctx.DocstoreManager.promises.getAllDeletedDocs(ctx.project_id) }) - it('should get all the project docs in the docstore api', function () { - this.request.get.should.have.been.calledWith({ - url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc-deleted`, + it('should get all the project docs in the docstore api', function (ctx) { + ctx.request.get.should.have.been.calledWith({ + url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc-deleted`, timeout: 30 * 1000, json: true, }) }) - it('should resolve with the docs', function () { - expect(getAllDeletedDocsResponse).to.eql(this.docs) + it('should resolve with the docs', function (ctx) { + expect(getAllDeletedDocsResponse).to.eql(ctx.docs) }) }) describe('with an error', function () { - beforeEach(async function () { - this.request.get = sinon + beforeEach(async function (ctx) { + ctx.request.get = sinon .stub() .callsArgWith(1, new Error('connect failed')) }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.getAllDocs(this.project_id) + await ctx.DocstoreManager.promises.getAllDocs(ctx.project_id) } catch (err) { error = err } @@ -451,17 +448,17 @@ describe('DocstoreManager', function () { }) describe('with a failed response code', function () { - beforeEach(function () { - this.request.get = sinon + beforeEach(function (ctx) { + ctx.request.get = sinon .stub() .callsArgWith(1, null, { statusCode: 500 }) }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.getAllDocs(this.project_id) + await ctx.DocstoreManager.promises.getAllDocs(ctx.project_id) } catch (err) { error = err } @@ -478,47 +475,47 @@ describe('DocstoreManager', function () { describe('getAllRanges', function () { describe('with a successful response code', function () { let getAllRangesResult - beforeEach(async function () { - this.request.get = sinon + beforeEach(async function (ctx) { + ctx.request.get = sinon .stub() .callsArgWith( 1, null, { statusCode: 204 }, - (this.docs = [{ _id: 'mock-doc-id', ranges: 'mock-ranges' }]) + (ctx.docs = [{ _id: 'mock-doc-id', ranges: 'mock-ranges' }]) ) - getAllRangesResult = await this.DocstoreManager.promises.getAllRanges( - this.project_id + getAllRangesResult = await ctx.DocstoreManager.promises.getAllRanges( + ctx.project_id ) }) - it('should get all the project doc ranges in the docstore api', function () { - this.request.get + it('should get all the project doc ranges in the docstore api', function (ctx) { + ctx.request.get .calledWith({ - url: `${this.settings.apis.docstore.url}/project/${this.project_id}/ranges`, + url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/ranges`, timeout: 30 * 1000, json: true, }) .should.equal(true) }) - it('should return the docs', async function () { - expect(getAllRangesResult).to.eql(this.docs) + it('should return the docs', async function (ctx) { + expect(getAllRangesResult).to.eql(ctx.docs) }) }) describe('with a failed response code', function () { - beforeEach(function () { - this.request.get = sinon + beforeEach(function (ctx) { + ctx.request.get = sinon .stub() .callsArgWith(1, null, { statusCode: 500 }, '') }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.getAllRanges(this.project_id) + await ctx.DocstoreManager.promises.getAllRanges(ctx.project_id) } catch (err) { error = err } @@ -534,31 +531,31 @@ describe('DocstoreManager', function () { describe('archiveProject', function () { describe('with a successful response code', function () { - beforeEach(function () { - this.request.post = sinon + beforeEach(function (ctx) { + ctx.request.post = sinon .stub() .callsArgWith(1, null, { statusCode: 204 }) }) - it('should resolve', async function () { + it('should resolve', async function (ctx) { await expect( - this.DocstoreManager.promises.archiveProject(this.project_id) + ctx.DocstoreManager.promises.archiveProject(ctx.project_id) ).to.eventually.be.fulfilled }) }) describe('with a failed response code', function () { - beforeEach(function () { - this.request.post = sinon + beforeEach(function (ctx) { + ctx.request.post = sinon .stub() .callsArgWith(1, null, { statusCode: 500 }) }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.archiveProject(this.project_id) + await ctx.DocstoreManager.promises.archiveProject(ctx.project_id) } catch (err) { error = err } @@ -574,31 +571,31 @@ describe('DocstoreManager', function () { describe('unarchiveProject', function () { describe('with a successful response code', function () { - beforeEach(function () { - this.request.post = sinon + beforeEach(function (ctx) { + ctx.request.post = sinon .stub() .callsArgWith(1, null, { statusCode: 204 }) }) - it('should resolve', async function () { + it('should resolve', async function (ctx) { await expect( - this.DocstoreManager.promises.unarchiveProject(this.project_id) + ctx.DocstoreManager.promises.unarchiveProject(ctx.project_id) ).to.eventually.be.fulfilled }) }) describe('with a failed response code', function () { - beforeEach(function () { - this.request.post = sinon + beforeEach(function (ctx) { + ctx.request.post = sinon .stub() .callsArgWith(1, null, { statusCode: 500 }) }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.unarchiveProject(this.project_id) + await ctx.DocstoreManager.promises.unarchiveProject(ctx.project_id) } catch (err) { error = err } @@ -614,31 +611,31 @@ describe('DocstoreManager', function () { describe('destroyProject', function () { describe('with a successful response code', function () { - beforeEach(function () { - this.request.post = sinon + beforeEach(function (ctx) { + ctx.request.post = sinon .stub() .callsArgWith(1, null, { statusCode: 204 }) }) - it('should resolve', async function () { + it('should resolve', async function (ctx) { await expect( - this.DocstoreManager.promises.destroyProject(this.project_id) + ctx.DocstoreManager.promises.destroyProject(ctx.project_id) ).to.eventually.be.fulfilled }) }) describe('with a failed response code', function () { - beforeEach(function () { - this.request.post = sinon + beforeEach(function (ctx) { + ctx.request.post = sinon .stub() .callsArgWith(1, null, { statusCode: 500 }) }) - it('should reject with an error', async function () { + it('should reject with an error', async function (ctx) { let error try { - await this.DocstoreManager.promises.destroyProject(this.project_id) + await ctx.DocstoreManager.promises.destroyProject(ctx.project_id) } catch (err) { error = err } diff --git a/services/web/test/unit/src/Documents/DocumentHelper.test.mjs b/services/web/test/unit/src/Documents/DocumentHelper.test.mjs index 7a4017a821..4c6bd5a235 100644 --- a/services/web/test/unit/src/Documents/DocumentHelper.test.mjs +++ b/services/web/test/unit/src/Documents/DocumentHelper.test.mjs @@ -1,74 +1,58 @@ -/* eslint-disable - max-len, - no-return-assign, - no-unused-vars, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const sinon = require('sinon') -const { expect } = require('chai') -const modulePath = '../../../../app/src/Features/Documents/DocumentHelper.js' -const SandboxedModule = require('sandboxed-module') +import { expect } from 'vitest' +const modulePath = '../../../../app/src/Features/Documents/DocumentHelper.mjs' describe('DocumentHelper', function () { - beforeEach(function () { - return (this.DocumentHelper = SandboxedModule.require(modulePath)) + beforeEach(async function (ctx) { + ctx.DocumentHelper = (await import(modulePath)).default }) describe('getTitleFromTexContent', function () { - it('should return the title', function () { + it('should return the title', function (ctx) { const document = '\\begin{document}\n\\title{foo}\n\\end{document}' - return expect( - this.DocumentHelper.getTitleFromTexContent(document) - ).to.equal('foo') + expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal( + 'foo' + ) }) - it('should return the title if surrounded by space', function () { + it('should return the title if surrounded by space', function (ctx) { const document = '\\begin{document}\n \\title{foo} \n\\end{document}' - return expect( - this.DocumentHelper.getTitleFromTexContent(document) - ).to.equal('foo') + expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal( + 'foo' + ) }) - it('should return null if there is no title', function () { + it('should return null if there is no title', function (ctx) { const document = '\\begin{document}\n\\end{document}' - return expect( - this.DocumentHelper.getTitleFromTexContent(document) - ).to.eql(null) + expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.eql(null) }) - it('should accept an array', function () { + it('should accept an array', function (ctx) { const document = ['\\begin{document}', '\\title{foo}', '\\end{document}'] - return expect( - this.DocumentHelper.getTitleFromTexContent(document) - ).to.equal('foo') + expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal( + 'foo' + ) }) - it('should parse out formatting elements from the title', function () { + it('should parse out formatting elements from the title', function (ctx) { const document = '\\title{\\textbf{\\large{Second Year LaTeX Exercise}}}' - return expect( - this.DocumentHelper.getTitleFromTexContent(document) - ).to.equal('Second Year LaTeX Exercise') + expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal( + 'Second Year LaTeX Exercise' + ) }) - it('should ignore junk after the title', function () { + it('should ignore junk after the title', function (ctx) { const document = '\\title{wombat} potato' - return expect( - this.DocumentHelper.getTitleFromTexContent(document) - ).to.equal('wombat') + expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal( + 'wombat' + ) }) - it('should ignore junk before the title', function () { + it('should ignore junk before the title', function (ctx) { const document = '% this is something that v1 relied on, even though it seems odd \\title{wombat}' - return expect( - this.DocumentHelper.getTitleFromTexContent(document) - ).to.equal('wombat') + expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal( + 'wombat' + ) }) // NICETOHAVE: Current implementation doesn't do this @@ -76,87 +60,83 @@ describe('DocumentHelper', function () { // document = "\\title{Second Year \\large{LaTeX} Exercise}" // expect(@DocumentHelper.getTitleFromTexContent(document)).to.equal "Second Year LaTeX Exercise" - it('should collapse whitespace', function () { + it('should collapse whitespace', function (ctx) { const document = '\\title{Second Year LaTeX Exercise}' - return expect( - this.DocumentHelper.getTitleFromTexContent(document) - ).to.equal('Second Year LaTeX Exercise') + expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal( + 'Second Year LaTeX Exercise' + ) }) }) describe('detex', function () { // note, there are a number of tests for getTitleFromTexContent that also test cases here - it('leaves a non-TeX string unchanged', function () { - expect(this.DocumentHelper.detex('')).to.equal('') - expect(this.DocumentHelper.detex('a')).to.equal('a') - return expect(this.DocumentHelper.detex('a a')).to.equal('a a') + it('leaves a non-TeX string unchanged', function (ctx) { + expect(ctx.DocumentHelper.detex('')).to.equal('') + expect(ctx.DocumentHelper.detex('a')).to.equal('a') + expect(ctx.DocumentHelper.detex('a a')).to.equal('a a') }) - it('collapses spaces', function () { - expect(this.DocumentHelper.detex('a a')).to.equal('a a') - return expect(this.DocumentHelper.detex('a \n a')).to.equal('a \n a') + it('collapses spaces', function (ctx) { + expect(ctx.DocumentHelper.detex('a a')).to.equal('a a') + expect(ctx.DocumentHelper.detex('a \n a')).to.equal('a \n a') }) - it('replaces named commands', function () { - expect(this.DocumentHelper.detex('\\LaTeX')).to.equal('LaTeX') - expect(this.DocumentHelper.detex('\\TikZ')).to.equal('TikZ') - expect(this.DocumentHelper.detex('\\TeX')).to.equal('TeX') - return expect(this.DocumentHelper.detex('\\BibTeX')).to.equal('BibTeX') + it('replaces named commands', function (ctx) { + expect(ctx.DocumentHelper.detex('\\LaTeX')).to.equal('LaTeX') + expect(ctx.DocumentHelper.detex('\\TikZ')).to.equal('TikZ') + expect(ctx.DocumentHelper.detex('\\TeX')).to.equal('TeX') + expect(ctx.DocumentHelper.detex('\\BibTeX')).to.equal('BibTeX') }) - it('removes general commands', function () { - expect(this.DocumentHelper.detex('\\foo')).to.equal('') - expect(this.DocumentHelper.detex('\\foo{}')).to.equal('') - expect(this.DocumentHelper.detex('\\foo~Test')).to.equal('Test') - expect(this.DocumentHelper.detex('\\"e')).to.equal('e') - return expect(this.DocumentHelper.detex('\\textit{e}')).to.equal('e') + it('removes general commands', function (ctx) { + expect(ctx.DocumentHelper.detex('\\foo')).to.equal('') + expect(ctx.DocumentHelper.detex('\\foo{}')).to.equal('') + expect(ctx.DocumentHelper.detex('\\foo~Test')).to.equal('Test') + expect(ctx.DocumentHelper.detex('\\"e')).to.equal('e') + expect(ctx.DocumentHelper.detex('\\textit{e}')).to.equal('e') }) - it('leaves basic math', function () { - return expect(this.DocumentHelper.detex('$\\cal{O}(n^2)$')).to.equal( - 'O(n^2)' - ) + it('leaves basic math', function (ctx) { + expect(ctx.DocumentHelper.detex('$\\cal{O}(n^2)$')).to.equal('O(n^2)') }) - it('removes line spacing commands', function () { - return expect(this.DocumentHelper.detex('a \\\\[1.50cm] b')).to.equal( - 'a b' - ) + it('removes line spacing commands', function (ctx) { + expect(ctx.DocumentHelper.detex('a \\\\[1.50cm] b')).to.equal('a b') }) }) describe('contentHasDocumentclass', function () { - it('should return true if the content has a documentclass', function () { + it('should return true if the content has a documentclass', function (ctx) { const document = ['% line', '% line', '% line', '\\documentclass'] - return expect( - this.DocumentHelper.contentHasDocumentclass(document) - ).to.equal(true) + expect(ctx.DocumentHelper.contentHasDocumentclass(document)).to.equal( + true + ) }) - it('should allow whitespace before the documentclass', function () { + it('should allow whitespace before the documentclass', function (ctx) { const document = ['% line', '% line', '% line', ' \\documentclass'] - return expect( - this.DocumentHelper.contentHasDocumentclass(document) - ).to.equal(true) + expect(ctx.DocumentHelper.contentHasDocumentclass(document)).to.equal( + true + ) }) - it('should not allow non-whitespace before the documentclass', function () { + it('should not allow non-whitespace before the documentclass', function (ctx) { const document = [ '% line', '% line', '% line', ' asdf \\documentclass', ] - return expect( - this.DocumentHelper.contentHasDocumentclass(document) - ).to.equal(false) + expect(ctx.DocumentHelper.contentHasDocumentclass(document)).to.equal( + false + ) }) - it('should return false when there is no documentclass', function () { + it('should return false when there is no documentclass', function (ctx) { const document = ['% line', '% line', '% line'] - return expect( - this.DocumentHelper.contentHasDocumentclass(document) - ).to.equal(false) + expect(ctx.DocumentHelper.contentHasDocumentclass(document)).to.equal( + false + ) }) }) }) diff --git a/services/web/test/unit/src/Editor/EditorRealTimeController.test.mjs b/services/web/test/unit/src/Editor/EditorRealTimeController.test.mjs index 6c8c2ec71f..79bcd269a7 100644 --- a/services/web/test/unit/src/Editor/EditorRealTimeController.test.mjs +++ b/services/web/test/unit/src/Editor/EditorRealTimeController.test.mjs @@ -1,84 +1,89 @@ -/* eslint-disable - max-len, - no-return-assign, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS101: Remove unnecessary use of Array.from - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const modulePath = require('path').join( - __dirname, +import { vi } from 'vitest' +import path from 'path' +import sinon from 'sinon' +const modulePath = path.join( + import.meta.dirname, '../../../../app/src/Features/Editor/EditorRealTimeController' ) describe('EditorRealTimeController', function () { - beforeEach(function () { - this.rclient = { publish: sinon.stub() } - this.Metrics = { summary: sinon.stub() } - this.EditorRealTimeController = SandboxedModule.require(modulePath, { - requires: { - '../../infrastructure/RedisWrapper': { - client: () => this.rclient, - }, - '../../infrastructure/Server': { - io: (this.io = {}), - }, - '@overleaf/settings': { redis: {} }, - '@overleaf/metrics': this.Metrics, - crypto: (this.crypto = { - randomBytes: sinon - .stub() - .withArgs(4) - .returns(Buffer.from([0x1, 0x2, 0x3, 0x4])), - }), - os: (this.os = { hostname: sinon.stub().returns('somehost') }), - }, - }) + beforeEach(async function (ctx) { + ctx.rclient = { publish: sinon.stub() } + ctx.Metrics = { summary: sinon.stub() } - this.room_id = 'room-id' - this.message = 'message-to-editor' - return (this.payload = ['argument one', 42]) + vi.doMock('../../../../app/src/infrastructure/RedisWrapper', () => ({ + default: { + client: () => ctx.rclient, + }, + })) + + vi.doMock('../../../../app/src/infrastructure/Server', () => ({ + default: { + io: (ctx.io = {}), + }, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: { redis: {} }, + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: ctx.Metrics, + })) + + vi.doMock('node:crypto', () => ({ + default: (ctx.crypto = { + randomBytes: sinon + .stub() + .withArgs(4) + .returns(Buffer.from([0x1, 0x2, 0x3, 0x4])), + }), + })) + + vi.doMock('node:os', () => ({ + default: (ctx.os = { hostname: sinon.stub().returns('somehost') }), + })) + + ctx.EditorRealTimeController = (await import(modulePath)).default + + ctx.room_id = 'room-id' + ctx.message = 'message-to-editor' + return (ctx.payload = ['argument one', 42]) }) describe('emitToRoom', function () { - beforeEach(function () { - this.message_id = 'web:somehost:01020304-0' - return this.EditorRealTimeController.emitToRoom( - this.room_id, - this.message, - ...Array.from(this.payload) + beforeEach(function (ctx) { + ctx.message_id = 'web:somehost:01020304-0' + return ctx.EditorRealTimeController.emitToRoom( + ctx.room_id, + ctx.message, + ...Array.from(ctx.payload) ) }) - it('should publish the message to redis', function () { - return this.rclient.publish + it('should publish the message to redis', function (ctx) { + return ctx.rclient.publish .calledWith( 'editor-events', JSON.stringify({ - room_id: this.room_id, - message: this.message, - payload: this.payload, - _id: this.message_id, + room_id: ctx.room_id, + message: ctx.message, + payload: ctx.payload, + _id: ctx.message_id, }) ) .should.equal(true) }) - it('should track the payload size', function () { - this.Metrics.summary + it('should track the payload size', function (ctx) { + ctx.Metrics.summary .calledWith( 'redis.publish.editor-events', JSON.stringify({ - room_id: this.room_id, - message: this.message, - payload: this.payload, - _id: this.message_id, + room_id: ctx.room_id, + message: ctx.message, + payload: ctx.payload, + _id: ctx.message_id, }).length ) .should.equal(true) @@ -86,17 +91,17 @@ describe('EditorRealTimeController', function () { }) describe('emitToAll', function () { - beforeEach(function () { - this.EditorRealTimeController.emitToRoom = sinon.stub() - return this.EditorRealTimeController.emitToAll( - this.message, - ...Array.from(this.payload) + beforeEach(function (ctx) { + ctx.EditorRealTimeController.emitToRoom = sinon.stub() + return ctx.EditorRealTimeController.emitToAll( + ctx.message, + ...Array.from(ctx.payload) ) }) - it("should emit to the room 'all'", function () { - return this.EditorRealTimeController.emitToRoom - .calledWith('all', this.message, ...Array.from(this.payload)) + it("should emit to the room 'all'", function (ctx) { + return ctx.EditorRealTimeController.emitToRoom + .calledWith('all', ctx.message, ...Array.from(ctx.payload)) .should.equal(true) }) }) diff --git a/services/web/test/unit/src/Email/EmailBuilder.test.mjs b/services/web/test/unit/src/Email/EmailBuilder.test.mjs index dbcdceb8d1..f47a4c0cee 100644 --- a/services/web/test/unit/src/Email/EmailBuilder.test.mjs +++ b/services/web/test/unit/src/Email/EmailBuilder.test.mjs @@ -1,38 +1,56 @@ -const SandboxedModule = require('sandboxed-module') -const cheerio = require('cheerio') -const path = require('path') -const { expect } = require('chai') +import { vi, expect } from 'vitest' +import cheerio from 'cheerio' +import path from 'path' + +import EmailMessageHelper from '../../../../app/src/Features/Email/EmailMessageHelper.js' +import ctaEmailBody from '../../../../app/src/Features/Email/Bodies/cta-email.js' +import NoCTAEmailBody from '../../../../app/src/Features/Email/Bodies/NoCTAEmailBody.js' +import BaseWithHeaderEmailLayout from '../../../../app/src/Features/Email/Layouts/BaseWithHeaderEmailLayout.js' const MODULE_PATH = path.join( - __dirname, + import.meta.dirname, '../../../../app/src/Features/Email/EmailBuilder' ) -const EmailMessageHelper = require('../../../../app/src/Features/Email/EmailMessageHelper') -const ctaEmailBody = require('../../../../app/src/Features/Email/Bodies/cta-email') -const NoCTAEmailBody = require('../../../../app/src/Features/Email/Bodies/NoCTAEmailBody') -const BaseWithHeaderEmailLayout = require('../../../../app/src/Features/Email/Layouts/BaseWithHeaderEmailLayout') - describe('EmailBuilder', function () { - before(function () { - this.settings = { + beforeEach(async function (ctx) { + ctx.settings = { appName: 'testApp', siteUrl: 'https://www.overleaf.com', } - this.EmailBuilder = SandboxedModule.require(MODULE_PATH, { - requires: { - './EmailMessageHelper': EmailMessageHelper, - './Bodies/cta-email': ctaEmailBody, - './Bodies/NoCTAEmailBody': NoCTAEmailBody, - './Layouts/BaseWithHeaderEmailLayout': BaseWithHeaderEmailLayout, - '@overleaf/settings': this.settings, - }, - }) + + vi.doMock('../../../../app/src/Features/Email/EmailMessageHelper', () => ({ + default: EmailMessageHelper, + })) + + vi.doMock('../../../../app/src/Features/Email/Bodies/cta-email', () => ({ + default: ctaEmailBody, + })) + + vi.doMock( + '../../../../app/src/Features/Email/Bodies/NoCTAEmailBody', + () => ({ + default: NoCTAEmailBody, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Email/Layouts/BaseWithHeaderEmailLayout', + () => ({ + default: BaseWithHeaderEmailLayout, + }) + ) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + ctx.EmailBuilder = (await import(MODULE_PATH)).default }) describe('projectInvite', function () { - beforeEach(function () { - this.opts = { + beforeEach(function (ctx) { + ctx.opts = { to: 'bob@bob.com', first_name: 'bob', owner: { @@ -47,83 +65,83 @@ describe('EmailBuilder', function () { }) describe('when sending a normal email', function () { - beforeEach(function () { - this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts) + beforeEach(function (ctx) { + ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts) }) - it('should have html and text properties', function () { - expect(this.email.html != null).to.equal(true) - expect(this.email.text != null).to.equal(true) + it('should have html and text properties', function (ctx) { + expect(ctx.email.html != null).to.equal(true) + expect(ctx.email.text != null).to.equal(true) }) - it('should not have undefined in it', function () { - this.email.html.indexOf('undefined').should.equal(-1) - this.email.subject.indexOf('undefined').should.equal(-1) + it('should not have undefined in it', function (ctx) { + ctx.email.html.indexOf('undefined').should.equal(-1) + ctx.email.subject.indexOf('undefined').should.equal(-1) }) }) describe('when dealing with escaping', function () { - it("should not show possessive 's as '", function () { - this.opts.project.name = "Aktöbe's project" - this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts) - expect(this.email.subject).to.not.contain(''') - expect(this.email.subject).to.contain(this.opts.project.name) + it("should not show possessive 's as '", function (ctx) { + ctx.opts.project.name = "Aktöbe's project" + ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts) + expect(ctx.email.subject).to.not.contain(''') + expect(ctx.email.subject).to.contain(ctx.opts.project.name) }) - it('should not show an ampersand as &', function () { - this.opts.project.name = 'Aktöbe & Almaty project' - this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts) - expect(this.email.subject).to.not.contain('&') - expect(this.email.subject).to.contain(this.opts.project.name) + it('should not show an ampersand as &', function (ctx) { + ctx.opts.project.name = 'Aktöbe & Almaty project' + ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts) + expect(ctx.email.subject).to.not.contain('&') + expect(ctx.email.subject).to.contain(ctx.opts.project.name) }) - it('should prevent dangerous characters as project names', function () { + it('should prevent dangerous characters as project names', function (ctx) { const characters = ['""', '<>', '//'] for (const pair of characters) { - this.opts.project.name = `${pair} project` - this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts) - expect(this.email.subject).to.not.contain(pair) + ctx.opts.project.name = `${pair} project` + ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts) + expect(ctx.email.subject).to.not.contain(pair) } }) }) describe('when someone is up to no good', function () { - it('should not contain the project name at all if unsafe', function () { - this.opts.project.name = "" - this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts) - expect(this.email.html).to.not.contain('evilsite.com') - expect(this.email.subject).to.not.contain('evilsite.com') + it('should not contain the project name at all if unsafe', function (ctx) { + ctx.opts.project.name = "" + ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts) + expect(ctx.email.html).to.not.contain('evilsite.com') + expect(ctx.email.subject).to.not.contain('evilsite.com') // but email should appear - expect(this.email.html).to.contain(this.opts.owner.email) - expect(this.email.subject).to.contain(this.opts.owner.email) + expect(ctx.email.html).to.contain(ctx.opts.owner.email) + expect(ctx.email.subject).to.contain(ctx.opts.owner.email) }) - it('should not contain the inviter email at all if unsafe', function () { - this.opts.owner.email = + it('should not contain the inviter email at all if unsafe', function (ctx) { + ctx.opts.owner.email = 'verylongemailaddressthatwillfailthecheck@longdomain.domain' - this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts) + ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts) - expect(this.email.html).to.not.contain(this.opts.owner.email) - expect(this.email.subject).to.not.contain(this.opts.owner.email) + expect(ctx.email.html).to.not.contain(ctx.opts.owner.email) + expect(ctx.email.subject).to.not.contain(ctx.opts.owner.email) // but title should appear - expect(this.email.html).to.contain(this.opts.project.name) - expect(this.email.subject).to.contain(this.opts.project.name) + expect(ctx.email.html).to.contain(ctx.opts.project.name) + expect(ctx.email.subject).to.contain(ctx.opts.project.name) }) - it('should handle both email and title being unsafe', function () { - this.opts.project.name = "" - this.opts.owner.email = + it('should handle both email and title being unsafe', function (ctx) { + ctx.opts.project.name = "" + ctx.opts.owner.email = 'verylongemailaddressthatwillfailthecheck@longdomain.domain' - this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts) + ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts) - expect(this.email.html).to.not.contain('evilsite.com') - expect(this.email.subject).to.not.contain('evilsite.com') - expect(this.email.html).to.not.contain(this.opts.owner.email) - expect(this.email.subject).to.not.contain(this.opts.owner.email) + expect(ctx.email.html).to.not.contain('evilsite.com') + expect(ctx.email.subject).to.not.contain('evilsite.com') + expect(ctx.email.html).to.not.contain(ctx.opts.owner.email) + expect(ctx.email.subject).to.not.contain(ctx.opts.owner.email) - expect(this.email.html).to.contain( + expect(ctx.email.html).to.contain( 'Please view the project to find out more' ) }) @@ -131,8 +149,8 @@ describe('EmailBuilder', function () { }) describe('SpamSafe', function () { - beforeEach(function () { - this.opts = { + beforeEach(function (ctx) { + ctx.opts = { to: 'bob@joe.com', first_name: 'bob', newOwner: { @@ -144,14 +162,14 @@ describe('EmailBuilder', function () { name: 'come buy my product at http://notascam.com', }, } - this.email = this.EmailBuilder.buildEmail( + ctx.email = ctx.EmailBuilder.buildEmail( 'ownershipTransferConfirmationPreviousOwner', - this.opts + ctx.opts ) }) - it('should replace spammy project name', function () { - this.email.html.indexOf('your project').should.not.equal(-1) + it('should replace spammy project name', function (ctx) { + ctx.email.html.indexOf('your project').should.not.equal(-1) }) }) @@ -166,28 +184,28 @@ describe('EmailBuilder', function () { ctaURL: () => {}, gmailGoToAction: () => {}, } - it('should throw an error when missing title', function () { + it('should throw an error when missing title', function (ctx) { const { title, ...missing } = content expect(() => { - this.EmailBuilder.ctaTemplate(missing) + ctx.EmailBuilder.ctaTemplate(missing) }).to.throw(Error) }) - it('should throw an error when missing message', function () { + it('should throw an error when missing message', function (ctx) { const { message, ...missing } = content expect(() => { - this.EmailBuilder.ctaTemplate(missing) + ctx.EmailBuilder.ctaTemplate(missing) }).to.throw(Error) }) - it('should throw an error when missing ctaText', function () { + it('should throw an error when missing ctaText', function (ctx) { const { ctaText, ...missing } = content expect(() => { - this.EmailBuilder.ctaTemplate(missing) + ctx.EmailBuilder.ctaTemplate(missing) }).to.throw(Error) }) - it('should throw an error when missing ctaURL', function () { + it('should throw an error when missing ctaURL', function (ctx) { const { ctaURL, ...missing } = content expect(() => { - this.EmailBuilder.ctaTemplate(missing) + ctx.EmailBuilder.ctaTemplate(missing) }).to.throw(Error) }) }) @@ -196,438 +214,432 @@ describe('EmailBuilder', function () { describe('templates', function () { describe('CTA', function () { describe('canceledSubscription', function () { - beforeEach(function () { - this.emailAddress = 'example@overleaf.com' - this.opts = { - to: this.emailAddress, + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, } - this.email = this.EmailBuilder.buildEmail( + ctx.email = ctx.EmailBuilder.buildEmail( 'canceledSubscription', - this.opts + ctx.opts ) - this.expectedUrl = + ctx.expectedUrl = 'https://docs.google.com/forms/d/e/1FAIpQLSfa7z_s-cucRRXm70N4jEcSbFsZeb0yuKThHGQL8ySEaQzF0Q/viewform?usp=sf_link' }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include a CTA button and a fallback CTA link', function () { - const dom = cheerio.load(this.email.html) + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) const buttonLink = dom('a:contains("Leave Feedback")') expect(buttonLink.length).to.equal(1) - expect(buttonLink.attr('href')).to.equal(this.expectedUrl) + expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl) const fallback = dom('.force-overleaf-style').last() expect(fallback.length).to.equal(1) const fallbackLink = fallback.html() - expect(fallbackLink).to.contain(this.expectedUrl) + expect(fallbackLink).to.contain(ctx.expectedUrl) }) }) describe('plain text email', function () { - it('should contain the CTA link', function () { - expect(this.email.text).to.contain(this.expectedUrl) + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.expectedUrl) }) }) }) describe('confirmEmail', function () { - before(function () { - this.emailAddress = 'example@overleaf.com' - this.userId = 'abc123' - this.opts = { - to: this.emailAddress, - confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=aToken123`, - sendingUser_id: this.userId, + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.userId = 'abc123' + ctx.opts = { + to: ctx.emailAddress, + confirmEmailUrl: `${ctx.settings.siteUrl}/user/emails/confirm?token=aToken123`, + sendingUser_id: ctx.userId, } - this.email = this.EmailBuilder.buildEmail('confirmEmail', this.opts) + ctx.email = ctx.EmailBuilder.buildEmail('confirmEmail', ctx.opts) }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include a CTA button and a fallback CTA link', function () { - const dom = cheerio.load(this.email.html) + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) const buttonLink = dom('a:contains("Confirm email")') expect(buttonLink.length).to.equal(1) - expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl) + expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl) const fallback = dom('.force-overleaf-style').last() expect(fallback.length).to.equal(1) const fallbackLink = fallback.html() - expect(fallbackLink).to.contain(this.opts.confirmEmailUrl) + expect(fallbackLink).to.contain(ctx.opts.confirmEmailUrl) }) }) describe('plain text email', function () { - it('should contain the CTA link', function () { - expect(this.email.text).to.contain(this.opts.confirmEmailUrl) + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.opts.confirmEmailUrl) }) }) }) describe('ownershipTransferConfirmationNewOwner', function () { - before(function () { - this.emailAddress = 'example@overleaf.com' - this.opts = { - to: this.emailAddress, + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, previousOwner: {}, project: { _id: 'abc123', name: 'example project', }, } - this.email = this.EmailBuilder.buildEmail( + ctx.email = ctx.EmailBuilder.buildEmail( 'ownershipTransferConfirmationNewOwner', - this.opts + ctx.opts ) - this.expectedUrl = `${ - this.settings.siteUrl - }/project/${this.opts.project._id.toString()}` + ctx.expectedUrl = `${ + ctx.settings.siteUrl + }/project/${ctx.opts.project._id.toString()}` }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include a CTA button and a fallback CTA link', function () { - const dom = cheerio.load(this.email.html) + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) const buttonLink = dom('td a') expect(buttonLink).to.exist - expect(buttonLink.attr('href')).to.equal(this.expectedUrl) + expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl) const fallback = dom('.force-overleaf-style').last() expect(fallback).to.exist const fallbackLink = fallback.html().replace(/&/g, '&') - expect(fallbackLink).to.contain(this.expectedUrl) + expect(fallbackLink).to.contain(ctx.expectedUrl) }) }) describe('plain text email', function () { - it('should contain the CTA link', function () { - expect(this.email.text).to.contain(this.expectedUrl) + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.expectedUrl) }) }) }) describe('passwordResetRequested', function () { - before(function () { - this.emailAddress = 'example@overleaf.com' - this.opts = { - to: this.emailAddress, + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, setNewPasswordUrl: `${ - this.settings.siteUrl + ctx.settings.siteUrl }/user/password/set?passwordResetToken=aToken&email=${encodeURIComponent( - this.emailAddress + ctx.emailAddress )}`, } - this.email = this.EmailBuilder.buildEmail( + ctx.email = ctx.EmailBuilder.buildEmail( 'passwordResetRequested', - this.opts + ctx.opts ) }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include a CTA button and a fallback CTA link', function () { - const dom = cheerio.load(this.email.html) + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) const buttonLink = dom('td a') expect(buttonLink).to.exist - expect(buttonLink.attr('href')).to.equal( - this.opts.setNewPasswordUrl - ) + expect(buttonLink.attr('href')).to.equal(ctx.opts.setNewPasswordUrl) const fallback = dom('.force-overleaf-style').last() expect(fallback).to.exist const fallbackLink = fallback.html().replace(/&/g, '&') - expect(fallbackLink).to.contain(this.opts.setNewPasswordUrl) + expect(fallbackLink).to.contain(ctx.opts.setNewPasswordUrl) }) }) describe('plain text email', function () { - it('should contain the CTA link', function () { - expect(this.email.text).to.contain(this.opts.setNewPasswordUrl) + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.opts.setNewPasswordUrl) }) }) }) describe('reconfirmEmail', function () { - before(function () { - this.emailAddress = 'example@overleaf.com' - this.userId = 'abc123' - this.opts = { - to: this.emailAddress, - confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=aToken123`, - sendingUser_id: this.userId, + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.userId = 'abc123' + ctx.opts = { + to: ctx.emailAddress, + confirmEmailUrl: `${ctx.settings.siteUrl}/user/emails/confirm?token=aToken123`, + sendingUser_id: ctx.userId, } - this.email = this.EmailBuilder.buildEmail('reconfirmEmail', this.opts) + ctx.email = ctx.EmailBuilder.buildEmail('reconfirmEmail', ctx.opts) }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include a CTA button and a fallback CTA link', function () { - const dom = cheerio.load(this.email.html) + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) const buttonLink = dom('a:contains("Reconfirm Email")') expect(buttonLink.length).to.equal(1) - expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl) + expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl) const fallback = dom('.force-overleaf-style').last() expect(fallback.length).to.equal(1) const fallbackLink = fallback.html() - expect(fallbackLink).to.contain(this.opts.confirmEmailUrl) + expect(fallbackLink).to.contain(ctx.opts.confirmEmailUrl) }) }) describe('plain text email', function () { - it('should contain the CTA link', function () { - expect(this.email.text).to.contain(this.opts.confirmEmailUrl) + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.opts.confirmEmailUrl) }) }) }) describe('verifyEmailToJoinTeam', function () { - before(function () { - this.emailAddress = 'example@overleaf.com' - this.opts = { - to: this.emailAddress, - acceptInviteUrl: `${this.settings.siteUrl}/subscription/invites/aToken123/`, + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, + acceptInviteUrl: `${ctx.settings.siteUrl}/subscription/invites/aToken123/`, inviter: { email: 'deanna@overleaf.com', first_name: 'Deanna', last_name: 'Troi', }, } - this.email = this.EmailBuilder.buildEmail( + ctx.email = ctx.EmailBuilder.buildEmail( 'verifyEmailToJoinTeam', - this.opts + ctx.opts ) }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include a CTA button and a fallback CTA link', function () { - const dom = cheerio.load(this.email.html) + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) const buttonLink = dom('a:contains("Join now")') expect(buttonLink.length).to.equal(1) - expect(buttonLink.attr('href')).to.equal(this.opts.acceptInviteUrl) + expect(buttonLink.attr('href')).to.equal(ctx.opts.acceptInviteUrl) const fallback = dom('.force-overleaf-style').last() expect(fallback.length).to.equal(1) const fallbackLink = fallback.html() - expect(fallbackLink).to.contain(this.opts.acceptInviteUrl) + expect(fallbackLink).to.contain(ctx.opts.acceptInviteUrl) }) }) describe('plain text email', function () { - it('should contain the CTA link', function () { - expect(this.email.text).to.contain(this.opts.acceptInviteUrl) + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.opts.acceptInviteUrl) }) }) }) describe('reactivatedSubscription', function () { - before(function () { - this.emailAddress = 'example@overleaf.com' - this.opts = { - to: this.emailAddress, + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, } - this.email = this.EmailBuilder.buildEmail( + ctx.email = ctx.EmailBuilder.buildEmail( 'reactivatedSubscription', - this.opts + ctx.opts ) - this.expectedUrl = `${this.settings.siteUrl}/user/subscription` + ctx.expectedUrl = `${ctx.settings.siteUrl}/user/subscription` }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include a CTA button and a fallback CTA link', function () { - const dom = cheerio.load(this.email.html) + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) const buttonLink = dom('a:contains("View Subscription Dashboard")') expect(buttonLink.length).to.equal(1) - expect(buttonLink.attr('href')).to.equal(this.expectedUrl) + expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl) const fallback = dom('.force-overleaf-style').last() expect(fallback.length).to.equal(1) const fallbackLink = fallback.html() - expect(fallbackLink).to.contain(this.expectedUrl) + expect(fallbackLink).to.contain(ctx.expectedUrl) }) }) describe('plain text email', function () { - it('should contain the CTA link', function () { - expect(this.email.text).to.contain(this.expectedUrl) + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.expectedUrl) }) }) }) describe('testEmail', function () { - before(function () { - this.emailAddress = 'example@overleaf.com' - this.opts = { - to: this.emailAddress, + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, } - this.email = this.EmailBuilder.buildEmail('testEmail', this.opts) + ctx.email = ctx.EmailBuilder.buildEmail('testEmail', ctx.opts) }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include a CTA button and a fallback CTA link', function () { - const dom = cheerio.load(this.email.html) - const buttonLink = dom( - `a:contains("Open ${this.settings.appName}")` - ) + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) + const buttonLink = dom(`a:contains("Open ${ctx.settings.appName}")`) expect(buttonLink.length).to.equal(1) - expect(buttonLink.attr('href')).to.equal(this.settings.siteUrl) + expect(buttonLink.attr('href')).to.equal(ctx.settings.siteUrl) const fallback = dom('.force-overleaf-style').last() expect(fallback.length).to.equal(1) const fallbackLink = fallback.html() - expect(fallbackLink).to.contain(this.settings.siteUrl) + expect(fallbackLink).to.contain(ctx.settings.siteUrl) }) }) describe('plain text email', function () { - it('should contain the CTA link', function () { - expect(this.email.text).to.contain( - `Open ${this.settings.appName}: ${this.settings.siteUrl}` + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain( + `Open ${ctx.settings.appName}: ${ctx.settings.siteUrl}` ) }) }) }) describe('registered', function () { - before(function () { - this.emailAddress = 'example@overleaf.com' - this.opts = { - to: this.emailAddress, - setNewPasswordUrl: `${this.settings.siteUrl}/user/activate?token=aToken123&user_id=aUserId123`, + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, + setNewPasswordUrl: `${ctx.settings.siteUrl}/user/activate?token=aToken123&user_id=aUserId123`, } - this.email = this.EmailBuilder.buildEmail('registered', this.opts) + ctx.email = ctx.EmailBuilder.buildEmail('registered', ctx.opts) }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include a CTA button and a fallback CTA link', function () { - const dom = cheerio.load(this.email.html) + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) const buttonLink = dom('a:contains("Set password")') expect(buttonLink.length).to.equal(1) - expect(buttonLink.attr('href')).to.equal( - this.opts.setNewPasswordUrl - ) + expect(buttonLink.attr('href')).to.equal(ctx.opts.setNewPasswordUrl) const fallback = dom('.force-overleaf-style').last() expect(fallback.length).to.equal(1) const fallbackLink = fallback.html().replace(/&/, '&') - expect(fallbackLink).to.contain(this.opts.setNewPasswordUrl) + expect(fallbackLink).to.contain(ctx.opts.setNewPasswordUrl) }) }) describe('plain text email', function () { - it('should contain the CTA link', function () { - expect(this.email.text).to.contain(this.opts.setNewPasswordUrl) + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.opts.setNewPasswordUrl) }) }) }) describe('projectInvite', function () { - before(function () { - this.emailAddress = 'example@overleaf.com' - this.owner = { + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.owner = { email: 'owner@example.com', name: 'Bailey', } - this.projectName = 'Top Secret' - this.opts = { - inviteUrl: `${this.settings.siteUrl}/project/projectId123/invite/token/aToken123`, + ctx.projectName = 'Top Secret' + ctx.opts = { + inviteUrl: `${ctx.settings.siteUrl}/project/projectId123/invite/token/aToken123`, owner: { - email: this.owner.email, + email: ctx.owner.email, }, project: { - name: this.projectName, + name: ctx.projectName, }, - to: this.emailAddress, + to: ctx.emailAddress, } - this.email = this.EmailBuilder.buildEmail('projectInvite', this.opts) + ctx.email = ctx.EmailBuilder.buildEmail('projectInvite', ctx.opts) }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include a CTA button and a fallback CTA link', function () { - const dom = cheerio.load(this.email.html) + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) const buttonLink = dom('a:contains("View project")') expect(buttonLink.length).to.equal(1) - expect(buttonLink.attr('href')).to.equal(this.opts.inviteUrl) + expect(buttonLink.attr('href')).to.equal(ctx.opts.inviteUrl) const fallback = dom('.force-overleaf-style').last() expect(fallback.length).to.equal(1) const fallbackLink = fallback.html().replace(/&/g, '&') - expect(fallbackLink).to.contain(this.opts.inviteUrl) + expect(fallbackLink).to.contain(ctx.opts.inviteUrl) }) }) describe('plain text email', function () { - it('should contain the CTA link', function () { - expect(this.email.text).to.contain(this.opts.inviteUrl) + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.opts.inviteUrl) }) }) }) describe('welcome', function () { - beforeEach(function () { - this.emailAddress = 'example@overleaf.com' - this.opts = { - to: this.emailAddress, - confirmEmailUrl: `${this.settings.siteUrl}/user/emails/confirm?token=token123`, + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, + confirmEmailUrl: `${ctx.settings.siteUrl}/user/emails/confirm?token=token123`, } - this.email = this.EmailBuilder.buildEmail('welcome', this.opts) - this.dom = cheerio.load(this.email.html) + ctx.email = ctx.EmailBuilder.buildEmail('welcome', ctx.opts) + ctx.dom = cheerio.load(ctx.email.html) }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include a CTA button and a fallback CTA link', function () { - const buttonLink = this.dom('a:contains("Confirm email")') + it('should include a CTA button and a fallback CTA link', function (ctx) { + const buttonLink = ctx.dom('a:contains("Confirm email")') expect(buttonLink.length).to.equal(1) - expect(buttonLink.attr('href')).to.equal(this.opts.confirmEmailUrl) - const fallback = this.dom('.force-overleaf-style').last() + expect(buttonLink.attr('href')).to.equal(ctx.opts.confirmEmailUrl) + const fallback = ctx.dom('.force-overleaf-style').last() expect(fallback.length).to.equal(1) - expect(fallback.html()).to.contain(this.opts.confirmEmailUrl) + expect(fallback.html()).to.contain(ctx.opts.confirmEmailUrl) }) - it('should include help links', function () { - const helpGuidesLink = this.dom('a:contains("Help Guides")') - const templatesLink = this.dom('a:contains("Templates")') - const logInLink = this.dom('a:contains("log in")') + it('should include help links', function (ctx) { + const helpGuidesLink = ctx.dom('a:contains("Help Guides")') + const templatesLink = ctx.dom('a:contains("Templates")') + const logInLink = ctx.dom('a:contains("log in")') expect(helpGuidesLink.length).to.equal(1) expect(templatesLink.length).to.equal(1) expect(logInLink.length).to.equal(1) @@ -635,30 +647,30 @@ describe('EmailBuilder', function () { }) describe('plain text email', function () { - it('should contain the CTA URL', function () { - expect(this.email.text).to.contain(this.opts.confirmEmailUrl) + it('should contain the CTA URL', function (ctx) { + expect(ctx.email.text).to.contain(ctx.opts.confirmEmailUrl) }) - it('should include help URL', function () { - expect(this.email.text).to.contain('/learn') - expect(this.email.text).to.contain('/login') - expect(this.email.text).to.contain('/templates') + it('should include help URL', function (ctx) { + expect(ctx.email.text).to.contain('/learn') + expect(ctx.email.text).to.contain('/login') + expect(ctx.email.text).to.contain('/templates') }) - it('should contain HTML links', function () { - expect(this.email.text).to.not.contain('${this.message}` - this.messageNotAllowedHTML = `
${this.messageHTML}` + beforeEach(function (ctx) { + ctx.message = 'more details about the action' + ctx.messageHTML = `
${ctx.message}` + ctx.messageNotAllowedHTML = `
${ctx.messageHTML}` - this.actionDescribed = 'an action described' - this.actionDescribedHTML = `
${this.actionDescribed}` - this.actionDescribedNotAllowedHTML = `
${this.actionDescribedHTML}` + ctx.actionDescribed = 'an action described' + ctx.actionDescribedHTML = `
${ctx.actionDescribed}` + ctx.actionDescribedNotAllowedHTML = `
${ctx.actionDescribedHTML}` - this.opts = { - to: this.email, - actionDescribed: this.actionDescribedNotAllowedHTML, + ctx.opts = { + to: ctx.email, + actionDescribed: ctx.actionDescribedNotAllowedHTML, action: 'an action', - message: [this.messageNotAllowedHTML], + message: [ctx.messageNotAllowedHTML], } - this.email = this.EmailBuilder.buildEmail('securityAlert', this.opts) + ctx.email = ctx.EmailBuilder.buildEmail('securityAlert', ctx.opts) }) - it('should build the email', function () { - expect(this.email.html != null).to.equal(true) - expect(this.email.text != null).to.equal(true) + it('should build the email', function (ctx) { + expect(ctx.email.html != null).to.equal(true) + expect(ctx.email.text != null).to.equal(true) }) describe('HTML email', function () { - it('should clean HTML in opts.actionDescribed', function () { - expect(this.email.html).to.not.contain( - this.actionDescribedNotAllowedHTML + it('should clean HTML in opts.actionDescribed', function (ctx) { + expect(ctx.email.html).to.not.contain( + ctx.actionDescribedNotAllowedHTML ) - expect(this.email.html).to.contain(this.actionDescribedHTML) + expect(ctx.email.html).to.contain(ctx.actionDescribedHTML) }) - it('should clean HTML in opts.message', function () { - expect(this.email.html).to.not.contain(this.messageNotAllowedHTML) - expect(this.email.html).to.contain(this.messageHTML) + it('should clean HTML in opts.message', function (ctx) { + expect(ctx.email.html).to.not.contain(ctx.messageNotAllowedHTML) + expect(ctx.email.html).to.contain(ctx.messageHTML) }) }) describe('plain text email', function () { - it('should remove all HTML in opts.actionDescribed', function () { - expect(this.email.text).to.not.contain(this.actionDescribedHTML) - expect(this.email.text).to.contain(this.actionDescribed) + it('should remove all HTML in opts.actionDescribed', function (ctx) { + expect(ctx.email.text).to.not.contain(ctx.actionDescribedHTML) + expect(ctx.email.text).to.contain(ctx.actionDescribed) }) - it('should remove all HTML in opts.message', function () { - expect(this.email.text).to.not.contain(this.messageHTML) - expect(this.email.text).to.contain(this.message) + it('should remove all HTML in opts.message', function (ctx) { + expect(ctx.email.text).to.not.contain(ctx.messageHTML) + expect(ctx.email.text).to.contain(ctx.message) }) }) }) describe('welcomeWithoutCTA', function () { - beforeEach(function () { - this.emailAddress = 'example@overleaf.com' - this.opts = { - to: this.emailAddress, + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, } - this.email = this.EmailBuilder.buildEmail( - 'welcomeWithoutCTA', - this.opts - ) - this.dom = cheerio.load(this.email.html) + ctx.email = ctx.EmailBuilder.buildEmail('welcomeWithoutCTA', ctx.opts) + ctx.dom = cheerio.load(ctx.email.html) }) - it('should build the email', function () { - expect(this.email.html).to.exist - expect(this.email.text).to.exist + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist }) describe('HTML email', function () { - it('should include help links', function () { - const helpGuidesLink = this.dom('a:contains("Help Guides")') - const templatesLink = this.dom('a:contains("Templates")') - const logInLink = this.dom('a:contains("log in")') + it('should include help links', function (ctx) { + const helpGuidesLink = ctx.dom('a:contains("Help Guides")') + const templatesLink = ctx.dom('a:contains("Templates")') + const logInLink = ctx.dom('a:contains("log in")') expect(helpGuidesLink.length).to.equal(1) expect(templatesLink.length).to.equal(1) expect(logInLink.length).to.equal(1) @@ -833,101 +842,98 @@ describe('EmailBuilder', function () { }) describe('plain text email', function () { - it('should include help URL', function () { - expect(this.email.text).to.contain('/learn') - expect(this.email.text).to.contain('/login') - expect(this.email.text).to.contain('/templates') + it('should include help URL', function (ctx) { + expect(ctx.email.text).to.contain('/learn') + expect(ctx.email.text).to.contain('/login') + expect(ctx.email.text).to.contain('/templates') }) - it('should contain HTML links', function () { - expect(this.email.text).to.not.contain(' ({ + default: ctx.EmailBuilder, + })) + + vi.doMock('../../../../app/src/Features/Email/EmailSender', () => ({ + default: ctx.EmailSender, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + vi.doMock('../../../../app/src/infrastructure/Queues', () => ({ + default: ctx.Queues, + })) + + ctx.EmailHandler = (await import(MODULE_PATH)).default }) describe('send email', function () { - it('should use the correct options', async function () { + it('should use the correct options', async function (ctx) { const opts = { to: 'bob@bob.com' } - await this.EmailHandler.promises.sendEmail('welcome', opts) - expect(this.EmailSender.promises.sendEmail).to.have.been.calledWithMatch({ - html: this.html, + await ctx.EmailHandler.promises.sendEmail('welcome', opts) + expect(ctx.EmailSender.promises.sendEmail).to.have.been.calledWithMatch({ + html: ctx.html, }) }) - it('should return the error', async function () { - this.EmailSender.promises.sendEmail.rejects(new Error('boom')) + it('should return the error', async function (ctx) { + ctx.EmailSender.promises.sendEmail.rejects(new Error('boom')) const opts = { to: 'bob@bob.com', subject: 'hello bob', } - await expect(this.EmailHandler.promises.sendEmail('welcome', opts)).to.be + await expect(ctx.EmailHandler.promises.sendEmail('welcome', opts)).to.be .rejected }) - it('should not send an email if lifecycle is not enabled', async function () { - this.Settings.email.lifecycle = false - this.EmailBuilder.buildEmail.returns({ type: 'lifecycle' }) - await this.EmailHandler.promises.sendEmail('welcome', {}) - expect(this.EmailSender.promises.sendEmail).not.to.have.been.called + it('should not send an email if lifecycle is not enabled', async function (ctx) { + ctx.Settings.email.lifecycle = false + ctx.EmailBuilder.buildEmail.returns({ type: 'lifecycle' }) + await ctx.EmailHandler.promises.sendEmail('welcome', {}) + expect(ctx.EmailSender.promises.sendEmail).not.to.have.been.called }) - it('should send an email if lifecycle is not enabled but the type is notification', async function () { - this.Settings.email.lifecycle = false - this.EmailBuilder.buildEmail.returns({ type: 'notification' }) + it('should send an email if lifecycle is not enabled but the type is notification', async function (ctx) { + ctx.Settings.email.lifecycle = false + ctx.EmailBuilder.buildEmail.returns({ type: 'notification' }) const opts = { to: 'bob@bob.com' } - await this.EmailHandler.promises.sendEmail('welcome', opts) - expect(this.EmailSender.promises.sendEmail).to.have.been.called + await ctx.EmailHandler.promises.sendEmail('welcome', opts) + expect(ctx.EmailSender.promises.sendEmail).to.have.been.called }) - it('should send lifecycle email if it is enabled', async function () { - this.Settings.email.lifecycle = true - this.EmailBuilder.buildEmail.returns({ type: 'lifecycle' }) + it('should send lifecycle email if it is enabled', async function (ctx) { + ctx.Settings.email.lifecycle = true + ctx.EmailBuilder.buildEmail.returns({ type: 'lifecycle' }) const opts = { to: 'bob@bob.com' } - await this.EmailHandler.promises.sendEmail('welcome', opts) - expect(this.EmailSender.promises.sendEmail).to.have.been.called + await ctx.EmailHandler.promises.sendEmail('welcome', opts) + expect(ctx.EmailSender.promises.sendEmail).to.have.been.called }) describe('with plain-text email content', function () { - beforeEach(function () { - this.text = 'hello there' + beforeEach(function (ctx) { + ctx.text = 'hello there' }) - it('should pass along the text field', async function () { - this.EmailBuilder.buildEmail.returns({ - html: this.html, - text: this.text, + it('should pass along the text field', async function (ctx) { + ctx.EmailBuilder.buildEmail.returns({ + html: ctx.html, + text: ctx.text, }) const opts = { to: 'bob@bob.com' } - await this.EmailHandler.promises.sendEmail('welcome', opts) - expect( - this.EmailSender.promises.sendEmail - ).to.have.been.calledWithMatch({ - html: this.html, - text: this.text, - }) + await ctx.EmailHandler.promises.sendEmail('welcome', opts) + expect(ctx.EmailSender.promises.sendEmail).to.have.been.calledWithMatch( + { + html: ctx.html, + text: ctx.text, + } + ) }) }) }) describe('send deferred email', function () { - beforeEach(function () { - this.opts = { + beforeEach(function (ctx) { + ctx.opts = { to: 'bob@bob.com', first_name: 'hello bob', } - this.emailType = 'canceledSubscription' - this.ONE_HOUR_IN_MS = 1000 * 60 * 60 - this.EmailHandler.sendDeferredEmail( - this.emailType, - this.opts, - this.ONE_HOUR_IN_MS + ctx.emailType = 'canceledSubscription' + ctx.ONE_HOUR_IN_MS = 1000 * 60 * 60 + ctx.EmailHandler.sendDeferredEmail( + ctx.emailType, + ctx.opts, + ctx.ONE_HOUR_IN_MS ) }) - it('should add a email job to the queue', function () { - expect(this.Queues.createScheduledJob).to.have.been.calledWith( + it('should add a email job to the queue', function (ctx) { + expect(ctx.Queues.createScheduledJob).to.have.been.calledWith( 'deferred-emails', - { data: { emailType: this.emailType, opts: this.opts } }, - this.ONE_HOUR_IN_MS + { data: { emailType: ctx.emailType, opts: ctx.opts } }, + ctx.ONE_HOUR_IN_MS ) }) }) diff --git a/services/web/test/unit/src/Email/EmailSender.test.mjs b/services/web/test/unit/src/Email/EmailSender.test.mjs index e70f71e198..e8ab70163f 100644 --- a/services/web/test/unit/src/Email/EmailSender.test.mjs +++ b/services/web/test/unit/src/Email/EmailSender.test.mjs @@ -1,23 +1,22 @@ -const SandboxedModule = require('sandboxed-module') -const path = require('path') -const sinon = require('sinon') -const { expect } = require('chai') +import { vi, expect } from 'vitest' +import path from 'path' +import sinon from 'sinon' const MODULE_PATH = path.join( - __dirname, - '../../../../app/src/Features/Email/EmailSender.js' + import.meta.dirname, + '../../../../app/src/Features/Email/EmailSender.mjs' ) describe('EmailSender', function () { - beforeEach(function () { - this.rateLimiter = { + beforeEach(async function (ctx) { + ctx.rateLimiter = { consume: sinon.stub().resolves(), } - this.RateLimiter = { - RateLimiter: sinon.stub().returns(this.rateLimiter), + ctx.RateLimiter = { + RateLimiter: sinon.stub().returns(ctx.rateLimiter), } - this.Settings = { + ctx.Settings = { email: { transport: 'ses', parameters: { @@ -29,25 +28,38 @@ describe('EmailSender', function () { }, } - this.sesClient = { sendMail: sinon.stub().resolves() } + ctx.sesClient = { sendMail: sinon.stub().resolves() } - this.ses = { createTransport: () => this.sesClient } + ctx.ses = { createTransport: () => ctx.sesClient } - this.SESClient = sinon.stub() + ctx.SESClient = sinon.stub() - this.EmailSender = SandboxedModule.require(MODULE_PATH, { - requires: { - nodemailer: this.ses, - '@aws-sdk/client-ses': { SESClient: this.SESClient }, - '@overleaf/settings': this.Settings, - '../../infrastructure/RateLimiter': this.RateLimiter, - '@overleaf/metrics': { - inc() {}, - }, + vi.doMock('nodemailer', () => ({ + default: ctx.ses, + })) + + vi.doMock('@aws-sdk/client-ses', () => ({ + default: { SESClient: ctx.SESClient }, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + vi.doMock( + '../../../../app/src/infrastructure/RateLimiter', + () => ctx.RateLimiter + ) + + vi.doMock('@overleaf/metrics', () => ({ + default: { + inc() {}, }, - }) + })) - this.opts = { + ctx.EmailSender = (await import(MODULE_PATH)).default + + ctx.opts = { to: 'bob@bob.com', subject: 'new email', html: '', @@ -55,77 +67,76 @@ describe('EmailSender', function () { }) describe('sendEmail', function () { - it('should set the properties on the email to send', async function () { - await this.EmailSender.promises.sendEmail(this.opts) - expect(this.sesClient.sendMail).to.have.been.calledWithMatch({ - html: this.opts.html, - to: this.opts.to, - subject: this.opts.subject, + it('should set the properties on the email to send', async function (ctx) { + await ctx.EmailSender.promises.sendEmail(ctx.opts) + expect(ctx.sesClient.sendMail).to.have.been.calledWithMatch({ + html: ctx.opts.html, + to: ctx.opts.to, + subject: ctx.opts.subject, }) }) - it('should return a non-specific error', async function () { - this.sesClient.sendMail.rejects(new Error('boom')) - await expect(this.EmailSender.promises.sendEmail({})).to.be.rejectedWith( + it('should return a non-specific error', async function (ctx) { + ctx.sesClient.sendMail.rejects(new Error('boom')) + await expect(ctx.EmailSender.promises.sendEmail({})).to.be.rejectedWith( 'error sending message' ) }) - it('should use the from address from settings', async function () { - await this.EmailSender.promises.sendEmail(this.opts) - expect(this.sesClient.sendMail).to.have.been.calledWithMatch({ - from: this.Settings.email.fromAddress, + it('should use the from address from settings', async function (ctx) { + await ctx.EmailSender.promises.sendEmail(ctx.opts) + expect(ctx.sesClient.sendMail).to.have.been.calledWithMatch({ + from: ctx.Settings.email.fromAddress, }) }) - it('should use the reply to address from settings', async function () { - await this.EmailSender.promises.sendEmail(this.opts) - expect(this.sesClient.sendMail).to.have.been.calledWithMatch({ - replyTo: this.Settings.email.replyToAddress, + it('should use the reply to address from settings', async function (ctx) { + await ctx.EmailSender.promises.sendEmail(ctx.opts) + expect(ctx.sesClient.sendMail).to.have.been.calledWithMatch({ + replyTo: ctx.Settings.email.replyToAddress, }) }) - it('should use the reply to address in options as an override', async function () { - this.opts.replyTo = 'someone@else.com' - await this.EmailSender.promises.sendEmail(this.opts) - expect(this.sesClient.sendMail).to.have.been.calledWithMatch({ - replyTo: this.opts.replyTo, + it('should use the reply to address in options as an override', async function (ctx) { + ctx.opts.replyTo = 'someone@else.com' + await ctx.EmailSender.promises.sendEmail(ctx.opts) + expect(ctx.sesClient.sendMail).to.have.been.calledWithMatch({ + replyTo: ctx.opts.replyTo, }) }) - it('should not send an email when the rate limiter says no', async function () { - this.opts.sendingUser_id = '12321312321' - this.rateLimiter.consume.rejects({ remainingPoints: 0 }) - await expect(this.EmailSender.promises.sendEmail(this.opts)).to.be - .rejected - expect(this.sesClient.sendMail).not.to.have.been.called + it('should not send an email when the rate limiter says no', async function (ctx) { + ctx.opts.sendingUser_id = '12321312321' + ctx.rateLimiter.consume.rejects({ remainingPoints: 0 }) + await expect(ctx.EmailSender.promises.sendEmail(ctx.opts)).to.be.rejected + expect(ctx.sesClient.sendMail).not.to.have.been.called }) - it('should send the email when the rate limtier says continue', async function () { - this.opts.sendingUser_id = '12321312321' - await this.EmailSender.promises.sendEmail(this.opts) - expect(this.sesClient.sendMail).to.have.been.called + it('should send the email when the rate limtier says continue', async function (ctx) { + ctx.opts.sendingUser_id = '12321312321' + await ctx.EmailSender.promises.sendEmail(ctx.opts) + expect(ctx.sesClient.sendMail).to.have.been.called }) - it('should not check the rate limiter when there is no sendingUser_id', async function () { - this.EmailSender.sendEmail(this.opts, () => { - expect(this.sesClient.sendMail).to.have.been.called - expect(this.rateLimiter.consume).not.to.have.been.called + it('should not check the rate limiter when there is no sendingUser_id', async function (ctx) { + ctx.EmailSender.sendEmail(ctx.opts, () => { + expect(ctx.sesClient.sendMail).to.have.been.called + expect(ctx.rateLimiter.consume).not.to.have.been.called }) }) describe('with plain-text email content', function () { - beforeEach(function () { - this.opts.text = 'hello there' + beforeEach(function (ctx) { + ctx.opts.text = 'hello there' }) - it('should set the text property on the email to send', async function () { - await this.EmailSender.promises.sendEmail(this.opts) - expect(this.sesClient.sendMail).to.have.been.calledWithMatch({ - html: this.opts.html, - text: this.opts.text, - to: this.opts.to, - subject: this.opts.subject, + it('should set the text property on the email to send', async function (ctx) { + await ctx.EmailSender.promises.sendEmail(ctx.opts) + expect(ctx.sesClient.sendMail).to.have.been.calledWithMatch({ + html: ctx.opts.html, + text: ctx.opts.text, + to: ctx.opts.to, + subject: ctx.opts.subject, }) }) }) diff --git a/services/web/test/unit/src/Email/SpamSafe.test.mjs b/services/web/test/unit/src/Email/SpamSafe.test.mjs index fa1770941c..7c86225505 100644 --- a/services/web/test/unit/src/Email/SpamSafe.test.mjs +++ b/services/web/test/unit/src/Email/SpamSafe.test.mjs @@ -1,10 +1,5 @@ -const path = require('path') -const modulePath = path.join( - __dirname, - '../../../../app/src/Features/Email/SpamSafe' -) -const SpamSafe = require(modulePath) -const { expect } = require('chai') +import SpamSafe from '../../../../app/src/Features/Email/SpamSafe.mjs' +import { expect } from 'vitest' describe('SpamSafe', function () { it('should reject spammy names', function () { diff --git a/services/web/test/unit/src/Errors/HttpErrorHandler.test.mjs b/services/web/test/unit/src/Errors/HttpErrorHandler.test.mjs index 6959bfe388..2bd9dbbc59 100644 --- a/services/web/test/unit/src/Errors/HttpErrorHandler.test.mjs +++ b/services/web/test/unit/src/Errors/HttpErrorHandler.test.mjs @@ -1,224 +1,168 @@ -const { expect } = require('chai') -const MockResponse = require('../helpers/MockResponse') -const MockRequest = require('../helpers/MockRequest') -const SandboxedModule = require('sandboxed-module') +import { vi, expect } from 'vitest' +import MockResponse from '../helpers/MockResponse.js' +import MockRequest from '../helpers/MockRequest.js' const modulePath = '../../../../app/src/Features/Errors/HttpErrorHandler.js' describe('HttpErrorHandler', function () { - beforeEach(function () { - this.req = new MockRequest() - this.res = new MockResponse() + beforeEach(async function (ctx) { + ctx.req = new MockRequest() + ctx.res = new MockResponse() - this.HttpErrorHandler = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': { - appName: 'Overleaf', - statusPageUrl: 'https://status.overlaf.com', - }, + vi.doMock('@overleaf/settings', () => ({ + default: { + appName: 'Overleaf', + statusPageUrl: 'https://status.overlaf.com', }, - }) + })) + + ctx.HttpErrorHandler = (await import(modulePath)).default }) describe('handleErrorByStatusCode', function () { - it('returns the http status code of 400 errors', function () { + it('returns the http status code of 400 errors', function (ctx) { const err = new Error() - this.HttpErrorHandler.handleErrorByStatusCode( - this.req, - this.res, - err, - 400 - ) - expect(this.res.statusCode).to.equal(400) + ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 400) + expect(ctx.res.statusCode).to.equal(400) }) - it('returns the http status code of 500 errors', function () { + it('returns the http status code of 500 errors', function (ctx) { const err = new Error() - this.HttpErrorHandler.handleErrorByStatusCode( - this.req, - this.res, - err, - 500 - ) - expect(this.res.statusCode).to.equal(500) + ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 500) + expect(ctx.res.statusCode).to.equal(500) }) - it('returns the http status code of any 5xx error', function () { + it('returns the http status code of any 5xx error', function (ctx) { const err = new Error() - this.HttpErrorHandler.handleErrorByStatusCode( - this.req, - this.res, - err, - 588 - ) - expect(this.res.statusCode).to.equal(588) + ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 588) + expect(ctx.res.statusCode).to.equal(588) }) - it('returns the http status code of any 4xx error', function () { + it('returns the http status code of any 4xx error', function (ctx) { const err = new Error() - this.HttpErrorHandler.handleErrorByStatusCode( - this.req, - this.res, - err, - 488 - ) - expect(this.res.statusCode).to.equal(488) + ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 488) + expect(ctx.res.statusCode).to.equal(488) }) - it('returns 500 for http status codes smaller than 400', function () { + it('returns 500 for http status codes smaller than 400', function (ctx) { const err = new Error() - this.HttpErrorHandler.handleErrorByStatusCode( - this.req, - this.res, - err, - 302 - ) - expect(this.res.statusCode).to.equal(500) + ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 302) + expect(ctx.res.statusCode).to.equal(500) }) - it('returns 500 for http status codes larger than 600', function () { + it('returns 500 for http status codes larger than 600', function (ctx) { const err = new Error() - this.HttpErrorHandler.handleErrorByStatusCode( - this.req, - this.res, - err, - 302 - ) - expect(this.res.statusCode).to.equal(500) + ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 302) + expect(ctx.res.statusCode).to.equal(500) }) - it('returns 500 when the error has no http status code', function () { + it('returns 500 when the error has no http status code', function (ctx) { const err = new Error() - this.HttpErrorHandler.handleErrorByStatusCode(this.req, this.res, err) - expect(this.res.statusCode).to.equal(500) + ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err) + expect(ctx.res.statusCode).to.equal(500) }) - it('uses the conflict() error handler', function () { + it('uses the conflict() error handler', function (ctx) { const err = new Error() - this.HttpErrorHandler.handleErrorByStatusCode( - this.req, - this.res, - err, - 409 - ) - expect(this.res.body).to.equal('conflict') + ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 409) + expect(ctx.res.body).to.equal('conflict') }) - it('uses the forbidden() error handler', function () { + it('uses the forbidden() error handler', function (ctx) { const err = new Error() - this.HttpErrorHandler.handleErrorByStatusCode( - this.req, - this.res, - err, - 403 - ) - expect(this.res.body).to.equal('restricted') + ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 403) + expect(ctx.res.body).to.equal('restricted') }) - it('uses the notFound() error handler', function () { + it('uses the notFound() error handler', function (ctx) { const err = new Error() - this.HttpErrorHandler.handleErrorByStatusCode( - this.req, - this.res, - err, - 404 - ) - expect(this.res.body).to.equal('not found') + ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 404) + expect(ctx.res.body).to.equal('not found') }) - it('uses the unprocessableEntity() error handler', function () { + it('uses the unprocessableEntity() error handler', function (ctx) { const err = new Error() err.httpStatusCode = 422 - this.HttpErrorHandler.handleErrorByStatusCode( - this.req, - this.res, - err, - 422 - ) - expect(this.res.body).to.equal('unprocessable entity') + ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 422) + expect(ctx.res.body).to.equal('unprocessable entity') }) }) describe('badRequest', function () { - it('returns 400', function () { - this.HttpErrorHandler.badRequest(this.req, this.res) - expect(this.res.statusCode).to.equal(400) + it('returns 400', function (ctx) { + ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res) + expect(ctx.res.statusCode).to.equal(400) }) - it('should print a message when no content-type is included', function () { - this.HttpErrorHandler.badRequest(this.req, this.res) - expect(this.res.body).to.equal('client error') + it('should print a message when no content-type is included', function (ctx) { + ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res) + expect(ctx.res.body).to.equal('client error') }) - it("should render a template including the error message when content-type is 'html'", function () { - this.req.accepts = () => 'html' - this.HttpErrorHandler.badRequest(this.req, this.res, 'an error') - expect(this.res.renderedTemplate).to.equal('general/400') - expect(this.res.renderedVariables).to.deep.equal({ + it("should render a template including the error message when content-type is 'html'", function (ctx) { + ctx.req.accepts = () => 'html' + ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res, 'an error') + expect(ctx.res.renderedTemplate).to.equal('general/400') + expect(ctx.res.renderedVariables).to.deep.equal({ title: 'Client Error', message: 'an error', }) }) - it("should render a default template when content-type is 'html' and no message is provided", function () { - this.req.accepts = () => 'html' - this.HttpErrorHandler.badRequest(this.req, this.res) - expect(this.res.renderedTemplate).to.equal('general/400') - expect(this.res.renderedVariables).to.deep.equal({ + it("should render a default template when content-type is 'html' and no message is provided", function (ctx) { + ctx.req.accepts = () => 'html' + ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res) + expect(ctx.res.renderedTemplate).to.equal('general/400') + expect(ctx.res.renderedVariables).to.deep.equal({ title: 'Client Error', message: undefined, }) }) - it("should return a json object when content-type is 'json'", function () { - this.req.accepts = () => 'json' - this.HttpErrorHandler.badRequest(this.req, this.res, 'an error', { + it("should return a json object when content-type is 'json'", function (ctx) { + ctx.req.accepts = () => 'json' + ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res, 'an error', { foo: 'bar', }) - expect(JSON.parse(this.res.body)).to.deep.equal({ + expect(JSON.parse(ctx.res.body)).to.deep.equal({ message: 'an error', foo: 'bar', }) }) - it("should return an empty json object when content-type is 'json' and no message and info are provided", function () { - this.req.accepts = () => 'json' - this.HttpErrorHandler.badRequest(this.req, this.res) - expect(JSON.parse(this.res.body)).to.deep.equal({}) + it("should return an empty json object when content-type is 'json' and no message and info are provided", function (ctx) { + ctx.req.accepts = () => 'json' + ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res) + expect(JSON.parse(ctx.res.body)).to.deep.equal({}) }) }) describe('conflict', function () { - it('returns 409', function () { - this.HttpErrorHandler.conflict(this.req, this.res) - expect(this.res.statusCode).to.equal(409) + it('returns 409', function (ctx) { + ctx.HttpErrorHandler.conflict(ctx.req, ctx.res) + expect(ctx.res.statusCode).to.equal(409) }) - it('should print a message when no content-type is included', function () { - this.HttpErrorHandler.conflict(this.req, this.res) - expect(this.res.body).to.equal('conflict') + it('should print a message when no content-type is included', function (ctx) { + ctx.HttpErrorHandler.conflict(ctx.req, ctx.res) + expect(ctx.res.body).to.equal('conflict') }) - it("should render a template including the error message when content-type is 'html'", function () { - this.req.accepts = () => 'html' - this.HttpErrorHandler.unprocessableEntity(this.req, this.res, 'an error') - expect(this.res.renderedTemplate).to.equal('general/400') - expect(this.res.renderedVariables).to.deep.equal({ + it("should render a template including the error message when content-type is 'html'", function (ctx) { + ctx.req.accepts = () => 'html' + ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res, 'an error') + expect(ctx.res.renderedTemplate).to.equal('general/400') + expect(ctx.res.renderedVariables).to.deep.equal({ title: 'Client Error', message: 'an error', }) }) - it("should return a json object when content-type is 'json'", function () { - this.req.accepts = () => 'json' - this.HttpErrorHandler.unprocessableEntity( - this.req, - this.res, - 'an error', - { - foo: 'bar', - } - ) - expect(JSON.parse(this.res.body)).to.deep.equal({ + it("should return a json object when content-type is 'json'", function (ctx) { + ctx.req.accepts = () => 'json' + ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res, 'an error', { + foo: 'bar', + }) + expect(JSON.parse(ctx.res.body)).to.deep.equal({ message: 'an error', foo: 'bar', }) @@ -226,31 +170,31 @@ describe('HttpErrorHandler', function () { }) describe('forbidden', function () { - it('returns 403', function () { - this.HttpErrorHandler.forbidden(this.req, this.res) - expect(this.res.statusCode).to.equal(403) + it('returns 403', function (ctx) { + ctx.HttpErrorHandler.forbidden(ctx.req, ctx.res) + expect(ctx.res.statusCode).to.equal(403) }) - it('should print a message when no content-type is included', function () { - this.HttpErrorHandler.forbidden(this.req, this.res) - expect(this.res.body).to.equal('restricted') + it('should print a message when no content-type is included', function (ctx) { + ctx.HttpErrorHandler.forbidden(ctx.req, ctx.res) + expect(ctx.res.body).to.equal('restricted') }) - it("should render a template when content-type is 'html'", function () { - this.req.accepts = () => 'html' - this.HttpErrorHandler.forbidden(this.req, this.res) - expect(this.res.renderedTemplate).to.equal('user/restricted') - expect(this.res.renderedVariables).to.deep.equal({ + it("should render a template when content-type is 'html'", function (ctx) { + ctx.req.accepts = () => 'html' + ctx.HttpErrorHandler.forbidden(ctx.req, ctx.res) + expect(ctx.res.renderedTemplate).to.equal('user/restricted') + expect(ctx.res.renderedVariables).to.deep.equal({ title: 'restricted', }) }) - it("should return a json object when content-type is 'json'", function () { - this.req.accepts = () => 'json' - this.HttpErrorHandler.forbidden(this.req, this.res, 'an error', { + it("should return a json object when content-type is 'json'", function (ctx) { + ctx.req.accepts = () => 'json' + ctx.HttpErrorHandler.forbidden(ctx.req, ctx.res, 'an error', { foo: 'bar', }) - expect(JSON.parse(this.res.body)).to.deep.equal({ + expect(JSON.parse(ctx.res.body)).to.deep.equal({ message: 'an error', foo: 'bar', }) @@ -258,31 +202,31 @@ describe('HttpErrorHandler', function () { }) describe('notFound', function () { - it('returns 404', function () { - this.HttpErrorHandler.notFound(this.req, this.res) - expect(this.res.statusCode).to.equal(404) + it('returns 404', function (ctx) { + ctx.HttpErrorHandler.notFound(ctx.req, ctx.res) + expect(ctx.res.statusCode).to.equal(404) }) - it('should print a message when no content-type is included', function () { - this.HttpErrorHandler.notFound(this.req, this.res) - expect(this.res.body).to.equal('not found') + it('should print a message when no content-type is included', function (ctx) { + ctx.HttpErrorHandler.notFound(ctx.req, ctx.res) + expect(ctx.res.body).to.equal('not found') }) - it("should render a template when content-type is 'html'", function () { - this.req.accepts = () => 'html' - this.HttpErrorHandler.notFound(this.req, this.res) - expect(this.res.renderedTemplate).to.equal('general/404') - expect(this.res.renderedVariables).to.deep.equal({ + it("should render a template when content-type is 'html'", function (ctx) { + ctx.req.accepts = () => 'html' + ctx.HttpErrorHandler.notFound(ctx.req, ctx.res) + expect(ctx.res.renderedTemplate).to.equal('general/404') + expect(ctx.res.renderedVariables).to.deep.equal({ title: 'page_not_found', }) }) - it("should return a json object when content-type is 'json'", function () { - this.req.accepts = () => 'json' - this.HttpErrorHandler.notFound(this.req, this.res, 'an error', { + it("should return a json object when content-type is 'json'", function (ctx) { + ctx.req.accepts = () => 'json' + ctx.HttpErrorHandler.notFound(ctx.req, ctx.res, 'an error', { foo: 'bar', }) - expect(JSON.parse(this.res.body)).to.deep.equal({ + expect(JSON.parse(ctx.res.body)).to.deep.equal({ message: 'an error', foo: 'bar', }) @@ -290,85 +234,75 @@ describe('HttpErrorHandler', function () { }) describe('unprocessableEntity', function () { - it('returns 422', function () { - this.HttpErrorHandler.unprocessableEntity(this.req, this.res) - expect(this.res.statusCode).to.equal(422) + it('returns 422', function (ctx) { + ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res) + expect(ctx.res.statusCode).to.equal(422) }) - it('should print a message when no content-type is included', function () { - this.HttpErrorHandler.unprocessableEntity(this.req, this.res) - expect(this.res.body).to.equal('unprocessable entity') + it('should print a message when no content-type is included', function (ctx) { + ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res) + expect(ctx.res.body).to.equal('unprocessable entity') }) - it("should render a template including the error message when content-type is 'html'", function () { - this.req.accepts = () => 'html' - this.HttpErrorHandler.unprocessableEntity(this.req, this.res, 'an error') - expect(this.res.renderedTemplate).to.equal('general/400') - expect(this.res.renderedVariables).to.deep.equal({ + it("should render a template including the error message when content-type is 'html'", function (ctx) { + ctx.req.accepts = () => 'html' + ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res, 'an error') + expect(ctx.res.renderedTemplate).to.equal('general/400') + expect(ctx.res.renderedVariables).to.deep.equal({ title: 'Client Error', message: 'an error', }) }) - it("should return a json object when content-type is 'json'", function () { - this.req.accepts = () => 'json' - this.HttpErrorHandler.unprocessableEntity( - this.req, - this.res, - 'an error', - { - foo: 'bar', - } - ) - expect(JSON.parse(this.res.body)).to.deep.equal({ + it("should return a json object when content-type is 'json'", function (ctx) { + ctx.req.accepts = () => 'json' + ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res, 'an error', { + foo: 'bar', + }) + expect(JSON.parse(ctx.res.body)).to.deep.equal({ message: 'an error', foo: 'bar', }) }) describe('legacyInternal', function () { - it('returns 500', function () { - this.HttpErrorHandler.legacyInternal(this.req, this.res, new Error()) - expect(this.res.statusCode).to.equal(500) + it('returns 500', function (ctx) { + ctx.HttpErrorHandler.legacyInternal(ctx.req, ctx.res, new Error()) + expect(ctx.res.statusCode).to.equal(500) }) - it('should send the error to the logger', function () { + it('should send the error to the logger', function (ctx) { const error = new Error('message') - this.HttpErrorHandler.legacyInternal( - this.req, - this.res, - 'message', - error - ) - expect(this.req.logger.setLevel).to.have.been.calledWith('error') - expect(this.req.logger.addFields).to.have.been.calledWith({ + ctx.HttpErrorHandler.legacyInternal(ctx.req, ctx.res, 'message', error) + expect(ctx.req.logger.setLevel).to.have.been.calledWith('error') + expect(ctx.req.logger.addFields).to.have.been.calledWith({ err: error, }) }) - it('should print a message when no content-type is included', function () { - this.HttpErrorHandler.legacyInternal(this.req, this.res, new Error()) - expect(this.res.body).to.equal('internal server error') + it('should print a message when no content-type is included', function (ctx) { + ctx.HttpErrorHandler.legacyInternal(ctx.req, ctx.res, new Error()) + expect(ctx.res.body).to.equal('internal server error') }) - it("should render a template when content-type is 'html'", function () { - this.req.accepts = () => 'html' - this.HttpErrorHandler.legacyInternal(this.req, this.res, new Error()) - expect(this.res.renderedTemplate).to.equal('general/500') - expect(this.res.renderedVariables).to.deep.equal({ + it("should render a template when content-type is 'html'", function (ctx) { + ctx.req.accepts = () => 'html' + ctx.HttpErrorHandler.legacyInternal(ctx.req, ctx.res, new Error()) + expect(ctx.res.renderedTemplate).to.equal('general/500') + expect(ctx.res.renderedVariables).to.deep.equal({ title: 'Server Error', }) }) - it("should return a json object with a static message when content-type is 'json'", function () { - this.req.accepts = () => 'json' - this.HttpErrorHandler.legacyInternal( - this.req, - this.res, + it("should return a json object with a static message when content-type is 'json'", function (ctx) { + ctx.req.accepts = () => 'json' + ctx.HttpErrorHandler.legacyInternal( + ctx.req, + ctx.res, 'a message', new Error() ) - expect(JSON.parse(this.res.body)).to.deep.equal({ + expect(JSON.parse(ctx.res.body)).to.deep.equal({ message: 'a message', }) }) diff --git a/services/web/test/unit/src/HelperFiles/SafeHTMLSubstitute.test.mjs b/services/web/test/unit/src/HelperFiles/SafeHTMLSubstitute.test.mjs index 8bd093c4a1..20ec754044 100644 --- a/services/web/test/unit/src/HelperFiles/SafeHTMLSubstitute.test.mjs +++ b/services/web/test/unit/src/HelperFiles/SafeHTMLSubstitute.test.mjs @@ -1,14 +1,14 @@ -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const MODULE_PATH = require('path').join( - __dirname, - '../../../../app/src/Features/Helpers/SafeHTMLSubstitution.js' +import { beforeAll, describe, expect } from 'vitest' +import path from 'node:path' +const MODULE_PATH = path.join( + import.meta.dirname, + '../../../../app/src/Features/Helpers/SafeHTMLSubstitution.mjs' ) describe('SafeHTMLSubstitution', function () { let SafeHTMLSubstitution - before(function () { - SafeHTMLSubstitution = SandboxedModule.require(MODULE_PATH) + beforeAll(async function () { + SafeHTMLSubstitution = (await import(MODULE_PATH)).default }) describe('SPLIT_REGEX', function () { diff --git a/services/web/test/unit/src/HelperFiles/UrlHelper.test.mjs b/services/web/test/unit/src/HelperFiles/UrlHelper.test.mjs index 455b99d36b..f23bd35cc5 100644 --- a/services/web/test/unit/src/HelperFiles/UrlHelper.test.mjs +++ b/services/web/test/unit/src/HelperFiles/UrlHelper.test.mjs @@ -1,42 +1,46 @@ -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const modulePath = require('path').join( - __dirname, - '../../../../app/src/Features/Helpers/UrlHelper.js' +import { vi, expect } from 'vitest' +import path from 'node:path' + +const modulePath = path.join( + import.meta.dirname, + '../../../../app/src/Features/Helpers/UrlHelper.mjs' ) describe('UrlHelper', function () { - beforeEach(function () { - this.settings = { + beforeEach(async function (ctx) { + ctx.settings = { apis: { linkedUrlProxy: { url: undefined } }, siteUrl: 'http://127.0.0.1:3000', } - this.UrlHelper = SandboxedModule.require(modulePath, { - requires: { '@overleaf/settings': this.settings }, - }) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + ctx.UrlHelper = (await import(modulePath)).default }) describe('getSafeRedirectPath', function () { - it('sanitize redirect path to prevent open redirects', function () { - expect(this.UrlHelper.getSafeRedirectPath('https://evil.com')).to.be + it('sanitize redirect path to prevent open redirects', function (ctx) { + expect(ctx.UrlHelper.getSafeRedirectPath('https://evil.com')).to.be .undefined - expect(this.UrlHelper.getSafeRedirectPath('//evil.com')).to.be.undefined + expect(ctx.UrlHelper.getSafeRedirectPath('//evil.com')).to.be.undefined - expect(this.UrlHelper.getSafeRedirectPath('//ol.com/evil')).to.equal( + expect(ctx.UrlHelper.getSafeRedirectPath('//ol.com/evil')).to.equal( '/evil' ) - expect(this.UrlHelper.getSafeRedirectPath('////evil.com')).to.be.undefined + expect(ctx.UrlHelper.getSafeRedirectPath('////evil.com')).to.be.undefined - expect(this.UrlHelper.getSafeRedirectPath('%2F%2Fevil.com')).to.equal( + expect(ctx.UrlHelper.getSafeRedirectPath('%2F%2Fevil.com')).to.equal( '/%2F%2Fevil.com' ) expect( - this.UrlHelper.getSafeRedirectPath('http://foo.com//evil.com/bad') + ctx.UrlHelper.getSafeRedirectPath('http://foo.com//evil.com/bad') ).to.equal('/evil.com/bad') - return expect(this.UrlHelper.getSafeRedirectPath('.evil.com')).to.equal( + return expect(ctx.UrlHelper.getSafeRedirectPath('.evil.com')).to.equal( '/.evil.com' ) }) diff --git a/services/web/test/unit/src/History/HistoryManager.test.mjs b/services/web/test/unit/src/History/HistoryManager.test.mjs index a4e0efe5a4..e8d3ea2052 100644 --- a/services/web/test/unit/src/History/HistoryManager.test.mjs +++ b/services/web/test/unit/src/History/HistoryManager.test.mjs @@ -1,6 +1,5 @@ -import { expect } from 'chai' +import { beforeAll, beforeEach, describe, it, vi, expect } from 'vitest' import sinon from 'sinon' -import SandboxedModule from 'sandboxed-module' import mongodb from 'mongodb-legacy' import { cleanupTestDatabase, @@ -18,11 +17,11 @@ const GLOBAL_BLOBS = [ ] describe('HistoryManager', function () { - before(async function () { + beforeAll(async function () { await waitForDb() }) - before(cleanupTestDatabase) - before(async function () { + beforeAll(cleanupTestDatabase) + beforeAll(async function () { await db.projectHistoryGlobalBlobs.insertMany( GLOBAL_BLOBS.map(sha => ({ _id: sha, @@ -32,32 +31,32 @@ describe('HistoryManager', function () { ) }) - beforeEach(function () { - this.user_id = 'user-id-123' - this.historyId = new ObjectId().toString() - this.AuthenticationController = { - getLoggedInUserId: sinon.stub().returns(this.user_id), + beforeEach(async function (ctx) { + ctx.user_id = 'user-id-123' + ctx.historyId = new ObjectId().toString() + ctx.AuthenticationController = { + getLoggedInUserId: sinon.stub().returns(ctx.user_id), } - this.FetchUtils = { + ctx.FetchUtils = { fetchJson: sinon.stub(), fetchNothing: sinon.stub().resolves(), } - this.projectHistoryUrl = 'http://project_history.example.com' - this.v1HistoryUrl = 'http://v1_history.example.com' - this.v1HistoryUser = 'system' - this.v1HistoryPassword = 'verysecret' - this.settings = { + ctx.projectHistoryUrl = 'http://project_history.example.com' + ctx.v1HistoryUrl = 'http://v1_history.example.com' + ctx.v1HistoryUser = 'system' + ctx.v1HistoryPassword = 'verysecret' + ctx.settings = { apis: { filestore: { url: 'http://filestore.example.com', }, project_history: { - url: this.projectHistoryUrl, + url: ctx.projectHistoryUrl, }, v1_history: { - url: this.v1HistoryUrl, - user: this.v1HistoryUser, - pass: this.v1HistoryPassword, + url: ctx.v1HistoryUrl, + user: ctx.v1HistoryUser, + pass: ctx.v1HistoryPassword, buckets: { globalBlobs: 'globalBlobs', projectBlobs: 'projectBlobs', @@ -66,169 +65,187 @@ describe('HistoryManager', function () { }, } - this.UserGetter = { + ctx.UserGetter = { promises: { getUsersByV1Ids: sinon.stub(), getUsers: sinon.stub(), }, } - this.project = { + ctx.project = { overleaf: { history: { - id: this.historyId, + id: ctx.historyId, }, }, } - this.ProjectGetter = { + ctx.ProjectGetter = { promises: { - getProject: sinon.stub().resolves(this.project), + getProject: sinon.stub().resolves(ctx.project), }, } - this.HistoryBackupDeletionHandler = { + ctx.HistoryBackupDeletionHandler = { deleteProject: sinon.stub().resolves(), } - this.HistoryManager = SandboxedModule.require(MODULE_PATH, { - requires: { - '../../infrastructure/mongodb': { ObjectId, db, waitForDb }, - '@overleaf/fetch-utils': this.FetchUtils, - '@overleaf/settings': this.settings, - '../User/UserGetter': this.UserGetter, - '../Project/ProjectGetter': this.ProjectGetter, - './HistoryBackupDeletionHandler': this.HistoryBackupDeletionHandler, - }, - }) + vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({ + ObjectId, + db, + waitForDb, + })) + + vi.doMock('@overleaf/fetch-utils', () => ctx.FetchUtils) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ + default: ctx.ProjectGetter, + })) + + vi.doMock( + '../../../../app/src/Features/History/HistoryBackupDeletionHandler', + () => ({ + default: ctx.HistoryBackupDeletionHandler, + }) + ) + + ctx.HistoryManager = (await import(MODULE_PATH)).default }) describe('getFilestoreBlobURL', function () { - beforeEach(async function () { - await this.HistoryManager.loadGlobalBlobsPromise + beforeEach(async function (ctx) { + await ctx.HistoryManager.loadGlobalBlobsPromise }) - it('should return a global blob location', function () { + it('should return a global blob location', function (ctx) { for (const sha of GLOBAL_BLOBS) { - expect(this.HistoryManager.getFilestoreBlobURL('42', sha)).to.equal( - `${this.settings.apis.filestore.url}/history/global/hash/${sha}` + expect(ctx.HistoryManager.getFilestoreBlobURL('42', sha)).to.equal( + `${ctx.settings.apis.filestore.url}/history/global/hash/${sha}` ) } }) - it('should return a project blob location for a v1 project', function () { + it('should return a project blob location for a v1 project', function (ctx) { const historyId = 42 const sha = '6ddfa0578a67fe5ad6623a8665ec9aafce1eb5ca' - expect(this.HistoryManager.getFilestoreBlobURL(historyId, sha)).to.equal( - `${this.settings.apis.filestore.url}/history/project/${historyId}/hash/${sha}` + expect(ctx.HistoryManager.getFilestoreBlobURL(historyId, sha)).to.equal( + `${ctx.settings.apis.filestore.url}/history/project/${historyId}/hash/${sha}` ) }) - it('should return a project blob location for a mongo project', function () { + it('should return a project blob location for a mongo project', function (ctx) { const historyId = '424242424242424242424242' const sha = '6ddfa0578a67fe5ad6623a8665ec9aafce1eb5ca' - expect(this.HistoryManager.getFilestoreBlobURL(historyId, sha)).to.equal( - `${this.settings.apis.filestore.url}/history/project/${historyId}/hash/${sha}` + expect(ctx.HistoryManager.getFilestoreBlobURL(historyId, sha)).to.equal( + `${ctx.settings.apis.filestore.url}/history/project/${historyId}/hash/${sha}` ) }) }) describe('initializeProject', function () { - beforeEach(function () { - this.settings.apis.project_history.initializeHistoryForNewProjects = true + beforeEach(function (ctx) { + ctx.settings.apis.project_history.initializeHistoryForNewProjects = true }) describe('project history returns a successful response', function () { - beforeEach(async function () { - this.FetchUtils.fetchJson.resolves({ project: { id: this.historyId } }) - this.result = await this.HistoryManager.promises.initializeProject( - this.historyId + beforeEach(async function (ctx) { + ctx.FetchUtils.fetchJson.resolves({ project: { id: ctx.historyId } }) + ctx.result = await ctx.HistoryManager.promises.initializeProject( + ctx.historyId ) }) - it('should call the project history api', function () { - this.FetchUtils.fetchJson.should.have.been.calledWithMatch( - `${this.settings.apis.project_history.url}/project`, + it('should call the project history api', function (ctx) { + ctx.FetchUtils.fetchJson.should.have.been.calledWithMatch( + `${ctx.settings.apis.project_history.url}/project`, { method: 'POST' } ) }) - it('should return the overleaf id', function () { - expect(this.result).to.equal(this.historyId) + it('should return the overleaf id', function (ctx) { + expect(ctx.result).to.equal(ctx.historyId) }) }) describe('project history returns a response without the project id', function () { - it('should throw an error', async function () { - this.FetchUtils.fetchJson.resolves({ project: {} }) + it('should throw an error', async function (ctx) { + ctx.FetchUtils.fetchJson.resolves({ project: {} }) await expect( - this.HistoryManager.promises.initializeProject(this.historyId) + ctx.HistoryManager.promises.initializeProject(ctx.historyId) ).to.be.rejected }) }) describe('project history errors', function () { - it('should propagate the error', async function () { - this.FetchUtils.fetchJson.rejects(new Error('problem connecting')) + it('should propagate the error', async function (ctx) { + ctx.FetchUtils.fetchJson.rejects(new Error('problem connecting')) await expect( - this.HistoryManager.promises.initializeProject(this.historyId) + ctx.HistoryManager.promises.initializeProject(ctx.historyId) ).to.be.rejected }) }) }) describe('injectUserDetails', function () { - beforeEach(function () { - this.user1 = { - _id: (this.user_id1 = '123456'), + beforeEach(function (ctx) { + ctx.user1 = { + _id: (ctx.user_id1 = '123456'), first_name: 'Jane', last_name: 'Doe', email: 'jane@example.com', overleaf: { id: 5011 }, } - this.user1_view = { - id: this.user_id1, + ctx.user1_view = { + id: ctx.user_id1, first_name: 'Jane', last_name: 'Doe', email: 'jane@example.com', } - this.user2 = { - _id: (this.user_id2 = 'abcdef'), + ctx.user2 = { + _id: (ctx.user_id2 = 'abcdef'), first_name: 'John', last_name: 'Doe', email: 'john@example.com', } - this.user2_view = { - id: this.user_id2, + ctx.user2_view = { + id: ctx.user_id2, first_name: 'John', last_name: 'Doe', email: 'john@example.com', } - this.UserGetter.promises.getUsersByV1Ids.resolves([this.user1]) - this.UserGetter.promises.getUsers.resolves([this.user1, this.user2]) + ctx.UserGetter.promises.getUsersByV1Ids.resolves([ctx.user1]) + ctx.UserGetter.promises.getUsers.resolves([ctx.user1, ctx.user2]) }) describe('with a diff', function () { - it('should turn user_ids into user objects', async function () { - const diff = await this.HistoryManager.promises.injectUserDetails({ + it('should turn user_ids into user objects', async function (ctx) { + const diff = await ctx.HistoryManager.promises.injectUserDetails({ diff: [ { i: 'foo', meta: { - users: [this.user_id1], + users: [ctx.user_id1], }, }, { i: 'bar', meta: { - users: [this.user_id2], + users: [ctx.user_id2], }, }, ], }) - expect(diff.diff[0].meta.users).to.deep.equal([this.user1_view]) - expect(diff.diff[1].meta.users).to.deep.equal([this.user2_view]) + expect(diff.diff[0].meta.users).to.deep.equal([ctx.user1_view]) + expect(diff.diff[1].meta.users).to.deep.equal([ctx.user2_view]) }) - it('should handle v1 user ids', async function () { - const diff = await this.HistoryManager.promises.injectUserDetails({ + it('should handle v1 user ids', async function (ctx) { + const diff = await ctx.HistoryManager.promises.injectUserDetails({ diff: [ { i: 'foo', @@ -239,38 +256,38 @@ describe('HistoryManager', function () { { i: 'bar', meta: { - users: [this.user_id2], + users: [ctx.user_id2], }, }, ], }) - expect(diff.diff[0].meta.users).to.deep.equal([this.user1_view]) - expect(diff.diff[1].meta.users).to.deep.equal([this.user2_view]) + expect(diff.diff[0].meta.users).to.deep.equal([ctx.user1_view]) + expect(diff.diff[1].meta.users).to.deep.equal([ctx.user2_view]) }) - it('should leave user objects', async function () { - const diff = await this.HistoryManager.promises.injectUserDetails({ + it('should leave user objects', async function (ctx) { + const diff = await ctx.HistoryManager.promises.injectUserDetails({ diff: [ { i: 'foo', meta: { - users: [this.user1_view], + users: [ctx.user1_view], }, }, { i: 'bar', meta: { - users: [this.user_id2], + users: [ctx.user_id2], }, }, ], }) - expect(diff.diff[0].meta.users).to.deep.equal([this.user1_view]) - expect(diff.diff[1].meta.users).to.deep.equal([this.user2_view]) + expect(diff.diff[0].meta.users).to.deep.equal([ctx.user1_view]) + expect(diff.diff[1].meta.users).to.deep.equal([ctx.user2_view]) }) - it('should handle a binary diff marker', async function () { - const diff = await this.HistoryManager.promises.injectUserDetails({ + it('should handle a binary diff marker', async function (ctx) { + const diff = await ctx.HistoryManager.promises.injectUserDetails({ diff: { binary: true }, }) expect(diff.diff.binary).to.be.true @@ -278,50 +295,50 @@ describe('HistoryManager', function () { }) describe('with a list of updates', function () { - it('should turn user_ids into user objects', async function () { - const updates = await this.HistoryManager.promises.injectUserDetails({ + it('should turn user_ids into user objects', async function (ctx) { + const updates = await ctx.HistoryManager.promises.injectUserDetails({ updates: [ { fromV: 5, toV: 8, meta: { - users: [this.user_id1], + users: [ctx.user_id1], }, }, { fromV: 4, toV: 5, meta: { - users: [this.user_id2], + users: [ctx.user_id2], }, }, ], }) - expect(updates.updates[0].meta.users).to.deep.equal([this.user1_view]) - expect(updates.updates[1].meta.users).to.deep.equal([this.user2_view]) + expect(updates.updates[0].meta.users).to.deep.equal([ctx.user1_view]) + expect(updates.updates[1].meta.users).to.deep.equal([ctx.user2_view]) }) - it('should leave user objects', async function () { - const updates = await this.HistoryManager.promises.injectUserDetails({ + it('should leave user objects', async function (ctx) { + const updates = await ctx.HistoryManager.promises.injectUserDetails({ updates: [ { fromV: 5, toV: 8, meta: { - users: [this.user1_view], + users: [ctx.user1_view], }, }, { fromV: 4, toV: 5, meta: { - users: [this.user_id2], + users: [ctx.user_id2], }, }, ], }) - expect(updates.updates[0].meta.users).to.deep.equal([this.user1_view]) - expect(updates.updates[1].meta.users).to.deep.equal([this.user2_view]) + expect(updates.updates[0].meta.users).to.deep.equal([ctx.user1_view]) + expect(updates.updates[1].meta.users).to.deep.equal([ctx.user2_view]) }) }) }) @@ -330,33 +347,33 @@ describe('HistoryManager', function () { const projectId = new ObjectId() const historyId = new ObjectId() - beforeEach(async function () { - await this.HistoryManager.promises.deleteProject(projectId, historyId) + beforeEach(async function (ctx) { + await ctx.HistoryManager.promises.deleteProject(projectId, historyId) }) - it('should call the project-history service', async function () { - expect(this.FetchUtils.fetchNothing).to.have.been.calledWith( - `${this.projectHistoryUrl}/project/${projectId}`, + it('should call the project-history service', async function (ctx) { + expect(ctx.FetchUtils.fetchNothing).to.have.been.calledWith( + `${ctx.projectHistoryUrl}/project/${projectId}`, { method: 'DELETE' } ) }) - it('should call the v1-history service', async function () { - expect(this.FetchUtils.fetchNothing).to.have.been.calledWith( - `${this.v1HistoryUrl}/projects/${historyId}`, + it('should call the v1-history service', async function (ctx) { + expect(ctx.FetchUtils.fetchNothing).to.have.been.calledWith( + `${ctx.v1HistoryUrl}/projects/${historyId}`, { method: 'DELETE', basicAuth: { - user: this.v1HistoryUser, - password: this.v1HistoryPassword, + user: ctx.v1HistoryUser, + password: ctx.v1HistoryPassword, }, } ) }) - it('should call the history-backup-deletion service', async function () { + it('should call the history-backup-deletion service', async function (ctx) { expect( - this.HistoryBackupDeletionHandler.deleteProject + ctx.HistoryBackupDeletionHandler.deleteProject ).to.have.been.calledWith(projectId) }) }) diff --git a/services/web/test/unit/src/History/RestoreManager.test.mjs b/services/web/test/unit/src/History/RestoreManager.test.mjs index d7add704e7..e13195af70 100644 --- a/services/web/test/unit/src/History/RestoreManager.test.mjs +++ b/services/web/test/unit/src/History/RestoreManager.test.mjs @@ -94,7 +94,49 @@ describe('RestoreManager', function () { })) vi.doMock('@overleaf/settings', () => ({ - default: {}, + default: { + fileIgnorePattern: + '**/{{__MACOSX,.git,.texpadtmp,.R}{,/**},.!(latexmkrc),*.{dvi,aux,log,toc,out,pdfsync,synctex,synctex(busy),fdb_latexmk,fls,nlo,ind,glo,gls,glg,bbl,blg,doc,docx,gz,swp}}', + textExtensions: [ + 'tex', + 'latex', + 'sty', + 'cls', + 'bst', + 'bib', + 'bibtex', + 'txt', + 'tikz', + 'mtx', + 'rtex', + 'md', + 'asy', + 'lbx', + 'bbx', + 'cbx', + 'm', + 'lco', + 'dtx', + 'ins', + 'ist', + 'def', + 'clo', + 'ldf', + 'rmd', + 'lua', + 'gv', + 'mf', + 'yml', + 'yaml', + 'lhs', + 'mk', + 'xmpdata', + 'cfg', + 'rnw', + 'ltx', + 'inc', + ], + }, })) vi.doMock('../../../../app/src/infrastructure/FileWriter', () => ({ diff --git a/services/web/test/unit/src/Institutions/InstitutionHelper.test.mjs b/services/web/test/unit/src/Institutions/InstitutionHelper.test.mjs index 9de43c4be6..e04e5458ee 100644 --- a/services/web/test/unit/src/Institutions/InstitutionHelper.test.mjs +++ b/services/web/test/unit/src/Institutions/InstitutionHelper.test.mjs @@ -1,11 +1,5 @@ -const { expect } = require('chai') -const path = require('path') -const InstitutionsHelper = require( - path.join( - __dirname, - '/../../../../app/src/Features/Institutions/InstitutionsHelper' - ) -) +import { expect } from 'chai' +import InstitutionsHelper from '../../../../app/src/Features/Institutions/InstitutionsHelper.mjs' describe('InstitutionsHelper', function () { describe('emailHasLicence', function () { diff --git a/services/web/test/unit/src/Institutions/InstitutionsAPI.test.mjs b/services/web/test/unit/src/Institutions/InstitutionsAPI.test.mjs index f4702f3bde..34c56eba2a 100644 --- a/services/web/test/unit/src/Institutions/InstitutionsAPI.test.mjs +++ b/services/web/test/unit/src/Institutions/InstitutionsAPI.test.mjs @@ -1,70 +1,88 @@ -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const path = require('path') -const sinon = require('sinon') -const { ObjectId } = require('mongodb-legacy') +import { vi, expect } from 'vitest' +import path from 'path' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' +import Errors from '../../../../app/src/Features/Errors/Errors.js' + const modulePath = path.join( - __dirname, + import.meta.dirname, '../../../../app/src/Features/Institutions/InstitutionsAPI' ) -const Errors = require('../../../../app/src/Features/Errors/Errors') +const { ObjectId } = mongodb +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) describe('InstitutionsAPI', function () { - beforeEach(function () { - this.settings = { + beforeEach(async function (ctx) { + ctx.settings = { apis: { v1: { url: 'v1.url', user: '', pass: '', timeout: 5000 } }, } - this.request = sinon.stub() - this.fetchNothing = sinon.stub() - this.ipMatcherNotification = { - read: (this.markAsReadIpMatcher = sinon.stub().resolves()), + ctx.request = sinon.stub() + ctx.fetchNothing = sinon.stub() + ctx.ipMatcherNotification = { + read: (ctx.markAsReadIpMatcher = sinon.stub().resolves()), } - this.InstitutionsAPI = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': this.settings, - requestretry: this.request, - '@overleaf/fetch-utils': { - fetchNothing: this.fetchNothing, - fetchJson: (this.fetchJson = sinon.stub()), - }, - '../Notifications/NotificationsBuilder': { + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('requestretry', () => ({ + default: ctx.request, + })) + + vi.doMock('@overleaf/fetch-utils', () => ({ + fetchNothing: ctx.fetchNothing, + fetchJson: (ctx.fetchJson = sinon.stub()), + })) + + vi.doMock( + '../../../../app/src/Features/Notifications/NotificationsBuilder', + () => ({ + default: { promises: { ipMatcherAffiliation: sinon .stub() - .returns(this.ipMatcherNotification), + .returns(ctx.ipMatcherNotification), }, }, - '../../infrastructure/Modules': (this.Modules = { - promises: { - hooks: { - fire: sinon.stub(), - }, - }, - }), - }, - }) + }) + ) - this.stubbedUser = { + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: (ctx.Modules = { + promises: { + hooks: { + fire: sinon.stub(), + }, + }, + }), + })) + + ctx.InstitutionsAPI = (await import(modulePath)).default + + ctx.stubbedUser = { _id: '3131231', name: 'bob', email: 'hello@world.com', } - this.newEmail = 'bob@bob.com' + ctx.newEmail = 'bob@bob.com' }) describe('getInstitutionAffiliations', function () { - it('get affiliations', async function () { - this.institutionId = 123 + it('get affiliations', async function (ctx) { + ctx.institutionId = 123 const responseBody = ['123abc', '456def'] - this.request.yields(null, { statusCode: 200 }, responseBody) + ctx.request.yields(null, { statusCode: 200 }, responseBody) const body = - await this.InstitutionsAPI.promises.getInstitutionAffiliations( - this.institutionId + await ctx.InstitutionsAPI.promises.getInstitutionAffiliations( + ctx.institutionId ) - this.request.calledOnce.should.equal(true) - const requestOptions = this.request.lastCall.args[0] - const expectedUrl = `v1.url/api/v2/institutions/${this.institutionId}/affiliations` + ctx.request.calledOnce.should.equal(true) + const requestOptions = ctx.request.lastCall.args[0] + const expectedUrl = `v1.url/api/v2/institutions/${ctx.institutionId}/affiliations` requestOptions.url.should.equal(expectedUrl) requestOptions.method.should.equal('GET') requestOptions.maxAttempts.should.exist @@ -74,12 +92,13 @@ describe('InstitutionsAPI', function () { body.should.equal(responseBody) }) - it('handle empty response', async function () { - this.settings.apis.v1.url = '' + it('handle empty response', async function (ctx) { + ctx.institutionId = 123 + ctx.settings.apis.v1.url = '' const body = - await this.InstitutionsAPI.promises.getInstitutionAffiliations( - this.institutionId + await ctx.InstitutionsAPI.promises.getInstitutionAffiliations( + ctx.institutionId ) expect(body).to.be.a('Array') body.length.should.equal(0) @@ -89,7 +108,7 @@ describe('InstitutionsAPI', function () { describe('getLicencesForAnalytics', function () { const lag = 'daily' const queryDate = '2017-01-07:00:00.000Z' - it('should send the request to v1', async function () { + it('should send the request to v1', async function (ctx) { const v1Result = { lag: 'daily', date: queryDate, @@ -98,22 +117,19 @@ describe('InstitutionsAPI', function () { max_confirmation_months: [], }, } - this.request.callsArgWith(1, null, { statusCode: 201 }, v1Result) - await this.InstitutionsAPI.promises.getLicencesForAnalytics( - lag, - queryDate - ) - const requestOptions = this.request.lastCall.args[0] + ctx.request.callsArgWith(1, null, { statusCode: 201 }, v1Result) + await ctx.InstitutionsAPI.promises.getLicencesForAnalytics(lag, queryDate) + const requestOptions = ctx.request.lastCall.args[0] expect(requestOptions.body.query_date).to.equal(queryDate) expect(requestOptions.body.lag).to.equal(lag) requestOptions.method.should.equal('GET') }) - it('should handle errors', async function () { - this.request.callsArgWith(1, null, { statusCode: 500 }) + it('should handle errors', async function (ctx) { + ctx.request.callsArgWith(1, null, { statusCode: 500 }) let error try { - await this.InstitutionsAPI.promises.getLicencesForAnalytics( + await ctx.InstitutionsAPI.promises.getLicencesForAnalytics( lag, queryDate ) @@ -126,7 +142,7 @@ describe('InstitutionsAPI', function () { }) describe('getUserAffiliations', function () { - it('get affiliations with commons', async function () { + it('get affiliations with commons', async function (ctx) { const responseBody = [ { foo: 'bar', @@ -135,22 +151,22 @@ describe('InstitutionsAPI', function () { }, }, ] - this.request.callsArgWith(1, null, { statusCode: 201 }, responseBody) - const body = await this.InstitutionsAPI.promises.getUserAffiliations( - this.stubbedUser._id + ctx.request.callsArgWith(1, null, { statusCode: 201 }, responseBody) + const body = await ctx.InstitutionsAPI.promises.getUserAffiliations( + ctx.stubbedUser._id ) - this.request.calledOnce.should.equal(true) - const requestOptions = this.request.lastCall.args[0] - const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations` + ctx.request.calledOnce.should.equal(true) + const requestOptions = ctx.request.lastCall.args[0] + const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations` requestOptions.url.should.equal(expectedUrl) requestOptions.method.should.equal('GET') requestOptions.maxAttempts.should.equal(3) - this.Modules.promises.hooks.fire.should.have.been.called + ctx.Modules.promises.hooks.fire.should.have.been.called expect(requestOptions.body).not.to.exist expect(body).to.deep.equal(responseBody) }) - it('get affiliations with domain capture for groups', async function () { + it('get affiliations with domain capture for groups', async function (ctx) { const responseBody = [ { id: '123abc', @@ -160,28 +176,28 @@ describe('InstitutionsAPI', function () { }, }, ] - this.request.callsArgWith(1, null, { statusCode: 201 }, responseBody) + ctx.request.callsArgWith(1, null, { statusCode: 201 }, responseBody) const groupResponse = { _id: new ObjectId(), managedUsersEnabled: false, domainCaptureEnabled: true, } - this.Modules.promises.hooks.fire + ctx.Modules.promises.hooks.fire .withArgs( 'getGroupWithDomainCaptureByV1Id', responseBody[0].institution.id ) .resolves([groupResponse]) - const body = await this.InstitutionsAPI.promises.getUserAffiliations( - this.stubbedUser._id + const body = await ctx.InstitutionsAPI.promises.getUserAffiliations( + ctx.stubbedUser._id ) - this.request.calledOnce.should.equal(true) - const requestOptions = this.request.lastCall.args[0] - const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations` + ctx.request.calledOnce.should.equal(true) + const requestOptions = ctx.request.lastCall.args[0] + const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations` requestOptions.url.should.equal(expectedUrl) requestOptions.method.should.equal('GET') requestOptions.maxAttempts.should.equal(3) - this.Modules.promises.hooks.fire.should.have.been.calledWith( + ctx.Modules.promises.hooks.fire.should.have.been.calledWith( 'getGroupWithDomainCaptureByV1Id', responseBody[0].institution.id ) @@ -196,14 +212,14 @@ describe('InstitutionsAPI', function () { ]) }) - it('handle error', async function () { + it('handle error', async function (ctx) { const body = { errors: 'affiliation error message' } - this.request.callsArgWith(1, null, { statusCode: 503 }, body) + ctx.request.callsArgWith(1, null, { statusCode: 503 }, body) let error try { - await this.InstitutionsAPI.promises.getUserAffiliations( - this.stubbedUser._id + await ctx.InstitutionsAPI.promises.getUserAffiliations( + ctx.stubbedUser._id ) } catch (err) { error = err @@ -212,10 +228,10 @@ describe('InstitutionsAPI', function () { expect(error).to.be.instanceOf(Errors.V1ConnectionError) }) - it('handle empty response', async function () { - this.settings.apis.v1.url = '' - const body = await this.InstitutionsAPI.promises.getUserAffiliations( - this.stubbedUser._id + it('handle empty response', async function (ctx) { + ctx.settings.apis.v1.url = '' + const body = await ctx.InstitutionsAPI.promises.getUserAffiliations( + ctx.stubbedUser._id ) expect(body).to.be.a('Array') body.length.should.equal(0) @@ -223,30 +239,30 @@ describe('InstitutionsAPI', function () { }) describe('getUsersNeedingReconfirmationsLapsedProcessed', function () { - it('get the list of users', async function () { - this.fetchJson.resolves({ statusCode: 200 }) - await this.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed() - this.fetchJson.calledOnce.should.equal(true) - const requestOptions = this.fetchJson.lastCall.args[1] + it('get the list of users', async function (ctx) { + ctx.fetchJson.resolves({ statusCode: 200 }) + await ctx.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed() + ctx.fetchJson.calledOnce.should.equal(true) + const requestOptions = ctx.fetchJson.lastCall.args[1] const expectedUrl = `v1.url/api/v2/institutions/need_reconfirmation_lapsed_processed` - this.fetchJson.lastCall.args[0].should.equal(expectedUrl) + ctx.fetchJson.lastCall.args[0].should.equal(expectedUrl) requestOptions.method.should.equal('GET') }) - it('handle error', async function () { - this.fetchJson.throws({ info: { statusCode: 500 } }) + it('handle error', async function (ctx) { + ctx.fetchJson.throws({ info: { statusCode: 500 } }) await expect( - this.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed() + ctx.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed() ).to.be.rejected }) }) describe('addAffiliation', function () { - beforeEach(function () { - this.fetchNothing.resolves({ status: 201 }) + beforeEach(function (ctx) { + ctx.fetchNothing.resolves({ status: 201 }) }) - it('add affiliation', async function () { + it('add affiliation', async function (ctx) { const affiliationOptions = { university: { id: 1 }, department: 'Math', @@ -254,38 +270,38 @@ describe('InstitutionsAPI', function () { confirmedAt: new Date(), entitlement: true, } - await this.InstitutionsAPI.promises.addAffiliation( - this.stubbedUser._id, - this.newEmail, + await ctx.InstitutionsAPI.promises.addAffiliation( + ctx.stubbedUser._id, + ctx.newEmail, affiliationOptions ) - this.fetchNothing.calledOnce.should.equal(true) - const requestOptions = this.fetchNothing.lastCall.args[1] - const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations` - expect(this.fetchNothing.lastCall.args[0]).to.equal(expectedUrl) + ctx.fetchNothing.calledOnce.should.equal(true) + const requestOptions = ctx.fetchNothing.lastCall.args[1] + const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations` + expect(ctx.fetchNothing.lastCall.args[0]).to.equal(expectedUrl) requestOptions.method.should.equal('POST') const { json } = requestOptions Object.keys(json).length.should.equal(7) expect(json).to.deep.equal( Object.assign( - { email: this.newEmail, rejectIfBlocklisted: undefined }, + { email: ctx.newEmail, rejectIfBlocklisted: undefined }, affiliationOptions ) ) - this.markAsReadIpMatcher.calledOnce.should.equal(true) + ctx.markAsReadIpMatcher.calledOnce.should.equal(true) }) - it('handles 422 error', async function () { + it('handles 422 error', async function (ctx) { const messageFromApi = 'affiliation error message' const body = JSON.stringify({ errors: messageFromApi }) - this.fetchNothing.throws({ response: { status: 422 }, body }) + ctx.fetchNothing.throws({ response: { status: 422 }, body }) let error try { - await this.InstitutionsAPI.promises.addAffiliation( - this.stubbedUser._id, - this.newEmail, + await ctx.InstitutionsAPI.promises.addAffiliation( + ctx.stubbedUser._id, + ctx.newEmail, {} ) } catch (err) { @@ -296,15 +312,15 @@ describe('InstitutionsAPI', function () { expect(error).to.have.property('message', `422: ${messageFromApi}`) }) - it('handles 500 error', async function () { + it('handles 500 error', async function (ctx) { const body = { errors: 'affiliation error message' } - this.fetchNothing.throws({ response: { status: 500 }, body }) + ctx.fetchNothing.throws({ response: { status: 500 }, body }) let error try { - await this.InstitutionsAPI.promises.addAffiliation( - this.stubbedUser._id, - this.newEmail, + await ctx.InstitutionsAPI.promises.addAffiliation( + ctx.stubbedUser._id, + ctx.newEmail, {} ) } catch (err) { @@ -319,14 +335,14 @@ describe('InstitutionsAPI', function () { }) }) - it('uses default error message when no error body in response', async function () { - this.fetchNothing.throws({ response: { status: 429 } }) + it('uses default error message when no error body in response', async function (ctx) { + ctx.fetchNothing.throws({ response: { status: 429 } }) let error try { - await this.InstitutionsAPI.promises.addAffiliation( - this.stubbedUser._id, - this.newEmail, + await ctx.InstitutionsAPI.promises.addAffiliation( + ctx.stubbedUser._id, + ctx.newEmail, {} ) } catch (err) { @@ -340,47 +356,47 @@ describe('InstitutionsAPI', function () { ) }) - it('does not try to mark IP matcher notifications as read if no university passed', async function () { + it('does not try to mark IP matcher notifications as read if no university passed', async function (ctx) { const affiliationOptions = { confirmedAt: new Date(), } - await this.InstitutionsAPI.promises.addAffiliation( - this.stubbedUser._id, - this.newEmail, + await ctx.InstitutionsAPI.promises.addAffiliation( + ctx.stubbedUser._id, + ctx.newEmail, affiliationOptions ) - expect(this.markAsReadIpMatcher.callCount).to.equal(0) + expect(ctx.markAsReadIpMatcher.callCount).to.equal(0) }) }) describe('removeAffiliation', function () { - beforeEach(function () { - this.fetchNothing.throws({ response: { status: 404 } }) + beforeEach(function (ctx) { + ctx.fetchNothing.throws({ response: { status: 404 } }) }) - it('remove affiliation', async function () { - await this.InstitutionsAPI.promises.removeAffiliation( - this.stubbedUser._id, - this.newEmail + it('remove affiliation', async function (ctx) { + await ctx.InstitutionsAPI.promises.removeAffiliation( + ctx.stubbedUser._id, + ctx.newEmail ) - this.fetchNothing.calledOnce.should.equal(true) - const requestOptions = this.fetchNothing.lastCall.args[1] - const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations/remove` - this.fetchNothing.lastCall.args[0].should.equal(expectedUrl) + ctx.fetchNothing.calledOnce.should.equal(true) + const requestOptions = ctx.fetchNothing.lastCall.args[1] + const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations/remove` + ctx.fetchNothing.lastCall.args[0].should.equal(expectedUrl) requestOptions.method.should.equal('POST') - expect(requestOptions.json).to.deep.equal({ email: this.newEmail }) + expect(requestOptions.json).to.deep.equal({ email: ctx.newEmail }) }) - it('handle error', async function () { - this.fetchNothing.throws({ response: { status: 500 } }) + it('handle error', async function (ctx) { + ctx.fetchNothing.throws({ response: { status: 500 } }) let error try { - await this.InstitutionsAPI.promises.removeAffiliation( - this.stubbedUser._id, - this.newEmail + await ctx.InstitutionsAPI.promises.removeAffiliation( + ctx.stubbedUser._id, + ctx.newEmail ) } catch (err) { error = err @@ -392,26 +408,24 @@ describe('InstitutionsAPI', function () { }) describe('deleteAffiliations', function () { - it('delete affiliations', async function () { - this.request.callsArgWith(1, null, { statusCode: 200 }) - await this.InstitutionsAPI.promises.deleteAffiliations( - this.stubbedUser._id - ) - this.request.calledOnce.should.equal(true) - const requestOptions = this.request.lastCall.args[0] - const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations` + it('delete affiliations', async function (ctx) { + ctx.request.callsArgWith(1, null, { statusCode: 200 }) + await ctx.InstitutionsAPI.promises.deleteAffiliations(ctx.stubbedUser._id) + ctx.request.calledOnce.should.equal(true) + const requestOptions = ctx.request.lastCall.args[0] + const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations` requestOptions.url.should.equal(expectedUrl) requestOptions.method.should.equal('DELETE') }) - it('handle error', async function () { + it('handle error', async function (ctx) { const body = { errors: 'affiliation error message' } - this.request.callsArgWith(1, null, { statusCode: 518 }, body) + ctx.request.callsArgWith(1, null, { statusCode: 518 }, body) let error try { - await this.InstitutionsAPI.promises.deleteAffiliations( - this.stubbedUser._id + await ctx.InstitutionsAPI.promises.deleteAffiliations( + ctx.stubbedUser._id ) } catch (err) { error = err @@ -422,26 +436,26 @@ describe('InstitutionsAPI', function () { }) describe('endorseAffiliation', function () { - beforeEach(function () { - this.request.callsArgWith(1, null, { statusCode: 204 }) + beforeEach(function (ctx) { + ctx.request.callsArgWith(1, null, { statusCode: 204 }) }) - it('endorse affiliation', async function () { - await this.InstitutionsAPI.promises.endorseAffiliation( - this.stubbedUser._id, - this.newEmail, + it('endorse affiliation', async function (ctx) { + await ctx.InstitutionsAPI.promises.endorseAffiliation( + ctx.stubbedUser._id, + ctx.newEmail, 'Student', 'Physics' ) - this.request.calledOnce.should.equal(true) - const requestOptions = this.request.lastCall.args[0] - const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations/endorse` + ctx.request.calledOnce.should.equal(true) + const requestOptions = ctx.request.lastCall.args[0] + const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations/endorse` requestOptions.url.should.equal(expectedUrl) requestOptions.method.should.equal('POST') const { body } = requestOptions Object.keys(body).length.should.equal(3) - body.email.should.equal(this.newEmail) + body.email.should.equal(ctx.newEmail) body.role.should.equal('Student') body.department.should.equal('Physics') }) @@ -450,13 +464,13 @@ describe('InstitutionsAPI', function () { describe('sendUsersWithReconfirmationsLapsedProcessed', function () { const users = ['abc123', 'def456'] - it('sends the list of users', async function () { - this.request.callsArgWith(1, null, { statusCode: 200 }) - await this.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed( + it('sends the list of users', async function (ctx) { + ctx.request.callsArgWith(1, null, { statusCode: 200 }) + await ctx.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed( users ) - this.request.calledOnce.should.equal(true) - const requestOptions = this.request.lastCall.args[0] + ctx.request.calledOnce.should.equal(true) + const requestOptions = ctx.request.lastCall.args[0] const expectedUrl = 'v1.url/api/v2/institutions/reconfirmation_lapsed_processed' requestOptions.url.should.equal(expectedUrl) @@ -464,12 +478,12 @@ describe('InstitutionsAPI', function () { expect(requestOptions.body).to.deep.equal({ users }) }) - it('handle error', async function () { - this.request.callsArgWith(1, null, { statusCode: 500 }) + it('handle error', async function (ctx) { + ctx.request.callsArgWith(1, null, { statusCode: 500 }) let error try { - await this.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed( + await ctx.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed( users ) } catch (err) { diff --git a/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs b/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs index 6f4a1e9715..fd5a2f905f 100644 --- a/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs +++ b/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs @@ -1,39 +1,47 @@ -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const sinon = require('sinon') -const modulePath = require('path').join( - __dirname, - '../../../../app/src/Features/Institutions/InstitutionsFeatures.js' +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import path from 'node:path' + +const modulePath = path.join( + import.meta.dirname, + '../../../../app/src/Features/Institutions/InstitutionsFeatures.mjs' ) describe('InstitutionsFeatures', function () { - beforeEach(function () { - this.UserGetter = { + beforeEach(async function (ctx) { + ctx.UserGetter = { promises: { getUserFullEmails: sinon.stub().resolves([]) }, } - this.PlansLocator = { findLocalPlanInSettings: sinon.stub() } - this.institutionPlanCode = 'institution_plan_code' - this.InstitutionsFeatures = SandboxedModule.require(modulePath, { - requires: { - '../User/UserGetter': this.UserGetter, - '../Subscription/PlansLocator': this.PlansLocator, - '@overleaf/settings': { - institutionPlanCode: this.institutionPlanCode, - }, + ctx.PlansLocator = { findLocalPlanInSettings: sinon.stub() } + ctx.institutionPlanCode = 'institution_plan_code' + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({ + default: ctx.PlansLocator, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: { + institutionPlanCode: ctx.institutionPlanCode, }, - }) - this.emailDataWithLicense = [{ emailHasInstitutionLicence: true }] - this.emailDataWithoutLicense = [{ emailHasInstitutionLicence: false }] - return (this.userId = '12345abcde') + })) + + ctx.InstitutionsFeatures = (await import(modulePath)).default + ctx.emailDataWithLicense = [{ emailHasInstitutionLicence: true }] + ctx.emailDataWithoutLicense = [{ emailHasInstitutionLicence: false }] + ctx.userId = '12345abcde' }) describe('hasLicence', function () { - it('should handle error', async function () { - this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope')) + it('should handle error', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope')) let error try { - await this.InstitutionsFeatures.promises.hasLicence(this.userId) + await ctx.InstitutionsFeatures.promises.hasLicence(ctx.userId) } catch (err) { error = err } @@ -41,93 +49,93 @@ describe('InstitutionsFeatures', function () { expect(error).to.exist }) - it('should return false if user has no paid affiliations', async function () { - this.UserGetter.promises.getUserFullEmails.resolves( - this.emailDataWithoutLicense + it('should return false if user has no paid affiliations', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves( + ctx.emailDataWithoutLicense ) - const hasLicence = await this.InstitutionsFeatures.promises.hasLicence( - this.userId + const hasLicence = await ctx.InstitutionsFeatures.promises.hasLicence( + ctx.userId ) expect(hasLicence).to.be.false }) - it('should return true if user has confirmed paid affiliation', async function () { + it('should return true if user has confirmed paid affiliation', async function (ctx) { const emailData = [ { emailHasInstitutionLicence: true }, { emailHasInstitutionLicence: false }, ] - this.UserGetter.promises.getUserFullEmails.resolves(emailData) - const hasLicence = await this.InstitutionsFeatures.promises.hasLicence( - this.userId + ctx.UserGetter.promises.getUserFullEmails.resolves(emailData) + const hasLicence = await ctx.InstitutionsFeatures.promises.hasLicence( + ctx.userId ) expect(hasLicence).to.be.true }) }) describe('getInstitutionsFeatures', function () { - beforeEach(function () { - this.testFeatures = { features: { institution: 'all' } } - return this.PlansLocator.findLocalPlanInSettings - .withArgs(this.institutionPlanCode) - .returns(this.testFeatures) + beforeEach(function (ctx) { + ctx.testFeatures = { features: { institution: 'all' } } + return ctx.PlansLocator.findLocalPlanInSettings + .withArgs(ctx.institutionPlanCode) + .returns(ctx.testFeatures) }) - it('should handle error', async function () { - this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope')) + it('should handle error', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope')) await expect( - this.InstitutionsFeatures.promises.getInstitutionsFeatures(this.userId) + ctx.InstitutionsFeatures.promises.getInstitutionsFeatures(ctx.userId) ).to.be.rejected }) - it('should return no feaures if user has no plan code', async function () { - this.UserGetter.promises.getUserFullEmails.resolves( - this.emailDataWithoutLicense + it('should return no feaures if user has no plan code', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves( + ctx.emailDataWithoutLicense ) const features = - await this.InstitutionsFeatures.promises.getInstitutionsFeatures( - this.userId + await ctx.InstitutionsFeatures.promises.getInstitutionsFeatures( + ctx.userId ) expect(features).to.deep.equal({}) }) - it('should return feaures if user has affiliations plan code', async function () { - this.UserGetter.promises.getUserFullEmails.resolves( - this.emailDataWithLicense + it('should return feaures if user has affiliations plan code', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves( + ctx.emailDataWithLicense ) const features = - await this.InstitutionsFeatures.promises.getInstitutionsFeatures( - this.userId + await ctx.InstitutionsFeatures.promises.getInstitutionsFeatures( + ctx.userId ) - expect(features).to.deep.equal(this.testFeatures.features) + expect(features).to.deep.equal(ctx.testFeatures.features) }) }) describe('getInstitutionsPlan', function () { - it('should handle error', async function () { - this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope')) + it('should handle error', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope')) await expect( - this.InstitutionsFeatures.promises.getInstitutionsPlan(this.userId) + ctx.InstitutionsFeatures.promises.getInstitutionsPlan(ctx.userId) ).to.be.rejected }) - it('should return no plan if user has no licence', async function () { - this.UserGetter.promises.getUserFullEmails.resolves( - this.emailDataWithoutLicense + it('should return no plan if user has no licence', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves( + ctx.emailDataWithoutLicense ) - const plan = await this.InstitutionsFeatures.promises.getInstitutionsPlan( - this.userId + const plan = await ctx.InstitutionsFeatures.promises.getInstitutionsPlan( + ctx.userId ) expect(plan).to.equal(null) }) - it('should return plan if user has licence', async function () { - this.UserGetter.promises.getUserFullEmails.resolves( - this.emailDataWithLicense + it('should return plan if user has licence', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves( + ctx.emailDataWithLicense ) - const plan = await this.InstitutionsFeatures.promises.getInstitutionsPlan( - this.userId + const plan = await ctx.InstitutionsFeatures.promises.getInstitutionsPlan( + ctx.userId ) - expect(plan).to.equal(this.institutionPlanCode) + expect(plan).to.equal(ctx.institutionPlanCode) }) }) }) diff --git a/services/web/test/unit/src/Newsletter/NewsletterManager.test.mjs b/services/web/test/unit/src/Newsletter/NewsletterManager.test.mjs index 193f9624da..e3800ad214 100644 --- a/services/web/test/unit/src/Newsletter/NewsletterManager.test.mjs +++ b/services/web/test/unit/src/Newsletter/NewsletterManager.test.mjs @@ -1,70 +1,72 @@ -const { expect } = require('chai') -const sinon = require('sinon') -const { RequestFailedError } = require('@overleaf/fetch-utils') -const SandboxedModule = require('sandboxed-module') +import { beforeEach, describe, expect, it, vi } from 'vitest' +import sinon from 'sinon' +import { RequestFailedError } from '@overleaf/fetch-utils' const MODULE_PATH = '../../../../app/src/Features/Newsletter/NewsletterManager' describe('NewsletterManager', function () { - beforeEach('setup mocks', function () { - this.Settings = { + beforeEach(async function (ctx) { + ctx.Settings = { mailchimp: { api_key: 'api_key', list_id: 'list_id', }, } - this.mailchimp = { + ctx.mailchimp = { get: sinon.stub(), put: sinon.stub(), patch: sinon.stub(), delete: sinon.stub(), } - this.Mailchimp = sinon.stub().returns(this.mailchimp) + ctx.Mailchimp = sinon.stub().returns(ctx.mailchimp) - this.mergeFields = { + ctx.mergeFields = { FNAME: 'Overleaf', LNAME: 'Duck', MONGO_ID: 'user_id', } - this.NewsletterManager = SandboxedModule.require(MODULE_PATH, { - requires: { - './MailChimpClient': this.Mailchimp, - '@overleaf/settings': this.Settings, - }, - globals: { AbortController }, - }).promises + vi.doMock( + '../../../../app/src/Features/Newsletter/MailChimpClient', + () => ({ + default: ctx.Mailchimp, + }) + ) - this.NewsletterManager.get = sinon.stub() - this.NewsletterManager.delete = sinon.stub() + vi.doMock('@overleaf/settings', () => ({ default: ctx.Settings })) - this.user = { + ctx.NewsletterManager = (await import(MODULE_PATH)).default.promises + + ctx.NewsletterManager.get = sinon.stub() + ctx.NewsletterManager.delete = sinon.stub() + + ctx.user = { _id: 'user_id', email: 'overleaf.duck@example.com', first_name: 'Overleaf', last_name: 'Duck', } // MD5 sum of the user email - this.emailHash = 'c02f60ed0ef51818186274e406c9a48f' + ctx.emailHash = 'c02f60ed0ef51818186274e406c9a48f' }) describe('subscribed', function () { - it('calls Mailchimp to get the user status', async function () { - await this.NewsletterManager.subscribed(this.user) - expect(this.mailchimp.get).to.have.been.calledWith( - `/lists/list_id/members/${this.emailHash}` + it('calls Mailchimp to get the user status', async function (ctx) { + await ctx.NewsletterManager.subscribed(ctx.user) + expect(ctx.mailchimp.get).to.have.been.calledWith( + `/lists/list_id/members/${ctx.emailHash}` ) }) - it('returns true when subscribed', async function () { - this.mailchimp.get.resolves({ status: 'subscribed' }) + it('returns true when subscribed', async function (ctx) { + ctx.mailchimp.get.resolves({ status: 'subscribed' }) - const subscribed = await this.NewsletterManager.subscribed(this.user) + const subscribed = await ctx.NewsletterManager.subscribed(ctx.user) expect(subscribed).to.be.true }) - it('returns false on 404', async function () { - this.mailchimp.get.rejects( + it('returns false on 404', async function (ctx) { + ctx.mailchimp.get.rejects( new RequestFailedError( 'http://some-url', {}, @@ -72,21 +74,21 @@ describe('NewsletterManager', function () { 'Not found' ) ) - const subscribed = await this.NewsletterManager.subscribed(this.user) + const subscribed = await ctx.NewsletterManager.subscribed(ctx.user) expect(subscribed).to.be.false }) }) describe('subscribe', function () { - it('calls Mailchimp to subscribe the user', async function () { - await this.NewsletterManager.subscribe(this.user) - expect(this.mailchimp.put).to.have.been.calledWith( - `/lists/list_id/members/${this.emailHash}`, + it('calls Mailchimp to subscribe the user', async function (ctx) { + await ctx.NewsletterManager.subscribe(ctx.user) + expect(ctx.mailchimp.put).to.have.been.calledWith( + `/lists/list_id/members/${ctx.emailHash}`, { - email_address: this.user.email, + email_address: ctx.user.email, status: 'subscribed', status_if_new: 'subscribed', - merge_fields: this.mergeFields, + merge_fields: ctx.mergeFields, } ) }) @@ -94,118 +96,117 @@ describe('NewsletterManager', function () { describe('unsubscribe', function () { describe('when unsubscribing normally', function () { - it('calls Mailchimp to unsubscribe the user', async function () { - await this.NewsletterManager.unsubscribe(this.user) - expect(this.mailchimp.patch).to.have.been.calledWith( - `/lists/list_id/members/${this.emailHash}`, + it('calls Mailchimp to unsubscribe the user', async function (ctx) { + await ctx.NewsletterManager.unsubscribe(ctx.user) + expect(ctx.mailchimp.patch).to.have.been.calledWith( + `/lists/list_id/members/${ctx.emailHash}`, { status: 'unsubscribed', - merge_fields: this.mergeFields, + merge_fields: ctx.mergeFields, } ) }) - it('ignores a Mailchimp error about fake emails', async function () { - this.mailchimp.patch.rejects( + it('ignores a Mailchimp error about fake emails', async function (ctx) { + ctx.mailchimp.patch.rejects( new Error( 'overleaf.duck@example.com looks fake or invalid, please enter a real email address' ) ) - await expect(this.NewsletterManager.unsubscribe(this.user)).to.be + await expect(ctx.NewsletterManager.unsubscribe(ctx.user)).to.be .fulfilled }) - it('rejects on other errors', async function () { - this.mailchimp.patch.rejects( + it('rejects on other errors', async function (ctx) { + ctx.mailchimp.patch.rejects( new Error('something really wrong is happening') ) - await expect(this.NewsletterManager.unsubscribe(this.user)).to.be - .rejected + await expect(ctx.NewsletterManager.unsubscribe(ctx.user)).to.be.rejected }) }) describe('when deleting', function () { - it('calls Mailchimp to delete the user', async function () { - await this.NewsletterManager.unsubscribe(this.user, { delete: true }) - expect(this.mailchimp.delete).to.have.been.calledWith( - `/lists/list_id/members/${this.emailHash}` + it('calls Mailchimp to delete the user', async function (ctx) { + await ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true }) + expect(ctx.mailchimp.delete).to.have.been.calledWith( + `/lists/list_id/members/${ctx.emailHash}` ) }) - it('ignores a Mailchimp error about fake emails', async function () { - this.mailchimp.delete.rejects( + it('ignores a Mailchimp error about fake emails', async function (ctx) { + ctx.mailchimp.delete.rejects( new Error( 'overleaf.duck@example.com looks fake or invalid, please enter a real email address' ) ) await expect( - this.NewsletterManager.unsubscribe(this.user, { delete: true }) + ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true }) ).to.be.fulfilled }) - it('rejects on other errors', async function () { - this.mailchimp.delete.rejects( + it('rejects on other errors', async function (ctx) { + ctx.mailchimp.delete.rejects( new Error('something really wrong is happening') ) await expect( - this.NewsletterManager.unsubscribe(this.user, { delete: true }) + ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true }) ).to.be.rejected }) }) }) describe('changeEmail', function () { - it('calls Mailchimp to change the subscriber email', async function () { - await this.NewsletterManager.changeEmail( - this.user, + it('calls Mailchimp to change the subscriber email', async function (ctx) { + await ctx.NewsletterManager.changeEmail( + ctx.user, 'overleaf.squirrel@example.com' ) - expect(this.mailchimp.patch).to.have.been.calledWith( - `/lists/list_id/members/${this.emailHash}`, + expect(ctx.mailchimp.patch).to.have.been.calledWith( + `/lists/list_id/members/${ctx.emailHash}`, { email_address: 'overleaf.squirrel@example.com', - merge_fields: this.mergeFields, + merge_fields: ctx.mergeFields, } ) }) - it('deletes the old email if changing the address fails', async function () { - this.mailchimp.patch - .withArgs(`/lists/list_id/members/${this.emailHash}`, { + it('deletes the old email if changing the address fails', async function (ctx) { + ctx.mailchimp.patch + .withArgs(`/lists/list_id/members/${ctx.emailHash}`, { email_address: 'overleaf.squirrel@example.com', - merge_fields: this.mergeFields, + merge_fields: ctx.mergeFields, }) .rejects(new Error('that did not work')) await expect( - this.NewsletterManager.changeEmail( - this.user, + ctx.NewsletterManager.changeEmail( + ctx.user, 'overleaf.squirrel@example.com' ) ).to.be.rejected - expect(this.mailchimp.delete).to.have.been.calledWith( - `/lists/list_id/members/${this.emailHash}` + expect(ctx.mailchimp.delete).to.have.been.calledWith( + `/lists/list_id/members/${ctx.emailHash}` ) }) - it('does not reject on non-fatal error ', async function () { + it('does not reject on non-fatal error ', async function (ctx) { const nonFatalError = new Error('merge fields were invalid') - this.mailchimp.patch.rejects(nonFatalError) + ctx.mailchimp.patch.rejects(nonFatalError) await expect( - this.NewsletterManager.changeEmail( - this.user, + ctx.NewsletterManager.changeEmail( + ctx.user, 'overleaf.squirrel@example.com' ) ).to.be.fulfilled }) - it('rejects on any other error', async function () { + it('rejects on any other error', async function (ctx) { const fatalError = new Error('fatal error') - this.mailchimp.patch.rejects(fatalError) + ctx.mailchimp.patch.rejects(fatalError) await expect( - this.NewsletterManager.changeEmail( - this.user, + ctx.NewsletterManager.changeEmail( + ctx.user, 'overleaf.squirrel@example.com' ) ).to.be.rejected diff --git a/services/web/test/unit/src/Notifications/NotificationsBuilder.test.mjs b/services/web/test/unit/src/Notifications/NotificationsBuilder.test.mjs index 5ad0476427..536cbb4275 100644 --- a/services/web/test/unit/src/Notifications/NotificationsBuilder.test.mjs +++ b/services/web/test/unit/src/Notifications/NotificationsBuilder.test.mjs @@ -1,37 +1,45 @@ -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const sinon = require('sinon') -const modulePath = require('path').join( - __dirname, - '../../../../app/src/Features/Notifications/NotificationsBuilder.js' +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import path from 'node:path' +const modulePath = path.join( + import.meta.dirname, + '../../../../app/src/Features/Notifications/NotificationsBuilder.mjs' ) describe('NotificationsBuilder', function () { const userId = '507f1f77bcf86cd799439011' - beforeEach(function () { - this.handler = { promises: { createNotification: sinon.stub().resolves() } } - this.settings = { + beforeEach(async function (ctx) { + ctx.handler = { promises: { createNotification: sinon.stub().resolves() } } + ctx.settings = { apis: { v1: { url: 'http://v1.url', user: '', pass: '' } }, } - this.FetchUtils = { + ctx.FetchUtils = { fetchJson: sinon.stub(), } - this.controller = SandboxedModule.require(modulePath, { - requires: { - './NotificationsHandler': this.handler, - '@overleaf/settings': this.settings, - '@overleaf/fetch-utils': this.FetchUtils, - }, - }) + + vi.doMock( + '../../../../app/src/Features/Notifications/NotificationsHandler', + () => ({ + default: ctx.handler, + }) + ) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('@overleaf/fetch-utils', () => ctx.FetchUtils) + + ctx.controller = (await import(modulePath)).default }) describe('dropboxUnlinkedDueToLapsedReconfirmation', function () { - it('should create the notification', async function () { - await this.controller.promises + it('should create the notification', async function (ctx) { + await ctx.controller.promises .dropboxUnlinkedDueToLapsedReconfirmation(userId) .create() - expect(this.handler.promises.createNotification).to.have.been.calledWith( + expect(ctx.handler.promises.createNotification).to.have.been.calledWith( userId, 'drobox-unlinked-due-to-lapsed-reconfirmation', 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation', @@ -42,15 +50,15 @@ describe('NotificationsBuilder', function () { }) describe('NotificationsHandler error', function () { let anError - beforeEach(function () { + beforeEach(function (ctx) { anError = new Error('oops') - this.handler.promises.createNotification.rejects(anError) + ctx.handler.promises.createNotification.rejects(anError) }) - it('should return errors from NotificationsHandler', async function () { + it('should return errors from NotificationsHandler', async function (ctx) { let error try { - await this.controller.promises + await ctx.controller.promises .dropboxUnlinkedDueToLapsedReconfirmation(userId) .create() } catch (err) { @@ -64,30 +72,26 @@ describe('NotificationsBuilder', function () { describe('groupInvitation', function () { const subscriptionId = '123123bcabca' - beforeEach(function () { - this.invite = { + beforeEach(function (ctx) { + ctx.invite = { token: '123123abcabc', inviterName: 'Mr Overleaf', managedUsersEnabled: false, } }) - it('should create the notification', async function () { - await this.controller.promises - .groupInvitation( - userId, - subscriptionId, - this.invite.managedUsersEnabled - ) - .create(this.invite) - expect(this.handler.promises.createNotification).to.have.been.calledWith( + it('should create the notification', async function (ctx) { + await ctx.controller.promises + .groupInvitation(userId, subscriptionId, ctx.invite.managedUsersEnabled) + .create(ctx.invite) + expect(ctx.handler.promises.createNotification).to.have.been.calledWith( userId, `groupInvitation-${subscriptionId}-${userId}`, 'notification_group_invitation', { - token: this.invite.token, - inviterName: this.invite.inviterName, - managedUsersEnabled: this.invite.managedUsersEnabled, + token: ctx.invite.token, + inviterName: ctx.invite.inviterName, + managedUsersEnabled: ctx.invite.managedUsersEnabled, }, null, true @@ -97,31 +101,31 @@ describe('NotificationsBuilder', function () { describe('ipMatcherAffiliation', function () { describe('with portal and with SSO', function () { - beforeEach(function () { - this.body = { + beforeEach(function (ctx) { + ctx.body = { id: 1, name: 'stanford', is_university: true, portal_slug: null, sso_enabled: false, } - this.FetchUtils.fetchJson.resolves(this.body) + ctx.FetchUtils.fetchJson.resolves(ctx.body) }) - it('should call v1 and create affiliation notifications', async function () { + it('should call v1 and create affiliation notifications', async function (ctx) { const ip = '192.168.0.1' - await this.controller.promises.ipMatcherAffiliation(userId).create(ip) - this.FetchUtils.fetchJson.calledOnce.should.equal(true) + await ctx.controller.promises.ipMatcherAffiliation(userId).create(ip) + ctx.FetchUtils.fetchJson.calledOnce.should.equal(true) const expectedOpts = { - institutionId: this.body.id, - university_name: this.body.name, + institutionId: ctx.body.id, + university_name: ctx.body.name, ssoEnabled: false, portalPath: undefined, } - this.handler.promises.createNotification + ctx.handler.promises.createNotification .calledWith( userId, - `ip-matched-affiliation-${this.body.id}`, + `ip-matched-affiliation-${ctx.body.id}`, 'notification_ip_matched_affiliation', expectedOpts ) @@ -129,31 +133,31 @@ describe('NotificationsBuilder', function () { }) }) describe('without portal and without SSO', function () { - beforeEach(function () { - this.body = { + beforeEach(function (ctx) { + ctx.body = { id: 1, name: 'stanford', is_university: true, portal_slug: 'stanford', sso_enabled: true, } - this.FetchUtils.fetchJson.resolves(this.body) + ctx.FetchUtils.fetchJson.resolves(ctx.body) }) - it('should call v1 and create affiliation notifications', async function () { + it('should call v1 and create affiliation notifications', async function (ctx) { const ip = '192.168.0.1' - await this.controller.promises.ipMatcherAffiliation(userId).create(ip) - this.FetchUtils.fetchJson.calledOnce.should.equal(true) + await ctx.controller.promises.ipMatcherAffiliation(userId).create(ip) + ctx.FetchUtils.fetchJson.calledOnce.should.equal(true) const expectedOpts = { - institutionId: this.body.id, - university_name: this.body.name, + institutionId: ctx.body.id, + university_name: ctx.body.name, ssoEnabled: true, portalPath: '/edu/stanford', } - this.handler.promises.createNotification + ctx.handler.promises.createNotification .calledWith( userId, - `ip-matched-affiliation-${this.body.id}`, + `ip-matched-affiliation-${ctx.body.id}`, 'notification_ip_matched_affiliation', expectedOpts ) diff --git a/services/web/test/unit/src/Notifications/NotificationsHandler.test.mjs b/services/web/test/unit/src/Notifications/NotificationsHandler.test.mjs index 0ed0001d65..56156cd389 100644 --- a/services/web/test/unit/src/Notifications/NotificationsHandler.test.mjs +++ b/services/web/test/unit/src/Notifications/NotificationsHandler.test.mjs @@ -1,9 +1,9 @@ -const SandboxedModule = require('sandboxed-module') -const { assert } = require('chai') -const sinon = require('sinon') -const modulePath = require('path').join( - __dirname, - '../../../../app/src/Features/Notifications/NotificationsHandler.js' +import { vi, assert } from 'vitest' +import sinon from 'sinon' +import path from 'node:path' +const modulePath = path.join( + import.meta.dirname, + '../../../../app/src/Features/Notifications/NotificationsHandler.mjs' ) describe('NotificationsHandler', function () { @@ -11,29 +11,33 @@ describe('NotificationsHandler', function () { const notificationId = '123njdskj9jlk' const notificationUrl = 'notification.overleaf.testing' - beforeEach(function () { - this.request = sinon.stub().callsArgWith(1) - this.handler = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': { - apis: { notifications: { url: notificationUrl } }, - }, - request: this.request, + beforeEach(async function (ctx) { + ctx.request = sinon.stub().callsArgWith(1) + + vi.doMock('@overleaf/settings', () => ({ + default: { + apis: { notifications: { url: notificationUrl } }, }, - }) + })) + + vi.doMock('request', () => ({ + default: ctx.request, + })) + + ctx.handler = (await import(modulePath)).default }) describe('getUserNotifications', function () { - it('should get unread notifications', async function () { + it('should get unread notifications', async function (ctx) { const stubbedNotifications = [{ _id: notificationId, user_id: userId }] - this.request.callsArgWith( + ctx.request.callsArgWith( 1, null, { statusCode: 200 }, stubbedNotifications ) const unreadNotifications = - await this.handler.promises.getUserNotifications(userId) + await ctx.handler.promises.getUserNotifications(userId) stubbedNotifications.should.deep.equal(unreadNotifications) const getOpts = { uri: `${notificationUrl}/user/${userId}`, @@ -41,89 +45,89 @@ describe('NotificationsHandler', function () { timeout: 1000, method: 'GET', } - this.request.calledWith(getOpts).should.equal(true) + ctx.request.calledWith(getOpts).should.equal(true) }) - it('should return empty arrays if there are no notifications', async function () { - this.request.callsArgWith(1, null, { statusCode: 200 }, null) + it('should return empty arrays if there are no notifications', async function (ctx) { + ctx.request.callsArgWith(1, null, { statusCode: 200 }, null) const unreadNotifications = - await this.handler.promises.getUserNotifications(userId) + await ctx.handler.promises.getUserNotifications(userId) unreadNotifications.length.should.equal(0) }) }) describe('markAsRead', function () { - beforeEach(function () { - this.key = 'some key here' + beforeEach(function (ctx) { + ctx.key = 'some key here' }) - it('should send a delete request when a delete has been received to mark a notification', async function () { - await this.handler.promises.markAsReadWithKey(userId, this.key) + it('should send a delete request when a delete has been received to mark a notification', async function (ctx) { + await ctx.handler.promises.markAsReadWithKey(userId, ctx.key) const opts = { uri: `${notificationUrl}/user/${userId}`, json: { - key: this.key, + key: ctx.key, }, timeout: 1000, method: 'DELETE', } - this.request.calledWith(opts).should.equal(true) + ctx.request.calledWith(opts).should.equal(true) }) }) describe('createNotification', function () { - beforeEach(function () { - this.key = 'some key here' - this.messageOpts = { value: 12344 } - this.templateKey = 'renderThisHtml' - this.expiry = null + beforeEach(function (ctx) { + ctx.key = 'some key here' + ctx.messageOpts = { value: 12344 } + ctx.templateKey = 'renderThisHtml' + ctx.expiry = null }) - it('should post the message over', async function () { - await this.handler.promises.createNotification( + it('should post the message over', async function (ctx) { + await ctx.handler.promises.createNotification( userId, - this.key, - this.templateKey, - this.messageOpts, - this.expiry + ctx.key, + ctx.templateKey, + ctx.messageOpts, + ctx.expiry ) - const args = this.request.args[0][0] + const args = ctx.request.args[0][0] args.uri.should.equal(`${notificationUrl}/user/${userId}`) args.timeout.should.equal(1000) const expectedJson = { - key: this.key, - templateKey: this.templateKey, - messageOpts: this.messageOpts, + key: ctx.key, + templateKey: ctx.templateKey, + messageOpts: ctx.messageOpts, forceCreate: true, } assert.deepEqual(args.json, expectedJson) }) describe('when expiry date is supplied', function () { - beforeEach(function () { - this.key = 'some key here' - this.messageOpts = { value: 12344 } - this.templateKey = 'renderThisHtml' - this.expiry = new Date() + beforeEach(function (ctx) { + ctx.key = 'some key here' + ctx.messageOpts = { value: 12344 } + ctx.templateKey = 'renderThisHtml' + ctx.expiry = new Date() }) - it('should post the message over with expiry field', async function () { - await this.handler.promises.createNotification( + it('should post the message over with expiry field', async function (ctx) { + await ctx.handler.promises.createNotification( userId, - this.key, - this.templateKey, - this.messageOpts, - this.expiry + ctx.key, + ctx.templateKey, + ctx.messageOpts, + ctx.expiry ) - const args = this.request.args[0][0] + const args = ctx.request.args[0][0] args.uri.should.equal(`${notificationUrl}/user/${userId}`) args.timeout.should.equal(1000) const expectedJson = { - key: this.key, - templateKey: this.templateKey, - messageOpts: this.messageOpts, - expires: this.expiry, + key: ctx.key, + templateKey: ctx.templateKey, + messageOpts: ctx.messageOpts, + expires: ctx.expiry, forceCreate: true, } assert.deepEqual(args.json, expectedJson) @@ -132,18 +136,18 @@ describe('NotificationsHandler', function () { }) describe('markAsReadByKeyOnly', function () { - beforeEach(function () { - this.key = 'some key here' + beforeEach(function (ctx) { + ctx.key = 'some key here' }) - it('should send a delete request when a delete has been received to mark a notification', async function () { - await this.handler.promises.markAsReadByKeyOnly(this.key) + it('should send a delete request when a delete has been received to mark a notification', async function (ctx) { + await ctx.handler.promises.markAsReadByKeyOnly(ctx.key) const opts = { - uri: `${notificationUrl}/key/${this.key}`, + uri: `${notificationUrl}/key/${ctx.key}`, timeout: 1000, method: 'DELETE', } - this.request.calledWith(opts).should.equal(true) + ctx.request.calledWith(opts).should.equal(true) }) }) }) diff --git a/services/web/test/unit/src/Project/FolderStructureBuilder.test.mjs b/services/web/test/unit/src/Project/FolderStructureBuilder.test.mjs index 0763ec74dc..7a61e2f2bd 100644 --- a/services/web/test/unit/src/Project/FolderStructureBuilder.test.mjs +++ b/services/web/test/unit/src/Project/FolderStructureBuilder.test.mjs @@ -1,26 +1,29 @@ -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const { ObjectId } = require('mongodb-legacy') -const sinon = require('sinon') +import { vi, expect } from 'vitest' +import mongodb from 'mongodb-legacy' +import sinon from 'sinon' + +const { ObjectId } = mongodb const MODULE_PATH = '../../../../app/src/Features/Project/FolderStructureBuilder' describe('FolderStructureBuilder', function () { - beforeEach(function () { - this.FolderStructureBuilder = SandboxedModule.require(MODULE_PATH, { - requires: { 'mongodb-legacy': { ObjectId } }, - }) + beforeEach(async function (ctx) { + vi.doMock('mongodb-legacy', () => ({ + default: { ObjectId }, + })) + + ctx.FolderStructureBuilder = (await import(MODULE_PATH)).default }) describe('buildFolderStructure', function () { describe('when given no documents at all', function () { - beforeEach(function () { - this.result = this.FolderStructureBuilder.buildFolderStructure([], []) + beforeEach(function (ctx) { + ctx.result = ctx.FolderStructureBuilder.buildFolderStructure([], []) }) - it('returns an empty root folder', function () { - sinon.assert.match(this.result, { + it('returns an empty root folder', function (ctx) { + sinon.assert.match(ctx.result, { _id: sinon.match.instanceOf(ObjectId), name: 'rootFolder', folders: [], @@ -31,7 +34,7 @@ describe('FolderStructureBuilder', function () { }) describe('when given documents and files', function () { - beforeEach(function () { + beforeEach(function (ctx) { const docUploads = [ { path: '/main.tex', doc: { _id: 'doc-1', name: 'main.tex' } }, { path: '/foo/other.tex', doc: { _id: 'doc-2', name: 'other.tex' } }, @@ -46,14 +49,14 @@ describe('FolderStructureBuilder', function () { { path: '/foo/bbb.jpg', file: { _id: 'file-2', name: 'bbb.jpg' } }, { path: '/bar/ccc.jpg', file: { _id: 'file-3', name: 'ccc.jpg' } }, ] - this.result = this.FolderStructureBuilder.buildFolderStructure( + ctx.result = ctx.FolderStructureBuilder.buildFolderStructure( docUploads, fileUploads ) }) - it('returns a full folder structure', function () { - sinon.assert.match(this.result, { + it('returns a full folder structure', function (ctx) { + sinon.assert.match(ctx.result, { _id: sinon.match.instanceOf(ObjectId), name: 'rootFolder', docs: [{ _id: 'doc-1', name: 'main.tex' }], @@ -98,13 +101,13 @@ describe('FolderStructureBuilder', function () { }) describe('when given duplicate files', function () { - it('throws an error', function () { + it('throws an error', function (ctx) { const docUploads = [ { path: '/foo/doc.tex', doc: { _id: 'doc-1', name: 'doc.tex' } }, { path: '/foo/doc.tex', doc: { _id: 'doc-2', name: 'doc.tex' } }, ] expect(() => - this.FolderStructureBuilder.buildFolderStructure(docUploads, []) + ctx.FolderStructureBuilder.buildFolderStructure(docUploads, []) ).to.throw() }) }) diff --git a/services/web/test/unit/src/Project/ProjectEditorHandler.test.mjs b/services/web/test/unit/src/Project/ProjectEditorHandler.test.mjs index 8456fe2227..a0494b71d7 100644 --- a/services/web/test/unit/src/Project/ProjectEditorHandler.test.mjs +++ b/services/web/test/unit/src/Project/ProjectEditorHandler.test.mjs @@ -1,12 +1,11 @@ -const _ = require('lodash') -const { expect } = require('chai') +import _ from 'lodash' +import { expect } from 'vitest' const modulePath = '../../../../app/src/Features/Project/ProjectEditorHandler' -const SandboxedModule = require('sandboxed-module') describe('ProjectEditorHandler', function () { - beforeEach(function () { - this.project = { + beforeEach(async function (ctx) { + ctx.project = { _id: 'project-id', owner_ref: 'owner-id', name: 'Project Name', @@ -27,14 +26,14 @@ describe('ProjectEditorHandler', function () { { _id: 'doc-id', name: 'main.tex', - lines: (this.lines = ['line 1', 'line 2', 'line 3']), + lines: (ctx.lines = ['line 1', 'line 2', 'line 3']), }, ], fileRefs: [ { _id: 'file-id', name: 'image.png', - created: (this.created = new Date()), + created: (ctx.created = new Date()), size: 1234, }, ], @@ -44,8 +43,8 @@ describe('ProjectEditorHandler', function () { }, ], } - this.ownerMember = { - user: (this.owner = { + ctx.ownerMember = { + user: (ctx.owner = { _id: 'owner-id', first_name: 'Owner', last_name: 'Overleaf', @@ -56,7 +55,7 @@ describe('ProjectEditorHandler', function () { }), privilegeLevel: 'owner', } - this.members = [ + ctx.members = [ { user: { _id: 'read-only-id', @@ -76,69 +75,69 @@ describe('ProjectEditorHandler', function () { privilegeLevel: 'readAndWrite', }, ] - this.invites = [ + ctx.invites = [ { _id: 'invite_one', email: 'user-one@example.com', privileges: 'readOnly', - projectId: this.project._id, + projectId: ctx.project._id, token: 'my-secret-token1', }, { _id: 'invite_two', email: 'user-two@example.com', privileges: 'readOnly', - projectId: this.project._id, + projectId: ctx.project._id, token: 'my-secret-token2', }, ] - this.handler = SandboxedModule.require(modulePath) + ctx.handler = (await import(modulePath)).default }) describe('buildProjectModelView', function () { describe('with owner, members and invites included', function () { - beforeEach(function () { - this.result = this.handler.buildProjectModelView( - this.project, - this.ownerMember, - this.members, - this.invites, + beforeEach(function (ctx) { + ctx.result = ctx.handler.buildProjectModelView( + ctx.project, + ctx.ownerMember, + ctx.members, + ctx.invites, false ) }) - it('should include the id', function () { - expect(this.result._id).to.exist - this.result._id.should.equal('project-id') + it('should include the id', function (ctx) { + expect(ctx.result._id).to.exist + ctx.result._id.should.equal('project-id') }) - it('should include the name', function () { - expect(this.result.name).to.exist - this.result.name.should.equal('Project Name') + it('should include the name', function (ctx) { + expect(ctx.result.name).to.exist + ctx.result.name.should.equal('Project Name') }) - it('should include the root doc id', function () { - expect(this.result.rootDoc_id).to.exist - this.result.rootDoc_id.should.equal('file-id') + it('should include the root doc id', function (ctx) { + expect(ctx.result.rootDoc_id).to.exist + ctx.result.rootDoc_id.should.equal('file-id') }) - it('should include the public access level', function () { - expect(this.result.publicAccesLevel).to.exist - this.result.publicAccesLevel.should.equal('private') + it('should include the public access level', function (ctx) { + expect(ctx.result.publicAccesLevel).to.exist + ctx.result.publicAccesLevel.should.equal('private') }) - it('should include the owner', function () { - expect(this.result.owner).to.exist - this.result.owner._id.should.equal('owner-id') - this.result.owner.email.should.equal('owner@overleaf.com') - this.result.owner.first_name.should.equal('Owner') - this.result.owner.last_name.should.equal('Overleaf') - this.result.owner.privileges.should.equal('owner') + it('should include the owner', function (ctx) { + expect(ctx.result.owner).to.exist + ctx.result.owner._id.should.equal('owner-id') + ctx.result.owner.email.should.equal('owner@overleaf.com') + ctx.result.owner.first_name.should.equal('Owner') + ctx.result.owner.last_name.should.equal('Overleaf') + ctx.result.owner.privileges.should.equal('owner') }) - it('should gather readOnly_refs and collaberators_refs into a list of members', function () { + it('should gather readOnly_refs and collaberators_refs into a list of members', function (ctx) { const findMember = id => { - for (const member of this.result.members) { + for (const member of ctx.result.members) { if (member._id === id) { return member } @@ -146,7 +145,7 @@ describe('ProjectEditorHandler', function () { return null } - this.result.members.length.should.equal(2) + ctx.result.members.length.should.equal(2) expect(findMember('read-only-id')).to.exist findMember('read-only-id').privileges.should.equal('readOnly') @@ -163,174 +162,174 @@ describe('ProjectEditorHandler', function () { ) }) - it('should include folders in the project', function () { - this.result.rootFolder[0]._id.should.equal('root-folder-id') - this.result.rootFolder[0].name.should.equal('') + it('should include folders in the project', function (ctx) { + ctx.result.rootFolder[0]._id.should.equal('root-folder-id') + ctx.result.rootFolder[0].name.should.equal('') - this.result.rootFolder[0].folders[0]._id.should.equal('sub-folder-id') - this.result.rootFolder[0].folders[0].name.should.equal('folder') + ctx.result.rootFolder[0].folders[0]._id.should.equal('sub-folder-id') + ctx.result.rootFolder[0].folders[0].name.should.equal('folder') }) - it('should not duplicate folder contents', function () { - this.result.rootFolder[0].docs.length.should.equal(0) - this.result.rootFolder[0].fileRefs.length.should.equal(0) + it('should not duplicate folder contents', function (ctx) { + ctx.result.rootFolder[0].docs.length.should.equal(0) + ctx.result.rootFolder[0].fileRefs.length.should.equal(0) }) - it('should include files in the project', function () { - this.result.rootFolder[0].folders[0].fileRefs[0]._id.should.equal( + it('should include files in the project', function (ctx) { + ctx.result.rootFolder[0].folders[0].fileRefs[0]._id.should.equal( 'file-id' ) - this.result.rootFolder[0].folders[0].fileRefs[0].name.should.equal( + ctx.result.rootFolder[0].folders[0].fileRefs[0].name.should.equal( 'image.png' ) - this.result.rootFolder[0].folders[0].fileRefs[0].created.should.equal( - this.created + ctx.result.rootFolder[0].folders[0].fileRefs[0].created.should.equal( + ctx.created ) - expect(this.result.rootFolder[0].folders[0].fileRefs[0].size).not.to + expect(ctx.result.rootFolder[0].folders[0].fileRefs[0].size).not.to .exist }) - it('should include docs in the project but not the lines', function () { - this.result.rootFolder[0].folders[0].docs[0]._id.should.equal('doc-id') - this.result.rootFolder[0].folders[0].docs[0].name.should.equal( + it('should include docs in the project but not the lines', function (ctx) { + ctx.result.rootFolder[0].folders[0].docs[0]._id.should.equal('doc-id') + ctx.result.rootFolder[0].folders[0].docs[0].name.should.equal( 'main.tex' ) - expect(this.result.rootFolder[0].folders[0].docs[0].lines).not.to.exist + expect(ctx.result.rootFolder[0].folders[0].docs[0].lines).not.to.exist }) - it('should include invites', function () { - expect(this.result.invites).to.exist - this.result.invites.should.deep.equal( - this.invites.map(invite => + it('should include invites', function (ctx) { + expect(ctx.result.invites).to.exist + ctx.result.invites.should.deep.equal( + ctx.invites.map(invite => _.pick(invite, ['_id', 'email', 'privileges']) ) ) }) - it('invites should not include the token', function () { - for (const invite of this.result.invites) { + it('invites should not include the token', function (ctx) { + for (const invite of ctx.result.invites) { expect(invite.token).not.to.exist } }) - it('should have the correct features', function () { - expect(this.result.features.compileTimeout).to.equal(240) + it('should have the correct features', function (ctx) { + expect(ctx.result.features.compileTimeout).to.equal(240) }) }) describe('with a restricted user', function () { - beforeEach(function () { - this.result = this.handler.buildProjectModelView( - this.project, - this.ownerMember, + beforeEach(function (ctx) { + ctx.result = ctx.handler.buildProjectModelView( + ctx.project, + ctx.ownerMember, [], [], true ) }) - it('should include the id', function () { - expect(this.result._id).to.exist - this.result._id.should.equal('project-id') + it('should include the id', function (ctx) { + expect(ctx.result._id).to.exist + ctx.result._id.should.equal('project-id') }) - it('should include the name', function () { - expect(this.result.name).to.exist - this.result.name.should.equal('Project Name') + it('should include the name', function (ctx) { + expect(ctx.result.name).to.exist + ctx.result.name.should.equal('Project Name') }) - it('should include the root doc id', function () { - expect(this.result.rootDoc_id).to.exist - this.result.rootDoc_id.should.equal('file-id') + it('should include the root doc id', function (ctx) { + expect(ctx.result.rootDoc_id).to.exist + ctx.result.rootDoc_id.should.equal('file-id') }) - it('should include the public access level', function () { - expect(this.result.publicAccesLevel).to.exist - this.result.publicAccesLevel.should.equal('private') + it('should include the public access level', function (ctx) { + expect(ctx.result.publicAccesLevel).to.exist + ctx.result.publicAccesLevel.should.equal('private') }) - it('should hide the owner', function () { - expect(this.result.owner).to.deep.equal({ _id: 'owner-id' }) + it('should hide the owner', function (ctx) { + expect(ctx.result.owner).to.deep.equal({ _id: 'owner-id' }) }) - it('should hide members', function () { - this.result.members.length.should.equal(0) + it('should hide members', function (ctx) { + ctx.result.members.length.should.equal(0) }) - it('should include folders in the project', function () { - this.result.rootFolder[0]._id.should.equal('root-folder-id') - this.result.rootFolder[0].name.should.equal('') + it('should include folders in the project', function (ctx) { + ctx.result.rootFolder[0]._id.should.equal('root-folder-id') + ctx.result.rootFolder[0].name.should.equal('') - this.result.rootFolder[0].folders[0]._id.should.equal('sub-folder-id') - this.result.rootFolder[0].folders[0].name.should.equal('folder') + ctx.result.rootFolder[0].folders[0]._id.should.equal('sub-folder-id') + ctx.result.rootFolder[0].folders[0].name.should.equal('folder') }) - it('should not duplicate folder contents', function () { - this.result.rootFolder[0].docs.length.should.equal(0) - this.result.rootFolder[0].fileRefs.length.should.equal(0) + it('should not duplicate folder contents', function (ctx) { + ctx.result.rootFolder[0].docs.length.should.equal(0) + ctx.result.rootFolder[0].fileRefs.length.should.equal(0) }) - it('should include files in the project', function () { - this.result.rootFolder[0].folders[0].fileRefs[0]._id.should.equal( + it('should include files in the project', function (ctx) { + ctx.result.rootFolder[0].folders[0].fileRefs[0]._id.should.equal( 'file-id' ) - this.result.rootFolder[0].folders[0].fileRefs[0].name.should.equal( + ctx.result.rootFolder[0].folders[0].fileRefs[0].name.should.equal( 'image.png' ) - this.result.rootFolder[0].folders[0].fileRefs[0].created.should.equal( - this.created + ctx.result.rootFolder[0].folders[0].fileRefs[0].created.should.equal( + ctx.created ) - expect(this.result.rootFolder[0].folders[0].fileRefs[0].size).not.to + expect(ctx.result.rootFolder[0].folders[0].fileRefs[0].size).not.to .exist }) - it('should include docs in the project but not the lines', function () { - this.result.rootFolder[0].folders[0].docs[0]._id.should.equal('doc-id') - this.result.rootFolder[0].folders[0].docs[0].name.should.equal( + it('should include docs in the project but not the lines', function (ctx) { + ctx.result.rootFolder[0].folders[0].docs[0]._id.should.equal('doc-id') + ctx.result.rootFolder[0].folders[0].docs[0].name.should.equal( 'main.tex' ) - expect(this.result.rootFolder[0].folders[0].docs[0].lines).not.to.exist + expect(ctx.result.rootFolder[0].folders[0].docs[0].lines).not.to.exist }) - it('should hide invites', function () { - expect(this.result.invites).to.have.length(0) + it('should hide invites', function (ctx) { + expect(ctx.result.invites).to.have.length(0) }) - it('should have the correct features', function () { - expect(this.result.features.compileTimeout).to.equal(240) + it('should have the correct features', function (ctx) { + expect(ctx.result.features.compileTimeout).to.equal(240) }) }) describe('deletedByExternalDataSource', function () { - it('should set the deletedByExternalDataSource flag to false when it is not there', function () { - delete this.project.deletedByExternalDataSource - const result = this.handler.buildProjectModelView( - this.project, - this.ownerMember, - this.members, + it('should set the deletedByExternalDataSource flag to false when it is not there', function (ctx) { + delete ctx.project.deletedByExternalDataSource + const result = ctx.handler.buildProjectModelView( + ctx.project, + ctx.ownerMember, + ctx.members, [], false ) result.deletedByExternalDataSource.should.equal(false) }) - it('should set the deletedByExternalDataSource flag to false when it is false', function () { - const result = this.handler.buildProjectModelView( - this.project, - this.ownerMember, - this.members, + it('should set the deletedByExternalDataSource flag to false when it is false', function (ctx) { + const result = ctx.handler.buildProjectModelView( + ctx.project, + ctx.ownerMember, + ctx.members, [], false ) result.deletedByExternalDataSource.should.equal(false) }) - it('should set the deletedByExternalDataSource flag to true when it is true', function () { - this.project.deletedByExternalDataSource = true - const result = this.handler.buildProjectModelView( - this.project, - this.ownerMember, - this.members, + it('should set the deletedByExternalDataSource flag to true when it is true', function (ctx) { + ctx.project.deletedByExternalDataSource = true + const result = ctx.handler.buildProjectModelView( + ctx.project, + ctx.ownerMember, + ctx.members, [], false ) @@ -339,60 +338,60 @@ describe('ProjectEditorHandler', function () { }) describe('features', function () { - beforeEach(function () { - this.owner.features = { + beforeEach(function (ctx) { + ctx.owner.features = { versioning: true, collaborators: 3, compileGroup: 'priority', compileTimeout: 96, } - this.result = this.handler.buildProjectModelView( - this.project, - this.ownerMember, - this.members, + ctx.result = ctx.handler.buildProjectModelView( + ctx.project, + ctx.ownerMember, + ctx.members, [], false ) }) - it('should copy the owner features to the project', function () { - this.result.features.versioning.should.equal( - this.owner.features.versioning + it('should copy the owner features to the project', function (ctx) { + ctx.result.features.versioning.should.equal( + ctx.owner.features.versioning ) - this.result.features.collaborators.should.equal( - this.owner.features.collaborators + ctx.result.features.collaborators.should.equal( + ctx.owner.features.collaborators ) - this.result.features.compileGroup.should.equal( - this.owner.features.compileGroup + ctx.result.features.compileGroup.should.equal( + ctx.owner.features.compileGroup ) - this.result.features.compileTimeout.should.equal( - this.owner.features.compileTimeout + ctx.result.features.compileTimeout.should.equal( + ctx.owner.features.compileTimeout ) }) }) describe('trackChangesState', function () { describe('when the owner does not have the trackChanges feature', function () { - beforeEach(function () { - this.owner.features = { + beforeEach(function (ctx) { + ctx.owner.features = { trackChanges: false, } - this.result = this.handler.buildProjectModelView( - this.project, - this.ownerMember, - this.members, + ctx.result = ctx.handler.buildProjectModelView( + ctx.project, + ctx.ownerMember, + ctx.members, [], false ) }) - it('should not emit trackChangesState', function () { - expect(this.result.trackChangesState).to.not.exist + it('should not emit trackChangesState', function (ctx) { + expect(ctx.result.trackChangesState).to.not.exist }) }) describe('when the owner has got the trackChanges feature', function () { - beforeEach(function () { - this.owner.features = { + beforeEach(function (ctx) { + ctx.owner.features = { trackChanges: true, } }) @@ -401,18 +400,18 @@ describe('ProjectEditorHandler', function () { describe(`when track_changes is ${JSON.stringify( dbEntry )}`, function () { - beforeEach(function () { - this.project.track_changes = dbEntry - this.result = this.handler.buildProjectModelView( - this.project, - this.ownerMember, - this.members, + beforeEach(function (ctx) { + ctx.project.track_changes = dbEntry + ctx.result = ctx.handler.buildProjectModelView( + ctx.project, + ctx.ownerMember, + ctx.members, [], false ) }) - it(`should set trackChangesState=${expected}`, function () { - expect(this.result.trackChangesState).to.deep.equal(expected) + it(`should set trackChangesState=${expected}`, function (ctx) { + expect(ctx.result.trackChangesState).to.deep.equal(expected) }) }) } diff --git a/services/web/test/unit/src/Project/ProjectHelper.test.mjs b/services/web/test/unit/src/Project/ProjectHelper.test.mjs index 63321e1cc5..e8f8564e66 100644 --- a/services/web/test/unit/src/Project/ProjectHelper.test.mjs +++ b/services/web/test/unit/src/Project/ProjectHelper.test.mjs @@ -1,8 +1,9 @@ -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const { ObjectId } = require('mongodb-legacy') +import { vi, expect } from 'vitest' +import mongodb from 'mongodb-legacy' -const MODULE_PATH = '../../../../app/src/Features/Project/ProjectHelper.js' +const { ObjectId } = mongodb + +const MODULE_PATH = '../../../../app/src/Features/Project/ProjectHelper.mjs' function _mapToAllowed(images) { return images.map(image => { @@ -11,12 +12,12 @@ function _mapToAllowed(images) { } describe('ProjectHelper', function () { - beforeEach(function () { - this.project = { + beforeEach(async function (ctx) { + ctx.project = { _id: '123213jlkj9kdlsaj', } - this.user = { + ctx.user = { _id: '588f3ddae8ebc1bac07c9fa4', first_name: 'bjkdsjfk', features: {}, @@ -24,13 +25,13 @@ describe('ProjectHelper', function () { labsExperiments: ['monthly-texlive'], } - this.adminUser = { + ctx.adminUser = { _id: 'admin-user-id', isAdmin: true, alphaProgram: true, } - this.Settings = { + ctx.Settings = { adminPrivilegeAvailable: true, allowedImageNames: [ { imageName: 'texlive-full:2018.1', imageDesc: 'TeX Live 2018' }, @@ -48,101 +49,104 @@ describe('ProjectHelper', function () { ], } - this.ProjectHelper = SandboxedModule.require(MODULE_PATH, { - requires: { - 'mongodb-legacy': { ObjectId }, - '@overleaf/settings': this.Settings, - }, - }) + vi.doMock('mongodb-legacy', () => ({ + default: { ObjectId }, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + ctx.ProjectHelper = (await import(MODULE_PATH)).default }) describe('isArchived', function () { describe('project.archived being an array', function () { - it('returns true if user id is found', function () { - this.project.archived = [ + it('returns true if user id is found', function (ctx) { + ctx.project.archived = [ new ObjectId('588f3ddae8ebc1bac07c9fa4'), new ObjectId('5c41deb2b4ca500153340809'), ] expect( - this.ProjectHelper.isArchived(this.project, this.user._id) + ctx.ProjectHelper.isArchived(ctx.project, ctx.user._id) ).to.equal(true) }) - it('returns false if user id is not found', function () { - this.project.archived = [] + it('returns false if user id is not found', function (ctx) { + ctx.project.archived = [] expect( - this.ProjectHelper.isArchived(this.project, this.user._id) + ctx.ProjectHelper.isArchived(ctx.project, ctx.user._id) ).to.equal(false) }) }) describe('project.archived being undefined', function () { - it('returns false if archived is undefined', function () { - this.project.archived = undefined + it('returns false if archived is undefined', function (ctx) { + ctx.project.archived = undefined expect( - this.ProjectHelper.isArchived(this.project, this.user._id) + ctx.ProjectHelper.isArchived(ctx.project, ctx.user._id) ).to.equal(false) }) }) }) describe('isTrashed', function () { - it('returns true if user id is found', function () { - this.project.trashed = [ + it('returns true if user id is found', function (ctx) { + ctx.project.trashed = [ new ObjectId('588f3ddae8ebc1bac07c9fa4'), new ObjectId('5c41deb2b4ca500153340809'), ] - expect( - this.ProjectHelper.isTrashed(this.project, this.user._id) - ).to.equal(true) + expect(ctx.ProjectHelper.isTrashed(ctx.project, ctx.user._id)).to.equal( + true + ) }) - it('returns false if user id is not found', function () { - this.project.trashed = [] - expect( - this.ProjectHelper.isTrashed(this.project, this.user._id) - ).to.equal(false) + it('returns false if user id is not found', function (ctx) { + ctx.project.trashed = [] + expect(ctx.ProjectHelper.isTrashed(ctx.project, ctx.user._id)).to.equal( + false + ) }) describe('project.trashed being undefined', function () { - it('returns false if trashed is undefined', function () { - this.project.trashed = undefined - expect( - this.ProjectHelper.isTrashed(this.project, this.user._id) - ).to.equal(false) + it('returns false if trashed is undefined', function (ctx) { + ctx.project.trashed = undefined + expect(ctx.ProjectHelper.isTrashed(ctx.project, ctx.user._id)).to.equal( + false + ) }) }) }) describe('compilerFromV1Engine', function () { - it('returns the correct engine for latex_dvipdf', function () { - expect(this.ProjectHelper.compilerFromV1Engine('latex_dvipdf')).to.equal( + it('returns the correct engine for latex_dvipdf', function (ctx) { + expect(ctx.ProjectHelper.compilerFromV1Engine('latex_dvipdf')).to.equal( 'latex' ) }) - it('returns the correct engine for pdflatex', function () { - expect(this.ProjectHelper.compilerFromV1Engine('pdflatex')).to.equal( + it('returns the correct engine for pdflatex', function (ctx) { + expect(ctx.ProjectHelper.compilerFromV1Engine('pdflatex')).to.equal( 'pdflatex' ) }) - it('returns the correct engine for xelatex', function () { - expect(this.ProjectHelper.compilerFromV1Engine('xelatex')).to.equal( + it('returns the correct engine for xelatex', function (ctx) { + expect(ctx.ProjectHelper.compilerFromV1Engine('xelatex')).to.equal( 'xelatex' ) }) - it('returns the correct engine for lualatex', function () { - expect(this.ProjectHelper.compilerFromV1Engine('lualatex')).to.equal( + it('returns the correct engine for lualatex', function (ctx) { + expect(ctx.ProjectHelper.compilerFromV1Engine('lualatex')).to.equal( 'lualatex' ) }) }) describe('getAllowedImagesForUser', function () { - it('marks alpha only images as not allowed when the user is anonymous', function () { - const images = this.ProjectHelper.getAllowedImagesForUser(null) + it('marks alpha only images as not allowed when the user is anonymous', function (ctx) { + const images = ctx.ProjectHelper.getAllowedImagesForUser(null) const imageNames = _mapToAllowed(images) expect(imageNames).to.deep.equal([ { imageName: 'texlive-full:2018.1', allowed: true }, @@ -152,8 +156,8 @@ describe('ProjectHelper', function () { ]) }) - it('marks monthly labs images as not allowed when the user is anonymous', function () { - const images = this.ProjectHelper.getAllowedImagesForUser(null) + it('marks monthly labs images as not allowed when the user is anonymous', function (ctx) { + const images = ctx.ProjectHelper.getAllowedImagesForUser(null) const imageNames = _mapToAllowed(images) expect(imageNames).to.deep.equal([ { imageName: 'texlive-full:2018.1', allowed: true }, @@ -163,8 +167,8 @@ describe('ProjectHelper', function () { ]) }) - it('marks monthly labs images as allowed when the user is enrolled', function () { - const images = this.ProjectHelper.getAllowedImagesForUser(this.user) + it('marks monthly labs images as allowed when the user is enrolled', function (ctx) { + const images = ctx.ProjectHelper.getAllowedImagesForUser(ctx.user) const imageNames = _mapToAllowed(images) expect(imageNames).to.deep.equal([ { imageName: 'texlive-full:2018.1', allowed: true }, @@ -174,8 +178,8 @@ describe('ProjectHelper', function () { ]) }) - it('marks alpha only images as not allowed when when the user is not admin', function () { - const images = this.ProjectHelper.getAllowedImagesForUser(this.user) + it('marks alpha only images as not allowed when when the user is not admin', function (ctx) { + const images = ctx.ProjectHelper.getAllowedImagesForUser(ctx.user) const imageNames = _mapToAllowed(images) expect(imageNames).to.deep.equal([ { imageName: 'texlive-full:2018.1', allowed: true }, @@ -185,8 +189,8 @@ describe('ProjectHelper', function () { ]) }) - it('returns all images when the user is admin', function () { - const images = this.ProjectHelper.getAllowedImagesForUser(this.adminUser) + it('returns all images when the user is admin', function (ctx) { + const images = ctx.ProjectHelper.getAllowedImagesForUser(ctx.adminUser) const imageNames = _mapToAllowed(images) expect(imageNames).to.deep.equal([ { imageName: 'texlive-full:2018.1', allowed: true }, diff --git a/services/web/test/unit/src/Project/ProjectListController.test.mjs b/services/web/test/unit/src/Project/ProjectListController.test.mjs index aa13023245..28307bd9e9 100644 --- a/services/web/test/unit/src/Project/ProjectListController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectListController.test.mjs @@ -1,4 +1,4 @@ -import { expect, vi } from 'vitest' +import { beforeEach, describe, it, expect, vi } from 'vitest' import sinon from 'sinon' import mongodb from 'mongodb-legacy' import Errors from '../../../../app/src/Features/Errors/Errors.js' @@ -7,9 +7,8 @@ const ObjectId = mongodb.ObjectId const MODULE_PATH = `${import.meta.dirname}/../../../../app/src/Features/Project/ProjectListController` -// Mock AnalyticsManager as it isn't used in these tests but causes the User model to be imported -// TODO: remove this once all models are ESM and this kind of mocking is no longer necessary -vi.mock('../../../../app/src/Features/Analytics/AnalyticsManager.js', () => { +// Mock AnalyticsManager as it isn't used in these tests but causes the User model to be imported and redeclares queues +vi.mock('../../../../app/src/Features/Analytics/AnalyticsManager.mjs', () => { return {} }) @@ -368,190 +367,154 @@ describe('ProjectListController', function () { }) it('should render the project/list-react page', async function (ctx) { - await new Promise(resolve => { - ctx.res.render = (pageName, opts) => { - pageName.should.equal('project/list-react') - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.res.render = (pageName, opts) => { + pageName.should.equal('project/list-react') + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should invoke the session maintenance', async function (ctx) { - await new Promise(resolve => { - ctx.Features.hasFeature.withArgs('saas').returns(true) - ctx.res.render = () => { - ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith( - ctx.req, - ctx.user - ) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.res.render = () => { + ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith( + ctx.req, + ctx.user + ) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should send the tags', async function (ctx) { - await new Promise(resolve => { - ctx.res.render = (pageName, opts) => { - opts.tags.length.should.equal(ctx.tags.length) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.res.render = (pageName, opts) => { + opts.tags.length.should.equal(ctx.tags.length) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should create trigger ip matcher notifications', async function (ctx) { - await new Promise(resolve => { - ctx.settings.overleaf = true - ctx.req.ip = '111.111.111.111' - ctx.res.render = (pageName, opts) => { - ctx.NotificationBuilder.promises.ipMatcherAffiliation.called.should.equal( - true - ) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.settings.overleaf = true + ctx.req.ip = '111.111.111.111' + ctx.res.render = (pageName, opts) => { + ctx.NotificationBuilder.promises.ipMatcherAffiliation.called.should.equal( + true + ) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should send the projects', async function (ctx) { - await new Promise(resolve => { - ctx.res.render = (pageName, opts) => { - opts.prefetchedProjectsBlob.projects.length.should.equal( - ctx.projects.length + - ctx.readAndWrite.length + - ctx.readOnly.length + - ctx.tokenReadAndWrite.length + - ctx.tokenReadOnly.length + - ctx.review.length - ) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.res.render = (pageName, opts) => { + opts.prefetchedProjectsBlob.projects.length.should.equal( + ctx.projects.length + + ctx.readAndWrite.length + + ctx.readOnly.length + + ctx.tokenReadAndWrite.length + + ctx.tokenReadOnly.length + + ctx.review.length + ) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should send the user', async function (ctx) { - await new Promise(resolve => { - ctx.res.render = (pageName, opts) => { - opts.user.should.deep.equal(ctx.user) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.res.render = (pageName, opts) => { + opts.user.should.deep.equal(ctx.user) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should inject the users', async function (ctx) { - await new Promise(resolve => { - ctx.res.render = (pageName, opts) => { - const projects = opts.prefetchedProjectsBlob.projects + ctx.res.render = (pageName, opts) => { + const projects = opts.prefetchedProjectsBlob.projects - projects - .filter(p => p.id === '1')[0] - .owner.firstName.should.equal( - ctx.users[ctx.projects.filter(p => p._id === 1)[0].owner_ref] - .first_name - ) - projects - .filter(p => p.id === '2')[0] - .owner.firstName.should.equal( - ctx.users[ctx.projects.filter(p => p._id === 2)[0].owner_ref] - .first_name - ) - projects - .filter(p => p.id === '2')[0] - .lastUpdatedBy.firstName.should.equal( - ctx.users[ctx.projects.filter(p => p._id === 2)[0].lastUpdatedBy] - .first_name - ) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + projects + .filter(p => p.id === '1')[0] + .owner.firstName.should.equal( + ctx.users[ctx.projects.filter(p => p._id === 1)[0].owner_ref] + .first_name + ) + projects + .filter(p => p.id === '2')[0] + .owner.firstName.should.equal( + ctx.users[ctx.projects.filter(p => p._id === 2)[0].owner_ref] + .first_name + ) + projects + .filter(p => p.id === '2')[0] + .lastUpdatedBy.firstName.should.equal( + ctx.users[ctx.projects.filter(p => p._id === 2)[0].lastUpdatedBy] + .first_name + ) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it("should send the user's best subscription when saas feature present", async function (ctx) { - await new Promise(resolve => { - ctx.Features.hasFeature.withArgs('saas').returns(true) - ctx.res.render = (pageName, opts) => { - expect(opts.usersBestSubscription).to.deep.include({ type: 'free' }) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.res.render = (pageName, opts) => { + expect(opts.usersBestSubscription).to.deep.include({ type: 'free' }) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should not return a best subscription without saas feature', async function (ctx) { - await new Promise(resolve => { - ctx.Features.hasFeature.withArgs('saas').returns(false) - ctx.res.render = (pageName, opts) => { - expect(opts.usersBestSubscription).to.be.undefined - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.Features.hasFeature.withArgs('saas').returns(false) + ctx.res.render = (pageName, opts) => { + expect(opts.usersBestSubscription).to.be.undefined + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should show INR Banner for Indian users with free account', async function (ctx) { - await new Promise(resolve => { - // usersBestSubscription is only available when saas feature is present - ctx.Features.hasFeature.withArgs('saas').returns(true) - ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( - { - bestSubscription: { - type: 'free', - }, - } - ) - ctx.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'IN', - }) - ctx.res.render = (pageName, opts) => { - expect(opts.showInrGeoBanner).to.be.true - resolve() + // usersBestSubscription is only available when saas feature is present + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( + { + bestSubscription: { + type: 'free', + }, } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + ) + ctx.GeoIpLookup.promises.getCurrencyCode.resolves({ + countryCode: 'IN', }) + ctx.res.render = (pageName, opts) => { + expect(opts.showInrGeoBanner).to.be.true + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should not show INR Banner for Indian users with premium account', async function (ctx) { - await new Promise(resolve => { - // usersBestSubscription is only available when saas feature is present - ctx.Features.hasFeature.withArgs('saas').returns(true) - ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( - { - bestSubscription: { - type: 'individual', - }, - } - ) - ctx.GeoIpLookup.promises.getCurrencyCode.resolves({ - countryCode: 'IN', - }) - ctx.res.render = (pageName, opts) => { - expect(opts.showInrGeoBanner).to.be.false - resolve() + // usersBestSubscription is only available when saas feature is present + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( + { + bestSubscription: { + type: 'individual', + }, } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + ) + ctx.GeoIpLookup.promises.getCurrencyCode.resolves({ + countryCode: 'IN', }) + ctx.res.render = (pageName, opts) => { + expect(opts.showInrGeoBanner).to.be.false + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should redirect to domain capture page', async function (ctx) { - await new Promise(resolve => { - ctx.Features.hasFeature.withArgs('saas').returns(true) - ctx.SplitTestHandler.promises.getAssignment - .withArgs(ctx.req, ctx.res, 'domain-capture-redirect') - .resolves({ variant: 'enabled' }) - ctx.Modules.promises.hooks.fire - .withArgs('findDomainCaptureGroupUserCouldBePartOf', ctx.user._id) - .resolves([{ _id: new ObjectId(), managedUsersEnabled: true }]) - ctx.res.redirect = url => { - url.should.equal('/domain-capture') - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.SplitTestHandler.promises.getAssignment + .withArgs(ctx.req, ctx.res, 'domain-capture-redirect') + .resolves({ variant: 'enabled' }) + ctx.Modules.promises.hooks.fire + .withArgs('findDomainCaptureGroupUserCouldBePartOf', ctx.user._id) + .resolves([{ _id: new ObjectId(), managedUsersEnabled: true }]) + ctx.res.redirect = url => { + url.should.equal('/domain-capture') + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) describe('when user linked to SSO', function () { @@ -572,18 +535,15 @@ describe('ProjectListController', function () { }) it('should render with Commons template when Commons was linked', async function (ctx) { - await new Promise(resolve => { - ctx.res.render = (pageName, opts) => { - expect(opts.notificationsInstitution).to.deep.equal([ - Object.assign( - { templateKey: 'notification_institution_sso_linked' }, - notificationData - ), - ]) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.equal([ + Object.assign( + { templateKey: 'notification_institution_sso_linked' }, + notificationData + ), + ]) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) describe('when via domain capture', function () { @@ -592,18 +552,15 @@ describe('ProjectListController', function () { }) it('should render with group template', async function (ctx) { - await new Promise(resolve => { - ctx.res.render = (pageName, opts) => { - expect(opts.notificationsInstitution).to.deep.equal([ - Object.assign( - { templateKey: 'notification_group_sso_linked' }, - notificationData - ), - ]) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.equal([ + Object.assign( + { templateKey: 'notification_group_sso_linked' }, + notificationData + ), + ]) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) describe('user created via domain capture and group is managed', function () { @@ -611,56 +568,47 @@ describe('ProjectListController', function () { ctx.req.session.saml.userCreatedViaDomainCapture = true }) it('should render with notification_group_sso_linked', async function (ctx) { - await new Promise(resolve => { - ctx.res.render = (pageName, opts) => { - expect(opts.notificationsInstitution).to.deep.equal([ - Object.assign( - { - templateKey: 'notification_group_sso_linked', - }, - notificationData - ), - ]) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.equal([ + Object.assign( + { + templateKey: 'notification_group_sso_linked', + }, + notificationData + ), + ]) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should render with notification_account_created_via_group_domain_capture_and_managed_users_enabled when managed user is enabled', async function (ctx) { ctx.req.session.saml.managedUsersEnabled = true - await new Promise(resolve => { - ctx.res.render = (pageName, opts) => { - expect(opts.notificationsInstitution).to.deep.equal([ - Object.assign( - { - templateKey: - 'notification_account_created_via_group_domain_capture_and_managed_users_enabled', - }, - notificationData - ), - ]) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.res.render = (pageName, opts) => { + expect(opts.notificationsInstitution).to.deep.equal([ + Object.assign( + { + templateKey: + 'notification_account_created_via_group_domain_capture_and_managed_users_enabled', + }, + notificationData + ), + ]) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) }) }) describe('With Institution SSO feature', function () { - beforeEach(async function (ctx) { - await new Promise(resolve => { - ctx.institutionEmail = 'test@overleaf.com' - ctx.institutionName = 'Overleaf' - ctx.Features.hasFeature.withArgs('saml').returns(true) - ctx.Features.hasFeature.withArgs('affiliations').returns(true) - ctx.Features.hasFeature.withArgs('saas').returns(true) - resolve() - }) + beforeEach(function (ctx) { + ctx.institutionEmail = 'test@overleaf.com' + ctx.institutionName = 'Overleaf' + ctx.Features.hasFeature.withArgs('saml').returns(true) + ctx.Features.hasFeature.withArgs('affiliations').returns(true) + ctx.Features.hasFeature.withArgs('saas').returns(true) }) - it('should show institution SSO available notification for confirmed domains', function (ctx) { + it('should show institution SSO available notification for confirmed domains', async function (ctx) { ctx.UserGetter.promises.getUserFullEmails.resolves([ { email: 'test@overleaf.com', @@ -683,9 +631,9 @@ describe('ProjectListController', function () { templateKey: 'notification_institution_sso_available', }) } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should show a linked notification', function (ctx) { + it('should show a linked notification', async function (ctx) { ctx.req.session.saml = { institutionEmail: ctx.institutionEmail, linked: { @@ -700,9 +648,9 @@ describe('ProjectListController', function () { templateKey: 'notification_institution_sso_linked', }) } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should show a group linked notification when domain capture enabled', function (ctx) { + it('should show a group linked notification when domain capture enabled', async function (ctx) { ctx.req.session.saml = { institutionEmail: ctx.institutionEmail, linked: { @@ -718,9 +666,9 @@ describe('ProjectListController', function () { templateKey: 'notification_group_sso_linked', }) } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should show a success notification when joining group via domain capture page', function (ctx) { + it('should show a success notification when joining group via domain capture page', async function (ctx) { ctx.req.session.saml = { linkedGroup: true, universityName: ctx.institutionName, @@ -733,9 +681,9 @@ describe('ProjectListController', function () { viaDomainCapture: true, }) } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should show a linked another email notification', function (ctx) { + it('should show a linked another email notification', async function (ctx) { // when they request to link an email but the institution returns // a different email ctx.res.render = (pageName, opts) => { @@ -754,10 +702,10 @@ describe('ProjectListController', function () { universityName: ctx.institutionName, }, } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should show a notification when intent was to register via SSO but account existed', function (ctx) { + it('should show a notification when intent was to register via SSO but account existed', async function (ctx) { ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.include({ email: ctx.institutionEmail, @@ -775,10 +723,10 @@ describe('ProjectListController', function () { name: 'Example University', }, } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should not show a register notification if the flow was abandoned', function (ctx) { + it('should not show a register notification if the flow was abandoned', async function (ctx) { // could initially start to register with an SSO email and then // abandon flow and login with an existing non-institution SSO email ctx.res.render = (pageName, opts) => { @@ -793,10 +741,10 @@ describe('ProjectListController', function () { name: 'Example University', }, } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should show error notification', function (ctx) { + it('should show error notification', async function (ctx) { ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution.length).to.equal(1) expect(opts.notificationsInstitution[0].templateKey).to.equal( @@ -810,38 +758,35 @@ describe('ProjectListController', function () { institutionEmail: ctx.institutionEmail, error: new Errors.SAMLAlreadyLinkedError(), } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) describe('for an unconfirmed domain for an SSO institution', function () { - beforeEach(async function (ctx) { - await new Promise(resolve => { - ctx.UserGetter.promises.getUserFullEmails.resolves([ - { - email: 'test@overleaf-uncofirmed.com', - affiliation: { - institution: { - id: 1, - confirmed: false, - name: 'Overleaf', - ssoBeta: false, - ssoEnabled: true, - }, + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves([ + { + email: 'test@overleaf-uncofirmed.com', + affiliation: { + institution: { + id: 1, + confirmed: false, + name: 'Overleaf', + ssoBeta: false, + ssoEnabled: true, }, }, - ]) - resolve() - }) + }, + ]) }) - it('should not show institution SSO available notification', function (ctx) { + it('should not show institution SSO available notification', async function (ctx) { ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution.length).to.equal(0) } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) describe('when linking/logging in initiated on institution side', function () { - it('should not show a linked another email notification', function (ctx) { + it('should not show a linked another email notification', async function (ctx) { // this is only used when initated on Overleaf, // because we keep track of the requested email they tried to link ctx.res.render = (pageName, opts) => { @@ -859,30 +804,27 @@ describe('ProjectListController', function () { universityName: ctx.institutionName, }, } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) describe('Institution with SSO beta testable', function () { - beforeEach(async function (ctx) { - await new Promise(resolve => { - ctx.UserGetter.promises.getUserFullEmails.resolves([ - { - email: 'beta@beta.com', - affiliation: { - institution: { - id: 2, - confirmed: true, - name: 'Beta University', - ssoBeta: true, - ssoEnabled: false, - }, + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves([ + { + email: 'beta@beta.com', + affiliation: { + institution: { + id: 2, + confirmed: true, + name: 'Beta University', + ssoBeta: true, + ssoEnabled: false, }, }, - ]) - resolve() - }) + }, + ]) }) - it('should show institution SSO available notification when on a beta testing session', function (ctx) { + it('should show institution SSO available notification when on a beta testing session', async function (ctx) { ctx.req.session.samlBeta = true ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.include({ @@ -892,9 +834,9 @@ describe('ProjectListController', function () { templateKey: 'notification_institution_sso_available', }) } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('should not show institution SSO available notification when not on a beta testing session', function (ctx) { + it('should not show institution SSO available notification when not on a beta testing session', async function (ctx) { ctx.req.session.samlBeta = false ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.not.include({ @@ -904,11 +846,11 @@ describe('ProjectListController', function () { templateKey: 'notification_institution_sso_available', }) } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) describe('group domain capture enabled for domain', function () { - it('does not show institution SSO available notification', function (ctx) { + it('does not show institution SSO available notification', async function (ctx) { ctx.UserGetter.promises.getUserFullEmails.resolves([ { email: 'test@overleaf.com', @@ -926,20 +868,17 @@ describe('ProjectListController', function () { ]) ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.equal([]) - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) }) describe('Without Institution SSO feature', function () { - beforeEach(async function (ctx) { - await new Promise(resolve => { - ctx.Features.hasFeature.withArgs('saml').returns(false) - resolve() - }) + beforeEach(function (ctx) { + ctx.Features.hasFeature.withArgs('saml').returns(false) }) - it('should not show institution sso available notification', function (ctx) { + it('should not show institution sso available notification', async function (ctx) { ctx.res.render = (pageName, opts) => { expect(opts.notificationsInstitution).to.deep.not.include({ email: 'test@overleaf.com', @@ -948,7 +887,7 @@ describe('ProjectListController', function () { templateKey: 'notification_institution_sso_available', }) } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) @@ -966,14 +905,14 @@ describe('ProjectListController', function () { }) describe('normal enterprise banner', function () { - it('shows banner', function (ctx) { + it('shows banner', async function (ctx) { ctx.res.render = (pageName, opts) => { expect(opts.showGroupsAndEnterpriseBanner).to.be.true } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('does not show banner if user is part of any affiliation', function (ctx) { + it('does not show banner if user is part of any affiliation', async function (ctx) { ctx.UserGetter.promises.getUserFullEmails.resolves([ { email: 'test@overleaf.com', @@ -993,10 +932,10 @@ describe('ProjectListController', function () { ctx.res.render = (pageName, opts) => { expect(opts.showGroupsAndEnterpriseBanner).to.be.false } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('does not show banner if user is part of any group subscription', function (ctx) { + it('does not show banner if user is part of any group subscription', async function (ctx) { ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( { memberGroupSubscriptions: [{}] } ) @@ -1004,22 +943,22 @@ describe('ProjectListController', function () { ctx.res.render = (pageName, opts) => { expect(opts.showGroupsAndEnterpriseBanner).to.be.false } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) - it('have a banner variant of "FOMO" or "on-premise"', function (ctx) { + it('have a banner variant of "FOMO" or "on-premise"', async function (ctx) { ctx.res.render = (pageName, opts) => { expect(opts.groupsAndEnterpriseBannerVariant).to.be.oneOf([ 'FOMO', 'on-premise', ]) } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) describe('US government enterprise banner', function () { - it('does not show enterprise banner if US government enterprise banner is shown', function (ctx) { + it('does not show enterprise banner if US government enterprise banner is shown', async function (ctx) { const emails = [ { email: 'test@test.mil', @@ -1040,7 +979,7 @@ describe('ProjectListController', function () { expect(opts.showGroupsAndEnterpriseBanner).to.be.false expect(opts.showUSGovBanner).to.be.true } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) }) @@ -1077,31 +1016,25 @@ describe('ProjectListController', function () { }) it('should render the project/list-react page', async function (ctx) { - await new Promise(resolve => { - ctx.res.render = (pageName, opts) => { - pageName.should.equal('project/list-react') - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.res.render = (pageName, opts) => { + pageName.should.equal('project/list-react') + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) it('should omit one of the projects', async function (ctx) { - await new Promise(resolve => { - ctx.res.render = (pageName, opts) => { - opts.prefetchedProjectsBlob.projects.length.should.equal( - ctx.projects.length + - ctx.readAndWrite.length + - ctx.readOnly.length + - ctx.tokenReadAndWrite.length + - ctx.tokenReadOnly.length + - ctx.review.length - - 1 - ) - resolve() - } - ctx.ProjectListController.projectListPage(ctx.req, ctx.res) - }) + ctx.res.render = (pageName, opts) => { + opts.prefetchedProjectsBlob.projects.length.should.equal( + ctx.projects.length + + ctx.readAndWrite.length + + ctx.readOnly.length + + ctx.tokenReadAndWrite.length + + ctx.tokenReadOnly.length + + ctx.review.length - + 1 + ) + } + await ctx.ProjectListController.projectListPage(ctx.req, ctx.res) }) }) }) diff --git a/services/web/test/unit/src/Project/ProjectOptionsHandler.test.mjs b/services/web/test/unit/src/Project/ProjectOptionsHandler.test.mjs index 12d4c70a20..c78dbd7227 100644 --- a/services/web/test/unit/src/Project/ProjectOptionsHandler.test.mjs +++ b/services/web/test/unit/src/Project/ProjectOptionsHandler.test.mjs @@ -1,231 +1,223 @@ -/* eslint-disable - n/handle-callback-err, - max-len, - no-return-assign, - no-unused-vars, - no-useless-constructor, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const sinon = require('sinon') -const { expect } = require('chai') +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' + const modulePath = - '../../../../app/src/Features/Project/ProjectOptionsHandler.js' -const SandboxedModule = require('sandboxed-module') -const { ObjectId } = require('mongodb-legacy') + '../../../../app/src/Features/Project/ProjectOptionsHandler.mjs' + +const { ObjectId } = mongodb describe('ProjectOptionsHandler', function () { const projectId = '4eecaffcbffa66588e000008' - beforeEach(function () { - let Project - this.projectModel = Project = class Project { - constructor(options) {} - } - this.projectModel.updateOne = sinon.stub().resolves() + beforeEach(async function (ctx) { + ctx.projectModel = class Project {} + ctx.projectModel.updateOne = sinon.stub().resolves() - this.db = { + ctx.db = { projects: { updateOne: sinon.stub().resolves(), }, } - this.handler = SandboxedModule.require(modulePath, { - requires: { - '../../models/Project': { Project: this.projectModel }, - '@overleaf/settings': { - languages: [ - { name: 'English', code: 'en' }, - { name: 'French', code: 'fr' }, - ], - imageRoot: 'docker-repo/subdir', - allowedImageNames: [ - { imageName: 'texlive-0000.0', imageDesc: 'test image 0' }, - { imageName: 'texlive-1234.5', imageDesc: 'test image 1' }, - ], - }, - '../../infrastructure/mongodb': { db: this.db, ObjectId }, + vi.doMock('../../../../app/src/models/Project', () => ({ + Project: ctx.projectModel, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: { + languages: [ + { name: 'English', code: 'en' }, + { name: 'French', code: 'fr' }, + ], + imageRoot: 'docker-repo/subdir', + allowedImageNames: [ + { imageName: 'texlive-0000.0', imageDesc: 'test image 0' }, + { imageName: 'texlive-1234.5', imageDesc: 'test image 1' }, + ], }, - }) + })) + + vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({ + db: ctx.db, + ObjectId, + })) + + ctx.handler = (await import(modulePath)).default }) describe('Setting the compiler', function () { - it('should perform and update on mongo', async function () { - await this.handler.promises.setCompiler(projectId, 'xeLaTeX') - const args = this.projectModel.updateOne.args[0] + it('should perform and update on mongo', async function (ctx) { + await ctx.handler.promises.setCompiler(projectId, 'xeLaTeX') + const args = ctx.projectModel.updateOne.args[0] args[0]._id.should.equal(projectId) args[1].compiler.should.equal('xelatex') }) - it('should not perform and update on mongo if it is not a recognised compiler', async function () { + it('should not perform and update on mongo if it is not a recognised compiler', async function (ctx) { const fakeComplier = 'something' expect( - this.handler.promises.setCompiler(projectId, 'something') + ctx.handler.promises.setCompiler(projectId, 'something') ).to.be.rejectedWith(`invalid compiler: ${fakeComplier}`) - this.projectModel.updateOne.called.should.equal(false) + ctx.projectModel.updateOne.called.should.equal(false) }) describe('when called without arg', function () { - it('should callback with null', async function () { - await this.handler.promises.setCompiler(projectId, null) - this.projectModel.updateOne.callCount.should.equal(0) + it('should callback with null', async function (ctx) { + await ctx.handler.promises.setCompiler(projectId, null) + ctx.projectModel.updateOne.callCount.should.equal(0) }) }) describe('when mongo update error occurs', function () { - beforeEach(function () { - this.projectModel.updateOne = sinon.stub().yields('error') + beforeEach(function (ctx) { + ctx.projectModel.updateOne = sinon.stub().yields('error') }) - it('should be rejected', async function () { - expect(this.handler.promises.setCompiler(projectId, 'xeLaTeX')).to.be + it('should be rejected', async function (ctx) { + expect(ctx.handler.promises.setCompiler(projectId, 'xeLaTeX')).to.be .rejected }) }) }) describe('Setting the imageName', function () { - it('should perform and update on mongo', async function () { - await this.handler.promises.setImageName(projectId, 'texlive-1234.5') - const args = this.projectModel.updateOne.args[0] + it('should perform and update on mongo', async function (ctx) { + await ctx.handler.promises.setImageName(projectId, 'texlive-1234.5') + const args = ctx.projectModel.updateOne.args[0] args[0]._id.should.equal(projectId) args[1].imageName.should.equal('docker-repo/subdir/texlive-1234.5') }) - it('should not perform and update on mongo if it is not a reconised image name', async function () { + it('should not perform and update on mongo if it is not a reconised image name', async function (ctx) { const fakeImageName = 'something' expect( - this.handler.promises.setImageName(projectId, fakeImageName) + ctx.handler.promises.setImageName(projectId, fakeImageName) ).to.be.rejectedWith(`invalid imageName: ${fakeImageName}`) - this.projectModel.updateOne.called.should.equal(false) + ctx.projectModel.updateOne.called.should.equal(false) }) describe('when called without arg', function () { - it('should callback with null', async function () { - await this.handler.promises.setImageName(projectId, null) - this.projectModel.updateOne.callCount.should.equal(0) + it('should callback with null', async function (ctx) { + await ctx.handler.promises.setImageName(projectId, null) + ctx.projectModel.updateOne.callCount.should.equal(0) }) }) describe('when mongo update error occurs', function () { - beforeEach(function () { - this.projectModel.updateOne = sinon.stub().yields('error') + beforeEach(function (ctx) { + ctx.projectModel.updateOne = sinon.stub().yields('error') }) - it('should be rejected', async function () { - expect(this.handler.promises.setImageName(projectId, 'texlive-1234.5')) + it('should be rejected', async function (ctx) { + expect(ctx.handler.promises.setImageName(projectId, 'texlive-1234.5')) .to.be.rejected }) }) }) describe('setting the spellCheckLanguage', function () { - it('should perform and update on mongo', async function () { - await this.handler.promises.setSpellCheckLanguage(projectId, 'fr') - const args = this.projectModel.updateOne.args[0] + it('should perform and update on mongo', async function (ctx) { + await ctx.handler.promises.setSpellCheckLanguage(projectId, 'fr') + const args = ctx.projectModel.updateOne.args[0] args[0]._id.should.equal(projectId) args[1].spellCheckLanguage.should.equal('fr') }) - it('should not perform and update on mongo if it is not a reconised langauge', async function () { + it('should not perform and update on mongo if it is not a reconised langauge', async function (ctx) { const fakeLanguageCode = 'not a lang' expect( - this.handler.promises.setSpellCheckLanguage(projectId, fakeLanguageCode) + ctx.handler.promises.setSpellCheckLanguage(projectId, fakeLanguageCode) ).to.be.rejectedWith(`invalid languageCode: ${fakeLanguageCode}`) - this.projectModel.updateOne.called.should.equal(false) + ctx.projectModel.updateOne.called.should.equal(false) }) - it('should perform and update on mongo if the language is blank (means turn it off)', async function () { - await this.handler.promises.setSpellCheckLanguage(projectId, '') - this.projectModel.updateOne.called.should.equal(true) + it('should perform and update on mongo if the language is blank (means turn it off)', async function (ctx) { + await ctx.handler.promises.setSpellCheckLanguage(projectId, '') + ctx.projectModel.updateOne.called.should.equal(true) }) describe('when mongo update error occurs', function () { - beforeEach(function () { - this.projectModel.updateOne = sinon.stub().yields('error') + beforeEach(function (ctx) { + ctx.projectModel.updateOne = sinon.stub().yields('error') }) - it('should be rejected', async function () { - expect(this.handler.promises.setSpellCheckLanguage(projectId)).to.be + it('should be rejected', async function (ctx) { + expect(ctx.handler.promises.setSpellCheckLanguage(projectId)).to.be .rejected }) }) }) describe('setting the brandVariationId', function () { - it('should perform and update on mongo', async function () { - await this.handler.promises.setBrandVariationId(projectId, '123') - const args = this.projectModel.updateOne.args[0] + it('should perform and update on mongo', async function (ctx) { + await ctx.handler.promises.setBrandVariationId(projectId, '123') + const args = ctx.projectModel.updateOne.args[0] args[0]._id.should.equal(projectId) args[1].brandVariationId.should.equal('123') }) - it('should not perform and update on mongo if there is no brand variation', async function () { - await this.handler.promises.setBrandVariationId(projectId, null) - this.projectModel.updateOne.called.should.equal(false) + it('should not perform and update on mongo if there is no brand variation', async function (ctx) { + await ctx.handler.promises.setBrandVariationId(projectId, null) + ctx.projectModel.updateOne.called.should.equal(false) }) - it('should not perform and update on mongo if brand variation is an empty string', async function () { - await this.handler.promises.setBrandVariationId(projectId, '') - this.projectModel.updateOne.called.should.equal(false) + it('should not perform and update on mongo if brand variation is an empty string', async function (ctx) { + await ctx.handler.promises.setBrandVariationId(projectId, '') + ctx.projectModel.updateOne.called.should.equal(false) }) describe('when mongo update error occurs', function () { - beforeEach(function () { - this.projectModel.updateOne = sinon.stub().yields('error') + beforeEach(function (ctx) { + ctx.projectModel.updateOne = sinon.stub().yields('error') }) - it('should be rejected', async function () { - expect(this.handler.promises.setBrandVariationId(projectId, '123')).to - .be.rejected + it('should be rejected', async function (ctx) { + expect(ctx.handler.promises.setBrandVariationId(projectId, '123')).to.be + .rejected }) }) }) describe('setting the rangesSupportEnabled', function () { - it('should perform and update on mongo', async function () { - await this.handler.promises.setHistoryRangesSupport(projectId, true) + it('should perform and update on mongo', async function (ctx) { + await ctx.handler.promises.setHistoryRangesSupport(projectId, true) sinon.assert.calledWith( - this.db.projects.updateOne, + ctx.db.projects.updateOne, { _id: new ObjectId(projectId) }, { $set: { 'overleaf.history.rangesSupportEnabled': true } } ) }) describe('when mongo update error occurs', function () { - beforeEach(function () { - this.db.projects.updateOne = sinon.stub().yields('error') + beforeEach(function (ctx) { + ctx.db.projects.updateOne = sinon.stub().yields('error') }) - it('should be rejected', async function () { - expect(this.handler.promises.setHistoryRangesSupport(projectId, true)) - .to.be.rejected + it('should be rejected', async function (ctx) { + expect(ctx.handler.promises.setHistoryRangesSupport(projectId, true)).to + .be.rejected }) }) }) describe('unsetting the brandVariationId', function () { - it('should perform and update on mongo', async function () { - await this.handler.promises.unsetBrandVariationId(projectId) - const args = this.projectModel.updateOne.args[0] + it('should perform and update on mongo', async function (ctx) { + await ctx.handler.promises.unsetBrandVariationId(projectId) + const args = ctx.projectModel.updateOne.args[0] args[0]._id.should.equal(projectId) expect(args[1]).to.deep.equal({ $unset: { brandVariationId: 1 } }) }) describe('when mongo update error occurs', function () { - beforeEach(function () { - this.projectModel.updateOne = sinon.stub().yields('error') + beforeEach(function (ctx) { + ctx.projectModel.updateOne = sinon.stub().yields('error') }) - it('should be rejected', async function () { - expect(this.handler.promises.unsetBrandVariationId(projectId)).to.be + it('should be rejected', async function (ctx) { + expect(ctx.handler.promises.unsetBrandVariationId(projectId)).to.be .rejected }) }) diff --git a/services/web/test/unit/src/Project/ProjectUpdateHandler.test.mjs b/services/web/test/unit/src/Project/ProjectUpdateHandler.test.mjs index 4984f1489d..1b12764726 100644 --- a/services/web/test/unit/src/Project/ProjectUpdateHandler.test.mjs +++ b/services/web/test/unit/src/Project/ProjectUpdateHandler.test.mjs @@ -1,67 +1,68 @@ -const sinon = require('sinon') +import { vi } from 'vitest' +import sinon from 'sinon' const modulePath = - '../../../../app/src/Features/Project/ProjectUpdateHandler.js' -const SandboxedModule = require('sandboxed-module') + '../../../../app/src/Features/Project/ProjectUpdateHandler.mjs' describe('ProjectUpdateHandler', function () { - beforeEach(function () { - this.fakeTime = new Date() - this.clock = sinon.useFakeTimers(this.fakeTime.getTime()) + beforeEach(function (ctx) { + ctx.fakeTime = new Date() + ctx.clock = sinon.useFakeTimers(ctx.fakeTime.getTime()) }) - afterEach(function () { - this.clock.restore() + afterEach(function (ctx) { + ctx.clock.restore() }) - beforeEach(function () { - this.ProjectModel = class Project {} - this.ProjectModel.updateOne = sinon.stub().returns({ + beforeEach(async function (ctx) { + ctx.ProjectModel = class Project {} + ctx.ProjectModel.updateOne = sinon.stub().returns({ exec: sinon.stub(), }) - this.handler = SandboxedModule.require(modulePath, { - requires: { - '../../models/Project': { Project: this.ProjectModel }, - }, - }) + + vi.doMock('../../../../app/src/models/Project', () => ({ + Project: ctx.ProjectModel, + })) + + ctx.handler = (await import(modulePath)).default }) describe('marking a project as recently updated', function () { - beforeEach(function () { - this.project_id = 'project_id' - this.lastUpdatedAt = 987654321 - this.lastUpdatedBy = 'fake-last-updater-id' + beforeEach(function (ctx) { + ctx.project_id = 'project_id' + ctx.lastUpdatedAt = 987654321 + ctx.lastUpdatedBy = 'fake-last-updater-id' }) - it('should send an update to mongo', async function () { - await this.handler.promises.markAsUpdated( - this.project_id, - this.lastUpdatedAt, - this.lastUpdatedBy + it('should send an update to mongo', async function (ctx) { + await ctx.handler.promises.markAsUpdated( + ctx.project_id, + ctx.lastUpdatedAt, + ctx.lastUpdatedBy ) sinon.assert.calledWith( - this.ProjectModel.updateOne, + ctx.ProjectModel.updateOne, { - _id: this.project_id, - lastUpdated: { $lt: this.lastUpdatedAt }, + _id: ctx.project_id, + lastUpdated: { $lt: ctx.lastUpdatedAt }, }, { - lastUpdated: this.lastUpdatedAt, - lastUpdatedBy: this.lastUpdatedBy, + lastUpdated: ctx.lastUpdatedAt, + lastUpdatedBy: ctx.lastUpdatedBy, } ) }) - it('should set smart fallbacks', async function () { - await this.handler.promises.markAsUpdated(this.project_id, null, null) + it('should set smart fallbacks', async function (ctx) { + await ctx.handler.promises.markAsUpdated(ctx.project_id, null, null) sinon.assert.calledWithMatch( - this.ProjectModel.updateOne, + ctx.ProjectModel.updateOne, { - _id: this.project_id, - lastUpdated: { $lt: this.fakeTime }, + _id: ctx.project_id, + lastUpdated: { $lt: ctx.fakeTime }, }, { - lastUpdated: this.fakeTime, + lastUpdated: ctx.fakeTime, lastUpdatedBy: null, } ) @@ -69,10 +70,10 @@ describe('ProjectUpdateHandler', function () { }) describe('markAsOpened', function () { - it('should send an update to mongo', async function () { + it('should send an update to mongo', async function (ctx) { const projectId = 'project_id' - await this.handler.promises.markAsOpened(projectId) - const args = this.ProjectModel.updateOne.args[0] + await ctx.handler.promises.markAsOpened(projectId) + const args = ctx.ProjectModel.updateOne.args[0] args[0]._id.should.equal(projectId) const date = args[1].lastOpened + '' const now = Date.now() + '' @@ -81,20 +82,20 @@ describe('ProjectUpdateHandler', function () { }) describe('markAsInactive', function () { - it('should send an update to mongo', async function () { + it('should send an update to mongo', async function (ctx) { const projectId = 'project_id' - await this.handler.promises.markAsInactive(projectId) - const args = this.ProjectModel.updateOne.args[0] + await ctx.handler.promises.markAsInactive(projectId) + const args = ctx.ProjectModel.updateOne.args[0] args[0]._id.should.equal(projectId) args[1].active.should.equal(false) }) }) describe('markAsActive', function () { - it('should send an update to mongo', async function () { + it('should send an update to mongo', async function (ctx) { const projectId = 'project_id' - await this.handler.promises.markAsActive(projectId) - const args = this.ProjectModel.updateOne.args[0] + await ctx.handler.promises.markAsActive(projectId) + const args = ctx.ProjectModel.updateOne.args[0] args[0]._id.should.equal(projectId) args[1].active.should.equal(true) }) diff --git a/services/web/test/unit/src/Project/SafePath.test.mjs b/services/web/test/unit/src/Project/SafePath.test.mjs index 0f6417bb74..00a70ab05a 100644 --- a/services/web/test/unit/src/Project/SafePath.test.mjs +++ b/services/web/test/unit/src/Project/SafePath.test.mjs @@ -10,89 +10,89 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const { assert, expect } = require('chai') -const sinon = require('sinon') +import { assert, expect } from 'vitest' + +import sinon from 'sinon' const modulePath = '../../../../app/src/Features/Project/SafePath' -const SandboxedModule = require('sandboxed-module') describe('SafePath', function () { - beforeEach(function () { - return (this.SafePath = SandboxedModule.require(modulePath)) + beforeEach(async function (ctx) { + return (ctx.SafePath = (await import(modulePath)).default) }) describe('isCleanFilename', function () { - it('should accept a valid filename "main.tex"', function () { - const result = this.SafePath.isCleanFilename('main.tex') + it('should accept a valid filename "main.tex"', function (ctx) { + const result = ctx.SafePath.isCleanFilename('main.tex') return result.should.equal(true) }) - it('should not accept an empty filename', function () { - const result = this.SafePath.isCleanFilename('') + it('should not accept an empty filename', function (ctx) { + const result = ctx.SafePath.isCleanFilename('') return result.should.equal(false) }) - it('should not accept / anywhere', function () { - const result = this.SafePath.isCleanFilename('foo/bar') + it('should not accept / anywhere', function (ctx) { + const result = ctx.SafePath.isCleanFilename('foo/bar') return result.should.equal(false) }) - it('should not accept .', function () { - const result = this.SafePath.isCleanFilename('.') + it('should not accept .', function (ctx) { + const result = ctx.SafePath.isCleanFilename('.') return result.should.equal(false) }) - it('should not accept ..', function () { - const result = this.SafePath.isCleanFilename('..') + it('should not accept ..', function (ctx) { + const result = ctx.SafePath.isCleanFilename('..') return result.should.equal(false) }) - it('should not accept * anywhere', function () { - const result = this.SafePath.isCleanFilename('foo*bar') + it('should not accept * anywhere', function (ctx) { + const result = ctx.SafePath.isCleanFilename('foo*bar') return result.should.equal(false) }) - it('should not accept leading whitespace', function () { - const result = this.SafePath.isCleanFilename(' foobar.tex') + it('should not accept leading whitespace', function (ctx) { + const result = ctx.SafePath.isCleanFilename(' foobar.tex') return result.should.equal(false) }) - it('should not accept trailing whitespace', function () { - const result = this.SafePath.isCleanFilename('foobar.tex ') + it('should not accept trailing whitespace', function (ctx) { + const result = ctx.SafePath.isCleanFilename('foobar.tex ') return result.should.equal(false) }) - it('should not accept leading and trailing whitespace', function () { - const result = this.SafePath.isCleanFilename(' foobar.tex ') + it('should not accept leading and trailing whitespace', function (ctx) { + const result = ctx.SafePath.isCleanFilename(' foobar.tex ') return result.should.equal(false) }) - it('should not accept control characters (0-31)', function () { - const result = this.SafePath.isCleanFilename('foo\u0010bar') + it('should not accept control characters (0-31)', function (ctx) { + const result = ctx.SafePath.isCleanFilename('foo\u0010bar') return result.should.equal(false) }) - it('should not accept control characters (127, delete)', function () { - const result = this.SafePath.isCleanFilename('foo\u007fbar') + it('should not accept control characters (127, delete)', function (ctx) { + const result = ctx.SafePath.isCleanFilename('foo\u007fbar') return result.should.equal(false) }) - it('should not accept control characters (128-159)', function () { - const result = this.SafePath.isCleanFilename('foo\u0080\u0090bar') + it('should not accept control characters (128-159)', function (ctx) { + const result = ctx.SafePath.isCleanFilename('foo\u0080\u0090bar') return result.should.equal(false) }) - it('should not accept surrogate characters (128-159)', function () { - const result = this.SafePath.isCleanFilename('foo\uD800\uDFFFbar') + it('should not accept surrogate characters (128-159)', function (ctx) { + const result = ctx.SafePath.isCleanFilename('foo\uD800\uDFFFbar') return result.should.equal(false) }) - it('should accept javascript property names', function () { - const result = this.SafePath.isCleanFilename('prototype') + it('should accept javascript property names', function (ctx) { + const result = ctx.SafePath.isCleanFilename('prototype') return result.should.equal(true) }) - it('should accept javascript property names in the prototype', function () { - const result = this.SafePath.isCleanFilename('hasOwnProperty') + it('should accept javascript property names in the prototype', function (ctx) { + const result = ctx.SafePath.isCleanFilename('hasOwnProperty') return result.should.equal(true) }) @@ -105,172 +105,172 @@ describe('SafePath', function () { // result = @SafePath.isCleanFilename 'hello.' // result.should.equal false - it('should not accept \\', function () { - const result = this.SafePath.isCleanFilename('foo\\bar') + it('should not accept \\', function (ctx) { + const result = ctx.SafePath.isCleanFilename('foo\\bar') return result.should.equal(false) }) - it('should reject filenames regardless of order (/g) for bad characters', function () { - const result1 = this.SafePath.isCleanFilename('foo*bar.tex') // * is not allowed - const result2 = this.SafePath.isCleanFilename('*foobar.tex') // bad char location is before previous match + it('should reject filenames regardless of order (/g) for bad characters', function (ctx) { + const result1 = ctx.SafePath.isCleanFilename('foo*bar.tex') // * is not allowed + const result2 = ctx.SafePath.isCleanFilename('*foobar.tex') // bad char location is before previous match return result1.should.equal(false) && result2.should.equal(false) }) - it('should reject filenames regardless of order (/g) for bad filenames', function () { - const result1 = this.SafePath.isCleanFilename('foo ') // trailing space - const result2 = this.SafePath.isCleanFilename(' foobar') // leading space, match location is before previous match + it('should reject filenames regardless of order (/g) for bad filenames', function (ctx) { + const result1 = ctx.SafePath.isCleanFilename('foo ') // trailing space + const result2 = ctx.SafePath.isCleanFilename(' foobar') // leading space, match location is before previous match return result1.should.equal(false) && result2.should.equal(false) }) }) describe('isCleanPath', function () { - it('should accept a valid filename "main.tex"', function () { - const result = this.SafePath.isCleanPath('main.tex') + it('should accept a valid filename "main.tex"', function (ctx) { + const result = ctx.SafePath.isCleanPath('main.tex') return result.should.equal(true) }) - it('should accept a valid path "foo/main.tex"', function () { - const result = this.SafePath.isCleanPath('foo/main.tex') + it('should accept a valid path "foo/main.tex"', function (ctx) { + const result = ctx.SafePath.isCleanPath('foo/main.tex') return result.should.equal(true) }) - it('should accept empty path elements', function () { - const result = this.SafePath.isCleanPath('foo//main.tex') + it('should accept empty path elements', function (ctx) { + const result = ctx.SafePath.isCleanPath('foo//main.tex') return result.should.equal(true) }) - it('should not accept an empty filename', function () { - const result = this.SafePath.isCleanPath('foo/bar/') + it('should not accept an empty filename', function (ctx) { + const result = ctx.SafePath.isCleanPath('foo/bar/') return result.should.equal(false) }) - it('should accept a path that starts with a slash', function () { - const result = this.SafePath.isCleanPath('/etc/passwd') + it('should accept a path that starts with a slash', function (ctx) { + const result = ctx.SafePath.isCleanPath('/etc/passwd') return result.should.equal(true) }) - it('should not accept a path that has an asterisk as the 0th element', function () { - const result = this.SafePath.isCleanPath('*/foo/bar') + it('should not accept a path that has an asterisk as the 0th element', function (ctx) { + const result = ctx.SafePath.isCleanPath('*/foo/bar') return result.should.equal(false) }) - it('should not accept a path that has an asterisk as a middle element', function () { - const result = this.SafePath.isCleanPath('foo/*/bar') + it('should not accept a path that has an asterisk as a middle element', function (ctx) { + const result = ctx.SafePath.isCleanPath('foo/*/bar') return result.should.equal(false) }) - it('should not accept a path that has an asterisk as the filename', function () { - const result = this.SafePath.isCleanPath('foo/bar/*') + it('should not accept a path that has an asterisk as the filename', function (ctx) { + const result = ctx.SafePath.isCleanPath('foo/bar/*') return result.should.equal(false) }) - it('should not accept a path that contains an asterisk in the 0th element', function () { - const result = this.SafePath.isCleanPath('f*o/bar/baz') + it('should not accept a path that contains an asterisk in the 0th element', function (ctx) { + const result = ctx.SafePath.isCleanPath('f*o/bar/baz') return result.should.equal(false) }) - it('should not accept a path that contains an asterisk in a middle element', function () { - const result = this.SafePath.isCleanPath('foo/b*r/baz') + it('should not accept a path that contains an asterisk in a middle element', function (ctx) { + const result = ctx.SafePath.isCleanPath('foo/b*r/baz') return result.should.equal(false) }) - it('should not accept a path that contains an asterisk in the filename', function () { - const result = this.SafePath.isCleanPath('foo/bar/b*z') + it('should not accept a path that contains an asterisk in the filename', function (ctx) { + const result = ctx.SafePath.isCleanPath('foo/bar/b*z') return result.should.equal(false) }) - it('should not accept multiple problematic elements', function () { - const result = this.SafePath.isCleanPath('f*o/b*r/b*z') + it('should not accept multiple problematic elements', function (ctx) { + const result = ctx.SafePath.isCleanPath('f*o/b*r/b*z') return result.should.equal(false) }) - it('should not accept a problematic path with an empty element', function () { - const result = this.SafePath.isCleanPath('foo//*/bar') + it('should not accept a problematic path with an empty element', function (ctx) { + const result = ctx.SafePath.isCleanPath('foo//*/bar') return result.should.equal(false) }) - it('should not accept javascript property names', function () { - const result = this.SafePath.isCleanPath('prototype') + it('should not accept javascript property names', function (ctx) { + const result = ctx.SafePath.isCleanPath('prototype') return result.should.equal(false) }) - it('should not accept javascript property names in the prototype', function () { - const result = this.SafePath.isCleanPath('hasOwnProperty') + it('should not accept javascript property names in the prototype', function (ctx) { + const result = ctx.SafePath.isCleanPath('hasOwnProperty') return result.should.equal(false) }) - it('should not accept javascript property names resulting from substitutions', function () { - const result = this.SafePath.isCleanPath(' proto ') + it('should not accept javascript property names resulting from substitutions', function (ctx) { + const result = ctx.SafePath.isCleanPath(' proto ') return result.should.equal(false) }) }) describe('isAllowedLength', function () { - it('should accept a valid path "main.tex"', function () { - const result = this.SafePath.isAllowedLength('main.tex') + it('should accept a valid path "main.tex"', function (ctx) { + const result = ctx.SafePath.isAllowedLength('main.tex') return result.should.equal(true) }) - it('should not accept an extremely long path', function () { + it('should not accept an extremely long path', function (ctx) { const longPath = new Array(1000).join('/subdir') + '/main.tex' - const result = this.SafePath.isAllowedLength(longPath) + const result = ctx.SafePath.isAllowedLength(longPath) return result.should.equal(false) }) - it('should not accept an empty path', function () { - const result = this.SafePath.isAllowedLength('') + it('should not accept an empty path', function (ctx) { + const result = ctx.SafePath.isAllowedLength('') return result.should.equal(false) }) }) describe('clean', function () { - it('should not modify a valid filename', function () { - const result = this.SafePath.clean('main.tex') + it('should not modify a valid filename', function (ctx) { + const result = ctx.SafePath.clean('main.tex') return result.should.equal('main.tex') }) - it('should replace invalid characters with _', function () { - const result = this.SafePath.clean('foo/bar*/main.tex') + it('should replace invalid characters with _', function (ctx) { + const result = ctx.SafePath.clean('foo/bar*/main.tex') return result.should.equal('foo_bar__main.tex') }) - it('should replace "." with "_"', function () { - const result = this.SafePath.clean('.') + it('should replace "." with "_"', function (ctx) { + const result = ctx.SafePath.clean('.') return result.should.equal('_') }) - it('should replace ".." with "__"', function () { - const result = this.SafePath.clean('..') + it('should replace ".." with "__"', function (ctx) { + const result = ctx.SafePath.clean('..') return result.should.equal('__') }) - it('should replace a single trailing space with _', function () { - const result = this.SafePath.clean('foo ') + it('should replace a single trailing space with _', function (ctx) { + const result = ctx.SafePath.clean('foo ') return result.should.equal('foo_') }) - it('should replace a multiple trailing spaces with ___', function () { - const result = this.SafePath.clean('foo ') + it('should replace a multiple trailing spaces with ___', function (ctx) { + const result = ctx.SafePath.clean('foo ') return result.should.equal('foo__') }) - it('should replace a single leading space with _', function () { - const result = this.SafePath.clean(' foo') + it('should replace a single leading space with _', function (ctx) { + const result = ctx.SafePath.clean(' foo') return result.should.equal('_foo') }) - it('should replace a multiple leading spaces with ___', function () { - const result = this.SafePath.clean(' foo') + it('should replace a multiple leading spaces with ___', function (ctx) { + const result = ctx.SafePath.clean(' foo') return result.should.equal('__foo') }) - it('should prefix javascript property names with @', function () { - const result = this.SafePath.clean('prototype') + it('should prefix javascript property names with @', function (ctx) { + const result = ctx.SafePath.clean('prototype') return result.should.equal('@prototype') }) - it('should prefix javascript property names in the prototype with @', function () { - const result = this.SafePath.clean('hasOwnProperty') + it('should prefix javascript property names in the prototype with @', function (ctx) { + const result = ctx.SafePath.clean('hasOwnProperty') return result.should.equal('@hasOwnProperty') }) }) diff --git a/services/web/test/unit/src/Publishers/PublishersGetter.test.mjs b/services/web/test/unit/src/Publishers/PublishersGetter.test.mjs index b5a3124436..e44f286228 100644 --- a/services/web/test/unit/src/Publishers/PublishersGetter.test.mjs +++ b/services/web/test/unit/src/Publishers/PublishersGetter.test.mjs @@ -1,25 +1,25 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const { expect } = require('chai') -const modulePath = require('path').join( - __dirname, - '../../../../app/src/Features/Publishers/PublishersGetter.js' +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import path from 'node:path' +const modulePath = path.join( + import.meta.dirname, + '../../../../app/src/Features/Publishers/PublishersGetter.mjs' ) describe('PublishersGetter', function () { - beforeEach(function () { - this.publisher = { + beforeEach(async function (ctx) { + ctx.publisher = { _id: 'mock-publsiher-id', slug: 'ieee', fetchV1Data: sinon.stub(), } - this.UserMembershipsHandler = { + ctx.UserMembershipsHandler = { promises: { - getEntitiesByUser: sinon.stub().resolves([this.publisher]), + getEntitiesByUser: sinon.stub().resolves([ctx.publisher]), }, } - this.UserMembershipEntityConfigs = { + ctx.UserMembershipEntityConfigs = { publisher: { modelName: 'Publisher', canCreate: true, @@ -29,22 +29,33 @@ describe('PublishersGetter', function () { }, } - this.PublishersGetter = SandboxedModule.require(modulePath, { - requires: { - '../User/UserGetter': this.UserGetter, - '../UserMembership/UserMembershipsHandler': this.UserMembershipsHandler, - '../UserMembership/UserMembershipEntityConfigs': - this.UserMembershipEntityConfigs, - }, - }) + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) - this.userId = '12345abcde' + vi.doMock( + '../../../../app/src/Features/UserMembership/UserMembershipsHandler', + () => ({ + default: ctx.UserMembershipsHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs', + () => ({ + default: ctx.UserMembershipEntityConfigs, + }) + ) + + ctx.PublishersGetter = (await import(modulePath)).default + + ctx.userId = '12345abcde' }) describe('getManagedPublishers', function () { - it('fetches v1 data before returning publisher list', async function () { + it('fetches v1 data before returning publisher list', async function (ctx) { const publishers = - await this.PublishersGetter.promises.getManagedPublishers(this.userId) + await ctx.PublishersGetter.promises.getManagedPublishers(ctx.userId) expect(publishers.length).to.equal(1) }) }) diff --git a/services/web/test/unit/src/Referal/ReferalFeatures.test.mjs b/services/web/test/unit/src/Referal/ReferalFeatures.test.mjs index 3134e8bfda..a2e33e4351 100644 --- a/services/web/test/unit/src/Referal/ReferalFeatures.test.mjs +++ b/services/web/test/unit/src/Referal/ReferalFeatures.test.mjs @@ -1,31 +1,32 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const { expect } = require('chai') -const modulePath = require('path').join( - __dirname, - '../../../../app/src/Features/Referal/ReferalFeatures.js' +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import path from 'node:path' +const modulePath = path.join( + import.meta.dirname, + '../../../../app/src/Features/Referal/ReferalFeatures.mjs' ) describe('ReferalFeatures', function () { - beforeEach(function () { - this.ReferalFeatures = SandboxedModule.require(modulePath, { - requires: { - '../../models/User': { - User: (this.User = {}), - }, - '@overleaf/settings': (this.Settings = {}), - }, - }) - this.referal_id = 'referal-id-123' - this.referal_medium = 'twitter' - this.user_id = 'user-id-123' - this.new_user_id = 'new-user-id-123' + beforeEach(async function (ctx) { + vi.doMock('../../../../app/src/models/User', () => ({ + User: (ctx.User = {}), + })) + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.Settings = {}), + })) + + ctx.ReferalFeatures = (await import(modulePath)).default + ctx.referal_id = 'referal-id-123' + ctx.referal_medium = 'twitter' + ctx.user_id = 'user-id-123' + ctx.new_user_id = 'new-user-id-123' }) describe('getBonusFeatures', function () { - beforeEach(async function () { - this.refered_user_count = 3 - this.Settings.bonus_features = { + beforeEach(async function (ctx) { + ctx.refered_user_count = 3 + ctx.Settings.bonus_features = { 3: { collaborators: 3, dropbox: false, @@ -33,54 +34,54 @@ describe('ReferalFeatures', function () { }, } const stubbedUser = { - refered_user_count: this.refered_user_count, + refered_user_count: ctx.refered_user_count, features: { collaborators: 1, dropbox: false, versioning: false }, } - this.User.findOne = sinon.stub().returns({ + ctx.User.findOne = sinon.stub().returns({ exec: sinon.stub().resolves(stubbedUser), }) - this.features = await this.ReferalFeatures.promises.getBonusFeatures( - this.user_id + ctx.features = await ctx.ReferalFeatures.promises.getBonusFeatures( + ctx.user_id ) }) - it('should get the users number of refered user', function () { - this.User.findOne.calledWith({ _id: this.user_id }).should.equal(true) + it('should get the users number of refered user', function (ctx) { + ctx.User.findOne.calledWith({ _id: ctx.user_id }).should.equal(true) }) - it('should return the features', function () { - expect(this.features).to.equal(this.Settings.bonus_features[3]) + it('should return the features', function (ctx) { + expect(ctx.features).to.equal(ctx.Settings.bonus_features[3]) }) }) describe('when the user is not at a bonus level', function () { - beforeEach(async function () { - this.refered_user_count = 0 - this.Settings.bonus_features = { + beforeEach(async function (ctx) { + ctx.refered_user_count = 0 + ctx.Settings.bonus_features = { 1: { collaborators: 3, dropbox: false, versioning: false, }, } - this.User.findOne = sinon.stub().returns({ + ctx.User.findOne = sinon.stub().returns({ exec: sinon .stub() - .resolves({ refered_user_count: this.refered_user_count }), + .resolves({ refered_user_count: ctx.refered_user_count }), }) - this.features = await this.ReferalFeatures.promises.getBonusFeatures( - this.user_id + ctx.features = await ctx.ReferalFeatures.promises.getBonusFeatures( + ctx.user_id ) }) - it('should get the users number of refered user', function () { - this.User.findOne.calledWith({ _id: this.user_id }).should.equal(true) + it('should get the users number of refered user', function (ctx) { + ctx.User.findOne.calledWith({ _id: ctx.user_id }).should.equal(true) }) - it('should return an empty feature set', function () { - expect(this.features).to.be.empty + it('should return an empty feature set', function (ctx) { + expect(ctx.features).to.be.empty }) }) }) diff --git a/services/web/test/unit/src/SplitTests/SplitTestHandler.test.mjs b/services/web/test/unit/src/SplitTests/SplitTestHandler.test.mjs index aafed5fdc6..f3103679a2 100644 --- a/services/web/test/unit/src/SplitTests/SplitTestHandler.test.mjs +++ b/services/web/test/unit/src/SplitTests/SplitTestHandler.test.mjs @@ -1,19 +1,21 @@ -const Path = require('path') -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const { ObjectId } = require('mongodb-legacy') -const { assert, expect } = require('chai') -const MockRequest = require('../helpers/MockRequest') -const MockResponse = require('../helpers/MockResponse') +import { vi, assert, expect } from 'vitest' +import Path from 'path' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' +import MockRequest from '../helpers/MockRequest.js' +import MockResponse from '../helpers/MockResponse.js' + +const { ObjectId } = mongodb const MODULE_PATH = Path.join( - __dirname, + import.meta.dirname, '../../../../app/src/Features/SplitTests/SplitTestHandler' ) describe('SplitTestHandler', function () { - beforeEach(function () { - this.splitTests = [ + let Features + beforeEach(async function (ctx) { + ctx.splitTests = [ makeSplitTest('active-test', { versionNumber: 2 }), makeSplitTest('not-active-test', { active: false }), makeSplitTest('legacy-test'), @@ -23,72 +25,119 @@ describe('SplitTestHandler', function () { versionNumber: 2, }), ] - this.cachedSplitTests = new Map() - for (const splitTest of this.splitTests) { - this.cachedSplitTests.set(splitTest.name, splitTest) + ctx.cachedSplitTests = new Map() + for (const splitTest of ctx.splitTests) { + ctx.cachedSplitTests.set(splitTest.name, splitTest) } - this.SplitTest = { + ctx.SplitTest = { find: sinon.stub().returns({ - exec: sinon.stub().resolves(this.splitTests), + exec: sinon.stub().resolves(ctx.splitTests), }), } - this.SplitTestCache = { + ctx.SplitTestCache = { get: sinon.stub().resolves({}), } - this.SplitTestCache.get.resolves(this.cachedSplitTests) - this.Settings = { + ctx.SplitTestCache.get.resolves(ctx.cachedSplitTests) + ctx.Settings = { moduleImportSequence: [], overleaf: {}, devToolbar: { enabled: false, }, } - this.AnalyticsManager = { + ctx.AnalyticsManager = { getIdsFromSession: sinon.stub(), setUserPropertyForAnalyticsId: sinon.stub().resolves(), } - this.LocalsHelper = { + ctx.LocalsHelper = { setSplitTestVariant: sinon.stub(), setSplitTestInfo: sinon.stub(), } - this.SplitTestSessionHandler = { + ctx.SplitTestSessionHandler = { collectSessionStats: sinon.stub(), getCachedVariant: sinon.stub(), setVariantInCache: sinon.stub(), } - this.SplitTestUserGetter = { + ctx.SplitTestUserGetter = { promises: { getUser: sinon.stub().resolves(null), }, } - this.SessionManager = { + ctx.SessionManager = { isUserLoggedIn: sinon.stub().returns(false), } - this.SplitTestHandler = SandboxedModule.require(MODULE_PATH, { - requires: { - '../User/UserGetter': this.UserGetter, - './SplitTestCache': this.SplitTestCache, - '../../models/SplitTest': { SplitTest: this.SplitTest }, - '../User/UserUpdater': {}, - '../Analytics/AnalyticsManager': this.AnalyticsManager, - './LocalsHelper': this.LocalsHelper, - './SplitTestSessionHandler': this.SplitTestSessionHandler, - './SplitTestUserGetter': this.SplitTestUserGetter, - '../Authentication/SessionManager': this.SessionManager, - '@overleaf/settings': this.Settings, - }, - }) + Features = { + hasFeature: vi.fn().mockReturnValue(true), + } - this.req = new MockRequest() - this.res = new MockResponse() + vi.doMock('../../../../app/src/infrastructure/Features', () => ({ + default: Features, + })) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock('../../../../app/src/Features/SplitTests/SplitTestCache', () => ({ + default: ctx.SplitTestCache, + })) + + vi.doMock('../../../../app/src/models/SplitTest', () => ({ + SplitTest: ctx.SplitTest, + })) + + vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({ + default: {}, + })) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: ctx.AnalyticsManager, + }) + ) + + vi.doMock('../../../../app/src/Features/SplitTests/LocalsHelper', () => ({ + default: ctx.LocalsHelper, + })) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestSessionHandler', + () => ({ + default: ctx.SplitTestSessionHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestUserGetter', + () => ({ + default: ctx.SplitTestUserGetter, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + ctx.SplitTestHandler = (await import(MODULE_PATH)).default + + ctx.req = new MockRequest() + ctx.res = new MockResponse() }) describe('with an existing user', function () { - beforeEach(async function () { - this.user = { + beforeEach(async function (ctx) { + ctx.user = { _id: new ObjectId(), splitTests: { 'active-test': [ @@ -110,53 +159,53 @@ describe('SplitTestHandler', function () { ], }, } - this.SplitTestUserGetter.promises.getUser.resolves(this.user) - this.SessionManager.isUserLoggedIn.returns(true) - this.assignments = - await this.SplitTestHandler.promises.getActiveAssignmentsForUser( - this.user._id + ctx.SplitTestUserGetter.promises.getUser.resolves(ctx.user) + ctx.SessionManager.isUserLoggedIn.returns(true) + ctx.assignments = + await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser( + ctx.user._id ) - this.explicitAssignments = - await this.SplitTestHandler.promises.getActiveAssignmentsForUser( - this.user._id, + ctx.explicitAssignments = + await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser( + ctx.user._id, false, true ) - this.assignedToActiveTest = - await this.SplitTestHandler.promises.hasUserBeenAssignedToVariant( - this.req, - this.user._id, + ctx.assignedToActiveTest = + await ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant( + ctx.req, + ctx.user._id, 'active-test', 'variant-1' ) - this.assignedToActiveTestAnyVersion = - await this.SplitTestHandler.promises.hasUserBeenAssignedToVariant( - this.req, - this.user._id, + ctx.assignedToActiveTestAnyVersion = + await ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant( + ctx.req, + ctx.user._id, 'active-test', 'variant-1', true ) }) - it('handles the legacy assignment format', function () { - expect(this.assignments['legacy-test']).to.deep.equal({ + it('handles the legacy assignment format', function (ctx) { + expect(ctx.assignments['legacy-test']).to.deep.equal({ variantName: 'variant-1', phase: 'release', versionNumber: 1, }) }) - it('returns the current assignment for each active test', function () { - expect(this.assignments['active-test']).to.deep.equal({ + it('returns the current assignment for each active test', function (ctx) { + expect(ctx.assignments['active-test']).to.deep.equal({ variantName: 'variant-1', phase: 'release', versionNumber: 2, }) }) - it('returns the explicit assignment for each active test', function () { - expect(this.explicitAssignments['active-test']).to.deep.equal({ + it('returns the explicit assignment for each active test', function (ctx) { + expect(ctx.explicitAssignments['active-test']).to.deep.equal({ variantName: 'variant-1', phase: 'release', versionNumber: 2, @@ -164,74 +213,74 @@ describe('SplitTestHandler', function () { }) }) - it('returns the current assignment for tests with analytics disabled', function () { - expect(this.assignments['no-analytics-test-1']).to.deep.equal({ + it('returns the current assignment for tests with analytics disabled', function (ctx) { + expect(ctx.assignments['no-analytics-test-1']).to.deep.equal({ variantName: 'variant-1', phase: 'release', versionNumber: 1, }) }) - it('returns the current assignment for tests with analytics disabled that had previous assignments', function () { - expect(this.assignments['no-analytics-test-2']).to.deep.equal({ + it('returns the current assignment for tests with analytics disabled that had previous assignments', function (ctx) { + expect(ctx.assignments['no-analytics-test-2']).to.deep.equal({ variantName: 'variant-1', phase: 'release', versionNumber: 2, }) }) - it('shows user has been assigned to previous version of variant', function () { - expect(this.assignedToActiveTestAnyVersion).to.be.true + it('shows user has been assigned to previous version of variant', function (ctx) { + expect(ctx.assignedToActiveTestAnyVersion).to.be.true }) - it('shows user has not been explicitly assigned to current version of variant', function () { - expect(this.assignedToActiveTest).to.be.false + it('shows user has not been explicitly assigned to current version of variant', function (ctx) { + expect(ctx.assignedToActiveTest).to.be.false }) - it('does not return assignments for unknown tests', function () { - expect(this.assignments).not.to.have.property('unknown-test') + it('does not return assignments for unknown tests', function (ctx) { + expect(ctx.assignments).not.to.have.property('unknown-test') }) }) describe('with an non-existent user', function () { - beforeEach(async function () { + beforeEach(async function (ctx) { const unknownUserId = new ObjectId() - this.assignments = - await this.SplitTestHandler.promises.getActiveAssignmentsForUser( + ctx.assignments = + await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser( unknownUserId ) }) - it('returns empty assignments', function () { - expect(this.assignments).to.deep.equal({}) + it('returns empty assignments', function (ctx) { + expect(ctx.assignments).to.deep.equal({}) }) }) describe('with a user without assignments', function () { - beforeEach(async function () { - this.user = { _id: new ObjectId() } - this.SplitTestUserGetter.promises.getUser.resolves(this.user) - this.assignments = - await this.SplitTestHandler.promises.getActiveAssignmentsForUser( - this.user._id + beforeEach(async function (ctx) { + ctx.user = { _id: new ObjectId() } + ctx.SplitTestUserGetter.promises.getUser.resolves(ctx.user) + ctx.assignments = + await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser( + ctx.user._id ) - this.explicitAssignments = - await this.SplitTestHandler.promises.getActiveAssignmentsForUser( - this.user._id, + ctx.explicitAssignments = + await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser( + ctx.user._id, false, true ) - this.assignedToActiveTest = - await this.SplitTestHandler.promises.hasUserBeenAssignedToVariant( - this.req, - this.user._id, + ctx.assignedToActiveTest = + await ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant( + ctx.req, + ctx.user._id, 'active-test', 'variant-1' ) }) - it('returns current assignments', function () { - expect(this.assignments).to.deep.equal({ + it('returns current assignments', function (ctx) { + expect(ctx.assignments).to.deep.equal({ 'active-test': { phase: 'release', variantName: 'variant-1', @@ -260,23 +309,23 @@ describe('SplitTestHandler', function () { }) }) - it('shows user not assigned to variant', function () { - expect(this.assignedToActiveTest).to.be.false + it('shows user not assigned to variant', function (ctx) { + expect(ctx.assignedToActiveTest).to.be.false }) }) describe('with settings overrides', function () { - beforeEach(function () { - this.Settings.splitTestOverrides = { + beforeEach(function (ctx) { + ctx.Settings.splitTestOverrides = { 'my-test-name': 'foo-1', } }) - it('should not use the override when in SaaS mode', async function () { - this.AnalyticsManager.getIdsFromSession.returns({ + it('should not use the override when in SaaS mode', async function (ctx) { + ctx.AnalyticsManager.getIdsFromSession.returns({ userId: 'abc123abc123', }) - this.SplitTestCache.get.resolves( + ctx.SplitTestCache.get.resolves( new Map([ [ 'my-test-name', @@ -300,37 +349,39 @@ describe('SplitTestHandler', function () { ]) ) - const assignment = await this.SplitTestHandler.promises.getAssignment( - this.req, - this.res, + const assignment = await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, 'my-test-name' ) assert.equal('100-percent-variant', assignment.variant) }) - it('should use the override when not in SaaS mode', async function () { - this.Settings.splitTestOverrides = { + it('should use the override when not in SaaS mode', async function (ctx) { + ctx.Settings.splitTestOverrides = { 'my-test-name': 'foo-1', } - this.Settings.overleaf = undefined - - const assignment = await this.SplitTestHandler.promises.getAssignment( - this.req, - this.res, + ctx.Settings.overleaf = undefined + Features.hasFeature.mockImplementation(function (feature) { + return feature !== 'saas' + }) + const assignment = await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, 'my-test-name' ) assert.equal('foo-1', assignment.variant) }) - it('should use default when not in SaaS mode and no override is provided', async function () { - this.Settings.splitTestOverrides = {} - this.Settings.overleaf = undefined + it('should use default when not in SaaS mode and no override is provided', async function (ctx) { + ctx.Settings.splitTestOverrides = {} + ctx.Settings.overleaf = undefined - const assignment = await this.SplitTestHandler.promises.getAssignment( - this.req, - this.res, + const assignment = await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, 'my-test-name' ) @@ -339,34 +390,36 @@ describe('SplitTestHandler', function () { }) describe('save assignments to res.locals', function () { - beforeEach(function () { - this.AnalyticsManager.getIdsFromSession.returns({ + beforeEach(function (ctx) { + ctx.AnalyticsManager.getIdsFromSession.returns({ userId: 'abc123abc123', }) }) - it('when in SaaS mode it should set the variant', async function () { - await this.SplitTestHandler.promises.getAssignment( - this.req, - this.res, + it('when in SaaS mode it should set the variant', async function (ctx) { + await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, 'active-test' ) - expect(this.LocalsHelper.setSplitTestVariant).to.have.been.calledWith( - this.res.locals, + expect(ctx.LocalsHelper.setSplitTestVariant).to.have.been.calledWith( + ctx.res.locals, 'active-test', 'variant-1' ) }) - it('when not in SaaS mode it should set the default variant', async function () { - this.Settings.overleaf = undefined - await this.SplitTestHandler.promises.getAssignment( - this.req, - this.res, + it('when not in SaaS mode it should set the default variant', async function (ctx) { + Features.hasFeature.mockImplementation(function (feature) { + return feature !== 'saas' + }) + await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, 'active-test' ) - expect(this.LocalsHelper.setSplitTestVariant).to.have.been.calledWith( - this.res.locals, + expect(ctx.LocalsHelper.setSplitTestVariant).to.have.been.calledWith( + ctx.res.locals, 'active-test', 'default' ) @@ -374,62 +427,62 @@ describe('SplitTestHandler', function () { }) describe('variant user limits', function () { - beforeEach(function () { - this.AnalyticsManager.getIdsFromSession.returns({ + beforeEach(function (ctx) { + ctx.AnalyticsManager.getIdsFromSession.returns({ userId: 'abc123abc123', }) - this.SplitTestUserGetter.promises.getUser.resolves({ + ctx.SplitTestUserGetter.promises.getUser.resolves({ _id: new ObjectId('abc123abc123abc123abc123'), splitTests: {}, }) }) - it('should assign to variant when under limit', async function () { - this.cachedSplitTests.set( + it('should assign to variant when under limit', async function (ctx) { + ctx.cachedSplitTests.set( 'active-test', makeSplitTest('active-test', { userLimit: 100, userCount: 50 }) ) - const assignment = await this.SplitTestHandler.promises.getAssignment( - this.req, - this.res, + const assignment = await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, 'active-test' ) expect(assignment.variant).to.equal('variant-1') }) - it('should assign to default when limit reached', async function () { - this.cachedSplitTests.set( + it('should assign to default when limit reached', async function (ctx) { + ctx.cachedSplitTests.set( 'active-test', makeSplitTest('active-test', { userLimit: 100, userCount: 100 }) ) - const assignment = await this.SplitTestHandler.promises.getAssignment( - this.req, - this.res, + const assignment = await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, 'active-test' ) expect(assignment.variant).to.equal('default') }) - it('should not apply limits when no limit configured', async function () { - const assignment = await this.SplitTestHandler.promises.getAssignment( - this.req, - this.res, + it('should not apply limits when no limit configured', async function (ctx) { + const assignment = await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, 'active-test' ) expect(assignment.variant).to.equal('variant-1') }) - it('should allow already assigned users even when limit reached', async function () { - this.cachedSplitTests.set( + it('should allow already assigned users even when limit reached', async function (ctx) { + ctx.cachedSplitTests.set( 'active-test', makeSplitTest('active-test', { userLimit: 100, userCount: 100 }) ) - this.SplitTestUserGetter.promises.getUser.resolves({ + ctx.SplitTestUserGetter.promises.getUser.resolves({ _id: new ObjectId('abc123abc123abc123abc123'), splitTests: { 'active-test': [ @@ -443,24 +496,24 @@ describe('SplitTestHandler', function () { }, }) - const assignment = await this.SplitTestHandler.promises.getAssignment( - this.req, - this.res, + const assignment = await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, 'active-test' ) expect(assignment.variant).to.equal('variant-1') }) - it('should assign to default if userCount is undefined', async function () { - this.cachedSplitTests.set( + it('should assign to default if userCount is undefined', async function (ctx) { + ctx.cachedSplitTests.set( 'active-test', makeSplitTest('active-test', { userLimit: 100, userCount: undefined }) ) - const assignment = await this.SplitTestHandler.promises.getAssignment( - this.req, - this.res, + const assignment = await ctx.SplitTestHandler.promises.getAssignment( + ctx.req, + ctx.res, 'active-test' ) diff --git a/services/web/test/unit/src/SplitTests/SplitTestSessionHandler.test.mjs b/services/web/test/unit/src/SplitTests/SplitTestSessionHandler.test.mjs index 026cddefae..33793ec1f6 100644 --- a/services/web/test/unit/src/SplitTests/SplitTestSessionHandler.test.mjs +++ b/services/web/test/unit/src/SplitTests/SplitTestSessionHandler.test.mjs @@ -1,23 +1,24 @@ -const Path = require('path') -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const { expect } = require('chai') -const { ObjectId } = require('mongodb-legacy') +import { vi, expect } from 'vitest' +import Path from 'path' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' + +const { ObjectId } = mongodb const MODULE_PATH = Path.join( - __dirname, + import.meta.dirname, '../../../../app/src/Features/SplitTests/SplitTestSessionHandler' ) describe('SplitTestSessionHandler', function () { - beforeEach(function () { - this.SplitTestCache = { + beforeEach(async function (ctx) { + ctx.SplitTestCache = { get: sinon.stub().resolves(), } - this.SplitTestUserGetter = {} - this.Metrics = {} + ctx.SplitTestUserGetter = {} + ctx.Metrics = {} - this.SplitTestCache.get = sinon.stub().resolves( + ctx.SplitTestCache.get = sinon.stub().resolves( new Map( Object.entries({ 'anon-test-1': { @@ -55,17 +56,29 @@ describe('SplitTestSessionHandler', function () { ) ) - this.SplitTestSessionHandler = SandboxedModule.require(MODULE_PATH, { - requires: { - './SplitTestCache': this.SplitTestCache, - './SplitTestUserGetter': this.SplitTestUserGetter, - '@overleaf/metrics': this.Metrics, - 'mongodb-legacy': { ObjectId }, - }, - }) + vi.doMock('../../../../app/src/Features/SplitTests/SplitTestCache', () => ({ + default: ctx.SplitTestCache, + })) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestUserGetter', + () => ({ + default: ctx.SplitTestUserGetter, + }) + ) + + vi.doMock('@overleaf/metrics', () => ({ + default: ctx.Metrics, + })) + + vi.doMock('mongodb-legacy', () => ({ + default: { ObjectId }, + })) + + ctx.SplitTestSessionHandler = (await import(MODULE_PATH)).default }) - it('should read from the splitTests field', async function () { + it('should read from the splitTests field', async function (ctx) { const session = { splitTests: { 'anon-test-1': [ @@ -95,7 +108,7 @@ describe('SplitTestSessionHandler', function () { } const assignments = - await this.SplitTestSessionHandler.promises.getAssignments(session) + await ctx.SplitTestSessionHandler.promises.getAssignments(session) expect(assignments).to.deep.equal({ 'anon-test-1': [ { @@ -122,8 +135,8 @@ describe('SplitTestSessionHandler', function () { }) }) - it('should read from the sta field', async function () { - this.SplitTestCache.get = sinon.stub().resolves( + it('should read from the sta field', async function (ctx) { + ctx.SplitTestCache.get = sinon.stub().resolves( new Map( Object.entries({ 'anon-test-1': { @@ -165,7 +178,7 @@ describe('SplitTestSessionHandler', function () { } const assignments = - await this.SplitTestSessionHandler.promises.getAssignments(session) + await ctx.SplitTestSessionHandler.promises.getAssignments(session) expect(assignments).to.deep.equal({ 'anon-test-1': [ { @@ -192,8 +205,8 @@ describe('SplitTestSessionHandler', function () { }) }) - it('should deduplicate entries from the sta field', async function () { - this.SplitTestCache.get = sinon.stub().resolves( + it('should deduplicate entries from the sta field', async function (ctx) { + ctx.SplitTestCache.get = sinon.stub().resolves( new Map( Object.entries({ 'anon-test-1': { @@ -235,7 +248,7 @@ describe('SplitTestSessionHandler', function () { } const assignments = - await this.SplitTestSessionHandler.promises.getAssignments(session) + await ctx.SplitTestSessionHandler.promises.getAssignments(session) expect(assignments).to.deep.equal({ 'anon-test-1': [ { @@ -262,7 +275,7 @@ describe('SplitTestSessionHandler', function () { }) }) - it('should merge assignments from both splitTests and sta fields', async function () { + it('should merge assignments from both splitTests and sta fields', async function (ctx) { const session = { splitTests: { 'anon-test-1': [ @@ -286,7 +299,7 @@ describe('SplitTestSessionHandler', function () { } const assignments = - await this.SplitTestSessionHandler.promises.getAssignments(session) + await ctx.SplitTestSessionHandler.promises.getAssignments(session) expect(assignments).to.deep.equal({ 'anon-test-1': [ { diff --git a/services/web/test/unit/src/Subscription/FeaturesHelper.test.mjs b/services/web/test/unit/src/Subscription/FeaturesHelper.test.mjs index 84ef9bc854..207be7f75b 100644 --- a/services/web/test/unit/src/Subscription/FeaturesHelper.test.mjs +++ b/services/web/test/unit/src/Subscription/FeaturesHelper.test.mjs @@ -1,112 +1,111 @@ -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') +import { expect } from 'vitest' const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesHelper' describe('FeaturesHelper', function () { - beforeEach(function () { - this.FeaturesHelper = SandboxedModule.require(MODULE_PATH) + beforeEach(async function (ctx) { + ctx.FeaturesHelper = (await import(MODULE_PATH)).default }) describe('mergeFeatures', function () { - it('should prefer priority over standard for compileGroup', function () { + it('should prefer priority over standard for compileGroup', function (ctx) { expect( - this.FeaturesHelper.mergeFeatures( + ctx.FeaturesHelper.mergeFeatures( { compileGroup: 'priority' }, { compileGroup: 'standard' } ) ).to.deep.equal({ compileGroup: 'priority' }) expect( - this.FeaturesHelper.mergeFeatures( + ctx.FeaturesHelper.mergeFeatures( { compileGroup: 'standard' }, { compileGroup: 'priority' } ) ).to.deep.equal({ compileGroup: 'priority' }) expect( - this.FeaturesHelper.mergeFeatures( + ctx.FeaturesHelper.mergeFeatures( { compileGroup: 'priority' }, { compileGroup: 'priority' } ) ).to.deep.equal({ compileGroup: 'priority' }) expect( - this.FeaturesHelper.mergeFeatures( + ctx.FeaturesHelper.mergeFeatures( { compileGroup: 'standard' }, { compileGroup: 'standard' } ) ).to.deep.equal({ compileGroup: 'standard' }) }) - it('should prefer -1 over any other for collaborators', function () { + it('should prefer -1 over any other for collaborators', function (ctx) { expect( - this.FeaturesHelper.mergeFeatures( + ctx.FeaturesHelper.mergeFeatures( { collaborators: -1 }, { collaborators: 10 } ) ).to.deep.equal({ collaborators: -1 }) expect( - this.FeaturesHelper.mergeFeatures( + ctx.FeaturesHelper.mergeFeatures( { collaborators: 10 }, { collaborators: -1 } ) ).to.deep.equal({ collaborators: -1 }) expect( - this.FeaturesHelper.mergeFeatures( + ctx.FeaturesHelper.mergeFeatures( { collaborators: 4 }, { collaborators: 10 } ) ).to.deep.equal({ collaborators: 10 }) }) - it('should prefer the higher of compileTimeout', function () { + it('should prefer the higher of compileTimeout', function (ctx) { expect( - this.FeaturesHelper.mergeFeatures( + ctx.FeaturesHelper.mergeFeatures( { compileTimeout: 20 }, { compileTimeout: 10 } ) ).to.deep.equal({ compileTimeout: 20 }) expect( - this.FeaturesHelper.mergeFeatures( + ctx.FeaturesHelper.mergeFeatures( { compileTimeout: 10 }, { compileTimeout: 20 } ) ).to.deep.equal({ compileTimeout: 20 }) }) - it('should prefer the true over false for other keys', function () { + it('should prefer the true over false for other keys', function (ctx) { expect( - this.FeaturesHelper.mergeFeatures({ github: true }, { github: false }) + ctx.FeaturesHelper.mergeFeatures({ github: true }, { github: false }) ).to.deep.equal({ github: true }) expect( - this.FeaturesHelper.mergeFeatures({ github: false }, { github: true }) + ctx.FeaturesHelper.mergeFeatures({ github: false }, { github: true }) ).to.deep.equal({ github: true }) expect( - this.FeaturesHelper.mergeFeatures({ github: true }, { github: true }) + ctx.FeaturesHelper.mergeFeatures({ github: true }, { github: true }) ).to.deep.equal({ github: true }) expect( - this.FeaturesHelper.mergeFeatures({ github: false }, { github: false }) + ctx.FeaturesHelper.mergeFeatures({ github: false }, { github: false }) ).to.deep.equal({ github: false }) }) }) describe('computeFeatureSet', function () { - it('should handle only one featureSet', function () { + it('should handle only one featureSet', function (ctx) { expect( - this.FeaturesHelper.computeFeatureSet([ + ctx.FeaturesHelper.computeFeatureSet([ { github: true, feat1: true, feat2: false }, ]) ).to.deep.equal({ github: true, feat1: true, feat2: false }) }) - it('should handle an empty array of featureSets', function () { - expect(this.FeaturesHelper.computeFeatureSet([])).to.deep.equal({}) + it('should handle an empty array of featureSets', function (ctx) { + expect(ctx.FeaturesHelper.computeFeatureSet([])).to.deep.equal({}) }) - it('should handle 3+ featureSets', function () { + it('should handle 3+ featureSets', function (ctx) { const featureSets = [ { github: true, feat1: false, feat2: false }, { github: false, feat1: true, feat2: false, feat3: false }, { github: false, feat1: false, feat2: true, feat4: true }, ] - expect(this.FeaturesHelper.computeFeatureSet(featureSets)).to.deep.equal({ + expect(ctx.FeaturesHelper.computeFeatureSet(featureSets)).to.deep.equal({ github: true, feat1: true, feat2: true, @@ -117,34 +116,34 @@ describe('FeaturesHelper', function () { }) describe('isFeatureSetBetter', function () { - it('simple comparisons', function () { - const result1 = this.FeaturesHelper.isFeatureSetBetter( + it('simple comparisons', function (ctx) { + const result1 = ctx.FeaturesHelper.isFeatureSetBetter( { dropbox: true }, { dropbox: false } ) expect(result1).to.be.true - const result2 = this.FeaturesHelper.isFeatureSetBetter( + const result2 = ctx.FeaturesHelper.isFeatureSetBetter( { dropbox: false }, { dropbox: true } ) expect(result2).to.be.false }) - it('compound comparisons with same features', function () { - const result1 = this.FeaturesHelper.isFeatureSetBetter( + it('compound comparisons with same features', function (ctx) { + const result1 = ctx.FeaturesHelper.isFeatureSetBetter( { collaborators: 9, dropbox: true }, { collaborators: 10, dropbox: true } ) expect(result1).to.be.false - const result2 = this.FeaturesHelper.isFeatureSetBetter( + const result2 = ctx.FeaturesHelper.isFeatureSetBetter( { collaborators: -1, dropbox: true }, { collaborators: 10, dropbox: true } ) expect(result2).to.be.true - const result3 = this.FeaturesHelper.isFeatureSetBetter( + const result3 = ctx.FeaturesHelper.isFeatureSetBetter( { collaborators: -1, compileTimeout: 60, dropbox: true }, { collaborators: 10, compileTimeout: 60, dropbox: true } ) diff --git a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs b/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs index c8113971f4..d34419f1bf 100644 --- a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs +++ b/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs @@ -1,39 +1,38 @@ -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const sinon = require('sinon') -const { ObjectId } = require('mongodb-legacy') -const { - AI_ADD_ON_CODE, -} = require('../../../../app/src/Features/Subscription/AiHelper') +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' +import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.js' + +const { ObjectId } = mongodb const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesUpdater' describe('FeaturesUpdater', function () { - beforeEach(function () { - this.v1UserId = 12345 - this.user = { + beforeEach(async function (ctx) { + ctx.v1UserId = 12345 + ctx.user = { _id: new ObjectId(), features: {}, - overleaf: { id: this.v1UserId }, + overleaf: { id: ctx.v1UserId }, } - this.aiAddOn = { addOnCode: AI_ADD_ON_CODE, quantity: 1 } - this.subscriptions = { + ctx.aiAddOn = { addOnCode: AI_ADD_ON_CODE, quantity: 1 } + ctx.subscriptions = { individual: { planCode: 'individual-plan' }, group1: { planCode: 'group-plan-1', groupPlan: true }, group2: { planCode: 'group-plan-2', groupPlan: true }, noDropbox: { planCode: 'no-dropbox' }, individualPlusAiAddOn: { planCode: 'individual-plan', - addOns: [this.aiAddOn], + addOns: [ctx.aiAddOn], }, groupPlusAiAddOn: { planCode: 'group-plan-1', groupPlan: true, - addOns: [this.aiAddOn], + addOns: [ctx.aiAddOn], }, } - this.UserFeaturesUpdater = { + ctx.UserFeaturesUpdater = { promises: { updateFeatures: sinon .stub() @@ -41,20 +40,20 @@ describe('FeaturesUpdater', function () { }, } - this.SubscriptionLocator = { + ctx.SubscriptionLocator = { promises: { getUsersSubscription: sinon.stub(), getGroupSubscriptionsMemberOf: sinon.stub(), }, } - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user._id) - .resolves(this.subscriptions.individual) - this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf - .withArgs(this.user._id) - .resolves([this.subscriptions.group1, this.subscriptions.group2]) + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user._id) + .resolves(ctx.subscriptions.individual) + ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf + .withArgs(ctx.user._id) + .resolves([ctx.subscriptions.group1, ctx.subscriptions.group2]) - this.Settings = { + ctx.Settings = { defaultFeatures: { default: 'features' }, plans: [ { planCode: 'individual-plan', features: { individual: 'features' } }, @@ -75,19 +74,19 @@ describe('FeaturesUpdater', function () { }, } - this.ReferalFeatures = { + ctx.ReferalFeatures = { promises: { getBonusFeatures: sinon.stub().resolves({ bonus: 'features' }), }, } - this.V1SubscriptionManager = { + ctx.V1SubscriptionManager = { getGrandfatheredFeaturesForV1User: sinon.stub(), } - this.V1SubscriptionManager.getGrandfatheredFeaturesForV1User - .withArgs(this.v1UserId) + ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User + .withArgs(ctx.v1UserId) .returns({ grandfathered: 'features' }) - this.InstitutionsFeatures = { + ctx.InstitutionsFeatures = { promises: { getInstitutionsFeatures: sinon .stub() @@ -95,113 +94,156 @@ describe('FeaturesUpdater', function () { }, } - this.UserGetter = { + ctx.UserGetter = { promises: { getUser: sinon.stub().resolves(null), }, } - this.UserGetter.promises.getUser.withArgs(this.user._id).resolves(this.user) - this.UserGetter.promises.getUser - .withArgs({ 'overleaf.id': this.v1UserId }) - .resolves(this.user) + ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user) + ctx.UserGetter.promises.getUser + .withArgs({ 'overleaf.id': ctx.v1UserId }) + .resolves(ctx.user) - this.AnalyticsManager = { + ctx.AnalyticsManager = { setUserPropertyForUserInBackground: sinon.stub(), } - this.Modules = { + ctx.Modules = { promises: { hooks: { fire: sinon.stub().resolves() } }, } - this.Queues = { + ctx.Queues = { getQueue: sinon.stub().returns({ add: sinon.stub().resolves(), }), } - this.FeaturesUpdater = SandboxedModule.require(MODULE_PATH, { - requires: { - './UserFeaturesUpdater': this.UserFeaturesUpdater, - './SubscriptionLocator': this.SubscriptionLocator, - '@overleaf/settings': this.Settings, - '../Referal/ReferalFeatures': this.ReferalFeatures, - './V1SubscriptionManager': this.V1SubscriptionManager, - '../Institutions/InstitutionsFeatures': this.InstitutionsFeatures, - '../User/UserGetter': this.UserGetter, - '../Analytics/AnalyticsManager': this.AnalyticsManager, - '../../infrastructure/Modules': this.Modules, - '../../infrastructure/Queues': this.Queues, - '../../models/Subscription': {}, - }, - }) + vi.doMock( + '../../../../app/src/Features/Subscription/UserFeaturesUpdater', + () => ({ + default: ctx.UserFeaturesUpdater, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator', + () => ({ + default: ctx.SubscriptionLocator, + }) + ) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + vi.doMock('../../../../app/src/Features/Referal/ReferalFeatures', () => ({ + default: ctx.ReferalFeatures, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/V1SubscriptionManager', + () => ({ + default: ctx.V1SubscriptionManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Institutions/InstitutionsFeatures', + () => ({ + default: ctx.InstitutionsFeatures, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: ctx.AnalyticsManager, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: ctx.Modules, + })) + + vi.doMock('../../../../app/src/infrastructure/Queues', () => ({ + default: ctx.Queues, + })) + + vi.doMock('../../../../app/src/models/Subscription', () => ({})) + + ctx.FeaturesUpdater = (await import(MODULE_PATH)).default }) describe('computeFeatures', function () { describe('when userFeaturesDisabled is true for individual plan', function () { - beforeEach(function () { - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user._id) + beforeEach(function (ctx) { + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user._id) .resolves({ planCode: 'individual-plan', userFeaturesDisabled: true, groupPlan: false, - addOns: [this.aiAddOn], + addOns: [ctx.aiAddOn], }) }) - it('removes all individual plan features', async function () { - const features = await this.FeaturesUpdater.promises.computeFeatures( - this.user._id + it('removes all individual plan features', async function (ctx) { + const features = await ctx.FeaturesUpdater.promises.computeFeatures( + ctx.user._id ) expect(features).to.deep.equal({ default: 'features' }) }) }) describe('when userFeaturesDisabled is true for group plan', function () { - beforeEach(function () { + beforeEach(function (ctx) { const groupSubscription = { planCode: 'group-plan-1', userFeaturesDisabled: true, groupPlan: true, - addOns: [this.aiAddOn], + addOns: [ctx.aiAddOn], } - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user._id) + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user._id) .resolves(groupSubscription) - this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf - .withArgs(this.user._id) + ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf + .withArgs(ctx.user._id) .resolves([groupSubscription]) }) - it('removes all group plan features', async function () { - const features = await this.FeaturesUpdater.promises.computeFeatures( - this.user._id + it('removes all group plan features', async function (ctx) { + const features = await ctx.FeaturesUpdater.promises.computeFeatures( + ctx.user._id ) expect(features).to.deep.equal({ default: 'features' }) }) }) - beforeEach(function () { - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user._id) + beforeEach(function (ctx) { + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user._id) .resolves(null) - this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf - .withArgs(this.user._id) + ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf + .withArgs(ctx.user._id) .resolves([]) - this.ReferalFeatures.promises.getBonusFeatures.resolves({}) - this.V1SubscriptionManager.getGrandfatheredFeaturesForV1User - .withArgs(this.v1UserId) + ctx.ReferalFeatures.promises.getBonusFeatures.resolves({}) + ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User + .withArgs(ctx.v1UserId) .returns({}) - this.InstitutionsFeatures.promises.getInstitutionsFeatures.resolves({}) + ctx.InstitutionsFeatures.promises.getInstitutionsFeatures.resolves({}) }) describe('individual subscriber', function () { - beforeEach(function () { - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user._id) - .resolves(this.subscriptions.individual) + beforeEach(function (ctx) { + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user._id) + .resolves(ctx.subscriptions.individual) }) - it('returns the individual features', async function () { - const features = await this.FeaturesUpdater.promises.computeFeatures( - this.user._id + it('returns the individual features', async function (ctx) { + const features = await ctx.FeaturesUpdater.promises.computeFeatures( + ctx.user._id ) expect(features).to.deep.equal({ default: 'features', @@ -211,15 +253,15 @@ describe('FeaturesUpdater', function () { }) describe('group admin', function () { - beforeEach(function () { - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user._id) - .resolves(this.subscriptions.group1) + beforeEach(function (ctx) { + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user._id) + .resolves(ctx.subscriptions.group1) }) - it("doesn't return the group features", async function () { - const features = await this.FeaturesUpdater.promises.computeFeatures( - this.user._id + it("doesn't return the group features", async function (ctx) { + const features = await ctx.FeaturesUpdater.promises.computeFeatures( + ctx.user._id ) expect(features).to.deep.equal({ default: 'features', @@ -228,15 +270,15 @@ describe('FeaturesUpdater', function () { }) describe('group member', function () { - beforeEach(function () { - this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf - .withArgs(this.user._id) - .resolves([this.subscriptions.group1]) + beforeEach(function (ctx) { + ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf + .withArgs(ctx.user._id) + .resolves([ctx.subscriptions.group1]) }) - it('returns the group features', async function () { - const features = await this.FeaturesUpdater.promises.computeFeatures( - this.user._id + it('returns the group features', async function (ctx) { + const features = await ctx.FeaturesUpdater.promises.computeFeatures( + ctx.user._id ) expect(features).to.deep.equal({ default: 'features', @@ -246,15 +288,15 @@ describe('FeaturesUpdater', function () { }) describe('individual subscription + AI add-on', function () { - beforeEach(function () { - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user._id) - .resolves(this.subscriptions.individualPlusAiAddOn) + beforeEach(function (ctx) { + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user._id) + .resolves(ctx.subscriptions.individualPlusAiAddOn) }) - it('returns the individual features and the AI error assistant', async function () { - const features = await this.FeaturesUpdater.promises.computeFeatures( - this.user._id + it('returns the individual features and the AI error assistant', async function (ctx) { + const features = await ctx.FeaturesUpdater.promises.computeFeatures( + ctx.user._id ) expect(features).to.deep.equal({ default: 'features', @@ -265,15 +307,15 @@ describe('FeaturesUpdater', function () { }) describe('group admin + AI add-on', function () { - beforeEach(function () { - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user._id) - .resolves(this.subscriptions.groupPlusAiAddOn) + beforeEach(function (ctx) { + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user._id) + .resolves(ctx.subscriptions.groupPlusAiAddOn) }) - it('returns the AI error assistant only', async function () { - const features = await this.FeaturesUpdater.promises.computeFeatures( - this.user._id + it('returns the AI error assistant only', async function (ctx) { + const features = await ctx.FeaturesUpdater.promises.computeFeatures( + ctx.user._id ) expect(features).to.deep.equal({ default: 'features', @@ -283,15 +325,15 @@ describe('FeaturesUpdater', function () { }) describe('group member + AI add-on', function () { - beforeEach(function () { - this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf - .withArgs(this.user._id) - .resolves([this.subscriptions.groupPlusAiAddOn]) + beforeEach(function (ctx) { + ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf + .withArgs(ctx.user._id) + .resolves([ctx.subscriptions.groupPlusAiAddOn]) }) - it('returns the group features without the AI features', async function () { - const features = await this.FeaturesUpdater.promises.computeFeatures( - this.user._id + it('returns the group features without the AI features', async function (ctx) { + const features = await ctx.FeaturesUpdater.promises.computeFeatures( + ctx.user._id ) expect(features).to.deep.equal({ default: 'features', @@ -302,52 +344,43 @@ describe('FeaturesUpdater', function () { }) describe('refreshFeatures', function () { - it('should return features and featuresChanged', async function () { + it('should return features and featuresChanged', async function (ctx) { const { features, featuresChanged } = - await this.FeaturesUpdater.promises.refreshFeatures( - this.user._id, - 'test' - ) + await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test') expect(features).to.exist expect(featuresChanged).to.exist }) describe('normally', function () { - beforeEach(async function () { - await this.FeaturesUpdater.promises.refreshFeatures( - this.user._id, - 'test' - ) + beforeEach(async function (ctx) { + await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test') }) - it('should update the user with the merged features', function () { + it('should update the user with the merged features', function (ctx) { expect( - this.UserFeaturesUpdater.promises.updateFeatures - ).to.have.been.calledWith(this.user._id, this.Settings.features.all) + ctx.UserFeaturesUpdater.promises.updateFeatures + ).to.have.been.calledWith(ctx.user._id, ctx.Settings.features.all) }) - it('should send the corresponding feature set user property', function () { + it('should send the corresponding feature set user property', function (ctx) { expect( - this.AnalyticsManager.setUserPropertyForUserInBackground - ).to.have.been.calledWith(this.user._id, 'feature-set', 'all') + ctx.AnalyticsManager.setUserPropertyForUserInBackground + ).to.have.been.calledWith(ctx.user._id, 'feature-set', 'all') }) }) describe('with a non-standard feature set', async function () { - beforeEach(async function () { - this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf - .withArgs(this.user._id) + beforeEach(async function (ctx) { + ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf + .withArgs(ctx.user._id) .resolves(null) - await this.FeaturesUpdater.promises.refreshFeatures( - this.user._id, - 'test' - ) + await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test') }) - it('should send mixed feature set user property', function () { + it('should send mixed feature set user property', function (ctx) { sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.user._id, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.user._id, 'feature-set', 'mixed' ) @@ -355,21 +388,18 @@ describe('FeaturesUpdater', function () { }) describe('when losing dropbox feature', async function () { - beforeEach(async function () { - this.user.features = { dropbox: true } - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user._id) - .resolves(this.subscriptions.noDropbox) - await this.FeaturesUpdater.promises.refreshFeatures( - this.user._id, - 'test' - ) + beforeEach(async function (ctx) { + ctx.user.features = { dropbox: true } + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user._id) + .resolves(ctx.subscriptions.noDropbox) + await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test') }) - it('should fire module hook to unlink dropbox', function () { - expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + it('should fire module hook to unlink dropbox', function (ctx) { + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( 'removeDropbox', - this.user._id, + ctx.user._id, 'test' ) }) @@ -378,40 +408,40 @@ describe('FeaturesUpdater', function () { describe('doSyncFromV1', function () { describe('when all goes well', function () { - beforeEach(async function () { - await this.FeaturesUpdater.promises.doSyncFromV1(this.v1UserId) + beforeEach(async function (ctx) { + await ctx.FeaturesUpdater.promises.doSyncFromV1(ctx.v1UserId) }) - it('should update the user with the merged features', function () { + it('should update the user with the merged features', function (ctx) { expect( - this.UserFeaturesUpdater.promises.updateFeatures - ).to.have.been.calledWith(this.user._id, this.Settings.features.all) + ctx.UserFeaturesUpdater.promises.updateFeatures + ).to.have.been.calledWith(ctx.user._id, ctx.Settings.features.all) }) }) describe('when getUser produces an error', function () { - beforeEach(function () { - this.UserGetter.promises.getUser.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUser.rejects(new Error('woops')) }) - it('should propagate the error', async function () { + it('should propagate the error', async function (ctx) { const someId = 9090 - await expect(this.FeaturesUpdater.promises.doSyncFromV1(someId)).to.be + await expect(ctx.FeaturesUpdater.promises.doSyncFromV1(someId)).to.be .rejected - expect(this.UserFeaturesUpdater.promises.updateFeatures).not.to.have - .been.called + expect(ctx.UserFeaturesUpdater.promises.updateFeatures).not.to.have.been + .called }) }) describe('when getUser does not find a user', function () { - beforeEach(async function () { + beforeEach(async function (ctx) { const someOtherId = 987 - await this.FeaturesUpdater.promises.doSyncFromV1(someOtherId) + await ctx.FeaturesUpdater.promises.doSyncFromV1(someOtherId) }) - it('should not update the user', function () { - expect(this.UserFeaturesUpdater.promises.updateFeatures).not.to.have - .been.called + it('should not update the user', function (ctx) { + expect(ctx.UserFeaturesUpdater.promises.updateFeatures).not.to.have.been + .called }) }) }) diff --git a/services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs b/services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs index 964a99b6ca..380d1b42e2 100644 --- a/services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs +++ b/services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs @@ -1,27 +1,26 @@ -// @ts-check +import { vi, expect } from 'vitest' + +import Errors from '../../../../app/src/Features/Subscription/Errors.js' + +import PaymentProviderEntities from '../../../../app/src/Features/Subscription/PaymentProviderEntities.mjs' +import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.js' +import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.js' -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const Errors = require('../../../../app/src/Features/Subscription/Errors') const { PaymentProviderSubscriptionChangeRequest, PaymentProviderSubscriptionUpdateRequest, PaymentProviderSubscriptionChange, PaymentProviderSubscription, PaymentProviderSubscriptionAddOnUpdate, -} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities') -const { - AI_ADD_ON_CODE, -} = require('../../../../app/src/Features/Subscription/AiHelper') -const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper') +} = PaymentProviderEntities const MODULE_PATH = '../../../../app/src/Features/Subscription/PaymentProviderEntities' describe('PaymentProviderEntities', function () { describe('PaymentProviderSubscription', function () { - beforeEach(function () { - this.Settings = { + beforeEach(async function (ctx) { + ctx.Settings = { plans: [ { planCode: 'assistant-annual', price_in_cents: 5900 }, { planCode: 'cheap-plan', price_in_cents: 500 }, @@ -35,34 +34,44 @@ describe('PaymentProviderEntities', function () { features: [], } - this.PaymentProviderEntities = SandboxedModule.require(MODULE_PATH, { - requires: { - '@overleaf/settings': this.Settings, - './Errors': Errors, - './SubscriptionHelper': SubscriptionHelper, - }, - }) + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/Errors', + () => Errors + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionHelper', + () => ({ + default: SubscriptionHelper, + }) + ) + + ctx.PaymentProviderEntities = (await import(MODULE_PATH)).default }) describe('with add-ons', function () { - beforeEach(function () { + beforeEach(function (ctx) { const { PaymentProviderSubscription, PaymentProviderSubscriptionAddOn, - } = this.PaymentProviderEntities - this.addOn = new PaymentProviderSubscriptionAddOn({ + } = ctx.PaymentProviderEntities + ctx.addOn = new PaymentProviderSubscriptionAddOn({ code: 'add-on-code', name: 'My Add-On', quantity: 1, unitPrice: 2, }) - this.subscription = new PaymentProviderSubscription({ + ctx.subscription = new PaymentProviderSubscription({ id: 'subscription-id', userId: 'user-id', planCode: 'regular-plan', planName: 'My Plan', planPrice: 10, - addOns: [this.addOn], + addOns: [ctx.addOn], subtotal: 10.99, taxRate: 0.2, taxAmount: 2.4, @@ -72,82 +81,82 @@ describe('PaymentProviderEntities', function () { }) describe('hasAddOn()', function () { - it('returns true if the subscription has the given add-on', function () { - expect(this.subscription.hasAddOn(this.addOn.code)).to.be.true + it('returns true if the subscription has the given add-on', function (ctx) { + expect(ctx.subscription.hasAddOn(ctx.addOn.code)).to.be.true }) - it("returns false if the subscription doesn't have the given add-on", function () { - expect(this.subscription.hasAddOn('another-add-on')).to.be.false + it("returns false if the subscription doesn't have the given add-on", function (ctx) { + expect(ctx.subscription.hasAddOn('another-add-on')).to.be.false }) }) describe('getRequestForPlanChange()', function () { - it('returns a change request for upgrades', function () { + it('returns a change request for upgrades', function (ctx) { const { PaymentProviderSubscriptionChangeRequest } = - this.PaymentProviderEntities - const changeRequest = this.subscription.getRequestForPlanChange( + ctx.PaymentProviderEntities + const changeRequest = ctx.subscription.getRequestForPlanChange( 'premium-plan', 1, - this.subscription.shouldPlanChangeAtTermEnd('premium-plan') + ctx.subscription.shouldPlanChangeAtTermEnd('premium-plan') ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', planCode: 'premium-plan', }) ) }) - it('returns a change request for downgrades', function () { + it('returns a change request for downgrades', function (ctx) { const { PaymentProviderSubscriptionChangeRequest } = - this.PaymentProviderEntities - const changeRequest = this.subscription.getRequestForPlanChange( + ctx.PaymentProviderEntities + const changeRequest = ctx.subscription.getRequestForPlanChange( 'cheap-plan', 1, - this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan') ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'term_end', planCode: 'cheap-plan', }) ) }) - it('returns a change request for downgrades while on trial', function () { + it('returns a change request for downgrades while on trial', function (ctx) { const fiveDaysFromNow = new Date() fiveDaysFromNow.setDate(fiveDaysFromNow.getDate() + 5) - this.subscription.trialPeriodEnd = fiveDaysFromNow + ctx.subscription.trialPeriodEnd = fiveDaysFromNow const { PaymentProviderSubscriptionChangeRequest } = - this.PaymentProviderEntities - const changeRequest = this.subscription.getRequestForPlanChange( + ctx.PaymentProviderEntities + const changeRequest = ctx.subscription.getRequestForPlanChange( 'cheap-plan', 1, - this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan') ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', planCode: 'cheap-plan', }) ) }) - it('preserves the AI add-on on upgrades', function () { + it('preserves the AI add-on on upgrades', function (ctx) { const { PaymentProviderSubscriptionChangeRequest } = - this.PaymentProviderEntities - this.addOn.code = AI_ADD_ON_CODE - const changeRequest = this.subscription.getRequestForPlanChange( + ctx.PaymentProviderEntities + ctx.addOn.code = AI_ADD_ON_CODE + const changeRequest = ctx.subscription.getRequestForPlanChange( 'premium-plan', 1, - this.subscription.shouldPlanChangeAtTermEnd('premium-plan') + ctx.subscription.shouldPlanChangeAtTermEnd('premium-plan') ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', planCode: 'premium-plan', addOnUpdates: [ @@ -160,18 +169,18 @@ describe('PaymentProviderEntities', function () { ) }) - it('preserves the AI add-on on downgrades', function () { + it('preserves the AI add-on on downgrades', function (ctx) { const { PaymentProviderSubscriptionChangeRequest } = - this.PaymentProviderEntities - this.addOn.code = AI_ADD_ON_CODE - const changeRequest = this.subscription.getRequestForPlanChange( + ctx.PaymentProviderEntities + ctx.addOn.code = AI_ADD_ON_CODE + const changeRequest = ctx.subscription.getRequestForPlanChange( 'cheap-plan', 1, - this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan') ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'term_end', planCode: 'cheap-plan', addOnUpdates: [ @@ -184,19 +193,19 @@ describe('PaymentProviderEntities', function () { ) }) - it('preserves the AI add-on on upgrades from the standalone AI plan', function () { + it('preserves the AI add-on on upgrades from the standalone AI plan', function (ctx) { const { PaymentProviderSubscriptionChangeRequest } = - this.PaymentProviderEntities - this.subscription.planCode = 'assistant-annual' - this.subscription.addOns = [] - const changeRequest = this.subscription.getRequestForPlanChange( + ctx.PaymentProviderEntities + ctx.subscription.planCode = 'assistant-annual' + ctx.subscription.addOns = [] + const changeRequest = ctx.subscription.getRequestForPlanChange( 'cheap-plan', 1, - this.subscription.shouldPlanChangeAtTermEnd('cheap-plan') + ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan') ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', planCode: 'cheap-plan', addOnUpdates: [ @@ -209,20 +218,20 @@ describe('PaymentProviderEntities', function () { ) }) - it('upgrade from individual to group plan for Stripe subscription', function () { - this.subscription.service = 'stripe-uk' + it('upgrade from individual to group plan for Stripe subscription', function (ctx) { + ctx.subscription.service = 'stripe-uk' const { PaymentProviderSubscriptionChangeRequest } = - this.PaymentProviderEntities - const changeRequest = this.subscription.getRequestForPlanChange( + ctx.PaymentProviderEntities + const changeRequest = ctx.subscription.getRequestForPlanChange( 'group_collaborator', 10, - this.subscription.shouldPlanChangeAtTermEnd( + ctx.subscription.shouldPlanChangeAtTermEnd( 'group_collaborator_10_enterprise' ) ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', planCode: 'group_collaborator', addOnUpdates: [ @@ -235,21 +244,21 @@ describe('PaymentProviderEntities', function () { ) }) - it('upgrade from individual to group plan and preserves the AI add-on for Stripe subscription', function () { - this.subscription.service = 'stripe-uk' + it('upgrade from individual to group plan and preserves the AI add-on for Stripe subscription', function (ctx) { + ctx.subscription.service = 'stripe-uk' const { PaymentProviderSubscriptionChangeRequest } = - this.PaymentProviderEntities - this.addOn.code = AI_ADD_ON_CODE - const changeRequest = this.subscription.getRequestForPlanChange( + ctx.PaymentProviderEntities + ctx.addOn.code = AI_ADD_ON_CODE + const changeRequest = ctx.subscription.getRequestForPlanChange( 'group_collaborator', 10, - this.subscription.shouldPlanChangeAtTermEnd( + ctx.subscription.shouldPlanChangeAtTermEnd( 'group_collaborator_10_enterprise' ) ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', planCode: 'group_collaborator', addOnUpdates: [ @@ -268,22 +277,22 @@ describe('PaymentProviderEntities', function () { }) describe('getRequestForAddOnPurchase()', function () { - it('returns a change request', function () { + it('returns a change request', function (ctx) { const { PaymentProviderSubscriptionChangeRequest, PaymentProviderSubscriptionAddOnUpdate, - } = this.PaymentProviderEntities + } = ctx.PaymentProviderEntities const changeRequest = - this.subscription.getRequestForAddOnPurchase('another-add-on') + ctx.subscription.getRequestForAddOnPurchase('another-add-on') expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', addOnUpdates: [ new PaymentProviderSubscriptionAddOnUpdate({ - code: this.addOn.code, - quantity: this.addOn.quantity, - unitPrice: this.addOn.unitPrice, + code: ctx.addOn.code, + quantity: ctx.addOn.quantity, + unitPrice: ctx.addOn.unitPrice, }), new PaymentProviderSubscriptionAddOnUpdate({ code: 'another-add-on', @@ -294,27 +303,27 @@ describe('PaymentProviderEntities', function () { ) }) - it('returns a change request with quantity and unit price specified', function () { + it('returns a change request with quantity and unit price specified', function (ctx) { const { PaymentProviderSubscriptionChangeRequest, PaymentProviderSubscriptionAddOnUpdate, - } = this.PaymentProviderEntities + } = ctx.PaymentProviderEntities const quantity = 5 const unitPrice = 10 - const changeRequest = this.subscription.getRequestForAddOnPurchase( + const changeRequest = ctx.subscription.getRequestForAddOnPurchase( 'another-add-on', quantity, unitPrice ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', addOnUpdates: [ new PaymentProviderSubscriptionAddOnUpdate({ - code: this.addOn.code, - quantity: this.addOn.quantity, - unitPrice: this.addOn.unitPrice, + code: ctx.addOn.code, + quantity: ctx.addOn.quantity, + unitPrice: ctx.addOn.unitPrice, }), new PaymentProviderSubscriptionAddOnUpdate({ code: 'another-add-on', @@ -326,95 +335,95 @@ describe('PaymentProviderEntities', function () { ) }) - it('throws a DuplicateAddOnError if the subscription already has the add-on', function () { + it('throws a DuplicateAddOnError if the subscription already has the add-on', function (ctx) { expect(() => - this.subscription.getRequestForAddOnPurchase(this.addOn.code) + ctx.subscription.getRequestForAddOnPurchase(ctx.addOn.code) ).to.throw(Errors.DuplicateAddOnError) }) }) describe('getRequestForAddOnUpdate()', function () { - it('returns a change request', function () { + it('returns a change request', function (ctx) { const { PaymentProviderSubscriptionChangeRequest, PaymentProviderSubscriptionAddOnUpdate, - } = this.PaymentProviderEntities + } = ctx.PaymentProviderEntities const newQuantity = 2 - const changeRequest = this.subscription.getRequestForAddOnUpdate( + const changeRequest = ctx.subscription.getRequestForAddOnUpdate( 'add-on-code', newQuantity ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', addOnUpdates: [ new PaymentProviderSubscriptionAddOnUpdate({ - code: this.addOn.code, + code: ctx.addOn.code, quantity: newQuantity, - unitPrice: this.addOn.unitPrice, + unitPrice: ctx.addOn.unitPrice, }), ], }) ) }) - it("throws a AddOnNotPresentError if the subscription doesn't have the add-on", function () { + it("throws a AddOnNotPresentError if the subscription doesn't have the add-on", function (ctx) { expect(() => - this.subscription.getRequestForAddOnUpdate('another-add-on', 2) + ctx.subscription.getRequestForAddOnUpdate('another-add-on', 2) ).to.throw(Errors.AddOnNotPresentError) }) }) describe('getRequestForAddOnRemoval()', function () { - it('returns a change request', function () { - const changeRequest = this.subscription.getRequestForAddOnRemoval( - this.addOn.code + it('returns a change request', function (ctx) { + const changeRequest = ctx.subscription.getRequestForAddOnRemoval( + ctx.addOn.code ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'term_end', addOnUpdates: [], }) ) }) - it('returns a change request when in trial', function () { + it('returns a change request when in trial', function (ctx) { const fiveDaysFromNow = new Date() fiveDaysFromNow.setDate(fiveDaysFromNow.getDate() + 5) - this.subscription.trialPeriodEnd = fiveDaysFromNow - const changeRequest = this.subscription.getRequestForAddOnRemoval( - this.addOn.code + ctx.subscription.trialPeriodEnd = fiveDaysFromNow + const changeRequest = ctx.subscription.getRequestForAddOnRemoval( + ctx.addOn.code ) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', addOnUpdates: [], }) ) }) - it("throws an AddOnNotPresentError if the subscription doesn't have the add-on", function () { + it("throws an AddOnNotPresentError if the subscription doesn't have the add-on", function (ctx) { expect(() => - this.subscription.getRequestForAddOnRemoval('another-add-on') + ctx.subscription.getRequestForAddOnRemoval('another-add-on') ).to.throw(Errors.AddOnNotPresentError) }) }) describe('getRequestForAddOnReactivation()', function () { - it('throws an AddOnNotPresentError', function () { + it('throws an AddOnNotPresentError', function (ctx) { expect(() => - this.subscription.getRequestForAddOnReactivation(this.addOn.code) + ctx.subscription.getRequestForAddOnReactivation(ctx.addOn.code) ).to.throw(Errors.AddOnNotPresentError) }) }) describe('getRequestForGroupPlanUpgrade()', function () { - it('returns a correct change request', function () { + it('returns a correct change request', function (ctx) { const changeRequest = - this.subscription.getRequestForGroupPlanUpgrade('test_plan_code') + ctx.subscription.getRequestForGroupPlanUpgrade('test_plan_code') const addOns = [ new PaymentProviderSubscriptionAddOnUpdate({ code: 'add-on-code', @@ -423,7 +432,7 @@ describe('PaymentProviderEntities', function () { ] expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', addOnUpdates: addOns, planCode: 'test_plan_code', @@ -433,15 +442,15 @@ describe('PaymentProviderEntities', function () { }) describe('getRequestForPoNumberAndTermsAndConditionsUpdate()', function () { - it('returns a correct update request', function () { + it('returns a correct update request', function (ctx) { const updateRequest = - this.subscription.getRequestForPoNumberAndTermsAndConditionsUpdate( + ctx.subscription.getRequestForPoNumberAndTermsAndConditionsUpdate( 'O12345', 'T&C copy' ) expect(updateRequest).to.deep.equal( new PaymentProviderSubscriptionUpdateRequest({ - subscription: this.subscription, + subscription: ctx.subscription, poNumber: 'O12345', termsAndConditions: 'T&C copy', }) @@ -450,12 +459,12 @@ describe('PaymentProviderEntities', function () { }) describe('getRequestForTermsAndConditionsUpdate()', function () { - it('returns a correct update request', function () { + it('returns a correct update request', function (ctx) { const updateRequest = - this.subscription.getRequestForTermsAndConditionsUpdate('T&C copy') + ctx.subscription.getRequestForTermsAndConditionsUpdate('T&C copy') expect(updateRequest).to.deep.equal( new PaymentProviderSubscriptionUpdateRequest({ - subscription: this.subscription, + subscription: ctx.subscription, termsAndConditions: 'T&C copy', }) ) @@ -463,41 +472,41 @@ describe('PaymentProviderEntities', function () { }) describe('with an add-on pending cancellation', function () { - beforeEach(function () { - this.subscription.pendingChange = + beforeEach(function (ctx) { + ctx.subscription.pendingChange = new PaymentProviderSubscriptionChange({ - subscription: this.subscription, - nextPlanCode: this.subscription.planCode, - nextPlanName: this.subscription.planName, - nextPlanPrice: this.subscription.planPrice, + subscription: ctx.subscription, + nextPlanCode: ctx.subscription.planCode, + nextPlanName: ctx.subscription.planName, + nextPlanPrice: ctx.subscription.planPrice, nextAddOns: [], }) }) describe('getRequestForAddOnReactivation()', function () { - it('returns a change request', function () { + it('returns a change request', function (ctx) { const changeRequest = - this.subscription.getRequestForAddOnReactivation(this.addOn.code) + ctx.subscription.getRequestForAddOnReactivation(ctx.addOn.code) expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'term_end', - addOnUpdates: [this.addOn.toAddOnUpdate()], + addOnUpdates: [ctx.addOn.toAddOnUpdate()], }) ) }) - it('throws an AddOnNotPresentError if given the wrong add-on', function () { + it('throws an AddOnNotPresentError if given the wrong add-on', function (ctx) { expect(() => - this.subscription.getRequestForAddOnReactivation('some-add-on') + ctx.subscription.getRequestForAddOnReactivation('some-add-on') ).to.throw(Errors.AddOnNotPresentError) }) }) describe('getRequestForPlanRevert()', function () { - beforeEach(function () { - const { PaymentProviderSubscription } = this.PaymentProviderEntities - this.subscription = new PaymentProviderSubscription({ + beforeEach(function (ctx) { + const { PaymentProviderSubscription } = ctx.PaymentProviderEntities + ctx.subscription = new PaymentProviderSubscription({ id: 'subscription-id', userId: 'user-id', planCode: 'regular-plan', @@ -523,24 +532,24 @@ describe('PaymentProviderEntities', function () { }) }) - it('throws if the plan to revert to doesnt exist', function () { + it('throws if the plan to revert to doesnt exist', function (ctx) { const invalidPlanCode = 'non-existent-plan' expect(() => - this.subscription.getRequestForPlanRevert(invalidPlanCode, null) + ctx.subscription.getRequestForPlanRevert(invalidPlanCode, null) ).to.throw('Unable to find plan in settings') }) - it('creates a change request with the restore point', function () { + it('creates a change request with the restore point', function (ctx) { const previousPlanCode = 'cheap-plan' const previousAddOns = [ { addOnCode: 'addon-1', quantity: 1, unitAmountInCents: 500 }, ] - const changeRequest = this.subscription.getRequestForPlanRevert( + const changeRequest = ctx.subscription.getRequestForPlanRevert( previousPlanCode, previousAddOns ) expect(changeRequest).to.be.an.instanceOf( - this.PaymentProviderEntities + ctx.PaymentProviderEntities .PaymentProviderSubscriptionChangeRequest ) expect(changeRequest.planCode).to.equal(previousPlanCode) @@ -553,9 +562,9 @@ describe('PaymentProviderEntities', function () { ]) }) - it('defaults to addons to an empty array to clear the addon state', function () { + it('defaults to addons to an empty array to clear the addon state', function (ctx) { const previousPlanCode = 'cheap-plan' - const changeRequest = this.subscription.getRequestForPlanRevert( + const changeRequest = ctx.subscription.getRequestForPlanRevert( previousPlanCode, null ) @@ -566,9 +575,9 @@ describe('PaymentProviderEntities', function () { }) describe('without add-ons', function () { - beforeEach(function () { - const { PaymentProviderSubscription } = this.PaymentProviderEntities - this.subscription = new PaymentProviderSubscription({ + beforeEach(function (ctx) { + const { PaymentProviderSubscription } = ctx.PaymentProviderEntities + ctx.subscription = new PaymentProviderSubscription({ id: 'subscription-id', userId: 'user-id', planCode: 'regular-plan', @@ -583,22 +592,22 @@ describe('PaymentProviderEntities', function () { }) describe('hasAddOn()', function () { - it('returns false for any add-on', function () { - expect(this.subscription.hasAddOn('some-add-on')).to.be.false + it('returns false for any add-on', function (ctx) { + expect(ctx.subscription.hasAddOn('some-add-on')).to.be.false }) }) describe('getRequestForAddOnPurchase()', function () { - it('returns a change request', function () { + it('returns a change request', function (ctx) { const { PaymentProviderSubscriptionChangeRequest, PaymentProviderSubscriptionAddOnUpdate, - } = this.PaymentProviderEntities + } = ctx.PaymentProviderEntities const changeRequest = - this.subscription.getRequestForAddOnPurchase('some-add-on') + ctx.subscription.getRequestForAddOnPurchase('some-add-on') expect(changeRequest).to.deep.equal( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', addOnUpdates: [ new PaymentProviderSubscriptionAddOnUpdate({ @@ -612,17 +621,17 @@ describe('PaymentProviderEntities', function () { }) describe('getRequestForAddOnRemoval()', function () { - it('throws an AddOnNotPresentError', function () { + it('throws an AddOnNotPresentError', function (ctx) { expect(() => - this.subscription.getRequestForAddOnRemoval('some-add-on') + ctx.subscription.getRequestForAddOnRemoval('some-add-on') ).to.throw(Errors.AddOnNotPresentError) }) }) describe('getRequestForAddOnReactivation()', function () { - it('throws an AddOnNotPresentError', function () { + it('throws an AddOnNotPresentError', function (ctx) { expect(() => - this.subscription.getRequestForAddOnReactivation('some-add-on') + ctx.subscription.getRequestForAddOnReactivation('some-add-on') ).to.throw(Errors.AddOnNotPresentError) }) }) diff --git a/services/web/test/unit/src/Subscription/PlansLocator.test.mjs b/services/web/test/unit/src/Subscription/PlansLocator.test.mjs index 7e35ccea58..7fd3c3effe 100644 --- a/services/web/test/unit/src/Subscription/PlansLocator.test.mjs +++ b/services/web/test/unit/src/Subscription/PlansLocator.test.mjs @@ -1,5 +1,4 @@ -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') +import { vi, expect } from 'vitest' const modulePath = '../../../../app/src/Features/Subscription/PlansLocator' const plans = [ @@ -27,125 +26,125 @@ const plans = [ ] describe('PlansLocator', function () { - beforeEach(function () { - this.settings = { plans } - this.AI_ADD_ON_CODE = 'assistant' + beforeEach(async function (ctx) { + ctx.settings = { plans } + ctx.AI_ADD_ON_CODE = 'assistant' - this.PlansLocator = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': this.settings, - }, - }) + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + ctx.PlansLocator = (await import(modulePath)).default }) describe('findLocalPlanInSettings', function () { - it('should return the found plan', function () { - const plan = this.PlansLocator.findLocalPlanInSettings('second') + it('should return the found plan', function (ctx) { + const plan = ctx.PlansLocator.findLocalPlanInSettings('second') expect(plan).to.have.property('name', '2nd') expect(plan).to.have.property('price_in_cents', 1500) }) - it('should return null if no matching plan is found', function () { - const plan = this.PlansLocator.findLocalPlanInSettings('gibberish') + it('should return null if no matching plan is found', function (ctx) { + const plan = ctx.PlansLocator.findLocalPlanInSettings('gibberish') expect(plan).to.be.a('null') }) }) describe('buildStripeLookupKey', function () { - it('should map "collaborator" plan code to stripe lookup keys', function () { + it('should map "collaborator" plan code to stripe lookup keys', function (ctx) { const planCode = 'collaborator' const currency = 'eur' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( planCode, currency ) expect(lookupKey).to.equal('standard_monthly_jun2025_eur') }) - it('should map "collaborator_free_trial_7_days" plan code to stripe lookup keys', function () { + it('should map "collaborator_free_trial_7_days" plan code to stripe lookup keys', function (ctx) { const planCode = 'collaborator_free_trial_7_days' const currency = 'eur' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( planCode, currency ) expect(lookupKey).to.equal('standard_monthly_jun2025_eur') }) - it('should map "collaborator-annual" plan code to stripe lookup keys', function () { + it('should map "collaborator-annual" plan code to stripe lookup keys', function (ctx) { const planCode = 'collaborator-annual' const currency = 'eur' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( planCode, currency ) expect(lookupKey).to.equal('standard_annual_jun2025_eur') }) - it('should map "professional" plan code to stripe lookup keys', function () { + it('should map "professional" plan code to stripe lookup keys', function (ctx) { const planCode = 'professional' const currency = 'eur' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( planCode, currency ) expect(lookupKey).to.equal('professional_monthly_jun2025_eur') }) - it('should map "professional_free_trial_7_days" plan code to stripe lookup keys', function () { + it('should map "professional_free_trial_7_days" plan code to stripe lookup keys', function (ctx) { const planCode = 'professional_free_trial_7_days' const currency = 'eur' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( planCode, currency ) expect(lookupKey).to.equal('professional_monthly_jun2025_eur') }) - it('should map "professional-annual" plan code to stripe lookup keys', function () { + it('should map "professional-annual" plan code to stripe lookup keys', function (ctx) { const planCode = 'professional-annual' const currency = 'eur' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( planCode, currency ) expect(lookupKey).to.equal('professional_annual_jun2025_eur') }) - it('should map "student" plan code to stripe lookup keys', function () { + it('should map "student" plan code to stripe lookup keys', function (ctx) { const planCode = 'student' const currency = 'eur' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( planCode, currency ) expect(lookupKey).to.equal('student_monthly_jun2025_eur') }) - it('shoult map "student_free_trial_7_days" plan code to stripe lookup keys', function () { + it('shoult map "student_free_trial_7_days" plan code to stripe lookup keys', function (ctx) { const planCode = 'student_free_trial_7_days' const currency = 'eur' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( planCode, currency ) expect(lookupKey).to.equal('student_monthly_jun2025_eur') }) - it('should map "student-annual" plan code to stripe lookup keys', function () { + it('should map "student-annual" plan code to stripe lookup keys', function (ctx) { const planCode = 'student-annual' const currency = 'eur' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( planCode, currency ) expect(lookupKey).to.equal('student_annual_jun2025_eur') }) - it('should return null for unknown add-on codes', function () { + it('should return null for unknown add-on codes', function (ctx) { const billingCycleInterval = 'month' const addOnCode = 'unknown_addon' const currency = 'gbp' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( addOnCode, currency, billingCycleInterval @@ -153,19 +152,19 @@ describe('PlansLocator', function () { expect(lookupKey).to.equal(null) }) - it('should handle missing input', function () { - const lookupKey = this.PlansLocator.buildStripeLookupKey( + it('should handle missing input', function (ctx) { + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( undefined, undefined ) expect(lookupKey).to.equal(null) }) - it('returns the key for a monthly AI assist add-on', function () { + it('returns the key for a monthly AI assist add-on', function (ctx) { const billingCycleInterval = 'month' - const addOnCode = this.AI_ADD_ON_CODE + const addOnCode = ctx.AI_ADD_ON_CODE const currency = 'gbp' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( addOnCode, currency, billingCycleInterval @@ -173,11 +172,11 @@ describe('PlansLocator', function () { expect(lookupKey).to.equal('assistant_monthly_jun2025_gbp') }) - it('returns the key for an annual AI assist add-on', function () { + it('returns the key for an annual AI assist add-on', function (ctx) { const billingCycleInterval = 'year' - const addOnCode = this.AI_ADD_ON_CODE + const addOnCode = ctx.AI_ADD_ON_CODE const currency = 'gbp' - const lookupKey = this.PlansLocator.buildStripeLookupKey( + const lookupKey = ctx.PlansLocator.buildStripeLookupKey( addOnCode, currency, billingCycleInterval @@ -187,79 +186,75 @@ describe('PlansLocator', function () { }) describe('getPlanTypeAndPeriodFromRecurlyPlanCode', function () { - it('should return the plan type and period for "collaborator"', function () { + it('should return the plan type and period for "collaborator"', function (ctx) { const { planType, period } = - this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( - 'collaborator' - ) + ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode('collaborator') expect(planType).to.equal('individual') expect(period).to.equal('monthly') }) - it('should return the plan type and period for "collaborator_free_trial_7_days"', function () { + it('should return the plan type and period for "collaborator_free_trial_7_days"', function (ctx) { const { planType, period } = - this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( + ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'collaborator_free_trial_7_days' ) expect(planType).to.equal('individual') expect(period).to.equal('monthly') }) - it('should return the plan type and period for "collaborator-annual"', function () { + it('should return the plan type and period for "collaborator-annual"', function (ctx) { const { planType, period } = - this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( + ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'collaborator-annual' ) expect(planType).to.equal('individual') expect(period).to.equal('annual') }) - it('should return the plan type and period for "professional"', function () { + it('should return the plan type and period for "professional"', function (ctx) { const { planType, period } = - this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( - 'professional' - ) + ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode('professional') expect(planType).to.equal('individual') expect(period).to.equal('monthly') }) - it('should return the plan type and period for "professional_free_trial_7_days"', function () { + it('should return the plan type and period for "professional_free_trial_7_days"', function (ctx) { const { planType, period } = - this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( + ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'professional_free_trial_7_days' ) expect(planType).to.equal('individual') expect(period).to.equal('monthly') }) - it('should return the plan type and period for "professional-annual"', function () { + it('should return the plan type and period for "professional-annual"', function (ctx) { const { planType, period } = - this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( + ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'professional-annual' ) expect(planType).to.equal('individual') expect(period).to.equal('annual') }) - it('should return the plan type and period for "student"', function () { + it('should return the plan type and period for "student"', function (ctx) { const { planType, period } = - this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode('student') + ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode('student') expect(planType).to.equal('student') expect(period).to.equal('monthly') }) - it('should return the plan type and period for "student_free_trial_7_days"', function () { + it('should return the plan type and period for "student_free_trial_7_days"', function (ctx) { const { planType, period } = - this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( + ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'student_free_trial_7_days' ) expect(planType).to.equal('student') expect(period).to.equal('monthly') }) - it('should return the plan type and period for "student-annual"', function () { + it('should return the plan type and period for "student-annual"', function (ctx) { const { planType, period } = - this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( + ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode( 'student-annual' ) expect(planType).to.equal('student') @@ -268,9 +263,9 @@ describe('PlansLocator', function () { }) describe('convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded', function () { - it('returns original plan name for non-group plan codes', function () { + it('returns original plan name for non-group plan codes', function (ctx) { expect( - this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( + ctx.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( 'professional' ) ).to.deep.equal({ @@ -279,9 +274,9 @@ describe('PlansLocator', function () { }) }) - it('converts Recurly enterprise group plan codes to Stripe group plan codes', function () { + it('converts Recurly enterprise group plan codes to Stripe group plan codes', function (ctx) { expect( - this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( + ctx.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( 'group_collaborator_10_enterprise' ) ).to.deep.equal({ @@ -290,9 +285,9 @@ describe('PlansLocator', function () { }) }) - it('converts Recurly educational group plan codes to Stripe group plan codes', function () { + it('converts Recurly educational group plan codes to Stripe group plan codes', function (ctx) { expect( - this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( + ctx.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded( 'group_professional_10_educational' ) ).to.deep.equal({ diff --git a/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs b/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs index 82923ace96..3c6d50f149 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs +++ b/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs @@ -1,21 +1,21 @@ -const sinon = require('sinon') -const { expect } = require('chai') -const recurly = require('recurly') -const SandboxedModule = require('sandboxed-module') -const { +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import recurly from 'recurly' + +import { PaymentProviderSubscription, PaymentProviderSubscriptionChangeRequest, PaymentProviderSubscriptionUpdateRequest, PaymentProviderSubscriptionAddOnUpdate, PaymentProviderAccount, PaymentProviderCoupon, -} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities') +} from '../../../../app/src/Features/Subscription/PaymentProviderEntities.js' const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient' describe('RecurlyClient', function () { - beforeEach(function () { - this.settings = { + beforeEach(async function (ctx) { + ctx.settings = { apis: { recurly: { apiKey: 'nonsense', @@ -27,15 +27,15 @@ describe('RecurlyClient', function () { features: [], } - this.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' } - this.subscriptionChange = { id: 'subscription-change-123' } - this.recurlyAccount = new recurly.Account() - Object.assign(this.recurlyAccount, { - code: this.user._id, - email: this.user.email, + ctx.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' } + ctx.subscriptionChange = { id: 'subscription-change-123' } + ctx.recurlyAccount = new recurly.Account() + Object.assign(ctx.recurlyAccount, { + code: ctx.user._id, + email: ctx.user.email, }) - this.subscriptionAddOn = { + ctx.subscriptionAddOn = { code: 'addon-code', name: 'My Add-On', quantity: 1, @@ -43,14 +43,14 @@ describe('RecurlyClient', function () { preTaxTotal: 2, } - this.subscription = new PaymentProviderSubscription({ + ctx.subscription = new PaymentProviderSubscription({ id: 'subscription-id', userId: 'user-id', currency: 'EUR', planCode: 'plan-code', planName: 'plan-name', planPrice: 13, - addOns: [this.subscriptionAddOn], + addOns: [ctx.subscriptionAddOn], subtotal: 15, taxRate: 0.1, taxAmount: 1.5, @@ -63,54 +63,54 @@ describe('RecurlyClient', function () { termsAndConditions: '', }) - this.recurlySubscription = { - uuid: this.subscription.id, + ctx.recurlySubscription = { + uuid: ctx.subscription.id, account: { - code: this.subscription.userId, + code: ctx.subscription.userId, }, plan: { - code: this.subscription.planCode, - name: this.subscription.planName, + code: ctx.subscription.planCode, + name: ctx.subscription.planName, }, addOns: [ { addOn: { - code: this.subscriptionAddOn.code, - name: this.subscriptionAddOn.name, + code: ctx.subscriptionAddOn.code, + name: ctx.subscriptionAddOn.name, }, - quantity: this.subscriptionAddOn.quantity, - unitAmount: this.subscriptionAddOn.unitPrice, + quantity: ctx.subscriptionAddOn.quantity, + unitAmount: ctx.subscriptionAddOn.unitPrice, }, ], - unitAmount: this.subscription.planPrice, - subtotal: this.subscription.subtotal, - taxInfo: { rate: this.subscription.taxRate }, - tax: this.subscription.taxAmount, - total: this.subscription.total, - currency: this.subscription.currency, - currentPeriodStartedAt: this.subscription.periodStart, - currentPeriodEndsAt: this.subscription.periodEnd, - collectionMethod: this.subscription.collectionMethod, - netTerms: this.subscription.netTerms, - poNumber: this.subscription.poNumber, - termsAndConditions: this.subscription.termsAndConditions, + unitAmount: ctx.subscription.planPrice, + subtotal: ctx.subscription.subtotal, + taxInfo: { rate: ctx.subscription.taxRate }, + tax: ctx.subscription.taxAmount, + total: ctx.subscription.total, + currency: ctx.subscription.currency, + currentPeriodStartedAt: ctx.subscription.periodStart, + currentPeriodEndsAt: ctx.subscription.periodEnd, + collectionMethod: ctx.subscription.collectionMethod, + netTerms: ctx.subscription.netTerms, + poNumber: ctx.subscription.poNumber, + termsAndConditions: ctx.subscription.termsAndConditions, } - this.recurlySubscriptionChange = new recurly.SubscriptionChange() - Object.assign(this.recurlySubscriptionChange, this.subscriptionChange) + ctx.recurlySubscriptionChange = new recurly.SubscriptionChange() + Object.assign(ctx.recurlySubscriptionChange, ctx.subscriptionChange) - this.UserGetter = { + ctx.UserGetter = { promises: { getUser: sinon.stub().callsFake(userId => { - if (userId === this.user._id) { - return this.user + if (userId === ctx.user._id) { + return ctx.user } }), }, } let client - this.client = client = { + ctx.client = client = { getAccount: sinon.stub(), getBillingInfo: sinon.stub(), listAccountSubscriptions: sinon.stub(), @@ -118,39 +118,50 @@ describe('RecurlyClient', function () { previewSubscriptionChange: sinon.stub(), listSubscriptionInvoices: sinon.stub(), } - this.recurly = { + ctx.recurly = { errors: recurly.errors, Client: function () { return client }, } - this.Errors = { + ctx.Errors = { MissingBillingInfoError: class extends Error {}, SubtotalLimitExceededError: class extends Error {}, } - return (this.RecurlyClient = SandboxedModule.require(MODULE_PATH, { - globals: { - console, - }, - requires: { - '@overleaf/settings': this.settings, - recurly: this.recurly, - '@overleaf/logger': { - err: sinon.stub(), - error: sinon.stub(), - warn: sinon.stub(), - log: sinon.stub(), - debug: sinon.stub(), - }, - '../User/UserGetter': this.UserGetter, - './Errors': this.Errors, - '../../models/Subscription': {}, + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('recurly', () => ({ + default: ctx.recurly, + })) + + vi.doMock('@overleaf/logger', () => ({ + default: { + err: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + log: sinon.stub(), + debug: sinon.stub(), }, })) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/Errors', + () => ctx.Errors + ) + + vi.doMock('../../../../app/src/models/Subscription', () => ({})) + + ctx.RecurlyClient = (await import(MODULE_PATH)).default }) - describe('initalizing recurly client with undefined API key parameter', function () { + describe('initializing recurly client with undefined API key parameter', function () { it('should create a client without error', function () { let testClient expect(() => { @@ -161,64 +172,64 @@ describe('RecurlyClient', function () { }) describe('getAccountForUserId', function () { - it('should return an Account if one exists', async function () { - this.client.getAccount = sinon.stub().resolves(this.recurlyAccount) - const account = await this.RecurlyClient.promises.getAccountForUserId( - this.user._id + it('should return an Account if one exists', async function (ctx) { + ctx.client.getAccount = sinon.stub().resolves(ctx.recurlyAccount) + const account = await ctx.RecurlyClient.promises.getAccountForUserId( + ctx.user._id ) const expectedAccount = new PaymentProviderAccount({ - code: this.user._id, - email: this.user.email, + code: ctx.user._id, + email: ctx.user.email, hasPastDueInvoice: false, }) expect(account).to.deep.equal(expectedAccount) }) - it('should return null if no account found', async function () { - this.client.getAccount = sinon + it('should return null if no account found', async function (ctx) { + ctx.client.getAccount = sinon .stub() .throws(new recurly.errors.NotFoundError()) const account = - await this.RecurlyClient.promises.getAccountForUserId('nonsense') + await ctx.RecurlyClient.promises.getAccountForUserId('nonsense') expect(account).to.equal(null) }) - it('should re-throw caught errors', async function () { - this.client.getAccount = sinon.stub().throws() + it('should re-throw caught errors', async function (ctx) { + ctx.client.getAccount = sinon.stub().throws() await expect( - this.RecurlyClient.promises.getAccountForUserId(this.user._id) + ctx.RecurlyClient.promises.getAccountForUserId(ctx.user._id) ).to.eventually.be.rejectedWith(Error) }) }) describe('createAccountForUserId', function () { - it('should return the Account as created by recurly', async function () { - this.client.createAccount = sinon.stub().resolves(this.recurlyAccount) - const result = await this.RecurlyClient.promises.createAccountForUserId( - this.user._id + it('should return the Account as created by recurly', async function (ctx) { + ctx.client.createAccount = sinon.stub().resolves(ctx.recurlyAccount) + const result = await ctx.RecurlyClient.promises.createAccountForUserId( + ctx.user._id ) - expect(result).to.has.property('code', this.user._id) + expect(result).to.has.property('code', ctx.user._id) }) - it('should throw any API errors', async function () { - this.client.createAccount = sinon.stub().throws() + it('should throw any API errors', async function (ctx) { + ctx.client.createAccount = sinon.stub().throws() await expect( - this.RecurlyClient.promises.createAccountForUserId(this.user._id) + ctx.RecurlyClient.promises.createAccountForUserId(ctx.user._id) ).to.eventually.be.rejectedWith(Error) }) }) describe('getActiveCouponsForUserId', function () { - it('should return an empty array if no coupons returned', async function () { - this.client.listActiveCouponRedemptions.returns({ + it('should return an empty array if no coupons returned', async function (ctx) { + ctx.client.listActiveCouponRedemptions.returns({ each: async function* () {}, }) const coupons = - await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user') + await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user') expect(coupons).to.deep.equal([]) }) - it('should return a coupons returned by recurly', async function () { + it('should return a coupons returned by recurly', async function (ctx) { const recurlyCoupon = { coupon: { code: 'coupon-code', @@ -227,13 +238,13 @@ describe('RecurlyClient', function () { invoiceDescription: 'invoice description', }, } - this.client.listActiveCouponRedemptions.returns({ + ctx.client.listActiveCouponRedemptions.returns({ each: async function* () { yield recurlyCoupon }, }) const coupons = - await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user') + await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user') const expectedCoupons = [ new PaymentProviderCoupon({ code: 'coupon-code', @@ -244,28 +255,28 @@ describe('RecurlyClient', function () { expect(coupons).to.deep.equal(expectedCoupons) }) - it('should not throw for Recurly not found error', async function () { - this.client.listActiveCouponRedemptions = sinon + it('should not throw for Recurly not found error', async function (ctx) { + ctx.client.listActiveCouponRedemptions = sinon .stub() .throws(new recurly.errors.NotFoundError()) const coupons = - await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user') + await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user') expect(coupons).to.deep.equal([]) }) - it('should throw any other API errors', async function () { - this.client.listActiveCouponRedemptions = sinon.stub().throws() + it('should throw any other API errors', async function (ctx) { + ctx.client.listActiveCouponRedemptions = sinon.stub().throws() await expect( - this.RecurlyClient.promises.getActiveCouponsForUserId('some-user') + ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user') ).to.eventually.be.rejectedWith(Error) }) }) describe('getCustomerManagementLink', function () { - it('should throw if recurly token is not returned', async function () { - this.client.getAccount.resolves({}) + it('should throw if recurly token is not returned', async function (ctx) { + ctx.client.getAccount.resolves({}) await expect( - this.RecurlyClient.promises.getCustomerManagementLink( + ctx.RecurlyClient.promises.getCustomerManagementLink( '12345', 'account-management', 'en-US' @@ -273,30 +284,28 @@ describe('RecurlyClient', function () { ).to.be.rejectedWith('recurly account does not have hosted login token') }) - it('should generate the correct account management url', async function () { - this.client.getAccount.resolves({ + it('should generate the correct account management url', async function (ctx) { + ctx.client.getAccount.resolves({ hostedLoginToken: '987654321', }) - const result = - await this.RecurlyClient.promises.getCustomerManagementLink( - '12345', - 'account-management', - 'en-US' - ) + const result = await ctx.RecurlyClient.promises.getCustomerManagementLink( + '12345', + 'account-management', + 'en-US' + ) expect(result).to.equal('https://test.recurly.com/account/987654321') }) - it('should generate the correct billing details url', async function () { - this.client.getAccount.resolves({ + it('should generate the correct billing details url', async function (ctx) { + ctx.client.getAccount.resolves({ hostedLoginToken: '987654321', }) - const result = - await this.RecurlyClient.promises.getCustomerManagementLink( - '12345', - 'billing-details', - 'en-US' - ) + const result = await ctx.RecurlyClient.promises.getCustomerManagementLink( + '12345', + 'billing-details', + 'en-US' + ) expect(result).to.equal( 'https://test.recurly.com/account/billing_info/edit?ht=987654321' @@ -305,98 +314,98 @@ describe('RecurlyClient', function () { }) describe('getSubscription', function () { - it('should return the subscription found by recurly', async function () { - this.client.getSubscription = sinon + it('should return the subscription found by recurly', async function (ctx) { + ctx.client.getSubscription = sinon .stub() .withArgs('uuid-subscription-id') - .resolves(this.recurlySubscription) - const subscription = await this.RecurlyClient.promises.getSubscription( - this.subscription.id + .resolves(ctx.recurlySubscription) + const subscription = await ctx.RecurlyClient.promises.getSubscription( + ctx.subscription.id ) - expect(subscription).to.deep.equal(this.subscription) + expect(subscription).to.deep.equal(ctx.subscription) }) - it('should throw any API errors', async function () { - this.client.getSubscription = sinon.stub().throws() + it('should throw any API errors', async function (ctx) { + ctx.client.getSubscription = sinon.stub().throws() await expect( - this.RecurlyClient.promises.getSubscription(this.user._id) + ctx.RecurlyClient.promises.getSubscription(ctx.user._id) ).to.eventually.be.rejectedWith(Error) }) }) describe('getSubscriptionForUser', function () { - it("should return null if the account doesn't exist", async function () { - this.client.listAccountSubscriptions.returns({ + it("should return null if the account doesn't exist", async function (ctx) { + ctx.client.listAccountSubscriptions.returns({ // eslint-disable-next-line require-yield each: async function* () { throw new recurly.errors.NotFoundError('account not found') }, }) const subscription = - await this.RecurlyClient.promises.getSubscriptionForUser('some-user') + await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user') expect(subscription).to.be.null }) - it("should return null if the account doesn't have subscriptions", async function () { - this.client.listAccountSubscriptions.returns({ + it("should return null if the account doesn't have subscriptions", async function (ctx) { + ctx.client.listAccountSubscriptions.returns({ each: async function* () {}, }) const subscription = - await this.RecurlyClient.promises.getSubscriptionForUser('some-user') + await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user') expect(subscription).to.be.null }) - it('should return the subscription if the account has one subscription', async function () { - const recurlySubscription = this.recurlySubscription - this.client.listAccountSubscriptions.returns({ + it('should return the subscription if the account has one subscription', async function (ctx) { + const recurlySubscription = ctx.recurlySubscription + ctx.client.listAccountSubscriptions.returns({ each: async function* () { yield recurlySubscription }, }) const subscription = - await this.RecurlyClient.promises.getSubscriptionForUser('some-user') - expect(subscription).to.deep.equal(this.subscription) + await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user') + expect(subscription).to.deep.equal(ctx.subscription) }) - it('should throw an error if the account has more than one subscription', async function () { - const recurlySubscription = this.recurlySubscription - this.client.listAccountSubscriptions.returns({ + it('should throw an error if the account has more than one subscription', async function (ctx) { + const recurlySubscription = ctx.recurlySubscription + ctx.client.listAccountSubscriptions.returns({ each: async function* () { yield recurlySubscription yield { another: 'subscription' } }, }) await expect( - this.RecurlyClient.promises.getSubscriptionForUser('some-user') + ctx.RecurlyClient.promises.getSubscriptionForUser('some-user') ).to.be.rejected }) }) describe('applySubscriptionChangeRequest', function () { - beforeEach(function () { - this.client.createSubscriptionChange = sinon + beforeEach(function (ctx) { + ctx.client.createSubscriptionChange = sinon .stub() - .resolves(this.recurlySubscriptionChange) + .resolves(ctx.recurlySubscriptionChange) }) - it('handles plan changes', async function () { - await this.RecurlyClient.promises.applySubscriptionChangeRequest( + it('handles plan changes', async function (ctx) { + await ctx.RecurlyClient.promises.applySubscriptionChangeRequest( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', planCode: 'new-plan', }) ) - expect(this.client.createSubscriptionChange).to.be.calledWith( + expect(ctx.client.createSubscriptionChange).to.be.calledWith( 'uuid-subscription-id', { timeframe: 'now', planCode: 'new-plan' } ) }) - it('handles add-on changes', async function () { - await this.RecurlyClient.promises.applySubscriptionChangeRequest( + it('handles add-on changes', async function (ctx) { + await ctx.RecurlyClient.promises.applySubscriptionChangeRequest( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', addOnUpdates: [ new PaymentProviderSubscriptionAddOnUpdate({ @@ -407,7 +416,7 @@ describe('RecurlyClient', function () { ], }) ) - expect(this.client.createSubscriptionChange).to.be.calledWith( + expect(ctx.client.createSubscriptionChange).to.be.calledWith( 'uuid-subscription-id', { timeframe: 'now', @@ -416,114 +425,114 @@ describe('RecurlyClient', function () { ) }) - it('should throw any API errors', async function () { - this.client.createSubscriptionChange = sinon.stub().throws() + it('should throw any API errors', async function (ctx) { + ctx.client.createSubscriptionChange = sinon.stub().throws() await expect( - this.RecurlyClient.promises.applySubscriptionChangeRequest({ - subscription: this.subscription, + ctx.RecurlyClient.promises.applySubscriptionChangeRequest({ + subscription: ctx.subscription, }) ).to.eventually.be.rejectedWith(Error) }) - it('should throw SubtotalLimitExceededError', async function () { + it('should throw SubtotalLimitExceededError', async function (ctx) { class ValidationError extends recurly.errors.ValidationError { constructor() { super() this.params = [{ param: 'subtotal_amount_in_cents' }] } } - this.client.createSubscriptionChange = sinon + ctx.client.createSubscriptionChange = sinon .stub() .throws(new ValidationError()) await expect( - this.RecurlyClient.promises.applySubscriptionChangeRequest({ - subscription: this.subscription, + ctx.RecurlyClient.promises.applySubscriptionChangeRequest({ + subscription: ctx.subscription, }) - ).to.be.rejectedWith(this.Errors.SubtotalLimitExceededError) + ).to.be.rejectedWith(ctx.Errors.SubtotalLimitExceededError) }) - it('should rethrow errors different than SubtotalLimitExceededError', async function () { - this.client.createSubscriptionChange = sinon.stub().throws(new Error()) + it('should rethrow errors different than SubtotalLimitExceededError', async function (ctx) { + ctx.client.createSubscriptionChange = sinon.stub().throws(new Error()) await expect( - this.RecurlyClient.promises.applySubscriptionChangeRequest({ - subscription: this.subscription, + ctx.RecurlyClient.promises.applySubscriptionChangeRequest({ + subscription: ctx.subscription, }) ).to.be.rejectedWith(Error) }) }) describe('updateSubscriptionDetails', function () { - beforeEach(function () { - this.client.updateSubscription = sinon + beforeEach(function (ctx) { + ctx.client.updateSubscription = sinon .stub() - .resolves({ id: this.subscription.id }) + .resolves({ id: ctx.subscription.id }) }) - it('handles subscription update', async function () { - await this.RecurlyClient.promises.updateSubscriptionDetails( + it('handles subscription update', async function (ctx) { + await ctx.RecurlyClient.promises.updateSubscriptionDetails( new PaymentProviderSubscriptionUpdateRequest({ - subscription: this.subscription, + subscription: ctx.subscription, poNumber: '012345', termsAndConditions: 'T&C', }) ) - expect(this.client.updateSubscription).to.be.calledWith( + expect(ctx.client.updateSubscription).to.be.calledWith( 'uuid-subscription-id', { poNumber: '012345', termsAndConditions: 'T&C' } ) }) - it('should throw any API errors', async function () { - this.client.updateSubscription = sinon.stub().throws() + it('should throw any API errors', async function (ctx) { + ctx.client.updateSubscription = sinon.stub().throws() await expect( - this.RecurlyClient.promises.updateSubscriptionDetails({ - subscription: this.subscription, + ctx.RecurlyClient.promises.updateSubscriptionDetails({ + subscription: ctx.subscription, }) ).to.eventually.be.rejectedWith(Error) }) }) describe('removeSubscriptionChange', function () { - beforeEach(function () { - this.client.removeSubscriptionChange = sinon.stub().resolves() + beforeEach(function (ctx) { + ctx.client.removeSubscriptionChange = sinon.stub().resolves() }) - it('should attempt to remove a pending subscription change', async function () { - this.RecurlyClient.promises.removeSubscriptionChange( - this.subscription.id, + it('should attempt to remove a pending subscription change', async function (ctx) { + ctx.RecurlyClient.promises.removeSubscriptionChange( + ctx.subscription.id, {} ) - expect(this.client.removeSubscriptionChange).to.be.calledWith( - this.subscription.id + expect(ctx.client.removeSubscriptionChange).to.be.calledWith( + ctx.subscription.id ) }) - it('should throw any API errors', async function () { - this.client.removeSubscriptionChange = sinon.stub().throws() + it('should throw any API errors', async function (ctx) { + ctx.client.removeSubscriptionChange = sinon.stub().throws() await expect( - this.RecurlyClient.promises.removeSubscriptionChange( - this.subscription.id, + ctx.RecurlyClient.promises.removeSubscriptionChange( + ctx.subscription.id, {} ) ).to.eventually.be.rejectedWith(Error) }) describe('removeSubscriptionChangeByUuid', function () { - it('should attempt to remove a pending subscription change', async function () { - this.RecurlyClient.promises.removeSubscriptionChangeByUuid( - this.subscription.uuid, + it('should attempt to remove a pending subscription change', async function (ctx) { + ctx.RecurlyClient.promises.removeSubscriptionChangeByUuid( + ctx.subscription.uuid, {} ) - expect(this.client.removeSubscriptionChange).to.be.calledWith( - 'uuid-' + this.subscription.uuid + expect(ctx.client.removeSubscriptionChange).to.be.calledWith( + 'uuid-' + ctx.subscription.uuid ) }) - it('should throw any API errors', async function () { - this.client.removeSubscriptionChange = sinon.stub().throws() + it('should throw any API errors', async function (ctx) { + ctx.client.removeSubscriptionChange = sinon.stub().throws() await expect( - this.RecurlyClient.promises.removeSubscriptionChangeByUuid( - this.subscription.id, + ctx.RecurlyClient.promises.removeSubscriptionChangeByUuid( + ctx.subscription.id, {} ) ).to.eventually.be.rejectedWith(Error) @@ -532,74 +541,74 @@ describe('RecurlyClient', function () { }) describe('reactivateSubscriptionByUuid', function () { - it('should attempt to reactivate the subscription', async function () { - this.client.reactivateSubscription = sinon + it('should attempt to reactivate the subscription', async function (ctx) { + ctx.client.reactivateSubscription = sinon .stub() - .resolves(this.recurlySubscription) + .resolves(ctx.recurlySubscription) const subscription = - await this.RecurlyClient.promises.reactivateSubscriptionByUuid( - this.subscription.uuid + await ctx.RecurlyClient.promises.reactivateSubscriptionByUuid( + ctx.subscription.uuid ) - expect(subscription).to.deep.equal(this.recurlySubscription) - expect(this.client.reactivateSubscription).to.be.calledWith( - 'uuid-' + this.subscription.uuid + expect(subscription).to.deep.equal(ctx.recurlySubscription) + expect(ctx.client.reactivateSubscription).to.be.calledWith( + 'uuid-' + ctx.subscription.uuid ) }) }) describe('cancelSubscriptionByUuid', function () { - it('should attempt to cancel the subscription', async function () { - this.client.cancelSubscription = sinon + it('should attempt to cancel the subscription', async function (ctx) { + ctx.client.cancelSubscription = sinon .stub() - .resolves(this.recurlySubscription) + .resolves(ctx.recurlySubscription) const subscription = - await this.RecurlyClient.promises.cancelSubscriptionByUuid( - this.subscription.uuid + await ctx.RecurlyClient.promises.cancelSubscriptionByUuid( + ctx.subscription.uuid ) - expect(subscription).to.deep.equal(this.recurlySubscription) - expect(this.client.cancelSubscription).to.be.calledWith( - 'uuid-' + this.subscription.uuid + expect(subscription).to.deep.equal(ctx.recurlySubscription) + expect(ctx.client.cancelSubscription).to.be.calledWith( + 'uuid-' + ctx.subscription.uuid ) }) - it('should terminate subscription when cancellation fails due to being in last cycle of paused term', async function () { + it('should terminate subscription when cancellation fails due to being in last cycle of paused term', async function (ctx) { const validationError = new recurly.errors.ValidationError() validationError.message = 'Cannot cancel a paused subscription in the last cycle of the term' - this.client.cancelSubscription = sinon.stub().throws(validationError) - this.client.terminateSubscription = sinon + ctx.client.cancelSubscription = sinon.stub().throws(validationError) + ctx.client.terminateSubscription = sinon .stub() - .resolves(this.recurlySubscription) + .resolves(ctx.recurlySubscription) const subscription = - await this.RecurlyClient.promises.cancelSubscriptionByUuid( - this.subscription.uuid + await ctx.RecurlyClient.promises.cancelSubscriptionByUuid( + ctx.subscription.uuid ) - expect(this.client.cancelSubscription).to.be.calledWith( - 'uuid-' + this.subscription.uuid + expect(ctx.client.cancelSubscription).to.be.calledWith( + 'uuid-' + ctx.subscription.uuid ) - expect(this.client.terminateSubscription).to.be.calledWith( - 'uuid-' + this.subscription.uuid + expect(ctx.client.terminateSubscription).to.be.calledWith( + 'uuid-' + ctx.subscription.uuid ) - expect(subscription).to.deep.equal(this.recurlySubscription) + expect(subscription).to.deep.equal(ctx.recurlySubscription) }) }) describe('pauseSubscriptionByUuid', function () { - it('should attempt to pause the subscription', async function () { - this.client.pauseSubscription = sinon + it('should attempt to pause the subscription', async function (ctx) { + ctx.client.pauseSubscription = sinon .stub() - .resolves(this.recurlySubscription) + .resolves(ctx.recurlySubscription) const subscription = - await this.RecurlyClient.promises.pauseSubscriptionByUuid( - this.subscription.uuid, + await ctx.RecurlyClient.promises.pauseSubscriptionByUuid( + ctx.subscription.uuid, 3 ) - expect(subscription).to.deep.equal(this.recurlySubscription) - expect(this.client.pauseSubscription).to.be.calledWith( - 'uuid-' + this.subscription.uuid, + expect(subscription).to.deep.equal(ctx.recurlySubscription) + expect(ctx.client.pauseSubscription).to.be.calledWith( + 'uuid-' + ctx.subscription.uuid, { remainingPauseCycles: 3 } ) }) @@ -607,8 +616,8 @@ describe('RecurlyClient', function () { describe('previewSubscriptionChange', function () { describe('compute immediate charge', function () { - it('only has charge invoice', async function () { - this.client.previewSubscriptionChange.resolves({ + it('only has charge invoice', async function (ctx) { + ctx.client.previewSubscriptionChange.resolves({ plan: { code: 'test_code', name: 'test name' }, unitAmount: 0, invoiceCollection: { @@ -620,9 +629,9 @@ describe('RecurlyClient', function () { }, }) const { immediateCharge } = - await this.RecurlyClient.promises.previewSubscriptionChange( + await ctx.RecurlyClient.promises.previewSubscriptionChange( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', planCode: 'new-plan', }) @@ -632,8 +641,8 @@ describe('RecurlyClient', function () { expect(immediateCharge.total).to.be.equal(120) }) - it('credit invoice with imprecise float number', async function () { - this.client.previewSubscriptionChange.resolves({ + it('credit invoice with imprecise float number', async function (ctx) { + ctx.client.previewSubscriptionChange.resolves({ plan: { code: 'test_code', name: 'test name' }, unitAmount: 0, invoiceCollection: { @@ -652,9 +661,9 @@ describe('RecurlyClient', function () { }, }) const { immediateCharge } = - await this.RecurlyClient.promises.previewSubscriptionChange( + await ctx.RecurlyClient.promises.previewSubscriptionChange( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', planCode: 'new-plan', }) @@ -665,33 +674,33 @@ describe('RecurlyClient', function () { }) }) - it('should throw SubtotalLimitExceededError', async function () { + it('should throw SubtotalLimitExceededError', async function (ctx) { class ValidationError extends recurly.errors.ValidationError { constructor() { super() this.params = [{ param: 'subtotal_amount_in_cents' }] } } - this.client.previewSubscriptionChange = sinon + ctx.client.previewSubscriptionChange = sinon .stub() .throws(new ValidationError()) await expect( - this.RecurlyClient.promises.previewSubscriptionChange( + ctx.RecurlyClient.promises.previewSubscriptionChange( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', planCode: 'new-plan', }) ) - ).to.be.rejectedWith(this.Errors.SubtotalLimitExceededError) + ).to.be.rejectedWith(ctx.Errors.SubtotalLimitExceededError) }) - it('should rethrow errors different than SubtotalLimitExceededError', async function () { - this.client.previewSubscriptionChange = sinon.stub().throws(new Error()) + it('should rethrow errors different than SubtotalLimitExceededError', async function (ctx) { + ctx.client.previewSubscriptionChange = sinon.stub().throws(new Error()) await expect( - this.RecurlyClient.promises.previewSubscriptionChange( + ctx.RecurlyClient.promises.previewSubscriptionChange( new PaymentProviderSubscriptionChangeRequest({ - subscription: this.subscription, + subscription: ctx.subscription, timeframe: 'now', planCode: 'new-plan', }) @@ -701,81 +710,81 @@ describe('RecurlyClient', function () { }) describe('getPaymentMethod', function () { - it('should throw MissingBillingInfoError', async function () { - this.client.getBillingInfo = sinon + it('should throw MissingBillingInfoError', async function (ctx) { + ctx.client.getBillingInfo = sinon .stub() .throws(new recurly.errors.NotFoundError()) await expect( - this.RecurlyClient.promises.getPaymentMethod(this.user._id) - ).to.be.rejectedWith(this.Errors.MissingBillingInfoError) + ctx.RecurlyClient.promises.getPaymentMethod(ctx.user._id) + ).to.be.rejectedWith(ctx.Errors.MissingBillingInfoError) }) - it('should rethrow errors different than MissingBillingInfoError', async function () { - this.client.getBillingInfo = sinon.stub().throws(new Error()) + it('should rethrow errors different than MissingBillingInfoError', async function (ctx) { + ctx.client.getBillingInfo = sinon.stub().throws(new Error()) await expect( - this.RecurlyClient.promises.getPaymentMethod(this.user._id) + ctx.RecurlyClient.promises.getPaymentMethod(ctx.user._id) ).to.be.rejectedWith(Error) }) }) describe('terminateSubscriptionByUuid', function () { - it('should attempt to terminate the subscription', async function () { - this.client.terminateSubscription = sinon + it('should attempt to terminate the subscription', async function (ctx) { + ctx.client.terminateSubscription = sinon .stub() - .resolves(this.recurlySubscription) + .resolves(ctx.recurlySubscription) const subscription = - await this.RecurlyClient.promises.terminateSubscriptionByUuid( - this.subscription.uuid + await ctx.RecurlyClient.promises.terminateSubscriptionByUuid( + ctx.subscription.uuid ) - expect(subscription).to.deep.equal(this.recurlySubscription) - expect(this.client.terminateSubscription).to.be.calledWith( - 'uuid-' + this.subscription.uuid + expect(subscription).to.deep.equal(ctx.recurlySubscription) + expect(ctx.client.terminateSubscription).to.be.calledWith( + 'uuid-' + ctx.subscription.uuid ) }) }) describe('getPastDueInvoices', function () { - beforeEach(function () { - this.client.listSubscriptionInvoices = sinon.stub() + beforeEach(function (ctx) { + ctx.client.listSubscriptionInvoices = sinon.stub() }) - it('should return empty if no past due are found', async function () { - this.client.listSubscriptionInvoices.returns({ + it('should return empty if no past due are found', async function (ctx) { + ctx.client.listSubscriptionInvoices.returns({ each: async function* () {}, }) - const invoices = await this.RecurlyClient.promises.getPastDueInvoices( - this.subscription.id + const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices( + ctx.subscription.id ) expect(invoices).to.deep.equal([]) }) - it('should return past due invoice', async function () { + it('should return past due invoice', async function (ctx) { const pastDueInvoice = { id: 'invoice-1', state: 'past_due' } - this.client.listSubscriptionInvoices.returns({ + ctx.client.listSubscriptionInvoices.returns({ each: async function* () { yield pastDueInvoice }, }) - const invoices = await this.RecurlyClient.promises.getPastDueInvoices( - this.subscription.id + const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices( + ctx.subscription.id ) expect(invoices).to.deep.equal([pastDueInvoice]) }) - it('should return multiple invoices if multiple past due exist', async function () { + it('should return multiple invoices if multiple past due exist', async function (ctx) { const pastDueInvoices = [ { id: 'invoice-1', state: 'past_due' }, { id: 'invoice-2', state: 'past_due' }, ] - this.client.listSubscriptionInvoices.returns({ + ctx.client.listSubscriptionInvoices.returns({ each: async function* () { for (const invoice of pastDueInvoices) { yield invoice } }, }) - const invoices = await this.RecurlyClient.promises.getPastDueInvoices( - this.subscription.id + const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices( + ctx.subscription.id ) expect(invoices).to.deep.equal(pastDueInvoices) }) diff --git a/services/web/test/unit/src/Subscription/RecurlyWrapper.test.mjs b/services/web/test/unit/src/Subscription/RecurlyWrapper.test.mjs index 9fb531ed16..32f86ad9ad 100644 --- a/services/web/test/unit/src/Subscription/RecurlyWrapper.test.mjs +++ b/services/web/test/unit/src/Subscription/RecurlyWrapper.test.mjs @@ -1,11 +1,10 @@ -const { assert, expect } = require('chai') -const sinon = require('sinon') +import { vi, assert, expect } from 'vitest' +import sinon from 'sinon' +import tk from 'timekeeper' +import Errors from '../../../../app/src/Features/Errors/Errors.js' +import SubscriptionErrors from '../../../../app/src/Features/Subscription/Errors.js' +import { RequestFailedError } from '@overleaf/fetch-utils' const modulePath = '../../../../app/src/Features/Subscription/RecurlyWrapper' -const SandboxedModule = require('sandboxed-module') -const tk = require('timekeeper') -const Errors = require('../../../../app/src/Features/Errors/Errors') -const SubscriptionErrors = require('../../../../app/src/Features/Subscription/Errors') -const { RequestFailedError } = require('@overleaf/fetch-utils') const fixtures = { 'subscriptions/44f83d7cba354d5b84812419f923ea96': @@ -91,6 +90,14 @@ const fixtures = { '', } +vi.mock('../../../../app/src/Features/Subscription/Errors', () => + vi.importActual('../../../../app/src/Features/Subscription/Errors') +) + +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) + const mockApiRequest = function (options) { if (fixtures[options.url]) { return { @@ -106,9 +113,9 @@ const mockApiRequest = function (options) { } describe('RecurlyWrapper', function () { - beforeEach(function () { + beforeEach(async function (ctx) { tk.freeze(Date.now()) // freeze the time for these tests - this.settings = { + ctx.settings = { plans: [ { planCode: 'collaborator', @@ -131,17 +138,20 @@ describe('RecurlyWrapper', function () { }, } - this.fetchUtils = { + ctx.fetchUtils = { fetchStringWithResponse: sinon.stub(), RequestFailedError, } - this.RecurlyWrapper = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': this.settings, - '@overleaf/fetch-utils': this.fetchUtils, - './Errors': SubscriptionErrors, - }, - }) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('@overleaf/fetch-utils', () => ({ + default: ctx.fetchUtils, + })) + + ctx.RecurlyWrapper = (await import(modulePath)).default }) afterEach(function () { @@ -151,15 +161,15 @@ describe('RecurlyWrapper', function () { describe('getSubscription', function () { for (const functionType of ['promise', 'callback']) { describe(`as ${functionType}`, function () { - beforeEach(function () { - this.recurlySubscription = 'RESET' - this.getSubscription = (...params) => { + beforeEach(function (ctx) { + ctx.recurlySubscription = 'RESET' + ctx.getSubscription = (...params) => { if (functionType === 'promise') { - return this.RecurlyWrapper.promises.getSubscription(...params) + return ctx.RecurlyWrapper.promises.getSubscription(...params) } if (functionType === 'callback') { return new Promise((resolve, reject) => - this.RecurlyWrapper.getSubscription( + ctx.RecurlyWrapper.getSubscription( ...params, (err, subscription) => err ? reject(err) : resolve(subscription) @@ -171,78 +181,78 @@ describe('RecurlyWrapper', function () { }) describe('with proper subscription id', function () { - beforeEach(async function () { - this.apiRequest = sinon - .stub(this.RecurlyWrapper.promises, 'apiRequest') + beforeEach(async function (ctx) { + ctx.apiRequest = sinon + .stub(ctx.RecurlyWrapper.promises, 'apiRequest') .callsFake(mockApiRequest) - this.recurlySubscription = await this.getSubscription( + ctx.recurlySubscription = await ctx.getSubscription( '44f83d7cba354d5b84812419f923ea96' ) }) - afterEach(function () { - this.RecurlyWrapper.promises.apiRequest.restore() + afterEach(function (ctx) { + ctx.RecurlyWrapper.promises.apiRequest.restore() }) - it('should look up the subscription at the normal API end point', function () { - this.apiRequest.args[0][0].url.should.equal( + it('should look up the subscription at the normal API end point', function (ctx) { + ctx.apiRequest.args[0][0].url.should.equal( 'subscriptions/44f83d7cba354d5b84812419f923ea96' ) }) - it('should return the subscription', function () { - this.recurlySubscription.uuid.should.equal( + it('should return the subscription', function (ctx) { + ctx.recurlySubscription.uuid.should.equal( '44f83d7cba354d5b84812419f923ea96' ) }) }) describe('with RecurlyJS token', function () { - beforeEach(async function () { - this.apiRequest = sinon - .stub(this.RecurlyWrapper.promises, 'apiRequest') + beforeEach(async function (ctx) { + ctx.apiRequest = sinon + .stub(ctx.RecurlyWrapper.promises, 'apiRequest') .callsFake(mockApiRequest) - this.recurlySubscription = await this.getSubscription( + ctx.recurlySubscription = await ctx.getSubscription( '70db44b10f5f4b238669480c9903f6f5', { recurlyJsResult: true } ) }) - afterEach(function () { - this.RecurlyWrapper.promises.apiRequest.restore() + afterEach(function (ctx) { + ctx.RecurlyWrapper.promises.apiRequest.restore() }) - it('should return the subscription', function () { - this.recurlySubscription.uuid.should.equal( + it('should return the subscription', function (ctx) { + ctx.recurlySubscription.uuid.should.equal( '44f83d7cba354d5b84812419f923ea96' ) }) - it('should look up the subscription at the RecurlyJS API end point', function () { - this.apiRequest.args[0][0].url.should.equal( + it('should look up the subscription at the RecurlyJS API end point', function (ctx) { + ctx.apiRequest.args[0][0].url.should.equal( 'recurly_js/result/70db44b10f5f4b238669480c9903f6f5' ) }) }) describe('with includeAccount', function () { - beforeEach(async function () { - this.apiRequest = sinon - .stub(this.RecurlyWrapper.promises, 'apiRequest') + beforeEach(async function (ctx) { + ctx.apiRequest = sinon + .stub(ctx.RecurlyWrapper.promises, 'apiRequest') .callsFake(mockApiRequest) - this.recurlySubscription = await this.getSubscription( + ctx.recurlySubscription = await ctx.getSubscription( '44f83d7cba354d5b84812419f923ea96', { includeAccount: true } ) }) - afterEach(function () { - this.RecurlyWrapper.promises.apiRequest.restore() + afterEach(function (ctx) { + ctx.RecurlyWrapper.promises.apiRequest.restore() }) - it('should request the account from the API', function () { - this.apiRequest.args[1][0].url.should.equal('accounts/104') + it('should request the account from the API', function (ctx) { + ctx.apiRequest.args[1][0].url.should.equal('accounts/104') }) - it('should populate the account attribute', function () { - this.recurlySubscription.account.account_code.should.equal('104') + it('should populate the account attribute', function (ctx) { + ctx.recurlySubscription.account.account_code.should.equal('104') }) }) }) @@ -250,13 +260,13 @@ describe('RecurlyWrapper', function () { }) describe('updateAccountEmailAddress', function () { - beforeEach(async function () { - this.recurlyAccountId = 'account-id-123' - this.newEmail = 'example@overleaf.com' - this.apiRequest = sinon - .stub(this.RecurlyWrapper.promises, 'apiRequest') + beforeEach(async function (ctx) { + ctx.recurlyAccountId = 'account-id-123' + ctx.newEmail = 'example@overleaf.com' + ctx.apiRequest = sinon + .stub(ctx.RecurlyWrapper.promises, 'apiRequest') .callsFake(options => { - this.requestOptions = options + ctx.requestOptions = options return { err: null, response: {}, @@ -264,43 +274,43 @@ describe('RecurlyWrapper', function () { } }) - this.recurlyAccount = - await this.RecurlyWrapper.promises.updateAccountEmailAddress( - this.recurlyAccountId, - this.newEmail + ctx.recurlyAccount = + await ctx.RecurlyWrapper.promises.updateAccountEmailAddress( + ctx.recurlyAccountId, + ctx.newEmail ) }) - afterEach(function () { - this.RecurlyWrapper.promises.apiRequest.restore() + afterEach(function (ctx) { + ctx.RecurlyWrapper.promises.apiRequest.restore() }) - it('sends correct XML', function () { - this.apiRequest.called.should.equal(true) - const { body } = this.apiRequest.lastCall.args[0] + it('sends correct XML', function (ctx) { + ctx.apiRequest.called.should.equal(true) + const { body } = ctx.apiRequest.lastCall.args[0] expect(body).to.equal(`\ example@overleaf.com \ `) - this.requestOptions.url.should.equal(`accounts/${this.recurlyAccountId}`) - this.requestOptions.method.should.equal('PUT') + ctx.requestOptions.url.should.equal(`accounts/${ctx.recurlyAccountId}`) + ctx.requestOptions.method.should.equal('PUT') }) - it('should return the updated account', function () { - expect(this.recurlyAccount).to.exist - this.recurlyAccount.account_code.should.equal('104') + it('should return the updated account', function (ctx) { + expect(ctx.recurlyAccount).to.exist + ctx.recurlyAccount.account_code.should.equal('104') }) }) describe('updateAccountEmailAddress, with invalid XML', function () { - beforeEach(async function () { - this.recurlyAccountId = 'account-id-123' - this.newEmail = '\uD800@example.com' - this.apiRequest = sinon - .stub(this.RecurlyWrapper.promises, 'apiRequest') + beforeEach(async function (ctx) { + ctx.recurlyAccountId = 'account-id-123' + ctx.newEmail = '\uD800@example.com' + ctx.apiRequest = sinon + .stub(ctx.RecurlyWrapper.promises, 'apiRequest') .callsFake(options => { - this.requestOptions = options + ctx.requestOptions = options return { err: null, response: {}, @@ -309,93 +319,93 @@ describe('RecurlyWrapper', function () { }) }) - afterEach(function () { - this.RecurlyWrapper.promises.apiRequest.restore() + afterEach(function (ctx) { + ctx.RecurlyWrapper.promises.apiRequest.restore() }) - it('should produce an error', async function () { + it('should produce an error', async function (ctx) { try { - await this.RecurlyWrapper.promises.updateAccountEmailAddress( - this.recurlyAccountId, - this.newEmail + await ctx.RecurlyWrapper.promises.updateAccountEmailAddress( + ctx.recurlyAccountId, + ctx.newEmail ) assert.fail('Expected error not thrown') } catch (error) { expect(error).to.have.property('message') expect(error.message.startsWith('Invalid character')).to.be.true - expect(this.apiRequest.called).to.equal(false) + expect(ctx.apiRequest.called).to.equal(false) } }) }) describe('updateSubscription', function () { - beforeEach(async function () { - this.recurlySubscriptionId = 'subscription-id-123' - this.apiRequest = sinon - .stub(this.RecurlyWrapper.promises, 'apiRequest') + beforeEach(async function (ctx) { + ctx.recurlySubscriptionId = 'subscription-id-123' + ctx.apiRequest = sinon + .stub(ctx.RecurlyWrapper.promises, 'apiRequest') .callsFake(options => { - this.requestOptions = options + ctx.requestOptions = options return { error: null, response: {}, body: fixtures['subscriptions/44f83d7cba354d5b84812419f923ea96'], } }) - this.recurlySubscription = - await this.RecurlyWrapper.promises.updateSubscription( - this.recurlySubscriptionId, + ctx.recurlySubscription = + await ctx.RecurlyWrapper.promises.updateSubscription( + ctx.recurlySubscriptionId, { plan_code: 'silver', timeframe: 'now' } ) }) - afterEach(function () { - this.RecurlyWrapper.promises.apiRequest.restore() + afterEach(function (ctx) { + ctx.RecurlyWrapper.promises.apiRequest.restore() }) - it('sends correct XML', function () { - this.apiRequest.called.should.equal(true) - const { body } = this.apiRequest.lastCall.args[0] + it('sends correct XML', function (ctx) { + ctx.apiRequest.called.should.equal(true) + const { body } = ctx.apiRequest.lastCall.args[0] expect(body).to.equal(`\ silver now \ `) - this.requestOptions.url.should.equal( - `subscriptions/${this.recurlySubscriptionId}` + ctx.requestOptions.url.should.equal( + `subscriptions/${ctx.recurlySubscriptionId}` ) - this.requestOptions.method.should.equal('PUT') + ctx.requestOptions.method.should.equal('PUT') }) - it('should return the updated subscription', function () { - expect(this.recurlySubscription).to.exist - this.recurlySubscription.plan.plan_code.should.equal('gold') + it('should return the updated subscription', function (ctx) { + expect(ctx.recurlySubscription).to.exist + ctx.recurlySubscription.plan.plan_code.should.equal('gold') }) }) describe('redeemCoupon', function () { - beforeEach(async function () { - this.recurlyAccountId = 'account-id-123' - this.coupon_code = '312321312' - this.apiRequest = sinon - .stub(this.RecurlyWrapper.promises, 'apiRequest') + beforeEach(async function (ctx) { + ctx.recurlyAccountId = 'account-id-123' + ctx.coupon_code = '312321312' + ctx.apiRequest = sinon + .stub(ctx.RecurlyWrapper.promises, 'apiRequest') .callsFake(options => { - options.url.should.equal(`coupons/${this.coupon_code}/redeem`) + options.url.should.equal(`coupons/${ctx.coupon_code}/redeem`) options.method.should.equal('POST') return {} }) - await this.RecurlyWrapper.promises.redeemCoupon( - this.recurlyAccountId, - this.coupon_code + await ctx.RecurlyWrapper.promises.redeemCoupon( + ctx.recurlyAccountId, + ctx.coupon_code ) }) - afterEach(function () { - this.RecurlyWrapper.promises.apiRequest.restore() + afterEach(function (ctx) { + ctx.RecurlyWrapper.promises.apiRequest.restore() }) - it('sends correct XML', function () { - this.apiRequest.called.should.equal(true) - const { body } = this.apiRequest.lastCall.args[0] + it('sends correct XML', function (ctx) { + ctx.apiRequest.called.should.equal(true) + const { body } = ctx.apiRequest.lastCall.args[0] expect(body).to.equal(`\ account-id-123 @@ -406,31 +416,31 @@ describe('RecurlyWrapper', function () { }) describe('createFixedAmountCoupon', function () { - beforeEach(async function () { - this.couponCode = 'a-coupon-code' - this.couponName = 'a-coupon-name' - this.currencyCode = 'EUR' - this.discount = 1337 - this.planCode = 'a-plan-code' - this.apiRequest = sinon - .stub(this.RecurlyWrapper.promises, 'apiRequest') + beforeEach(async function (ctx) { + ctx.couponCode = 'a-coupon-code' + ctx.couponName = 'a-coupon-name' + ctx.currencyCode = 'EUR' + ctx.discount = 1337 + ctx.planCode = 'a-plan-code' + ctx.apiRequest = sinon + .stub(ctx.RecurlyWrapper.promises, 'apiRequest') .resolves() - await this.RecurlyWrapper.promises.createFixedAmountCoupon( - this.couponCode, - this.couponName, - this.currencyCode, - this.discount, - this.planCode + await ctx.RecurlyWrapper.promises.createFixedAmountCoupon( + ctx.couponCode, + ctx.couponName, + ctx.currencyCode, + ctx.discount, + ctx.planCode ) }) - afterEach(function () { - this.RecurlyWrapper.promises.apiRequest.restore() + afterEach(function (ctx) { + ctx.RecurlyWrapper.promises.apiRequest.restore() }) - it('sends correct XML', function () { - this.apiRequest.called.should.equal(true) - const { body } = this.apiRequest.lastCall.args[0] + it('sends correct XML', function (ctx) { + ctx.apiRequest.called.should.equal(true) + const { body } = ctx.apiRequest.lastCall.args[0] expect(body).to.equal(`\ a-coupon-code @@ -449,12 +459,12 @@ describe('RecurlyWrapper', function () { }) describe('createSubscription', function () { - beforeEach(function () { - this.user = { + beforeEach(function (ctx) { + ctx.user = { _id: 'some_id', email: 'user@example.com', } - this.subscriptionDetails = { + ctx.subscriptionDetails = { currencyCode: 'EUR', plan_code: 'some_plan_code', coupon_code: '', @@ -467,108 +477,108 @@ describe('RecurlyWrapper', function () { zip: 'some_zip', }, } - this.subscription = {} - this.recurlyTokenIds = { + ctx.subscription = {} + ctx.recurlyTokenIds = { billing: 'a-token-id', threeDSecureActionResult: 'a-3d-token-id', } - this.call = () => { - return this.RecurlyWrapper.promises.createSubscription( - this.user, - this.subscriptionDetails, - this.recurlyTokenIds + ctx.call = () => { + return ctx.RecurlyWrapper.promises.createSubscription( + ctx.user, + ctx.subscriptionDetails, + ctx.recurlyTokenIds ) } }) describe('when paypal', function () { - beforeEach(function () { - this.subscriptionDetails.isPaypal = true - this._createPaypalSubscription = sinon.stub( - this.RecurlyWrapper.promises, + beforeEach(function (ctx) { + ctx.subscriptionDetails.isPaypal = true + ctx._createPaypalSubscription = sinon.stub( + ctx.RecurlyWrapper.promises, '_createPaypalSubscription' ) - this._createPaypalSubscription.resolves(this.subscription) + ctx._createPaypalSubscription.resolves(ctx.subscription) }) - afterEach(function () { - this._createPaypalSubscription.restore() + afterEach(function (ctx) { + ctx._createPaypalSubscription.restore() }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled }) - it('should produce a subscription object', async function () { - const sub = await this.call() - expect(sub).to.deep.equal(this.subscription) + it('should produce a subscription object', async function (ctx) { + const sub = await ctx.call() + expect(sub).to.deep.equal(ctx.subscription) }) - it('should call _createPaypalSubscription', async function () { - await this.call() - this._createPaypalSubscription.callCount.should.equal(1) + it('should call _createPaypalSubscription', async function (ctx) { + await ctx.call() + ctx._createPaypalSubscription.callCount.should.equal(1) }) describe('when _createPaypalSubscription produces an error', function () { - beforeEach(function () { - this._createPaypalSubscription.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx._createPaypalSubscription.rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith('woops') + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith('woops') }) }) }) describe('when not paypal', function () { - beforeEach(function () { - this.subscriptionDetails.isPaypal = false - this._createCreditCardSubscription = sinon.stub( - this.RecurlyWrapper.promises, + beforeEach(function (ctx) { + ctx.subscriptionDetails.isPaypal = false + ctx._createCreditCardSubscription = sinon.stub( + ctx.RecurlyWrapper.promises, '_createCreditCardSubscription' ) - this._createCreditCardSubscription.resolves(this.subscription) + ctx._createCreditCardSubscription.resolves(ctx.subscription) }) - afterEach(function () { - this._createCreditCardSubscription.restore() + afterEach(function (ctx) { + ctx._createCreditCardSubscription.restore() }) - it('should not produce an error', async function () { - await this.call() + it('should not produce an error', async function (ctx) { + await ctx.call() }) - it('should produce a subscription object', async function () { - const sub = await this.call() - expect(sub).to.deep.equal(this.subscription) + it('should produce a subscription object', async function (ctx) { + const sub = await ctx.call() + expect(sub).to.deep.equal(ctx.subscription) }) - it('should call _createCreditCardSubscription', async function () { - await this.call() - this._createCreditCardSubscription.callCount.should.equal(1) + it('should call _createCreditCardSubscription', async function (ctx) { + await ctx.call() + ctx._createCreditCardSubscription.callCount.should.equal(1) }) describe('when _createCreditCardSubscription produces an error', function () { - beforeEach(function () { - this._createCreditCardSubscription.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx._createCreditCardSubscription.rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith('woops') + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith('woops') }) }) }) }) describe('_createCreditCardSubscription', function () { - beforeEach(function () { - this.user = { + beforeEach(function (ctx) { + ctx.user = { _id: 'some_id', email: 'user@example.com', first_name: 'Foo', last_name: 'Johnson', } - this.subscriptionDetails = { + ctx.subscriptionDetails = { currencyCode: 'EUR', plan_code: 'some_plan_code', coupon_code: '', @@ -588,41 +598,41 @@ describe('RecurlyWrapper', function () { ITMContent: 'itm-content-value', ITMReferrer: 'itm-referrer-value', } - this.subscription = {} - this.recurlyTokenIds = { + ctx.subscription = {} + ctx.recurlyTokenIds = { billing: 'a-token-id', threeDSecureActionResult: 'a-3d-token-id', } - this.apiRequest = sinon.stub(this.RecurlyWrapper.promises, 'apiRequest') - this.response = { status: 200 } - this.body = 'is_bad' - this.apiRequest.resolves({ - response: this.response, - body: this.body, + ctx.apiRequest = sinon.stub(ctx.RecurlyWrapper.promises, 'apiRequest') + ctx.response = { status: 200 } + ctx.body = 'is_bad' + ctx.apiRequest.resolves({ + response: ctx.response, + body: ctx.body, }) - this._parseSubscriptionXml = sinon.stub( - this.RecurlyWrapper.promises, + ctx._parseSubscriptionXml = sinon.stub( + ctx.RecurlyWrapper.promises, '_parseSubscriptionXml' ) - this._parseSubscriptionXml.resolves(this.subscription) - this.call = () => { - return this.RecurlyWrapper.promises._createCreditCardSubscription( - this.user, - this.subscriptionDetails, - this.recurlyTokenIds + ctx._parseSubscriptionXml.resolves(ctx.subscription) + ctx.call = () => { + return ctx.RecurlyWrapper.promises._createCreditCardSubscription( + ctx.user, + ctx.subscriptionDetails, + ctx.recurlyTokenIds ) } }) - afterEach(function () { - this.apiRequest.restore() - this._parseSubscriptionXml.restore() + afterEach(function (ctx) { + ctx.apiRequest.restore() + ctx._parseSubscriptionXml.restore() }) - it('sends correct XML', async function () { - await this.call() + it('sends correct XML', async function (ctx) { + await ctx.call() - const { body } = this.apiRequest.lastCall.args[0] + const { body } = ctx.apiRequest.lastCall.args[0] expect(body).to.equal(`\ some_plan_code @@ -662,27 +672,27 @@ describe('RecurlyWrapper', function () { `) }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled }) - it('should produce a subscription', async function () { - const sub = await this.call() - expect(sub).to.equal(this.subscription) + it('should produce a subscription', async function (ctx) { + const sub = await ctx.call() + expect(sub).to.equal(ctx.subscription) }) - it('should call apiRequest', async function () { - await this.call() - this.apiRequest.callCount.should.equal(1) + it('should call apiRequest', async function (ctx) { + await ctx.call() + ctx.apiRequest.callCount.should.equal(1) }) - it('should call _parseSubscriptionXml', async function () { - await this.call() - this._parseSubscriptionXml.callCount.should.equal(1) + it('should call _parseSubscriptionXml', async function (ctx) { + await ctx.call() + ctx._parseSubscriptionXml.callCount.should.equal(1) }) describe('when api request returns 422', function () { - beforeEach(function () { + beforeEach(function (ctx) { const body = `\ @@ -698,14 +708,14 @@ describe('RecurlyWrapper', function () { ` // this.apiRequest.yields(null, { statusCode: 422 }, body) - this.apiRequest.resolves({ + ctx.apiRequest.resolves({ response: { status: 422 }, body, }) }) - it('should produce an error', async function () { - const promise = this.call() + it('should produce an error', async function (ctx) { + const promise = ctx.call() let error try { @@ -729,63 +739,63 @@ describe('RecurlyWrapper', function () { }) describe('when api request produces an error', function () { - beforeEach(function () { - this.apiRequest.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.apiRequest.rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith('woops') + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith('woops') }) - it('should call apiRequest', async function () { - await expect(this.call()).to.be.rejected - this.apiRequest.callCount.should.equal(1) + it('should call apiRequest', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx.apiRequest.callCount.should.equal(1) }) - it('should not _parseSubscriptionXml', async function () { - await expect(this.call()).to.be.rejected - this._parseSubscriptionXml.callCount.should.equal(0) + it('should not _parseSubscriptionXml', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx._parseSubscriptionXml.callCount.should.equal(0) }) }) describe('when parse xml produces an error', function () { - beforeEach(function () { - this._parseSubscriptionXml.rejects(new Error('woops xml')) + beforeEach(function (ctx) { + ctx._parseSubscriptionXml.rejects(new Error('woops xml')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith('woops xml') + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith('woops xml') }) }) }) describe('_createPaypalSubscription', function () { - beforeEach(function () { - this.checkAccountExists = sinon.stub( - this.RecurlyWrapper.promises._paypal, + beforeEach(function (ctx) { + ctx.checkAccountExists = sinon.stub( + ctx.RecurlyWrapper.promises._paypal, 'checkAccountExists' ) - this.createAccount = sinon.stub( - this.RecurlyWrapper.promises._paypal, + ctx.createAccount = sinon.stub( + ctx.RecurlyWrapper.promises._paypal, 'createAccount' ) - this.createBillingInfo = sinon.stub( - this.RecurlyWrapper.promises._paypal, + ctx.createBillingInfo = sinon.stub( + ctx.RecurlyWrapper.promises._paypal, 'createBillingInfo' ) - this.setAddressAndCompanyBillingInfo = sinon.stub( - this.RecurlyWrapper.promises._paypal, + ctx.setAddressAndCompanyBillingInfo = sinon.stub( + ctx.RecurlyWrapper.promises._paypal, 'setAddressAndCompanyBillingInfo' ) - this.createSubscription = sinon.stub( - this.RecurlyWrapper.promises._paypal, + ctx.createSubscription = sinon.stub( + ctx.RecurlyWrapper.promises._paypal, 'createSubscription' ) - this.user = { + ctx.user = { _id: 'some_id', email: 'user@example.com', } - this.subscriptionDetails = { + ctx.subscriptionDetails = { currencyCode: 'EUR', plan_code: 'some_plan_code', coupon_code: '', @@ -798,32 +808,32 @@ describe('RecurlyWrapper', function () { zip: 'some_zip', }, } - this.subscription = {} - this.recurlyTokenIds = { + ctx.subscription = {} + ctx.recurlyTokenIds = { billing: 'a-token-id', threeDSecureActionResult: 'a-3d-token-id', } // set up data callbacks - const { user } = this - const { subscriptionDetails } = this - const { recurlyTokenIds } = this + const { user } = ctx + const { subscriptionDetails } = ctx + const { recurlyTokenIds } = ctx - this.checkAccountExists.resolves({ + ctx.checkAccountExists.resolves({ user, subscriptionDetails, recurlyTokenIds, userExists: false, account: { accountCode: 'xx' }, }) - this.createAccount.resolves({ + ctx.createAccount.resolves({ user, subscriptionDetails, recurlyTokenIds, userExists: false, account: { accountCode: 'xx' }, }) - this.createBillingInfo.resolves({ + ctx.createBillingInfo.resolves({ user, subscriptionDetails, recurlyTokenIds, @@ -831,7 +841,7 @@ describe('RecurlyWrapper', function () { account: { accountCode: 'xx' }, billingInfo: { token_id: 'abc' }, }) - this.setAddressAndCompanyBillingInfo.resolves({ + ctx.setAddressAndCompanyBillingInfo.resolves({ user, subscriptionDetails, recurlyTokenIds, @@ -839,99 +849,99 @@ describe('RecurlyWrapper', function () { account: { accountCode: 'xx' }, billingInfo: { token_id: 'abc' }, }) - this.createSubscription.resolves({ + ctx.createSubscription.resolves({ user, subscriptionDetails, recurlyTokenIds, userExists: false, account: { accountCode: 'xx' }, billingInfo: { token_id: 'abc' }, - subscription: this.subscription, + subscription: ctx.subscription, }) - this.call = () => { - return this.RecurlyWrapper.promises._createPaypalSubscription( - this.user, - this.subscriptionDetails, - this.recurlyTokenIds + ctx.call = () => { + return ctx.RecurlyWrapper.promises._createPaypalSubscription( + ctx.user, + ctx.subscriptionDetails, + ctx.recurlyTokenIds ) } }) - afterEach(function () { - this.checkAccountExists.restore() - this.createAccount.restore() - this.createBillingInfo.restore() - this.setAddressAndCompanyBillingInfo.restore() - this.createSubscription.restore() + afterEach(function (ctx) { + ctx.checkAccountExists.restore() + ctx.createAccount.restore() + ctx.createBillingInfo.restore() + ctx.setAddressAndCompanyBillingInfo.restore() + ctx.createSubscription.restore() }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled }) - it('should produce a subscription object', async function () { - const sub = await this.call() + it('should produce a subscription object', async function (ctx) { + const sub = await ctx.call() expect(sub).to.not.equal(null) - expect(sub).to.equal(this.subscription) + expect(sub).to.equal(ctx.subscription) }) - it('should call each of the paypal stages', async function () { - await this.call() - this.checkAccountExists.callCount.should.equal(1) - this.createAccount.callCount.should.equal(1) - this.createBillingInfo.callCount.should.equal(1) - this.setAddressAndCompanyBillingInfo.callCount.should.equal(1) - this.createSubscription.callCount.should.equal(1) + it('should call each of the paypal stages', async function (ctx) { + await ctx.call() + ctx.checkAccountExists.callCount.should.equal(1) + ctx.createAccount.callCount.should.equal(1) + ctx.createBillingInfo.callCount.should.equal(1) + ctx.setAddressAndCompanyBillingInfo.callCount.should.equal(1) + ctx.createSubscription.callCount.should.equal(1) }) describe('when one of the paypal stages produces an error', function () { - beforeEach(function () { - this.createAccount.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.createAccount.rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith('woops') + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith('woops') }) - it('should stop calling the paypal stages after the error', async function () { - await expect(this.call()).to.be.rejected - this.checkAccountExists.callCount.should.equal(1) - this.createAccount.callCount.should.equal(1) - this.createBillingInfo.callCount.should.equal(0) - this.setAddressAndCompanyBillingInfo.callCount.should.equal(0) - this.createSubscription.callCount.should.equal(0) + it('should stop calling the paypal stages after the error', async function (ctx) { + await expect(ctx.call()).to.be.rejected + ctx.checkAccountExists.callCount.should.equal(1) + ctx.createAccount.callCount.should.equal(1) + ctx.createBillingInfo.callCount.should.equal(0) + ctx.setAddressAndCompanyBillingInfo.callCount.should.equal(0) + ctx.createSubscription.callCount.should.equal(0) }) }) }) describe('paypal actions', function () { - beforeEach(function () { - this.apiRequest = sinon.stub(this.RecurlyWrapper.promises, 'apiRequest') - this._parseAccountXml = sinon.spy( - this.RecurlyWrapper.promises, + beforeEach(function (ctx) { + ctx.apiRequest = sinon.stub(ctx.RecurlyWrapper.promises, 'apiRequest') + ctx._parseAccountXml = sinon.spy( + ctx.RecurlyWrapper.promises, '_parseAccountXml' ) - this._parseBillingInfoXml = sinon.spy( - this.RecurlyWrapper.promises, + ctx._parseBillingInfoXml = sinon.spy( + ctx.RecurlyWrapper.promises, '_parseBillingInfoXml' ) - this._parseSubscriptionXml = sinon.spy( - this.RecurlyWrapper.promises, + ctx._parseSubscriptionXml = sinon.spy( + ctx.RecurlyWrapper.promises, '_parseSubscriptionXml' ) - this.cache = { - user: (this.user = { + ctx.cache = { + user: (ctx.user = { _id: 'some_id', email: 'foo@bar.com', first_name: 'Foo', last_name: 'Bar', }), - recurlyTokenIds: (this.recurlyTokenIds = { + recurlyTokenIds: (ctx.recurlyTokenIds = { billing: 'a-token-id', threeDSecureActionResult: 'a-3d-token-id', }), - subscriptionDetails: (this.subscriptionDetails = { + subscriptionDetails: (ctx.subscriptionDetails = { currencyCode: 'EUR', plan_code: 'some_plan_code', coupon_code: '', @@ -950,50 +960,48 @@ describe('RecurlyWrapper', function () { } }) - afterEach(function () { - this.apiRequest.restore() - this._parseAccountXml.restore() - this._parseBillingInfoXml.restore() - this._parseSubscriptionXml.restore() + afterEach(function (ctx) { + ctx.apiRequest.restore() + ctx._parseAccountXml.restore() + ctx._parseBillingInfoXml.restore() + ctx._parseSubscriptionXml.restore() }) describe('_paypal.checkAccountExists', function () { - beforeEach(function () { - this.call = () => { - return this.RecurlyWrapper.promises._paypal.checkAccountExists( - this.cache + beforeEach(function (ctx) { + ctx.call = () => { + return ctx.RecurlyWrapper.promises._paypal.checkAccountExists( + ctx.cache ) } }) describe('when the account exists', function () { - beforeEach(function () { + beforeEach(function (ctx) { const resultXml = 'abc' - this.apiRequest.resolves({ + ctx.apiRequest.resolves({ response: { status: 200 }, body: resultXml, }) }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled }) - it('should call apiRequest', async function () { - await this.call() - this.apiRequest.callCount.should.equal(1) + it('should call apiRequest', async function (ctx) { + await ctx.call() + ctx.apiRequest.callCount.should.equal(1) }) - it('should call _parseAccountXml', async function () { - await this.call() - this.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal( - 1 - ) + it('should call _parseAccountXml', async function (ctx) { + await ctx.call() + ctx.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal(1) }) - it('should add the account to the cumulative result', async function () { - const result = await this.call() + it('should add the account to the cumulative result', async function (ctx) { + const result = await ctx.call() expect(result.account).to.not.equal(null) expect(result.account).to.not.equal(undefined) expect(result.account).to.deep.equal({ @@ -1001,131 +1009,127 @@ describe('RecurlyWrapper', function () { }) }) - it('should set userExists to true', async function () { - const result = await this.call() + it('should set userExists to true', async function (ctx) { + const result = await ctx.call() expect(result.userExists).to.equal(true) }) }) describe('when the account does not exist', function () { - beforeEach(function () { - this.apiRequest.resolves({ + beforeEach(function (ctx) { + ctx.apiRequest.resolves({ response: { status: 404 }, body: '', }) }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled }) - it('should call apiRequest', async function () { - await this.call() - this.apiRequest.callCount.should.equal(1) - this.apiRequest.firstCall.args[0].method.should.equal('GET') + it('should call apiRequest', async function (ctx) { + await ctx.call() + ctx.apiRequest.callCount.should.equal(1) + ctx.apiRequest.firstCall.args[0].method.should.equal('GET') }) - it('should not call _parseAccountXml', async function () { - await this.call() - this.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal( - 0 - ) + it('should not call _parseAccountXml', async function (ctx) { + await ctx.call() + ctx.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal(0) }) - it('should not add the account to result', async function () { - const result = await this.call() + it('should not add the account to result', async function (ctx) { + const result = await ctx.call() expect(result.account).to.equal(undefined) }) - it('should set userExists to false', async function () { - const result = await this.call() + it('should set userExists to false', async function (ctx) { + const result = await ctx.call() expect(result.userExists).to.equal(false) }) }) describe('when apiRequest produces an error', function () { - beforeEach(function () { - this.apiRequest.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.apiRequest.rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejected + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejected }) }) }) describe('_paypal.createAccount', function () { - beforeEach(function () { - this.call = () => { - return this.RecurlyWrapper.promises._paypal.createAccount(this.cache) + beforeEach(function (ctx) { + ctx.call = () => { + return ctx.RecurlyWrapper.promises._paypal.createAccount(ctx.cache) } }) describe('when address is missing from subscriptionDetails', function () { - beforeEach(function () { - this.cache.subscriptionDetails.address = null + beforeEach(function (ctx) { + ctx.cache.subscriptionDetails.address = null }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejected + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejected }) }) describe('when country is missing from address', function () { - beforeEach(function () { - this.cache.subscriptionDetails.address = {} + beforeEach(function (ctx) { + ctx.cache.subscriptionDetails.address = {} }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Errors.InvalidError) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Errors.InvalidError) }) }) describe('when account already exists', function () { - beforeEach(function () { - this.cache.userExists = true - this.cache.account = { account_code: 'abc' } + beforeEach(function (ctx) { + ctx.cache.userExists = true + ctx.cache.account = { account_code: 'abc' } }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled }) - it('should produce cache object', async function () { - const result = await this.call() - expect(result).to.deep.equal(this.cache) + it('should produce cache object', async function (ctx) { + const result = await ctx.call() + expect(result).to.deep.equal(ctx.cache) expect(result.account).to.deep.equal({ account_code: 'abc', }) }) - it('should not call apiRequest', async function () { - await expect(this.call()).to.be.fulfilled - this.apiRequest.callCount.should.equal(0) + it('should not call apiRequest', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled + ctx.apiRequest.callCount.should.equal(0) }) - it('should not call _parseAccountXml', async function () { - await this.call() - this.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal( - 0 - ) + it('should not call _parseAccountXml', async function (ctx) { + await ctx.call() + ctx.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal(0) }) }) describe('when account does not exist', function () { - beforeEach(function () { - this.cache.userExists = false + beforeEach(function (ctx) { + ctx.cache.userExists = false const resultXml = 'abc' - this.apiRequest.resolves({ + ctx.apiRequest.resolves({ response: { status: 200 }, body: resultXml, }) }) - it('sends correct XML', async function () { - await this.call() - const { body } = this.apiRequest.lastCall.args[0] + it('sends correct XML', async function (ctx) { + await ctx.call() + const { body } = ctx.apiRequest.lastCall.args[0] expect(body).to.equal(`\ some_id @@ -1144,67 +1148,65 @@ describe('RecurlyWrapper', function () { `) }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled }) - it('should call apiRequest', async function () { - await this.call() - this.apiRequest.callCount.should.equal(1) - this.apiRequest.firstCall.args[0].method.should.equal('POST') + it('should call apiRequest', async function (ctx) { + await ctx.call() + ctx.apiRequest.callCount.should.equal(1) + ctx.apiRequest.firstCall.args[0].method.should.equal('POST') }) - it('should call _parseAccountXml', async function () { - await this.call() - this.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal( - 1 - ) + it('should call _parseAccountXml', async function (ctx) { + await ctx.call() + ctx.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal(1) }) describe('when apiRequest produces an error', function () { - beforeEach(function () { - this.apiRequest.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.apiRequest.rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith('woops') + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith('woops') }) }) }) }) describe('_paypal.createBillingInfo', function () { - beforeEach(function () { - this.cache.account = { account_code: 'abc' } - this.call = () => { - return this.RecurlyWrapper.promises._paypal.createBillingInfo( - this.cache + beforeEach(function (ctx) { + ctx.cache.account = { account_code: 'abc' } + ctx.call = () => { + return ctx.RecurlyWrapper.promises._paypal.createBillingInfo( + ctx.cache ) } }) describe('when account_code is missing from cache', function () { - beforeEach(function () { - this.cache.account.account_code = null + beforeEach(function (ctx) { + ctx.cache.account.account_code = null }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejected + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejected }) }) describe('when all goes well', function () { - beforeEach(function () { + beforeEach(function (ctx) { const resultXml = '1' - this.apiRequest.resolves({ + ctx.apiRequest.resolves({ response: { status: 200 }, body: resultXml, }) }) - it('sends correct XML', async function () { - await this.call() - const { body } = this.apiRequest.lastCall.args[0] + it('sends correct XML', async function (ctx) { + await ctx.call() + const { body } = ctx.apiRequest.lastCall.args[0] expect(body).to.equal(`\ a-token-id @@ -1212,25 +1214,25 @@ describe('RecurlyWrapper', function () { `) }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled }) - it('should call apiRequest', async function () { - await this.call() - this.apiRequest.callCount.should.equal(1) - this.apiRequest.firstCall.args[0].method.should.equal('POST') + it('should call apiRequest', async function (ctx) { + await ctx.call() + ctx.apiRequest.callCount.should.equal(1) + ctx.apiRequest.firstCall.args[0].method.should.equal('POST') }) - it('should call _parseBillingInfoXml', async function () { - await this.call() - this.RecurlyWrapper.promises._parseBillingInfoXml.callCount.should.equal( + it('should call _parseBillingInfoXml', async function (ctx) { + await ctx.call() + ctx.RecurlyWrapper.promises._parseBillingInfoXml.callCount.should.equal( 1 ) }) - it('should set billingInfo on cache', async function () { - const result = await this.call() + it('should set billingInfo on cache', async function (ctx) { + const result = await ctx.call() expect(result.billingInfo).to.deep.equal({ a: '1', }) @@ -1238,59 +1240,59 @@ describe('RecurlyWrapper', function () { }) describe('when apiRequest produces an error', function () { - beforeEach(function () { - this.apiRequest.resolves(new Error('woops')) + beforeEach(function (ctx) { + ctx.apiRequest.resolves(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejected + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejected }) }) }) describe('_paypal.setAddressAndCompanyBillingInfo', function () { - beforeEach(function () { - this.cache.account = { account_code: 'abc' } - this.cache.billingInfo = {} - this.call = () => { - return this.RecurlyWrapper.promises._paypal.setAddressAndCompanyBillingInfo( - this.cache + beforeEach(function (ctx) { + ctx.cache.account = { account_code: 'abc' } + ctx.cache.billingInfo = {} + ctx.call = () => { + return ctx.RecurlyWrapper.promises._paypal.setAddressAndCompanyBillingInfo( + ctx.cache ) } }) describe('when account_code is missing from cache', function () { - beforeEach(function () { - this.cache.account.account_code = null + beforeEach(function (ctx) { + ctx.cache.account.account_code = null }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejected + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejected }) }) describe('when country is missing', function () { - beforeEach(function () { - this.cache.subscriptionDetails.address = { country: '' } + beforeEach(function (ctx) { + ctx.cache.subscriptionDetails.address = { country: '' } }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Errors.InvalidError) + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejectedWith(Errors.InvalidError) }) }) describe('when all goes well', function () { - beforeEach(function () { + beforeEach(function (ctx) { const resultXml = 'London' - this.apiRequest.resolves({ + ctx.apiRequest.resolves({ response: { status: 200 }, body: resultXml, }) }) - it('sends correct XML', async function () { - await this.call() - const { body } = this.apiRequest.lastCall.args[0] + it('sends correct XML', async function (ctx) { + await ctx.call() + const { body } = ctx.apiRequest.lastCall.args[0] expect(body).to.equal(`\ addr_one @@ -1303,25 +1305,25 @@ describe('RecurlyWrapper', function () { `) }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled }) - it('should call apiRequest', async function () { - await this.call() - this.apiRequest.callCount.should.equal(1) - this.apiRequest.firstCall.args[0].method.should.equal('PUT') + it('should call apiRequest', async function (ctx) { + await ctx.call() + ctx.apiRequest.callCount.should.equal(1) + ctx.apiRequest.firstCall.args[0].method.should.equal('PUT') }) - it('should call _parseBillingInfoXml', async function () { - await this.call() - this.RecurlyWrapper.promises._parseBillingInfoXml.callCount.should.equal( + it('should call _parseBillingInfoXml', async function (ctx) { + await ctx.call() + ctx.RecurlyWrapper.promises._parseBillingInfoXml.callCount.should.equal( 1 ) }) - it('should set billingInfo on cache', async function () { - const result = await this.call() + it('should set billingInfo on cache', async function (ctx) { + const result = await ctx.call() expect(result.billingInfo).to.deep.equal({ city: 'London', }) @@ -1329,36 +1331,36 @@ describe('RecurlyWrapper', function () { }) describe('when apiRequest produces an error', function () { - beforeEach(function () { - this.apiRequest.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.apiRequest.rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejected + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejected }) }) }) describe('_paypal.createSubscription', function () { - beforeEach(function () { - this.cache.account = { account_code: 'abc' } - this.cache.billingInfo = {} - this.call = () => - this.RecurlyWrapper.promises._paypal.createSubscription(this.cache) + beforeEach(function (ctx) { + ctx.cache.account = { account_code: 'abc' } + ctx.cache.billingInfo = {} + ctx.call = () => + ctx.RecurlyWrapper.promises._paypal.createSubscription(ctx.cache) }) describe('when all goes well', function () { - beforeEach(function () { + beforeEach(function (ctx) { const resultXml = '1' - this.apiRequest.resolves({ + ctx.apiRequest.resolves({ response: { status: 200 }, body: resultXml, }) }) - it('sends correct XML', async function () { - await this.call() - const { body } = this.apiRequest.lastCall.args[0] + it('sends correct XML', async function (ctx) { + await ctx.call() + const { body } = ctx.apiRequest.lastCall.args[0] expect(body).to.equal(`\ some_plan_code @@ -1381,25 +1383,25 @@ describe('RecurlyWrapper', function () { `) }) - it('should not produce an error', async function () { - await expect(this.call()).to.be.fulfilled + it('should not produce an error', async function (ctx) { + await expect(ctx.call()).to.be.fulfilled }) - it('should call apiRequest', async function () { - await this.call() - this.apiRequest.callCount.should.equal(1) - this.apiRequest.firstCall.args[0].method.should.equal('POST') + it('should call apiRequest', async function (ctx) { + await ctx.call() + ctx.apiRequest.callCount.should.equal(1) + ctx.apiRequest.firstCall.args[0].method.should.equal('POST') }) - it('should call _parseSubscriptionXml', async function () { - await this.call() - this.RecurlyWrapper.promises._parseSubscriptionXml.callCount.should.equal( + it('should call _parseSubscriptionXml', async function (ctx) { + await ctx.call() + ctx.RecurlyWrapper.promises._parseSubscriptionXml.callCount.should.equal( 1 ) }) - it('should set subscription on cache', async function () { - const result = await this.call() + it('should set subscription on cache', async function (ctx) { + const result = await ctx.call() expect(result.subscription).to.deep.equal({ a: '1', }) @@ -1407,44 +1409,44 @@ describe('RecurlyWrapper', function () { }) describe('when apiRequest produces an error', function () { - beforeEach(function () { - this.apiRequest.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.apiRequest.rejects(new Error('woops')) }) - it('should produce an error', async function () { - await expect(this.call()).to.be.rejected + it('should produce an error', async function (ctx) { + await expect(ctx.call()).to.be.rejected }) }) }) }) describe('listAccountActiveSubscriptions', function () { - beforeEach(function () { - this.user_id = 'mock-user-id' - this.response = { mock: 'response' } - this.body = '' - this.RecurlyWrapper.promises.apiRequest = sinon.stub().resolves({ - response: this.response, - body: this.body, + beforeEach(function (ctx) { + ctx.user_id = 'mock-user-id' + ctx.response = { mock: 'response' } + ctx.body = '' + ctx.RecurlyWrapper.promises.apiRequest = sinon.stub().resolves({ + response: ctx.response, + body: ctx.body, }) - this.subscriptions = ['mock', 'subscriptions'] - this.RecurlyWrapper.promises._parseSubscriptionsXml = sinon + ctx.subscriptions = ['mock', 'subscriptions'] + ctx.RecurlyWrapper.promises._parseSubscriptionsXml = sinon .stub() - .resolves(this.subscriptions) + .resolves(ctx.subscriptions) }) describe('with an account', function () { - beforeEach(async function () { - this.result = - await this.RecurlyWrapper.promises.listAccountActiveSubscriptions( - this.user_id + beforeEach(async function (ctx) { + ctx.result = + await ctx.RecurlyWrapper.promises.listAccountActiveSubscriptions( + ctx.user_id ) }) - it('should send a request to Recurly', async function () { - this.RecurlyWrapper.promises.apiRequest + it('should send a request to Recurly', async function (ctx) { + ctx.RecurlyWrapper.promises.apiRequest .calledWith({ - url: `accounts/${this.user_id}/subscriptions`, + url: `accounts/${ctx.user_id}/subscriptions`, qs: { state: 'active', }, @@ -1453,48 +1455,48 @@ describe('RecurlyWrapper', function () { .should.equal(true) }) - it('should return the subscriptions', async function () { - expect(this.result).to.deep.equal(this.subscriptions) + it('should return the subscriptions', async function (ctx) { + expect(ctx.result).to.deep.equal(ctx.subscriptions) }) }) describe('without an account', function () { - beforeEach(async function () { - this.response.status = 404 - this.accountActiveSubscriptions = - await this.RecurlyWrapper.promises.listAccountActiveSubscriptions( - this.user_id + beforeEach(async function (ctx) { + ctx.response.status = 404 + ctx.accountActiveSubscriptions = + await ctx.RecurlyWrapper.promises.listAccountActiveSubscriptions( + ctx.user_id ) }) - it('should return an empty array of subscriptions', function () { - expect(this.accountActiveSubscriptions).to.deep.equal([]) + it('should return an empty array of subscriptions', function (ctx) { + expect(ctx.accountActiveSubscriptions).to.deep.equal([]) }) }) }) describe('extendTrial', function () { - beforeEach(function () { - this.subscriptionId = 'subscription-id-123' + beforeEach(function (ctx) { + ctx.subscriptionId = 'subscription-id-123' }) - afterEach(function () { - this.RecurlyWrapper.promises.apiRequest.restore() + afterEach(function (ctx) { + ctx.RecurlyWrapper.promises.apiRequest.restore() tk.reset() }) describe('with default parameters (7 days)', function () { - beforeEach(async function () { + beforeEach(async function (ctx) { tk.freeze(new Date('2025-01-25T10:30:00Z')) - this.apiRequest = sinon - .stub(this.RecurlyWrapper.promises, 'apiRequest') + ctx.apiRequest = sinon + .stub(ctx.RecurlyWrapper.promises, 'apiRequest') .resolves() - await this.RecurlyWrapper.promises.extendTrial(this.subscriptionId) + await ctx.RecurlyWrapper.promises.extendTrial(ctx.subscriptionId) }) - it('should extend trial by 7 days from current time', function () { - const options = this.apiRequest.lastCall.args[0] + it('should extend trial by 7 days from current time', function (ctx) { + const options = ctx.apiRequest.lastCall.args[0] expect(options.qs.next_bill_date).to.deep.equal( new Date('2025-02-01T10:30:00Z') ) @@ -1502,23 +1504,23 @@ describe('RecurlyWrapper', function () { }) describe('extending trial across year boundary', function () { - beforeEach(async function () { + beforeEach(async function (ctx) { // Trial ends on December 28, 2025, extend by 14 days to cross into 2026 - this.trialEndsAt = new Date('2025-12-28T12:00:00Z') - this.daysUntilExpire = 14 + ctx.trialEndsAt = new Date('2025-12-28T12:00:00Z') + ctx.daysUntilExpire = 14 - this.apiRequest = sinon - .stub(this.RecurlyWrapper.promises, 'apiRequest') + ctx.apiRequest = sinon + .stub(ctx.RecurlyWrapper.promises, 'apiRequest') .resolves() - await this.RecurlyWrapper.promises.extendTrial( - this.subscriptionId, - this.trialEndsAt, - this.daysUntilExpire + await ctx.RecurlyWrapper.promises.extendTrial( + ctx.subscriptionId, + ctx.trialEndsAt, + ctx.daysUntilExpire ) }) - it('should correctly calculate date across year boundary', function () { - const options = this.apiRequest.lastCall.args[0] + it('should correctly calculate date across year boundary', function (ctx) { + const options = ctx.apiRequest.lastCall.args[0] expect(options.qs.next_bill_date).to.deep.equal( new Date('2026-01-11T12:00:00Z') ) diff --git a/services/web/test/unit/src/Subscription/SubscriptionLocator.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionLocator.test.mjs index e8202424fc..1a576025ae 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionLocator.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionLocator.test.mjs @@ -1,14 +1,13 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') +import { vi, expect } from 'vitest' +import sinon from 'sinon' const modulePath = '../../../../app/src/Features/Subscription/SubscriptionLocator' -const { expect } = require('chai') describe('Subscription Locator Tests', function () { - beforeEach(function () { - this.user = { _id: '5208dd34438842e2db333333' } - this.subscription = { hello: 'world' } - this.Subscription = { + beforeEach(async function (ctx) { + ctx.user = { _id: '5208dd34438842e2db333333' } + ctx.subscription = { hello: 'world' } + ctx.Subscription = { findOne: sinon.stub().returns({ exec: sinon.stub().resolves(), }), @@ -21,7 +20,7 @@ describe('Subscription Locator Tests', function () { exec: sinon.stub().resolves(), }), } - this.DeletedSubscription = { + ctx.DeletedSubscription = { findOne: sinon.stub().returns({ exec: sinon.stub().resolves(), }), @@ -30,63 +29,69 @@ describe('Subscription Locator Tests', function () { }), } - this.SubscriptionLocator = SandboxedModule.require(modulePath, { - requires: { - './GroupPlansData': {}, - '../../models/Subscription': { - Subscription: this.Subscription, - }, - '../../models/DeletedSubscription': { - DeletedSubscription: this.DeletedSubscription, - }, - '../../models/SSOConfig': { - SSOConfig: this.SSOConfig, - }, - }, - }) + vi.doMock( + '../../../../app/src/Features/Subscription/GroupPlansData', + () => ({ + default: {}, + }) + ) + + vi.doMock('../../../../app/src/models/Subscription', () => ({ + Subscription: ctx.Subscription, + })) + + vi.doMock('../../../../app/src/models/DeletedSubscription', () => ({ + DeletedSubscription: ctx.DeletedSubscription, + })) + + vi.doMock('../../../../app/src/models/SSOConfig', () => ({ + SSOConfig: ctx.SSOConfig, + })) + + ctx.SubscriptionLocator = (await import(modulePath)).default }) describe('finding users subscription', function () { - it('should send the users features', async function () { - this.Subscription.findOne.returns({ - exec: sinon.stub().resolves(this.subscription), + it('should send the users features', async function (ctx) { + ctx.Subscription.findOne.returns({ + exec: sinon.stub().resolves(ctx.subscription), }) const subscription = - await this.SubscriptionLocator.promises.getUsersSubscription(this.user) - this.Subscription.findOne - .calledWith({ admin_id: this.user._id }) + await ctx.SubscriptionLocator.promises.getUsersSubscription(ctx.user) + ctx.Subscription.findOne + .calledWith({ admin_id: ctx.user._id }) .should.equal(true) - subscription.should.equal(this.subscription) + subscription.should.equal(ctx.subscription) }) - it('should error if not found', async function () { - this.Subscription.findOne.returns({ + it('should error if not found', async function (ctx) { + ctx.Subscription.findOne.returns({ exec: sinon.stub().rejects('not found'), }) await expect( - this.SubscriptionLocator.promises.getUsersSubscription(this.user) + ctx.SubscriptionLocator.promises.getUsersSubscription(ctx.user) ).to.be.rejected }) - it('should take a user id rather than the user object', async function () { - this.Subscription.findOne.returns({ - exec: sinon.stub().resolves(this.subscription), + it('should take a user id rather than the user object', async function (ctx) { + ctx.Subscription.findOne.returns({ + exec: sinon.stub().resolves(ctx.subscription), }) const subscription = - await this.SubscriptionLocator.promises.getUsersSubscription( - this.user._id + await ctx.SubscriptionLocator.promises.getUsersSubscription( + ctx.user._id ) - this.Subscription.findOne - .calledWith({ admin_id: this.user._id }) + ctx.Subscription.findOne + .calledWith({ admin_id: ctx.user._id }) .should.equal(true) - subscription.should.equal(this.subscription) + subscription.should.equal(ctx.subscription) }) }) describe('getUserSubscriptionStatus', function () { - it('should return no active personal or group subscription when no user is passed', async function () { + it('should return no active personal or group subscription when no user is passed', async function (ctx) { const subscriptionStatus = - await this.SubscriptionLocator.promises.getUserSubscriptionStatus( + await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus( undefined ) expect(subscriptionStatus).to.deep.equal({ @@ -95,10 +100,10 @@ describe('Subscription Locator Tests', function () { }) }) - it('should return no active personal or group subscription when the user has no subscription', async function () { + it('should return no active personal or group subscription when the user has no subscription', async function (ctx) { const subscriptionStatus = - await this.SubscriptionLocator.promises.getUserSubscriptionStatus( - this.user._id + await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus( + ctx.user._id ) expect(subscriptionStatus).to.deep.equal({ personal: false, @@ -106,8 +111,8 @@ describe('Subscription Locator Tests', function () { }) }) - it('should return active personal subscription', async function () { - this.Subscription.findOne.returns({ + it('should return active personal subscription', async function (ctx) { + ctx.Subscription.findOne.returns({ exec: sinon.stub().resolves({ recurlyStatus: { state: 'active', @@ -115,14 +120,14 @@ describe('Subscription Locator Tests', function () { }), }) const subscriptionStatus = - await this.SubscriptionLocator.promises.getUserSubscriptionStatus( - this.user._id + await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus( + ctx.user._id ) expect(subscriptionStatus).to.deep.equal({ personal: true, group: false }) }) - it('should return active group subscription when member of a group plan', async function () { - this.Subscription.find.returns({ + it('should return active group subscription when member of a group plan', async function (ctx) { + ctx.Subscription.find.returns({ populate: sinon.stub().returns({ populate: sinon.stub().returns({ exec: sinon.stub().resolves([ @@ -137,14 +142,14 @@ describe('Subscription Locator Tests', function () { }), }) const subscriptionStatus = - await this.SubscriptionLocator.promises.getUserSubscriptionStatus( - this.user._id + await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus( + ctx.user._id ) expect(subscriptionStatus).to.deep.equal({ personal: false, group: true }) }) - it('should return active group subscription when owner of a group plan', async function () { - this.Subscription.findOne.returns({ + it('should return active group subscription when owner of a group plan', async function (ctx) { + ctx.Subscription.findOne.returns({ exec: sinon.stub().resolves({ recurlyStatus: { state: 'active', @@ -153,14 +158,14 @@ describe('Subscription Locator Tests', function () { }), }) const subscriptionStatus = - await this.SubscriptionLocator.promises.getUserSubscriptionStatus( - this.user._id + await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus( + ctx.user._id ) expect(subscriptionStatus).to.deep.equal({ personal: false, group: true }) }) - it('should return active personal and group subscription when has personal subscription and member of a group', async function () { - this.Subscription.find.returns({ + it('should return active personal and group subscription when has personal subscription and member of a group', async function (ctx) { + ctx.Subscription.find.returns({ populate: sinon.stub().returns({ populate: sinon.stub().returns({ exec: sinon.stub().resolves([ @@ -174,7 +179,7 @@ describe('Subscription Locator Tests', function () { }), }), }) - this.Subscription.findOne.returns({ + ctx.Subscription.findOne.returns({ exec: sinon.stub().resolves({ recurlyStatus: { state: 'active', @@ -182,8 +187,8 @@ describe('Subscription Locator Tests', function () { }), }) const subscriptionStatus = - await this.SubscriptionLocator.promises.getUserSubscriptionStatus( - this.user._id + await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus( + ctx.user._id ) expect(subscriptionStatus).to.deep.equal({ personal: true, group: true }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionUpdater.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionUpdater.test.mjs index d9ce693837..d85edfa24b 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionUpdater.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionUpdater.test.mjs @@ -1,57 +1,58 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') +import { beforeEach, describe, it, vi, assert, expect } from 'vitest' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' const modulePath = '../../../../app/src/Features/Subscription/SubscriptionUpdater' -const { assert, expect } = require('chai') -const { ObjectId } = require('mongodb-legacy') + +const { ObjectId } = mongodb describe('SubscriptionUpdater', function () { - beforeEach(function () { - this.recurlyPlan = { planCode: 'recurly-plan' } - this.recurlySubscription = { + beforeEach(async function (ctx) { + ctx.recurlyPlan = { planCode: 'recurly-plan' } + ctx.recurlySubscription = { uuid: '1238uoijdasjhd', plan: { - plan_code: this.recurlyPlan.planCode, + plan_code: ctx.recurlyPlan.planCode, }, } - this.adminUser = { _id: (this.adminuser_id = '5208dd34438843e2db000007') } - this.otherUserId = '5208dd34438842e2db000005' - this.allUserIds = ['13213', 'dsadas', 'djsaiud89'] - this.subscription = { + ctx.adminUser = { _id: (ctx.adminuser_id = '5208dd34438843e2db000007') } + ctx.otherUserId = '5208dd34438842e2db000005' + ctx.allUserIds = ['13213', 'dsadas', 'djsaiud89'] + ctx.subscription = { _id: '111111111111111111111111', - admin_id: this.adminUser._id, - manager_ids: [this.adminUser._id], + admin_id: ctx.adminUser._id, + manager_ids: [ctx.adminUser._id], member_ids: [], save: sinon.stub().resolves(), planCode: 'student_or_something', recurlySubscription_id: 'abc123def456fab789', } - this.user_id = this.adminuser_id + ctx.user_id = ctx.adminuser_id - this.groupSubscription = { + ctx.groupSubscription = { _id: '222222222222222222222222', - admin_id: this.adminUser._id, - manager_ids: [this.adminUser._id], - member_ids: this.allUserIds, + admin_id: ctx.adminUser._id, + manager_ids: [ctx.adminUser._id], + member_ids: ctx.allUserIds, save: sinon.stub().resolves(), groupPlan: true, planCode: 'group_subscription', recurlySubscription_id: '456fab789abc123def', } - this.betterGroupSubscription = { + ctx.betterGroupSubscription = { _id: '999999999999999999999999', - admin_id: this.adminUser._id, - manager_ids: [this.adminUser._id], - member_ids: [this.otherUserId], + admin_id: ctx.adminUser._id, + manager_ids: [ctx.adminUser._id], + member_ids: [ctx.otherUserId], save: sinon.stub().resolves(), groupPlan: true, planCode: 'better_group_subscription', recurlySubscription_id: '123def456fab789abc', } - const subscription = this.subscription - this.SubscriptionModel = class { + const subscription = ctx.subscription + ctx.SubscriptionModel = class { constructor(opts) { // Always return our mock subscription when creating a new one subscription.admin_id = opts.admin_id @@ -63,29 +64,29 @@ describe('SubscriptionUpdater', function () { return Promise.resolve(subscription) } } - this.SubscriptionModel.deleteOne = sinon + ctx.SubscriptionModel.deleteOne = sinon .stub() .returns({ exec: sinon.stub().resolves() }) - this.SubscriptionModel.updateOne = sinon + ctx.SubscriptionModel.updateOne = sinon .stub() .returns({ exec: sinon.stub().resolves() }) - this.SubscriptionModel.findOne = sinon.stub().resolves() - this.SubscriptionModel.findById = sinon.stub().resolves() - this.SubscriptionModel.updateMany = sinon + ctx.SubscriptionModel.findOne = sinon.stub().resolves() + ctx.SubscriptionModel.findById = sinon.stub().resolves() + ctx.SubscriptionModel.updateMany = sinon .stub() .returns({ exec: sinon.stub().resolves() }) - this.SubscriptionModel.findOneAndUpdate = sinon.stub().returns({ - exec: sinon.stub().resolves(this.subscription), + ctx.SubscriptionModel.findOneAndUpdate = sinon.stub().returns({ + exec: sinon.stub().resolves(ctx.subscription), }) - this.SSOConfigModel = class {} - this.SSOConfigModel.findOne = sinon.stub().returns({ + ctx.SSOConfigModel = class {} + ctx.SSOConfigModel.findOne = sinon.stub().returns({ lean: sinon.stub().returns({ exec: sinon.stub().resolves({ enabled: true }), }), }) - this.SubscriptionLocator = { + ctx.SubscriptionLocator = { promises: { getUsersSubscription: sinon.stub(), getGroupSubscriptionMemberOf: sinon.stub(), @@ -94,18 +95,18 @@ describe('SubscriptionUpdater', function () { }, } - this.SubscriptionLocator.promises.getSubscription - .withArgs(this.subscription._id) - .resolves(this.subscription) + ctx.SubscriptionLocator.promises.getSubscription + .withArgs(ctx.subscription._id) + .resolves(ctx.subscription) - this.Settings = { + ctx.Settings = { defaultPlanCode: 'personal', defaultFeatures: { default: 'features' }, plans: [ - this.recurlyPlan, - { planCode: this.subscription.planCode, features: {} }, + ctx.recurlyPlan, + { planCode: ctx.subscription.planCode, features: {} }, { - planCode: this.groupSubscription.planCode, + planCode: ctx.groupSubscription.planCode, features: { collaborators: 10, compileTimeout: 60, @@ -113,7 +114,7 @@ describe('SubscriptionUpdater', function () { }, }, { - planCode: this.betterGroupSubscription.planCode, + planCode: ctx.betterGroupSubscription.planCode, features: { collaborators: -1, compileTimeout: 240, @@ -135,339 +136,380 @@ describe('SubscriptionUpdater', function () { }, } - this.UserFeaturesUpdater = { + ctx.UserFeaturesUpdater = { promises: { updateFeatures: sinon.stub().resolves(), }, } - this.ReferalFeatures = { + ctx.ReferalFeatures = { promises: { getBonusFeatures: sinon.stub().resolves(), }, } - this.FeaturesUpdater = { + ctx.FeaturesUpdater = { promises: { scheduleRefreshFeatures: sinon.stub().resolves(), refreshFeatures: sinon.stub().resolves({}), }, } - this.DeletedSubscription = { + ctx.DeletedSubscription = { findOneAndUpdate: sinon.stub().returns({ exec: sinon.stub().resolves() }), } - this.AnalyticsManager = { + ctx.AnalyticsManager = { recordEventForUserInBackground: sinon.stub().resolves(), setUserPropertyForUserInBackground: sinon.stub(), registerAccountMapping: sinon.stub(), } - this.Features = { + ctx.Features = { hasFeature: sinon.stub().returns(false), } - this.UserAuditLogHandler = { + ctx.UserAuditLogHandler = { promises: { addEntry: sinon.stub().resolves(), }, } - this.UserUpdater = { + ctx.UserUpdater = { promises: { updateUser: sinon.stub().resolves(), }, } - this.SubscriptionUpdater = SandboxedModule.require(modulePath, { - requires: { - '../../models/Subscription': { - Subscription: this.SubscriptionModel, - }, - '../../models/SSOConfig': { SSOConfig: this.SSOConfigModel }, - './UserFeaturesUpdater': this.UserFeaturesUpdater, - './SubscriptionLocator': this.SubscriptionLocator, - '@overleaf/settings': this.Settings, - '../../infrastructure/mongodb': { db: {}, ObjectId }, - './FeaturesUpdater': this.FeaturesUpdater, - '../../models/DeletedSubscription': { - DeletedSubscription: this.DeletedSubscription, - }, - '../Analytics/AnalyticsManager': this.AnalyticsManager, - '../Analytics/AccountMappingHelper': (this.AccountMappingHelper = { + vi.doMock('../../../../app/src/models/Subscription', () => ({ + Subscription: ctx.SubscriptionModel, + })) + + vi.doMock('../../../../app/src/models/SSOConfig', () => ({ + SSOConfig: ctx.SSOConfigModel, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/UserFeaturesUpdater', + () => ({ + default: ctx.UserFeaturesUpdater, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator', + () => ({ + default: ctx.SubscriptionLocator, + }) + ) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({ + db: {}, + ObjectId, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/FeaturesUpdater', + () => ({ + default: ctx.FeaturesUpdater, + }) + ) + + vi.doMock('../../../../app/src/models/DeletedSubscription', () => ({ + DeletedSubscription: ctx.DeletedSubscription, + })) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: ctx.AnalyticsManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Analytics/AccountMappingHelper', + () => ({ + default: (ctx.AccountMappingHelper = { generateSubscriptionToRecurlyMapping: sinon.stub(), }), - '../../infrastructure/Features': this.Features, - '../User/UserAuditLogHandler': this.UserAuditLogHandler, - '../User/UserUpdater': this.UserUpdater, - '../../infrastructure/Modules': (this.Modules = { - promises: { - hooks: { - fire: sinon.stub().resolves(), - }, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Features', () => ({ + default: ctx.Features, + })) + + vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({ + default: ctx.UserAuditLogHandler, + })) + + vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({ + default: ctx.UserUpdater, + })) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: (ctx.Modules = { + promises: { + hooks: { + fire: sinon.stub().resolves(), }, - }), - }, - }) + }, + }), + })) + + ctx.SubscriptionUpdater = (await import(modulePath)).default }) describe('updateAdmin', function () { - it('should update the subscription admin', async function () { - this.subscription.groupPlan = true - await this.SubscriptionUpdater.promises.updateAdmin( - this.subscription, - this.otherUserId + it('should update the subscription admin', async function (ctx) { + ctx.subscription.groupPlan = true + await ctx.SubscriptionUpdater.promises.updateAdmin( + ctx.subscription, + ctx.otherUserId ) const query = { - _id: new ObjectId(this.subscription._id), + _id: new ObjectId(ctx.subscription._id), customAccount: true, } const update = { - $set: { admin_id: new ObjectId(this.otherUserId) }, - $addToSet: { manager_ids: new ObjectId(this.otherUserId) }, + $set: { admin_id: new ObjectId(ctx.otherUserId) }, + $addToSet: { manager_ids: new ObjectId(ctx.otherUserId) }, } - this.SubscriptionModel.updateOne.should.have.been.calledOnce - this.SubscriptionModel.updateOne.should.have.been.calledWith( - query, - update - ) + ctx.SubscriptionModel.updateOne.should.have.been.calledOnce + ctx.SubscriptionModel.updateOne.should.have.been.calledWith(query, update) }) - it('should remove the manager for non-group subscriptions', async function () { - await this.SubscriptionUpdater.promises.updateAdmin( - this.subscription, - this.otherUserId + it('should remove the manager for non-group subscriptions', async function (ctx) { + await ctx.SubscriptionUpdater.promises.updateAdmin( + ctx.subscription, + ctx.otherUserId ) const query = { - _id: new ObjectId(this.subscription._id), + _id: new ObjectId(ctx.subscription._id), customAccount: true, } const update = { $set: { - admin_id: new ObjectId(this.otherUserId), - manager_ids: [new ObjectId(this.otherUserId)], + admin_id: new ObjectId(ctx.otherUserId), + manager_ids: [new ObjectId(ctx.otherUserId)], }, } - this.SubscriptionModel.updateOne.should.have.been.calledOnce - this.SubscriptionModel.updateOne.should.have.been.calledWith( - query, - update - ) + ctx.SubscriptionModel.updateOne.should.have.been.calledOnce + ctx.SubscriptionModel.updateOne.should.have.been.calledWith(query, update) }) }) describe('syncSubscription', function () { - beforeEach(function () { - this.SubscriptionLocator.promises.getUsersSubscription.resolves( - this.subscription + beforeEach(function (ctx) { + ctx.SubscriptionLocator.promises.getUsersSubscription.resolves( + ctx.subscription ) }) - it('should update the subscription if the user already is admin of one', async function () { - await this.SubscriptionUpdater.promises.syncSubscription( - this.recurlySubscription, - this.adminUser._id + it('should update the subscription if the user already is admin of one', async function (ctx) { + await ctx.SubscriptionUpdater.promises.syncSubscription( + ctx.recurlySubscription, + ctx.adminUser._id ) - this.SubscriptionLocator.promises.getUsersSubscription - .calledWith(this.adminUser._id) + ctx.SubscriptionLocator.promises.getUsersSubscription + .calledWith(ctx.adminUser._id) .should.equal(true) }) - it('should not call updateFeatures with group subscription if recurly subscription is not expired', async function () { - await this.SubscriptionUpdater.promises.syncSubscription( - this.recurlySubscription, - this.adminUser._id + it('should not call updateFeatures with group subscription if recurly subscription is not expired', async function (ctx) { + await ctx.SubscriptionUpdater.promises.syncSubscription( + ctx.recurlySubscription, + ctx.adminUser._id ) - this.SubscriptionLocator.promises.getUsersSubscription - .calledWith(this.adminUser._id) + ctx.SubscriptionLocator.promises.getUsersSubscription + .calledWith(ctx.adminUser._id) .should.equal(true) - this.UserFeaturesUpdater.promises.updateFeatures.called.should.equal( - false - ) + ctx.UserFeaturesUpdater.promises.updateFeatures.called.should.equal(false) }) }) describe('updateSubscriptionFromRecurly', function () { - afterEach(function () { - this.subscription.member_ids = [] + afterEach(function (ctx) { + ctx.subscription.member_ids = [] }) - it('should update the subscription with token etc when not expired', async function () { - await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - this.recurlySubscription, - this.subscription, + it('should update the subscription with token etc when not expired', async function (ctx) { + await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + ctx.recurlySubscription, + ctx.subscription, {} ) - this.subscription.recurlySubscription_id.should.equal( - this.recurlySubscription.uuid + ctx.subscription.recurlySubscription_id.should.equal( + ctx.recurlySubscription.uuid ) - this.subscription.planCode.should.equal( - this.recurlySubscription.plan.plan_code + ctx.subscription.planCode.should.equal( + ctx.recurlySubscription.plan.plan_code ) - this.subscription.save.called.should.equal(true) + ctx.subscription.save.called.should.equal(true) expect( - this.FeaturesUpdater.promises.scheduleRefreshFeatures - ).to.have.been.calledWith(this.adminUser._id) + ctx.FeaturesUpdater.promises.scheduleRefreshFeatures + ).to.have.been.calledWith(ctx.adminUser._id) }) - it('should send a recurly account mapping event', async function () { + it('should send a recurly account mapping event', async function (ctx) { const createdAt = new Date().toISOString() - this.AccountMappingHelper.generateSubscriptionToRecurlyMapping.returns({ + ctx.AccountMappingHelper.generateSubscriptionToRecurlyMapping.returns({ source: 'recurly', sourceEntity: 'subscription', - sourceEntityId: this.recurlySubscription.uuid, + sourceEntityId: ctx.recurlySubscription.uuid, target: 'v2', targetEntity: 'subscription', - targetEntityId: this.subscription._id, + targetEntityId: ctx.subscription._id, createdAt, }) - await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - this.recurlySubscription, - this.subscription, + await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + ctx.recurlySubscription, + ctx.subscription, {} ) expect( - this.AccountMappingHelper.generateSubscriptionToRecurlyMapping + ctx.AccountMappingHelper.generateSubscriptionToRecurlyMapping ).to.have.been.calledWith( - this.subscription._id, - this.recurlySubscription.uuid + ctx.subscription._id, + ctx.recurlySubscription.uuid ) expect( - this.AnalyticsManager.registerAccountMapping + ctx.AnalyticsManager.registerAccountMapping ).to.have.been.calledWith({ source: 'recurly', sourceEntity: 'subscription', - sourceEntityId: this.recurlySubscription.uuid, + sourceEntityId: ctx.recurlySubscription.uuid, target: 'v2', targetEntity: 'subscription', - targetEntityId: this.subscription._id, + targetEntityId: ctx.subscription._id, createdAt, }) }) - it('should remove the subscription when expired', async function () { - this.recurlySubscription.state = 'expired' - await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - this.recurlySubscription, - this.subscription, + it('should remove the subscription when expired', async function (ctx) { + ctx.recurlySubscription.state = 'expired' + await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + ctx.recurlySubscription, + ctx.subscription, {} ) - this.SubscriptionModel.deleteOne.should.have.been.calledWith({ - _id: this.subscription._id, + ctx.SubscriptionModel.deleteOne.should.have.been.calledWith({ + _id: ctx.subscription._id, }) }) - it('should not remove the subscription when expired if it has Managed Users enabled', async function () { - this.Features.hasFeature.withArgs('saas').returns(true) - this.subscription.managedUsersEnabled = true + it('should not remove the subscription when expired if it has Managed Users enabled', async function (ctx) { + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.subscription.managedUsersEnabled = true - this.recurlySubscription.state = 'expired' - await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - this.recurlySubscription, - this.subscription, + ctx.recurlySubscription.state = 'expired' + await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + ctx.recurlySubscription, + ctx.subscription, {} ) - this.SubscriptionModel.deleteOne.should.not.have.been.called + ctx.SubscriptionModel.deleteOne.should.not.have.been.called }) - it('should not remove the subscription when expired if it has Group SSO enabled', async function () { - this.Features.hasFeature.withArgs('saas').returns(true) - this.subscription.ssoConfig = new ObjectId('abc123abc123abc123abc123') + it('should not remove the subscription when expired if it has Group SSO enabled', async function (ctx) { + ctx.Features.hasFeature.withArgs('saas').returns(true) + ctx.subscription.ssoConfig = new ObjectId('abc123abc123abc123abc123') - this.recurlySubscription.state = 'expired' - await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - this.recurlySubscription, - this.subscription, + ctx.recurlySubscription.state = 'expired' + await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + ctx.recurlySubscription, + ctx.subscription, {} ) - this.SubscriptionModel.deleteOne.should.not.have.been.called + ctx.SubscriptionModel.deleteOne.should.not.have.been.called }) - it('should update all the users features', async function () { - this.subscription.member_ids = this.allUserIds - await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - this.recurlySubscription, - this.subscription, + it('should update all the users features', async function (ctx) { + ctx.subscription.member_ids = ctx.allUserIds + await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + ctx.recurlySubscription, + ctx.subscription, {} ) expect( - this.FeaturesUpdater.promises.scheduleRefreshFeatures - ).to.have.been.calledWith(this.adminUser._id) + ctx.FeaturesUpdater.promises.scheduleRefreshFeatures + ).to.have.been.calledWith(ctx.adminUser._id) expect( - this.FeaturesUpdater.promises.scheduleRefreshFeatures - ).to.have.been.calledWith(this.allUserIds[0]) + ctx.FeaturesUpdater.promises.scheduleRefreshFeatures + ).to.have.been.calledWith(ctx.allUserIds[0]) expect( - this.FeaturesUpdater.promises.scheduleRefreshFeatures - ).to.have.been.calledWith(this.allUserIds[1]) + ctx.FeaturesUpdater.promises.scheduleRefreshFeatures + ).to.have.been.calledWith(ctx.allUserIds[1]) expect( - this.FeaturesUpdater.promises.scheduleRefreshFeatures - ).to.have.been.calledWith(this.allUserIds[2]) + ctx.FeaturesUpdater.promises.scheduleRefreshFeatures + ).to.have.been.calledWith(ctx.allUserIds[2]) }) - it('should set group to true and save how many members can be added to group', async function () { - this.recurlyPlan.groupPlan = true - this.recurlyPlan.membersLimit = 5 - await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - this.recurlySubscription, - this.subscription, + it('should set group to true and save how many members can be added to group', async function (ctx) { + ctx.recurlyPlan.groupPlan = true + ctx.recurlyPlan.membersLimit = 5 + await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + ctx.recurlySubscription, + ctx.subscription, {} ) - this.subscription.membersLimit.should.equal(5) - this.subscription.groupPlan.should.equal(true) - this.subscription.member_ids.should.deep.equal([ - this.subscription.admin_id, - ]) + ctx.subscription.membersLimit.should.equal(5) + ctx.subscription.groupPlan.should.equal(true) + ctx.subscription.member_ids.should.deep.equal([ctx.subscription.admin_id]) }) - it('should delete and replace subscription when downgrading from group to individual plan', async function () { - this.recurlyPlan.groupPlan = false - await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - this.recurlySubscription, - this.groupSubscription, + it('should delete and replace subscription when downgrading from group to individual plan', async function (ctx) { + ctx.recurlyPlan.groupPlan = false + await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + ctx.recurlySubscription, + ctx.groupSubscription, {} ) }) - it('should not set group to true or set groupPlan', async function () { - await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - this.recurlySubscription, - this.subscription, + it('should not set group to true or set groupPlan', async function (ctx) { + await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + ctx.recurlySubscription, + ctx.subscription, {} ) - assert.notEqual(this.subscription.membersLimit, 5) - assert.notEqual(this.subscription.groupPlan, true) + assert.notEqual(ctx.subscription.membersLimit, 5) + assert.notEqual(ctx.subscription.groupPlan, true) }) describe('when the plan allows adding more seats', function () { - beforeEach(function () { - this.membersLimitAddOn = 'add_on1' - this.recurlyPlan.groupPlan = true - this.recurlyPlan.membersLimit = 5 - this.recurlyPlan.membersLimitAddOn = this.membersLimitAddOn + beforeEach(function (ctx) { + ctx.membersLimitAddOn = 'add_on1' + ctx.recurlyPlan.groupPlan = true + ctx.recurlyPlan.membersLimit = 5 + ctx.recurlyPlan.membersLimitAddOn = ctx.membersLimitAddOn }) function expectMembersLimit(limit) { - it('should set the membersLimit accordingly', async function () { - await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( - this.recurlySubscription, - this.subscription, + it('should set the membersLimit accordingly', async function (ctx) { + await ctx.SubscriptionUpdater.promises.updateSubscriptionFromRecurly( + ctx.recurlySubscription, + ctx.subscription, {} ) - expect(this.subscription.membersLimit).to.equal(limit) + expect(ctx.subscription.membersLimit).to.equal(limit) }) } describe('when the recurlySubscription does not have add ons', function () { - beforeEach(function () { - delete this.recurlySubscription.subscription_add_ons + beforeEach(function (ctx) { + delete ctx.recurlySubscription.subscription_add_ons }) expectMembersLimit(5) }) describe('when the recurlySubscription has non-matching add ons', function () { - beforeEach(function () { - this.recurlySubscription.subscription_add_ons = [ + beforeEach(function (ctx) { + ctx.recurlySubscription.subscription_add_ons = [ { add_on_code: 'add_on_99', quantity: 3 }, ] }) @@ -475,9 +517,9 @@ describe('SubscriptionUpdater', function () { }) describe('when the recurlySubscription has a matching add on', function () { - beforeEach(function () { - this.recurlySubscription.subscription_add_ons = [ - { add_on_code: this.membersLimitAddOn, quantity: 10 }, + beforeEach(function (ctx) { + ctx.recurlySubscription.subscription_add_ons = [ + { add_on_code: ctx.membersLimitAddOn, quantity: 10 }, ] }) expectMembersLimit(15) @@ -485,10 +527,10 @@ describe('SubscriptionUpdater', function () { // NOTE: This is unexpected, but we are going to support it anyways. describe('when the recurlySubscription has multiple matching add ons', function () { - beforeEach(function () { - this.recurlySubscription.subscription_add_ons = [ - { add_on_code: this.membersLimitAddOn, quantity: 10 }, - { add_on_code: this.membersLimitAddOn, quantity: 3 }, + beforeEach(function (ctx) { + ctx.recurlySubscription.subscription_add_ons = [ + { add_on_code: ctx.membersLimitAddOn, quantity: 10 }, + { add_on_code: ctx.membersLimitAddOn, quantity: 3 }, ] }) expectMembersLimit(18) @@ -497,120 +539,120 @@ describe('SubscriptionUpdater', function () { }) describe('addUserToGroup', function () { - it('should add the user ids to the group as a set', async function () { - this.SubscriptionModel.findOne = sinon + it('should add the user ids to the group as a set', async function (ctx) { + ctx.SubscriptionModel.findOne = sinon .stub() - .resolves(this.groupSubscription) + .resolves(ctx.groupSubscription) - await this.SubscriptionUpdater.promises.addUserToGroup( - this.groupSubscription._id, - this.otherUserId + await ctx.SubscriptionUpdater.promises.addUserToGroup( + ctx.groupSubscription._id, + ctx.otherUserId ) - const searchOps = { _id: this.groupSubscription._id } + const searchOps = { _id: ctx.groupSubscription._id } const insertOperation = { - $addToSet: { member_ids: this.otherUserId }, + $addToSet: { member_ids: ctx.otherUserId }, } - this.SubscriptionModel.updateOne + ctx.SubscriptionModel.updateOne .calledWith(searchOps, insertOperation) .should.equal(true) - expect(this.SubscriptionModel.updateOne.lastCall.args[2].session).to.exist + expect(ctx.SubscriptionModel.updateOne.lastCall.args[2].session).to.exist sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.otherUserId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.otherUserId, 'group-subscription-joined', { - groupId: this.groupSubscription._id, - subscriptionId: this.groupSubscription.recurlySubscription_id, + groupId: ctx.groupSubscription._id, + subscriptionId: ctx.groupSubscription.recurlySubscription_id, } ) }) - it('should update the users features', async function () { - await this.SubscriptionUpdater.promises.addUserToGroup( - this.subscription._id, - this.otherUserId + it('should update the users features', async function (ctx) { + await ctx.SubscriptionUpdater.promises.addUserToGroup( + ctx.subscription._id, + ctx.otherUserId ) - this.FeaturesUpdater.promises.refreshFeatures - .calledWith(this.otherUserId) + ctx.FeaturesUpdater.promises.refreshFeatures + .calledWith(ctx.otherUserId) .should.equal(true) }) - it('should set the group plan code user property to the best plan with 1 group subscription', async function () { - this.SubscriptionLocator.promises.getMemberSubscriptions - .withArgs(this.otherUserId) - .resolves([this.groupSubscription]) - await this.SubscriptionUpdater.promises.addUserToGroup( - this.groupSubscription._id, - this.otherUserId + it('should set the group plan code user property to the best plan with 1 group subscription', async function (ctx) { + ctx.SubscriptionLocator.promises.getMemberSubscriptions + .withArgs(ctx.otherUserId) + .resolves([ctx.groupSubscription]) + await ctx.SubscriptionUpdater.promises.addUserToGroup( + ctx.groupSubscription._id, + ctx.otherUserId ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.otherUserId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.otherUserId, 'group-subscription-plan-code', 'group_subscription' ) }) - it('should set the group plan code user property to the best plan with 2 group subscriptions', async function () { - this.SubscriptionLocator.promises.getMemberSubscriptions - .withArgs(this.otherUserId) - .resolves([this.groupSubscription, this.betterGroupSubscription]) - await this.SubscriptionUpdater.promises.addUserToGroup( - this.betterGroupSubscription._id, - this.otherUserId + it('should set the group plan code user property to the best plan with 2 group subscriptions', async function (ctx) { + ctx.SubscriptionLocator.promises.getMemberSubscriptions + .withArgs(ctx.otherUserId) + .resolves([ctx.groupSubscription, ctx.betterGroupSubscription]) + await ctx.SubscriptionUpdater.promises.addUserToGroup( + ctx.betterGroupSubscription._id, + ctx.otherUserId ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.otherUserId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.otherUserId, 'group-subscription-plan-code', 'better_group_subscription' ) }) - it('should set the group plan code user property to the best plan with 2 group subscriptions in reverse order', async function () { - this.SubscriptionLocator.promises.getMemberSubscriptions - .withArgs(this.otherUserId) - .resolves([this.betterGroupSubscription, this.groupSubscription]) - await this.SubscriptionUpdater.promises.addUserToGroup( - this.betterGroupSubscription._id, - this.otherUserId + it('should set the group plan code user property to the best plan with 2 group subscriptions in reverse order', async function (ctx) { + ctx.SubscriptionLocator.promises.getMemberSubscriptions + .withArgs(ctx.otherUserId) + .resolves([ctx.betterGroupSubscription, ctx.groupSubscription]) + await ctx.SubscriptionUpdater.promises.addUserToGroup( + ctx.betterGroupSubscription._id, + ctx.otherUserId ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.otherUserId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.otherUserId, 'group-subscription-plan-code', 'better_group_subscription' ) }) - it('should add an entry to the user audit log when joining a group', async function () { - await this.SubscriptionUpdater.promises.addUserToGroup( - this.subscription._id, - this.otherUserId + it('should add an entry to the user audit log when joining a group', async function (ctx) { + await ctx.SubscriptionUpdater.promises.addUserToGroup( + ctx.subscription._id, + ctx.otherUserId ) sinon.assert.calledWith( - this.UserAuditLogHandler.promises.addEntry, - this.otherUserId, + ctx.UserAuditLogHandler.promises.addEntry, + ctx.otherUserId, 'join-group-subscription', undefined, undefined, { - subscriptionId: this.subscription._id, + subscriptionId: ctx.subscription._id, } ) }) - it('should add an entry to the group audit log when joining a group', async function () { - await this.SubscriptionUpdater.promises.addUserToGroup( - this.subscription._id, - this.otherUserId, + it('should add an entry to the group audit log when joining a group', async function (ctx) { + await ctx.SubscriptionUpdater.promises.addUserToGroup( + ctx.subscription._id, + ctx.otherUserId, { ipAddress: '0:0:0:0', initiatorId: 'user123' } ) - expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( 'addGroupAuditLogEntry', { - groupId: this.subscription._id, + groupId: ctx.subscription._id, initiatorId: 'user123', ipAddress: '0:0:0:0', operation: 'join-group', @@ -620,8 +662,8 @@ describe('SubscriptionUpdater', function () { }) describe('removeUserFromGroup', function () { - beforeEach(function () { - this.fakeSubscriptions = [ + beforeEach(function (ctx) { + ctx.fakeSubscriptions = [ { _id: 'fake-id-1', }, @@ -629,38 +671,38 @@ describe('SubscriptionUpdater', function () { _id: 'fake-id-2', }, ] - this.SubscriptionModel.findOne.resolves(this.groupSubscription) - this.SubscriptionModel.findById = sinon + ctx.SubscriptionModel.findOne.resolves(ctx.groupSubscription) + ctx.SubscriptionModel.findById = sinon .stub() - .resolves(this.groupSubscription) - this.SubscriptionLocator.promises.getMemberSubscriptions.resolves( - this.fakeSubscriptions + .resolves(ctx.groupSubscription) + ctx.SubscriptionLocator.promises.getMemberSubscriptions.resolves( + ctx.fakeSubscriptions ) }) - it('should pull the users id from the group', async function () { - await this.SubscriptionUpdater.promises.removeUserFromGroup( - this.subscription._id, - this.otherUserId + it('should pull the users id from the group', async function (ctx) { + await ctx.SubscriptionUpdater.promises.removeUserFromGroup( + ctx.subscription._id, + ctx.otherUserId ) - const removeOperation = { $pull: { member_ids: this.otherUserId } } - this.SubscriptionModel.updateOne - .calledWith({ _id: this.subscription._id }, removeOperation) + const removeOperation = { $pull: { member_ids: ctx.otherUserId } } + ctx.SubscriptionModel.updateOne + .calledWith({ _id: ctx.subscription._id }, removeOperation) .should.equal(true) }) - it('should remove user enrollment if the group is managed', async function () { - this.SubscriptionModel.findById.resolves({ - ...this.groupSubscription, + it('should remove user enrollment if the group is managed', async function (ctx) { + ctx.SubscriptionModel.findById.resolves({ + ...ctx.groupSubscription, managedUsersEnabled: true, }) - await this.SubscriptionUpdater.promises.removeUserFromGroup( - this.groupSubscription._id, - this.otherUserId + await ctx.SubscriptionUpdater.promises.removeUserFromGroup( + ctx.groupSubscription._id, + ctx.otherUserId ) - this.UserUpdater.promises.updateUser + ctx.UserUpdater.promises.updateUser .calledWith( - { _id: this.otherUserId }, + { _id: ctx.otherUserId }, { $unset: { 'enrollment.managedBy': 1, @@ -671,66 +713,66 @@ describe('SubscriptionUpdater', function () { .should.equal(true) }) - it('should send a group-subscription-left event', async function () { - await this.SubscriptionUpdater.promises.removeUserFromGroup( - this.groupSubscription._id, - this.otherUserId + it('should send a group-subscription-left event', async function (ctx) { + await ctx.SubscriptionUpdater.promises.removeUserFromGroup( + ctx.groupSubscription._id, + ctx.otherUserId ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.otherUserId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.otherUserId, 'group-subscription-left', { - groupId: this.groupSubscription._id, - subscriptionId: this.groupSubscription.recurlySubscription_id, + groupId: ctx.groupSubscription._id, + subscriptionId: ctx.groupSubscription.recurlySubscription_id, } ) }) - it('should set the group plan code user property when removing user from group', async function () { - await this.SubscriptionUpdater.promises.removeUserFromGroup( - this.subscription._id, - this.otherUserId + it('should set the group plan code user property when removing user from group', async function (ctx) { + await ctx.SubscriptionUpdater.promises.removeUserFromGroup( + ctx.subscription._id, + ctx.otherUserId ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.otherUserId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.otherUserId, 'group-subscription-plan-code', null ) }) - it('should update the users features', async function () { - await this.SubscriptionUpdater.promises.removeUserFromGroup( - this.subscription._id, - this.otherUserId + it('should update the users features', async function (ctx) { + await ctx.SubscriptionUpdater.promises.removeUserFromGroup( + ctx.subscription._id, + ctx.otherUserId ) - this.FeaturesUpdater.promises.refreshFeatures - .calledWith(this.otherUserId) + ctx.FeaturesUpdater.promises.refreshFeatures + .calledWith(ctx.otherUserId) .should.equal(true) }) - it('should add an audit log when a user leaves a group', async function () { - await this.SubscriptionUpdater.promises.removeUserFromGroup( - this.subscription._id, - this.otherUserId + it('should add an audit log when a user leaves a group', async function (ctx) { + await ctx.SubscriptionUpdater.promises.removeUserFromGroup( + ctx.subscription._id, + ctx.otherUserId ) sinon.assert.calledWith( - this.UserAuditLogHandler.promises.addEntry, - this.otherUserId, + ctx.UserAuditLogHandler.promises.addEntry, + ctx.otherUserId, 'leave-group-subscription', undefined, undefined, { - subscriptionId: this.subscription._id, + subscriptionId: ctx.subscription._id, } ) }) }) describe('removeUserFromAllGroups', function () { - beforeEach(function () { - this.SubscriptionLocator.promises.getMemberSubscriptions.resolves([ + beforeEach(function (ctx) { + ctx.SubscriptionLocator.promises.getMemberSubscriptions.resolves([ { _id: 'fake-id-1', }, @@ -740,60 +782,60 @@ describe('SubscriptionUpdater', function () { ]) }) - it('should set the group plan code user property when removing user from all groups', async function () { - await this.SubscriptionUpdater.promises.removeUserFromAllGroups( - this.otherUserId + it('should set the group plan code user property when removing user from all groups', async function (ctx) { + await ctx.SubscriptionUpdater.promises.removeUserFromAllGroups( + ctx.otherUserId ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.otherUserId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.otherUserId, 'group-subscription-plan-code', null ) }) - it('should pull the users id from all groups', async function () { - await this.SubscriptionUpdater.promises.removeUserFromAllGroups( - this.otherUserId + it('should pull the users id from all groups', async function (ctx) { + await ctx.SubscriptionUpdater.promises.removeUserFromAllGroups( + ctx.otherUserId ) const filter = { _id: ['fake-id-1', 'fake-id-2'] } - const removeOperation = { $pull: { member_ids: this.otherUserId } } + const removeOperation = { $pull: { member_ids: ctx.otherUserId } } sinon.assert.calledWith( - this.SubscriptionModel.updateMany, + ctx.SubscriptionModel.updateMany, filter, removeOperation ) }) - it('should send a group-subscription-left event for each group', async function () { - this.fakeSub1 = { + it('should send a group-subscription-left event for each group', async function (ctx) { + ctx.fakeSub1 = { _id: 'fake-id-1', groupPlan: true, recurlySubscription_id: 'fake-sub-1', } - this.fakeSub2 = { + ctx.fakeSub2 = { _id: 'fake-id-2', groupPlan: true, recurlySubscription_id: 'fake-sub-2', } - this.SubscriptionModel.findOne + ctx.SubscriptionModel.findOne .withArgs( { _id: 'fake-id-1' }, { recurlySubscription_id: 1, groupPlan: 1 } ) - .resolves(this.fakeSub1) + .resolves(ctx.fakeSub1) .withArgs( { _id: 'fake-id-2' }, { recurlySubscription_id: 1, groupPlan: 1 } ) - .resolves(this.fakeSub2) + .resolves(ctx.fakeSub2) - await this.SubscriptionUpdater.promises.removeUserFromAllGroups( - this.otherUserId + await ctx.SubscriptionUpdater.promises.removeUserFromAllGroups( + ctx.otherUserId ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.otherUserId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.otherUserId, 'group-subscription-left', { groupId: 'fake-id-1', @@ -801,8 +843,8 @@ describe('SubscriptionUpdater', function () { } ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.otherUserId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.otherUserId, 'group-subscription-left', { groupId: 'fake-id-2', @@ -811,13 +853,13 @@ describe('SubscriptionUpdater', function () { ) }) - it('should add an audit log entry for each group the user leaves', async function () { - await this.SubscriptionUpdater.promises.removeUserFromAllGroups( - this.otherUserId + it('should add an audit log entry for each group the user leaves', async function (ctx) { + await ctx.SubscriptionUpdater.promises.removeUserFromAllGroups( + ctx.otherUserId ) sinon.assert.calledWith( - this.UserAuditLogHandler.promises.addEntry, - this.otherUserId, + ctx.UserAuditLogHandler.promises.addEntry, + ctx.otherUserId, 'leave-group-subscription', undefined, undefined, @@ -826,8 +868,8 @@ describe('SubscriptionUpdater', function () { } ) sinon.assert.calledWith( - this.UserAuditLogHandler.promises.addEntry, - this.otherUserId, + ctx.UserAuditLogHandler.promises.addEntry, + ctx.otherUserId, 'leave-group-subscription', undefined, undefined, @@ -839,75 +881,75 @@ describe('SubscriptionUpdater', function () { }) describe('deleteSubscription', function () { - beforeEach(async function () { - this.subscription = { + beforeEach(async function (ctx) { + ctx.subscription = { _id: new ObjectId().toString(), mock: 'subscription', admin_id: new ObjectId(), member_ids: [new ObjectId(), new ObjectId(), new ObjectId()], } - await this.SubscriptionUpdater.promises.deleteSubscription( - this.subscription, + await ctx.SubscriptionUpdater.promises.deleteSubscription( + ctx.subscription, {} ) }) - it('should remove the subscription', function () { - this.SubscriptionModel.deleteOne - .calledWith({ _id: this.subscription._id }) + it('should remove the subscription', function (ctx) { + ctx.SubscriptionModel.deleteOne + .calledWith({ _id: ctx.subscription._id }) .should.equal(true) }) - it('should downgrade the admin_id', function () { + it('should downgrade the admin_id', function (ctx) { expect( - this.FeaturesUpdater.promises.scheduleRefreshFeatures - ).to.have.been.calledWith(this.subscription.admin_id) + ctx.FeaturesUpdater.promises.scheduleRefreshFeatures + ).to.have.been.calledWith(ctx.subscription.admin_id) }) - it('should downgrade all of the members', function () { - for (const userId of this.subscription.member_ids) { + it('should downgrade all of the members', function (ctx) { + for (const userId of ctx.subscription.member_ids) { expect( - this.FeaturesUpdater.promises.scheduleRefreshFeatures + ctx.FeaturesUpdater.promises.scheduleRefreshFeatures ).to.have.been.calledWith(userId) } }) }) describe('scheduleRefreshFeatures', function () { - it('should call upgrades feature for personal subscription from admin_id', async function () { - this.subscription = { + it('should call upgrades feature for personal subscription from admin_id', async function (ctx) { + ctx.subscription = { _id: new ObjectId().toString(), mock: 'subscription', admin_id: new ObjectId(), } - await this.SubscriptionUpdater.promises.scheduleRefreshFeatures( - this.subscription + await ctx.SubscriptionUpdater.promises.scheduleRefreshFeatures( + ctx.subscription ) expect( - this.FeaturesUpdater.promises.scheduleRefreshFeatures - ).to.have.been.calledOnceWith(this.subscription.admin_id) + ctx.FeaturesUpdater.promises.scheduleRefreshFeatures + ).to.have.been.calledOnceWith(ctx.subscription.admin_id) }) - it('should call upgrades feature for group subscription from admin_id and member_ids', async function () { - this.subscription = { + it('should call upgrades feature for group subscription from admin_id and member_ids', async function (ctx) { + ctx.subscription = { _id: new ObjectId().toString(), mock: 'subscription', admin_id: new ObjectId(), member_ids: [new ObjectId(), new ObjectId(), new ObjectId()], } - await this.SubscriptionUpdater.promises.scheduleRefreshFeatures( - this.subscription + await ctx.SubscriptionUpdater.promises.scheduleRefreshFeatures( + ctx.subscription ) expect( - this.FeaturesUpdater.promises.scheduleRefreshFeatures.callCount + ctx.FeaturesUpdater.promises.scheduleRefreshFeatures.callCount ).to.equal(4) }) }) describe('setRestorePoint', function () { - it('should set the restore point with the given plan code and add-ons', async function () { + it('should set the restore point with the given plan code and add-ons', async function (ctx) { const subscriptionId = new ObjectId() const planCode = 'gold-plan' const addOns = [ @@ -916,7 +958,7 @@ describe('SubscriptionUpdater', function () { ] const consumed = false - await this.SubscriptionUpdater.promises.setRestorePoint( + await ctx.SubscriptionUpdater.promises.setRestorePoint( subscriptionId, planCode, addOns, @@ -924,7 +966,7 @@ describe('SubscriptionUpdater', function () { ) sinon.assert.calledWith( - this.SubscriptionModel.updateOne, + ctx.SubscriptionModel.updateOne, { _id: subscriptionId }, { $set: { @@ -935,11 +977,11 @@ describe('SubscriptionUpdater', function () { ) }) - it('should increment revertedDueToFailedPayment if consumed is true', async function () { + it('should increment revertedDueToFailedPayment if consumed is true', async function (ctx) { const consumed = true const subscriptionId = new ObjectId() - await this.SubscriptionUpdater.promises.setRestorePoint( + await ctx.SubscriptionUpdater.promises.setRestorePoint( subscriptionId, null, null, @@ -947,7 +989,7 @@ describe('SubscriptionUpdater', function () { ) sinon.assert.calledWith( - this.SubscriptionModel.updateOne, + ctx.SubscriptionModel.updateOne, { _id: subscriptionId }, { $set: { @@ -961,14 +1003,14 @@ describe('SubscriptionUpdater', function () { }) describe('setSubscriptionWasReverted', function () { - it('should clear the restore point and mark the subscription as reverted', async function () { + it('should clear the restore point and mark the subscription as reverted', async function (ctx) { const subscriptionId = new ObjectId().toString() - await this.SubscriptionUpdater.promises.setSubscriptionWasReverted( + await ctx.SubscriptionUpdater.promises.setSubscriptionWasReverted( subscriptionId ) - this.SubscriptionModel.updateOne.should.have.been.calledWith( + ctx.SubscriptionModel.updateOne.should.have.been.calledWith( { _id: subscriptionId }, { $set: { @@ -982,13 +1024,13 @@ describe('SubscriptionUpdater', function () { }) describe('voidRestorePoint', function () { - it('should clear the restore point without marking the subscription as reverted', async function () { + it('should clear the restore point without marking the subscription as reverted', async function (ctx) { const subscriptionId = new ObjectId().toString() - await this.SubscriptionUpdater.promises.voidRestorePoint(subscriptionId) + await ctx.SubscriptionUpdater.promises.voidRestorePoint(subscriptionId) sinon.assert.calledWith( - this.SubscriptionModel.updateOne, + ctx.SubscriptionModel.updateOne, { _id: subscriptionId }, { $set: { diff --git a/services/web/test/unit/src/Subscription/UserFeaturesUpdater.test.mjs b/services/web/test/unit/src/Subscription/UserFeaturesUpdater.test.mjs index 0f0b15b7e8..debf6c3718 100644 --- a/services/web/test/unit/src/Subscription/UserFeaturesUpdater.test.mjs +++ b/services/web/test/unit/src/Subscription/UserFeaturesUpdater.test.mjs @@ -1,12 +1,11 @@ -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const sinon = require('sinon') +import { vi, expect } from 'vitest' +import sinon from 'sinon' const modulePath = '../../../../app/src/Features/Subscription/UserFeaturesUpdater' describe('UserFeaturesUpdater', function () { - beforeEach(function () { - this.features = { + beforeEach(async function (ctx) { + ctx.features = { collaborators: 6, dropbox: true, github: true, @@ -22,23 +21,25 @@ describe('UserFeaturesUpdater', function () { mendeley: true, symbolPalette: true, } - this.User = { + ctx.User = { findByIdAndUpdate: sinon.stub().returns({ - exec: sinon.stub().resolves({ features: this.features }), + exec: sinon.stub().resolves({ features: ctx.features }), }), } - this.UserFeaturesUpdater = SandboxedModule.require(modulePath, { - requires: { - '../../models/User': { - User: this.User, - }, - '@overleaf/settings': (this.Settings = {}), - }, - }) + + vi.doMock('../../../../app/src/models/User', () => ({ + User: ctx.User, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.Settings = {}), + })) + + ctx.UserFeaturesUpdater = (await import(modulePath)).default }) describe('updateFeatures', function () { - it('should send the users features', async function () { + it('should send the users features', async function (ctx) { const userId = '5208dd34438842e2db000005' const update = { versioning: true, @@ -46,9 +47,9 @@ describe('UserFeaturesUpdater', function () { } const { features } = - await this.UserFeaturesUpdater.promises.updateFeatures(userId, update) + await ctx.UserFeaturesUpdater.promises.updateFeatures(userId, update) - const updateArgs = this.User.findByIdAndUpdate.lastCall.args + const updateArgs = ctx.User.findByIdAndUpdate.lastCall.args expect(updateArgs[0]).to.deep.equal(userId) expect(Object.keys(updateArgs[1]).length).to.equal(3) expect(updateArgs[1]['features.versioning']).to.equal(update.versioning) @@ -60,16 +61,16 @@ describe('UserFeaturesUpdater', function () { expect(updateArgs[1].featuresEpoch).to.be.undefined }) - it('should set the featuresEpoch when present', async function () { + it('should set the featuresEpoch when present', async function (ctx) { const userId = '5208dd34438842e2db000005' const update = { versioning: true, } - this.Settings.featuresEpoch = 'epoch-1' + ctx.Settings.featuresEpoch = 'epoch-1' const { features } = - await this.UserFeaturesUpdater.promises.updateFeatures(userId, update) + await ctx.UserFeaturesUpdater.promises.updateFeatures(userId, update) - const updateArgs = this.User.findByIdAndUpdate.lastCall.args + const updateArgs = ctx.User.findByIdAndUpdate.lastCall.args expect(updateArgs[0]).to.deep.equal(userId) expect(Object.keys(updateArgs[1]).length).to.equal(3) expect(updateArgs[1]['features.versioning']).to.equal(update.versioning) @@ -80,13 +81,13 @@ describe('UserFeaturesUpdater', function () { }) describe('overrideFeatures', function () { - it('should send the users features', async function () { + it('should send the users features', async function (ctx) { const userId = '5208dd34438842e2db000005' - const update = Object.assign({}, { mendeley: !this.features.mendeley }) + const update = Object.assign({}, { mendeley: !ctx.features.mendeley }) const featuresChanged = - await this.UserFeaturesUpdater.promises.overrideFeatures(userId, update) + await ctx.UserFeaturesUpdater.promises.overrideFeatures(userId, update) - const updateArgs = this.User.findByIdAndUpdate.lastCall.args + const updateArgs = ctx.User.findByIdAndUpdate.lastCall.args expect(updateArgs[0]).to.equal(userId) expect(Object.keys(updateArgs[1]).length).to.equal(2) expect(updateArgs[1].features).to.deep.equal(update) diff --git a/services/web/test/unit/src/Subscription/V1SusbcriptionManager.test.mjs b/services/web/test/unit/src/Subscription/V1SusbcriptionManager.test.mjs index 4e0cece595..7c2e1f821c 100644 --- a/services/web/test/unit/src/Subscription/V1SusbcriptionManager.test.mjs +++ b/services/web/test/unit/src/Subscription/V1SusbcriptionManager.test.mjs @@ -1,80 +1,89 @@ -const SandboxedModule = require('sandboxed-module') -const path = require('path') +import { vi, expect } from 'vitest' +import path from 'path' +import sinon from 'sinon' const modulePath = path.join( - __dirname, + import.meta.dirname, '../../../../app/src/Features/Subscription/V1SubscriptionManager' ) -const sinon = require('sinon') -const { expect } = require('chai') describe('V1SubscriptionManager', function () { - beforeEach(function () { - this.V1SubscriptionManager = SandboxedModule.require(modulePath, { - requires: { - '../User/UserGetter': (this.UserGetter = {}), - '@overleaf/settings': (this.Settings = { - apis: { - v1: { - host: (this.host = 'http://overleaf.example.com'), - url: 'v1.url', - }, + beforeEach(async function (ctx) { + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: (ctx.UserGetter = {}), + })) + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.Settings = { + apis: { + v1: { + host: (ctx.host = 'http://overleaf.example.com'), + url: 'v1.url', }, - v1GrandfatheredFeaturesUidCutoff: 10, - v1GrandfatheredFeatures: { - github: true, - mendeley: true, - }, - }), - requestretry: (this.request = sinon.stub()), - }, - }) - this.userId = 'abcd' - this.v1UserId = 42 - this.user = { - _id: this.userId, + }, + v1GrandfatheredFeaturesUidCutoff: 10, + v1GrandfatheredFeatures: { + github: true, + mendeley: true, + }, + }), + })) + + vi.doMock('requestretry', () => ({ + default: (ctx.request = sinon.stub()), + })) + + ctx.V1SubscriptionManager = (await import(modulePath)).default + ctx.userId = 'abcd' + ctx.v1UserId = 42 + ctx.user = { + _id: ctx.userId, email: 'user@example.com', overleaf: { - id: this.v1UserId, + id: ctx.v1UserId, }, } }) describe('getGrandfatheredFeaturesForV1User', function () { describe('when the user ID is greater than the cutoff', function () { - it('should return an empty feature set', function (done) { - expect( - this.V1SubscriptionManager.getGrandfatheredFeaturesForV1User(100) - ).to.eql({}) - done() + it('should return an empty feature set', async function (ctx) { + await new Promise(resolve => { + expect( + ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User(100) + ).to.eql({}) + resolve() + }) }) }) describe('when the user ID is less than the cutoff', function () { - it('should return a feature set with grandfathered properties for github and mendeley', function (done) { - expect( - this.V1SubscriptionManager.getGrandfatheredFeaturesForV1User(1) - ).to.eql({ - github: true, - mendeley: true, + it('should return a feature set with grandfathered properties for github and mendeley', async function (ctx) { + await new Promise(resolve => { + expect( + ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User(1) + ).to.eql({ + github: true, + mendeley: true, + }) + resolve() }) - done() }) }) }) describe('_v1Request', function () { - beforeEach(function () { - this.UserGetter.getUser = sinon.stub().yields(null, this.user) + beforeEach(function (ctx) { + ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user) }) describe('when v1IdForUser produces an error', function () { - beforeEach(function () { - this.V1SubscriptionManager.v1IdForUser = sinon + beforeEach(function (ctx) { + ctx.V1SubscriptionManager.v1IdForUser = sinon .stub() .yields(new Error('woops')) - this.call = cb => { - this.V1SubscriptionManager._v1Request( - this.user_id, + ctx.call = cb => { + ctx.V1SubscriptionManager._v1Request( + ctx.user_id, { url() { return '/foo' @@ -85,27 +94,31 @@ describe('V1SubscriptionManager', function () { } }) - it('should not call request', function (done) { - this.call(() => { - expect(this.request.callCount).to.equal(0) - done() + it('should not call request', async function (ctx) { + await new Promise(resolve => { + ctx.call(() => { + expect(ctx.request.callCount).to.equal(0) + resolve() + }) }) }) - it('should produce an error', function (done) { - this.call((err, planCode) => { - expect(err).to.exist - done() + it('should produce an error', async function (ctx) { + await new Promise(resolve => { + ctx.call((err, planCode) => { + expect(err).to.exist + resolve() + }) }) }) }) describe('when v1IdForUser does not find a user', function () { - beforeEach(function () { - this.V1SubscriptionManager.v1IdForUser = sinon.stub().yields(null, null) - this.call = cb => { - this.V1SubscriptionManager._v1Request( - this.user_id, + beforeEach(function (ctx) { + ctx.V1SubscriptionManager.v1IdForUser = sinon.stub().yields(null, null) + ctx.call = cb => { + ctx.V1SubscriptionManager._v1Request( + ctx.user_id, { url() { return '/foo' @@ -116,28 +129,32 @@ describe('V1SubscriptionManager', function () { } }) - it('should not call request', function (done) { - this.call((err, planCode) => { - if (err) return done(err) - expect(this.request.callCount).to.equal(0) - done() + it('should not call request', async function (ctx) { + await new Promise(resolve => { + ctx.call((err, planCode) => { + if (err) return resolve(err) + expect(ctx.request.callCount).to.equal(0) + resolve() + }) }) }) - it('should not error', function (done) { - this.call(err => { - expect(err).to.not.exist - done() + it('should not error', async function (ctx) { + await new Promise(resolve => { + ctx.call(err => { + expect(err).to.not.exist + resolve() + }) }) }) }) describe('when the request to v1 fails', function () { - beforeEach(function () { - this.request.yields(new Error('woops')) - this.call = cb => { - this.V1SubscriptionManager._v1Request( - this.user_id, + beforeEach(function (ctx) { + ctx.request.yields(new Error('woops')) + ctx.call = cb => { + ctx.V1SubscriptionManager._v1Request( + ctx.user_id, { url() { return '/foo' @@ -148,23 +165,25 @@ describe('V1SubscriptionManager', function () { } }) - it('should produce an error', function (done) { - this.call(err => { - expect(err).to.exist - done() + it('should produce an error', async function (ctx) { + await new Promise(resolve => { + ctx.call(err => { + expect(err).to.exist + resolve() + }) }) }) }) describe('when the call succeeds', function () { - beforeEach(function () { - this.V1SubscriptionManager.v1IdForUser = sinon + beforeEach(function (ctx) { + ctx.V1SubscriptionManager.v1IdForUser = sinon .stub() - .yields(null, this.v1UserId) - this.request.yields(null, { statusCode: 200 }, '{}') - this.call = cb => { - this.V1SubscriptionManager._v1Request( - this.user_id, + .yields(null, ctx.v1UserId) + ctx.request.yields(null, { statusCode: 200 }, '{}') + ctx.call = cb => { + ctx.V1SubscriptionManager._v1Request( + ctx.user_id, { method: 'GET', url() { @@ -176,52 +195,60 @@ describe('V1SubscriptionManager', function () { } }) - it('should not produce an error', function (done) { - this.call((err, body, v1Id) => { - expect(err).not.to.exist - done() + it('should not produce an error', async function (ctx) { + await new Promise(resolve => { + ctx.call((err, body, v1Id) => { + expect(err).not.to.exist + resolve() + }) }) }) - it('should have supplied retry options to request', function (done) { - this.call((err, body, v1Id) => { - if (err) return done(err) - const requestOptions = this.request.lastCall.args[0] - expect(requestOptions.url).to.equal('/foo') - expect(requestOptions.maxAttempts).to.exist - expect(requestOptions.maxAttempts > 0).to.be.true - expect(requestOptions.retryDelay).to.exist - expect(requestOptions.retryDelay > 0).to.be.true - done() + it('should have supplied retry options to request', async function (ctx) { + await new Promise(resolve => { + ctx.call((err, body, v1Id) => { + if (err) return resolve(err) + const requestOptions = ctx.request.lastCall.args[0] + expect(requestOptions.url).to.equal('/foo') + expect(requestOptions.maxAttempts).to.exist + expect(requestOptions.maxAttempts > 0).to.be.true + expect(requestOptions.retryDelay).to.exist + expect(requestOptions.retryDelay > 0).to.be.true + resolve() + }) }) }) - it('should return the v1 user id', function (done) { - this.call((err, body, v1Id) => { - if (err) return done(err) - expect(v1Id).to.equal(this.v1UserId) - done() + it('should return the v1 user id', async function (ctx) { + await new Promise(resolve => { + ctx.call((err, body, v1Id) => { + if (err) return resolve(err) + expect(v1Id).to.equal(ctx.v1UserId) + resolve() + }) }) }) - it('should return the http response body', function (done) { - this.call((err, body, v1Id) => { - if (err) return done(err) - expect(body).to.equal('{}') - done() + it('should return the http response body', async function (ctx) { + await new Promise(resolve => { + ctx.call((err, body, v1Id) => { + if (err) return resolve(err) + expect(body).to.equal('{}') + resolve() + }) }) }) }) describe('when the call returns an http error status code', function () { - beforeEach(function () { - this.V1SubscriptionManager.v1IdForUser = sinon + beforeEach(function (ctx) { + ctx.V1SubscriptionManager.v1IdForUser = sinon .stub() - .yields(null, this.v1UserId) - this.request.yields(null, { statusCode: 500 }, '{}') - this.call = cb => { - this.V1SubscriptionManager._v1Request( - this.user_id, + .yields(null, ctx.v1UserId) + ctx.request.yields(null, { statusCode: 500 }, '{}') + ctx.call = cb => { + ctx.V1SubscriptionManager._v1Request( + ctx.user_id, { url() { return '/foo' @@ -232,23 +259,25 @@ describe('V1SubscriptionManager', function () { } }) - it('should produce an error', function (done) { - this.call((err, body, v1Id) => { - expect(err).to.exist - done() + it('should produce an error', async function (ctx) { + await new Promise(resolve => { + ctx.call((err, body, v1Id) => { + expect(err).to.exist + resolve() + }) }) }) }) describe('when the call returns an http not-found status code', function () { - beforeEach(function () { - this.V1SubscriptionManager.v1IdForUser = sinon + beforeEach(function (ctx) { + ctx.V1SubscriptionManager.v1IdForUser = sinon .stub() - .yields(null, this.v1UserId) - this.request.yields(null, { statusCode: 404 }, '{}') - this.call = cb => { - this.V1SubscriptionManager._v1Request( - this.user_id, + .yields(null, ctx.v1UserId) + ctx.request.yields(null, { statusCode: 404 }, '{}') + ctx.call = cb => { + ctx.V1SubscriptionManager._v1Request( + ctx.user_id, { url() { return '/foo' @@ -259,72 +288,82 @@ describe('V1SubscriptionManager', function () { } }) - it('should produce an not-found error', function (done) { - this.call((err, body, v1Id) => { - expect(err).to.exist - expect(err.name).to.equal('NotFoundError') - done() + it('should produce an not-found error', async function (ctx) { + await new Promise(resolve => { + ctx.call((err, body, v1Id) => { + expect(err).to.exist + expect(err.name).to.equal('NotFoundError') + resolve() + }) }) }) }) }) describe('v1IdForUser', function () { - beforeEach(function () { - this.UserGetter.getUser = sinon.stub().yields(null, this.user) + beforeEach(function (ctx) { + ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user) }) describe('when getUser produces an error', function () { - beforeEach(function () { - this.UserGetter.getUser = sinon.stub().yields(new Error('woops')) - this.call = cb => { - this.V1SubscriptionManager.v1IdForUser(this.user_id, cb) + beforeEach(function (ctx) { + ctx.UserGetter.getUser = sinon.stub().yields(new Error('woops')) + ctx.call = cb => { + ctx.V1SubscriptionManager.v1IdForUser(ctx.user_id, cb) } }) - it('should produce an error', function (done) { - this.call(err => { - expect(err).to.exist - done() + it('should produce an error', async function (ctx) { + await new Promise(resolve => { + ctx.call(err => { + expect(err).to.exist + resolve() + }) }) }) }) describe('when getUser does not find a user', function () { - beforeEach(function () { - this.UserGetter.getUser = sinon.stub().yields(null, null) - this.call = cb => { - this.V1SubscriptionManager.v1IdForUser(this.user_id, cb) + beforeEach(function (ctx) { + ctx.UserGetter.getUser = sinon.stub().yields(null, null) + ctx.call = cb => { + ctx.V1SubscriptionManager.v1IdForUser(ctx.user_id, cb) } }) - it('should not error', function (done) { - this.call((err, userId) => { - expect(err).to.not.exist - done() + it('should not error', async function (ctx) { + await new Promise(resolve => { + ctx.call((err, userId) => { + expect(err).to.not.exist + resolve() + }) }) }) }) describe('when it works', function () { - beforeEach(function () { - this.call = cb => { - this.V1SubscriptionManager.v1IdForUser(this.user_id, cb) + beforeEach(function (ctx) { + ctx.call = cb => { + ctx.V1SubscriptionManager.v1IdForUser(ctx.user_id, cb) } }) - it('should not error', function (done) { - this.call((err, userId) => { - expect(err).to.not.exist - done() + it('should not error', async function (ctx) { + await new Promise(resolve => { + ctx.call((err, userId) => { + expect(err).to.not.exist + resolve() + }) }) }) - it('should return the v1 user id', function (done) { - this.call((err, userId) => { - if (err) return done(err) - expect(userId).to.eql(42) - done() + it('should return the v1 user id', async function (ctx) { + await new Promise(resolve => { + ctx.call((err, userId) => { + if (err) return resolve(err) + expect(userId).to.eql(42) + resolve() + }) }) }) }) diff --git a/services/web/test/unit/src/Tags/TagsHandler.test.mjs b/services/web/test/unit/src/Tags/TagsHandler.test.mjs index 0ea36d7e17..b1f2fdb545 100644 --- a/services/web/test/unit/src/Tags/TagsHandler.test.mjs +++ b/services/web/test/unit/src/Tags/TagsHandler.test.mjs @@ -1,92 +1,91 @@ -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const sinon = require('sinon') -const { Tag } = require('../helpers/models/Tag') -const { ObjectId } = require('mongodb-legacy') -const modulePath = require('path').join( - __dirname, - '../../../../app/src/Features/Tags/TagsHandler.js' +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import { Tag } from '../helpers/models/Tag.js' +import mongodb from 'mongodb-legacy' +import path from 'node:path' +const { ObjectId } = mongodb + +const modulePath = path.join( + import.meta.dirname, + '../../../../app/src/Features/Tags/TagsHandler.mjs' ) describe('TagsHandler', function () { - beforeEach(function () { - this.userId = new ObjectId().toString() - this.callback = sinon.stub() + beforeEach(async function (ctx) { + ctx.userId = new ObjectId().toString() + ctx.callback = sinon.stub() - this.tag = { user_id: this.userId, name: 'some name', color: '#3399CC' } - this.tagId = new ObjectId().toString() - this.projectId = new ObjectId().toString() - this.projectIds = [new ObjectId().toString(), new ObjectId().toString()] + ctx.tag = { user_id: ctx.userId, name: 'some name', color: '#3399CC' } + ctx.tagId = new ObjectId().toString() + ctx.projectId = new ObjectId().toString() + ctx.projectIds = [new ObjectId().toString(), new ObjectId().toString()] - this.mongodb = { ObjectId } - this.TagMock = sinon.mock(Tag) + ctx.mongodb = { ObjectId } + ctx.TagMock = sinon.mock(Tag) - this.TagsHandler = SandboxedModule.require(modulePath, { - requires: { - '../../infrastructure/mongodb': this.mongodb, - '../../models/Tag': { Tag }, - }, - }) + vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({ + default: ctx.mongodb, + })) + + vi.doMock('../../../../app/src/models/Tag', () => ({ + Tag, + })) + + ctx.TagsHandler = (await import(modulePath)).default }) describe('finding users tags', function () { - it('should find all the documents with that user id', async function () { + it('should find all the documents with that user id', async function (ctx) { const stubbedTags = [{ name: 'tag1' }, { name: 'tag2' }, { name: 'tag3' }] - this.TagMock.expects('find') + ctx.TagMock.expects('find') .once() - .withArgs({ user_id: this.userId }) + .withArgs({ user_id: ctx.userId }) .resolves(stubbedTags) - const result = await this.TagsHandler.promises.getAllTags(this.userId) - this.TagMock.verify() + const result = await ctx.TagsHandler.promises.getAllTags(ctx.userId) + ctx.TagMock.verify() expect(result).to.deep.equal(stubbedTags) }) }) describe('createTag', function () { describe('when insert succeeds', function () { - it('should call insert in mongo', async function () { - this.TagMock.expects('create') - .withArgs(this.tag) - .once() - .resolves(this.tag) - const resultTag = await this.TagsHandler.promises.createTag( - this.tag.user_id, - this.tag.name, - this.tag.color + it('should call insert in mongo', async function (ctx) { + ctx.TagMock.expects('create').withArgs(ctx.tag).once().resolves(ctx.tag) + const resultTag = await ctx.TagsHandler.promises.createTag( + ctx.tag.user_id, + ctx.tag.name, + ctx.tag.color ) - this.TagMock.verify() - expect(resultTag.user_id).to.equal(this.tag.user_id) - expect(resultTag.name).to.equal(this.tag.name) - expect(resultTag.color).to.equal(this.tag.color) + ctx.TagMock.verify() + expect(resultTag.user_id).to.equal(ctx.tag.user_id) + expect(resultTag.name).to.equal(ctx.tag.name) + expect(resultTag.color).to.equal(ctx.tag.color) }) }) describe('when truncate=true, and tag is too long', function () { - it('should truncate the tag name', async function () { + it('should truncate the tag name', async function (ctx) { // Expect the tag to end up with this truncated name - this.tag.name = 'a comically long tag that will be truncated intern' - this.TagMock.expects('create') - .withArgs(this.tag) - .once() - .resolves(this.tag) - const resultTag = await this.TagsHandler.promises.createTag( - this.tag.user_id, + ctx.tag.name = 'a comically long tag that will be truncated intern' + ctx.TagMock.expects('create').withArgs(ctx.tag).once().resolves(ctx.tag) + const resultTag = await ctx.TagsHandler.promises.createTag( + ctx.tag.user_id, // Pass this too-long name 'a comically long tag that will be truncated internally and not throw an error', - this.tag.color, + ctx.tag.color, { truncate: true } ) - expect(resultTag.name).to.equal(this.tag.name) + expect(resultTag.name).to.equal(ctx.tag.name) }) }) describe('when tag is too long', function () { - it('should throw an error', async function () { + it('should throw an error', async function (ctx) { let error try { - await this.TagsHandler.promises.createTag( - this.tag.user_id, + await ctx.TagsHandler.promises.createTag( + ctx.tag.user_id, 'this is a tag that is very very very very very very long', undefined ) @@ -100,230 +99,230 @@ describe('TagsHandler', function () { }) describe('when insert has duplicate key error error', function () { - beforeEach(function () { - this.duplicateKeyError = new Error('Duplicate') - this.duplicateKeyError.code = 11000 + beforeEach(function (ctx) { + ctx.duplicateKeyError = new Error('Duplicate') + ctx.duplicateKeyError.code = 11000 }) - it('should get tag with findOne and return that tag', async function () { - this.TagMock.expects('create') - .withArgs(this.tag) + it('should get tag with findOne and return that tag', async function (ctx) { + ctx.TagMock.expects('create') + .withArgs(ctx.tag) .once() - .throws(this.duplicateKeyError) - this.TagMock.expects('findOne') - .withArgs({ user_id: this.tag.user_id, name: this.tag.name }) + .throws(ctx.duplicateKeyError) + ctx.TagMock.expects('findOne') + .withArgs({ user_id: ctx.tag.user_id, name: ctx.tag.name }) .once() - .resolves(this.tag) - const resultTag = await this.TagsHandler.promises.createTag( - this.tag.user_id, - this.tag.name, - this.tag.color + .resolves(ctx.tag) + const resultTag = await ctx.TagsHandler.promises.createTag( + ctx.tag.user_id, + ctx.tag.name, + ctx.tag.color ) - this.TagMock.verify() - expect(resultTag.user_id).to.equal(this.tag.user_id) - expect(resultTag.name).to.equal(this.tag.name) - expect(resultTag.color).to.equal(this.tag.color) + ctx.TagMock.verify() + expect(resultTag.user_id).to.equal(ctx.tag.user_id) + expect(resultTag.name).to.equal(ctx.tag.name) + expect(resultTag.color).to.equal(ctx.tag.color) }) }) }) describe('addProjectToTag', function () { describe('with a valid tag_id', function () { - it('should call update in mongo', async function () { - this.TagMock.expects('findOneAndUpdate') + it('should call update in mongo', async function (ctx) { + ctx.TagMock.expects('findOneAndUpdate') .once() .withArgs( - { _id: this.tagId, user_id: this.userId }, - { $addToSet: { project_ids: this.projectId } } + { _id: ctx.tagId, user_id: ctx.userId }, + { $addToSet: { project_ids: ctx.projectId } } ) .resolves() - await this.TagsHandler.promises.addProjectToTag( - this.userId, - this.tagId, - this.projectId + await ctx.TagsHandler.promises.addProjectToTag( + ctx.userId, + ctx.tagId, + ctx.projectId ) - this.TagMock.verify() + ctx.TagMock.verify() }) }) }) describe('addProjectsToTag', function () { describe('with a valid tag_id', function () { - it('should call update in mongo', async function () { - this.TagMock.expects('findOneAndUpdate') + it('should call update in mongo', async function (ctx) { + ctx.TagMock.expects('findOneAndUpdate') .once() .withArgs( - { _id: this.tagId, user_id: this.userId }, - { $addToSet: { project_ids: { $each: this.projectIds } } } + { _id: ctx.tagId, user_id: ctx.userId }, + { $addToSet: { project_ids: { $each: ctx.projectIds } } } ) .resolves() - await this.TagsHandler.promises.addProjectsToTag( - this.userId, - this.tagId, - this.projectIds + await ctx.TagsHandler.promises.addProjectsToTag( + ctx.userId, + ctx.tagId, + ctx.projectIds ) - this.TagMock.verify() + ctx.TagMock.verify() }) }) }) describe('addProjectToTagName', function () { - it('should call update in mongo', async function () { - this.TagMock.expects('updateOne') + it('should call update in mongo', async function (ctx) { + ctx.TagMock.expects('updateOne') .once() .withArgs( - { name: this.tag.name, user_id: this.tag.userId }, - { $addToSet: { project_ids: this.projectId } }, + { name: ctx.tag.name, user_id: ctx.tag.userId }, + { $addToSet: { project_ids: ctx.projectId } }, { upsert: true } ) .resolves() - await this.TagsHandler.promises.addProjectToTagName( - this.tag.userId, - this.tag.name, - this.projectId + await ctx.TagsHandler.promises.addProjectToTagName( + ctx.tag.userId, + ctx.tag.name, + ctx.projectId ) - this.TagMock.verify() + ctx.TagMock.verify() }) }) describe('removeProjectFromTag', function () { describe('with a valid tag_id', function () { - it('should call update in mongo', async function () { - this.TagMock.expects('updateOne') + it('should call update in mongo', async function (ctx) { + ctx.TagMock.expects('updateOne') .once() .withArgs( { - _id: this.tagId, - user_id: this.userId, + _id: ctx.tagId, + user_id: ctx.userId, }, { - $pull: { project_ids: this.projectId }, + $pull: { project_ids: ctx.projectId }, } ) .resolves() - await this.TagsHandler.promises.removeProjectFromTag( - this.userId, - this.tagId, - this.projectId + await ctx.TagsHandler.promises.removeProjectFromTag( + ctx.userId, + ctx.tagId, + ctx.projectId ) - this.TagMock.verify() + ctx.TagMock.verify() }) }) }) describe('removeProjectsFromTag', function () { describe('with a valid tag_id', function () { - it('should call update in mongo', async function () { - this.TagMock.expects('updateOne') + it('should call update in mongo', async function (ctx) { + ctx.TagMock.expects('updateOne') .once() .withArgs( { - _id: this.tagId, - user_id: this.userId, + _id: ctx.tagId, + user_id: ctx.userId, }, { - $pullAll: { project_ids: this.projectIds }, + $pullAll: { project_ids: ctx.projectIds }, } ) .resolves() - await this.TagsHandler.promises.removeProjectsFromTag( - this.userId, - this.tagId, - this.projectIds + await ctx.TagsHandler.promises.removeProjectsFromTag( + ctx.userId, + ctx.tagId, + ctx.projectIds ) - this.TagMock.verify() + ctx.TagMock.verify() }) }) }) describe('removeProjectFromAllTags', function () { - it('should pull the project id from the tag', async function () { - this.TagMock.expects('updateMany') + it('should pull the project id from the tag', async function (ctx) { + ctx.TagMock.expects('updateMany') .once() .withArgs( { - user_id: this.userId, + user_id: ctx.userId, }, { - $pull: { project_ids: this.projectId }, + $pull: { project_ids: ctx.projectId }, } ) .resolves() - await this.TagsHandler.promises.removeProjectFromAllTags( - this.userId, - this.projectId + await ctx.TagsHandler.promises.removeProjectFromAllTags( + ctx.userId, + ctx.projectId ) - this.TagMock.verify() + ctx.TagMock.verify() }) }) describe('addProjectToTags', function () { - it('should add the project id to each tag', async function () { + it('should add the project id to each tag', async function (ctx) { const tagIds = [] - this.TagMock.expects('updateMany') + ctx.TagMock.expects('updateMany') .once() .withArgs( { - user_id: this.userId, + user_id: ctx.userId, _id: { $in: tagIds }, }, { - $addToSet: { project_ids: this.projectId }, + $addToSet: { project_ids: ctx.projectId }, } ) .resolves() - await this.TagsHandler.promises.addProjectToTags( - this.userId, + await ctx.TagsHandler.promises.addProjectToTags( + ctx.userId, tagIds, - this.projectId + ctx.projectId ) - this.TagMock.verify() + ctx.TagMock.verify() }) }) describe('deleteTag', function () { describe('with a valid tag_id', function () { - it('should call remove in mongo', async function () { - this.TagMock.expects('deleteOne') + it('should call remove in mongo', async function (ctx) { + ctx.TagMock.expects('deleteOne') .once() - .withArgs({ _id: this.tagId, user_id: this.userId }) + .withArgs({ _id: ctx.tagId, user_id: ctx.userId }) .resolves() - await this.TagsHandler.promises.deleteTag(this.userId, this.tagId) - this.TagMock.verify() + await ctx.TagsHandler.promises.deleteTag(ctx.userId, ctx.tagId) + ctx.TagMock.verify() }) }) }) describe('renameTag', function () { describe('with a valid tag_id', function () { - it('should call remove in mongo', async function () { - this.newName = 'new name' - this.TagMock.expects('updateOne') + it('should call remove in mongo', async function (ctx) { + ctx.newName = 'new name' + ctx.TagMock.expects('updateOne') .once() .withArgs( - { _id: this.tagId, user_id: this.userId }, - { $set: { name: this.newName } } + { _id: ctx.tagId, user_id: ctx.userId }, + { $set: { name: ctx.newName } } ) .resolves() - await this.TagsHandler.promises.renameTag( - this.userId, - this.tagId, - this.newName + await ctx.TagsHandler.promises.renameTag( + ctx.userId, + ctx.tagId, + ctx.newName ) - this.TagMock.verify() + ctx.TagMock.verify() }) }) describe('when tag is too long', function () { - it('should throw an error', async function () { + it('should throw an error', async function (ctx) { let error try { - await this.TagsHandler.promises.renameTag( - this.userId, - this.tagId, + await ctx.TagsHandler.promises.renameTag( + ctx.userId, + ctx.tagId, 'this is a tag that is very very very very very very long' ) } catch (err) { diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs b/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs index 6da7a0570e..7ac267b64f 100644 --- a/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs +++ b/services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs @@ -245,17 +245,15 @@ describe('TokenAccessController', function () { () => ({ default: ctx.AdminAuthorizationHelper }) ) - vi.doMock( - '../../../../app/src/Features/Helpers/UrlHelper', - () => - (ctx.UrlHelper = { - getSafeAdminDomainRedirect: sinon - .stub() - .callsFake( - path => `${ctx.Settings.adminUrl}${getSafeRedirectPath(path)}` - ), - }) - ) + vi.doMock('../../../../app/src/Features/Helpers/UrlHelper', () => ({ + default: (ctx.UrlHelper = { + getSafeAdminDomainRedirect: sinon + .stub() + .callsFake( + path => `${ctx.Settings.adminUrl}${getSafeRedirectPath(path)}` + ), + }), + })) vi.doMock( '../../../../app/src/Features/Analytics/AnalyticsManager', diff --git a/services/web/test/unit/src/TokenAccess/TokenAccessHandler.test.mjs b/services/web/test/unit/src/TokenAccess/TokenAccessHandler.test.mjs index 4843096327..ffb8127725 100644 --- a/services/web/test/unit/src/TokenAccess/TokenAccessHandler.test.mjs +++ b/services/web/test/unit/src/TokenAccess/TokenAccessHandler.test.mjs @@ -1,54 +1,89 @@ -const SandboxedModule = require('sandboxed-module') -const path = require('path') -const sinon = require('sinon') +import { vi, expect } from 'vitest' +import path from 'path' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' +import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.js' + const modulePath = path.join( - __dirname, + import.meta.dirname, '../../../../app/src/Features/TokenAccess/TokenAccessHandler' ) -const { expect } = require('chai') -const { ObjectId } = require('mongodb-legacy') -const PrivilegeLevels = require('../../../../app/src/Features/Authorization/PrivilegeLevels') + +vi.mock('node:crypto', async () => { + const originalModule = await vi.importActual('node:crypto') + return { + default: { + ...originalModule, + timingSafeEqual: vi.fn(originalModule.default.timingSafeEqual), + }, + } +}) + +const { ObjectId } = mongodb describe('TokenAccessHandler', function () { - beforeEach(function () { - this.token = 'abcdefabcdef' - this.projectId = new ObjectId() - this.project = { - _id: this.projectId, + beforeEach(async function (ctx) { + ctx.token = 'abcdefabcdef' + ctx.projectId = new ObjectId() + ctx.project = { + _id: ctx.projectId, publicAccesLevel: 'tokenBased', owner_ref: new ObjectId(), } - this.userId = new ObjectId() - this.req = {} - this.TokenAccessHandler = SandboxedModule.require(modulePath, { - requires: { - 'mongodb-legacy': { ObjectId }, - '../../models/Project': { Project: (this.Project = {}) }, - '@overleaf/metrics': (this.Metrics = { inc: sinon.stub() }), - '@overleaf/settings': (this.settings = { disableLinkSharing: false }), - '../V1/V1Api': (this.V1Api = { - promises: { - request: sinon.stub(), - }, - }), - crypto: (this.Crypto = require('crypto')), - '../Analytics/AnalyticsManager': (this.Analytics = { + ctx.userId = new ObjectId() + ctx.req = {} + + vi.doMock('mongodb-legacy', () => ({ + default: { ObjectId }, + })) + + vi.doMock('../../../../app/src/models/Project', () => ({ + Project: (ctx.Project = {}), + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: (ctx.Metrics = { inc: sinon.stub() }), + })) + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = { disableLinkSharing: false }), + })) + + vi.doMock('../../../../app/src/Features/V1/V1Api', () => ({ + default: (ctx.V1Api = { + promises: { + request: sinon.stub(), + }, + }), + })) + + ctx.Crypto = (await vi.importMock('node:crypto')).default + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: (ctx.Analytics = { recordEventForUserInBackground: sinon.stub(), }), - '../../infrastructure/Features': (this.Features = {}), - }, - }) + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Features', () => ({ + default: (ctx.Features = {}), + })) + + ctx.TokenAccessHandler = (await import(modulePath)).default }) describe('when link sharing is enabled', function () { - beforeEach(function () { - this.Features.hasFeature = sinon + beforeEach(function (ctx) { + ctx.Features.hasFeature = sinon .stub() .withArgs('link-sharing') .returns(true) }) describe('getTokenType', function () { - it('should determine tokens correctly', function () { + it('should determine tokens correctly', function (ctx) { const specs = { abcdefabcdef: 'readOnly', aaaaaabbbbbb: 'readOnly', @@ -58,7 +93,7 @@ describe('TokenAccessHandler', function () { abc123def: null, } for (const token of Object.keys(specs)) { - expect(this.TokenAccessHandler.getTokenType(token)).to.equal( + expect(ctx.TokenAccessHandler.getTokenType(token)).to.equal( specs[token] ) } @@ -66,102 +101,99 @@ describe('TokenAccessHandler', function () { }) describe('getProjectByReadOnlyToken', function () { - beforeEach(function () { - this.token = 'abcdefabcdef' - this.Project.findOne = sinon.stub().returns({ - exec: sinon.stub().resolves(this.project), + beforeEach(function (ctx) { + ctx.token = 'abcdefabcdef' + ctx.Project.findOne = sinon.stub().returns({ + exec: sinon.stub().resolves(ctx.project), }) }) - it('should get the project', async function () { + it('should get the project', async function (ctx) { const project = - await this.TokenAccessHandler.promises.getProjectByReadOnlyToken( - this.token + await ctx.TokenAccessHandler.promises.getProjectByReadOnlyToken( + ctx.token ) expect(project).to.exist - expect(this.Project.findOne.callCount).to.equal(1) + expect(ctx.Project.findOne.callCount).to.equal(1) }) }) describe('getProjectByReadAndWriteToken', function () { - beforeEach(function () { - sinon.spy(this.Crypto, 'timingSafeEqual') - this.token = '1234abcdefabcdef' - this.project.tokens = { - readAndWrite: this.token, + beforeEach(function (ctx) { + ctx.token = '1234abcdefabcdef' + ctx.project.tokens = { + readAndWrite: ctx.token, readAndWritePrefix: '1234', } - this.Project.findOne = sinon.stub().returns({ - exec: sinon.stub().resolves(this.project), + ctx.Project.findOne = sinon.stub().returns({ + exec: sinon.stub().resolves(ctx.project), }) }) - afterEach(function () { - this.Crypto.timingSafeEqual.restore() - }) - - it('should get the project and do timing-safe comparison', async function () { + it('should get the project and do timing-safe comparison', async function (ctx) { const project = - await this.TokenAccessHandler.promises.getProjectByReadAndWriteToken( - this.token + await ctx.TokenAccessHandler.promises.getProjectByReadAndWriteToken( + ctx.token ) expect(project).to.exist - expect(this.Crypto.timingSafeEqual.callCount).to.equal(1) + expect(ctx.Crypto.timingSafeEqual).toHaveBeenCalledTimes(1) expect( - this.Crypto.timingSafeEqual.calledWith(Buffer.from(this.token)) - ).to.equal(true) - expect(this.Project.findOne.callCount).to.equal(1) + ctx.Crypto.timingSafeEqual.mock.calls[0][0].equals( + Buffer.from(ctx.token) + ) + ).toBeTruthy() + expect(ctx.Project.findOne.callCount).to.equal(1) }) }) describe('addReadOnlyUserToProject', function () { - beforeEach(function () { - this.Project.updateOne = sinon.stub().returns({ + beforeEach(function (ctx) { + ctx.Project.updateOne = sinon.stub().returns({ exec: sinon.stub().resolves(null), }) }) - it('should call Project.updateOne', async function () { - await this.TokenAccessHandler.promises.addReadOnlyUserToProject( - this.userId, - this.projectId, - this.project.owner_ref + it('should call Project.updateOne', async function (ctx) { + await ctx.TokenAccessHandler.promises.addReadOnlyUserToProject( + ctx.userId, + ctx.projectId, + ctx.project.owner_ref ) - expect(this.Project.updateOne.callCount).to.equal(1) + expect(ctx.Project.updateOne.callCount).to.equal(1) expect( - this.Project.updateOne.calledWith({ - _id: this.projectId, + ctx.Project.updateOne.calledWith({ + _id: ctx.projectId, }) ).to.equal(true) - expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys( + expect(ctx.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys( 'tokenAccessReadOnly_refs' ) sinon.assert.calledWith( - this.Analytics.recordEventForUserInBackground, - this.userId, + ctx.Analytics.recordEventForUserInBackground, + ctx.userId, 'project-joined', { mode: 'view', role: PrivilegeLevels.READ_ONLY, - projectId: this.projectId.toString(), - ownerId: this.project.owner_ref.toString(), + projectId: ctx.projectId.toString(), + ownerId: ctx.project.owner_ref.toString(), source: 'link-sharing', } ) }) describe('when Project.updateOne produces an error', function () { - beforeEach(function () { - this.Project.updateOne = sinon + beforeEach(function (ctx) { + ctx.Project.updateOne = sinon .stub() .returns({ exec: sinon.stub().rejects(new Error('woops')) }) }) - it('should be rejected', async function () { + it('should be rejected', async function (ctx) { await expect( - this.TokenAccessHandler.promises.addReadOnlyUserToProject( - this.userId, - this.projectId + ctx.TokenAccessHandler.promises.addReadOnlyUserToProject( + ctx.userId, + ctx.projectId ) ).to.be.rejected }) @@ -169,102 +201,100 @@ describe('TokenAccessHandler', function () { }) describe('removeReadAndWriteUserFromProject', function () { - beforeEach(function () { - this.Project.updateOne = sinon + beforeEach(function (ctx) { + ctx.Project.updateOne = sinon .stub() .returns({ exec: sinon.stub().resolves(null) }) }) - it('should call Project.updateOne', async function () { - await this.TokenAccessHandler.promises.removeReadAndWriteUserFromProject( - this.userId, - this.projectId + it('should call Project.updateOne', async function (ctx) { + await ctx.TokenAccessHandler.promises.removeReadAndWriteUserFromProject( + ctx.userId, + ctx.projectId ) - expect(this.Project.updateOne.callCount).to.equal(1) + expect(ctx.Project.updateOne.callCount).to.equal(1) expect( - this.Project.updateOne.calledWith({ - _id: this.projectId, + ctx.Project.updateOne.calledWith({ + _id: ctx.projectId, }) ).to.equal(true) - expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys( + expect(ctx.Project.updateOne.lastCall.args[1].$pull).to.have.keys( 'tokenAccessReadAndWrite_refs' ) }) }) describe('moveReadAndWriteUserToReadOnly', function () { - beforeEach(function () { - this.Project.updateOne = sinon + beforeEach(function (ctx) { + ctx.Project.updateOne = sinon .stub() .returns({ exec: sinon.stub().resolves(null) }) }) - it('should call Project.updateOne', async function () { - await this.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly( - this.userId, - this.projectId + it('should call Project.updateOne', async function (ctx) { + await ctx.TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly( + ctx.userId, + ctx.projectId ) - expect(this.Project.updateOne.callCount).to.equal(1) + expect(ctx.Project.updateOne.callCount).to.equal(1) expect( - this.Project.updateOne.calledWith({ - _id: this.projectId, + ctx.Project.updateOne.calledWith({ + _id: ctx.projectId, }) ).to.equal(true) - expect(this.Project.updateOne.lastCall.args[1].$pull).to.have.keys( + expect(ctx.Project.updateOne.lastCall.args[1].$pull).to.have.keys( 'tokenAccessReadAndWrite_refs' ) - expect(this.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys( + expect(ctx.Project.updateOne.lastCall.args[1].$addToSet).to.have.keys( 'tokenAccessReadOnly_refs' ) }) }) describe('grantSessionTokenAccess', function () { - beforeEach(function () { - this.req = { session: {}, headers: {} } + beforeEach(function (ctx) { + ctx.req = { session: {}, headers: {} } }) - it('should add the token to the session', function () { - this.TokenAccessHandler.promises.grantSessionTokenAccess( - this.req, - this.projectId, - this.token + it('should add the token to the session', function (ctx) { + ctx.TokenAccessHandler.promises.grantSessionTokenAccess( + ctx.req, + ctx.projectId, + ctx.token ) expect( - this.req.session.anonTokenAccess[this.projectId.toString()] - ).to.equal(this.token) + ctx.req.session.anonTokenAccess[ctx.projectId.toString()] + ).to.equal(ctx.token) }) }) describe('validateTokenForAnonymousAccess', function () { describe('when a read-only project is found', function () { - beforeEach(function () { - this.TokenAccessHandler.getTokenType = sinon + beforeEach(function (ctx) { + ctx.TokenAccessHandler.getTokenType = sinon.stub().returns('readOnly') + ctx.TokenAccessHandler.promises.getProjectByToken = sinon .stub() - .returns('readOnly') - this.TokenAccessHandler.promises.getProjectByToken = sinon - .stub() - .resolves(this.project) + .resolves(ctx.project) }) - it('should try to find projects with both kinds of token', async function () { - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + it('should try to find projects with both kinds of token', async function (ctx) { + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount + ctx.TokenAccessHandler.promises.getProjectByToken.callCount ).to.equal(1) }) - it('should allow read-only access', async function () { + it('should allow read-only access', async function (ctx) { const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect(isValidReadAndWrite).to.equal(false) @@ -273,36 +303,36 @@ describe('TokenAccessHandler', function () { }) describe('when a read-and-write project is found', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.getTokenType = sinon + beforeEach(function (ctx) { + ctx.TokenAccessHandler.promises.getTokenType = sinon .stub() .returns('readAndWrite') - this.TokenAccessHandler.promises.getProjectByToken = sinon + ctx.TokenAccessHandler.promises.getProjectByToken = sinon .stub() - .resolves(this.project) + .resolves(ctx.project) }) describe('when Anonymous token access is not enabled', function () { - beforeEach(function () { - this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false + beforeEach(function (ctx) { + ctx.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false }) - it('should try to find projects with both kinds of token', async function () { - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + it('should try to find projects with both kinds of token', async function (ctx) { + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount + ctx.TokenAccessHandler.promises.getProjectByToken.callCount ).to.equal(1) }) - it('should not allow read-and-write access', async function () { + it('should not allow read-and-write access', async function (ctx) { const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect(isValidReadAndWrite).to.equal(false) @@ -311,26 +341,26 @@ describe('TokenAccessHandler', function () { }) describe('when anonymous token access is enabled', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true + beforeEach(function (ctx) { + ctx.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true }) - it('should try to find projects with both kinds of token', async function () { - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + it('should try to find projects with both kinds of token', async function (ctx) { + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount + ctx.TokenAccessHandler.promises.getProjectByToken.callCount ).to.equal(1) }) - it('should allow read-and-write access', async function () { + it('should allow read-and-write access', async function (ctx) { const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect(isValidReadAndWrite).to.equal(true) @@ -340,28 +370,28 @@ describe('TokenAccessHandler', function () { }) describe('when no project is found', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.getProjectByToken = sinon + beforeEach(function (ctx) { + ctx.TokenAccessHandler.promises.getProjectByToken = sinon .stub() .resolves(null) }) - it('should try to find projects with both kinds of token', async function () { - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + it('should try to find projects with both kinds of token', async function (ctx) { + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount + ctx.TokenAccessHandler.promises.getProjectByToken.callCount ).to.equal(1) }) - it('should not allow any access', async function () { + it('should not allow any access', async function (ctx) { const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect(isValidReadAndWrite).to.equal(false) @@ -370,55 +400,55 @@ describe('TokenAccessHandler', function () { }) describe('when findProject produces an error', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.getProjectByToken = sinon + beforeEach(function (ctx) { + ctx.TokenAccessHandler.promises.getProjectByToken = sinon .stub() .rejects(new Error('woops')) }) - it('should try to find projects with both kinds of token', async function () { + it('should try to find projects with both kinds of token', async function (ctx) { await expect( - this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) ).to.be.rejected expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount + ctx.TokenAccessHandler.promises.getProjectByToken.callCount ).to.equal(1) }) - it('should produce an error and not allow access', async function () { + it('should produce an error and not allow access', async function (ctx) { await expect( - this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) ).to.be.rejected }) }) describe('when project is not set to token-based access', function () { - beforeEach(function () { - this.project.publicAccesLevel = 'private' + beforeEach(function (ctx) { + ctx.project.publicAccesLevel = 'private' }) describe('for read-and-write project', function () { - beforeEach(function () { - this.TokenAccessHandler.getTokenType = sinon + beforeEach(function (ctx) { + ctx.TokenAccessHandler.getTokenType = sinon .stub() .returns('readAndWrite') - this.TokenAccessHandler.promises.getProjectByToken = sinon + ctx.TokenAccessHandler.promises.getProjectByToken = sinon .stub() - .resolves(this.project) + .resolves(ctx.project) }) - it('should not allow any access', async function () { + it('should not allow any access', async function (ctx) { const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect(isValidReadAndWrite).to.equal(false) @@ -427,20 +457,20 @@ describe('TokenAccessHandler', function () { }) describe('for read-only project', function () { - beforeEach(function () { - this.TokenAccessHandler.getTokenType = sinon + beforeEach(function (ctx) { + ctx.TokenAccessHandler.getTokenType = sinon .stub() .returns('readOnly') - this.TokenAccessHandler.promises.getProjectByToken = sinon + ctx.TokenAccessHandler.promises.getProjectByToken = sinon .stub() - .resolves(this.project) + .resolves(ctx.project) }) - it('should not allow any access', async function () { + it('should not allow any access', async function (ctx) { const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect(isValidReadAndWrite).to.equal(false) @@ -449,17 +479,17 @@ describe('TokenAccessHandler', function () { }) describe('with nothing', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.getProjectByToken = sinon + beforeEach(function (ctx) { + ctx.TokenAccessHandler.promises.getProjectByToken = sinon .stub() .resolves(null) }) - it('should not allow any access', async function () { + it('should not allow any access', async function (ctx) { const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect(isValidReadAndWrite).to.equal(false) @@ -471,65 +501,63 @@ describe('TokenAccessHandler', function () { }) describe('when link sharing is disabled', function () { - beforeEach(function () { - this.Features.hasFeature = sinon + beforeEach(function (ctx) { + ctx.Features.hasFeature = sinon .stub() .withArgs('link-sharing') .returns(false) }) describe('addReadOnlyUserToProject', function () { - beforeEach(function () { - this.Project.updateOne = sinon.stub().returns({ + beforeEach(function (ctx) { + ctx.Project.updateOne = sinon.stub().returns({ exec: sinon.stub().resolves(null), }) }) - it('should throw an error', async function () { + it('should throw an error', async function (ctx) { await expect( - this.TokenAccessHandler.promises.addReadOnlyUserToProject( - this.userId, - this.projectId, - this.project.owner_ref + ctx.TokenAccessHandler.promises.addReadOnlyUserToProject( + ctx.userId, + ctx.projectId, + ctx.project.owner_ref ) ).to.be.rejectedWith('link sharing is disabled') - expect(this.Project.updateOne.callCount).to.equal(0) + expect(ctx.Project.updateOne.callCount).to.equal(0) }) }) describe('grantSessionTokenAccess', function () { - beforeEach(function () { - this.req = { session: {}, headers: {} } + beforeEach(function (ctx) { + ctx.req = { session: {}, headers: {} } }) - it('should throw an error', function () { + it('should throw an error', function (ctx) { expect(() => { - this.TokenAccessHandler.promises.grantSessionTokenAccess( - this.req, - this.projectId, - this.token + ctx.TokenAccessHandler.promises.grantSessionTokenAccess( + ctx.req, + ctx.projectId, + ctx.token ) }).to.throw('link sharing is disabled') - expect(this.req.session.anonTokenAccess).to.be.undefined + expect(ctx.req.session.anonTokenAccess).to.be.undefined }) }) describe('validateTokenForAnonymousAccess', function () { describe('when a read-only project is found', function () { - beforeEach(function () { - this.TokenAccessHandler.getTokenType = sinon + beforeEach(function (ctx) { + ctx.TokenAccessHandler.getTokenType = sinon.stub().returns('readOnly') + ctx.TokenAccessHandler.promises.getProjectByToken = sinon .stub() - .returns('readOnly') - this.TokenAccessHandler.promises.getProjectByToken = sinon - .stub() - .resolves(this.project) + .resolves(ctx.project) }) - it('should refuse access', async function () { + it('should refuse access', async function (ctx) { const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect(isValidReadAndWrite).to.equal(false) @@ -538,25 +566,25 @@ describe('TokenAccessHandler', function () { }) describe('when a read-and-write project is found', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.getTokenType = sinon + beforeEach(function (ctx) { + ctx.TokenAccessHandler.promises.getTokenType = sinon .stub() .returns('readAndWrite') - this.TokenAccessHandler.promises.getProjectByToken = sinon + ctx.TokenAccessHandler.promises.getProjectByToken = sinon .stub() - .resolves(this.project) + .resolves(ctx.project) }) describe('when Anonymous token access is not enabled', function () { - beforeEach(function () { - this.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false + beforeEach(function (ctx) { + ctx.TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED = false }) - it('should refuse access', async function () { + it('should refuse access', async function (ctx) { const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect(isValidReadAndWrite).to.equal(false) @@ -565,26 +593,26 @@ describe('TokenAccessHandler', function () { }) describe('when anonymous token access is enabled', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true + beforeEach(function (ctx) { + ctx.TokenAccessHandler.promises.ANONYMOUS_READ_AND_WRITE_ENABLED = true }) - it('should not try to find any projects', async function () { - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + it('should not try to find any projects', async function (ctx) { + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount + ctx.TokenAccessHandler.promises.getProjectByToken.callCount ).to.equal(0) }) - it('should refuse access', async function () { + it('should refuse access', async function (ctx) { const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect(isValidReadAndWrite).to.equal(false) @@ -594,28 +622,28 @@ describe('TokenAccessHandler', function () { }) describe('when no project is found', function () { - beforeEach(function () { - this.TokenAccessHandler.promises.getProjectByToken = sinon + beforeEach(function (ctx) { + ctx.TokenAccessHandler.promises.getProjectByToken = sinon .stub() .resolves(null) }) - it('should not try to find any projects ', async function () { - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + it('should not try to find any projects ', async function (ctx) { + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect( - this.TokenAccessHandler.promises.getProjectByToken.callCount + ctx.TokenAccessHandler.promises.getProjectByToken.callCount ).to.equal(0) }) - it('should not allow any access', async function () { + it('should not allow any access', async function (ctx) { const { isValidReadAndWrite, isValidReadOnly } = - await this.TokenAccessHandler.promises.validateTokenForAnonymousAccess( - this.projectId, - this.token + await ctx.TokenAccessHandler.promises.validateTokenForAnonymousAccess( + ctx.projectId, + ctx.token ) expect(isValidReadAndWrite).to.equal(false) @@ -627,17 +655,15 @@ describe('TokenAccessHandler', function () { describe('getDocPublishedInfo', function () { describe('when v1 api not set', function () { - beforeEach(function () { - this.settings.apis = { v1: undefined } + beforeEach(function (ctx) { + ctx.settings.apis = { v1: undefined } }) - it('should not check access and return default info', async function () { + it('should not check access and return default info', async function (ctx) { const info = - await this.TokenAccessHandler.promises.getV1DocPublishedInfo( - this.token - ) + await ctx.TokenAccessHandler.promises.getV1DocPublishedInfo(ctx.token) - expect(this.V1Api.promises.request.called).to.equal(false) + expect(ctx.V1Api.promises.request.called).to.equal(false) expect(info).to.deep.equal({ allow: true, }) @@ -645,26 +671,26 @@ describe('TokenAccessHandler', function () { }) describe('when v1 api is set', function () { - beforeEach(function () { - this.settings.apis = { v1: { url: 'v1Url' } } + beforeEach(function (ctx) { + ctx.settings.apis = { v1: { url: 'v1Url' } } }) describe('on V1Api.request success', function () { - beforeEach(function () { - this.V1Api.promises.request = sinon + beforeEach(function (ctx) { + ctx.V1Api.promises.request = sinon .stub() .resolves({ body: 'mock-data' }) }) - it('should return response body', async function () { + it('should return response body', async function (ctx) { const info = - await this.TokenAccessHandler.promises.getV1DocPublishedInfo( - this.token + await ctx.TokenAccessHandler.promises.getV1DocPublishedInfo( + ctx.token ) expect( - this.V1Api.promises.request.calledWith({ - url: `/api/v1/overleaf/docs/${this.token}/is_published`, + ctx.V1Api.promises.request.calledWith({ + url: `/api/v1/overleaf/docs/${ctx.token}/is_published`, }) ).to.equal(true) expect(info).to.equal('mock-data') @@ -672,13 +698,13 @@ describe('TokenAccessHandler', function () { }) describe('on V1Api.request error', function () { - beforeEach(function () { - this.V1Api.promises.request = sinon.stub().rejects('error') + beforeEach(function (ctx) { + ctx.V1Api.promises.request = sinon.stub().rejects('error') }) - it('should be rejected', async function () { + it('should be rejected', async function (ctx) { await expect( - this.TokenAccessHandler.promises.getV1DocPublishedInfo(this.token) + ctx.TokenAccessHandler.promises.getV1DocPublishedInfo(ctx.token) ).to.be.rejected }) }) @@ -687,13 +713,13 @@ describe('TokenAccessHandler', function () { describe('getV1DocInfo', function () { describe('when v1 api not set', function () { - it('should not check access and return default info', async function () { - const info = await this.TokenAccessHandler.promises.getV1DocInfo( - this.token, - this.v2UserId + it('should not check access and return default info', async function (ctx) { + const info = await ctx.TokenAccessHandler.promises.getV1DocInfo( + ctx.token, + ctx.v2UserId ) - expect(this.V1Api.promises.request.called).to.equal(false) + expect(ctx.V1Api.promises.request.called).to.equal(false) expect(info).to.deep.equal({ exists: true, exported: false, @@ -702,26 +728,26 @@ describe('TokenAccessHandler', function () { }) describe('when v1 api is set', function () { - beforeEach(function () { - this.settings.apis = { v1: 'v1' } + beforeEach(function (ctx) { + ctx.settings.apis = { v1: 'v1' } }) describe('on V1Api.request success', function () { - beforeEach(function () { - this.V1Api.promises.request = sinon + beforeEach(function (ctx) { + ctx.V1Api.promises.request = sinon .stub() .resolves({ body: 'mock-data' }) }) - it('should return response body', async function () { - const info = await this.TokenAccessHandler.promises.getV1DocInfo( - this.token, - this.v2UserId + it('should return response body', async function (ctx) { + const info = await ctx.TokenAccessHandler.promises.getV1DocInfo( + ctx.token, + ctx.v2UserId ) expect( - this.V1Api.promises.request.calledWith({ - url: `/api/v1/overleaf/docs/${this.token}/info`, + ctx.V1Api.promises.request.calledWith({ + url: `/api/v1/overleaf/docs/${ctx.token}/info`, }) ).to.equal(true) expect(info).to.equal('mock-data') @@ -729,15 +755,15 @@ describe('TokenAccessHandler', function () { }) describe('on V1Api.request error', function () { - beforeEach(function () { - this.V1Api.promises.request = sinon.stub().rejects('error') + beforeEach(function (ctx) { + ctx.V1Api.promises.request = sinon.stub().rejects('error') }) - it('should be rejected', async function () { + it('should be rejected', async function (ctx) { await expect( - this.TokenAccessHandler.promises.getV1DocInfo( - this.token, - this.v2UserId + ctx.TokenAccessHandler.promises.getV1DocInfo( + ctx.token, + ctx.v2UserId ) ).to.be.rejected }) @@ -746,9 +772,9 @@ describe('TokenAccessHandler', function () { }) describe('createTokenHashPrefix', function () { - it('creates a prefix of the hash', function () { + it('creates a prefix of the hash', function (ctx) { const prefix = - this.TokenAccessHandler.createTokenHashPrefix('zxpxjrwdtsgd') + ctx.TokenAccessHandler.createTokenHashPrefix('zxpxjrwdtsgd') expect(prefix.length).to.equal(6) }) }) @@ -769,10 +795,10 @@ describe('TokenAccessHandler', function () { '%2F1234567%2F': '%2F1234567%2F', } for (const [input, output] of Object.entries(cases)) { - it(`should handle ${JSON.stringify(input)}`, function () { - expect( - this.TokenAccessHandler.normalizeTokenHashPrefix(input) - ).to.equal(output) + it(`should handle ${JSON.stringify(input)}`, function (ctx) { + expect(ctx.TokenAccessHandler.normalizeTokenHashPrefix(input)).to.equal( + output + ) }) } }) @@ -780,11 +806,11 @@ describe('TokenAccessHandler', function () { describe('checkTokenHashPrefix', function () { const userId = 'abc123' const projectId = 'def456' - it('sends "match" to metrics when prefix matches the prefix of the hash of the token', function () { + it('sends "match" to metrics when prefix matches the prefix of the hash of the token', function (ctx) { const token = 'zxpxjrwdtsgd' - const prefix = this.TokenAccessHandler.createTokenHashPrefix(token) + const prefix = ctx.TokenAccessHandler.createTokenHashPrefix(token) - this.TokenAccessHandler.checkTokenHashPrefix( + ctx.TokenAccessHandler.checkTokenHashPrefix( token, `#${prefix}`, 'readOnly', @@ -792,7 +818,7 @@ describe('TokenAccessHandler', function () { { projectId } ) - expect(this.Metrics.inc).to.have.been.calledWith( + expect(ctx.Metrics.inc).to.have.been.calledWith( 'link-sharing.hash-check', { path: 'readOnly', @@ -800,10 +826,10 @@ describe('TokenAccessHandler', function () { } ) }) - it('sends "mismatch" to metrics when prefix does not match the prefix of the hash of the token', function () { + it('sends "mismatch" to metrics when prefix does not match the prefix of the hash of the token', function (ctx) { const token = 'zxpxjrwdtsgd' - const prefix = this.TokenAccessHandler.createTokenHashPrefix(token) - this.TokenAccessHandler.checkTokenHashPrefix( + const prefix = ctx.TokenAccessHandler.createTokenHashPrefix(token) + ctx.TokenAccessHandler.checkTokenHashPrefix( 'anothertoken', `#${prefix}`, 'readOnly', @@ -811,14 +837,14 @@ describe('TokenAccessHandler', function () { { projectId } ) - expect(this.Metrics.inc).to.have.been.calledWith( + expect(ctx.Metrics.inc).to.have.been.calledWith( 'link-sharing.hash-check', { path: 'readOnly', status: 'mismatch', } ) - expect(this.logger.info).to.have.been.calledWith( + expect(ctx.logger.info).toHaveBeenCalledWith( { tokenHashPrefix: prefix, hashPrefixStatus: 'mismatch', @@ -829,8 +855,8 @@ describe('TokenAccessHandler', function () { 'mismatched token hash prefix' ) }) - it('sends "missing" to metrics when prefix is undefined', function () { - this.TokenAccessHandler.checkTokenHashPrefix( + it('sends "missing" to metrics when prefix is undefined', function (ctx) { + ctx.TokenAccessHandler.checkTokenHashPrefix( 'anothertoken', undefined, 'readOnly', @@ -838,7 +864,7 @@ describe('TokenAccessHandler', function () { { projectId } ) - expect(this.Metrics.inc).to.have.been.calledWith( + expect(ctx.Metrics.inc).to.have.been.calledWith( 'link-sharing.hash-check', { path: 'readOnly', @@ -846,8 +872,8 @@ describe('TokenAccessHandler', function () { } ) }) - it('sends "missing" to metrics when URL hash is sent as "#" only', function () { - this.TokenAccessHandler.checkTokenHashPrefix( + it('sends "missing" to metrics when URL hash is sent as "#" only', function (ctx) { + ctx.TokenAccessHandler.checkTokenHashPrefix( 'anothertoken', '#', 'readOnly', @@ -855,7 +881,7 @@ describe('TokenAccessHandler', function () { { projectId } ) - expect(this.Metrics.inc).to.have.been.calledWith( + expect(ctx.Metrics.inc).to.have.been.calledWith( 'link-sharing.hash-check', { path: 'readOnly', @@ -863,11 +889,11 @@ describe('TokenAccessHandler', function () { } ) }) - it('handles encoded hashtags', function () { + it('handles encoded hashtags', function (ctx) { const token = 'zxpxjrwdtsgd' - const prefix = this.TokenAccessHandler.createTokenHashPrefix(token) + const prefix = ctx.TokenAccessHandler.createTokenHashPrefix(token) - this.TokenAccessHandler.checkTokenHashPrefix( + ctx.TokenAccessHandler.checkTokenHashPrefix( token, `%23${prefix}`, 'readOnly', @@ -875,7 +901,7 @@ describe('TokenAccessHandler', function () { { projectId } ) - expect(this.Metrics.inc).to.have.been.calledWith( + expect(ctx.Metrics.inc).to.have.been.calledWith( 'link-sharing.hash-check', { path: 'readOnly', @@ -884,17 +910,17 @@ describe('TokenAccessHandler', function () { ) }) - it('sends "mismatch-v1-format" for suspected v1 URLs with 7 numbers in URL fragment', function () { + it('sends "mismatch-v1-format" for suspected v1 URLs with 7 numbers in URL fragment', function (ctx) { const token = '4112142489ddsbkrdzhxrq' const prefix = '%2F1234567%2F' - this.TokenAccessHandler.checkTokenHashPrefix( + ctx.TokenAccessHandler.checkTokenHashPrefix( token, `#${prefix}`, 'readAndWrite', userId, { projectId } ) - expect(this.Metrics.inc).to.have.been.calledWith( + expect(ctx.Metrics.inc).to.have.been.calledWith( 'link-sharing.hash-check', { path: 'readAndWrite', @@ -902,17 +928,17 @@ describe('TokenAccessHandler', function () { } ) }) - it('sends "mismatch-v1-format" for suspected v1 URLs with 8 numbers in URL fragment', function () { + it('sends "mismatch-v1-format" for suspected v1 URLs with 8 numbers in URL fragment', function (ctx) { const token = '4112142489ddsbkrdzhxrq' const prefix = '%2F12345678%2F' - this.TokenAccessHandler.checkTokenHashPrefix( + ctx.TokenAccessHandler.checkTokenHashPrefix( token, `#${prefix}`, 'readAndWrite', userId, { projectId } ) - expect(this.Metrics.inc).to.have.been.calledWith( + expect(ctx.Metrics.inc).to.have.been.calledWith( 'link-sharing.hash-check', { path: 'readAndWrite', diff --git a/services/web/test/unit/src/Uploads/ArchiveManager.test.mjs b/services/web/test/unit/src/Uploads/ArchiveManager.test.mjs index 12696cf9a0..b3aa7abc5d 100644 --- a/services/web/test/unit/src/Uploads/ArchiveManager.test.mjs +++ b/services/web/test/unit/src/Uploads/ArchiveManager.test.mjs @@ -1,9 +1,4 @@ -/* eslint-disable - n/handle-callback-err, - max-len, - no-return-assign, - no-unused-vars, -*/ +import { vi, expect } from 'vitest' // TODO: This file was created by bulk-decaffeinate. // Fix any style issues and re-enable lint. /* @@ -12,17 +7,20 @@ * DS206: Consider reworking classes to avoid initClass * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const sinon = require('sinon') -const { expect } = require('chai') -const modulePath = '../../../../app/src/Features/Uploads/ArchiveManager.js' -const ArchiveErrors = require('../../../../app/src/Features/Uploads/ArchiveErrors') -const SandboxedModule = require('sandboxed-module') -const events = require('events') +import sinon from 'sinon' +import ArchiveErrors from '../../../../app/src/Features/Uploads/ArchiveErrors.js' +import events from 'events' + +vi.mock('../../../../app/src/Features/Uploads/ArchiveErrors.js', () => + vi.importActual('../../../../app/src/Features/Uploads/ArchiveErrors.js') +) + +const modulePath = '../../../../app/src/Features/Uploads/ArchiveManager.mjs' describe('ArchiveManager', function () { - beforeEach(function () { + beforeEach(async function (ctx) { let Timer - this.metrics = { + ctx.metrics = { Timer: (Timer = (function () { Timer = class Timer { static initClass() { @@ -33,181 +31,206 @@ describe('ArchiveManager', function () { return Timer })()), } - this.zipfile = new events.EventEmitter() - this.zipfile.readEntry = sinon.stub() - this.zipfile.close = sinon.stub() + ctx.zipfile = new events.EventEmitter() + ctx.zipfile.readEntry = sinon.stub() + ctx.zipfile.close = sinon.stub() - this.ArchiveManager = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': {}, - yauzl: (this.yauzl = { - open: sinon.stub().callsArgWith(2, null, this.zipfile), - }), - '@overleaf/metrics': this.metrics, - fs: (this.fs = { mkdir: sinon.stub().yields() }), - './ArchiveErrors': ArchiveErrors, - }, - }) - return (this.callback = sinon.stub()) + vi.doMock('@overleaf/settings', () => ({ + default: {}, + })) + + vi.doMock('yauzl', () => ({ + default: (ctx.yauzl = { + open: sinon.stub().callsArgWith(2, null, ctx.zipfile), + }), + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: ctx.metrics, + })) + ctx.fs = { mkdir: sinon.stub().yields(), stat: sinon.stub() } + vi.doMock('fs', () => ({ + default: ctx.fs, + })) + + vi.doMock( + '../../../../app/src/Features/Uploads/ArchiveErrors', + () => ArchiveErrors + ) + + ctx.ArchiveManager = (await import(modulePath)).default + ctx.callback = sinon.stub() }) describe('extractZipArchive', function () { - beforeEach(function () { - this.source = '/path/to/zip/source.zip' - this.destination = '/path/to/zip/destination' - return (this.ArchiveManager._isZipTooLarge = sinon + beforeEach(function (ctx) { + ctx.source = '/path/to/zip/source.zip' + ctx.destination = '/path/to/zip/destination' + ctx.ArchiveManager._isZipTooLarge = sinon .stub() - .callsArgWith(1, null, false)) + .callsArgWith(1, null, false) }) describe('successfully', function () { - beforeEach(function (done) { - this.readStream = new events.EventEmitter() - this.readStream.pipe = sinon.stub() - this.zipfile.openReadStream = sinon - .stub() - .callsArgWith(1, null, this.readStream) - this.writeStream = new events.EventEmitter() - this.fs.createWriteStream = sinon.stub().returns(this.writeStream) - this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - done - ) + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.readStream = new events.EventEmitter() + ctx.readStream.pipe = sinon.stub() + ctx.zipfile.openReadStream = sinon + .stub() + .callsArgWith(1, null, ctx.readStream) + ctx.writeStream = new events.EventEmitter() + ctx.fs.createWriteStream = sinon.stub().returns(ctx.writeStream) + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + resolve + ) - // entry contains a single file - this.zipfile.emit('entry', { fileName: 'testfile.txt' }) - this.readStream.emit('end') - return this.zipfile.emit('end') + // entry contains a single file + ctx.zipfile.emit('entry', { fileName: 'testfile.txt' }) + ctx.readStream.emit('end') + ctx.zipfile.emit('end') + }) }) - it('should run yauzl', function () { - return this.yauzl.open.calledWith(this.source).should.equal(true) + it('should run yauzl', function (ctx) { + ctx.yauzl.open.calledWith(ctx.source).should.equal(true) }) - it('should time the unzip', function () { - return this.metrics.Timer.prototype.done.called.should.equal(true) + it('should time the unzip', function (ctx) { + ctx.metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('with a zipfile containing an empty directory', function () { - beforeEach(function (done) { - this.readStream = new events.EventEmitter() - this.readStream.pipe = sinon.stub() - this.zipfile.openReadStream = sinon - .stub() - .callsArgWith(1, null, this.readStream) - this.writeStream = new events.EventEmitter() - this.fs.createWriteStream = sinon.stub().returns(this.writeStream) - this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - done() - } - ) + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.readStream = new events.EventEmitter() + ctx.readStream.pipe = sinon.stub() + ctx.zipfile.openReadStream = sinon + .stub() + .callsArgWith(1, null, ctx.readStream) + ctx.writeStream = new events.EventEmitter() + ctx.fs.createWriteStream = sinon.stub().returns(ctx.writeStream) + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + resolve() + } + ) - // entry contains a single, empty directory - this.zipfile.emit('entry', { fileName: 'testdir/' }) - this.readStream.emit('end') - return this.zipfile.emit('end') + // entry contains a single, empty directory + ctx.zipfile.emit('entry', { fileName: 'testdir/' }) + ctx.readStream.emit('end') + ctx.zipfile.emit('end') + }) }) - it('should return the callback with an error', function () { - return sinon.assert.calledWithExactly( - this.callback, + it('should return the callback with an error', function (ctx) { + sinon.assert.calledWithExactly( + ctx.callback, sinon.match.instanceOf(ArchiveErrors.EmptyZipFileError) ) }) }) describe('with an empty zipfile', function () { - beforeEach(function (done) { - this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - return done() - } - ) - return this.zipfile.emit('end') + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + resolve() + } + ) + ctx.zipfile.emit('end') + }) }) - it('should return the callback with an error', function () { - return sinon.assert.calledWithExactly( - this.callback, + it('should return the callback with an error', function (ctx) { + sinon.assert.calledWithExactly( + ctx.callback, sinon.match.instanceOf(ArchiveErrors.EmptyZipFileError) ) }) }) describe('with an error in the zip file header', function () { - beforeEach(function (done) { - this.yauzl.open = sinon - .stub() - .callsArgWith(2, new ArchiveErrors.InvalidZipFileError()) - return this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - return done() - } - ) + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.yauzl.open = sinon + .stub() + .callsArgWith(2, new ArchiveErrors.InvalidZipFileError()) + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + resolve() + } + ) + }) }) - it('should return the callback with an error', function () { - return sinon.assert.calledWithExactly( - this.callback, + it('should return the callback with an error', function (ctx) { + sinon.assert.calledWithExactly( + ctx.callback, sinon.match.instanceOf(ArchiveErrors.InvalidZipFileError) ) }) }) describe('with a zip that is too large', function () { - beforeEach(function (done) { - this.ArchiveManager._isZipTooLarge = sinon - .stub() - .callsArgWith(1, null, true) - return this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - return done() - } - ) + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.ArchiveManager._isZipTooLarge = sinon + .stub() + .callsArgWith(1, null, true) + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + resolve() + } + ) + }) }) - it('should return the callback with an error', function () { - return sinon.assert.calledWithExactly( - this.callback, + it('should return the callback with an error', function (ctx) { + sinon.assert.calledWithExactly( + ctx.callback, sinon.match.instanceOf(ArchiveErrors.ZipContentsTooLargeError) ) }) - it('should not call yauzl.open', function () { - return this.yauzl.open.called.should.equal(false) + it('should not call yauzl.open', function (ctx) { + ctx.yauzl.open.called.should.equal(false) }) }) describe('with an error in the extracted files', function () { - beforeEach(function (done) { - this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - return done() - } - ) - return this.zipfile.emit('error', new Error('Something went wrong')) + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + resolve() + } + ) + ctx.zipfile.emit('error', new Error('Something went wrong')) + }) }) - it('should return the callback with an error', function () { - return this.callback.should.have.been.calledWithExactly( + it('should return the callback with an error', function (ctx) { + return ctx.callback.should.have.been.calledWithExactly( sinon.match .instanceOf(Error) .and(sinon.match.has('message', 'Something went wrong')) @@ -216,346 +239,358 @@ describe('ArchiveManager', function () { }) describe('with a relative extracted file path', function () { - beforeEach(function (done) { - this.zipfile.openReadStream = sinon.stub() - this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - return done() - } - ) - this.zipfile.emit('entry', { fileName: '../testfile.txt' }) - return this.zipfile.emit('end') + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.zipfile.openReadStream = sinon.stub() + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + return resolve() + } + ) + ctx.zipfile.emit('entry', { fileName: '../testfile.txt' }) + return ctx.zipfile.emit('end') + }) }) - it('should not write try to read the file entry', function () { - return this.zipfile.openReadStream.called.should.equal(false) + it('should not write try to read the file entry', function (ctx) { + return ctx.zipfile.openReadStream.called.should.equal(false) }) }) describe('with an unnormalized extracted file path', function () { - beforeEach(function (done) { - this.zipfile.openReadStream = sinon.stub() - this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - return done() - } - ) - this.zipfile.emit('entry', { fileName: 'foo/./testfile.txt' }) - return this.zipfile.emit('end') + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.zipfile.openReadStream = sinon.stub() + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + return resolve() + } + ) + ctx.zipfile.emit('entry', { fileName: 'foo/./testfile.txt' }) + return ctx.zipfile.emit('end') + }) }) - it('should not try to read the file entry', function () { - return this.zipfile.openReadStream.called.should.equal(false) + it('should not try to read the file entry', function (ctx) { + return ctx.zipfile.openReadStream.called.should.equal(false) }) }) describe('with backslashes in the path', function () { - beforeEach(function (done) { - this.readStream = new events.EventEmitter() - this.readStream.pipe = sinon.stub() - this.writeStream = new events.EventEmitter() - this.fs.createWriteStream = sinon.stub().returns(this.writeStream) - this.zipfile.openReadStream = sinon - .stub() - .callsArgWith(1, null, this.readStream) - this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - return done() - } - ) - this.zipfile.emit('entry', { fileName: 'wombat\\foo.tex' }) - this.zipfile.emit('entry', { fileName: 'potato\\bar.tex' }) - return this.zipfile.emit('end') + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.readStream = new events.EventEmitter() + ctx.readStream.pipe = sinon.stub() + ctx.writeStream = new events.EventEmitter() + ctx.fs.createWriteStream = sinon.stub().returns(ctx.writeStream) + ctx.zipfile.openReadStream = sinon + .stub() + .callsArgWith(1, null, ctx.readStream) + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + return resolve() + } + ) + ctx.zipfile.emit('entry', { fileName: 'wombat\\foo.tex' }) + ctx.zipfile.emit('entry', { fileName: 'potato\\bar.tex' }) + return ctx.zipfile.emit('end') + }) }) - it('should read the file entry with its original path', function () { - this.zipfile.openReadStream.should.be.calledWith({ + it('should read the file entry with its original path', function (ctx) { + ctx.zipfile.openReadStream.should.be.calledWith({ fileName: 'wombat\\foo.tex', }) - return this.zipfile.openReadStream.should.be.calledWith({ + ctx.zipfile.openReadStream.should.be.calledWith({ fileName: 'potato\\bar.tex', }) }) - it('should treat the backslashes as a directory separator when creating the directory', function () { - this.fs.mkdir.should.be.calledWith(`${this.destination}/wombat`, { + it('should treat the backslashes as a directory separator when creating the directory', function (ctx) { + ctx.fs.mkdir.should.be.calledWith(`${ctx.destination}/wombat`, { recursive: true, }) - this.fs.mkdir.should.be.calledWith(`${this.destination}/potato`, { + ctx.fs.mkdir.should.be.calledWith(`${ctx.destination}/potato`, { recursive: true, }) }) - it('should treat the backslashes as a directory separator when creating the file', function () { - this.fs.createWriteStream.should.be.calledWith( - `${this.destination}/wombat/foo.tex` + it('should treat the backslashes as a directory separator when creating the file', function (ctx) { + ctx.fs.createWriteStream.should.be.calledWith( + `${ctx.destination}/wombat/foo.tex` ) - return this.fs.createWriteStream.should.be.calledWith( - `${this.destination}/potato/bar.tex` + ctx.fs.createWriteStream.should.be.calledWith( + `${ctx.destination}/potato/bar.tex` ) }) }) describe('with a directory entry', function () { - beforeEach(function (done) { - this.zipfile.openReadStream = sinon.stub() - this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - return done() - } - ) - this.zipfile.emit('entry', { fileName: 'testdir/' }) - return this.zipfile.emit('end') + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.zipfile.openReadStream = sinon.stub() + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + resolve() + } + ) + ctx.zipfile.emit('entry', { fileName: 'testdir/' }) + ctx.zipfile.emit('end') + }) }) - it('should not try to read the entry', function () { - return this.zipfile.openReadStream.called.should.equal(false) + it('should not try to read the entry', function (ctx) { + ctx.zipfile.openReadStream.called.should.equal(false) }) }) describe('with an error opening the file read stream', function () { - beforeEach(function (done) { - this.zipfile.openReadStream = sinon - .stub() - .callsArgWith(1, new Error('Something went wrong')) - this.writeStream = new events.EventEmitter() - this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - return done() - } - ) - this.zipfile.emit('entry', { fileName: 'testfile.txt' }) - return this.zipfile.emit('end') + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.zipfile.openReadStream = sinon + .stub() + .callsArgWith(1, new Error('Something went wrong')) + ctx.writeStream = new events.EventEmitter() + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + resolve() + } + ) + ctx.zipfile.emit('entry', { fileName: 'testfile.txt' }) + ctx.zipfile.emit('end') + }) }) - it('should return the callback with an error', function () { - return this.callback.should.have.been.calledWithExactly( + it('should return the callback with an error', function (ctx) { + ctx.callback.should.have.been.calledWithExactly( sinon.match .instanceOf(Error) .and(sinon.match.has('message', 'Something went wrong')) ) }) - it('should close the zipfile', function () { - return this.zipfile.close.called.should.equal(true) + it('should close the zipfile', function (ctx) { + ctx.zipfile.close.called.should.equal(true) }) }) describe('with an error in the file read stream', function () { - beforeEach(function (done) { - this.readStream = new events.EventEmitter() - this.readStream.pipe = sinon.stub() - this.zipfile.openReadStream = sinon - .stub() - .callsArgWith(1, null, this.readStream) - this.writeStream = new events.EventEmitter() - this.fs.createWriteStream = sinon.stub().returns(this.writeStream) - this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - return done() - } - ) - this.zipfile.emit('entry', { fileName: 'testfile.txt' }) - this.readStream.emit('error', new Error('Something went wrong')) - return this.zipfile.emit('end') + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.readStream = new events.EventEmitter() + ctx.readStream.pipe = sinon.stub() + ctx.zipfile.openReadStream = sinon + .stub() + .callsArgWith(1, null, ctx.readStream) + ctx.writeStream = new events.EventEmitter() + ctx.fs.createWriteStream = sinon.stub().returns(ctx.writeStream) + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + return resolve() + } + ) + ctx.zipfile.emit('entry', { fileName: 'testfile.txt' }) + ctx.readStream.emit('error', new Error('Something went wrong')) + ctx.zipfile.emit('end') + }) }) - it('should return the callback with an error', function () { - return this.callback.should.have.been.calledWithExactly( + it('should return the callback with an error', function (ctx) { + ctx.callback.should.have.been.calledWithExactly( sinon.match .instanceOf(Error) .and(sinon.match.has('message', 'Something went wrong')) ) }) - it('should close the zipfile', function () { - return this.zipfile.close.called.should.equal(true) + it('should close the zipfile', function (ctx) { + ctx.zipfile.close.called.should.equal(true) }) }) describe('with an error in the file write stream', function () { - beforeEach(function (done) { - this.readStream = new events.EventEmitter() - this.readStream.pipe = sinon.stub() - this.readStream.unpipe = sinon.stub() - this.readStream.destroy = sinon.stub() - this.zipfile.openReadStream = sinon - .stub() - .callsArgWith(1, null, this.readStream) - this.writeStream = new events.EventEmitter() - this.fs.createWriteStream = sinon.stub().returns(this.writeStream) - this.ArchiveManager.extractZipArchive( - this.source, - this.destination, - error => { - this.callback(error) - return done() - } - ) - this.zipfile.emit('entry', { fileName: 'testfile.txt' }) - this.writeStream.emit('error', new Error('Something went wrong')) - return this.zipfile.emit('end') + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.readStream = new events.EventEmitter() + ctx.readStream.pipe = sinon.stub() + ctx.readStream.unpipe = sinon.stub() + ctx.readStream.destroy = sinon.stub() + ctx.zipfile.openReadStream = sinon + .stub() + .callsArgWith(1, null, ctx.readStream) + ctx.writeStream = new events.EventEmitter() + ctx.fs.createWriteStream = sinon.stub().returns(ctx.writeStream) + ctx.ArchiveManager.extractZipArchive( + ctx.source, + ctx.destination, + error => { + ctx.callback(error) + return resolve() + } + ) + ctx.zipfile.emit('entry', { fileName: 'testfile.txt' }) + ctx.writeStream.emit('error', new Error('Something went wrong')) + ctx.zipfile.emit('end') + }) }) - it('should return the callback with an error', function () { - return this.callback.should.have.been.calledWithExactly( + it('should return the callback with an error', function (ctx) { + ctx.callback.should.have.been.calledWithExactly( sinon.match .instanceOf(Error) .and(sinon.match.has('message', 'Something went wrong')) ) }) - it('should unpipe from the readstream', function () { - return this.readStream.unpipe.called.should.equal(true) + it('should unpipe from the readstream', function (ctx) { + ctx.readStream.unpipe.called.should.equal(true) }) - it('should destroy the readstream', function () { - return this.readStream.destroy.called.should.equal(true) + it('should destroy the readstream', function (ctx) { + ctx.readStream.destroy.called.should.equal(true) }) - it('should close the zipfile', function () { - return this.zipfile.close.called.should.equal(true) + it('should close the zipfile', function (ctx) { + ctx.zipfile.close.called.should.equal(true) }) }) }) describe('_isZipTooLarge', function () { - it('should return false with small output', function (done) { - this.ArchiveManager._isZipTooLarge(this.source, (error, isTooLarge) => { - isTooLarge.should.equal(false) - return done() + it('should return false with small output', async function (ctx) { + await new Promise(resolve => { + ctx.ArchiveManager._isZipTooLarge(ctx.source, (error, isTooLarge) => { + expect(error).not.to.exist + isTooLarge.should.equal(false) + resolve() + }) + ctx.zipfile.emit('entry', { uncompressedSize: 109042 }) + ctx.zipfile.emit('end') }) - this.zipfile.emit('entry', { uncompressedSize: 109042 }) - return this.zipfile.emit('end') }) - it('should return true with large bytes', function (done) { - this.ArchiveManager._isZipTooLarge(this.source, (error, isTooLarge) => { - isTooLarge.should.equal(true) - return done() + it('should return true with large bytes', async function (ctx) { + await new Promise(resolve => { + ctx.ArchiveManager._isZipTooLarge(ctx.source, (error, isTooLarge) => { + expect(error).not.to.exist + isTooLarge.should.equal(true) + resolve() + }) + ctx.zipfile.emit('entry', { uncompressedSize: 109e16 }) + ctx.zipfile.emit('end') }) - this.zipfile.emit('entry', { uncompressedSize: 109e16 }) - return this.zipfile.emit('end') }) - it('should return error on no data', function (done) { - this.ArchiveManager._isZipTooLarge(this.source, (error, isTooLarge) => { - expect(error).to.exist - return done() + it('should return error on no data', async function (ctx) { + await new Promise(resolve => { + ctx.ArchiveManager._isZipTooLarge(ctx.source, (error, isTooLarge) => { + expect(error).to.exist + resolve() + }) + ctx.zipfile.emit('entry', {}) + ctx.zipfile.emit('end') }) - this.zipfile.emit('entry', {}) - return this.zipfile.emit('end') }) - it("should return error if it didn't get a number", function (done) { - this.ArchiveManager._isZipTooLarge(this.source, (error, isTooLarge) => { - expect(error).to.exist - return done() + it("should return error if it didn't get a number", async function (ctx) { + await new Promise(resolve => { + ctx.ArchiveManager._isZipTooLarge(ctx.source, (error, isTooLarge) => { + expect(error).to.exist + resolve() + }) + ctx.zipfile.emit('entry', { uncompressedSize: 'random-error' }) + ctx.zipfile.emit('end') }) - this.zipfile.emit('entry', { uncompressedSize: 'random-error' }) - return this.zipfile.emit('end') }) - it('should return error if there is no data', function (done) { - this.ArchiveManager._isZipTooLarge(this.source, (error, isTooLarge) => { - expect(error).to.exist - return done() + it('should return error if there is no data', async function (ctx) { + await new Promise(resolve => { + ctx.ArchiveManager._isZipTooLarge(ctx.source, (error, isTooLarge) => { + expect(error).to.exist + resolve() + }) + ctx.zipfile.emit('end') }) - return this.zipfile.emit('end') }) }) describe('findTopLevelDirectory', function () { - beforeEach(function () { - this.fs.readdir = sinon.stub() - this.fs.stat = sinon.stub() - return (this.directory = 'test/directory') + beforeEach(function (ctx) { + ctx.fs.readdir = sinon.stub() + ctx.fs.stat((ctx.directory = 'test/directory')) }) describe('with multiple files', function () { - beforeEach(function () { - this.fs.readdir.callsArgWith(1, null, ['multiple', 'files']) - return this.ArchiveManager.findTopLevelDirectory( - this.directory, - this.callback - ) + beforeEach(function (ctx) { + ctx.fs.readdir.callsArgWith(1, null, ['multiple', 'files']) + ctx.ArchiveManager.findTopLevelDirectory(ctx.directory, ctx.callback) }) - it('should find the files in the directory', function () { - return this.fs.readdir.calledWith(this.directory).should.equal(true) + it('should find the files in the directory', function (ctx) { + ctx.fs.readdir.calledWith(ctx.directory).should.equal(true) }) - it('should return the original directory', function () { - return this.callback.calledWith(null, this.directory).should.equal(true) + it('should return the original directory', function (ctx) { + ctx.callback.calledWith(null, ctx.directory).should.equal(true) }) }) describe('with a single file (not folder)', function () { - beforeEach(function () { - this.fs.readdir.callsArgWith(1, null, ['foo.tex']) - this.fs.stat.callsArgWith(1, null, { + beforeEach(function (ctx) { + ctx.fs.readdir.callsArgWith(1, null, ['foo.tex']) + ctx.fs.stat.callsArgWith(1, null, { isDirectory() { return false }, }) - return this.ArchiveManager.findTopLevelDirectory( - this.directory, - this.callback - ) + ctx.ArchiveManager.findTopLevelDirectory(ctx.directory, ctx.callback) }) - it('should check if the file is a directory', function () { - return this.fs.stat - .calledWith(this.directory + '/foo.tex') - .should.equal(true) + it('should check if the file is a directory', function (ctx) { + ctx.fs.stat.calledWith(ctx.directory + '/foo.tex').should.equal(true) }) - it('should return the original directory', function () { - return this.callback.calledWith(null, this.directory).should.equal(true) + it('should return the original directory', function (ctx) { + ctx.callback.calledWith(null, ctx.directory).should.equal(true) }) }) describe('with a single top-level folder', function () { - beforeEach(function () { - this.fs.readdir.callsArgWith(1, null, ['folder']) - this.fs.stat.callsArgWith(1, null, { + beforeEach(function (ctx) { + ctx.fs.readdir.callsArgWith(1, null, ['folder']) + ctx.fs.stat.callsArgWith(1, null, { isDirectory() { return true }, }) - return this.ArchiveManager.findTopLevelDirectory( - this.directory, - this.callback - ) + ctx.ArchiveManager.findTopLevelDirectory(ctx.directory, ctx.callback) }) - it('should check if the file is a directory', function () { - return this.fs.stat - .calledWith(this.directory + '/folder') - .should.equal(true) + it('should check if the file is a directory', function (ctx) { + ctx.fs.stat.calledWith(ctx.directory + '/folder').should.equal(true) }) - it('should return the child directory', function () { - return this.callback - .calledWith(null, this.directory + '/folder') + it('should return the child directory', function (ctx) { + ctx.callback + .calledWith(null, ctx.directory + '/folder') .should.equal(true) }) }) diff --git a/services/web/test/unit/src/Uploads/FileTypeManager.test.mjs b/services/web/test/unit/src/Uploads/FileTypeManager.test.mjs index 97f0eff11e..3564a58812 100644 --- a/services/web/test/unit/src/Uploads/FileTypeManager.test.mjs +++ b/services/web/test/unit/src/Uploads/FileTypeManager.test.mjs @@ -1,114 +1,119 @@ -const sinon = require('sinon') -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const isUtf8 = require('utf-8-validate') -const Settings = require('@overleaf/settings') +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import isUtf8 from 'utf-8-validate' +import Settings from '@overleaf/settings' -const MODULE_PATH = '../../../../app/src/Features/Uploads/FileTypeManager.js' +const MODULE_PATH = '../../../../app/src/Features/Uploads/FileTypeManager.mjs' describe('FileTypeManager', function () { const fileContents = 'Ich bin eine kleine Teekanne, kurz und kräftig.' - beforeEach(function () { - this.isUtf8 = sinon.spy(isUtf8) - this.stats = { + beforeEach(async function (ctx) { + ctx.isUtf8 = sinon.spy(isUtf8) + ctx.stats = { isDirectory: sinon.stub().returns(false), size: 100, } - this.fs = { - stat: sinon.stub().resolves(this.stats), + ctx.fs = { + stat: sinon.stub().resolves(ctx.stats), readFile: sinon.stub(), } - this.fs.readFile + ctx.fs.readFile .withArgs('utf8.tex') .resolves(Buffer.from(fileContents, 'utf-8')) - this.fs.readFile + ctx.fs.readFile .withArgs('utf16.tex') .resolves(Buffer.from(`\uFEFF${fileContents}`, 'utf-16le')) - this.fs.readFile + ctx.fs.readFile .withArgs('latin1.tex') .resolves(Buffer.from(fileContents, 'latin1')) - this.fs.readFile + ctx.fs.readFile .withArgs('latin1-null.tex') .resolves(Buffer.from(`${fileContents}\x00${fileContents}`, 'utf-8')) - this.fs.readFile + ctx.fs.readFile .withArgs('utf8-null.tex') .resolves(Buffer.from(`${fileContents}\x00${fileContents}`, 'utf-8')) - this.fs.readFile + ctx.fs.readFile .withArgs('utf8-non-bmp.tex') .resolves(Buffer.from(`${fileContents}😈`)) - this.fs.readFile + ctx.fs.readFile .withArgs('utf8-control-chars.tex') .resolves(Buffer.from(`${fileContents}\x0c${fileContents}`)) - this.fs.readFile + ctx.fs.readFile .withArgs('text-short.tex') .resolves(Buffer.from('a'.repeat(0.5 * 1024 * 1024), 'utf-8')) - this.fs.readFile + ctx.fs.readFile .withArgs('text-smaller.tex') .resolves(Buffer.from('a'.repeat(2 * 1024 * 1024 - 1), 'utf-8')) - this.fs.readFile + ctx.fs.readFile .withArgs('text-exact.tex') .resolves(Buffer.from('a'.repeat(2 * 1024 * 1024), 'utf-8')) - this.fs.readFile + ctx.fs.readFile .withArgs('text-long.tex') .resolves(Buffer.from('a'.repeat(3 * 1024 * 1024), 'utf-8')) - this.FileTypeManager = SandboxedModule.require(MODULE_PATH, { - requires: { - 'fs/promises': this.fs, - 'utf-8-validate': this.isUtf8, - '@overleaf/settings': Settings, - }, - }) + vi.doMock('fs/promises', () => ({ + default: ctx.fs, + })) + + vi.doMock('utf-8-validate', () => ({ + default: ctx.isUtf8, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: Settings, + })) + + ctx.FileTypeManager = (await import(MODULE_PATH)).default }) describe('isDirectory', function () { describe('when it is a directory', function () { - beforeEach(function () { - this.stats.isDirectory.returns(true) + beforeEach(function (ctx) { + ctx.stats.isDirectory.returns(true) }) - it('should return true', async function () { + it('should return true', async function (ctx) { const result = - await this.FileTypeManager.promises.isDirectory('/some/path') + await ctx.FileTypeManager.promises.isDirectory('/some/path') expect(result).to.equal(true) }) }) describe('when it is not a directory', function () { - beforeEach(function () { - this.stats.isDirectory.returns(false) + beforeEach(function (ctx) { + ctx.stats.isDirectory.returns(false) }) - it('should return false', async function () { + it('should return false', async function (ctx) { const result = - await this.FileTypeManager.promises.isDirectory('/some/path') + await ctx.FileTypeManager.promises.isDirectory('/some/path') expect(result).to.equal(false) }) }) }) describe('isEditable', function () { - it('classifies simple UTF-8 as editable', function () { - expect(this.FileTypeManager.isEditable(fileContents)).to.be.true + it('classifies simple UTF-8 as editable', function (ctx) { + expect(ctx.FileTypeManager.isEditable(fileContents)).to.be.true }) - it('classifies text with non-BMP characters as binary', function () { - expect(this.FileTypeManager.isEditable(`${fileContents}😈`)).to.be.false + it('classifies text with non-BMP characters as binary', function (ctx) { + expect(ctx.FileTypeManager.isEditable(`${fileContents}😈`)).to.be.false }) - it('classifies a .tex file as editable', function () { + it('classifies a .tex file as editable', function (ctx) { expect( - this.FileTypeManager.isEditable(fileContents, { + ctx.FileTypeManager.isEditable(fileContents, { filename: 'some/file.tex', }) ).to.be.true }) - it('classifies a .exe file as binary', function () { + it('classifies a .exe file as binary', function (ctx) { expect( - this.FileTypeManager.isEditable(fileContents, { + ctx.FileTypeManager.isEditable(fileContents, { filename: 'command.exe', }) ).to.be.false @@ -143,8 +148,8 @@ describe('FileTypeManager', function () { '/GNUMakefile', ] TEXT_FILENAMES.forEach(filename => { - it(`should classify ${filename} as text`, async function () { - const { binary } = await this.FileTypeManager.promises.getType( + it(`should classify ${filename} as text`, async function (ctx) { + const { binary } = await ctx.FileTypeManager.promises.getType( filename, 'utf8.tex', null @@ -154,9 +159,9 @@ describe('FileTypeManager', function () { }) }) - it('should not classify short text files as binary', async function () { - this.stats.size = 2 * 1024 * 1024 // 2MB - const { binary } = await this.FileTypeManager.promises.getType( + it('should not classify short text files as binary', async function (ctx) { + ctx.stats.size = 2 * 1024 * 1024 // 2MB + const { binary } = await ctx.FileTypeManager.promises.getType( '/file.tex', 'text-short.tex', null @@ -165,9 +170,9 @@ describe('FileTypeManager', function () { binary.should.equal(false) }) - it('should not classify text files just under the size limit as binary', async function () { - this.stats.size = 2 * 1024 * 1024 // 2MB - const { binary } = await this.FileTypeManager.promises.getType( + it('should not classify text files just under the size limit as binary', async function (ctx) { + ctx.stats.size = 2 * 1024 * 1024 // 2MB + const { binary } = await ctx.FileTypeManager.promises.getType( '/file.tex', 'text-smaller.tex', null @@ -176,9 +181,9 @@ describe('FileTypeManager', function () { binary.should.equal(false) }) - it('should classify text files at the size limit as binary', async function () { - this.stats.size = 2 * 1024 * 1024 // 2MB - const { binary } = await this.FileTypeManager.promises.getType( + it('should classify text files at the size limit as binary', async function (ctx) { + ctx.stats.size = 2 * 1024 * 1024 // 2MB + const { binary } = await ctx.FileTypeManager.promises.getType( '/file.tex', 'text-exact.tex', null @@ -187,9 +192,9 @@ describe('FileTypeManager', function () { binary.should.equal(true) }) - it('should classify long text files as binary', async function () { - this.stats.size = 2 * 1024 * 1024 // 2MB - const { binary } = await this.FileTypeManager.promises.getType( + it('should classify long text files as binary', async function (ctx) { + ctx.stats.size = 2 * 1024 * 1024 // 2MB + const { binary } = await ctx.FileTypeManager.promises.getType( '/file.tex', 'text-long.tex', null @@ -198,9 +203,9 @@ describe('FileTypeManager', function () { binary.should.equal(true) }) - it('should classify large text files as binary', async function () { - this.stats.size = 8 * 1024 * 1024 // 8MB - const { binary } = await this.FileTypeManager.promises.getType( + it('should classify large text files as binary', async function (ctx) { + ctx.stats.size = 8 * 1024 * 1024 // 8MB + const { binary } = await ctx.FileTypeManager.promises.getType( '/file.tex', 'utf8.tex', null @@ -209,55 +214,55 @@ describe('FileTypeManager', function () { binary.should.equal(true) }) - it('should not try to determine the encoding of large files', async function () { - this.stats.size = 8 * 1024 * 1024 // 8MB - await this.FileTypeManager.promises.getType( + it('should not try to determine the encoding of large files', async function (ctx) { + ctx.stats.size = 8 * 1024 * 1024 // 8MB + await ctx.FileTypeManager.promises.getType( '/file.tex', 'utf8.tex', null ) - sinon.assert.notCalled(this.isUtf8) + sinon.assert.notCalled(ctx.isUtf8) }) - it('should detect the encoding of a utf8 file', async function () { - const { encoding } = await this.FileTypeManager.promises.getType( + it('should detect the encoding of a utf8 file', async function (ctx) { + const { encoding } = await ctx.FileTypeManager.promises.getType( '/file.tex', 'utf8.tex', null ) - sinon.assert.calledOnce(this.isUtf8) - this.isUtf8.returned(true).should.equal(true) + sinon.assert.calledOnce(ctx.isUtf8) + ctx.isUtf8.returned(true).should.equal(true) encoding.should.equal('utf-8') }) - it("should return 'latin1' for non-unicode encodings", async function () { - const { encoding } = await this.FileTypeManager.promises.getType( + it("should return 'latin1' for non-unicode encodings", async function (ctx) { + const { encoding } = await ctx.FileTypeManager.promises.getType( '/file.tex', 'latin1.tex', null ) - sinon.assert.calledOnce(this.isUtf8) - this.isUtf8.returned(false).should.equal(true) + sinon.assert.calledOnce(ctx.isUtf8) + ctx.isUtf8.returned(false).should.equal(true) encoding.should.equal('latin1') }) - it('should classify utf16 with BOM as utf-16', async function () { - const { encoding } = await this.FileTypeManager.promises.getType( + it('should classify utf16 with BOM as utf-16', async function (ctx) { + const { encoding } = await ctx.FileTypeManager.promises.getType( '/file.tex', 'utf16.tex', null ) - sinon.assert.calledOnce(this.isUtf8) - this.isUtf8.returned(false).should.equal(true) + sinon.assert.calledOnce(ctx.isUtf8) + ctx.isUtf8.returned(false).should.equal(true) encoding.should.equal('utf-16le') }) - it('should classify latin1 files with a null char as binary', async function () { - const { binary } = await this.FileTypeManager.promises.getType( + it('should classify latin1 files with a null char as binary', async function (ctx) { + const { binary } = await ctx.FileTypeManager.promises.getType( '/file.tex', 'latin1-null.tex', null @@ -265,8 +270,8 @@ describe('FileTypeManager', function () { expect(binary).to.equal(true) }) - it('should classify utf8 files with a null char as binary', async function () { - const { binary } = await this.FileTypeManager.promises.getType( + it('should classify utf8 files with a null char as binary', async function (ctx) { + const { binary } = await ctx.FileTypeManager.promises.getType( '/file.tex', 'utf8-null.tex', null @@ -275,8 +280,8 @@ describe('FileTypeManager', function () { expect(binary).to.equal(true) }) - it('should classify utf8 files with non-BMP chars as binary', async function () { - const { binary } = await this.FileTypeManager.promises.getType( + it('should classify utf8 files with non-BMP chars as binary', async function (ctx) { + const { binary } = await ctx.FileTypeManager.promises.getType( '/file.tex', 'utf8-non-bmp.tex', null @@ -285,13 +290,12 @@ describe('FileTypeManager', function () { expect(binary).to.equal(true) }) - it('should classify utf8 files with ascii control chars as utf-8', async function () { - const { binary, encoding } = - await this.FileTypeManager.promises.getType( - '/file.tex', - 'utf8-control-chars.tex', - null - ) + it('should classify utf8 files with ascii control chars as utf-8', async function (ctx) { + const { binary, encoding } = await ctx.FileTypeManager.promises.getType( + '/file.tex', + 'utf8-control-chars.tex', + null + ) expect(binary).to.equal(false) expect(encoding).to.equal('utf-8') @@ -307,8 +311,8 @@ describe('FileTypeManager', function () { '/tex', ] BINARY_FILENAMES.forEach(filename => { - it(`should classify ${filename} as binary`, async function () { - const { binary } = await this.FileTypeManager.promises.getType( + it(`should classify ${filename} as binary`, async function (ctx) { + const { binary } = await ctx.FileTypeManager.promises.getType( filename, 'latin1.tex', // even if the content is not binary null @@ -318,18 +322,18 @@ describe('FileTypeManager', function () { }) }) - it('should not try to get the character encoding', async function () { - await this.FileTypeManager.promises.getType( + it('should not try to get the character encoding', async function (ctx) { + await ctx.FileTypeManager.promises.getType( '/file.png', 'utf8.tex', null ) - sinon.assert.notCalled(this.isUtf8) + sinon.assert.notCalled(ctx.isUtf8) }) - it('should recognise new binary files as binary', async function () { - const { binary } = await this.FileTypeManager.promises.getType( + it('should recognise new binary files as binary', async function (ctx) { + const { binary } = await ctx.FileTypeManager.promises.getType( '/file.py', 'latin1.tex', null @@ -338,8 +342,8 @@ describe('FileTypeManager', function () { binary.should.equal(true) }) - it('should recognise existing binary files as binary', async function () { - const { binary } = await this.FileTypeManager.promises.getType( + it('should recognise existing binary files as binary', async function (ctx) { + const { binary } = await ctx.FileTypeManager.promises.getType( '/file.py', 'latin1.tex', 'file' @@ -348,8 +352,8 @@ describe('FileTypeManager', function () { binary.should.equal(true) }) - it('should preserve existing non-binary files as non-binary', async function () { - const { binary } = await this.FileTypeManager.promises.getType( + it('should preserve existing non-binary files as non-binary', async function (ctx) { + const { binary } = await ctx.FileTypeManager.promises.getType( '/file.py', 'latin1.tex', 'doc' @@ -361,59 +365,59 @@ describe('FileTypeManager', function () { }) describe('shouldIgnore', function () { - it('should ignore tex auxiliary files', async function () { - const ignore = this.FileTypeManager.shouldIgnore('file.aux') + it('should ignore tex auxiliary files', async function (ctx) { + const ignore = ctx.FileTypeManager.shouldIgnore('file.aux') ignore.should.equal(true) }) - it('should ignore dotfiles', async function () { - const ignore = this.FileTypeManager.shouldIgnore('path/.git') + it('should ignore dotfiles', async function (ctx) { + const ignore = ctx.FileTypeManager.shouldIgnore('path/.git') ignore.should.equal(true) }) - it('should ignore .git directories and contained files', async function () { - const ignore = await this.FileTypeManager.shouldIgnore('path/.git/info') + it('should ignore .git directories and contained files', async function (ctx) { + const ignore = await ctx.FileTypeManager.shouldIgnore('path/.git/info') ignore.should.equal(true) }) - it('should not ignore .latexmkrc dotfile', async function () { - const ignore = this.FileTypeManager.shouldIgnore('path/.latexmkrc') + it('should not ignore .latexmkrc dotfile', async function (ctx) { + const ignore = ctx.FileTypeManager.shouldIgnore('path/.latexmkrc') ignore.should.equal(false) }) - it('should ignore __MACOSX', async function () { - const ignore = this.FileTypeManager.shouldIgnore('path/__MACOSX') + it('should ignore __MACOSX', async function (ctx) { + const ignore = ctx.FileTypeManager.shouldIgnore('path/__MACOSX') ignore.should.equal(true) }) - it('should ignore synctex files', async function () { - const ignore = this.FileTypeManager.shouldIgnore('file.synctex') + it('should ignore synctex files', async function (ctx) { + const ignore = ctx.FileTypeManager.shouldIgnore('file.synctex') ignore.should.equal(true) }) - it('should ignore synctex(busy) files', async function () { - const ignore = this.FileTypeManager.shouldIgnore('file.synctex(busy)') + it('should ignore synctex(busy) files', async function (ctx) { + const ignore = ctx.FileTypeManager.shouldIgnore('file.synctex(busy)') ignore.should.equal(true) }) - it('should not ignore .tex files', async function () { - const ignore = this.FileTypeManager.shouldIgnore('file.tex') + it('should not ignore .tex files', async function (ctx) { + const ignore = ctx.FileTypeManager.shouldIgnore('file.tex') ignore.should.equal(false) }) - it('should ignore the case of the extension', async function () { - const ignore = this.FileTypeManager.shouldIgnore('file.AUX') + it('should ignore the case of the extension', async function (ctx) { + const ignore = ctx.FileTypeManager.shouldIgnore('file.AUX') ignore.should.equal(true) }) - it('should not ignore files with an ignored extension as full name', async function () { - const ignore = this.FileTypeManager.shouldIgnore('dvi') + it('should not ignore files with an ignored extension as full name', async function (ctx) { + const ignore = ctx.FileTypeManager.shouldIgnore('dvi') ignore.should.equal(false) }) - it('should not ignore directories with an ignored extension as full name', async function () { - this.stats.isDirectory.returns(true) - const ignore = this.FileTypeManager.shouldIgnore('dvi') + it('should not ignore directories with an ignored extension as full name', async function (ctx) { + ctx.stats.isDirectory.returns(true) + const ignore = ctx.FileTypeManager.shouldIgnore('dvi') ignore.should.equal(false) }) }) diff --git a/services/web/test/unit/src/User/ThirdPartyIdentityManager.test.mjs b/services/web/test/unit/src/User/ThirdPartyIdentityManager.test.mjs index 2dbbf64991..3e0a57af62 100644 --- a/services/web/test/unit/src/User/ThirdPartyIdentityManager.test.mjs +++ b/services/web/test/unit/src/User/ThirdPartyIdentityManager.test.mjs @@ -1,131 +1,132 @@ -const sinon = require('sinon') -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const OError = require('@overleaf/o-error') -const { - ThirdPartyUserNotFoundError, -} = require('../../../../app/src/Features/Errors/Errors') +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import OError from '@overleaf/o-error' +import { ThirdPartyUserNotFoundError } from '../../../../app/src/Features/Errors/Errors.js' const modulePath = - '../../../../app/src/Features/User/ThirdPartyIdentityManager.js' + '../../../../app/src/Features/User/ThirdPartyIdentityManager.mjs' + +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) describe('ThirdPartyIdentityManager', function () { - beforeEach(function () { - this.userId = 'a1b2c3' - this.user = { - _id: this.userId, + beforeEach(async function (ctx) { + ctx.userId = 'a1b2c3' + ctx.user = { + _id: ctx.userId, email: 'example@overleaf.com', } - this.externalUserId = 'id789' - this.externalData = {} - this.auditLog = { initiatorId: this.userId, ipAddress: '0:0:0:0' } - this.ThirdPartyIdentityManager = SandboxedModule.require(modulePath, { - requires: { - '../../../../app/src/Features/User/UserAuditLogHandler': - (this.UserAuditLogHandler = { - promises: { - addEntry: sinon.stub().resolves(), - }, - }), - '../../../../app/src/Features/Email/EmailHandler': (this.EmailHandler = - { - promises: { - sendEmail: sinon.stub().resolves(), - }, - }), - '../../../../app/src/models/User': { - User: (this.User = { - findOneAndUpdate: sinon - .stub() - .returns({ exec: sinon.stub().resolves(this.user) }), - findOne: sinon.stub().returns({ - exec: sinon.stub().resolves(undefined), - }), - }), + ctx.externalUserId = 'id789' + ctx.externalData = {} + ctx.auditLog = { initiatorId: ctx.userId, ipAddress: '0:0:0:0' } + + vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({ + default: (ctx.UserAuditLogHandler = { + promises: { + addEntry: sinon.stub().resolves(), }, - '@overleaf/settings': { - oauthProviders: { - google: { - name: 'Google', - }, - orcid: { - name: 'ORCID', - }, + }), + })) + + vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({ + default: (ctx.EmailHandler = { + promises: { + sendEmail: sinon.stub().resolves(), + }, + }), + })) + + vi.doMock('../../../../app/src/models/User', () => ({ + User: (ctx.User = { + findOneAndUpdate: sinon + .stub() + .returns({ exec: sinon.stub().resolves(ctx.user) }), + findOne: sinon.stub().returns({ + exec: sinon.stub().resolves(undefined), + }), + }), + })) + + vi.doMock('@overleaf/settings', () => ({ + default: { + oauthProviders: { + google: { + name: 'Google', + }, + orcid: { + name: 'ORCID', }, }, }, - }) + })) + + ctx.ThirdPartyIdentityManager = (await import(modulePath)).default }) describe('getUser', function () { - it('should throw an error when missing providerId or externalUserId', async function () { + it('should throw an error when missing providerId or externalUserId', async function (ctx) { await expect( - this.ThirdPartyIdentityManager.promises.getUser(undefined, undefined) + ctx.ThirdPartyIdentityManager.promises.getUser(undefined, undefined) ).to.be.rejectedWith(OError, `invalid SSO arguments`) }) describe('when user linked', function () { - beforeEach(function () { - this.User.findOne.returns({ - exec: sinon.stub().resolves(this.user), + beforeEach(function (ctx) { + ctx.User.findOne.returns({ + exec: sinon.stub().resolves(ctx.user), }) }) - it('should return the user', async function () { - this.User.findOne.returns({ - exec: sinon.stub().resolves(this.user), + it('should return the user', async function (ctx) { + ctx.User.findOne.returns({ + exec: sinon.stub().resolves(ctx.user), }) - const user = await this.ThirdPartyIdentityManager.promises.getUser( + const user = await ctx.ThirdPartyIdentityManager.promises.getUser( 'google', 'an-id-linked' ) - expect(user).to.deep.equal(this.user) + expect(user).to.deep.equal(ctx.user) }) }) - it('should return ThirdPartyUserNotFoundError when no user linked', async function () { - let error - - try { - await this.ThirdPartyIdentityManager.promises.getUser( + it('should throw ThirdPartyUserNotFoundError when no user linked', async function (ctx) { + await expect( + ctx.ThirdPartyIdentityManager.promises.getUser( 'google', 'an-id-not-linked' ) - } catch (err) { - error = err - } - - expect(error).to.be.instanceOf(ThirdPartyUserNotFoundError) + ).to.be.rejectedWith(ThirdPartyUserNotFoundError) }) }) describe('link', function () { - it('should send email alert', async function () { - await this.ThirdPartyIdentityManager.promises.link( - this.userId, + it('should send email alert', async function (ctx) { + await ctx.ThirdPartyIdentityManager.promises.link( + ctx.userId, 'google', - this.externalUserId, - this.externalData, - this.auditLog + ctx.externalUserId, + ctx.externalData, + ctx.auditLog ) - const emailCall = this.EmailHandler.promises.sendEmail.getCall(0) + const emailCall = ctx.EmailHandler.promises.sendEmail.getCall(0) expect(emailCall.args[0]).to.equal('securityAlert') expect(emailCall.args[1].actionDescribed).to.contain( 'a Google account was linked' ) }) - it('should update user audit log', async function () { - await this.ThirdPartyIdentityManager.promises.link( - this.userId, + it('should update user audit log', async function (ctx) { + await ctx.ThirdPartyIdentityManager.promises.link( + ctx.userId, 'google', - this.externalUserId, - this.externalData, - this.auditLog + ctx.externalUserId, + ctx.externalData, + ctx.auditLog ) expect( - this.UserAuditLogHandler.promises.addEntry + ctx.UserAuditLogHandler.promises.addEntry ).to.have.been.calledOnceWith( - this.userId, + ctx.userId, 'link-sso', - this.auditLog.initiatorId, - this.auditLog.ipAddress, + ctx.auditLog.initiatorId, + ctx.auditLog.ipAddress, { providerId: 'google', } @@ -134,38 +135,38 @@ describe('ThirdPartyIdentityManager', function () { describe('errors', function () { const anError = new Error('oops') - it('should not unlink if the UserAuditLogHandler throws an error', async function () { - this.UserAuditLogHandler.promises.addEntry.throws(anError) + it('should not unlink if the UserAuditLogHandler throws an error', async function (ctx) { + ctx.UserAuditLogHandler.promises.addEntry.throws(anError) await expect( - this.ThirdPartyIdentityManager.promises.link( - this.userId, + ctx.ThirdPartyIdentityManager.promises.link( + ctx.userId, 'google', - this.externalUserId, - this.externalData, - this.auditLog + ctx.externalUserId, + ctx.externalData, + ctx.auditLog ) ).to.be.rejectedWith(anError) - expect(this.User.findOneAndUpdate).to.not.have.been.called + expect(ctx.User.findOneAndUpdate).to.not.have.been.called }) describe('EmailHandler', function () { - beforeEach(function () { - this.EmailHandler.promises.sendEmail.rejects(anError) + beforeEach(function (ctx) { + ctx.EmailHandler.promises.sendEmail.rejects(anError) }) - it('should log but not return the error', async function () { + it('should log but not return the error', async function (ctx) { await expect( - this.ThirdPartyIdentityManager.promises.link( - this.userId, + ctx.ThirdPartyIdentityManager.promises.link( + ctx.userId, 'google', - this.externalUserId, - this.externalData, - this.auditLog + ctx.externalUserId, + ctx.externalData, + ctx.auditLog ) ).to.be.fulfilled - expect(this.logger.error.lastCall).to.be.calledWithExactly( + expect(ctx.logger.error).toBeCalledWith( { err: anError, - userId: this.userId, + userId: ctx.userId, }, 'could not send security alert email when new account linked' ) @@ -175,31 +176,31 @@ describe('ThirdPartyIdentityManager', function () { }) describe('unlink', function () { - it('should send email alert', async function () { - await this.ThirdPartyIdentityManager.promises.unlink( - this.userId, + it('should send email alert', async function (ctx) { + await ctx.ThirdPartyIdentityManager.promises.unlink( + ctx.userId, 'orcid', - this.auditLog + ctx.auditLog ) - const emailCall = this.EmailHandler.promises.sendEmail.getCall(0) + const emailCall = ctx.EmailHandler.promises.sendEmail.getCall(0) expect(emailCall.args[0]).to.equal('securityAlert') expect(emailCall.args[1].actionDescribed).to.contain( 'an ORCID account was unlinked from' ) }) - it('should update user audit log', async function () { - await this.ThirdPartyIdentityManager.promises.unlink( - this.userId, + it('should update user audit log', async function (ctx) { + await ctx.ThirdPartyIdentityManager.promises.unlink( + ctx.userId, 'orcid', - this.auditLog + ctx.auditLog ) expect( - this.UserAuditLogHandler.promises.addEntry + ctx.UserAuditLogHandler.promises.addEntry ).to.have.been.calledOnceWith( - this.userId, + ctx.userId, 'unlink-sso', - this.auditLog.initiatorId, - this.auditLog.ipAddress, + ctx.auditLog.initiatorId, + ctx.auditLog.ipAddress, { providerId: 'orcid', } @@ -209,37 +210,37 @@ describe('ThirdPartyIdentityManager', function () { describe('errors', function () { const anError = new Error('oops') - it('should not unlink if the UserAuditLogHandler throws an error', async function () { - this.UserAuditLogHandler.promises.addEntry.throws(anError) + it('should not unlink if the UserAuditLogHandler throws an error', async function (ctx) { + ctx.UserAuditLogHandler.promises.addEntry.throws(anError) await expect( - this.ThirdPartyIdentityManager.promises.unlink( - this.userId, + ctx.ThirdPartyIdentityManager.promises.unlink( + ctx.userId, 'orcid', - this.auditLog + ctx.auditLog ) ).to.be.rejectedWith(anError) - expect(this.User.findOneAndUpdate).to.not.have.been.called + expect(ctx.User.findOneAndUpdate).to.not.have.been.called }) describe('EmailHandler', function () { - beforeEach(function () { - this.EmailHandler.promises.sendEmail.rejects(anError) + beforeEach(function (ctx) { + ctx.EmailHandler.promises.sendEmail.rejects(anError) }) - it('should log but not return the error', async function () { + it('should log but not return the error', async function (ctx) { await expect( - this.ThirdPartyIdentityManager.promises.unlink( - this.userId, + ctx.ThirdPartyIdentityManager.promises.unlink( + ctx.userId, 'google', - this.auditLog + ctx.auditLog ) ).to.be.fulfilled - expect(this.logger.error.lastCall).to.be.calledWithExactly( + expect(ctx.logger.error).toBeCalledWith( { err: anError, - userId: this.userId, + userId: ctx.userId, }, 'could not send security alert email when account no longer linked' ) diff --git a/services/web/test/unit/src/User/UserAuditLogHandler.test.mjs b/services/web/test/unit/src/User/UserAuditLogHandler.test.mjs index 19f13c0b84..7e3bb5bbb4 100644 --- a/services/web/test/unit/src/User/UserAuditLogHandler.test.mjs +++ b/services/web/test/unit/src/User/UserAuditLogHandler.test.mjs @@ -1,19 +1,22 @@ -const sinon = require('sinon') -const { expect } = require('chai') -const { ObjectId } = require('mongodb-legacy') -const SandboxedModule = require('sandboxed-module') -const { UserAuditLogEntry } = require('../helpers/models/UserAuditLogEntry') +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' +import indirectlyImportModels from '../helpers/indirectlyImportModels.js' + +const { UserAuditLogEntry } = indirectlyImportModels(['UserAuditLogEntry']) + +const { ObjectId } = mongodb const MODULE_PATH = '../../../../app/src/Features/User/UserAuditLogHandler' describe('UserAuditLogHandler', function () { - beforeEach(function () { - this.userId = new ObjectId() - this.initiatorId = new ObjectId() - this.subscriptionId = new ObjectId() - this.action = { + beforeEach(async function (ctx) { + ctx.userId = new ObjectId() + ctx.initiatorId = new ObjectId() + ctx.subscriptionId = new ObjectId() + ctx.action = { operation: 'clear-sessions', - initiatorId: this.initiatorId, + initiatorId: ctx.initiatorId, info: { sessions: [ { @@ -24,71 +27,77 @@ describe('UserAuditLogHandler', function () { }, ip: '0:0:0:0', } - this.UserAuditLogEntryMock = sinon.mock(UserAuditLogEntry) - this.getUniqueManagedSubscriptionMemberOfMock = sinon.stub().resolves() - this.UserAuditLogHandler = SandboxedModule.require(MODULE_PATH, { - requires: { - '../../models/UserAuditLogEntry': { UserAuditLogEntry }, - '../Subscription/SubscriptionLocator': { + ctx.UserAuditLogEntryMock = sinon.mock(UserAuditLogEntry) + ctx.getUniqueManagedSubscriptionMemberOfMock = sinon.stub().resolves() + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator', + () => ({ + default: { promises: { getUniqueManagedSubscriptionMemberOf: - this.getUniqueManagedSubscriptionMemberOfMock, + ctx.getUniqueManagedSubscriptionMemberOfMock, }, }, - }, - }) + }) + ) + + vi.doMock('../../../../app/src/models/UserAuditLogEntry', () => ({ + UserAuditLogEntry, + })) + + ctx.UserAuditLogHandler = (await import(MODULE_PATH)).default }) - afterEach(function () { - this.UserAuditLogEntryMock.restore() + afterEach(function (ctx) { + ctx.UserAuditLogEntryMock.restore() }) describe('addEntry', function () { describe('success', function () { - beforeEach(function () { - this.dbUpdate = this.UserAuditLogEntryMock.expects('create') + beforeEach(function (ctx) { + ctx.dbUpdate = ctx.UserAuditLogEntryMock.expects('create') .chain('exec') .resolves({ modifiedCount: 1 }) }) - it('writes a log', async function () { - await this.UserAuditLogHandler.promises.addEntry( - this.userId, - this.action.operation, - this.action.initiatorId, - this.action.ip, - this.action.info + it('writes a log', async function (ctx) { + await ctx.UserAuditLogHandler.promises.addEntry( + ctx.userId, + ctx.action.operation, + ctx.action.initiatorId, + ctx.action.ip, + ctx.action.info ) - this.UserAuditLogEntryMock.verify() + ctx.UserAuditLogEntryMock.verify() }) - it('updates the log for password reset operation without a initiatorId', async function () { - await this.UserAuditLogHandler.promises.addEntry( - this.userId, + it('updates the log for password reset operation without a initiatorId', async function (ctx) { + await ctx.UserAuditLogHandler.promises.addEntry( + ctx.userId, 'reset-password', undefined, - this.action.ip, - this.action.info + ctx.action.ip, + ctx.action.info ) - this.UserAuditLogEntryMock.verify() + ctx.UserAuditLogEntryMock.verify() }) - it('updates the log for a email removal via script', async function () { - await this.UserAuditLogHandler.promises.addEntry( - this.userId, + it('updates the log for a email removal via script', async function (ctx) { + await ctx.UserAuditLogHandler.promises.addEntry( + ctx.userId, 'remove-email', undefined, - this.action.ip, + ctx.action.ip, { removedEmail: 'foo', script: true, } ) - this.UserAuditLogEntryMock.verify() + ctx.UserAuditLogEntryMock.verify() }) - it('updates the log when no ip address or initiatorId is specified for a group join event', async function () { - await this.UserAuditLogHandler.promises.addEntry( - this.userId, + it('updates the log when no ip address or initiatorId is specified for a group join event', async function (ctx) { + await ctx.UserAuditLogHandler.promises.addEntry( + ctx.userId, 'join-group-subscription', undefined, undefined, @@ -96,79 +105,78 @@ describe('UserAuditLogHandler', function () { subscriptionId: 'foo', } ) - this.UserAuditLogEntryMock.verify() + ctx.UserAuditLogEntryMock.verify() }) - it('includes managedSubscriptionId for managed group user events ', async function () { - await this.UserAuditLogHandler.promises.addEntry( - this.userId, + it('includes managedSubscriptionId for managed group user events ', async function (ctx) { + await ctx.UserAuditLogHandler.promises.addEntry( + ctx.userId, 'reset-password', undefined, - this.action.ip + ctx.action.ip ) - this.UserAuditLogEntryMock.verify() - expect(this.getUniqueManagedSubscriptionMemberOfMock).to.have.been - .called + ctx.UserAuditLogEntryMock.verify() + expect(ctx.getUniqueManagedSubscriptionMemberOfMock).to.have.been.called }) - it('does not includes managedSubscriptionId for events not in the managed group event list', async function () { - await this.UserAuditLogHandler.promises.addEntry( - this.userId, + it('does not includes managedSubscriptionId for events not in the managed group event list', async function (ctx) { + await ctx.UserAuditLogHandler.promises.addEntry( + ctx.userId, 'foo', - this.action.initiatorId, - this.action.ip + ctx.action.initiatorId, + ctx.action.ip ) - this.UserAuditLogEntryMock.verify() - expect(this.getUniqueManagedSubscriptionMemberOfMock).not.to.have.been + ctx.UserAuditLogEntryMock.verify() + expect(ctx.getUniqueManagedSubscriptionMemberOfMock).not.to.have.been .called }) }) describe('errors', function () { describe('missing parameters', function () { - it('throws an error when no operation', async function () { + it('throws an error when no operation', async function (ctx) { await expect( - this.UserAuditLogHandler.promises.addEntry( - this.userId, + ctx.UserAuditLogHandler.promises.addEntry( + ctx.userId, undefined, - this.action.initiatorId, - this.action.ip, - this.action.info + ctx.action.initiatorId, + ctx.action.ip, + ctx.action.info ) ).to.be.rejected }) - it('throws an error when no IP and not excempt', async function () { + it('throws an error when no IP and not excempt', async function (ctx) { await expect( - this.UserAuditLogHandler.promises.addEntry( - this.userId, - this.action.operation, - this.action.initiatorId, + ctx.UserAuditLogHandler.promises.addEntry( + ctx.userId, + ctx.action.operation, + ctx.action.initiatorId, undefined, - this.action.info + ctx.action.info ) ).to.be.rejected }) - it('throws an error when no initiatorId and not a password reset operation', async function () { + it('throws an error when no initiatorId and not a password reset operation', async function (ctx) { await expect( - this.UserAuditLogHandler.promises.addEntry( - this.userId, - this.action.operation, + ctx.UserAuditLogHandler.promises.addEntry( + ctx.userId, + ctx.action.operation, undefined, - this.action.ip, - this.action.info + ctx.action.ip, + ctx.action.info ) ).to.be.rejected }) - it('throws an error when remove-email is not from a script, but has no initiatorId', async function () { + it('throws an error when remove-email is not from a script, but has no initiatorId', async function (ctx) { await expect( - this.UserAuditLogHandler.promises.addEntry( - this.userId, + ctx.UserAuditLogHandler.promises.addEntry( + ctx.userId, 'remove-email', undefined, - this.action.ip, + ctx.action.ip, { removedEmail: 'foo', } diff --git a/services/web/test/unit/src/User/UserGetter.test.mjs b/services/web/test/unit/src/User/UserGetter.test.mjs index 5119d1d807..71c711f320 100644 --- a/services/web/test/unit/src/User/UserGetter.test.mjs +++ b/services/web/test/unit/src/User/UserGetter.test.mjs @@ -1,24 +1,29 @@ -const { ObjectId } = require('mongodb-legacy') -const SandboxedModule = require('sandboxed-module') -const assert = require('assert') -const moment = require('moment') -const path = require('path') -const sinon = require('sinon') -const modulePath = path.join( - __dirname, - '../../../../app/src/Features/User/UserGetter' -) -const { expect } = require('chai') -const Errors = require('../../../../app/src/Features/Errors/Errors') -const { +import { vi, expect } from 'vitest' +import mongodb from 'mongodb-legacy' +import assert from 'assert' +import moment from 'moment' +import path from 'path' +import sinon from 'sinon' +import Errors from '../../../../app/src/Features/Errors/Errors.js' +import { normalizeQuery, normalizeMultiQuery, -} = require('../../../../app/src/Features/Helpers/Mongo') +} from '../../../../app/src/Features/Helpers/Mongo.js' +const modulePath = path.join( + import.meta.dirname, + '../../../../app/src/Features/User/UserGetter' +) + +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) + +const { ObjectId } = mongodb describe('UserGetter', function () { - beforeEach(function () { + beforeEach(async function (ctx) { const confirmedAt = new Date() - this.fakeUser = { + ctx.fakeUser = { _id: new ObjectId(), email: 'email2@foo.bar', emails: [ @@ -31,128 +36,149 @@ describe('UserGetter', function () { { email: 'email2@foo.bar', reversedHostname: 'rab.oof' }, ], } - this.findOne = sinon.stub().resolves(this.fakeUser) - this.findToArrayStub = sinon.stub().resolves([this.fakeUser]) - this.find = sinon.stub().returns({ toArray: this.findToArrayStub }) - this.Mongo = { + ctx.findOne = sinon.stub().resolves(ctx.fakeUser) + ctx.findToArrayStub = sinon.stub().resolves([ctx.fakeUser]) + ctx.find = sinon.stub().returns({ toArray: ctx.findToArrayStub }) + ctx.Mongo = { db: { users: { - findOne: this.findOne, - find: this.find, + findOne: ctx.findOne, + find: ctx.find, }, }, ObjectId, } - this.getUserAffiliations = sinon.stub().resolves([]) + ctx.getUserAffiliations = sinon.stub().resolves([]) - this.Modules = { + ctx.Modules = { promises: { hooks: { fire: sinon.stub().resolves() } }, } - this.AsyncLocalStorage = { + ctx.AsyncLocalStorage = { storage: { getStore: sinon.stub().returns(undefined), }, } - this.UserGetter = SandboxedModule.require(modulePath, { - requires: { - '../Helpers/Mongo': { normalizeQuery, normalizeMultiQuery }, - '../../infrastructure/mongodb': this.Mongo, - '@overleaf/settings': (this.settings = { - reconfirmNotificationDays: 14, - }), - '../Institutions/InstitutionsAPI': { + vi.doMock('../../../../app/src/Features/Helpers/Mongo', () => ({ + normalizeQuery, + normalizeMultiQuery, + })) + + vi.doMock('../../../../app/src/infrastructure/mongodb', () => ctx.Mongo) + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = { + reconfirmNotificationDays: 14, + }), + })) + + vi.doMock( + '../../../../app/src/Features/Institutions/InstitutionsAPI', + () => ({ + default: { promises: { - getUserAffiliations: this.getUserAffiliations, + getUserAffiliations: ctx.getUserAffiliations, }, }, - '../../infrastructure/Features': { - hasFeature: sinon.stub().returns(true), - }, - '../../models/User': { - User: (this.User = {}), - }, - '../../infrastructure/Modules': this.Modules, - '../../infrastructure/AsyncLocalStorage': this.AsyncLocalStorage, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Features', () => ({ + default: { + hasFeature: sinon.stub().returns(true), }, - }) + })) + + vi.doMock('../../../../app/src/models/User', () => ({ + User: (ctx.User = {}), + })) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: ctx.Modules, + })) + + vi.doMock('../../../../app/src/infrastructure/AsyncLocalStorage', () => ({ + default: ctx.AsyncLocalStorage, + })) + + ctx.UserGetter = (await import(modulePath)).default }) describe('getSsoUsersAtInstitution', function () { - it('should throw an error when no projection is passed', async function () { + it('should throw an error when no projection is passed', async function (ctx) { await expect( - this.UserGetter.promises.getSsoUsersAtInstitution(1, undefined) + ctx.UserGetter.promises.getSsoUsersAtInstitution(1, undefined) ).to.be.rejectedWith('missing projection') }) }) describe('getUser', function () { - it('should get user', async function () { + it('should get user', async function (ctx) { const query = { _id: '000000000000000000000000' } const projection = { email: 1 } - const user = await this.UserGetter.promises.getUser(query, projection) - this.findOne.called.should.equal(true) - this.findOne.calledWith(query, { projection }).should.equal(true) - expect(user).to.deep.equal(this.fakeUser) + const user = await ctx.UserGetter.promises.getUser(query, projection) + ctx.findOne.called.should.equal(true) + ctx.findOne.calledWith(query, { projection }).should.equal(true) + expect(user).to.deep.equal(ctx.fakeUser) }) - it('should not allow null query', async function () { + it('should not allow null query', async function (ctx) { await expect( - this.UserGetter.promises.getUser(null, {}) + ctx.UserGetter.promises.getUser(null, {}) ).to.be.rejectedWith('no query provided') }) }) describe('getUsers', function () { - it('should get users with array of userIds', async function () { + it('should get users with array of userIds', async function (ctx) { const query = [new ObjectId()] const projection = { email: 1 } - const users = await this.UserGetter.promises.getUsers(query, projection) - this.find.should.have.been.calledWithMatch( + const users = await ctx.UserGetter.promises.getUsers(query, projection) + ctx.find.should.have.been.calledWithMatch( { _id: { $in: query } }, { projection } ) - users.should.deep.equal([this.fakeUser]) + users.should.deep.equal([ctx.fakeUser]) }) - it('should not call mongo with empty list', async function () { + it('should not call mongo with empty list', async function (ctx) { const query = [] const projection = { email: 1 } - const users = await this.UserGetter.promises.getUsers(query, projection) + const users = await ctx.UserGetter.promises.getUsers(query, projection) expect(users).to.deep.equal([]) - expect(this.find).to.not.have.been.called + expect(ctx.find).to.not.have.been.called }) - it('should not allow null query', async function () { + it('should not allow null query', async function (ctx) { await expect( - this.UserGetter.promises.getUsers(null, {}) + ctx.UserGetter.promises.getUsers(null, {}) ).to.be.rejectedWith('no query provided') }) }) describe('getUserFullEmails', function () { - it('should get user', async function () { - this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) + it('should get user', async function (ctx) { + ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser) const projection = { email: 1, emails: 1, samlIdentifiers: 1 } - await this.UserGetter.promises.getUserFullEmails(this.fakeUser._id) - this.UserGetter.promises.getUser.called.should.equal(true) - this.UserGetter.promises.getUser - .calledWith(this.fakeUser._id, projection) + await ctx.UserGetter.promises.getUserFullEmails(ctx.fakeUser._id) + ctx.UserGetter.promises.getUser.called.should.equal(true) + ctx.UserGetter.promises.getUser + .calledWith(ctx.fakeUser._id, projection) .should.equal(true) }) - it('should fetch emails data', async function () { - this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + it('should fetch emails data', async function (ctx) { + ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) assert.deepEqual(fullEmails, [ { email: 'email1@foo.bar', reversedHostname: 'rab.oof', - confirmedAt: this.fakeUser.emails[0].confirmedAt, - lastConfirmedAt: this.fakeUser.emails[0].lastConfirmedAt, + confirmedAt: ctx.fakeUser.emails[0].confirmedAt, + lastConfirmedAt: ctx.fakeUser.emails[0].lastConfirmedAt, emailHasInstitutionLicence: false, default: false, }, @@ -166,8 +192,8 @@ describe('UserGetter', function () { ]) }) - it('should merge affiliation data', async function () { - this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) + it('should merge affiliation data', async function (ctx) { + ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser) const affiliationsData = [ { email: 'email1@foo.bar', @@ -188,17 +214,17 @@ describe('UserGetter', function () { portal: undefined, }, ] - this.getUserAffiliations.resolves(affiliationsData) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.getUserAffiliations.resolves(affiliationsData) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) assert.deepEqual(fullEmails, [ { email: 'email1@foo.bar', reversedHostname: 'rab.oof', - confirmedAt: this.fakeUser.emails[0].confirmedAt, - lastConfirmedAt: this.fakeUser.emails[0].lastConfirmedAt, + confirmedAt: ctx.fakeUser.emails[0].confirmedAt, + lastConfirmedAt: ctx.fakeUser.emails[0].lastConfirmedAt, default: false, emailHasInstitutionLicence: true, affiliation: { @@ -228,25 +254,25 @@ describe('UserGetter', function () { ]) }) - it('should merge SAML identifier', async function () { + it('should merge SAML identifier', async function (ctx) { const fakeSamlIdentifiers = [ { providerId: 'saml_id', externalUserId: 'whatever' }, ] - const fakeUserWithSaml = this.fakeUser + const fakeUserWithSaml = ctx.fakeUser fakeUserWithSaml.emails[0].samlProviderId = 'saml_id' fakeUserWithSaml.samlIdentifiers = fakeSamlIdentifiers - this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) - this.getUserAffiliations.resolves([]) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser) + ctx.getUserAffiliations.resolves([]) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) assert.deepEqual(fullEmails, [ { email: 'email1@foo.bar', reversedHostname: 'rab.oof', - confirmedAt: this.fakeUser.emails[0].confirmedAt, - lastConfirmedAt: this.fakeUser.emails[0].lastConfirmedAt, + confirmedAt: ctx.fakeUser.emails[0].confirmedAt, + lastConfirmedAt: ctx.fakeUser.emails[0].lastConfirmedAt, default: false, emailHasInstitutionLicence: false, samlProviderId: 'saml_id', @@ -262,21 +288,21 @@ describe('UserGetter', function () { ]) }) - it('should get user when it has no emails field', async function () { - this.fakeUserNoEmails = { + it('should get user when it has no emails field', async function (ctx) { + ctx.fakeUserNoEmails = { _id: '12390i', email: 'email2@foo.bar', } - this.UserGetter.promises.getUser = sinon + ctx.UserGetter.promises.getUser = sinon .stub() - .resolves(this.fakeUserNoEmails) + .resolves(ctx.fakeUserNoEmails) const projection = { email: 1, emails: 1, samlIdentifiers: 1 } - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUserNoEmails._id + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUserNoEmails._id ) - this.UserGetter.promises.getUser.called.should.equal(true) - this.UserGetter.promises.getUser - .calledWith(this.fakeUserNoEmails._id, projection) + ctx.UserGetter.promises.getUser.called.should.equal(true) + ctx.UserGetter.promises.getUser + .calledWith(ctx.fakeUserNoEmails._id, projection) .should.equal(true) assert.deepEqual(fullEmails, []) }) @@ -322,7 +348,7 @@ describe('UserGetter', function () { institution: institutionNonSSO, }, ] - it('should flag inReconfirmNotificationPeriod for all affiliations in period', async function () { + it('should flag inReconfirmNotificationPeriod for all affiliations in period', async function (ctx) { const { maxConfirmationMonths } = institutionNonSSO const confirmed1 = moment() .subtract(maxConfirmationMonths + 2, 'months') @@ -356,10 +382,10 @@ describe('UserGetter', function () { const affiliations = [...affiliationsData] affiliations[0].last_day_to_reconfirm = lastDayToReconfirm1 affiliations[1].last_day_to_reconfirm = lastDayToReconfirm2 - this.getUserAffiliations.resolves(affiliations) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.getUserAffiliations.resolves(affiliations) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod @@ -369,7 +395,7 @@ describe('UserGetter', function () { ).to.equal(true) }) - it('should not flag affiliations outside of notification period', async function () { + it('should not flag affiliations outside of notification period', async function (ctx) { const { maxConfirmationMonths } = institutionNonSSO const confirmed1 = new Date() const lastDayToReconfirm1 = moment(confirmed1) @@ -402,10 +428,10 @@ describe('UserGetter', function () { const affiliations = [...affiliationsData] affiliations[0].last_day_to_reconfirm = lastDayToReconfirm1 affiliations[1].last_day_to_reconfirm = lastDayToReconfirm2 - this.getUserAffiliations.resolves(affiliations) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.getUserAffiliations.resolves(affiliations) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod @@ -417,7 +443,7 @@ describe('UserGetter', function () { }) describe('SSO institutions', function () { - it('should flag only linked email, if in notification period', async function () { + it('should flag only linked email, if in notification period', async function (ctx) { const { maxConfirmationMonths } = institutionSSO const email1 = 'email1@sso.bar' const email2 = 'email2@sso.bar' @@ -487,10 +513,10 @@ describe('UserGetter', function () { last_day_to_reconfirm: lastDayToReconfirm, }, ] - this.getUserAffiliations.resolves(affiliations) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.getUserAffiliations.resolves(affiliations) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod @@ -505,7 +531,7 @@ describe('UserGetter', function () { }) describe('multiple institution affiliations', function () { - it('should flag each institution', async function () { + it('should flag each institution', async function (ctx) { const { maxConfirmationMonths } = institutionSSO const email1 = 'email1@sso.bar' const email2 = 'email2@sso.bar' @@ -593,10 +619,10 @@ describe('UserGetter', function () { ], } - this.getUserAffiliations.resolves(affiliationsData) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.getUserAffiliations.resolves(affiliationsData) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod @@ -614,7 +640,7 @@ describe('UserGetter', function () { }) describe('reconfirmedAt', function () { - it('only use confirmedAt when no reconfirmedAt', async function () { + it('only use confirmedAt when no reconfirmedAt', async function (ctx) { const { maxConfirmationMonths } = institutionSSO const email1 = 'email1@foo.bar' const reconfirmed1 = moment().subtract( @@ -704,10 +730,10 @@ describe('UserGetter', function () { }, ], } - this.getUserAffiliations.resolves(affiliationsData) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.getUserAffiliations.resolves(affiliationsData) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod @@ -723,7 +749,7 @@ describe('UserGetter', function () { describe('before reconfirmation period expires and within reconfirmation notification period', function () { const email = 'leonard@example-affiliation.com' - it('should flag the email', async function () { + it('should flag the email', async function (ctx) { const { maxConfirmationMonths } = institutionNonSSO const confirmedAt = moment() .subtract(maxConfirmationMonths, 'months') @@ -753,10 +779,10 @@ describe('UserGetter', function () { }, ], } - this.getUserAffiliations.resolves(affiliationsData) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.getUserAffiliations.resolves(affiliationsData) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod @@ -765,7 +791,7 @@ describe('UserGetter', function () { }) describe('when no Settings.reconfirmNotificationDays', function () { - it('should always return inReconfirmNotificationPeriod:false', async function () { + it('should always return inReconfirmNotificationPeriod:false', async function (ctx) { const email1 = 'email1@sso.bar' const email2 = 'email2@foo.bar' const email3 = 'email3@foo.bar' @@ -814,11 +840,11 @@ describe('UserGetter', function () { }, ], } - this.settings.reconfirmNotificationDays = undefined - this.getUserAffiliations.resolves(affiliationsData) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.settings.reconfirmNotificationDays = undefined + ctx.getUserAffiliations.resolves(affiliationsData) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod @@ -832,7 +858,7 @@ describe('UserGetter', function () { }) }) - it('should flag to show notification if v1 shows as past reconfirmation but v2 does not', async function () { + it('should flag to show notification if v1 shows as past reconfirmation but v2 does not', async function (ctx) { const email = 'abc123@test.com' const confirmedAt = new Date() const affiliationsData = [ @@ -855,17 +881,17 @@ describe('UserGetter', function () { }, ], } - this.getUserAffiliations.resolves(affiliationsData) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.getUserAffiliations.resolves(affiliationsData) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod ).to.equal(true) }) - it('should flag to show notification if v1 shows as reconfirmation upcoming but v2 does not', async function () { + it('should flag to show notification if v1 shows as reconfirmation upcoming but v2 does not', async function (ctx) { const email = 'abc123@test.com' const { maxConfirmationMonths } = institutionNonSSO const affiliationsData = [ @@ -890,17 +916,17 @@ describe('UserGetter', function () { }, ], } - this.getUserAffiliations.resolves(affiliationsData) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.getUserAffiliations.resolves(affiliationsData) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod ).to.equal(true) }) - it('should flag to show notification if v2 shows as reconfirmation upcoming but v1 does not', async function () { + it('should flag to show notification if v2 shows as reconfirmation upcoming but v1 does not', async function (ctx) { const email = 'abc123@test.com' const { maxConfirmationMonths } = institutionNonSSO @@ -930,10 +956,10 @@ describe('UserGetter', function () { }, ], } - this.getUserAffiliations.resolves(affiliationsData) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.getUserAffiliations.resolves(affiliationsData) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect( fullEmails[0].affiliation.inReconfirmNotificationPeriod @@ -965,35 +991,35 @@ describe('UserGetter', function () { ], } - it('should set cachedLastDayToReconfirm for SSO institutions if email is linked to SSO', async function () { + it('should set cachedLastDayToReconfirm for SSO institutions if email is linked to SSO', async function (ctx) { const userLinked = Object.assign({}, user) userLinked.emails[0].samlProviderId = institutionSSO.id.toString() - this.getUserAffiliations.resolves(affiliationsData) - this.UserGetter.promises.getUser = sinon.stub().resolves(userLinked) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + ctx.getUserAffiliations.resolves(affiliationsData) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(userLinked) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect(fullEmails[0].affiliation.cachedLastDayToReconfirm).to.equal( lastDay ) }) - it('should NOT set cachedLastDayToReconfirm for SSO institutions if email is NOT linked to SSO', async function () { - this.getUserAffiliations.resolves(affiliationsData) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + it('should NOT set cachedLastDayToReconfirm for SSO institutions if email is NOT linked to SSO', async function (ctx) { + ctx.getUserAffiliations.resolves(affiliationsData) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect(fullEmails[0].affiliation.cachedLastDayToReconfirm).to.equal( lastDay ) }) - it('should set cachedLastDayToReconfirm for non-SSO institutions', async function () { - this.getUserAffiliations.resolves(affiliationsData) - this.UserGetter.promises.getUser = sinon.stub().resolves(user) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + it('should set cachedLastDayToReconfirm for non-SSO institutions', async function (ctx) { + ctx.getUserAffiliations.resolves(affiliationsData) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(user) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) expect(fullEmails[0].affiliation.cachedLastDayToReconfirm).to.equal( lastDay @@ -1003,63 +1029,63 @@ describe('UserGetter', function () { }) describe('caching full emails data if run inside AsyncLocalStorage context', function () { - it('should store the data in the AsyncLocalStorage store', async function () { - this.store = {} - this.AsyncLocalStorage.storage.getStore.returns(this.store) - this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) - this.getUserAffiliations.resolves([ + it('should store the data in the AsyncLocalStorage store', async function (ctx) { + ctx.store = {} + ctx.AsyncLocalStorage.storage.getStore.returns(ctx.store) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser) + ctx.getUserAffiliations.resolves([ { email: 'email1@foo.bar', licence: 'professional', institution: {}, }, ]) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id ) - expect(this.UserGetter.promises.getUser).to.have.been.calledOnce - expect(this.getUserAffiliations).to.have.been.calledOnce + expect(ctx.UserGetter.promises.getUser).to.have.been.calledOnce + expect(ctx.getUserAffiliations).to.have.been.calledOnce expect(fullEmails).to.be.an('array') expect(fullEmails.length).to.equal(2) - expect(this.store.userFullEmails[this.fakeUser._id]).to.deep.equal( + expect(ctx.store.userFullEmails[ctx.fakeUser._id]).to.deep.equal( fullEmails ) }) - it('should fetch data from the store if available', async function () { - this.store = { + it('should fetch data from the store if available', async function (ctx) { + ctx.store = { userFullEmails: { - [this.fakeUser._id]: [{ email: '1' }, { email: '2' }], + [ctx.fakeUser._id]: [{ email: '1' }, { email: '2' }], }, } - this.AsyncLocalStorage.storage.getStore.returns(this.store) - this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id, - this.req + ctx.AsyncLocalStorage.storage.getStore.returns(ctx.store) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id, + ctx.req ) - expect(this.UserGetter.promises.getUser).to.not.have.been.called - expect(this.getUserAffiliations).to.not.have.been.called + expect(ctx.UserGetter.promises.getUser).to.not.have.been.called + expect(ctx.getUserAffiliations).to.not.have.been.called expect(fullEmails).to.be.an('array') expect(fullEmails.length).to.equal(2) - expect(this.store.userFullEmails[this.fakeUser._id]).to.deep.equal( + expect(ctx.store.userFullEmails[ctx.fakeUser._id]).to.deep.equal( fullEmails ) }) - it('should not return cached data for different user ids', async function () { - this.store = {} - this.AsyncLocalStorage.storage.getStore.returns(this.store) - this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) - const fullEmails = await this.UserGetter.promises.getUserFullEmails( - this.fakeUser._id, - this.req + it('should not return cached data for different user ids', async function (ctx) { + ctx.store = {} + ctx.AsyncLocalStorage.storage.getStore.returns(ctx.store) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser) + const fullEmails = await ctx.UserGetter.promises.getUserFullEmails( + ctx.fakeUser._id, + ctx.req ) - expect(this.UserGetter.promises.getUser).to.have.been.calledOnce - expect(this.getUserAffiliations).to.have.been.calledOnce + expect(ctx.UserGetter.promises.getUser).to.have.been.calledOnce + expect(ctx.getUserAffiliations).to.have.been.calledOnce expect(fullEmails).to.be.an('array') expect(fullEmails.length).to.equal(2) - this.otherUser = { + ctx.otherUser = { _id: new ObjectId(), email: 'other@foo.bar', emails: [ @@ -1071,28 +1097,27 @@ describe('UserGetter', function () { }, ], } - this.UserGetter.promises.getUser.resolves(this.otherUser) - this.getUserAffiliations.resolves([ + ctx.UserGetter.promises.getUser.resolves(ctx.otherUser) + ctx.getUserAffiliations.resolves([ { email: 'other@foo.bar', licence: 'professional', institution: {}, }, ]) - const fullEmailsOther = - await this.UserGetter.promises.getUserFullEmails( - this.otherUser._id, - this.req - ) - expect(this.UserGetter.promises.getUser).to.have.been.calledTwice - expect(this.getUserAffiliations).to.have.been.calledTwice + const fullEmailsOther = await ctx.UserGetter.promises.getUserFullEmails( + ctx.otherUser._id, + ctx.req + ) + expect(ctx.UserGetter.promises.getUser).to.have.been.calledTwice + expect(ctx.getUserAffiliations).to.have.been.calledTwice expect(fullEmailsOther).to.not.deep.equal(fullEmails) expect(fullEmailsOther).to.be.an('array') expect(fullEmailsOther.length).to.equal(1) - expect(this.store.userFullEmails[this.fakeUser._id]).to.deep.equal( + expect(ctx.store.userFullEmails[ctx.fakeUser._id]).to.deep.equal( fullEmails ) - expect(this.store.userFullEmails[this.otherUser._id]).to.deep.equal( + expect(ctx.store.userFullEmails[ctx.otherUser._id]).to.deep.equal( fullEmailsOther ) }) @@ -1100,8 +1125,8 @@ describe('UserGetter', function () { }) describe('getUserConfirmedEmails', function () { - beforeEach(function () { - this.fakeUser = { + beforeEach(function (ctx) { + ctx.fakeUser = { emails: [ { email: 'email1@foo.bar', @@ -1116,21 +1141,21 @@ describe('UserGetter', function () { }, ], } - this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser) + ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser) }) - it('should get user', async function () { + it('should get user', async function (ctx) { const projection = { emails: 1 } - await this.UserGetter.promises.getUserConfirmedEmails(this.fakeUser._id) + await ctx.UserGetter.promises.getUserConfirmedEmails(ctx.fakeUser._id) - this.UserGetter.promises.getUser - .calledWith(this.fakeUser._id, projection) + ctx.UserGetter.promises.getUser + .calledWith(ctx.fakeUser._id, projection) .should.equal(true) }) - it('should return only confirmed emails', async function () { + it('should return only confirmed emails', async function (ctx) { const confirmedEmails = - await this.UserGetter.promises.getUserConfirmedEmails(this.fakeUser._id) + await ctx.UserGetter.promises.getUserConfirmedEmails(ctx.fakeUser._id) expect(confirmedEmails.length).to.equal(2) expect(confirmedEmails[0].email).to.equal('email1@foo.bar') @@ -1139,85 +1164,85 @@ describe('UserGetter', function () { }) describe('getUserbyMainEmail', function () { - it('query user by main email', async function () { + it('query user by main email', async function (ctx) { const email = 'hello@world.com' const projection = { emails: 1 } - await this.UserGetter.promises.getUserByMainEmail(email, projection) - this.findOne.called.should.equal(true) - this.findOne.calledWith({ email }, { projection }).should.equal(true) + await ctx.UserGetter.promises.getUserByMainEmail(email, projection) + ctx.findOne.called.should.equal(true) + ctx.findOne.calledWith({ email }, { projection }).should.equal(true) }) - it('return user if found', async function () { + it('return user if found', async function (ctx) { const email = 'hello@world.com' - const user = await this.UserGetter.promises.getUserByMainEmail(email) - user.should.deep.equal(this.fakeUser) + const user = await ctx.UserGetter.promises.getUserByMainEmail(email) + user.should.deep.equal(ctx.fakeUser) }) - it('trim email', async function () { + it('trim email', async function (ctx) { const email = 'hello@world.com' - await this.UserGetter.promises.getUserByMainEmail(` ${email} `) - this.findOne.called.should.equal(true) - this.findOne.calledWith({ email }).should.equal(true) + await ctx.UserGetter.promises.getUserByMainEmail(` ${email} `) + ctx.findOne.called.should.equal(true) + ctx.findOne.calledWith({ email }).should.equal(true) }) }) describe('getUserByAnyEmail', function () { - it('query user for any email', async function () { + it('query user for any email', async function (ctx) { const email = 'hello@world.com' const expectedQuery = { emails: { $exists: true }, 'emails.email': email, } const projection = { emails: 1 } - const user = await this.UserGetter.promises.getUserByAnyEmail( + const user = await ctx.UserGetter.promises.getUserByAnyEmail( ` ${email} `, projection ) - this.findOne.calledWith(expectedQuery, { projection }).should.equal(true) - user.should.deep.equal(this.fakeUser) + ctx.findOne.calledWith(expectedQuery, { projection }).should.equal(true) + user.should.deep.equal(ctx.fakeUser) }) - it('query contains $exists:true so partial index is used', async function () { + it('query contains $exists:true so partial index is used', async function (ctx) { const expectedQuery = { emails: { $exists: true }, 'emails.email': '', } - await this.UserGetter.promises.getUserByAnyEmail('', {}) - this.findOne + await ctx.UserGetter.promises.getUserByAnyEmail('', {}) + ctx.findOne .calledWith(expectedQuery, { projection: {} }) .should.equal(true) }) - it('checks main email as well', async function () { - this.findOne.resolves(null) + it('checks main email as well', async function (ctx) { + ctx.findOne.resolves(null) const email = 'hello@world.com' const projection = { emails: 1 } - await this.UserGetter.promises.getUserByAnyEmail(` ${email} `, projection) - this.findOne.calledTwice.should.equal(true) - this.findOne.calledWith({ email }, { projection }).should.equal(true) + await ctx.UserGetter.promises.getUserByAnyEmail(` ${email} `, projection) + ctx.findOne.calledTwice.should.equal(true) + ctx.findOne.calledWith({ email }, { projection }).should.equal(true) }) }) describe('getUsersByHostname', function () { - it('should find user by hostname', async function () { + it('should find user by hostname', async function (ctx) { const hostname = 'bar.foo' const expectedQuery = { emails: { $exists: true }, 'emails.reversedHostname': hostname.split('').reverse().join(''), } const projection = { emails: 1 } - await this.UserGetter.promises.getUsersByHostname(hostname, projection) - this.find.calledOnce.should.equal(true) - this.find.calledWith(expectedQuery, { projection }).should.equal(true) + await ctx.UserGetter.promises.getUsersByHostname(hostname, projection) + ctx.find.calledOnce.should.equal(true) + ctx.find.calledWith(expectedQuery, { projection }).should.equal(true) }) }) describe('getUsersByAnyConfirmedEmail', function () { - it('should find users by confirmed email', async function () { + it('should find users by confirmed email', async function (ctx) { const emails = ['confirmed@example.com'] - await this.UserGetter.promises.getUsersByAnyConfirmedEmail(emails) - expect(this.find).to.be.calledOnceWith( + await ctx.UserGetter.promises.getUsersByAnyConfirmedEmail(emails) + expect(ctx.find).to.be.calledOnceWith( { 'emails.email': { $in: emails }, // use the index on emails.email emails: { @@ -1234,85 +1259,85 @@ describe('UserGetter', function () { }) describe('getUsersByV1Id', function () { - it('should find users by list of v1 ids', async function () { + it('should find users by list of v1 ids', async function (ctx) { const v1Ids = [501] const expectedQuery = { 'overleaf.id': { $in: v1Ids }, } const projection = { emails: 1 } - await this.UserGetter.promises.getUsersByV1Ids(v1Ids, projection) - this.find.calledOnce.should.equal(true) - this.find.calledWith(expectedQuery, { projection }).should.equal(true) + await ctx.UserGetter.promises.getUsersByV1Ids(v1Ids, projection) + ctx.find.calledOnce.should.equal(true) + ctx.find.calledWith(expectedQuery, { projection }).should.equal(true) }) }) describe('ensureUniqueEmailAddress', function () { - beforeEach(function () { - this.UserGetter.promises.getUserByAnyEmail = sinon.stub() + beforeEach(function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail = sinon.stub() }) - it('should return error if existing user is found', async function () { - this.UserGetter.promises.getUserByAnyEmail.resolves(this.fakeUser) + it('should return error if existing user is found', async function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.fakeUser) await expect( - this.UserGetter.promises.ensureUniqueEmailAddress(this.newEmail) + ctx.UserGetter.promises.ensureUniqueEmailAddress(ctx.newEmail) ).to.be.rejectedWith(Errors.EmailExistsError) }) - it('should return null if no user is found', async function () { - this.UserGetter.promises.getUserByAnyEmail.resolves(null) + it('should return null if no user is found', async function (ctx) { + ctx.UserGetter.promises.getUserByAnyEmail.resolves(null) await expect( - this.UserGetter.promises.ensureUniqueEmailAddress(this.newEmail) + ctx.UserGetter.promises.ensureUniqueEmailAddress(ctx.newEmail) ).to.be.fulfilled }) }) describe('getUserFeatures', function () { - beforeEach(function () { - this.Modules.promises.hooks.fire = sinon.stub().resolves() - this.fakeUser.features = {} + beforeEach(function (ctx) { + ctx.Modules.promises.hooks.fire = sinon.stub().resolves() + ctx.fakeUser.features = {} }) - it('should return user features', async function () { - this.fakeUser.features = { feature1: true, feature2: false } - const features = await this.UserGetter.promises.getUserFeatures( + it('should return user features', async function (ctx) { + ctx.fakeUser.features = { feature1: true, feature2: false } + const features = await ctx.UserGetter.promises.getUserFeatures( new ObjectId() ) - expect(features).to.deep.equal(this.fakeUser.features) + expect(features).to.deep.equal(ctx.fakeUser.features) }) - it('should return user features when using promises', async function () { - this.fakeUser.features = { feature1: true, feature2: false } - const features = await this.UserGetter.promises.getUserFeatures( - this.fakeUser._id + it('should return user features when using promises', async function (ctx) { + ctx.fakeUser.features = { feature1: true, feature2: false } + const features = await ctx.UserGetter.promises.getUserFeatures( + ctx.fakeUser._id ) - expect(features).to.deep.equal(this.fakeUser.features) + expect(features).to.deep.equal(ctx.fakeUser.features) }) - it('should take into account features overrides from modules', async function () { + it('should take into account features overrides from modules', async function (ctx) { // this case occurs when the user has bought the ai bundle on WF, which should include our error assistant const bundleFeatures = { aiErrorAssistant: true } - this.fakeUser.features = { aiErrorAssistant: false } - this.Modules.promises.hooks.fire = sinon.stub().resolves([bundleFeatures]) - const features = await this.UserGetter.promises.getUserFeatures( - this.fakeUser._id + ctx.fakeUser.features = { aiErrorAssistant: false } + ctx.Modules.promises.hooks.fire = sinon.stub().resolves([bundleFeatures]) + const features = await ctx.UserGetter.promises.getUserFeatures( + ctx.fakeUser._id ) expect(features).to.deep.equal(bundleFeatures) - this.Modules.promises.hooks.fire.should.have.been.calledWith( + ctx.Modules.promises.hooks.fire.should.have.been.calledWith( 'getModuleProvidedFeatures', - this.fakeUser._id + ctx.fakeUser._id ) }) - it('should handle modules not returning any features', async function () { - this.Modules.promises.hooks.fire = sinon.stub().resolves([]) - this.fakeUser.features = { test: true } - const features = await this.UserGetter.promises.getUserFeatures( - this.fakeUser._id + it('should handle modules not returning any features', async function (ctx) { + ctx.Modules.promises.hooks.fire = sinon.stub().resolves([]) + ctx.fakeUser.features = { test: true } + const features = await ctx.UserGetter.promises.getUserFeatures( + ctx.fakeUser._id ) expect(features).to.deep.equal({ test: true }) - this.Modules.promises.hooks.fire.should.have.been.calledWith( + ctx.Modules.promises.hooks.fire.should.have.been.calledWith( 'getModuleProvidedFeatures', - this.fakeUser._id + ctx.fakeUser._id ) }) }) diff --git a/services/web/test/unit/src/User/UserSessionsManager.test.mjs b/services/web/test/unit/src/User/UserSessionsManager.test.mjs index 872ea4c56a..90101580a5 100644 --- a/services/web/test/unit/src/User/UserSessionsManager.test.mjs +++ b/services/web/test/unit/src/User/UserSessionsManager.test.mjs @@ -1,17 +1,17 @@ -const sinon = require('sinon') -const { expect } = require('chai') -const modulePath = '../../../../app/src/Features/User/UserSessionsManager.js' -const SandboxedModule = require('sandboxed-module') +import { vi, expect } from 'vitest' +import sinon from 'sinon' + +const modulePath = '../../../../app/src/Features/User/UserSessionsManager.mjs' describe('UserSessionsManager', function () { - beforeEach(function () { - this.user = { + beforeEach(async function (ctx) { + ctx.user = { _id: 'abcd', email: 'user@example.com', } - this.sessionId = 'some_session_id' + ctx.sessionId = 'some_session_id' - this.rclient = { + ctx.rclient = { multi: sinon.stub(), exec: sinon.stub(), get: sinon.stub(), @@ -22,348 +22,338 @@ describe('UserSessionsManager', function () { mget: sinon.stub(), pexpire: sinon.stub(), } - this.rclient.multi.returns({ + ctx.rclient.multi.returns({ sadd: sinon.stub().returnsThis(), srem: sinon.stub().returnsThis(), pexpire: sinon.stub().returnsThis(), exec: sinon.stub().resolves(), }) - this.rclient.get.resolves() - this.rclient.del.resolves() - this.rclient.sadd.resolves() - this.rclient.srem.resolves() - this.rclient.smembers.resolves([]) - this.rclient.pexpire.resolves() + ctx.rclient.get.resolves() + ctx.rclient.del.resolves() + ctx.rclient.sadd.resolves() + ctx.rclient.srem.resolves() + ctx.rclient.smembers.resolves([]) + ctx.rclient.pexpire.resolves() - this.UserSessionsRedis = { - client: () => this.rclient, + ctx.UserSessionsRedis = { + client: () => ctx.rclient, sessionSetKey: user => `UserSessions:{${user._id}}`, } - this.settings = { + ctx.settings = { redis: { web: {}, }, } - return (this.UserSessionsManager = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': this.settings, - './UserSessionsRedis': this.UserSessionsRedis, - }, + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, })) + + vi.doMock('../../../../app/src/Features/User/UserSessionsRedis', () => ({ + default: ctx.UserSessionsRedis, + })) + + return (ctx.UserSessionsManager = (await import(modulePath)).default) }) describe('_sessionKey', function () { - it('should build the correct key', function () { - const result = this.UserSessionsManager._sessionKey(this.sessionId) + it('should build the correct key', function (ctx) { + const result = ctx.UserSessionsManager._sessionKey(ctx.sessionId) return result.should.equal('sess:some_session_id') }) }) describe('trackSession', function () { - beforeEach(function () { - this._checkSessions = sinon - .stub(this.UserSessionsManager.promises, '_checkSessions') + beforeEach(function (ctx) { + ctx._checkSessions = sinon + .stub(ctx.UserSessionsManager.promises, '_checkSessions') .resolves() }) - afterEach(function () { - return this._checkSessions.restore() + afterEach(function (ctx) { + return ctx._checkSessions.restore() }) - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.trackSession( - this.user, - this.sessionId + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.trackSession( + ctx.user, + ctx.sessionId ) }) - it('should call the appropriate redis methods', async function () { - await this.UserSessionsManager.promises.trackSession( - this.user, - this.sessionId + it('should call the appropriate redis methods', async function (ctx) { + await ctx.UserSessionsManager.promises.trackSession( + ctx.user, + ctx.sessionId ) - this.rclient.multi.callCount.should.equal(1) - const multiInstance = this.rclient.multi.returnValues[0] + ctx.rclient.multi.callCount.should.equal(1) + const multiInstance = ctx.rclient.multi.returnValues[0] multiInstance.sadd.callCount.should.equal(1) multiInstance.pexpire.callCount.should.equal(1) multiInstance.exec.callCount.should.equal(1) }) - it('should call _checkSessions', async function () { - await this.UserSessionsManager.promises.trackSession( - this.user, - this.sessionId + it('should call _checkSessions', async function (ctx) { + await ctx.UserSessionsManager.promises.trackSession( + ctx.user, + ctx.sessionId ) - this._checkSessions.callCount.should.equal(1) + ctx._checkSessions.callCount.should.equal(1) }) describe('when rclient produces an error', function () { - beforeEach(function () { - this.rclient.multi.returns({ + beforeEach(function (ctx) { + ctx.rclient.multi.returns({ sadd: sinon.stub().returnsThis(), pexpire: sinon.stub().returnsThis(), exec: sinon.stub().rejects(new Error('woops')), }) }) - it('should produce an error', async function () { + it('should produce an error', async function (ctx) { await expect( - this.UserSessionsManager.promises.trackSession( - this.user, - this.sessionId - ) + ctx.UserSessionsManager.promises.trackSession(ctx.user, ctx.sessionId) ).to.be.rejectedWith(Error) }) - it('should not call _checkSessions', async function () { + it('should not call _checkSessions', async function (ctx) { try { - await this.UserSessionsManager.promises.trackSession( - this.user, - this.sessionId + await ctx.UserSessionsManager.promises.trackSession( + ctx.user, + ctx.sessionId ) } catch (err) { // Expected error } - this._checkSessions.callCount.should.equal(0) + ctx._checkSessions.callCount.should.equal(0) }) }) describe('when no user is supplied', function () { - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.trackSession( - null, - this.sessionId - ) + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.trackSession(null, ctx.sessionId) }) - it('should not call the appropriate redis methods', async function () { - await this.UserSessionsManager.promises.trackSession( - null, - this.sessionId - ) - this.rclient.multi.callCount.should.equal(0) + it('should not call the appropriate redis methods', async function (ctx) { + await ctx.UserSessionsManager.promises.trackSession(null, ctx.sessionId) + ctx.rclient.multi.callCount.should.equal(0) }) - it('should not call _checkSessions', async function () { - await this.UserSessionsManager.promises.trackSession( - null, - this.sessionId - ) - this._checkSessions.callCount.should.equal(0) + it('should not call _checkSessions', async function (ctx) { + await ctx.UserSessionsManager.promises.trackSession(null, ctx.sessionId) + ctx._checkSessions.callCount.should.equal(0) }) }) describe('when no sessionId is supplied', function () { - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.trackSession(this.user, null) + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.trackSession(ctx.user, null) }) - it('should not call the appropriate redis methods', async function () { - await this.UserSessionsManager.promises.trackSession(this.user, null) - this.rclient.multi.callCount.should.equal(0) + it('should not call the appropriate redis methods', async function (ctx) { + await ctx.UserSessionsManager.promises.trackSession(ctx.user, null) + ctx.rclient.multi.callCount.should.equal(0) }) - it('should not call _checkSessions', async function () { - await this.UserSessionsManager.promises.trackSession(this.user, null) - this._checkSessions.callCount.should.equal(0) + it('should not call _checkSessions', async function (ctx) { + await ctx.UserSessionsManager.promises.trackSession(ctx.user, null) + ctx._checkSessions.callCount.should.equal(0) }) }) }) describe('untrackSession', function () { - beforeEach(function () { - this._checkSessions = sinon - .stub(this.UserSessionsManager.promises, '_checkSessions') + beforeEach(function (ctx) { + ctx._checkSessions = sinon + .stub(ctx.UserSessionsManager.promises, '_checkSessions') .resolves() }) - afterEach(function () { - return this._checkSessions.restore() + afterEach(function (ctx) { + return ctx._checkSessions.restore() }) - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.untrackSession( - this.user, - this.sessionId + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.untrackSession( + ctx.user, + ctx.sessionId ) }) - it('should call the appropriate redis methods', async function () { - await this.UserSessionsManager.promises.untrackSession( - this.user, - this.sessionId + it('should call the appropriate redis methods', async function (ctx) { + await ctx.UserSessionsManager.promises.untrackSession( + ctx.user, + ctx.sessionId ) - this.rclient.multi.callCount.should.equal(1) - const multiInstance = this.rclient.multi.returnValues[0] + ctx.rclient.multi.callCount.should.equal(1) + const multiInstance = ctx.rclient.multi.returnValues[0] multiInstance.srem.callCount.should.equal(1) multiInstance.pexpire.callCount.should.equal(1) multiInstance.exec.callCount.should.equal(1) }) - it('should call _checkSessions', async function () { - await this.UserSessionsManager.promises.untrackSession( - this.user, - this.sessionId + it('should call _checkSessions', async function (ctx) { + await ctx.UserSessionsManager.promises.untrackSession( + ctx.user, + ctx.sessionId ) - this._checkSessions.callCount.should.equal(1) + ctx._checkSessions.callCount.should.equal(1) }) describe('when rclient produces an error', function () { - beforeEach(function () { - this.rclient.multi.returns({ + beforeEach(function (ctx) { + ctx.rclient.multi.returns({ srem: sinon.stub().returnsThis(), pexpire: sinon.stub().returnsThis(), exec: sinon.stub().rejects(new Error('woops')), }) }) - it('should produce an error', async function () { + it('should produce an error', async function (ctx) { await expect( - this.UserSessionsManager.promises.untrackSession( - this.user, - this.sessionId + ctx.UserSessionsManager.promises.untrackSession( + ctx.user, + ctx.sessionId ) ).to.be.rejectedWith(Error) }) - it('should not call _checkSessions', async function () { + it('should not call _checkSessions', async function (ctx) { try { - await this.UserSessionsManager.promises.untrackSession( - this.user, - this.sessionId + await ctx.UserSessionsManager.promises.untrackSession( + ctx.user, + ctx.sessionId ) } catch (err) { // Expected error } - this._checkSessions.callCount.should.equal(0) + ctx._checkSessions.callCount.should.equal(0) }) }) describe('when no user is supplied', function () { - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.untrackSession( + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.untrackSession( null, - this.sessionId + ctx.sessionId ) }) - it('should not call the appropriate redis methods', async function () { - await this.UserSessionsManager.promises.untrackSession( + it('should not call the appropriate redis methods', async function (ctx) { + await ctx.UserSessionsManager.promises.untrackSession( null, - this.sessionId + ctx.sessionId ) - this.rclient.multi.callCount.should.equal(0) + ctx.rclient.multi.callCount.should.equal(0) }) - it('should not call _checkSessions', async function () { - await this.UserSessionsManager.promises.untrackSession( + it('should not call _checkSessions', async function (ctx) { + await ctx.UserSessionsManager.promises.untrackSession( null, - this.sessionId + ctx.sessionId ) - this._checkSessions.callCount.should.equal(0) + ctx._checkSessions.callCount.should.equal(0) }) }) describe('when no sessionId is supplied', function () { - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.untrackSession(this.user, null) + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.untrackSession(ctx.user, null) }) - it('should not call the appropriate redis methods', async function () { - await this.UserSessionsManager.promises.untrackSession(this.user, null) - this.rclient.multi.callCount.should.equal(0) + it('should not call the appropriate redis methods', async function (ctx) { + await ctx.UserSessionsManager.promises.untrackSession(ctx.user, null) + ctx.rclient.multi.callCount.should.equal(0) }) - it('should not call _checkSessions', async function () { - await this.UserSessionsManager.promises.untrackSession(this.user, null) - this._checkSessions.callCount.should.equal(0) + it('should not call _checkSessions', async function (ctx) { + await ctx.UserSessionsManager.promises.untrackSession(ctx.user, null) + ctx._checkSessions.callCount.should.equal(0) }) }) }) describe('removeSessionsFromRedis', function () { - beforeEach(function () { - this.sessionKeys = ['sess:one', 'sess:two'] - this.currentSessionID = undefined - this.rclient.smembers.resolves(this.sessionKeys) - this.rclient.del.resolves() - this.rclient.srem.resolves() + beforeEach(function (ctx) { + ctx.sessionKeys = ['sess:one', 'sess:two'] + ctx.currentSessionID = undefined + ctx.rclient.smembers.resolves(ctx.sessionKeys) + ctx.rclient.del.resolves() + ctx.rclient.srem.resolves() }) - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.removeSessionsFromRedis( - this.user, - this.currentSessionID + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.removeSessionsFromRedis( + ctx.user, + ctx.currentSessionID ) }) - it('should yield the number of purged sessions', async function () { + it('should yield the number of purged sessions', async function (ctx) { const result = - await this.UserSessionsManager.promises.removeSessionsFromRedis( - this.user, - this.currentSessionID + await ctx.UserSessionsManager.promises.removeSessionsFromRedis( + ctx.user, + ctx.currentSessionID ) - expect(result).to.equal(this.sessionKeys.length) + expect(result).to.equal(ctx.sessionKeys.length) }) - it('should call the appropriate redis methods', async function () { - await this.UserSessionsManager.promises.removeSessionsFromRedis( - this.user, - this.currentSessionID + it('should call the appropriate redis methods', async function (ctx) { + await ctx.UserSessionsManager.promises.removeSessionsFromRedis( + ctx.user, + ctx.currentSessionID ) - this.rclient.smembers.callCount.should.equal(1) + ctx.rclient.smembers.callCount.should.equal(1) - this.rclient.del.callCount.should.equal(2) - expect(this.rclient.del.firstCall.args[0]).to.deep.equal( - this.sessionKeys[0] + ctx.rclient.del.callCount.should.equal(2) + expect(ctx.rclient.del.firstCall.args[0]).to.deep.equal( + ctx.sessionKeys[0] ) - expect(this.rclient.del.secondCall.args[0]).to.deep.equal( - this.sessionKeys[1] + expect(ctx.rclient.del.secondCall.args[0]).to.deep.equal( + ctx.sessionKeys[1] ) - this.rclient.srem.callCount.should.equal(1) - expect(this.rclient.srem.firstCall.args[0]).to.deep.equal( + ctx.rclient.srem.callCount.should.equal(1) + expect(ctx.rclient.srem.firstCall.args[0]).to.deep.equal( 'UserSessions:{abcd}' ) - expect(this.rclient.srem.firstCall.args[1]).to.deep.equal( - this.sessionKeys - ) + expect(ctx.rclient.srem.firstCall.args[1]).to.deep.equal(ctx.sessionKeys) }) describe('when a session is retained', function () { - beforeEach(function () { - this.sessionKeys = ['sess:one', 'sess:two', 'sess:three', 'sess:four'] - this.currentSessionID = 'two' - this.rclient.smembers.resolves(this.sessionKeys) - this.rclient.del.resolves() + beforeEach(function (ctx) { + ctx.sessionKeys = ['sess:one', 'sess:two', 'sess:three', 'sess:four'] + ctx.currentSessionID = 'two' + ctx.rclient.smembers.resolves(ctx.sessionKeys) + ctx.rclient.del.resolves() }) - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.removeSessionsFromRedis( - this.user, - this.currentSessionID + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.removeSessionsFromRedis( + ctx.user, + ctx.currentSessionID ) }) - it('should call the appropriate redis methods', async function () { - await this.UserSessionsManager.promises.removeSessionsFromRedis( - this.user, - this.currentSessionID + it('should call the appropriate redis methods', async function (ctx) { + await ctx.UserSessionsManager.promises.removeSessionsFromRedis( + ctx.user, + ctx.currentSessionID ) - this.rclient.smembers.callCount.should.equal(1) - this.rclient.del.callCount.should.equal(this.sessionKeys.length - 1) - this.rclient.srem.callCount.should.equal(1) + ctx.rclient.smembers.callCount.should.equal(1) + ctx.rclient.del.callCount.should.equal(ctx.sessionKeys.length - 1) + ctx.rclient.srem.callCount.should.equal(1) }) - it('should remove all sessions except for the retained one', async function () { - await this.UserSessionsManager.promises.removeSessionsFromRedis( - this.user, - this.currentSessionID + it('should remove all sessions except for the retained one', async function (ctx) { + await ctx.UserSessionsManager.promises.removeSessionsFromRedis( + ctx.user, + ctx.currentSessionID ) - expect(this.rclient.del.firstCall.args[0]).to.deep.equal('sess:one') - expect(this.rclient.del.secondCall.args[0]).to.deep.equal('sess:three') - expect(this.rclient.del.thirdCall.args[0]).to.deep.equal('sess:four') - expect(this.rclient.srem.firstCall.args[1]).to.deep.equal([ + expect(ctx.rclient.del.firstCall.args[0]).to.deep.equal('sess:one') + expect(ctx.rclient.del.secondCall.args[0]).to.deep.equal('sess:three') + expect(ctx.rclient.del.thirdCall.args[0]).to.deep.equal('sess:four') + expect(ctx.rclient.srem.firstCall.args[1]).to.deep.equal([ 'sess:one', 'sess:three', 'sess:four', @@ -372,141 +362,141 @@ describe('UserSessionsManager', function () { }) describe('when rclient produces an error', function () { - beforeEach(function () { - this.rclient.del.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.rclient.del.rejects(new Error('woops')) }) - it('should produce an error', async function () { + it('should produce an error', async function (ctx) { await expect( - this.UserSessionsManager.promises.removeSessionsFromRedis( - this.user, - this.currentSessionID + ctx.UserSessionsManager.promises.removeSessionsFromRedis( + ctx.user, + ctx.currentSessionID ) ).to.be.rejectedWith(Error) }) - it('should not call rclient.srem', async function () { + it('should not call rclient.srem', async function (ctx) { try { - await this.UserSessionsManager.promises.removeSessionsFromRedis( - this.user, - this.currentSessionID + await ctx.UserSessionsManager.promises.removeSessionsFromRedis( + ctx.user, + ctx.currentSessionID ) } catch (err) { // Expected error } - this.rclient.srem.callCount.should.equal(0) + ctx.rclient.srem.callCount.should.equal(0) }) }) describe('when no user is supplied', function () { - it('should produce an error', async function () { + it('should produce an error', async function (ctx) { await expect( - this.UserSessionsManager.promises.removeSessionsFromRedis( + ctx.UserSessionsManager.promises.removeSessionsFromRedis( null, - this.currentSessionID + ctx.currentSessionID ) ).to.be.rejectedWith(/bug: user not passed to removeSessionsFromRedis/) }) - it('should not call the appropriate redis methods', async function () { + it('should not call the appropriate redis methods', async function (ctx) { try { - await this.UserSessionsManager.promises.removeSessionsFromRedis( + await ctx.UserSessionsManager.promises.removeSessionsFromRedis( null, - this.currentSessionID + ctx.currentSessionID ) } catch (err) { // Expected error } - this.rclient.smembers.callCount.should.equal(0) - this.rclient.del.callCount.should.equal(0) - this.rclient.srem.callCount.should.equal(0) + ctx.rclient.smembers.callCount.should.equal(0) + ctx.rclient.del.callCount.should.equal(0) + ctx.rclient.srem.callCount.should.equal(0) }) }) describe('when there are no keys to delete', function () { - beforeEach(function () { - this.rclient.smembers.resolves([]) + beforeEach(function (ctx) { + ctx.rclient.smembers.resolves([]) }) - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.removeSessionsFromRedis( - this.user, - this.currentSessionID + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.removeSessionsFromRedis( + ctx.user, + ctx.currentSessionID ) }) - it('should not do the delete operation', async function () { - await this.UserSessionsManager.promises.removeSessionsFromRedis( - this.user, - this.currentSessionID + it('should not do the delete operation', async function (ctx) { + await ctx.UserSessionsManager.promises.removeSessionsFromRedis( + ctx.user, + ctx.currentSessionID ) - this.rclient.smembers.callCount.should.equal(1) - this.rclient.del.callCount.should.equal(0) - this.rclient.srem.callCount.should.equal(0) + ctx.rclient.smembers.callCount.should.equal(1) + ctx.rclient.del.callCount.should.equal(0) + ctx.rclient.srem.callCount.should.equal(0) }) }) }) describe('touch', function () { - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.touch(this.user) + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.touch(ctx.user) }) - it('should call rclient.pexpire', async function () { - await this.UserSessionsManager.promises.touch(this.user) - this.rclient.pexpire.callCount.should.equal(1) + it('should call rclient.pexpire', async function (ctx) { + await ctx.UserSessionsManager.promises.touch(ctx.user) + ctx.rclient.pexpire.callCount.should.equal(1) }) describe('when rclient produces an error', function () { - beforeEach(function () { - this.rclient.pexpire.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.rclient.pexpire.rejects(new Error('woops')) }) - it('should produce an error', async function () { + it('should produce an error', async function (ctx) { await expect( - this.UserSessionsManager.promises.touch(this.user) + ctx.UserSessionsManager.promises.touch(ctx.user) ).to.be.rejectedWith(Error) }) }) describe('when no user is supplied', function () { - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.touch(null) + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.touch(null) }) - it('should not call pexpire', async function () { - await this.UserSessionsManager.promises.touch(null) - this.rclient.pexpire.callCount.should.equal(0) + it('should not call pexpire', async function (ctx) { + await ctx.UserSessionsManager.promises.touch(null) + ctx.rclient.pexpire.callCount.should.equal(0) }) }) }) describe('getAllUserSessions', function () { - beforeEach(function () { - this.sessionKeys = ['sess:one', 'sess:two', 'sess:three'] - this.sessions = [ + beforeEach(function (ctx) { + ctx.sessionKeys = ['sess:one', 'sess:two', 'sess:three'] + ctx.sessions = [ '{"user": {"ip_address": "a", "session_created": "b"}}', '{"passport": {"user": {"ip_address": "c", "session_created": "d"}}}', ] - this.exclude = ['two'] - this.rclient.smembers.resolves(this.sessionKeys) - this.rclient.get = sinon.stub() - this.rclient.get.onCall(0).resolves(this.sessions[0]) - this.rclient.get.onCall(1).resolves(this.sessions[1]) + ctx.exclude = ['two'] + ctx.rclient.smembers.resolves(ctx.sessionKeys) + ctx.rclient.get = sinon.stub() + ctx.rclient.get.onCall(0).resolves(ctx.sessions[0]) + ctx.rclient.get.onCall(1).resolves(ctx.sessions[1]) }) - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.getAllUserSessions( - this.user, - this.exclude + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.getAllUserSessions( + ctx.user, + ctx.exclude ) }) - it('should get sessions', async function () { + it('should get sessions', async function (ctx) { const sessions = - await this.UserSessionsManager.promises.getAllUserSessions( - this.user, - this.exclude + await ctx.UserSessionsManager.promises.getAllUserSessions( + ctx.user, + ctx.exclude ) expect(sessions).to.deep.equal([ { ip_address: 'a', session_created: 'b' }, @@ -514,98 +504,98 @@ describe('UserSessionsManager', function () { ]) }) - it('should have called rclient.smembers', async function () { - await this.UserSessionsManager.promises.getAllUserSessions( - this.user, - this.exclude + it('should have called rclient.smembers', async function (ctx) { + await ctx.UserSessionsManager.promises.getAllUserSessions( + ctx.user, + ctx.exclude ) - this.rclient.smembers.callCount.should.equal(1) + ctx.rclient.smembers.callCount.should.equal(1) }) - it('should have called rclient.get', async function () { - await this.UserSessionsManager.promises.getAllUserSessions( - this.user, - this.exclude + it('should have called rclient.get', async function (ctx) { + await ctx.UserSessionsManager.promises.getAllUserSessions( + ctx.user, + ctx.exclude ) - this.rclient.get.callCount.should.equal(this.sessionKeys.length - 1) + ctx.rclient.get.callCount.should.equal(ctx.sessionKeys.length - 1) }) describe('when there are no other sessions', function () { - beforeEach(function () { - this.sessionKeys = ['sess:two'] - this.rclient.smembers.resolves(this.sessionKeys) + beforeEach(function (ctx) { + ctx.sessionKeys = ['sess:two'] + ctx.rclient.smembers.resolves(ctx.sessionKeys) }) - it('should not produce an error', async function () { - await this.UserSessionsManager.promises.getAllUserSessions( - this.user, - this.exclude + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises.getAllUserSessions( + ctx.user, + ctx.exclude ) }) - it('should produce an empty list of sessions', async function () { + it('should produce an empty list of sessions', async function (ctx) { const sessions = - await this.UserSessionsManager.promises.getAllUserSessions( - this.user, - this.exclude + await ctx.UserSessionsManager.promises.getAllUserSessions( + ctx.user, + ctx.exclude ) expect(sessions).to.deep.equal([]) }) - it('should have called rclient.smembers', async function () { - await this.UserSessionsManager.promises.getAllUserSessions( - this.user, - this.exclude + it('should have called rclient.smembers', async function (ctx) { + await ctx.UserSessionsManager.promises.getAllUserSessions( + ctx.user, + ctx.exclude ) - this.rclient.smembers.callCount.should.equal(1) + ctx.rclient.smembers.callCount.should.equal(1) }) - it('should not have called rclient.get for individual keys', async function () { - await this.UserSessionsManager.promises.getAllUserSessions( - this.user, - this.exclude + it('should not have called rclient.get for individual keys', async function (ctx) { + await ctx.UserSessionsManager.promises.getAllUserSessions( + ctx.user, + ctx.exclude ) - this.rclient.get.callCount.should.equal(0) + ctx.rclient.get.callCount.should.equal(0) }) }) describe('when smembers produces an error', function () { - beforeEach(function () { - this.rclient.smembers.rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.rclient.smembers.rejects(new Error('woops')) }) - it('should produce an error', async function () { + it('should produce an error', async function (ctx) { await expect( - this.UserSessionsManager.promises.getAllUserSessions( - this.user, - this.exclude + ctx.UserSessionsManager.promises.getAllUserSessions( + ctx.user, + ctx.exclude ) ).to.be.rejectedWith(Error) }) - it('should not have called rclient.get', async function () { + it('should not have called rclient.get', async function (ctx) { try { - await this.UserSessionsManager.promises.getAllUserSessions( - this.user, - this.exclude + await ctx.UserSessionsManager.promises.getAllUserSessions( + ctx.user, + ctx.exclude ) } catch (err) { // Expected error } - this.rclient.get.callCount.should.equal(0) + ctx.rclient.get.callCount.should.equal(0) }) }) describe('when get produces an error', function () { - beforeEach(function () { - this.rclient.get = sinon.stub().rejects(new Error('woops')) + beforeEach(function (ctx) { + ctx.rclient.get = sinon.stub().rejects(new Error('woops')) }) - it('should produce an error', async function () { + it('should produce an error', async function (ctx) { await expect( - this.UserSessionsManager.promises.getAllUserSessions( - this.user, - this.exclude + ctx.UserSessionsManager.promises.getAllUserSessions( + ctx.user, + ctx.exclude ) ).to.be.rejectedWith(Error) }) @@ -613,76 +603,76 @@ describe('UserSessionsManager', function () { }) describe('_checkSessions', function () { - beforeEach(function () { - this.sessionKeys = ['one', 'two'] - this.rclient.smembers.resolves(this.sessionKeys) - this.rclient.get.resolves('some-value') - this.rclient.srem.resolves({}) + beforeEach(function (ctx) { + ctx.sessionKeys = ['one', 'two'] + ctx.rclient.smembers.resolves(ctx.sessionKeys) + ctx.rclient.get.resolves('some-value') + ctx.rclient.srem.resolves({}) }) - it('should not produce an error', async function () { - await this.UserSessionsManager.promises._checkSessions(this.user) + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises._checkSessions(ctx.user) }) - it('should call the appropriate redis methods', async function () { - await this.UserSessionsManager.promises._checkSessions(this.user) - this.rclient.smembers.callCount.should.equal(1) - this.rclient.get.callCount.should.equal(2) - this.rclient.srem.callCount.should.equal(0) + it('should call the appropriate redis methods', async function (ctx) { + await ctx.UserSessionsManager.promises._checkSessions(ctx.user) + ctx.rclient.smembers.callCount.should.equal(1) + ctx.rclient.get.callCount.should.equal(2) + ctx.rclient.srem.callCount.should.equal(0) }) describe('when one of the keys is not present in redis', function () { - beforeEach(function () { - this.rclient.get.onCall(0).resolves('some-val') - this.rclient.get.onCall(1).resolves(null) + beforeEach(function (ctx) { + ctx.rclient.get.onCall(0).resolves('some-val') + ctx.rclient.get.onCall(1).resolves(null) }) - it('should not produce an error', async function () { - await this.UserSessionsManager.promises._checkSessions(this.user) + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises._checkSessions(ctx.user) }) - it('should remove that key from the set', async function () { - await this.UserSessionsManager.promises._checkSessions(this.user) - this.rclient.smembers.callCount.should.equal(1) - this.rclient.get.callCount.should.equal(2) - this.rclient.srem.callCount.should.equal(1) - this.rclient.srem.firstCall.args[1].should.equal('two') + it('should remove that key from the set', async function (ctx) { + await ctx.UserSessionsManager.promises._checkSessions(ctx.user) + ctx.rclient.smembers.callCount.should.equal(1) + ctx.rclient.get.callCount.should.equal(2) + ctx.rclient.srem.callCount.should.equal(1) + ctx.rclient.srem.firstCall.args[1].should.equal('two') }) }) describe('when no user is supplied', function () { - it('should not produce an error', async function () { - await this.UserSessionsManager.promises._checkSessions(null) + it('should not produce an error', async function (ctx) { + await ctx.UserSessionsManager.promises._checkSessions(null) }) - it('should not call redis methods', async function () { - await this.UserSessionsManager.promises._checkSessions(null) - this.rclient.smembers.callCount.should.equal(0) - this.rclient.get.callCount.should.equal(0) + it('should not call redis methods', async function (ctx) { + await ctx.UserSessionsManager.promises._checkSessions(null) + ctx.rclient.smembers.callCount.should.equal(0) + ctx.rclient.get.callCount.should.equal(0) }) }) describe('when one of the get operations produces an error', function () { - beforeEach(function () { - this.rclient.get.onCall(0).rejects(new Error('woops')) - this.rclient.get.onCall(1).resolves(null) + beforeEach(function (ctx) { + ctx.rclient.get.onCall(0).rejects(new Error('woops')) + ctx.rclient.get.onCall(1).resolves(null) }) - it('should produce an error', async function () { + it('should produce an error', async function (ctx) { await expect( - this.UserSessionsManager.promises._checkSessions(this.user) + ctx.UserSessionsManager.promises._checkSessions(ctx.user) ).to.be.rejectedWith(Error) }) - it('should call the right redis methods, bailing out early', async function () { + it('should call the right redis methods, bailing out early', async function (ctx) { try { - await this.UserSessionsManager.promises._checkSessions(this.user) + await ctx.UserSessionsManager.promises._checkSessions(ctx.user) } catch (err) { // Expected error } - this.rclient.smembers.callCount.should.equal(1) - this.rclient.get.callCount.should.equal(1) - this.rclient.srem.callCount.should.equal(0) + ctx.rclient.smembers.callCount.should.equal(1) + ctx.rclient.get.callCount.should.equal(1) + ctx.rclient.srem.callCount.should.equal(0) }) }) }) diff --git a/services/web/test/unit/src/User/UserUpdater.test.mjs b/services/web/test/unit/src/User/UserUpdater.test.mjs index 5a5ddf5b0d..fd0b68779e 100644 --- a/services/web/test/unit/src/User/UserUpdater.test.mjs +++ b/services/web/test/unit/src/User/UserUpdater.test.mjs @@ -1,40 +1,45 @@ -const { setTimeout } = require('timers/promises') -const SandboxedModule = require('sandboxed-module') -const path = require('path') -const sinon = require('sinon') -const { ObjectId } = require('mongodb-legacy') -const tk = require('timekeeper') -const { expect } = require('chai') -const { normalizeQuery } = require('../../../../app/src/Features/Helpers/Mongo') -const Errors = require('../../../../app/src/Features/Errors/Errors') +import { vi, expect } from 'vitest' +import { setTimeout } from 'timers/promises' +import path from 'path' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' +import tk from 'timekeeper' +import { normalizeQuery } from '../../../../app/src/Features/Helpers/Mongo.js' +import Errors from '../../../../app/src/Features/Errors/Errors.js' + +const { ObjectId } = mongodb const MODULE_PATH = path.join( - __dirname, + import.meta.dirname, '../../../../app/src/Features/User/UserUpdater' ) +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) + describe('UserUpdater', function () { - beforeEach(function () { + beforeEach(async function (ctx) { tk.freeze(Date.now()) - this.user = { + ctx.user = { _id: new ObjectId(), name: 'bob', email: 'hello@world.com', emails: [{ email: 'hello@world.com' }], } - this.db = { + ctx.db = { users: { updateOne: sinon.stub().resolves({ matchedCount: 1, modifiedCount: 1 }), }, } - this.mongodb = { - db: this.db, + ctx.mongodb = { + db: ctx.db, ObjectId, } - this.UserGetter = { + ctx.UserGetter = { promises: { ensureUniqueEmailAddress: sinon.stub().resolves(), getUser: sinon.stub(), @@ -43,58 +48,58 @@ describe('UserUpdater', function () { getUserEmail: sinon.stub(), }, } - this.UserGetter.promises.getUser.withArgs(this.user._id).resolves(this.user) - this.UserGetter.promises.getUserByMainEmail - .withArgs(this.user.email) - .resolves(this.user) - this.UserGetter.promises.getUserFullEmails - .withArgs(this.user._id) - .resolves(this.user.emails) - this.UserGetter.promises.getUserEmail - .withArgs(this.user._id) - .resolves(this.user.email) + ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user) + ctx.UserGetter.promises.getUserByMainEmail + .withArgs(ctx.user.email) + .resolves(ctx.user) + ctx.UserGetter.promises.getUserFullEmails + .withArgs(ctx.user._id) + .resolves(ctx.user.emails) + ctx.UserGetter.promises.getUserEmail + .withArgs(ctx.user._id) + .resolves(ctx.user.email) - this.NewsletterManager = { + ctx.NewsletterManager = { promises: { changeEmail: sinon.stub().resolves(), }, } - this.AnalyticsManager = { + ctx.AnalyticsManager = { recordEventForUserInBackground: sinon.stub(), } - this.InstitutionsAPI = { + ctx.InstitutionsAPI = { promises: { addAffiliation: sinon.stub().resolves(), removeAffiliation: sinon.stub().resolves(), getUserAffiliations: sinon.stub().resolves(), }, } - this.EmailHandler = { + ctx.EmailHandler = { promises: { sendEmail: sinon.stub().resolves(), }, } - this.Features = { + ctx.Features = { hasFeature: sinon.stub().returns(false), } - this.FeaturesUpdater = { + ctx.FeaturesUpdater = { promises: { refreshFeatures: sinon.stub().resolves(), }, } - this.UserAuditLogHandler = { + ctx.UserAuditLogHandler = { promises: { addEntry: sinon.stub().resolves(), }, } - this.SubscriptionLocator = { + ctx.SubscriptionLocator = { promises: { getUserIndividualSubscription: sinon.stub().resolves(), }, } - this.NotificationsBuilder = { + ctx.NotificationsBuilder = { promises: { redundantPersonalSubscription: sinon .stub() @@ -102,7 +107,7 @@ describe('UserUpdater', function () { }, } - this.Modules = { + ctx.Modules = { promises: { hooks: { fire: sinon.stub().resolves([]), @@ -110,41 +115,113 @@ describe('UserUpdater', function () { }, } - this.UserSessionsManager = { + ctx.UserSessionsManager = { promises: { removeSessionsFromRedis: sinon.stub().resolves(), }, } - this.AsyncLocalStorage = { + ctx.AsyncLocalStorage = { removeItem: sinon.stub(), } - this.UserUpdater = SandboxedModule.require(MODULE_PATH, { - requires: { - '../Helpers/Mongo': { normalizeQuery }, - '../../infrastructure/mongodb': this.mongodb, - './UserGetter': this.UserGetter, - '../Institutions/InstitutionsAPI': this.InstitutionsAPI, - '../Email/EmailHandler': this.EmailHandler, - '../../infrastructure/Features': this.Features, - '../Subscription/FeaturesUpdater': this.FeaturesUpdater, - '@overleaf/settings': (this.settings = {}), - '../Newsletter/NewsletterManager': this.NewsletterManager, - '../Subscription/RecurlyWrapper': this.RecurlyWrapper, - './UserAuditLogHandler': this.UserAuditLogHandler, - '../Analytics/AnalyticsManager': this.AnalyticsManager, - '../../Errors/Errors': Errors, - '../Subscription/SubscriptionLocator': this.SubscriptionLocator, - '../Notifications/NotificationsBuilder': this.NotificationsBuilder, - '../../infrastructure/Modules': this.Modules, - './UserSessionsManager': this.UserSessionsManager, - './ThirdPartyIdentityManager': this.ThirdPartyIdentityManager, - '../../infrastructure/AsyncLocalStorage': this.AsyncLocalStorage, - }, - }) + vi.doMock('../../../../app/src/Features/Helpers/Mongo', () => ({ + normalizeQuery, + })) - this.newEmail = 'bob@bob.com' + vi.doMock('../../../../app/src/infrastructure/mongodb', () => ctx.mongodb) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Institutions/InstitutionsAPI', + () => ({ + default: ctx.InstitutionsAPI, + }) + ) + + vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({ + default: ctx.EmailHandler, + })) + + vi.doMock('../../../../app/src/infrastructure/Features', () => ({ + default: ctx.Features, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/FeaturesUpdater', + () => ({ + default: ctx.FeaturesUpdater, + }) + ) + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = {}), + })) + + vi.doMock( + '../../../../app/src/Features/Newsletter/NewsletterManager', + () => ({ + default: ctx.NewsletterManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/RecurlyWrapper', + () => ({ + default: ctx.RecurlyWrapper, + }) + ) + + vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({ + default: ctx.UserAuditLogHandler, + })) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: ctx.AnalyticsManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator', + () => ({ + default: ctx.SubscriptionLocator, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Notifications/NotificationsBuilder', + () => ({ + default: ctx.NotificationsBuilder, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: ctx.Modules, + })) + + vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({ + default: ctx.UserSessionsManager, + })) + + vi.doMock( + '../../../../app/src/Features/User/ThirdPartyIdentityManager', + () => ({ + default: ctx.ThirdPartyIdentityManager, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/AsyncLocalStorage', () => ({ + default: ctx.AsyncLocalStorage, + })) + + ctx.UserUpdater = (await import(MODULE_PATH)).default + + ctx.newEmail = 'bob@bob.com' }) afterEach(function () { @@ -152,167 +229,167 @@ describe('UserUpdater', function () { }) describe('addAffiliationForNewUser', function () { - it('should not remove affiliationUnchecked flag if v1 returns an error', async function () { - this.InstitutionsAPI.promises.addAffiliation.rejects() + it('should not remove affiliationUnchecked flag if v1 returns an error', async function (ctx) { + ctx.InstitutionsAPI.promises.addAffiliation.rejects() await expect( - this.UserUpdater.promises.addAffiliationForNewUser( - this.user._id, - this.newEmail + ctx.UserUpdater.promises.addAffiliationForNewUser( + ctx.user._id, + ctx.newEmail ) ).to.be.rejected - sinon.assert.notCalled(this.db.users.updateOne) + sinon.assert.notCalled(ctx.db.users.updateOne) }) - it('should remove affiliationUnchecked flag if v1 does not return an error', async function () { - await this.UserUpdater.promises.addAffiliationForNewUser( - this.user._id, - this.newEmail + it('should remove affiliationUnchecked flag if v1 does not return an error', async function (ctx) { + await ctx.UserUpdater.promises.addAffiliationForNewUser( + ctx.user._id, + ctx.newEmail ) - sinon.assert.calledOnce(this.db.users.updateOne) + sinon.assert.calledOnce(ctx.db.users.updateOne) sinon.assert.calledWithMatch( - this.db.users.updateOne, - { _id: this.user._id, 'emails.email': this.newEmail }, + ctx.db.users.updateOne, + { _id: ctx.user._id, 'emails.email': ctx.newEmail }, { $unset: { 'emails.$.affiliationUnchecked': 1 } } ) }) - it('should not throw if removing affiliationUnchecked flag errors', async function () { - this.db.users.updateOne.rejects(new Error('nope')) - await this.UserUpdater.promises.addAffiliationForNewUser( - this.user._id, - this.newEmail + it('should not throw if removing affiliationUnchecked flag errors', async function (ctx) { + ctx.db.users.updateOne.rejects(new Error('nope')) + await ctx.UserUpdater.promises.addAffiliationForNewUser( + ctx.user._id, + ctx.newEmail ) }) - it('calls to remove userFullEmails from AsyncLocalStorage', async function () { - await this.UserUpdater.promises.addAffiliationForNewUser( - this.user._id, - this.newEmail + it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) { + await ctx.UserUpdater.promises.addAffiliationForNewUser( + ctx.user._id, + ctx.newEmail ) - expect(this.AsyncLocalStorage.removeItem).to.have.been.calledWith( + expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith( 'userFullEmails' ) }) }) describe('changeEmailAddress', function () { - beforeEach(async function () { - this.auditLog = { + beforeEach(async function (ctx) { + ctx.auditLog = { initiatorId: 'abc123', ipAddress: '0:0:0:0', } // After the email changed, make sure that UserGetter.getUser() returns a // user with the new email. - this.UserGetter.promises.getUser - .withArgs(this.user._id) + ctx.UserGetter.promises.getUser + .withArgs(ctx.user._id) .onCall(1) .resolves({ - ...this.user, - emails: [...this.user.emails, { email: this.newEmail }], + ...ctx.user, + emails: [...ctx.user.emails, { email: ctx.newEmail }], }) // The main email changes as a result of the email change - this.UserGetter.promises.getUserByMainEmail - .withArgs(this.user.email) + ctx.UserGetter.promises.getUserByMainEmail + .withArgs(ctx.user.email) .resolves(null) - this.user.emails.push({ email: this.newEmail }) - await this.UserUpdater.promises.changeEmailAddress( - this.user._id, - this.newEmail, - this.auditLog + ctx.user.emails.push({ email: ctx.newEmail }) + await ctx.UserUpdater.promises.changeEmailAddress( + ctx.user._id, + ctx.newEmail, + ctx.auditLog ) }) - it('adds the new email', function () { - expect(this.db.users.updateOne).to.have.been.calledWith( - { _id: this.user._id, 'emails.email': { $ne: this.newEmail } }, + it('adds the new email', function (ctx) { + expect(ctx.db.users.updateOne).to.have.been.calledWith( + { _id: ctx.user._id, 'emails.email': { $ne: ctx.newEmail } }, { $push: { - emails: sinon.match({ email: this.newEmail }), + emails: sinon.match({ email: ctx.newEmail }), }, } ) }) - it('adds the new affiliation', function () { - this.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith( - this.user._id, - this.newEmail + it('adds the new affiliation', function (ctx) { + ctx.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith( + ctx.user._id, + ctx.newEmail ) }) - it('removes the old email', function () { - expect(this.db.users.updateOne).to.have.been.calledWith( - { _id: this.user._id, email: { $ne: this.user.email } }, - { $pull: { emails: { email: this.user.email } } } + it('removes the old email', function (ctx) { + expect(ctx.db.users.updateOne).to.have.been.calledWith( + { _id: ctx.user._id, email: { $ne: ctx.user.email } }, + { $pull: { emails: { email: ctx.user.email } } } ) }) - it('removes the affiliation', function () { + it('removes the affiliation', function (ctx) { expect( - this.InstitutionsAPI.promises.removeAffiliation - ).to.have.been.calledWith(this.user._id, this.user.email) + ctx.InstitutionsAPI.promises.removeAffiliation + ).to.have.been.calledWith(ctx.user._id, ctx.user.email) }) - it('refreshes features', function () { + it('refreshes features', function (ctx) { sinon.assert.calledWith( - this.FeaturesUpdater.promises.refreshFeatures, - this.user._id + ctx.FeaturesUpdater.promises.refreshFeatures, + ctx.user._id ) }) - it('sets the default email', function () { - expect(this.db.users.updateOne).to.have.been.calledWith( - { _id: this.user._id, 'emails.email': this.newEmail }, + it('sets the default email', function (ctx) { + expect(ctx.db.users.updateOne).to.have.been.calledWith( + { _id: ctx.user._id, 'emails.email': ctx.newEmail }, { $set: sinon.match({ - email: this.newEmail, + email: ctx.newEmail, }), } ) }) - it('sets the new email in the newsletter', function () { + it('sets the new email in the newsletter', function (ctx) { expect( - this.NewsletterManager.promises.changeEmail - ).to.have.been.calledWith(this.user, this.newEmail) - expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + ctx.NewsletterManager.promises.changeEmail + ).to.have.been.calledWith(ctx.user, ctx.newEmail) + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( 'updateAccountEmailAddress', - this.user._id, - this.newEmail + ctx.user._id, + ctx.newEmail ) }) - it('validates email', async function () { + it('validates email', async function (ctx) { await expect( - this.UserUpdater.promises.changeEmailAddress( - this.user._id, + ctx.UserUpdater.promises.changeEmailAddress( + ctx.user._id, 'foo', - this.auditLog + ctx.auditLog ) ).to.be.rejected }) }) describe('addEmailAddress', function () { - it('adds the email', async function () { - await this.UserUpdater.promises.addEmailAddress( - this.user._id, - this.newEmail, + it('adds the email', async function (ctx) { + await ctx.UserUpdater.promises.addEmailAddress( + ctx.user._id, + ctx.newEmail, {}, - { initiatorId: this.user._id, ipAddress: '127:0:0:0' } + { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' } ) - this.UserGetter.promises.ensureUniqueEmailAddress.should.have.been.called - const reversedHostname = this.newEmail + ctx.UserGetter.promises.ensureUniqueEmailAddress.should.have.been.called + const reversedHostname = ctx.newEmail .split('@')[1] .split('') .reverse() .join('') - this.db.users.updateOne.should.have.been.calledWith( - { _id: this.user._id, 'emails.email': { $ne: this.newEmail } }, + ctx.db.users.updateOne.should.have.been.calledWith( + { _id: ctx.user._id, 'emails.email': { $ne: ctx.newEmail } }, { $push: { emails: { - email: this.newEmail, + email: ctx.newEmail, createdAt: sinon.match.date, reversedHostname, }, @@ -321,282 +398,278 @@ describe('UserUpdater', function () { ) }) - it('adds the affiliation', async function () { + it('adds the affiliation', async function (ctx) { const affiliationOptions = { university: { id: 1 }, role: 'Prof', department: 'Math', } - await this.UserUpdater.promises.addEmailAddress( - this.user._id, - this.newEmail, + await ctx.UserUpdater.promises.addEmailAddress( + ctx.user._id, + ctx.newEmail, affiliationOptions, - { initiatorId: this.user._id, ipAddress: '127:0:0:0' } + { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' } ) - this.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith( - this.user._id, - this.newEmail, + ctx.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith( + ctx.user._id, + ctx.newEmail, affiliationOptions ) }) - it('handles affiliation errors', async function () { - this.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope')) + it('handles affiliation errors', async function (ctx) { + ctx.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope')) await expect( - this.UserUpdater.promises.addEmailAddress( - this.user._id, - this.newEmail, + ctx.UserUpdater.promises.addEmailAddress( + ctx.user._id, + ctx.newEmail, {}, - { initiatorId: this.user._id, ipAddress: '127:0:0:0' } + { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' } ) ).to.be.rejected - this.db.users.updateOne.should.not.have.been.called + ctx.db.users.updateOne.should.not.have.been.called }) - it('validates the email', async function () { + it('validates the email', async function (ctx) { expect( - this.UserUpdater.promises.addEmailAddress( - this.user._id, + ctx.UserUpdater.promises.addEmailAddress( + ctx.user._id, 'bar', {}, - { initiatorId: this.user._id, ipAddress: '127:0:0:0' } + { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' } ) ).to.be.rejected }) - it('updates the audit log', async function () { - this.ip = '127:0:0:0' - await this.UserUpdater.promises.addEmailAddress( - this.user._id, - this.newEmail, + it('updates the audit log', async function (ctx) { + ctx.ip = '127:0:0:0' + await ctx.UserUpdater.promises.addEmailAddress( + ctx.user._id, + ctx.newEmail, {}, - { initiatorId: this.user._id, ipAddress: this.ip } + { initiatorId: ctx.user._id, ipAddress: ctx.ip } ) - this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true) - const { args } = this.UserAuditLogHandler.promises.addEntry.lastCall - expect(args[0]).to.equal(this.user._id) + ctx.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true) + const { args } = ctx.UserAuditLogHandler.promises.addEntry.lastCall + expect(args[0]).to.equal(ctx.user._id) expect(args[1]).to.equal('add-email') - expect(args[2]).to.equal(this.user._id) - expect(args[3]).to.equal(this.ip) - expect(args[4]).to.deep.equal({ newSecondaryEmail: this.newEmail }) + expect(args[2]).to.equal(ctx.user._id) + expect(args[3]).to.equal(ctx.ip) + expect(args[4]).to.deep.equal({ newSecondaryEmail: ctx.newEmail }) }) describe('errors', function () { describe('via UserAuditLogHandler', function () { const anError = new Error('oops') - beforeEach(function () { - this.UserAuditLogHandler.promises.addEntry.rejects(anError) + beforeEach(function (ctx) { + ctx.UserAuditLogHandler.promises.addEntry.rejects(anError) }) - it('should not add email and should return error', async function () { + it('should not add email and should return error', async function (ctx) { await expect( - this.UserUpdater.promises.addEmailAddress( - this.user._id, - this.newEmail, + ctx.UserUpdater.promises.addEmailAddress( + ctx.user._id, + ctx.newEmail, {}, - { initiatorId: this.user._id, ipAddress: '127:0:0:0' } + { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' } ) ).to.be.rejectedWith(anError) - expect(this.db.users.updateOne).to.not.have.been.called + expect(ctx.db.users.updateOne).to.not.have.been.called }) }) }) - it('calls to remove userFullEmails from AsyncLocalStorage', async function () { - await this.UserUpdater.promises.addEmailAddress( - this.user._id, - this.newEmail, + it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) { + await ctx.UserUpdater.promises.addEmailAddress( + ctx.user._id, + ctx.newEmail, {}, - { initiatorId: this.user._id, ipAddress: '127:0:0:0' } + { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' } ) - expect(this.AsyncLocalStorage.removeItem).to.have.been.calledWith( + expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith( 'userFullEmails' ) }) }) describe('removeEmailAddress', function () { - this.beforeEach(function () { - this.auditLog = { initiatorId: this.user._id, ipAddress: '127:0:0:0' } + beforeEach(function (ctx) { + ctx.auditLog = { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' } }) - it('removes the email', async function () { - await this.UserUpdater.promises.removeEmailAddress( - this.user._id, - this.newEmail, - this.auditLog + it('removes the email', async function (ctx) { + await ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, + ctx.newEmail, + ctx.auditLog ) - expect(this.db.users.updateOne).to.have.been.calledWith( - { _id: this.user._id, email: { $ne: this.newEmail } }, - { $pull: { emails: { email: this.newEmail } } } + expect(ctx.db.users.updateOne).to.have.been.calledWith( + { _id: ctx.user._id, email: { $ne: ctx.newEmail } }, + { $pull: { emails: { email: ctx.newEmail } } } ) }) - it('removes the affiliation', async function () { - await this.UserUpdater.promises.removeEmailAddress( - this.user._id, - this.newEmail, - this.auditLog + it('removes the affiliation', async function (ctx) { + await ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, + ctx.newEmail, + ctx.auditLog ) - expect(this.InstitutionsAPI.promises.removeAffiliation).to.have.been + expect(ctx.InstitutionsAPI.promises.removeAffiliation).to.have.been .calledOnce - const { args } = this.InstitutionsAPI.promises.removeAffiliation.lastCall - args[0].should.equal(this.user._id) - args[1].should.equal(this.newEmail) + const { args } = ctx.InstitutionsAPI.promises.removeAffiliation.lastCall + args[0].should.equal(ctx.user._id) + args[1].should.equal(ctx.newEmail) }) - it('refreshes features', async function () { - await this.UserUpdater.promises.removeEmailAddress( - this.user._id, - this.newEmail, - this.auditLog + it('refreshes features', async function (ctx) { + await ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, + ctx.newEmail, + ctx.auditLog ) sinon.assert.calledWith( - this.FeaturesUpdater.promises.refreshFeatures, - this.user._id + ctx.FeaturesUpdater.promises.refreshFeatures, + ctx.user._id ) }) - it('handles Mongo errors', async function () { + it('handles Mongo errors', async function (ctx) { const anError = new Error('nope') - this.db.users.updateOne.rejects(anError) + ctx.db.users.updateOne.rejects(anError) await expect( - this.UserUpdater.promises.removeEmailAddress( - this.user._id, - this.newEmail, - this.auditLog + ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, + ctx.newEmail, + ctx.auditLog ) ).to.be.rejected - expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been + expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been .called }) - it('handles missed update', async function () { - this.db.users.updateOne.resolves({ matchedCount: 0 }) + it('handles missed update', async function (ctx) { + ctx.db.users.updateOne.resolves({ matchedCount: 0 }) await expect( - this.UserUpdater.promises.removeEmailAddress( - this.user._id, - this.newEmail, - this.auditLog + ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, + ctx.newEmail, + ctx.auditLog ) ).to.be.rejectedWith('Cannot remove email') - expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been + expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been .called }) - it('handles an affiliation error', async function () { + it('handles an affiliation error', async function (ctx) { const anError = new Error('nope') - this.InstitutionsAPI.promises.removeAffiliation.rejects(anError) + ctx.InstitutionsAPI.promises.removeAffiliation.rejects(anError) await expect( - this.UserUpdater.promises.removeEmailAddress( - this.user._id, - this.newEmail, - this.auditLog + ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, + ctx.newEmail, + ctx.auditLog ) ).to.be.rejected - expect(this.db.users.updateOne).not.to.have.been.called - expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been + expect(ctx.db.users.updateOne).not.to.have.been.called + expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been .called }) - it('throws an error when removing the primary email', async function () { + it('throws an error when removing the primary email', async function (ctx) { await expect( - this.UserUpdater.promises.removeEmailAddress( - this.user._id, - this.user.email, - this.auditLog + ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, + ctx.user.email, + ctx.auditLog ) ).to.be.rejectedWith('cannot remove primary email') - expect(this.db.users.updateOne).not.to.have.been.called - expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been + expect(ctx.db.users.updateOne).not.to.have.been.called + expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been .called }) - it('validates the email', function () { + it('validates the email', function (ctx) { expect( - this.UserUpdater.promises.removeEmailAddress( - this.user._id, + ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, 'baz', - this.auditLog + ctx.auditLog ) ).to.be.rejectedWith('invalid email') }) - it('skips email validation when skipParseEmail included', async function () { + it('skips email validation when skipParseEmail included', async function (ctx) { const skipParseEmail = true - await this.UserUpdater.promises.removeEmailAddress( - this.user._id, + await ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, 'baz', - this.auditLog, + ctx.auditLog, skipParseEmail ) }) - it('throws an error when skipParseEmail included but email is not a string', async function () { + it('throws an error when skipParseEmail included but email is not a string', async function (ctx) { const skipParseEmail = true await expect( - this.UserUpdater.promises.removeEmailAddress( - this.user._id, + ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, 1, - this.auditLog, + ctx.auditLog, skipParseEmail ) ).to.be.rejectedWith('email must be a string') }) - it('logs the removal to the audit log', async function () { - await this.UserUpdater.promises.removeEmailAddress( - this.user._id, - this.newEmail, - this.auditLog + it('logs the removal to the audit log', async function (ctx) { + await ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, + ctx.newEmail, + ctx.auditLog ) - expect( - this.UserAuditLogHandler.promises.addEntry - ).to.have.been.calledWith( - this.user._id, + expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith( + ctx.user._id, 'remove-email', - this.auditLog.initiatorId, - this.auditLog.ipAddress, + ctx.auditLog.initiatorId, + ctx.auditLog.ipAddress, { - removedEmail: this.newEmail, + removedEmail: ctx.newEmail, } ) }) - it('logs the removal from script to the audit log', async function () { - this.auditLog = { + it('logs the removal from script to the audit log', async function (ctx) { + ctx.auditLog = { initiatorId: undefined, ipAddress: '0.0.0.0', extraInfo: { script: true, }, } - await this.UserUpdater.promises.removeEmailAddress( - this.user._id, - this.newEmail, - this.auditLog + await ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, + ctx.newEmail, + ctx.auditLog ) - expect( - this.UserAuditLogHandler.promises.addEntry - ).to.have.been.calledWith( - this.user._id, + expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith( + ctx.user._id, 'remove-email', - this.auditLog.initiatorId, - this.auditLog.ipAddress, + ctx.auditLog.initiatorId, + ctx.auditLog.ipAddress, { - removedEmail: this.newEmail, + removedEmail: ctx.newEmail, script: true, } ) }) - it('calls to remove userFullEmails from AsyncLocalStorage', async function () { - await this.UserUpdater.promises.removeEmailAddress( - this.user._id, - this.newEmail, - this.auditLog + it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) { + await ctx.UserUpdater.promises.removeEmailAddress( + ctx.user._id, + ctx.newEmail, + ctx.auditLog ) - expect(this.AsyncLocalStorage.removeItem).to.have.been.calledWith( + expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith( 'userFullEmails' ) }) @@ -610,208 +683,204 @@ describe('UserUpdater', function () { .resolves(emails) } - beforeEach(function () { - this.auditLog = { - initiatorId: this.user, + beforeEach(function (ctx) { + ctx.auditLog = { + initiatorId: ctx.user, ipAddress: '0:0:0:0', } - setUserEmails(this, [ + setUserEmails(ctx, [ { - email: this.newEmail, + email: ctx.newEmail, confirmedAt: new Date(), }, ]) }) - it('set default', async function () { - await this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + it('set default', async function (ctx) { + await ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog + ctx.auditLog ) - expect(this.db.users.updateOne).to.have.been.calledWith( - { _id: this.user._id, 'emails.email': this.newEmail }, + expect(ctx.db.users.updateOne).to.have.been.calledWith( + { _id: ctx.user._id, 'emails.email': ctx.newEmail }, { $set: { - email: this.newEmail, + email: ctx.newEmail, lastPrimaryEmailCheck: sinon.match.date, }, } ) }) - it('sets the changed email in the newsletter', async function () { - await this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + it('sets the changed email in the newsletter', async function (ctx) { + await ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog + ctx.auditLog ) expect( - this.NewsletterManager.promises.changeEmail - ).to.have.been.calledWith(this.user, this.newEmail) - expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + ctx.NewsletterManager.promises.changeEmail + ).to.have.been.calledWith(ctx.user, ctx.newEmail) + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( 'updateAccountEmailAddress', - this.user._id, - this.newEmail + ctx.user._id, + ctx.newEmail ) }) - it('handles Mongo errors', async function () { - this.db.users.updateOne = sinon.stub().rejects(Error('nope')) + it('handles Mongo errors', async function (ctx) { + ctx.db.users.updateOne = sinon.stub().rejects(Error('nope')) await expect( - this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog + ctx.auditLog ) ).to.be.rejected }) - it('handles missed updates', async function () { - this.db.users.updateOne.resolves({ matchedCount: 0 }) + it('handles missed updates', async function (ctx) { + ctx.db.users.updateOne.resolves({ matchedCount: 0 }) await expect( - this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog + ctx.auditLog ) ).to.be.rejected }) - it('validates the email', async function () { + it('validates the email', async function (ctx) { await expect( - this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, + ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, '.edu', false, - this.auditLog + ctx.auditLog ) ).to.be.rejected }) - it('updates the audit log', async function () { - await this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + it('updates the audit log', async function (ctx) { + await ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog + ctx.auditLog ) - expect( - this.UserAuditLogHandler.promises.addEntry - ).to.have.been.calledWith( - this.user._id, + expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith( + ctx.user._id, 'change-primary-email', - this.auditLog.initiatorId, - this.auditLog.ipAddress, + ctx.auditLog.initiatorId, + ctx.auditLog.ipAddress, { - newPrimaryEmail: this.newEmail, - oldPrimaryEmail: this.user.email, + newPrimaryEmail: ctx.newEmail, + oldPrimaryEmail: ctx.user.email, } ) }) - it('blocks email update if audit log returns an error', async function () { - this.UserAuditLogHandler.promises.addEntry.rejects(new Error('oops')) + it('blocks email update if audit log returns an error', async function (ctx) { + ctx.UserAuditLogHandler.promises.addEntry.rejects(new Error('oops')) await expect( - this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog + ctx.auditLog ) ).to.be.rejected - expect(this.db.users.updateOne).to.not.have.been.called + expect(ctx.db.users.updateOne).to.not.have.been.called }) - it('calls to remove userFullEmails from AsyncLocalStorage', async function () { - await this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) { + await ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog + ctx.auditLog ) - expect(this.AsyncLocalStorage.removeItem).to.have.been.calledWith( + expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith( 'userFullEmails' ) }) describe('when email not confirmed', function () { - beforeEach(function () { - setUserEmails(this, [ + beforeEach(function (ctx) { + setUserEmails(ctx, [ { - email: this.newEmail, + email: ctx.newEmail, confirmedAt: null, }, ]) }) - it('should throw an error', async function () { + it('should throw an error', async function (ctx) { await expect( - this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog + ctx.auditLog ) ).to.be.rejectedWith(Errors.UnconfirmedEmailError) - expect(this.db.users.updateOne).to.not.have.been.called - expect(this.NewsletterManager.promises.changeEmail).to.not.have.been + expect(ctx.db.users.updateOne).to.not.have.been.called + expect(ctx.NewsletterManager.promises.changeEmail).to.not.have.been .called }) }) describe('when email does not belong to user', function () { - beforeEach(function () { - setUserEmails(this, []) - this.UserUpdater.promises.updateUser = sinon.stub() + beforeEach(function (ctx) { + setUserEmails(ctx, []) + ctx.UserUpdater.promises.updateUser = sinon.stub() }) - it('should callback with error', function () { - this.UserUpdater.setDefaultEmailAddress( - this.user._id, - this.newEmail, + it('should callback with error', function (ctx) { + ctx.UserUpdater.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog, + ctx.auditLog, error => { expect(error).to.exist expect(error.name).to.equal('Error') - this.UserUpdater.promises.updateUser.callCount.should.equal(0) - this.NewsletterManager.promises.changeEmail.callCount.should.equal( - 0 - ) + ctx.UserUpdater.promises.updateUser.callCount.should.equal(0) + ctx.NewsletterManager.promises.changeEmail.callCount.should.equal(0) } ) }) }) describe('security alert', function () { - it('should be sent to old and new email when sendSecurityAlert=true', async function () { - await this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + it('should be sent to old and new email when sendSecurityAlert=true', async function (ctx) { + await ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog, + ctx.auditLog, true ) // Emails are sent asynchronously. Wait a bit. await setTimeout(100) - this.EmailHandler.promises.sendEmail.callCount.should.equal(2) - for (const recipient of [this.user.email, this.newEmail]) { - expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith( + ctx.EmailHandler.promises.sendEmail.callCount.should.equal(2) + for (const recipient of [ctx.user.email, ctx.newEmail]) { + expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith( 'securityAlert', sinon.match({ to: recipient }) ) } }) - it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails', async function () { - setUserEmails(this, [ + it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails', async function (ctx) { + setUserEmails(ctx, [ { email: '1@a1.uni', confirmedAt: new Date(2020, 0, 1), @@ -853,41 +922,41 @@ describe('UserUpdater', function () { lastConfirmedAt: new Date(2021, 6, 1), }, { - email: this.user.email, + email: ctx.user.email, confirmedAt: new Date(2021, 6, 1), lastConfirmedAt: new Date(2021, 6, 1), }, { - email: this.newEmail, + email: ctx.newEmail, confirmedAt: new Date(2021, 6, 1), lastConfirmedAt: new Date(2021, 6, 1), }, ]) - await this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + await ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog, + ctx.auditLog, true ) // Emails are sent asynchronously. Wait a bit. await setTimeout(100) - this.EmailHandler.promises.sendEmail.callCount.should.equal(4) + ctx.EmailHandler.promises.sendEmail.callCount.should.equal(4) for (const recipient of [ - this.user.email, - this.newEmail, + ctx.user.email, + ctx.newEmail, '2@a1.uni', '2021@foo.bar', ]) { - expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith( + expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith( 'securityAlert', sinon.match({ to: recipient }) ) } }) - it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails (multiple institutions and unaffiliated email domains)', async function () { - setUserEmails(this, [ + it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails (multiple institutions and unaffiliated email domains)', async function (ctx) { + setUserEmails(ctx, [ { email: '1@a1.uni', confirmedAt: new Date(2020, 0, 1), @@ -929,35 +998,35 @@ describe('UserUpdater', function () { lastConfirmedAt: new Date(2021, 6, 1), }, { - email: this.user.email, + email: ctx.user.email, confirmedAt: new Date(2021, 6, 1), lastConfirmedAt: new Date(2021, 6, 1), }, { - email: this.newEmail, + email: ctx.newEmail, confirmedAt: new Date(2021, 6, 1), lastConfirmedAt: new Date(2021, 6, 1), }, ]) - await this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + await ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog, + ctx.auditLog, true ) // Emails are sent asynchronously. Wait a bit. await setTimeout(100) - this.EmailHandler.promises.sendEmail.callCount.should.equal(6) + ctx.EmailHandler.promises.sendEmail.callCount.should.equal(6) for (const recipient of [ - this.user.email, - this.newEmail, + ctx.user.email, + ctx.newEmail, '1@a1.uni', '1@b2.uni', '2020@foo.bar', '2021@bar.foo', ]) { - expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith( + expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith( 'securityAlert', sinon.match({ to: recipient }) ) @@ -967,23 +1036,23 @@ describe('UserUpdater', function () { describe('errors', function () { const anError = new Error('oops') describe('EmailHandler', function () { - beforeEach(function () { - this.EmailHandler.promises.sendEmail.rejects(anError) + beforeEach(function (ctx) { + ctx.EmailHandler.promises.sendEmail.rejects(anError) }) - it('should log but not pass back the error', async function () { - await this.UserUpdater.promises.setDefaultEmailAddress( - this.user._id, - this.newEmail, + it('should log but not pass back the error', async function (ctx) { + await ctx.UserUpdater.promises.setDefaultEmailAddress( + ctx.user._id, + ctx.newEmail, false, - this.auditLog, + ctx.auditLog, true ) - const loggerCall = this.logger.error.firstCall - expect(loggerCall.args[0]).to.deep.equal({ + const loggerCall = ctx.logger.error.mock.calls[0] + expect(loggerCall[0]).to.deep.equal({ error: anError, - userId: this.user._id, + userId: ctx.user._id, }) - expect(loggerCall.args[1]).to.contain( + expect(loggerCall[1]).to.contain( 'could not send security alert email when primary email changed' ) }) @@ -993,15 +1062,12 @@ describe('UserUpdater', function () { }) describe('confirmEmail', function () { - it('should update the email record', async function () { - await this.UserUpdater.promises.confirmEmail( - this.user._id, - this.user.email - ) - expect(this.db.users.updateOne).to.have.been.calledWith( + it('should update the email record', async function (ctx) { + await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.user.email) + expect(ctx.db.users.updateOne).to.have.been.calledWith( { - _id: this.user._id, - 'emails.email': this.user.email, + _id: ctx.user._id, + 'emails.email': ctx.user.email, }, { $set: { @@ -1014,89 +1080,89 @@ describe('UserUpdater', function () { ) }) - it('adds affiliation', async function () { - await this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail) - this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true) + it('adds affiliation', async function (ctx) { + await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail) + ctx.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true) sinon.assert.calledWith( - this.InstitutionsAPI.promises.addAffiliation, - this.user._id, - this.newEmail, + ctx.InstitutionsAPI.promises.addAffiliation, + ctx.user._id, + ctx.newEmail, { confirmedAt: new Date() } ) }) - it('handles errors', async function () { - this.db.users.updateOne.rejects(new Error('nope')) + it('handles errors', async function (ctx) { + ctx.db.users.updateOne.rejects(new Error('nope')) await expect( - this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail) + ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail) ).to.be.rejected }) - it('handle missed update', async function () { - this.db.users.updateOne.resolves({ matchedCount: 0 }) + it('handle missed update', async function (ctx) { + ctx.db.users.updateOne.resolves({ matchedCount: 0 }) await expect( - this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail) + ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail) ).to.be.rejected }) - it('validates email', async function () { - expect(this.UserUpdater.promises.confirmEmail(this.user._id, '@')).to.be + it('validates email', async function (ctx) { + expect(ctx.UserUpdater.promises.confirmEmail(ctx.user._id, '@')).to.be .rejected }) - it('handles affiliation errors', async function () { - this.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope')) + it('handles affiliation errors', async function (ctx) { + ctx.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope')) await expect( - this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail) + ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail) ).to.be.rejected - expect(this.db.users.updateOne).to.not.have.been.called + expect(ctx.db.users.updateOne).to.not.have.been.called }) - it('refreshes features', async function () { - await this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail) + it('refreshes features', async function (ctx) { + await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail) sinon.assert.calledWith( - this.FeaturesUpdater.promises.refreshFeatures, - this.user._id + ctx.FeaturesUpdater.promises.refreshFeatures, + ctx.user._id ) }) - it('should not call redundantPersonalSubscription when user is not on a commons license', async function () { - this.InstitutionsAPI.promises.getUserAffiliations.resolves([]) - this.SubscriptionLocator.promises.getUserIndividualSubscription.resolves({ + it('should not call redundantPersonalSubscription when user is not on a commons license', async function (ctx) { + ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([]) + ctx.SubscriptionLocator.promises.getUserIndividualSubscription.resolves({ planCode: 'personal', groupPlan: false, }) - await this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail) + await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail) sinon.assert.notCalled( - this.NotificationsBuilder.promises.redundantPersonalSubscription + ctx.NotificationsBuilder.promises.redundantPersonalSubscription ) }) - it('calls to remove userFullEmails from AsyncLocalStorage', async function () { - await this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail) - expect(this.AsyncLocalStorage.removeItem).to.have.been.called - expect(this.AsyncLocalStorage.removeItem).to.have.been.calledWith( + it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) { + await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail) + expect(ctx.AsyncLocalStorage.removeItem).to.have.been.called + expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith( 'userFullEmails' ) }) describe('with institution licence and subscription', function () { - beforeEach(async function () { - this.affiliation = { - email: this.newEmail, + beforeEach(async function (ctx) { + ctx.affiliation = { + email: ctx.newEmail, licence: 'pro_plus', institution: { id: 123, name: 'Institution', }, } - this.InstitutionsAPI.promises.getUserAffiliations.resolves([ - this.affiliation, + ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([ + ctx.affiliation, { email: 'other@email.edu' }, ]) - this.SubscriptionLocator.promises.getUserIndividualSubscription.resolves( + ctx.SubscriptionLocator.promises.getUserIndividualSubscription.resolves( { planCode: 'personal', groupPlan: false, @@ -1104,80 +1170,75 @@ describe('UserUpdater', function () { ) }) - it('creates redundant subscription notification', async function () { - await this.UserUpdater.promises.confirmEmail( - this.user._id, - this.newEmail + it('creates redundant subscription notification', async function (ctx) { + await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail) + sinon.assert.calledWith( + ctx.InstitutionsAPI.promises.getUserAffiliations, + ctx.user._id ) sinon.assert.calledWith( - this.InstitutionsAPI.promises.getUserAffiliations, - this.user._id + ctx.SubscriptionLocator.promises.getUserIndividualSubscription, + ctx.user._id ) sinon.assert.calledWith( - this.SubscriptionLocator.promises.getUserIndividualSubscription, - this.user._id - ) - sinon.assert.calledWith( - this.NotificationsBuilder.promises.redundantPersonalSubscription, + ctx.NotificationsBuilder.promises.redundantPersonalSubscription, { institutionId: 123, institutionName: 'Institution', }, - { _id: this.user._id } + { _id: ctx.user._id } ) }) }) }) describe('suspendUser', function () { - beforeEach(function () { - this.auditLog = { + beforeEach(function (ctx) { + ctx.auditLog = { initiatorId: 'abc123', ip: '0.0.0.0', } }) - it('should suspend the user', async function () { - await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog) - expect(this.db.users.updateOne).to.have.been.calledWith( - { _id: this.user._id, suspended: { $ne: true } }, + it('should suspend the user', async function (ctx) { + await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog) + expect(ctx.db.users.updateOne).to.have.been.calledWith( + { _id: ctx.user._id, suspended: { $ne: true } }, { $set: { suspended: true } } ) }) - it('should remove sessions from redis', async function () { - await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog) + it('should remove sessions from redis', async function (ctx) { + await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog) expect( - this.UserSessionsManager.promises.removeSessionsFromRedis - ).to.have.been.calledWith({ _id: this.user._id }) + ctx.UserSessionsManager.promises.removeSessionsFromRedis + ).to.have.been.calledWith({ _id: ctx.user._id }) }) - it('should log the suspension to the audit log', async function () { - await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog) - expect( - this.UserAuditLogHandler.promises.addEntry - ).to.have.been.calledWith( - this.user._id, + it('should log the suspension to the audit log', async function (ctx) { + await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog) + expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith( + ctx.user._id, 'account-suspension', - this.auditLog.initiatorId, - this.auditLog.ip, + ctx.auditLog.initiatorId, + ctx.auditLog.ip, {} ) }) - it('should fire the removeDropbox hook', async function () { - await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog) - expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( + it('should fire the removeDropbox hook', async function (ctx) { + await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog) + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( 'removeDropbox', - this.user._id, + ctx.user._id, 'account-suspension' ) }) - it('should handle not finding a record to update', async function () { - this.db.users.updateOne.resolves({ matchedCount: 0 }) + it('should handle not finding a record to update', async function (ctx) { + ctx.db.users.updateOne.resolves({ matchedCount: 0 }) await expect( - this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog) + ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog) ).to.be.rejectedWith(Errors.NotFoundError) }) }) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs index f31dd7d831..4d7aae6336 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs +++ b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs @@ -103,6 +103,24 @@ describe('UserMembershipController', () => { managedUsers: { enabled: false, }, + plans: [ + { + planCode: 'personal', + name: 'Personal', + price_in_cents: 0, + features: { + collaborators: -1, + dropbox: true, + github: true, + gitBridge: true, + versioning: true, + compileTimeout: 180, + compileGroup: 'standard', + references: true, + trackChanges: true, + }, + }, + ], } ctx.SessionManager = { diff --git a/services/web/test/unit/src/UserMembership/UserMembershipsHandler.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipsHandler.test.mjs index ba349f213f..d06510fc44 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipsHandler.test.mjs +++ b/services/web/test/unit/src/UserMembership/UserMembershipsHandler.test.mjs @@ -1,66 +1,56 @@ -/* eslint-disable - n/handle-callback-err, - max-len, - no-return-assign, -*/ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -const sinon = require('sinon') +import { vi } from 'vitest' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' + const assertCalledWith = sinon.assert.calledWith -const { ObjectId } = require('mongodb-legacy') const modulePath = '../../../../app/src/Features/UserMembership/UserMembershipsHandler' -const SandboxedModule = require('sandboxed-module') + +const { ObjectId } = mongodb describe('UserMembershipsHandler', function () { - beforeEach(function () { - this.user = { _id: new ObjectId() } + beforeEach(async function (ctx) { + ctx.user = { _id: new ObjectId() } - this.Institution = { updateMany: sinon.stub().resolves(null) } - this.Subscription = { updateMany: sinon.stub().resolves(null) } - this.Publisher = { updateMany: sinon.stub().resolves(null) } - return (this.UserMembershipsHandler = SandboxedModule.require(modulePath, { - requires: { - '../../models/Institution': { - Institution: this.Institution, - }, - '../../models/Subscription': { - Subscription: this.Subscription, - }, - '../../models/Publisher': { - Publisher: this.Publisher, - }, - }, + ctx.Institution = { updateMany: sinon.stub().resolves(null) } + ctx.Subscription = { updateMany: sinon.stub().resolves(null) } + ctx.Publisher = { updateMany: sinon.stub().resolves(null) } + + vi.doMock('../../../../app/src/models/Institution', () => ({ + default: { Institution: ctx.Institution }, })) + + vi.doMock('../../../../app/src/models/Subscription', () => ({ + default: { Subscription: ctx.Subscription }, + })) + + vi.doMock('../../../../app/src/models/Publisher', () => ({ + default: { Publisher: ctx.Publisher }, + })) + + ctx.UserMembershipsHandler = (await import(modulePath)).default }) describe('remove user', function () { - it('remove user from all entities', function (done) { - return this.UserMembershipsHandler.removeUserFromAllEntities( - this.user._id, - error => { - assertCalledWith( - this.Institution.updateMany, - {}, - { $pull: { managerIds: this.user._id } } - ) - assertCalledWith( - this.Subscription.updateMany, - {}, - { $pull: { manager_ids: this.user._id } } - ) - assertCalledWith( - this.Publisher.updateMany, - {}, - { $pull: { managerIds: this.user._id } } - ) - return done() - } + it('remove user from all entities', async function (ctx) { + await ctx.UserMembershipsHandler.promises.removeUserFromAllEntities( + ctx.user._id + ) + + assertCalledWith( + ctx.Institution.updateMany, + {}, + { $pull: { managerIds: ctx.user._id } } + ) + assertCalledWith( + ctx.Subscription.updateMany, + {}, + { $pull: { manager_ids: ctx.user._id } } + ) + assertCalledWith( + ctx.Publisher.updateMany, + {}, + { $pull: { managerIds: ctx.user._id } } ) }) })