diff --git a/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.js b/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.mjs similarity index 80% rename from services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.js rename to services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.mjs index 733d636f60..99f931e1d9 100644 --- a/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.js +++ b/services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.mjs @@ -1,7 +1,7 @@ -const logger = require('@overleaf/logger') -const OError = require('@overleaf/o-error') -const AnalyticsRegistrationSourceHelper = require('./AnalyticsRegistrationSourceHelper') -const SessionManager = require('../../Features/Authentication/SessionManager') +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import AnalyticsRegistrationSourceHelper from './AnalyticsRegistrationSourceHelper.js' +import SessionManager from '../../Features/Authentication/SessionManager.js' function setSource(medium, source) { return function (req, res, next) { @@ -51,7 +51,7 @@ function setInbound() { } } -module.exports = { +export default { setSource, clearSource, setInbound, diff --git a/services/web/app/src/Features/Analytics/AnalyticsRouter.mjs b/services/web/app/src/Features/Analytics/AnalyticsRouter.mjs index 933d4b0c13..4f773c2d40 100644 --- a/services/web/app/src/Features/Analytics/AnalyticsRouter.mjs +++ b/services/web/app/src/Features/Analytics/AnalyticsRouter.mjs @@ -2,7 +2,7 @@ import AuthenticationController from './../Authentication/AuthenticationControll import AnalyticsController from './AnalyticsController.mjs' import AnalyticsProxy from './AnalyticsProxy.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.js' -import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs' const rateLimiters = { recordEvent: new RateLimiter('analytics-record-event', { diff --git a/services/web/app/src/Features/Authorization/PermissionsController.mjs b/services/web/app/src/Features/Authorization/PermissionsController.mjs index 0b6454ce5d..25b9702547 100644 --- a/services/web/app/src/Features/Authorization/PermissionsController.mjs +++ b/services/web/app/src/Features/Authorization/PermissionsController.mjs @@ -1,6 +1,6 @@ // @ts-check import { ForbiddenError, UserNotFoundError } from '../Errors/Errors.js' -import PermissionsManager from './PermissionsManager.js' +import PermissionsManager from './PermissionsManager.mjs' import Modules from '../../infrastructure/Modules.js' import { expressify } from '@overleaf/promise-utils' import Features from '../../infrastructure/Features.js' @@ -9,7 +9,7 @@ import Features from '../../infrastructure/Features.js' * @typedef {(import('express').Request)} Request * @typedef {(import('express').Response)} Response * @typedef {(import('express').NextFunction)} NextFunction - * @typedef {import('./PermissionsManager').Capability} Capability + * @typedef {import('./PermissionsManager.mjs').Capability} Capability */ const { diff --git a/services/web/app/src/Features/Authorization/PermissionsManager.js b/services/web/app/src/Features/Authorization/PermissionsManager.mjs similarity index 98% rename from services/web/app/src/Features/Authorization/PermissionsManager.js rename to services/web/app/src/Features/Authorization/PermissionsManager.mjs index aad1021b24..5924750f9e 100644 --- a/services/web/app/src/Features/Authorization/PermissionsManager.js +++ b/services/web/app/src/Features/Authorization/PermissionsManager.mjs @@ -41,9 +41,12 @@ * } */ -const { callbackify } = require('util') -const { ForbiddenError } = require('../Errors/Errors') -const Modules = require('../../infrastructure/Modules') +import { callbackify } from 'node:util' + +import Errors from '../Errors/Errors.js' +import Modules from '../../infrastructure/Modules.js' + +const { ForbiddenError } = Errors /** * @typedef {(import('../../../../types/capabilities').Capability)} Capability @@ -466,7 +469,7 @@ async function checkUserListPermissions(userList, capabilities) { return true } -module.exports = { +export default { validatePolicies, registerCapability, registerPolicy, diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs index b38f80d1f0..c8f8c5b44b 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs @@ -3,9 +3,9 @@ import AuthenticationController from '../Authentication/AuthenticationController import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs' import CollaboratorsInviteController from './CollaboratorsInviteController.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.js' -import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs' import CaptchaMiddleware from '../Captcha/CaptchaMiddleware.mjs' -import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.js' +import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.mjs' const rateLimiters = { inviteToProjectByProjectId: new RateLimiter( diff --git a/services/web/app/src/Features/Compile/ClsiManager.js b/services/web/app/src/Features/Compile/ClsiManager.mjs similarity index 95% rename from services/web/app/src/Features/Compile/ClsiManager.js rename to services/web/app/src/Features/Compile/ClsiManager.mjs index e0c95a1685..0a4c327a42 100644 --- a/services/web/app/src/Features/Compile/ClsiManager.js +++ b/services/web/app/src/Features/Compile/ClsiManager.mjs @@ -1,33 +1,35 @@ -const { callbackify } = require('util') -const { callbackifyMultiResult } = require('@overleaf/promise-utils') -const { +import { callbackify } from 'node:util' +import { callbackifyMultiResult } from '@overleaf/promise-utils' +import { fetchString, fetchStringWithResponse, fetchStream, RequestFailedError, -} = require('@overleaf/fetch-utils') -const Settings = require('@overleaf/settings') -const ProjectGetter = require('../Project/ProjectGetter') -const ProjectEntityHandler = require('../Project/ProjectEntityHandler') -const logger = require('@overleaf/logger') -const OError = require('@overleaf/o-error') -const { Cookie } = require('tough-cookie') -const ClsiCookieManager = require('./ClsiCookieManager')( +} from '@overleaf/fetch-utils' +import Settings from '@overleaf/settings' +import ProjectGetter from '../Project/ProjectGetter.js' +import ProjectEntityHandler from '../Project/ProjectEntityHandler.js' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import { Cookie } from 'tough-cookie' +import ClsiCookieManagerFactory from './ClsiCookieManager.js' +import ClsiStateManager from './ClsiStateManager.js' +import _ from 'lodash' +import ClsiFormatChecker from './ClsiFormatChecker.js' +import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js' +import Metrics from '@overleaf/metrics' +import Errors from '../Errors/Errors.js' +import ClsiCacheHandler from './ClsiCacheHandler.js' +import HistoryManager from '../History/HistoryManager.js' +import SplitTestHandler from '../SplitTests/SplitTestHandler.js' +import AnalyticsManager from '../Analytics/AnalyticsManager.js' + +const ClsiCookieManager = ClsiCookieManagerFactory( Settings.apis.clsi?.backendGroupName ) -const NewBackendCloudClsiCookieManager = require('./ClsiCookieManager')( +const NewBackendCloudClsiCookieManager = ClsiCookieManagerFactory( Settings.apis.clsi_new?.backendGroupName ) -const ClsiStateManager = require('./ClsiStateManager') -const _ = require('lodash') -const ClsiFormatChecker = require('./ClsiFormatChecker') -const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler') -const Metrics = require('@overleaf/metrics') -const Errors = require('../Errors/Errors') -const ClsiCacheHandler = require('./ClsiCacheHandler') -const { getFilestoreBlobURL } = require('../History/HistoryManager') -const SplitTestHandler = require('../SplitTests/SplitTestHandler') -const AnalyticsManager = require('../Analytics/AnalyticsManager') const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex'] const OUTPUT_FILE_TIMEOUT_MS = 60000 @@ -843,7 +845,7 @@ function _finaliseRequest(projectId, options, project, docs, files) { path = path.replace(/^\//, '') // Remove leading / resources.push({ path, - url: getFilestoreBlobURL(historyId, file.hash), + url: HistoryManager.getFilestoreBlobURL(historyId, file.hash), modified: file.created?.getTime(), }) } @@ -975,7 +977,7 @@ function _getClsiServerIdFromResponse(response) { return null } -module.exports = { +export default { sendRequest: callbackifyMultiResult(sendRequest, [ 'status', 'outputFiles', diff --git a/services/web/app/src/Features/Compile/CompileController.mjs b/services/web/app/src/Features/Compile/CompileController.mjs index a7e6a317bd..58ce47c25b 100644 --- a/services/web/app/src/Features/Compile/CompileController.mjs +++ b/services/web/app/src/Features/Compile/CompileController.mjs @@ -5,7 +5,7 @@ import OError from '@overleaf/o-error' import Metrics from '@overleaf/metrics' import ProjectGetter from '../Project/ProjectGetter.js' import CompileManager from './CompileManager.mjs' -import ClsiManager from './ClsiManager.js' +import ClsiManager from './ClsiManager.mjs' import logger from '@overleaf/logger' import Settings from '@overleaf/settings' import Errors from '../Errors/Errors.js' diff --git a/services/web/app/src/Features/Compile/CompileManager.mjs b/services/web/app/src/Features/Compile/CompileManager.mjs index c509ec3d49..de0ad7b9cd 100644 --- a/services/web/app/src/Features/Compile/CompileManager.mjs +++ b/services/web/app/src/Features/Compile/CompileManager.mjs @@ -4,7 +4,7 @@ import RedisWrapper from '../../infrastructure/RedisWrapper.js' import ProjectGetter from '../Project/ProjectGetter.js' import ProjectRootDocManager from '../Project/ProjectRootDocManager.js' import UserGetter from '../User/UserGetter.js' -import ClsiManager from './ClsiManager.js' +import ClsiManager from './ClsiManager.mjs' import Metrics from '@overleaf/metrics' import { RateLimiter } from '../../infrastructure/RateLimiter.js' import UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.js' diff --git a/services/web/app/src/Features/Editor/EditorRouter.mjs b/services/web/app/src/Features/Editor/EditorRouter.mjs index f208132f9c..f39dcec991 100644 --- a/services/web/app/src/Features/Editor/EditorRouter.mjs +++ b/services/web/app/src/Features/Editor/EditorRouter.mjs @@ -2,7 +2,7 @@ import EditorHttpController from './EditorHttpController.mjs' import AuthenticationController from '../Authentication/AuthenticationController.js' import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.js' -import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs' const rateLimiters = { addDocToProject: new RateLimiter('add-doc-to-project', { diff --git a/services/web/app/src/Features/History/HistoryRouter.mjs b/services/web/app/src/Features/History/HistoryRouter.mjs index 38c338a061..1bf1edf2b2 100644 --- a/services/web/app/src/Features/History/HistoryRouter.mjs +++ b/services/web/app/src/Features/History/HistoryRouter.mjs @@ -4,7 +4,7 @@ import Settings from '@overleaf/settings' import { RateLimiter } from '../../infrastructure/RateLimiter.js' import AuthenticationController from '../Authentication/AuthenticationController.js' import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs' -import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs' import HistoryController from './HistoryController.mjs' const rateLimiters = { diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesRouter.mjs b/services/web/app/src/Features/LinkedFiles/LinkedFilesRouter.mjs index 66007aec52..41cad0bace 100644 --- a/services/web/app/src/Features/LinkedFiles/LinkedFilesRouter.mjs +++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesRouter.mjs @@ -1,7 +1,7 @@ import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs' import AuthenticationController from '../Authentication/AuthenticationController.js' import { RateLimiter } from '../../infrastructure/RateLimiter.js' -import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs' import LinkedFilesController from './LinkedFilesController.mjs' const rateLimiters = { diff --git a/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs b/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs index 99acbfc1c4..676d4f0031 100644 --- a/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs +++ b/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs @@ -1,6 +1,6 @@ import AuthorizationManager from '../Authorization/AuthorizationManager.js' import CompileManager from '../Compile/CompileManager.mjs' -import ClsiManager from '../Compile/ClsiManager.js' +import ClsiManager from '../Compile/ClsiManager.mjs' import ProjectFileAgent from './ProjectFileAgent.mjs' import _ from 'lodash' import LinkedFilesErrors from './LinkedFilesErrors.mjs' diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs index 094f18b95f..995306a431 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs @@ -5,7 +5,7 @@ import OneTimeTokenHandler from '../Security/OneTimeTokenHandler.js' import EmailHandler from '../Email/EmailHandler.js' import AuthenticationManager from '../Authentication/AuthenticationManager.js' import { callbackify, promisify } from 'node:util' -import PermissionsManager from '../Authorization/PermissionsManager.js' +import PermissionsManager from '../Authorization/PermissionsManager.mjs' const assertUserPermissions = PermissionsManager.promises.assertUserPermissions diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetRouter.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetRouter.mjs index ebb9997952..36a729aef3 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetRouter.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetRouter.mjs @@ -2,7 +2,7 @@ import PasswordResetController from './PasswordResetController.mjs' import AuthenticationController from '../Authentication/AuthenticationController.js' import CaptchaMiddleware from '../../Features/Captcha/CaptchaMiddleware.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.js' -import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs' const rateLimiter = new RateLimiter('password_reset_rate_limit', { points: 6, diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs index 8b8150c21d..b5957b1e77 100644 --- a/services/web/app/src/Features/Project/ProjectListController.mjs +++ b/services/web/app/src/Features/Project/ProjectListController.mjs @@ -14,7 +14,7 @@ import TagsHandler from '../Tags/TagsHandler.js' import { expressify } from '@overleaf/promise-utils' import logger from '@overleaf/logger' import Features from '../../infrastructure/Features.js' -import SubscriptionViewModelBuilder from '../Subscription/SubscriptionViewModelBuilder.js' +import SubscriptionViewModelBuilder from '../Subscription/SubscriptionViewModelBuilder.mjs' import NotificationsHandler from '../Notifications/NotificationsHandler.js' import Modules from '../../infrastructure/Modules.js' import { OError, V1ConnectionError } from '../Errors/Errors.js' @@ -27,7 +27,7 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.js' import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js' import TutorialHandler from '../Tutorial/TutorialHandler.mjs' import SubscriptionHelper from '../Subscription/SubscriptionHelper.js' -import PermissionsManager from '../Authorization/PermissionsManager.js' +import PermissionsManager from '../Authorization/PermissionsManager.mjs' import AnalyticsManager from '../Analytics/AnalyticsManager.js' /** diff --git a/services/web/app/src/Features/Security/RateLimiterMiddleware.js b/services/web/app/src/Features/Security/RateLimiterMiddleware.mjs similarity index 89% rename from services/web/app/src/Features/Security/RateLimiterMiddleware.js rename to services/web/app/src/Features/Security/RateLimiterMiddleware.mjs index 571eb8a307..3b4555a4e1 100644 --- a/services/web/app/src/Features/Security/RateLimiterMiddleware.js +++ b/services/web/app/src/Features/Security/RateLimiterMiddleware.mjs @@ -1,7 +1,7 @@ -const logger = require('@overleaf/logger') -const SessionManager = require('../Authentication/SessionManager') -const LoginRateLimiter = require('./LoginRateLimiter') -const settings = require('@overleaf/settings') +import logger from '@overleaf/logger' +import SessionManager from '../Authentication/SessionManager.js' +import LoginRateLimiter from './LoginRateLimiter.js' +import settings from '@overleaf/settings' /** * Return a rate limiting middleware @@ -88,4 +88,4 @@ const RateLimiterMiddleware = { loginRateLimitEmail, } -module.exports = RateLimiterMiddleware +export default RateLimiterMiddleware diff --git a/services/web/app/src/Features/Spelling/LearnedWordsManager.js b/services/web/app/src/Features/Spelling/LearnedWordsManager.mjs similarity index 88% rename from services/web/app/src/Features/Spelling/LearnedWordsManager.js rename to services/web/app/src/Features/Spelling/LearnedWordsManager.mjs index 4d3162c0a1..aae59aa51b 100644 --- a/services/web/app/src/Features/Spelling/LearnedWordsManager.js +++ b/services/web/app/src/Features/Spelling/LearnedWordsManager.mjs @@ -1,9 +1,9 @@ -// @ts-check +import mongodb from '../../infrastructure/mongodb.js' +import { callbackify } from 'node:util' +import Settings from '@overleaf/settings' +import Errors from '../Errors/Errors.js' -const { db } = require('../../infrastructure/mongodb') -const { callbackify } = require('util') -const Settings = require('@overleaf/settings') -const { InvalidError } = require('../Errors/Errors') +const { db } = mongodb const LearnedWordsManager = { /** @@ -15,7 +15,7 @@ const LearnedWordsManager = { const wordSize = Buffer.from(word).length if (wordsSize + wordSize > Settings.maxDictionarySize) { - throw new InvalidError('Max dictionary size reached') + throw new Errors.InvalidError('Max dictionary size reached') } return await db.spellingPreferences.updateOne( @@ -86,7 +86,7 @@ const LearnedWordsManager = { }, } -module.exports = { +export default { learnWord: callbackify(LearnedWordsManager.learnWord), unlearnWord: callbackify(LearnedWordsManager.unlearnWord), getLearnedWords: callbackify(LearnedWordsManager.getLearnedWords), diff --git a/services/web/app/src/Features/Spelling/SpellingController.mjs b/services/web/app/src/Features/Spelling/SpellingController.mjs index 5750ee5ce3..e27c5412bb 100644 --- a/services/web/app/src/Features/Spelling/SpellingController.mjs +++ b/services/web/app/src/Features/Spelling/SpellingController.mjs @@ -1,7 +1,7 @@ // @ts-check import SessionManager from '../Authentication/SessionManager.js' -import LearnedWordsManager from './LearnedWordsManager.js' +import LearnedWordsManager from './LearnedWordsManager.mjs' import { z, validateReq } from '../../infrastructure/Validation.js' const learnSchema = z.object({ diff --git a/services/web/app/src/Features/Spelling/SpellingHandler.mjs b/services/web/app/src/Features/Spelling/SpellingHandler.mjs index 0d55b0ff2a..f9ee3253bc 100644 --- a/services/web/app/src/Features/Spelling/SpellingHandler.mjs +++ b/services/web/app/src/Features/Spelling/SpellingHandler.mjs @@ -1,7 +1,7 @@ import OError from '@overleaf/o-error' import Metrics from '@overleaf/metrics' import { promisifyAll } from '@overleaf/promise-utils' -import LearnedWordsManager from './LearnedWordsManager.js' +import LearnedWordsManager from './LearnedWordsManager.mjs' const SpellingHandler = { getUserDictionary(userId, callback) { diff --git a/services/web/app/src/Features/Subscription/RecurlyEventHandler.js b/services/web/app/src/Features/Subscription/RecurlyEventHandler.mjs similarity index 96% rename from services/web/app/src/Features/Subscription/RecurlyEventHandler.js rename to services/web/app/src/Features/Subscription/RecurlyEventHandler.mjs index e03edca319..9f90ff4905 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEventHandler.js +++ b/services/web/app/src/Features/Subscription/RecurlyEventHandler.mjs @@ -1,8 +1,10 @@ -const SplitTestHandler = require('../SplitTests/SplitTestHandler') -const AnalyticsManager = require('../Analytics/AnalyticsManager') -const SubscriptionEmailHandler = require('./SubscriptionEmailHandler') -const { AI_ADD_ON_CODE } = require('./AiHelper') -const { ObjectId } = require('mongodb-legacy') +import SplitTestHandler from '../SplitTests/SplitTestHandler.js' +import AnalyticsManager from '../Analytics/AnalyticsManager.js' +import SubscriptionEmailHandler from './SubscriptionEmailHandler.js' +import { AI_ADD_ON_CODE } from './AiHelper.js' +import mongodb from 'mongodb-legacy' + +const { ObjectId } = mongodb const INVOICE_SUBSCRIPTION_LIMIT = 10 @@ -389,6 +391,6 @@ function _getSubscriptionData(eventData) { } } -module.exports = { +export default { sendRecurlyAnalyticsEvent, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.mjs b/services/web/app/src/Features/Subscription/SubscriptionController.mjs index 7e1bcaf977..641f1eaa53 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionController.mjs @@ -3,7 +3,7 @@ import SessionManager from '../Authentication/SessionManager.js' import SubscriptionHandler from './SubscriptionHandler.js' import SubscriptionHelper from './SubscriptionHelper.js' -import SubscriptionViewModelBuilder from './SubscriptionViewModelBuilder.js' +import SubscriptionViewModelBuilder from './SubscriptionViewModelBuilder.mjs' import LimitationsManager from './LimitationsManager.js' import RecurlyWrapper from './RecurlyWrapper.js' import Settings from '@overleaf/settings' @@ -13,7 +13,7 @@ import FeaturesUpdater from './FeaturesUpdater.js' import GroupPlansData from './GroupPlansData.js' import V1SubscriptionManager from './V1SubscriptionManager.js' import AnalyticsManager from '../Analytics/AnalyticsManager.js' -import RecurlyEventHandler from './RecurlyEventHandler.js' +import RecurlyEventHandler from './RecurlyEventHandler.mjs' import { expressify } from '@overleaf/promise-utils' import OError from '@overleaf/o-error' import Errors from './Errors.js' @@ -30,7 +30,7 @@ import { import PlansLocator from './PlansLocator.js' import { User } from '../../models/User.js' import UserGetter from '../User/UserGetter.js' -import PermissionsManager from '../Authorization/PermissionsManager.js' +import PermissionsManager from '../Authorization/PermissionsManager.mjs' import { sanitizeSessionUserForFrontEnd } from '../../infrastructure/FrontEndUser.js' import { z, validateReq } from '../../infrastructure/Validation.js' import { IndeterminateInvoiceError } from '../Errors/Errors.js' diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 40c9333145..5a9a7fbb21 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -4,7 +4,7 @@ import SubscriptionController from './SubscriptionController.mjs' import SubscriptionGroupController from './SubscriptionGroupController.mjs' import TeamInvitesController from './TeamInvitesController.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.js' -import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs' import Settings from '@overleaf/settings' const teamInviteRateLimiter = new RateLimiter('team-invite', { diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs similarity index 94% rename from services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js rename to services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs index 21a7e4906d..42eed5476a 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs @@ -1,23 +1,24 @@ // ts-check -const Settings = require('@overleaf/settings') -const PlansLocator = require('./PlansLocator') -const { isStandaloneAiAddOnPlanCode } = require('./AiHelper') -const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./PaymentProviderEntities') -const SubscriptionFormatters = require('./SubscriptionFormatters') -const SubscriptionLocator = require('./SubscriptionLocator') -const InstitutionsGetter = require('../Institutions/InstitutionsGetter') -const InstitutionsManager = require('../Institutions/InstitutionsManager') -const PublishersGetter = require('../Publishers/PublishersGetter') -const sanitizeHtml = require('sanitize-html') -const _ = require('lodash') -const async = require('async') -const SubscriptionHelper = require('./SubscriptionHelper') -const { callbackify } = require('@overleaf/promise-utils') -const { V1ConnectionError } = require('../Errors/Errors') -const FeaturesHelper = require('./FeaturesHelper') -const { formatCurrency } = require('../../util/currency') -const Modules = require('../../infrastructure/Modules') -const SplitTestHandler = require('../SplitTests/SplitTestHandler') +import Settings from '@overleaf/settings' + +import PlansLocator from './PlansLocator.js' +import { isStandaloneAiAddOnPlanCode } from './AiHelper.js' +import { MEMBERS_LIMIT_ADD_ON_CODE } from './PaymentProviderEntities.js' +import SubscriptionFormatters from './SubscriptionFormatters.js' +import SubscriptionLocator from './SubscriptionLocator.js' +import InstitutionsGetter from '../Institutions/InstitutionsGetter.js' +import InstitutionsManager from '../Institutions/InstitutionsManager.js' +import PublishersGetter from '../Publishers/PublishersGetter.js' +import sanitizeHtml from 'sanitize-html' +import _ from 'lodash' +import async from 'async' +import SubscriptionHelper from './SubscriptionHelper.js' +import { callbackify } from '@overleaf/promise-utils' +import { V1ConnectionError } from '../Errors/Errors.js' +import FeaturesHelper from './FeaturesHelper.js' +import { formatCurrency } from '../../util/currency.js' +import Modules from '../../infrastructure/Modules.js' +import SplitTestHandler from '../SplitTests/SplitTestHandler.js' /** * @import { Subscription } from "../../../../types/project/dashboard/subscription" @@ -629,7 +630,7 @@ function buildPlansListForSubscriptionDash(currentPlan, isInTrial) { } } -module.exports = { +export default { buildUsersSubscriptionViewModel: callbackify(buildUsersSubscriptionViewModel), buildPlansList, buildPlansListForSubscriptionDash, diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs index 70e48e9b05..6048da1ccf 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs @@ -10,7 +10,7 @@ import EmailHelper from '../Helpers/EmailHelper.js' import UserGetter from '../User/UserGetter.js' import { expressify } from '@overleaf/promise-utils' import HttpErrorHandler from '../Errors/HttpErrorHandler.js' -import PermissionsManager from '../Authorization/PermissionsManager.js' +import PermissionsManager from '../Authorization/PermissionsManager.mjs' import EmailHandler from '../Email/EmailHandler.js' import { RateLimiter } from '../../infrastructure/RateLimiter.js' import Modules from '../../infrastructure/Modules.js' diff --git a/services/web/app/src/Features/Templates/TemplatesController.js b/services/web/app/src/Features/Templates/TemplatesController.mjs similarity index 81% rename from services/web/app/src/Features/Templates/TemplatesController.js rename to services/web/app/src/Features/Templates/TemplatesController.mjs index 39c4d50ae0..ecd53e4656 100644 --- a/services/web/app/src/Features/Templates/TemplatesController.js +++ b/services/web/app/src/Features/Templates/TemplatesController.mjs @@ -1,9 +1,9 @@ -const path = require('path') -const SessionManager = require('../Authentication/SessionManager') -const TemplatesManager = require('./TemplatesManager') -const ProjectHelper = require('../Project/ProjectHelper') -const logger = require('@overleaf/logger') -const { expressify } = require('@overleaf/promise-utils') +import path from 'node:path' +import SessionManager from '../Authentication/SessionManager.js' +import TemplatesManager from './TemplatesManager.js' +import ProjectHelper from '../Project/ProjectHelper.js' +import logger from '@overleaf/logger' +import { expressify } from '@overleaf/promise-utils' const TemplatesController = { async getV1Template(req, res) { @@ -27,7 +27,7 @@ const TemplatesController = { } res.render( path.resolve( - __dirname, + import.meta.dirname, '../../../views/project/editor/new_from_template' ), data @@ -54,7 +54,7 @@ const TemplatesController = { }, } -module.exports = { +export default { getV1Template: expressify(TemplatesController.getV1Template), createProjectFromV1Template: expressify( TemplatesController.createProjectFromV1Template diff --git a/services/web/app/src/Features/Templates/TemplatesMiddleware.js b/services/web/app/src/Features/Templates/TemplatesMiddleware.mjs similarity index 81% rename from services/web/app/src/Features/Templates/TemplatesMiddleware.js rename to services/web/app/src/Features/Templates/TemplatesMiddleware.mjs index ff3bebf4c9..efa8bcb6ac 100644 --- a/services/web/app/src/Features/Templates/TemplatesMiddleware.js +++ b/services/web/app/src/Features/Templates/TemplatesMiddleware.mjs @@ -8,10 +8,11 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -const settings = require('@overleaf/settings') -const logger = require('@overleaf/logger') +import settings from '@overleaf/settings' -module.exports = { +import logger from '@overleaf/logger' + +export default { saveTemplateDataInSession(req, res, next) { if (req.query.templateName) { req.session.templateData = req.query diff --git a/services/web/app/src/Features/Templates/TemplatesRouter.mjs b/services/web/app/src/Features/Templates/TemplatesRouter.mjs index f5f459ab5c..437e8686eb 100644 --- a/services/web/app/src/Features/Templates/TemplatesRouter.mjs +++ b/services/web/app/src/Features/Templates/TemplatesRouter.mjs @@ -1,9 +1,9 @@ import AuthenticationController from '../Authentication/AuthenticationController.js' -import TemplatesController from './TemplatesController.js' -import TemplatesMiddleware from './TemplatesMiddleware.js' +import TemplatesController from './TemplatesController.mjs' +import TemplatesMiddleware from './TemplatesMiddleware.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.js' -import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' -import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs' +import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.mjs' const rateLimiter = new RateLimiter('create-project-from-template', { points: 20, diff --git a/services/web/app/src/Features/Uploads/UploadsRouter.mjs b/services/web/app/src/Features/Uploads/UploadsRouter.mjs index 3aa477f535..5e1382d866 100644 --- a/services/web/app/src/Features/Uploads/UploadsRouter.mjs +++ b/services/web/app/src/Features/Uploads/UploadsRouter.mjs @@ -2,7 +2,7 @@ import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mj import AuthenticationController from '../Authentication/AuthenticationController.js' import ProjectUploadController from './ProjectUploadController.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.js' -import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs' import Settings from '@overleaf/settings' const rateLimiters = { diff --git a/services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs b/services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs index 6676c4c19e..e70bc391c5 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs +++ b/services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs @@ -3,7 +3,7 @@ import UserMembershipController from './UserMembershipController.mjs' import SubscriptionGroupController from '../Subscription/SubscriptionGroupController.mjs' import TeamInvitesController from '../Subscription/TeamInvitesController.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.js' -import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs' const rateLimiters = { createTeamInvite: new RateLimiter('create-team-invite', { diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index a1b6f9abc5..5265cae6d4 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -41,7 +41,7 @@ import { openProjectRateLimiter, overleafLoginRateLimiter, } from './infrastructure/RateLimiter.js' -import RateLimiterMiddleware from './Features/Security/RateLimiterMiddleware.js' +import RateLimiterMiddleware from './Features/Security/RateLimiterMiddleware.mjs' import InactiveProjectController from './Features/InactiveData/InactiveProjectController.mjs' import ContactRouter from './Features/Contacts/ContactRouter.mjs' import ReferencesController from './Features/References/ReferencesController.mjs' @@ -55,7 +55,7 @@ import LinkedFilesRouter from './Features/LinkedFiles/LinkedFilesRouter.mjs' import TemplatesRouter from './Features/Templates/TemplatesRouter.mjs' import UserMembershipRouter from './Features/UserMembership/UserMembershipRouter.mjs' import SystemMessageController from './Features/SystemMessages/SystemMessageController.mjs' -import AnalyticsRegistrationSourceMiddleware from './Features/Analytics/AnalyticsRegistrationSourceMiddleware.js' +import AnalyticsRegistrationSourceMiddleware from './Features/Analytics/AnalyticsRegistrationSourceMiddleware.mjs' import AnalyticsUTMTrackingMiddleware from './Features/Analytics/AnalyticsUTMTrackingMiddleware.mjs' import CaptchaMiddleware from './Features/Captcha/CaptchaMiddleware.mjs' import UnsupportedBrowserMiddleware from './infrastructure/UnsupportedBrowserMiddleware.js' diff --git a/services/web/test/acceptance/src/helpers/Subscription.mjs b/services/web/test/acceptance/src/helpers/Subscription.mjs index 1ce0e87afd..4187b79bfa 100644 --- a/services/web/test/acceptance/src/helpers/Subscription.mjs +++ b/services/web/test/acceptance/src/helpers/Subscription.mjs @@ -2,7 +2,7 @@ import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.js' import { expect } from 'chai' import { callbackifyClass } from '@overleaf/promise-utils' import SubscriptionUpdater from '../../../../app/src/Features/Subscription/SubscriptionUpdater.js' -import PermissionsManager from '../../../../app/src/Features/Authorization/PermissionsManager.js' +import PermissionsManager from '../../../../app/src/Features/Authorization/PermissionsManager.mjs' import SSOConfigManager from '../../../../modules/group-settings/app/src/sso/SSOConfigManager.mjs' import { Subscription as SubscriptionModel } from '../../../../app/src/models/Subscription.js' import { DeletedSubscription as DeletedSubscriptionModel } from '../../../../app/src/models/DeletedSubscription.js' diff --git a/services/web/test/unit/src/Authorization/PermissionsManagerTests.js b/services/web/test/unit/src/Authorization/PermissionsManager.test.mjs similarity index 66% rename from services/web/test/unit/src/Authorization/PermissionsManagerTests.js rename to services/web/test/unit/src/Authorization/PermissionsManager.test.mjs index faec52d79e..b14506c99c 100644 --- a/services/web/test/unit/src/Authorization/PermissionsManagerTests.js +++ b/services/web/test/unit/src/Authorization/PermissionsManager.test.mjs @@ -1,44 +1,43 @@ -const sinon = require('sinon') -const { expect } = require('chai') +import { vi, expect } from 'vitest' +import sinon from 'sinon' + const modulePath = - '../../../../app/src/Features/Authorization/PermissionsManager.js' -const SandboxedModule = require('sandboxed-module') -const { ForbiddenError } = require('../../../../app/src/Features/Errors/Errors') + '../../../../app/src/Features/Authorization/PermissionsManager.mjs' describe('PermissionsManager', function () { - beforeEach(function () { - this.PermissionsManager = SandboxedModule.require(modulePath, { - requires: { - '../../infrastructure/Modules': (this.Modules = { - promises: { - hooks: { - fire: (this.hooksFire = sinon.stub().resolves([[]])), - }, + beforeEach(async function (ctx) { + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: (ctx.Modules = { + promises: { + hooks: { + fire: (ctx.hooksFire = sinon.stub().resolves([[]])), }, - }), - }, - }) - this.PermissionsManager.registerCapability('capability1', { + }, + }), + })) + + ctx.PermissionsManager = (await import(modulePath)).default + ctx.PermissionsManager.registerCapability('capability1', { default: true, }) - this.PermissionsManager.registerCapability('capability2', { + ctx.PermissionsManager.registerCapability('capability2', { default: true, }) - this.PermissionsManager.registerCapability('capability3', { + ctx.PermissionsManager.registerCapability('capability3', { default: true, }) - this.PermissionsManager.registerCapability('capability4', { + ctx.PermissionsManager.registerCapability('capability4', { default: false, }) - this.PermissionsManager.registerPolicy('openPolicy', { + ctx.PermissionsManager.registerPolicy('openPolicy', { capability1: true, capability2: true, }) - this.PermissionsManager.registerPolicy('restrictivePolicy', { + ctx.PermissionsManager.registerPolicy('restrictivePolicy', { capability1: true, capability2: false, }) - this.openPolicyResponseSet = [ + ctx.openPolicyResponseSet = [ [ { managedUsersEnabled: true, @@ -50,7 +49,7 @@ describe('PermissionsManager', function () { }, ], ] - this.restrictivePolicyResponseSet = [ + ctx.restrictivePolicyResponseSet = [ [ { managedUsersEnabled: true, @@ -65,40 +64,40 @@ describe('PermissionsManager', function () { }) describe('validatePolicies', function () { - it('accepts empty object', function () { - expect(() => this.PermissionsManager.validatePolicies({})).not.to.throw + it('accepts empty object', function (ctx) { + expect(() => ctx.PermissionsManager.validatePolicies({})).not.to.throw }) - it('accepts object with registered policies', function () { + it('accepts object with registered policies', function (ctx) { expect(() => - this.PermissionsManager.validatePolicies({ + ctx.PermissionsManager.validatePolicies({ openPolicy: true, restrictivePolicy: false, }) ).not.to.throw }) - it('accepts object with policies containing non-boolean values', function () { + it('accepts object with policies containing non-boolean values', function (ctx) { expect(() => - this.PermissionsManager.validatePolicies({ + ctx.PermissionsManager.validatePolicies({ openPolicy: 1, }) ).to.throw('policy value must be a boolean: openPolicy = 1') expect(() => - this.PermissionsManager.validatePolicies({ + ctx.PermissionsManager.validatePolicies({ openPolicy: undefined, }) ).to.throw('policy value must be a boolean: openPolicy = undefined') expect(() => - this.PermissionsManager.validatePolicies({ + ctx.PermissionsManager.validatePolicies({ openPolicy: null, }) ).to.throw('policy value must be a boolean: openPolicy = null') }) - it('throws error on object with policies that are not registered', function () { + it('throws error on object with policies that are not registered', function (ctx) { expect(() => - this.PermissionsManager.validatePolicies({ + ctx.PermissionsManager.validatePolicies({ openPolicy: true, unregisteredPolicy: false, }) @@ -108,20 +107,20 @@ describe('PermissionsManager', function () { describe('hasPermission', function () { describe('when no policies apply to the user', function () { - it('should return true if default permission is true', function () { + it('should return true if default permission is true', function (ctx) { const groupPolicy = {} const capability = 'capability1' - const result = this.PermissionsManager.hasPermission( + const result = ctx.PermissionsManager.hasPermission( groupPolicy, capability ) expect(result).to.be.true }) - it('should return false if the default permission is false', function () { + it('should return false if the default permission is false', function (ctx) { const groupPolicy = {} const capability = 'capability4' - const result = this.PermissionsManager.hasPermission( + const result = ctx.PermissionsManager.hasPermission( groupPolicy, capability ) @@ -130,8 +129,8 @@ describe('PermissionsManager', function () { }) describe('when a policy applies to the user', function () { - it('should return true if the user has the capability after the policy is applied', function () { - this.PermissionsManager.registerPolicy('policy', { + it('should return true if the user has the capability after the policy is applied', function (ctx) { + ctx.PermissionsManager.registerPolicy('policy', { capability1: true, capability2: false, }) @@ -139,15 +138,15 @@ describe('PermissionsManager', function () { policy: true, } const capability = 'capability1' - const result = this.PermissionsManager.hasPermission( + const result = ctx.PermissionsManager.hasPermission( groupPolicy, capability ) expect(result).to.be.true }) - it('should return false if the user does not have the capability after the policy is applied', function () { - this.PermissionsManager.registerPolicy('policy', { + it('should return false if the user does not have the capability after the policy is applied', function (ctx) { + ctx.PermissionsManager.registerPolicy('policy', { capability1: true, capability2: false, }) @@ -155,15 +154,15 @@ describe('PermissionsManager', function () { policy: true, } const capability = 'capability2' - const result = this.PermissionsManager.hasPermission( + const result = ctx.PermissionsManager.hasPermission( groupPolicy, capability ) expect(result).to.be.false }) - it('should return the default permission if the policy does not apply to the capability', function () { - this.PermissionsManager.registerPolicy('policy', { + it('should return the default permission if the policy does not apply to the capability', function (ctx) { + ctx.PermissionsManager.registerPolicy('policy', { capability1: true, capability2: false, }) @@ -172,7 +171,7 @@ describe('PermissionsManager', function () { } { const capability = 'capability3' - const result = this.PermissionsManager.hasPermission( + const result = ctx.PermissionsManager.hasPermission( groupPolicy, capability ) @@ -180,7 +179,7 @@ describe('PermissionsManager', function () { } { const capability = 'capability4' - const result = this.PermissionsManager.hasPermission( + const result = ctx.PermissionsManager.hasPermission( groupPolicy, capability ) @@ -188,8 +187,8 @@ describe('PermissionsManager', function () { } }) - it('should return the default permission if the policy is not enforced', function () { - this.PermissionsManager.registerPolicy('policy', { + it('should return the default permission if the policy is not enforced', function (ctx) { + ctx.PermissionsManager.registerPolicy('policy', { capability1: true, capability2: false, }) @@ -197,12 +196,12 @@ describe('PermissionsManager', function () { policy: false, } const capability1 = 'capability1' - const result1 = this.PermissionsManager.hasPermission( + const result1 = ctx.PermissionsManager.hasPermission( groupPolicy, capability1 ) const capability2 = 'capability2' - const result2 = this.PermissionsManager.hasPermission( + const result2 = ctx.PermissionsManager.hasPermission( groupPolicy, capability2 ) @@ -212,13 +211,13 @@ describe('PermissionsManager', function () { }) describe('when multiple policies apply to the user', function () { - it('should return true if all policies allow the capability', function () { - this.PermissionsManager.registerPolicy('policy1', { + it('should return true if all policies allow the capability', function (ctx) { + ctx.PermissionsManager.registerPolicy('policy1', { capability1: true, capability2: true, }) - this.PermissionsManager.registerPolicy('policy2', { + ctx.PermissionsManager.registerPolicy('policy2', { capability1: true, capability2: true, }) @@ -227,20 +226,20 @@ describe('PermissionsManager', function () { policy2: true, } const capability = 'capability1' - const result = this.PermissionsManager.hasPermission( + const result = ctx.PermissionsManager.hasPermission( groupPolicy, capability ) expect(result).to.be.true }) - it('should return false if any policy denies the capability', function () { - this.PermissionsManager.registerPolicy('policy1', { + it('should return false if any policy denies the capability', function (ctx) { + ctx.PermissionsManager.registerPolicy('policy1', { capability1: true, capability2: true, }) - this.PermissionsManager.registerPolicy('policy2', { + ctx.PermissionsManager.registerPolicy('policy2', { capability1: false, capability2: true, }) @@ -249,20 +248,20 @@ describe('PermissionsManager', function () { policy2: true, } const capability = 'capability1' - const result = this.PermissionsManager.hasPermission( + const result = ctx.PermissionsManager.hasPermission( groupPolicy, capability ) expect(result).to.be.false }) - it('should return the default permssion when the applicable policy is not enforced', function () { - this.PermissionsManager.registerPolicy('policy1', { + it('should return the default permssion when the applicable policy is not enforced', function (ctx) { + ctx.PermissionsManager.registerPolicy('policy1', { capability1: true, capability2: true, }) - this.PermissionsManager.registerPolicy('policy2', { + ctx.PermissionsManager.registerPolicy('policy2', { capability1: false, capability2: true, }) @@ -271,15 +270,15 @@ describe('PermissionsManager', function () { policy2: false, } const capability = 'capability1' - const result = this.PermissionsManager.hasPermission( + const result = ctx.PermissionsManager.hasPermission( groupPolicy, capability ) expect(result).to.be.true }) - it('should return the default permission if the policies do not restrict to the capability', function () { - this.PermissionsManager.registerPolicy('policy', { + it('should return the default permission if the policies do not restrict to the capability', function (ctx) { + ctx.PermissionsManager.registerPolicy('policy', { capability1: true, capability2: false, }) @@ -288,7 +287,7 @@ describe('PermissionsManager', function () { } { const capability = 'capability3' - const result = this.PermissionsManager.hasPermission( + const result = ctx.PermissionsManager.hasPermission( groupPolicy, capability ) @@ -296,7 +295,7 @@ describe('PermissionsManager', function () { } { const capability = 'capability4' - const result = this.PermissionsManager.hasPermission( + const result = ctx.PermissionsManager.hasPermission( groupPolicy, capability ) @@ -307,17 +306,17 @@ describe('PermissionsManager', function () { }) describe('getUserCapabilities', function () { - it('should return the default capabilities when no group policy is provided', function () { + it('should return the default capabilities when no group policy is provided', function (ctx) { const groupPolicy = {} const capabilities = - this.PermissionsManager.getUserCapabilities(groupPolicy) + ctx.PermissionsManager.getUserCapabilities(groupPolicy) expect(capabilities).to.deep.equal( new Set(['capability1', 'capability2', 'capability3']) ) }) - it('should return a reduced capability set when a group policy is provided', function () { - this.PermissionsManager.registerPolicy('policy', { + it('should return a reduced capability set when a group policy is provided', function (ctx) { + ctx.PermissionsManager.registerPolicy('policy', { capability1: true, capability2: false, }) @@ -325,18 +324,18 @@ describe('PermissionsManager', function () { policy: true, } const capabilities = - this.PermissionsManager.getUserCapabilities(groupPolicy) + ctx.PermissionsManager.getUserCapabilities(groupPolicy) expect(capabilities).to.deep.equal( new Set(['capability1', 'capability3']) ) }) - it('should return a reduced capability set when multiple group policies are provided', function () { - this.PermissionsManager.registerPolicy('policy1', { + it('should return a reduced capability set when multiple group policies are provided', function (ctx) { + ctx.PermissionsManager.registerPolicy('policy1', { capability1: true, capability2: false, }) - this.PermissionsManager.registerPolicy('policy2', { + ctx.PermissionsManager.registerPolicy('policy2', { capability1: false, capability2: true, }) @@ -346,20 +345,20 @@ describe('PermissionsManager', function () { policy2: true, } const capabilities = - this.PermissionsManager.getUserCapabilities(groupPolicy) + ctx.PermissionsManager.getUserCapabilities(groupPolicy) expect(capabilities).to.deep.equal(new Set(['capability3'])) }) - it('should return an empty capability set when group policies remove all permissions', function () { - this.PermissionsManager.registerPolicy('policy1', { + it('should return an empty capability set when group policies remove all permissions', function (ctx) { + ctx.PermissionsManager.registerPolicy('policy1', { capability1: true, capability2: false, }) - this.PermissionsManager.registerPolicy('policy2', { + ctx.PermissionsManager.registerPolicy('policy2', { capability1: false, capability2: true, }) - this.PermissionsManager.registerPolicy('policy3', { + ctx.PermissionsManager.registerPolicy('policy3', { capability1: true, capability2: true, capability3: false, @@ -370,14 +369,14 @@ describe('PermissionsManager', function () { policy3: true, } const capabilities = - this.PermissionsManager.getUserCapabilities(groupPolicy) + ctx.PermissionsManager.getUserCapabilities(groupPolicy) expect(capabilities).to.deep.equal(new Set()) }) }) describe('getUserValidationStatus', function () { - it('should return the status for the policy when the user conforms', async function () { - this.PermissionsManager.registerPolicy( + it('should return the status for the policy when the user conforms', async function (ctx) { + ctx.PermissionsManager.registerPolicy( 'policy', {}, { @@ -392,7 +391,7 @@ describe('PermissionsManager', function () { const user = { prop: 'allowed' } const subscription = { prop: 'managed' } const result = - await this.PermissionsManager.promises.getUserValidationStatus({ + await ctx.PermissionsManager.promises.getUserValidationStatus({ user, groupPolicy, subscription, @@ -400,8 +399,8 @@ describe('PermissionsManager', function () { expect(result).to.deep.equal(new Map([['policy', true]])) }) - it('should return the status for the policy when the user does not conform', async function () { - this.PermissionsManager.registerPolicy( + it('should return the status for the policy when the user does not conform', async function (ctx) { + ctx.PermissionsManager.registerPolicy( 'policy', {}, { @@ -416,15 +415,15 @@ describe('PermissionsManager', function () { const user = { prop: 'not allowed' } const subscription = { prop: 'managed' } const result = - await this.PermissionsManager.promises.getUserValidationStatus({ + await ctx.PermissionsManager.promises.getUserValidationStatus({ user, groupPolicy, subscription, }) expect(result).to.deep.equal(new Map([['policy', false]])) }) - it('should return the status for multiple policies according to whether the user conforms', async function () { - this.PermissionsManager.registerPolicy( + it('should return the status for multiple policies according to whether the user conforms', async function (ctx) { + ctx.PermissionsManager.registerPolicy( 'policy1', {}, { @@ -433,7 +432,7 @@ describe('PermissionsManager', function () { }, } ) - this.PermissionsManager.registerPolicy( + ctx.PermissionsManager.registerPolicy( 'policy2', {}, { @@ -442,7 +441,7 @@ describe('PermissionsManager', function () { }, } ) - this.PermissionsManager.registerPolicy( + ctx.PermissionsManager.registerPolicy( 'policy3', {}, { @@ -460,7 +459,7 @@ describe('PermissionsManager', function () { const user = { prop: 'allowed' } const subscription = { prop: 'managed' } const result = - await this.PermissionsManager.promises.getUserValidationStatus({ + await ctx.PermissionsManager.promises.getUserValidationStatus({ user, groupPolicy, subscription, @@ -475,20 +474,20 @@ describe('PermissionsManager', function () { }) describe('assertUserPermissions', function () { describe('allowed', function () { - it('should not error when managedUsersEnabled is not enabled for user', async function () { + it('should not error when managedUsersEnabled is not enabled for user', async function (ctx) { const result = - await this.PermissionsManager.promises.assertUserPermissions( + await ctx.PermissionsManager.promises.assertUserPermissions( { _id: 'user123' }, ['add-secondary-email'] ) expect(result).to.be.undefined }) - it('should not error when default capability is true', async function () { - this.PermissionsManager.registerCapability('some-policy-to-check', { + it('should not error when default capability is true', async function (ctx) { + ctx.PermissionsManager.registerCapability('some-policy-to-check', { default: true, }) - this.hooksFire.resolves([ + ctx.hooksFire.resolves([ [ { managedUsersEnabled: true, @@ -497,21 +496,21 @@ describe('PermissionsManager', function () { ], ]) const result = - await this.PermissionsManager.promises.assertUserPermissions( + await ctx.PermissionsManager.promises.assertUserPermissions( { _id: 'user123' }, ['some-policy-to-check'] ) expect(result).to.be.undefined }) - it('should not error when default permission is false but user has permission', async function () { - this.PermissionsManager.registerCapability('some-policy-to-check', { + it('should not error when default permission is false but user has permission', async function (ctx) { + ctx.PermissionsManager.registerCapability('some-policy-to-check', { default: false, }) - this.PermissionsManager.registerPolicy('userCanDoSomePolicy', { + ctx.PermissionsManager.registerPolicy('userCanDoSomePolicy', { 'some-policy-to-check': true, }) - this.hooksFire.resolves([ + ctx.hooksFire.resolves([ [ { managedUsersEnabled: true, @@ -522,7 +521,7 @@ describe('PermissionsManager', function () { ], ]) const result = - await this.PermissionsManager.promises.assertUserPermissions( + await ctx.PermissionsManager.promises.assertUserPermissions( { _id: 'user123' }, ['some-policy-to-check'] ) @@ -531,21 +530,21 @@ describe('PermissionsManager', function () { }) describe('not allowed', function () { - it('should return error when managedUsersEnabled is enabled for user but there is no group policy', async function () { - this.hooksFire.resolves([[{ managedUsersEnabled: true }]]) + it('should return error when managedUsersEnabled is enabled for user but there is no group policy', async function (ctx) { + ctx.hooksFire.resolves([[{ managedUsersEnabled: true }]]) await expect( - this.PermissionsManager.promises.assertUserPermissions( + ctx.PermissionsManager.promises.assertUserPermissions( { _id: 'user123' }, ['add-secondary-email'] ) ).to.be.rejectedWith(Error, 'unknown capability: add-secondary-email') }) - it('should return error when default permission is false', async function () { - this.PermissionsManager.registerCapability('some-policy-to-check', { + it('should return error when default permission is false', async function (ctx) { + ctx.PermissionsManager.registerCapability('some-policy-to-check', { default: false, }) - this.hooksFire.resolves([ + ctx.hooksFire.resolves([ [ { managedUsersEnabled: true, @@ -554,21 +553,23 @@ describe('PermissionsManager', function () { ], ]) await expect( - this.PermissionsManager.promises.assertUserPermissions( + ctx.PermissionsManager.promises.assertUserPermissions( { _id: 'user123' }, ['some-policy-to-check'] ) - ).to.be.rejectedWith(ForbiddenError) + ).to.be.rejectedWith( + 'user does not have one or more permissions within some-policy-to-check' + ) }) - it('should return error when default permission is true but user does not have permission', async function () { - this.PermissionsManager.registerCapability('some-policy-to-check', { + it('should return error when default permission is true but user does not have permission', async function (ctx) { + ctx.PermissionsManager.registerCapability('some-policy-to-check', { default: true, }) - this.PermissionsManager.registerPolicy('userCannotDoSomePolicy', { + ctx.PermissionsManager.registerPolicy('userCannotDoSomePolicy', { 'some-policy-to-check': false, }) - this.hooksFire.resolves([ + ctx.hooksFire.resolves([ [ { managedUsersEnabled: true, @@ -577,33 +578,35 @@ describe('PermissionsManager', function () { ], ]) await expect( - this.PermissionsManager.promises.assertUserPermissions( + ctx.PermissionsManager.promises.assertUserPermissions( { _id: 'user123' }, ['some-policy-to-check'] ) - ).to.be.rejectedWith(ForbiddenError) + ).to.be.rejectedWith( + 'user does not have one or more permissions within some-policy-to-check' + ) }) }) }) describe('registerAllowedProperty', function () { - it('allows us to register a property', async function () { - this.PermissionsManager.registerAllowedProperty('metadata1') - const result = await this.PermissionsManager.getAllowedProperties() + it('allows us to register a property', async function (ctx) { + ctx.PermissionsManager.registerAllowedProperty('metadata1') + const result = await ctx.PermissionsManager.getAllowedProperties() expect(result).to.deep.equal(new Set(['metadata1'])) }) // used if multiple modules would require the same prop, since we dont know which will load first, both must register - it('should handle multiple registrations of the same property', async function () { - this.PermissionsManager.registerAllowedProperty('metadata1') - this.PermissionsManager.registerAllowedProperty('metadata1') - const result = await this.PermissionsManager.getAllowedProperties() + it('should handle multiple registrations of the same property', async function (ctx) { + ctx.PermissionsManager.registerAllowedProperty('metadata1') + ctx.PermissionsManager.registerAllowedProperty('metadata1') + const result = await ctx.PermissionsManager.getAllowedProperties() expect(result).to.deep.equal(new Set(['metadata1'])) }) }) describe('combineAllowedProperties', function () { - it('should handle multiple occurences of the same property, preserving the first occurence', async function () { + it('should handle multiple occurences of the same property, preserving the first occurence', async function (ctx) { const policy1 = { groupPolicy: { policy: false, @@ -618,17 +621,17 @@ describe('PermissionsManager', function () { } const results = [policy1, policy2] - this.PermissionsManager.registerAllowedProperty('prop1') + ctx.PermissionsManager.registerAllowedProperty('prop1') const combinedProps = - this.PermissionsManager.combineAllowedProperties(results) + ctx.PermissionsManager.combineAllowedProperties(results) expect(combinedProps).to.deep.equal({ prop1: 'some other value here', }) }) - it('should add registered properties to the set', async function () { + it('should add registered properties to the set', async function (ctx) { const policy = { groupPolicy: { policy: false, @@ -645,11 +648,11 @@ describe('PermissionsManager', function () { } const results = [policy, policy2] - this.PermissionsManager.registerAllowedProperty('prop1') - this.PermissionsManager.registerAllowedProperty('prop2') + ctx.PermissionsManager.registerAllowedProperty('prop1') + ctx.PermissionsManager.registerAllowedProperty('prop2') const combinedProps = - this.PermissionsManager.combineAllowedProperties(results) + ctx.PermissionsManager.combineAllowedProperties(results) expect(combinedProps).to.deep.equal({ prop1: 'some value here', @@ -657,7 +660,7 @@ describe('PermissionsManager', function () { }) }) - it('should not add unregistered properties to the req object', async function () { + it('should not add unregistered properties to the req object', async function (ctx) { const policy = { groupPolicy: { policy: false, @@ -671,36 +674,36 @@ describe('PermissionsManager', function () { }, prop2: 'some value here', } - this.PermissionsManager.registerAllowedProperty('prop1') + ctx.PermissionsManager.registerAllowedProperty('prop1') const results = [policy, policy2] const combinedProps = - this.PermissionsManager.combineAllowedProperties(results) + ctx.PermissionsManager.combineAllowedProperties(results) expect(combinedProps).to.deep.equal({ prop1: 'some value here' }) }) - it('should handle an empty array', async function () { + it('should handle an empty array', async function (ctx) { const results = [] const combinedProps = - this.PermissionsManager.combineAllowedProperties(results) + ctx.PermissionsManager.combineAllowedProperties(results) expect(combinedProps).to.deep.equal({}) }) }) describe('combineGroupPolicies', function () { - it('should return an empty object when an empty array is passed', async function () { + it('should return an empty object when an empty array is passed', async function (ctx) { const results = [] const combinedPolicy = - this.PermissionsManager.combineGroupPolicies(results) + ctx.PermissionsManager.combineGroupPolicies(results) expect(combinedPolicy).to.deep.equal({}) }) - it('should combine multiple group policies into a single policy object', async function () { + it('should combine multiple group policies into a single policy object', async function (ctx) { const groupPolicy = { policy1: true, } @@ -709,12 +712,12 @@ describe('PermissionsManager', function () { policy2: false, policy3: true, } - this.PermissionsManager.registerAllowedProperty('prop1') + ctx.PermissionsManager.registerAllowedProperty('prop1') const results = [groupPolicy, groupPolicy2] const combinedPolicy = - this.PermissionsManager.combineGroupPolicies(results) + ctx.PermissionsManager.combineGroupPolicies(results) expect(combinedPolicy).to.deep.equal({ policy1: true, @@ -722,7 +725,7 @@ describe('PermissionsManager', function () { }) }) - it('should handle duplicate enforced policies across different group policies', async function () { + it('should handle duplicate enforced policies across different group policies', async function (ctx) { const groupPolicy = { policy1: false, policy2: true, @@ -732,12 +735,12 @@ describe('PermissionsManager', function () { policy2: true, policy3: true, } - this.PermissionsManager.registerAllowedProperty('prop1') + ctx.PermissionsManager.registerAllowedProperty('prop1') const results = [groupPolicy, groupPolicy2] const combinedPolicy = - this.PermissionsManager.combineGroupPolicies(results) + ctx.PermissionsManager.combineGroupPolicies(results) expect(combinedPolicy).to.deep.equal({ policy2: true, @@ -745,7 +748,7 @@ describe('PermissionsManager', function () { }) }) - it('should handle group policies with no enforced policies', async function () { + it('should handle group policies with no enforced policies', async function (ctx) { const groupPolicy = { policy1: false, policy2: false, @@ -755,17 +758,17 @@ describe('PermissionsManager', function () { policy2: false, policy3: true, } - this.PermissionsManager.registerAllowedProperty('prop1') + ctx.PermissionsManager.registerAllowedProperty('prop1') const results = [groupPolicy, groupPolicy2] const combinedPolicy = - this.PermissionsManager.combineGroupPolicies(results) + ctx.PermissionsManager.combineGroupPolicies(results) expect(combinedPolicy).to.deep.equal({ policy3: true }) }) - it('should choose the stricter option between two policy values', async function () { + it('should choose the stricter option between two policy values', async function (ctx) { const groupPolicy = { policy1: false, policy2: true, @@ -777,12 +780,12 @@ describe('PermissionsManager', function () { policy3: true, policy4: false, } - this.PermissionsManager.registerAllowedProperty('prop1') + ctx.PermissionsManager.registerAllowedProperty('prop1') const results = [groupPolicy, groupPolicy2] const combinedPolicy = - this.PermissionsManager.combineGroupPolicies(results) + ctx.PermissionsManager.combineGroupPolicies(results) expect(combinedPolicy).to.deep.equal({ policy2: true, @@ -793,30 +796,30 @@ describe('PermissionsManager', function () { }) describe('checkUserListPermissions', function () { - it('should return true when all users have permissions required', async function () { + it('should return true when all users have permissions required', async function (ctx) { const userList = ['user1', 'user2', 'user3'] const capabilities = ['capability1', 'capability2'] - this.hooksFire.onCall(0).resolves(this.openPolicyResponseSet) - this.hooksFire.onCall(1).resolves(this.openPolicyResponseSet) - this.hooksFire.onCall(2).resolves(this.openPolicyResponseSet) + ctx.hooksFire.onCall(0).resolves(ctx.openPolicyResponseSet) + ctx.hooksFire.onCall(1).resolves(ctx.openPolicyResponseSet) + ctx.hooksFire.onCall(2).resolves(ctx.openPolicyResponseSet) const usersHavePermission = - await this.PermissionsManager.promises.checkUserListPermissions( + await ctx.PermissionsManager.promises.checkUserListPermissions( userList, capabilities ) expect(usersHavePermission).to.equal(true) }) - it('should return false if any user does not have permission', async function () { + it('should return false if any user does not have permission', async function (ctx) { const userList = ['user1', 'user2', 'user3'] const capabilities = ['capability1', 'capability2'] - this.hooksFire.onCall(0).resolves(this.openPolicyResponseSet) - this.hooksFire.onCall(1).resolves(this.restrictivePolicyResponseSet) - this.hooksFire.onCall(2).resolves(this.openPolicyResponseSet) + ctx.hooksFire.onCall(0).resolves(ctx.openPolicyResponseSet) + ctx.hooksFire.onCall(1).resolves(ctx.restrictivePolicyResponseSet) + ctx.hooksFire.onCall(2).resolves(ctx.openPolicyResponseSet) const usersHavePermission = - await this.PermissionsManager.promises.checkUserListPermissions( + await ctx.PermissionsManager.promises.checkUserListPermissions( userList, capabilities ) diff --git a/services/web/test/unit/src/Compile/ClsiManagerTests.js b/services/web/test/unit/src/Compile/ClsiManager.test.mjs similarity index 51% rename from services/web/test/unit/src/Compile/ClsiManagerTests.js rename to services/web/test/unit/src/Compile/ClsiManager.test.mjs index ec49971d23..dffe8f6ff1 100644 --- a/services/web/test/unit/src/Compile/ClsiManagerTests.js +++ b/services/web/test/unit/src/Compile/ClsiManager.test.mjs @@ -1,29 +1,28 @@ -const { setTimeout } = require('timers/promises') -const sinon = require('sinon') -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const tk = require('timekeeper') -const { RequestFailedError } = require('@overleaf/fetch-utils') +import { vi, expect } from 'vitest' +import { setTimeout } from 'timers/promises' +import sinon from 'sinon' +import tk from 'timekeeper' +import { RequestFailedError } from '@overleaf/fetch-utils' const FILESTORE_URL = 'http://filestore.example.com' const CLSI_HOST = 'clsi.example.com' -const MODULE_PATH = '../../../../app/src/Features/Compile/ClsiManager.js' +const MODULE_PATH = '../../../../app/src/Features/Compile/ClsiManager.mjs' const GLOBAL_BLOB_HASH = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' describe('ClsiManager', function () { - beforeEach(function () { + beforeEach(async function (ctx) { tk.freeze(Date.now()) - this.user_id = 'user-id' - this.project = { + ctx.user_id = 'user-id' + ctx.project = { _id: 'project-id', compiler: 'latex', rootDoc_id: 'mock-doc-id-1', imageName: 'mock-image-name', overleaf: { history: { id: 42 } }, } - this.docs = { + ctx.docs = { '/main.tex': { name: 'main.tex', _id: 'mock-doc-id-1', @@ -35,7 +34,7 @@ describe('ClsiManager', function () { lines: ['Chapter 1'], }, } - this.files = { + ctx.files = { '/images/frog.png': { name: 'frog.png', _id: 'mock-file-id-1', @@ -54,11 +53,11 @@ describe('ClsiManager', function () { created: new Date(), }, } - this.clsiCookieKey = 'clsiserver' - this.clsiServerId = 'clsi-server-id' - this.newClsiServerId = 'newserver' - this.rawOutputFiles = {} - this.responseBody = { + ctx.clsiCookieKey = 'clsiserver' + ctx.clsiServerId = 'clsi-server-id' + ctx.newClsiServerId = 'newserver' + ctx.rawOutputFiles = {} + ctx.responseBody = { compile: { status: 'success', stats: { @@ -75,66 +74,66 @@ describe('ClsiManager', function () { ], }, } - this.response = { + ctx.response = { ok: true, status: 200, headers: { raw: sinon.stub().returns({ - 'set-cookie': [`${this.clsiCookieKey}=${this.newClsiServerId}`], + 'set-cookie': [`${ctx.clsiCookieKey}=${ctx.newClsiServerId}`], }), }, } - this.FetchUtils = { + ctx.FetchUtils = { fetchString: sinon .stub() - .callsFake(() => Promise.resolve(JSON.stringify(this.responseBody))), + .callsFake(() => Promise.resolve(JSON.stringify(ctx.responseBody))), fetchStringWithResponse: sinon.stub().callsFake(() => Promise.resolve({ - body: JSON.stringify(this.responseBody), - response: this.response, + body: JSON.stringify(ctx.responseBody), + response: ctx.response, }) ), fetchStream: sinon.stub(), RequestFailedError, } - this.ClsiCookieManager = { + ctx.ClsiCookieManager = { promises: { clearServerId: sinon.stub().resolves(), getServerId: sinon.stub().resolves('clsi-server-id'), setServerId: sinon.stub().resolves(), }, } - this.ClsiStateManager = { + ctx.ClsiStateManager = { computeHash: sinon.stub().returns('01234567890abcdef'), } - this.ClsiFormatChecker = { + ctx.ClsiFormatChecker = { promises: { checkRecoursesForProblems: sinon.stub().resolves(), }, } - this.Project = {} - this.ProjectEntityHandler = { + ctx.Project = {} + ctx.ProjectEntityHandler = { getAllDocPathsFromProject: sinon.stub(), promises: { - getAllDocs: sinon.stub().resolves(this.docs), - getAllFiles: sinon.stub().resolves(this.files), + getAllDocs: sinon.stub().resolves(ctx.docs), + getAllFiles: sinon.stub().resolves(ctx.files), }, } - this.ProjectGetter = { + ctx.ProjectGetter = { promises: { - findById: sinon.stub().resolves(this.project), - getProject: sinon.stub().resolves(this.project), + findById: sinon.stub().resolves(ctx.project), + getProject: sinon.stub().resolves(ctx.project), }, } - this.DocumentUpdaterHandler = { + ctx.DocumentUpdaterHandler = { promises: { clearProjectState: sinon.stub().resolves(), flushProjectToMongo: sinon.stub().resolves(), getProjectDocsIfMatch: sinon.stub().resolves(), }, } - this.Metrics = { + ctx.Metrics = { Timer: class Metrics { constructor() { this.done = sinon.stub() @@ -144,7 +143,7 @@ describe('ClsiManager', function () { count: sinon.stub(), histogram: sinon.stub(), } - this.Settings = { + ctx.Settings = { apis: { filestore: { url: FILESTORE_URL, @@ -161,10 +160,10 @@ describe('ClsiManager', function () { enablePdfCaching: true, clsiCookie: { key: 'clsiserver' }, } - this.ClsiCacheHandler = { + ctx.ClsiCacheHandler = { clearCache: sinon.stub().resolves(), } - this.HistoryManager = { + ctx.HistoryManager = { getFilestoreBlobURL: sinon.stub().callsFake((historyId, hash) => { if (hash === GLOBAL_BLOB_HASH) { return `${FILESTORE_URL}/history/global/hash/${hash}` @@ -172,37 +171,83 @@ describe('ClsiManager', function () { return `${FILESTORE_URL}/history/project/${historyId}/hash/${hash}` }), } - this.SplitTestHandler = { + ctx.SplitTestHandler = { getPercentile: sinon.stub().returns(42), } - this.AnalyticsManager = { + ctx.AnalyticsManager = { recordEventForUserInBackground: sinon.stub(), } - this.ClsiManager = SandboxedModule.require(MODULE_PATH, { - requires: { - '@overleaf/settings': this.Settings, - '../SplitTests/SplitTestHandler': this.SplitTestHandler, - '../../models/Project': { - Project: this.Project, - }, - '../Project/ProjectEntityHandler': this.ProjectEntityHandler, - '../Project/ProjectGetter': this.ProjectGetter, - '../DocumentUpdater/DocumentUpdaterHandler': - this.DocumentUpdaterHandler, - './ClsiCookieManager': () => this.ClsiCookieManager, - './ClsiStateManager': this.ClsiStateManager, - './ClsiCacheHandler': this.ClsiCacheHandler, - '@overleaf/fetch-utils': this.FetchUtils, - './ClsiFormatChecker': this.ClsiFormatChecker, - '@overleaf/metrics': this.Metrics, - '../History/HistoryManager': this.HistoryManager, - '../Analytics/AnalyticsManager': this.AnalyticsManager, - }, - }) + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + vi.doMock('../../../../app/src/models/Project', () => ({ + Project: ctx.Project, + })) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectEntityHandler', + () => ({ + default: ctx.ProjectEntityHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ + default: ctx.ProjectGetter, + })) + + vi.doMock( + '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler', + () => ({ + default: ctx.DocumentUpdaterHandler, + }) + ) + + vi.doMock('../../../../app/src/Features/Compile/ClsiCookieManager', () => ({ + default: () => ctx.ClsiCookieManager, + })) + + vi.doMock('../../../../app/src/Features/Compile/ClsiStateManager', () => ({ + default: ctx.ClsiStateManager, + })) + + vi.doMock('../../../../app/src/Features/Compile/ClsiCacheHandler', () => ({ + default: ctx.ClsiCacheHandler, + })) + + vi.doMock('@overleaf/fetch-utils', () => ctx.FetchUtils) + + vi.doMock('../../../../app/src/Features/Compile/ClsiFormatChecker', () => ({ + default: ctx.ClsiFormatChecker, + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: ctx.Metrics, + })) + + vi.doMock('../../../../app/src/Features/History/HistoryManager', () => ({ + default: ctx.HistoryManager, + })) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: ctx.AnalyticsManager, + }) + ) + + ctx.ClsiManager = (await import(MODULE_PATH)).default }) - after(function () { + afterAll(function () { tk.reset() }) @@ -210,47 +255,47 @@ describe('ClsiManager', function () { describe('with a successful compile', function () { const buildId = '18fbe9e7564-30dcb2f71250c690' - beforeEach(async function () { - this.outputFiles = [ + beforeEach(async function (ctx) { + ctx.outputFiles = [ { - url: `/project/${this.project_id}/user/${this.user_id}/build/1234/output/output.pdf`, + url: `/project/${ctx.project_id}/user/${ctx.user_id}/build/1234/output/output.pdf`, path: 'output.pdf', type: 'pdf', build: buildId, }, { - url: `/project/${this.project_id}/user/${this.user_id}/build/1234/output/output.log`, + url: `/project/${ctx.project_id}/user/${ctx.user_id}/build/1234/output/output.log`, path: 'output.log', type: 'log', build: buildId, }, ] - this.responseBody.compile.outputFiles = this.outputFiles.map( + ctx.responseBody.compile.outputFiles = ctx.outputFiles.map( outputFile => ({ ...outputFile, url: `http://${CLSI_HOST}${outputFile.url}`, }) ) - this.responseBody.compile.buildId = buildId - this.timeout = 100 - this.result = await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + ctx.responseBody.compile.buildId = buildId + ctx.timeout = 100 + ctx.result = await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, { compileBackendClass: 'n2d', compileGroup: 'standard', - timeout: this.timeout, + timeout: ctx.timeout, } ) }) - it('should send the request to the CLSI', function () { - this.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( + it('should send the request to the CLSI', function (ctx) { + ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( sinon.match( url => url.host === CLSI_HOST && url.pathname === - `/project/${this.project._id}/user/${this.user_id}/compile` && + `/project/${ctx.project._id}/user/${ctx.user_id}/compile` && url.searchParams.get('compileBackendClass') === 'n2d' && url.searchParams.get('compileGroup') === 'standard' ), @@ -259,9 +304,9 @@ describe('ClsiManager', function () { json: sinon.match({ compile: { options: { - compiler: this.project.compiler, - imageName: this.project.imageName, - timeout: this.timeout, + compiler: ctx.project.compiler, + imageName: ctx.project.imageName, + timeout: ctx.timeout, draft: false, compileGroup: 'standard', metricsMethod: 'standard', @@ -269,22 +314,22 @@ describe('ClsiManager', function () { syncType: undefined, }, rootResourcePath: 'main.tex', - resources: _makeResources(this.project, this.docs, this.files), + resources: _makeResources(ctx.project, ctx.docs, ctx.files), }, }), headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Cookie: `${this.clsiCookieKey}=${this.clsiServerId}`, + Cookie: `${ctx.clsiCookieKey}=${ctx.clsiServerId}`, }, signal: sinon.match.instanceOf(AbortSignal), } ) }) - it('should get the project with the required fields', function () { - this.ProjectGetter.promises.getProject.should.have.been.calledWith( - this.project._id, + it('should get the project with the required fields', function (ctx) { + ctx.ProjectGetter.promises.getProject.should.have.been.calledWith( + ctx.project._id, { compiler: 1, rootDoc_id: 1, @@ -295,117 +340,117 @@ describe('ClsiManager', function () { ) }) - it('should flush the project to the database', function () { - this.DocumentUpdaterHandler.promises.flushProjectToMongo.should.have.been.calledWith( - this.project._id + it('should flush the project to the database', function (ctx) { + ctx.DocumentUpdaterHandler.promises.flushProjectToMongo.should.have.been.calledWith( + ctx.project._id ) }) - it('should get all the docs', function () { - this.ProjectEntityHandler.promises.getAllDocs.should.have.been.calledWith( - this.project._id + it('should get all the docs', function (ctx) { + ctx.ProjectEntityHandler.promises.getAllDocs.should.have.been.calledWith( + ctx.project._id ) }) - it('should get all the files', function () { - this.ProjectEntityHandler.promises.getAllFiles.should.have.been.calledWith( - this.project._id + it('should get all the files', function (ctx) { + ctx.ProjectEntityHandler.promises.getAllFiles.should.have.been.calledWith( + ctx.project._id ) }) - it('should return the status and output files', function () { - expect(this.result.status).to.equal('success') - expect(this.result.outputFiles.map(f => f.path)).to.have.members( - this.outputFiles.map(f => f.path) + it('should return the status and output files', function (ctx) { + expect(ctx.result.status).to.equal('success') + expect(ctx.result.outputFiles.map(f => f.path)).to.have.members( + ctx.outputFiles.map(f => f.path) ) }) - it('should return the buildId', function () { - expect(this.result.buildId).to.equal(buildId) + it('should return the buildId', function (ctx) { + expect(ctx.result.buildId).to.equal(buildId) }) - it('should persist the cookie from the response', function () { + it('should persist the cookie from the response', function (ctx) { expect( - this.ClsiCookieManager.promises.setServerId + ctx.ClsiCookieManager.promises.setServerId ).to.have.been.calledWith( - this.project._id, - this.user_id, + ctx.project._id, + ctx.user_id, 'standard', 'n2d', - this.newClsiServerId + ctx.newClsiServerId ) }) }) describe('with ranges on the pdf and stats/timings details', function () { - beforeEach(async function () { - this.ranges = [{ start: 1, end: 42, hash: 'foo' }] - this.startXRefTable = 123 - this.size = 456 - this.contentId = '123-321' - this.outputFiles = [ + beforeEach(async function (ctx) { + ctx.ranges = [{ start: 1, end: 42, hash: 'foo' }] + ctx.startXRefTable = 123 + ctx.size = 456 + ctx.contentId = '123-321' + ctx.outputFiles = [ { - url: `/project/${this.project._id}/user/${this.user_id}/build/1234/output/output.pdf`, + url: `/project/${ctx.project._id}/user/${ctx.user_id}/build/1234/output/output.pdf`, path: 'output.pdf', type: 'pdf', build: 1234, - contentId: this.contentId, - ranges: this.ranges, - startXRefTable: this.startXRefTable, - size: this.size, + contentId: ctx.contentId, + ranges: ctx.ranges, + startXRefTable: ctx.startXRefTable, + size: ctx.size, }, { - url: `/project/${this.project._id}/user/${this.user_id}/build/1234/output/output.log`, + url: `/project/${ctx.project._id}/user/${ctx.user_id}/build/1234/output/output.log`, path: 'output.log', type: 'log', build: 1234, }, ] - this.stats = { fooStat: 1 } - this.timings = { barTiming: 2 } - this.responseBody.compile.outputFiles = this.outputFiles.map( + ctx.stats = { fooStat: 1 } + ctx.timings = { barTiming: 2 } + ctx.responseBody.compile.outputFiles = ctx.outputFiles.map( outputFile => ({ ...outputFile, url: `http://${CLSI_HOST}${outputFile.url}`, }) ) - this.responseBody.compile.stats = this.stats - this.responseBody.compile.timings = this.timings - this.result = await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + ctx.responseBody.compile.stats = ctx.stats + ctx.responseBody.compile.timings = ctx.timings + ctx.result = await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, { compileBackendClass: 'n2d', compileGroup: 'standard' } ) }) - it('should emit the caching details and stats/timings', function () { - expect(this.result.status).to.equal('success') - expect(this.result.clsiServerId).to.equal(this.newClsiServerId) - expect(this.result.validationError).to.be.undefined - expect(this.result.stats).to.deep.equal(this.stats) - expect(this.result.timings).to.deep.equal(this.timings) - const outputPdf = this.result.outputFiles.find( + it('should emit the caching details and stats/timings', function (ctx) { + expect(ctx.result.status).to.equal('success') + expect(ctx.result.clsiServerId).to.equal(ctx.newClsiServerId) + expect(ctx.result.validationError).to.be.undefined + expect(ctx.result.stats).to.deep.equal(ctx.stats) + expect(ctx.result.timings).to.deep.equal(ctx.timings) + const outputPdf = ctx.result.outputFiles.find( f => f.path === 'output.pdf' ) - expect(outputPdf.ranges).to.deep.equal(this.ranges) - expect(outputPdf.startXRefTable).to.equal(this.startXRefTable) - expect(outputPdf.contentId).to.equal(this.contentId) - expect(outputPdf.size).to.equal(this.size) + expect(outputPdf.ranges).to.deep.equal(ctx.ranges) + expect(outputPdf.startXRefTable).to.equal(ctx.startXRefTable) + expect(outputPdf.contentId).to.equal(ctx.contentId) + expect(outputPdf.size).to.equal(ctx.size) }) }) describe('with the incremental compile option', function () { - beforeEach(async function () { - const doc = this.docs['/main.tex'] - this.DocumentUpdaterHandler.promises.getProjectDocsIfMatch.resolves([ + beforeEach(async function (ctx) { + const doc = ctx.docs['/main.tex'] + ctx.DocumentUpdaterHandler.promises.getProjectDocsIfMatch.resolves([ { _id: doc._id, lines: doc.lines, v: 123 }, ]) - this.ProjectEntityHandler.getAllDocPathsFromProject.returns({ + ctx.ProjectEntityHandler.getAllDocPathsFromProject.returns({ 'mock-doc-id-1': 'main.tex', }) - this.result = await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + ctx.result = await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, { timeout: 100, incrementalCompilesEnabled: true, @@ -419,9 +464,9 @@ describe('ClsiManager', function () { ) }) - it('should get the project with the required fields', function () { - this.ProjectGetter.promises.getProject.should.have.been.calledWith( - this.project._id, + it('should get the project with the required fields', function (ctx) { + ctx.ProjectGetter.promises.getProject.should.have.been.calledWith( + ctx.project._id, { compiler: 1, rootDoc_id: 1, @@ -432,30 +477,30 @@ describe('ClsiManager', function () { ) }) - it('should not explicitly flush the project to the database', function () { - this.DocumentUpdaterHandler.promises.flushProjectToMongo.should.not.have.been.calledWith( - this.project._id + it('should not explicitly flush the project to the database', function (ctx) { + ctx.DocumentUpdaterHandler.promises.flushProjectToMongo.should.not.have.been.calledWith( + ctx.project._id ) }) - it('should get only the live docs from the docupdater with a background flush in docupdater', function () { - this.DocumentUpdaterHandler.promises.getProjectDocsIfMatch.should.have.been.calledWith( - this.project._id + it('should get only the live docs from the docupdater with a background flush in docupdater', function (ctx) { + ctx.DocumentUpdaterHandler.promises.getProjectDocsIfMatch.should.have.been.calledWith( + ctx.project._id ) }) - it('should not get any of the files', function () { - this.ProjectEntityHandler.promises.getAllFiles.should.not.have.been + it('should not get any of the files', function (ctx) { + ctx.ProjectEntityHandler.promises.getAllFiles.should.not.have.been .called }) - it('should build up the CLSI request', function () { - this.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( + it('should build up the CLSI request', function (ctx) { + ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( sinon.match( url => url.hostname === CLSI_HOST && url.pathname === - `/project/${this.project._id}/user/${this.user_id}/compile` && + `/project/${ctx.project._id}/user/${ctx.user_id}/compile` && url.searchParams.get('compileBackendClass') === 'n2d' && url.searchParams.get('compileGroup') === 'priority' ), @@ -464,9 +509,9 @@ describe('ClsiManager', function () { json: sinon.match({ compile: { options: { - compiler: this.project.compiler, + compiler: ctx.project.compiler, timeout: 100, - imageName: this.project.imageName, + imageName: ctx.project.imageName, draft: false, syncType: 'incremental', syncState: '01234567890abcdef', @@ -482,7 +527,7 @@ describe('ClsiManager', function () { resources: [ { path: 'main.tex', - content: this.docs['/main.tex'].lines.join('\n'), + content: ctx.docs['/main.tex'].lines.join('\n'), }, ], }, @@ -490,7 +535,7 @@ describe('ClsiManager', function () { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Cookie: `${this.clsiCookieKey}=${this.clsiServerId}`, + Cookie: `${ctx.clsiCookieKey}=${ctx.clsiServerId}`, }, signal: sinon.match.instanceOf(AbortSignal), } @@ -499,18 +544,18 @@ describe('ClsiManager', function () { }) describe('when the root doc is set and not in the docupdater', function () { - beforeEach(async function () { - const doc = this.docs['/main.tex'] - this.DocumentUpdaterHandler.promises.getProjectDocsIfMatch.resolves([ + beforeEach(async function (ctx) { + const doc = ctx.docs['/main.tex'] + ctx.DocumentUpdaterHandler.promises.getProjectDocsIfMatch.resolves([ { _id: doc._id, lines: doc.lines, v: 123 }, ]) - this.ProjectEntityHandler.getAllDocPathsFromProject.returns({ + ctx.ProjectEntityHandler.getAllDocPathsFromProject.returns({ 'mock-doc-id-1': 'main.tex', 'mock-doc-id-2': '/chapters/chapter1.tex', }) - await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, { timeout: 100, incrementalCompilesEnabled: true, @@ -519,8 +564,8 @@ describe('ClsiManager', function () { ) }) - it('should still change the root path', function () { - this.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( + it('should still change the root path', function (ctx) { + ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( sinon.match.any, sinon.match({ json: { compile: { rootResourcePath: 'chapters/chapter1.tex' } }, @@ -530,16 +575,16 @@ describe('ClsiManager', function () { }) describe('when root doc override is valid', function () { - beforeEach(async function () { - await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, { rootDoc_id: 'mock-doc-id-2' } ) }) - it('should change root path', function () { - this.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( + it('should change root path', function (ctx) { + ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( sinon.match.any, sinon.match({ json: { compile: { rootResourcePath: 'chapters/chapter1.tex' } }, @@ -549,16 +594,16 @@ describe('ClsiManager', function () { }) describe('when root doc override is invalid', function () { - beforeEach(async function () { - await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, { rootDoc_id: 'invalid-id' } ) }) - it('should fallback to default root doc', function () { - this.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( + it('should fallback to default root doc', function (ctx) { + ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( sinon.match.any, sinon.match({ json: { compile: { rootResourcePath: 'main.tex' } }, @@ -568,17 +613,17 @@ describe('ClsiManager', function () { }) describe('when the project has an invalid compiler', function () { - beforeEach(async function () { - this.project.compiler = 'context' - await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + ctx.project.compiler = 'context' + await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, {} ) }) - it('should set the compiler to pdflatex', function () { - expect(this.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( + it('should set the compiler to pdflatex', function (ctx) { + expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( sinon.match.any, sinon.match({ json: { compile: { options: { compiler: 'pdflatex' } } }, @@ -588,17 +633,17 @@ describe('ClsiManager', function () { }) describe('when there is no valid root document', function () { - beforeEach(async function () { - this.project.rootDoc_id = 'not-valid' - await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + ctx.project.rootDoc_id = 'not-valid' + await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, {} ) }) - it('should set to main.tex', function () { - expect(this.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( + it('should set to main.tex', function (ctx) { + expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( sinon.match.any, sinon.match({ json: { compile: { rootResourcePath: 'main.tex' } }, @@ -608,9 +653,9 @@ describe('ClsiManager', function () { }) describe('when there is no valid root document and no main.tex document', function () { - beforeEach(async function () { - this.project.rootDoc_id = 'not-valid' - this.docs = { + beforeEach(async function (ctx) { + ctx.project.rootDoc_id = 'not-valid' + ctx.docs = { '/other.tex': { name: 'other.tex', _id: 'mock-doc-id-1', @@ -622,39 +667,39 @@ describe('ClsiManager', function () { lines: ['Chapter 1'], }, } - this.ProjectEntityHandler.promises.getAllDocs.resolves(this.docs) - this.result = await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + ctx.ProjectEntityHandler.promises.getAllDocs.resolves(ctx.docs) + ctx.result = await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, {} ) }) - it('should report a validation problem', function () { - expect(this.result.status).to.equal('validation-problems') + it('should report a validation problem', function (ctx) { + expect(ctx.result.status).to.equal('validation-problems') }) }) describe('when there is no valid root document and a single document which is not main.tex', function () { - beforeEach(async function () { - this.project.rootDoc_id = 'not-valid' - this.docs = { + beforeEach(async function (ctx) { + ctx.project.rootDoc_id = 'not-valid' + ctx.docs = { '/other.tex': { name: 'other.tex', _id: 'mock-doc-id-1', lines: ['Hello', 'world'], }, } - this.ProjectEntityHandler.promises.getAllDocs.resolves(this.docs) - await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + ctx.ProjectEntityHandler.promises.getAllDocs.resolves(ctx.docs) + await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, {} ) }) - it('should set io to the only file', function () { - expect(this.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( + it('should set io to the only file', function (ctx) { + expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( sinon.match.any, sinon.match({ json: { compile: { rootResourcePath: 'other.tex' } }, @@ -664,10 +709,10 @@ describe('ClsiManager', function () { }) describe('with the draft option', function () { - beforeEach(async function () { - await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, { timeout: 100, draft: true, @@ -675,8 +720,8 @@ describe('ClsiManager', function () { ) }) - it('should add the draft option into the request', function () { - expect(this.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( + it('should add the draft option into the request', function (ctx) { + expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( sinon.match.any, sinon.match({ json: { compile: { options: { draft: true } } }, @@ -686,24 +731,24 @@ describe('ClsiManager', function () { }) describe('with a failed compile', function () { - beforeEach(async function () { - this.responseBody.compile.status = 'failure' - this.result = await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + ctx.responseBody.compile.status = 'failure' + ctx.result = await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, {} ) }) - it('should return a failure status', function () { - expect(this.result.status).to.equal('failure') + it('should return a failure status', function (ctx) { + expect(ctx.result.status).to.equal('failure') }) }) describe('with a sync conflict', function () { - beforeEach(async function () { + beforeEach(async function (ctx) { const conflictResponseBody = { compile: { status: 'conflict' } } - this.FetchUtils.fetchStringWithResponse + ctx.FetchUtils.fetchStringWithResponse .withArgs( sinon.match.any, sinon.match({ @@ -714,104 +759,100 @@ describe('ClsiManager', function () { ) .resolves({ body: JSON.stringify(conflictResponseBody), - response: this.response, + response: ctx.response, }) - this.result = await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + ctx.result = await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, {} ) }) - it('should send two requests to CLSI', function () { - this.FetchUtils.fetchStringWithResponse.should.have.been.calledTwice + it('should send two requests to CLSI', function (ctx) { + ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledTwice }) - it('should call the CLSI first without syncType:full', function () { + it('should call the CLSI first without syncType:full', function (ctx) { const compileOptions = - this.FetchUtils.fetchStringWithResponse.getCall(0).args[1].json - .compile.options + ctx.FetchUtils.fetchStringWithResponse.getCall(0).args[1].json.compile + .options expect(compileOptions.syncType).to.be.undefined }) - it('should call the CLSI a second time with syncType:full', function () { + it('should call the CLSI a second time with syncType:full', function (ctx) { const compileOptions = - this.FetchUtils.fetchStringWithResponse.getCall(1).args[1].json - .compile.options + ctx.FetchUtils.fetchStringWithResponse.getCall(1).args[1].json.compile + .options expect(compileOptions.syncType).to.equal('full') }) - it('should return a success status', function () { - this.result.status.should.equal('success') + it('should return a success status', function (ctx) { + ctx.result.status.should.equal('success') }) }) describe('with an unavailable response', function () { - beforeEach(async function () { - this.FetchUtils.fetchStringWithResponse.onCall(0).resolves({ + beforeEach(async function (ctx) { + ctx.FetchUtils.fetchStringWithResponse.onCall(0).resolves({ body: JSON.stringify({ compile: { status: 'unavailable' } }), - response: this.response, + response: ctx.response, }) - this.result = await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + ctx.result = await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, {} ) }) - it('should send two requests to CLSI', function () { - this.FetchUtils.fetchStringWithResponse.should.have.been.calledTwice + it('should send two requests to CLSI', function (ctx) { + ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledTwice }) - it('should call the CLSI first without syncType:full', function () { + it('should call the CLSI first without syncType:full', function (ctx) { const compileOptions = - this.FetchUtils.fetchStringWithResponse.getCall(0).args[1].json - .compile.options + ctx.FetchUtils.fetchStringWithResponse.getCall(0).args[1].json.compile + .options expect(compileOptions.syncType).to.be.undefined }) - it('should call the CLSI a second time with syncType:full', function () { + it('should call the CLSI a second time with syncType:full', function (ctx) { const compileOptions = - this.FetchUtils.fetchStringWithResponse.getCall(1).args[1].json - .compile.options + ctx.FetchUtils.fetchStringWithResponse.getCall(1).args[1].json.compile + .options expect(compileOptions.syncType).to.equal('full') }) - it('should clear the CLSI server id cookie', function () { + it('should clear the CLSI server id cookie', function (ctx) { expect( - this.ClsiCookieManager.promises.clearServerId - ).to.have.been.calledWith(this.project._id, this.user_id) + ctx.ClsiCookieManager.promises.clearServerId + ).to.have.been.calledWith(ctx.project._id, ctx.user_id) }) - it('should return a success status', function () { - expect(this.result.status).to.equal('success') + it('should return a success status', function (ctx) { + expect(ctx.result.status).to.equal('success') }) }) describe('when the resources fail the precompile check', function () { - beforeEach(function () { - this.ClsiFormatChecker.promises.checkRecoursesForProblems.rejects( + beforeEach(function (ctx) { + ctx.ClsiFormatChecker.promises.checkRecoursesForProblems.rejects( new Error('failed') ) }) - it('should throw an error', async function () { + it('should throw an error', async function (ctx) { await expect( - this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, - {} - ) + ctx.ClsiManager.promises.sendRequest(ctx.project._id, ctx.user_id, {}) ).to.be.rejected }) }) describe('when a new backend is configured', function () { - beforeEach(async function () { - this.Settings.apis.clsi_new = { url: 'https://compiles.somewhere.test' } - await this.ClsiManager.promises.sendRequest( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + ctx.Settings.apis.clsi_new = { url: 'https://compiles.somewhere.test' } + await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, { compileBackendClass: 'c2d', compileGroup: 'priority', @@ -821,30 +862,30 @@ describe('ClsiManager', function () { await setTimeout(0) }) - it('makes a request to the new backend', function () { - expect(this.FetchUtils.fetchStringWithResponse).to.have.been.calledTwice - expect(this.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( + it('makes a request to the new backend', function (ctx) { + expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledTwice + expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( sinon.match( url => url.host === CLSI_HOST && url.pathname === - `/project/${this.project._id}/user/${this.user_id}/compile` && + `/project/${ctx.project._id}/user/${ctx.user_id}/compile` && url.searchParams.get('compileBackendClass') === 'c2d' && url.searchParams.get('compileGroup') === 'priority' ) ) - expect(this.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( + expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( sinon.match( url => url.toString() === - `${this.Settings.apis.clsi_new.url}/project/${this.project._id}/user/${this.user_id}/compile?compileBackendClass=c4d&compileGroup=priority` + `${ctx.Settings.apis.clsi_new.url}/project/${ctx.project._id}/user/${ctx.user_id}/compile?compileBackendClass=c4d&compileGroup=priority` ) ) }) - it('should record an event', function () { + it('should record an event', function (ctx) { expect( - this.AnalyticsManager.recordEventForUserInBackground - ).to.have.been.calledWith(this.user_id, 'double-compile-result', { + ctx.AnalyticsManager.recordEventForUserInBackground + ).to.have.been.calledWith(ctx.user_id, 'double-compile-result', { projectId: 'project-id', compileBackendClass: 'c2d', newCompileBackendClass: 'c4d', @@ -861,98 +902,98 @@ describe('ClsiManager', function () { }) describe('sendExternalRequest', function () { - beforeEach(function () { - this.submissionId = 'submission-id' - this.clsiRequest = 'mock-request' + beforeEach(function (ctx) { + ctx.submissionId = 'submission-id' + ctx.clsiRequest = 'mock-request' }) describe('with a successful compile', function () { - beforeEach(async function () { - this.outputFiles = [ + beforeEach(async function (ctx) { + ctx.outputFiles = [ { - url: `/project/${this.submissionId}/build/1234/output/output.pdf`, + url: `/project/${ctx.submissionId}/build/1234/output/output.pdf`, path: 'output.pdf', type: 'pdf', build: 1234, }, { - url: `/project/${this.submissionId}/build/1234/output/output.log`, + url: `/project/${ctx.submissionId}/build/1234/output/output.log`, path: 'output.log', type: 'log', build: 1234, }, ] - this.responseBody.compile.outputFiles = this.outputFiles.map( + ctx.responseBody.compile.outputFiles = ctx.outputFiles.map( outputFile => ({ ...outputFile, url: `http://${CLSI_HOST}${outputFile.url}`, }) ) - this.result = await this.ClsiManager.promises.sendExternalRequest( - this.submissionId, - this.clsiRequest, + ctx.result = await ctx.ClsiManager.promises.sendExternalRequest( + ctx.submissionId, + ctx.clsiRequest, { compileBackendClass: 'n2d', compileGroup: 'standard' } ) }) - it('should send the request to the CLSI', function () { - this.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( + it('should send the request to the CLSI', function (ctx) { + ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( sinon.match( url => url.host === CLSI_HOST && - url.pathname === `/project/${this.submissionId}/compile` && + url.pathname === `/project/${ctx.submissionId}/compile` && url.searchParams.get('compileBackendClass') === 'n2d' && url.searchParams.get('compileGroup') === 'standard' ), { method: 'POST', - json: this.clsiRequest, + json: ctx.clsiRequest, headers: { Accept: 'application/json', 'Content-Type': 'application/json', - Cookie: `${this.clsiCookieKey}=${this.clsiServerId}`, + Cookie: `${ctx.clsiCookieKey}=${ctx.clsiServerId}`, }, signal: sinon.match.instanceOf(AbortSignal), } ) }) - it('should return the status and output files', function () { - expect(this.result.status).to.equal('success') - expect(this.result.outputFiles.map(f => f.path)).to.have.members( - this.outputFiles.map(f => f.path) + it('should return the status and output files', function (ctx) { + expect(ctx.result.status).to.equal('success') + expect(ctx.result.outputFiles.map(f => f.path)).to.have.members( + ctx.outputFiles.map(f => f.path) ) }) }) describe('with a failed compile', function () { - beforeEach(async function () { - this.responseBody.compile.status = 'failure' - this.result = await this.ClsiManager.promises.sendExternalRequest( - this.submissionId, - this.clsiRequest, + beforeEach(async function (ctx) { + ctx.responseBody.compile.status = 'failure' + ctx.result = await ctx.ClsiManager.promises.sendExternalRequest( + ctx.submissionId, + ctx.clsiRequest, {} ) }) - it('should return a failure status', function () { - expect(this.result.status).to.equal('failure') + it('should return a failure status', function (ctx) { + expect(ctx.result.status).to.equal('failure') }) }) describe('when the resources fail the precompile check', function () { - beforeEach(async function () { - this.ClsiFormatChecker.promises.checkRecoursesForProblems.rejects( + beforeEach(async function (ctx) { + ctx.ClsiFormatChecker.promises.checkRecoursesForProblems.rejects( new Error('failed') ) - this.responseBody.compile.status = 'failure' + ctx.responseBody.compile.status = 'failure' }) - it('should throw an error', async function () { + it('should throw an error', async function (ctx) { await expect( - this.ClsiManager.promises.sendExternalRequest( - this.submissionId, - this.clsiRequest, + ctx.ClsiManager.promises.sendExternalRequest( + ctx.submissionId, + ctx.clsiRequest, {} ) ).to.be.rejected @@ -962,22 +1003,22 @@ describe('ClsiManager', function () { describe('deleteAuxFiles', function () { describe('with the standard compileGroup', function () { - beforeEach(async function () { - await this.ClsiManager.promises.deleteAuxFiles( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + await ctx.ClsiManager.promises.deleteAuxFiles( + ctx.project._id, + ctx.user_id, { compileBackendClass: 'n2d', compileGroup: 'standard' }, 'node-1' ) }) - it('should call the delete method in the standard CLSI', function () { - this.FetchUtils.fetchString.should.have.been.calledWith( + it('should call the delete method in the standard CLSI', function (ctx) { + ctx.FetchUtils.fetchString.should.have.been.calledWith( sinon.match( url => url.host === CLSI_HOST && url.pathname === - `/project/${this.project._id}/user/${this.user_id}` && + `/project/${ctx.project._id}/user/${ctx.user_id}` && url.searchParams.get('compileBackendClass') === 'n2d' && url.searchParams.get('compileGroup') === 'standard' && url.searchParams.get('clsiserverid') === 'node-1' @@ -986,36 +1027,36 @@ describe('ClsiManager', function () { ) }) - it('should clear the output.tar.gz files in clsi-cache', function () { - this.ClsiCacheHandler.clearCache - .calledWith(this.project._id, this.user_id) + it('should clear the output.tar.gz files in clsi-cache', function (ctx) { + ctx.ClsiCacheHandler.clearCache + .calledWith(ctx.project._id, ctx.user_id) .should.equal(true) }) - it('should clear the project state from the docupdater', function () { - this.DocumentUpdaterHandler.promises.clearProjectState - .calledWith(this.project._id) + it('should clear the project state from the docupdater', function (ctx) { + ctx.DocumentUpdaterHandler.promises.clearProjectState + .calledWith(ctx.project._id) .should.equal(true) }) - it('should clear the clsi persistance', function () { - this.ClsiCookieManager.promises.clearServerId - .calledWith(this.project._id, this.user_id) + it('should clear the clsi persistance', function (ctx) { + ctx.ClsiCookieManager.promises.clearServerId + .calledWith(ctx.project._id, ctx.user_id) .should.equal(true) }) - it('should not persist a cookie on response', function () { - expect(this.ClsiCookieManager.promises.setServerId).not.to.have.been + it('should not persist a cookie on response', function (ctx) { + expect(ctx.ClsiCookieManager.promises.setServerId).not.to.have.been .called }) }) describe('when a new backend is configured', function () { - beforeEach(async function () { - this.Settings.apis.clsi_new = { url: 'https://compiles.somewhere.test' } - await this.ClsiManager.promises.deleteAuxFiles( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + ctx.Settings.apis.clsi_new = { url: 'https://compiles.somewhere.test' } + await ctx.ClsiManager.promises.deleteAuxFiles( + ctx.project._id, + ctx.user_id, { compileBackendClass: 'c2d', compileGroup: 'priority' }, 'node-1' ) @@ -1023,25 +1064,25 @@ describe('ClsiManager', function () { await setTimeout(0) }) - it('should forward delete request', function () { - expect(this.FetchUtils.fetchString).to.have.been.calledWith( + it('should forward delete request', function (ctx) { + expect(ctx.FetchUtils.fetchString).to.have.been.calledWith( sinon.match( url => url.host === CLSI_HOST && url.pathname === - `/project/${this.project._id}/user/${this.user_id}` && + `/project/${ctx.project._id}/user/${ctx.user_id}` && url.searchParams.get('compileBackendClass') === 'c2d' && url.searchParams.get('compileGroup') === 'priority' && url.searchParams.get('clsiserverid') === 'node-1' ), { method: 'DELETE' } ) - expect(this.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( + expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( sinon.match( url => url.host === 'compiles.somewhere.test' && url.pathname === - `/project/${this.project._id}/user/${this.user_id}` && + `/project/${ctx.project._id}/user/${ctx.user_id}` && url.searchParams.get('compileBackendClass') === 'c4d' && url.searchParams.get('compileGroup') === 'priority' && !url.searchParams.has('clsiserverid') @@ -1054,50 +1095,50 @@ describe('ClsiManager', function () { describe('wordCount', function () { describe('with root file', function () { - beforeEach(async function () { - await this.ClsiManager.promises.wordCount( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + await ctx.ClsiManager.promises.wordCount( + ctx.project._id, + ctx.user_id, false, { compileBackendClass: 'n2d', compileGroup: 'standard' }, 'node-1' ) }) - it('should call wordCount with root file', function () { - expect(this.FetchUtils.fetchString).to.have.been.calledWith( + it('should call wordCount with root file', function (ctx) { + expect(ctx.FetchUtils.fetchString).to.have.been.calledWith( sinon.match( url => url.toString() === - `http://clsi.example.com/project/${this.project._id}/user/${this.user_id}/wordcount?compileBackendClass=n2d&compileGroup=standard&file=main.tex&image=mock-image-name&clsiserverid=node-1` + `http://clsi.example.com/project/${ctx.project._id}/user/${ctx.user_id}/wordcount?compileBackendClass=n2d&compileGroup=standard&file=main.tex&image=mock-image-name&clsiserverid=node-1` ) ) }) - it('should not persist a cookie on response', function () { - expect(this.ClsiCookieManager.promises.setServerId).not.to.have.been + it('should not persist a cookie on response', function (ctx) { + expect(ctx.ClsiCookieManager.promises.setServerId).not.to.have.been .called }) }) describe('with param file', function () { - beforeEach(async function () { - await this.ClsiManager.promises.wordCount( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + await ctx.ClsiManager.promises.wordCount( + ctx.project._id, + ctx.user_id, 'other.tex', { compileBackendClass: 'n2d', compileGroup: 'standard' }, 'node-2' ) }) - it('should call wordCount with param file', function () { - expect(this.FetchUtils.fetchString).to.have.been.calledWith( + it('should call wordCount with param file', function (ctx) { + expect(ctx.FetchUtils.fetchString).to.have.been.calledWith( sinon.match( url => url.host === CLSI_HOST && url.pathname === - `/project/${this.project._id}/user/${this.user_id}/wordcount` && + `/project/${ctx.project._id}/user/${ctx.user_id}/wordcount` && url.searchParams.get('compileBackendClass') === 'n2d' && url.searchParams.get('compileGroup') === 'standard' && url.searchParams.get('clsiserverid') === 'node-2' && @@ -1107,18 +1148,18 @@ describe('ClsiManager', function () { ) }) - it('should not persist a cookie on response', function () { - expect(this.ClsiCookieManager.promises.setServerId).not.to.have.been + it('should not persist a cookie on response', function (ctx) { + expect(ctx.ClsiCookieManager.promises.setServerId).not.to.have.been .called }) }) describe('when a new backend is configured', function () { - beforeEach(async function () { - this.Settings.apis.clsi_new = { url: 'https://compiles.somewhere.test' } - await this.ClsiManager.promises.wordCount( - this.project._id, - this.user_id, + beforeEach(async function (ctx) { + ctx.Settings.apis.clsi_new = { url: 'https://compiles.somewhere.test' } + await ctx.ClsiManager.promises.wordCount( + ctx.project._id, + ctx.user_id, false, { compileBackendClass: 'c2d', compileGroup: 'priority' }, 'node-1' @@ -1127,19 +1168,19 @@ describe('ClsiManager', function () { await setTimeout(0) }) - it('should forward wordcount request', function () { - expect(this.FetchUtils.fetchString).to.have.been.calledWith( + it('should forward wordcount request', function (ctx) { + expect(ctx.FetchUtils.fetchString).to.have.been.calledWith( sinon.match( url => url.toString() === - `http://clsi.example.com/project/${this.project._id}/user/${this.user_id}/wordcount?compileBackendClass=c2d&compileGroup=priority&file=main.tex&image=mock-image-name&clsiserverid=node-1` + `http://clsi.example.com/project/${ctx.project._id}/user/${ctx.user_id}/wordcount?compileBackendClass=c2d&compileGroup=priority&file=main.tex&image=mock-image-name&clsiserverid=node-1` ) ) - expect(this.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( + expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith( sinon.match( url => url.toString() === - `${this.Settings.apis.clsi_new.url}/project/${this.project._id}/user/${this.user_id}/wordcount?compileBackendClass=c4d&compileGroup=priority&file=main.tex&image=mock-image-name` + `${ctx.Settings.apis.clsi_new.url}/project/${ctx.project._id}/user/${ctx.user_id}/wordcount?compileBackendClass=c4d&compileGroup=priority&file=main.tex&image=mock-image-name` ) ) }) diff --git a/services/web/test/unit/src/Security/RateLimiterMiddleware.test.mjs b/services/web/test/unit/src/Security/RateLimiterMiddleware.test.mjs new file mode 100644 index 0000000000..1c656e2b6d --- /dev/null +++ b/services/web/test/unit/src/Security/RateLimiterMiddleware.test.mjs @@ -0,0 +1,148 @@ +import { vi } from 'vitest' +import sinon from 'sinon' + +const modulePath = + '../../../../app/src/Features/Security/RateLimiterMiddleware.mjs' + +describe('RateLimiterMiddleware', function () { + beforeEach(async function (ctx) { + ctx.SessionManager = { + getLoggedInUserId: () => ctx.req.session?.user?._id, + } + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = {}), + })) + + vi.doMock('../../../../app/src/Features/Security/LoginRateLimiter', () => ({ + default: {}, + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + ctx.RateLimiterMiddleware = (await import(modulePath)).default + ctx.req = { params: {} } + ctx.res = { + status: sinon.stub(), + write: sinon.stub(), + end: sinon.stub(), + } + ctx.next = sinon.stub() + }) + + describe('rateLimit', function () { + beforeEach(function (ctx) { + ctx.projectId = 'project-id' + ctx.docId = 'doc-id' + ctx.rateLimiter = { + consume: sinon.stub().resolves({ remainingPoints: 2 }), + } + ctx.middleware = ctx.RateLimiterMiddleware.rateLimit(ctx.rateLimiter, { + params: ['projectId', 'docId'], + }) + ctx.req.params = { projectId: ctx.projectId, docId: ctx.docId } + }) + + describe('when there is no session', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.req.ip = ctx.ip = '1.2.3.4' + ctx.middleware(ctx.req, ctx.res, () => { + resolve() + }) + }) + }) + + it('should call the rate limiter with the ip address', function (ctx) { + ctx.rateLimiter.consume.should.have.been.calledWith( + `${ctx.projectId}:${ctx.docId}:${ctx.ip}` + ) + }) + }) + + describe('when smoke test user', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.userId = 'smoke-test-user-id' + ctx.req.session = { + user: { _id: ctx.userId }, + } + ctx.settings.smokeTest = { userId: ctx.userId } + ctx.middleware(ctx.req, ctx.res, () => { + resolve() + }) + }) + }) + + it('should not call the rate limiter', function (ctx) { + ctx.rateLimiter.consume.should.not.have.been.called + }) + }) + + describe('when under the rate limit with logged in user', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.userId = 'user-id' + ctx.req.session = { + user: { _id: ctx.userId }, + } + ctx.middleware(ctx.req, ctx.res, () => { + resolve() + }) + }) + }) + + it('should call the rate limiter backend with the userId', function (ctx) { + ctx.rateLimiter.consume.should.have.been.calledWith( + `${ctx.projectId}:${ctx.docId}:${ctx.userId}` + ) + }) + }) + + describe('when under the rate limit with anonymous user', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.req.ip = '1.2.3.4' + ctx.middleware(ctx.req, ctx.res, () => { + resolve() + }) + }) + }) + + it('should call the rate limiter backend with the ip address', function (ctx) { + ctx.rateLimiter.consume.should.have.been.calledWith( + `${ctx.projectId}:${ctx.docId}:${ctx.req.ip}` + ) + }) + }) + + describe('when over the rate limit', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.userId = 'user-id' + ctx.req.session = { + user: { _id: ctx.userId }, + } + ctx.res.end.callsFake(() => { + resolve() + }) + ctx.rateLimiter.consume.rejects({ remainingPoints: 0 }) + ctx.middleware(ctx.req, ctx.res, ctx.next) + }) + }) + + it('should return a 429', function (ctx) { + ctx.res.status.should.have.been.calledWith(429) + }) + + it('should not continue', function (ctx) { + ctx.next.should.not.have.been.called + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Security/RateLimiterMiddlewareTests.js b/services/web/test/unit/src/Security/RateLimiterMiddlewareTests.js deleted file mode 100644 index 05ab4260e5..0000000000 --- a/services/web/test/unit/src/Security/RateLimiterMiddlewareTests.js +++ /dev/null @@ -1,129 +0,0 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const modulePath = require('path').join( - __dirname, - '../../../../app/src/Features/Security/RateLimiterMiddleware' -) - -describe('RateLimiterMiddleware', function () { - beforeEach(function () { - this.SessionManager = { - getLoggedInUserId: () => this.req.session?.user?._id, - } - this.RateLimiterMiddleware = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': (this.settings = {}), - './LoginRateLimiter': {}, - '../Authentication/SessionManager': this.SessionManager, - }, - }) - this.req = { params: {} } - this.res = { - status: sinon.stub(), - write: sinon.stub(), - end: sinon.stub(), - } - this.next = sinon.stub() - }) - - describe('rateLimit', function () { - beforeEach(function () { - this.projectId = 'project-id' - this.docId = 'doc-id' - this.rateLimiter = { - consume: sinon.stub().resolves({ remainingPoints: 2 }), - } - this.middleware = this.RateLimiterMiddleware.rateLimit(this.rateLimiter, { - params: ['projectId', 'docId'], - }) - this.req.params = { projectId: this.projectId, docId: this.docId } - }) - - describe('when there is no session', function () { - beforeEach(function (done) { - this.req.ip = this.ip = '1.2.3.4' - this.middleware(this.req, this.res, () => { - done() - }) - }) - - it('should call the rate limiter with the ip address', function () { - this.rateLimiter.consume.should.have.been.calledWith( - `${this.projectId}:${this.docId}:${this.ip}` - ) - }) - }) - - describe('when smoke test user', function () { - beforeEach(function (done) { - this.userId = 'smoke-test-user-id' - this.req.session = { - user: { _id: this.userId }, - } - this.settings.smokeTest = { userId: this.userId } - this.middleware(this.req, this.res, () => { - done() - }) - }) - - it('should not call the rate limiter', function () { - this.rateLimiter.consume.should.not.have.been.called - }) - }) - - describe('when under the rate limit with logged in user', function () { - beforeEach(function (done) { - this.userId = 'user-id' - this.req.session = { - user: { _id: this.userId }, - } - this.middleware(this.req, this.res, () => { - done() - }) - }) - - it('should call the rate limiter backend with the userId', function () { - this.rateLimiter.consume.should.have.been.calledWith( - `${this.projectId}:${this.docId}:${this.userId}` - ) - }) - }) - - describe('when under the rate limit with anonymous user', function () { - beforeEach(function (done) { - this.req.ip = '1.2.3.4' - this.middleware(this.req, this.res, () => { - done() - }) - }) - - it('should call the rate limiter backend with the ip address', function () { - this.rateLimiter.consume.should.have.been.calledWith( - `${this.projectId}:${this.docId}:${this.req.ip}` - ) - }) - }) - - describe('when over the rate limit', function () { - beforeEach(function (done) { - this.userId = 'user-id' - this.req.session = { - user: { _id: this.userId }, - } - this.res.end.callsFake(() => { - done() - }) - this.rateLimiter.consume.rejects({ remainingPoints: 0 }) - this.middleware(this.req, this.res, this.next) - }) - - it('should return a 429', function () { - this.res.status.should.have.been.calledWith(429) - }) - - it('should not continue', function () { - this.next.should.not.have.been.called - }) - }) - }) -}) diff --git a/services/web/test/unit/src/Spelling/LearnedWordsManager.test.mjs b/services/web/test/unit/src/Spelling/LearnedWordsManager.test.mjs new file mode 100644 index 0000000000..ef45cefa07 --- /dev/null +++ b/services/web/test/unit/src/Spelling/LearnedWordsManager.test.mjs @@ -0,0 +1,142 @@ +import { vi, expect } from 'vitest' +import Errors from '../../../../app/src/Features/Errors/Errors.js' + +const modulePath = + '../../../../app/src/Features/Spelling/LearnedWordsManager.mjs' + +vi.mock('../../../../app/src/Features/Errors/Errors.js', () => + vi.importActual('../../../../app/src/Features/Errors/Errors.js') +) + +describe('LearnedWordsManager', function () { + beforeEach(async function (ctx) { + ctx.token = 'a6b3cd919ge' + ctx.db = { + spellingPreferences: { + updateOne: vi.fn(), + findOne: vi.fn().mockResolvedValue(['pear']), + }, + } + + vi.doMock('../../../../app/src/infrastructure/mongodb.js', () => ({ + default: { db: ctx.db }, + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: { + inc: vi.fn(), + }, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: { + maxDictionarySize: 20, + }, + })) + ctx.LearnedWordsManager = (await import(modulePath)).default + }) + + describe('learnWord', function () { + describe('under size limit', function () { + beforeEach(async function (ctx) { + ctx.word = 'instanton' + await ctx.LearnedWordsManager.promises.learnWord(ctx.token, ctx.word) + }) + + it('should insert the word in the word list in the database', function (ctx) { + expect(ctx.db.spellingPreferences.updateOne).toHaveBeenCalledWith( + { + token: ctx.token, + }, + { + $addToSet: { learnedWords: ctx.word }, + }, + { + upsert: true, + } + ) + }) + }) + + describe('over size limit', function () { + beforeEach(function (ctx) { + ctx.word = 'superlongwordthatwillgobeyondthelimit' + }) + + it('should throw an error and not insert the word in the word list in the database', async function (ctx) { + await expect( + ctx.LearnedWordsManager.promises.learnWord(ctx.token, ctx.word) + ).to.be.rejectedWith(Errors.InvalidError) + expect(ctx.db.spellingPreferences.updateOne).not.toHaveBeenCalled() + }) + }) + }) + + describe('unlearnWord', function () { + beforeEach(async function (ctx) { + ctx.word = 'instanton' + await ctx.LearnedWordsManager.promises.unlearnWord(ctx.token, ctx.word) + }) + + it('should remove the word from the word list in the database', function (ctx) { + expect(ctx.db.spellingPreferences.updateOne).toHaveBeenCalledWith( + { + token: ctx.token, + }, + { + $pull: { learnedWords: ctx.word }, + } + ) + }) + }) + + describe('getLearnedWords', function () { + beforeEach(async function (ctx) { + ctx.wordList = ['apples', 'bananas', 'pears'] + ctx.wordListWithDuplicates = ctx.wordList.slice() + ctx.wordListWithDuplicates.push('bananas') + ctx.db.spellingPreferences.findOne = vi + .fn() + .mockResolvedValue({ learnedWords: ctx.wordListWithDuplicates }) + ctx.learnedWords = await ctx.LearnedWordsManager.promises.getLearnedWords( + ctx.token + ) + }) + + it('should get the word list for the given user', function (ctx) { + expect(ctx.db.spellingPreferences.findOne).toHaveBeenCalledWith({ + token: ctx.token, + }) + }) + + it('should return the word list without duplicates', function (ctx) { + expect(ctx.learnedWords).to.deep.equal(ctx.wordList) + }) + }) + + describe('getLearnedWordsSize', function () { + it('should return the word list size in the callback', async function (ctx) { + ctx.db.spellingPreferences.findOne = conditions => { + return Promise.resolve({ + learnedWords: ['apples', 'bananas', 'pears', 'bananas'], + }) + } + const learnedWordsSize = + await ctx.LearnedWordsManager.promises.getLearnedWordsSize(ctx.token) + expect(learnedWordsSize).to.equal(38) + }) + }) + + describe('deleteUsersLearnedWords', function () { + beforeEach(function (ctx) { + ctx.db.spellingPreferences.deleteOne = vi.fn() + }) + + it('should get the word list for the given user', async function (ctx) { + await ctx.LearnedWordsManager.promises.deleteUsersLearnedWords(ctx.token) + expect(ctx.db.spellingPreferences.deleteOne).toHaveBeenCalledWith({ + token: ctx.token, + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Spelling/LearnedWordsManagerTests.js b/services/web/test/unit/src/Spelling/LearnedWordsManagerTests.js deleted file mode 100644 index 53e1ae3a8b..0000000000 --- a/services/web/test/unit/src/Spelling/LearnedWordsManagerTests.js +++ /dev/null @@ -1,141 +0,0 @@ -const sinon = require('sinon') -const { expect } = require('chai') -const SandboxedModule = require('sandboxed-module') -const modulePath = require('path').join( - __dirname, - '/../../../../app/src/Features/Spelling/LearnedWordsManager' -) -const { InvalidError } = require('../../../../app/src/Features/Errors/Errors') - -describe('LearnedWordsManager', function () { - beforeEach(function () { - this.token = 'a6b3cd919ge' - this.db = { - spellingPreferences: { - updateOne: sinon.stub().resolves(), - findOne: sinon.stub().resolves(['pear']), - }, - } - this.LearnedWordsManager = SandboxedModule.require(modulePath, { - requires: { - '../../infrastructure/mongodb': { db: this.db }, - '@overleaf/metrics': { - inc: sinon.stub(), - }, - '@overleaf/settings': { - maxDictionarySize: 20, - }, - }, - }) - }) - - describe('learnWord', function () { - describe('under size limit', function () { - beforeEach(async function () { - this.word = 'instanton' - await this.LearnedWordsManager.promises.learnWord(this.token, this.word) - }) - - it('should insert the word in the word list in the database', function () { - expect( - this.db.spellingPreferences.updateOne.calledWith( - { - token: this.token, - }, - { - $addToSet: { learnedWords: this.word }, - }, - { - upsert: true, - } - ) - ).to.equal(true) - }) - }) - - describe('over size limit', function () { - beforeEach(function () { - this.word = 'superlongwordthatwillgobeyondthelimit' - }) - - it('should throw an error and not insert the word in the word list in the database', async function () { - await expect( - this.LearnedWordsManager.promises.learnWord(this.token, this.word) - ).to.be.rejectedWith(InvalidError) - expect(this.db.spellingPreferences.updateOne.notCalled).to.equal(true) - }) - }) - }) - - describe('unlearnWord', function () { - beforeEach(async function () { - this.word = 'instanton' - await this.LearnedWordsManager.promises.unlearnWord(this.token, this.word) - }) - - it('should remove the word from the word list in the database', function () { - expect( - this.db.spellingPreferences.updateOne.calledWith( - { - token: this.token, - }, - { - $pull: { learnedWords: this.word }, - } - ) - ).to.equal(true) - }) - }) - - describe('getLearnedWords', function () { - beforeEach(async function () { - this.wordList = ['apples', 'bananas', 'pears'] - this.wordListWithDuplicates = this.wordList.slice() - this.wordListWithDuplicates.push('bananas') - this.db.spellingPreferences.findOne = conditions => { - return Promise.resolve({ learnedWords: this.wordListWithDuplicates }) - } - sinon.spy(this.db.spellingPreferences, 'findOne') - this.learnedWords = - await this.LearnedWordsManager.promises.getLearnedWords(this.token) - }) - - it('should get the word list for the given user', function () { - expect( - this.db.spellingPreferences.findOne.calledWith({ token: this.token }) - ).to.equal(true) - }) - - it('should return the word list without duplicates', function () { - expect(this.learnedWords).to.deep.equal(this.wordList) - }) - }) - - describe('getLearnedWordsSize', function () { - it('should return the word list size in the callback', async function () { - this.db.spellingPreferences.findOne = conditions => { - return Promise.resolve({ - learnedWords: ['apples', 'bananas', 'pears', 'bananas'], - }) - } - const learnedWordsSize = - await this.LearnedWordsManager.promises.getLearnedWordsSize(this.token) - expect(learnedWordsSize).to.equal(38) - }) - }) - - describe('deleteUsersLearnedWords', function () { - beforeEach(function () { - this.db.spellingPreferences.deleteOne = sinon.stub().resolves() - }) - - it('should get the word list for the given user', async function () { - await this.LearnedWordsManager.promises.deleteUsersLearnedWords( - this.token - ) - expect( - this.db.spellingPreferences.deleteOne.calledWith({ token: this.token }) - ).to.equal(true) - }) - }) -}) diff --git a/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js b/services/web/test/unit/src/Subscription/RecurlyEventHandler.test.mjs similarity index 50% rename from services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js rename to services/web/test/unit/src/Subscription/RecurlyEventHandler.test.mjs index 5620d0f106..d513eead83 100644 --- a/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyEventHandler.test.mjs @@ -1,16 +1,19 @@ -const SandboxedModule = require('sandboxed-module') -const { ObjectId } = require('mongodb-legacy') -const sinon = require('sinon') +import { vi } from 'vitest' +import mongodb from 'mongodb-legacy' +import sinon from 'sinon' + +const { ObjectId } = mongodb + const modulePath = - '../../../../app/src/Features/Subscription/RecurlyEventHandler' + '../../../../app/src/Features/Subscription/RecurlyEventHandler.mjs' describe('RecurlyEventHandler', function () { - beforeEach(function () { - this.userId = '123abc234bcd456cde567def' - this.planCode = 'collaborator-annual' - this.eventData = { + beforeEach(async function (ctx) { + ctx.userId = '123abc234bcd456cde567def' + ctx.planCode = 'collaborator-annual' + ctx.eventData = { account: { - account_code: this.userId, + account_code: ctx.userId, }, subscription: { uuid: '8435ad98c1ce45da99b07f6a6a2e780f', @@ -26,17 +29,33 @@ describe('RecurlyEventHandler', function () { }, } - this.RecurlyEventHandler = SandboxedModule.require(modulePath, { - requires: { - 'mongodb-legacy': { ObjectId }, - './SubscriptionEmailHandler': (this.SubscriptionEmailHandler = { + vi.doMock('mongodb-legacy', () => ({ + default: { ObjectId }, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionEmailHandler', + () => ({ + default: (ctx.SubscriptionEmailHandler = { sendTrialOnboardingEmail: sinon.stub(), }), - '../Analytics/AnalyticsManager': (this.AnalyticsManager = { + }) + ) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: (ctx.AnalyticsManager = { recordEventForUserInBackground: sinon.stub(), setUserPropertyForUserInBackground: sinon.stub(), }), - '../SplitTests/SplitTestHandler': (this.SplitTestHandler = { + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: (ctx.SplitTestHandler = { promises: { getAssignmentForUser: sinon.stub().resolves({ variant: 'default', @@ -44,340 +63,342 @@ describe('RecurlyEventHandler', function () { hasUserBeenAssignedToVariant: sinon.stub().resolves(false), }, }), - }, - }) + }) + ) + + ctx.RecurlyEventHandler = (await import(modulePath)).default }) - it('with new_subscription_notification - free trial', async function () { - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with new_subscription_notification - free trial', async function (ctx) { + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'new_subscription_notification', - this.eventData + ctx.eventData ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.userId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.userId, 'subscription-started', { - plan_code: this.planCode, + plan_code: ctx.planCode, quantity: 1, is_trial: true, has_ai_add_on: false, - subscriptionId: this.eventData.subscription.uuid, + subscriptionId: ctx.eventData.subscription.uuid, payment_provider: 'recurly', 'customerio-integration': false, } ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-plan-code', - this.planCode + ctx.planCode ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-state', 'active' ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-is-trial', true ) }) - it('with new_subscription_notification - free trial with customerio integration enabled', async function () { - this.SplitTestHandler.promises.hasUserBeenAssignedToVariant.resolves(true) + it('with new_subscription_notification - free trial with customerio integration enabled', async function (ctx) { + ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant.resolves(true) - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'new_subscription_notification', - this.eventData + ctx.eventData ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.userId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.userId, 'subscription-started', { - plan_code: this.planCode, + plan_code: ctx.planCode, quantity: 1, is_trial: true, has_ai_add_on: false, - subscriptionId: this.eventData.subscription.uuid, + subscriptionId: ctx.eventData.subscription.uuid, payment_provider: 'recurly', 'customerio-integration': true, } ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-plan-code', - this.planCode + ctx.planCode ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-state', 'active' ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-is-trial', true ) }) - it('sends free trial onboarding email if user starting a trial', async function () { - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('sends free trial onboarding email if user starting a trial', async function (ctx) { + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'new_subscription_notification', - this.eventData + ctx.eventData ) - sinon.assert.called(this.SubscriptionEmailHandler.sendTrialOnboardingEmail) + sinon.assert.called(ctx.SubscriptionEmailHandler.sendTrialOnboardingEmail) }) - it('with new_subscription_notification - no free trial', async function () { - this.eventData.subscription.current_period_started_at = new Date( + it('with new_subscription_notification - no free trial', async function (ctx) { + ctx.eventData.subscription.current_period_started_at = new Date( '2021-02-10 12:34:56' ) - this.eventData.subscription.current_period_ends_at = new Date( + ctx.eventData.subscription.current_period_ends_at = new Date( '2021-02-17 12:34:56' ) - this.eventData.subscription.quantity = 3 + ctx.eventData.subscription.quantity = 3 - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'new_subscription_notification', - this.eventData + ctx.eventData ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.userId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.userId, 'subscription-started', { - plan_code: this.planCode, + plan_code: ctx.planCode, quantity: 3, is_trial: false, has_ai_add_on: false, - subscriptionId: this.eventData.subscription.uuid, + subscriptionId: ctx.eventData.subscription.uuid, payment_provider: 'recurly', 'customerio-integration': false, } ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-state', 'active' ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-is-trial', false ) }) - it('with updated_subscription_notification', async function () { - this.planCode = 'new-plan-code' - this.eventData.subscription.plan.plan_code = this.planCode - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with updated_subscription_notification', async function (ctx) { + ctx.planCode = 'new-plan-code' + ctx.eventData.subscription.plan.plan_code = ctx.planCode + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'updated_subscription_notification', - this.eventData + ctx.eventData ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.userId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.userId, 'subscription-updated', { - plan_code: this.planCode, + plan_code: ctx.planCode, quantity: 1, is_trial: true, has_ai_add_on: false, - subscriptionId: this.eventData.subscription.uuid, + subscriptionId: ctx.eventData.subscription.uuid, payment_provider: 'recurly', 'customerio-integration': false, } ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-plan-code', - this.planCode + ctx.planCode ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-state', 'active' ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-is-trial', true ) }) - it('with updated_subscription_notification with customerio integration enabled', async function () { - this.SplitTestHandler.promises.hasUserBeenAssignedToVariant.resolves(true) - this.planCode = 'new-plan-code' - this.eventData.subscription.plan.plan_code = this.planCode + it('with updated_subscription_notification with customerio integration enabled', async function (ctx) { + ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant.resolves(true) + ctx.planCode = 'new-plan-code' + ctx.eventData.subscription.plan.plan_code = ctx.planCode - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'updated_subscription_notification', - this.eventData + ctx.eventData ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.userId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.userId, 'subscription-updated', { - plan_code: this.planCode, + plan_code: ctx.planCode, quantity: 1, is_trial: true, has_ai_add_on: false, - subscriptionId: this.eventData.subscription.uuid, + subscriptionId: ctx.eventData.subscription.uuid, payment_provider: 'recurly', 'customerio-integration': true, } ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-plan-code', - this.planCode + ctx.planCode ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-state', 'active' ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-is-trial', true ) }) - it('with canceled_subscription_notification', async function () { - this.eventData.subscription.state = 'cancelled' - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with canceled_subscription_notification', async function (ctx) { + ctx.eventData.subscription.state = 'cancelled' + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'canceled_subscription_notification', - this.eventData + ctx.eventData ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.userId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.userId, 'subscription-cancelled', { - plan_code: this.planCode, + plan_code: ctx.planCode, quantity: 1, is_trial: true, has_ai_add_on: false, - subscriptionId: this.eventData.subscription.uuid, + subscriptionId: ctx.eventData.subscription.uuid, payment_provider: 'recurly', 'customerio-integration': false, } ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-state', 'cancelled' ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-is-trial', true ) }) - it('with expired_subscription_notification', async function () { - this.eventData.subscription.state = 'expired' - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with expired_subscription_notification', async function (ctx) { + ctx.eventData.subscription.state = 'expired' + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'expired_subscription_notification', - this.eventData + ctx.eventData ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.userId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.userId, 'subscription-expired', { - plan_code: this.planCode, + plan_code: ctx.planCode, quantity: 1, is_trial: true, has_ai_add_on: false, - subscriptionId: this.eventData.subscription.uuid, + subscriptionId: ctx.eventData.subscription.uuid, payment_provider: 'recurly', 'customerio-integration': false, } ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-plan-code', - this.planCode + ctx.planCode ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-state', 'expired' ) sinon.assert.calledWith( - this.AnalyticsManager.setUserPropertyForUserInBackground, - this.userId, + ctx.AnalyticsManager.setUserPropertyForUserInBackground, + ctx.userId, 'subscription-is-trial', true ) }) - it('with renewed_subscription_notification', async function () { - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with renewed_subscription_notification', async function (ctx) { + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'renewed_subscription_notification', - this.eventData + ctx.eventData ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.userId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.userId, 'subscription-renewed', { - plan_code: this.planCode, + plan_code: ctx.planCode, quantity: 1, is_trial: true, has_ai_add_on: false, - subscriptionId: this.eventData.subscription.uuid, + subscriptionId: ctx.eventData.subscription.uuid, payment_provider: 'recurly', 'customerio-integration': false, } ) }) - it('with reactivated_account_notification', async function () { - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with reactivated_account_notification', async function (ctx) { + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'reactivated_account_notification', - this.eventData + ctx.eventData ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.userId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.userId, 'subscription-reactivated', { - plan_code: this.planCode, + plan_code: ctx.planCode, quantity: 1, has_ai_add_on: false, - subscriptionId: this.eventData.subscription.uuid, + subscriptionId: ctx.eventData.subscription.uuid, payment_provider: 'recurly', 'customerio-integration': false, } ) }) - it('with paid_charge_invoice_notification', async function () { + it('with paid_charge_invoice_notification', async function (ctx) { const invoice = { invoice_number: 1234, currency: 'USD', @@ -390,18 +411,18 @@ describe('RecurlyEventHandler', function () { collection_method: 'automatic', subscription_ids: ['abcd1234', 'defa3214'], } - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'paid_charge_invoice_notification', { account: { - account_code: this.userId, + account_code: ctx.userId, }, invoice, } ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.userId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.userId, 'subscription-invoice-collected', { invoiceNumber: invoice.invoice_number, @@ -417,12 +438,12 @@ describe('RecurlyEventHandler', function () { ) }) - it('with paid_charge_invoice_notification and total_in_cents 0', async function () { - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with paid_charge_invoice_notification and total_in_cents 0', async function (ctx) { + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'paid_charge_invoice_notification', { account: { - account_code: this.userId, + account_code: ctx.userId, }, invoice: { state: 'paid', @@ -430,15 +451,15 @@ describe('RecurlyEventHandler', function () { }, } ) - sinon.assert.notCalled(this.AnalyticsManager.recordEventForUserInBackground) + sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForUserInBackground) }) - it('with closed_invoice_notification', async function () { - await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with closed_invoice_notification', async function (ctx) { + await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'closed_invoice_notification', { account: { - account_code: this.userId, + account_code: ctx.userId, }, invoice: { state: 'collected', @@ -447,18 +468,18 @@ describe('RecurlyEventHandler', function () { } ) sinon.assert.calledWith( - this.AnalyticsManager.recordEventForUserInBackground, - this.userId, + ctx.AnalyticsManager.recordEventForUserInBackground, + ctx.userId, 'subscription-invoice-collected' ) }) - it('with closed_invoice_notification and total_in_cents 0', function () { - this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + it('with closed_invoice_notification and total_in_cents 0', function (ctx) { + ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'closed_invoice_notification', { account: { - account_code: this.userId, + account_code: ctx.userId, }, invoice: { state: 'collected', @@ -466,25 +487,25 @@ describe('RecurlyEventHandler', function () { }, } ) - sinon.assert.notCalled(this.AnalyticsManager.recordEventForUserInBackground) + sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForUserInBackground) }) - it('nothing is called with invalid account code', function () { - this.eventData.account.account_code = 'foo_bar' + it('nothing is called with invalid account code', function (ctx) { + ctx.eventData.account.account_code = 'foo_bar' - this.RecurlyEventHandler.sendRecurlyAnalyticsEvent( + ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent( 'new_subscription_notification', - this.eventData + ctx.eventData ) - sinon.assert.notCalled(this.AnalyticsManager.recordEventForUserInBackground) + sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForUserInBackground) sinon.assert.notCalled( - this.AnalyticsManager.setUserPropertyForUserInBackground + ctx.AnalyticsManager.setUserPropertyForUserInBackground ) sinon.assert.notCalled( - this.AnalyticsManager.setUserPropertyForUserInBackground + ctx.AnalyticsManager.setUserPropertyForUserInBackground ) sinon.assert.notCalled( - this.AnalyticsManager.setUserPropertyForUserInBackground + ctx.AnalyticsManager.setUserPropertyForUserInBackground ) }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilder.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilder.test.mjs new file mode 100644 index 0000000000..fd9afa8f57 --- /dev/null +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilder.test.mjs @@ -0,0 +1,1064 @@ +import { vi, assert } from 'vitest' +import sinon from 'sinon' +import { + PaymentProviderAccount, + PaymentProviderSubscription, + PaymentProviderSubscriptionAddOn, + PaymentProviderSubscriptionChange, +} from '../../../../app/src/Features/Subscription/PaymentProviderEntities.js' +import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.js' + +const modulePath = + '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs' + +describe('SubscriptionViewModelBuilder', function () { + beforeEach(async function (ctx) { + ctx.user = { _id: '5208dd34438842e2db333333' } + ctx.recurlySubscription_id = '123abc456def' + ctx.planCode = 'collaborator_monthly' + ctx.planFeatures = { + compileGroup: 'priority', + collaborators: -1, + compileTimeout: 240, + } + ctx.plan = { + planCode: ctx.planCode, + features: ctx.planFeatures, + } + ctx.annualPlanCode = 'collaborator_annual' + ctx.annualPlan = { + planCode: ctx.annualPlanCode, + features: ctx.planFeatures, + } + ctx.individualSubscription = { + planCode: ctx.planCode, + plan: ctx.plan, + recurlySubscription_id: ctx.recurlySubscription_id, + recurlyStatus: { + state: 'active', + }, + } + ctx.paymentRecord = new PaymentProviderSubscription({ + id: ctx.recurlySubscription_id, + userId: ctx.user._id, + currency: 'EUR', + planCode: 'plan-code', + planName: 'plan-name', + planPrice: 13, + addOns: [ + new PaymentProviderSubscriptionAddOn({ + code: 'addon-code', + name: 'addon name', + quantity: 1, + unitPrice: 2, + }), + ], + subtotal: 15, + taxRate: 0.1, + taxAmount: 1.5, + total: 16.5, + periodStart: new Date('2025-01-20T12:00:00.000Z'), + periodEnd: new Date('2025-02-20T12:00:00.000Z'), + collectionMethod: 'automatic', + }) + + ctx.individualCustomSubscription = { + planCode: ctx.planCode, + plan: ctx.plan, + recurlySubscription_id: ctx.recurlySubscription_id, + } + + ctx.groupPlanCode = 'group_collaborator_monthly' + ctx.groupPlanFeatures = { + compileGroup: 'priority', + collaborators: 10, + compileTimeout: 240, + } + ctx.groupPlan = { + planCode: ctx.groupPlanCode, + features: ctx.groupPlanFeatures, + membersLimit: 4, + membersLimitAddOn: 'additional-license', + groupPlan: true, + } + ctx.groupSubscription = { + planCode: ctx.groupPlanCode, + plan: ctx.plan, + recurlyStatus: { + state: 'active', + }, + } + + ctx.commonsPlanCode = 'commons_license' + ctx.commonsPlanFeatures = { + compileGroup: 'priority', + collaborators: '-1', + compileTimeout: 240, + } + ctx.commonsPlan = { + planCode: ctx.commonsPlanCode, + features: ctx.commonsPlanFeatures, + } + ctx.commonsSubscription = { + planCode: ctx.commonsPlanCode, + plan: ctx.commonsPlan, + name: 'Digital Science', + } + + ctx.Settings = { + institutionPlanCode: ctx.commonsPlanCode, + } + ctx.SubscriptionLocator = { + promises: { + getUsersSubscription: sinon.stub().resolves(), + getMemberSubscriptions: sinon.stub().resolves(), + }, + getUsersSubscription: sinon.stub().yields(), + getMemberSubscriptions: sinon.stub().yields(null, []), + getManagedGroupSubscriptions: sinon.stub().yields(null, []), + findLocalPlanInSettings: sinon.stub(), + } + ctx.InstitutionsGetter = { + promises: { + getCurrentInstitutionsWithLicence: sinon.stub().resolves(), + }, + getCurrentInstitutionsWithLicence: sinon.stub().yields(null, []), + getManagedInstitutions: sinon.stub().yields(null, []), + } + ctx.InstitutionsManager = { + promises: { + fetchV1Data: sinon.stub().resolves(), + }, + } + ctx.PublishersGetter = { + promises: { + fetchV1Data: sinon.stub().resolves(), + }, + getManagedPublishers: sinon.stub().yields(null, []), + } + ctx.RecurlyWrapper = { + promises: { + getSubscription: sinon.stub().resolves(), + }, + } + ctx.SubscriptionUpdater = { + promises: { + updateSubscriptionFromRecurly: sinon.stub().resolves(), + }, + } + ctx.PlansLocator = { + findLocalPlanInSettings: sinon.stub(), + } + ctx.SplitTestHandler = { + promises: { + getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), + }, + } + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator', + () => ({ + default: ctx.SubscriptionLocator, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Institutions/InstitutionsGetter', + () => ({ + default: ctx.InstitutionsGetter, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Institutions/InstitutionsManager', + () => ({ + default: ctx.InstitutionsManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/RecurlyWrapper', + () => ({ + default: ctx.RecurlyWrapper, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionUpdater', + () => ({ + default: ctx.SubscriptionUpdater, + }) + ) + + vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({ + default: ctx.PlansLocator, + })) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: (ctx.Modules = { + promises: { hooks: { fire: sinon.stub().resolves([]) } }, + hooks: { + fire: sinon.stub().yields(null, []), + }, + }), + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/V1SubscriptionManager', + () => ({ + default: {}, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Publishers/PublishersGetter', + () => ({ + default: ctx.PublishersGetter, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionHelper', + () => ({ + default: SubscriptionHelper, + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + ctx.SubscriptionViewModelBuilder = (await import(modulePath)).default + + ctx.PlansLocator.findLocalPlanInSettings + .withArgs(ctx.planCode) + .returns(ctx.plan) + .withArgs(ctx.annualPlanCode) + .returns(ctx.annualPlan) + .withArgs(ctx.groupPlanCode) + .returns(ctx.groupPlan) + .withArgs(ctx.commonsPlanCode) + .returns(ctx.commonsPlan) + }) + + describe('getUsersSubscriptionDetails', function () { + it('should return a free plan when user has no subscription or affiliation', async function (ctx) { + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + assert.deepEqual(usersBestSubscription, { type: 'free' }) + }) + + describe('with a individual subscription only', function () { + it('should return a individual subscription when user has non-Recurly one', async function (ctx) { + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user) + .resolves(ctx.individualCustomSubscription) + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'individual', + subscription: ctx.individualCustomSubscription, + plan: ctx.plan, + remainingTrialDays: -1, + }) + }) + + it('should return a individual subscription when user has an active one', async function (ctx) { + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user) + .resolves(ctx.individualSubscription) + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'individual', + subscription: ctx.individualSubscription, + plan: ctx.plan, + remainingTrialDays: -1, + }) + }) + + it('should return a individual subscription with remaining free trial days', async function (ctx) { + const threeDaysLater = new Date() + threeDaysLater.setDate(threeDaysLater.getDate() + 3) + ctx.individualSubscription.recurlyStatus.trialEndsAt = threeDaysLater + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user) + .resolves(ctx.individualSubscription) + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'individual', + subscription: ctx.individualSubscription, + plan: ctx.plan, + remainingTrialDays: 3, + }) + }) + + it('should return a individual subscription with free trial on last day', async function (ctx) { + const threeHoursLater = new Date() + threeHoursLater.setTime(threeHoursLater.getTime() + 3 * 60 * 60 * 1000) + ctx.individualSubscription.recurlyStatus.trialEndsAt = threeHoursLater + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user) + .resolves(ctx.individualSubscription) + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'individual', + subscription: ctx.individualSubscription, + plan: ctx.plan, + remainingTrialDays: 1, + }) + }) + + it('should update subscription if recurly payment state is missing', async function (ctx) { + ctx.individualSubscriptionWithoutPaymentState = { + planCode: ctx.planCode, + plan: ctx.plan, + recurlySubscription_id: ctx.recurlySubscription_id, + } + ctx.paymentRecord = { + state: 'active', + } + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user) + .onCall(0) + .resolves(ctx.individualSubscriptionWithoutPaymentState) + .withArgs(ctx.user) + .onCall(1) + .resolves(ctx.individualSubscription) + const payment = { + subscription: ctx.paymentRecord, + account: new PaymentProviderAccount({}), + coupons: [], + } + + ctx.Modules.promises.hooks.fire + .withArgs( + 'getPaymentFromRecordPromise', + ctx.individualSubscriptionWithoutPaymentState + ) + .resolves([payment]) + ctx.Modules.promises.hooks.fire + .withArgs( + 'syncSubscription', + payment, + ctx.individualSubscriptionWithoutPaymentState + ) + .resolves([]) + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'individual', + subscription: ctx.individualSubscription, + plan: ctx.plan, + remainingTrialDays: -1, + }) + assert.isTrue( + ctx.Modules.promises.hooks.fire.withArgs( + 'getPaymentFromRecordPromise', + ctx.individualSubscriptionWithoutPaymentState + ).calledOnce + ) + }) + + it('should update subscription if stripe payment state is missing', async function (ctx) { + ctx.individualSubscriptionWithoutPaymentState = { + planCode: ctx.planCode, + plan: ctx.plan, + paymentProvider: { + subscriptionId: ctx.recurlySubscription_id, + }, + } + ctx.paymentRecord = { + state: 'active', + } + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user) + .onCall(0) + .resolves(ctx.individualSubscriptionWithoutPaymentState) + .withArgs(ctx.user) + .onCall(1) + .resolves(ctx.individualSubscription) + const payment = { + subscription: ctx.paymentRecord, + account: new PaymentProviderAccount({}), + coupons: [], + } + + ctx.Modules.promises.hooks.fire + .withArgs( + 'getPaymentFromRecordPromise', + ctx.individualSubscriptionWithoutPaymentState + ) + .resolves([payment]) + ctx.Modules.promises.hooks.fire + .withArgs( + 'syncSubscription', + payment, + ctx.individualSubscriptionWithoutPaymentState + ) + .resolves([]) + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'individual', + subscription: ctx.individualSubscription, + plan: ctx.plan, + remainingTrialDays: -1, + }) + assert.isTrue( + ctx.Modules.promises.hooks.fire.withArgs( + 'getPaymentFromRecordPromise', + ctx.individualSubscriptionWithoutPaymentState + ).calledOnce + ) + }) + }) + + it('should return a group subscription when user has one', async function (ctx) { + ctx.SubscriptionLocator.promises.getMemberSubscriptions + .withArgs(ctx.user) + .resolves([ctx.groupSubscription]) + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + assert.deepEqual(usersBestSubscription, { + type: 'group', + subscription: {}, + plan: ctx.groupPlan, + remainingTrialDays: -1, + }) + }) + + it('should return a group subscription with team name when user has one', async function (ctx) { + ctx.SubscriptionLocator.promises.getMemberSubscriptions + .withArgs(ctx.user) + .resolves([ + Object.assign({}, ctx.groupSubscription, { teamName: 'test team' }), + ]) + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + assert.deepEqual(usersBestSubscription, { + type: 'group', + subscription: { teamName: 'test team' }, + plan: ctx.groupPlan, + remainingTrialDays: -1, + }) + }) + + it('should return a commons subscription when user has an institution affiliation', async function (ctx) { + ctx.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence + .withArgs(ctx.user._id) + .resolves([ctx.commonsSubscription]) + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'commons', + subscription: ctx.commonsSubscription, + plan: ctx.commonsPlan, + }) + }) + + describe('with multiple subscriptions', function () { + beforeEach(function (ctx) { + ctx.SubscriptionLocator.promises.getUsersSubscription + .withArgs(ctx.user) + .resolves(ctx.individualSubscription) + ctx.SubscriptionLocator.promises.getMemberSubscriptions + .withArgs(ctx.user) + .resolves([ctx.groupSubscription]) + ctx.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence + .withArgs(ctx.user._id) + .resolves([ctx.commonsSubscription]) + }) + + it('should return individual when the individual subscription has the best feature set', async function (ctx) { + ctx.commonsPlan.features = { + compileGroup: 'standard', + collaborators: 1, + compileTimeout: 60, + } + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'individual', + subscription: ctx.individualSubscription, + plan: ctx.plan, + remainingTrialDays: -1, + }) + }) + + it('should return group when the group subscription has the best feature set', async function (ctx) { + ctx.plan.features = { + compileGroup: 'standard', + collaborators: 1, + compileTimeout: 60, + } + ctx.commonsPlan.features = { + compileGroup: 'standard', + collaborators: 1, + compileTimeout: 60, + } + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'group', + subscription: {}, + plan: ctx.groupPlan, + remainingTrialDays: -1, + }) + }) + + it('should return commons when the commons affiliation has the best feature set', async function (ctx) { + ctx.plan.features = { + compileGroup: 'priority', + collaborators: 5, + compileTimeout: 240, + } + ctx.groupPlan.features = { + compileGroup: 'standard', + collaborators: 1, + compileTimeout: 60, + } + ctx.commonsPlan.features = { + compileGroup: 'priority', + collaborators: -1, + compileTimeout: 240, + } + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'commons', + subscription: ctx.commonsSubscription, + plan: ctx.commonsPlan, + }) + }) + + it('should return individual with equal feature sets', async function (ctx) { + ctx.plan.features = { + compileGroup: 'priority', + collaborators: -1, + compileTimeout: 240, + } + ctx.groupPlan.features = { + compileGroup: 'priority', + collaborators: -1, + compileTimeout: 240, + } + ctx.commonsPlan.features = { + compileGroup: 'priority', + collaborators: -1, + compileTimeout: 240, + } + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'individual', + subscription: ctx.individualSubscription, + plan: ctx.plan, + remainingTrialDays: -1, + }) + }) + + it('should return group over commons with equal feature sets', async function (ctx) { + ctx.plan.features = { + compileGroup: 'standard', + collaborators: 1, + compileTimeout: 60, + } + ctx.groupPlan.features = { + compileGroup: 'priority', + collaborators: -1, + compileTimeout: 240, + } + ctx.commonsPlan.features = { + compileGroup: 'priority', + collaborators: -1, + compileTimeout: 240, + } + + const { bestSubscription: usersBestSubscription } = + await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( + ctx.user + ) + + assert.deepEqual(usersBestSubscription, { + type: 'group', + subscription: {}, + plan: ctx.groupPlan, + remainingTrialDays: -1, + }) + }) + }) + }) + + describe('buildUsersSubscriptionViewModel', function () { + beforeEach(function (ctx) { + ctx.SubscriptionLocator.getUsersSubscription.yields( + null, + ctx.individualSubscription + ) + ctx.Modules.hooks.fire + .withArgs('getPaymentFromRecord', ctx.individualSubscription) + .yields(null, [ + { + subscription: ctx.paymentRecord, + account: new PaymentProviderAccount({}), + coupons: [], + }, + ]) + }) + + describe('with a paid subscription', function () { + it('adds payment data to the personal subscription', async function (ctx) { + ctx.Modules.hooks.fire + .withArgs('getPaymentFromRecord', ctx.individualSubscription) + .yields(null, [ + { + subscription: ctx.paymentRecord, + account: new PaymentProviderAccount({ + email: 'example@example.com', + hasPastDueInvoice: false, + }), + coupons: [], + }, + ]) + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.deepEqual(result.personalSubscription.payment, { + taxRate: 0.1, + billingDetailsLink: '/user/subscription/payment/billing-details', + accountManagementLink: + '/user/subscription/payment/account-management', + additionalLicenses: 0, + addOns: [ + { + code: 'addon-code', + name: 'addon name', + quantity: 1, + unitPrice: 2, + preTaxTotal: 2, + }, + ], + totalLicenses: 0, + nextPaymentDueAt: 'February 20th, 2025 12:00 PM UTC', + nextPaymentDueDate: 'February 20th, 2025', + currency: 'EUR', + state: 'active', + trialEndsAtFormatted: null, + trialEndsAt: null, + activeCoupons: [], + accountEmail: 'example@example.com', + hasPastDueInvoice: false, + pausedAt: null, + remainingPauseCycles: null, + displayPrice: '€16.50', + planOnlyDisplayPrice: '€14.30', + addOnDisplayPricesWithoutAdditionalLicense: { + 'addon-code': '€2.20', + }, + isEligibleForGroupPlan: true, + isEligibleForPause: false, + isEligibleForDowngradeUpsell: true, + }) + }) + + describe('isEligibleForGroupPlan', function () { + it('is false for Stripe subscriptions', async function (ctx) { + ctx.paymentRecord.service = 'stripe-us' + ctx.Modules.promises.hooks.fire + .withArgs('canUpgradeFromIndividualToGroup') + .resolves([false]) + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForGroupPlan + ) + }) + + it('is false when in trial', async function (ctx) { + const msIn24Hours = 24 * 60 * 60 * 1000 + const tomorrow = new Date(Date.now() + msIn24Hours) + ctx.paymentRecord.trialPeriodEnd = tomorrow + ctx.paymentRecord.service = 'recurly' + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForGroupPlan + ) + }) + + it('is true when not in trial and for a Recurly subscription', async function (ctx) { + ctx.paymentRecord.service = 'recurly' + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isTrue( + result.personalSubscription.payment.isEligibleForGroupPlan + ) + }) + }) + + describe('isEligibleForPause', function () { + beforeEach(function (ctx) { + ctx.paymentRecord.service = 'recurly' + ctx.paymentRecord.addOns = [] + ctx.paymentRecord.planCode = 'plan-code' + ctx.paymentRecord.trialPeriodEnd = null + ctx.individualSubscription.pendingPlan = undefined + ctx.individualSubscription.groupPlan = undefined + }) + + it('is false for Stripe subscriptions when feature flag is disabled', async function (ctx) { + ctx.paymentRecord.service = 'stripe-us' + ctx.SplitTestHandler.promises.getAssignmentForUser + .withArgs(ctx.user._id, 'stripe-pause') + .resolves({ variant: 'default' }) + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is true for Stripe subscriptions when feature flag is enabled', async function (ctx) { + ctx.paymentRecord.service = 'stripe-us' + ctx.SplitTestHandler.promises.getAssignmentForUser + .withArgs(ctx.user._id, 'stripe-pause') + .resolves({ variant: 'enabled' }) + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isTrue(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is false for Stripe subscriptions with pending plan even when feature flag is enabled', async function (ctx) { + ctx.paymentRecord.service = 'stripe-us' + ctx.individualSubscription.pendingPlan = {} // anything + ctx.SplitTestHandler.promises.getAssignmentForUser + .withArgs(ctx.user._id, 'stripe-pause') + .resolves({ variant: 'enabled' }) + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is false for Stripe subscriptions with annual plan even when feature flag is enabled', async function (ctx) { + ctx.paymentRecord.service = 'stripe-us' + ctx.paymentRecord.planCode = 'collaborator-annual' + ctx.SplitTestHandler.promises.getAssignmentForUser + .withArgs(ctx.user._id, 'stripe-pause') + .resolves({ variant: 'enabled' }) + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is false for subscriptions with pending plan', async function (ctx) { + ctx.paymentRecord.service = 'recurly' + ctx.individualSubscription.pendingPlan = {} // anything + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is false for a group subscription', async function (ctx) { + ctx.paymentRecord.service = 'recurly' + ctx.individualSubscription.groupPlan = true + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is false when in trial', async function (ctx) { + ctx.paymentRecord.service = 'recurly' + const msIn24Hours = 24 * 60 * 60 * 1000 + const tomorrow = new Date(Date.now() + msIn24Hours) + ctx.paymentRecord.trialPeriodEnd = tomorrow + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is false for annual subscriptions', async function (ctx) { + ctx.paymentRecord.service = 'recurly' + ctx.paymentRecord.planCode = 'collaborator-annual' + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is false for subscriptions with add-ons', async function (ctx) { + ctx.paymentRecord.service = 'recurly' + ctx.paymentRecord.addOns = [{}] // anything + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse(result.personalSubscription.payment.isEligibleForPause) + }) + + it('is true when conditions are met', async function (ctx) { + ctx.paymentRecord.service = 'recurly' + ctx.paymentRecord.addOns = [] + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isTrue(result.personalSubscription.payment.isEligibleForPause) + }) + }) + + describe('isEligibleForDowngradeUpsell', function () { + it('is true for eligible individual subscriptions', async function (ctx) { + ctx.paymentRecord.pausePeriodStart = null + ctx.paymentRecord.remainingPauseCycles = null + ctx.paymentRecord.trialPeriodEnd = null + ctx.paymentRecord.service = 'recurly' + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isTrue( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + + it('is false for group plans', async function (ctx) { + ctx.individualSubscription.planCode = ctx.groupPlanCode + ctx.paymentRecord.pausePeriodStart = null + ctx.paymentRecord.remainingPauseCycles = null + ctx.paymentRecord.trialPeriodEnd = null + ctx.paymentRecord.service = 'recurly' + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + + it('is false for annual individual plans', async function (ctx) { + ctx.individualSubscription.planCode = ctx.annualPlanCode + ctx.paymentRecord.pausePeriodStart = null + ctx.paymentRecord.remainingPauseCycles = null + ctx.paymentRecord.trialPeriodEnd = null + ctx.paymentRecord.service = 'recurly' + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + + it('is false for paused plans', async function (ctx) { + ctx.paymentRecord.pausePeriodStart = new Date() + ctx.paymentRecord.remainingPauseCycles = 1 + ctx.paymentRecord.trialPeriodEnd = null + ctx.paymentRecord.service = 'recurly' + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + + it('is false for plans in free trial period', async function (ctx) { + ctx.paymentRecord.pausePeriodStart = null + ctx.paymentRecord.remainingPauseCycles = null + ctx.paymentRecord.trialPeriodEnd = new Date( + Date.now() + 24 * 60 * 60 * 1000 // tomorrow + ) + ctx.paymentRecord.service = 'recurly' + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + + it('is false for Stripe subscriptions', async function (ctx) { + ctx.paymentRecord.pausePeriodStart = null + ctx.paymentRecord.remainingPauseCycles = null + ctx.paymentRecord.trialPeriodEnd = null + ctx.paymentRecord.service = 'stripe-us' + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.isFalse( + result.personalSubscription.payment.isEligibleForDowngradeUpsell + ) + }) + }) + + it('includes pending changes', async function (ctx) { + ctx.paymentRecord.pendingChange = new PaymentProviderSubscriptionChange( + { + subscription: ctx.paymentRecord, + nextPlanCode: ctx.groupPlanCode, + nextPlanName: 'Group Collaborator (Annual) 4 licenses', + nextPlanPrice: 1400, + nextAddOns: [ + new PaymentProviderSubscriptionAddOn({ + code: 'additional-license', + name: 'additional license', + quantity: 8, + unitPrice: 24.4, + }), + new PaymentProviderSubscriptionAddOn({ + code: 'addon-code', + name: 'addon name', + quantity: 1, + unitPrice: 2, + }), + ], + } + ) + ctx.Modules.hooks.fire + .withArgs('getPaymentFromRecord', ctx.individualSubscription) + .yields(null, [ + { + subscription: ctx.paymentRecord, + account: {}, + coupons: [], + }, + ]) + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.equal( + result.personalSubscription.payment.displayPrice, + '€1,756.92' + ) + assert.equal( + result.personalSubscription.payment.planOnlyDisplayPrice, + '€1,754.72' + ) + assert.deepEqual( + result.personalSubscription.payment + .addOnDisplayPricesWithoutAdditionalLicense, + { 'addon-code': '€2.20' } + ) + assert.equal( + result.personalSubscription.payment.pendingAdditionalLicenses, + 8 + ) + assert.equal( + result.personalSubscription.payment.pendingTotalLicenses, + 12 + ) + }) + + it('does not add a billing details link for a Stripe subscription', async function (ctx) { + ctx.paymentRecord.service = 'stripe-us' + ctx.Modules.hooks.fire + .withArgs('getPaymentFromRecord', ctx.individualSubscription) + .yields(null, [ + { + subscription: ctx.paymentRecord, + account: new PaymentProviderAccount({}), + coupons: [], + }, + ]) + const result = + await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( + ctx.user + ) + assert.equal( + result.personalSubscription.payment.billingDetailsLink, + undefined + ) + assert.equal( + result.personalSubscription.payment.accountManagementLink, + '/user/subscription/payment/account-management' + ) + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js deleted file mode 100644 index 38c34062ca..0000000000 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js +++ /dev/null @@ -1,1003 +0,0 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const { assert } = require('chai') -const { - PaymentProviderAccount, - PaymentProviderSubscription, - PaymentProviderSubscriptionAddOn, - PaymentProviderSubscriptionChange, -} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities') -const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper') - -const modulePath = - '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder' - -describe('SubscriptionViewModelBuilder', function () { - beforeEach(function () { - this.user = { _id: '5208dd34438842e2db333333' } - this.recurlySubscription_id = '123abc456def' - this.planCode = 'collaborator_monthly' - this.planFeatures = { - compileGroup: 'priority', - collaborators: -1, - compileTimeout: 240, - } - this.plan = { - planCode: this.planCode, - features: this.planFeatures, - } - this.annualPlanCode = 'collaborator_annual' - this.annualPlan = { - planCode: this.annualPlanCode, - features: this.planFeatures, - } - this.individualSubscription = { - planCode: this.planCode, - plan: this.plan, - recurlySubscription_id: this.recurlySubscription_id, - recurlyStatus: { - state: 'active', - }, - } - this.paymentRecord = new PaymentProviderSubscription({ - id: this.recurlySubscription_id, - userId: this.user._id, - currency: 'EUR', - planCode: 'plan-code', - planName: 'plan-name', - planPrice: 13, - addOns: [ - new PaymentProviderSubscriptionAddOn({ - code: 'addon-code', - name: 'addon name', - quantity: 1, - unitPrice: 2, - }), - ], - subtotal: 15, - taxRate: 0.1, - taxAmount: 1.5, - total: 16.5, - periodStart: new Date('2025-01-20T12:00:00.000Z'), - periodEnd: new Date('2025-02-20T12:00:00.000Z'), - collectionMethod: 'automatic', - }) - - this.individualCustomSubscription = { - planCode: this.planCode, - plan: this.plan, - recurlySubscription_id: this.recurlySubscription_id, - } - - this.groupPlanCode = 'group_collaborator_monthly' - this.groupPlanFeatures = { - compileGroup: 'priority', - collaborators: 10, - compileTimeout: 240, - } - this.groupPlan = { - planCode: this.groupPlanCode, - features: this.groupPlanFeatures, - membersLimit: 4, - membersLimitAddOn: 'additional-license', - groupPlan: true, - } - this.groupSubscription = { - planCode: this.groupPlanCode, - plan: this.plan, - recurlyStatus: { - state: 'active', - }, - } - - this.commonsPlanCode = 'commons_license' - this.commonsPlanFeatures = { - compileGroup: 'priority', - collaborators: '-1', - compileTimeout: 240, - } - this.commonsPlan = { - planCode: this.commonsPlanCode, - features: this.commonsPlanFeatures, - } - this.commonsSubscription = { - planCode: this.commonsPlanCode, - plan: this.commonsPlan, - name: 'Digital Science', - } - - this.Settings = { - institutionPlanCode: this.commonsPlanCode, - } - this.SubscriptionLocator = { - promises: { - getUsersSubscription: sinon.stub().resolves(), - getMemberSubscriptions: sinon.stub().resolves(), - }, - getUsersSubscription: sinon.stub().yields(), - getMemberSubscriptions: sinon.stub().yields(null, []), - getManagedGroupSubscriptions: sinon.stub().yields(null, []), - findLocalPlanInSettings: sinon.stub(), - } - this.InstitutionsGetter = { - promises: { - getCurrentInstitutionsWithLicence: sinon.stub().resolves(), - }, - getCurrentInstitutionsWithLicence: sinon.stub().yields(null, []), - getManagedInstitutions: sinon.stub().yields(null, []), - } - this.InstitutionsManager = { - promises: { - fetchV1Data: sinon.stub().resolves(), - }, - } - this.PublishersGetter = { - promises: { - fetchV1Data: sinon.stub().resolves(), - }, - getManagedPublishers: sinon.stub().yields(null, []), - } - this.RecurlyWrapper = { - promises: { - getSubscription: sinon.stub().resolves(), - }, - } - this.SubscriptionUpdater = { - promises: { - updateSubscriptionFromRecurly: sinon.stub().resolves(), - }, - } - this.PlansLocator = { - findLocalPlanInSettings: sinon.stub(), - } - this.SplitTestHandler = { - promises: { - getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), - }, - } - this.SubscriptionViewModelBuilder = SandboxedModule.require(modulePath, { - requires: { - '@overleaf/settings': this.Settings, - './SubscriptionLocator': this.SubscriptionLocator, - '../Institutions/InstitutionsGetter': this.InstitutionsGetter, - '../Institutions/InstitutionsManager': this.InstitutionsManager, - './RecurlyWrapper': this.RecurlyWrapper, - './SubscriptionUpdater': this.SubscriptionUpdater, - './PlansLocator': this.PlansLocator, - '../../infrastructure/Modules': (this.Modules = { - promises: { hooks: { fire: sinon.stub().resolves([]) } }, - hooks: { - fire: sinon.stub().yields(null, []), - }, - }), - './V1SubscriptionManager': {}, - '../Publishers/PublishersGetter': this.PublishersGetter, - './SubscriptionHelper': SubscriptionHelper, - '../SplitTests/SplitTestHandler': this.SplitTestHandler, - }, - }) - - this.PlansLocator.findLocalPlanInSettings - .withArgs(this.planCode) - .returns(this.plan) - .withArgs(this.annualPlanCode) - .returns(this.annualPlan) - .withArgs(this.groupPlanCode) - .returns(this.groupPlan) - .withArgs(this.commonsPlanCode) - .returns(this.commonsPlan) - }) - - describe('getUsersSubscriptionDetails', function () { - it('should return a free plan when user has no subscription or affiliation', async function () { - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - assert.deepEqual(usersBestSubscription, { type: 'free' }) - }) - - describe('with a individual subscription only', function () { - it('should return a individual subscription when user has non-Recurly one', async function () { - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user) - .resolves(this.individualCustomSubscription) - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'individual', - subscription: this.individualCustomSubscription, - plan: this.plan, - remainingTrialDays: -1, - }) - }) - - it('should return a individual subscription when user has an active one', async function () { - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user) - .resolves(this.individualSubscription) - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'individual', - subscription: this.individualSubscription, - plan: this.plan, - remainingTrialDays: -1, - }) - }) - - it('should return a individual subscription with remaining free trial days', async function () { - const threeDaysLater = new Date() - threeDaysLater.setDate(threeDaysLater.getDate() + 3) - this.individualSubscription.recurlyStatus.trialEndsAt = threeDaysLater - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user) - .resolves(this.individualSubscription) - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'individual', - subscription: this.individualSubscription, - plan: this.plan, - remainingTrialDays: 3, - }) - }) - - it('should return a individual subscription with free trial on last day', async function () { - const threeHoursLater = new Date() - threeHoursLater.setTime(threeHoursLater.getTime() + 3 * 60 * 60 * 1000) - this.individualSubscription.recurlyStatus.trialEndsAt = threeHoursLater - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user) - .resolves(this.individualSubscription) - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'individual', - subscription: this.individualSubscription, - plan: this.plan, - remainingTrialDays: 1, - }) - }) - - it('should update subscription if recurly payment state is missing', async function () { - this.individualSubscriptionWithoutPaymentState = { - planCode: this.planCode, - plan: this.plan, - recurlySubscription_id: this.recurlySubscription_id, - } - this.paymentRecord = { - state: 'active', - } - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user) - .onCall(0) - .resolves(this.individualSubscriptionWithoutPaymentState) - .withArgs(this.user) - .onCall(1) - .resolves(this.individualSubscription) - const payment = { - subscription: this.paymentRecord, - account: new PaymentProviderAccount({}), - coupons: [], - } - - this.Modules.promises.hooks.fire - .withArgs( - 'getPaymentFromRecordPromise', - this.individualSubscriptionWithoutPaymentState - ) - .resolves([payment]) - this.Modules.promises.hooks.fire - .withArgs( - 'syncSubscription', - payment, - this.individualSubscriptionWithoutPaymentState - ) - .resolves([]) - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'individual', - subscription: this.individualSubscription, - plan: this.plan, - remainingTrialDays: -1, - }) - assert.isTrue( - this.Modules.promises.hooks.fire.withArgs( - 'getPaymentFromRecordPromise', - this.individualSubscriptionWithoutPaymentState - ).calledOnce - ) - }) - - it('should update subscription if stripe payment state is missing', async function () { - this.individualSubscriptionWithoutPaymentState = { - planCode: this.planCode, - plan: this.plan, - paymentProvider: { - subscriptionId: this.recurlySubscription_id, - }, - } - this.paymentRecord = { - state: 'active', - } - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user) - .onCall(0) - .resolves(this.individualSubscriptionWithoutPaymentState) - .withArgs(this.user) - .onCall(1) - .resolves(this.individualSubscription) - const payment = { - subscription: this.paymentRecord, - account: new PaymentProviderAccount({}), - coupons: [], - } - - this.Modules.promises.hooks.fire - .withArgs( - 'getPaymentFromRecordPromise', - this.individualSubscriptionWithoutPaymentState - ) - .resolves([payment]) - this.Modules.promises.hooks.fire - .withArgs( - 'syncSubscription', - payment, - this.individualSubscriptionWithoutPaymentState - ) - .resolves([]) - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'individual', - subscription: this.individualSubscription, - plan: this.plan, - remainingTrialDays: -1, - }) - assert.isTrue( - this.Modules.promises.hooks.fire.withArgs( - 'getPaymentFromRecordPromise', - this.individualSubscriptionWithoutPaymentState - ).calledOnce - ) - }) - }) - - it('should return a group subscription when user has one', async function () { - this.SubscriptionLocator.promises.getMemberSubscriptions - .withArgs(this.user) - .resolves([this.groupSubscription]) - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - assert.deepEqual(usersBestSubscription, { - type: 'group', - subscription: {}, - plan: this.groupPlan, - remainingTrialDays: -1, - }) - }) - - it('should return a group subscription with team name when user has one', async function () { - this.SubscriptionLocator.promises.getMemberSubscriptions - .withArgs(this.user) - .resolves([ - Object.assign({}, this.groupSubscription, { teamName: 'test team' }), - ]) - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - assert.deepEqual(usersBestSubscription, { - type: 'group', - subscription: { teamName: 'test team' }, - plan: this.groupPlan, - remainingTrialDays: -1, - }) - }) - - it('should return a commons subscription when user has an institution affiliation', async function () { - this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence - .withArgs(this.user._id) - .resolves([this.commonsSubscription]) - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'commons', - subscription: this.commonsSubscription, - plan: this.commonsPlan, - }) - }) - - describe('with multiple subscriptions', function () { - beforeEach(function () { - this.SubscriptionLocator.promises.getUsersSubscription - .withArgs(this.user) - .resolves(this.individualSubscription) - this.SubscriptionLocator.promises.getMemberSubscriptions - .withArgs(this.user) - .resolves([this.groupSubscription]) - this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence - .withArgs(this.user._id) - .resolves([this.commonsSubscription]) - }) - - it('should return individual when the individual subscription has the best feature set', async function () { - this.commonsPlan.features = { - compileGroup: 'standard', - collaborators: 1, - compileTimeout: 60, - } - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'individual', - subscription: this.individualSubscription, - plan: this.plan, - remainingTrialDays: -1, - }) - }) - - it('should return group when the group subscription has the best feature set', async function () { - this.plan.features = { - compileGroup: 'standard', - collaborators: 1, - compileTimeout: 60, - } - this.commonsPlan.features = { - compileGroup: 'standard', - collaborators: 1, - compileTimeout: 60, - } - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'group', - subscription: {}, - plan: this.groupPlan, - remainingTrialDays: -1, - }) - }) - - it('should return commons when the commons affiliation has the best feature set', async function () { - this.plan.features = { - compileGroup: 'priority', - collaborators: 5, - compileTimeout: 240, - } - this.groupPlan.features = { - compileGroup: 'standard', - collaborators: 1, - compileTimeout: 60, - } - this.commonsPlan.features = { - compileGroup: 'priority', - collaborators: -1, - compileTimeout: 240, - } - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'commons', - subscription: this.commonsSubscription, - plan: this.commonsPlan, - }) - }) - - it('should return individual with equal feature sets', async function () { - this.plan.features = { - compileGroup: 'priority', - collaborators: -1, - compileTimeout: 240, - } - this.groupPlan.features = { - compileGroup: 'priority', - collaborators: -1, - compileTimeout: 240, - } - this.commonsPlan.features = { - compileGroup: 'priority', - collaborators: -1, - compileTimeout: 240, - } - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'individual', - subscription: this.individualSubscription, - plan: this.plan, - remainingTrialDays: -1, - }) - }) - - it('should return group over commons with equal feature sets', async function () { - this.plan.features = { - compileGroup: 'standard', - collaborators: 1, - compileTimeout: 60, - } - this.groupPlan.features = { - compileGroup: 'priority', - collaborators: -1, - compileTimeout: 240, - } - this.commonsPlan.features = { - compileGroup: 'priority', - collaborators: -1, - compileTimeout: 240, - } - - const { bestSubscription: usersBestSubscription } = - await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails( - this.user - ) - - assert.deepEqual(usersBestSubscription, { - type: 'group', - subscription: {}, - plan: this.groupPlan, - remainingTrialDays: -1, - }) - }) - }) - }) - - describe('buildUsersSubscriptionViewModel', function () { - beforeEach(function () { - this.SubscriptionLocator.getUsersSubscription.yields( - null, - this.individualSubscription - ) - this.Modules.hooks.fire - .withArgs('getPaymentFromRecord', this.individualSubscription) - .yields(null, [ - { - subscription: this.paymentRecord, - account: new PaymentProviderAccount({}), - coupons: [], - }, - ]) - }) - - describe('with a paid subscription', function () { - it('adds payment data to the personal subscription', async function () { - this.Modules.hooks.fire - .withArgs('getPaymentFromRecord', this.individualSubscription) - .yields(null, [ - { - subscription: this.paymentRecord, - account: new PaymentProviderAccount({ - email: 'example@example.com', - hasPastDueInvoice: false, - }), - coupons: [], - }, - ]) - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.deepEqual(result.personalSubscription.payment, { - taxRate: 0.1, - billingDetailsLink: '/user/subscription/payment/billing-details', - accountManagementLink: - '/user/subscription/payment/account-management', - additionalLicenses: 0, - addOns: [ - { - code: 'addon-code', - name: 'addon name', - quantity: 1, - unitPrice: 2, - preTaxTotal: 2, - }, - ], - totalLicenses: 0, - nextPaymentDueAt: 'February 20th, 2025 12:00 PM UTC', - nextPaymentDueDate: 'February 20th, 2025', - currency: 'EUR', - state: 'active', - trialEndsAtFormatted: null, - trialEndsAt: null, - activeCoupons: [], - accountEmail: 'example@example.com', - hasPastDueInvoice: false, - pausedAt: null, - remainingPauseCycles: null, - displayPrice: '€16.50', - planOnlyDisplayPrice: '€14.30', - addOnDisplayPricesWithoutAdditionalLicense: { - 'addon-code': '€2.20', - }, - isEligibleForGroupPlan: true, - isEligibleForPause: false, - isEligibleForDowngradeUpsell: true, - }) - }) - - describe('isEligibleForGroupPlan', function () { - it('is false for Stripe subscriptions', async function () { - this.paymentRecord.service = 'stripe-us' - this.Modules.promises.hooks.fire - .withArgs('canUpgradeFromIndividualToGroup') - .resolves([false]) - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse( - result.personalSubscription.payment.isEligibleForGroupPlan - ) - }) - - it('is false when in trial', async function () { - const msIn24Hours = 24 * 60 * 60 * 1000 - const tomorrow = new Date(Date.now() + msIn24Hours) - this.paymentRecord.trialPeriodEnd = tomorrow - this.paymentRecord.service = 'recurly' - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse( - result.personalSubscription.payment.isEligibleForGroupPlan - ) - }) - - it('is true when not in trial and for a Recurly subscription', async function () { - this.paymentRecord.service = 'recurly' - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isTrue( - result.personalSubscription.payment.isEligibleForGroupPlan - ) - }) - }) - - describe('isEligibleForPause', function () { - beforeEach(function () { - this.paymentRecord.service = 'recurly' - this.paymentRecord.addOns = [] - this.paymentRecord.planCode = 'plan-code' - this.paymentRecord.trialPeriodEnd = null - this.individualSubscription.pendingPlan = undefined - this.individualSubscription.groupPlan = undefined - }) - - it('is false for Stripe subscriptions when feature flag is disabled', async function () { - this.paymentRecord.service = 'stripe-us' - this.SplitTestHandler.promises.getAssignmentForUser - .withArgs(this.user._id, 'stripe-pause') - .resolves({ variant: 'default' }) - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse(result.personalSubscription.payment.isEligibleForPause) - }) - - it('is true for Stripe subscriptions when feature flag is enabled', async function () { - this.paymentRecord.service = 'stripe-us' - this.SplitTestHandler.promises.getAssignmentForUser - .withArgs(this.user._id, 'stripe-pause') - .resolves({ variant: 'enabled' }) - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isTrue(result.personalSubscription.payment.isEligibleForPause) - }) - - it('is false for Stripe subscriptions with pending plan even when feature flag is enabled', async function () { - this.paymentRecord.service = 'stripe-us' - this.individualSubscription.pendingPlan = {} // anything - this.SplitTestHandler.promises.getAssignmentForUser - .withArgs(this.user._id, 'stripe-pause') - .resolves({ variant: 'enabled' }) - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse(result.personalSubscription.payment.isEligibleForPause) - }) - - it('is false for Stripe subscriptions with annual plan even when feature flag is enabled', async function () { - this.paymentRecord.service = 'stripe-us' - this.paymentRecord.planCode = 'collaborator-annual' - this.SplitTestHandler.promises.getAssignmentForUser - .withArgs(this.user._id, 'stripe-pause') - .resolves({ variant: 'enabled' }) - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse(result.personalSubscription.payment.isEligibleForPause) - }) - - it('is false for subscriptions with pending plan', async function () { - this.paymentRecord.service = 'recurly' - this.individualSubscription.pendingPlan = {} // anything - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse(result.personalSubscription.payment.isEligibleForPause) - }) - - it('is false for a group subscription', async function () { - this.paymentRecord.service = 'recurly' - this.individualSubscription.groupPlan = true - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse(result.personalSubscription.payment.isEligibleForPause) - }) - - it('is false when in trial', async function () { - this.paymentRecord.service = 'recurly' - const msIn24Hours = 24 * 60 * 60 * 1000 - const tomorrow = new Date(Date.now() + msIn24Hours) - this.paymentRecord.trialPeriodEnd = tomorrow - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse(result.personalSubscription.payment.isEligibleForPause) - }) - - it('is false for annual subscriptions', async function () { - this.paymentRecord.service = 'recurly' - this.paymentRecord.planCode = 'collaborator-annual' - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse(result.personalSubscription.payment.isEligibleForPause) - }) - - it('is false for subscriptions with add-ons', async function () { - this.paymentRecord.service = 'recurly' - this.paymentRecord.addOns = [{}] // anything - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse(result.personalSubscription.payment.isEligibleForPause) - }) - - it('is true when conditions are met', async function () { - this.paymentRecord.service = 'recurly' - this.paymentRecord.addOns = [] - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isTrue(result.personalSubscription.payment.isEligibleForPause) - }) - }) - - describe('isEligibleForDowngradeUpsell', function () { - it('is true for eligible individual subscriptions', async function () { - this.paymentRecord.pausePeriodStart = null - this.paymentRecord.remainingPauseCycles = null - this.paymentRecord.trialPeriodEnd = null - this.paymentRecord.service = 'recurly' - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isTrue( - result.personalSubscription.payment.isEligibleForDowngradeUpsell - ) - }) - - it('is false for group plans', async function () { - this.individualSubscription.planCode = this.groupPlanCode - this.paymentRecord.pausePeriodStart = null - this.paymentRecord.remainingPauseCycles = null - this.paymentRecord.trialPeriodEnd = null - this.paymentRecord.service = 'recurly' - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse( - result.personalSubscription.payment.isEligibleForDowngradeUpsell - ) - }) - - it('is false for annual individual plans', async function () { - this.individualSubscription.planCode = this.annualPlanCode - this.paymentRecord.pausePeriodStart = null - this.paymentRecord.remainingPauseCycles = null - this.paymentRecord.trialPeriodEnd = null - this.paymentRecord.service = 'recurly' - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse( - result.personalSubscription.payment.isEligibleForDowngradeUpsell - ) - }) - - it('is false for paused plans', async function () { - this.paymentRecord.pausePeriodStart = new Date() - this.paymentRecord.remainingPauseCycles = 1 - this.paymentRecord.trialPeriodEnd = null - this.paymentRecord.service = 'recurly' - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse( - result.personalSubscription.payment.isEligibleForDowngradeUpsell - ) - }) - - it('is false for plans in free trial period', async function () { - this.paymentRecord.pausePeriodStart = null - this.paymentRecord.remainingPauseCycles = null - this.paymentRecord.trialPeriodEnd = new Date( - Date.now() + 24 * 60 * 60 * 1000 // tomorrow - ) - this.paymentRecord.service = 'recurly' - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse( - result.personalSubscription.payment.isEligibleForDowngradeUpsell - ) - }) - - it('is false for Stripe subscriptions', async function () { - this.paymentRecord.pausePeriodStart = null - this.paymentRecord.remainingPauseCycles = null - this.paymentRecord.trialPeriodEnd = null - this.paymentRecord.service = 'stripe-us' - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.isFalse( - result.personalSubscription.payment.isEligibleForDowngradeUpsell - ) - }) - }) - - it('includes pending changes', async function () { - this.paymentRecord.pendingChange = - new PaymentProviderSubscriptionChange({ - subscription: this.paymentRecord, - nextPlanCode: this.groupPlanCode, - nextPlanName: 'Group Collaborator (Annual) 4 licenses', - nextPlanPrice: 1400, - nextAddOns: [ - new PaymentProviderSubscriptionAddOn({ - code: 'additional-license', - name: 'additional license', - quantity: 8, - unitPrice: 24.4, - }), - new PaymentProviderSubscriptionAddOn({ - code: 'addon-code', - name: 'addon name', - quantity: 1, - unitPrice: 2, - }), - ], - }) - this.Modules.hooks.fire - .withArgs('getPaymentFromRecord', this.individualSubscription) - .yields(null, [ - { - subscription: this.paymentRecord, - account: {}, - coupons: [], - }, - ]) - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.equal( - result.personalSubscription.payment.displayPrice, - '€1,756.92' - ) - assert.equal( - result.personalSubscription.payment.planOnlyDisplayPrice, - '€1,754.72' - ) - assert.deepEqual( - result.personalSubscription.payment - .addOnDisplayPricesWithoutAdditionalLicense, - { 'addon-code': '€2.20' } - ) - assert.equal( - result.personalSubscription.payment.pendingAdditionalLicenses, - 8 - ) - assert.equal( - result.personalSubscription.payment.pendingTotalLicenses, - 12 - ) - }) - - it('does not add a billing details link for a Stripe subscription', async function () { - this.paymentRecord.service = 'stripe-us' - this.Modules.hooks.fire - .withArgs('getPaymentFromRecord', this.individualSubscription) - .yields(null, [ - { - subscription: this.paymentRecord, - account: new PaymentProviderAccount({}), - coupons: [], - }, - ]) - const result = - await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( - this.user - ) - assert.equal( - result.personalSubscription.payment.billingDetailsLink, - undefined - ) - assert.equal( - result.personalSubscription.payment.accountManagementLink, - '/user/subscription/payment/account-management' - ) - }) - }) - }) -}) diff --git a/services/web/test/unit/src/Templates/TemplatesController.test.mjs b/services/web/test/unit/src/Templates/TemplatesController.test.mjs new file mode 100644 index 0000000000..e4323ab1de --- /dev/null +++ b/services/web/test/unit/src/Templates/TemplatesController.test.mjs @@ -0,0 +1,126 @@ +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import ProjectHelper from '../../../../app/src/Features/Project/ProjectHelper.js' + +const modulePath = + '../../../../app/src/Features/Templates/TemplatesController.mjs' + +describe('TemplatesController', function () { + beforeEach(async function (ctx) { + ctx.user_id = 'user-id' + + vi.doMock('../../../../app/src/Features/Project/ProjectHelper', () => ({ + default: ProjectHelper, + })) + + vi.doMock( + '../../../../app/src/Features/Authentication/AuthenticationController', + () => ({ + default: (ctx.AuthenticationController = { + getLoggedInUserId: sinon.stub().returns(ctx.user_id), + }), + }) + ) + + vi.doMock( + '../../../../app/src/Features/Templates/TemplatesManager', + () => ({ + default: (ctx.TemplatesManager = { + promises: { createProjectFromV1Template: sinon.stub() }, + }), + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: (ctx.SplitTestHandler = { + promises: { + getAssignment: sinon.stub().resolves({ variant: 'default' }), + }, + }), + }) + ) + + ctx.TemplatesController = (await import(modulePath)).default + ctx.next = sinon.stub() + ctx.req = { + body: { + brandVariationId: 'brand-variation-id', + compiler: 'compiler', + mainFile: 'main-file', + templateId: 'template-id', + templateName: 'template-name', + templateVersionId: 'template-version-id', + }, + session: { + templateData: 'template-data', + user: { + _id: ctx.user_id, + }, + }, + } + return (ctx.res = { redirect: sinon.stub() }) + }) + + describe('createProjectFromV1Template', function () { + describe('on success', function () { + beforeEach(function (ctx) { + ctx.project = { _id: 'project-id' } + ctx.TemplatesManager.promises.createProjectFromV1Template.resolves( + ctx.project + ) + return ctx.TemplatesController.createProjectFromV1Template( + ctx.req, + ctx.res, + ctx.next + ) + }) + + it('should call TemplatesManager', function (ctx) { + return ctx.TemplatesManager.promises.createProjectFromV1Template.should.have.been.calledWithMatch( + 'brand-variation-id', + 'compiler', + 'main-file', + 'template-id', + 'template-name', + 'template-version-id', + 'user-id' + ) + }) + + it('should redirect to project', function (ctx) { + return ctx.res.redirect.should.have.been.calledWith( + '/project/project-id' + ) + }) + + it('should delete session', function (ctx) { + return expect(ctx.req.session.templateData).to.be.undefined + }) + }) + + describe('on error', function () { + beforeEach(function (ctx) { + ctx.TemplatesManager.promises.createProjectFromV1Template.rejects( + 'error' + ) + return ctx.TemplatesController.createProjectFromV1Template( + ctx.req, + ctx.res, + ctx.next + ) + }) + + it('should call next with error', function (ctx) { + return ctx.next.should.have.been.calledWithMatch( + sinon.match.instanceOf(Error) + ) + }) + + it('should not redirect', function (ctx) { + return ctx.res.redirect.called.should.equal(false) + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Templates/TemplatesControllerTests.js b/services/web/test/unit/src/Templates/TemplatesControllerTests.js deleted file mode 100644 index 282f3121f9..0000000000 --- a/services/web/test/unit/src/Templates/TemplatesControllerTests.js +++ /dev/null @@ -1,108 +0,0 @@ -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const sinon = require('sinon') -const ProjectHelper = require('../../../../app/src/Features/Project/ProjectHelper') - -const modulePath = '../../../../app/src/Features/Templates/TemplatesController' - -describe('TemplatesController', function () { - beforeEach(function () { - this.user_id = 'user-id' - this.TemplatesController = SandboxedModule.require(modulePath, { - requires: { - '../Project/ProjectHelper': ProjectHelper, - '../Authentication/AuthenticationController': - (this.AuthenticationController = { - getLoggedInUserId: sinon.stub().returns(this.user_id), - }), - './TemplatesManager': (this.TemplatesManager = { - promises: { createProjectFromV1Template: sinon.stub() }, - }), - '../SplitTests/SplitTestHandler': (this.SplitTestHandler = { - promises: { - getAssignment: sinon.stub().resolves({ variant: 'default' }), - }, - }), - }, - }) - this.next = sinon.stub() - this.req = { - body: { - brandVariationId: 'brand-variation-id', - compiler: 'compiler', - mainFile: 'main-file', - templateId: 'template-id', - templateName: 'template-name', - templateVersionId: 'template-version-id', - }, - session: { - templateData: 'template-data', - user: { - _id: this.user_id, - }, - }, - } - return (this.res = { redirect: sinon.stub() }) - }) - - describe('createProjectFromV1Template', function () { - describe('on success', function () { - beforeEach(function () { - this.project = { _id: 'project-id' } - this.TemplatesManager.promises.createProjectFromV1Template.resolves( - this.project - ) - return this.TemplatesController.createProjectFromV1Template( - this.req, - this.res, - this.next - ) - }) - - it('should call TemplatesManager', function () { - return this.TemplatesManager.promises.createProjectFromV1Template.should.have.been.calledWithMatch( - 'brand-variation-id', - 'compiler', - 'main-file', - 'template-id', - 'template-name', - 'template-version-id', - 'user-id' - ) - }) - - it('should redirect to project', function () { - return this.res.redirect.should.have.been.calledWith( - '/project/project-id' - ) - }) - - it('should delete session', function () { - return expect(this.req.session.templateData).to.be.undefined - }) - }) - - describe('on error', function () { - beforeEach(function () { - this.TemplatesManager.promises.createProjectFromV1Template.rejects( - 'error' - ) - return this.TemplatesController.createProjectFromV1Template( - this.req, - this.res, - this.next - ) - }) - - it('should call next with error', function () { - return this.next.should.have.been.calledWithMatch( - sinon.match.instanceOf(Error) - ) - }) - - it('should not redirect', function () { - return this.res.redirect.called.should.equal(false) - }) - }) - }) -})