8 Commits

Author SHA1 Message Date
Brian Gough
7b6565c98f Merge pull request #28946 from overleaf/bg-set-health-check-flag-on-compile-status-requests
set health-check flag on compile status requests

GitOrigin-RevId: 56decc98aecf7fa5e12f99efc39ef38915ceebe2
2025-10-10 08:06:28 +00:00
Jimmy Domagala-Tang
0ecfc246a2 Rolling builds error logs notification (#28654)
* feat: allow for monthly tl builds experiment

* feat: add in-editor notification when rolling image has updated

* feat: add in-editor notification when rolling image has updated

* feat: allowing for different messages in experiment when user is optend in

* feat: add a banner notification in the error logs when the user is on the rolling build

* moving rolling check from context to util

* Update services/web/frontend/js/features/pdf-preview/components/rolling-build-selected-reminder.tsx

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>

---------

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>
GitOrigin-RevId: fb669db28a7194babb299413f20209e76dcbd351
2025-10-10 08:06:20 +00:00
Maria Florencia Besteiro Gonzalez
81ababb7aa Merge pull request #28850 from overleaf/mfb-promisify-user-membership-view-model
Promisify UserMembershipViewModel.js

GitOrigin-RevId: d06b44ca7df65886f420332a1f9fc3d5f9c989af
2025-10-10 08:06:15 +00:00
Antoine Clausse
5a11958a57 [v1, web] Return "200 null" instead of no content in v2/api/v2/users/:userId/ip_matcher when there is no data, so it can be read as JSON in the frontend (#28913)
⚠️ Deploy this to `web` first so the breaking change is handled correctly

See https://github.com/overleaf/internal/pull/28792

GitOrigin-RevId: 3cc0344643557541791571dac7338bf878328095
2025-10-10 08:06:07 +00:00
Antoine Clausse
0abab86dc1 [web] Hide the "Project URL lookup" menu item for admins without view-project-setting (#28952)
the destination of that link (https://admin.stag-overleaf.com/admin/project) is blocked correctly already

GitOrigin-RevId: e94978d9fe77b3da3a5dab1dd2998beca6e26102
2025-10-10 08:06:02 +00:00
Alf Eaton
e5e279a19f Use path.resolve for resolving paths (#28905)
GitOrigin-RevId: 8f549b410ebf35e330a472fd4de1e3343747794e
2025-10-10 08:05:57 +00:00
Andrew Rumble
95fda8dd36 Use promises version of getUsersByHostname
GitOrigin-RevId: c7b8be79436075a817e5f1429dbe41d824133141
2025-10-10 08:05:53 +00:00
Liangjun Song
2153fd7fa5 Merge pull request #28898 from overleaf/ls-send-email-for-tax-exempt-certificate
Send email if tax exempt certificate is required

GitOrigin-RevId: 685fec7dbb129eab19095470e681d09423558e4c
2025-10-10 08:05:22 +00:00
25 changed files with 273 additions and 148 deletions

1
package-lock.json generated
View File

@@ -52822,6 +52822,7 @@
"nock": "^13.5.6",
"nvd3": "^1.8.6",
"p-reflect": "^3.1.0",
"path-browserify": "^1.0.1",
"pdfjs-dist": "5.1.91",
"pirates": "^4.0.1",
"postcss": "^8.4.31",

View File

@@ -880,6 +880,7 @@ function _finaliseRequest(projectId, options, project, docs, files) {
pdfCachingMinChunkSize: options.pdfCachingMinChunkSize,
flags,
metricsMethod: options.compileGroup,
metricsPath: options.metricsPath,
},
rootResourcePath,
resources,

View File

@@ -974,6 +974,35 @@ templates.removeGroupMember = NoCTAEmailTemplate({
},
})
templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
subject(opts) {
return `Action required: Tax exemption verification for Overleaf [${opts.ein}]`
},
title() {
return 'Action required: Tax exemption verification'
},
greeting() {
return ''
},
message(opts) {
return [
'Thanks for letting us know your organization is tax exempt. To confirm this, we need some additional verification.',
'Please reply to this email with one of the following documents attached:',
'<ul>',
'<li>Your IRS determination letter (for non-profits and similar organizations)</li>',
'<li>Your state resale or exemption certificate</li>',
'</ul>',
`These should match the EIN you provided:${opts.ein}.`,
'If you have any questions, let us know by replying to this email.',
'<br/>',
'Best wishes,',
'Team Overleaf',
'<br/>',
`Our reference:${opts.stripeCustomerId}`,
]
},
})
function _formatUserNameAndEmail(user, placeholder) {
if (user.first_name && user.last_name) {
const fullName = `${user.first_name} ${user.last_name}`

View File

@@ -104,6 +104,9 @@ async function sendEmail(options, emailType) {
replyTo: options.replyTo || EMAIL_SETTINGS.replyToAddress,
socketTimeout: 30 * 1000,
}
if (options.cc) {
sendMailOptions.cc = options.cc
}
if (EMAIL_SETTINGS.textEncoding != null) {
sendMailOptions.textEncoding = EMAIL_SETTINGS.textEncoding
}

View File

@@ -157,7 +157,7 @@ function ipMatcherAffiliation(userId) {
if (error != null) {
return callback(error)
}
if (response.statusCode !== 200) {
if (response.statusCode !== 200 || !body) {
return callback()
}

View File

@@ -227,7 +227,10 @@ async function getInstitutionUsersByHostname(hostname) {
samlIdentifiers: 1,
}
const users = await UserGetter.getUsersByHostname(hostname, projection)
const users = await UserGetter.promises.getUsersByHostname(
hostname,
projection
)
users.forEach(user => {
user.emails = decorateFullEmails(
user.email,

View File

@@ -1,88 +1,60 @@
/* eslint-disable
n/handle-callback-err,
max-len,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const UserGetter = require('../User/UserGetter')
const { isObjectIdInstance } = require('../Helpers/Mongo')
const { promisify } = require('@overleaf/promise-utils')
const UserMembershipViewModel = {
build(userOrEmail) {
if (userOrEmail._id) {
return buildUserViewModel(userOrEmail)
return buildUserViewModel(userOrEmail, false)
} else {
return buildUserViewModelWithEmail(userOrEmail)
}
},
buildAsync(userOrIdOrEmailArray, callback) {
if (callback == null) {
callback = function () {}
}
async buildAsync(userOrIdOrEmailArray) {
const userObjectIds = userOrIdOrEmailArray.filter(isObjectIdInstance)
return UserGetter.getUsers(
userObjectIds,
{
const results = []
try {
const users = await UserGetter.promises.getUsers(userObjectIds, {
email: 1,
first_name: 1,
last_name: 1,
lastLoggedIn: 1,
lastActive: 1,
enrollment: 1,
},
function (error, users) {
const results = []
if (error != null) {
userOrIdOrEmailArray.forEach(item => {
if (isObjectIdInstance(item)) {
results.push(buildUserViewModelWithId(item.toString()))
} else {
// `item` is a user or an email and can be parsed by #build
results.push(UserMembershipViewModel.build(item))
}
})
} else {
const usersMap = new Map()
for (const user of users) {
usersMap.set(user._id.toString(), user)
}
userOrIdOrEmailArray.forEach(item => {
if (isObjectIdInstance(item)) {
const user = usersMap.get(item.toString())
if (user == null) {
results.push(buildUserViewModelWithId(item.toString()))
} else {
results.push(buildUserViewModel(user))
}
} else {
// `item` is a user or an email and can be parsed by #build
results.push(UserMembershipViewModel.build(item))
}
})
}
callback(null, results)
})
const usersMap = new Map()
for (const user of users) {
usersMap.set(user._id.toString(), user)
}
)
userOrIdOrEmailArray.forEach(item => {
if (isObjectIdInstance(item)) {
const user = usersMap.get(item.toString())
if (!user) {
results.push(buildUserViewModelWithId(item.toString()))
} else {
results.push(buildUserViewModel(user, false))
}
} else {
// `item` is a user or an email and can be parsed by #build
results.push(UserMembershipViewModel.build(item))
}
})
} catch (error) {
userOrIdOrEmailArray.forEach(item => {
if (isObjectIdInstance(item)) {
results.push(buildUserViewModelWithId(item.toString()))
} else {
// `item` is a user or an email and can be parsed by #build
results.push(UserMembershipViewModel.build(item))
}
})
}
return results
},
}
function buildUserViewModel(user, isInvite) {
if (isInvite == null) {
isInvite = false
}
return {
_id: user._id || null,
email: user.email || null,
@@ -106,7 +78,7 @@ const buildUserViewModelWithEmail = email => buildUserViewModel({ email }, true)
const buildUserViewModelWithId = id => buildUserViewModel({ _id: id }, false)
UserMembershipViewModel.promises = {
buildAsync: promisify(UserMembershipViewModel.buildAsync),
buildAsync: UserMembershipViewModel.buildAsync,
}
module.exports = UserMembershipViewModel

View File

@@ -1159,7 +1159,7 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
CompileManager.compile(
projectId,
testUserId,
{},
{ metricsPath: 'health-check' },
function (error, status, _outputFiles, clsiServerId) {
if (handler) {
clearTimeout(handler)

View File

@@ -12,6 +12,7 @@ block append meta
- const canDisplayAdminRedirect = canRedirectToAdminDomain()
- const sessionUser = getSessionUser()
- const staffAccess = sessionUser?.staffAccess
- const canDisplayProjectUrlLookup = hasFeature('saas') && canDisplayAdminMenu && hasAdminCapability('view-project-setting', false)
- const canDisplaySplitTestMenu = hasFeature('saas') && ((canDisplayAdminMenu && hasAdminCapability('view-split-test')) || staffAccess?.splitTestMetrics || staffAccess?.splitTestManagement)
- const canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu && hasAdminCapability('manage-survey', false)
- const canDisplayScriptLogMenu = hasFeature('saas') && hasAdminCapability('view-script-log', false) && canDisplayAdminMenu
@@ -26,6 +27,7 @@ block append meta
title: nav.title,
canDisplayAdminMenu,
canDisplayAdminRedirect,
canDisplayProjectUrlLookup,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
canDisplayScriptLogMenu,

View File

@@ -30,6 +30,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(
- var canDisplayAdminMenu = hasAdminAccess()
- var canDisplayAdminRedirect = canRedirectToAdminDomain()
- var canDisplayProjectUrlLookup = hasFeature('saas') && canDisplayAdminMenu && hasAdminCapability('view-project-setting', false)
- var canDisplaySplitTestMenu = hasFeature('saas') && ((canDisplayAdminMenu && hasAdminCapability('view-split-test')) || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement)))
- var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu && hasAdminCapability('manage-survey', false)
- var canDisplayScriptLogMenu = hasFeature('saas') && hasAdminCapability('view-script-log', false) && canDisplayAdminMenu
@@ -47,7 +48,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(
#navbar-main-collapse.navbar-collapse.collapse
ul.nav.navbar-nav.navbar-right.ms-auto(role='menubar')
if canDisplayAdminMenu || canDisplayAdminRedirect || canDisplaySplitTestMenu
if canDisplayAdminMenu || canDisplayAdminRedirect || canDisplayProjectUrlLookup || canDisplaySplitTestMenu
+nav-item.dropdown.subdued
button.dropdown-toggle(
aria-haspopup='true'
@@ -64,6 +65,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(
if canDisplayAdminMenu
+dropdown-menu-link-item(href='/admin') Manage Site
+dropdown-menu-link-item(href='/admin/user') Manage Users
if canDisplayProjectUrlLookup
+dropdown-menu-link-item(href='/admin/project') Project URL Lookup
if canDisplayAdminRedirect
+dropdown-menu-link-item(href=settings.adminUrl) Switch to Admin

View File

@@ -1287,6 +1287,7 @@
"please_contact_support_to_makes_change_to_your_plan": "",
"please_enter_confirmation_code": "",
"please_get_in_touch": "",
"please_keep_an_eye_out_for_issues": "",
"please_link_before_making_primary": "",
"please_provide_a_message": "",
"please_provide_a_subject": "",
@@ -1869,6 +1870,7 @@
"this_project_exceeded_collaborator_limit": "",
"this_project_exceeded_compile_timeout_limit_on_free_plan": "",
"this_project_has_more_than_max_collabs": "",
"this_project_is_compiled_using_untested_version": "",
"this_project_is_public": "",
"this_project_is_public_read_only": "",
"this_project_will_appear_in_your_dropbox_folder_at": "",

View File

@@ -17,6 +17,7 @@ import getMeta from '@/utils/meta'
import PdfClearCacheButton from '@/features/pdf-preview/components/pdf-clear-cache-button'
import PdfDownloadFilesButton from '@/features/pdf-preview/components/pdf-download-files-button'
import { useIsNewErrorLogsPositionEnabled } from '../../utils/new-editor-utils'
import RollingBuildSelectedReminder from './rolling-build-selected-reminder'
const logsComponents: Array<{
import: { default: ElementType }
@@ -85,6 +86,7 @@ function ErrorLogs({
))}
<TabContent className="error-logs new-error-logs">
<div className="logs-pane-content">
<RollingBuildSelectedReminder />
{stoppedOnFirstError && includeErrors && <StopOnFirstErrorPrompt />}
{loadingError && (

View File

@@ -0,0 +1,33 @@
import OLNotification from '@/shared/components/ol/ol-notification'
import { useTranslation, Trans } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import { onRollingBuild } from '@/shared/utils/rolling-build'
const RollingBuildSelectedReminder = () => {
const { t } = useTranslation()
const { project } = useProjectContext()
if (!onRollingBuild(project?.imageName)) {
return null
}
const content = (
<Trans
i18nKey="please_keep_an_eye_out_for_issues"
components={[
<a href="https://forms.gle/yD8CVm4Kop9KwShx9" />, // eslint-disable-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="https://docs.overleaf.com/getting-started/recompiling-your-project/selecting-a-tex-live-version-and-latex-compiler" />, // eslint-disable-line react/jsx-key, jsx-a11y/anchor-has-content
]}
/>
)
return (
<OLNotification
title={t('this_project_is_compiled_using_untested_version')}
content={content}
type="info"
className="mb-0"
/>
)
}
export default RollingBuildSelectedReminder

View File

@@ -1,31 +1,28 @@
import { useTutorial } from '@/shared/hooks/promotions/use-tutorial'
import { useEditorContext } from '@/shared/context/editor-context'
import { useProjectSettingsContext } from '../editor-left-menu/context/project-settings-context'
import { useProjectContext } from '@/shared/context/project-context'
import OLNotification from '@/shared/components/ol/ol-notification'
import getMeta from '@/utils/meta'
import { useTranslation } from 'react-i18next'
import { useCallback } from 'react'
import { onRollingBuild } from '@/shared/utils/rolling-build'
export const TUTORIAL_KEY = 'rolling-compile-image-changed'
const rollingImages = getMeta('ol-imageNames')
.filter(img => img.rolling)
.map(img => img.imageName)
const RollingCompileImageChangedAlert = () => {
const { completeTutorial } = useTutorial(TUTORIAL_KEY)
const { project } = useProjectContext()
const { inactiveTutorials } = useEditorContext()
const { imageName } = useProjectSettingsContext()
const { t } = useTranslation()
const onClose = useCallback(() => {
completeTutorial({ event: 'promo-click', action: 'complete' })
}, [completeTutorial])
const onRollingBuild = imageName && rollingImages.includes(imageName)
if (inactiveTutorials.includes(TUTORIAL_KEY) || !onRollingBuild) {
if (
inactiveTutorials.includes(TUTORIAL_KEY) ||
!onRollingBuild(project?.imageName)
) {
return null
}

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import classnames from 'classnames'
import RollingBuildSelectedReminder from './rolling-build-selected-reminder'
import PdfValidationIssue from './pdf-validation-issue'
import StopOnFirstErrorPrompt from './stop-on-first-error-prompt'
import TimeoutUpgradePromptNew from './timeout-upgrade-prompt-new'
@@ -41,6 +42,8 @@ function PdfLogsViewer({ alwaysVisible = false }: { alwaysVisible?: boolean }) {
data-testid="logs-pane"
>
<div className="logs-pane-content">
<RollingBuildSelectedReminder />
{codeCheckFailed && <PdfCodeCheckFailedNotice />}
{stoppedOnFirstError && <StopOnFirstErrorPrompt />}

View File

@@ -0,0 +1,33 @@
import OLNotification from '@/shared/components/ol/ol-notification'
import { useTranslation, Trans } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import { onRollingBuild } from '@/shared/utils/rolling-build'
const RollingBuildSelectedReminder = () => {
const { t } = useTranslation()
const { project } = useProjectContext()
if (!onRollingBuild(project?.imageName)) {
return null
}
const content = (
<Trans
i18nKey="please_keep_an_eye_out_for_issues"
components={[
<a href="https://forms.gle/yD8CVm4Kop9KwShx9" />, // eslint-disable-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="https://docs.overleaf.com/getting-started/recompiling-your-project/selecting-a-tex-live-version-and-latex-compiler" />, // eslint-disable-line react/jsx-key, jsx-a11y/anchor-has-content
]}
/>
)
return (
<OLNotification
title={t('this_project_is_compiled_using_untested_version')}
content={content}
type="info"
/>
)
}
export default RollingBuildSelectedReminder

View File

@@ -2,6 +2,7 @@ import pLimit from 'p-limit'
import { Change, Chunk, Snapshot, File } from 'overleaf-editor-core'
import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
import path from 'path-browserify'
const DOWNLOAD_BLOBS_CONCURRENCY = 10
@@ -101,21 +102,21 @@ export class ProjectSnapshot {
const snapshotPaths = new Set(this.snapshot.getFilePathnames())
const baseURLs = [
const basePaths = [
// relative to the root of the compile directory
new URL('https://overleaf.invalid'),
'/',
]
if (currentPath !== '/') {
// relative to the current directory
baseURLs.push(new URL(currentPath, 'https://overleaf.invalid'))
basePaths.push(currentPath)
}
const extensionsToTest = ['', ...extensions]
for (const baseURL of baseURLs) {
for (const basePath of basePaths) {
for (const extension of extensionsToTest) {
const { pathname } = new URL(`${filePath}${extension}`, baseURL)
const pathname = path.resolve(basePath, `${filePath}${extension}`)
const snapshotPath = pathname.substring(1) // remove leading slash
if (snapshotPaths.has(snapshotPath)) {
return snapshotPath

View File

@@ -6,6 +6,7 @@ import { useSendProjectListMB } from '@/features/project-list/components/project
export default function AdminMenu({
canDisplayAdminMenu,
canDisplayAdminRedirect,
canDisplayProjectUrlLookup,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
canDisplayScriptLogMenu,
@@ -14,6 +15,7 @@ export default function AdminMenu({
DefaultNavbarMetadata,
| 'canDisplayAdminMenu'
| 'canDisplayAdminRedirect'
| 'canDisplayProjectUrlLookup'
| 'canDisplaySplitTestMenu'
| 'canDisplaySurveyMenu'
| 'canDisplayScriptLogMenu'
@@ -39,11 +41,13 @@ export default function AdminMenu({
<NavDropdownLinkItem href="/admin/user">
Manage Users
</NavDropdownLinkItem>
<NavDropdownLinkItem href="/admin/project">
Project URL lookup
</NavDropdownLinkItem>
</>
) : null}
{canDisplayProjectUrlLookup ? (
<NavDropdownLinkItem href="/admin/project">
Project URL lookup
</NavDropdownLinkItem>
) : null}
{canDisplayAdminRedirect && adminUrl ? (
<NavDropdownLinkItem href={adminUrl}>
Switch to Admin

View File

@@ -26,6 +26,7 @@ function DefaultNavbar(
title,
canDisplayAdminMenu,
canDisplayAdminRedirect,
canDisplayProjectUrlLookup,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
canDisplayScriptLogMenu,
@@ -112,6 +113,7 @@ function DefaultNavbar(
<AdminMenu
canDisplayAdminMenu={canDisplayAdminMenu}
canDisplayAdminRedirect={canDisplayAdminRedirect}
canDisplayProjectUrlLookup={canDisplayProjectUrlLookup}
canDisplaySplitTestMenu={canDisplaySplitTestMenu}
canDisplaySurveyMenu={canDisplaySurveyMenu}
canDisplayScriptLogMenu={canDisplayScriptLogMenu}

View File

@@ -8,6 +8,7 @@ export type DefaultNavbarMetadata = {
title?: string
canDisplayAdminMenu: boolean
canDisplayAdminRedirect: boolean
canDisplayProjectUrlLookup: boolean
canDisplaySplitTestMenu: boolean
canDisplaySurveyMenu: boolean
canDisplayScriptLogMenu: boolean

View File

@@ -0,0 +1,10 @@
import getMeta from '@/utils/meta'
const images = getMeta('ol-imageNames') || []
const rollingImages = images
.filter(img => img.rolling)
.map(img => img.imageName)
export function onRollingBuild(imageName: string | undefined) {
return Boolean(imageName && rollingImages.includes(imageName))
}

View File

@@ -1685,6 +1685,7 @@
"please_enter_confirmation_code": "Please enter your confirmation code",
"please_enter_email": "Please enter your email address",
"please_get_in_touch": "Please get in touch",
"please_keep_an_eye_out_for_issues": "Please keep an eye out for any issues with TeX Live, packages, or compilation, and <0>provide feedback here</0>. If you encounter problems, you can <1>revert your project</1> to a stable TeX Live version.",
"please_link_before_making_primary": "Please confirm your email by linking to your institutional account before making it the primary email.",
"please_provide_a_message": "Please provide a message",
"please_provide_a_subject": "Please provide a subject",
@@ -2389,6 +2390,7 @@
"this_project_exceeded_collaborator_limit": "This project exceeded the collaborator limit for your plan. All other users now have view-only access.",
"this_project_exceeded_compile_timeout_limit_on_free_plan": "This project exceeded the compile timeout limit on our free plan.",
"this_project_has_more_than_max_collabs": "This project has more than the maximum number of collaborators allowed on the project owners Overleaf plan. This means you could lose edit access from __linkSharingDate__.",
"this_project_is_compiled_using_untested_version": "This project is compiled using an untested version of TeX Live",
"this_project_is_public": "This project is public and can be edited by anyone with the URL.",
"this_project_is_public_read_only": "This project is public and can be viewed but not edited by anyone with the URL",
"this_project_will_appear_in_your_dropbox_folder_at": "This project will appear in your Dropbox folder at ",

View File

@@ -351,6 +351,7 @@
"mock-fs": "^5.1.2",
"nock": "^13.5.6",
"nvd3": "^1.8.6",
"path-browserify": "^1.0.1",
"p-reflect": "^3.1.0",
"pdfjs-dist": "5.1.91",
"pirates": "^4.0.1",

View File

@@ -880,6 +880,57 @@ describe('EmailBuilder', function () {
})
})
})
describe('taxExemptCertificateRequired', function () {
beforeEach(function () {
this.emailAddress = 'customer@example.com'
this.opts = {
to: this.emailAddress,
ein: '12-3456789',
stripeCustomerId: 'cus_123456789',
}
this.email = this.EmailBuilder.buildEmail(
'taxExemptCertificateRequired',
this.opts
)
this.dom = cheerio.load(this.email.html)
})
it('should build the email', function () {
expect(this.email.html).to.exist
expect(this.email.text).to.exist
})
describe('HTML email', function () {
it('should include the EIN', function () {
expect(this.email.html).to.contain(this.opts.ein)
})
it('should include the Stripe customer ID', function () {
expect(this.email.html).to.contain(this.opts.stripeCustomerId)
})
it('should include tax exemption verification text', function () {
expect(this.email.html).to.contain('tax exempt')
expect(this.email.html).to.contain('verification')
})
})
describe('plain text email', function () {
it('should include the EIN', function () {
expect(this.email.text).to.contain(this.opts.ein)
})
it('should include the Stripe customer ID', function () {
expect(this.email.text).to.contain(this.opts.stripeCustomerId)
})
it('should include tax exemption verification text', function () {
expect(this.email.text).to.contain('tax exempt')
expect(this.email.text).to.contain('verification')
})
})
})
})
})
})

View File

@@ -1,15 +1,3 @@
/* eslint-disable
n/handle-callback-err,
max-len,
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const { expect } = require('chai')
const sinon = require('sinon')
const assertCalledWith = sinon.assert.calledWith
@@ -25,7 +13,7 @@ const {
describe('UserMembershipViewModel', function () {
beforeEach(function () {
this.UserGetter = { getUsers: sinon.stub() }
this.UserGetter = { promises: { getUsers: sinon.stub() } }
this.UserMembershipViewModel = SandboxedModule.require(modulePath, {
requires: {
'mongodb-legacy': { ObjectId },
@@ -54,7 +42,7 @@ describe('UserMembershipViewModel', function () {
describe('build', function () {
it('build email', function () {
const viewModel = this.UserMembershipViewModel.build(this.email)
return expect(viewModel).to.deep.equal({
expect(viewModel).to.deep.equal({
email: this.email,
invite: true,
last_active_at: null,
@@ -83,67 +71,49 @@ describe('UserMembershipViewModel', function () {
describe('build async', function () {
beforeEach(function () {
return (this.UserMembershipViewModel.build = sinon.stub())
this.UserMembershipViewModel.build = sinon.stub()
})
it('build email', function (done) {
this.UserGetter.getUsers.yields(null, [])
return this.UserMembershipViewModel.buildAsync(
[this.email],
(error, [viewModel]) => {
assertCalledWith(this.UserMembershipViewModel.build, this.email)
return done()
}
)
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', function (done) {
this.UserGetter.getUsers.yields(null, [])
return this.UserMembershipViewModel.buildAsync(
[this.user],
(error, [viewModel]) => {
assertCalledWith(this.UserMembershipViewModel.build, this.user)
return done()
}
)
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', function (done) {
it('build user id', async function () {
const user = {
...this.user,
_id: new ObjectId(),
}
this.UserGetter.getUsers.yields(null, [user])
return this.UserMembershipViewModel.buildAsync(
[user._id],
(error, [viewModel]) => {
expect(error).not.to.exist
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)
return done()
}
)
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', function (done) {
this.UserGetter.getUsers.yields(new Error('nope'), [])
it('build user id with error', async function () {
this.UserGetter.promises.getUsers.rejects(new Error('nope'))
const userId = new ObjectId()
return this.UserMembershipViewModel.buildAsync(
[userId],
(error, [viewModel]) => {
expect(error).not.to.exist
assertNotCalled(this.UserMembershipViewModel.build)
expect(viewModel._id).to.equal(userId.toString())
expect(viewModel.email).not.to.exist
return done()
}
)
const [viewModel] = await this.UserMembershipViewModel.buildAsync([
userId,
])
assertNotCalled(this.UserMembershipViewModel.build)
expect(viewModel._id).to.equal(userId.toString())
expect(viewModel.email).not.to.exist
})
})
})