import { vi, expect } from 'vitest'
import { setTimeout } from 'node:timers/promises'
import path from 'node:path'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import tk from 'timekeeper'
import MongoHelpers from '../../../../app/src/Features/Helpers/Mongo.mjs'
import Errors from '../../../../app/src/Features/Errors/Errors.js'

const { normalizeQuery } = MongoHelpers
const { ObjectId } = mongodb

const MODULE_PATH = path.join(
  import.meta.dirname,
  '../../../../app/src/Features/User/UserUpdater'
)

vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)

describe('UserUpdater', function () {
  beforeEach(async function (ctx) {
    tk.freeze(Date.now())

    ctx.user = {
      _id: new ObjectId(),
      name: 'bob',
      email: 'hello@world.com',
      emails: [{ email: 'hello@world.com' }],
    }

    ctx.db = {
      users: {
        updateOne: sinon.stub().resolves({ matchedCount: 1, modifiedCount: 1 }),
      },
    }
    ctx.mongodb = {
      db: ctx.db,
      ObjectId,
    }

    ctx.UserGetter = {
      promises: {
        ensureUniqueEmailAddress: sinon.stub().resolves(),
        getUser: sinon.stub(),
        getUserByMainEmail: sinon.stub(),
        getUserFullEmails: sinon.stub(),
        getUserEmail: sinon.stub(),
      },
    }
    ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
    ctx.UserGetter.promises.getUserByMainEmail
      .withArgs(ctx.user.email)
      .resolves(ctx.user)
    ctx.UserGetter.promises.getUserFullEmails
      .withArgs(ctx.user._id)
      .resolves(ctx.user.emails)
    ctx.UserGetter.promises.getUserEmail
      .withArgs(ctx.user._id)
      .resolves(ctx.user.email)

    ctx.AnalyticsManager = {
      recordEventForUserInBackground: sinon.stub(),
    }
    ctx.InstitutionsAPI = {
      promises: {
        addAffiliation: sinon.stub().resolves(),
        removeAffiliation: sinon.stub().resolves(),
        getUserAffiliations: sinon.stub().resolves(),
      },
    }
    ctx.EmailHandler = {
      promises: {
        sendEmail: sinon.stub().resolves(),
      },
    }
    ctx.Features = {
      hasFeature: sinon.stub().returns(false),
    }
    ctx.FeaturesUpdater = {
      promises: {
        refreshFeatures: sinon.stub().resolves(),
      },
    }
    ctx.UserAuditLogHandler = {
      promises: {
        addEntry: sinon.stub().resolves(),
      },
    }

    ctx.SubscriptionLocator = {
      promises: {
        getUserIndividualSubscription: sinon.stub().resolves(),
      },
    }

    ctx.NotificationsBuilder = {
      promises: {
        redundantPersonalSubscription: sinon
          .stub()
          .returns({ create: () => {} }),
      },
    }

    ctx.Modules = {
      promises: {
        hooks: {
          fire: sinon.stub().resolves([]),
        },
      },
    }

    ctx.UserSessionsManager = {
      promises: {
        removeSessionsFromRedis: sinon.stub().resolves(),
      },
    }

    ctx.AsyncLocalStorage = {
      removeItem: sinon.stub(),
    }

    vi.doMock('../../../../app/src/Features/Helpers/Mongo', () => ({
      default: { normalizeQuery },
    }))

    vi.doMock('../../../../app/src/infrastructure/mongodb', () => ctx.mongodb)

    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
      default: ctx.UserGetter,
    }))

    vi.doMock(
      '../../../../app/src/Features/Institutions/InstitutionsAPI',
      () => ({
        default: ctx.InstitutionsAPI,
      })
    )

    vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
      default: ctx.EmailHandler,
    }))

    vi.doMock('../../../../app/src/infrastructure/Features', () => ({
      default: ctx.Features,
    }))

    vi.doMock(
      '../../../../app/src/Features/Subscription/FeaturesUpdater',
      () => ({
        default: ctx.FeaturesUpdater,
      })
    )

    vi.doMock('@overleaf/settings', () => ({
      default: (ctx.settings = {}),
    }))

    vi.doMock(
      '../../../../app/src/Features/Subscription/RecurlyWrapper',
      () => ({
        default: ctx.RecurlyWrapper,
      })
    )

    vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
      default: ctx.UserAuditLogHandler,
    }))

    vi.doMock(
      '../../../../app/src/Features/Analytics/AnalyticsManager',
      () => ({
        default: ctx.AnalyticsManager,
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Subscription/SubscriptionLocator',
      () => ({
        default: ctx.SubscriptionLocator,
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Notifications/NotificationsBuilder',
      () => ({
        default: ctx.NotificationsBuilder,
      })
    )

    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
      default: ctx.Modules,
    }))

    vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({
      default: ctx.UserSessionsManager,
    }))

    vi.doMock(
      '../../../../app/src/Features/User/ThirdPartyIdentityManager',
      () => ({
        default: ctx.ThirdPartyIdentityManager,
      })
    )

    vi.doMock('../../../../app/src/infrastructure/AsyncLocalStorage', () => ({
      default: ctx.AsyncLocalStorage,
    }))

    ctx.UserUpdater = (await import(MODULE_PATH)).default

    ctx.newEmail = 'bob@bob.com'
  })

  afterEach(function () {
    return tk.reset()
  })

  describe('addAffiliationForNewUser', function () {
    it('should not remove affiliationUnchecked flag if v1 returns an error', async function (ctx) {
      ctx.InstitutionsAPI.promises.addAffiliation.rejects()
      await expect(
        ctx.UserUpdater.promises.addAffiliationForNewUser(
          ctx.user._id,
          ctx.newEmail
        )
      ).to.be.rejected
      sinon.assert.notCalled(ctx.db.users.updateOne)
    })

    it('should remove affiliationUnchecked flag if v1 does not return an error', async function (ctx) {
      await ctx.UserUpdater.promises.addAffiliationForNewUser(
        ctx.user._id,
        ctx.newEmail
      )
      sinon.assert.calledOnce(ctx.db.users.updateOne)
      sinon.assert.calledWithMatch(
        ctx.db.users.updateOne,
        { _id: ctx.user._id, 'emails.email': ctx.newEmail },
        { $unset: { 'emails.$.affiliationUnchecked': 1 } }
      )
    })

    it('should not throw if removing affiliationUnchecked flag errors', async function (ctx) {
      ctx.db.users.updateOne.rejects(new Error('nope'))
      await ctx.UserUpdater.promises.addAffiliationForNewUser(
        ctx.user._id,
        ctx.newEmail
      )
    })

    it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) {
      await ctx.UserUpdater.promises.addAffiliationForNewUser(
        ctx.user._id,
        ctx.newEmail
      )
      expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith(
        'userFullEmails'
      )
    })
  })

  describe('changeEmailAddress', function () {
    beforeEach(async function (ctx) {
      ctx.auditLog = {
        initiatorId: 'abc123',
        ipAddress: '0:0:0:0',
      }
      // After the email changed, make sure that UserGetter.getUser() returns a
      // user with the new email.
      ctx.UserGetter.promises.getUser
        .withArgs(ctx.user._id)
        .onCall(1)
        .resolves({
          ...ctx.user,
          emails: [...ctx.user.emails, { email: ctx.newEmail }],
        })
      // The main email changes as a result of the email change
      ctx.UserGetter.promises.getUserByMainEmail
        .withArgs(ctx.user.email)
        .resolves(null)
      ctx.user.emails.push({ email: ctx.newEmail })
      await ctx.UserUpdater.promises.changeEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        ctx.auditLog
      )
    })

    it('adds the new email', function (ctx) {
      expect(ctx.db.users.updateOne).to.have.been.calledWith(
        { _id: ctx.user._id, 'emails.email': { $ne: ctx.newEmail } },
        {
          $push: {
            emails: sinon.match({ email: ctx.newEmail }),
          },
        }
      )
    })

    it('adds the new affiliation', function (ctx) {
      ctx.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith(
        ctx.user._id,
        ctx.newEmail
      )
    })

    it('removes the old email', function (ctx) {
      expect(ctx.db.users.updateOne).to.have.been.calledWith(
        { _id: ctx.user._id, email: { $ne: ctx.user.email } },
        { $pull: { emails: { email: ctx.user.email } } }
      )
    })

    it('removes the affiliation', function (ctx) {
      expect(
        ctx.InstitutionsAPI.promises.removeAffiliation
      ).to.have.been.calledWith(ctx.user._id, ctx.user.email)
    })

    it('refreshes features', function (ctx) {
      sinon.assert.calledWith(
        ctx.FeaturesUpdater.promises.refreshFeatures,
        ctx.user._id
      )
    })

    it('sets the default email', function (ctx) {
      expect(ctx.db.users.updateOne).to.have.been.calledWith(
        { _id: ctx.user._id, 'emails.email': ctx.newEmail },
        {
          $set: sinon.match({
            email: ctx.newEmail,
          }),
        }
      )
    })

    it('fires userEmailChanged hook', function (ctx) {
      expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
        'userEmailChanged',
        ctx.user,
        ctx.newEmail
      )
      expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
        'updateAccountEmailAddress',
        ctx.user._id,
        ctx.newEmail
      )
    })

    it('validates email', async function (ctx) {
      await expect(
        ctx.UserUpdater.promises.changeEmailAddress(
          ctx.user._id,
          'foo',
          ctx.auditLog
        )
      ).to.be.rejected
    })
  })

  describe('addEmailAddress', function () {
    it('adds the email', async function (ctx) {
      await ctx.UserUpdater.promises.addEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        {},
        { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
      )
      ctx.UserGetter.promises.ensureUniqueEmailAddress.should.have.been.called
      const reversedHostname = ctx.newEmail
        .split('@')[1]
        .split('')
        .reverse()
        .join('')
      ctx.db.users.updateOne.should.have.been.calledWith(
        { _id: ctx.user._id, 'emails.email': { $ne: ctx.newEmail } },
        {
          $push: {
            emails: {
              email: ctx.newEmail,
              createdAt: sinon.match.date,
              reversedHostname,
            },
          },
        }
      )
    })

    it('adds the affiliation', async function (ctx) {
      const affiliationOptions = {
        university: { id: 1 },
        role: 'Prof',
        department: 'Math',
      }
      await ctx.UserUpdater.promises.addEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        affiliationOptions,
        { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
      )
      ctx.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith(
        ctx.user._id,
        ctx.newEmail,
        affiliationOptions
      )
    })

    it('handles affiliation errors', async function (ctx) {
      ctx.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope'))
      await expect(
        ctx.UserUpdater.promises.addEmailAddress(
          ctx.user._id,
          ctx.newEmail,
          {},
          { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
        )
      ).to.be.rejected
      ctx.db.users.updateOne.should.not.have.been.called
    })

    it('validates the email', async function (ctx) {
      expect(
        ctx.UserUpdater.promises.addEmailAddress(
          ctx.user._id,
          'bar',
          {},
          { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
        )
      ).to.be.rejected
    })

    it('updates the audit log', async function (ctx) {
      ctx.ip = '127:0:0:0'
      await ctx.UserUpdater.promises.addEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        {},
        { initiatorId: ctx.user._id, ipAddress: ctx.ip }
      )
      ctx.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true)
      const { args } = ctx.UserAuditLogHandler.promises.addEntry.lastCall
      expect(args[0]).to.equal(ctx.user._id)
      expect(args[1]).to.equal('add-email')
      expect(args[2]).to.equal(ctx.user._id)
      expect(args[3]).to.equal(ctx.ip)
      expect(args[4]).to.deep.equal({ newSecondaryEmail: ctx.newEmail })
    })

    describe('errors', function () {
      describe('via UserAuditLogHandler', function () {
        const anError = new Error('oops')
        beforeEach(function (ctx) {
          ctx.UserAuditLogHandler.promises.addEntry.rejects(anError)
        })
        it('should not add email and should return error', async function (ctx) {
          await expect(
            ctx.UserUpdater.promises.addEmailAddress(
              ctx.user._id,
              ctx.newEmail,
              {},
              { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
            )
          ).to.be.rejectedWith(anError)
          expect(ctx.db.users.updateOne).to.not.have.been.called
        })
      })
    })

    it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) {
      await ctx.UserUpdater.promises.addEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        {},
        { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
      )
      expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith(
        'userFullEmails'
      )
    })
  })

  describe('removeEmailAddress', function () {
    beforeEach(function (ctx) {
      ctx.auditLog = { initiatorId: ctx.user._id, ipAddress: '127:0:0:0' }
    })
    it('removes the email', async function (ctx) {
      await ctx.UserUpdater.promises.removeEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        ctx.auditLog
      )
      expect(ctx.db.users.updateOne).to.have.been.calledWith(
        { _id: ctx.user._id, email: { $ne: ctx.newEmail } },
        { $pull: { emails: { email: ctx.newEmail } } }
      )
    })

    it('removes the affiliation', async function (ctx) {
      await ctx.UserUpdater.promises.removeEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        ctx.auditLog
      )
      expect(ctx.InstitutionsAPI.promises.removeAffiliation).to.have.been
        .calledOnce
      const { args } = ctx.InstitutionsAPI.promises.removeAffiliation.lastCall
      args[0].should.equal(ctx.user._id)
      args[1].should.equal(ctx.newEmail)
    })

    it('refreshes features', async function (ctx) {
      await ctx.UserUpdater.promises.removeEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        ctx.auditLog
      )
      sinon.assert.calledWith(
        ctx.FeaturesUpdater.promises.refreshFeatures,
        ctx.user._id
      )
    })

    it('handles Mongo errors', async function (ctx) {
      const anError = new Error('nope')
      ctx.db.users.updateOne.rejects(anError)

      await expect(
        ctx.UserUpdater.promises.removeEmailAddress(
          ctx.user._id,
          ctx.newEmail,
          ctx.auditLog
        )
      ).to.be.rejected
      expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
        .called
    })

    it('handles missed update', async function (ctx) {
      ctx.db.users.updateOne.resolves({ matchedCount: 0 })

      await expect(
        ctx.UserUpdater.promises.removeEmailAddress(
          ctx.user._id,
          ctx.newEmail,
          ctx.auditLog
        )
      ).to.be.rejectedWith('Cannot remove email')
      expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
        .called
    })

    it('handles an affiliation error', async function (ctx) {
      const anError = new Error('nope')
      ctx.InstitutionsAPI.promises.removeAffiliation.rejects(anError)
      await expect(
        ctx.UserUpdater.promises.removeEmailAddress(
          ctx.user._id,
          ctx.newEmail,
          ctx.auditLog
        )
      ).to.be.rejected
      expect(ctx.db.users.updateOne).not.to.have.been.called
      expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
        .called
    })

    it('throws an error when removing the primary email', async function (ctx) {
      await expect(
        ctx.UserUpdater.promises.removeEmailAddress(
          ctx.user._id,
          ctx.user.email,
          ctx.auditLog
        )
      ).to.be.rejectedWith('cannot remove primary email')
      expect(ctx.db.users.updateOne).not.to.have.been.called
      expect(ctx.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
        .called
    })

    it('validates the email', function (ctx) {
      expect(
        ctx.UserUpdater.promises.removeEmailAddress(
          ctx.user._id,
          'baz',
          ctx.auditLog
        )
      ).to.be.rejectedWith('invalid email')
    })

    it('skips email validation when skipParseEmail included', async function (ctx) {
      const skipParseEmail = true
      await ctx.UserUpdater.promises.removeEmailAddress(
        ctx.user._id,
        'baz',
        ctx.auditLog,
        skipParseEmail
      )
    })

    it('throws an error when skipParseEmail included but email is not a string', async function (ctx) {
      const skipParseEmail = true
      await expect(
        ctx.UserUpdater.promises.removeEmailAddress(
          ctx.user._id,
          1,
          ctx.auditLog,
          skipParseEmail
        )
      ).to.be.rejectedWith('email must be a string')
    })

    it('logs the removal to the audit log', async function (ctx) {
      await ctx.UserUpdater.promises.removeEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        ctx.auditLog
      )
      expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith(
        ctx.user._id,
        'remove-email',
        ctx.auditLog.initiatorId,
        ctx.auditLog.ipAddress,
        {
          removedEmail: ctx.newEmail,
        }
      )
    })

    it('logs the removal from script to the audit log', async function (ctx) {
      ctx.auditLog = {
        initiatorId: undefined,
        ipAddress: '0.0.0.0',
        extraInfo: {
          script: true,
        },
      }
      await ctx.UserUpdater.promises.removeEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        ctx.auditLog
      )
      expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith(
        ctx.user._id,
        'remove-email',
        ctx.auditLog.initiatorId,
        ctx.auditLog.ipAddress,
        {
          removedEmail: ctx.newEmail,
          script: true,
        }
      )
    })

    it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) {
      await ctx.UserUpdater.promises.removeEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        ctx.auditLog
      )
      expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith(
        'userFullEmails'
      )
    })
  })

  describe('setDefaultEmailAddress', function () {
    function setUserEmails(test, emails) {
      test.user.emails = emails
      test.UserGetter.promises.getUserFullEmails
        .withArgs(test.user._id)
        .resolves(emails)
    }

    beforeEach(function (ctx) {
      ctx.auditLog = {
        initiatorId: ctx.user,
        ipAddress: '0:0:0:0',
      }
      setUserEmails(ctx, [
        {
          email: ctx.newEmail,
          confirmedAt: new Date(),
        },
      ])
    })

    it('set default', async function (ctx) {
      await ctx.UserUpdater.promises.setDefaultEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        false,
        ctx.auditLog
      )
      expect(ctx.db.users.updateOne).to.have.been.calledWith(
        { _id: ctx.user._id, 'emails.email': ctx.newEmail },
        {
          $set: {
            email: ctx.newEmail,
            lastPrimaryEmailCheck: sinon.match.date,
          },
        }
      )
    })

    it('fires userEmailChanged hook', async function (ctx) {
      await ctx.UserUpdater.promises.setDefaultEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        false,
        ctx.auditLog
      )
      expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
        'userEmailChanged',
        ctx.user,
        ctx.newEmail
      )
      expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
        'updateAccountEmailAddress',
        ctx.user._id,
        ctx.newEmail
      )
    })

    it('handles Mongo errors', async function (ctx) {
      ctx.db.users.updateOne = sinon.stub().rejects(Error('nope'))

      await expect(
        ctx.UserUpdater.promises.setDefaultEmailAddress(
          ctx.user._id,
          ctx.newEmail,
          false,
          ctx.auditLog
        )
      ).to.be.rejected
    })

    it('handles missed updates', async function (ctx) {
      ctx.db.users.updateOne.resolves({ matchedCount: 0 })

      await expect(
        ctx.UserUpdater.promises.setDefaultEmailAddress(
          ctx.user._id,
          ctx.newEmail,
          false,
          ctx.auditLog
        )
      ).to.be.rejected
    })

    it('validates the email', async function (ctx) {
      await expect(
        ctx.UserUpdater.promises.setDefaultEmailAddress(
          ctx.user._id,
          '.edu',
          false,
          ctx.auditLog
        )
      ).to.be.rejected
    })

    it('updates the audit log', async function (ctx) {
      await ctx.UserUpdater.promises.setDefaultEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        false,
        ctx.auditLog
      )
      expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith(
        ctx.user._id,
        'change-primary-email',
        ctx.auditLog.initiatorId,
        ctx.auditLog.ipAddress,
        {
          newPrimaryEmail: ctx.newEmail,
          oldPrimaryEmail: ctx.user.email,
        }
      )
    })

    it('blocks email update if audit log returns an error', async function (ctx) {
      ctx.UserAuditLogHandler.promises.addEntry.rejects(new Error('oops'))
      await expect(
        ctx.UserUpdater.promises.setDefaultEmailAddress(
          ctx.user._id,
          ctx.newEmail,
          false,
          ctx.auditLog
        )
      ).to.be.rejected
      expect(ctx.db.users.updateOne).to.not.have.been.called
    })

    it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) {
      await ctx.UserUpdater.promises.setDefaultEmailAddress(
        ctx.user._id,
        ctx.newEmail,
        false,
        ctx.auditLog
      )
      expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith(
        'userFullEmails'
      )
    })

    describe('when email not confirmed', function () {
      beforeEach(function (ctx) {
        setUserEmails(ctx, [
          {
            email: ctx.newEmail,
            confirmedAt: null,
          },
        ])
      })

      it('should throw an error', async function (ctx) {
        await expect(
          ctx.UserUpdater.promises.setDefaultEmailAddress(
            ctx.user._id,
            ctx.newEmail,
            false,
            ctx.auditLog
          )
        ).to.be.rejectedWith(Errors.UnconfirmedEmailError)
        expect(ctx.db.users.updateOne).to.not.have.been.called
      })
    })

    describe('when email does not belong to user', function () {
      beforeEach(function (ctx) {
        setUserEmails(ctx, [])
        ctx.UserUpdater.promises.updateUser = sinon.stub()
      })

      it('should callback with error', function (ctx) {
        ctx.UserUpdater.setDefaultEmailAddress(
          ctx.user._id,
          ctx.newEmail,
          false,
          ctx.auditLog,
          error => {
            expect(error).to.exist
            expect(error.name).to.equal('Error')
            ctx.UserUpdater.promises.updateUser.callCount.should.equal(0)
          }
        )
      })
    })

    describe('security alert', function () {
      it('should be sent to old and new email when sendSecurityAlert=true', async function (ctx) {
        await ctx.UserUpdater.promises.setDefaultEmailAddress(
          ctx.user._id,
          ctx.newEmail,
          false,
          ctx.auditLog,
          true
        )
        // Emails are sent asynchronously. Wait a bit.
        await setTimeout(100)
        ctx.EmailHandler.promises.sendEmail.callCount.should.equal(2)
        for (const recipient of [ctx.user.email, ctx.newEmail]) {
          expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
            'securityAlert',
            sinon.match({ to: recipient })
          )
        }
      })

      it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails', async function (ctx) {
        setUserEmails(ctx, [
          {
            email: '1@a1.uni',
            confirmedAt: new Date(2020, 0, 1),
            reConfirmedAt: new Date(2021, 2, 11),
            lastConfirmedAt: new Date(2021, 2, 11),
            default: false,
            affiliation: {
              institution: {
                id: 123,
                name: 'A1 University',
              },
              cachedConfirmedAt: '2020-01-01T18:25:01.639Z',
              cachedReconfirmedAt: '2021-03-11T18:25:01.639Z',
            },
          },
          {
            email: '2@a1.uni',
            confirmedAt: new Date(2019, 0, 1),
            reConfirmedAt: new Date(2022, 2, 11),
            lastConfirmedAt: new Date(2022, 2, 11),
            default: false,
            affiliation: {
              institution: {
                id: 123,
                name: 'A1 University',
              },
              cachedConfirmedAt: '2019-01-01T18:25:01.639Z',
              cachedReconfirmedAt: '2022-03-11T18:25:01.639Z',
            },
          },
          {
            email: '2020@foo.bar',
            confirmedAt: new Date(2020, 6, 1),
            lastConfirmedAt: new Date(2020, 6, 1),
          },
          {
            email: '2021@foo.bar',
            confirmedAt: new Date(2021, 6, 1),
            lastConfirmedAt: new Date(2021, 6, 1),
          },
          {
            email: ctx.user.email,
            confirmedAt: new Date(2021, 6, 1),
            lastConfirmedAt: new Date(2021, 6, 1),
          },
          {
            email: ctx.newEmail,
            confirmedAt: new Date(2021, 6, 1),
            lastConfirmedAt: new Date(2021, 6, 1),
          },
        ])
        await ctx.UserUpdater.promises.setDefaultEmailAddress(
          ctx.user._id,
          ctx.newEmail,
          false,
          ctx.auditLog,
          true
        )
        // Emails are sent asynchronously. Wait a bit.
        await setTimeout(100)
        ctx.EmailHandler.promises.sendEmail.callCount.should.equal(4)
        for (const recipient of [
          ctx.user.email,
          ctx.newEmail,
          '2@a1.uni',
          '2021@foo.bar',
        ]) {
          expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
            'securityAlert',
            sinon.match({ to: recipient })
          )
        }
      })

      it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails (multiple institutions and unaffiliated email domains)', async function (ctx) {
        setUserEmails(ctx, [
          {
            email: '1@a1.uni',
            confirmedAt: new Date(2020, 0, 1),
            reConfirmedAt: new Date(2021, 2, 11),
            lastConfirmedAt: new Date(2021, 2, 11),
            default: false,
            affiliation: {
              institution: {
                id: 123,
                name: 'A1 University',
              },
              cachedConfirmedAt: '2020-01-01T18:25:01.639Z',
              cachedReconfirmedAt: '2021-03-11T18:25:01.639Z',
            },
          },
          {
            email: '1@b2.uni',
            confirmedAt: new Date(2019, 0, 1),
            reConfirmedAt: new Date(2022, 2, 11),
            lastConfirmedAt: new Date(2022, 2, 11),
            default: false,
            affiliation: {
              institution: {
                id: 234,
                name: 'B2 University',
              },
              cachedConfirmedAt: '2019-01-01T18:25:01.639Z',
              cachedReconfirmedAt: '2022-03-11T18:25:01.639Z',
            },
          },
          {
            email: '2020@foo.bar',
            confirmedAt: new Date(2020, 6, 1),
            lastConfirmedAt: new Date(2020, 6, 1),
          },
          {
            email: '2021@bar.foo',
            confirmedAt: new Date(2021, 6, 1),
            lastConfirmedAt: new Date(2021, 6, 1),
          },
          {
            email: ctx.user.email,
            confirmedAt: new Date(2021, 6, 1),
            lastConfirmedAt: new Date(2021, 6, 1),
          },
          {
            email: ctx.newEmail,
            confirmedAt: new Date(2021, 6, 1),
            lastConfirmedAt: new Date(2021, 6, 1),
          },
        ])
        await ctx.UserUpdater.promises.setDefaultEmailAddress(
          ctx.user._id,
          ctx.newEmail,
          false,
          ctx.auditLog,
          true
        )
        // Emails are sent asynchronously. Wait a bit.
        await setTimeout(100)
        ctx.EmailHandler.promises.sendEmail.callCount.should.equal(6)
        for (const recipient of [
          ctx.user.email,
          ctx.newEmail,
          '1@a1.uni',
          '1@b2.uni',
          '2020@foo.bar',
          '2021@bar.foo',
        ]) {
          expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
            'securityAlert',
            sinon.match({ to: recipient })
          )
        }
      })

      describe('errors', function () {
        const anError = new Error('oops')
        describe('EmailHandler', function () {
          beforeEach(function (ctx) {
            ctx.EmailHandler.promises.sendEmail.rejects(anError)
          })
          it('should log but not pass back the error', async function (ctx) {
            await ctx.UserUpdater.promises.setDefaultEmailAddress(
              ctx.user._id,
              ctx.newEmail,
              false,
              ctx.auditLog,
              true
            )
            const loggerCall = ctx.logger.error.mock.calls[0]
            expect(loggerCall[0]).to.deep.equal({
              error: anError,
              userId: ctx.user._id,
            })
            expect(loggerCall[1]).to.contain(
              'could not send security alert email when primary email changed'
            )
          })
        })
      })
    })
  })

  describe('confirmEmail', function () {
    it('should update the email record', async function (ctx) {
      await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.user.email)
      expect(ctx.db.users.updateOne).to.have.been.calledWith(
        {
          _id: ctx.user._id,
          'emails.email': ctx.user.email,
        },
        {
          $set: {
            'emails.$.reconfirmedAt': new Date(),
          },
          $min: {
            'emails.$.confirmedAt': new Date(),
          },
        }
      )
    })

    it('adds affiliation', async function (ctx) {
      await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
      ctx.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true)
      sinon.assert.calledWith(
        ctx.InstitutionsAPI.promises.addAffiliation,
        ctx.user._id,
        ctx.newEmail,
        { confirmedAt: new Date() }
      )
    })

    it('handles errors', async function (ctx) {
      ctx.db.users.updateOne.rejects(new Error('nope'))

      await expect(
        ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
      ).to.be.rejected
    })

    it('handle missed update', async function (ctx) {
      ctx.db.users.updateOne.resolves({ matchedCount: 0 })

      await expect(
        ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
      ).to.be.rejected
    })

    it('validates email', async function (ctx) {
      expect(ctx.UserUpdater.promises.confirmEmail(ctx.user._id, '@')).to.be
        .rejected
    })

    it('handles affiliation errors', async function (ctx) {
      ctx.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope'))
      await expect(
        ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
      ).to.be.rejected
      expect(ctx.db.users.updateOne).to.not.have.been.called
    })

    it('refreshes features', async function (ctx) {
      await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
      sinon.assert.calledWith(
        ctx.FeaturesUpdater.promises.refreshFeatures,
        ctx.user._id
      )
    })

    it('should not call redundantPersonalSubscription when user is not on a commons license', async function (ctx) {
      ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([])
      ctx.SubscriptionLocator.promises.getUserIndividualSubscription.resolves({
        planCode: 'personal',
        groupPlan: false,
      })
      await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
      sinon.assert.notCalled(
        ctx.NotificationsBuilder.promises.redundantPersonalSubscription
      )
    })

    it('calls to remove userFullEmails from AsyncLocalStorage', async function (ctx) {
      await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
      expect(ctx.AsyncLocalStorage.removeItem).to.have.been.called
      expect(ctx.AsyncLocalStorage.removeItem).to.have.been.calledWith(
        'userFullEmails'
      )
    })

    describe('with institution licence and subscription', function () {
      beforeEach(async function (ctx) {
        ctx.affiliation = {
          email: ctx.newEmail,
          licence: 'pro_plus',
          institution: {
            id: 123,
            name: 'Institution',
          },
        }
        ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([
          ctx.affiliation,
          { email: 'other@email.edu' },
        ])
        ctx.SubscriptionLocator.promises.getUserIndividualSubscription.resolves(
          {
            planCode: 'personal',
            groupPlan: false,
          }
        )
      })

      it('creates redundant subscription notification', async function (ctx) {
        await ctx.UserUpdater.promises.confirmEmail(ctx.user._id, ctx.newEmail)
        sinon.assert.calledWith(
          ctx.InstitutionsAPI.promises.getUserAffiliations,
          ctx.user._id
        )
        sinon.assert.calledWith(
          ctx.SubscriptionLocator.promises.getUserIndividualSubscription,
          ctx.user._id
        )
        sinon.assert.calledWith(
          ctx.NotificationsBuilder.promises.redundantPersonalSubscription,
          {
            institutionId: 123,
            institutionName: 'Institution',
          },
          { _id: ctx.user._id }
        )
      })
    })
  })

  describe('suspendUser', function () {
    beforeEach(function (ctx) {
      ctx.auditLog = {
        initiatorId: 'abc123',
        ip: '0.0.0.0',
      }
    })

    it('should suspend the user', async function (ctx) {
      await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog)
      expect(ctx.db.users.updateOne).to.have.been.calledWith(
        { _id: ctx.user._id, suspended: { $ne: true } },
        { $set: { suspended: true } }
      )
    })

    it('should remove sessions from redis', async function (ctx) {
      await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog)
      expect(
        ctx.UserSessionsManager.promises.removeSessionsFromRedis
      ).to.have.been.calledWith({ _id: ctx.user._id })
    })

    it('should log the suspension to the audit log', async function (ctx) {
      await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog)
      expect(ctx.UserAuditLogHandler.promises.addEntry).to.have.been.calledWith(
        ctx.user._id,
        'account-suspension',
        ctx.auditLog.initiatorId,
        ctx.auditLog.ip,
        {}
      )
    })

    it('should fire the removeDropbox hook', async function (ctx) {
      await ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog)
      expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
        'removeDropbox',
        ctx.user._id,
        'account-suspension'
      )
    })

    it('should handle not finding a record to update', async function (ctx) {
      ctx.db.users.updateOne.resolves({ matchedCount: 0 })
      await expect(
        ctx.UserUpdater.promises.suspendUser(ctx.user._id, ctx.auditLog)
      ).to.be.rejectedWith(Errors.NotFoundError)
    })
  })
})
