first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,224 @@
import { expect } from 'chai'
import { fireEvent, screen, render } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import AccountInfoSection from '../../../../../frontend/js/features/settings/components/account-info-section'
import { UserProvider } from '../../../../../frontend/js/shared/context/user-context'
import getMeta from '@/utils/meta'
function renderSectionWithUserProvider() {
render(<AccountInfoSection />, {
wrapper: ({ children }) => <UserProvider>{children}</UserProvider>,
})
}
describe('<AccountInfoSection />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-user', {
email: 'sherlock@holmes.co.uk',
first_name: 'Sherlock',
last_name: 'Holmes',
})
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: false,
})
window.metaAttributesCache.set(
'ol-isExternalAuthenticationSystemUsed',
false
)
window.metaAttributesCache.set('ol-shouldAllowEditingDetails', true)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('submits all inputs', async function () {
const updateMock = fetchMock.post('/user/settings', 200)
renderSectionWithUserProvider()
fireEvent.change(screen.getByLabelText('Email'), {
target: { value: 'john@watson.co.uk' },
})
fireEvent.change(screen.getByLabelText('First name'), {
target: { value: 'John' },
})
fireEvent.change(screen.getByLabelText('Last name'), {
target: { value: 'Watson' },
})
fireEvent.click(
screen.getByRole('button', {
name: 'Update',
})
)
expect(updateMock.callHistory.called()).to.be.true
expect(
JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
).to.deep.equal({
email: 'john@watson.co.uk',
first_name: 'John',
last_name: 'Watson',
})
})
it('disables button on invalid email', async function () {
const updateMock = fetchMock.post('/user/settings', 200)
renderSectionWithUserProvider()
fireEvent.change(screen.getByLabelText('Email'), {
target: { value: 'john' },
})
const button = screen.getByRole('button', {
name: 'Update',
}) as HTMLButtonElement
expect(button.disabled).to.be.true
fireEvent.click(button)
expect(updateMock.callHistory.called()).to.be.false
})
it('shows inflight state and success message', async function () {
let finishUpdateCall: (value: any) => void = () => {}
fetchMock.post(
'/user/settings',
new Promise(resolve => (finishUpdateCall = resolve))
)
renderSectionWithUserProvider()
fireEvent.click(
screen.getByRole('button', {
name: 'Update',
})
)
await screen.findByRole('button', { name: /saving/i })
finishUpdateCall(200)
await screen.findByRole('button', {
name: 'Update',
})
screen.getByText('Thanks, your settings have been updated.')
})
it('shows server error', async function () {
fetchMock.post('/user/settings', 500)
renderSectionWithUserProvider()
fireEvent.click(
screen.getByRole('button', {
name: 'Update',
})
)
await screen.findByText('Something went wrong. Please try again.')
})
it('shows invalid error', async function () {
fetchMock.post('/user/settings', 400)
renderSectionWithUserProvider()
fireEvent.click(
screen.getByRole('button', {
name: 'Update',
})
)
await screen.findByText(
'Invalid Request. Please correct the data and try again.'
)
})
it('shows conflict error', async function () {
fetchMock.post('/user/settings', {
status: 409,
body: {
message: 'This email is already registered',
},
})
renderSectionWithUserProvider()
fireEvent.click(
screen.getByRole('button', {
name: 'Update',
})
)
await screen.findByText('This email is already registered')
})
it('hides email input', async function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
})
const updateMock = fetchMock.post('/user/settings', 200)
renderSectionWithUserProvider()
expect(screen.queryByLabelText('Email')).to.not.exist
fireEvent.click(
screen.getByRole('button', {
name: 'Update',
})
)
expect(
JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
).to.deep.equal({
first_name: 'Sherlock',
last_name: 'Holmes',
})
})
it('disables email input', async function () {
window.metaAttributesCache.set(
'ol-isExternalAuthenticationSystemUsed',
true
)
const updateMock = fetchMock.post('/user/settings', 200)
renderSectionWithUserProvider()
expect(screen.getByLabelText('Email')).to.have.property('readOnly', true)
expect(screen.getByLabelText('First name')).to.have.property(
'readOnly',
false
)
expect(screen.getByLabelText('Last name')).to.have.property(
'readOnly',
false
)
fireEvent.click(
screen.getByRole('button', {
name: 'Update',
})
)
expect(
JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
).to.deep.equal({
first_name: 'Sherlock',
last_name: 'Holmes',
})
})
it('disables names input', async function () {
window.metaAttributesCache.set('ol-shouldAllowEditingDetails', false)
const updateMock = fetchMock.post('/user/settings', 200)
renderSectionWithUserProvider()
expect(screen.getByLabelText('Email')).to.have.property('readOnly', false)
expect(screen.getByLabelText('First name')).to.have.property(
'readOnly',
true
)
expect(screen.getByLabelText('Last name')).to.have.property(
'readOnly',
true
)
fireEvent.click(
screen.getByRole('button', {
name: 'Update',
})
)
expect(
JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
).to.deep.equal({
email: 'sherlock@holmes.co.uk',
})
})
})

View File

@@ -0,0 +1,42 @@
import { expect } from 'chai'
import { screen, render } from '@testing-library/react'
import BetaProgramSection from '../../../../../frontend/js/features/settings/components/beta-program-section'
import { UserProvider } from '../../../../../frontend/js/shared/context/user-context'
function renderSectionWithUserProvider() {
render(<BetaProgramSection />, {
wrapper: ({ children }) => <UserProvider>{children}</UserProvider>,
})
}
describe('<BetaProgramSection />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-user', {
betaProgram: true,
})
})
it('shows link to sessions', async function () {
renderSectionWithUserProvider()
const link = screen.getByRole('link', {
name: 'Manage Beta Program Membership',
})
expect(link.getAttribute('href')).to.equal('/beta/participate')
})
it('shows enrolled status', async function () {
renderSectionWithUserProvider()
screen.getByText('You are enrolled in the Beta Program')
})
it('shows not enrolled status', async function () {
window.metaAttributesCache.set('ol-user', {
betaProgram: false,
})
renderSectionWithUserProvider()
screen.getByText('You are not enrolled in the Beta Program')
screen.getByText(/By joining this program you can have/)
})
})

View File

@@ -0,0 +1,401 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
import Input, {
clearDomainCache,
} from '../../../../../../frontend/js/features/settings/components/emails/add-email/input'
import domainBlocklist from '../../../../../../frontend/js/features/settings/domain-blocklist'
const testInstitutionData = [
{ university: { id: 124 }, hostname: 'domain.edu' },
]
describe('<AddEmailInput/>', function () {
const defaultProps = {
onChange: (value: string) => {},
handleAddNewEmail: () => {},
}
beforeEach(function () {
clearDomainCache()
fetchMock.removeRoutes().clearHistory()
})
describe('on initial render', function () {
it('should render an input with a placeholder', function () {
render(<Input {...defaultProps} />)
screen.getByPlaceholderText('e.g. johndoe@mit.edu')
})
it('should not dispatch any `change` event', function () {
const onChangeStub = sinon.stub()
render(<Input {...defaultProps} onChange={onChangeStub} />)
expect(onChangeStub.called).to.equal(false)
})
})
describe('when typing text that does not contain any potential domain match', function () {
let onChangeStub: sinon.SinonStub
let handleAddNewEmailStub: sinon.SinonStub
beforeEach(function () {
fetchMock.get('express:/institutions/domains', 200)
onChangeStub = sinon.stub()
handleAddNewEmailStub = sinon.stub()
render(
<Input
{...defaultProps}
onChange={onChangeStub}
handleAddNewEmail={handleAddNewEmailStub}
/>
)
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user' },
})
})
it('should render the text being typed', function () {
const input = screen.getByTestId('affiliations-email') as HTMLInputElement
expect(input.value).to.equal('user')
})
it('should dispatch a `change` event on every stroke', function () {
expect(onChangeStub.calledWith('user')).to.equal(true)
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 's' },
})
expect(onChangeStub.calledWith('s')).to.equal(true)
})
it('should not make any request for institution domains', function () {
expect(fetchMock.callHistory.called()).to.be.false
})
it('should submit on Enter if email looks valid', async function () {
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@domain.com' },
})
fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
key: 'Enter',
})
expect(handleAddNewEmailStub.calledWith()).to.equal(true)
})
it('should not submit on Enter if email does not look valid', async function () {
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@' },
})
fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
key: 'Enter',
})
expect(handleAddNewEmailStub.calledWith()).to.equal(false)
})
})
describe('when typing text that contains a potential domain match', function () {
let onChangeStub: sinon.SinonStub
beforeEach(function () {
onChangeStub = sinon.stub()
render(<Input {...defaultProps} onChange={onChangeStub} />)
})
describe('when there are no matches', function () {
beforeEach(function () {
fetchMock.get('express:/institutions/domains', 200)
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@d' },
})
})
it('should render the text being typed', function () {
const input = screen.getByTestId(
'affiliations-email'
) as HTMLInputElement
expect(input.value).to.equal('user@d')
})
})
describe('when there is a domain match', function () {
beforeEach(async function () {
fetchMock.get('express:/institutions/domains', testInstitutionData)
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@d' },
})
// Wait for component to process the change and update the shadow input
await waitFor(() => {
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('user@domain.edu')
})
})
it('should render the text being typed along with the suggestion', async function () {
const input = screen.getByTestId(
'affiliations-email'
) as HTMLInputElement
expect(input.value).to.equal('user@d')
await waitFor(() => {
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('user@domain.edu')
})
})
it('should dispatch a `change` event with the typed text', function () {
expect(onChangeStub.calledWith('user@d')).to.equal(true)
})
it('should dispatch a `change` event with institution data when the typed email contains the institution domain', async function () {
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@domain.edu' },
})
await fetchMock.callHistory.flush(true)
expect(
onChangeStub.calledWith(
'user@domain.edu',
sinon.match(testInstitutionData[0])
)
).to.equal(true)
})
it('should clear the suggestion when the potential domain match is completely deleted', async function () {
await waitFor(() => {
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('user@domain.edu')
})
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: '' },
})
expect(screen.queryByText('user@domain.edu')).to.be.null
})
describe('when there is a suggestion and "Tab" key is pressed', function () {
beforeEach(async function () {
// wait until autocompletion available
await waitFor(() => {
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('user@domain.edu')
})
fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
key: 'Tab',
})
})
it('it should autocomplete the input', async function () {
const input = screen.getByTestId(
'affiliations-email'
) as HTMLInputElement
expect(input.value).to.equal('user@domain.edu')
})
it('should dispatch a `change` event with the domain matched', async function () {
expect(
onChangeStub.calledWith(
'user@domain.edu',
sinon.match(testInstitutionData[0])
)
).to.equal(true)
})
})
describe('when there is a suggestion and "Enter" key is pressed', function () {
beforeEach(async function () {
// wait until autocompletion available
await waitFor(() => {
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('user@domain.edu')
})
fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
key: 'Enter',
})
})
it('it should autocomplete the input', async function () {
const input = screen.getByTestId(
'affiliations-email'
) as HTMLInputElement
expect(input.value).to.equal('user@domain.edu')
})
it('should dispatch a `change` event with the domain matched', async function () {
expect(
onChangeStub.calledWith(
'user@domain.edu',
sinon.match(testInstitutionData[0])
)
).to.equal(true)
})
})
it('should cache the result and skip subsequent requests', async function () {
fetchMock.removeRoutes().clearHistory()
// clear input
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: '' },
})
// type a hint to trigger the domain search
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@d' },
})
expect(fetchMock.callHistory.called()).to.be.false
expect(onChangeStub.calledWith('user@d')).to.equal(true)
await waitFor(() => {
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('user@domain.edu')
})
})
})
describe('when there is a match for a blocklisted domain', function () {
const [blockedDomain] = domainBlocklist
afterEach(function () {
clearDomainCache()
fetchMock.removeRoutes().clearHistory()
})
it('should not render the suggestion with blocked domain', async function () {
const blockedInstitution = [
{ university: { id: 1 }, hostname: blockedDomain },
]
fetchMock.get('express:/institutions/domains', blockedInstitution)
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: `user@${blockedDomain.split('.')[0]}` },
})
await fetchMock.callHistory.flush(true)
expect(screen.queryByText(`user@${blockedDomain}`)).to.be.null
})
it('should not render the suggestion with blocked domain having a subdomain', async function () {
const blockedInstitution = [
{
university: { id: 1 },
hostname: `subdomain.${blockedDomain}`,
},
]
fetchMock.get('express:/institutions/domains', blockedInstitution)
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: {
value: `user@subdomain.${blockedDomain.split('.')[0]}`,
},
})
await fetchMock.callHistory.flush(true)
expect(screen.queryByText(`user@subdomain.${blockedDomain}`)).to.be.null
})
})
describe('while waiting for a response', function () {
beforeEach(async function () {
// type an initial suggestion
fetchMock.get('express:/institutions/domains', testInstitutionData)
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@d' },
})
await waitFor(() => {
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('user@domain.edu')
})
// make sure the next suggestions are delayed
clearDomainCache()
fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/institutions/domains', 200, { delay: 1000 })
})
it('should keep the suggestion if the hint matches the previously matched domain', async function () {
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@do' },
})
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('user@domain.edu')
})
it('should remove the suggestion if the hint does not match the previously matched domain', async function () {
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@foo' },
})
await waitFor(() => {
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('')
})
})
})
})
describe('when the request to fetch institution domains fail', function () {
let onChangeStub
beforeEach(async function () {
// initial request populates the suggestion
fetchMock.get('express:/institutions/domains', testInstitutionData)
onChangeStub = sinon.stub()
render(<Input {...defaultProps} onChange={onChangeStub} />)
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@d' },
})
await waitFor(() => {
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('user@domain.edu')
})
// subsequent requests fail
fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/institutions/domains', 500)
})
it('should clear suggestions', async function () {
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@dom' },
})
const input = screen.getByTestId('affiliations-email') as HTMLInputElement
expect(input.value).to.equal('user@dom')
await waitFor(() => {
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('')
})
expect(fetchMock.callHistory.called()).to.be.true // ensures `domainCache` hasn't been hit
})
})
describe('when the request to fetch institution is not matching input', function () {
it('should clear suggestion', async function () {
fetchMock.get('express:/institutions/domains', testInstitutionData)
render(<Input {...defaultProps} onChange={sinon.stub()} />)
fireEvent.change(screen.getByTestId('affiliations-email'), {
target: { value: 'user@other' },
})
await fetchMock.callHistory.flush(true)
const shadowInput = screen.getByTestId(
'affiliations-email-shadow'
) as HTMLInputElement
expect(shadowInput.value).to.equal('')
})
})
})

View File

@@ -0,0 +1,121 @@
import { render, screen } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import { cloneDeep } from 'lodash'
import EmailsRow from '../../../../../../frontend/js/features/settings/components/emails/row'
import {
professionalUserData,
unconfirmedUserData,
} from '../../fixtures/test-user-email-data'
import { UserEmailData } from '../../../../../../types/user-email'
import { UserEmailsProvider } from '../../../../../../frontend/js/features/settings/context/user-email-context'
import { Affiliation } from '../../../../../../types/affiliation'
import getMeta from '@/utils/meta'
function renderEmailsRow(data: UserEmailData) {
return render(
<UserEmailsProvider>
<EmailsRow userEmailData={data} />
</UserEmailsProvider>
)
}
function getByTextContent(text: string) {
return screen.getAllByText(
(content, node) =>
content === text || node?.children[0]?.textContent === text
)
}
describe('<EmailsRow/>', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
samlInitPath: '/saml',
hasSamlBeta: true,
})
fetchMock.get('/user/emails?ensureAffiliation=true', [])
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
describe('with unaffiliated email data', function () {
it('renders email info', function () {
renderEmailsRow(unconfirmedUserData)
screen.getByText('baz@overleaf.com')
})
it('renders actions', function () {
renderEmailsRow(unconfirmedUserData)
screen.getByRole('button', { name: 'Make Primary' })
})
})
describe('with affiliated email data', function () {
it('renders email info', function () {
renderEmailsRow(professionalUserData)
screen.getByText('foo@overleaf.com')
screen.getByText('Primary')
})
it('renders actions', function () {
renderEmailsRow(professionalUserData)
screen.getByRole('button', { name: 'Remove' })
})
it('renders institution info', function () {
renderEmailsRow(professionalUserData)
screen.getByText('Overleaf')
screen.getByText('Reader, Art History')
})
})
describe('with email data affiliated to an institution with SSO available', function () {
let affiliatedEmail: UserEmailData & { affiliation: Affiliation }
beforeEach(function () {
window.metaAttributesCache.get('ol-ExposedSettings').hasSamlFeature = true
// make sure the institution has SSO available
affiliatedEmail = cloneDeep(professionalUserData)
affiliatedEmail.affiliation.institution.confirmed = true
affiliatedEmail.affiliation.institution.isUniversity = true
affiliatedEmail.affiliation.institution.ssoEnabled = true
})
describe('when the email is not yet linked to the institution', function () {
beforeEach(async function () {
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [affiliatedEmail, unconfirmedUserData])
await fetchMock.callHistory.flush(true)
})
it('prompts the user to link to their institutional account', function () {
renderEmailsRow(affiliatedEmail)
getByTextContent(
'You can now link your Overleaf account to your Overleaf institutional account.'
)
screen.getByRole('button', { name: 'Link Accounts' })
})
})
describe('when the email is already linked to the institution', function () {
beforeEach(async function () {
affiliatedEmail.samlProviderId = '1'
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [affiliatedEmail, unconfirmedUserData])
await fetchMock.callHistory.flush(true)
})
it('prompts the user to login using their institutional account', function () {
renderEmailsRow(affiliatedEmail)
getByTextContent(
'You can log in to Overleaf through your Overleaf institutional login.'
)
expect(screen.queryByRole('button', { name: 'Link Accounts' })).to.be
.null
})
})
})
})

View File

@@ -0,0 +1,272 @@
import {
render,
screen,
waitForElementToBeRemoved,
within,
fireEvent,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect } from 'chai'
import { UserEmailData } from '../../../../../../types/user-email'
import fetchMock from 'fetch-mock'
import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section'
import { Institution } from '../../../../../../types/institution'
import { Affiliation } from '../../../../../../types/affiliation'
import getMeta from '@/utils/meta'
const userEmailData: UserEmailData = {
confirmedAt: '2022-03-10T10:59:44.139Z',
email: 'bar@overleaf.com',
default: false,
}
const userEmailData2: UserEmailData & { affiliation: Affiliation } = {
affiliation: {
inReconfirmNotificationPeriod: false,
institution: {
confirmed: false,
} as Institution,
} as Affiliation,
confirmedAt: '2022-03-10T10:59:44.139Z',
email: 'bar@overleaf.com',
default: false,
}
describe('email actions - make primary', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
})
fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
describe('disabled `make primary` button', function () {
it('when renders with unconfirmed email', async function () {
const userEmailDataCopy = { ...userEmailData2 }
const { confirmedAt: _, ...userEmailData } = userEmailDataCopy
fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailData])
render(<EmailsSection />)
const button = (await screen.findByRole('button', {
name: /make primary/i,
})) as HTMLButtonElement
expect(button.disabled).to.be.true
})
it('when renders with email in reconfirmation period', async function () {
const userEmailDataCopy = {
...userEmailData2,
affiliation: {
...userEmailData2.affiliation,
inReconfirmNotificationPeriod: true,
},
}
fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
render(<EmailsSection />)
const button = (await screen.findByRole('button', {
name: /make primary/i,
})) as HTMLButtonElement
expect(button.disabled).to.be.true
})
})
describe('button tooltips', function () {
it('when the email is in reconfirmation period', async function () {
const userEmailDataCopy = {
...userEmailData2,
affiliation: {
...userEmailData2.affiliation,
inReconfirmNotificationPeriod: true,
},
}
fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
render(<EmailsSection />)
const button = (await screen.findByRole('button', {
name: /make primary/i,
})) as HTMLButtonElement
await userEvent.hover(button.parentElement!)
await screen.findByText(
/Please confirm your affiliation before making this the primary/i
)
})
it('when the email is confirmed', async function () {
const userEmailDataCopy = { ...userEmailData2 }
fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
render(<EmailsSection />)
const button = (await screen.findByRole('button', {
name: /make primary/i,
})) as HTMLButtonElement
await userEvent.hover(button.parentElement!)
await screen.findByText('Make this the primary email, used to log in', {
exact: false,
})
})
it('when not linked to institution', async function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
hasSamlFeature: true,
})
const userEmailDataCopy = { ...userEmailData2 }
const { confirmedAt: _, ...userEmailData } = userEmailDataCopy
const userEmailDataCopy1 = { ...userEmailData }
const userEmailDataCopy2 = {
...userEmailData,
email: 'baz@overleaf.com',
affiliation: {
...userEmailData.affiliation,
institution: {
...userEmailData.affiliation.institution,
id: 123,
confirmed: true,
isUniversity: true,
ssoEnabled: true,
},
},
}
fetchMock.get('/user/emails?ensureAffiliation=true', [
userEmailDataCopy1,
userEmailDataCopy2,
])
render(<EmailsSection />)
const buttons = (await screen.findAllByRole('button', {
name: /make primary/i,
})) as HTMLButtonElement[]
await userEvent.hover(buttons[1].parentElement!)
await screen.findByText(
'Please confirm your email by linking to your institutional account before making it the primary email',
{
exact: false,
}
)
})
})
describe('make an email primary', function () {
const confirmPrimaryEmail = async () => {
const button = await screen.findByRole('button', {
name: /make primary/i,
})
fireEvent.click(button)
const withinModal = within(screen.getByRole('dialog'))
fireEvent.click(
withinModal.getByRole('button', { name: 'Change primary email' })
)
await waitForElementToBeRemoved(() => screen.getByRole('dialog'))
}
it('shows confirmation modal and closes it', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailData])
render(<EmailsSection />)
const button = await screen.findByRole('button', {
name: /make primary/i,
})
fireEvent.click(button)
const withinModal = within(screen.getByRole('dialog'))
withinModal.getByText(
/do you want to change your primary email address to .*/i
)
withinModal.getByText(
/this will be the email address to use if you log in with an email address and password/i
)
withinModal.getByText(
/important .* notifications will be sent to this email address/i
)
withinModal.getByRole('button', { name: 'Change primary email' })
fireEvent.click(withinModal.getByRole('button', { name: /cancel/i }))
await waitForElementToBeRemoved(screen.getByRole('dialog'))
})
it('shows loader and removes button', async function () {
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailData])
.post('/user/emails/default?delete-unconfirmed-primary', 200)
render(<EmailsSection />)
await confirmPrimaryEmail()
expect(
screen.queryByText(
/an error has occurred while performing your request/i
)
).to.be.null
expect(screen.queryByRole('button', { name: /make primary/i })).to.be.null
})
it('shows error', async function () {
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailData])
.post('/user/emails/default?delete-unconfirmed-primary', 503)
render(<EmailsSection />)
await confirmPrimaryEmail()
screen.getByText(/sorry, something went wrong/i)
await screen.findByRole('button', { name: /make primary/i })
})
})
})
describe('email actions - delete', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
})
fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows loader when deleting and removes the row', async function () {
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailData])
.post('/user/emails/delete', 200)
render(<EmailsSection />)
const button = await screen.findByRole('button', { name: /remove/i })
fireEvent.click(button)
await waitForElementToBeRemoved(() =>
screen.queryByText(userEmailData.email)
)
})
it('shows error when deleting', async function () {
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailData])
.post('/user/emails/delete', 503)
render(<EmailsSection />)
const button = await screen.findByRole('button', { name: /remove/i })
fireEvent.click(button)
await screen.queryByText(/sorry, something went wrong/i)
screen.getByRole('button', { name: /remove/i })
})
})

View File

@@ -0,0 +1,697 @@
import {
render,
screen,
fireEvent,
waitForElementToBeRemoved,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import { UserEmailData } from '../../../../../../types/user-email'
import { Affiliation } from '../../../../../../types/affiliation'
import withMarkup from '../../../../helpers/with-markup'
import getMeta from '@/utils/meta'
const userEmailData: UserEmailData & { affiliation: Affiliation } = {
affiliation: {
cachedConfirmedAt: null,
cachedEntitlement: null,
cachedLastDayToReconfirm: null,
cachedPastReconfirmDate: false,
cachedReconfirmedAt: null,
department: 'Art History',
institution: {
commonsAccount: false,
writefullCommonsAccount: false,
confirmed: true,
id: 1,
isUniversity: true,
maxConfirmationMonths: null,
name: 'Overleaf',
ssoEnabled: false,
ssoBeta: false,
},
inReconfirmNotificationPeriod: false,
inferred: false,
licence: 'pro_plus',
pastReconfirmDate: false,
portal: { slug: '', templates_count: 1 },
role: 'Reader',
},
email: 'baz@overleaf.com',
default: false,
}
const institutionDomainData = [
{
university: {
id: 1234,
ssoEnabled: true,
name: 'Auto Complete University',
},
hostname: 'autocomplete.edu',
confirmed: true,
},
] as const
function resetFetchMock() {
fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/institutions/domains', [])
}
async function confirmCodeForEmail(email: string) {
screen.getByText(`Enter the 6-digit confirmation code sent to ${email}.`)
const inputCode = screen.getByLabelText(/6-digit confirmation code/i)
fireEvent.change(inputCode, { target: { value: '123456' } })
const submitCodeBtn = screen.getByRole<HTMLButtonElement>('button', {
name: 'Confirm',
})
fireEvent.click(submitCodeBtn)
await waitForElementToBeRemoved(() =>
screen.getByRole('button', { name: /confirming/i })
)
}
describe('<EmailsSection />', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
hasSamlFeature: true,
samlInitPath: 'saml/init',
})
fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
resetFetchMock()
})
it('renders "add another email" button', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
await screen.findByRole('button', { name: /add another email/i })
})
it('renders input', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
await fetchMock.callHistory.flush(true)
const button = await screen.findByRole<HTMLButtonElement>('button', {
name: /add another email/i,
})
fireEvent.click(button)
await screen.findByLabelText(/email/i)
})
it('renders "Start adding your address" until a valid email is typed', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
fetchMock.get(`/institutions/domains?hostname=email.com&limit=1`, 200)
fetchMock.get(`/institutions/domains?hostname=email&limit=1`, 200)
render(<EmailsSection />)
await fetchMock.callHistory.flush(true)
const button = await screen.findByRole<HTMLButtonElement>('button', {
name: /add another email/i,
})
fireEvent.click(button)
const input = screen.getByLabelText(/email/i)
// initially the text is displayed and the "add email" button disabled
screen.getByText('Start by adding your email address.')
expect(
screen.getByRole<HTMLButtonElement>('button', {
name: /add new email/i,
}).disabled
).to.be.true
// no changes while writing the email address
fireEvent.change(input, {
target: { value: 'partial@email' },
})
screen.getByText('Start by adding your email address.')
expect(
screen.getByRole<HTMLButtonElement>('button', {
name: /add new email/i,
}).disabled
).to.be.true
// the text is removed when the complete email address is typed, and the "add button" is reenabled
fireEvent.change(input, {
target: { value: 'valid@email.com' },
})
expect(screen.queryByText('Start by adding your email address.')).to.be.null
expect(
screen.getByRole<HTMLButtonElement>('button', {
name: /add new email/i,
}).disabled
).to.be.false
})
it('renders "add new email" button', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
const button = await screen.findByRole<HTMLButtonElement>('button', {
name: /add another email/i,
})
fireEvent.click(button)
screen.getByRole('button', { name: /add new email/i })
})
it('prevent users from adding new emails when the limit is reached', async function () {
const emails = []
for (let i = 0; i < 10; i++) {
emails.push({ email: `bar${i}@overleaf.com` })
}
fetchMock.get('/user/emails?ensureAffiliation=true', emails)
render(<EmailsSection />)
const findByTextWithMarkup = withMarkup(screen.findByText)
await findByTextWithMarkup(
'You can have a maximum of 10 email addresses on this account. To add another email address, please delete an existing one.'
)
expect(screen.queryByRole('button', { name: /add another email/i })).to.not
.exist
})
it('adds new email address', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
const addAnotherEmailBtn = await screen.findByRole<HTMLButtonElement>(
'button',
{ name: /add another email/i }
)
await fetchMock.callHistory.flush(true)
resetFetchMock()
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailData])
.post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
fireEvent.click(addAnotherEmailBtn)
const input = screen.getByLabelText(/email/i)
fireEvent.change(input, {
target: { value: userEmailData.email },
})
const submitBtn = screen.getByRole<HTMLButtonElement>('button', {
name: /add new email/i,
})
expect(submitBtn.disabled).to.be.false
fireEvent.click(submitBtn)
expect(submitBtn.disabled).to.be.true
await waitForElementToBeRemoved(() =>
screen.getByRole('button', {
name: /Loading/i,
})
)
await confirmCodeForEmail(userEmailData.email)
await screen.findByText(userEmailData.email)
})
it('fails to add add new email address', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
const addAnotherEmailBtn = await screen.findByRole<HTMLButtonElement>(
'button',
{ name: /add another email/i }
)
await fetchMock.callHistory.flush(true)
resetFetchMock()
fetchMock
.get('/user/emails?ensureAffiliation=true', [])
.post('/user/emails/secondary', 400)
fireEvent.click(addAnotherEmailBtn)
const input = screen.getByLabelText(/email/i)
fireEvent.change(input, {
target: { value: userEmailData.email },
})
const submitBtn = screen.getByRole<HTMLButtonElement>('button', {
name: /add new email/i,
})
expect(submitBtn.disabled).to.be.false
fireEvent.click(submitBtn)
expect(submitBtn.disabled).to.be.true
await screen.findByText(
/Invalid Request. Please correct the data and try again./i
)
expect(submitBtn).to.not.be.null
expect(submitBtn.disabled).to.be.false
})
it('can link email address to an existing SSO institution', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
const button = await screen.findByRole<HTMLButtonElement>('button', {
name: /add another email/i,
})
await fetchMock.callHistory.flush(true)
fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/institutions/domains', institutionDomainData)
await userEvent.click(button)
const input = screen.getByLabelText(/email/i)
fireEvent.change(input, {
target: { value: 'user@autocomplete.edu' },
})
await screen.findByRole('button', { name: 'Link Accounts and Add Email' })
})
it('adds new email address with existing institution and custom departments', async function () {
const country = 'Germany'
const customDepartment = 'Custom department'
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
const button = await screen.findByRole<HTMLButtonElement>('button', {
name: /add another email/i,
})
await fetchMock.callHistory.flush(true)
resetFetchMock()
await userEvent.click(button)
await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
name: /university/i,
})
expect(universityInput.disabled).to.be.true
fetchMock.get('/institutions/list?country_code=de', [
{
id: userEmailData.affiliation.institution.id,
name: userEmailData.affiliation.institution.name,
country_code: 'de',
departments: [customDepartment],
},
])
// Select the country from dropdown
await userEvent.type(
screen.getByRole('textbox', {
name: /country/i,
}),
country
)
await userEvent.click(screen.getByText(country))
expect(universityInput.disabled).to.be.false
await fetchMock.callHistory.flush(true)
resetFetchMock()
// Select the university from dropdown
await userEvent.click(universityInput)
await userEvent.click(
await screen.findByText(userEmailData.affiliation.institution.name)
)
const roleInput = screen.getByRole('textbox', { name: /role/i })
await userEvent.type(roleInput, userEmailData.affiliation.role!)
const departmentInput = screen.getByRole('textbox', { name: /department/i })
await userEvent.click(departmentInput)
await userEvent.click(screen.getByText(customDepartment))
const userEmailDataCopy = {
...userEmailData,
affiliation: {
...userEmailData.affiliation,
department: customDepartment,
},
}
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
.post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
await userEvent.click(
screen.getByRole('button', {
name: /add new email/i,
})
)
const request = fetchMock.callHistory.calls(/\/user\/emails/).at(0)
expect(
JSON.parse(request?.options.body?.toString() || '{}')
).to.deep.include({
email: userEmailData.email,
university: {
id: userEmailData.affiliation?.institution.id,
},
role: userEmailData.affiliation?.role,
department: customDepartment,
})
screen.getByText(
`Enter the 6-digit confirmation code sent to ${userEmailData.email}.`
)
await confirmCodeForEmail(userEmailData.email)
await screen.findByText(userEmailData.affiliation.role!, { exact: false })
await screen.findByText(customDepartment, { exact: false })
})
it('autocompletes institution name', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
const button = await screen.findByRole<HTMLButtonElement>('button', {
name: /add another email/i,
})
await fetchMock.callHistory.flush(true)
resetFetchMock()
fetchMock.get('/institutions/list?country_code=de', [
{
id: 1,
name: 'University of Bonn',
},
{
id: 2,
name: 'Bochum institute of Science',
},
])
// open "add new email" section and click "let us know" to open the Country/University form
await userEvent.click(button)
await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
// select a country
const countryInput = screen.getByRole<HTMLInputElement>('textbox', {
name: /country/i,
})
await userEvent.click(countryInput)
await userEvent.type(countryInput, 'Germ')
await userEvent.click(await screen.findByText('Germany'))
// match several universities on initial typing
const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
name: /university/i,
})
await userEvent.click(universityInput)
await userEvent.type(universityInput, 'bo')
await screen.findByText('University of Bonn')
await screen.findByText('Bochum institute of Science')
// match a single university when typing to refine the search
await userEvent.type(universityInput, 'nn')
await screen.findByText('University of Bonn')
expect(screen.queryByText('Bochum institute of Science')).to.be.null
})
it('adds new email address without existing institution', async function () {
const country = 'Germany'
const countryCode = 'de'
const newUniversity = 'Abcdef'
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
const button = await screen.findByRole<HTMLButtonElement>('button', {
name: /add another email/i,
})
await fetchMock.callHistory.flush(true)
resetFetchMock()
await userEvent.click(button)
await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
name: /university/i,
})
expect(universityInput.disabled).to.be.true
fetchMock.get(/\/institutions\/list/, [
{
id: userEmailData.affiliation.institution.id,
name: userEmailData.affiliation.institution.name,
country_code: 'de',
departments: [],
},
])
// Select the country from dropdown
await userEvent.type(
screen.getByRole('textbox', {
name: /country/i,
}),
country
)
await userEvent.click(screen.getByText(country))
expect(universityInput.disabled).to.be.false
await fetchMock.callHistory.flush(true)
resetFetchMock()
// Enter the university manually
await userEvent.type(universityInput, newUniversity)
const roleInput = screen.getByRole('textbox', { name: /role/i })
await userEvent.type(roleInput, userEmailData.affiliation.role!)
const departmentInput = screen.getByRole('textbox', { name: /department/i })
await userEvent.type(departmentInput, userEmailData.affiliation.department!)
const userEmailDataCopy = {
...userEmailData,
affiliation: {
...userEmailData.affiliation,
institution: {
...userEmailData.affiliation.institution,
name: newUniversity,
},
},
}
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
.post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
await userEvent.click(
screen.getByRole('button', {
name: /add new email/i,
})
)
await confirmCodeForEmail(userEmailData.email)
const request = fetchMock.callHistory.calls(/\/user\/emails/).at(0)
expect(
JSON.parse(request?.options.body?.toString() || '{}')
).to.deep.include({
email: userEmailData.email,
university: {
name: newUniversity,
country_code: countryCode,
},
role: userEmailData.affiliation?.role,
department: userEmailData.affiliation?.department,
})
await screen.findByText(userEmailData.email)
await screen.findByText(newUniversity)
await screen.findByText(userEmailData.affiliation.role!, { exact: false })
await screen.findByText(userEmailData.affiliation.department!, {
exact: false,
})
})
it('shows country, university, role and department fields based on whether `change` was clicked or not', async function () {
const institutionDomainDataCopy = [
{
...institutionDomainData[0],
university: {
...institutionDomainData[0].university,
ssoEnabled: false,
},
},
]
const hostnameFirstChar = institutionDomainDataCopy[0].hostname.charAt(0)
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
const button = await screen.findByRole<HTMLButtonElement>('button', {
name: /add another email/i,
})
await fetchMock.callHistory.flush(true)
fetchMock.removeRoutes().clearHistory()
fetchMock.get(
`/institutions/domains?hostname=${hostnameFirstChar}&limit=1`,
institutionDomainDataCopy
)
await userEvent.click(button)
await userEvent.type(
screen.getByLabelText(/email/i),
`user@${hostnameFirstChar}`
)
await userEvent.keyboard('{Tab}')
await fetchMock.callHistory.flush(true)
fetchMock.removeRoutes().clearHistory()
expect(
screen.queryByRole('textbox', {
name: /country/i,
})
).to.be.null
expect(
screen.queryByRole('textbox', {
name: /university/i,
})
).to.be.null
screen.getByRole('textbox', {
name: /role/i,
})
screen.getByRole('textbox', {
name: /department/i,
})
await userEvent.click(screen.getByRole('button', { name: /change/i }))
screen.getByRole('textbox', {
name: /country/i,
})
screen.getByRole('textbox', {
name: /university/i,
})
expect(
screen.queryByRole('textbox', {
name: /role/i,
})
).to.be.null
expect(
screen.queryByRole('textbox', {
name: /department/i,
})
).to.be.null
})
it('displays institution name with change button when autocompleted and adds new record', async function () {
const institutionDomainDataCopy = [
{
...institutionDomainData[0],
university: {
...institutionDomainData[0].university,
ssoEnabled: false,
},
},
]
const hostnameFirstChar = institutionDomainDataCopy[0].hostname.charAt(0)
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)
const button = await screen.findByRole<HTMLButtonElement>('button', {
name: /add another email/i,
})
await fetchMock.callHistory.flush(true)
fetchMock.removeRoutes().clearHistory()
fetchMock.get(
`/institutions/domains?hostname=${hostnameFirstChar}&limit=1`,
institutionDomainDataCopy
)
await userEvent.click(button)
await userEvent.type(
screen.getByLabelText(/email/i),
`user@${hostnameFirstChar}`
)
await userEvent.keyboard('{Tab}')
await fetchMock.callHistory.flush(true)
fetchMock.removeRoutes().clearHistory()
screen.getByText(institutionDomainDataCopy[0].university.name)
const userEmailDataCopy = {
...userEmailData,
affiliation: {
...userEmailData.affiliation,
institution: {
...userEmailData.affiliation.institution,
name: institutionDomainDataCopy[0].university.name,
},
},
}
fetchMock
.get('/user/emails?ensureAffiliation=true', [userEmailDataCopy])
.post('/user/emails/secondary', 200)
.post('/user/emails/confirm-secondary', 200)
await userEvent.type(
screen.getByRole('textbox', { name: /role/i }),
userEmailData.affiliation.role!
)
await userEvent.type(
screen.getByRole('textbox', { name: /department/i }),
userEmailData.affiliation.department!
)
await userEvent.click(
screen.getByRole('button', {
name: /add new email/i,
})
)
await confirmCodeForEmail('user@autocomplete.edu')
await fetchMock.callHistory.flush(true)
fetchMock.removeRoutes().clearHistory()
screen.getByText(userEmailDataCopy.affiliation.institution.name, {
exact: false,
})
screen.getByText(userEmailDataCopy.affiliation.role!, { exact: false })
screen.getByText(userEmailDataCopy.affiliation.department!, {
exact: false,
})
})
})

View File

@@ -0,0 +1,199 @@
import {
render,
screen,
fireEvent,
waitForElementToBeRemoved,
} from '@testing-library/react'
import { expect } from 'chai'
import { UserEmailData } from '../../../../../../types/user-email'
import fetchMock from 'fetch-mock'
import InstitutionAndRole from '../../../../../../frontend/js/features/settings/components/emails/institution-and-role'
import { UserEmailsProvider } from '../../../../../../frontend/js/features/settings/context/user-email-context'
import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section'
import { Affiliation } from '../../../../../../types/affiliation'
import getMeta from '@/utils/meta'
const userData1: UserEmailData & { affiliation: Affiliation } = {
affiliation: {
cachedConfirmedAt: null,
cachedEntitlement: null,
cachedLastDayToReconfirm: null,
cachedPastReconfirmDate: false,
cachedReconfirmedAt: null,
department: null,
institution: {
commonsAccount: false,
writefullCommonsAccount: false,
confirmed: true,
id: 1,
isUniversity: false,
maxConfirmationMonths: null,
name: 'Overleaf',
ssoEnabled: false,
ssoBeta: false,
},
inReconfirmNotificationPeriod: false,
inferred: false,
licence: 'pro_plus',
pastReconfirmDate: false,
portal: { slug: '', templates_count: 1 },
role: null,
},
confirmedAt: '2022-03-09T10:59:44.139Z',
email: 'foo@overleaf.com',
default: true,
}
const userData2: UserEmailData & { affiliation: Affiliation } = {
affiliation: {
cachedConfirmedAt: null,
cachedEntitlement: null,
cachedLastDayToReconfirm: null,
cachedPastReconfirmDate: false,
cachedReconfirmedAt: null,
department: 'Art History',
institution: {
commonsAccount: false,
writefullCommonsAccount: false,
confirmed: true,
id: 1,
isUniversity: false,
maxConfirmationMonths: null,
name: 'Overleaf',
ssoEnabled: false,
ssoBeta: false,
},
inReconfirmNotificationPeriod: false,
inferred: false,
licence: 'pro_plus',
pastReconfirmDate: false,
portal: { slug: '', templates_count: 1 },
role: 'Reader',
},
email: 'baz@overleaf.com',
default: false,
}
describe('user role and institution', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
})
fetchMock.removeRoutes().clearHistory()
fetchMock.get('/user/emails?ensureAffiliation=true', [], {
name: 'get user emails',
})
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('renders affiliation name with add role/department button', function () {
const userEmailData = userData1
render(
<UserEmailsProvider>
<InstitutionAndRole userEmailData={userEmailData} />
</UserEmailsProvider>
)
screen.getByText(userEmailData.affiliation.institution.name, {
exact: false,
})
screen.getByRole('button', { name: /add role and department/i })
expect(screen.queryByRole('button', { name: /change/i })).to.not.exist
})
it('renders affiliation name, role and department with change button', function () {
const userEmailData = userData2
render(
<UserEmailsProvider>
<InstitutionAndRole userEmailData={userEmailData} />
</UserEmailsProvider>
)
screen.getByText(userEmailData.affiliation.institution.name, {
exact: false,
})
screen.getByText(userEmailData.affiliation.department!, { exact: false })
screen.getByText(userEmailData.affiliation.role!, { exact: false })
screen.getByRole('button', { name: /change/i })
expect(screen.queryByRole('button', { name: /add role and department/i }))
.to.not.exist
})
it('fetches institution data and replaces departments dropdown on add/change', async function () {
const userEmailData = userData1
fetchMock.modifyRoute('get user emails', { response: [userEmailData] })
render(<EmailsSection />)
await fetchMock.callHistory.flush(true)
fetchMock.removeRoutes().clearHistory()
const fakeDepartment = 'Fake department'
const institution = userEmailData.affiliation.institution
fetchMock.get(`/institutions/list/${institution.id}`, {
id: institution.id,
name: institution.name,
country_code: 'de',
departments: [fakeDepartment],
})
fireEvent.click(
screen.getByRole('button', { name: /add role and department/i })
)
await fetchMock.callHistory.flush(true)
fetchMock.removeRoutes().clearHistory()
fireEvent.click(screen.getByRole('textbox', { name: /department/i }))
screen.getByText(fakeDepartment)
})
it('adds new role and department', async function () {
fetchMock
.modifyRoute('get user emails', { response: [userData1] })
.get(/\/institutions\/list/, { departments: [] })
.post('/user/emails/endorse', 200)
render(<EmailsSection />)
const addBtn = await screen.findByRole('button', {
name: /add role and department/i,
})
fireEvent.click(addBtn)
const submitBtn = screen.getByRole('button', {
name: /save/i,
}) as HTMLButtonElement
expect(submitBtn.disabled).to.be.true
const roleValue = 'Dummy role'
const departmentValue = 'Dummy department'
const roleInput = screen.getByPlaceholderText(/role/i)
fireEvent.change(roleInput, {
target: { value: roleValue },
})
expect(submitBtn.disabled).to.be.true
const departmentInput = screen.getByPlaceholderText(/department/i)
fireEvent.change(departmentInput, {
target: { value: departmentValue },
})
expect(submitBtn.disabled).to.be.false
fireEvent.click(submitBtn)
expect(submitBtn.disabled).to.be.true
await waitForElementToBeRemoved(() =>
screen.getByRole('button', { name: /saving/i })
)
await screen.findByText(roleValue, { exact: false })
await screen.findByText(departmentValue, { exact: false })
})
})

View File

@@ -0,0 +1,163 @@
import {
render,
screen,
within,
fireEvent,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react'
import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import {
confirmedUserData,
fakeUsersData,
professionalUserData,
unconfirmedUserData,
} from '../../fixtures/test-user-email-data'
import getMeta from '@/utils/meta'
describe('<EmailsSection />', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
})
fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('renders translated heading', function () {
render(<EmailsSection />)
screen.getByRole('heading', { name: /emails and affiliations/i })
})
it('renders translated description', function () {
render(<EmailsSection />)
screen.getByText(/add additional email addresses/i)
screen.getByText(/to change your primary email/i)
})
it('renders a loading message when loading', async function () {
render(<EmailsSection />)
await screen.findByText(/loading/i)
})
it('renders an error message and hides loading message on error', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', 500)
render(<EmailsSection />)
await screen.findByText(
/an error has occurred while performing your request/i
)
expect(screen.queryByText(/loading/i)).to.be.null
})
it('renders user emails', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', fakeUsersData)
render(<EmailsSection />)
await waitFor(() => {
fakeUsersData.forEach(userData => {
screen.getByText(new RegExp(userData.email, 'i'))
})
})
})
it('renders primary status', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [professionalUserData])
render(<EmailsSection />)
await screen.findByText(`${professionalUserData.email}`)
screen.getByText('Primary')
})
it('shows confirmation status for unconfirmed users', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData])
render(<EmailsSection />)
await screen.findByText(/unconfirmed/i)
})
it('hides confirmation status for confirmed users', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [confirmedUserData])
render(<EmailsSection />)
await waitForElementToBeRemoved(() => screen.getByText(/loading/i))
expect(screen.queryByText(/please check your inbox/i)).to.be.null
})
it('renders resend link', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData])
render(<EmailsSection />)
await screen.findByRole('button', { name: /resend confirmation code/i })
})
it('renders professional label', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [professionalUserData])
render(<EmailsSection />)
const node = await screen.findByText(professionalUserData.email, {
exact: false,
})
expect(within(node).getByText(/professional/i)).to.exist
})
it('shows loader when resending email', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData])
render(<EmailsSection />)
await waitForElementToBeRemoved(() => screen.getByText(/loading/i))
fetchMock.post('/user/emails/send-confirmation-code', 200)
const button = screen.getByRole('button', {
name: /resend confirmation code/i,
})
fireEvent.click(button)
expect(
screen.queryByRole('button', {
name: /resend confirmation code/i,
})
).to.be.null
await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
expect(
screen.queryByText(/an error has occurred while performing your request/i)
).to.be.null
await screen.findAllByRole('button', {
name: /resend confirmation code/i,
})
})
it('shows error when resending email fails', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [unconfirmedUserData])
render(<EmailsSection />)
await waitForElementToBeRemoved(() => screen.getByText(/loading/i))
fetchMock.post('/user/emails/send-confirmation-code', 503)
const button = screen.getByRole('button', {
name: /resend confirmation code/i,
})
fireEvent.click(button)
expect(screen.queryByRole('button', { name: /resend confirmation code/i }))
.to.be.null
await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
screen.getByText(/sorry, something went wrong/i)
screen.getByRole('button', { name: /resend confirmation code/i })
})
})

View File

@@ -0,0 +1,171 @@
import {
fireEvent,
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react'
import sinon from 'sinon'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import { cloneDeep } from 'lodash'
import ReconfirmationInfo from '../../../../../../frontend/js/features/settings/components/emails/reconfirmation-info'
import { ssoUserData } from '../../fixtures/test-user-email-data'
import { UserEmailData } from '../../../../../../types/user-email'
import { UserEmailsProvider } from '../../../../../../frontend/js/features/settings/context/user-email-context'
import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
import getMeta from '@/utils/meta'
function renderReconfirmationInfo(data: UserEmailData) {
return render(
<UserEmailsProvider>
<ReconfirmationInfo userEmailData={data} />
</UserEmailsProvider>
)
}
describe('<ReconfirmationInfo/>', function () {
let assignStub: sinon.SinonStub
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
samlInitPath: '/saml',
})
fetchMock.get('/user/emails?ensureAffiliation=true', [])
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
setHash: sinon.stub(),
})
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
this.locationStub.restore()
})
describe('reconfirmed via SAML', function () {
beforeEach(function () {
window.metaAttributesCache.set(
'ol-reconfirmedViaSAML',
'sso-prof-saml-id'
)
})
it('show reconfirmed confirmation', function () {
renderReconfirmationInfo(ssoUserData)
screen.getByText('SSO University')
screen.getByText(/affiliation is confirmed/)
screen.getByText(/Thank you!/)
})
})
describe('in reconfirm notification period', function () {
let inReconfirmUserData: UserEmailData
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
samlInitPath: '/saml',
})
inReconfirmUserData = cloneDeep(ssoUserData)
if (inReconfirmUserData.affiliation) {
inReconfirmUserData.affiliation.inReconfirmNotificationPeriod = true
}
})
it('renders prompt', function () {
renderReconfirmationInfo(inReconfirmUserData)
screen.getByText(/Are you still at/)
screen.getByText('SSO University')
screen.getByText(
/Please take a moment to confirm your institutional email address/
)
screen.getByRole('link', { name: 'Learn more' })
expect(screen.queryByText(/add a new primary email address/)).to.not.exist
})
it('renders default emails prompt', function () {
inReconfirmUserData.default = true
renderReconfirmationInfo(inReconfirmUserData)
screen.getByText(/add a new primary email address/)
})
describe('SAML reconfirmations', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasSamlFeature: true,
samlInitPath: '/saml/init',
})
})
it('redirects to SAML flow', async function () {
renderReconfirmationInfo(inReconfirmUserData)
const confirmButton = screen.getByRole('button', {
name: 'Confirm Affiliation',
}) as HTMLButtonElement
await waitFor(() => {
expect(confirmButton.disabled).to.be.false
})
fireEvent.click(confirmButton)
await waitFor(() => {
expect(confirmButton.disabled).to.be.true
})
sinon.assert.calledOnce(assignStub)
sinon.assert.calledWithMatch(
assignStub,
'/saml/init?university_id=2&reconfirm=/user/settings'
)
})
})
describe('Email reconfirmations', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasSamlFeature: false,
})
fetchMock.post('/user/emails/send-reconfirmation', 200)
})
it('sends and resends confirmation email', async function () {
renderReconfirmationInfo(inReconfirmUserData)
const confirmButton = (await screen.findByRole('button', {
name: 'Confirm Affiliation',
})) as HTMLButtonElement
await waitFor(() => {
expect(confirmButton.disabled).to.be.false
})
fireEvent.click(confirmButton)
await waitFor(() => {
expect(confirmButton.disabled).to.be.true
})
expect(fetchMock.callHistory.called()).to.be.true
// the confirmation text should now be displayed
await screen.findByText(/Please check your email inbox to confirm/)
// try the resend button
fetchMock.clearHistory()
const resendButton = await screen.findByRole('button', {
name: 'Resend confirmation email',
})
fireEvent.click(resendButton)
// commented out as it's already gone by this point
// await screen.findByText(/Sending/)
expect(fetchMock.callHistory.called()).to.be.true
await waitForElementToBeRemoved(() => screen.getByText('Sending…'))
await screen.findByRole('button', {
name: 'Resend confirmation email',
})
})
})
})
})

View File

@@ -0,0 +1,96 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { expect } from 'chai'
import { SSOAlert } from '../../../../../../frontend/js/features/settings/components/emails/sso-alert'
describe('<SSOAlert/>', function () {
describe('when there is no institutional linking information', function () {
it('should be empty', function () {
render(<SSOAlert />)
expect(screen.queryByRole('alert')).to.be.null
})
})
describe('when there is institutional linking information', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-institutionLinked', {
universityName: 'Overleaf University',
})
})
it('should render an information alert with the university name', function () {
render(<SSOAlert />)
screen.getByRole('alert')
screen.getByText('account was successfully linked', { exact: false })
screen.getByText('Overleaf University', { exact: false })
})
it('when entitled, it should render access granted to "professional" features', function () {
window.metaAttributesCache.get('ol-institutionLinked').hasEntitlement =
true
render(<SSOAlert />)
screen.getByText('this grants you access', { exact: false })
screen.getByText('Professional')
})
it('when the email is not canonical it should also render a warning alert', function () {
window.metaAttributesCache.set(
'ol-institutionEmailNonCanonical',
'user@example.com'
)
render(<SSOAlert />)
const alerts = screen.getAllByRole('alert')
expect(alerts.length).to.equal(2)
})
it('the alerts should be closeable', function () {
window.metaAttributesCache.set(
'ol-institutionEmailNonCanonical',
'user@example.com'
)
render(<SSOAlert />)
const closeButtons = screen.getAllByRole('button', {
name: 'Close',
})
fireEvent.click(closeButtons[0])
fireEvent.click(closeButtons[1])
expect(screen.queryByRole('button', { name: 'Close' })).to.be.null
})
})
describe('when there is a SAML Error', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-samlError', {
message: 'there was an error',
})
})
it('should render an error alert', function () {
render(<SSOAlert />)
screen.getByRole('alert')
screen.getByText('there was an error')
})
it('should render translated error if available', function () {
window.metaAttributesCache.get('ol-samlError').translatedMessage =
'translated error'
render(<SSOAlert />)
screen.getByText('translated error')
expect(screen.queryByText('there was an error')).to.be.null
})
it('should render a "try again" label when requested by the error payload', function () {
window.metaAttributesCache.get('ol-samlError').tryAgain = true
render(<SSOAlert />)
screen.getByText('Please try again')
})
it('the alert should be closeable', function () {
render(<SSOAlert />)
const closeButton = screen.getByRole('button', {
name: 'Close',
})
fireEvent.click(closeButton)
expect(screen.queryByRole('button', { name: 'Close' })).to.be.null
})
})
})

View File

@@ -0,0 +1,45 @@
import {
fireEvent,
screen,
waitForElementToBeRemoved,
render,
} from '@testing-library/react'
import LeaveSection from '../../../../../frontend/js/features/settings/components/leave-section'
import getMeta from '@/utils/meta'
describe('<LeaveSection />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-usersEmail', 'foo@bar.com')
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
window.metaAttributesCache.set('ol-hasPassword', true)
})
it('opens modal', async function () {
render(<LeaveSection />)
const button = screen.getByRole('button', {
name: 'Delete your account',
})
fireEvent.click(button)
await screen.findByText('Delete Account')
})
it('closes modal', async function () {
render(<LeaveSection />)
fireEvent.click(
screen.getByRole('button', {
name: 'Delete your account',
})
)
const cancelButton = screen.getByRole('button', {
name: 'Close',
})
fireEvent.click(cancelButton)
await waitForElementToBeRemoved(() => screen.getByText('Delete Account'))
})
})

View File

@@ -0,0 +1,50 @@
import { expect } from 'chai'
import { screen, render } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import LeaveModalContent from '../../../../../../frontend/js/features/settings/components/leave/modal-content'
import getMeta from '@/utils/meta'
describe('<LeaveModalContent />', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
window.metaAttributesCache.set('ol-hasPassword', true)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('disable delete button if form is not valid', function () {
render(
<LeaveModalContent
handleHide={() => {}}
inFlight={false}
setInFlight={() => {}}
/>
)
screen.getByLabelText('Email')
const deleteButton = screen.getByRole('button', {
name: 'Delete',
})
expect(deleteButton.hasAttribute('disabled')).to.be.true
})
it('shows no password message', function () {
window.metaAttributesCache.set('ol-isSaas', true)
window.metaAttributesCache.set('ol-hasPassword', false)
render(
<LeaveModalContent
handleHide={() => {}}
inFlight={false}
setInFlight={() => {}}
/>
)
const link = screen.getByRole('link', {
name: 'Please use the password reset form to set a password before deleting your account',
})
expect(link.getAttribute('href')).to.equal('/user/password/reset')
})
})

View File

@@ -0,0 +1,196 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { fireEvent, screen, render, waitFor } from '@testing-library/react'
import fetchMock, { type FetchMock } from 'fetch-mock'
import LeaveModalForm from '../../../../../../frontend/js/features/settings/components/leave/modal-form'
import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
import getMeta from '@/utils/meta'
describe('<LeaveModalForm />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-usersEmail', 'foo@bar.com')
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('validates form', async function () {
const setIsFormValid = sinon.stub()
render(
<LeaveModalForm
setInFlight={() => {}}
isFormValid={false}
setIsFormValid={setIsFormValid}
/>
)
const emailInput = screen.getByLabelText('Email')
fireEvent.change(emailInput, { target: { value: 'FOO@bar.com' } })
const passwordInput = screen.getByLabelText('Password')
fireEvent.change(passwordInput, { target: { value: 'foobar' } })
const checkbox = screen.getByLabelText(
'I understand this will delete all projects in my Overleaf account with email address foo@bar.com'
)
fireEvent.click(checkbox)
const setIsFormValidCalls = setIsFormValid.getCalls()
const lastSetIsFormValidCall = setIsFormValidCalls.pop()
expect(lastSetIsFormValidCall!.args[0]).to.be.true
for (const setIsFormValidCall of setIsFormValidCalls) {
expect(setIsFormValidCall.args[0]).to.be.false
}
})
describe('submits', async function () {
let setInFlight: sinon.SinonStub
let setIsFormValid: sinon.SinonStub
let deleteMock: FetchMock
let assignStub: sinon.SinonStub
beforeEach(function () {
setInFlight = sinon.stub()
setIsFormValid = sinon.stub()
deleteMock = fetchMock.post('/user/delete', 200)
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
setHash: sinon.stub(),
})
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
this.locationStub.restore()
})
it('with valid form', async function () {
render(
<LeaveModalForm
setInFlight={setInFlight}
isFormValid
setIsFormValid={setIsFormValid}
/>
)
fireEvent.submit(screen.getByLabelText('Email'))
sinon.assert.calledOnce(setInFlight)
sinon.assert.calledWithMatch(setInFlight, true)
expect(deleteMock.callHistory.called()).to.be.true
await waitFor(() => {
sinon.assert.calledTwice(setInFlight)
sinon.assert.calledWithMatch(setInFlight, false)
sinon.assert.calledOnce(assignStub)
sinon.assert.calledWith(assignStub, '/')
})
})
it('with invalid form', async function () {
render(
<LeaveModalForm
setInFlight={setInFlight}
isFormValid={false}
setIsFormValid={setIsFormValid}
/>
)
fireEvent.submit(screen.getByLabelText('Email'))
expect(deleteMock.callHistory.called()).to.be.false
sinon.assert.notCalled(setInFlight)
})
})
it('handles credentials error without Saas tip', async function () {
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: false })
fetchMock.post('/user/delete', 403)
render(
<LeaveModalForm
setInFlight={() => {}}
isFormValid
setIsFormValid={() => {}}
/>
)
fireEvent.submit(screen.getByLabelText('Email'))
await waitFor(() => {
screen.getByText(/Your email or password is incorrect. Please try again/)
})
expect(screen.queryByText(/If you cannot remember your password/)).to.not
.exist
})
it('handles credentials error with Saas tip', async function () {
fetchMock.post('/user/delete', 403)
render(
<LeaveModalForm
setInFlight={() => {}}
isFormValid
setIsFormValid={() => {}}
/>
)
fireEvent.submit(screen.getByLabelText('Email'))
await waitFor(() => {
screen.getByText(/Your email or password is incorrect. Please try again/)
})
screen.getByText(/If you cannot remember your password/)
const link = screen.getByRole('link', { name: 'reset your password' })
expect(link.getAttribute('href')).to.equal('/user/password/reset')
})
it('handles subscription error', async function () {
fetchMock.post('/user/delete', {
status: 422,
body: {
error: 'SubscriptionAdminDeletionError',
},
})
render(
<LeaveModalForm
setInFlight={() => {}}
isFormValid
setIsFormValid={() => {}}
/>
)
fireEvent.submit(screen.getByLabelText('Email'))
await waitFor(() => {
screen.getByText(
'You cannot delete your account while on a subscription. Please cancel your subscription and try again. If you keep seeing this message please contact us.'
)
})
})
it('handles generic error', async function () {
fetchMock.post('/user/delete', 500)
render(
<LeaveModalForm
setInFlight={() => {}}
isFormValid
setIsFormValid={() => {}}
/>
)
fireEvent.submit(screen.getByLabelText('Email'))
await waitFor(() => {
screen.getByText(
'Sorry, something went wrong deleting your account. Please try again in a minute.'
)
})
})
})

View File

@@ -0,0 +1,66 @@
import sinon from 'sinon'
import { fireEvent, screen, render, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import LeaveModal from '../../../../../../frontend/js/features/settings/components/leave/modal'
import getMeta from '@/utils/meta'
describe('<LeaveModal />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-usersEmail', 'foo@bar.com')
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
window.metaAttributesCache.set('ol-hasPassword', true)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('closes modal on cancel', async function () {
const handleClose = sinon.stub()
render(<LeaveModal isOpen handleClose={handleClose} />)
const cancelButton = screen.getByRole('button', {
name: 'Cancel',
})
fireEvent.click(cancelButton)
sinon.assert.calledOnce(handleClose)
})
it('does not close modal while in flight', async function () {
fetchMock.post('/user/delete', new Promise(() => {}))
const handleClose = sinon.stub()
render(<LeaveModal isOpen handleClose={handleClose} />)
fillValidForm()
const deleteButton = screen.getByRole('button', {
name: 'Delete',
})
fireEvent.click(deleteButton)
await waitFor(() => {
screen.getByRole('button', {
name: 'Deleting…',
})
})
const cancelButton = screen.getByRole('button', {
name: 'Cancel',
})
fireEvent.click(cancelButton)
sinon.assert.notCalled(handleClose)
})
})
function fillValidForm() {
fireEvent.change(screen.getByLabelText('Email'), {
target: { value: 'foo@bar.com' },
})
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'foobar' },
})
fireEvent.click(screen.getByLabelText(/I understand/))
}

View File

@@ -0,0 +1,109 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { fireEvent, screen, render } from '@testing-library/react'
import { UserEmailsProvider } from '../../../../../frontend/js/features/settings/context/user-email-context'
import { LeaversSurveyAlert } from '../../../../../frontend/js/features/settings/components/leavers-survey-alert'
import * as eventTracking from '@/infrastructure/event-tracking'
import localStorage from '@/infrastructure/local-storage'
import fetchMock from 'fetch-mock'
function renderWithProvider() {
render(<LeaversSurveyAlert />, {
wrapper: ({ children }) => (
<UserEmailsProvider>{children}</UserEmailsProvider>
),
})
}
describe('<LeaversSurveyAlert/>', function () {
beforeEach(function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('should render before the expiration date', function () {
const tomorrow = Date.now() + 1000 * 60 * 60 * 24
localStorage.setItem('showInstitutionalLeaversSurveyUntil', tomorrow)
localStorage.setItem('hideInstitutionalLeaversSurvey', false)
renderWithProvider()
screen.getByRole('alert')
screen.getByText(/Provide some quick feedback/)
screen.getByRole('link', { name: 'Take a short survey' })
})
it('should not render after the expiration date', function () {
const yesterday = Date.now() - 1000 * 60 * 60 * 24
localStorage.setItem('showInstitutionalLeaversSurveyUntil', yesterday)
localStorage.setItem('hideInstitutionalLeaversSurvey', false)
renderWithProvider()
expect(screen.queryByRole('alert')).to.be.null
})
it('should not render if it has been hidden', function () {
const tomorrow = Date.now() + 1000 * 60 * 60 * 24
localStorage.setItem('showInstitutionalLeaversSurveyUntil', tomorrow)
localStorage.setItem('hideInstitutionalLeaversSurvey', true)
renderWithProvider()
expect(screen.queryByRole('alert')).to.be.null
})
it('should reset the expiration date when it is closed', function () {
const tomorrow = Date.now() + 1000 * 60 * 60 * 24
localStorage.setItem('showInstitutionalLeaversSurveyUntil', tomorrow)
localStorage.setItem('hideInstitutionalLeaversSurvey', false)
renderWithProvider()
screen.getByRole('alert')
fireEvent.click(screen.getByRole('button'))
expect(screen.queryByRole('alert')).to.be.null
expect(localStorage.getItem('showInstitutionalLeaversSurveyUntil')).to.be
.null
})
describe('event tracking', function () {
let sendMBSpy: sinon.SinonSpy
beforeEach(function () {
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
const tomorrow = Date.now() + 1000 * 60 * 60 * 24
localStorage.setItem('showInstitutionalLeaversSurveyUntil', tomorrow)
localStorage.setItem('hideInstitutionalLeaversSurvey', false)
renderWithProvider()
})
afterEach(function () {
sendMBSpy.restore()
localStorage.clear()
})
it('should sent a `view` event on load', function () {
expect(sendMBSpy).to.be.calledOnce
expect(sendMBSpy).calledWith(
'institutional-leavers-survey-notification',
{ type: 'view', page: '/' }
)
})
it('should sent a `click` event when the link is clicked', function () {
fireEvent.click(screen.getByRole('link'))
expect(sendMBSpy).to.be.calledTwice
expect(sendMBSpy).calledWith(
'institutional-leavers-survey-notification',
{ type: 'click', page: '/' }
)
})
it('should sent a `close` event when it is closed', function () {
fireEvent.click(screen.getByRole('button'))
expect(sendMBSpy).to.be.calledTwice
expect(sendMBSpy).calledWith(
'institutional-leavers-survey-notification',
{ type: 'close', page: '/' }
)
})
})
})

View File

@@ -0,0 +1,97 @@
import { expect } from 'chai'
import { screen, render } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import LinkingSection from '@/features/settings/components/linking-section'
import { UserProvider } from '@/shared/context/user-context'
import { SSOProvider } from '@/features/settings/context/sso-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
function renderSectionWithProviders() {
render(<LinkingSection />, {
wrapper: ({ children }) => (
<SplitTestProvider>
<UserProvider>
<SSOProvider>{children}</SSOProvider>
</UserProvider>
</SplitTestProvider>
),
})
}
const mockOauthProviders = {
google: {
descriptionKey: 'login_with_service',
descriptionOptions: { service: 'Google' },
name: 'Google',
linkPath: '/auth/google',
},
orcid: {
descriptionKey: 'oauth_orcid_description',
descriptionOptions: {
link: '/blog/434',
appName: 'Overleaf',
},
name: 'ORCID',
linkPath: '/auth/orcid',
},
}
describe('<LinkingSection />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-user', {})
// suppress integrations and references widgets as they cannot be tested in
// all environments
window.metaAttributesCache.set('ol-hideLinkingWidgets', true)
window.metaAttributesCache.set('ol-thirdPartyIds', {
google: 'google-id',
})
window.metaAttributesCache.set('ol-oauthProviders', mockOauthProviders)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows header', async function () {
renderSectionWithProviders()
screen.getByText('Integrations')
screen.getByText(
'You can link your Overleaf account with other services to enable the features described below.'
)
})
it('lists SSO providers', async function () {
renderSectionWithProviders()
screen.getByText('linked accounts')
screen.getByText('Google')
screen.getByText('Log in with Google.')
screen.getByRole('button', { name: 'Unlink' })
screen.getByText('ORCID')
screen.getByText(
/Securely establish your identity by linking your ORCID iD/
)
const helpLink = screen.getByRole('link', { name: 'Learn more' })
expect(helpLink.getAttribute('href')).to.equal('/blog/434')
const linkButton = screen.getByRole('button', { name: 'Link' })
expect(linkButton.getAttribute('href')).to.equal('/auth/orcid?intent=link')
})
it('shows SSO error message', async function () {
window.metaAttributesCache.set('ol-ssoErrorMessage', 'You no SSO')
renderSectionWithProviders()
screen.getByText('Error linking account: You no SSO')
})
it('does not show providers section when empty', async function () {
window.metaAttributesCache.delete('ol-oauthProviders')
renderSectionWithProviders()
expect(screen.queryByText('linked accounts')).to.not.exist
})
})

View File

@@ -0,0 +1,111 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { screen, fireEvent, render, within } from '@testing-library/react'
import { IntegrationLinkingWidget } from '../../../../../../frontend/js/features/settings/components/linking/integration-widget'
import * as eventTracking from '@/infrastructure/event-tracking'
describe('<IntegrationLinkingWidgetTest/>', function () {
const defaultProps = {
logo: <div />,
title: 'Integration',
description: 'paragraph1',
helpPath: '/learn',
linkPath: '/link',
unlinkPath: '/unlink',
unlinkConfirmationTitle: 'confirm unlink',
unlinkConfirmationText: 'you will be unlinked',
}
describe('when the feature is not available', function () {
let sendMBSpy: sinon.SinonSpy
beforeEach(function () {
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
render(<IntegrationLinkingWidget {...defaultProps} hasFeature={false} />)
})
afterEach(function () {
sendMBSpy.restore()
})
it("should render 'Premium feature' label", function () {
screen.getByText('Premium feature')
})
it('should render an upgrade link and track clicks', function () {
const upgradeLink = screen.getByRole('button', { name: 'Upgrade' })
expect(upgradeLink.getAttribute('href')).to.equal(
'/user/subscription/plans'
)
fireEvent.click(upgradeLink)
expect(sendMBSpy).to.be.calledOnce
expect(sendMBSpy).calledWith('settings-upgrade-click')
})
})
describe('when the integration is not linked', function () {
beforeEach(function () {
render(
<IntegrationLinkingWidget {...defaultProps} hasFeature linked={false} />
)
})
it('should render a link to initiate integration linking', function () {
expect(
screen.getByRole('button', { name: 'Link' }).getAttribute('href')
).to.equal('/link')
})
it("should not render 'premium feature' labels", function () {
expect(screen.queryByText('premium_feature')).to.not.exist
expect(screen.queryByText('integration_is_a_premium_feature')).to.not
.exist
})
})
describe('when the integration is linked', function () {
beforeEach(function () {
render(
<IntegrationLinkingWidget
{...defaultProps}
hasFeature
linked
statusIndicator={<div>status indicator</div>}
/>
)
})
it('should render a status indicator', function () {
screen.getByText('status indicator')
})
it("should not render 'premium feature' labels", function () {
expect(screen.queryByText('premium_feature')).to.not.exist
expect(screen.queryByText('integration_is_a_premium_feature')).to.not
.exist
})
it('should display an `unlink` button', function () {
screen.getByRole('button', { name: 'Unlink' })
})
it('should open a modal with a link to confirm integration unlinking', function () {
fireEvent.click(screen.getByRole('button', { name: 'Unlink' }))
const withinModal = within(screen.getByRole('dialog'))
withinModal.getByText('confirm unlink')
withinModal.getByText('you will be unlinked')
withinModal.getByRole('button', { name: 'Cancel' })
withinModal.getByRole('button', { name: 'Unlink' })
})
it('should cancel unlinking when clicking "cancel" in the confirmation modal', async function () {
fireEvent.click(screen.getByRole('button', { name: 'Unlink' }))
screen.getByText('confirm unlink')
const cancelBtn = screen.getByRole('button', {
name: 'Cancel',
hidden: false,
})
fireEvent.click(cancelBtn)
await screen.findByRole('button', { name: 'Cancel', hidden: true })
})
})
})

View File

@@ -0,0 +1,136 @@
import { expect } from 'chai'
import sinon from 'sinon'
import {
screen,
fireEvent,
render,
waitFor,
within,
} from '@testing-library/react'
import { FetchError } from '../../../../../../frontend/js/infrastructure/fetch-json'
import { SSOLinkingWidget } from '../../../../../../frontend/js/features/settings/components/linking/sso-widget'
describe('<SSOLinkingWidget />', function () {
const defaultProps = {
providerId: 'integration_id',
title: 'integration',
description: 'integration description',
helpPath: '/help/integration',
linkPath: '/integration/link',
onUnlink: () => Promise.resolve(),
}
it('should render', function () {
render(<SSOLinkingWidget {...defaultProps} />)
screen.getByText('integration')
screen.getByText('integration description')
expect(
screen.getByRole('link', { name: 'Learn more' }).getAttribute('href')
).to.equal('/help/integration')
})
describe('when unlinked', function () {
it('should render a link to `linkPath`', function () {
render(<SSOLinkingWidget {...defaultProps} linked={false} />)
expect(
screen.getByRole('button', { name: 'Link' }).getAttribute('href')
).to.equal('/integration/link?intent=link')
})
})
describe('when linked', function () {
let unlinkFunction: sinon.SinonStub
beforeEach(function () {
unlinkFunction = sinon.stub()
render(
<SSOLinkingWidget {...defaultProps} linked onUnlink={unlinkFunction} />
)
})
it('should display an `unlink` button', function () {
screen.getByRole('button', { name: 'Unlink' })
})
it('should open a modal to confirm integration unlinking', function () {
fireEvent.click(screen.getByRole('button', { name: 'Unlink' }))
screen.getByText('Unlink integration Account')
screen.getByText(
'Warning: When you unlink your account from integration you will not be able to sign in using integration anymore.'
)
})
it('should cancel unlinking when clicking cancel in the confirmation modal', async function () {
fireEvent.click(screen.getByRole('button', { name: 'Unlink' }))
const cancelBtn = screen.getByRole('button', {
name: 'Cancel',
hidden: false,
})
fireEvent.click(cancelBtn)
await screen.findByRole('button', { name: 'Cancel', hidden: true })
expect(unlinkFunction).not.to.have.been.called
})
})
describe('unlinking an account', function () {
let confirmBtn: HTMLElement, unlinkFunction: sinon.SinonStub
beforeEach(function () {
unlinkFunction = sinon.stub()
render(
<SSOLinkingWidget {...defaultProps} linked onUnlink={unlinkFunction} />
)
fireEvent.click(screen.getByRole('button', { name: 'Unlink' }))
confirmBtn = within(screen.getByRole('dialog')).getByRole('button', {
name: 'Unlink',
hidden: false,
})
})
it('should make an `unlink` request', function () {
unlinkFunction.resolves()
fireEvent.click(confirmBtn)
expect(unlinkFunction).to.have.been.called
})
it('should display feedback while the request is inflight', async function () {
unlinkFunction.returns(
new Promise<void>(resolve => {
setTimeout(resolve, 500)
})
)
fireEvent.click(confirmBtn)
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Unlinking' }))
)
})
})
describe('when unlinking fails', function () {
beforeEach(function () {
const unlinkFunction = sinon
.stub()
.rejects(new FetchError('unlinking failed', ''))
render(
<SSOLinkingWidget {...defaultProps} linked onUnlink={unlinkFunction} />
)
fireEvent.click(screen.getByRole('button', { name: 'Unlink' }))
const confirmBtn = within(screen.getByRole('dialog')).getByRole(
'button',
{
name: 'Unlink',
hidden: false,
}
)
fireEvent.click(confirmBtn)
})
it('should display an error message ', async function () {
await screen.findByText('Something went wrong. Please try again.')
})
it('should display the unlink button ', async function () {
await screen.findByRole('button', { name: 'Unlink' })
})
})
})

View File

@@ -0,0 +1,16 @@
import { expect } from 'chai'
import { screen, render } from '@testing-library/react'
import NewsletterSection from '../../../../../frontend/js/features/settings/components/newsletter-section'
describe('<NewsletterSection />', function () {
it('shows link to sessions', async function () {
render(<NewsletterSection />)
const link = screen.getByRole('link', {
name: 'Manage Your Newsletter Preferences',
})
expect(link.getAttribute('href')).to.equal('/user/email-preferences')
})
})

View File

@@ -0,0 +1,213 @@
import { expect } from 'chai'
import { fireEvent, screen, render } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import PasswordSection from '../../../../../frontend/js/features/settings/components/password-section'
import getMeta from '@/utils/meta'
describe('<PasswordSection />', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
isOverleaf: true,
})
window.metaAttributesCache.set(
'ol-isExternalAuthenticationSystemUsed',
false
)
window.metaAttributesCache.set('ol-hasPassword', true)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows password managed externally message', async function () {
Object.assign(getMeta('ol-ExposedSettings'), {
isOverleaf: false,
})
window.metaAttributesCache.set(
'ol-isExternalAuthenticationSystemUsed',
true
)
render(<PasswordSection />)
screen.getByText('Password settings are managed externally')
})
it('shows no existing password message', async function () {
window.metaAttributesCache.set('ol-hasPassword', false)
render(<PasswordSection />)
screen.getByText('Please use the password reset form to set your password')
})
it('submits all inputs', async function () {
const updateMock = fetchMock.post('/user/password/update', 200)
render(<PasswordSection />)
submitValidForm()
expect(updateMock.callHistory.called()).to.be.true
expect(
JSON.parse(updateMock.callHistory.calls().at(-1)?.options.body as string)
).to.deep.equal({
currentPassword: 'foobar',
newPassword1: 'barbaz',
newPassword2: 'barbaz',
})
})
it('disables button on invalid form', async function () {
const updateMock = fetchMock.post('/user/password/update', 200)
render(<PasswordSection />)
fireEvent.click(
screen.getByRole('button', {
name: 'Change',
})
)
expect(updateMock.callHistory.called()).to.be.false
})
it('validates inputs', async function () {
render(<PasswordSection />)
const button = screen.getByRole('button', {
name: 'Change',
}) as HTMLButtonElement
expect(button.disabled).to.be.true
fireEvent.change(screen.getByLabelText('Current password'), {
target: { value: 'foobar' },
})
expect(button.disabled).to.be.true
fireEvent.change(screen.getByLabelText('New password'), {
target: { value: 'barbaz' },
})
expect(button.disabled).to.be.true
fireEvent.change(screen.getByLabelText('Confirm new password'), {
target: { value: 'bar' },
})
screen.getByText('Doesnt match')
expect(button.disabled).to.be.true
fireEvent.change(screen.getByLabelText('Confirm new password'), {
target: { value: 'barbaz' },
})
expect(button.disabled).to.be.false
})
it('sets browser validation attributes', async function () {
window.metaAttributesCache.set('ol-passwordStrengthOptions', {
length: {
min: 3,
},
})
render(<PasswordSection />)
const currentPasswordInput = screen.getByLabelText(
'Current password'
) as HTMLInputElement
const newPassword1Input = screen.getByLabelText(
'New password'
) as HTMLInputElement
const newPassword2Input = screen.getByLabelText(
'Confirm new password'
) as HTMLInputElement
expect(newPassword1Input.minLength).to.equal(3)
// not required before changes
expect(currentPasswordInput.required).to.be.false
expect(newPassword1Input.required).to.be.false
expect(newPassword2Input.required).to.be.false
fireEvent.change(currentPasswordInput, {
target: { value: 'foobar' },
})
fireEvent.change(newPassword1Input, {
target: { value: 'barbaz' },
})
fireEvent.change(newPassword2Input, {
target: { value: 'barbaz' },
})
expect(currentPasswordInput.required).to.be.true
expect(newPassword1Input.required).to.be.true
expect(newPassword2Input.required).to.be.true
})
it('shows inflight state and success message', async function () {
let finishUpdateCall: (value: any) => void = () => {}
fetchMock.post(
'/user/password/update',
new Promise(resolve => (finishUpdateCall = resolve))
)
render(<PasswordSection />)
submitValidForm()
await screen.findByRole('button', { name: 'Saving…' })
finishUpdateCall({
status: 200,
body: {
message: {
type: 'success',
email: 'tim.alby@overleaf.com',
text: 'Password changed',
},
},
})
await screen.findByRole('button', {
name: 'Change',
})
screen.getByText('Password changed')
})
it('shows server error', async function () {
fetchMock.post('/user/password/update', 500)
render(<PasswordSection />)
submitValidForm()
await screen.findByText('Something went wrong. Please try again.')
})
it('shows server error message', async function () {
fetchMock.post('/user/password/update', {
status: 400,
body: {
message: 'Your old password is wrong',
},
})
render(<PasswordSection />)
submitValidForm()
await screen.findByText('Your old password is wrong')
})
it('shows message when user cannot use password log in', async function () {
window.metaAttributesCache.set('ol-cannot-change-password', true)
render(<PasswordSection />)
await screen.findByRole('heading', { name: 'Change Password' })
screen.getByText(
'You cant add or change your password because your group or organization uses',
{ exact: false }
)
screen.getByRole('link', { name: 'single sign-on (SSO)' })
})
})
function submitValidForm() {
fireEvent.change(screen.getByLabelText('Current password'), {
target: { value: 'foobar' },
})
fireEvent.change(screen.getByLabelText('New password'), {
target: { value: 'barbaz' },
})
fireEvent.change(screen.getByLabelText('Confirm new password'), {
target: { value: 'barbaz' },
})
fireEvent.click(
screen.getByRole('button', {
name: 'Change',
})
)
}

View File

@@ -0,0 +1,83 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { screen, render, waitFor } from '@testing-library/react'
import * as eventTracking from '@/infrastructure/event-tracking'
import SettingsPageRoot from '../../../../../frontend/js/features/settings/components/root'
import getMeta from '@/utils/meta'
describe('<SettingsPageRoot />', function () {
let sendMBSpy: sinon.SinonSpy
beforeEach(function () {
window.metaAttributesCache.set('ol-usersEmail', 'foo@bar.com')
Object.assign(getMeta('ol-ExposedSettings'), { isOverleaf: true })
window.metaAttributesCache.set('ol-hasPassword', true)
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
isOverleaf: true,
})
window.metaAttributesCache.set('ol-user', {
features: { github: true, dropbox: true, mendeley: true, zotero: true },
refProviders: {
mendeley: true,
zotero: true,
},
})
window.metaAttributesCache.set('ol-github', { enabled: true })
window.metaAttributesCache.set('ol-dropbox', { registered: true })
window.metaAttributesCache.set('ol-oauthProviders', {})
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
})
afterEach(function () {
sendMBSpy.restore()
})
it('displays page for Overleaf', async function () {
render(<SettingsPageRoot />)
await waitFor(() => {
screen.getByText('Account Settings')
})
screen.getByText('Emails and Affiliations')
screen.getByText('Update Account Info')
screen.getByText('Change Password')
screen.getByText('Integrations')
screen.getByText('Overleaf Beta Program')
screen.getByText('Sessions')
screen.getByText('Newsletter')
screen.getByRole('button', {
name: 'Delete your account',
})
})
it('displays page for non-Overleaf', async function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: false,
isOverleaf: false,
})
render(<SettingsPageRoot />)
await waitFor(() => {
screen.getByText('Account Settings')
})
expect(screen.queryByText('Emails and Affiliations')).to.not.exist
screen.getByText('Update Account Info')
screen.getByText('Change Password')
screen.getByText('Integrations')
expect(screen.queryByText('Overleaf Beta Program')).to.not.exist
screen.getByText('Sessions')
expect(screen.queryByText('Newsletter')).to.not.exist
expect(
screen.queryByRole('button', {
name: 'Delete your account',
})
).to.not.exist
})
it('sends tracking event on load', async function () {
render(<SettingsPageRoot />)
sinon.assert.calledOnce(sendMBSpy)
sinon.assert.calledWith(sendMBSpy, 'settings-view')
})
})

View File

@@ -0,0 +1,40 @@
import SecuritySection from '@/features/settings/components/security-section'
import { expect } from 'chai'
import { screen, render } from '@testing-library/react'
import fetchMock from 'fetch-mock'
describe('<SecuritySection />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows Group SSO rows in security section', async function () {
window.metaAttributesCache.set('ol-memberOfSSOEnabledGroups', [
{
groupId: 'abc123abc123',
linked: true,
},
{
groupId: 'fff999fff999',
linked: false,
},
])
render(<SecuritySection />)
expect(screen.getAllByText('Single Sign-On (SSO)').length).to.equal(2)
const link = screen.getByRole('button', {
name: /Set up SSO/i,
})
expect(link).to.exist
expect(link.getAttribute('href')).to.equal(
'/subscription/fff999fff999/sso_enrollment'
)
})
it('does not show the security section with no groups with SSO enabled', async function () {
window.metaAttributesCache.set('ol-memberOfSSOEnabledGroups', [])
render(<SecuritySection />)
expect(screen.queryByText('Security')).to.not.exist
})
})

View File

@@ -0,0 +1,16 @@
import { expect } from 'chai'
import { screen, render } from '@testing-library/react'
import SessionsSection from '../../../../../frontend/js/features/settings/components/sessions-section'
describe('<SessionsSection />', function () {
it('shows link to sessions', async function () {
render(<SessionsSection />)
const link = screen.getByRole('link', {
name: 'Manage Your Sessions',
})
expect(link.getAttribute('href')).to.equal('/user/sessions')
})
})

View File

@@ -0,0 +1,82 @@
import { expect } from 'chai'
import { renderHook } from '@testing-library/react-hooks'
import {
SSOProvider,
useSSOContext,
} from '../../../../../frontend/js/features/settings/context/sso-context'
import fetchMock from 'fetch-mock'
const mockOauthProviders = {
google: {
descriptionKey: 'login_with_service',
descriptionOptions: { service: 'Google' },
name: 'Google',
linkPath: '/auth/google',
},
orcid: {
descriptionKey: 'oauth_orcid_description',
descriptionOptions: {
link: '/blog/434',
appName: 'Overleaf',
},
name: 'ORCID',
linkPath: '/auth/orcid',
},
}
describe('SSOContext', function () {
const renderSSOContext = () =>
renderHook(() => useSSOContext(), {
wrapper: ({ children }) => <SSOProvider>{children}</SSOProvider>,
})
beforeEach(function () {
window.metaAttributesCache.set('ol-thirdPartyIds', {
google: 'google-id',
})
window.metaAttributesCache.set('ol-oauthProviders', mockOauthProviders)
fetchMock.removeRoutes().clearHistory()
})
it('should initialise subscriptions with their linked status', function () {
const { result } = renderSSOContext()
expect(result.current.subscriptions).to.deep.equal({
google: {
providerId: 'google',
provider: mockOauthProviders.google,
linked: true,
},
orcid: {
providerId: 'orcid',
provider: mockOauthProviders.orcid,
linked: false,
},
})
})
describe('unlink', function () {
beforeEach(function () {
fetchMock.post('express:/user/oauth-unlink', 200)
})
it('should unlink an existing subscription', async function () {
const { result, waitForNextUpdate } = renderSSOContext()
result.current.unlink('google')
await waitForNextUpdate()
expect(result.current.subscriptions.google.linked).to.be.false
})
it('when the provider is not linked, should do nothing', function () {
const { result } = renderSSOContext()
result.current.unlink('orcid')
expect(fetchMock.callHistory.called()).to.be.false
})
it('supports unmounting the component while the request is inflight', async function () {
const { result, unmount } = renderSSOContext()
result.current.unlink('google')
expect(fetchMock.callHistory.called()).to.be.true
unmount()
})
})
})

View File

@@ -0,0 +1,364 @@
import { expect } from 'chai'
import { cloneDeep } from 'lodash'
import { renderHook } from '@testing-library/react-hooks'
import { waitFor } from '@testing-library/react'
import {
EmailContextType,
UserEmailsProvider,
useUserEmailsContext,
} from '../../../../../frontend/js/features/settings/context/user-email-context'
import fetchMock from 'fetch-mock'
import {
confirmedUserData,
professionalUserData,
unconfirmedUserData,
fakeUsersData,
unconfirmedCommonsUserData,
untrustedUserData,
} from '../fixtures/test-user-email-data'
import localStorage from '@/infrastructure/local-storage'
const renderUserEmailsContext = () =>
renderHook(() => useUserEmailsContext(), {
wrapper: ({ children }) => (
<UserEmailsProvider>{children}</UserEmailsProvider>
),
})
describe('UserEmailContext', function () {
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
})
describe('context bootstrap', function () {
it('should start with an "in progress" initialisation state', function () {
const { result } = renderUserEmailsContext()
expect(result.current.isInitializing).to.equal(true)
expect(result.current.isInitializingSuccess).to.equal(false)
expect(result.current.isInitializingError).to.equal(false)
})
it('should start with an empty state', function () {
const { result } = renderUserEmailsContext()
expect(result.current.state.data.byId).to.deep.equal({})
expect(result.current.state.data.emailAffiliationBeingEdited).to.be.null
expect(result.current.state.data.linkedInstitutionIds).to.have.length(0)
})
it('should load all user emails and update the initialisation state to "success"', async function () {
fetchMock.get(/\/user\/emails/, fakeUsersData)
const { result } = renderUserEmailsContext()
await fetchMock.callHistory.flush(true)
expect(fetchMock.callHistory.calls()).to.have.lengthOf(1)
expect(result.current.state.data.byId).to.deep.equal({
'bar@overleaf.com': { ...untrustedUserData, ...confirmedUserData },
'baz@overleaf.com': unconfirmedUserData,
'foo@overleaf.com': professionalUserData,
'qux@overleaf.com': unconfirmedCommonsUserData,
})
expect(result.current.state.data.linkedInstitutionIds).to.have.lengthOf(0)
expect(result.current.isInitializing).to.equal(false)
expect(result.current.isInitializingSuccess).to.equal(true)
})
it('when loading user email fails, it should update the initialisation state to "failed"', async function () {
fetchMock.get(/\/user\/emails/, 500)
const { result } = renderUserEmailsContext()
await fetchMock.callHistory.flush()
await waitFor(() => {
expect(result.current.isInitializing).to.equal(false)
expect(result.current.isInitializingError).to.equal(true)
})
})
describe('state.isLoading', function () {
it('should be `true` on bootstrap', function () {
const { result } = renderUserEmailsContext()
expect(result.current.state.isLoading).to.equal(true)
})
it('should be updated with `setLoading`', function () {
const { result } = renderUserEmailsContext()
result.current.setLoading(true)
expect(result.current.state.isLoading).to.equal(true)
result.current.setLoading(false)
expect(result.current.state.isLoading).to.equal(false)
})
})
})
describe('context initialised', function () {
let result: { current: EmailContextType }
beforeEach(async function () {
fetchMock.get(/\/user\/emails/, fakeUsersData)
const value = renderUserEmailsContext()
result = value.result
await fetchMock.callHistory.flush(true)
})
describe('getEmails()', function () {
beforeEach(async function () {
fetchMock.removeRoutes().clearHistory()
})
it('should set `isLoading === true`', function () {
fetchMock.get(/\/user\/emails/, [
{
email: 'new@email.com',
default: true,
},
])
result.current.getEmails()
expect(result.current.state.isLoading).to.be.true
})
it('requests a new set of emails', async function () {
const emailData = {
email: 'new@email.com',
default: true,
}
fetchMock.get(/\/user\/emails/, [emailData])
result.current.getEmails()
await fetchMock.callHistory.flush(true)
expect(result.current.state.data.byId).to.deep.equal({
'new@email.com': emailData,
})
})
it('should populate `linkedInstitutionIds`', async function () {
fetchMock.get(/\/user\/emails/, [
confirmedUserData,
{ ...unconfirmedUserData, samlProviderId: 'saml_provider_1' },
{ ...professionalUserData, samlProviderId: 'saml_provider_2' },
])
const { result } = renderUserEmailsContext()
await fetchMock.callHistory.flush(true)
expect(result.current.state.data.linkedInstitutionIds).to.deep.equal([
'saml_provider_1',
'saml_provider_2',
])
})
})
describe('makePrimary()', function () {
it('sets an email as `default`', function () {
expect(result.current.state.data.byId['bar@overleaf.com'].default).to.be
.false
result.current.makePrimary('bar@overleaf.com')
expect(result.current.state.data.byId['bar@overleaf.com'].default).to.be
.true
})
it('sets `default=false` for the current primary email ', function () {
expect(result.current.state.data.byId['foo@overleaf.com'].default).to.be
.true
result.current.makePrimary('bar@overleaf.com')
expect(result.current.state.data.byId['foo@overleaf.com'].default).to.be
.false
})
it('produces no effect when passing a non-existing email', function () {
const emails = cloneDeep(result.current.state.data.byId)
result.current.makePrimary('non-existing@email.com')
expect(result.current.state.data.byId).to.deep.equal(emails)
})
})
describe('deleteEmail()', function () {
it('removes data from the deleted email', function () {
result.current.deleteEmail('bar@overleaf.com')
expect(result.current.state.data.byId['bar@overleaf.com']).to.be
.undefined
})
it('produces no effect when passing a non-existing email', function () {
const emails = cloneDeep(result.current.state.data.byId)
result.current.deleteEmail('non-existing@email.com')
expect(result.current.state.data.byId).to.deep.equal(emails)
})
})
describe('setEmailAffiliationBeingEdited()', function () {
it('sets an email as currently being edited', function () {
result.current.setEmailAffiliationBeingEdited('bar@overleaf.com')
expect(result.current.state.data.emailAffiliationBeingEdited).to.equal(
'bar@overleaf.com'
)
result.current.setEmailAffiliationBeingEdited(null)
expect(result.current.state.data.emailAffiliationBeingEdited).to.be.null
})
it('produces no effect when passing a non-existing email', function () {
expect(result.current.state.data.emailAffiliationBeingEdited).to.be.null
result.current.setEmailAffiliationBeingEdited('non-existing@email.com')
expect(result.current.state.data.emailAffiliationBeingEdited).to.be.null
})
})
describe('updateAffiliation()', function () {
it('updates affiliation data for an email', function () {
result.current.updateAffiliation(
'foo@overleaf.com',
'new role',
'new department'
)
expect(
result.current.state.data.byId['foo@overleaf.com'].affiliation!.role
).to.equal('new role')
expect(
result.current.state.data.byId['foo@overleaf.com'].affiliation!
.department
).to.equal('new department')
})
it('clears an email from currently being edited', function () {
result.current.setEmailAffiliationBeingEdited('foo@overleaf.com')
result.current.updateAffiliation(
'foo@overleaf.com',
'new role',
'new department'
)
expect(result.current.state.data.emailAffiliationBeingEdited).to.be.null
})
it('produces no effect when passing an email with no affiliation', function () {
const emails = cloneDeep(result.current.state.data.byId)
result.current.updateAffiliation(
'bar@overleaf.com',
'new role',
'new department'
)
expect(result.current.state.data.byId).to.deep.equal(emails)
})
it('produces no effect when passing a non-existing email', function () {
const emails = cloneDeep(result.current.state.data.byId)
result.current.updateAffiliation(
'non-existing@email.com',
'new role',
'new department'
)
expect(result.current.state.data.byId).to.deep.equal(emails)
})
})
describe('resetLeaversSurveyExpiration()', function () {
beforeEach(function () {
localStorage.removeItem('showInstitutionalLeaversSurveyUntil')
})
it('when the leaver has institution license, and there is another email with institution license, it should not reset the survey expiration date', async function () {
const affiliatedEmail1 = cloneDeep(professionalUserData)
affiliatedEmail1.email = 'institution-test@example.com'
affiliatedEmail1.emailHasInstitutionLicence = true
const affiliatedEmail2 = cloneDeep(professionalUserData)
affiliatedEmail2.emailHasInstitutionLicence = true
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [affiliatedEmail1, affiliatedEmail2])
result.current.getEmails()
await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(affiliatedEmail1.email)
result.current.resetLeaversSurveyExpiration(affiliatedEmail1)
const expiration = localStorage.getItem(
'showInstitutionalLeaversSurveyUntil'
) as number
expect(expiration).to.be.null
})
it("when the leaver's affiliation is past reconfirmation date, and there is another email with institution license, it should not reset the survey expiration date", async function () {
const affiliatedEmail1 = cloneDeep(professionalUserData)
affiliatedEmail1.email = 'institution-test@example.com'
affiliatedEmail1.affiliation.pastReconfirmDate = true
const affiliatedEmail2 = cloneDeep(professionalUserData)
affiliatedEmail2.emailHasInstitutionLicence = true
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [affiliatedEmail1, affiliatedEmail2])
result.current.getEmails()
await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(affiliatedEmail1.email)
result.current.resetLeaversSurveyExpiration(affiliatedEmail1)
const expiration = localStorage.getItem(
'showInstitutionalLeaversSurveyUntil'
) as number
expect(expiration).to.be.null
})
it('when there are no other emails with institution license, it should reset the survey expiration date', async function () {
const affiliatedEmail1 = cloneDeep(professionalUserData)
affiliatedEmail1.emailHasInstitutionLicence = true
affiliatedEmail1.email = 'institution-test@example.com'
affiliatedEmail1.affiliation.pastReconfirmDate = true
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [confirmedUserData, affiliatedEmail1])
result.current.getEmails()
await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(affiliatedEmail1.email)
result.current.resetLeaversSurveyExpiration(affiliatedEmail1)
expect(
localStorage.getItem('showInstitutionalLeaversSurveyUntil')
).to.be.greaterThan(Date.now())
})
it("when the leaver has no institution license, it shouldn't reset the survey expiration date", async function () {
const emailWithInstitutionLicense = cloneDeep(professionalUserData)
emailWithInstitutionLicense.email = 'institution-licensed@example.com'
emailWithInstitutionLicense.emailHasInstitutionLicence = false
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [emailWithInstitutionLicense])
result.current.getEmails()
await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(emailWithInstitutionLicense.email)
result.current.resetLeaversSurveyExpiration(professionalUserData)
expect(localStorage.getItem('showInstitutionalLeaversSurveyUntil')).to
.be.null
})
it("when the leaver is not past its reconfirmation date, it shouldn't reset the survey expiration date", async function () {
const emailWithInstitutionLicense = cloneDeep(professionalUserData)
emailWithInstitutionLicense.email = 'institution-licensed@example.com'
emailWithInstitutionLicense.affiliation.pastReconfirmDate = false
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/\/user\/emails/, [emailWithInstitutionLicense])
result.current.getEmails()
await fetchMock.callHistory.flush(true)
// `resetLeaversSurveyExpiration` always happens after deletion
result.current.deleteEmail(emailWithInstitutionLicense.email)
result.current.resetLeaversSurveyExpiration(professionalUserData)
expect(localStorage.getItem('showInstitutionalLeaversSurveyUntil')).to
.be.null
})
})
})
})

View File

@@ -0,0 +1,123 @@
import { UserEmailData } from '../../../../../types/user-email'
import { Affiliation } from '../../../../../types/affiliation'
export const confirmedUserData: UserEmailData = {
confirmedAt: '2022-03-10T10:59:44.139Z',
email: 'bar@overleaf.com',
default: false,
}
export const unconfirmedUserData: UserEmailData = {
email: 'baz@overleaf.com',
default: false,
}
export const untrustedUserData = {
...confirmedUserData,
lastConfirmedAt: '2024-01-01T10:59:44.139Z',
}
export const professionalUserData: UserEmailData & {
affiliation: Affiliation
} = {
affiliation: {
cachedConfirmedAt: null,
cachedEntitlement: null,
cachedLastDayToReconfirm: null,
cachedPastReconfirmDate: false,
cachedReconfirmedAt: null,
department: 'Art History',
institution: {
commonsAccount: false,
writefullCommonsAccount: false,
confirmed: true,
id: 1,
isUniversity: false,
maxConfirmationMonths: null,
name: 'Overleaf',
ssoEnabled: false,
ssoBeta: false,
},
inReconfirmNotificationPeriod: false,
inferred: false,
licence: 'pro_plus',
pastReconfirmDate: false,
portal: { slug: '', templates_count: 1 },
role: 'Reader',
},
confirmedAt: '2022-03-09T10:59:44.139Z',
email: 'foo@overleaf.com',
default: true,
}
export const unconfirmedCommonsUserData: UserEmailData & {
affiliation: Affiliation
} = {
affiliation: {
cachedConfirmedAt: null,
cachedEntitlement: null,
cachedLastDayToReconfirm: null,
cachedPastReconfirmDate: false,
cachedReconfirmedAt: null,
department: 'Art History',
institution: {
commonsAccount: true,
writefullCommonsAccount: false,
confirmed: true,
id: 1,
isUniversity: false,
maxConfirmationMonths: null,
name: 'Overleaf',
ssoEnabled: false,
ssoBeta: false,
},
inReconfirmNotificationPeriod: false,
inferred: false,
licence: 'free',
pastReconfirmDate: false,
portal: { slug: '', templates_count: 1 },
role: 'Reader',
},
email: 'qux@overleaf.com',
default: true,
}
export const ssoUserData: UserEmailData = {
affiliation: {
cachedConfirmedAt: '2022-02-03T11:46:28.249Z',
cachedEntitlement: null,
cachedLastDayToReconfirm: null,
cachedPastReconfirmDate: false,
cachedReconfirmedAt: null,
department: 'Art History',
institution: {
commonsAccount: true,
writefullCommonsAccount: false,
confirmed: true,
id: 2,
isUniversity: true,
maxConfirmationMonths: 12,
name: 'SSO University',
ssoEnabled: true,
ssoBeta: false,
},
inReconfirmNotificationPeriod: false,
inferred: false,
licence: 'pro_plus',
pastReconfirmDate: false,
portal: { slug: '', templates_count: 0 },
role: 'Prof',
},
confirmedAt: '2022-02-03T11:46:28.249Z',
email: 'sso-prof@sso-university.edu',
samlProviderId: 'sso-prof-saml-id',
default: false,
}
export const fakeUsersData = [
{ ...confirmedUserData },
{ ...unconfirmedUserData },
{ ...untrustedUserData },
{ ...professionalUserData },
{ ...unconfirmedCommonsUserData },
]