From 6b663a850997750bde4ffed2d3a67e0c4701bd71 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Tue, 23 Sep 2025 11:20:07 +0200 Subject: [PATCH] Merge pull request #28544 from overleaf/ac-some-web-esm-migration-4 [web] Convert some Features files to ES modules (part 4) GitOrigin-RevId: cf11a7584e39c4d4de08e2f924240e488a4066c4 --- .../app/src/Features/Chat/ChatController.mjs | 2 +- .../Chat/{ChatManager.js => ChatManager.mjs} | 8 +- .../Features/Compile/CompileController.mjs | 2 +- .../{CompileManager.js => CompileManager.mjs} | 27 +- .../src/Features/Errors/ErrorController.mjs | 2 +- .../src/Features/History/RestoreManager.mjs | 2 +- .../LinkedFiles/LinkedFilesController.mjs | 32 +- ...edFilesErrors.js => LinkedFilesErrors.mjs} | 4 +- .../LinkedFiles/LinkedFilesHandler.mjs | 9 +- .../Features/LinkedFiles/ProjectFileAgent.mjs | 10 +- .../LinkedFiles/ProjectOutputFileAgent.mjs | 11 +- .../app/src/Features/LinkedFiles/UrlAgent.mjs | 4 +- .../Features/Project/ProjectController.mjs | 6 +- .../Project/ProjectListController.mjs | 2 +- .../{SamlLogHandler.js => SamlLogHandler.mjs} | 16 +- .../Features/ServerAdmin/AdminController.mjs | 2 +- ...SpellingHandler.js => SpellingHandler.mjs} | 12 +- ...ntroller.js => SubscriptionController.mjs} | 76 +- .../Subscription/SubscriptionGroupHandler.mjs | 2 +- .../Subscription/SubscriptionRouter.mjs | 2 +- .../SystemMessageController.mjs | 2 +- ...ageManager.js => SystemMessageManager.mjs} | 12 +- ...TemplatesRouter.js => TemplatesRouter.mjs} | 14 +- .../ThirdPartyDataStore/TpdsController.mjs | 2 +- .../ThirdPartyDataStore/TpdsUpdateHandler.mjs | 2 +- .../{UpdateMerger.js => UpdateMerger.mjs} | 24 +- .../Features/Tutorial/TutorialController.mjs | 2 +- ...TutorialHandler.js => TutorialHandler.mjs} | 4 +- services/web/app/src/router.mjs | 4 +- .../acceptance/src/mocks/MockRecurlyApi.mjs | 2 +- .../unit/src/Chat/ChatController.test.mjs | 2 +- .../test/unit/src/Chat/ChatManager.test.mjs | 129 ++ .../test/unit/src/Chat/ChatManagerTests.js | 131 -- .../unit/src/Compile/CompileManager.test.mjs | 457 +++++ .../unit/src/Compile/CompileManagerTests.js | 426 ----- .../src/Editor/EditorHttpController.test.mjs | 9 +- ...andlerTests.js => SamlLogHandler.test.mjs} | 44 +- .../SubscriptionController.test.mjs | 1560 +++++++++++++++++ .../SubscriptionControllerTests.js | 1453 --------------- .../SystemMessageManager.test.mjs | 50 + .../SystemMessageManagerTests.js | 50 - .../ThirdPartyDataStore/UpdateMerger.test.mjs | 412 +++++ .../ThirdPartyDataStore/UpdateMergerTests.js | 387 ---- ...ndlerTests.js => TutorialHandler.test.mjs} | 39 +- 44 files changed, 2806 insertions(+), 2642 deletions(-) rename services/web/app/src/Features/Chat/{ChatManager.js => ChatManager.mjs} (84%) rename services/web/app/src/Features/Compile/{CompileManager.js => CompileManager.mjs} (91%) rename services/web/app/src/Features/LinkedFiles/{LinkedFilesErrors.js => LinkedFilesErrors.mjs} (93%) rename services/web/app/src/Features/SamlLog/{SamlLogHandler.js => SamlLogHandler.mjs} (82%) rename services/web/app/src/Features/Spelling/{SpellingHandler.js => SpellingHandler.mjs} (67%) rename services/web/app/src/Features/Subscription/{SubscriptionController.js => SubscriptionController.mjs} (94%) rename services/web/app/src/Features/SystemMessages/{SystemMessageManager.js => SystemMessageManager.mjs} (80%) rename services/web/app/src/Features/Templates/{TemplatesRouter.js => TemplatesRouter.mjs} (62%) rename services/web/app/src/Features/ThirdPartyDataStore/{UpdateMerger.js => UpdateMerger.mjs} (90%) rename services/web/app/src/Features/Tutorial/{TutorialHandler.js => TutorialHandler.mjs} (93%) create mode 100644 services/web/test/unit/src/Chat/ChatManager.test.mjs delete mode 100644 services/web/test/unit/src/Chat/ChatManagerTests.js create mode 100644 services/web/test/unit/src/Compile/CompileManager.test.mjs delete mode 100644 services/web/test/unit/src/Compile/CompileManagerTests.js rename services/web/test/unit/src/SamlLog/{SamlLogHandlerTests.js => SamlLogHandler.test.mjs} (78%) create mode 100644 services/web/test/unit/src/Subscription/SubscriptionController.test.mjs delete mode 100644 services/web/test/unit/src/Subscription/SubscriptionControllerTests.js create mode 100644 services/web/test/unit/src/SystemMessages/SystemMessageManager.test.mjs delete mode 100644 services/web/test/unit/src/SystemMessages/SystemMessageManagerTests.js create mode 100644 services/web/test/unit/src/ThirdPartyDataStore/UpdateMerger.test.mjs delete mode 100644 services/web/test/unit/src/ThirdPartyDataStore/UpdateMergerTests.js rename services/web/test/unit/src/Tutorial/{TutorialHandlerTests.js => TutorialHandler.test.mjs} (72%) diff --git a/services/web/app/src/Features/Chat/ChatController.mjs b/services/web/app/src/Features/Chat/ChatController.mjs index f341b6154c..9470bf2b32 100644 --- a/services/web/app/src/Features/Chat/ChatController.mjs +++ b/services/web/app/src/Features/Chat/ChatController.mjs @@ -5,7 +5,7 @@ import EditorRealTimeController from '../Editor/EditorRealTimeController.js' import SessionManager from '../Authentication/SessionManager.js' import UserInfoManager from '../User/UserInfoManager.js' import UserInfoController from '../User/UserInfoController.js' -import ChatManager from './ChatManager.js' +import ChatManager from './ChatManager.mjs' async function sendMessage(req, res) { const { project_id: projectId } = req.params diff --git a/services/web/app/src/Features/Chat/ChatManager.js b/services/web/app/src/Features/Chat/ChatManager.mjs similarity index 84% rename from services/web/app/src/Features/Chat/ChatManager.js rename to services/web/app/src/Features/Chat/ChatManager.mjs index 7eab6039d8..abf7941638 100644 --- a/services/web/app/src/Features/Chat/ChatManager.js +++ b/services/web/app/src/Features/Chat/ChatManager.mjs @@ -1,6 +1,6 @@ -const UserInfoController = require('../User/UserInfoController') -const UserGetter = require('../User/UserGetter') -const { callbackify } = require('@overleaf/promise-utils') +import UserInfoController from '../User/UserInfoController.js' +import UserGetter from '../User/UserGetter.js' +import { callbackify } from '@overleaf/promise-utils' async function injectUserInfoIntoThreads(threads) { const userIds = new Set() @@ -38,7 +38,7 @@ async function injectUserInfoIntoThreads(threads) { return threads } -module.exports = { +export default { injectUserInfoIntoThreads: callbackify(injectUserInfoIntoThreads), promises: { injectUserInfoIntoThreads, diff --git a/services/web/app/src/Features/Compile/CompileController.mjs b/services/web/app/src/Features/Compile/CompileController.mjs index 82486d1bfc..a7e6a317bd 100644 --- a/services/web/app/src/Features/Compile/CompileController.mjs +++ b/services/web/app/src/Features/Compile/CompileController.mjs @@ -4,7 +4,7 @@ import { Cookie } from 'tough-cookie' import OError from '@overleaf/o-error' import Metrics from '@overleaf/metrics' import ProjectGetter from '../Project/ProjectGetter.js' -import CompileManager from './CompileManager.js' +import CompileManager from './CompileManager.mjs' import ClsiManager from './ClsiManager.js' import logger from '@overleaf/logger' import Settings from '@overleaf/settings' diff --git a/services/web/app/src/Features/Compile/CompileManager.js b/services/web/app/src/Features/Compile/CompileManager.mjs similarity index 91% rename from services/web/app/src/Features/Compile/CompileManager.js rename to services/web/app/src/Features/Compile/CompileManager.mjs index 0c36f0a326..c509ec3d49 100644 --- a/services/web/app/src/Features/Compile/CompileManager.js +++ b/services/web/app/src/Features/Compile/CompileManager.mjs @@ -1,19 +1,16 @@ +import Crypto from 'node:crypto' +import Settings from '@overleaf/settings' +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 Metrics from '@overleaf/metrics' +import { RateLimiter } from '../../infrastructure/RateLimiter.js' +import UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.js' +import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils' let CompileManager -const Crypto = require('crypto') -const Settings = require('@overleaf/settings') -const RedisWrapper = require('../../infrastructure/RedisWrapper') const rclient = RedisWrapper.client('clsi_recently_compiled') -const ProjectGetter = require('../Project/ProjectGetter') -const ProjectRootDocManager = require('../Project/ProjectRootDocManager') -const UserGetter = require('../User/UserGetter') -const ClsiManager = require('./ClsiManager') -const Metrics = require('@overleaf/metrics') -const { RateLimiter } = require('../../infrastructure/RateLimiter') -const UserAnalyticsIdCache = require('../Analytics/UserAnalyticsIdCache') -const { - callbackify, - callbackifyMultiResult, -} = require('@overleaf/promise-utils') function instrumentWithTimer(fn, key) { return async (...args) => { @@ -196,7 +193,7 @@ async function deleteAuxFiles(projectId, userId, clsiserverid) { ) } -module.exports = CompileManager = { +export default CompileManager = { promises: { compile: instrumentedCompile, deleteAuxFiles, diff --git a/services/web/app/src/Features/Errors/ErrorController.mjs b/services/web/app/src/Features/Errors/ErrorController.mjs index d608078aee..6a56b99669 100644 --- a/services/web/app/src/Features/Errors/ErrorController.mjs +++ b/services/web/app/src/Features/Errors/ErrorController.mjs @@ -1,7 +1,7 @@ import { isZodErrorLike, fromZodError } from 'zod-validation-error' import Errors from './Errors.js' import SessionManager from '../Authentication/SessionManager.js' -import SamlLogHandler from '../SamlLog/SamlLogHandler.js' +import SamlLogHandler from '../SamlLog/SamlLogHandler.mjs' import HttpErrorHandler from './HttpErrorHandler.js' import { plainTextResponse } from '../../infrastructure/Response.js' import { expressifyErrorHandler } from '@overleaf/promise-utils' diff --git a/services/web/app/src/Features/History/RestoreManager.mjs b/services/web/app/src/Features/History/RestoreManager.mjs index 3a0859cdd4..938a883d27 100644 --- a/services/web/app/src/Features/History/RestoreManager.mjs +++ b/services/web/app/src/Features/History/RestoreManager.mjs @@ -14,7 +14,7 @@ import ChatApiHandler from '../Chat/ChatApiHandler.js' import DocstoreManager from '../Docstore/DocstoreManager.js' import logger from '@overleaf/logger' import EditorRealTimeController from '../Editor/EditorRealTimeController.js' -import ChatManager from '../Chat/ChatManager.js' +import ChatManager from '../Chat/ChatManager.mjs' import OError from '@overleaf/o-error' import ProjectGetter from '../Project/ProjectGetter.js' import ProjectEntityHandler from '../Project/ProjectEntityHandler.js' diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs index da8c54e88c..fb7436a546 100644 --- a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs +++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs @@ -15,21 +15,7 @@ import Settings from '@overleaf/settings' import _ from 'lodash' import AnalyticsManager from '../../../../app/src/Features/Analytics/AnalyticsManager.js' import LinkedFilesHandler from './LinkedFilesHandler.mjs' -import { - CompileFailedError, - UrlFetchFailedError, - InvalidUrlError, - AccessDeniedError, - BadEntityTypeError, - BadDataError, - ProjectNotFoundError, - V1ProjectNotFoundError, - SourceFileNotFoundError, - NotOriginalImporterError, - FeatureNotAvailableError, - RemoteServiceError, - FileCannotRefreshError, -} from './LinkedFilesErrors.js' +import LinkedFilesErrors from './LinkedFilesErrors.mjs' import { OutputFileFetchFailedError, FileTooLargeError, @@ -45,6 +31,22 @@ import ProjectOutputFileAgent from './ProjectOutputFileAgent.mjs' import ProjectFileAgent from './ProjectFileAgent.mjs' import UrlAgent from './UrlAgent.mjs' +const { + CompileFailedError, + UrlFetchFailedError, + InvalidUrlError, + AccessDeniedError, + BadEntityTypeError, + BadDataError, + ProjectNotFoundError, + V1ProjectNotFoundError, + SourceFileNotFoundError, + NotOriginalImporterError, + FeatureNotAvailableError, + RemoteServiceError, + FileCannotRefreshError, +} = LinkedFilesErrors + let LinkedFilesController const createLinkedFileSchema = z.object({ diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesErrors.js b/services/web/app/src/Features/LinkedFiles/LinkedFilesErrors.mjs similarity index 93% rename from services/web/app/src/Features/LinkedFiles/LinkedFilesErrors.js rename to services/web/app/src/Features/LinkedFiles/LinkedFilesErrors.mjs index f74152f3ce..0973a4dc64 100644 --- a/services/web/app/src/Features/LinkedFiles/LinkedFilesErrors.js +++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesErrors.mjs @@ -1,4 +1,4 @@ -const { BackwardCompatibleError } = require('../Errors/Errors') +import { BackwardCompatibleError } from '../Errors/Errors.js' class UrlFetchFailedError extends BackwardCompatibleError {} @@ -26,7 +26,7 @@ class RemoteServiceError extends BackwardCompatibleError {} class FileCannotRefreshError extends BackwardCompatibleError {} -module.exports = { +export default { CompileFailedError, UrlFetchFailedError, InvalidUrlError, diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.mjs b/services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.mjs index 9e42cd069d..be46d91220 100644 --- a/services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.mjs +++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.mjs @@ -3,13 +3,12 @@ import EditorController from '../Editor/EditorController.js' import ProjectLocator from '../Project/ProjectLocator.js' import { Project } from '../../models/Project.js' import ProjectGetter from '../Project/ProjectGetter.js' -import { - ProjectNotFoundError, - V1ProjectNotFoundError, - BadDataError, -} from './LinkedFilesErrors.js' +import LinkedFilesErrors from './LinkedFilesErrors.mjs' import { callbackifyAll } from '@overleaf/promise-utils' +const { ProjectNotFoundError, V1ProjectNotFoundError, BadDataError } = + LinkedFilesErrors + const LinkedFilesHandler = { async getFileById(projectId, fileId) { const { element, path, folder } = await ProjectLocator.promises.findElement( diff --git a/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs b/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs index 76f845e19b..bf0e8a788b 100644 --- a/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs +++ b/services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs @@ -16,16 +16,16 @@ import DocstoreManager from '../Docstore/DocstoreManager.js' import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js' import _ from 'lodash' import LinkedFilesHandler from './LinkedFilesHandler.mjs' +import LinkedFilesErrors from './LinkedFilesErrors.mjs' +import { promisify } from '@overleaf/promise-utils' +import HistoryManager from '../History/HistoryManager.js' -import { +const { BadDataError, AccessDeniedError, BadEntityTypeError, SourceFileNotFoundError, -} from './LinkedFilesErrors.js' - -import { promisify } from '@overleaf/promise-utils' -import HistoryManager from '../History/HistoryManager.js' +} = LinkedFilesErrors let ProjectFileAgent diff --git a/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs b/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs index b99522a5f0..99acbfc1c4 100644 --- a/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs +++ b/services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs @@ -1,17 +1,16 @@ import AuthorizationManager from '../Authorization/AuthorizationManager.js' -import CompileManager from '../Compile/CompileManager.js' +import CompileManager from '../Compile/CompileManager.mjs' import ClsiManager from '../Compile/ClsiManager.js' import ProjectFileAgent from './ProjectFileAgent.mjs' import _ from 'lodash' -import { - CompileFailedError, - BadDataError, - AccessDeniedError, -} from './LinkedFilesErrors.js' +import LinkedFilesErrors from './LinkedFilesErrors.mjs' import { OutputFileFetchFailedError } from '../Errors/Errors.js' import LinkedFilesHandler from './LinkedFilesHandler.mjs' import { promisify } from '@overleaf/promise-utils' +const { CompileFailedError, BadDataError, AccessDeniedError } = + LinkedFilesErrors + function _prepare(projectId, linkedFileData, userId, callback) { _checkAuth(projectId, linkedFileData, userId, (err, allowed) => { if (err) { diff --git a/services/web/app/src/Features/LinkedFiles/UrlAgent.mjs b/services/web/app/src/Features/LinkedFiles/UrlAgent.mjs index 2e78ec7ccc..12612bff2f 100644 --- a/services/web/app/src/Features/LinkedFiles/UrlAgent.mjs +++ b/services/web/app/src/Features/LinkedFiles/UrlAgent.mjs @@ -1,12 +1,14 @@ import logger from '@overleaf/logger' import urlValidator from 'valid-url' -import { InvalidUrlError, UrlFetchFailedError } from './LinkedFilesErrors.js' +import LinkedFilesErrors from './LinkedFilesErrors.mjs' import LinkedFilesHandler from './LinkedFilesHandler.mjs' import UrlHelper from '../Helpers/UrlHelper.js' import { fetchStream, RequestFailedError } from '@overleaf/fetch-utils' import { callbackify } from '@overleaf/promise-utils' import { FileTooLargeError } from '../Errors/Errors.js' +const { InvalidUrlError, UrlFetchFailedError } = LinkedFilesErrors + async function createLinkedFile( projectId, linkedFileData, diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 043267825a..e17edd1512 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -35,20 +35,20 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.js' import SplitTestHandler from '../SplitTests/SplitTestHandler.js' import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js' import FeaturesUpdater from '../Subscription/FeaturesUpdater.js' -import SpellingHandler from '../Spelling/SpellingHandler.js' +import SpellingHandler from '../Spelling/SpellingHandler.mjs' import { hasAdminAccess } from '../Helpers/AdminAuthorizationHelper.js' import InstitutionsFeatures from '../Institutions/InstitutionsFeatures.js' import InstitutionsGetter from '../Institutions/InstitutionsGetter.js' import ProjectAuditLogHandler from './ProjectAuditLogHandler.mjs' import PublicAccessLevels from '../Authorization/PublicAccessLevels.js' import TagsHandler from '../Tags/TagsHandler.js' -import TutorialHandler from '../Tutorial/TutorialHandler.js' +import TutorialHandler from '../Tutorial/TutorialHandler.mjs' import UserUpdater from '../User/UserUpdater.js' import Modules from '../../infrastructure/Modules.js' import { z, zz, validateReq } from '../../infrastructure/Validation.js' import UserGetter from '../User/UserGetter.js' import { isStandaloneAiAddOnPlanCode } from '../Subscription/AiHelper.js' -import SubscriptionController from '../Subscription/SubscriptionController.js' +import SubscriptionController from '../Subscription/SubscriptionController.mjs' import { formatCurrency } from '../../util/currency.js' const { ObjectId } = mongodb diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs index 096190c093..747ef04ba7 100644 --- a/services/web/app/src/Features/Project/ProjectListController.mjs +++ b/services/web/app/src/Features/Project/ProjectListController.mjs @@ -25,7 +25,7 @@ import NotificationsBuilder from '../Notifications/NotificationsBuilder.js' import GeoIpLookup from '../../infrastructure/GeoIpLookup.js' import SplitTestHandler from '../SplitTests/SplitTestHandler.js' import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js' -import TutorialHandler from '../Tutorial/TutorialHandler.js' +import TutorialHandler from '../Tutorial/TutorialHandler.mjs' import SubscriptionHelper from '../Subscription/SubscriptionHelper.js' import PermissionsManager from '../Authorization/PermissionsManager.js' import AnalyticsManager from '../Analytics/AnalyticsManager.js' diff --git a/services/web/app/src/Features/SamlLog/SamlLogHandler.js b/services/web/app/src/Features/SamlLog/SamlLogHandler.mjs similarity index 82% rename from services/web/app/src/Features/SamlLog/SamlLogHandler.js rename to services/web/app/src/Features/SamlLog/SamlLogHandler.mjs index 2069fac4be..2fd0038500 100644 --- a/services/web/app/src/Features/SamlLog/SamlLogHandler.js +++ b/services/web/app/src/Features/SamlLog/SamlLogHandler.mjs @@ -1,9 +1,9 @@ -const { SamlLog } = require('../../models/SamlLog') -const SessionManager = require('../Authentication/SessionManager') -const logger = require('@overleaf/logger') -const { err: errSerializer } = require('@overleaf/logger/serializers') -const { callbackify } = require('util') -const Settings = require('@overleaf/settings') +import { SamlLog } from '../../models/SamlLog.js' +import SessionManager from '../Authentication/SessionManager.js' +import logger from '@overleaf/logger' +import loggerSerializers from '@overleaf/logger/serializers.js' +import { callbackify } from 'node:util' +import Settings from '@overleaf/settings' const ALLOWED_PATHS = Settings.saml?.logAllowList || ['/saml/'] @@ -33,7 +33,7 @@ async function log(req, data, samlAssertion) { data.samlSession = saml if (data.error instanceof Error) { - const errSerialized = errSerializer(data.error) + const errSerialized = loggerSerializers.err(data.error) if (data.error.tryAgain) { errSerialized.tryAgain = data.error.tryAgain } @@ -82,4 +82,4 @@ const SamlLogHandler = { }, } -module.exports = SamlLogHandler +export default SamlLogHandler diff --git a/services/web/app/src/Features/ServerAdmin/AdminController.mjs b/services/web/app/src/Features/ServerAdmin/AdminController.mjs index d5b81dfa9e..0d028d734e 100644 --- a/services/web/app/src/Features/ServerAdmin/AdminController.mjs +++ b/services/web/app/src/Features/ServerAdmin/AdminController.mjs @@ -5,7 +5,7 @@ import Settings from '@overleaf/settings' import TpdsUpdateSender from '../ThirdPartyDataStore/TpdsUpdateSender.js' import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.js' import EditorRealTimeController from '../Editor/EditorRealTimeController.js' -import SystemMessageManager from '../SystemMessages/SystemMessageManager.js' +import SystemMessageManager from '../SystemMessages/SystemMessageManager.mjs' const AdminController = { _sendDisconnectAllUsersMessage: delay => { diff --git a/services/web/app/src/Features/Spelling/SpellingHandler.js b/services/web/app/src/Features/Spelling/SpellingHandler.mjs similarity index 67% rename from services/web/app/src/Features/Spelling/SpellingHandler.js rename to services/web/app/src/Features/Spelling/SpellingHandler.mjs index 8d6c42b540..0d55b0ff2a 100644 --- a/services/web/app/src/Features/Spelling/SpellingHandler.js +++ b/services/web/app/src/Features/Spelling/SpellingHandler.mjs @@ -1,9 +1,9 @@ -const OError = require('@overleaf/o-error') -const Metrics = require('@overleaf/metrics') -const { promisifyAll } = require('@overleaf/promise-utils') -const LearnedWordsManager = require('./LearnedWordsManager') +import OError from '@overleaf/o-error' +import Metrics from '@overleaf/metrics' +import { promisifyAll } from '@overleaf/promise-utils' +import LearnedWordsManager from './LearnedWordsManager.js' -module.exports = { +const SpellingHandler = { getUserDictionary(userId, callback) { const timer = new Metrics.Timer('spelling_get_dict') LearnedWordsManager.getLearnedWords(userId, (error, words) => { @@ -26,4 +26,4 @@ module.exports = { }, } -module.exports.promises = promisifyAll(module.exports) +export default { ...SpellingHandler, promises: promisifyAll(SpellingHandler) } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.mjs similarity index 94% rename from services/web/app/src/Features/Subscription/SubscriptionController.js rename to services/web/app/src/Features/Subscription/SubscriptionController.mjs index de1b7ae331..7e1bcaf977 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.mjs @@ -1,48 +1,48 @@ // @ts-check -const SessionManager = require('../Authentication/SessionManager') -const SubscriptionHandler = require('./SubscriptionHandler') -const SubscriptionHelper = require('./SubscriptionHelper') -const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder') -const LimitationsManager = require('./LimitationsManager') -const RecurlyWrapper = require('./RecurlyWrapper') -const Settings = require('@overleaf/settings') -const logger = require('@overleaf/logger') -const GeoIpLookup = require('../../infrastructure/GeoIpLookup') -const FeaturesUpdater = require('./FeaturesUpdater') -const GroupPlansData = require('./GroupPlansData') -const V1SubscriptionManager = require('./V1SubscriptionManager') -const AnalyticsManager = require('../Analytics/AnalyticsManager') -const RecurlyEventHandler = require('./RecurlyEventHandler') -const { expressify } = require('@overleaf/promise-utils') -const OError = require('@overleaf/o-error') +import SessionManager from '../Authentication/SessionManager.js' +import SubscriptionHandler from './SubscriptionHandler.js' +import SubscriptionHelper from './SubscriptionHelper.js' +import SubscriptionViewModelBuilder from './SubscriptionViewModelBuilder.js' +import LimitationsManager from './LimitationsManager.js' +import RecurlyWrapper from './RecurlyWrapper.js' +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import GeoIpLookup from '../../infrastructure/GeoIpLookup.js' +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 { expressify } from '@overleaf/promise-utils' +import OError from '@overleaf/o-error' +import Errors from './Errors.js' +import SplitTestHandler from '../SplitTests/SplitTestHandler.js' +import AuthorizationManager from '../Authorization/AuthorizationManager.js' +import Modules from '../../infrastructure/Modules.js' +import async from 'async' +import HttpErrorHandler from '../Errors/HttpErrorHandler.js' +import RecurlyClient from './RecurlyClient.js' +import { + AI_ADD_ON_CODE, + subscriptionChangeIsAiAssistUpgrade, +} from './AiHelper.js' +import PlansLocator from './PlansLocator.js' +import { User } from '../../models/User.js' +import UserGetter from '../User/UserGetter.js' +import PermissionsManager from '../Authorization/PermissionsManager.js' +import { sanitizeSessionUserForFrontEnd } from '../../infrastructure/FrontEndUser.js' +import { z, validateReq } from '../../infrastructure/Validation.js' +import { IndeterminateInvoiceError } from '../Errors/Errors.js' +import SubscriptionLocator from './SubscriptionLocator.js' + const { DuplicateAddOnError, AddOnNotPresentError, PaymentActionRequiredError, PaymentFailedError, MissingBillingInfoError, -} = require('./Errors') -const SplitTestHandler = require('../SplitTests/SplitTestHandler') -const AuthorizationManager = require('../Authorization/AuthorizationManager') -const Modules = require('../../infrastructure/Modules') -const async = require('async') -const HttpErrorHandler = require('../Errors/HttpErrorHandler') -const RecurlyClient = require('./RecurlyClient') -const { - AI_ADD_ON_CODE, - subscriptionChangeIsAiAssistUpgrade, -} = require('./AiHelper') -const PlansLocator = require('./PlansLocator') -const { User } = require('../../models/User') -const UserGetter = require('../User/UserGetter') -const PermissionsManager = require('../Authorization/PermissionsManager') -const { - sanitizeSessionUserForFrontEnd, -} = require('../../infrastructure/FrontEndUser') -const { z, validateReq } = require('../../infrastructure/Validation') -const { IndeterminateInvoiceError } = require('../Errors/Errors') -const SubscriptionLocator = require('./SubscriptionLocator') +} = Errors const SUBSCRIPTION_PAUSED_REDIRECT_PATH = '/user/subscription?redirect-reason=subscription-paused' @@ -1101,7 +1101,7 @@ function makeChangePreview( } } -module.exports = { +export default { userSubscriptionPage: expressify(userSubscriptionPage), successfulSubscription: expressify(successfulSubscription), cancelSubscription, diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.mjs index c6eadc6cca..392c568a6a 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.mjs @@ -3,7 +3,7 @@ import _ from 'lodash' import OError from '@overleaf/o-error' import SubscriptionUpdater from './SubscriptionUpdater.js' import SubscriptionLocator from './SubscriptionLocator.js' -import SubscriptionController from './SubscriptionController.js' +import SubscriptionController from './SubscriptionController.mjs' import SubscriptionHelper from './SubscriptionHelper.js' import { Subscription } from '../../models/Subscription.js' import { User } from '../../models/User.js' diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 18ec8331c4..40c9333145 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -1,6 +1,6 @@ import AuthenticationController from '../Authentication/AuthenticationController.js' import PermissionsController from '../Authorization/PermissionsController.mjs' -import SubscriptionController from './SubscriptionController.js' +import SubscriptionController from './SubscriptionController.mjs' import SubscriptionGroupController from './SubscriptionGroupController.mjs' import TeamInvitesController from './TeamInvitesController.mjs' import { RateLimiter } from '../../infrastructure/RateLimiter.js' diff --git a/services/web/app/src/Features/SystemMessages/SystemMessageController.mjs b/services/web/app/src/Features/SystemMessages/SystemMessageController.mjs index 5bcd27c6f3..790810f706 100644 --- a/services/web/app/src/Features/SystemMessages/SystemMessageController.mjs +++ b/services/web/app/src/Features/SystemMessages/SystemMessageController.mjs @@ -1,6 +1,6 @@ import Settings from '@overleaf/settings' import SessionManager from '../Authentication/SessionManager.js' -import SystemMessageManager from './SystemMessageManager.js' +import SystemMessageManager from './SystemMessageManager.mjs' const ProjectController = { getMessages(req, res, next) { diff --git a/services/web/app/src/Features/SystemMessages/SystemMessageManager.js b/services/web/app/src/Features/SystemMessages/SystemMessageManager.mjs similarity index 80% rename from services/web/app/src/Features/SystemMessages/SystemMessageManager.js rename to services/web/app/src/Features/SystemMessages/SystemMessageManager.mjs index 96a6676eb2..0da9e302fe 100644 --- a/services/web/app/src/Features/SystemMessages/SystemMessageManager.js +++ b/services/web/app/src/Features/SystemMessages/SystemMessageManager.mjs @@ -1,9 +1,7 @@ -const { SystemMessage } = require('../../models/SystemMessage') -const { - addRequiredCleanupHandlerBeforeDrainingConnections, -} = require('../../infrastructure/GracefulShutdown') -const { callbackifyAll } = require('@overleaf/promise-utils') -const logger = require('@overleaf/logger') +import { SystemMessage } from '../../models/SystemMessage.js' +import { addRequiredCleanupHandlerBeforeDrainingConnections } from '../../infrastructure/GracefulShutdown.js' +import { callbackifyAll } from '@overleaf/promise-utils' +import logger from '@overleaf/logger' const SystemMessageManager = { _cachedMessages: [], @@ -52,7 +50,7 @@ addRequiredCleanupHandlerBeforeDrainingConnections( } ) -module.exports = { +export default { getMessages: SystemMessageManager.getMessages.bind(SystemMessageManager), ...callbackifyAll(SystemMessageManager, { without: ['getMessages'] }), promises: SystemMessageManager, diff --git a/services/web/app/src/Features/Templates/TemplatesRouter.js b/services/web/app/src/Features/Templates/TemplatesRouter.mjs similarity index 62% rename from services/web/app/src/Features/Templates/TemplatesRouter.js rename to services/web/app/src/Features/Templates/TemplatesRouter.mjs index 8f66c53754..f5f459ab5c 100644 --- a/services/web/app/src/Features/Templates/TemplatesRouter.js +++ b/services/web/app/src/Features/Templates/TemplatesRouter.mjs @@ -1,16 +1,16 @@ -const AuthenticationController = require('../Authentication/AuthenticationController') -const TemplatesController = require('./TemplatesController') -const TemplatesMiddleware = require('./TemplatesMiddleware') -const { RateLimiter } = require('../../infrastructure/RateLimiter') -const RateLimiterMiddleware = require('../Security/RateLimiterMiddleware') -const AnalyticsRegistrationSourceMiddleware = require('../Analytics/AnalyticsRegistrationSourceMiddleware') +import AuthenticationController from '../Authentication/AuthenticationController.js' +import TemplatesController from './TemplatesController.js' +import TemplatesMiddleware from './TemplatesMiddleware.js' +import { RateLimiter } from '../../infrastructure/RateLimiter.js' +import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js' +import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.js' const rateLimiter = new RateLimiter('create-project-from-template', { points: 20, duration: 60, }) -module.exports = { +export default { rateLimiter, apply(app) { app.get( diff --git a/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.mjs b/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.mjs index 2b8667bed4..451a966351 100644 --- a/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.mjs +++ b/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.mjs @@ -1,6 +1,6 @@ import { expressify } from '@overleaf/promise-utils' import TpdsUpdateHandler from './TpdsUpdateHandler.mjs' -import UpdateMerger from './UpdateMerger.js' +import UpdateMerger from './UpdateMerger.mjs' import Errors from '../Errors/Errors.js' import logger from '@overleaf/logger' import Path from 'node:path' diff --git a/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs b/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs index 219db88b12..be2fc8a6ac 100644 --- a/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs +++ b/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs @@ -1,5 +1,5 @@ import { callbackify } from 'node:util' -import UpdateMerger from './UpdateMerger.js' +import UpdateMerger from './UpdateMerger.mjs' import logger from '@overleaf/logger' import NotificationsBuilder from '../Notifications/NotificationsBuilder.js' import ProjectCreationHandler from '../Project/ProjectCreationHandler.js' diff --git a/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js b/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs similarity index 90% rename from services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js rename to services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs index f68d366d8e..88636f6f67 100644 --- a/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js +++ b/services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs @@ -1,14 +1,14 @@ -const { callbackify } = require('util') -const _ = require('lodash') -const fsPromises = require('fs/promises') -const fs = require('fs') -const logger = require('@overleaf/logger') -const EditorController = require('../Editor/EditorController') -const FileTypeManager = require('../Uploads/FileTypeManager') -const ProjectEntityHandler = require('../Project/ProjectEntityHandler') -const crypto = require('crypto') -const Settings = require('@overleaf/settings') -const { pipeline } = require('stream/promises') +import { callbackify } from 'node:util' +import _ from 'lodash' +import fsPromises from 'node:fs/promises' +import fs from 'node:fs' +import logger from '@overleaf/logger' +import EditorController from '../Editor/EditorController.js' +import FileTypeManager from '../Uploads/FileTypeManager.js' +import ProjectEntityHandler from '../Project/ProjectEntityHandler.js' +import crypto from 'node:crypto' +import Settings from '@overleaf/settings' +import { pipeline } from 'node:stream/promises' async function mergeUpdate(userId, projectId, path, updateRequest, source) { const fsPath = await writeUpdateToDisk(projectId, updateRequest) @@ -185,7 +185,7 @@ async function createFolder(projectId, path, userId) { return folder } -module.exports = { +export default { mergeUpdate: callbackify(mergeUpdate), _mergeUpdate: callbackify(_mergeUpdate), deleteUpdate: callbackify(deleteUpdate), diff --git a/services/web/app/src/Features/Tutorial/TutorialController.mjs b/services/web/app/src/Features/Tutorial/TutorialController.mjs index 38e1a3ee89..543bf00bf0 100644 --- a/services/web/app/src/Features/Tutorial/TutorialController.mjs +++ b/services/web/app/src/Features/Tutorial/TutorialController.mjs @@ -1,5 +1,5 @@ import SessionManager from '../Authentication/SessionManager.js' -import TutorialHandler from './TutorialHandler.js' +import TutorialHandler from './TutorialHandler.mjs' import { expressify } from '@overleaf/promise-utils' const VALID_KEYS = [ diff --git a/services/web/app/src/Features/Tutorial/TutorialHandler.js b/services/web/app/src/Features/Tutorial/TutorialHandler.mjs similarity index 93% rename from services/web/app/src/Features/Tutorial/TutorialHandler.js rename to services/web/app/src/Features/Tutorial/TutorialHandler.mjs index 6ffe9aa89c..fe54943b5c 100644 --- a/services/web/app/src/Features/Tutorial/TutorialHandler.js +++ b/services/web/app/src/Features/Tutorial/TutorialHandler.mjs @@ -1,4 +1,4 @@ -const UserUpdater = require('../User/UserUpdater') +import UserUpdater from '../User/UserUpdater.js' const POSTPONE_DURATION_MS = 24 * 60 * 60 * 1000 // 1 day @@ -59,4 +59,4 @@ function getInactiveTutorials(user, tutorialKey) { return inactiveTutorials } -module.exports = { setTutorialState, getInactiveTutorials } +export default { setTutorialState, getInactiveTutorials } diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 2368d61110..65cedfca79 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -24,7 +24,7 @@ import UserEmailsController from './Features/User/UserEmailsController.js' import UserPagesController from './Features/User/UserPagesController.mjs' import TutorialController from './Features/Tutorial/TutorialController.mjs' import DocumentController from './Features/Documents/DocumentController.mjs' -import CompileManager from './Features/Compile/CompileManager.js' +import CompileManager from './Features/Compile/CompileManager.mjs' import CompileController from './Features/Compile/CompileController.mjs' import HealthCheckController from './Features/HealthCheck/HealthCheckController.mjs' import ProjectDownloadsController from './Features/Downloads/ProjectDownloadsController.mjs' @@ -52,7 +52,7 @@ import MetaController from './Features/Metadata/MetaController.mjs' import TokenAccessController from './Features/TokenAccess/TokenAccessController.mjs' import TokenAccessRouter from './Features/TokenAccess/TokenAccessRouter.mjs' import LinkedFilesRouter from './Features/LinkedFiles/LinkedFilesRouter.mjs' -import TemplatesRouter from './Features/Templates/TemplatesRouter.js' +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' diff --git a/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs index 091ff62ad5..9876b86eda 100644 --- a/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs @@ -1,5 +1,5 @@ import AbstractMockApi from './AbstractMockApi.mjs' -import SubscriptionController from '../../../../app/src/Features/Subscription/SubscriptionController.js' +import SubscriptionController from '../../../../app/src/Features/Subscription/SubscriptionController.mjs' import { xmlResponse } from '../../../../app/src/infrastructure/Response.js' class MockRecurlyApi extends AbstractMockApi { diff --git a/services/web/test/unit/src/Chat/ChatController.test.mjs b/services/web/test/unit/src/Chat/ChatController.test.mjs index 8d3872e368..1be6c78122 100644 --- a/services/web/test/unit/src/Chat/ChatController.test.mjs +++ b/services/web/test/unit/src/Chat/ChatController.test.mjs @@ -33,7 +33,7 @@ describe('ChatController', function () { default: ctx.ChatApiHandler, })) - vi.doMock('../../../../app/src/Features/Chat/ChatManager.js', () => ({ + vi.doMock('../../../../app/src/Features/Chat/ChatManager.mjs', () => ({ default: ctx.ChatManager, })) diff --git a/services/web/test/unit/src/Chat/ChatManager.test.mjs b/services/web/test/unit/src/Chat/ChatManager.test.mjs new file mode 100644 index 0000000000..0030390b27 --- /dev/null +++ b/services/web/test/unit/src/Chat/ChatManager.test.mjs @@ -0,0 +1,129 @@ +import { vi, expect } from 'vitest' +import sinon from 'sinon' + +const modulePath = '../../../../app/src/Features/Chat/ChatManager.mjs' + +describe('ChatManager', function () { + beforeEach(async function (ctx) { + ctx.user_id = 'mock-user-id' + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: (ctx.UserGetter = { promises: {} }), + })) + + vi.doMock('../../../../app/src/Features/User/UserInfoController', () => ({ + default: (ctx.UserInfoController = {}), + })) + + ctx.ChatManager = (await import(modulePath)).default + ctx.req = { + params: { + project_id: ctx.project_id, + }, + } + ctx.res = { + json: sinon.stub(), + send: sinon.stub(), + sendStatus: sinon.stub(), + } + }) + + describe('injectUserInfoIntoThreads', function () { + beforeEach(function (ctx) { + ctx.users = { + user_id_1: { + _id: 'user_id_1', + }, + user_id_2: { + _id: 'user_id_2', + }, + } + ctx.UserGetter.promises.getUsers = userIds => + Promise.resolve( + Array.from(userIds) + .map(id => ctx.users[id]) + .filter(u => !!u) + ) + + sinon.spy(ctx.UserGetter.promises, 'getUsers') + ctx.UserInfoController.formatPersonalInfo = user => ({ + formatted: { id: user._id.toString() }, + }) + }) + + it('should inject a user object into messaged and resolved data', async function (ctx) { + const threads = await ctx.ChatManager.promises.injectUserInfoIntoThreads({ + thread1: { + resolved: true, + resolved_by_user_id: 'user_id_1', + messages: [ + { + user_id: 'user_id_1', + content: 'foo', + }, + { + user_id: 'user_id_2', + content: 'bar', + }, + ], + }, + thread2: { + messages: [ + { + user_id: 'user_id_1', + content: 'baz', + }, + ], + }, + }) + + expect(threads).to.deep.equal({ + thread1: { + resolved: true, + resolved_by_user_id: 'user_id_1', + resolved_by_user: { formatted: { id: 'user_id_1' } }, + messages: [ + { + user_id: 'user_id_1', + user: { formatted: { id: 'user_id_1' } }, + content: 'foo', + }, + { + user_id: 'user_id_2', + user: { formatted: { id: 'user_id_2' } }, + content: 'bar', + }, + ], + }, + thread2: { + messages: [ + { + user_id: 'user_id_1', + user: { formatted: { id: 'user_id_1' } }, + content: 'baz', + }, + ], + }, + }) + }) + + it('should lookup all users in a single batch', async function (ctx) { + await ctx.ChatManager.promises.injectUserInfoIntoThreads([ + { + messages: [ + { + user_id: 'user_id_1', + content: 'foo', + }, + { + user_id: 'user_id_1', + content: 'bar', + }, + ], + }, + ]) + + ctx.UserGetter.promises.getUsers.should.have.been.calledOnce + }) + }) +}) diff --git a/services/web/test/unit/src/Chat/ChatManagerTests.js b/services/web/test/unit/src/Chat/ChatManagerTests.js deleted file mode 100644 index 5578b8b167..0000000000 --- a/services/web/test/unit/src/Chat/ChatManagerTests.js +++ /dev/null @@ -1,131 +0,0 @@ -const SandboxedModule = require('sandboxed-module') -const path = require('path') -const sinon = require('sinon') -const modulePath = path.join( - __dirname, - '../../../../app/src/Features/Chat/ChatManager' -) -const { expect } = require('chai') - -describe('ChatManager', function () { - beforeEach(function () { - this.user_id = 'mock-user-id' - this.ChatManager = SandboxedModule.require(modulePath, { - requires: { - '../User/UserGetter': (this.UserGetter = { promises: {} }), - '../User/UserInfoController': (this.UserInfoController = {}), - }, - }) - this.req = { - params: { - project_id: this.project_id, - }, - } - this.res = { - json: sinon.stub(), - send: sinon.stub(), - sendStatus: sinon.stub(), - } - }) - - describe('injectUserInfoIntoThreads', function () { - beforeEach(function () { - this.users = { - user_id_1: { - _id: 'user_id_1', - }, - user_id_2: { - _id: 'user_id_2', - }, - } - this.UserGetter.promises.getUsers = userIds => - Promise.resolve( - Array.from(userIds) - .map(id => this.users[id]) - .filter(u => !!u) - ) - - sinon.spy(this.UserGetter.promises, 'getUsers') - return (this.UserInfoController.formatPersonalInfo = user => ({ - formatted: { id: user._id.toString() }, - })) - }) - - it('should inject a user object into messaged and resolved data', async function () { - const threads = await this.ChatManager.promises.injectUserInfoIntoThreads( - { - thread1: { - resolved: true, - resolved_by_user_id: 'user_id_1', - messages: [ - { - user_id: 'user_id_1', - content: 'foo', - }, - { - user_id: 'user_id_2', - content: 'bar', - }, - ], - }, - thread2: { - messages: [ - { - user_id: 'user_id_1', - content: 'baz', - }, - ], - }, - } - ) - - expect(threads).to.deep.equal({ - thread1: { - resolved: true, - resolved_by_user_id: 'user_id_1', - resolved_by_user: { formatted: { id: 'user_id_1' } }, - messages: [ - { - user_id: 'user_id_1', - user: { formatted: { id: 'user_id_1' } }, - content: 'foo', - }, - { - user_id: 'user_id_2', - user: { formatted: { id: 'user_id_2' } }, - content: 'bar', - }, - ], - }, - thread2: { - messages: [ - { - user_id: 'user_id_1', - user: { formatted: { id: 'user_id_1' } }, - content: 'baz', - }, - ], - }, - }) - }) - - it('should lookup all users in a single batch', async function () { - await this.ChatManager.promises.injectUserInfoIntoThreads([ - { - messages: [ - { - user_id: 'user_id_1', - content: 'foo', - }, - { - user_id: 'user_id_1', - content: 'bar', - }, - ], - }, - ]) - - this.UserGetter.promises.getUsers.should.have.been.calledOnce - }) - }) -}) diff --git a/services/web/test/unit/src/Compile/CompileManager.test.mjs b/services/web/test/unit/src/Compile/CompileManager.test.mjs new file mode 100644 index 0000000000..35cb3b1d85 --- /dev/null +++ b/services/web/test/unit/src/Compile/CompileManager.test.mjs @@ -0,0 +1,457 @@ +import { vi, expect } from 'vitest' +import sinon from 'sinon' + +const MODULE_PATH = '../../../../app/src/Features/Compile/CompileManager.mjs' + +describe('CompileManager', function () { + beforeEach(async function (ctx) { + ctx.rateLimiter = { + consume: sinon.stub().resolves(), + } + ctx.timer = { + done: sinon.stub(), + } + ctx.Metrics = { + Timer: sinon.stub().returns(ctx.timer), + inc: sinon.stub(), + } + + vi.doMock('@overleaf/settings', () => ({ + default: (ctx.settings = { + apis: { + clsi: { submissionBackendClass: 'n2d' }, + }, + redis: { web: { host: '127.0.0.1', port: 42 } }, + rateLimit: { autoCompile: {} }, + }), + })) + + vi.doMock('../../../../app/src/infrastructure/RedisWrapper', () => ({ + default: { + client: () => + (ctx.rclient = { + auth() {}, + }), + }, + })) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectRootDocManager', + () => ({ + default: (ctx.ProjectRootDocManager = { + promises: {}, + }), + }) + ) + + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ + default: (ctx.ProjectGetter = { promises: {} }), + })) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: (ctx.UserGetter = { promises: {} }), + })) + + vi.doMock('../../../../app/src/Features/Compile/ClsiManager', () => ({ + default: (ctx.ClsiManager = { promises: {} }), + })) + + vi.doMock('../../../../app/src/infrastructure/RateLimiter.js', () => ({ + RateLimiter: sinon.stub().returns(ctx.rateLimiter), + })) + + vi.doMock('@overleaf/metrics', () => ({ + default: ctx.Metrics, + })) + + vi.doMock( + '../../../../app/src/Features/Analytics/UserAnalyticsIdCache', + () => ({ + default: (ctx.UserAnalyticsIdCache = { + get: sinon.stub().resolves('abc'), + }), + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: (ctx.SplitTestHandler = { + promises: {}, + }), + }) + ) + + ctx.CompileManager = (await import(MODULE_PATH)).default + ctx.project_id = 'mock-project-id-123' + ctx.user_id = 'mock-user-id-123' + ctx.callback = sinon.stub() + ctx.limits = { + timeout: 42, + compileGroup: 'standard', + } + }) + + describe('compile', function () { + beforeEach(function (ctx) { + ctx.CompileManager._checkIfRecentlyCompiled = sinon.stub().resolves(false) + ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet = sinon + .stub() + .resolves() + ctx.CompileManager.promises.getProjectCompileLimits = sinon + .stub() + .resolves(ctx.limits) + ctx.ClsiManager.promises.sendRequest = sinon.stub().resolves({ + status: (ctx.status = 'mock-status'), + outputFiles: (ctx.outputFiles = []), + clsiServerId: (ctx.output = 'mock output'), + }) + }) + + describe('succesfully', function () { + let result + beforeEach(async function (ctx) { + ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit = async ( + isAutoCompile, + compileGroup + ) => true + ctx.ProjectGetter.promises.getProject = sinon + .stub() + .resolves( + (ctx.project = { owner_ref: (ctx.owner_id = 'owner-id-123') }) + ) + ctx.UserGetter.promises.getUser = sinon.stub().resolves( + (ctx.user = { + features: { compileTimeout: '20s', compileGroup: 'standard' }, + analyticsId: 'abc', + }) + ) + result = await ctx.CompileManager.promises.compile( + ctx.project_id, + ctx.user_id, + {} + ) + }) + + it('should check the project has not been recently compiled', function (ctx) { + ctx.CompileManager._checkIfRecentlyCompiled + .calledWith(ctx.project_id, ctx.user_id) + .should.equal(true) + }) + + it('should ensure that the root document is set', function (ctx) { + ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet + .calledWith(ctx.project_id) + .should.equal(true) + }) + + it('should get the project compile limits', function (ctx) { + ctx.CompileManager.promises.getProjectCompileLimits + .calledWith(ctx.project_id) + .should.equal(true) + }) + + it('should run the compile with the compile limits', function (ctx) { + ctx.ClsiManager.promises.sendRequest + .calledWith(ctx.project_id, ctx.user_id, { + timeout: ctx.limits.timeout, + compileGroup: 'standard', + buildId: sinon.match(/[a-f0-9]+-[a-f0-9]+/), + }) + .should.equal(true) + }) + + it('should resolve with the output', function (ctx) { + expect(result).to.haveOwnProperty('status', ctx.status) + expect(result).to.haveOwnProperty('clsiServerId', ctx.output) + expect(result).to.haveOwnProperty('outputFiles', ctx.outputFiles) + }) + + it('should time the compile', function (ctx) { + ctx.timer.done.called.should.equal(true) + }) + }) + + describe('when the project has been recently compiled', function () { + it('should return', async function (ctx) { + ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit = async ( + isAutoCompile, + compileGroup + ) => true + ctx.CompileManager._checkIfRecentlyCompiled = sinon + .stub() + .resolves(true) + const { status } = await ctx.CompileManager.promises.compile( + ctx.project_id, + ctx.user_id, + {} + ) + status.should.equal('too-recently-compiled') + }) + }) + + describe('should check the rate limit', function () { + it('should return', async function (ctx) { + ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit = sinon + .stub() + .resolves(false) + const { status } = await ctx.CompileManager.promises.compile( + ctx.project_id, + ctx.user_id, + {} + ) + + expect(status).to.equal('autocompile-backoff') + }) + }) + }) + + describe('getProjectCompileLimits', function () { + beforeEach(async function (ctx) { + ctx.features = { + compileTimeout: (ctx.timeout = 42), + compileGroup: (ctx.group = 'priority'), + } + ctx.ProjectGetter.promises.getProject = sinon + .stub() + .resolves( + (ctx.project = { owner_ref: (ctx.owner_id = 'owner-id-123') }) + ) + ctx.UserGetter.promises.getUser = sinon + .stub() + .resolves((ctx.user = { features: ctx.features, analyticsId: 'abc' })) + try { + const result = + await ctx.CompileManager.promises.getProjectCompileLimits( + ctx.project_id + ) + ctx.callback(null, result) + } catch (error) { + ctx.callback(error) + } + }) + + it('should look up the owner of the project', function (ctx) { + ctx.ProjectGetter.promises.getProject + .calledWith(ctx.project_id, { owner_ref: 1 }) + .should.equal(true) + }) + + it("should look up the owner's features", function (ctx) { + ctx.UserGetter.promises.getUser + .calledWith(ctx.project.owner_ref, { + _id: 1, + alphaProgram: 1, + analyticsId: 1, + betaProgram: 1, + features: 1, + }) + .should.equal(true) + }) + + it('should return the limits', function (ctx) { + ctx.callback + .calledWith(null, { + timeout: ctx.timeout, + compileGroup: ctx.group, + compileBackendClass: 'c2d', + ownerAnalyticsId: 'abc', + }) + .should.equal(true) + }) + }) + + describe('compileBackendClass', function () { + beforeEach(function (ctx) { + ctx.features = { + compileTimeout: 42, + compileGroup: 'standard', + } + ctx.ProjectGetter.promises.getProject = sinon + .stub() + .resolves({ owner_ref: 'owner-id-123' }) + ctx.UserGetter.promises.getUser = sinon + .stub() + .resolves({ features: ctx.features, analyticsId: 'abc' }) + }) + + describe('with priority compile', function () { + beforeEach(function (ctx) { + ctx.features.compileGroup = 'priority' + }) + it('should return the default class', async function (ctx) { + const { compileBackendClass } = + await ctx.CompileManager.promises.getProjectCompileLimits( + ctx.project_id + ) + expect(compileBackendClass).to.equal('c2d') + }) + }) + }) + + describe('deleteAuxFiles', function () { + let result + + beforeEach(async function (ctx) { + ctx.CompileManager.promises.getProjectCompileLimits = sinon + .stub() + .resolves((ctx.limits = { compileGroup: 'mock-compile-group' })) + ctx.ClsiManager.promises.deleteAuxFiles = sinon.stub().resolves('test') + result = await ctx.CompileManager.promises.deleteAuxFiles( + ctx.project_id, + ctx.user_id + ) + }) + + it('should look up the compile group to use', function (ctx) { + ctx.CompileManager.promises.getProjectCompileLimits + .calledWith(ctx.project_id) + .should.equal(true) + }) + + it('should delete the aux files', function (ctx) { + ctx.ClsiManager.promises.deleteAuxFiles + .calledWith(ctx.project_id, ctx.user_id, ctx.limits) + .should.equal(true) + }) + + it('should resolve', function () { + expect(result).not.to.be.undefined + }) + }) + + describe('_checkIfRecentlyCompiled', function () { + describe('when the key exists in redis', function () { + let result + + beforeEach(async function (ctx) { + ctx.rclient.set = sinon.stub().resolves(null) + result = await ctx.CompileManager._checkIfRecentlyCompiled( + ctx.project_id, + ctx.user_id + ) + }) + + it('should try to set the key', function (ctx) { + ctx.rclient.set + .calledWith( + `compile:${ctx.project_id}:${ctx.user_id}`, + true, + 'EX', + ctx.CompileManager.COMPILE_DELAY, + 'NX' + ) + .should.equal(true) + }) + + it('should resolve with true', function () { + result.should.equal(true) + }) + }) + + describe('when the key does not exist in redis', function () { + let result + + beforeEach(async function (ctx) { + ctx.rclient.set = sinon.stub().resolves('OK') + result = await ctx.CompileManager._checkIfRecentlyCompiled( + ctx.project_id, + ctx.user_id + ) + }) + + it('should try to set the key', function (ctx) { + ctx.rclient.set + .calledWith( + `compile:${ctx.project_id}:${ctx.user_id}`, + true, + 'EX', + ctx.CompileManager.COMPILE_DELAY, + 'NX' + ) + .should.equal(true) + }) + + it('should resolve with false', function () { + result.should.equal(false) + }) + }) + }) + + describe('_checkIfAutoCompileLimitHasBeenHit', function () { + it('should be able to compile if it is not an autocompile', async function (ctx) { + const canCompile = + await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit( + false, + 'everyone' + ) + expect(canCompile).to.equal(true) + }) + + it('should be able to compile if rate limit has remaining', async function (ctx) { + const canCompile = + await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit( + true, + 'everyone' + ) + + expect(ctx.rateLimiter.consume).to.have.been.calledWith('global') + expect(canCompile).to.equal(true) + }) + + it('should be not able to compile if rate limit has no remianing', async function (ctx) { + ctx.rateLimiter.consume.rejects({ remainingPoints: 0 }) + const canCompile = + await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit( + true, + 'everyone' + ) + + expect(canCompile).to.equal(false) + }) + + it('should return false if there is an error in the rate limit', async function (ctx) { + ctx.rateLimiter.consume.rejects(new Error('BOOM!')) + const canCompile = + await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit( + true, + 'everyone' + ) + + expect(canCompile).to.equal(false) + }) + }) + + describe('wordCount', function () { + let result + const wordCount = 1 + + beforeEach(async function (ctx) { + ctx.CompileManager.promises.getProjectCompileLimits = sinon + .stub() + .resolves((ctx.limits = { compileGroup: 'mock-compile-group' })) + ctx.ClsiManager.promises.wordCount = sinon.stub().resolves(wordCount) + result = await ctx.CompileManager.promises.wordCount( + ctx.project_id, + ctx.user_id, + false + ) + }) + + it('should look up the compile group to use', function (ctx) { + ctx.CompileManager.promises.getProjectCompileLimits + .calledWith(ctx.project_id) + .should.equal(true) + }) + + it('should call wordCount for project', function (ctx) { + ctx.ClsiManager.promises.wordCount + .calledWith(ctx.project_id, ctx.user_id, false, ctx.limits) + .should.equal(true) + }) + + it('should resolve with the wordCount from the ClsiManager', function () { + expect(result).to.equal(wordCount) + }) + }) +}) diff --git a/services/web/test/unit/src/Compile/CompileManagerTests.js b/services/web/test/unit/src/Compile/CompileManagerTests.js deleted file mode 100644 index d6bd844e76..0000000000 --- a/services/web/test/unit/src/Compile/CompileManagerTests.js +++ /dev/null @@ -1,426 +0,0 @@ -const { expect } = require('chai') -const sinon = require('sinon') -const SandboxedModule = require('sandboxed-module') - -const MODULE_PATH = '../../../../app/src/Features/Compile/CompileManager.js' - -describe('CompileManager', function () { - beforeEach(function () { - this.rateLimiter = { - consume: sinon.stub().resolves(), - } - this.RateLimiter = { - RateLimiter: sinon.stub().returns(this.rateLimiter), - } - this.timer = { - done: sinon.stub(), - } - this.Metrics = { - Timer: sinon.stub().returns(this.timer), - inc: sinon.stub(), - } - this.CompileManager = SandboxedModule.require(MODULE_PATH, { - requires: { - '@overleaf/settings': (this.settings = { - apis: { - clsi: { submissionBackendClass: 'n2d' }, - }, - redis: { web: { host: '127.0.0.1', port: 42 } }, - rateLimit: { autoCompile: {} }, - }), - '../../infrastructure/RedisWrapper': { - client: () => - (this.rclient = { - auth() {}, - }), - }, - '../Project/ProjectRootDocManager': (this.ProjectRootDocManager = { - promises: {}, - }), - '../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }), - '../User/UserGetter': (this.UserGetter = { promises: {} }), - './ClsiManager': (this.ClsiManager = { promises: {} }), - '../../infrastructure/RateLimiter': this.RateLimiter, - '@overleaf/metrics': this.Metrics, - '../Analytics/UserAnalyticsIdCache': (this.UserAnalyticsIdCache = { - get: sinon.stub().resolves('abc'), - }), - '../SplitTests/SplitTestHandler': (this.SplitTestHandler = { - promises: {}, - }), - }, - }) - this.project_id = 'mock-project-id-123' - this.user_id = 'mock-user-id-123' - this.callback = sinon.stub() - this.limits = { - timeout: 42, - compileGroup: 'standard', - } - }) - - describe('compile', function () { - beforeEach(function () { - this.CompileManager._checkIfRecentlyCompiled = sinon - .stub() - .resolves(false) - this.ProjectRootDocManager.promises.ensureRootDocumentIsSet = sinon - .stub() - .resolves() - this.CompileManager.promises.getProjectCompileLimits = sinon - .stub() - .resolves(this.limits) - this.ClsiManager.promises.sendRequest = sinon.stub().resolves({ - status: (this.status = 'mock-status'), - outputFiles: (this.outputFiles = []), - clsiServerId: (this.output = 'mock output'), - }) - }) - - describe('succesfully', function () { - let result - beforeEach(async function () { - this.CompileManager._checkIfAutoCompileLimitHasBeenHit = async ( - isAutoCompile, - compileGroup - ) => true - this.ProjectGetter.promises.getProject = sinon - .stub() - .resolves( - (this.project = { owner_ref: (this.owner_id = 'owner-id-123') }) - ) - this.UserGetter.promises.getUser = sinon.stub().resolves( - (this.user = { - features: { compileTimeout: '20s', compileGroup: 'standard' }, - analyticsId: 'abc', - }) - ) - result = await this.CompileManager.promises.compile( - this.project_id, - this.user_id, - {} - ) - }) - - it('should check the project has not been recently compiled', function () { - this.CompileManager._checkIfRecentlyCompiled - .calledWith(this.project_id, this.user_id) - .should.equal(true) - }) - - it('should ensure that the root document is set', function () { - this.ProjectRootDocManager.promises.ensureRootDocumentIsSet - .calledWith(this.project_id) - .should.equal(true) - }) - - it('should get the project compile limits', function () { - this.CompileManager.promises.getProjectCompileLimits - .calledWith(this.project_id) - .should.equal(true) - }) - - it('should run the compile with the compile limits', function () { - this.ClsiManager.promises.sendRequest - .calledWith(this.project_id, this.user_id, { - timeout: this.limits.timeout, - compileGroup: 'standard', - buildId: sinon.match(/[a-f0-9]+-[a-f0-9]+/), - }) - .should.equal(true) - }) - - it('should resolve with the output', function () { - expect(result).to.haveOwnProperty('status', this.status) - expect(result).to.haveOwnProperty('clsiServerId', this.output) - expect(result).to.haveOwnProperty('outputFiles', this.outputFiles) - }) - - it('should time the compile', function () { - this.timer.done.called.should.equal(true) - }) - }) - - describe('when the project has been recently compiled', function () { - it('should return', async function () { - this.CompileManager._checkIfAutoCompileLimitHasBeenHit = async ( - isAutoCompile, - compileGroup - ) => true - this.CompileManager._checkIfRecentlyCompiled = sinon - .stub() - .resolves(true) - const { status } = await this.CompileManager.promises.compile( - this.project_id, - this.user_id, - {} - ) - status.should.equal('too-recently-compiled') - }) - }) - - describe('should check the rate limit', function () { - it('should return', async function () { - this.CompileManager._checkIfAutoCompileLimitHasBeenHit = sinon - .stub() - .resolves(false) - const { status } = await this.CompileManager.promises.compile( - this.project_id, - this.user_id, - {} - ) - - expect(status).to.equal('autocompile-backoff') - }) - }) - }) - - describe('getProjectCompileLimits', function () { - beforeEach(async function () { - this.features = { - compileTimeout: (this.timeout = 42), - compileGroup: (this.group = 'priority'), - } - this.ProjectGetter.promises.getProject = sinon - .stub() - .resolves( - (this.project = { owner_ref: (this.owner_id = 'owner-id-123') }) - ) - this.UserGetter.promises.getUser = sinon - .stub() - .resolves((this.user = { features: this.features, analyticsId: 'abc' })) - try { - const result = - await this.CompileManager.promises.getProjectCompileLimits( - this.project_id - ) - this.callback(null, result) - } catch (error) { - this.callback(error) - } - }) - - it('should look up the owner of the project', function () { - this.ProjectGetter.promises.getProject - .calledWith(this.project_id, { owner_ref: 1 }) - .should.equal(true) - }) - - it("should look up the owner's features", function () { - this.UserGetter.promises.getUser - .calledWith(this.project.owner_ref, { - _id: 1, - alphaProgram: 1, - analyticsId: 1, - betaProgram: 1, - features: 1, - }) - .should.equal(true) - }) - - it('should return the limits', function () { - this.callback - .calledWith(null, { - timeout: this.timeout, - compileGroup: this.group, - compileBackendClass: 'c2d', - ownerAnalyticsId: 'abc', - }) - .should.equal(true) - }) - }) - - describe('compileBackendClass', function () { - beforeEach(function () { - this.features = { - compileTimeout: 42, - compileGroup: 'standard', - } - this.ProjectGetter.promises.getProject = sinon - .stub() - .resolves({ owner_ref: 'owner-id-123' }) - this.UserGetter.promises.getUser = sinon - .stub() - .resolves({ features: this.features, analyticsId: 'abc' }) - }) - - describe('with priority compile', function () { - beforeEach(function () { - this.features.compileGroup = 'priority' - }) - it('should return the default class', async function () { - const { compileBackendClass } = - await this.CompileManager.promises.getProjectCompileLimits( - this.project_id - ) - expect(compileBackendClass).to.equal('c2d') - }) - }) - }) - - describe('deleteAuxFiles', function () { - let result - - beforeEach(async function () { - this.CompileManager.promises.getProjectCompileLimits = sinon - .stub() - .resolves((this.limits = { compileGroup: 'mock-compile-group' })) - this.ClsiManager.promises.deleteAuxFiles = sinon.stub().resolves('test') - result = await this.CompileManager.promises.deleteAuxFiles( - this.project_id, - this.user_id - ) - }) - - it('should look up the compile group to use', function () { - this.CompileManager.promises.getProjectCompileLimits - .calledWith(this.project_id) - .should.equal(true) - }) - - it('should delete the aux files', function () { - this.ClsiManager.promises.deleteAuxFiles - .calledWith(this.project_id, this.user_id, this.limits) - .should.equal(true) - }) - - it('should resolve', function () { - expect(result).not.to.be.undefined - }) - }) - - describe('_checkIfRecentlyCompiled', function () { - describe('when the key exists in redis', function () { - let result - - beforeEach(async function () { - this.rclient.set = sinon.stub().resolves(null) - result = await this.CompileManager._checkIfRecentlyCompiled( - this.project_id, - this.user_id - ) - }) - - it('should try to set the key', function () { - this.rclient.set - .calledWith( - `compile:${this.project_id}:${this.user_id}`, - true, - 'EX', - this.CompileManager.COMPILE_DELAY, - 'NX' - ) - .should.equal(true) - }) - - it('should resolve with true', function () { - result.should.equal(true) - }) - }) - - describe('when the key does not exist in redis', function () { - let result - - beforeEach(async function () { - this.rclient.set = sinon.stub().resolves('OK') - result = await this.CompileManager._checkIfRecentlyCompiled( - this.project_id, - this.user_id - ) - }) - - it('should try to set the key', function () { - this.rclient.set - .calledWith( - `compile:${this.project_id}:${this.user_id}`, - true, - 'EX', - this.CompileManager.COMPILE_DELAY, - 'NX' - ) - .should.equal(true) - }) - - it('should resolve with false', function () { - result.should.equal(false) - }) - }) - }) - - describe('_checkIfAutoCompileLimitHasBeenHit', function () { - it('should be able to compile if it is not an autocompile', async function () { - const canCompile = - await this.CompileManager._checkIfAutoCompileLimitHasBeenHit( - false, - 'everyone' - ) - expect(canCompile).to.equal(true) - }) - - it('should be able to compile if rate limit has remaining', async function () { - const canCompile = - await this.CompileManager._checkIfAutoCompileLimitHasBeenHit( - true, - 'everyone' - ) - - expect(this.rateLimiter.consume).to.have.been.calledWith('global') - expect(canCompile).to.equal(true) - }) - - it('should be not able to compile if rate limit has no remianing', async function () { - this.rateLimiter.consume.rejects({ remainingPoints: 0 }) - const canCompile = - await this.CompileManager._checkIfAutoCompileLimitHasBeenHit( - true, - 'everyone' - ) - - expect(canCompile).to.equal(false) - }) - - it('should return false if there is an error in the rate limit', async function () { - this.rateLimiter.consume.rejects(new Error('BOOM!')) - const canCompile = - await this.CompileManager._checkIfAutoCompileLimitHasBeenHit( - true, - 'everyone' - ) - - expect(canCompile).to.equal(false) - }) - }) - - describe('wordCount', function () { - let result - const wordCount = 1 - - beforeEach(async function () { - this.CompileManager.promises.getProjectCompileLimits = sinon - .stub() - .resolves((this.limits = { compileGroup: 'mock-compile-group' })) - this.ClsiManager.promises.wordCount = sinon.stub().resolves(wordCount) - result = await this.CompileManager.promises.wordCount( - this.project_id, - this.user_id, - false - ) - }) - - it('should look up the compile group to use', function () { - this.CompileManager.promises.getProjectCompileLimits - .calledWith(this.project_id) - .should.equal(true) - }) - - it('should call wordCount for project', function () { - this.ClsiManager.promises.wordCount - .calledWith(this.project_id, this.user_id, false, this.limits) - .should.equal(true) - }) - - it('should resolve with the wordCount from the ClsiManager', function () { - expect(result).to.equal(wordCount) - }) - }) -}) diff --git a/services/web/test/unit/src/Editor/EditorHttpController.test.mjs b/services/web/test/unit/src/Editor/EditorHttpController.test.mjs index a49dc2f727..a677f065b2 100644 --- a/services/web/test/unit/src/Editor/EditorHttpController.test.mjs +++ b/services/web/test/unit/src/Editor/EditorHttpController.test.mjs @@ -252,9 +252,12 @@ describe('EditorHttpController', function () { default: ctx.SplitTestHandler, }) ) - vi.doMock('../../../../app/src/Features/Compile/CompileManager.js', () => ({ - default: {}, - })) + vi.doMock( + '../../../../app/src/Features/Compile/CompileManager.mjs', + () => ({ + default: {}, + }) + ) vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({ default: ctx.UserGetter, })) diff --git a/services/web/test/unit/src/SamlLog/SamlLogHandlerTests.js b/services/web/test/unit/src/SamlLog/SamlLogHandler.test.mjs similarity index 78% rename from services/web/test/unit/src/SamlLog/SamlLogHandlerTests.js rename to services/web/test/unit/src/SamlLog/SamlLogHandler.test.mjs index d1c2e1058a..f9f7e51615 100644 --- a/services/web/test/unit/src/SamlLog/SamlLogHandlerTests.js +++ b/services/web/test/unit/src/SamlLog/SamlLogHandler.test.mjs @@ -1,27 +1,31 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const { expect } = require('chai') +import { vi, expect } from 'vitest' +import sinon from 'sinon' -const modulePath = '../../../../app/src/Features/SamlLog/SamlLogHandler' +const modulePath = '../../../../app/src/Features/SamlLog/SamlLogHandler.mjs' describe('SamlLogHandler', function () { - let SamlLog, SamlLogHandler, SamlLogModel + let SamlLog, SamlLogHandler let data, providerId, samlLog, sessionId - beforeEach(function () { + beforeEach(async function (ctx) { samlLog = { save: sinon.stub(), } SamlLog = function () { return samlLog } - SamlLogModel = { SamlLog } - SamlLogHandler = SandboxedModule.require(modulePath, { - requires: { - '../../models/SamlLog': SamlLogModel, - }, - }) + + ctx.logger = { + error: sinon.stub(), + } + vi.doMock('@overleaf/logger', () => ({ + default: ctx.logger, + })) + + vi.doMock('../../../../app/src/models/SamlLog', () => ({ SamlLog })) + + SamlLogHandler = (await import(modulePath)).default data = { foo: true } providerId = 'provider-id' @@ -69,13 +73,13 @@ describe('SamlLogHandler', function () { ) }) - it('should log without data and log error', function () { + it('should log without data and log error', function (ctx) { samlLog.providerId.should.equal(providerId) samlLog.sessionId.should.equal(sessionId.substr(0, 8)) expect(samlLog.data).to.be.undefined expect(samlLog.jsonData).to.be.undefined samlLog.save.should.have.been.calledOnce - this.logger.error.should.have.been.calledOnce.and.calledWithMatch( + ctx.logger.error.should.have.been.calledOnce.and.calledWithMatch( { providerId, sessionId: sessionId.substr(0, 8) }, 'SamlLog JSON.stringify Error' ) @@ -99,8 +103,8 @@ describe('SamlLogHandler', function () { ) }) - it('should log error', function () { - this.logger.error.should.have.been.calledOnce.and.calledWithMatch( + it('should log error', function (ctx) { + ctx.logger.error.should.have.been.calledOnce.and.calledWithMatch( { err, sessionId: sessionId.substr(0, 8), @@ -127,8 +131,8 @@ describe('SamlLogHandler', function () { ) }) - it('should log error', function () { - this.logger.error.should.have.been.calledOnce.and.calledWithMatch( + it('should log error', function (ctx) { + ctx.logger.error.should.have.been.calledOnce.and.calledWithMatch( { err, sessionId: sessionId.substr(0, 8), @@ -155,8 +159,8 @@ describe('SamlLogHandler', function () { ) }) - it('should not log any error', function () { - this.logger.error.should.not.have.been.called + it('should not log any error', function (ctx) { + ctx.logger.error.should.not.have.been.called }) }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs new file mode 100644 index 0000000000..c880a9164a --- /dev/null +++ b/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs @@ -0,0 +1,1560 @@ +import { vi, assert, expect } from 'vitest' +import sinon from 'sinon' +import MockRequest from '../helpers/MockRequest.js' +import MockResponse from '../helpers/MockResponse.js' +import SubscriptionErrors from '../../../../app/src/Features/Subscription/Errors.js' +import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.js' +import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.js' + +const modulePath = + '../../../../app/src/Features/Subscription/SubscriptionController.mjs' + +const mockSubscriptions = { + 'subscription-123-active': { + uuid: 'subscription-123-active', + plan: { + name: 'Gold', + plan_code: 'gold', + }, + current_period_ends_at: new Date(), + state: 'active', + unit_amount_in_cents: 999, + account: { + account_code: 'user-123', + }, + }, +} + +describe('SubscriptionController', function () { + beforeEach(async function (ctx) { + ctx.logger = { + debug: sinon.stub(), + warn: sinon.stub(), + } + vi.doMock('@overleaf/logger', () => ({ + default: ctx.logger, + })) + + ctx.user = { + email: 'tom@yahoo.com', + _id: 'one', + signUpDate: new Date('2000-10-01'), + emails: [{ email: 'tom@yahoo.com', confirmedAt: new Date('2000-10-02') }], + } + ctx.activeRecurlySubscription = mockSubscriptions['subscription-123-active'] + + ctx.SessionManager = { + getLoggedInUser: sinon.stub().callsArgWith(1, null, ctx.user), + getLoggedInUserId: sinon.stub().returns(ctx.user._id), + getSessionUser: sinon.stub().returns(ctx.user), + isUserLoggedIn: sinon.stub().returns(true), + } + ctx.SubscriptionHandler = { + createSubscription: sinon.stub().callsArgWith(3), + updateSubscription: sinon.stub().callsArgWith(3), + reactivateSubscription: sinon.stub().callsArgWith(1), + cancelSubscription: sinon.stub().callsArgWith(1), + syncSubscription: sinon.stub().yields(), + attemptPaypalInvoiceCollection: sinon.stub().yields(), + startFreeTrial: sinon.stub(), + revertPlanChange: sinon.stub(), + promises: { + createSubscription: sinon.stub().resolves(), + updateSubscription: sinon.stub().resolves(), + reactivateSubscription: sinon.stub().resolves(), + cancelSubscription: sinon.stub().resolves(), + pauseSubscription: sinon.stub().resolves(), + resumeSubscription: sinon.stub().resolves(), + syncSubscription: sinon.stub().resolves(), + attemptPaypalInvoiceCollection: sinon.stub().resolves(), + startFreeTrial: sinon.stub().resolves(), + purchaseAddon: sinon.stub().resolves(), + previewAddonPurchase: sinon.stub().resolves({ + subscription: { + currency: 'USD', + netTerms: 0, + periodEnd: new Date(), + taxRate: 0, + }, + immediateCharge: { amount: 0 }, + nextPlanCode: 'professional', + nextPlanName: 'Professional', + nextPlanPrice: 2000, + nextAddOns: [], + subtotal: 2000, + tax: 0, + total: 2000, + }), + revertPlanChange: sinon.stub().resolves(), + }, + } + + ctx.LimitationsManager = { + hasPaidSubscription: sinon.stub(), + userHasSubscription: sinon + .stub() + .yields(null, { hasSubscription: false }), + promises: { + hasPaidSubscription: sinon.stub().resolves(), + userHasSubscription: sinon.stub().resolves({ hasSubscription: false }), + }, + } + + ctx.SubscriptionViewModelBuilder = { + buildUsersSubscriptionViewModel: sinon.stub().callsArgWith(1, null, {}), + buildPlansList: sinon.stub(), + promises: { + buildUsersSubscriptionViewModel: sinon.stub().resolves({}), + }, + buildPlansListForSubscriptionDash: sinon + .stub() + .returns({ plans: [], planCodesChangingAtTermEnd: [] }), + } + ctx.settings = { + coupon_codes: { + upgradeToAnnualPromo: { + student: 'STUDENTCODEHERE', + collaborator: 'COLLABORATORCODEHERE', + }, + }, + groupPlanModalOptions: { + plan_codes: [], + currencies: [ + { + display: 'GBP (£)', + code: 'GBP', + }, + ], + sizes: ['42'], + usages: [{ code: 'foo', display: 'Foo' }], + }, + apis: { + recurly: { + subdomain: 'sl', + }, + }, + planReverts: { + enabled: false, + }, + siteUrl: 'http://de.overleaf.dev:3000', + } + ctx.AuthorizationManager = { + promises: { + isUserSiteAdmin: sinon.stub().resolves(false), + }, + } + ctx.GeoIpLookup = { + isValidCurrencyParam: sinon.stub().returns(true), + getCurrencyCode: sinon.stub().yields('USD', 'US'), + promises: { + getCurrencyCode: sinon.stub().resolves({ + countryCode: 'US', + currencyCode: 'USD', + }), + }, + } + ctx.UserGetter = { + getUser: sinon.stub().callsArgWith(2, null, ctx.user), + promises: { + getUser: sinon.stub().resolves(ctx.user), + getWritefullData: sinon + .stub() + .resolves({ isPremium: false, premiumSource: null }), + }, + } + ctx.SplitTestV2Hander = { + promises: { + getAssignment: sinon.stub().resolves({ variant: 'default' }), + }, + } + ctx.Features = { + hasFeature: sinon.stub().returns(false), + } + + vi.doMock( + '../../../../app/src/Features/Authorization/AuthorizationManager', + () => ({ + default: ctx.AuthorizationManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestV2Hander, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authentication/SessionManager', + () => ({ + default: ctx.SessionManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionHandler', + () => ({ + default: ctx.SubscriptionHandler, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionHelper', + () => ({ + default: SubscriptionHelper, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder', + () => ({ + default: ctx.SubscriptionViewModelBuilder, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/LimitationsManager', + () => ({ + default: ctx.LimitationsManager, + }) + ) + + vi.doMock('../../../../app/src/infrastructure/GeoIpLookup', () => ({ + default: ctx.GeoIpLookup, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ + default: ctx.UserGetter, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/RecurlyWrapper', + () => ({ + default: (ctx.RecurlyWrapper = { + promises: { + updateAccountEmailAddress: sinon.stub().resolves(), + getSubscription: sinon.stub().resolves({}), + }, + }), + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/RecurlyEventHandler', + () => ({ + default: { + sendRecurlyAnalyticsEvent: sinon.stub().resolves(), + }, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/FeaturesUpdater', + () => ({ + default: (ctx.FeaturesUpdater = { + promises: { + refreshFeatures: sinon.stub().resolves({ features: {} }), + }, + }), + }) + ) + + vi.doMock('celebrate', () => ({ + default: (ctx.celebrate = { + celebrate: sinon.stub(), + errors: sinon.stub(), + Joi: { + any: sinon.stub(), + extend: sinon.stub(), + }, + }), + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/GroupPlansData', + () => ({ + default: (ctx.GroupPlansData = {}), + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/V1SubscriptionManager', + () => ({ + default: (ctx.V1SubscriptionManager = {}), + }) + ) + + vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({ + default: (ctx.HttpErrorHandler = { + unprocessableEntity: sinon.stub().callsFake((req, res, message) => { + res.status(422) + res.json({ message }) + }), + badRequest: sinon.stub().callsFake((req, res, message) => { + res.status(400) + res.json({ message }) + }), + }), + })) + + vi.doMock('../../../../app/src/Features/Subscription/Errors', () => ({ + default: SubscriptionErrors, + })) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: (ctx.AnalyticsManager = { + recordEventForUser: sinon.stub(), + recordEventForUserInBackground: sinon.stub(), + recordEventForSession: sinon.stub(), + setUserPropertyForUser: sinon.stub(), + }), + }) + ) + + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: (ctx.Modules = { + promises: { hooks: { fire: sinon.stub().resolves() } }, + }), + })) + + vi.doMock('../../../../app/src/infrastructure/Features', () => ({ + default: ctx.Features, + })) + + vi.doMock('../../../../app/src/util/currency', () => ({ + default: (ctx.currency = { + formatCurrency: sinon.stub(), + }), + })) + + vi.doMock('../../../../app/src/models/User', () => ({ + User: { + findById: sinon.stub().resolves(ctx.user), + }, + })) + + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionLocator', + () => ({ + default: (ctx.SubscriptionLocator = { + promises: { + getUsersSubscription: sinon.stub().resolves(null), + }, + }), + }) + ) + + vi.doMock( + '../../../../app/src/Features/Authorization/PermissionsManager', + () => ({ + default: (ctx.PermissionsManager = { + promises: { + checkUserPermissions: sinon.stub().resolves(true), + }, + }), + }) + ) + + vi.doMock( + '../../../../app/src/Features/Subscription/RecurlyClient', + () => ({ + default: (ctx.RecurlyClient = { + promises: { + getAddOn: sinon.stub().resolves({ + code: 'ai-assistant', + name: 'AI Assistant', + }), + }, + }), + }) + ) + + vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({ + default: (ctx.PlansLocator = { + findLocalPlanInSettings: sinon.stub().returns({ + annual: false, + }), + }), + })) + + ctx.SubscriptionController = (await import(modulePath)).default + + ctx.res = new MockResponse() + ctx.req = new MockRequest() + ctx.req.body = {} + ctx.req.query = { planCode: '123123' } + + ctx.stubbedCurrencyCode = 'GBP' + }) + + describe('successfulSubscription', function () { + it('without a personal subscription', async function (ctx) { + await new Promise(resolve => { + ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves( + {} + ) + ctx.res.redirect = url => { + url.should.equal('/user/subscription/plans') + resolve() + } + ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res) + }) + }) + + it('with a personal subscription', async function (ctx) { + await new Promise(resolve => { + ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves( + { + personalSubscription: 'foo', + } + ) + ctx.res.render = (url, variables) => { + url.should.equal('subscriptions/successful-subscription-react') + assert.deepEqual(variables, { + title: 'thank_you', + personalSubscription: 'foo', + postCheckoutRedirect: undefined, + user: { + _id: ctx.user._id, + features: ctx.user.features, + }, + }) + resolve() + } + ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res) + }) + }) + + it('with an error', async function (ctx) { + await new Promise(resolve => { + ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves( + undefined + ) + ctx.SubscriptionController.successfulSubscription( + ctx.req, + ctx.res, + error => { + assert.isNotNull(error) + resolve() + } + ) + }) + }) + }) + + describe('userSubscriptionPage', function () { + beforeEach(async function (ctx) { + await new Promise((resolve, reject) => { + ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves( + { + personalSubscription: (ctx.personalSubscription = { + 'personal-subscription': 'mock', + }), + memberGroupSubscriptions: (ctx.memberGroupSubscriptions = { + 'group-subscriptions': 'mock', + }), + } + ) + ctx.SubscriptionViewModelBuilder.buildPlansList.returns( + (ctx.plans = { plans: 'mock' }) + ) + ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash.returns( + { + plans: ctx.plans, + planCodesChangingAtTermEnd: [], + } + ) + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + hasSubscription: false, + }) + ctx.res.render = (view, data) => { + ctx.data = data + expect(view).to.equal('subscriptions/dashboard-react') + resolve() + } + ctx.SubscriptionController.userSubscriptionPage( + ctx.req, + ctx.res, + ctx.rejectOnError(reject) + ) + }) + }) + + it('should load the personal, groups and v1 subscriptions', function (ctx) { + expect(ctx.data.personalSubscription).to.deep.equal( + ctx.personalSubscription + ) + expect(ctx.data.memberGroupSubscriptions).to.deep.equal( + ctx.memberGroupSubscriptions + ) + }) + + it('should load the user', function (ctx) { + expect(ctx.data.user).to.deep.equal(ctx.user) + }) + + it('should load the plans', function (ctx) { + expect(ctx.data.plans).to.deep.equal(ctx.plans) + }) + + it('should load an empty list of groups with settings available', function (ctx) { + expect(ctx.data.groupSettingsEnabledFor).to.deep.equal([]) + }) + }) + + describe('updateAccountEmailAddress via put', function () { + beforeEach(function (ctx) { + ctx.req.body = { + account_email: 'current_account_email@overleaf.com', + } + }) + + it('should send the user and subscriptionId to "updateAccountEmailAddress" hooks', async function (ctx) { + ctx.res.sendStatus = sinon.spy() + + await ctx.SubscriptionController.updateAccountEmailAddress( + ctx.req, + ctx.res + ) + + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'updateAccountEmailAddress', + ctx.user._id, + ctx.user.email + ) + }) + + it('should respond with 200', async function (ctx) { + ctx.res.sendStatus = sinon.spy() + await ctx.SubscriptionController.updateAccountEmailAddress( + ctx.req, + ctx.res + ) + ctx.res.sendStatus.calledWith(200).should.equal(true) + }) + + it('should send the error to the next handler when updating recurly account email fails', async function (ctx) { + ctx.Modules.promises.hooks.fire + .withArgs('updateAccountEmailAddress', ctx.user._id, ctx.user.email) + .rejects(new Error()) + + ctx.next = sinon.spy(error => { + expect(error).to.be.instanceOf(Error) + }) + await ctx.SubscriptionController.updateAccountEmailAddress( + ctx.req, + ctx.res, + ctx.next + ) + expect(ctx.next.calledOnce).to.be.true + }) + }) + + describe('reactivateSubscription', function () { + describe('when the user has permission', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.res = { + redirect() { + resolve() + }, + } + ctx.req.assertPermission = sinon.stub() + ctx.next = sinon.stub().callsFake(error => { + resolve(error) + }) + sinon.spy(ctx.res, 'redirect') + ctx.SubscriptionController.reactivateSubscription( + ctx.req, + ctx.res, + ctx.next + ) + }) + }) + + it('should assert the user has permission to reactivate their subscription', async function (ctx) { + await new Promise(resolve => { + ctx.req.assertPermission + .calledWith('reactivate-subscription') + .should.equal(true) + resolve() + }) + }) + + it('should tell the handler to reactivate this user', async function (ctx) { + await new Promise(resolve => { + ctx.SubscriptionHandler.reactivateSubscription + .calledWith(ctx.user) + .should.equal(true) + resolve() + }) + }) + + it('should redurect to the subscription page', async function (ctx) { + await new Promise(resolve => { + ctx.res.redirect.calledWith('/user/subscription').should.equal(true) + resolve() + }) + }) + }) + + describe('when the user does not have permission', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.res = { + redirect() { + resolve() + }, + } + ctx.req.assertPermission = sinon.stub().throws() + ctx.next = sinon.stub().callsFake(() => { + resolve() + }) + sinon.spy(ctx.res, 'redirect') + ctx.SubscriptionController.reactivateSubscription( + ctx.req, + ctx.res, + ctx.next + ) + }) + }) + + it('should not reactivate the user', async function (ctx) { + await new Promise(resolve => { + ctx.req.assertPermission = sinon.stub().throws() + ctx.SubscriptionHandler.reactivateSubscription.called.should.equal( + false + ) + resolve() + }) + }) + + it('should call next with an error', async function (ctx) { + await new Promise(resolve => { + ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) + resolve() + }) + }) + }) + }) + + describe('pauseSubscription', function () { + it('should throw an error if no pause length is provided', async function (ctx) { + ctx.res = new MockResponse() + ctx.req = new MockRequest() + ctx.next = sinon.stub() + await expect( + ctx.SubscriptionController.pauseSubscription(ctx.req, ctx.res, ctx.next) + ).to.be.rejectedWith('Not found') + }) + + it('should throw an error if an invalid pause length is provided', async function (ctx) { + ctx.res = new MockResponse() + ctx.req = new MockRequest() + ctx.req.params = { pauseCycles: '-10' } + ctx.next = sinon.stub() + await ctx.SubscriptionController.pauseSubscription( + ctx.req, + ctx.res, + ctx.next + ) + expect(ctx.res.statusCode).to.equal(400) + }) + + it('should return a 200 when requesting a pause', async function (ctx) { + ctx.res = new MockResponse() + ctx.req = new MockRequest() + ctx.req.params = { pauseCycles: '3' } + ctx.next = sinon.stub() + await ctx.SubscriptionController.pauseSubscription( + ctx.req, + ctx.res, + ctx.next + ) + expect(ctx.res.statusCode).to.equal(200) + }) + }) + + describe('resumeSubscription', function () { + it('should return a 200 when resuming a subscription', async function (ctx) { + ctx.res = new MockResponse() + ctx.req = new MockRequest() + ctx.next = sinon.stub() + await ctx.SubscriptionController.resumeSubscription( + ctx.req, + ctx.res, + ctx.next + ) + expect(ctx.res.statusCode).to.equal(200) + }) + }) + + describe('cancelSubscription', function () { + it('should tell the handler to cancel this user', async function (ctx) { + ctx.next = sinon.stub() + await ctx.SubscriptionController.cancelSubscription( + ctx.req, + ctx.res, + ctx.next + ) + ctx.SubscriptionHandler.promises.cancelSubscription + .calledWith(ctx.user) + .should.equal(true) + }) + + it('should return a 200 on success', async function (ctx) { + ctx.next = sinon.stub() + await ctx.SubscriptionController.cancelSubscription( + ctx.req, + ctx.res, + ctx.next + ) + expect(ctx.res.statusCode).to.equal(200) + }) + + it('should call next with error', async function (ctx) { + ctx.SubscriptionHandler.promises.cancelSubscription.rejects( + new Error('cancel error') + ) + ctx.next = sinon.stub() + await ctx.SubscriptionController.cancelSubscription( + ctx.req, + ctx.res, + ctx.next + ) + ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) + }) + }) + + describe('recurly callback', function () { + describe('with a sync subscription request', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.req = { + body: { + expired_subscription_notification: { + account: { + account_code: ctx.user._id, + }, + subscription: { + uuid: ctx.activeRecurlySubscription.uuid, + plan: { + plan_code: 'collaborator', + state: 'active', + }, + }, + }, + }, + } + ctx.res = { + sendStatus() { + resolve() + }, + } + sinon.spy(ctx.res, 'sendStatus') + ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res) + }) + }) + + it('should tell the SubscriptionHandler to process the recurly callback', async function (ctx) { + await new Promise(resolve => { + ctx.SubscriptionHandler.syncSubscription.called.should.equal(true) + resolve() + }) + }) + + it('should send a 200', async function (ctx) { + await new Promise(resolve => { + ctx.res.sendStatus.calledWith(200) + resolve() + }) + }) + }) + + describe('with a billing info updated request', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.req = { + body: { + billing_info_updated_notification: { + account: { + account_code: 'mock-account-code', + }, + }, + }, + } + ctx.res = { + sendStatus() { + resolve() + }, + } + sinon.spy(ctx.res, 'sendStatus') + ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res) + }) + }) + + it('should call attemptPaypalInvoiceCollection', async function (ctx) { + await new Promise(resolve => { + ctx.SubscriptionHandler.attemptPaypalInvoiceCollection + .calledWith('mock-account-code') + .should.equal(true) + resolve() + }) + }) + + it('should send a 200', async function (ctx) { + await new Promise(resolve => { + ctx.res.sendStatus.calledWith(200) + resolve() + }) + }) + }) + + describe('with a non-actionable request', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.user.id = ctx.activeRecurlySubscription.account.account_code + ctx.req = { + body: { + renewed_subscription_notification: { + account: { + account_code: ctx.user._id, + }, + subscription: { + uuid: ctx.activeRecurlySubscription.uuid, + plan: { + plan_code: 'collaborator', + state: 'active', + }, + }, + }, + }, + } + ctx.res = { + sendStatus() { + resolve() + }, + } + sinon.spy(ctx.res, 'sendStatus') + ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res) + }) + }) + + it('should not call the subscriptionshandler', function (ctx) { + ctx.SubscriptionHandler.syncSubscription.called.should.equal(false) + ctx.SubscriptionHandler.attemptPaypalInvoiceCollection.called.should.equal( + false + ) + }) + + it('should respond with a 200 status', function (ctx) { + ctx.res.sendStatus.calledWith(200) + }) + }) + + describe('with a failed payment notification', function () { + describe('with planReverts disabled in settings', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.settings.planReverts = { enabled: false } + ctx.SubscriptionHandler.revertPlanChange = sinon.stub() + + ctx.req.body = { + failed_payment_notification: { + transaction: { + subscription_id: 'subscription-123', + }, + }, + } + + ctx.res = { + sendStatus() { + resolve() + }, + } + sinon.spy(ctx.res, 'sendStatus') + ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res) + }) + }) + it('should not call revertPlanChange', function (ctx) { + expect(ctx.SubscriptionHandler.revertPlanChange.called).to.be.false + }) + + it('should respond with 200', async function (ctx) { + await new Promise(resolve => { + ctx.res.sendStatus.calledWith(200) + resolve() + }) + }) + }) + + describe('with planReverts enabled in settings', function () { + beforeEach(function (ctx) { + ctx.settings.planReverts = { enabled: true } + }) + + describe('with no valid restore point', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.SubscriptionHandler.getSubscriptionRestorePoint = sinon + .stub() + .yields(null, null) + ctx.SubscriptionHandler.revertPlanChange = sinon.stub() + + ctx.req.body = { + failed_payment_notification: { + transaction: { + subscription_id: 'subscription-123', + }, + }, + } + ctx.res = { + sendStatus() { + resolve() + }, + } + sinon.spy(ctx.res, 'sendStatus') + ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res) + }) + }) + it('should not call revertPlanChange()', function (ctx) { + expect(ctx.SubscriptionHandler.revertPlanChange.called).to.be.false + }) + + it('should respond with 200', function (ctx) { + ctx.res.sendStatus.calledWith(200) + }) + }) + + describe('with a valid restore point', function () { + beforeEach(async function (ctx) { + await new Promise(resolve => { + ctx.addOns = [ + { + addOnCode: 'addon-1', + quantity: 2, + unitAmountInCents: 500, + }, + { + addOnCode: 'addon-2', + quantity: 1, + unitAmountInCents: 600, + }, + ] + ctx.lastSubscription = { + planCode: 'gold', + addOns: ctx.addOns, + } + ctx.SubscriptionHandler.getSubscriptionRestorePoint = sinon + .stub() + .yields(null, ctx.lastSubscription) + ctx.SubscriptionHandler.revertPlanChange = sinon.stub().yields() + ctx.req.body = { + failed_payment_notification: { + transaction: { + subscription_id: 'subscription-123', + }, + }, + } + ctx.res = { + sendStatus() { + resolve() + }, + } + sinon.spy(ctx.res, 'sendStatus') + ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res) + }) + }) + + it('should get the subscription restore point', function (ctx) { + expect( + ctx.SubscriptionHandler.getSubscriptionRestorePoint.calledWith( + 'subscription-123' + ) + ).to.be.true + }) + + it('should call revertPlanChange()', function (ctx) { + expect( + ctx.SubscriptionHandler.revertPlanChange.calledWith( + 'subscription-123', + ctx.lastSubscription + ) + ).to.be.true + }) + + it('should respond with 200', function (ctx) { + ctx.res.sendStatus.calledWith(200) + }) + }) + }) + }) + }) + + describe('purchaseAddon', function () { + beforeEach(function (ctx) { + ctx.SessionManager.getSessionUser.returns(ctx.user) // Make sure getSessionUser returns the user + ctx.next = sinon.stub() + ctx.req.params = { addOnCode: AI_ADD_ON_CODE } // Mock add-on code + }) + + it('should return 200 on successful purchase of AI add-on', async function (ctx) { + await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next) + ctx.res.sendStatus = sinon.spy() + + await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next) + + expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.have.been.called + expect( + ctx.SubscriptionHandler.promises.purchaseAddon + ).to.have.been.calledWith(ctx.user._id, AI_ADD_ON_CODE, 1) + expect( + ctx.FeaturesUpdater.promises.refreshFeatures + ).to.have.been.calledWith(ctx.user._id, 'add-on-purchase') + expect(ctx.res.sendStatus).to.have.been.calledWith(200) + expect(ctx.logger.debug).to.have.been.calledWith( + { userId: ctx.user._id, addOnCode: AI_ADD_ON_CODE }, + 'purchasing add-ons' + ) + }) + + it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function (ctx) { + ctx.req.params = { addOnCode: 'some-other-addon' } + ctx.res.sendStatus = sinon.spy() + + await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next) + + expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.not.have.been + .called + expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been + .called + expect(ctx.res.sendStatus).to.have.been.calledWith(404) + }) + + it('should handle DuplicateAddOnError and send badRequest while sending 200', async function (ctx) { + ctx.req.params.addOnCode = AI_ADD_ON_CODE + ctx.SubscriptionHandler.promises.purchaseAddon.rejects( + new SubscriptionErrors.DuplicateAddOnError() + ) + + await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next) + + expect(ctx.HttpErrorHandler.badRequest).to.have.been.calledWith( + ctx.req, + ctx.res, + 'Your subscription already includes this add-on', + { addon: AI_ADD_ON_CODE } + ) + expect( + ctx.FeaturesUpdater.promises.refreshFeatures + ).to.have.been.calledWith(ctx.user._id, 'add-on-purchase') + expect(ctx.res.sendStatus).to.have.been.calledWith(200) + }) + + it('should handle PaymentActionRequiredError and return 402 with details', async function (ctx) { + ctx.req.params.addOnCode = AI_ADD_ON_CODE + const paymentError = new SubscriptionErrors.PaymentActionRequiredError({ + clientSecret: 'secret123', + publicKey: 'pubkey456', + }) + ctx.SubscriptionHandler.promises.purchaseAddon.rejects(paymentError) + + await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next) + + ctx.res.status.calledWith(402).should.equal(true) + ctx.res.json + .calledWith({ + message: 'Payment action required', + clientSecret: 'secret123', + publicKey: 'pubkey456', + }) + .should.equal(true) + + expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been + .called + }) + + it('should refresh features', async function (ctx) { + ctx.req.params.addOnCode = 'assistant' + ctx.SubscriptionHandler.promises.purchaseAddon = sinon.stub().resolves() + ctx.FeaturesUpdater.promises.refreshFeatures = sinon.stub().resolves() + + await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res) + + expect( + ctx.FeaturesUpdater.promises.refreshFeatures.calledWith( + ctx.user._id, + 'add-on-purchase' + ) + ).to.be.true + }) + + it('should respond with a bad request if the subscription already includes the addOn', async function (ctx) { + ctx.req.params.addOnCode = 'assistant' + ctx.SubscriptionHandler.promises.purchaseAddon = sinon + .stub() + .rejects(new SubscriptionErrors.DuplicateAddOnError()) + + await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res) + + expect( + ctx.HttpErrorHandler.badRequest.calledWith( + ctx.req, + ctx.res, + 'Your subscription already includes this add-on', + { addon: 'assistant' } + ) + ).to.be.true + }) + }) + + describe('checkSubscriptionPauseStatus', function () { + beforeEach(function (ctx) { + ctx.user = { + _id: 'user-id-123', + email: 'test@example.com', + } + }) + + it('should return isPaused: false when user has no subscription', async function (ctx) { + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription: null, + }) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ isPaused: false }) + }) + + it('should return isPaused: false when subscription has no paymentProvider', async function (ctx) { + const subscription = { + planCode: 'professional', + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ isPaused: false }) + }) + + it('should return isPaused: false when subscription has no subscriptionId', async function (ctx) { + const subscription = { + paymentProvider: { + service: 'stripe', + subscriptionId: null, + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ isPaused: false }) + }) + + it('should return isPaused: false when Stripe subscription has no remaining pause cycles', async function (ctx) { + const subscription = { + paymentProvider: { + service: 'stripe', + subscriptionId: 'sub-123', + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const paymentRecord = { + subscription: { + remainingPauseCycles: 0, + }, + } + ctx.Modules.promises.hooks.fire + .withArgs('getPaymentFromRecord', subscription) + .resolves([paymentRecord]) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ isPaused: false }) + }) + + it('should return isPaused: false when Stripe subscription has no remainingPauseCycles property', async function (ctx) { + const subscription = { + paymentProvider: { + service: 'stripe', + subscriptionId: 'sub-123', + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const paymentRecord = { + subscription: {}, + } + ctx.Modules.promises.hooks.fire + .withArgs('getPaymentFromRecord', subscription) + .resolves([paymentRecord]) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ isPaused: false }) + }) + + it('should return isPaused: true with redirect path when Stripe subscription has remaining pause cycles', async function (ctx) { + const subscription = { + paymentProvider: { + service: 'stripe', + subscriptionId: 'sub-123', + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const paymentRecord = { + subscription: { + remainingPauseCycles: 2, + }, + } + ctx.Modules.promises.hooks.fire + .withArgs('getPaymentFromRecord', subscription) + .resolves([paymentRecord]) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ + isPaused: true, + redirectPath: '/user/subscription?redirect-reason=subscription-paused', + }) + }) + + it('should return isPaused: true when remainingPauseCycles is exactly 1', async function (ctx) { + const subscription = { + paymentProvider: { + service: 'stripe', + subscriptionId: 'sub-123', + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const paymentRecord = { + subscription: { + remainingPauseCycles: 1, + }, + } + ctx.Modules.promises.hooks.fire + .withArgs('getPaymentFromRecord', subscription) + .resolves([paymentRecord]) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ + isPaused: true, + redirectPath: '/user/subscription?redirect-reason=subscription-paused', + }) + }) + + it('should return isPaused: false when userHasSubscription throws error', async function (ctx) { + const error = new Error('Something bad happened') + ctx.LimitationsManager.promises.userHasSubscription.rejects(error) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ isPaused: false }) + }) + + it('should return isPaused: false when getPaymentFromRecord throws error', async function (ctx) { + const subscription = { + paymentProvider: { + service: 'stripe', + subscriptionId: 'sub-123', + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const error = new Error('Something bad happened') + ctx.Modules.promises.hooks.fire + .withArgs('getPaymentFromRecord', subscription) + .rejects(error) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ isPaused: false }) + }) + + it('should return isPaused: false when Recurly subscription is not paused', async function (ctx) { + const subscription = { + recurlySubscription_id: 'uuid-123', + recurlyStatus: { + state: 'active', + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ isPaused: false }) + }) + + it('should return isPaused: true when Recurly subscription is paused', async function (ctx) { + const subscription = { + recurlySubscription_id: 'uuid-123', + recurlyStatus: { + state: 'paused', + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ + isPaused: true, + redirectPath: '/user/subscription?redirect-reason=subscription-paused', + }) + }) + + it('should return isPaused: true when Recurly subscription has pending pause cycles', async function (ctx) { + const subscription = { + recurlySubscription_id: 'uuid-123', + recurlyStatus: { + state: 'active', + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const recurlySubscriptionData = { + remaining_pause_cycles: 2, + } + ctx.RecurlyWrapper.promises.getSubscription.resolves( + recurlySubscriptionData + ) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ + isPaused: true, + redirectPath: '/user/subscription?redirect-reason=subscription-paused', + }) + expect( + ctx.RecurlyWrapper.promises.getSubscription + ).to.have.been.calledWith('uuid-123') + }) + + it('should return isPaused: false when Recurly subscription has no remaining pause cycles', async function (ctx) { + const subscription = { + recurlySubscription_id: 'uuid-123', + recurlyStatus: { + state: 'active', + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const recurlySubscriptionData = { + remaining_pause_cycles: 0, + } + ctx.RecurlyWrapper.promises.getSubscription.resolves( + recurlySubscriptionData + ) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ isPaused: false }) + }) + + it('should return isPaused: false when Recurly subscription has no remaining_pause_cycles property', async function (ctx) { + const subscription = { + recurlySubscription_id: 'uuid-123', + recurlyStatus: { + state: 'active', + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const recurlySubscriptionData = {} + ctx.RecurlyWrapper.promises.getSubscription.resolves( + recurlySubscriptionData + ) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ isPaused: false }) + }) + + it('should return isPaused: false when Recurly API call fails', async function (ctx) { + const subscription = { + recurlySubscription_id: 'uuid-123', + recurlyStatus: { + state: 'active', + }, + } + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + subscription, + }) + + const error = new Error('Recurly API failed') + ctx.RecurlyWrapper.promises.getSubscription.rejects(error) + + const result = + await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user) + + expect(result).to.deep.equal({ isPaused: false }) + }) + }) + + describe('previewAddonPurchase', function () { + beforeEach(function (ctx) { + ctx.req = new MockRequest() + ctx.req.params = { addOnCode: 'assistant' } + ctx.req.query = { purchaseReferrer: 'fake-referrer' } + ctx.res = new MockResponse() + + ctx.Modules.promises.hooks.fire + .withArgs('getPaymentMethod') + .resolves(['fake-method']) + ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(null) + }) + + describe('when user has manual or custom subscription', function () { + it('should redirect with ai-assist-unavailable when subscription has customAccount = true', async function (ctx) { + const customSubscription = { + _id: 'sub-123', + customAccount: true, + collectionMethod: 'automatic', + } + ctx.SubscriptionLocator.promises.getUsersSubscription.resolves( + customSubscription + ) + + ctx.res.redirect = sinon.stub() + + await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res) + + expect(ctx.res.redirect).to.have.been.calledWith( + '/user/subscription?redirect-reason=ai-assist-unavailable' + ) + }) + + it('should redirect with ai-assist-unavailable when subscription has collectionMethod = manual', async function (ctx) { + const manualSubscription = { + _id: 'sub-123', + customAccount: false, + collectionMethod: 'manual', + } + ctx.SubscriptionLocator.promises.getUsersSubscription.resolves( + manualSubscription + ) + + ctx.res.redirect = sinon.stub() + + await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res) + + expect(ctx.res.redirect).to.have.been.calledWith( + '/user/subscription?redirect-reason=ai-assist-unavailable' + ) + }) + + it('should redirect with ai-assist-unavailable when subscription has both customAccount and manual collection', async function (ctx) { + const customManualSubscription = { + _id: 'sub-123', + customAccount: true, + collectionMethod: 'manual', + } + ctx.SubscriptionLocator.promises.getUsersSubscription.resolves( + customManualSubscription + ) + + ctx.res.redirect = sinon.stub() + + await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res) + + expect(ctx.res.redirect).to.have.been.calledWith( + '/user/subscription?redirect-reason=ai-assist-unavailable' + ) + }) + }) + + describe('when user has normal subscription', function () { + it('should proceed with preview when subscription is not manual or custom', async function (ctx) { + const normalSubscription = { + _id: 'sub-123', + customAccount: false, + collectionMethod: 'automatic', + } + ctx.SubscriptionLocator.promises.getUsersSubscription.resolves( + normalSubscription + ) + + ctx.res.render = sinon.stub() + + await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res) + + expect(ctx.res.render).to.have.been.calledWith( + 'subscriptions/preview-change' + ) + expect( + ctx.SubscriptionHandler.promises.previewAddonPurchase + ).to.have.been.calledWith(ctx.user._id, 'assistant') + }) + + it('should proceed with preview when customAccount is undefined and collectionMethod is automatic', async function (ctx) { + const normalSubscription = { + _id: 'sub-123', + // customAccount: undefined (not set) + collectionMethod: 'automatic', + } + ctx.SubscriptionLocator.promises.getUsersSubscription.resolves( + normalSubscription + ) + + ctx.res.render = sinon.stub() + + await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res) + + expect(ctx.res.render).to.have.been.calledWith( + 'subscriptions/preview-change' + ) + expect( + ctx.SubscriptionHandler.promises.previewAddonPurchase + ).to.have.been.calledWith(ctx.user._id, 'assistant') + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js deleted file mode 100644 index 66a6a871f3..0000000000 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ /dev/null @@ -1,1453 +0,0 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const { assert, expect } = require('chai') -const MockRequest = require('../helpers/MockRequest') -const MockResponse = require('../helpers/MockResponse') -const modulePath = - '../../../../app/src/Features/Subscription/SubscriptionController' -const SubscriptionErrors = require('../../../../app/src/Features/Subscription/Errors') -const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper') -const { - AI_ADD_ON_CODE, -} = require('../../../../app/src/Features/Subscription/AiHelper') -const Errors = require('../../../../app/src/Features/Errors/Errors') - -const mockSubscriptions = { - 'subscription-123-active': { - uuid: 'subscription-123-active', - plan: { - name: 'Gold', - plan_code: 'gold', - }, - current_period_ends_at: new Date(), - state: 'active', - unit_amount_in_cents: 999, - account: { - account_code: 'user-123', - }, - }, -} - -describe('SubscriptionController', function () { - beforeEach(function () { - this.user = { - email: 'tom@yahoo.com', - _id: 'one', - signUpDate: new Date('2000-10-01'), - emails: [{ email: 'tom@yahoo.com', confirmedAt: new Date('2000-10-02') }], - } - this.activeRecurlySubscription = - mockSubscriptions['subscription-123-active'] - - this.SessionManager = { - getLoggedInUser: sinon.stub().callsArgWith(1, null, this.user), - getLoggedInUserId: sinon.stub().returns(this.user._id), - getSessionUser: sinon.stub().returns(this.user), - isUserLoggedIn: sinon.stub().returns(true), - } - this.SubscriptionHandler = { - createSubscription: sinon.stub().callsArgWith(3), - updateSubscription: sinon.stub().callsArgWith(3), - reactivateSubscription: sinon.stub().callsArgWith(1), - cancelSubscription: sinon.stub().callsArgWith(1), - syncSubscription: sinon.stub().yields(), - attemptPaypalInvoiceCollection: sinon.stub().yields(), - startFreeTrial: sinon.stub(), - revertPlanChange: sinon.stub(), - promises: { - createSubscription: sinon.stub().resolves(), - updateSubscription: sinon.stub().resolves(), - reactivateSubscription: sinon.stub().resolves(), - cancelSubscription: sinon.stub().resolves(), - pauseSubscription: sinon.stub().resolves(), - resumeSubscription: sinon.stub().resolves(), - syncSubscription: sinon.stub().resolves(), - attemptPaypalInvoiceCollection: sinon.stub().resolves(), - startFreeTrial: sinon.stub().resolves(), - purchaseAddon: sinon.stub().resolves(), - previewAddonPurchase: sinon.stub().resolves({ - subscription: { - currency: 'USD', - netTerms: 0, - periodEnd: new Date(), - taxRate: 0, - }, - immediateCharge: { amount: 0 }, - nextPlanCode: 'professional', - nextPlanName: 'Professional', - nextPlanPrice: 2000, - nextAddOns: [], - subtotal: 2000, - tax: 0, - total: 2000, - }), - revertPlanChange: sinon.stub().resolves(), - }, - } - - this.LimitationsManager = { - hasPaidSubscription: sinon.stub(), - userHasSubscription: sinon - .stub() - .yields(null, { hasSubscription: false }), - promises: { - hasPaidSubscription: sinon.stub().resolves(), - userHasSubscription: sinon.stub().resolves({ hasSubscription: false }), - }, - } - - this.SubscriptionViewModelBuilder = { - buildUsersSubscriptionViewModel: sinon.stub().callsArgWith(1, null, {}), - buildPlansList: sinon.stub(), - promises: { - buildUsersSubscriptionViewModel: sinon.stub().resolves({}), - }, - buildPlansListForSubscriptionDash: sinon - .stub() - .returns({ plans: [], planCodesChangingAtTermEnd: [] }), - } - this.settings = { - coupon_codes: { - upgradeToAnnualPromo: { - student: 'STUDENTCODEHERE', - collaborator: 'COLLABORATORCODEHERE', - }, - }, - groupPlanModalOptions: { - plan_codes: [], - currencies: [ - { - display: 'GBP (£)', - code: 'GBP', - }, - ], - sizes: ['42'], - usages: [{ code: 'foo', display: 'Foo' }], - }, - apis: { - recurly: { - subdomain: 'sl', - }, - }, - planReverts: { - enabled: false, - }, - siteUrl: 'http://de.overleaf.dev:3000', - } - this.AuthorizationManager = { - promises: { - isUserSiteAdmin: sinon.stub().resolves(false), - }, - } - this.GeoIpLookup = { - isValidCurrencyParam: sinon.stub().returns(true), - getCurrencyCode: sinon.stub().yields('USD', 'US'), - promises: { - getCurrencyCode: sinon.stub().resolves({ - countryCode: 'US', - currencyCode: 'USD', - }), - }, - } - this.UserGetter = { - getUser: sinon.stub().callsArgWith(2, null, this.user), - promises: { - getUser: sinon.stub().resolves(this.user), - getWritefullData: sinon - .stub() - .resolves({ isPremium: false, premiumSource: null }), - }, - } - this.SplitTestV2Hander = { - promises: { - getAssignment: sinon.stub().resolves({ variant: 'default' }), - }, - } - this.Features = { - hasFeature: sinon.stub().returns(false), - } - this.SubscriptionController = SandboxedModule.require(modulePath, { - requires: { - '../Authorization/AuthorizationManager': this.AuthorizationManager, - '../SplitTests/SplitTestHandler': this.SplitTestV2Hander, - '../Authentication/SessionManager': this.SessionManager, - './SubscriptionHandler': this.SubscriptionHandler, - './SubscriptionHelper': SubscriptionHelper, - './SubscriptionViewModelBuilder': this.SubscriptionViewModelBuilder, - './LimitationsManager': this.LimitationsManager, - '../../infrastructure/GeoIpLookup': this.GeoIpLookup, - '@overleaf/settings': this.settings, - '../User/UserGetter': this.UserGetter, - './RecurlyWrapper': (this.RecurlyWrapper = { - promises: { - updateAccountEmailAddress: sinon.stub().resolves(), - getSubscription: sinon.stub().resolves({}), - }, - }), - './RecurlyEventHandler': { - sendRecurlyAnalyticsEvent: sinon.stub().resolves(), - }, - './FeaturesUpdater': (this.FeaturesUpdater = { - promises: { - refreshFeatures: sinon.stub().resolves({ features: {} }), - }, - }), - celebrate: (this.celebrate = { - celebrate: sinon.stub(), - errors: sinon.stub(), - Joi: { - any: sinon.stub(), - extend: sinon.stub(), - }, - }), - './GroupPlansData': (this.GroupPlansData = {}), - './V1SubscriptionManager': (this.V1SubscriptionManager = {}), - '../Errors/HttpErrorHandler': (this.HttpErrorHandler = { - unprocessableEntity: sinon.stub().callsFake((req, res, message) => { - res.status(422) - res.json({ message }) - }), - badRequest: sinon.stub().callsFake((req, res, message) => { - res.status(400) - res.json({ message }) - }), - }), - './Errors': SubscriptionErrors, - '../Analytics/AnalyticsManager': (this.AnalyticsManager = { - recordEventForUser: sinon.stub(), - recordEventForUserInBackground: sinon.stub(), - recordEventForSession: sinon.stub(), - setUserPropertyForUser: sinon.stub(), - }), - '../../infrastructure/Modules': (this.Modules = { - promises: { hooks: { fire: sinon.stub().resolves() } }, - }), - '../../infrastructure/Features': this.Features, - '../../util/currency': (this.currency = { - formatCurrency: sinon.stub(), - }), - '../../models/User': { - User: { - findById: sinon.stub().resolves(this.user), - }, - }, - './SubscriptionLocator': (this.SubscriptionLocator = { - promises: { - getUsersSubscription: sinon.stub().resolves(null), - }, - }), - '../Authorization/PermissionsManager': (this.PermissionsManager = { - promises: { - checkUserPermissions: sinon.stub().resolves(true), - }, - }), - './RecurlyClient': (this.RecurlyClient = { - promises: { - getAddOn: sinon.stub().resolves({ - code: 'ai-assistant', - name: 'AI Assistant', - }), - }, - }), - './PlansLocator': (this.PlansLocator = { - findLocalPlanInSettings: sinon.stub().returns({ - annual: false, - }), - }), - }, - }) - - this.res = new MockResponse() - this.req = new MockRequest() - this.req.body = {} - this.req.query = { planCode: '123123' } - - this.stubbedCurrencyCode = 'GBP' - }) - - describe('successfulSubscription', function () { - it('without a personal subscription', function (done) { - this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves( - {} - ) - this.res.redirect = url => { - url.should.equal('/user/subscription/plans') - done() - } - this.SubscriptionController.successfulSubscription(this.req, this.res) - }) - - it('with a personal subscription', function (done) { - this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves( - { - personalSubscription: 'foo', - } - ) - this.res.render = (url, variables) => { - url.should.equal('subscriptions/successful-subscription-react') - assert.deepEqual(variables, { - title: 'thank_you', - personalSubscription: 'foo', - postCheckoutRedirect: undefined, - user: { - _id: this.user._id, - features: this.user.features, - }, - }) - done() - } - this.SubscriptionController.successfulSubscription(this.req, this.res) - }) - - it('with an error', function (done) { - this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves( - undefined - ) - this.SubscriptionController.successfulSubscription( - this.req, - this.res, - error => { - assert.isNotNull(error) - done() - } - ) - }) - }) - - describe('userSubscriptionPage', function () { - beforeEach(function (done) { - this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves( - { - personalSubscription: (this.personalSubscription = { - 'personal-subscription': 'mock', - }), - memberGroupSubscriptions: (this.memberGroupSubscriptions = { - 'group-subscriptions': 'mock', - }), - } - ) - this.SubscriptionViewModelBuilder.buildPlansList.returns( - (this.plans = { plans: 'mock' }) - ) - this.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash.returns( - { - plans: this.plans, - planCodesChangingAtTermEnd: [], - } - ) - this.LimitationsManager.promises.userHasSubscription.resolves({ - hasSubscription: false, - }) - this.res.render = (view, data) => { - this.data = data - expect(view).to.equal('subscriptions/dashboard-react') - done() - } - this.SubscriptionController.userSubscriptionPage(this.req, this.res, done) - }) - - it('should load the personal, groups and v1 subscriptions', function () { - expect(this.data.personalSubscription).to.deep.equal( - this.personalSubscription - ) - expect(this.data.memberGroupSubscriptions).to.deep.equal( - this.memberGroupSubscriptions - ) - }) - - it('should load the user', function () { - expect(this.data.user).to.deep.equal(this.user) - }) - - it('should load the plans', function () { - expect(this.data.plans).to.deep.equal(this.plans) - }) - - it('should load an empty list of groups with settings available', function () { - expect(this.data.groupSettingsEnabledFor).to.deep.equal([]) - }) - }) - - describe('updateAccountEmailAddress via put', function () { - beforeEach(function () { - this.req.body = { - account_email: 'current_account_email@overleaf.com', - } - }) - - it('should send the user and subscriptionId to "updateAccountEmailAddress" hooks', async function () { - this.res.sendStatus = sinon.spy() - - await this.SubscriptionController.updateAccountEmailAddress( - this.req, - this.res - ) - - expect(this.Modules.promises.hooks.fire).to.have.been.calledWith( - 'updateAccountEmailAddress', - this.user._id, - this.user.email - ) - }) - - it('should respond with 200', async function () { - this.res.sendStatus = sinon.spy() - await this.SubscriptionController.updateAccountEmailAddress( - this.req, - this.res - ) - this.res.sendStatus.calledWith(200).should.equal(true) - }) - - it('should send the error to the next handler when updating recurly account email fails', async function () { - this.Modules.promises.hooks.fire - .withArgs('updateAccountEmailAddress', this.user._id, this.user.email) - .rejects(new Error()) - - this.next = sinon.spy(error => { - expect(error).to.be.instanceOf(Error) - }) - await this.SubscriptionController.updateAccountEmailAddress( - this.req, - this.res, - this.next - ) - expect(this.next.calledOnce).to.be.true - }) - }) - - describe('reactivateSubscription', function () { - describe('when the user has permission', function () { - beforeEach(function (done) { - this.res = { - redirect() { - done() - }, - } - this.req.assertPermission = sinon.stub() - this.next = sinon.stub().callsFake(error => { - done(error) - }) - sinon.spy(this.res, 'redirect') - this.SubscriptionController.reactivateSubscription( - this.req, - this.res, - this.next - ) - }) - - it('should assert the user has permission to reactivate their subscription', function (done) { - this.req.assertPermission - .calledWith('reactivate-subscription') - .should.equal(true) - done() - }) - - it('should tell the handler to reactivate this user', function (done) { - this.SubscriptionHandler.reactivateSubscription - .calledWith(this.user) - .should.equal(true) - done() - }) - - it('should redurect to the subscription page', function (done) { - this.res.redirect.calledWith('/user/subscription').should.equal(true) - done() - }) - }) - - describe('when the user does not have permission', function () { - beforeEach(function (done) { - this.res = { - redirect() { - done() - }, - } - this.req.assertPermission = sinon.stub().throws() - this.next = sinon.stub().callsFake(() => { - done() - }) - sinon.spy(this.res, 'redirect') - this.SubscriptionController.reactivateSubscription( - this.req, - this.res, - this.next - ) - }) - - it('should not reactivate the user', function (done) { - this.req.assertPermission = sinon.stub().throws() - this.SubscriptionHandler.reactivateSubscription.called.should.equal( - false - ) - done() - }) - - it('should call next with an error', function (done) { - this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) - done() - }) - }) - }) - - describe('pauseSubscription', function () { - it('should throw an error if no pause length is provided', async function () { - this.res = new MockResponse() - this.req = new MockRequest() - this.next = sinon.stub() - await expect( - this.SubscriptionController.pauseSubscription( - this.req, - this.res, - this.next - ) - ).to.be.rejectedWith(Errors.NotFoundError) - }) - - it('should throw an error if an invalid pause length is provided', async function () { - this.res = new MockResponse() - this.req = new MockRequest() - this.req.params = { pauseCycles: '-10' } - this.next = sinon.stub() - await this.SubscriptionController.pauseSubscription( - this.req, - this.res, - this.next - ) - expect(this.res.statusCode).to.equal(400) - }) - - it('should return a 200 when requesting a pause', async function () { - this.res = new MockResponse() - this.req = new MockRequest() - this.req.params = { pauseCycles: '3' } - this.next = sinon.stub() - await this.SubscriptionController.pauseSubscription( - this.req, - this.res, - this.next - ) - expect(this.res.statusCode).to.equal(200) - }) - }) - - describe('resumeSubscription', function () { - it('should return a 200 when resuming a subscription', async function () { - this.res = new MockResponse() - this.req = new MockRequest() - this.next = sinon.stub() - await this.SubscriptionController.resumeSubscription( - this.req, - this.res, - this.next - ) - expect(this.res.statusCode).to.equal(200) - }) - }) - - describe('cancelSubscription', function () { - it('should tell the handler to cancel this user', async function () { - this.next = sinon.stub() - await this.SubscriptionController.cancelSubscription( - this.req, - this.res, - this.next - ) - this.SubscriptionHandler.promises.cancelSubscription - .calledWith(this.user) - .should.equal(true) - }) - - it('should return a 200 on success', async function () { - this.next = sinon.stub() - await this.SubscriptionController.cancelSubscription( - this.req, - this.res, - this.next - ) - expect(this.res.statusCode).to.equal(200) - }) - - it('should call next with error', async function () { - this.SubscriptionHandler.promises.cancelSubscription.rejects( - new Error('cancel error') - ) - this.next = sinon.stub() - await this.SubscriptionController.cancelSubscription( - this.req, - this.res, - this.next - ) - this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) - }) - }) - - describe('recurly callback', function () { - describe('with a sync subscription request', function () { - beforeEach(function (done) { - this.req = { - body: { - expired_subscription_notification: { - account: { - account_code: this.user._id, - }, - subscription: { - uuid: this.activeRecurlySubscription.uuid, - plan: { - plan_code: 'collaborator', - state: 'active', - }, - }, - }, - }, - } - this.res = { - sendStatus() { - done() - }, - } - sinon.spy(this.res, 'sendStatus') - this.SubscriptionController.recurlyCallback(this.req, this.res) - }) - - it('should tell the SubscriptionHandler to process the recurly callback', function (done) { - this.SubscriptionHandler.syncSubscription.called.should.equal(true) - done() - }) - - it('should send a 200', function (done) { - this.res.sendStatus.calledWith(200) - done() - }) - }) - - describe('with a billing info updated request', function () { - beforeEach(function (done) { - this.req = { - body: { - billing_info_updated_notification: { - account: { - account_code: 'mock-account-code', - }, - }, - }, - } - this.res = { - sendStatus() { - done() - }, - } - sinon.spy(this.res, 'sendStatus') - this.SubscriptionController.recurlyCallback(this.req, this.res) - }) - - it('should call attemptPaypalInvoiceCollection', function (done) { - this.SubscriptionHandler.attemptPaypalInvoiceCollection - .calledWith('mock-account-code') - .should.equal(true) - done() - }) - - it('should send a 200', function (done) { - this.res.sendStatus.calledWith(200) - done() - }) - }) - - describe('with a non-actionable request', function () { - beforeEach(function (done) { - this.user.id = this.activeRecurlySubscription.account.account_code - this.req = { - body: { - renewed_subscription_notification: { - account: { - account_code: this.user._id, - }, - subscription: { - uuid: this.activeRecurlySubscription.uuid, - plan: { - plan_code: 'collaborator', - state: 'active', - }, - }, - }, - }, - } - this.res = { - sendStatus() { - done() - }, - } - sinon.spy(this.res, 'sendStatus') - this.SubscriptionController.recurlyCallback(this.req, this.res) - }) - - it('should not call the subscriptionshandler', function () { - this.SubscriptionHandler.syncSubscription.called.should.equal(false) - this.SubscriptionHandler.attemptPaypalInvoiceCollection.called.should.equal( - false - ) - }) - - it('should respond with a 200 status', function () { - this.res.sendStatus.calledWith(200) - }) - }) - - describe('with a failed payment notification', function () { - describe('with planReverts disabled in settings', function () { - beforeEach(function (done) { - this.settings.planReverts = { enabled: false } - this.SubscriptionHandler.revertPlanChange = sinon.stub() - - this.req.body = { - failed_payment_notification: { - transaction: { - subscription_id: 'subscription-123', - }, - }, - } - - this.res = { - sendStatus() { - done() - }, - } - sinon.spy(this.res, 'sendStatus') - this.SubscriptionController.recurlyCallback(this.req, this.res) - }) - it('should not call revertPlanChange', function () { - expect(this.SubscriptionHandler.revertPlanChange.called).to.be.false - }) - - it('should respond with 200', function (done) { - this.res.sendStatus.calledWith(200) - done() - }) - }) - - describe('with planReverts enabled in settings', function () { - beforeEach(function () { - this.settings.planReverts = { enabled: true } - }) - - describe('with no valid restore point', function () { - beforeEach(function (done) { - this.SubscriptionHandler.getSubscriptionRestorePoint = sinon - .stub() - .yields(null, null) - this.SubscriptionHandler.revertPlanChange = sinon.stub() - - this.req.body = { - failed_payment_notification: { - transaction: { - subscription_id: 'subscription-123', - }, - }, - } - this.res = { - sendStatus() { - done() - }, - } - sinon.spy(this.res, 'sendStatus') - this.SubscriptionController.recurlyCallback(this.req, this.res) - }) - it('should not call revertPlanChange()', function () { - expect(this.SubscriptionHandler.revertPlanChange.called).to.be.false - }) - - it('should respond with 200', function () { - this.res.sendStatus.calledWith(200) - }) - }) - - describe('with a valid restore point', function () { - beforeEach(function (done) { - this.addOns = [ - { - addOnCode: 'addon-1', - quantity: 2, - unitAmountInCents: 500, - }, - { - addOnCode: 'addon-2', - quantity: 1, - unitAmountInCents: 600, - }, - ] - this.lastSubscription = { - planCode: 'gold', - addOns: this.addOns, - } - this.SubscriptionHandler.getSubscriptionRestorePoint = sinon - .stub() - .yields(null, this.lastSubscription) - this.SubscriptionHandler.revertPlanChange = sinon.stub().yields() - this.req.body = { - failed_payment_notification: { - transaction: { - subscription_id: 'subscription-123', - }, - }, - } - this.res = { - sendStatus() { - done() - }, - } - sinon.spy(this.res, 'sendStatus') - this.SubscriptionController.recurlyCallback(this.req, this.res) - }) - - it('should get the subscription restore point', function () { - expect( - this.SubscriptionHandler.getSubscriptionRestorePoint.calledWith( - 'subscription-123' - ) - ).to.be.true - }) - - it('should call revertPlanChange()', function () { - expect( - this.SubscriptionHandler.revertPlanChange.calledWith( - 'subscription-123', - this.lastSubscription - ) - ).to.be.true - }) - - it('should respond with 200', function () { - this.res.sendStatus.calledWith(200) - }) - }) - }) - }) - }) - - describe('purchaseAddon', function () { - beforeEach(function () { - this.SessionManager.getSessionUser.returns(this.user) // Make sure getSessionUser returns the user - this.next = sinon.stub() - this.req.params = { addOnCode: AI_ADD_ON_CODE } // Mock add-on code - }) - - it('should return 200 on successful purchase of AI add-on', async function () { - await this.SubscriptionController.purchaseAddon( - this.req, - this.res, - this.next - ) - this.res.sendStatus = sinon.spy() - - await this.SubscriptionController.purchaseAddon( - this.req, - this.res, - this.next - ) - - expect(this.SubscriptionHandler.promises.purchaseAddon).to.have.been - .called - expect( - this.SubscriptionHandler.promises.purchaseAddon - ).to.have.been.calledWith(this.user._id, AI_ADD_ON_CODE, 1) - expect( - this.FeaturesUpdater.promises.refreshFeatures - ).to.have.been.calledWith(this.user._id, 'add-on-purchase') - expect(this.res.sendStatus).to.have.been.calledWith(200) - expect(this.logger.debug).to.have.been.calledWith( - { userId: this.user._id, addOnCode: AI_ADD_ON_CODE }, - 'purchasing add-ons' - ) - }) - - it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function () { - this.req.params = { addOnCode: 'some-other-addon' } - this.res.sendStatus = sinon.spy() - - await this.SubscriptionController.purchaseAddon( - this.req, - this.res, - this.next - ) - - expect(this.SubscriptionHandler.promises.purchaseAddon).to.not.have.been - .called - expect(this.FeaturesUpdater.promises.refreshFeatures).to.not.have.been - .called - expect(this.res.sendStatus).to.have.been.calledWith(404) - }) - - it('should handle DuplicateAddOnError and send badRequest while sending 200', async function () { - this.req.params.addOnCode = AI_ADD_ON_CODE - this.SubscriptionHandler.promises.purchaseAddon.rejects( - new SubscriptionErrors.DuplicateAddOnError() - ) - - await this.SubscriptionController.purchaseAddon( - this.req, - this.res, - this.next - ) - - expect(this.HttpErrorHandler.badRequest).to.have.been.calledWith( - this.req, - this.res, - 'Your subscription already includes this add-on', - { addon: AI_ADD_ON_CODE } - ) - expect( - this.FeaturesUpdater.promises.refreshFeatures - ).to.have.been.calledWith(this.user._id, 'add-on-purchase') - expect(this.res.sendStatus).to.have.been.calledWith(200) - }) - - it('should handle PaymentActionRequiredError and return 402 with details', async function () { - this.req.params.addOnCode = AI_ADD_ON_CODE - const paymentError = new SubscriptionErrors.PaymentActionRequiredError({ - clientSecret: 'secret123', - publicKey: 'pubkey456', - }) - this.SubscriptionHandler.promises.purchaseAddon.rejects(paymentError) - - await this.SubscriptionController.purchaseAddon( - this.req, - this.res, - this.next - ) - - this.res.status.calledWith(402).should.equal(true) - this.res.json - .calledWith({ - message: 'Payment action required', - clientSecret: 'secret123', - publicKey: 'pubkey456', - }) - .should.equal(true) - - expect(this.FeaturesUpdater.promises.refreshFeatures).to.not.have.been - .called - }) - - it('should refresh features', async function () { - this.req.params.addOnCode = 'assistant' - this.SubscriptionHandler.promises.purchaseAddon = sinon.stub().resolves() - this.FeaturesUpdater.promises.refreshFeatures = sinon.stub().resolves() - - await this.SubscriptionController.purchaseAddon(this.req, this.res) - - expect( - this.FeaturesUpdater.promises.refreshFeatures.calledWith( - this.user._id, - 'add-on-purchase' - ) - ).to.be.true - }) - - it('should respond with a bad request if the subscription already includes the addOn', async function () { - this.req.params.addOnCode = 'assistant' - this.SubscriptionHandler.promises.purchaseAddon = sinon - .stub() - .rejects(new SubscriptionErrors.DuplicateAddOnError()) - - await this.SubscriptionController.purchaseAddon(this.req, this.res) - - expect( - this.HttpErrorHandler.badRequest.calledWith( - this.req, - this.res, - 'Your subscription already includes this add-on', - { addon: 'assistant' } - ) - ).to.be.true - }) - }) - - describe('checkSubscriptionPauseStatus', function () { - beforeEach(function () { - this.user = { - _id: 'user-id-123', - email: 'test@example.com', - } - }) - - it('should return isPaused: false when user has no subscription', async function () { - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription: null, - }) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ isPaused: false }) - }) - - it('should return isPaused: false when subscription has no paymentProvider', async function () { - const subscription = { - planCode: 'professional', - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ isPaused: false }) - }) - - it('should return isPaused: false when subscription has no subscriptionId', async function () { - const subscription = { - paymentProvider: { - service: 'stripe', - subscriptionId: null, - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ isPaused: false }) - }) - - it('should return isPaused: false when Stripe subscription has no remaining pause cycles', async function () { - const subscription = { - paymentProvider: { - service: 'stripe', - subscriptionId: 'sub-123', - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const paymentRecord = { - subscription: { - remainingPauseCycles: 0, - }, - } - this.Modules.promises.hooks.fire - .withArgs('getPaymentFromRecord', subscription) - .resolves([paymentRecord]) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ isPaused: false }) - }) - - it('should return isPaused: false when Stripe subscription has no remainingPauseCycles property', async function () { - const subscription = { - paymentProvider: { - service: 'stripe', - subscriptionId: 'sub-123', - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const paymentRecord = { - subscription: {}, - } - this.Modules.promises.hooks.fire - .withArgs('getPaymentFromRecord', subscription) - .resolves([paymentRecord]) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ isPaused: false }) - }) - - it('should return isPaused: true with redirect path when Stripe subscription has remaining pause cycles', async function () { - const subscription = { - paymentProvider: { - service: 'stripe', - subscriptionId: 'sub-123', - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const paymentRecord = { - subscription: { - remainingPauseCycles: 2, - }, - } - this.Modules.promises.hooks.fire - .withArgs('getPaymentFromRecord', subscription) - .resolves([paymentRecord]) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ - isPaused: true, - redirectPath: '/user/subscription?redirect-reason=subscription-paused', - }) - }) - - it('should return isPaused: true when remainingPauseCycles is exactly 1', async function () { - const subscription = { - paymentProvider: { - service: 'stripe', - subscriptionId: 'sub-123', - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const paymentRecord = { - subscription: { - remainingPauseCycles: 1, - }, - } - this.Modules.promises.hooks.fire - .withArgs('getPaymentFromRecord', subscription) - .resolves([paymentRecord]) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ - isPaused: true, - redirectPath: '/user/subscription?redirect-reason=subscription-paused', - }) - }) - - it('should return isPaused: false when userHasSubscription throws error', async function () { - const error = new Error('Something bad happened') - this.LimitationsManager.promises.userHasSubscription.rejects(error) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ isPaused: false }) - }) - - it('should return isPaused: false when getPaymentFromRecord throws error', async function () { - const subscription = { - paymentProvider: { - service: 'stripe', - subscriptionId: 'sub-123', - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const error = new Error('Something bad happened') - this.Modules.promises.hooks.fire - .withArgs('getPaymentFromRecord', subscription) - .rejects(error) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ isPaused: false }) - }) - - it('should return isPaused: false when Recurly subscription is not paused', async function () { - const subscription = { - recurlySubscription_id: 'uuid-123', - recurlyStatus: { - state: 'active', - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ isPaused: false }) - }) - - it('should return isPaused: true when Recurly subscription is paused', async function () { - const subscription = { - recurlySubscription_id: 'uuid-123', - recurlyStatus: { - state: 'paused', - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ - isPaused: true, - redirectPath: '/user/subscription?redirect-reason=subscription-paused', - }) - }) - - it('should return isPaused: true when Recurly subscription has pending pause cycles', async function () { - const subscription = { - recurlySubscription_id: 'uuid-123', - recurlyStatus: { - state: 'active', - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const recurlySubscriptionData = { - remaining_pause_cycles: 2, - } - this.RecurlyWrapper.promises.getSubscription.resolves( - recurlySubscriptionData - ) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ - isPaused: true, - redirectPath: '/user/subscription?redirect-reason=subscription-paused', - }) - expect( - this.RecurlyWrapper.promises.getSubscription - ).to.have.been.calledWith('uuid-123') - }) - - it('should return isPaused: false when Recurly subscription has no remaining pause cycles', async function () { - const subscription = { - recurlySubscription_id: 'uuid-123', - recurlyStatus: { - state: 'active', - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const recurlySubscriptionData = { - remaining_pause_cycles: 0, - } - this.RecurlyWrapper.promises.getSubscription.resolves( - recurlySubscriptionData - ) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ isPaused: false }) - }) - - it('should return isPaused: false when Recurly subscription has no remaining_pause_cycles property', async function () { - const subscription = { - recurlySubscription_id: 'uuid-123', - recurlyStatus: { - state: 'active', - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const recurlySubscriptionData = {} - this.RecurlyWrapper.promises.getSubscription.resolves( - recurlySubscriptionData - ) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ isPaused: false }) - }) - - it('should return isPaused: false when Recurly API call fails', async function () { - const subscription = { - recurlySubscription_id: 'uuid-123', - recurlyStatus: { - state: 'active', - }, - } - this.LimitationsManager.promises.userHasSubscription.resolves({ - subscription, - }) - - const error = new Error('Recurly API failed') - this.RecurlyWrapper.promises.getSubscription.rejects(error) - - const result = - await this.SubscriptionController.checkSubscriptionPauseStatus( - this.user - ) - - expect(result).to.deep.equal({ isPaused: false }) - }) - }) - - describe('previewAddonPurchase', function () { - beforeEach(function () { - this.req = new MockRequest() - this.req.params = { addOnCode: 'assistant' } - this.req.query = { purchaseReferrer: 'fake-referrer' } - this.res = new MockResponse() - - this.Modules.promises.hooks.fire - .withArgs('getPaymentMethod') - .resolves(['fake-method']) - this.SubscriptionLocator.promises.getUsersSubscription.resolves(null) - }) - - describe('when user has manual or custom subscription', function () { - it('should redirect with ai-assist-unavailable when subscription has customAccount = true', async function () { - const customSubscription = { - _id: 'sub-123', - customAccount: true, - collectionMethod: 'automatic', - } - this.SubscriptionLocator.promises.getUsersSubscription.resolves( - customSubscription - ) - - this.res.redirect = sinon.stub() - - await this.SubscriptionController.previewAddonPurchase( - this.req, - this.res - ) - - expect(this.res.redirect).to.have.been.calledWith( - '/user/subscription?redirect-reason=ai-assist-unavailable' - ) - }) - - it('should redirect with ai-assist-unavailable when subscription has collectionMethod = manual', async function () { - const manualSubscription = { - _id: 'sub-123', - customAccount: false, - collectionMethod: 'manual', - } - this.SubscriptionLocator.promises.getUsersSubscription.resolves( - manualSubscription - ) - - this.res.redirect = sinon.stub() - - await this.SubscriptionController.previewAddonPurchase( - this.req, - this.res - ) - - expect(this.res.redirect).to.have.been.calledWith( - '/user/subscription?redirect-reason=ai-assist-unavailable' - ) - }) - - it('should redirect with ai-assist-unavailable when subscription has both customAccount and manual collection', async function () { - const customManualSubscription = { - _id: 'sub-123', - customAccount: true, - collectionMethod: 'manual', - } - this.SubscriptionLocator.promises.getUsersSubscription.resolves( - customManualSubscription - ) - - this.res.redirect = sinon.stub() - - await this.SubscriptionController.previewAddonPurchase( - this.req, - this.res - ) - - expect(this.res.redirect).to.have.been.calledWith( - '/user/subscription?redirect-reason=ai-assist-unavailable' - ) - }) - }) - - describe('when user has normal subscription', function () { - it('should proceed with preview when subscription is not manual or custom', async function () { - const normalSubscription = { - _id: 'sub-123', - customAccount: false, - collectionMethod: 'automatic', - } - this.SubscriptionLocator.promises.getUsersSubscription.resolves( - normalSubscription - ) - - this.res.render = sinon.stub() - - await this.SubscriptionController.previewAddonPurchase( - this.req, - this.res - ) - - expect(this.res.render).to.have.been.calledWith( - 'subscriptions/preview-change' - ) - expect( - this.SubscriptionHandler.promises.previewAddonPurchase - ).to.have.been.calledWith(this.user._id, 'assistant') - }) - - it('should proceed with preview when customAccount is undefined and collectionMethod is automatic', async function () { - const normalSubscription = { - _id: 'sub-123', - // customAccount: undefined (not set) - collectionMethod: 'automatic', - } - this.SubscriptionLocator.promises.getUsersSubscription.resolves( - normalSubscription - ) - - this.res.render = sinon.stub() - - await this.SubscriptionController.previewAddonPurchase( - this.req, - this.res - ) - - expect(this.res.render).to.have.been.calledWith( - 'subscriptions/preview-change' - ) - expect( - this.SubscriptionHandler.promises.previewAddonPurchase - ).to.have.been.calledWith(this.user._id, 'assistant') - }) - }) - }) -}) diff --git a/services/web/test/unit/src/SystemMessages/SystemMessageManager.test.mjs b/services/web/test/unit/src/SystemMessages/SystemMessageManager.test.mjs new file mode 100644 index 0000000000..b56bd062cc --- /dev/null +++ b/services/web/test/unit/src/SystemMessages/SystemMessageManager.test.mjs @@ -0,0 +1,50 @@ +import { vi } from 'vitest' +import sinon from 'sinon' + +const modulePath = + '../../../../app/src/Features/SystemMessages/SystemMessageManager.mjs' + +describe('SystemMessageManager', function () { + beforeEach(async function (ctx) { + ctx.messages = ['messages-stub'] + ctx.SystemMessage = { + find: sinon.stub().returns({ + exec: sinon.stub().resolves(ctx.messages), + }), + } + + vi.doMock('../../../../app/src/models/SystemMessage', () => ({ + SystemMessage: ctx.SystemMessage, + })) + + ctx.SystemMessageManager = (await import(modulePath)).default + }) + + it('should look the messages up in the database on import', function (ctx) { + sinon.assert.called(ctx.SystemMessage.find) + }) + + describe('getMessage', function () { + beforeEach(function (ctx) { + ctx.SystemMessageManager._cachedMessages = ctx.messages + ctx.result = ctx.SystemMessageManager.getMessages() + }) + + it('should return the messages', function (ctx) { + ctx.result.should.equal(ctx.messages) + }) + }) + + describe('clearMessages', function () { + beforeEach(function (ctx) { + ctx.SystemMessage.deleteMany = sinon.stub().returns({ + exec: sinon.stub().resolves(), + }) + ctx.SystemMessageManager.promises.clearMessages() + }) + + it('should remove the messages from the database', function (ctx) { + ctx.SystemMessage.deleteMany.calledWith({}).should.equal(true) + }) + }) +}) diff --git a/services/web/test/unit/src/SystemMessages/SystemMessageManagerTests.js b/services/web/test/unit/src/SystemMessages/SystemMessageManagerTests.js deleted file mode 100644 index 8d542c3096..0000000000 --- a/services/web/test/unit/src/SystemMessages/SystemMessageManagerTests.js +++ /dev/null @@ -1,50 +0,0 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const modulePath = require('path').join( - __dirname, - '../../../../app/src/Features/SystemMessages/SystemMessageManager.js' -) - -describe('SystemMessageManager', function () { - beforeEach(function () { - this.messages = ['messages-stub'] - this.SystemMessage = { - find: sinon.stub().returns({ - exec: sinon.stub().resolves(this.messages), - }), - } - this.SystemMessageManager = SandboxedModule.require(modulePath, { - requires: { - '../../models/SystemMessage': { SystemMessage: this.SystemMessage }, - }, - }) - }) - - it('should look the messages up in the database on import', function () { - sinon.assert.called(this.SystemMessage.find) - }) - - describe('getMessage', function () { - beforeEach(function () { - this.SystemMessageManager._cachedMessages = this.messages - this.result = this.SystemMessageManager.getMessages() - }) - - it('should return the messages', function () { - this.result.should.equal(this.messages) - }) - }) - - describe('clearMessages', function () { - beforeEach(function () { - this.SystemMessage.deleteMany = sinon.stub().returns({ - exec: sinon.stub().resolves(), - }) - this.SystemMessageManager.promises.clearMessages() - }) - - it('should remove the messages from the database', function () { - this.SystemMessage.deleteMany.calledWith({}).should.equal(true) - }) - }) -}) diff --git a/services/web/test/unit/src/ThirdPartyDataStore/UpdateMerger.test.mjs b/services/web/test/unit/src/ThirdPartyDataStore/UpdateMerger.test.mjs new file mode 100644 index 0000000000..d4290dce5d --- /dev/null +++ b/services/web/test/unit/src/ThirdPartyDataStore/UpdateMerger.test.mjs @@ -0,0 +1,412 @@ +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import { Writable } from 'stream' +import mongodb from 'mongodb-legacy' + +const { ObjectId } = mongodb + +const MODULE_PATH = + '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs' + +describe('UpdateMerger :', function () { + beforeEach(async function (ctx) { + ctx.projectId = 'project_id_here' + ctx.userId = 'mock-user-id' + ctx.randomUUID = 'random-uuid' + ctx.dumpPath = '/dump' + + ctx.docPath = ctx.newDocPath = '/folder/doc.tex' + ctx.filePath = ctx.newFilePath = '/folder/file.png' + + ctx.existingDocPath = '/folder/other.tex' + ctx.existingFilePath = '/folder/fig1.pdf' + + ctx.linkedFileData = { provider: 'url' } + + ctx.existingDocs = [{ path: '/main.tex' }, { path: '/folder/other.tex' }] + ctx.existingFiles = [{ path: '/figure.pdf' }, { path: '/folder/fig1.pdf' }] + + ctx.fsPath = `${ctx.dumpPath}/${ctx.projectId}_${ctx.randomUUID}` + ctx.fileContents = `\\documentclass{article} +\\usepackage[utf8]{inputenc} + +\\title{42} +\\author{Jane Doe} +\\date{June 2011}` + ctx.docLines = ctx.fileContents.split('\n') + ctx.source = 'dropbox' + ctx.updateRequest = new Writable() + ctx.writeStream = new Writable() + + ctx.fsPromises = { + unlink: sinon.stub().resolves(), + readFile: sinon.stub().withArgs(ctx.fsPath).resolves(ctx.fileContents), + mkdir: sinon.stub().resolves(), + } + + ctx.fs = { + createWriteStream: sinon.stub().returns(ctx.writeStream), + } + + ctx.doc = { + _id: new ObjectId(), + rev: 2, + } + + ctx.file = { + _id: new ObjectId(), + rev: 6, + } + + ctx.folder = { + _id: new ObjectId(), + } + + ctx.EditorController = { + promises: { + deleteEntityWithPath: sinon.stub().resolves(new ObjectId()), + upsertDocWithPath: sinon + .stub() + .resolves({ doc: ctx.doc, folder: ctx.folder }), + upsertFileWithPath: sinon + .stub() + .resolves({ file: ctx.file, folder: ctx.folder }), + }, + } + + ctx.FileTypeManager = { + promises: { + getType: sinon.stub(), + }, + } + + ctx.crypto = { + randomUUID: sinon.stub().returns(ctx.randomUUID), + } + + ctx.ProjectEntityHandler = { + promises: { + getAllEntities: sinon.stub().resolves({ + docs: ctx.existingDocs, + files: ctx.existingFiles, + }), + }, + } + + ctx.Settings = { path: { dumpFolder: ctx.dumpPath } } + + ctx.stream = { pipeline: sinon.stub().resolves() } + + vi.doMock('fs/promises', () => ({ + default: ctx.fsPromises, + })) + + vi.doMock('fs', () => ({ + default: ctx.fs, + })) + + vi.doMock('../../../../app/src/Features/Editor/EditorController', () => ({ + default: ctx.EditorController, + })) + + vi.doMock('../../../../app/src/Features/Uploads/FileTypeManager', () => ({ + default: ctx.FileTypeManager, + })) + + vi.doMock( + '../../../../app/src/Features/Project/ProjectEntityHandler', + () => ({ + default: ctx.ProjectEntityHandler, + }) + ) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.Settings, + })) + + vi.doMock('stream/promises', () => ({ + pipeline: ctx.stream.pipeline, + })) + + vi.doMock('crypto', () => ({ + default: ctx.crypto, + })) + + ctx.UpdateMerger = (await import(MODULE_PATH)).default + }) + + describe('mergeUpdate', function () { + describe('doc updates for a new doc', function () { + beforeEach(async function (ctx) { + ctx.FileTypeManager.promises.getType.resolves({ + binary: false, + encoding: 'utf-8', + }) + ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate( + ctx.userId, + ctx.projectId, + ctx.docPath, + ctx.updateRequest, + ctx.source + ) + }) + + it('should look at the file contents', function (ctx) { + expect(ctx.FileTypeManager.promises.getType).to.have.been.called + }) + + it('should process update as doc', function (ctx) { + expect( + ctx.EditorController.promises.upsertDocWithPath + ).to.have.been.calledWith( + ctx.projectId, + ctx.docPath, + ctx.docLines, + ctx.source, + ctx.userId + ) + }) + + it('removes the temp file from disk', function (ctx) { + expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath) + }) + + it('returns the entity id and rev', function (ctx) { + expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) + expect(ctx.mergeUpdateResult.rev).to.equal(2) + }) + }) + + describe('file updates for a new file ', function () { + beforeEach(async function (ctx) { + ctx.FileTypeManager.promises.getType.resolves({ binary: true }) + ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate( + ctx.userId, + ctx.projectId, + ctx.filePath, + ctx.updateRequest, + ctx.source + ) + }) + + it('should look at the file contents', function (ctx) { + expect(ctx.FileTypeManager.promises.getType).to.have.been.called + }) + + it('should process update as file', function (ctx) { + expect( + ctx.EditorController.promises.upsertFileWithPath + ).to.have.been.calledWith( + ctx.projectId, + ctx.filePath, + ctx.fsPath, + null, + ctx.source, + ctx.userId + ) + }) + + it('removes the temp file from disk', function (ctx) { + expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath) + }) + + it('returns the entity id and rev', function (ctx) { + expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) + expect(ctx.mergeUpdateResult.rev).to.equal(6) + }) + }) + + describe('doc updates for an existing doc', function () { + beforeEach(async function (ctx) { + ctx.FileTypeManager.promises.getType.resolves({ + binary: false, + encoding: 'utf-8', + }) + ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate( + ctx.userId, + ctx.projectId, + ctx.existingDocPath, + ctx.updateRequest, + ctx.source + ) + }) + + it('should look at the file contents', function (ctx) { + expect(ctx.FileTypeManager.promises.getType).to.have.been.called + }) + + it('should process update as doc', function (ctx) { + expect( + ctx.EditorController.promises.upsertDocWithPath + ).to.have.been.calledWith( + ctx.projectId, + ctx.existingDocPath, + ctx.docLines, + ctx.source, + ctx.userId + ) + }) + + it('removes the temp file from disk', function (ctx) { + expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath) + }) + + it('returns the entity id and rev', function (ctx) { + expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) + expect(ctx.mergeUpdateResult.rev).to.equal(2) + }) + }) + + describe('file updates for an existing file', function () { + beforeEach(async function (ctx) { + ctx.FileTypeManager.promises.getType.resolves({ binary: true }) + ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate( + ctx.userId, + ctx.projectId, + ctx.existingFilePath, + ctx.updateRequest, + ctx.source + ) + }) + + it('should look at the file contents', function (ctx) { + expect(ctx.FileTypeManager.promises.getType).to.have.been.called + }) + + it('should process update as file', function (ctx) { + expect( + ctx.EditorController.promises.upsertFileWithPath + ).to.have.been.calledWith( + ctx.projectId, + ctx.existingFilePath, + ctx.fsPath, + null, + ctx.source, + ctx.userId + ) + }) + + it('removes the temp file from disk', function (ctx) { + expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath) + }) + + it('returns the entity id and rev', function (ctx) { + expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) + expect(ctx.mergeUpdateResult.rev).to.equal(6) + }) + }) + }) + + describe('file updates for an existing doc', function () { + beforeEach(async function (ctx) { + ctx.FileTypeManager.promises.getType.resolves({ binary: true }) + ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate( + ctx.userId, + ctx.projectId, + ctx.existingDocPath, + ctx.updateRequest, + ctx.source + ) + }) + + it('should look at the file contents', function (ctx) { + expect(ctx.FileTypeManager.promises.getType).to.have.been.called + }) + + it('should process update as file', function (ctx) { + expect( + ctx.EditorController.promises.upsertFileWithPath + ).to.have.been.calledWith( + ctx.projectId, + ctx.existingDocPath, + ctx.fsPath, + null, + ctx.source, + ctx.userId + ) + }) + + it('removes the temp file from disk', function (ctx) { + expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath) + }) + + it('returns the entity id and rev', function (ctx) { + expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) + expect(ctx.mergeUpdateResult.rev).to.equal(6) + }) + }) + + describe('doc updates for an existing file', function () { + beforeEach(async function (ctx) { + ctx.FileTypeManager.promises.getType.resolves({ binary: true }) + ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate( + ctx.userId, + ctx.projectId, + ctx.existingFilePath, + ctx.updateRequest, + ctx.source + ) + }) + + it('should look at the file contents', function (ctx) { + expect(ctx.FileTypeManager.promises.getType).to.have.been.called + }) + + it('should not delete the existing file', function (ctx) { + expect(ctx.EditorController.promises.deleteEntityWithPath).to.not.have + .been.called + }) + + it('should process update as file', function (ctx) { + expect( + ctx.EditorController.promises.upsertFileWithPath + ).to.have.been.calledWith( + ctx.projectId, + ctx.existingFilePath, + ctx.fsPath, + null, + ctx.source, + ctx.userId + ) + }) + + it('removes the temp file from disk', function (ctx) { + expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath) + }) + + it('returns the entity id and rev', function (ctx) { + expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) + expect(ctx.mergeUpdateResult.rev).to.equal(6) + }) + }) + + describe('deleteUpdate', function () { + beforeEach(async function (ctx) { + ctx.deleteUpdateResult = await ctx.UpdateMerger.promises.deleteUpdate( + ctx.userId, + ctx.projectId, + ctx.docPath, + ctx.source + ) + }) + + afterEach(function (ctx) { + delete ctx.deleteUpdateResult + }) + + it('should delete the entity in the editor controller', function (ctx) { + expect( + ctx.EditorController.promises.deleteEntityWithPath + ).to.have.been.calledWith( + ctx.projectId, + ctx.docPath, + ctx.source, + ctx.userId + ) + }) + + it('returns the entity id', function (ctx) { + expect(ctx.deleteUpdateResult).to.be.instanceOf(ObjectId) + }) + }) +}) diff --git a/services/web/test/unit/src/ThirdPartyDataStore/UpdateMergerTests.js b/services/web/test/unit/src/ThirdPartyDataStore/UpdateMergerTests.js deleted file mode 100644 index dbeda6f4e5..0000000000 --- a/services/web/test/unit/src/ThirdPartyDataStore/UpdateMergerTests.js +++ /dev/null @@ -1,387 +0,0 @@ -const SandboxedModule = require('sandboxed-module') -const sinon = require('sinon') -const { expect } = require('chai') -const { Writable } = require('stream') -const { ObjectId } = require('mongodb-legacy') - -const MODULE_PATH = - '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger.js' - -describe('UpdateMerger :', function () { - beforeEach(function () { - this.projectId = 'project_id_here' - this.userId = 'mock-user-id' - this.randomUUID = 'random-uuid' - this.dumpPath = '/dump' - - this.docPath = this.newDocPath = '/folder/doc.tex' - this.filePath = this.newFilePath = '/folder/file.png' - - this.existingDocPath = '/folder/other.tex' - this.existingFilePath = '/folder/fig1.pdf' - - this.linkedFileData = { provider: 'url' } - - this.existingDocs = [{ path: '/main.tex' }, { path: '/folder/other.tex' }] - this.existingFiles = [{ path: '/figure.pdf' }, { path: '/folder/fig1.pdf' }] - - this.fsPath = `${this.dumpPath}/${this.projectId}_${this.randomUUID}` - this.fileContents = `\\documentclass{article} -\\usepackage[utf8]{inputenc} - -\\title{42} -\\author{Jane Doe} -\\date{June 2011}` - this.docLines = this.fileContents.split('\n') - this.source = 'dropbox' - this.updateRequest = new Writable() - this.writeStream = new Writable() - - this.fsPromises = { - unlink: sinon.stub().resolves(), - readFile: sinon.stub().withArgs(this.fsPath).resolves(this.fileContents), - mkdir: sinon.stub().resolves(), - } - - this.fs = { - createWriteStream: sinon.stub().returns(this.writeStream), - } - - this.doc = { - _id: new ObjectId(), - rev: 2, - } - - this.file = { - _id: new ObjectId(), - rev: 6, - } - - this.folder = { - _id: new ObjectId(), - } - - this.EditorController = { - promises: { - deleteEntityWithPath: sinon.stub().resolves(new ObjectId()), - upsertDocWithPath: sinon - .stub() - .resolves({ doc: this.doc, folder: this.folder }), - upsertFileWithPath: sinon - .stub() - .resolves({ file: this.file, folder: this.folder }), - }, - } - - this.FileTypeManager = { - promises: { - getType: sinon.stub(), - }, - } - - this.crypto = { - randomUUID: sinon.stub().returns(this.randomUUID), - } - - this.ProjectEntityHandler = { - promises: { - getAllEntities: sinon.stub().resolves({ - docs: this.existingDocs, - files: this.existingFiles, - }), - }, - } - - this.Settings = { path: { dumpFolder: this.dumpPath } } - - this.stream = { pipeline: sinon.stub().resolves() } - - this.UpdateMerger = SandboxedModule.require(MODULE_PATH, { - requires: { - 'fs/promises': this.fsPromises, - fs: this.fs, - '../Editor/EditorController': this.EditorController, - '../Uploads/FileTypeManager': this.FileTypeManager, - '../Project/ProjectEntityHandler': this.ProjectEntityHandler, - '@overleaf/settings': this.Settings, - 'stream/promises': this.stream, - crypto: this.crypto, - }, - }) - }) - - describe('mergeUpdate', function () { - describe('doc updates for a new doc', function () { - beforeEach(async function () { - this.FileTypeManager.promises.getType.resolves({ - binary: false, - encoding: 'utf-8', - }) - this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate( - this.userId, - this.projectId, - this.docPath, - this.updateRequest, - this.source - ) - }) - - it('should look at the file contents', function () { - expect(this.FileTypeManager.promises.getType).to.have.been.called - }) - - it('should process update as doc', function () { - expect( - this.EditorController.promises.upsertDocWithPath - ).to.have.been.calledWith( - this.projectId, - this.docPath, - this.docLines, - this.source, - this.userId - ) - }) - - it('removes the temp file from disk', function () { - expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath) - }) - - it('returns the entity id and rev', function () { - expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) - expect(this.mergeUpdateResult.rev).to.equal(2) - }) - }) - - describe('file updates for a new file ', function () { - beforeEach(async function () { - this.FileTypeManager.promises.getType.resolves({ binary: true }) - this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate( - this.userId, - this.projectId, - this.filePath, - this.updateRequest, - this.source - ) - }) - - it('should look at the file contents', function () { - expect(this.FileTypeManager.promises.getType).to.have.been.called - }) - - it('should process update as file', function () { - expect( - this.EditorController.promises.upsertFileWithPath - ).to.have.been.calledWith( - this.projectId, - this.filePath, - this.fsPath, - null, - this.source, - this.userId - ) - }) - - it('removes the temp file from disk', function () { - expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath) - }) - - it('returns the entity id and rev', function () { - expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) - expect(this.mergeUpdateResult.rev).to.equal(6) - }) - }) - - describe('doc updates for an existing doc', function () { - beforeEach(async function () { - this.FileTypeManager.promises.getType.resolves({ - binary: false, - encoding: 'utf-8', - }) - this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate( - this.userId, - this.projectId, - this.existingDocPath, - this.updateRequest, - this.source - ) - }) - - it('should look at the file contents', function () { - expect(this.FileTypeManager.promises.getType).to.have.been.called - }) - - it('should process update as doc', function () { - expect( - this.EditorController.promises.upsertDocWithPath - ).to.have.been.calledWith( - this.projectId, - this.existingDocPath, - this.docLines, - this.source, - this.userId - ) - }) - - it('removes the temp file from disk', function () { - expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath) - }) - - it('returns the entity id and rev', function () { - expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) - expect(this.mergeUpdateResult.rev).to.equal(2) - }) - }) - - describe('file updates for an existing file', function () { - beforeEach(async function () { - this.FileTypeManager.promises.getType.resolves({ binary: true }) - this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate( - this.userId, - this.projectId, - this.existingFilePath, - this.updateRequest, - this.source - ) - }) - - it('should look at the file contents', function () { - expect(this.FileTypeManager.promises.getType).to.have.been.called - }) - - it('should process update as file', function () { - expect( - this.EditorController.promises.upsertFileWithPath - ).to.have.been.calledWith( - this.projectId, - this.existingFilePath, - this.fsPath, - null, - this.source, - this.userId - ) - }) - - it('removes the temp file from disk', function () { - expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath) - }) - - it('returns the entity id and rev', function () { - expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) - expect(this.mergeUpdateResult.rev).to.equal(6) - }) - }) - }) - - describe('file updates for an existing doc', function () { - beforeEach(async function () { - this.FileTypeManager.promises.getType.resolves({ binary: true }) - this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate( - this.userId, - this.projectId, - this.existingDocPath, - this.updateRequest, - this.source - ) - }) - - it('should look at the file contents', function () { - expect(this.FileTypeManager.promises.getType).to.have.been.called - }) - - it('should process update as file', function () { - expect( - this.EditorController.promises.upsertFileWithPath - ).to.have.been.calledWith( - this.projectId, - this.existingDocPath, - this.fsPath, - null, - this.source, - this.userId - ) - }) - - it('removes the temp file from disk', function () { - expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath) - }) - - it('returns the entity id and rev', function () { - expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) - expect(this.mergeUpdateResult.rev).to.equal(6) - }) - }) - - describe('doc updates for an existing file', function () { - beforeEach(async function () { - this.FileTypeManager.promises.getType.resolves({ binary: true }) - this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate( - this.userId, - this.projectId, - this.existingFilePath, - this.updateRequest, - this.source - ) - }) - - it('should look at the file contents', function () { - expect(this.FileTypeManager.promises.getType).to.have.been.called - }) - - it('should not delete the existing file', function () { - expect(this.EditorController.promises.deleteEntityWithPath).to.not.have - .been.called - }) - - it('should process update as file', function () { - expect( - this.EditorController.promises.upsertFileWithPath - ).to.have.been.calledWith( - this.projectId, - this.existingFilePath, - this.fsPath, - null, - this.source, - this.userId - ) - }) - - it('removes the temp file from disk', function () { - expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath) - }) - - it('returns the entity id and rev', function () { - expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId) - expect(this.mergeUpdateResult.rev).to.equal(6) - }) - }) - - describe('deleteUpdate', function () { - beforeEach(async function () { - this.deleteUpdateResult = await this.UpdateMerger.promises.deleteUpdate( - this.userId, - this.projectId, - this.docPath, - this.source - ) - }) - - afterEach(function () { - delete this.deleteUpdateResult - }) - - it('should delete the entity in the editor controller', function () { - expect( - this.EditorController.promises.deleteEntityWithPath - ).to.have.been.calledWith( - this.projectId, - this.docPath, - this.source, - this.userId - ) - }) - - it('returns the entity id', function () { - expect(this.deleteUpdateResult).to.be.instanceOf(ObjectId) - }) - }) -}) diff --git a/services/web/test/unit/src/Tutorial/TutorialHandlerTests.js b/services/web/test/unit/src/Tutorial/TutorialHandler.test.mjs similarity index 72% rename from services/web/test/unit/src/Tutorial/TutorialHandlerTests.js rename to services/web/test/unit/src/Tutorial/TutorialHandler.test.mjs index a58ff9d6f5..99de537eba 100644 --- a/services/web/test/unit/src/Tutorial/TutorialHandlerTests.js +++ b/services/web/test/unit/src/Tutorial/TutorialHandler.test.mjs @@ -1,19 +1,20 @@ -const SandboxedModule = require('sandboxed-module') -const { expect } = require('chai') -const sinon = require('sinon') -const { ObjectId } = require('mongodb-legacy') +import { vi, expect } from 'vitest' +import sinon from 'sinon' +import mongodb from 'mongodb-legacy' + +const { ObjectId } = mongodb const MODULE_PATH = '../../../../app/src/Features/Tutorial/TutorialHandler' describe('TutorialHandler', function () { - beforeEach(function () { - this.clock = sinon.useFakeTimers() + beforeEach(async function (ctx) { + ctx.clock = sinon.useFakeTimers() const THIRTY_DAYS_AGO = Date.now() - 30 * 24 * 60 * 60 * 1000 const TOMORROW = Date.now() + 24 * 60 * 60 * 1000 const YESTERDAY = Date.now() - 24 * 60 * 60 * 1000 - this.user = { + ctx.user = { _id: new ObjectId(), completedTutorials: { 'legacy-format': new Date(Date.now() - 1000), @@ -42,28 +43,26 @@ describe('TutorialHandler', function () { }, } - this.UserUpdater = { + ctx.UserUpdater = { promises: { updateUser: sinon.stub().resolves(), }, } - this.TutorialHandler = SandboxedModule.require(MODULE_PATH, { - requires: { - '../User/UserUpdater': this.UserUpdater, - }, - }) + vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({ + default: ctx.UserUpdater, + })) + + ctx.TutorialHandler = (await import(MODULE_PATH)).default }) - afterEach(function () { - this.clock.restore() + afterEach(function (ctx) { + ctx.clock.restore() }) describe('getInactiveTutorials', function () { - it('returns all recorded tutorials except when they were posponed long ago', function () { - const hiddenTutorials = this.TutorialHandler.getInactiveTutorials( - this.user - ) + it('returns all recorded tutorials except when they were posponed long ago', function (ctx) { + const hiddenTutorials = ctx.TutorialHandler.getInactiveTutorials(ctx.user) expect(hiddenTutorials).to.have.members([ 'legacy-format', 'completed', @@ -73,7 +72,7 @@ describe('TutorialHandler', function () { expect(hiddenTutorials).to.have.lengthOf(4) - const shownTutorials = Object.keys(this.user.completedTutorials).filter( + const shownTutorials = Object.keys(ctx.user.completedTutorials).filter( key => !hiddenTutorials.includes(key) )