mirror of
https://github.com/overleaf/overleaf.git
synced 2025-12-05 01:10:29 +00:00
Compare commits
21 Commits
6e9df02c16
...
0409b27311
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0409b27311 | ||
|
|
677b68a6b7 | ||
|
|
3a1f3af6a4 | ||
|
|
93526bec96 | ||
|
|
db1966b0aa | ||
|
|
678cef1fa6 | ||
|
|
43f6f10d85 | ||
|
|
3d3be18f57 | ||
|
|
e909995ce0 | ||
|
|
7de4133d08 | ||
|
|
bdf47f7a78 | ||
|
|
1a94ebca8e | ||
|
|
bb4134dc05 | ||
|
|
6b1c14b263 | ||
|
|
72b8f9b9d2 | ||
|
|
d66c73a29e | ||
|
|
d75c5f72fb | ||
|
|
48a3d6a10a | ||
|
|
71457d74cb | ||
|
|
387ef81a31 | ||
|
|
a4e29d5380 |
1
server-ce/test/.gitignore
vendored
1
server-ce/test/.gitignore
vendored
@@ -1,3 +1,2 @@
|
||||
cypress-reports/
|
||||
data/
|
||||
docker-mailtrap/
|
||||
|
||||
@@ -7,75 +7,22 @@ patches/**
|
||||
server-ce/**
|
||||
server-pro/**
|
||||
|
||||
services/clsi/seccomp/**
|
||||
services/history-v1/api/**
|
||||
services/history-v1/storage/**
|
||||
|
||||
# echo chat clsi contacts docstore document-updater filestore history-v1 notifications project-history real-time references templates | xargs -n1 echo | xargs -I% echo 'services/%/*' 'services/%/app/**' 'services/%/config/*' 'services/%/scripts/**' | xargs -n1 echo | sort
|
||||
services/chat/*
|
||||
services/chat/app/**
|
||||
services/chat/config/*
|
||||
services/chat/scripts/**
|
||||
services/clsi/*
|
||||
services/clsi/app/**
|
||||
services/clsi/config/*
|
||||
services/clsi/scripts/**
|
||||
services/contacts/*
|
||||
services/contacts/app/**
|
||||
services/contacts/config/*
|
||||
services/contacts/scripts/**
|
||||
services/docstore/*
|
||||
services/docstore/app/**
|
||||
services/docstore/config/*
|
||||
services/docstore/scripts/**
|
||||
services/document-updater/*
|
||||
services/document-updater/app/**
|
||||
services/document-updater/config/*
|
||||
services/document-updater/scripts/**
|
||||
services/filestore/*
|
||||
services/filestore/app/**
|
||||
services/filestore/config/*
|
||||
services/filestore/scripts/**
|
||||
services/history-v1/*
|
||||
services/history-v1/app/**
|
||||
services/history-v1/config/*
|
||||
services/history-v1/scripts/**
|
||||
services/notifications/*
|
||||
services/notifications/app/**
|
||||
services/notifications/config/*
|
||||
services/notifications/scripts/**
|
||||
services/project-history/*
|
||||
services/project-history/app/**
|
||||
services/project-history/config/*
|
||||
services/project-history/scripts/**
|
||||
services/real-time/*
|
||||
services/real-time/app/**
|
||||
services/real-time/config/*
|
||||
services/real-time/scripts/**
|
||||
services/references/*
|
||||
services/references/app/**
|
||||
services/references/config/*
|
||||
services/references/scripts/**
|
||||
services/templates/*
|
||||
services/templates/app/**
|
||||
services/templates/config/*
|
||||
services/templates/scripts/**
|
||||
|
||||
|
||||
services/web/*
|
||||
services/web/app/**
|
||||
services/web/config/settings.defaults.js
|
||||
services/web/config/settings.overrides.server-pro.js
|
||||
services/web/frontend/**
|
||||
services/web/locales/**
|
||||
services/web/modules/*/*
|
||||
services/web/modules/*/app/**
|
||||
services/web/modules/*/frontend/**
|
||||
services/web/modules/*/scripts/**
|
||||
services/web/modules/*/types/**
|
||||
services/web/public/**
|
||||
services/web/types/**
|
||||
services/web/webpack-plugins/**
|
||||
# echo chat clsi contacts docstore document-updater filestore history-v1 notifications project-history real-time references templates web | xargs -n1 echo | xargs -I% echo 'services/%/**'
|
||||
# BEGIN GENERATED
|
||||
services/chat/**
|
||||
services/clsi/**
|
||||
services/contacts/**
|
||||
services/docstore/**
|
||||
services/document-updater/**
|
||||
services/filestore/**
|
||||
services/history-v1/**
|
||||
services/notifications/**
|
||||
services/project-history/**
|
||||
services/real-time/**
|
||||
services/references/**
|
||||
services/templates/**
|
||||
services/web/**
|
||||
# END GENERATED
|
||||
|
||||
tools/migrations/**
|
||||
|
||||
|
||||
19
server-ce/test/Jenkinsfile
vendored
19
server-ce/test/Jenkinsfile
vendored
@@ -41,22 +41,9 @@ pipeline {
|
||||
OVERLEAF_PRO_TAG_LATEST = "us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pro-internal:main"
|
||||
}
|
||||
stages {
|
||||
// Retries will use the same pipeline instance. Reset the vars.
|
||||
stage('Reset vars') {
|
||||
stage('Create reports folder') {
|
||||
steps {
|
||||
script {
|
||||
job_copybara_done = false
|
||||
job_npm_install_done = false
|
||||
job_prefetch_custom_done = false
|
||||
job_prefetch_default_done = false
|
||||
job_server_ce_build_done = false
|
||||
job_server_pro_build_done = false
|
||||
}
|
||||
// Reset the results folder.
|
||||
// Use a folder that is not managed by cypress, as cypress will clear its results folder at the start of each individual run.
|
||||
// I.e. we would loose the test results from finished/running test suites when the last test suite starts.
|
||||
sh 'rm -rf server-ce/test/cypress-reports/'
|
||||
sh 'mkdir -p server-ce/test/cypress-reports/'
|
||||
sh 'mkdir server-ce/test/reports'
|
||||
}
|
||||
}
|
||||
stage('Parallel') {
|
||||
@@ -350,7 +337,7 @@ pipeline {
|
||||
post {
|
||||
// Collect junit test results for both success and failure case.
|
||||
always {
|
||||
junit checksName: 'Server Pro E2E test results', testResults: 'server-ce/test/cypress-reports/junit-*.xml'
|
||||
junit checksName: 'Server Pro E2E test results', testResults: 'server-ce/test/reports/junit-*.xml'
|
||||
}
|
||||
// Ensure tear down of test containers, remove CE docker images, then run general Jenkins VM cleanup.
|
||||
cleanup {
|
||||
|
||||
@@ -45,7 +45,7 @@ SHARD_PROJECT_NAMES = \
|
||||
test-pro-custom-4
|
||||
CLEAN_SHARDS=$(addprefix clean/,$(SHARD_PROJECT_NAMES))
|
||||
clean: $(CLEAN_SHARDS)
|
||||
-docker compose run --no-deps --rm --entrypoint rm host-admin -rf docker-compose.override.yml docker-compose.*_*.yml cypress-reports/ data/
|
||||
-docker compose run --no-deps --rm --entrypoint rm host-admin -rf docker-compose.override.yml docker-compose.*_*.yml reports/ data/
|
||||
-docker compose down --remove-orphans --rmi local --timeout 0 --volumes
|
||||
|
||||
$(CLEAN_SHARDS): clean/%:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||
mochaJunitReporterReporterOptions: {
|
||||
mochaFile: `cypress-reports/junit-${process.env.CYPRESS_SHARD}-[suiteFilename].xml`,
|
||||
mochaFile: `reports/junit-${process.env.CYPRESS_SHARD}-[suiteFilename].xml`,
|
||||
includePending: true,
|
||||
jenkinsMode: true,
|
||||
jenkinsClassnamePrefix: 'Server Pro E2E tests',
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"scripts": {
|
||||
"cypress:open": "cypress open --e2e --browser chrome",
|
||||
"cypress:run": "cypress run --e2e --browser chrome",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,mjs,ts,tsx,json}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,mjs,ts,tsx,json}'"
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,mjs,ts,tsx}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,mjs,ts,tsx}'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@isomorphic-git/lightning-fs": "^4.6.0",
|
||||
|
||||
@@ -62,6 +62,10 @@ export async function getThread(context) {
|
||||
return await callMessageHttpController(context, _getThread)
|
||||
}
|
||||
|
||||
export async function getThreadMessage(context) {
|
||||
return await callMessageHttpController(context, _getThreadMessage)
|
||||
}
|
||||
|
||||
export async function resolveThread(context) {
|
||||
return await callMessageHttpController(context, _resolveThread)
|
||||
}
|
||||
@@ -208,6 +212,30 @@ const _getThread = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const _getThreadMessage = async (req, res) => {
|
||||
const { projectId, threadId, messageId } = req.params
|
||||
logger.debug(
|
||||
{ projectId, threadId, messageId },
|
||||
'getting single thread message'
|
||||
)
|
||||
try {
|
||||
const room = await ThreadManager.findThread(projectId, threadId)
|
||||
const message = await MessageManager.getMessage(room._id, messageId)
|
||||
const formattedMsg = MessageFormatter.formatMessageForClientSide(message)
|
||||
|
||||
res.status(200).setBody(formattedMsg)
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ThreadManager.MissingThreadError ||
|
||||
error instanceof MessageManager.MissingMessageError
|
||||
) {
|
||||
res.status(404)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const _resolveThread = async (req, res) => {
|
||||
const { projectId, threadId } = req.params
|
||||
const { user_id: userId } = req.body
|
||||
|
||||
@@ -243,6 +243,20 @@ paths:
|
||||
name: messageId
|
||||
in: path
|
||||
required: true
|
||||
get:
|
||||
summary: Get thread message
|
||||
tags: []
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Message'
|
||||
'404':
|
||||
description: Message not found
|
||||
operationId: getThreadMessage
|
||||
description: Get a specific message by message ID from the thread with Thread ID and Project ID provided
|
||||
delete:
|
||||
summary: Delete message
|
||||
operationId: deleteMessage
|
||||
|
||||
@@ -766,6 +766,19 @@ function _emitMetrics(request, status, stats, timings) {
|
||||
}
|
||||
}
|
||||
|
||||
const imgTimings = stats.latexmk?.['latexmk-img-times']
|
||||
if (imgTimings != null) {
|
||||
for (const timing of imgTimings) {
|
||||
ClsiMetrics.imageProcessingDurationSeconds.observe(
|
||||
{
|
||||
group: request.compileGroup,
|
||||
type: timing.type,
|
||||
},
|
||||
timing.time_ms / 1000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ClsiMetrics.compilesTotal.inc({
|
||||
status,
|
||||
engine: request.compiler,
|
||||
|
||||
@@ -22,7 +22,7 @@ const NOTEWORTHY_DEPENDENCIES_REGEXP =
|
||||
* There are different formats of `latexmk` output depending on the version.
|
||||
* The parsers attempt to handle these variations gracefully.
|
||||
*/
|
||||
const LATEX_MK_METRICS = [
|
||||
const LATEX_MK_METRICS_STDOUT = [
|
||||
// Extract individual latexmk rule times as an array of objects, each with 'rule'
|
||||
// and 'time_ms' properties
|
||||
[
|
||||
@@ -100,22 +100,61 @@ const LATEX_MK_METRICS = [
|
||||
],
|
||||
]
|
||||
|
||||
const LATEX_MK_METRICS_STDERR = [
|
||||
[
|
||||
'latexmk-img-times',
|
||||
s => {
|
||||
const pngCopyMatches = s.matchAll(/^PNG copy: (.*)$/gm)
|
||||
const pngCopyFiles = new Set()
|
||||
for (const match of pngCopyMatches) {
|
||||
const filename = match[1]
|
||||
pngCopyFiles.add(filename)
|
||||
}
|
||||
|
||||
const timingMatches = s.matchAll(
|
||||
/^Image written \((PNG|JPG|JBIG2|PDF), (\d+) ms\): (.*)$/gm
|
||||
)
|
||||
const timingsByType = new Map()
|
||||
for (const match of timingMatches) {
|
||||
let type = match[1]
|
||||
const timeMs = parseInt(match[2], 10)
|
||||
const filename = match[3]
|
||||
if (type === 'PNG' && pngCopyFiles.has(filename)) {
|
||||
type = 'PNG-fast-copy'
|
||||
}
|
||||
const accumulatedTime = timingsByType.get(type) ?? 0
|
||||
timingsByType.set(type, accumulatedTime + timeMs)
|
||||
}
|
||||
return Array.from(timingsByType.entries()).map(([type, timeMs]) => ({
|
||||
type,
|
||||
time_ms: timeMs,
|
||||
}))
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
/**
|
||||
* Parses latexmk stdout for metrics and adds them to the stats object.
|
||||
* It iterates through a predefined list of metric matchers (LATEX_MK_METRICS),
|
||||
* applies them to the stdout, and adds any successful matches to the
|
||||
* `stats.latexmk` object.
|
||||
*
|
||||
* @param {{stdout?: string}} output - The output from the latexmk process.
|
||||
* @param {{stdout?: string, stderr?: string}} output - The output from the latexmk process.
|
||||
* @param {{latexmk: object}} stats - The statistics object to update. This object is mutated.
|
||||
*/
|
||||
function addLatexMkMetrics(output, stats) {
|
||||
for (const [stat, matcher] of LATEX_MK_METRICS) {
|
||||
for (const [stat, matcher] of LATEX_MK_METRICS_STDOUT) {
|
||||
const match = matcher(output?.stdout || '', stats.latexmk)
|
||||
if (match) {
|
||||
stats.latexmk[stat] = match
|
||||
}
|
||||
}
|
||||
for (const [stat, matcher] of LATEX_MK_METRICS_STDERR) {
|
||||
const match = matcher(output?.stderr || '', stats.latexmk)
|
||||
if (match) {
|
||||
stats.latexmk[stat] = match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,13 @@ const latexmkRuleDurationSeconds = new prom.Histogram({
|
||||
labelNames: ['group', 'rule'],
|
||||
})
|
||||
|
||||
const imageProcessingDurationSeconds = new prom.Histogram({
|
||||
name: 'clsi_image_processing_duration_seconds',
|
||||
help: 'Time spent processing images',
|
||||
buckets: COMPILE_TIME_BUCKETS,
|
||||
labelNames: ['group', 'type'],
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
compilesTotal,
|
||||
compileDurationSeconds,
|
||||
@@ -61,4 +68,5 @@ module.exports = {
|
||||
syncResourcesDurationSeconds,
|
||||
processOutputFilesDurationSeconds,
|
||||
latexmkRuleDurationSeconds,
|
||||
imageProcessingDurationSeconds,
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ describe('LatexRunner', function () {
|
||||
'makeindex,bibtex,latex,makeindex,bibtex,latex,makeindex,bibtex,latex',
|
||||
'latexmk-rules-run': 9,
|
||||
'latexmk-time': { total: 2930 },
|
||||
'latexmk-img-times': [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -280,6 +281,7 @@ describe('LatexRunner', function () {
|
||||
},
|
||||
'latexmk-clock-time': 4870,
|
||||
'latexmk-rules-run': 4,
|
||||
'latexmk-img-times': [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,12 @@ async function getThread(projectId, threadId) {
|
||||
return await fetchJson(chatApiUrl(`/project/${projectId}/thread/${threadId}`))
|
||||
}
|
||||
|
||||
async function getThreadMessage(projectId, threadId, messageId) {
|
||||
return await fetchJson(
|
||||
chatApiUrl(`/project/${projectId}/thread/${threadId}/messages/${messageId}`)
|
||||
)
|
||||
}
|
||||
|
||||
async function getThreads(projectId) {
|
||||
return await fetchJson(chatApiUrl(`/project/${projectId}/threads`))
|
||||
}
|
||||
@@ -161,6 +167,7 @@ function chatApiUrl(path) {
|
||||
|
||||
module.exports = {
|
||||
getThread: callbackify(getThread),
|
||||
getThreadMessage: callbackify(getThreadMessage),
|
||||
getThreads: callbackify(getThreads),
|
||||
destroyProject: callbackify(destroyProject),
|
||||
sendGlobalMessage: callbackify(sendGlobalMessage),
|
||||
@@ -180,6 +187,7 @@ module.exports = {
|
||||
generateThreadData: callbackify(generateThreadData),
|
||||
promises: {
|
||||
getThread,
|
||||
getThreadMessage,
|
||||
getThreads,
|
||||
destroyProject,
|
||||
sendGlobalMessage,
|
||||
|
||||
@@ -172,7 +172,14 @@ async function prepareClsiCache(
|
||||
userId,
|
||||
'populate-clsi-cache'
|
||||
)
|
||||
if (variant !== 'enabled') return
|
||||
if (variant !== 'enabled') {
|
||||
// Pre-populate the cache for the users in the split-test for prompts.
|
||||
const { variant } = await SplitTestHandler.promises.getAssignmentForUser(
|
||||
userId,
|
||||
'populate-clsi-cache-for-prompt'
|
||||
)
|
||||
if (variant !== 'enabled') return
|
||||
}
|
||||
|
||||
const features = await UserGetter.promises.getUserFeatures(userId)
|
||||
if (features.compileGroup !== 'priority') return
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
const { URL, URLSearchParams } = require('url')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const {
|
||||
import { URL, URLSearchParams } from 'node:url'
|
||||
import OError from '@overleaf/o-error'
|
||||
import Settings from '@overleaf/settings'
|
||||
import {
|
||||
fetchNothing,
|
||||
fetchStringWithResponse,
|
||||
RequestFailedError,
|
||||
} = require('@overleaf/fetch-utils')
|
||||
const RedisWrapper = require('../../infrastructure/RedisWrapper')
|
||||
const Cookie = require('cookie')
|
||||
const logger = require('@overleaf/logger')
|
||||
const Metrics = require('@overleaf/metrics')
|
||||
} from '@overleaf/fetch-utils'
|
||||
import RedisWrapper from '../../infrastructure/RedisWrapper.js'
|
||||
import Cookie from 'cookie'
|
||||
import logger from '@overleaf/logger'
|
||||
import Metrics from '@overleaf/metrics'
|
||||
|
||||
const clsiCookiesEnabled = (Settings.clsiCookie?.key ?? '') !== ''
|
||||
|
||||
@@ -256,4 +256,4 @@ const ClsiCookieManagerFactory = function (backendGroup) {
|
||||
return cookieManager
|
||||
}
|
||||
|
||||
module.exports = ClsiCookieManagerFactory
|
||||
export default ClsiCookieManagerFactory
|
||||
@@ -1,5 +1,5 @@
|
||||
const _ = require('lodash')
|
||||
const settings = require('@overleaf/settings')
|
||||
import _ from 'lodash'
|
||||
import settings from '@overleaf/settings'
|
||||
|
||||
const ClsiFormatChecker = {
|
||||
checkRecoursesForProblems(resources) {
|
||||
@@ -56,4 +56,4 @@ const ClsiFormatChecker = {
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = ClsiFormatChecker
|
||||
export default ClsiFormatChecker
|
||||
@@ -12,10 +12,10 @@ import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { Cookie } from 'tough-cookie'
|
||||
import ClsiCookieManagerFactory from './ClsiCookieManager.js'
|
||||
import ClsiStateManager from './ClsiStateManager.js'
|
||||
import ClsiCookieManagerFactory from './ClsiCookieManager.mjs'
|
||||
import ClsiStateManager from './ClsiStateManager.mjs'
|
||||
import _ from 'lodash'
|
||||
import ClsiFormatChecker from './ClsiFormatChecker.js'
|
||||
import ClsiFormatChecker from './ClsiFormatChecker.mjs'
|
||||
import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
|
||||
import Metrics from '@overleaf/metrics'
|
||||
import Errors from '../Errors/Errors.js'
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/* eslint-disable
|
||||
n/handle-callback-err,
|
||||
max-len,
|
||||
no-unused-vars,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
@@ -13,11 +8,8 @@
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
let ClsiStateManager
|
||||
const Settings = require('@overleaf/settings')
|
||||
const logger = require('@overleaf/logger')
|
||||
const crypto = require('crypto')
|
||||
const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
|
||||
import crypto from 'node:crypto'
|
||||
import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
|
||||
|
||||
// The "state" of a project is a hash of the relevant attributes in the
|
||||
// project object in this case we only need the rootFolder.
|
||||
@@ -36,7 +28,7 @@ const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
|
||||
const buildState = s =>
|
||||
crypto.createHash('sha1').update(s, 'utf8').digest('hex')
|
||||
|
||||
module.exports = ClsiStateManager = {
|
||||
export default {
|
||||
computeHash(project, options) {
|
||||
const { docs, files } =
|
||||
ProjectEntityHandler.getAllEntitiesFromProject(project)
|
||||
@@ -12,7 +12,7 @@ import Errors from '../Errors/Errors.js'
|
||||
import SessionManager from '../Authentication/SessionManager.js'
|
||||
import { RateLimiter } from '../../infrastructure/RateLimiter.js'
|
||||
import Validation from '../../infrastructure/Validation.js'
|
||||
import ClsiCookieManagerFactory from './ClsiCookieManager.js'
|
||||
import ClsiCookieManagerFactory from './ClsiCookieManager.mjs'
|
||||
import Path from 'node:path'
|
||||
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
|
||||
import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
|
||||
@@ -73,9 +73,20 @@ async function _getSplitTestOptions(req, res) {
|
||||
res,
|
||||
'populate-clsi-cache'
|
||||
)
|
||||
const populateClsiCache = populateClsiCacheVariant === 'enabled'
|
||||
let populateClsiCache = populateClsiCacheVariant === 'enabled'
|
||||
const compileFromClsiCache = populateClsiCache // use same split-test
|
||||
|
||||
if (!populateClsiCache) {
|
||||
// Pre-populate the cache for the users in the split-test for prompts.
|
||||
// Keep the compile from cache disabled for now.
|
||||
const { variant } = await SplitTestHandler.promises.getAssignment(
|
||||
editorReq,
|
||||
res,
|
||||
'populate-clsi-cache-for-prompt'
|
||||
)
|
||||
populateClsiCache = variant === 'enabled'
|
||||
}
|
||||
|
||||
const pdfDownloadDomain = Settings.pdfDownloadDomain
|
||||
|
||||
if (!req.query.enable_pdf_caching) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { promisify, callbackify } = require('util')
|
||||
const UserGetter = require('../User/UserGetter')
|
||||
const UserMembershipsHandler = require('../UserMembership/UserMembershipsHandler')
|
||||
const UserMembershipEntityConfigs = require('../UserMembership/UserMembershipEntityConfigs')
|
||||
import { promisify, callbackify } from 'node:util'
|
||||
import UserGetter from '../User/UserGetter.js'
|
||||
import UserMembershipsHandler from '../UserMembership/UserMembershipsHandler.js'
|
||||
import UserMembershipEntityConfigs from '../UserMembership/UserMembershipEntityConfigs.js'
|
||||
|
||||
async function getCurrentAffiliations(userId) {
|
||||
const fullEmails = await UserGetter.promises.getUserFullEmails(userId)
|
||||
@@ -98,4 +98,4 @@ InstitutionsGetter.promises = {
|
||||
getManagedInstitutions: promisify(InstitutionsGetter.getManagedInstitutions),
|
||||
}
|
||||
|
||||
module.exports = InstitutionsGetter
|
||||
export default InstitutionsGetter
|
||||
@@ -1,21 +1,20 @@
|
||||
const {
|
||||
callbackifyAll,
|
||||
promiseMapWithLimit,
|
||||
} = require('@overleaf/promise-utils')
|
||||
const { ObjectId } = require('mongodb-legacy')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const logger = require('@overleaf/logger')
|
||||
const { fetchJson } = require('@overleaf/fetch-utils')
|
||||
const InstitutionsAPI = require('./InstitutionsAPI')
|
||||
const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
|
||||
const FeaturesHelper = require('../Subscription/FeaturesHelper')
|
||||
const UserGetter = require('../User/UserGetter')
|
||||
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
|
||||
const NotificationsHandler = require('../Notifications/NotificationsHandler')
|
||||
const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
|
||||
const { Institution } = require('../../models/Institution')
|
||||
const { Subscription } = require('../../models/Subscription')
|
||||
const OError = require('@overleaf/o-error')
|
||||
import { callbackifyAll, promiseMapWithLimit } from '@overleaf/promise-utils'
|
||||
import mongodb from 'mongodb-legacy'
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import { fetchJson } from '@overleaf/fetch-utils'
|
||||
import InstitutionsAPI from './InstitutionsAPI.js'
|
||||
import FeaturesUpdater from '../Subscription/FeaturesUpdater.js'
|
||||
import FeaturesHelper from '../Subscription/FeaturesHelper.js'
|
||||
import UserGetter from '../User/UserGetter.js'
|
||||
import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
|
||||
import NotificationsHandler from '../Notifications/NotificationsHandler.js'
|
||||
import SubscriptionLocator from '../Subscription/SubscriptionLocator.js'
|
||||
import { Institution } from '../../models/Institution.js'
|
||||
import { Subscription } from '../../models/Subscription.js'
|
||||
import OError from '@overleaf/o-error'
|
||||
|
||||
const { ObjectId } = mongodb
|
||||
|
||||
const ASYNC_LIMIT = parseInt(process.env.ASYNC_LIMIT, 10) || 5
|
||||
|
||||
@@ -355,7 +354,7 @@ async function affiliateUserByReversedHostname(user, reversedHostname) {
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
...callbackifyAll(InstitutionsManager),
|
||||
promises: InstitutionsManager,
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import FeaturesUpdater from '../Subscription/FeaturesUpdater.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 InstitutionsGetter from '../Institutions/InstitutionsGetter.mjs'
|
||||
import ProjectAuditLogHandler from './ProjectAuditLogHandler.mjs'
|
||||
import PublicAccessLevels from '../Authorization/PublicAccessLevels.js'
|
||||
import TagsHandler from '../Tags/TagsHandler.js'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
|
||||
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
|
||||
import SubscriptionEmailHandler from './SubscriptionEmailHandler.js'
|
||||
import SubscriptionEmailHandler from './SubscriptionEmailHandler.mjs'
|
||||
import { AI_ADD_ON_CODE } from './AiHelper.js'
|
||||
import mongodb from 'mongodb-legacy'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const EmailBuilder = require('../Email/EmailBuilder')
|
||||
const EmailMessageHelper = require('../Email/EmailMessageHelper')
|
||||
const settings = require('@overleaf/settings')
|
||||
import EmailBuilder from '../Email/EmailBuilder.js'
|
||||
import EmailMessageHelper from '../Email/EmailMessageHelper.js'
|
||||
import settings from '@overleaf/settings'
|
||||
|
||||
EmailBuilder.templates.trialOnboarding = EmailBuilder.NoCTAEmailTemplate({
|
||||
subject(opts) {
|
||||
@@ -1,8 +1,8 @@
|
||||
const EmailHandler = require('../Email/EmailHandler')
|
||||
const UserGetter = require('../User/UserGetter')
|
||||
require('./SubscriptionEmailBuilder')
|
||||
const PlansLocator = require('./PlansLocator')
|
||||
const Settings = require('@overleaf/settings')
|
||||
import EmailHandler from '../Email/EmailHandler.js'
|
||||
import UserGetter from '../User/UserGetter.js'
|
||||
import './SubscriptionEmailBuilder.mjs'
|
||||
import PlansLocator from './PlansLocator.js'
|
||||
import Settings from '@overleaf/settings'
|
||||
|
||||
const SubscriptionEmailHandler = {
|
||||
async sendTrialOnboardingEmail(userId, planCode) {
|
||||
@@ -26,4 +26,4 @@ const SubscriptionEmailHandler = {
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = SubscriptionEmailHandler
|
||||
export default SubscriptionEmailHandler
|
||||
@@ -1,4 +1,4 @@
|
||||
const dateformat = require('dateformat')
|
||||
import dateformat from 'dateformat'
|
||||
|
||||
function formatDateTime(date) {
|
||||
if (!date) {
|
||||
@@ -14,7 +14,7 @@ function formatDate(date) {
|
||||
return dateformat(date, 'mmmm dS, yyyy', true)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
formatDateTime,
|
||||
formatDate,
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import Settings from '@overleaf/settings'
|
||||
import PlansLocator from './PlansLocator.js'
|
||||
import { isStandaloneAiAddOnPlanCode } from './AiHelper.js'
|
||||
import { MEMBERS_LIMIT_ADD_ON_CODE } from './PaymentProviderEntities.js'
|
||||
import SubscriptionFormatters from './SubscriptionFormatters.js'
|
||||
import SubscriptionFormatters from './SubscriptionFormatters.mjs'
|
||||
import SubscriptionLocator from './SubscriptionLocator.js'
|
||||
import InstitutionsGetter from '../Institutions/InstitutionsGetter.js'
|
||||
import InstitutionsManager from '../Institutions/InstitutionsManager.js'
|
||||
import InstitutionsGetter from '../Institutions/InstitutionsGetter.mjs'
|
||||
import InstitutionsManager from '../Institutions/InstitutionsManager.mjs'
|
||||
import PublishersGetter from '../Publishers/PublishersGetter.js'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import _ from 'lodash'
|
||||
|
||||
@@ -7,8 +7,8 @@ import UserDeleter from './UserDeleter.js'
|
||||
import UserGetter from './UserGetter.js'
|
||||
import UserUpdater from './UserUpdater.js'
|
||||
import Analytics from '../Analytics/AnalyticsManager.js'
|
||||
import UserOnboardingEmailManager from './UserOnboardingEmailManager.js'
|
||||
import UserPostRegistrationAnalyticsManager from './UserPostRegistrationAnalyticsManager.js'
|
||||
import UserOnboardingEmailManager from './UserOnboardingEmailManager.mjs'
|
||||
import UserPostRegistrationAnalyticsManager from './UserPostRegistrationAnalyticsManager.mjs'
|
||||
import OError from '@overleaf/o-error'
|
||||
|
||||
async function _addAffiliation(user, affiliationOptions) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const Queues = require('../../infrastructure/Queues')
|
||||
const EmailHandler = require('../Email/EmailHandler')
|
||||
const UserUpdater = require('./UserUpdater')
|
||||
const UserGetter = require('./UserGetter')
|
||||
const Settings = require('@overleaf/settings')
|
||||
import Queues from '../../infrastructure/Queues.js'
|
||||
import EmailHandler from '../Email/EmailHandler.js'
|
||||
import UserUpdater from './UserUpdater.js'
|
||||
import UserGetter from './UserGetter.js'
|
||||
import Settings from '@overleaf/settings'
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -26,4 +26,4 @@ async function sendOnboardingEmail(userId) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { scheduleOnboardingEmail, sendOnboardingEmail }
|
||||
export default { scheduleOnboardingEmail, sendOnboardingEmail }
|
||||
@@ -1,9 +1,7 @@
|
||||
const Queues = require('../../infrastructure/Queues')
|
||||
const UserGetter = require('./UserGetter')
|
||||
const {
|
||||
promises: InstitutionsAPIPromises,
|
||||
} = require('../Institutions/InstitutionsAPI')
|
||||
const AnalyticsManager = require('../Analytics/AnalyticsManager')
|
||||
import Queues from '../../infrastructure/Queues.js'
|
||||
import UserGetter from './UserGetter.js'
|
||||
import InstitutionsAPI from '../Institutions/InstitutionsAPI.js'
|
||||
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -25,7 +23,7 @@ async function postRegistrationAnalytics(userId) {
|
||||
|
||||
async function checkAffiliations(userId) {
|
||||
const affiliationsData =
|
||||
await InstitutionsAPIPromises.getUserAffiliations(userId)
|
||||
await InstitutionsAPI.promises.getUserAffiliations(userId)
|
||||
const hasCommonsAccountAffiliation = affiliationsData.some(
|
||||
affiliationData =>
|
||||
affiliationData.institution && affiliationData.institution.commonsAccount
|
||||
@@ -40,7 +38,7 @@ async function checkAffiliations(userId) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
schedulePostRegistrationAnalytics,
|
||||
postRegistrationAnalytics,
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
const {
|
||||
import {
|
||||
hasAdminCapability,
|
||||
hasAdminAccess,
|
||||
} = require('../Helpers/AdminAuthorizationHelper')
|
||||
const SessionManager = require('../Authentication/SessionManager')
|
||||
const Settings = require('@overleaf/settings')
|
||||
} from '../Helpers/AdminAuthorizationHelper.js'
|
||||
import SessionManager from '../Authentication/SessionManager.js'
|
||||
import Settings from '@overleaf/settings'
|
||||
|
||||
const UserMembershipAuthorization = {
|
||||
hasStaffAccess(requiredStaffAccess) {
|
||||
@@ -52,4 +52,4 @@ const UserMembershipAuthorization = {
|
||||
}
|
||||
},
|
||||
}
|
||||
module.exports = UserMembershipAuthorization
|
||||
export default UserMembershipAuthorization
|
||||
@@ -1,20 +1,16 @@
|
||||
import SessionManager from '../Authentication/SessionManager.js'
|
||||
import UserMembershipHandler from './UserMembershipHandler.js'
|
||||
import UserMembershipHandler from './UserMembershipHandler.mjs'
|
||||
import Errors from '../Errors/Errors.js'
|
||||
import EmailHelper from '../Helpers/EmailHelper.js'
|
||||
import { csvAttachment } from '../../infrastructure/Response.js'
|
||||
import {
|
||||
UserIsManagerError,
|
||||
UserAlreadyAddedError,
|
||||
UserNotFoundError,
|
||||
} from './UserMembershipErrors.js'
|
||||
import UserMembershipErrors from './UserMembershipErrors.mjs'
|
||||
import { SSOConfig } from '../../models/SSOConfig.js'
|
||||
import { Parser as CSVParser } from 'json2csv'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import PlansLocator from '../Subscription/PlansLocator.js'
|
||||
import RecurlyClient from '../Subscription/RecurlyClient.js'
|
||||
import Modules from '../../infrastructure/Modules.js'
|
||||
import UserMembershipAuthorization from './UserMembershipAuthorization.js'
|
||||
import UserMembershipAuthorization from './UserMembershipAuthorization.mjs'
|
||||
|
||||
async function manageGroupMembers(req, res, next) {
|
||||
const { entity: subscription, entityConfig } = req
|
||||
@@ -175,93 +171,98 @@ async function exportCsv(req, res) {
|
||||
csvAttachment(res, csvParser.parse(users), 'Group.csv')
|
||||
}
|
||||
|
||||
async function add(req, res) {
|
||||
const { entity, entityConfig } = req
|
||||
const email = EmailHelper.parseEmail(req.body.email)
|
||||
if (email == null) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'invalid_email',
|
||||
message: req.i18n.translate('invalid_email'),
|
||||
},
|
||||
})
|
||||
}
|
||||
if (entityConfig.readOnly) {
|
||||
throw new Errors.NotFoundError('Cannot add users to entity')
|
||||
}
|
||||
let user
|
||||
try {
|
||||
user = await UserMembershipHandler.promises.addUser(
|
||||
entity,
|
||||
entityConfig,
|
||||
email
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof UserMembershipErrors.UserAlreadyAddedError) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'user_already_added',
|
||||
message: req.i18n.translate('user_already_added'),
|
||||
},
|
||||
})
|
||||
}
|
||||
if (err instanceof UserMembershipErrors.UserNotFoundError) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
code: 'user_not_found',
|
||||
message: req.i18n.translate('add_manager_user_not_found'),
|
||||
},
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
res.json({ user })
|
||||
}
|
||||
|
||||
async function remove(req, res) {
|
||||
const { entity, entityConfig } = req
|
||||
const { userId } = req.params
|
||||
if (entityConfig.readOnly) {
|
||||
throw new Errors.NotFoundError('Cannot remove users from entity')
|
||||
}
|
||||
const loggedInUserId = SessionManager.getLoggedInUserId(req.session)
|
||||
if (loggedInUserId === userId) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'managers_cannot_remove_self',
|
||||
message: req.i18n.translate('managers_cannot_remove_self'),
|
||||
},
|
||||
})
|
||||
}
|
||||
try {
|
||||
await UserMembershipHandler.promises.removeUser(
|
||||
entity,
|
||||
entityConfig,
|
||||
userId
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof UserMembershipErrors.UserIsManagerError) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'managers_cannot_remove_admin',
|
||||
message: req.i18n.translate('managers_cannot_remove_admin'),
|
||||
},
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async function create(req, res) {
|
||||
const entityId = req.params.id
|
||||
const entityConfig = req.entityConfig
|
||||
await UserMembershipHandler.promises.createEntity(entityId, entityConfig)
|
||||
res.redirect(entityConfig.pathsFor(entityId).index)
|
||||
}
|
||||
|
||||
export default {
|
||||
manageGroupMembers: expressify(manageGroupMembers),
|
||||
manageGroupManagers: expressify(manageGroupManagers),
|
||||
manageInstitutionManagers: expressify(manageInstitutionManagers),
|
||||
managePublisherManagers: expressify(managePublisherManagers),
|
||||
add(req, res, next) {
|
||||
const { entity, entityConfig } = req
|
||||
const email = EmailHelper.parseEmail(req.body.email)
|
||||
if (email == null) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'invalid_email',
|
||||
message: req.i18n.translate('invalid_email'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (entityConfig.readOnly) {
|
||||
return next(new Errors.NotFoundError('Cannot add users to entity'))
|
||||
}
|
||||
|
||||
UserMembershipHandler.addUser(
|
||||
entity,
|
||||
entityConfig,
|
||||
email,
|
||||
function (error, user) {
|
||||
if (error && error instanceof UserAlreadyAddedError) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'user_already_added',
|
||||
message: req.i18n.translate('user_already_added'),
|
||||
},
|
||||
})
|
||||
}
|
||||
if (error && error instanceof UserNotFoundError) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
code: 'user_not_found',
|
||||
message: req.i18n.translate('add_manager_user_not_found'),
|
||||
},
|
||||
})
|
||||
}
|
||||
if (error != null) {
|
||||
return next(error)
|
||||
}
|
||||
res.json({ user })
|
||||
}
|
||||
)
|
||||
},
|
||||
remove(req, res, next) {
|
||||
const { entity, entityConfig } = req
|
||||
const { userId } = req.params
|
||||
|
||||
if (entityConfig.readOnly) {
|
||||
return next(new Errors.NotFoundError('Cannot remove users from entity'))
|
||||
}
|
||||
|
||||
const loggedInUserId = SessionManager.getLoggedInUserId(req.session)
|
||||
if (loggedInUserId === userId) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'managers_cannot_remove_self',
|
||||
message: req.i18n.translate('managers_cannot_remove_self'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
UserMembershipHandler.removeUser(
|
||||
entity,
|
||||
entityConfig,
|
||||
userId,
|
||||
function (error, user) {
|
||||
if (error && error instanceof UserIsManagerError) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'managers_cannot_remove_admin',
|
||||
message: req.i18n.translate('managers_cannot_remove_admin'),
|
||||
},
|
||||
})
|
||||
}
|
||||
if (error != null) {
|
||||
return next(error)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
)
|
||||
},
|
||||
add: expressify(add),
|
||||
remove: expressify(remove),
|
||||
exportCsv: expressify(exportCsv),
|
||||
new(req, res, next) {
|
||||
res.render('user_membership/new', {
|
||||
@@ -269,19 +270,5 @@ export default {
|
||||
entityId: req.params.id,
|
||||
})
|
||||
},
|
||||
create(req, res, next) {
|
||||
const entityId = req.params.id
|
||||
const entityConfig = req.entityConfig
|
||||
|
||||
UserMembershipHandler.createEntity(
|
||||
entityId,
|
||||
entityConfig,
|
||||
function (error, entity) {
|
||||
if (error != null) {
|
||||
return next(error)
|
||||
}
|
||||
res.redirect(entityConfig.pathsFor(entityId).index)
|
||||
}
|
||||
)
|
||||
},
|
||||
create: expressify(create),
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
const OError = require('@overleaf/o-error')
|
||||
import OError from '@overleaf/o-error'
|
||||
|
||||
class UserIsManagerError extends OError {}
|
||||
class UserNotFoundError extends OError {}
|
||||
class UserAlreadyAddedError extends OError {}
|
||||
|
||||
module.exports = {
|
||||
const UserMembershipErrors = {
|
||||
UserIsManagerError,
|
||||
UserNotFoundError,
|
||||
UserAlreadyAddedError,
|
||||
}
|
||||
|
||||
export default UserMembershipErrors
|
||||
@@ -1,17 +1,15 @@
|
||||
const { ObjectId } = require('mongodb-legacy')
|
||||
const { promisifyAll, callbackify } = require('@overleaf/promise-utils')
|
||||
const EntityModels = {
|
||||
Institution: require('../../models/Institution').Institution,
|
||||
Subscription: require('../../models/Subscription').Subscription,
|
||||
Publisher: require('../../models/Publisher').Publisher,
|
||||
}
|
||||
const UserMembershipViewModel = require('./UserMembershipViewModel')
|
||||
const UserGetter = require('../User/UserGetter')
|
||||
const {
|
||||
UserIsManagerError,
|
||||
UserNotFoundError,
|
||||
UserAlreadyAddedError,
|
||||
} = require('./UserMembershipErrors')
|
||||
import mongodb from 'mongodb-legacy'
|
||||
import { callbackify } from '@overleaf/promise-utils'
|
||||
import { Institution } from '../../models/Institution.js'
|
||||
import { Subscription } from '../../models/Subscription.js'
|
||||
import { Publisher } from '../../models/Publisher.js'
|
||||
import UserMembershipViewModel from './UserMembershipViewModel.mjs'
|
||||
import UserGetter from '../User/UserGetter.js'
|
||||
import UserMembershipErrors from './UserMembershipErrors.mjs'
|
||||
|
||||
const { ObjectId } = mongodb
|
||||
|
||||
const EntityModels = { Institution, Subscription, Publisher }
|
||||
|
||||
const UserMembershipHandler = {
|
||||
async getEntityWithoutAuthorizationCheck(entityId, entityConfig) {
|
||||
@@ -34,11 +32,11 @@ const UserMembershipHandler = {
|
||||
const user = await UserGetter.promises.getUserByAnyEmail(email)
|
||||
|
||||
if (!user) {
|
||||
throw new UserNotFoundError()
|
||||
throw new UserMembershipErrors.UserNotFoundError()
|
||||
}
|
||||
|
||||
if (entity[attribute].some(managerId => managerId.equals(user._id))) {
|
||||
throw new UserAlreadyAddedError()
|
||||
throw new UserMembershipErrors.UserAlreadyAddedError()
|
||||
}
|
||||
|
||||
await addUserToEntity(entity, attribute, user)
|
||||
@@ -48,24 +46,12 @@ const UserMembershipHandler = {
|
||||
async removeUser(entity, entityConfig, userId) {
|
||||
const attribute = entityConfig.fields.write
|
||||
if (entity.admin_id ? entity.admin_id.equals(userId) : undefined) {
|
||||
throw new UserIsManagerError()
|
||||
throw new UserMembershipErrors.UserIsManagerError()
|
||||
}
|
||||
return await removeUserFromEntity(entity, attribute, userId)
|
||||
},
|
||||
}
|
||||
|
||||
UserMembershipHandler.promises = promisifyAll(UserMembershipHandler)
|
||||
module.exports = {
|
||||
getEntityWithoutAuthorizationCheck: callbackify(
|
||||
UserMembershipHandler.getEntityWithoutAuthorizationCheck
|
||||
),
|
||||
createEntity: callbackify(UserMembershipHandler.createEntity),
|
||||
getUsers: callbackify(UserMembershipHandler.getUsers),
|
||||
addUser: callbackify(UserMembershipHandler.addUser),
|
||||
removeUser: callbackify(UserMembershipHandler.removeUser),
|
||||
promises: UserMembershipHandler,
|
||||
}
|
||||
|
||||
async function getPopulatedListOfMembers(entity, attributes) {
|
||||
const userObjects = []
|
||||
|
||||
@@ -114,3 +100,14 @@ function buildEntityQuery(entityId, entityConfig) {
|
||||
query[entityConfig.fields.primaryKey] = entityId
|
||||
return query
|
||||
}
|
||||
|
||||
export default {
|
||||
getEntityWithoutAuthorizationCheck: callbackify(
|
||||
UserMembershipHandler.getEntityWithoutAuthorizationCheck
|
||||
),
|
||||
createEntity: callbackify(UserMembershipHandler.createEntity),
|
||||
getUsers: callbackify(UserMembershipHandler.getUsers),
|
||||
addUser: callbackify(UserMembershipHandler.addUser),
|
||||
removeUser: callbackify(UserMembershipHandler.removeUser),
|
||||
promises: UserMembershipHandler,
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
// @ts-check
|
||||
|
||||
const { expressify } = require('@overleaf/promise-utils')
|
||||
const async = require('async')
|
||||
const UserMembershipAuthorization = require('./UserMembershipAuthorization')
|
||||
const AuthenticationController = require('../Authentication/AuthenticationController')
|
||||
const UserMembershipHandler = require('./UserMembershipHandler')
|
||||
const EntityConfigs = require('./UserMembershipEntityConfigs')
|
||||
const Errors = require('../Errors/Errors')
|
||||
const HttpErrorHandler = require('../Errors/HttpErrorHandler')
|
||||
const TemplatesManager = require('../Templates/TemplatesManager')
|
||||
const { z, zz, validateReq } = require('../../infrastructure/Validation')
|
||||
const { useAdminCapabilities } = require('../Helpers/AdminAuthorizationHelper')
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
|
||||
import async from 'async'
|
||||
import UserMembershipAuthorization from './UserMembershipAuthorization.mjs'
|
||||
import AuthenticationController from '../Authentication/AuthenticationController.js'
|
||||
import UserMembershipHandler from './UserMembershipHandler.mjs'
|
||||
import EntityConfigs from './UserMembershipEntityConfigs.js'
|
||||
import Errors from '../Errors/Errors.js'
|
||||
import HttpErrorHandler from '../Errors/HttpErrorHandler.js'
|
||||
import TemplatesManager from '../Templates/TemplatesManager.js'
|
||||
import { z, zz, validateReq } from '../../infrastructure/Validation.js'
|
||||
import { useAdminCapabilities } from '../Helpers/AdminAuthorizationHelper.js'
|
||||
|
||||
// set of middleware arrays or functions that checks user access to an entity
|
||||
// (publisher, institution, group, template, etc.)
|
||||
@@ -234,7 +235,7 @@ const UserMembershipMiddleware = {
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = UserMembershipMiddleware
|
||||
export default UserMembershipMiddleware
|
||||
|
||||
// fetch entity config and set it in the request
|
||||
function fetchEntityConfig(entityName) {
|
||||
@@ -1,4 +1,4 @@
|
||||
import UserMembershipMiddleware from './UserMembershipMiddleware.js'
|
||||
import UserMembershipMiddleware from './UserMembershipMiddleware.mjs'
|
||||
import UserMembershipController from './UserMembershipController.mjs'
|
||||
import SubscriptionGroupController from '../Subscription/SubscriptionGroupController.mjs'
|
||||
import TeamInvitesController from '../Subscription/TeamInvitesController.mjs'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const UserGetter = require('../User/UserGetter')
|
||||
const { isObjectIdInstance } = require('../Helpers/Mongo')
|
||||
import UserGetter from '../User/UserGetter.js'
|
||||
import { isObjectIdInstance } from '../Helpers/Mongo.js'
|
||||
|
||||
const UserMembershipViewModel = {
|
||||
build(userOrEmail) {
|
||||
@@ -81,4 +81,4 @@ UserMembershipViewModel.promises = {
|
||||
buildAsync: UserMembershipViewModel.buildAsync,
|
||||
}
|
||||
|
||||
module.exports = UserMembershipViewModel
|
||||
export default UserMembershipViewModel
|
||||
@@ -1,7 +1,7 @@
|
||||
import Features from './Features.js'
|
||||
import Queues from './Queues.js'
|
||||
import UserOnboardingEmailManager from '../Features/User/UserOnboardingEmailManager.js'
|
||||
import UserPostRegistrationAnalyticsManager from '../Features/User/UserPostRegistrationAnalyticsManager.js'
|
||||
import UserOnboardingEmailManager from '../Features/User/UserOnboardingEmailManager.mjs'
|
||||
import UserPostRegistrationAnalyticsManager from '../Features/User/UserPostRegistrationAnalyticsManager.mjs'
|
||||
import FeaturesUpdater from '../Features/Subscription/FeaturesUpdater.js'
|
||||
|
||||
import {
|
||||
|
||||
@@ -54,6 +54,7 @@ const db = {
|
||||
messages: internalDb.collection('messages'),
|
||||
migrations: internalDb.collection('migrations'),
|
||||
notifications: internalDb.collection('notifications'),
|
||||
emailNotifications: internalDb.collection('emailNotifications'),
|
||||
oauthAccessTokens: internalDb.collection('oauthAccessTokens'),
|
||||
oauthApplications: internalDb.collection('oauthApplications'),
|
||||
oauthAuthorizationCodes: internalDb.collection('oauthAuthorizationCodes'),
|
||||
|
||||
@@ -715,6 +715,10 @@ module.exports = {
|
||||
parseInt(process.env.OVERLEAF_PROJECT_HARD_DELETION_DELAY, 10) ||
|
||||
1000 * 60 * 60 * 24 * 90, // 90 days
|
||||
|
||||
// Delay before sending comment mention notifications
|
||||
commentMentionDelay:
|
||||
parseInt(process.env.COMMENT_MENTION_DELAY_MINUTES) || 30 * 60 * 1000, // 30 minutes
|
||||
|
||||
// Maximum JSON size in HTTP requests
|
||||
// We should be able to process twice the max doc length, to allow for
|
||||
// - the doc content
|
||||
|
||||
@@ -15,6 +15,8 @@ import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import clientId from '@/utils/client-id'
|
||||
import { useReferencesContext } from '@/features/ide-react/context/references-context'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
type FileViewRefreshButtonProps = {
|
||||
setRefreshError: Dispatch<SetStateAction<Nullable<string>>>
|
||||
@@ -35,14 +37,17 @@ export default function FileViewRefreshButton({
|
||||
const { projectId } = useProjectContext()
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const isMountedRef = useIsMounted()
|
||||
const { indexAllReferences } = useReferencesContext()
|
||||
const clientSideReferences = useFeatureFlag('client-side-references')
|
||||
|
||||
const refreshFile = useCallback(
|
||||
(isTPR: Nullable<boolean>) => {
|
||||
setRefreshing(true)
|
||||
// Replacement of the file handled by the file tree
|
||||
window.expectingLinkedFileRefreshedSocketFor = file.name
|
||||
const shouldReindexReferences = isTPR || /\.bib$/.test(file.name)
|
||||
const body = {
|
||||
shouldReindexReferences: isTPR || /\.bib$/.test(file.name),
|
||||
shouldReindexReferences,
|
||||
clientId: clientId.get(),
|
||||
}
|
||||
postJSON(`/project/${projectId}/linked_file/${file.id}/refresh`, {
|
||||
@@ -52,6 +57,9 @@ export default function FileViewRefreshButton({
|
||||
if (isMountedRef.current) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
if (clientSideReferences && shouldReindexReferences) {
|
||||
indexAllReferences(false)
|
||||
}
|
||||
sendMB('refresh-linked-file', {
|
||||
provider: file.linkedFileData?.provider,
|
||||
})
|
||||
@@ -63,7 +71,14 @@ export default function FileViewRefreshButton({
|
||||
}
|
||||
})
|
||||
},
|
||||
[file, projectId, setRefreshError, isMountedRef]
|
||||
[
|
||||
file,
|
||||
projectId,
|
||||
setRefreshError,
|
||||
isMountedRef,
|
||||
indexAllReferences,
|
||||
clientSideReferences,
|
||||
]
|
||||
)
|
||||
|
||||
if (tprFileViewRefreshButton.length > 0) {
|
||||
|
||||
@@ -188,13 +188,13 @@ export const ReferencesProvider: FC<React.PropsWithChildren> = ({
|
||||
doneInitialIndex.current = true
|
||||
indexAllReferences(false)
|
||||
}
|
||||
}, [projectJoined, indexAllReferences])
|
||||
|
||||
useEffect(() => {
|
||||
const handleProjectJoined = () => {
|
||||
// We only need to grab the references when the editor first loads,
|
||||
// not on every reconnect
|
||||
socket.on('references:keys:updated', (keys, allDocs, refresherId) => {
|
||||
if (projectJoined && socket) {
|
||||
const processUpdatedReferenceKeys = (
|
||||
keys: string[],
|
||||
allDocs: boolean,
|
||||
refresherId: string
|
||||
) => {
|
||||
if (clientSideReferences) {
|
||||
if (refresherId === clientId.get()) {
|
||||
// We asked for this broadcast, so we must have already done the indexing
|
||||
@@ -206,15 +206,17 @@ export const ReferencesProvider: FC<React.PropsWithChildren> = ({
|
||||
allDocs ? new Set(keys) : new Set([...oldDocs, ...keys])
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
eventEmitter.once('project:joined', handleProjectJoined)
|
||||
|
||||
return () => {
|
||||
eventEmitter.off('project:joined', handleProjectJoined)
|
||||
socket.on('references:keys:updated', processUpdatedReferenceKeys)
|
||||
return () => {
|
||||
socket.removeListener(
|
||||
'references:keys:updated',
|
||||
processUpdatedReferenceKeys
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [eventEmitter, indexAllReferences, socket, clientSideReferences])
|
||||
}, [projectJoined, indexAllReferences, socket, clientSideReferences])
|
||||
|
||||
const searchLocalReferences = useCallback(
|
||||
async (query: string): Promise<AdvancedReferenceSearchResult> => {
|
||||
|
||||
@@ -33,7 +33,12 @@ const en = {
|
||||
'translate.fr': 'French',
|
||||
'translate.hi': 'Hindi',
|
||||
'translate.es': 'Spanish',
|
||||
'translate.de': 'German',
|
||||
'translate.ja': 'Japanese',
|
||||
'translate.other': 'Other',
|
||||
'translate.more-languages-coming-soon.title': 'More languages coming soon',
|
||||
'translate.more-languages-coming-soon.body':
|
||||
'Sorry, we don’t currently offer any other languages for Translate. We will be adding more throughout October and November so watch this space!',
|
||||
'blocked-suggestion-signpost.question':
|
||||
'Do you want to permanently stop this suggestion from appearing again?',
|
||||
'blocked-suggestion-signpost.tooltip': 'You can block a suggestion here.',
|
||||
@@ -214,7 +219,7 @@ const en = {
|
||||
'We’ll use this to assess whether we add text to speech functionality to language suggestions in Overleaf.',
|
||||
'language-model.using-this-feature':
|
||||
'Using this feature means your text is sent to OpenAI’s servers and may be kept there for up to 30 days. It is not used to train OpenAI’s models. Writefull does not store or train on your texts.',
|
||||
'language-model.learn-more': 'Learn more',
|
||||
'language-model.learn-more': 'Learn more about how your data is used',
|
||||
'language-model.writefull-automatically-revises':
|
||||
'Writefull automatically revises your text. You can choose what language model is used for this: Writefull’s own model, designed specifically for English research writing, or a GPT-based option, suitable for non-academic texts in any language.',
|
||||
'language-model.which-language-model':
|
||||
@@ -247,7 +252,7 @@ const en = {
|
||||
'settings.manage-plan.freemium': 'Get AI Assist',
|
||||
'settings.manage-plan.wf-premium': 'Manage Writefull plan',
|
||||
'settings.manage-plan': 'Manage plan',
|
||||
'settings.open': 'Open',
|
||||
'settings.open': 'Open settings dialog',
|
||||
'settings.writefulls-model': 'Model: Writefull',
|
||||
'settings.gpt-model': 'Model: GPT',
|
||||
'settings.with-custom-prompt': ' with custom prompt',
|
||||
@@ -369,7 +374,12 @@ const es = {
|
||||
'translate.fr': 'Francés',
|
||||
'translate.hi': 'Hindi',
|
||||
'translate.es': 'Español',
|
||||
'translate.de': 'Alemán',
|
||||
'translate.ja': 'Japonés',
|
||||
'translate.other': 'Otro',
|
||||
'translate.more-languages-coming-soon.title': 'Más idiomas próximamente',
|
||||
'translate.more-languages-coming-soon.body':
|
||||
'Lo sentimos, actualmente no ofrecemos otros idiomas para la traducción. Agregaremos más a lo largo de octubre y noviembre, ¡así que mantente atento!',
|
||||
'blocked-suggestion-signpost.question':
|
||||
'¿Quieres dejar de ver esta sugerencia permanentemente? Puedes bloquear esta sugerencia aquí.',
|
||||
'blocked-suggestion-signpost.tooltip':
|
||||
@@ -598,7 +608,7 @@ const es = {
|
||||
'settings.manage-plan.freemium': 'Obtener AI Assist',
|
||||
'settings.manage-plan.wf-premium': 'Gestionar Plan Writefull',
|
||||
'settings.manage-plan': 'Gestionar plan',
|
||||
'settings.open': 'Abrir',
|
||||
'settings.open': 'Abrir diálogo de configuración',
|
||||
'settings.writefulls-model': 'Modelo de Writefull',
|
||||
'settings.gpt-model': 'Modelo GPT',
|
||||
'settings.with-custom-prompt': ' con prompt personalizado',
|
||||
|
||||
@@ -3,8 +3,6 @@ export interface WritefullEvents {
|
||||
method: 'email-password' | 'login-with-overleaf'
|
||||
isPremium: boolean
|
||||
}
|
||||
'writefull-received-suggestions': { numberOfSuggestions: number }
|
||||
'writefull-register-as-auto-account': { email: string }
|
||||
'writefull-ai-assist-show-paywall': { origin?: string }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
const minimist = require('minimist')
|
||||
const {
|
||||
/* eslint-disable @overleaf/require-script-runner */
|
||||
import minimist from 'minimist'
|
||||
import {
|
||||
mkdirSync,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
unlinkSync,
|
||||
renameSync,
|
||||
} = require('fs')
|
||||
const mongodb = require('../app/src/infrastructure/mongodb')
|
||||
const DocumentUpdaterHandler = require('../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js')
|
||||
const ProjectZipStreamManager = require('../app/src/Features/Downloads/ProjectZipStreamManager.js')
|
||||
const logger = require('logger-sharelatex')
|
||||
const { Project } = require('../app/src/models/Project.js')
|
||||
const { User } = require('../app/src/models/User.js')
|
||||
const readline = require('readline')
|
||||
} from 'fs'
|
||||
import mongodb from '../../../app/src/infrastructure/mongodb.js'
|
||||
import DocumentUpdaterHandler from '../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js'
|
||||
import ProjectZipStreamManager from '../../../app/src/Features/Downloads/ProjectZipStreamManager.js'
|
||||
import logger from 'logger-sharelatex'
|
||||
import { Project } from '../../../app/src/models/Project.js'
|
||||
import { User } from '../../../app/src/models/User.js'
|
||||
import readline from 'readline'
|
||||
|
||||
function parseArgs() {
|
||||
return minimist(process.argv.slice(2), {
|
||||
@@ -45,7 +45,7 @@
|
||||
"cypress:run-ct": "OVERLEAF_CONFIG=$PWD/config/settings.webpack.js cypress run --component --browser chrome",
|
||||
"cypress:docker:open-ct": "DOCKER_USER=\"$(id -u):$(id -g)\" docker compose -f docker-compose.cypress.yml run --rm cypress run cypress:open-ct",
|
||||
"cypress:docker:run-ct": "DOCKER_USER=\"$(id -u):$(id -g)\" docker compose -f docker-compose.cypress.yml run --rm cypress run cypress:run-ct --browser chrome",
|
||||
"lezer-latex:generate": "node scripts/lezer-latex/generate.js",
|
||||
"lezer-latex:generate": "node scripts/lezer-latex/generate.mjs",
|
||||
"lezer-latex:run": "node scripts/lezer-latex/run.mjs",
|
||||
"lezer-latex:benchmark": "node scripts/lezer-latex/benchmark.mjs",
|
||||
"lezer-latex:benchmark-incremental": "node scripts/lezer-latex/test-incremental-parser.mjs",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.js'
|
||||
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.mjs'
|
||||
import { ensureRunningOnMongoSecondaryWithTimeout } from './helpers/env_variable_helper.mjs'
|
||||
|
||||
// ScriptRunner can not be used when using this assertion
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
const Path = require('path')
|
||||
const DocstoreManager = require('../app/src/Features/Docstore/DocstoreManager')
|
||||
const DocumentUpdaterHandler = require('../app/src/Features/DocumentUpdater/DocumentUpdaterHandler')
|
||||
const ProjectGetter = require('../app/src/Features/Project/ProjectGetter')
|
||||
const ProjectEntityMongoUpdateHandler = require('../app/src/Features/Project/ProjectEntityMongoUpdateHandler')
|
||||
const { waitForDb, db, ObjectId } = require('../app/src/infrastructure/mongodb')
|
||||
const HistoryManager = require('../app/src/Features/History/HistoryManager')
|
||||
const logger = require('@overleaf/logger').logger
|
||||
import { scriptRunner } from './lib/ScriptRunner.mjs'
|
||||
import Path from 'node:path'
|
||||
import DocstoreManager from '../app/src/Features/Docstore/DocstoreManager.js'
|
||||
import DocumentUpdaterHandler from '../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js'
|
||||
import ProjectGetter from '../app/src/Features/Project/ProjectGetter.js'
|
||||
import ProjectEntityMongoUpdateHandler from '../app/src/Features/Project/ProjectEntityMongoUpdateHandler.js'
|
||||
import { waitForDb, db, ObjectId } from '../app/src/infrastructure/mongodb.js'
|
||||
import HistoryManager from '../app/src/Features/History/HistoryManager.js'
|
||||
import logger from '@overleaf/logger'
|
||||
import minimist from 'minimist'
|
||||
|
||||
const args = require('minimist')(process.argv.slice(2), {
|
||||
const args = minimist(process.argv.slice(2), {
|
||||
boolean: ['verbose', 'fix'],
|
||||
})
|
||||
|
||||
const verbose = args.verbose
|
||||
|
||||
if (!verbose) {
|
||||
logger.level('error')
|
||||
logger.logger.level('error')
|
||||
}
|
||||
|
||||
// no remaining arguments, print usage
|
||||
@@ -254,7 +257,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
scriptRunner(main, args)
|
||||
.then(() => {
|
||||
console.log('DONE')
|
||||
process.exit(0)
|
||||
@@ -1,5 +1,5 @@
|
||||
import { promisify } from 'node:util'
|
||||
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.js'
|
||||
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.mjs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { scriptRunner } from './lib/ScriptRunner.mjs'
|
||||
const sleep = promisify(setTimeout)
|
||||
|
||||
@@ -2,7 +2,51 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import minimist from 'minimist'
|
||||
|
||||
const APP_CODE_PATH = ['app', 'modules', 'migrations', 'scripts', 'test']
|
||||
const APP_CODE_PATH = ['app', 'modules', 'scripts', 'test']
|
||||
|
||||
// These have already been converted but don't have a `.mjs` extension
|
||||
const converted = new Set([
|
||||
'scripts/ukamf/check-certs.js',
|
||||
'scripts/ukamf/check-idp-metadata.js',
|
||||
'scripts/ukamf/metadata-processor.js',
|
||||
'scripts/ukamf/ukamf-db.js',
|
||||
'scripts/ukamf/ukamf-entity.js',
|
||||
'scripts/translations/checkCoverage.js',
|
||||
'scripts/translations/checkSanitizeOptions.js',
|
||||
'scripts/translations/checkVariables.js',
|
||||
'scripts/translations/cleanupUnusedLocales.js',
|
||||
'scripts/translations/config.js',
|
||||
'scripts/translations/download.js',
|
||||
'scripts/translations/insertHTMLFragments.js',
|
||||
'scripts/translations/replaceLinkFragments.js',
|
||||
'scripts/translations/sanitize.js',
|
||||
'scripts/translations/sort.js',
|
||||
'scripts/translations/transformLocales.js',
|
||||
'scripts/translations/translateLocales.js',
|
||||
'scripts/translations/upload.js',
|
||||
'scripts/translations/uploadNonEnglish.js',
|
||||
'scripts/translations/utils.js',
|
||||
])
|
||||
// These files are not to be converted (e.g. they use CommonJS features that are not available in ES Modules)
|
||||
const excluded = new Set([
|
||||
'modules/server-ce-scripts/scripts/create-user.js', // must be CJS for backwards compatibility
|
||||
'test/acceptance/config/settings.test.saas.js', // must be CJS for @overleaf/settings module
|
||||
'test/acceptance/config/settings.test.server-pro.js', // must be CJS for @overleaf/settings module
|
||||
'app/src/infrastructure/PackageVersions.js', // required by webpack
|
||||
])
|
||||
|
||||
function fileIsESM(file) {
|
||||
const relativePath = file.replace(process.cwd() + '/', '')
|
||||
return file.endsWith('.mjs') || converted.has(relativePath)
|
||||
}
|
||||
|
||||
function fileCanBeConvertedToESM(file) {
|
||||
const relativePath = file.replace(process.cwd() + '/', '')
|
||||
if (fileIsESM(relativePath)) {
|
||||
return false
|
||||
}
|
||||
return !excluded.has(relativePath)
|
||||
}
|
||||
|
||||
const {
|
||||
_: args,
|
||||
@@ -149,7 +193,7 @@ function printDirectoriesReport(allFilesAndImports) {
|
||||
// collect all files that are imported via CommonJS in the entire backend codebase
|
||||
const filesImportedViaCjs = new Set()
|
||||
allFilesAndImports.forEach((imports, file) => {
|
||||
if (!file.endsWith('.mjs')) {
|
||||
if (!fileIsESM(file)) {
|
||||
imports.forEach(imprt => filesImportedViaCjs.add(imprt))
|
||||
}
|
||||
})
|
||||
@@ -158,10 +202,8 @@ function printDirectoriesReport(allFilesAndImports) {
|
||||
const selectedFiles = Array.from(
|
||||
findJSAndImports(paths.map(dir => path.resolve(dir))).keys()
|
||||
).filter(file => !file.endsWith('settings.test.js'))
|
||||
const nonMigratedFiles = selectedFiles.filter(file => !file.endsWith('.mjs'))
|
||||
const migratedFileCount = selectedFiles.filter(file =>
|
||||
file.endsWith('.mjs')
|
||||
).length
|
||||
const nonMigratedFiles = selectedFiles.filter(fileCanBeConvertedToESM)
|
||||
const migratedFileCount = selectedFiles.filter(fileIsESM).length
|
||||
|
||||
// collect files in the selected paths that are not imported via CommonJs in the entire backend codebase
|
||||
const filesNotImportedViaCjs = nonMigratedFiles.filter(
|
||||
@@ -196,7 +238,7 @@ function printDirectoriesReport(allFilesAndImports) {
|
||||
|
||||
function printFileReport(allFilesAndImports) {
|
||||
const filePath = path.resolve(paths[0])
|
||||
if (filePath.endsWith('.mjs')) {
|
||||
if (fileIsESM(filePath)) {
|
||||
console.log(`${filePath} is already migrated to ESM`)
|
||||
return
|
||||
}
|
||||
@@ -204,7 +246,7 @@ function printFileReport(allFilesAndImports) {
|
||||
|
||||
const importingFiles = []
|
||||
allFilesAndImports.forEach((imports, file) => {
|
||||
if (file.endsWith('.mjs')) {
|
||||
if (fileIsESM(file)) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const csv = require('csv')
|
||||
const fs = require('fs')
|
||||
const minimist = require('minimist')
|
||||
const { User } = require('../app/src/models/User')
|
||||
import { scriptRunner } from './lib/ScriptRunner.mjs'
|
||||
import * as csv from 'csv'
|
||||
import fs from 'node:fs'
|
||||
import minimist from 'minimist'
|
||||
import { User } from '../app/src/models/User.js'
|
||||
|
||||
/**
|
||||
* This script extracts users who churned after day 1 - ie. their last session was within 24 hours of registering
|
||||
@@ -205,9 +206,9 @@ async function getDay1ChurnUsers({
|
||||
return allChurnUsers
|
||||
}
|
||||
|
||||
async function runScript() {
|
||||
const args = parseArgs()
|
||||
const args = parseArgs()
|
||||
|
||||
async function runScript() {
|
||||
console.log(
|
||||
`Starting Day 1 churn extraction with lookback period: ${args.lookbackMonths} months`
|
||||
)
|
||||
@@ -268,7 +269,7 @@ async function runScript() {
|
||||
)
|
||||
}
|
||||
|
||||
runScript().catch(err => {
|
||||
scriptRunner(runScript, args).catch(err => {
|
||||
console.error('Script failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,8 +1,7 @@
|
||||
const csv = require('csv')
|
||||
const fs = require('fs')
|
||||
const {
|
||||
OnboardingDataCollection,
|
||||
} = require('../app/src/models/OnboardingDataCollection')
|
||||
import { scriptRunner } from './lib/ScriptRunner.mjs'
|
||||
import * as csv from 'csv'
|
||||
import fs from 'node:fs'
|
||||
import { OnboardingDataCollection } from '../app/src/models/OnboardingDataCollection.js'
|
||||
|
||||
/**
|
||||
* This script extracts the OnboardingDataCollection collection from the database
|
||||
@@ -90,7 +89,7 @@ const runScript = async () => {
|
||||
csvWriter.on('error', err => console.error('CSV Writer Error:', err))
|
||||
}
|
||||
|
||||
runScript().catch(err => {
|
||||
scriptRunner(runScript).catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,13 +1,12 @@
|
||||
const csv = require('csv')
|
||||
const fs = require('fs')
|
||||
const minimist = require('minimist')
|
||||
const {
|
||||
OnboardingDataCollection,
|
||||
} = require('../app/src/models/OnboardingDataCollection')
|
||||
const { User } = require('../app/src/models/User')
|
||||
const SubscriptionLocator = require('../app/src/Features/Subscription/SubscriptionLocator')
|
||||
const Settings = require('@overleaf/settings')
|
||||
const { fetchJson } = require('@overleaf/fetch-utils')
|
||||
import { scriptRunner } from './lib/ScriptRunner.mjs'
|
||||
import * as csv from 'csv'
|
||||
import fs from 'node:fs'
|
||||
import minimist from 'minimist'
|
||||
import { OnboardingDataCollection } from '../app/src/models/OnboardingDataCollection.js'
|
||||
import { User } from '../app/src/models/User.js'
|
||||
import SubscriptionLocator from '../app/src/Features/Subscription/SubscriptionLocator.js'
|
||||
import Settings from '@overleaf/settings'
|
||||
import { fetchJson } from '@overleaf/fetch-utils'
|
||||
|
||||
/**
|
||||
* This script extracts ODC data with some extra fields, and filters on registration date and LaTeX experience
|
||||
@@ -146,12 +145,11 @@ async function getUserCountries(institutions) {
|
||||
}
|
||||
return countryCodes
|
||||
}
|
||||
const args = parseArgs()
|
||||
|
||||
async function runScript() {
|
||||
const columns = ['email']
|
||||
|
||||
const args = parseArgs()
|
||||
|
||||
if (args.includeSignUpDate) {
|
||||
columns.push('signUpDate')
|
||||
}
|
||||
@@ -196,7 +194,7 @@ async function runScript() {
|
||||
)
|
||||
}
|
||||
|
||||
runScript().catch(err => {
|
||||
scriptRunner(runScript, args).catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import cheerio from 'cheerio'
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import prettier from 'prettier'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import { sanitizeOptions } from '../../../modules/learn/app/src/sanitizeOptions.js'
|
||||
import { sanitizeOptions } from '../../../modules/learn/app/src/sanitizeOptions.mjs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = Path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
const { buildParserFile } = require('@lezer/generator')
|
||||
const { writeFileSync, readFileSync } = require('fs')
|
||||
const path = require('path')
|
||||
/* eslint-disable @overleaf/require-script-runner */
|
||||
// This script doesn't work with ScriptRunner because it is run during the build process.
|
||||
import { buildParserFile } from '@lezer/generator'
|
||||
import { writeFileSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const grammars = [
|
||||
{
|
||||
grammarPath: path.resolve(
|
||||
__dirname,
|
||||
import.meta.dirname,
|
||||
'../../frontend/js/features/source-editor/lezer-latex/latex.grammar'
|
||||
),
|
||||
parserOutputPath: path.resolve(
|
||||
__dirname,
|
||||
import.meta.dirname,
|
||||
'../../frontend/js/features/source-editor/lezer-latex/latex.mjs'
|
||||
),
|
||||
termsOutputPath: path.resolve(
|
||||
__dirname,
|
||||
import.meta.dirname,
|
||||
'../../frontend/js/features/source-editor/lezer-latex/latex.terms.mjs'
|
||||
),
|
||||
},
|
||||
{
|
||||
grammarPath: path.resolve(
|
||||
__dirname,
|
||||
import.meta.dirname,
|
||||
'../../frontend/js/features/source-editor/lezer-bibtex/bibtex.grammar'
|
||||
),
|
||||
parserOutputPath: path.resolve(
|
||||
__dirname,
|
||||
import.meta.dirname,
|
||||
'../../frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs'
|
||||
),
|
||||
termsOutputPath: path.resolve(
|
||||
__dirname,
|
||||
import.meta.dirname,
|
||||
'../../frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs'
|
||||
),
|
||||
},
|
||||
@@ -56,9 +58,12 @@ function compile(grammar) {
|
||||
console.info('Done!')
|
||||
}
|
||||
|
||||
module.exports = { compile, grammars }
|
||||
export default { compile, grammars }
|
||||
|
||||
if (require.main === module) {
|
||||
if (
|
||||
import.meta.url === process.argv[1] ||
|
||||
import.meta.url === `file://${process.argv[1]}`
|
||||
) {
|
||||
try {
|
||||
grammars.forEach(compile)
|
||||
process.exit(0)
|
||||
17
services/web/scripts/process_notifications.mjs
Normal file
17
services/web/scripts/process_notifications.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
import { processNotifications } from '../modules/notifications/app/src/ProcessNotifications.mjs'
|
||||
import { scriptRunner } from './lib/ScriptRunner.mjs'
|
||||
|
||||
async function main() {
|
||||
console.log('Processing notifications...')
|
||||
await processNotifications()
|
||||
console.log('Notifications processed successfully.')
|
||||
}
|
||||
|
||||
try {
|
||||
await scriptRunner(main)
|
||||
console.log('Done.')
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
console.error({ error })
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// @ts-check
|
||||
|
||||
const _ = require('lodash')
|
||||
const recurly = require('recurly')
|
||||
const minimist = require('minimist')
|
||||
const Settings = require('@overleaf/settings')
|
||||
import { scriptRunner } from '../lib/ScriptRunner.mjs'
|
||||
import _ from 'lodash'
|
||||
import recurly from 'recurly'
|
||||
import minimist from 'minimist'
|
||||
import Settings from '@overleaf/settings'
|
||||
|
||||
const ADD_ON_CODE = 'assistant'
|
||||
const ADD_ON_NAME = 'AI Assist'
|
||||
@@ -209,7 +209,7 @@ function getAddOnConfig(prices) {
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
scriptRunner(main)
|
||||
.then(() => {
|
||||
process.exit(0)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import minimist from 'minimist'
|
||||
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.js'
|
||||
import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.mjs'
|
||||
import { scriptRunner } from './lib/ScriptRunner.mjs'
|
||||
|
||||
const institutionId = parseInt(process.argv[2])
|
||||
|
||||
@@ -58,6 +58,20 @@ class MockChatApi extends AbstractMockApi {
|
||||
res.json(this.getThread(req.params.project_id, req.params.thread_id))
|
||||
}
|
||||
)
|
||||
this.app.get(
|
||||
'/project/:project_id/thread/:thread_id/messages/:message_id',
|
||||
(req, res) => {
|
||||
const projectId = req.params.project_id
|
||||
const threadId = req.params.thread_id
|
||||
const messageId = req.params.message_id
|
||||
const thread = this.getThread(projectId, threadId)
|
||||
const message = thread.find(msg => msg.id === messageId)
|
||||
if (!message) {
|
||||
return res.status(404).json({ error: 'Message not found' })
|
||||
}
|
||||
res.json(message)
|
||||
}
|
||||
)
|
||||
this.app.post(
|
||||
'/project/:project_id/thread/:thread_id/messages',
|
||||
(req, res) => {
|
||||
|
||||
355
services/web/test/unit/src/Compile/ClsiCookieManager.test.mjs
Normal file
355
services/web/test/unit/src/Compile/ClsiCookieManager.test.mjs
Normal file
@@ -0,0 +1,355 @@
|
||||
import sinon from 'sinon'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const modulePath = '../../../../app/src/Features/Compile/ClsiCookieManager.mjs'
|
||||
|
||||
describe('ClsiCookieManager', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.redis = {
|
||||
auth() {},
|
||||
del: sinon.stub(),
|
||||
get: sinon.stub(),
|
||||
setex: sinon.stub().resolves(),
|
||||
}
|
||||
ctx.project_id = '123423431321-proj-id'
|
||||
ctx.user_id = 'abc-user-id'
|
||||
ctx.fetchUtils = {
|
||||
fetchNothing: sinon.stub().returns(Promise.resolve()),
|
||||
fetchStringWithResponse: sinon.stub().returns(Promise.resolve()),
|
||||
}
|
||||
ctx.metrics = { inc: sinon.stub() }
|
||||
ctx.settings = {
|
||||
redis: {
|
||||
web: 'redis.something',
|
||||
},
|
||||
apis: {
|
||||
clsi: {
|
||||
url: 'http://clsi.example.com',
|
||||
},
|
||||
},
|
||||
clsiCookie: {
|
||||
ttlInSeconds: Math.random().toString(),
|
||||
ttlInSecondsRegular: Math.random().toString(),
|
||||
key: 'coooookie',
|
||||
},
|
||||
}
|
||||
vi.doMock('../../../../app/src/infrastructure/RedisWrapper', () => ({
|
||||
default: (ctx.RedisWrapper = {
|
||||
client: () => ctx.redis,
|
||||
}),
|
||||
}))
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: ctx.settings,
|
||||
}))
|
||||
vi.doMock('@overleaf/fetch-utils', () => ctx.fetchUtils)
|
||||
vi.doMock('@overleaf/metrics', () => ({
|
||||
default: ctx.metrics,
|
||||
}))
|
||||
|
||||
ctx.ClsiCookieManager = (await import(modulePath)).default()
|
||||
})
|
||||
|
||||
describe('getServerId', function () {
|
||||
it('should call get for the key', async function (ctx) {
|
||||
ctx.redis.get.resolves('clsi-7')
|
||||
const serverId = await ctx.ClsiCookieManager.promises.getServerId(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'',
|
||||
'n2d'
|
||||
)
|
||||
ctx.redis.get
|
||||
.calledWith(`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`)
|
||||
.should.equal(true)
|
||||
serverId.should.equal('clsi-7')
|
||||
})
|
||||
|
||||
it('should fallback to old key', async function (ctx) {
|
||||
ctx.redis.get
|
||||
.withArgs(`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`)
|
||||
.resolves(null)
|
||||
ctx.redis.get
|
||||
.withArgs(`clsiserver:${ctx.project_id}:${ctx.user_id}`)
|
||||
.resolves('clsi-7')
|
||||
const serverId = await ctx.ClsiCookieManager.promises.getServerId(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'',
|
||||
'n2d'
|
||||
)
|
||||
ctx.redis.get
|
||||
.calledWith(`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`)
|
||||
.should.equal(true)
|
||||
ctx.redis.get
|
||||
.calledWith(`clsiserver:${ctx.project_id}:${ctx.user_id}`)
|
||||
.should.equal(true)
|
||||
serverId.should.equal('clsi-7')
|
||||
})
|
||||
|
||||
it('should _populateServerIdViaRequest if no key is found', async function (ctx) {
|
||||
ctx.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
|
||||
.stub()
|
||||
.resolves()
|
||||
ctx.redis.get.resolves(null)
|
||||
await ctx.ClsiCookieManager.promises.getServerId(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
''
|
||||
)
|
||||
ctx.ClsiCookieManager.promises._populateServerIdViaRequest
|
||||
.calledWith(ctx.project_id, ctx.user_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should _populateServerIdViaRequest if no key is blank', async function (ctx) {
|
||||
ctx.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
|
||||
.stub()
|
||||
.resolves(null)
|
||||
ctx.redis.get.resolves('')
|
||||
await ctx.ClsiCookieManager.promises.getServerId(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'',
|
||||
'n2d'
|
||||
)
|
||||
ctx.ClsiCookieManager.promises._populateServerIdViaRequest
|
||||
.calledWith(ctx.project_id, ctx.user_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_populateServerIdViaRequest', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.clsiServerId = 'server-id'
|
||||
ctx.ClsiCookieManager.promises.setServerId = sinon.stub().resolves()
|
||||
})
|
||||
|
||||
describe('with a server id in the response', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.response = {
|
||||
headers: {
|
||||
'set-cookie': [
|
||||
`${ctx.settings.clsiCookie.key}=${ctx.clsiServerId}`,
|
||||
],
|
||||
},
|
||||
}
|
||||
ctx.fetchUtils.fetchNothing.returns(ctx.response)
|
||||
})
|
||||
|
||||
it('should make a request to the clsi', async function (ctx) {
|
||||
await ctx.ClsiCookieManager.promises._populateServerIdViaRequest(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'standard',
|
||||
'n2d'
|
||||
)
|
||||
const args = ctx.ClsiCookieManager.promises.setServerId.args[0]
|
||||
args[0].should.equal(ctx.project_id)
|
||||
args[1].should.equal(ctx.user_id)
|
||||
args[2].should.equal('standard')
|
||||
args[3].should.equal('n2d')
|
||||
args[4].should.deep.equal(ctx.clsiServerId)
|
||||
})
|
||||
|
||||
it('should return the server id', async function (ctx) {
|
||||
const serverId =
|
||||
await ctx.ClsiCookieManager.promises._populateServerIdViaRequest(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'',
|
||||
'n2d'
|
||||
)
|
||||
serverId.should.equal(ctx.clsiServerId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('without a server id in the response', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.response = { headers: {} }
|
||||
ctx.fetchUtils.fetchNothing.returns(ctx.response)
|
||||
})
|
||||
it('should not set the server id there is no server id in the response', async function (ctx) {
|
||||
ctx.ClsiCookieManager._parseServerIdFromResponse = sinon
|
||||
.stub()
|
||||
.returns(null)
|
||||
await ctx.ClsiCookieManager.promises.setServerId(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
ctx.clsiServerId,
|
||||
null
|
||||
)
|
||||
ctx.redis.setex.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearServerId', function () {
|
||||
it('should clear both keys', async function (ctx) {
|
||||
await ctx.ClsiCookieManager.promises.clearServerId(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'n2d'
|
||||
)
|
||||
ctx.redis.del.should.have.been.calledWith(
|
||||
`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
|
||||
`clsiserver:${ctx.project_id}:${ctx.user_id}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setServerId', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.clsiServerId = 'server-id'
|
||||
ctx.ClsiCookieManager._parseServerIdFromResponse = sinon
|
||||
.stub()
|
||||
.returns('clsi-8')
|
||||
})
|
||||
|
||||
it('should set the server id with a ttl', async function (ctx) {
|
||||
await ctx.ClsiCookieManager.promises.setServerId(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
ctx.clsiServerId,
|
||||
null
|
||||
)
|
||||
ctx.redis.setex.should.have.been.calledWith(
|
||||
`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
|
||||
ctx.settings.clsiCookie.ttlInSeconds,
|
||||
ctx.clsiServerId
|
||||
)
|
||||
})
|
||||
|
||||
it('should set the server id with the regular ttl for reg instance', async function (ctx) {
|
||||
ctx.clsiServerId = 'clsi-reg-8'
|
||||
await ctx.ClsiCookieManager.promises.setServerId(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
ctx.clsiServerId,
|
||||
null
|
||||
)
|
||||
expect(ctx.redis.setex).to.have.been.calledWith(
|
||||
`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
|
||||
ctx.settings.clsiCookie.ttlInSecondsRegular,
|
||||
ctx.clsiServerId
|
||||
)
|
||||
})
|
||||
|
||||
describe('when clsiCookies are not enabled', function (ctx) {
|
||||
let oldKey
|
||||
beforeEach(async function (ctx) {
|
||||
oldKey = ctx.settings.clsiCookie.key
|
||||
delete ctx.settings.clsiCookie.key
|
||||
vi.resetModules()
|
||||
ctx.ClsiCookieManager2 = (await import(modulePath)).default()
|
||||
})
|
||||
afterEach(function (ctx) {
|
||||
ctx.settings.clsiCookie.key = oldKey
|
||||
})
|
||||
|
||||
it('should not set the server id if clsiCookies are not enabled', async function (ctx) {
|
||||
await ctx.ClsiCookieManager2.promises.setServerId(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
ctx.clsiServerId,
|
||||
null
|
||||
)
|
||||
ctx.redis.setex.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should also set in the secondary if secondary redis is enabled', async function (ctx) {
|
||||
ctx.redis_secondary = { setex: sinon.stub().resolves() }
|
||||
ctx.settings.redis.clsi_cookie_secondary = {}
|
||||
ctx.RedisWrapper.client = sinon.stub()
|
||||
ctx.RedisWrapper.client.withArgs('clsi_cookie').returns(ctx.redis)
|
||||
ctx.RedisWrapper.client
|
||||
.withArgs('clsi_cookie_secondary')
|
||||
.returns(ctx.redis_secondary)
|
||||
vi.resetModules()
|
||||
ctx.ClsiCookieManager2 = (await import(modulePath)).default()
|
||||
ctx.ClsiCookieManager2._parseServerIdFromResponse = sinon
|
||||
.stub()
|
||||
.returns('clsi-8')
|
||||
await ctx.ClsiCookieManager2.promises.setServerId(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
ctx.clsiServerId,
|
||||
null
|
||||
)
|
||||
ctx.redis_secondary.setex.should.have.been.calledWith(
|
||||
`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
|
||||
ctx.settings.clsiCookie.ttlInSeconds,
|
||||
ctx.clsiServerId
|
||||
)
|
||||
})
|
||||
|
||||
describe('checkIsLoadSheddingEvent', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.fetchUtils.fetchStringWithResponse.reset()
|
||||
ctx.call = async () => {
|
||||
await ctx.ClsiCookieManager.promises.setServerId(
|
||||
ctx.project_id,
|
||||
ctx.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
ctx.clsiServerId,
|
||||
'previous-clsi-server-id'
|
||||
)
|
||||
expect(
|
||||
ctx.fetchUtils.fetchStringWithResponse
|
||||
).to.have.been.calledWith(
|
||||
`${ctx.settings.apis.clsi.url}/instance-state?clsiserverid=previous-clsi-server-id&compileGroup=standard&compileBackendClass=n2d`,
|
||||
{ method: 'GET', signal: sinon.match.instanceOf(AbortSignal) }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('should report "load-shedding" when previous is UP', async function (ctx) {
|
||||
ctx.fetchUtils.fetchStringWithResponse.resolves({
|
||||
response: { status: 200 },
|
||||
body: 'previous-clsi-server-id,UP\n',
|
||||
})
|
||||
await ctx.call()
|
||||
expect(ctx.metrics.inc).to.have.been.calledWith(
|
||||
'clsi-lb-switch-backend',
|
||||
1,
|
||||
{ status: 'load-shedding' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should report "cycle" when other is UP', async function (ctx) {
|
||||
ctx.fetchUtils.fetchStringWithResponse.resolves({
|
||||
response: { status: 200 },
|
||||
body: 'other-clsi-server-id,UP\n',
|
||||
})
|
||||
await ctx.call()
|
||||
expect(ctx.metrics.inc).to.have.been.calledWith(
|
||||
'clsi-lb-switch-backend',
|
||||
1,
|
||||
{ status: 'cycle' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should report "cycle" when previous is 404', async function (ctx) {
|
||||
ctx.fetchUtils.fetchStringWithResponse.resolves({
|
||||
response: { status: 404 },
|
||||
})
|
||||
await ctx.call()
|
||||
expect(ctx.metrics.inc).to.have.been.calledWith(
|
||||
'clsi-lb-switch-backend',
|
||||
1,
|
||||
{ status: 'cycle' }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,351 +0,0 @@
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
const modulePath = '../../../../app/src/Features/Compile/ClsiCookieManager.js'
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
|
||||
describe('ClsiCookieManager', function () {
|
||||
beforeEach(function () {
|
||||
this.redis = {
|
||||
auth() {},
|
||||
del: sinon.stub(),
|
||||
get: sinon.stub(),
|
||||
setex: sinon.stub().resolves(),
|
||||
}
|
||||
this.project_id = '123423431321-proj-id'
|
||||
this.user_id = 'abc-user-id'
|
||||
this.fetchUtils = {
|
||||
fetchNothing: sinon.stub().returns(Promise.resolve()),
|
||||
fetchStringWithResponse: sinon.stub().returns(Promise.resolve()),
|
||||
}
|
||||
this.metrics = { inc: sinon.stub() }
|
||||
this.settings = {
|
||||
redis: {
|
||||
web: 'redis.something',
|
||||
},
|
||||
apis: {
|
||||
clsi: {
|
||||
url: 'http://clsi.example.com',
|
||||
},
|
||||
},
|
||||
clsiCookie: {
|
||||
ttlInSeconds: Math.random().toString(),
|
||||
ttlInSecondsRegular: Math.random().toString(),
|
||||
key: 'coooookie',
|
||||
},
|
||||
}
|
||||
this.requires = {
|
||||
'../../infrastructure/RedisWrapper': (this.RedisWrapper = {
|
||||
client: () => this.redis,
|
||||
}),
|
||||
'@overleaf/settings': this.settings,
|
||||
'@overleaf/fetch-utils': this.fetchUtils,
|
||||
'@overleaf/metrics': this.metrics,
|
||||
}
|
||||
this.ClsiCookieManager = SandboxedModule.require(modulePath, {
|
||||
requires: this.requires,
|
||||
})()
|
||||
})
|
||||
|
||||
describe('getServerId', function () {
|
||||
it('should call get for the key', async function () {
|
||||
this.redis.get.resolves('clsi-7')
|
||||
const serverId = await this.ClsiCookieManager.promises.getServerId(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'',
|
||||
'n2d'
|
||||
)
|
||||
this.redis.get
|
||||
.calledWith(`clsiserver:n2d:${this.project_id}:${this.user_id}`)
|
||||
.should.equal(true)
|
||||
serverId.should.equal('clsi-7')
|
||||
})
|
||||
|
||||
it('should fallback to old key', async function () {
|
||||
this.redis.get
|
||||
.withArgs(`clsiserver:n2d:${this.project_id}:${this.user_id}`)
|
||||
.resolves(null)
|
||||
this.redis.get
|
||||
.withArgs(`clsiserver:${this.project_id}:${this.user_id}`)
|
||||
.resolves('clsi-7')
|
||||
const serverId = await this.ClsiCookieManager.promises.getServerId(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'',
|
||||
'n2d'
|
||||
)
|
||||
this.redis.get
|
||||
.calledWith(`clsiserver:n2d:${this.project_id}:${this.user_id}`)
|
||||
.should.equal(true)
|
||||
this.redis.get
|
||||
.calledWith(`clsiserver:${this.project_id}:${this.user_id}`)
|
||||
.should.equal(true)
|
||||
serverId.should.equal('clsi-7')
|
||||
})
|
||||
|
||||
it('should _populateServerIdViaRequest if no key is found', async function () {
|
||||
this.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
|
||||
.stub()
|
||||
.resolves()
|
||||
this.redis.get.resolves(null)
|
||||
await this.ClsiCookieManager.promises.getServerId(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
''
|
||||
)
|
||||
this.ClsiCookieManager.promises._populateServerIdViaRequest
|
||||
.calledWith(this.project_id, this.user_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should _populateServerIdViaRequest if no key is blank', async function () {
|
||||
this.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
|
||||
.stub()
|
||||
.resolves(null)
|
||||
this.redis.get.resolves('')
|
||||
await this.ClsiCookieManager.promises.getServerId(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'',
|
||||
'n2d'
|
||||
)
|
||||
this.ClsiCookieManager.promises._populateServerIdViaRequest
|
||||
.calledWith(this.project_id, this.user_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_populateServerIdViaRequest', function () {
|
||||
beforeEach(function () {
|
||||
this.clsiServerId = 'server-id'
|
||||
this.ClsiCookieManager.promises.setServerId = sinon.stub().resolves()
|
||||
})
|
||||
|
||||
describe('with a server id in the response', function () {
|
||||
beforeEach(function () {
|
||||
this.response = {
|
||||
headers: {
|
||||
'set-cookie': [
|
||||
`${this.settings.clsiCookie.key}=${this.clsiServerId}`,
|
||||
],
|
||||
},
|
||||
}
|
||||
this.fetchUtils.fetchNothing.returns(this.response)
|
||||
})
|
||||
|
||||
it('should make a request to the clsi', async function () {
|
||||
await this.ClsiCookieManager.promises._populateServerIdViaRequest(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'standard',
|
||||
'n2d'
|
||||
)
|
||||
const args = this.ClsiCookieManager.promises.setServerId.args[0]
|
||||
args[0].should.equal(this.project_id)
|
||||
args[1].should.equal(this.user_id)
|
||||
args[2].should.equal('standard')
|
||||
args[3].should.equal('n2d')
|
||||
args[4].should.deep.equal(this.clsiServerId)
|
||||
})
|
||||
|
||||
it('should return the server id', async function () {
|
||||
const serverId =
|
||||
await this.ClsiCookieManager.promises._populateServerIdViaRequest(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'',
|
||||
'n2d'
|
||||
)
|
||||
serverId.should.equal(this.clsiServerId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('without a server id in the response', function () {
|
||||
beforeEach(function () {
|
||||
this.response = { headers: {} }
|
||||
this.fetchUtils.fetchNothing.returns(this.response)
|
||||
})
|
||||
it('should not set the server id there is no server id in the response', async function () {
|
||||
this.ClsiCookieManager._parseServerIdFromResponse = sinon
|
||||
.stub()
|
||||
.returns(null)
|
||||
await this.ClsiCookieManager.promises.setServerId(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
this.clsiServerId,
|
||||
null
|
||||
)
|
||||
this.redis.setex.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearServerId', function () {
|
||||
it('should clear both keys', async function () {
|
||||
await this.ClsiCookieManager.promises.clearServerId(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'n2d'
|
||||
)
|
||||
this.redis.del.should.have.been.calledWith(
|
||||
`clsiserver:n2d:${this.project_id}:${this.user_id}`,
|
||||
`clsiserver:${this.project_id}:${this.user_id}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setServerId', function () {
|
||||
beforeEach(function () {
|
||||
this.clsiServerId = 'server-id'
|
||||
this.ClsiCookieManager._parseServerIdFromResponse = sinon
|
||||
.stub()
|
||||
.returns('clsi-8')
|
||||
})
|
||||
|
||||
it('should set the server id with a ttl', async function () {
|
||||
await this.ClsiCookieManager.promises.setServerId(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
this.clsiServerId,
|
||||
null
|
||||
)
|
||||
this.redis.setex.should.have.been.calledWith(
|
||||
`clsiserver:n2d:${this.project_id}:${this.user_id}`,
|
||||
this.settings.clsiCookie.ttlInSeconds,
|
||||
this.clsiServerId
|
||||
)
|
||||
})
|
||||
|
||||
it('should set the server id with the regular ttl for reg instance', async function () {
|
||||
this.clsiServerId = 'clsi-reg-8'
|
||||
await this.ClsiCookieManager.promises.setServerId(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
this.clsiServerId,
|
||||
null
|
||||
)
|
||||
expect(this.redis.setex).to.have.been.calledWith(
|
||||
`clsiserver:n2d:${this.project_id}:${this.user_id}`,
|
||||
this.settings.clsiCookie.ttlInSecondsRegular,
|
||||
this.clsiServerId
|
||||
)
|
||||
})
|
||||
|
||||
it('should not set the server id if clsiCookies are not enabled', async function () {
|
||||
delete this.settings.clsiCookie.key
|
||||
this.ClsiCookieManager2 = SandboxedModule.require(modulePath, {
|
||||
globals: {
|
||||
console,
|
||||
},
|
||||
requires: this.requires,
|
||||
})()
|
||||
await this.ClsiCookieManager2.promises.setServerId(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
this.clsiServerId,
|
||||
null
|
||||
)
|
||||
this.redis.setex.called.should.equal(false)
|
||||
})
|
||||
|
||||
it('should also set in the secondary if secondary redis is enabled', async function () {
|
||||
this.redis_secondary = { setex: sinon.stub().resolves() }
|
||||
this.settings.redis.clsi_cookie_secondary = {}
|
||||
this.RedisWrapper.client = sinon.stub()
|
||||
this.RedisWrapper.client.withArgs('clsi_cookie').returns(this.redis)
|
||||
this.RedisWrapper.client
|
||||
.withArgs('clsi_cookie_secondary')
|
||||
.returns(this.redis_secondary)
|
||||
this.ClsiCookieManager2 = SandboxedModule.require(modulePath, {
|
||||
globals: {
|
||||
console,
|
||||
},
|
||||
requires: this.requires,
|
||||
})()
|
||||
this.ClsiCookieManager2._parseServerIdFromResponse = sinon
|
||||
.stub()
|
||||
.returns('clsi-8')
|
||||
await this.ClsiCookieManager2.promises.setServerId(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
this.clsiServerId,
|
||||
null
|
||||
)
|
||||
this.redis_secondary.setex.should.have.been.calledWith(
|
||||
`clsiserver:n2d:${this.project_id}:${this.user_id}`,
|
||||
this.settings.clsiCookie.ttlInSeconds,
|
||||
this.clsiServerId
|
||||
)
|
||||
})
|
||||
|
||||
describe('checkIsLoadSheddingEvent', function () {
|
||||
beforeEach(function () {
|
||||
this.fetchUtils.fetchStringWithResponse.reset()
|
||||
this.call = async () => {
|
||||
await this.ClsiCookieManager.promises.setServerId(
|
||||
this.project_id,
|
||||
this.user_id,
|
||||
'standard',
|
||||
'n2d',
|
||||
this.clsiServerId,
|
||||
'previous-clsi-server-id'
|
||||
)
|
||||
expect(
|
||||
this.fetchUtils.fetchStringWithResponse
|
||||
).to.have.been.calledWith(
|
||||
`${this.settings.apis.clsi.url}/instance-state?clsiserverid=previous-clsi-server-id&compileGroup=standard&compileBackendClass=n2d`,
|
||||
{ method: 'GET', signal: sinon.match.instanceOf(AbortSignal) }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('should report "load-shedding" when previous is UP', async function () {
|
||||
this.fetchUtils.fetchStringWithResponse.resolves({
|
||||
response: { status: 200 },
|
||||
body: 'previous-clsi-server-id,UP\n',
|
||||
})
|
||||
await this.call()
|
||||
expect(this.metrics.inc).to.have.been.calledWith(
|
||||
'clsi-lb-switch-backend',
|
||||
1,
|
||||
{ status: 'load-shedding' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should report "cycle" when other is UP', async function () {
|
||||
this.fetchUtils.fetchStringWithResponse.resolves({
|
||||
response: { status: 200 },
|
||||
body: 'other-clsi-server-id,UP\n',
|
||||
})
|
||||
await this.call()
|
||||
expect(this.metrics.inc).to.have.been.calledWith(
|
||||
'clsi-lb-switch-backend',
|
||||
1,
|
||||
{ status: 'cycle' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should report "cycle" when previous is 404', async function () {
|
||||
this.fetchUtils.fetchStringWithResponse.resolves({
|
||||
response: { status: 404 },
|
||||
})
|
||||
await this.call()
|
||||
expect(this.metrics.inc).to.have.been.calledWith(
|
||||
'clsi-lb-switch-backend',
|
||||
1,
|
||||
{ status: 'cycle' }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,23 @@
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
const modulePath = '../../../../app/src/Features/Compile/ClsiFormatChecker.js'
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
import { vi, expect } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
|
||||
const modulePath = '../../../../app/src/Features/Compile/ClsiFormatChecker.mjs'
|
||||
|
||||
describe('ClsiFormatChecker', function () {
|
||||
beforeEach(function () {
|
||||
this.ClsiFormatChecker = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/settings': (this.settings = {
|
||||
compileBodySizeLimitMb: 5,
|
||||
}),
|
||||
},
|
||||
})
|
||||
return (this.project_id = 'project-id')
|
||||
beforeEach(async function (ctx) {
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: (ctx.settings = {
|
||||
compileBodySizeLimitMb: 5,
|
||||
}),
|
||||
}))
|
||||
|
||||
ctx.ClsiFormatChecker = (await import(modulePath)).default
|
||||
ctx.project_id = 'project-id'
|
||||
})
|
||||
|
||||
describe('checkRecoursesForProblems', function () {
|
||||
beforeEach(function () {
|
||||
return (this.resources = [
|
||||
beforeEach(function (ctx) {
|
||||
ctx.resources = [
|
||||
{
|
||||
path: 'main.tex',
|
||||
content: 'stuff',
|
||||
@@ -28,65 +28,59 @@ describe('ClsiFormatChecker', function () {
|
||||
},
|
||||
{
|
||||
path: 'stuff/image/image.png',
|
||||
url: `http:somewhere.com/project/${this.project_id}/file/1234124321312`,
|
||||
url: `http:somewhere.com/project/${ctx.project_id}/file/1234124321312`,
|
||||
modified: 'more stuff',
|
||||
},
|
||||
])
|
||||
]
|
||||
})
|
||||
|
||||
it('should call _checkDocsAreUnderSizeLimit and _checkForConflictingPaths', async function () {
|
||||
this.ClsiFormatChecker._checkForConflictingPaths = sinon
|
||||
it('should call _checkDocsAreUnderSizeLimit and _checkForConflictingPaths', async function (ctx) {
|
||||
ctx.ClsiFormatChecker._checkForConflictingPaths = sinon
|
||||
.stub()
|
||||
.returns(null)
|
||||
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
|
||||
ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
|
||||
.stub()
|
||||
.returns(null)
|
||||
this.ClsiFormatChecker.checkRecoursesForProblems(this.resources)
|
||||
this.ClsiFormatChecker._checkForConflictingPaths.called.should.equal(true)
|
||||
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit.called.should.equal(
|
||||
await ctx.ClsiFormatChecker.checkRecoursesForProblems(ctx.resources)
|
||||
ctx.ClsiFormatChecker._checkForConflictingPaths.called.should.equal(true)
|
||||
ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit.called.should.equal(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove undefined errors', async function () {
|
||||
this.ClsiFormatChecker._checkForConflictingPaths = sinon
|
||||
.stub()
|
||||
.returns([])
|
||||
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
|
||||
it('should remove undefined errors', async function (ctx) {
|
||||
ctx.ClsiFormatChecker._checkForConflictingPaths = sinon.stub().returns([])
|
||||
ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
|
||||
.stub()
|
||||
.returns({})
|
||||
const problems = this.ClsiFormatChecker.checkRecoursesForProblems(
|
||||
this.resources
|
||||
const problems = await ctx.ClsiFormatChecker.checkRecoursesForProblems(
|
||||
ctx.resources
|
||||
)
|
||||
expect(problems).to.not.exist
|
||||
})
|
||||
|
||||
it('should keep populated arrays', async function () {
|
||||
this.ClsiFormatChecker._checkForConflictingPaths = sinon
|
||||
it('should keep populated arrays', async function (ctx) {
|
||||
ctx.ClsiFormatChecker._checkForConflictingPaths = sinon
|
||||
.stub()
|
||||
.returns([{ path: 'somewhere/main.tex' }])
|
||||
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
|
||||
ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
|
||||
.stub()
|
||||
.returns({})
|
||||
const problems = this.ClsiFormatChecker.checkRecoursesForProblems(
|
||||
this.resources
|
||||
const problems = await ctx.ClsiFormatChecker.checkRecoursesForProblems(
|
||||
ctx.resources
|
||||
)
|
||||
problems.conflictedPaths[0].path.should.equal('somewhere/main.tex')
|
||||
expect(problems.sizeCheck).to.not.exist
|
||||
})
|
||||
|
||||
it('should keep populated object', async function () {
|
||||
this.ClsiFormatChecker._checkForConflictingPaths = sinon
|
||||
.stub()
|
||||
.returns([])
|
||||
this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
|
||||
.stub()
|
||||
.returns({
|
||||
resources: [{ 'a.tex': 'a.tex' }, { 'b.tex': 'b.tex' }],
|
||||
totalSize: 1000000,
|
||||
})
|
||||
const problems = this.ClsiFormatChecker.checkRecoursesForProblems(
|
||||
this.resources
|
||||
it('should keep populated object', async function (ctx) {
|
||||
ctx.ClsiFormatChecker._checkForConflictingPaths = sinon.stub().returns([])
|
||||
ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon.stub().returns({
|
||||
resources: [{ 'a.tex': 'a.tex' }, { 'b.tex': 'b.tex' }],
|
||||
totalSize: 1000000,
|
||||
})
|
||||
const problems = await ctx.ClsiFormatChecker.checkRecoursesForProblems(
|
||||
ctx.resources
|
||||
)
|
||||
problems.sizeCheck.resources.length.should.equal(2)
|
||||
problems.sizeCheck.totalSize.should.equal(1000000)
|
||||
@@ -94,93 +88,91 @@ describe('ClsiFormatChecker', function () {
|
||||
})
|
||||
|
||||
describe('_checkForConflictingPaths', function () {
|
||||
beforeEach(function () {
|
||||
this.resources.push({
|
||||
beforeEach(function (ctx) {
|
||||
ctx.resources.push({
|
||||
path: 'chapters/chapter1.tex',
|
||||
content: 'other stuff',
|
||||
})
|
||||
|
||||
return this.resources.push({
|
||||
ctx.resources.push({
|
||||
path: 'chapters.tex',
|
||||
content: 'other stuff',
|
||||
})
|
||||
})
|
||||
|
||||
it('should flag up when a nested file has folder with same subpath as file elsewhere', async function () {
|
||||
this.resources.push({
|
||||
it('should flag up when a nested file has folder with same subpath as file elsewhere', async function (ctx) {
|
||||
ctx.resources.push({
|
||||
path: 'stuff/image',
|
||||
url: 'http://somwhere.com',
|
||||
})
|
||||
|
||||
const conflictPathErrors =
|
||||
this.ClsiFormatChecker._checkForConflictingPaths(this.resources)
|
||||
await ctx.ClsiFormatChecker._checkForConflictingPaths(ctx.resources)
|
||||
conflictPathErrors.length.should.equal(1)
|
||||
conflictPathErrors[0].path.should.equal('stuff/image')
|
||||
})
|
||||
|
||||
it('should flag up when a root level file has folder with same subpath as file elsewhere', async function () {
|
||||
this.resources.push({
|
||||
it('should flag up when a root level file has folder with same subpath as file elsewhere', async function (ctx) {
|
||||
ctx.resources.push({
|
||||
path: 'stuff',
|
||||
content: 'other stuff',
|
||||
})
|
||||
|
||||
const conflictPathErrors =
|
||||
this.ClsiFormatChecker._checkForConflictingPaths(this.resources)
|
||||
await ctx.ClsiFormatChecker._checkForConflictingPaths(ctx.resources)
|
||||
conflictPathErrors.length.should.equal(1)
|
||||
conflictPathErrors[0].path.should.equal('stuff')
|
||||
})
|
||||
|
||||
it('should not flag up when the file is a substring of a path', async function () {
|
||||
this.resources.push({
|
||||
it('should not flag up when the file is a substring of a path', async function (ctx) {
|
||||
ctx.resources.push({
|
||||
path: 'stuf',
|
||||
content: 'other stuff',
|
||||
})
|
||||
|
||||
const conflictPathErrors =
|
||||
this.ClsiFormatChecker._checkForConflictingPaths(this.resources)
|
||||
await ctx.ClsiFormatChecker._checkForConflictingPaths(ctx.resources)
|
||||
conflictPathErrors.length.should.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_checkDocsAreUnderSizeLimit', function () {
|
||||
it('should error when there is more than 5mb of data', async function () {
|
||||
this.resources.push({
|
||||
it('should error when there is more than 5mb of data', async function (ctx) {
|
||||
ctx.resources.push({
|
||||
path: 'massive.tex',
|
||||
content: 'hello world'.repeat(833333), // over 5mb limit
|
||||
})
|
||||
|
||||
while (this.resources.length < 20) {
|
||||
this.resources.push({
|
||||
while (ctx.resources.length < 20) {
|
||||
ctx.resources.push({
|
||||
path: 'chapters/chapter1.tex',
|
||||
url: 'http://somwhere.com',
|
||||
})
|
||||
}
|
||||
|
||||
const sizeError = this.ClsiFormatChecker._checkDocsAreUnderSizeLimit(
|
||||
this.resources
|
||||
)
|
||||
const sizeError =
|
||||
await ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit(ctx.resources)
|
||||
sizeError.totalSize.should.equal(16 + 833333 * 11) // 16 is for earlier resources
|
||||
sizeError.resources.length.should.equal(10)
|
||||
sizeError.resources[0].path.should.equal('massive.tex')
|
||||
sizeError.resources[0].size.should.equal(833333 * 11)
|
||||
})
|
||||
|
||||
it('should return nothing when project is correct size', async function () {
|
||||
this.resources.push({
|
||||
it('should return nothing when project is correct size', async function (ctx) {
|
||||
ctx.resources.push({
|
||||
path: 'massive.tex',
|
||||
content: 'x'.repeat(2 * 1000 * 1000),
|
||||
})
|
||||
|
||||
while (this.resources.length < 20) {
|
||||
this.resources.push({
|
||||
while (ctx.resources.length < 20) {
|
||||
ctx.resources.push({
|
||||
path: 'chapters/chapter1.tex',
|
||||
url: 'http://somwhere.com',
|
||||
})
|
||||
}
|
||||
|
||||
const sizeError = this.ClsiFormatChecker._checkDocsAreUnderSizeLimit(
|
||||
this.resources
|
||||
)
|
||||
const sizeError =
|
||||
await ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit(ctx.resources)
|
||||
expect(sizeError).to.not.exist
|
||||
})
|
||||
})
|
||||
193
services/web/test/unit/src/Compile/ClsiStateManager.test.mjs
Normal file
193
services/web/test/unit/src/Compile/ClsiStateManager.test.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
import { vi, expect } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
|
||||
const modulePath = '../../../../app/src/Features/Compile/ClsiStateManager.mjs'
|
||||
|
||||
describe('ClsiStateManager', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: (ctx.settings = {}),
|
||||
}))
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Project/ProjectEntityHandler',
|
||||
() => ({
|
||||
default: (ctx.ProjectEntityHandler = {}),
|
||||
})
|
||||
)
|
||||
|
||||
ctx.ClsiStateManager = (await import(modulePath)).default
|
||||
ctx.project = 'project'
|
||||
ctx.options = { draft: true, isAutoCompile: false }
|
||||
ctx.callback = sinon.stub()
|
||||
})
|
||||
|
||||
describe('computeHash', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.docs = [
|
||||
{ path: '/main.tex', doc: { _id: 'doc-id-1' } },
|
||||
{ path: '/folder/sub.tex', doc: { _id: 'doc-id-2' } },
|
||||
]
|
||||
ctx.files = [
|
||||
{
|
||||
path: '/figure.pdf',
|
||||
file: { _id: 'file-id-1', rev: 123, created: 'aaaaaa' },
|
||||
},
|
||||
{
|
||||
path: '/folder/fig2.pdf',
|
||||
file: { _id: 'file-id-2', rev: 456, created: 'bbbbbb' },
|
||||
},
|
||||
]
|
||||
ctx.ProjectEntityHandler.getAllEntitiesFromProject = sinon
|
||||
.stub()
|
||||
.returns({ docs: ctx.docs, files: ctx.files })
|
||||
ctx.hash0 = ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
})
|
||||
|
||||
describe('with a sample project', function () {
|
||||
beforeEach(function () {})
|
||||
|
||||
it('should return a hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).to.equal('21b1ab73aa3892bec452baf8ffa0956179e1880f')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the files and docs are in a different order', function () {
|
||||
beforeEach(function (ctx) {
|
||||
;[ctx.docs[0], ctx.docs[1]] = [ctx.docs[1], ctx.docs[0]]
|
||||
;[ctx.files[0], ctx.files[1]] = [ctx.files[1], ctx.files[0]]
|
||||
})
|
||||
|
||||
it('should return the same hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).to.equal(ctx.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a doc is renamed', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.docs[0].path = '/new.tex'
|
||||
})
|
||||
|
||||
it('should return a different hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).not.to.equal(ctx.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a file is renamed', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.files[0].path = '/newfigure.pdf'
|
||||
})
|
||||
|
||||
it('should return a different hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).not.to.equal(ctx.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a doc is added', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.docs.push({ path: '/newdoc.tex', doc: { _id: 'newdoc-id' } })
|
||||
})
|
||||
|
||||
it('should return a different hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).not.to.equal(ctx.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a file is added', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.files.push({
|
||||
path: '/newfile.tex',
|
||||
file: { _id: 'newfile-id', rev: 123 },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a different hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).not.to.equal(ctx.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a doc is removed', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.docs.pop()
|
||||
})
|
||||
|
||||
it('should return a different hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).not.to.equal(ctx.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a file is removed', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.files.pop()
|
||||
})
|
||||
|
||||
it('should return a different hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).not.to.equal(ctx.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("when a file's revision is updated", function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.files[0].file.rev++
|
||||
})
|
||||
|
||||
it('should return a different hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).not.to.equal(ctx.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("when a file's date is updated", function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.files[0].file.created = 'zzzzzz'
|
||||
})
|
||||
|
||||
it('should return a different hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).not.to.equal(ctx.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the compile options are changed', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.options.draft = !ctx.options.draft
|
||||
})
|
||||
|
||||
it('should return a different hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).not.to.equal(ctx.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the isAutoCompile option is changed', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.options.isAutoCompile = !ctx.options.isAutoCompile
|
||||
})
|
||||
|
||||
it('should return the same hash value', function (ctx) {
|
||||
expect(
|
||||
ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
|
||||
).to.equal(ctx.hash0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,204 +0,0 @@
|
||||
/* eslint-disable
|
||||
n/handle-callback-err,
|
||||
max-len,
|
||||
no-return-assign,
|
||||
no-unused-vars,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
const modulePath = '../../../../app/src/Features/Compile/ClsiStateManager.js'
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
|
||||
describe('ClsiStateManager', function () {
|
||||
beforeEach(function () {
|
||||
this.ClsiStateManager = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/settings': (this.settings = {}),
|
||||
'../Project/ProjectEntityHandler': (this.ProjectEntityHandler = {}),
|
||||
},
|
||||
})
|
||||
this.project = 'project'
|
||||
this.options = { draft: true, isAutoCompile: false }
|
||||
return (this.callback = sinon.stub())
|
||||
})
|
||||
|
||||
describe('computeHash', function () {
|
||||
beforeEach(function () {
|
||||
this.docs = [
|
||||
{ path: '/main.tex', doc: { _id: 'doc-id-1' } },
|
||||
{ path: '/folder/sub.tex', doc: { _id: 'doc-id-2' } },
|
||||
]
|
||||
this.files = [
|
||||
{
|
||||
path: '/figure.pdf',
|
||||
file: { _id: 'file-id-1', rev: 123, created: 'aaaaaa' },
|
||||
},
|
||||
{
|
||||
path: '/folder/fig2.pdf',
|
||||
file: { _id: 'file-id-2', rev: 456, created: 'bbbbbb' },
|
||||
},
|
||||
]
|
||||
this.ProjectEntityHandler.getAllEntitiesFromProject = sinon
|
||||
.stub()
|
||||
.returns({ docs: this.docs, files: this.files })
|
||||
this.hash0 = this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
})
|
||||
|
||||
describe('with a sample project', function () {
|
||||
beforeEach(function () {})
|
||||
|
||||
it('should return a hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).to.equal('21b1ab73aa3892bec452baf8ffa0956179e1880f')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the files and docs are in a different order', function () {
|
||||
beforeEach(function () {
|
||||
;[this.docs[0], this.docs[1]] = Array.from([this.docs[1], this.docs[0]])
|
||||
;[this.files[0], this.files[1]] = Array.from([
|
||||
this.files[1],
|
||||
this.files[0],
|
||||
])
|
||||
})
|
||||
|
||||
it('should return the same hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).to.equal(this.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a doc is renamed', function () {
|
||||
beforeEach(function () {
|
||||
this.docs[0].path = '/new.tex'
|
||||
})
|
||||
|
||||
it('should return a different hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).not.to.equal(this.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a file is renamed', function () {
|
||||
beforeEach(function () {
|
||||
this.files[0].path = '/newfigure.pdf'
|
||||
})
|
||||
|
||||
it('should return a different hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).not.to.equal(this.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a doc is added', function () {
|
||||
beforeEach(function () {
|
||||
this.docs.push({ path: '/newdoc.tex', doc: { _id: 'newdoc-id' } })
|
||||
})
|
||||
|
||||
it('should return a different hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).not.to.equal(this.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a file is added', function () {
|
||||
beforeEach(function () {
|
||||
this.files.push({
|
||||
path: '/newfile.tex',
|
||||
file: { _id: 'newfile-id', rev: 123 },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a different hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).not.to.equal(this.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a doc is removed', function () {
|
||||
beforeEach(function () {
|
||||
this.docs.pop()
|
||||
})
|
||||
|
||||
it('should return a different hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).not.to.equal(this.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a file is removed', function () {
|
||||
beforeEach(function () {
|
||||
this.files.pop()
|
||||
})
|
||||
|
||||
it('should return a different hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).not.to.equal(this.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("when a file's revision is updated", function () {
|
||||
beforeEach(function () {
|
||||
this.files[0].file.rev++
|
||||
})
|
||||
|
||||
it('should return a different hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).not.to.equal(this.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("when a file's date is updated", function () {
|
||||
beforeEach(function () {
|
||||
this.files[0].file.created = 'zzzzzz'
|
||||
})
|
||||
|
||||
it('should return a different hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).not.to.equal(this.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the compile options are changed', function () {
|
||||
beforeEach(function () {
|
||||
this.options.draft = !this.options.draft
|
||||
})
|
||||
|
||||
it('should return a different hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).not.to.equal(this.hash0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the isAutoCompile option is changed', function () {
|
||||
beforeEach(function () {
|
||||
this.options.isAutoCompile = !this.options.isAutoCompile
|
||||
})
|
||||
|
||||
it('should return the same hash value', function () {
|
||||
expect(
|
||||
this.ClsiStateManager.computeHash(this.project, this.options)
|
||||
).to.equal(this.hash0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,31 +1,39 @@
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const modulePath = require('path').join(
|
||||
__dirname,
|
||||
'../../../../app/src/Features/Institutions/InstitutionsGetter.js'
|
||||
)
|
||||
import { vi, expect } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/Institutions/InstitutionsGetter.mjs'
|
||||
|
||||
describe('InstitutionsGetter', function () {
|
||||
beforeEach(function () {
|
||||
this.UserGetter = {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.UserGetter = {
|
||||
getUserFullEmails: sinon.stub(),
|
||||
promises: {
|
||||
getUserFullEmails: sinon.stub(),
|
||||
},
|
||||
}
|
||||
this.InstitutionsGetter = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'../User/UserGetter': this.UserGetter,
|
||||
'../UserMembership/UserMembershipsHandler':
|
||||
(this.UserMembershipsHandler = {}),
|
||||
'../UserMembership/UserMembershipEntityConfigs':
|
||||
(this.UserMembershipEntityConfigs = {}),
|
||||
},
|
||||
})
|
||||
|
||||
this.userId = '12345abcde'
|
||||
this.confirmedAffiliation = {
|
||||
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
|
||||
default: ctx.UserGetter,
|
||||
}))
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipsHandler',
|
||||
() => ({
|
||||
default: (ctx.UserMembershipsHandler = {}),
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs',
|
||||
() => ({
|
||||
default: (ctx.UserMembershipEntityConfigs = {}),
|
||||
})
|
||||
)
|
||||
|
||||
ctx.InstitutionsGetter = (await import(modulePath)).default
|
||||
|
||||
ctx.userId = '12345abcde'
|
||||
ctx.confirmedAffiliation = {
|
||||
confirmedAt: new Date(),
|
||||
affiliation: {
|
||||
institution: { id: 456, confirmed: true },
|
||||
@@ -33,7 +41,7 @@ describe('InstitutionsGetter', function () {
|
||||
pastReconfirmDate: false,
|
||||
},
|
||||
}
|
||||
this.confirmedAffiliationPastReconfirmation = {
|
||||
ctx.confirmedAffiliationPastReconfirmation = {
|
||||
confirmedAt: new Date('2000-01-01'),
|
||||
affiliation: {
|
||||
institution: { id: 135, confirmed: true },
|
||||
@@ -41,7 +49,7 @@ describe('InstitutionsGetter', function () {
|
||||
pastReconfirmDate: true,
|
||||
},
|
||||
}
|
||||
this.licencedAffiliation = {
|
||||
ctx.licencedAffiliation = {
|
||||
confirmedAt: new Date(),
|
||||
affiliation: {
|
||||
licence: 'pro_plus',
|
||||
@@ -50,7 +58,7 @@ describe('InstitutionsGetter', function () {
|
||||
pastReconfirmDate: false,
|
||||
},
|
||||
}
|
||||
this.licencedAffiliationPastReconfirmation = {
|
||||
ctx.licencedAffiliationPastReconfirmation = {
|
||||
confirmedAt: new Date('2000-01-01'),
|
||||
affiliation: {
|
||||
licence: 'pro_plus',
|
||||
@@ -59,7 +67,7 @@ describe('InstitutionsGetter', function () {
|
||||
pastReconfirmDate: true,
|
||||
},
|
||||
}
|
||||
this.unconfirmedEmailLicensedAffiliation = {
|
||||
ctx.unconfirmedEmailLicensedAffiliation = {
|
||||
confirmedAt: null,
|
||||
affiliation: {
|
||||
licence: 'pro_plus',
|
||||
@@ -71,7 +79,7 @@ describe('InstitutionsGetter', function () {
|
||||
},
|
||||
},
|
||||
}
|
||||
this.unconfirmedDomainLicensedAffiliation = {
|
||||
ctx.unconfirmedDomainLicensedAffiliation = {
|
||||
confirmedAt: new Date(),
|
||||
affiliation: {
|
||||
licence: 'pro_plus',
|
||||
@@ -83,7 +91,7 @@ describe('InstitutionsGetter', function () {
|
||||
},
|
||||
},
|
||||
}
|
||||
this.userEmails = [
|
||||
ctx.userEmails = [
|
||||
{
|
||||
confirmedAt: null,
|
||||
affiliation: {
|
||||
@@ -95,9 +103,9 @@ describe('InstitutionsGetter', function () {
|
||||
},
|
||||
},
|
||||
},
|
||||
this.confirmedAffiliation,
|
||||
this.confirmedAffiliation,
|
||||
this.confirmedAffiliationPastReconfirmation,
|
||||
ctx.confirmedAffiliation,
|
||||
ctx.confirmedAffiliation,
|
||||
ctx.confirmedAffiliationPastReconfirmation,
|
||||
{
|
||||
confirmedAt: new Date(),
|
||||
affiliation: null,
|
||||
@@ -124,41 +132,41 @@ describe('InstitutionsGetter', function () {
|
||||
},
|
||||
},
|
||||
]
|
||||
this.fullEmailCollection = [
|
||||
this.licencedAffiliation,
|
||||
this.licencedAffiliation,
|
||||
this.licencedAffiliationPastReconfirmation,
|
||||
this.confirmedAffiliation,
|
||||
this.confirmedAffiliationPastReconfirmation,
|
||||
this.unconfirmedDomainLicensedAffiliation,
|
||||
this.unconfirmedEmailLicensedAffiliation,
|
||||
ctx.fullEmailCollection = [
|
||||
ctx.licencedAffiliation,
|
||||
ctx.licencedAffiliation,
|
||||
ctx.licencedAffiliationPastReconfirmation,
|
||||
ctx.confirmedAffiliation,
|
||||
ctx.confirmedAffiliationPastReconfirmation,
|
||||
ctx.unconfirmedDomainLicensedAffiliation,
|
||||
ctx.unconfirmedEmailLicensedAffiliation,
|
||||
]
|
||||
})
|
||||
|
||||
describe('getCurrentInstitutionIds', function () {
|
||||
it('filters unconfirmed affiliations, those past reconfirmation, and returns only 1 result per institution', async function () {
|
||||
this.UserGetter.promises.getUserFullEmails.resolves(this.userEmails)
|
||||
it('filters unconfirmed affiliations, those past reconfirmation, and returns only 1 result per institution', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserFullEmails.resolves(ctx.userEmails)
|
||||
const institutions =
|
||||
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
|
||||
this.userId
|
||||
await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
|
||||
ctx.userId
|
||||
)
|
||||
expect(institutions.length).to.equal(1)
|
||||
expect(institutions[0]).to.equal(456)
|
||||
})
|
||||
it('handles empty response', async function () {
|
||||
this.UserGetter.promises.getUserFullEmails.resolves([])
|
||||
it('handles empty response', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserFullEmails.resolves([])
|
||||
const institutions =
|
||||
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
|
||||
this.userId
|
||||
await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
|
||||
ctx.userId
|
||||
)
|
||||
expect(institutions).to.deep.equal([])
|
||||
})
|
||||
it('handles errors', async function () {
|
||||
this.UserGetter.promises.getUserFullEmails.rejects(new Error('oops'))
|
||||
it('handles errors', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserFullEmails.rejects(new Error('oops'))
|
||||
let e
|
||||
try {
|
||||
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
|
||||
this.userId
|
||||
await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
|
||||
ctx.userId
|
||||
)
|
||||
} catch (error) {
|
||||
e = error
|
||||
@@ -168,37 +176,37 @@ describe('InstitutionsGetter', function () {
|
||||
})
|
||||
|
||||
describe('getCurrentAndPastAffiliationIds', function () {
|
||||
it('filters unconfirmed affiliations, preserves those past reconfirmation, and returns only 1 result per institution', async function () {
|
||||
this.UserGetter.promises.getUserFullEmails.resolves(
|
||||
this.fullEmailCollection
|
||||
it('filters unconfirmed affiliations, preserves those past reconfirmation, and returns only 1 result per institution', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserFullEmails.resolves(
|
||||
ctx.fullEmailCollection
|
||||
)
|
||||
const institutions =
|
||||
await this.InstitutionsGetter.promises.getCurrentAndPastAffiliationIds(
|
||||
this.userId
|
||||
await ctx.InstitutionsGetter.promises.getCurrentAndPastAffiliationIds(
|
||||
ctx.userId
|
||||
)
|
||||
expect(institutions).to.deep.equal([777, 888, 456, 135])
|
||||
})
|
||||
it('handles empty response', async function () {
|
||||
this.UserGetter.promises.getUserFullEmails.resolves([])
|
||||
it('handles empty response', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserFullEmails.resolves([])
|
||||
const institutions =
|
||||
await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
|
||||
this.userId
|
||||
await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
|
||||
ctx.userId
|
||||
)
|
||||
expect(institutions).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCurrentInstitutionsWithLicence', function () {
|
||||
it('returns one result per institution and filters out affiliations without license', async function () {
|
||||
this.UserGetter.promises.getUserFullEmails.resolves(
|
||||
this.fullEmailCollection
|
||||
it('returns one result per institution and filters out affiliations without license', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserFullEmails.resolves(
|
||||
ctx.fullEmailCollection
|
||||
)
|
||||
const institutions =
|
||||
await this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(
|
||||
this.userId
|
||||
await ctx.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(
|
||||
ctx.userId
|
||||
)
|
||||
expect(institutions.map(institution => institution.id)).to.deep.equal([
|
||||
this.licencedAffiliation.affiliation.institution.id,
|
||||
ctx.licencedAffiliation.affiliation.institution.id,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,506 @@
|
||||
import { vi, expect } from 'vitest'
|
||||
import path from 'path'
|
||||
import sinon from 'sinon'
|
||||
import mongodb from 'mongodb-legacy'
|
||||
import Features from '../../../../app/src/infrastructure/Features.js'
|
||||
const modulePath = path.join(
|
||||
import.meta.dirname,
|
||||
'../../../../app/src/Features/Institutions/InstitutionsManager'
|
||||
)
|
||||
|
||||
const { ObjectId } = mongodb
|
||||
|
||||
describe('InstitutionsManager', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.institutionId = 123
|
||||
ctx.user = {}
|
||||
const lapsedUser = {
|
||||
_id: '657300a08a14461b3d1aac3e',
|
||||
features: {},
|
||||
}
|
||||
ctx.users = [
|
||||
lapsedUser,
|
||||
{ _id: '657300a08a14461b3d1aac3f', features: {} },
|
||||
{ _id: '657300a08a14461b3d1aac40', features: {} },
|
||||
{ _id: '657300a08a14461b3d1aac41', features: {} },
|
||||
]
|
||||
ctx.ssoUsers = [
|
||||
{
|
||||
_id: '657300a08a14461b3d1aac3f',
|
||||
samlIdentifiers: [{ providerId: ctx.institutionId.toString() }],
|
||||
},
|
||||
{
|
||||
_id: '657300a08a14461b3d1aac40',
|
||||
samlIdentifiers: [
|
||||
{
|
||||
providerId: ctx.institutionId.toString(),
|
||||
hasEntitlement: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
_id: '657300a08a14461b3d1aac3e',
|
||||
samlIdentifiers: [{ providerId: ctx.institutionId.toString() }],
|
||||
hasEntitlement: true,
|
||||
},
|
||||
]
|
||||
|
||||
ctx.UserGetter = {
|
||||
promises: {
|
||||
getUser: sinon.stub().resolves(ctx.user),
|
||||
getUsers: sinon.stub().resolves(ctx.users),
|
||||
getUsersByAnyConfirmedEmail: sinon.stub().resolves(),
|
||||
getSsoUsersAtInstitution: (ctx.getSsoUsersAtInstitution = sinon
|
||||
.stub()
|
||||
.resolves(ctx.ssoUsers)),
|
||||
},
|
||||
}
|
||||
ctx.creator = { create: sinon.stub().resolves() }
|
||||
ctx.NotificationsBuilder = {
|
||||
promises: {
|
||||
featuresUpgradedByAffiliation: sinon.stub().returns(ctx.creator),
|
||||
redundantPersonalSubscription: sinon.stub().returns(ctx.creator),
|
||||
},
|
||||
}
|
||||
ctx.SubscriptionLocator = {
|
||||
promises: {
|
||||
getUsersSubscription: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
ctx.institutionWithV1Data = { name: 'Wombat University' }
|
||||
ctx.institution = {
|
||||
fetchV1DataPromise: sinon.stub().resolves(ctx.institutionWithV1Data),
|
||||
}
|
||||
ctx.InstitutionModel = {
|
||||
Institution: {
|
||||
findOne: sinon.stub().returns({
|
||||
exec: sinon.stub().resolves(ctx.institution),
|
||||
}),
|
||||
},
|
||||
}
|
||||
ctx.subscriptionExec = sinon.stub().resolves()
|
||||
const SubscriptionModel = {
|
||||
Subscription: {
|
||||
find: () => ({
|
||||
populate: () => ({
|
||||
exec: ctx.subscriptionExec,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
ctx.Mongo = {
|
||||
ObjectId,
|
||||
}
|
||||
|
||||
ctx.v1Counts = {
|
||||
user_ids: ctx.users.map(user => user._id),
|
||||
current_users_count: 3,
|
||||
lapsed_user_ids: [lapsedUser._id],
|
||||
entitled_via_sso: 1, // 2 entitled, but 1 lapsed
|
||||
with_confirmed_email: 2, // 1 non entitled SSO + 1 email user
|
||||
}
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Institutions/InstitutionsAPI',
|
||||
() => ({
|
||||
default: {
|
||||
promises: {
|
||||
addAffiliation: (ctx.addAffiliationPromise = sinon
|
||||
.stub()
|
||||
.resolves()),
|
||||
getInstitutionAffiliations: (ctx.getInstitutionAffiliationsPromise =
|
||||
sinon.stub().resolves(ctx.affiliations)),
|
||||
getConfirmedInstitutionAffiliations:
|
||||
(ctx.getConfirmedInstitutionAffiliationsPromise = sinon
|
||||
.stub()
|
||||
.resolves(ctx.affiliations)),
|
||||
getInstitutionAffiliationsCounts:
|
||||
(ctx.getInstitutionAffiliationsCounts = sinon
|
||||
.stub()
|
||||
.resolves(ctx.v1Counts)),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Subscription/FeaturesUpdater',
|
||||
() => ({
|
||||
default: {
|
||||
promises: {
|
||||
refreshFeatures: (ctx.refreshFeaturesPromise = sinon
|
||||
.stub()
|
||||
.resolves()),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Subscription/FeaturesHelper',
|
||||
() => ({
|
||||
default: {
|
||||
isFeatureSetBetter: (ctx.isFeatureSetBetter = sinon.stub()),
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
|
||||
default: ctx.UserGetter,
|
||||
}))
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Notifications/NotificationsBuilder',
|
||||
() => ({
|
||||
default: ctx.NotificationsBuilder,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Subscription/SubscriptionLocator',
|
||||
() => ({
|
||||
default: ctx.SubscriptionLocator,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/models/Institution',
|
||||
() => ctx.InstitutionModel
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/models/Subscription',
|
||||
() => SubscriptionModel
|
||||
)
|
||||
|
||||
vi.doMock('mongodb-legacy', () => ({
|
||||
default: ctx.Mongo,
|
||||
}))
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: {
|
||||
features: { professional: { 'test-feature': true } },
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.InstitutionsManager = (await import(modulePath)).default
|
||||
})
|
||||
|
||||
describe('refreshInstitutionUsers', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.user1Id = '123abc123abc123abc123abc'
|
||||
ctx.user2Id = '456def456def456def456def'
|
||||
ctx.user3Id = '789abd789abd789abd789abd'
|
||||
ctx.user4Id = '321cba321cba321cba321cba'
|
||||
ctx.affiliations = [
|
||||
{ user_id: ctx.user1Id },
|
||||
{ user_id: ctx.user2Id },
|
||||
{ user_id: ctx.user3Id },
|
||||
{ user_id: ctx.user4Id },
|
||||
]
|
||||
ctx.user1 = { _id: ctx.user1Id }
|
||||
ctx.user2 = { _id: ctx.user2Id }
|
||||
ctx.user3 = { _id: ctx.user3Id }
|
||||
ctx.user4 = { _id: ctx.user4Id }
|
||||
|
||||
ctx.UserGetter.promises.getUser
|
||||
.withArgs(new ObjectId(ctx.user1Id))
|
||||
.resolves(ctx.user1)
|
||||
ctx.UserGetter.promises.getUser
|
||||
.withArgs(new ObjectId(ctx.user2Id))
|
||||
.resolves(ctx.user2)
|
||||
ctx.UserGetter.promises.getUser
|
||||
.withArgs(new ObjectId(ctx.user3Id))
|
||||
.resolves(ctx.user3)
|
||||
ctx.UserGetter.promises.getUser
|
||||
.withArgs(new ObjectId(ctx.user4Id))
|
||||
.resolves(ctx.user4)
|
||||
|
||||
ctx.SubscriptionLocator.promises.getUsersSubscription
|
||||
.withArgs(ctx.user2)
|
||||
.resolves({
|
||||
planCode: 'pro',
|
||||
groupPlan: false,
|
||||
})
|
||||
ctx.SubscriptionLocator.promises.getUsersSubscription
|
||||
.withArgs(ctx.user3)
|
||||
.resolves({
|
||||
planCode: 'collaborator_free_trial_7_days',
|
||||
groupPlan: false,
|
||||
})
|
||||
ctx.SubscriptionLocator.promises.getUsersSubscription
|
||||
.withArgs(ctx.user4)
|
||||
.resolves({
|
||||
planCode: 'collaborator-annual',
|
||||
groupPlan: true,
|
||||
})
|
||||
|
||||
ctx.refreshFeaturesPromise.resolves({
|
||||
newFeatures: {},
|
||||
featuresChanged: false,
|
||||
})
|
||||
ctx.refreshFeaturesPromise
|
||||
.withArgs(new ObjectId(ctx.user1Id))
|
||||
.resolves({ newFeatures: {}, featuresChanged: true })
|
||||
ctx.getInstitutionAffiliationsPromise.resolves(ctx.affiliations)
|
||||
ctx.getConfirmedInstitutionAffiliationsPromise.resolves(ctx.affiliations)
|
||||
})
|
||||
|
||||
it('refresh all users Features', async function (ctx) {
|
||||
await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
|
||||
ctx.institutionId,
|
||||
false
|
||||
)
|
||||
sinon.assert.callCount(ctx.refreshFeaturesPromise, 4)
|
||||
// expect no notifications
|
||||
sinon.assert.notCalled(
|
||||
ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation
|
||||
)
|
||||
sinon.assert.notCalled(
|
||||
ctx.NotificationsBuilder.promises.redundantPersonalSubscription
|
||||
)
|
||||
})
|
||||
|
||||
it('notifies users if their features have been upgraded', async function (ctx) {
|
||||
await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
|
||||
ctx.institutionId,
|
||||
true
|
||||
)
|
||||
sinon.assert.calledOnce(
|
||||
ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation,
|
||||
ctx.affiliations[0],
|
||||
ctx.user1
|
||||
)
|
||||
})
|
||||
|
||||
it('notifies users if they have a subscription, or a trial subscription, that should be cancelled', async function (ctx) {
|
||||
await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
|
||||
ctx.institutionId,
|
||||
true
|
||||
)
|
||||
|
||||
sinon.assert.calledTwice(
|
||||
ctx.NotificationsBuilder.promises.redundantPersonalSubscription
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
ctx.NotificationsBuilder.promises.redundantPersonalSubscription,
|
||||
ctx.affiliations[1],
|
||||
ctx.user2
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
ctx.NotificationsBuilder.promises.redundantPersonalSubscription,
|
||||
ctx.affiliations[2],
|
||||
ctx.user3
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkInstitutionUsers', function () {
|
||||
it('returns entitled/not, sso/not, lapsed/current, and pro counts', async function (ctx) {
|
||||
if (Features.hasFeature('saas')) {
|
||||
ctx.isFeatureSetBetter.returns(true)
|
||||
const usersSummary =
|
||||
await ctx.InstitutionsManager.promises.checkInstitutionUsers(
|
||||
ctx.institutionId
|
||||
)
|
||||
expect(usersSummary).to.deep.equal({
|
||||
emailUsers: {
|
||||
total: 1,
|
||||
current: 1,
|
||||
lapsed: 0,
|
||||
pro: {
|
||||
current: 1, // isFeatureSetBetter stubbed to return true for all
|
||||
lapsed: 0,
|
||||
},
|
||||
nonPro: {
|
||||
current: 0,
|
||||
lapsed: 0,
|
||||
},
|
||||
},
|
||||
ssoUsers: {
|
||||
total: 3,
|
||||
lapsed: 1,
|
||||
current: {
|
||||
entitled: 1,
|
||||
notEntitled: 1,
|
||||
},
|
||||
pro: {
|
||||
current: 2,
|
||||
lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
|
||||
},
|
||||
nonPro: {
|
||||
current: 0,
|
||||
lapsed: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('includes withConfirmedEmailMismatch when v1 and v2 counts do not add up', async function (ctx) {
|
||||
if (Features.hasFeature('saas')) {
|
||||
ctx.isFeatureSetBetter.returns(true)
|
||||
ctx.v1Counts.with_confirmed_email = 100
|
||||
const usersSummary =
|
||||
await ctx.InstitutionsManager.promises.checkInstitutionUsers(
|
||||
ctx.institutionId
|
||||
)
|
||||
expect(usersSummary).to.deep.equal({
|
||||
emailUsers: {
|
||||
total: 1,
|
||||
current: 1,
|
||||
lapsed: 0,
|
||||
pro: {
|
||||
current: 1, // isFeatureSetBetter stubbed to return true for all
|
||||
lapsed: 0,
|
||||
},
|
||||
nonPro: {
|
||||
current: 0,
|
||||
lapsed: 0,
|
||||
},
|
||||
},
|
||||
ssoUsers: {
|
||||
total: 3,
|
||||
lapsed: 1,
|
||||
current: {
|
||||
entitled: 1,
|
||||
notEntitled: 1,
|
||||
},
|
||||
pro: {
|
||||
current: 2,
|
||||
lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
|
||||
},
|
||||
nonPro: {
|
||||
current: 0,
|
||||
lapsed: 0,
|
||||
},
|
||||
},
|
||||
databaseMismatch: {
|
||||
withConfirmedEmail: {
|
||||
v1: 100,
|
||||
v2: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInstitutionUsersSubscriptions', function () {
|
||||
it('returns all institution users subscriptions', async function (ctx) {
|
||||
const stubbedUsers = [
|
||||
{ user_id: '123abc123abc123abc123abc' },
|
||||
{ user_id: '456def456def456def456def' },
|
||||
{ user_id: '789def789def789def789def' },
|
||||
]
|
||||
ctx.getInstitutionAffiliationsPromise.resolves(stubbedUsers)
|
||||
await ctx.InstitutionsManager.promises.getInstitutionUsersSubscriptions(
|
||||
ctx.institutionId
|
||||
)
|
||||
sinon.assert.calledOnce(ctx.subscriptionExec)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addAffiliations', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.host = 'mit.edu'.split('').reverse().join('')
|
||||
ctx.stubbedUser1 = {
|
||||
_id: '6573014d8a14461b3d1aac3f',
|
||||
name: 'bob',
|
||||
email: 'hello@world.com',
|
||||
emails: [
|
||||
{ email: 'stubb1@mit.edu', reversedHostname: ctx.host },
|
||||
{ email: 'test@test.com', reversedHostname: 'test.com' },
|
||||
{ email: 'another@mit.edu', reversedHostname: ctx.host },
|
||||
],
|
||||
}
|
||||
ctx.stubbedUser1DecoratedEmails = [
|
||||
{
|
||||
email: 'stubb1@mit.edu',
|
||||
reversedHostname: ctx.host,
|
||||
samlIdentifier: { hasEntitlement: false },
|
||||
},
|
||||
{ email: 'test@test.com', reversedHostname: 'test.com' },
|
||||
{
|
||||
email: 'another@mit.edu',
|
||||
reversedHostname: ctx.host,
|
||||
samlIdentifier: { hasEntitlement: true },
|
||||
},
|
||||
]
|
||||
ctx.stubbedUser2 = {
|
||||
_id: '6573014d8a14461b3d1aac40',
|
||||
name: 'test',
|
||||
email: 'hello2@world.com',
|
||||
emails: [{ email: 'subb2@mit.edu', reversedHostname: ctx.host }],
|
||||
}
|
||||
ctx.stubbedUser2DecoratedEmails = [
|
||||
{
|
||||
email: 'subb2@mit.edu',
|
||||
reversedHostname: ctx.host,
|
||||
},
|
||||
]
|
||||
|
||||
ctx.getInstitutionUsersByHostname = sinon.stub().resolves([
|
||||
{
|
||||
_id: ctx.stubbedUser1._id,
|
||||
emails: ctx.stubbedUser1DecoratedEmails,
|
||||
},
|
||||
{
|
||||
_id: ctx.stubbedUser2._id,
|
||||
emails: ctx.stubbedUser2DecoratedEmails,
|
||||
},
|
||||
])
|
||||
ctx.UserGetter.promises.getInstitutionUsersByHostname =
|
||||
ctx.getInstitutionUsersByHostname
|
||||
})
|
||||
|
||||
describe('affiliateUsers', function () {
|
||||
it('should add affiliations for matching users', async function (ctx) {
|
||||
await ctx.InstitutionsManager.promises.affiliateUsers('mit.edu')
|
||||
|
||||
ctx.getInstitutionUsersByHostname.calledOnce.should.equal(true)
|
||||
ctx.addAffiliationPromise.calledThrice.should.equal(true)
|
||||
ctx.addAffiliationPromise
|
||||
.calledWithMatch(
|
||||
ctx.stubbedUser1._id,
|
||||
ctx.stubbedUser1.emails[0].email,
|
||||
{ entitlement: false }
|
||||
)
|
||||
.should.equal(true)
|
||||
ctx.addAffiliationPromise
|
||||
.calledWithMatch(
|
||||
ctx.stubbedUser1._id,
|
||||
ctx.stubbedUser1.emails[2].email,
|
||||
{ entitlement: true }
|
||||
)
|
||||
.should.equal(true)
|
||||
ctx.addAffiliationPromise
|
||||
.calledWithMatch(
|
||||
ctx.stubbedUser2._id,
|
||||
ctx.stubbedUser2.emails[0].email,
|
||||
{ entitlement: undefined }
|
||||
)
|
||||
.should.equal(true)
|
||||
ctx.refreshFeaturesPromise
|
||||
.calledWith(ctx.stubbedUser1._id)
|
||||
.should.equal(true)
|
||||
ctx.refreshFeaturesPromise
|
||||
.calledWith(ctx.stubbedUser2._id)
|
||||
.should.equal(true)
|
||||
ctx.refreshFeaturesPromise.should.have.been.calledTwice
|
||||
})
|
||||
|
||||
it('should return errors if last affiliation cannot be added', async function (ctx) {
|
||||
ctx.addAffiliationPromise.onCall(2).rejects()
|
||||
await expect(ctx.InstitutionsManager.promises.affiliateUsers('mit.edu'))
|
||||
.to.be.rejected
|
||||
|
||||
ctx.getInstitutionUsersByHostname.calledOnce.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,466 +0,0 @@
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const path = require('path')
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
const { ObjectId } = require('mongodb-legacy')
|
||||
const modulePath = path.join(
|
||||
__dirname,
|
||||
'../../../../app/src/Features/Institutions/InstitutionsManager'
|
||||
)
|
||||
const Features = require('../../../../app/src/infrastructure/Features')
|
||||
|
||||
describe('InstitutionsManager', function () {
|
||||
beforeEach(function () {
|
||||
this.institutionId = 123
|
||||
this.user = {}
|
||||
const lapsedUser = {
|
||||
_id: '657300a08a14461b3d1aac3e',
|
||||
features: {},
|
||||
}
|
||||
this.users = [
|
||||
lapsedUser,
|
||||
{ _id: '657300a08a14461b3d1aac3f', features: {} },
|
||||
{ _id: '657300a08a14461b3d1aac40', features: {} },
|
||||
{ _id: '657300a08a14461b3d1aac41', features: {} },
|
||||
]
|
||||
this.ssoUsers = [
|
||||
{
|
||||
_id: '657300a08a14461b3d1aac3f',
|
||||
samlIdentifiers: [{ providerId: this.institutionId.toString() }],
|
||||
},
|
||||
{
|
||||
_id: '657300a08a14461b3d1aac40',
|
||||
samlIdentifiers: [
|
||||
{
|
||||
providerId: this.institutionId.toString(),
|
||||
hasEntitlement: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
_id: '657300a08a14461b3d1aac3e',
|
||||
samlIdentifiers: [{ providerId: this.institutionId.toString() }],
|
||||
hasEntitlement: true,
|
||||
},
|
||||
]
|
||||
|
||||
this.UserGetter = {
|
||||
promises: {
|
||||
getUser: sinon.stub().resolves(this.user),
|
||||
getUsers: sinon.stub().resolves(this.users),
|
||||
getUsersByAnyConfirmedEmail: sinon.stub().resolves(),
|
||||
getSsoUsersAtInstitution: (this.getSsoUsersAtInstitution = sinon
|
||||
.stub()
|
||||
.resolves(this.ssoUsers)),
|
||||
},
|
||||
}
|
||||
this.creator = { create: sinon.stub().resolves() }
|
||||
this.NotificationsBuilder = {
|
||||
promises: {
|
||||
featuresUpgradedByAffiliation: sinon.stub().returns(this.creator),
|
||||
redundantPersonalSubscription: sinon.stub().returns(this.creator),
|
||||
},
|
||||
}
|
||||
this.SubscriptionLocator = {
|
||||
promises: {
|
||||
getUsersSubscription: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
this.institutionWithV1Data = { name: 'Wombat University' }
|
||||
this.institution = {
|
||||
fetchV1DataPromise: sinon.stub().resolves(this.institutionWithV1Data),
|
||||
}
|
||||
this.InstitutionModel = {
|
||||
Institution: {
|
||||
findOne: sinon.stub().returns({
|
||||
exec: sinon.stub().resolves(this.institution),
|
||||
}),
|
||||
},
|
||||
}
|
||||
this.subscriptionExec = sinon.stub().resolves()
|
||||
const SubscriptionModel = {
|
||||
Subscription: {
|
||||
find: () => ({
|
||||
populate: () => ({
|
||||
exec: this.subscriptionExec,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
this.Mongo = {
|
||||
ObjectId,
|
||||
}
|
||||
|
||||
this.v1Counts = {
|
||||
user_ids: this.users.map(user => user._id),
|
||||
current_users_count: 3,
|
||||
lapsed_user_ids: [lapsedUser._id],
|
||||
entitled_via_sso: 1, // 2 entitled, but 1 lapsed
|
||||
with_confirmed_email: 2, // 1 non entitled SSO + 1 email user
|
||||
}
|
||||
|
||||
this.InstitutionsManager = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'./InstitutionsAPI': {
|
||||
promises: {
|
||||
addAffiliation: (this.addAffiliationPromise = sinon
|
||||
.stub()
|
||||
.resolves()),
|
||||
getInstitutionAffiliations:
|
||||
(this.getInstitutionAffiliationsPromise = sinon
|
||||
.stub()
|
||||
.resolves(this.affiliations)),
|
||||
getConfirmedInstitutionAffiliations:
|
||||
(this.getConfirmedInstitutionAffiliationsPromise = sinon
|
||||
.stub()
|
||||
.resolves(this.affiliations)),
|
||||
getInstitutionAffiliationsCounts:
|
||||
(this.getInstitutionAffiliationsCounts = sinon
|
||||
.stub()
|
||||
.resolves(this.v1Counts)),
|
||||
},
|
||||
},
|
||||
'../Subscription/FeaturesUpdater': {
|
||||
promises: {
|
||||
refreshFeatures: (this.refreshFeaturesPromise = sinon
|
||||
.stub()
|
||||
.resolves()),
|
||||
},
|
||||
},
|
||||
'../Subscription/FeaturesHelper': {
|
||||
isFeatureSetBetter: (this.isFeatureSetBetter = sinon.stub()),
|
||||
},
|
||||
'../User/UserGetter': this.UserGetter,
|
||||
'../Notifications/NotificationsBuilder': this.NotificationsBuilder,
|
||||
'../Subscription/SubscriptionLocator': this.SubscriptionLocator,
|
||||
'../../models/Institution': this.InstitutionModel,
|
||||
'../../models/Subscription': SubscriptionModel,
|
||||
'mongodb-legacy': this.Mongo,
|
||||
'@overleaf/settings': {
|
||||
features: { professional: { 'test-feature': true } },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshInstitutionUsers', function () {
|
||||
beforeEach(function () {
|
||||
this.user1Id = '123abc123abc123abc123abc'
|
||||
this.user2Id = '456def456def456def456def'
|
||||
this.user3Id = '789abd789abd789abd789abd'
|
||||
this.user4Id = '321cba321cba321cba321cba'
|
||||
this.affiliations = [
|
||||
{ user_id: this.user1Id },
|
||||
{ user_id: this.user2Id },
|
||||
{ user_id: this.user3Id },
|
||||
{ user_id: this.user4Id },
|
||||
]
|
||||
this.user1 = { _id: this.user1Id }
|
||||
this.user2 = { _id: this.user2Id }
|
||||
this.user3 = { _id: this.user3Id }
|
||||
this.user4 = { _id: this.user4Id }
|
||||
|
||||
this.UserGetter.promises.getUser
|
||||
.withArgs(new ObjectId(this.user1Id))
|
||||
.resolves(this.user1)
|
||||
this.UserGetter.promises.getUser
|
||||
.withArgs(new ObjectId(this.user2Id))
|
||||
.resolves(this.user2)
|
||||
this.UserGetter.promises.getUser
|
||||
.withArgs(new ObjectId(this.user3Id))
|
||||
.resolves(this.user3)
|
||||
this.UserGetter.promises.getUser
|
||||
.withArgs(new ObjectId(this.user4Id))
|
||||
.resolves(this.user4)
|
||||
|
||||
this.SubscriptionLocator.promises.getUsersSubscription
|
||||
.withArgs(this.user2)
|
||||
.resolves({
|
||||
planCode: 'pro',
|
||||
groupPlan: false,
|
||||
})
|
||||
this.SubscriptionLocator.promises.getUsersSubscription
|
||||
.withArgs(this.user3)
|
||||
.resolves({
|
||||
planCode: 'collaborator_free_trial_7_days',
|
||||
groupPlan: false,
|
||||
})
|
||||
this.SubscriptionLocator.promises.getUsersSubscription
|
||||
.withArgs(this.user4)
|
||||
.resolves({
|
||||
planCode: 'collaborator-annual',
|
||||
groupPlan: true,
|
||||
})
|
||||
|
||||
this.refreshFeaturesPromise.resolves({
|
||||
newFeatures: {},
|
||||
featuresChanged: false,
|
||||
})
|
||||
this.refreshFeaturesPromise
|
||||
.withArgs(new ObjectId(this.user1Id))
|
||||
.resolves({ newFeatures: {}, featuresChanged: true })
|
||||
this.getInstitutionAffiliationsPromise.resolves(this.affiliations)
|
||||
this.getConfirmedInstitutionAffiliationsPromise.resolves(
|
||||
this.affiliations
|
||||
)
|
||||
})
|
||||
|
||||
it('refresh all users Features', async function () {
|
||||
await this.InstitutionsManager.promises.refreshInstitutionUsers(
|
||||
this.institutionId,
|
||||
false
|
||||
)
|
||||
sinon.assert.callCount(this.refreshFeaturesPromise, 4)
|
||||
// expect no notifications
|
||||
sinon.assert.notCalled(
|
||||
this.NotificationsBuilder.promises.featuresUpgradedByAffiliation
|
||||
)
|
||||
sinon.assert.notCalled(
|
||||
this.NotificationsBuilder.promises.redundantPersonalSubscription
|
||||
)
|
||||
})
|
||||
|
||||
it('notifies users if their features have been upgraded', async function () {
|
||||
await this.InstitutionsManager.promises.refreshInstitutionUsers(
|
||||
this.institutionId,
|
||||
true
|
||||
)
|
||||
sinon.assert.calledOnce(
|
||||
this.NotificationsBuilder.promises.featuresUpgradedByAffiliation
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
this.NotificationsBuilder.promises.featuresUpgradedByAffiliation,
|
||||
this.affiliations[0],
|
||||
this.user1
|
||||
)
|
||||
})
|
||||
|
||||
it('notifies users if they have a subscription, or a trial subscription, that should be cancelled', async function () {
|
||||
await this.InstitutionsManager.promises.refreshInstitutionUsers(
|
||||
this.institutionId,
|
||||
true
|
||||
)
|
||||
|
||||
sinon.assert.calledTwice(
|
||||
this.NotificationsBuilder.promises.redundantPersonalSubscription
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
this.NotificationsBuilder.promises.redundantPersonalSubscription,
|
||||
this.affiliations[1],
|
||||
this.user2
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
this.NotificationsBuilder.promises.redundantPersonalSubscription,
|
||||
this.affiliations[2],
|
||||
this.user3
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkInstitutionUsers', function () {
|
||||
it('returns entitled/not, sso/not, lapsed/current, and pro counts', async function () {
|
||||
if (Features.hasFeature('saas')) {
|
||||
this.isFeatureSetBetter.returns(true)
|
||||
const usersSummary =
|
||||
await this.InstitutionsManager.promises.checkInstitutionUsers(
|
||||
this.institutionId
|
||||
)
|
||||
expect(usersSummary).to.deep.equal({
|
||||
emailUsers: {
|
||||
total: 1,
|
||||
current: 1,
|
||||
lapsed: 0,
|
||||
pro: {
|
||||
current: 1, // isFeatureSetBetter stubbed to return true for all
|
||||
lapsed: 0,
|
||||
},
|
||||
nonPro: {
|
||||
current: 0,
|
||||
lapsed: 0,
|
||||
},
|
||||
},
|
||||
ssoUsers: {
|
||||
total: 3,
|
||||
lapsed: 1,
|
||||
current: {
|
||||
entitled: 1,
|
||||
notEntitled: 1,
|
||||
},
|
||||
pro: {
|
||||
current: 2,
|
||||
lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
|
||||
},
|
||||
nonPro: {
|
||||
current: 0,
|
||||
lapsed: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('includes withConfirmedEmailMismatch when v1 and v2 counts do not add up', async function () {
|
||||
if (Features.hasFeature('saas')) {
|
||||
this.isFeatureSetBetter.returns(true)
|
||||
this.v1Counts.with_confirmed_email = 100
|
||||
const usersSummary =
|
||||
await this.InstitutionsManager.promises.checkInstitutionUsers(
|
||||
this.institutionId
|
||||
)
|
||||
expect(usersSummary).to.deep.equal({
|
||||
emailUsers: {
|
||||
total: 1,
|
||||
current: 1,
|
||||
lapsed: 0,
|
||||
pro: {
|
||||
current: 1, // isFeatureSetBetter stubbed to return true for all
|
||||
lapsed: 0,
|
||||
},
|
||||
nonPro: {
|
||||
current: 0,
|
||||
lapsed: 0,
|
||||
},
|
||||
},
|
||||
ssoUsers: {
|
||||
total: 3,
|
||||
lapsed: 1,
|
||||
current: {
|
||||
entitled: 1,
|
||||
notEntitled: 1,
|
||||
},
|
||||
pro: {
|
||||
current: 2,
|
||||
lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
|
||||
},
|
||||
nonPro: {
|
||||
current: 0,
|
||||
lapsed: 0,
|
||||
},
|
||||
},
|
||||
databaseMismatch: {
|
||||
withConfirmedEmail: {
|
||||
v1: 100,
|
||||
v2: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInstitutionUsersSubscriptions', function () {
|
||||
it('returns all institution users subscriptions', async function () {
|
||||
const stubbedUsers = [
|
||||
{ user_id: '123abc123abc123abc123abc' },
|
||||
{ user_id: '456def456def456def456def' },
|
||||
{ user_id: '789def789def789def789def' },
|
||||
]
|
||||
this.getInstitutionAffiliationsPromise.resolves(stubbedUsers)
|
||||
await this.InstitutionsManager.promises.getInstitutionUsersSubscriptions(
|
||||
this.institutionId
|
||||
)
|
||||
sinon.assert.calledOnce(this.subscriptionExec)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addAffiliations', function () {
|
||||
beforeEach(function () {
|
||||
this.host = 'mit.edu'.split('').reverse().join('')
|
||||
this.stubbedUser1 = {
|
||||
_id: '6573014d8a14461b3d1aac3f',
|
||||
name: 'bob',
|
||||
email: 'hello@world.com',
|
||||
emails: [
|
||||
{ email: 'stubb1@mit.edu', reversedHostname: this.host },
|
||||
{ email: 'test@test.com', reversedHostname: 'test.com' },
|
||||
{ email: 'another@mit.edu', reversedHostname: this.host },
|
||||
],
|
||||
}
|
||||
this.stubbedUser1DecoratedEmails = [
|
||||
{
|
||||
email: 'stubb1@mit.edu',
|
||||
reversedHostname: this.host,
|
||||
samlIdentifier: { hasEntitlement: false },
|
||||
},
|
||||
{ email: 'test@test.com', reversedHostname: 'test.com' },
|
||||
{
|
||||
email: 'another@mit.edu',
|
||||
reversedHostname: this.host,
|
||||
samlIdentifier: { hasEntitlement: true },
|
||||
},
|
||||
]
|
||||
this.stubbedUser2 = {
|
||||
_id: '6573014d8a14461b3d1aac40',
|
||||
name: 'test',
|
||||
email: 'hello2@world.com',
|
||||
emails: [{ email: 'subb2@mit.edu', reversedHostname: this.host }],
|
||||
}
|
||||
this.stubbedUser2DecoratedEmails = [
|
||||
{
|
||||
email: 'subb2@mit.edu',
|
||||
reversedHostname: this.host,
|
||||
},
|
||||
]
|
||||
|
||||
this.getInstitutionUsersByHostname = sinon.stub().resolves([
|
||||
{
|
||||
_id: this.stubbedUser1._id,
|
||||
emails: this.stubbedUser1DecoratedEmails,
|
||||
},
|
||||
{
|
||||
_id: this.stubbedUser2._id,
|
||||
emails: this.stubbedUser2DecoratedEmails,
|
||||
},
|
||||
])
|
||||
this.UserGetter.promises.getInstitutionUsersByHostname =
|
||||
this.getInstitutionUsersByHostname
|
||||
})
|
||||
|
||||
describe('affiliateUsers', function () {
|
||||
it('should add affiliations for matching users', async function () {
|
||||
await this.InstitutionsManager.promises.affiliateUsers('mit.edu')
|
||||
|
||||
this.getInstitutionUsersByHostname.calledOnce.should.equal(true)
|
||||
this.addAffiliationPromise.calledThrice.should.equal(true)
|
||||
this.addAffiliationPromise
|
||||
.calledWithMatch(
|
||||
this.stubbedUser1._id,
|
||||
this.stubbedUser1.emails[0].email,
|
||||
{ entitlement: false }
|
||||
)
|
||||
.should.equal(true)
|
||||
this.addAffiliationPromise
|
||||
.calledWithMatch(
|
||||
this.stubbedUser1._id,
|
||||
this.stubbedUser1.emails[2].email,
|
||||
{ entitlement: true }
|
||||
)
|
||||
.should.equal(true)
|
||||
this.addAffiliationPromise
|
||||
.calledWithMatch(
|
||||
this.stubbedUser2._id,
|
||||
this.stubbedUser2.emails[0].email,
|
||||
{ entitlement: undefined }
|
||||
)
|
||||
.should.equal(true)
|
||||
this.refreshFeaturesPromise
|
||||
.calledWith(this.stubbedUser1._id)
|
||||
.should.equal(true)
|
||||
this.refreshFeaturesPromise
|
||||
.calledWith(this.stubbedUser2._id)
|
||||
.should.equal(true)
|
||||
this.refreshFeaturesPromise.should.have.been.calledTwice
|
||||
})
|
||||
|
||||
it('should return errors if last affiliation cannot be added', async function () {
|
||||
this.addAffiliationPromise.onCall(2).rejects()
|
||||
await expect(
|
||||
this.InstitutionsManager.promises.affiliateUsers('mit.edu')
|
||||
).to.be.rejected
|
||||
|
||||
this.getInstitutionUsersByHostname.calledOnce.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { vi, expect } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/Subscription/SubscriptionEmailHandler'
|
||||
|
||||
describe('SubscriptionEmailHandler', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.userId = '123456789abcde'
|
||||
ctx.email = 'test@test.com'
|
||||
|
||||
vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
|
||||
default: (ctx.EmailHandler = {
|
||||
promises: {
|
||||
sendEmail: sinon.stub().resolves({}),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
|
||||
default: (ctx.UserGetter = {
|
||||
promises: {
|
||||
getUser: sinon
|
||||
.stub()
|
||||
.resolves({ _id: ctx.userId, email: 'test@test.com' }),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
|
||||
default: (ctx.PlansLocator = {
|
||||
findLocalPlanInSettings: sinon.stub().returns({
|
||||
name: 'foo',
|
||||
features: { collaborators: 42 },
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: (ctx.Settings = {
|
||||
enableOnboardingEmails: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
ctx.SubscriptionEmailHandler = (await import(modulePath)).default
|
||||
})
|
||||
|
||||
describe('when onboarding emails are disabled', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.Settings.enableOnboardingEmails = false
|
||||
})
|
||||
it('does not send a trial onboarding email', async function (ctx) {
|
||||
await ctx.SubscriptionEmailHandler.sendTrialOnboardingEmail(
|
||||
ctx.userId,
|
||||
'foo-plan-code'
|
||||
)
|
||||
expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('when onboarding emails are enabled', function () {
|
||||
it('sends trial onboarding email', async function (ctx) {
|
||||
await ctx.SubscriptionEmailHandler.sendTrialOnboardingEmail(
|
||||
ctx.userId,
|
||||
'foo-plan-code'
|
||||
)
|
||||
|
||||
expect(ctx.PlansLocator.findLocalPlanInSettings).to.have.been.calledWith(
|
||||
'foo-plan-code'
|
||||
)
|
||||
expect(ctx.EmailHandler.promises.sendEmail.lastCall.args).to.deep.equal([
|
||||
'trialOnboarding',
|
||||
{
|
||||
to: ctx.email,
|
||||
sendingUser_id: ctx.userId,
|
||||
planName: 'foo',
|
||||
features: { collaborators: 42 },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/Subscription/SubscriptionEmailHandler'
|
||||
|
||||
describe('SubscriptionEmailHandler', function () {
|
||||
beforeEach(function () {
|
||||
this.userId = '123456789abcde'
|
||||
this.email = 'test@test.com'
|
||||
|
||||
this.SubscriptionEmailHandler = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'../Email/EmailHandler': (this.EmailHandler = {
|
||||
promises: {
|
||||
sendEmail: sinon.stub().resolves({}),
|
||||
},
|
||||
}),
|
||||
'../User/UserGetter': (this.UserGetter = {
|
||||
promises: {
|
||||
getUser: sinon
|
||||
.stub()
|
||||
.resolves({ _id: this.userId, email: 'test@test.com' }),
|
||||
},
|
||||
}),
|
||||
'./PlansLocator': (this.PlansLocator = {
|
||||
findLocalPlanInSettings: sinon.stub().returns({
|
||||
name: 'foo',
|
||||
features: { collaborators: 42 },
|
||||
}),
|
||||
}),
|
||||
'@overleaf/settings': (this.Settings = {
|
||||
enableOnboardingEmails: true,
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('when onboarding emails are disabled', function () {
|
||||
beforeEach(function () {
|
||||
this.Settings.enableOnboardingEmails = false
|
||||
})
|
||||
it('does not send a trial onboarding email', async function () {
|
||||
await this.SubscriptionEmailHandler.sendTrialOnboardingEmail(
|
||||
this.userId,
|
||||
'foo-plan-code'
|
||||
)
|
||||
expect(this.EmailHandler.promises.sendEmail).to.not.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('when onboarding emails are enabled', function () {
|
||||
it('sends trial onboarding email', async function () {
|
||||
await this.SubscriptionEmailHandler.sendTrialOnboardingEmail(
|
||||
this.userId,
|
||||
'foo-plan-code'
|
||||
)
|
||||
|
||||
expect(this.PlansLocator.findLocalPlanInSettings).to.have.been.calledWith(
|
||||
'foo-plan-code'
|
||||
)
|
||||
expect(this.EmailHandler.promises.sendEmail.lastCall.args).to.deep.equal([
|
||||
'trialOnboarding',
|
||||
{
|
||||
to: this.email,
|
||||
sendingUser_id: this.userId,
|
||||
planName: 'foo',
|
||||
features: { collaborators: 42 },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,5 @@
|
||||
const chai = require('chai')
|
||||
const SubscriptionFormatters = require('../../../../app/src/Features/Subscription/SubscriptionFormatters')
|
||||
|
||||
const { expect } = chai
|
||||
import { expect } from 'vitest'
|
||||
import SubscriptionFormatters from '../../../../app/src/Features/Subscription/SubscriptionFormatters.mjs'
|
||||
|
||||
describe('SubscriptionFormatters', function () {
|
||||
describe('formatDateTime', function () {
|
||||
@@ -0,0 +1,114 @@
|
||||
import { vi, expect } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
|
||||
const MODULE_PATH =
|
||||
'../../../../app/src/Features/User/UserOnboardingEmailManager'
|
||||
|
||||
describe('UserOnboardingEmailManager', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.fakeUserId = '123abc'
|
||||
ctx.fakeUserEmail = 'frog@overleaf.com'
|
||||
ctx.onboardingEmailsQueue = {
|
||||
add: sinon.stub().resolves(),
|
||||
process: callback => {
|
||||
ctx.queueProcessFunction = callback
|
||||
},
|
||||
}
|
||||
ctx.Queues = {
|
||||
createScheduledJob: sinon.stub().resolves(),
|
||||
}
|
||||
ctx.UserGetter = {
|
||||
promises: {
|
||||
getUser: sinon.stub().resolves(null),
|
||||
},
|
||||
}
|
||||
ctx.UserGetter.promises.getUser.withArgs({ _id: ctx.fakeUserId }).resolves({
|
||||
_id: ctx.fakeUserId,
|
||||
email: ctx.fakeUserEmail,
|
||||
})
|
||||
ctx.EmailHandler = {
|
||||
promises: {
|
||||
sendEmail: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
ctx.UserUpdater = {
|
||||
promises: {
|
||||
updateUser: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
|
||||
vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
|
||||
default: ctx.Queues,
|
||||
}))
|
||||
|
||||
vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
|
||||
default: ctx.EmailHandler,
|
||||
}))
|
||||
|
||||
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
|
||||
default: ctx.UserGetter,
|
||||
}))
|
||||
|
||||
vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
|
||||
default: ctx.UserUpdater,
|
||||
}))
|
||||
|
||||
vi.doMock('@overleaf/settings', () => ({
|
||||
default: (ctx.Settings = {
|
||||
enableOnboardingEmails: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
ctx.UserOnboardingEmailManager = (await import(MODULE_PATH)).default
|
||||
})
|
||||
|
||||
describe('scheduleOnboardingEmail', function () {
|
||||
it('should schedule delayed job on queue', async function (ctx) {
|
||||
await ctx.UserOnboardingEmailManager.scheduleOnboardingEmail({
|
||||
_id: ctx.fakeUserId,
|
||||
})
|
||||
sinon.assert.calledWith(
|
||||
ctx.Queues.createScheduledJob,
|
||||
'emails-onboarding',
|
||||
{ data: { userId: ctx.fakeUserId } },
|
||||
24 * 60 * 60 * 1000
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendOnboardingEmail', function () {
|
||||
describe('when onboarding emails are disabled', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.Settings.enableOnboardingEmails = false
|
||||
})
|
||||
it('should not send onboarding email', async function (ctx) {
|
||||
await ctx.UserOnboardingEmailManager.sendOnboardingEmail(ctx.fakeUserId)
|
||||
expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
|
||||
expect(ctx.UserUpdater.promises.updateUser).not.to.have.been.called
|
||||
})
|
||||
})
|
||||
describe('when onboarding emails are enabled', function () {
|
||||
it('should send onboarding email and update user', async function (ctx) {
|
||||
await ctx.UserOnboardingEmailManager.sendOnboardingEmail(ctx.fakeUserId)
|
||||
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
|
||||
'userOnboardingEmail',
|
||||
{
|
||||
to: ctx.fakeUserEmail,
|
||||
}
|
||||
)
|
||||
expect(ctx.UserUpdater.promises.updateUser).to.have.been.calledWith(
|
||||
ctx.fakeUserId,
|
||||
{ $set: { onboardingEmailSentAt: sinon.match.date } }
|
||||
)
|
||||
})
|
||||
|
||||
it('should stop if user is not found', async function (ctx) {
|
||||
await ctx.UserOnboardingEmailManager.sendOnboardingEmail({
|
||||
data: { userId: 'deleted-user' },
|
||||
})
|
||||
expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
|
||||
expect(ctx.UserUpdater.promises.updateUser).not.to.have.been.called
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const path = require('path')
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
|
||||
const MODULE_PATH = path.join(
|
||||
__dirname,
|
||||
'../../../../app/src/Features/User/UserOnboardingEmailManager'
|
||||
)
|
||||
|
||||
describe('UserOnboardingEmailManager', function () {
|
||||
beforeEach(function () {
|
||||
this.fakeUserId = '123abc'
|
||||
this.fakeUserEmail = 'frog@overleaf.com'
|
||||
this.onboardingEmailsQueue = {
|
||||
add: sinon.stub().resolves(),
|
||||
process: callback => {
|
||||
this.queueProcessFunction = callback
|
||||
},
|
||||
}
|
||||
this.Queues = {
|
||||
createScheduledJob: sinon.stub().resolves(),
|
||||
}
|
||||
this.UserGetter = {
|
||||
promises: {
|
||||
getUser: sinon.stub().resolves(null),
|
||||
},
|
||||
}
|
||||
this.UserGetter.promises.getUser
|
||||
.withArgs({ _id: this.fakeUserId })
|
||||
.resolves({
|
||||
_id: this.fakeUserId,
|
||||
email: this.fakeUserEmail,
|
||||
})
|
||||
this.EmailHandler = {
|
||||
promises: {
|
||||
sendEmail: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
this.UserUpdater = {
|
||||
promises: {
|
||||
updateUser: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
|
||||
this.UserOnboardingEmailManager = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
'../../infrastructure/Queues': this.Queues,
|
||||
'../Email/EmailHandler': this.EmailHandler,
|
||||
'./UserGetter': this.UserGetter,
|
||||
'./UserUpdater': this.UserUpdater,
|
||||
'@overleaf/settings': (this.Settings = {
|
||||
enableOnboardingEmails: true,
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('scheduleOnboardingEmail', function () {
|
||||
it('should schedule delayed job on queue', async function () {
|
||||
await this.UserOnboardingEmailManager.scheduleOnboardingEmail({
|
||||
_id: this.fakeUserId,
|
||||
})
|
||||
sinon.assert.calledWith(
|
||||
this.Queues.createScheduledJob,
|
||||
'emails-onboarding',
|
||||
{ data: { userId: this.fakeUserId } },
|
||||
24 * 60 * 60 * 1000
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendOnboardingEmail', function () {
|
||||
describe('when onboarding emails are disabled', function () {
|
||||
beforeEach(function () {
|
||||
this.Settings.enableOnboardingEmails = false
|
||||
})
|
||||
it('should not send onboarding email', async function () {
|
||||
await this.UserOnboardingEmailManager.sendOnboardingEmail(
|
||||
this.fakeUserId
|
||||
)
|
||||
expect(this.EmailHandler.promises.sendEmail).not.to.have.been.called
|
||||
expect(this.UserUpdater.promises.updateUser).not.to.have.been.called
|
||||
})
|
||||
})
|
||||
describe('when onboarding emails are enabled', function () {
|
||||
it('should send onboarding email and update user', async function () {
|
||||
await this.UserOnboardingEmailManager.sendOnboardingEmail(
|
||||
this.fakeUserId
|
||||
)
|
||||
expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
|
||||
'userOnboardingEmail',
|
||||
{
|
||||
to: this.fakeUserEmail,
|
||||
}
|
||||
)
|
||||
expect(this.UserUpdater.promises.updateUser).to.have.been.calledWith(
|
||||
this.fakeUserId,
|
||||
{ $set: { onboardingEmailSentAt: sinon.match.date } }
|
||||
)
|
||||
})
|
||||
|
||||
it('should stop if user is not found', async function () {
|
||||
await this.UserOnboardingEmailManager.sendOnboardingEmail({
|
||||
data: { userId: 'deleted-user' },
|
||||
})
|
||||
expect(this.EmailHandler.promises.sendEmail).not.to.have.been.called
|
||||
expect(this.UserUpdater.promises.updateUser).not.to.have.been.called
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { vi, expect } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
|
||||
const MODULE_PATH =
|
||||
'../../../../app/src/Features/User/UserPostRegistrationAnalyticsManager'
|
||||
|
||||
describe('UserPostRegistrationAnalyticsManager', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.fakeUserId = '123abc'
|
||||
ctx.Queues = {
|
||||
createScheduledJob: sinon.stub().resolves(),
|
||||
}
|
||||
ctx.UserGetter = {
|
||||
promises: {
|
||||
getUser: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
ctx.UserGetter.promises.getUser
|
||||
.withArgs({ _id: ctx.fakeUserId })
|
||||
.resolves({ _id: ctx.fakeUserId })
|
||||
ctx.InstitutionsAPI = {
|
||||
promises: {
|
||||
getUserAffiliations: sinon.stub().resolves([]),
|
||||
},
|
||||
}
|
||||
ctx.AnalyticsManager = {
|
||||
setUserPropertyForUser: sinon.stub().resolves(),
|
||||
}
|
||||
|
||||
vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
|
||||
default: ctx.Queues,
|
||||
}))
|
||||
|
||||
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
|
||||
default: ctx.UserGetter,
|
||||
}))
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Institutions/InstitutionsAPI',
|
||||
() => ({
|
||||
default: ctx.InstitutionsAPI,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Analytics/AnalyticsManager',
|
||||
() => ({
|
||||
default: ctx.AnalyticsManager,
|
||||
})
|
||||
)
|
||||
|
||||
ctx.UserPostRegistrationAnalyticsManager = (
|
||||
await import(MODULE_PATH)
|
||||
).default
|
||||
})
|
||||
|
||||
describe('schedulePostRegistrationAnalytics', function () {
|
||||
it('should schedule delayed job on queue', async function (ctx) {
|
||||
await ctx.UserPostRegistrationAnalyticsManager.schedulePostRegistrationAnalytics(
|
||||
{
|
||||
_id: ctx.fakeUserId,
|
||||
}
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
ctx.Queues.createScheduledJob,
|
||||
'post-registration-analytics',
|
||||
{ data: { userId: ctx.fakeUserId } },
|
||||
24 * 60 * 60 * 1000
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('postRegistrationAnalytics', function () {
|
||||
it('stops without errors if user is not found', async function (ctx) {
|
||||
await ctx.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
|
||||
'not-a-user'
|
||||
)
|
||||
expect(ctx.InstitutionsAPI.promises.getUserAffiliations).not.to.have.been
|
||||
.called
|
||||
expect(ctx.AnalyticsManager.setUserPropertyForUser).not.to.have.been
|
||||
.called
|
||||
})
|
||||
|
||||
it('sets user property if user has commons account affiliationd', async function (ctx) {
|
||||
ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([
|
||||
{},
|
||||
{
|
||||
institution: {
|
||||
commonsAccount: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
institution: {
|
||||
commonsAccount: false,
|
||||
},
|
||||
},
|
||||
])
|
||||
await ctx.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
|
||||
ctx.fakeUserId
|
||||
)
|
||||
expect(
|
||||
ctx.AnalyticsManager.setUserPropertyForUser
|
||||
).to.have.been.calledWith(
|
||||
ctx.fakeUserId,
|
||||
'registered-from-commons-account',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not set user property if user has no commons account affiliation', async function (ctx) {
|
||||
ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([
|
||||
{
|
||||
institution: {
|
||||
commonsAccount: false,
|
||||
},
|
||||
},
|
||||
])
|
||||
await ctx.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
|
||||
ctx.fakeUserId
|
||||
)
|
||||
expect(ctx.AnalyticsManager.setUserPropertyForUser).not.to.have.been
|
||||
.called
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const path = require('path')
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
|
||||
const MODULE_PATH = path.join(
|
||||
__dirname,
|
||||
'../../../../app/src/Features/User/UserPostRegistrationAnalyticsManager'
|
||||
)
|
||||
|
||||
describe('UserPostRegistrationAnalyticsManager', function () {
|
||||
beforeEach(function () {
|
||||
this.fakeUserId = '123abc'
|
||||
this.Queues = {
|
||||
createScheduledJob: sinon.stub().resolves(),
|
||||
}
|
||||
this.UserGetter = {
|
||||
promises: {
|
||||
getUser: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
this.UserGetter.promises.getUser
|
||||
.withArgs({ _id: this.fakeUserId })
|
||||
.resolves({ _id: this.fakeUserId })
|
||||
this.InstitutionsAPI = {
|
||||
promises: {
|
||||
getUserAffiliations: sinon.stub().resolves([]),
|
||||
},
|
||||
}
|
||||
this.AnalyticsManager = {
|
||||
setUserPropertyForUser: sinon.stub().resolves(),
|
||||
}
|
||||
this.UserPostRegistrationAnalyticsManager = SandboxedModule.require(
|
||||
MODULE_PATH,
|
||||
{
|
||||
requires: {
|
||||
'../../infrastructure/Queues': this.Queues,
|
||||
'./UserGetter': this.UserGetter,
|
||||
'../Institutions/InstitutionsAPI': this.InstitutionsAPI,
|
||||
'../Analytics/AnalyticsManager': this.AnalyticsManager,
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('schedulePostRegistrationAnalytics', function () {
|
||||
it('should schedule delayed job on queue', async function () {
|
||||
await this.UserPostRegistrationAnalyticsManager.schedulePostRegistrationAnalytics(
|
||||
{
|
||||
_id: this.fakeUserId,
|
||||
}
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
this.Queues.createScheduledJob,
|
||||
'post-registration-analytics',
|
||||
{ data: { userId: this.fakeUserId } },
|
||||
24 * 60 * 60 * 1000
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('postRegistrationAnalytics', function () {
|
||||
it('stops without errors if user is not found', async function () {
|
||||
await this.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
|
||||
'not-a-user'
|
||||
)
|
||||
expect(this.InstitutionsAPI.promises.getUserAffiliations).not.to.have.been
|
||||
.called
|
||||
expect(this.AnalyticsManager.setUserPropertyForUser).not.to.have.been
|
||||
.called
|
||||
})
|
||||
|
||||
it('sets user property if user has commons account affiliationd', async function () {
|
||||
this.InstitutionsAPI.promises.getUserAffiliations.resolves([
|
||||
{},
|
||||
{
|
||||
institution: {
|
||||
commonsAccount: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
institution: {
|
||||
commonsAccount: false,
|
||||
},
|
||||
},
|
||||
])
|
||||
await this.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
|
||||
this.fakeUserId
|
||||
)
|
||||
expect(
|
||||
this.AnalyticsManager.setUserPropertyForUser
|
||||
).to.have.been.calledWith(
|
||||
this.fakeUserId,
|
||||
'registered-from-commons-account',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not set user property if user has no commons account affiliation', async function () {
|
||||
this.InstitutionsAPI.promises.getUserAffiliations.resolves([
|
||||
{
|
||||
institution: {
|
||||
commonsAccount: false,
|
||||
},
|
||||
},
|
||||
])
|
||||
await this.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
|
||||
this.fakeUserId
|
||||
)
|
||||
expect(this.AnalyticsManager.setUserPropertyForUser).not.to.have.been
|
||||
.called
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,25 +1,18 @@
|
||||
import { expect, vi } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import MockRequest from '../helpers/MockRequest.js'
|
||||
import MockResponse from '../helpers/MockResponse.js'
|
||||
import { expect, vi, describe, it, beforeEach } from 'vitest'
|
||||
import MockRequest from '../helpers/MockRequestVitest.mjs'
|
||||
import MockResponse from '../helpers/MockResponseVitest.mjs'
|
||||
import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js'
|
||||
import Errors from '../../../../app/src/Features/Errors/Errors.js'
|
||||
import {
|
||||
UserIsManagerError,
|
||||
UserNotFoundError,
|
||||
UserAlreadyAddedError,
|
||||
} from '../../../../app/src/Features/UserMembership/UserMembershipErrors.js'
|
||||
|
||||
const assertCalledWith = sinon.assert.calledWith
|
||||
import UserMembershipErrors from '../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
|
||||
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipController.mjs'
|
||||
|
||||
vi.mock(
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipErrors.js',
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs',
|
||||
() =>
|
||||
vi.importActual(
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipErrors.js'
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
|
||||
)
|
||||
)
|
||||
|
||||
@@ -27,9 +20,9 @@ vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
|
||||
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
|
||||
)
|
||||
|
||||
describe('UserMembershipController', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.req = new MockRequest()
|
||||
describe('UserMembershipController', () => {
|
||||
beforeEach(async ctx => {
|
||||
ctx.req = new MockRequest(vi)
|
||||
ctx.req.params.id = 'mock-entity-id'
|
||||
ctx.user = { _id: 'mock-user-id' }
|
||||
ctx.newUser = { _id: 'mock-new-user-id', email: 'new-user-email@foo.bar' }
|
||||
@@ -37,16 +30,16 @@ describe('UserMembershipController', function () {
|
||||
_id: 'mock-subscription-id',
|
||||
admin_id: 'mock-admin-id',
|
||||
manager_ids: ['mock-admin-id'],
|
||||
fetchV1Data: callback => callback(null, ctx.subscription),
|
||||
fetchV1Data: vi.fn(callback => callback(null, ctx.subscription)),
|
||||
}
|
||||
ctx.institution = {
|
||||
_id: 'mock-institution-id',
|
||||
v1Id: 123,
|
||||
fetchV1Data: callback => {
|
||||
const institution = Object.assign({}, ctx.institution)
|
||||
fetchV1Data: vi.fn(callback => {
|
||||
const institution = { ...ctx.institution }
|
||||
institution.name = 'Test Institution Name'
|
||||
callback(null, institution)
|
||||
},
|
||||
}),
|
||||
managerIds: ['mock-member-id-1'],
|
||||
}
|
||||
ctx.users = [
|
||||
@@ -113,45 +106,51 @@ describe('UserMembershipController', function () {
|
||||
}
|
||||
|
||||
ctx.SessionManager = {
|
||||
getSessionUser: sinon.stub().returns(ctx.user),
|
||||
getLoggedInUserId: sinon.stub().returns(ctx.user._id),
|
||||
getSessionUser: vi.fn().mockReturnValue(ctx.user),
|
||||
getLoggedInUserId: vi.fn().mockReturnValue(ctx.user._id),
|
||||
}
|
||||
ctx.SSOConfig = {
|
||||
findById: sinon
|
||||
.stub()
|
||||
.returns({ exec: sinon.stub().resolves({ enabled: true }) }),
|
||||
findById: vi.fn().mockReturnValue({
|
||||
exec: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}),
|
||||
}
|
||||
ctx.UserMembershipHandler = {
|
||||
getEntity: sinon.stub().yields(null, ctx.subscription),
|
||||
createEntity: sinon.stub().yields(null, ctx.institution),
|
||||
getUsers: sinon.stub().yields(null, ctx.users),
|
||||
addUser: sinon.stub().yields(null, ctx.newUser),
|
||||
removeUser: sinon.stub().yields(null),
|
||||
getEntity: vi.fn((_entity, _options, callback) =>
|
||||
callback(null, ctx.subscription)
|
||||
),
|
||||
createEntity: vi.fn((_entity, _options, callback) =>
|
||||
callback(null, ctx.institution)
|
||||
),
|
||||
getUsers: vi.fn((_entity, _options, callback) =>
|
||||
callback(null, ctx.users)
|
||||
),
|
||||
addUser: vi.fn((_entity, _options, _email, callback) =>
|
||||
callback(null, ctx.newUser)
|
||||
),
|
||||
removeUser: vi.fn((_entity, _options, _userId, callback) =>
|
||||
callback(null)
|
||||
),
|
||||
promises: {
|
||||
getUsers: sinon.stub().resolves(ctx.users),
|
||||
getUsers: vi.fn().mockResolvedValue(ctx.users),
|
||||
addUser: vi.fn().mockResolvedValue(ctx.newUser),
|
||||
removeUser: vi.fn().mockResolvedValue(),
|
||||
createEntity: vi.fn().mockResolvedValue(ctx.institution),
|
||||
},
|
||||
}
|
||||
ctx.SplitTestHandler = {
|
||||
promises: {
|
||||
getAssignment: sinon.stub().resolves({ variant: 'default' }),
|
||||
getAssignment: vi.fn().mockResolvedValue({ variant: 'default' }),
|
||||
},
|
||||
getAssignment: sinon.stub().yields(null, { variant: 'default' }),
|
||||
getAssignment: vi.fn((_testName, _userId, callback) =>
|
||||
callback(null, { variant: 'default' })
|
||||
),
|
||||
}
|
||||
ctx.RecurlyClient = {
|
||||
promises: {
|
||||
getSubscription: sinon.stub().resolves({}),
|
||||
getSubscription: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipErrors',
|
||||
() => ({
|
||||
UserIsManagerError,
|
||||
UserNotFoundError,
|
||||
UserAlreadyAddedError,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/Authentication/SessionManager',
|
||||
() => ({
|
||||
@@ -191,7 +190,7 @@ describe('UserMembershipController', function () {
|
||||
ctx.Modules = {
|
||||
promises: {
|
||||
hooks: {
|
||||
fire: sinon.stub(),
|
||||
fire: vi.fn(),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -202,55 +201,90 @@ describe('UserMembershipController', function () {
|
||||
ctx.UserMembershipController = (await import(modulePath)).default
|
||||
})
|
||||
|
||||
describe('index', function () {
|
||||
beforeEach(function (ctx) {
|
||||
describe('index', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.req.user = ctx.user
|
||||
ctx.req.entity = ctx.subscription
|
||||
ctx.req.entityConfig = EntityConfigs.group
|
||||
ctx.Modules.promises.hooks.fire.resolves([])
|
||||
ctx.Modules.promises.hooks.fire.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('get users', async function (ctx) {
|
||||
await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
|
||||
it('get users', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
UserMembershipHandler,
|
||||
subscription,
|
||||
}) => {
|
||||
expect.assertions(1)
|
||||
await UserMembershipController.manageGroupMembers(req, {
|
||||
render: () => {
|
||||
sinon.assert.calledWithMatch(
|
||||
ctx.UserMembershipHandler.promises.getUsers,
|
||||
ctx.subscription,
|
||||
{ modelName: 'Subscription' }
|
||||
expect(UserMembershipHandler.promises.getUsers).toHaveBeenCalledWith(
|
||||
subscription,
|
||||
{
|
||||
modelName: 'Subscription',
|
||||
baseQuery: { groupPlan: true },
|
||||
fields: {
|
||||
access: 'manager_ids',
|
||||
membership: 'member_ids',
|
||||
name: 'teamName',
|
||||
primaryKey: '_id',
|
||||
read: ['invited_emails', 'teamInvites', 'member_ids'],
|
||||
write: null,
|
||||
},
|
||||
hasMembersLimit: true,
|
||||
readOnly: true,
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('render group view', async function (ctx) {
|
||||
ctx.subscription.managedUsersEnabled = false
|
||||
await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
|
||||
it('render group view', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
subscription,
|
||||
users,
|
||||
}) => {
|
||||
expect.assertions(4)
|
||||
subscription.managedUsersEnabled = false
|
||||
await UserMembershipController.manageGroupMembers(req, {
|
||||
render: (viewPath, viewParams) => {
|
||||
expect(viewPath).to.equal('user_membership/group-members-react')
|
||||
expect(viewParams.users).to.deep.equal(ctx.users)
|
||||
expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
|
||||
expect(viewParams.users).to.deep.equal(users)
|
||||
expect(viewParams.groupSize).to.equal(subscription.membersLimit)
|
||||
expect(viewParams.managedUsersActive).to.equal(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('render group view with managed users', async function (ctx) {
|
||||
ctx.subscription.managedUsersEnabled = true
|
||||
await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
|
||||
it('render group view with managed users', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
subscription,
|
||||
users,
|
||||
}) => {
|
||||
expect.assertions(5)
|
||||
subscription.managedUsersEnabled = true
|
||||
await UserMembershipController.manageGroupMembers(req, {
|
||||
render: (viewPath, viewParams) => {
|
||||
expect(viewPath).to.equal('user_membership/group-members-react')
|
||||
expect(viewParams.users).to.deep.equal(ctx.users)
|
||||
expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
|
||||
expect(viewParams.users).to.deep.equal(users)
|
||||
expect(viewParams.groupSize).to.equal(subscription.membersLimit)
|
||||
expect(viewParams.managedUsersActive).to.equal(true)
|
||||
expect(viewParams.isUserGroupManager).to.equal(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('render group managers view', async function (ctx) {
|
||||
ctx.req.user = ctx.user
|
||||
ctx.req.entityConfig = EntityConfigs.groupManagers
|
||||
await ctx.UserMembershipController.manageGroupManagers(ctx.req, {
|
||||
it('render group managers view', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
user,
|
||||
}) => {
|
||||
expect.assertions(2)
|
||||
req.user = user
|
||||
req.entityConfig = EntityConfigs.groupManagers
|
||||
await UserMembershipController.manageGroupManagers(req, {
|
||||
render: (viewPath, viewParams) => {
|
||||
expect(viewPath).to.equal('user_membership/group-managers-react')
|
||||
expect(viewParams.groupSize).to.equal(undefined)
|
||||
@@ -258,11 +292,17 @@ describe('UserMembershipController', function () {
|
||||
})
|
||||
})
|
||||
|
||||
it('render institution view', async function (ctx) {
|
||||
ctx.req.user = ctx.user
|
||||
ctx.req.entity = ctx.institution
|
||||
ctx.req.entityConfig = EntityConfigs.institution
|
||||
await ctx.UserMembershipController.manageInstitutionManagers(ctx.req, {
|
||||
it('render institution view', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
user,
|
||||
institution,
|
||||
}) => {
|
||||
expect.assertions(3)
|
||||
req.user = user
|
||||
req.entity = institution
|
||||
req.entityConfig = EntityConfigs.institution
|
||||
await UserMembershipController.manageInstitutionManagers(req, {
|
||||
render: (viewPath, viewParams) => {
|
||||
expect(viewPath).to.equal(
|
||||
'user_membership/institution-managers-react'
|
||||
@@ -274,294 +314,354 @@ describe('UserMembershipController', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('add', function () {
|
||||
beforeEach(function (ctx) {
|
||||
describe('add', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.req.body.email = ctx.newUser.email
|
||||
ctx.req.entity = ctx.subscription
|
||||
ctx.req.entityConfig = EntityConfigs.groupManagers
|
||||
})
|
||||
|
||||
it('add user', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.UserMembershipController.add(ctx.req, {
|
||||
json: () => {
|
||||
sinon.assert.calledWithMatch(
|
||||
ctx.UserMembershipHandler.addUser,
|
||||
ctx.subscription,
|
||||
{ modelName: 'Subscription' },
|
||||
ctx.newUser.email
|
||||
)
|
||||
resolve()
|
||||
},
|
||||
})
|
||||
it('add user', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
UserMembershipHandler,
|
||||
subscription,
|
||||
newUser,
|
||||
}) => {
|
||||
expect.assertions(1)
|
||||
await UserMembershipController.add(req, {
|
||||
json: () => {
|
||||
expect(UserMembershipHandler.promises.addUser).toHaveBeenCalledWith(
|
||||
subscription,
|
||||
{
|
||||
modelName: 'Subscription',
|
||||
baseQuery: { groupPlan: true },
|
||||
fields: {
|
||||
access: 'manager_ids',
|
||||
membership: 'member_ids',
|
||||
name: 'teamName',
|
||||
primaryKey: '_id',
|
||||
read: ['manager_ids'],
|
||||
write: 'manager_ids',
|
||||
},
|
||||
},
|
||||
newUser.email
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('return user object', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.UserMembershipController.add(ctx.req, {
|
||||
json: payload => {
|
||||
payload.user.should.equal(ctx.newUser)
|
||||
resolve()
|
||||
},
|
||||
})
|
||||
it('return user object', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
newUser,
|
||||
}) => {
|
||||
expect.assertions(1)
|
||||
await UserMembershipController.add(req, {
|
||||
json: payload => {
|
||||
expect(payload.user).to.equal(newUser)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('handle readOnly entity', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.req.entityConfig = EntityConfigs.group
|
||||
ctx.UserMembershipController.add(ctx.req, null, error => {
|
||||
expect(error).to.exist
|
||||
expect(error).to.be.an.instanceof(Errors.NotFoundError)
|
||||
resolve()
|
||||
})
|
||||
it('handle readOnly entity', async ({ UserMembershipController, req }) => {
|
||||
expect.assertions(2)
|
||||
req.entityConfig = EntityConfigs.group
|
||||
await UserMembershipController.add(req, null, error => {
|
||||
expect(error).to.exist
|
||||
expect(error).to.be.an.instanceof(Errors.NotFoundError)
|
||||
})
|
||||
})
|
||||
|
||||
it('handle user already added', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.UserMembershipHandler.addUser.yields(new UserAlreadyAddedError())
|
||||
ctx.UserMembershipController.add(ctx.req, {
|
||||
it('handle user already added', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
UserMembershipHandler,
|
||||
}) => {
|
||||
expect.assertions(1)
|
||||
UserMembershipHandler.promises.addUser.mockRejectedValue(
|
||||
new UserMembershipErrors.UserAlreadyAddedError()
|
||||
)
|
||||
await UserMembershipController.add(
|
||||
req,
|
||||
{
|
||||
status: () => ({
|
||||
json: payload => {
|
||||
expect(payload.error.code).to.equal('user_already_added')
|
||||
resolve()
|
||||
},
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
() => {}
|
||||
)
|
||||
})
|
||||
|
||||
it('handle user not found', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
UserMembershipHandler,
|
||||
}) => {
|
||||
expect.assertions(1)
|
||||
UserMembershipHandler.promises.addUser.mockRejectedValue(
|
||||
new UserMembershipErrors.UserNotFoundError()
|
||||
)
|
||||
await UserMembershipController.add(req, {
|
||||
status: () => ({
|
||||
json: payload => {
|
||||
expect(payload.error.code).to.equal('user_not_found')
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('handle user not found', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.UserMembershipHandler.addUser.yields(new UserNotFoundError())
|
||||
ctx.UserMembershipController.add(ctx.req, {
|
||||
status: () => ({
|
||||
json: payload => {
|
||||
expect(payload.error.code).to.equal('user_not_found')
|
||||
resolve()
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('handle invalid email', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.req.body.email = 'not_valid_email'
|
||||
ctx.UserMembershipController.add(ctx.req, {
|
||||
status: () => ({
|
||||
json: payload => {
|
||||
expect(payload.error.code).to.equal('invalid_email')
|
||||
resolve()
|
||||
},
|
||||
}),
|
||||
})
|
||||
it('handle invalid email', async ({ UserMembershipController, req }) => {
|
||||
expect.assertions(1)
|
||||
req.body.email = 'not_valid_email'
|
||||
await UserMembershipController.add(req, {
|
||||
status: () => ({
|
||||
json: payload => {
|
||||
expect(payload.error.code).to.equal('invalid_email')
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('remove', function () {
|
||||
beforeEach(function (ctx) {
|
||||
describe('remove', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.req.params.userId = ctx.newUser._id
|
||||
ctx.req.entity = ctx.subscription
|
||||
ctx.req.entityConfig = EntityConfigs.groupManagers
|
||||
})
|
||||
|
||||
it('remove user', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.UserMembershipController.remove(ctx.req, {
|
||||
sendStatus: () => {
|
||||
sinon.assert.calledWithMatch(
|
||||
ctx.UserMembershipHandler.removeUser,
|
||||
ctx.subscription,
|
||||
{ modelName: 'Subscription' },
|
||||
ctx.newUser._id
|
||||
)
|
||||
resolve()
|
||||
it('remove user', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
UserMembershipHandler,
|
||||
subscription,
|
||||
newUser,
|
||||
}) => {
|
||||
expect.assertions(1)
|
||||
await UserMembershipController.remove(req, {
|
||||
sendStatus: () => {
|
||||
expect(
|
||||
UserMembershipHandler.promises.removeUser
|
||||
).toHaveBeenCalledWith(
|
||||
subscription,
|
||||
{
|
||||
modelName: 'Subscription',
|
||||
|
||||
baseQuery: {
|
||||
groupPlan: true,
|
||||
},
|
||||
fields: {
|
||||
access: 'manager_ids',
|
||||
membership: 'member_ids',
|
||||
name: 'teamName',
|
||||
primaryKey: '_id',
|
||||
read: ['manager_ids'],
|
||||
write: 'manager_ids',
|
||||
},
|
||||
},
|
||||
newUser._id
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('handle readOnly entity', async ({ UserMembershipController, req }) => {
|
||||
expect.assertions(2)
|
||||
req.entityConfig = EntityConfigs.group
|
||||
await UserMembershipController.remove(req, null, error => {
|
||||
expect(error).to.exist
|
||||
expect(error).to.be.an.instanceof(Errors.NotFoundError)
|
||||
})
|
||||
})
|
||||
|
||||
it('prevent self removal', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
user,
|
||||
}) => {
|
||||
expect.assertions(1)
|
||||
req.params.userId = user._id
|
||||
await UserMembershipController.remove(req, {
|
||||
status: () => ({
|
||||
json: payload => {
|
||||
expect(payload.error.code).to.equal('managers_cannot_remove_self')
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('handle readOnly entity', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.req.entityConfig = EntityConfigs.group
|
||||
ctx.UserMembershipController.remove(ctx.req, null, error => {
|
||||
expect(error).to.exist
|
||||
expect(error).to.be.an.instanceof(Errors.NotFoundError)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('prevent self removal', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.req.params.userId = ctx.user._id
|
||||
ctx.UserMembershipController.remove(ctx.req, {
|
||||
status: () => ({
|
||||
json: payload => {
|
||||
expect(payload.error.code).to.equal('managers_cannot_remove_self')
|
||||
resolve()
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('prevent admin removal', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.UserMembershipHandler.removeUser.yields(new UserIsManagerError())
|
||||
ctx.UserMembershipController.remove(ctx.req, {
|
||||
status: () => ({
|
||||
json: payload => {
|
||||
expect(payload.error.code).to.equal(
|
||||
'managers_cannot_remove_admin'
|
||||
)
|
||||
resolve()
|
||||
},
|
||||
}),
|
||||
})
|
||||
it('prevent admin removal', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
UserMembershipHandler,
|
||||
}) => {
|
||||
expect.assertions(1)
|
||||
UserMembershipHandler.promises.removeUser.mockRejectedValue(
|
||||
new UserMembershipErrors.UserIsManagerError()
|
||||
)
|
||||
await UserMembershipController.remove(req, {
|
||||
status: () => ({
|
||||
json: payload => {
|
||||
expect(payload.error.code).to.equal('managers_cannot_remove_admin')
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportCsv', function () {
|
||||
beforeEach(function (ctx) {
|
||||
describe('exportCsv', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.req.entity = ctx.subscription
|
||||
ctx.req.entityConfig = EntityConfigs.groupManagers
|
||||
ctx.res = new MockResponse()
|
||||
ctx.res = new MockResponse(vi)
|
||||
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
|
||||
})
|
||||
|
||||
it('get users', function (ctx) {
|
||||
sinon.assert.calledWithMatch(
|
||||
ctx.UserMembershipHandler.promises.getUsers,
|
||||
ctx.subscription,
|
||||
{ modelName: 'Subscription' }
|
||||
it('get users', ({ UserMembershipHandler, subscription }) => {
|
||||
expect(UserMembershipHandler.promises.getUsers).toHaveBeenCalledWith(
|
||||
subscription,
|
||||
{
|
||||
modelName: 'Subscription',
|
||||
baseQuery: { groupPlan: true },
|
||||
fields: {
|
||||
access: 'manager_ids',
|
||||
membership: 'member_ids',
|
||||
name: 'teamName',
|
||||
primaryKey: '_id',
|
||||
read: ['manager_ids'],
|
||||
write: 'manager_ids',
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should set the correct content type on the request', function (ctx) {
|
||||
assertCalledWith(ctx.res.contentType, 'text/csv; charset=utf-8')
|
||||
it('should set the correct content type on the request', ({ res }) => {
|
||||
expect(res.contentType).toHaveBeenCalledWith('text/csv; charset=utf-8')
|
||||
})
|
||||
|
||||
it('should name the exported csv file', function (ctx) {
|
||||
assertCalledWith(
|
||||
ctx.res.header,
|
||||
it('should name the exported csv file', ({ res }) => {
|
||||
expect(res.header).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="Group.csv"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should export the correct csv', function (ctx) {
|
||||
assertCalledWith(
|
||||
ctx.res.send,
|
||||
it('should export the correct csv', ({ res }) => {
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
'"email","last_logged_in_at","last_active_at"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z"\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z"\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z"\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z"\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z"\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z"'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportCsv when group is managed', function () {
|
||||
beforeEach(function (ctx) {
|
||||
describe('exportCsv when group is managed', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.req.entity = Object.assign(
|
||||
{ managedUsersEnabled: true },
|
||||
ctx.subscription
|
||||
)
|
||||
ctx.req.entityConfig = EntityConfigs.groupManagers
|
||||
ctx.res = new MockResponse()
|
||||
ctx.res = new MockResponse(vi)
|
||||
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
|
||||
})
|
||||
|
||||
it('should export the correct csv', function (ctx) {
|
||||
assertCalledWith(
|
||||
ctx.res.send,
|
||||
it('should export the correct csv', ({ res }) => {
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
'"email","last_logged_in_at","last_active_at","managed"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportCsv when group has SSO', function () {
|
||||
beforeEach(function (ctx) {
|
||||
describe('exportCsv when group has SSO', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.req.entity = Object.assign(
|
||||
{ ssoConfig: 'sso-config-id' },
|
||||
ctx.subscription
|
||||
)
|
||||
ctx.req.entityConfig = EntityConfigs.groupManagers
|
||||
ctx.Modules.promises.hooks.fire.resolves([true])
|
||||
ctx.res = new MockResponse()
|
||||
ctx.Modules.promises.hooks.fire.mockResolvedValue([true])
|
||||
ctx.res = new MockResponse(vi)
|
||||
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
|
||||
})
|
||||
|
||||
it('should export the correct csv', function (ctx) {
|
||||
assertCalledWith(
|
||||
ctx.res.send,
|
||||
it('should export the correct csv', ({ res }) => {
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
'"email","last_logged_in_at","last_active_at","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportCsv when group has SSO and managed users enabled', function () {
|
||||
beforeEach(function (ctx) {
|
||||
describe('exportCsv when group has SSO and managed users enabled', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.req.entity = Object.assign(
|
||||
{ managedUsersEnabled: true },
|
||||
{ ssoConfig: 'sso-config-id' },
|
||||
ctx.subscription
|
||||
)
|
||||
ctx.req.entityConfig = EntityConfigs.groupManagers
|
||||
ctx.Modules.promises.hooks.fire.resolves([true])
|
||||
ctx.res = new MockResponse()
|
||||
ctx.Modules.promises.hooks.fire.mockResolvedValue([true])
|
||||
ctx.res = new MockResponse(vi)
|
||||
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
|
||||
})
|
||||
|
||||
it('should export the correct csv', function (ctx) {
|
||||
assertCalledWith(
|
||||
ctx.res.send,
|
||||
it('should export the correct csv', ({ res }) => {
|
||||
expect(res.send).toHaveBeenCalledWith(
|
||||
'"email","last_logged_in_at","last_active_at","managed","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false,false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false,false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false,false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true,false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false,true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true,true'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('new', function () {
|
||||
beforeEach(function (ctx) {
|
||||
describe('new', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.req.params.name = 'publisher'
|
||||
ctx.req.params.id = 'abc'
|
||||
})
|
||||
|
||||
it('renders view', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.UserMembershipController.new(ctx.req, {
|
||||
render: (viewPath, data) => {
|
||||
expect(data.entityName).to.eq('publisher')
|
||||
expect(data.entityId).to.eq('abc')
|
||||
resolve()
|
||||
},
|
||||
})
|
||||
it('renders view', async ({ UserMembershipController, req }) => {
|
||||
expect.assertions(2)
|
||||
await UserMembershipController.new(req, {
|
||||
render: (viewPath, data) => {
|
||||
expect(data.entityName).to.eq('publisher')
|
||||
expect(data.entityId).to.eq('abc')
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('create', function () {
|
||||
beforeEach(function (ctx) {
|
||||
describe('create', () => {
|
||||
beforeEach(ctx => {
|
||||
ctx.req.params.name = 'institution'
|
||||
ctx.req.entityConfig = EntityConfigs.institution
|
||||
ctx.req.params.id = 123
|
||||
})
|
||||
|
||||
it('creates institution', async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.UserMembershipController.create(ctx.req, {
|
||||
redirect: path => {
|
||||
expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index)
|
||||
sinon.assert.calledWithMatch(
|
||||
ctx.UserMembershipHandler.createEntity,
|
||||
123,
|
||||
{ modelName: 'Institution' }
|
||||
)
|
||||
resolve()
|
||||
},
|
||||
})
|
||||
it('creates institution', async ({
|
||||
UserMembershipController,
|
||||
req,
|
||||
UserMembershipHandler,
|
||||
}) => {
|
||||
expect.assertions(2)
|
||||
await UserMembershipController.create(req, {
|
||||
redirect: path => {
|
||||
expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index)
|
||||
expect(
|
||||
UserMembershipHandler.promises.createEntity
|
||||
).toHaveBeenCalledWith(123, {
|
||||
fields: {
|
||||
access: 'managerIds',
|
||||
membership: 'member_ids',
|
||||
name: 'name',
|
||||
primaryKey: 'v1Id',
|
||||
read: ['managerIds'],
|
||||
write: 'managerIds',
|
||||
},
|
||||
modelName: 'Institution',
|
||||
pathsFor: EntityConfigs.institution.pathsFor,
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { vi, expect } from 'vitest'
|
||||
import mongodb from 'mongodb-legacy'
|
||||
import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js'
|
||||
import UserMembershipErrors from '../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
|
||||
|
||||
const { ObjectId } = mongodb
|
||||
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipHandler'
|
||||
|
||||
const serializeIds = ids =>
|
||||
ids.map(id => (id instanceof ObjectId ? `objectId-${id.toString()}` : id))
|
||||
|
||||
vi.mock(
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs',
|
||||
() =>
|
||||
vi.importActual(
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
|
||||
)
|
||||
)
|
||||
|
||||
describe('UserMembershipHandler', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.user = { _id: new ObjectId() }
|
||||
ctx.newUser = { _id: new ObjectId(), email: 'new-user-email@foo.bar' }
|
||||
ctx.fakeEntityId = new ObjectId()
|
||||
ctx.subscription = {
|
||||
_id: 'mock-subscription-id',
|
||||
groupPlan: true,
|
||||
membersLimit: 10,
|
||||
member_ids: [new ObjectId(), new ObjectId()],
|
||||
manager_ids: [new ObjectId()],
|
||||
invited_emails: ['mock-email-1@foo.com'],
|
||||
teamInvites: [{ email: 'mock-email-1@bar.com' }],
|
||||
update: vi.fn().mockReturnValue({
|
||||
exec: vi.fn().mockResolvedValue(),
|
||||
}),
|
||||
}
|
||||
ctx.institution = {
|
||||
_id: 'mock-institution-id',
|
||||
v1Id: 123,
|
||||
managerIds: [new ObjectId(), new ObjectId(), new ObjectId()],
|
||||
updateOne: vi.fn().mockReturnValue({
|
||||
exec: vi.fn().mockResolvedValue(),
|
||||
}),
|
||||
}
|
||||
ctx.publisher = {
|
||||
_id: 'mock-publisher-id',
|
||||
slug: 'slug',
|
||||
managerIds: [new ObjectId(), new ObjectId()],
|
||||
updateOne: vi.fn().mockReturnValue({
|
||||
exec: vi.fn().mockResolvedValue(),
|
||||
}),
|
||||
}
|
||||
|
||||
ctx.UserMembershipViewModel = {
|
||||
promises: {
|
||||
buildAsync: vi.fn().mockResolvedValue([{ _id: 'mock-member-id' }]),
|
||||
},
|
||||
build: vi.fn().mockReturnValue(ctx.newUser),
|
||||
}
|
||||
ctx.UserGetter = {
|
||||
promises: {
|
||||
getUserByAnyEmail: vi.fn().mockResolvedValue(ctx.newUser),
|
||||
},
|
||||
}
|
||||
ctx.Institution = {
|
||||
findOne: vi.fn().mockReturnValue({
|
||||
exec: vi.fn().mockResolvedValue(ctx.institution),
|
||||
}),
|
||||
}
|
||||
ctx.Subscription = {
|
||||
findOne: vi.fn().mockReturnValue({
|
||||
exec: vi.fn().mockResolvedValue(ctx.subscription),
|
||||
}),
|
||||
}
|
||||
ctx.Publisher = {
|
||||
findOne: vi.fn().mockReturnValue({
|
||||
exec: vi.fn().mockResolvedValue(ctx.publisher),
|
||||
}),
|
||||
create: vi.fn().mockReturnValue({
|
||||
exec: vi.fn().mockResolvedValue(ctx.publisher),
|
||||
}),
|
||||
}
|
||||
|
||||
vi.doMock('mongodb-legacy', () => ({
|
||||
default: { ObjectId },
|
||||
}))
|
||||
|
||||
vi.doMock(
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipViewModel',
|
||||
() => ({
|
||||
default: ctx.UserMembershipViewModel,
|
||||
})
|
||||
)
|
||||
|
||||
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
|
||||
default: ctx.UserGetter,
|
||||
}))
|
||||
|
||||
vi.doMock('../../../../app/src/models/Institution', () => ({
|
||||
Institution: ctx.Institution,
|
||||
}))
|
||||
|
||||
vi.doMock('../../../../app/src/models/Subscription', () => ({
|
||||
Subscription: ctx.Subscription,
|
||||
}))
|
||||
|
||||
vi.doMock('../../../../app/src/models/Publisher', () => ({
|
||||
Publisher: ctx.Publisher,
|
||||
}))
|
||||
|
||||
ctx.UserMembershipHandler = (await import(modulePath)).default
|
||||
})
|
||||
|
||||
describe('getEntityWithoutAuthorizationCheck', function () {
|
||||
it('get publisher', async function (ctx) {
|
||||
const subscription =
|
||||
await ctx.UserMembershipHandler.promises.getEntityWithoutAuthorizationCheck(
|
||||
ctx.fakeEntityId,
|
||||
EntityConfigs.publisher
|
||||
)
|
||||
const expectedQuery = { slug: ctx.fakeEntityId }
|
||||
expect(ctx.Publisher.findOne).toHaveBeenCalledWith(expectedQuery)
|
||||
expect(subscription).to.equal(ctx.publisher)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsers', function () {
|
||||
describe('group', function () {
|
||||
it('build view model for all users', async function (ctx) {
|
||||
await ctx.UserMembershipHandler.promises.getUsers(
|
||||
ctx.subscription,
|
||||
EntityConfigs.group
|
||||
)
|
||||
expect(
|
||||
serializeIds(
|
||||
ctx.UserMembershipViewModel.promises.buildAsync.mock.calls[0][0]
|
||||
)
|
||||
).toEqual(
|
||||
serializeIds(
|
||||
ctx.subscription.invited_emails.concat(
|
||||
ctx.subscription.teamInvites[0].email,
|
||||
ctx.subscription.member_ids
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('group managers', function () {
|
||||
it('build view model for all managers', async function (ctx) {
|
||||
await ctx.UserMembershipHandler.promises.getUsers(
|
||||
ctx.subscription,
|
||||
EntityConfigs.groupManagers
|
||||
)
|
||||
expect(
|
||||
serializeIds(
|
||||
ctx.UserMembershipViewModel.promises.buildAsync.mock.calls[0][0]
|
||||
)
|
||||
).toEqual(serializeIds(ctx.subscription.manager_ids))
|
||||
})
|
||||
})
|
||||
|
||||
describe('institution', function () {
|
||||
it('build view model for all managers', async function (ctx) {
|
||||
await ctx.UserMembershipHandler.promises.getUsers(
|
||||
ctx.institution,
|
||||
EntityConfigs.institution
|
||||
)
|
||||
expect(
|
||||
serializeIds(
|
||||
ctx.UserMembershipViewModel.promises.buildAsync.mock.calls[0][0]
|
||||
)
|
||||
).toEqual(serializeIds(ctx.institution.managerIds))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEntity', function () {
|
||||
it('creates publisher', async function (ctx) {
|
||||
await ctx.UserMembershipHandler.promises.createEntity(
|
||||
ctx.fakeEntityId,
|
||||
EntityConfigs.publisher
|
||||
)
|
||||
expect(ctx.Publisher.create).toHaveBeenCalledWith({
|
||||
slug: ctx.fakeEntityId,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('addUser', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.email = ctx.newUser.email
|
||||
})
|
||||
|
||||
describe('institution', function () {
|
||||
it('get user', async function (ctx) {
|
||||
await ctx.UserMembershipHandler.promises.addUser(
|
||||
ctx.institution,
|
||||
EntityConfigs.institution,
|
||||
ctx.email
|
||||
)
|
||||
expect(ctx.UserGetter.promises.getUserByAnyEmail).toHaveBeenCalledWith(
|
||||
ctx.email
|
||||
)
|
||||
})
|
||||
|
||||
it('handle user not found', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUserByAnyEmail.mockResolvedValue(null)
|
||||
try {
|
||||
await ctx.UserMembershipHandler.promises.addUser(
|
||||
ctx.institution,
|
||||
EntityConfigs.institution,
|
||||
ctx.email
|
||||
)
|
||||
expect.fail('Expected addUser to throw')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(UserMembershipErrors.UserNotFoundError)
|
||||
}
|
||||
})
|
||||
|
||||
it('handle user already added', async function (ctx) {
|
||||
ctx.institution.managerIds.push(ctx.newUser._id)
|
||||
try {
|
||||
await ctx.UserMembershipHandler.promises.addUser(
|
||||
ctx.institution,
|
||||
EntityConfigs.institution,
|
||||
ctx.email
|
||||
)
|
||||
expect.fail('Expected addUser to throw')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(UserMembershipErrors.UserAlreadyAddedError)
|
||||
}
|
||||
})
|
||||
|
||||
it('add user to institution', async function (ctx) {
|
||||
await ctx.UserMembershipHandler.promises.addUser(
|
||||
ctx.institution,
|
||||
EntityConfigs.institution,
|
||||
ctx.email
|
||||
)
|
||||
expect(ctx.institution.updateOne).toHaveBeenCalledWith({
|
||||
$addToSet: { managerIds: ctx.newUser._id },
|
||||
})
|
||||
})
|
||||
|
||||
it('return user view', async function (ctx) {
|
||||
const user = await ctx.UserMembershipHandler.promises.addUser(
|
||||
ctx.institution,
|
||||
EntityConfigs.institution,
|
||||
ctx.email
|
||||
)
|
||||
expect(user).to.equal(ctx.newUser)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeUser', function () {
|
||||
describe('institution', function () {
|
||||
it('remove user from institution', async function (ctx) {
|
||||
await ctx.UserMembershipHandler.promises.removeUser(
|
||||
ctx.institution,
|
||||
EntityConfigs.institution,
|
||||
ctx.newUser._id
|
||||
)
|
||||
expect(ctx.institution.updateOne).toHaveBeenCalledWith({
|
||||
$pull: { managerIds: ctx.newUser._id },
|
||||
})
|
||||
})
|
||||
|
||||
it('handle admin', async function (ctx) {
|
||||
ctx.subscription.admin_id = ctx.newUser._id
|
||||
try {
|
||||
await ctx.UserMembershipHandler.promises.removeUser(
|
||||
ctx.subscription,
|
||||
EntityConfigs.groupManagers,
|
||||
ctx.newUser._id
|
||||
)
|
||||
expect.fail('Expected removeUser to throw')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(UserMembershipErrors.UserIsManagerError)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,251 +0,0 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const assertCalledWith = sinon.assert.calledWith
|
||||
const { ObjectId } = require('mongodb-legacy')
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipHandler'
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const EntityConfigs = require('../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs')
|
||||
const {
|
||||
UserIsManagerError,
|
||||
UserNotFoundError,
|
||||
UserAlreadyAddedError,
|
||||
} = require('../../../../app/src/Features/UserMembership/UserMembershipErrors')
|
||||
|
||||
describe('UserMembershipHandler', function () {
|
||||
beforeEach(function () {
|
||||
this.user = { _id: new ObjectId() }
|
||||
this.newUser = { _id: new ObjectId(), email: 'new-user-email@foo.bar' }
|
||||
this.fakeEntityId = new ObjectId()
|
||||
this.subscription = {
|
||||
_id: 'mock-subscription-id',
|
||||
groupPlan: true,
|
||||
membersLimit: 10,
|
||||
member_ids: [new ObjectId(), new ObjectId()],
|
||||
manager_ids: [new ObjectId()],
|
||||
invited_emails: ['mock-email-1@foo.com'],
|
||||
teamInvites: [{ email: 'mock-email-1@bar.com' }],
|
||||
update: sinon.stub().returns({
|
||||
exec: sinon.stub().resolves(),
|
||||
}),
|
||||
}
|
||||
this.institution = {
|
||||
_id: 'mock-institution-id',
|
||||
v1Id: 123,
|
||||
managerIds: [new ObjectId(), new ObjectId(), new ObjectId()],
|
||||
updateOne: sinon.stub().returns({
|
||||
exec: sinon.stub().resolves(),
|
||||
}),
|
||||
}
|
||||
this.publisher = {
|
||||
_id: 'mock-publisher-id',
|
||||
slug: 'slug',
|
||||
managerIds: [new ObjectId(), new ObjectId()],
|
||||
updateOne: sinon.stub().returns({
|
||||
exec: sinon.stub().resolves(),
|
||||
}),
|
||||
}
|
||||
|
||||
this.UserMembershipViewModel = {
|
||||
promises: {
|
||||
buildAsync: sinon.stub().resolves([{ _id: 'mock-member-id' }]),
|
||||
},
|
||||
build: sinon.stub().returns(this.newUser),
|
||||
}
|
||||
this.UserGetter = {
|
||||
promises: {
|
||||
getUserByAnyEmail: sinon.stub().resolves(this.newUser),
|
||||
},
|
||||
}
|
||||
this.Institution = {
|
||||
findOne: sinon.stub().returns({
|
||||
exec: sinon.stub().resolves(this.institution),
|
||||
}),
|
||||
}
|
||||
this.Subscription = {
|
||||
findOne: sinon.stub().returns({
|
||||
exec: sinon.stub().resolves(this.subscription),
|
||||
}),
|
||||
}
|
||||
this.Publisher = {
|
||||
findOne: sinon.stub().returns({
|
||||
exec: sinon.stub().resolves(this.publisher),
|
||||
}),
|
||||
create: sinon.stub().returns({
|
||||
exec: sinon.stub().resolves(this.publisher),
|
||||
}),
|
||||
}
|
||||
this.UserMembershipHandler = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'mongodb-legacy': { ObjectId },
|
||||
'./UserMembershipErrors': {
|
||||
UserIsManagerError,
|
||||
UserNotFoundError,
|
||||
UserAlreadyAddedError,
|
||||
},
|
||||
'./UserMembershipViewModel': this.UserMembershipViewModel,
|
||||
'../User/UserGetter': this.UserGetter,
|
||||
'../../models/Institution': {
|
||||
Institution: this.Institution,
|
||||
},
|
||||
'../../models/Subscription': {
|
||||
Subscription: this.Subscription,
|
||||
},
|
||||
'../../models/Publisher': {
|
||||
Publisher: this.Publisher,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEntityWithoutAuthorizationCheck', function () {
|
||||
it('get publisher', async function () {
|
||||
const subscription =
|
||||
await this.UserMembershipHandler.promises.getEntityWithoutAuthorizationCheck(
|
||||
this.fakeEntityId,
|
||||
EntityConfigs.publisher
|
||||
)
|
||||
const expectedQuery = { slug: this.fakeEntityId }
|
||||
assertCalledWith(this.Publisher.findOne, expectedQuery)
|
||||
expect(subscription).to.equal(this.publisher)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsers', function () {
|
||||
describe('group', function () {
|
||||
it('build view model for all users', async function () {
|
||||
await this.UserMembershipHandler.promises.getUsers(
|
||||
this.subscription,
|
||||
EntityConfigs.group
|
||||
)
|
||||
expect(
|
||||
this.UserMembershipViewModel.promises.buildAsync
|
||||
).to.be.calledOnceWith(
|
||||
this.subscription.invited_emails.concat(
|
||||
this.subscription.teamInvites[0].email,
|
||||
this.subscription.member_ids
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('group managers', function () {
|
||||
it('build view model for all managers', async function () {
|
||||
await this.UserMembershipHandler.promises.getUsers(
|
||||
this.subscription,
|
||||
EntityConfigs.groupManagers
|
||||
)
|
||||
expect(
|
||||
this.UserMembershipViewModel.promises.buildAsync
|
||||
).to.be.calledOnceWith(this.subscription.manager_ids)
|
||||
})
|
||||
})
|
||||
|
||||
describe('institution', function () {
|
||||
it('build view model for all managers', async function () {
|
||||
await this.UserMembershipHandler.promises.getUsers(
|
||||
this.institution,
|
||||
EntityConfigs.institution
|
||||
)
|
||||
expect(
|
||||
this.UserMembershipViewModel.promises.buildAsync
|
||||
).to.be.calledOnceWith(this.institution.managerIds)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEntity', function () {
|
||||
it('creates publisher', async function () {
|
||||
await this.UserMembershipHandler.promises.createEntity(
|
||||
this.fakeEntityId,
|
||||
EntityConfigs.publisher
|
||||
)
|
||||
assertCalledWith(this.Publisher.create, { slug: this.fakeEntityId })
|
||||
})
|
||||
})
|
||||
|
||||
describe('addUser', function () {
|
||||
beforeEach(function () {
|
||||
this.email = this.newUser.email
|
||||
})
|
||||
|
||||
describe('institution', function () {
|
||||
it('get user', async function () {
|
||||
await this.UserMembershipHandler.promises.addUser(
|
||||
this.institution,
|
||||
EntityConfigs.institution,
|
||||
this.email
|
||||
)
|
||||
assertCalledWith(this.UserGetter.promises.getUserByAnyEmail, this.email)
|
||||
})
|
||||
|
||||
it('handle user not found', async function () {
|
||||
this.UserGetter.promises.getUserByAnyEmail.resolves(null)
|
||||
expect(
|
||||
this.UserMembershipHandler.promises.addUser(
|
||||
this.institution,
|
||||
EntityConfigs.institution,
|
||||
this.email
|
||||
)
|
||||
).to.be.rejectedWith(UserNotFoundError)
|
||||
})
|
||||
|
||||
it('handle user already added', async function () {
|
||||
this.institution.managerIds.push(this.newUser._id)
|
||||
expect(
|
||||
this.UserMembershipHandler.promises.addUser(
|
||||
this.institution,
|
||||
EntityConfigs.institution,
|
||||
this.email
|
||||
)
|
||||
).to.be.rejectedWith(UserAlreadyAddedError)
|
||||
})
|
||||
|
||||
it('add user to institution', async function () {
|
||||
await this.UserMembershipHandler.promises.addUser(
|
||||
this.institution,
|
||||
EntityConfigs.institution,
|
||||
this.email
|
||||
)
|
||||
assertCalledWith(this.institution.updateOne, {
|
||||
$addToSet: { managerIds: this.newUser._id },
|
||||
})
|
||||
})
|
||||
|
||||
it('return user view', async function () {
|
||||
const user = await this.UserMembershipHandler.promises.addUser(
|
||||
this.institution,
|
||||
EntityConfigs.institution,
|
||||
this.email
|
||||
)
|
||||
user.should.equal(this.newUser)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeUser', function () {
|
||||
describe('institution', function () {
|
||||
it('remove user from institution', async function () {
|
||||
await this.UserMembershipHandler.promises.removeUser(
|
||||
this.institution,
|
||||
EntityConfigs.institution,
|
||||
this.newUser._id
|
||||
)
|
||||
assertCalledWith(this.institution.updateOne, {
|
||||
$pull: { managerIds: this.newUser._id },
|
||||
})
|
||||
})
|
||||
|
||||
it('handle admin', async function () {
|
||||
this.subscription.admin_id = this.newUser._id
|
||||
expect(
|
||||
this.UserMembershipHandler.promises.removeUser(
|
||||
this.subscription,
|
||||
EntityConfigs.groupManagers,
|
||||
this.newUser._id
|
||||
)
|
||||
).to.be.rejectedWith(UserIsManagerError)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { vi, expect } from 'vitest'
|
||||
import sinon from 'sinon'
|
||||
import mongodb from 'mongodb-legacy'
|
||||
import {
|
||||
isObjectIdInstance,
|
||||
normalizeQuery,
|
||||
} from '../../../../app/src/Features/Helpers/Mongo.js'
|
||||
|
||||
const assertCalledWith = sinon.assert.calledWith
|
||||
const assertNotCalled = sinon.assert.notCalled
|
||||
|
||||
const { ObjectId } = mongodb
|
||||
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipViewModel'
|
||||
|
||||
describe('UserMembershipViewModel', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
ctx.UserGetter = { promises: { getUsers: sinon.stub() } }
|
||||
|
||||
vi.doMock('mongodb-legacy', () => ({
|
||||
default: { ObjectId },
|
||||
}))
|
||||
|
||||
vi.doMock('../../../../app/src/Features/Helpers/Mongo', () => ({
|
||||
isObjectIdInstance,
|
||||
normalizeQuery,
|
||||
}))
|
||||
|
||||
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
|
||||
default: ctx.UserGetter,
|
||||
}))
|
||||
|
||||
ctx.UserMembershipViewModel = (await import(modulePath)).default
|
||||
ctx.email = 'mock-email@bar.com'
|
||||
ctx.user = {
|
||||
_id: 'mock-user-id',
|
||||
email: 'mock-email@baz.com',
|
||||
first_name: 'Name',
|
||||
lastLoggedIn: '2020-05-20T10:41:11.407Z',
|
||||
enrollment: {
|
||||
managedBy: 'mock-group-id',
|
||||
enrolledAt: new Date(),
|
||||
sso: {
|
||||
groupId: 'abc123abc123',
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('build', function () {
|
||||
it('build email', function (ctx) {
|
||||
const viewModel = ctx.UserMembershipViewModel.build(ctx.email)
|
||||
expect(viewModel).to.deep.equal({
|
||||
email: ctx.email,
|
||||
invite: true,
|
||||
last_active_at: null,
|
||||
last_logged_in_at: null,
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
_id: null,
|
||||
enrollment: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('build user', function (ctx) {
|
||||
const viewModel = ctx.UserMembershipViewModel.build(ctx.user)
|
||||
expect(viewModel).to.deep.equal({
|
||||
email: ctx.user.email,
|
||||
invite: false,
|
||||
last_active_at: ctx.user.lastLoggedIn,
|
||||
last_logged_in_at: ctx.user.lastLoggedIn,
|
||||
first_name: ctx.user.first_name,
|
||||
last_name: null,
|
||||
_id: ctx.user._id,
|
||||
enrollment: ctx.user.enrollment,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('build async', function () {
|
||||
beforeEach(function (ctx) {
|
||||
ctx.UserMembershipViewModel.build = sinon.stub()
|
||||
})
|
||||
|
||||
it('build email', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUsers.resolves([])
|
||||
await ctx.UserMembershipViewModel.buildAsync([ctx.email])
|
||||
assertCalledWith(ctx.UserMembershipViewModel.build, ctx.email)
|
||||
})
|
||||
|
||||
it('build user', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUsers.resolves([])
|
||||
await ctx.UserMembershipViewModel.buildAsync([ctx.user])
|
||||
assertCalledWith(ctx.UserMembershipViewModel.build, ctx.user)
|
||||
})
|
||||
|
||||
it('build user id', async function (ctx) {
|
||||
const user = {
|
||||
...ctx.user,
|
||||
_id: new ObjectId(),
|
||||
}
|
||||
ctx.UserGetter.promises.getUsers.resolves([user])
|
||||
const [viewModel] = await ctx.UserMembershipViewModel.buildAsync([
|
||||
user._id,
|
||||
])
|
||||
assertNotCalled(ctx.UserMembershipViewModel.build)
|
||||
expect(viewModel._id.toString()).to.equal(user._id.toString())
|
||||
expect(viewModel.email).to.equal(user.email)
|
||||
expect(viewModel.first_name).to.equal(user.first_name)
|
||||
expect(viewModel.invite).to.equal(false)
|
||||
expect(viewModel.email).to.exist
|
||||
expect(viewModel.enrollment).to.exist
|
||||
expect(viewModel.enrollment).to.deep.equal(user.enrollment)
|
||||
})
|
||||
|
||||
it('build user id with error', async function (ctx) {
|
||||
ctx.UserGetter.promises.getUsers.rejects(new Error('nope'))
|
||||
const userId = new ObjectId()
|
||||
const [viewModel] = await ctx.UserMembershipViewModel.buildAsync([userId])
|
||||
assertNotCalled(ctx.UserMembershipViewModel.build)
|
||||
expect(viewModel._id).to.equal(userId.toString())
|
||||
expect(viewModel.email).not.to.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const assertCalledWith = sinon.assert.calledWith
|
||||
const assertNotCalled = sinon.assert.notCalled
|
||||
const { ObjectId } = require('mongodb-legacy')
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/UserMembership/UserMembershipViewModel'
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const {
|
||||
isObjectIdInstance,
|
||||
normalizeQuery,
|
||||
} = require('../../../../app/src/Features/Helpers/Mongo')
|
||||
|
||||
describe('UserMembershipViewModel', function () {
|
||||
beforeEach(function () {
|
||||
this.UserGetter = { promises: { getUsers: sinon.stub() } }
|
||||
this.UserMembershipViewModel = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'mongodb-legacy': { ObjectId },
|
||||
'../Helpers/Mongo': { isObjectIdInstance, normalizeQuery },
|
||||
'../User/UserGetter': this.UserGetter,
|
||||
},
|
||||
})
|
||||
this.email = 'mock-email@bar.com'
|
||||
this.user = {
|
||||
_id: 'mock-user-id',
|
||||
email: 'mock-email@baz.com',
|
||||
first_name: 'Name',
|
||||
lastLoggedIn: '2020-05-20T10:41:11.407Z',
|
||||
enrollment: {
|
||||
managedBy: 'mock-group-id',
|
||||
enrolledAt: new Date(),
|
||||
sso: {
|
||||
groupId: 'abc123abc123',
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('build', function () {
|
||||
it('build email', function () {
|
||||
const viewModel = this.UserMembershipViewModel.build(this.email)
|
||||
expect(viewModel).to.deep.equal({
|
||||
email: this.email,
|
||||
invite: true,
|
||||
last_active_at: null,
|
||||
last_logged_in_at: null,
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
_id: null,
|
||||
enrollment: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('build user', function () {
|
||||
const viewModel = this.UserMembershipViewModel.build(this.user)
|
||||
expect(viewModel).to.deep.equal({
|
||||
email: this.user.email,
|
||||
invite: false,
|
||||
last_active_at: this.user.lastLoggedIn,
|
||||
last_logged_in_at: this.user.lastLoggedIn,
|
||||
first_name: this.user.first_name,
|
||||
last_name: null,
|
||||
_id: this.user._id,
|
||||
enrollment: this.user.enrollment,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('build async', function () {
|
||||
beforeEach(function () {
|
||||
this.UserMembershipViewModel.build = sinon.stub()
|
||||
})
|
||||
|
||||
it('build email', async function () {
|
||||
this.UserGetter.promises.getUsers.resolves([])
|
||||
await this.UserMembershipViewModel.buildAsync([this.email])
|
||||
assertCalledWith(this.UserMembershipViewModel.build, this.email)
|
||||
})
|
||||
|
||||
it('build user', async function () {
|
||||
this.UserGetter.promises.getUsers.resolves([])
|
||||
await this.UserMembershipViewModel.buildAsync([this.user])
|
||||
assertCalledWith(this.UserMembershipViewModel.build, this.user)
|
||||
})
|
||||
|
||||
it('build user id', async function () {
|
||||
const user = {
|
||||
...this.user,
|
||||
_id: new ObjectId(),
|
||||
}
|
||||
this.UserGetter.promises.getUsers.resolves([user])
|
||||
const [viewModel] = await this.UserMembershipViewModel.buildAsync([
|
||||
user._id,
|
||||
])
|
||||
assertNotCalled(this.UserMembershipViewModel.build)
|
||||
expect(viewModel._id.toString()).to.equal(user._id.toString())
|
||||
expect(viewModel.email).to.equal(user.email)
|
||||
expect(viewModel.first_name).to.equal(user.first_name)
|
||||
expect(viewModel.invite).to.equal(false)
|
||||
expect(viewModel.email).to.exist
|
||||
expect(viewModel.enrollment).to.exist
|
||||
expect(viewModel.enrollment).to.deep.equal(user.enrollment)
|
||||
})
|
||||
|
||||
it('build user id with error', async function () {
|
||||
this.UserGetter.promises.getUsers.rejects(new Error('nope'))
|
||||
const userId = new ObjectId()
|
||||
const [viewModel] = await this.UserMembershipViewModel.buildAsync([
|
||||
userId,
|
||||
])
|
||||
assertNotCalled(this.UserMembershipViewModel.build)
|
||||
expect(viewModel._id).to.equal(userId.toString())
|
||||
expect(viewModel.email).not.to.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
10
services/web/types/module-hooks.ts
Normal file
10
services/web/types/module-hooks.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Types for module hook events fired across the application
|
||||
*/
|
||||
|
||||
export type CommentAddedEvent = {
|
||||
projectId: string
|
||||
userId: string
|
||||
threadId: string
|
||||
messageId: string
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const modulePath = path.resolve(__dirname, '../scripts/lezer-latex/generate.js')
|
||||
const modulePath = path.resolve(
|
||||
__dirname,
|
||||
'../scripts/lezer-latex/generate.mjs'
|
||||
)
|
||||
|
||||
try {
|
||||
fs.accessSync(modulePath, fs.constants.W_OK)
|
||||
const { compile, grammars } = require(modulePath)
|
||||
const { compile, grammars } = require(modulePath).default
|
||||
const PLUGIN_NAME = 'lezer-grammar-compiler'
|
||||
class LezerGrammarCompilerPlugin {
|
||||
apply(compiler) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
import Helpers from './lib/helpers.mjs'
|
||||
import { getCollectionInternal } from './lib/mongodb.mjs'
|
||||
|
||||
const tags = ['server-pro', 'saas']
|
||||
|
||||
const indexes = [
|
||||
{
|
||||
key: {
|
||||
scheduledAt: 1,
|
||||
},
|
||||
name: 'scheduledAt_1',
|
||||
expireAfterSeconds: 60 * 60 * 24, // expire after 24 hours
|
||||
},
|
||||
{
|
||||
// used for querying notifications to find possible duplicates
|
||||
unique: false,
|
||||
key: {
|
||||
user_id: 1,
|
||||
recipient_id: 1,
|
||||
project_id: 1,
|
||||
},
|
||||
name: 'user_id_1_recipient_id_1_project_id_1',
|
||||
},
|
||||
]
|
||||
|
||||
const migrate = async () => {
|
||||
const emailNotifications = await getCollectionInternal('emailNotifications')
|
||||
await Helpers.addIndexesToCollection(emailNotifications, indexes)
|
||||
}
|
||||
|
||||
const rollback = async () => {
|
||||
const emailNotifications = await getCollectionInternal('emailNotifications')
|
||||
await Helpers.dropIndexesFromCollection(emailNotifications, indexes)
|
||||
}
|
||||
|
||||
export default {
|
||||
tags,
|
||||
migrate,
|
||||
rollback,
|
||||
}
|
||||
Reference in New Issue
Block a user