import { vi, expect } from 'vitest'
import mongodb from 'mongodb-legacy'
import assert from 'node:assert'
import moment from 'moment'
import path from 'node:path'
import sinon from 'sinon'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import MongoHelpers from '../../../../app/src/Features/Helpers/Mongo.mjs'
const modulePath = path.join(
  import.meta.dirname,
  '../../../../app/src/Features/User/UserGetter'
)
const { normalizeQuery, normalizeMultiQuery } = MongoHelpers

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

const { ObjectId } = mongodb

describe('UserGetter', function () {
  beforeEach(async function (ctx) {
    const confirmedAt = new Date()
    ctx.fakeUser = {
      _id: new ObjectId(),
      email: 'email2@foo.bar',
      emails: [
        {
          email: 'email1@foo.bar',
          reversedHostname: 'rab.oof',
          confirmedAt,
          lastConfirmedAt: confirmedAt,
        },
        { email: 'email2@foo.bar', reversedHostname: 'rab.oof' },
      ],
    }
    ctx.findOne = sinon.stub().resolves(ctx.fakeUser)
    ctx.findToArrayStub = sinon.stub().resolves([ctx.fakeUser])
    ctx.find = sinon.stub().returns({ toArray: ctx.findToArrayStub })
    ctx.Mongo = {
      db: {
        users: {
          findOne: ctx.findOne,
          find: ctx.find,
        },
      },
      ObjectId,
    }
    ctx.getUserAffiliations = sinon.stub().resolves([])

    ctx.Modules = {
      promises: { hooks: { fire: sinon.stub().resolves() } },
    }
    ctx.AsyncLocalStorage = {
      storage: {
        getStore: sinon.stub().returns(undefined),
      },
    }

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

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

    vi.doMock('@overleaf/settings', () => ({
      default: (ctx.settings = {
        reconfirmNotificationDays: 14,
        aiFeatures: {
          freeQuota: 'free',
          standardQuota: 'standard',
          basicQuota: 'basic',
          unlimitedQuota: 'unlimited',
        },
        quotaGrants: {
          ai: {
            free: 5,
            basic: 5,
            standard: 10,
            unlimited: 200,
          },
        },
      }),
    }))

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

    vi.doMock('../../../../app/src/infrastructure/Features', () => ({
      default: {
        hasFeature: sinon.stub().returns(true),
      },
    }))

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

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

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

    ctx.UserGetter = (await import(modulePath)).default
  })

  describe('getSsoUsersAtInstitution', function () {
    it('should throw an error when no projection is passed', async function (ctx) {
      await expect(
        ctx.UserGetter.promises.getSsoUsersAtInstitution(1, undefined)
      ).to.be.rejectedWith('missing projection')
    })
  })

  describe('getUser', function () {
    it('should get user', async function (ctx) {
      const query = { _id: '000000000000000000000000' }
      const projection = { email: 1 }
      const user = await ctx.UserGetter.promises.getUser(query, projection)
      ctx.findOne.called.should.equal(true)
      ctx.findOne.calledWith(query, { projection }).should.equal(true)
      expect(user).to.deep.equal(ctx.fakeUser)
    })

    it('should not allow null query', async function (ctx) {
      await expect(
        ctx.UserGetter.promises.getUser(null, {})
      ).to.be.rejectedWith('no query provided')
    })
  })

  describe('getUsers', function () {
    it('should get users with array of userIds', async function (ctx) {
      const query = [new ObjectId()]
      const projection = { email: 1 }
      const users = await ctx.UserGetter.promises.getUsers(query, projection)
      ctx.find.should.have.been.calledWithMatch(
        { _id: { $in: query } },
        { projection }
      )
      users.should.deep.equal([ctx.fakeUser])
    })

    it('should not call mongo with empty list', async function (ctx) {
      const query = []
      const projection = { email: 1 }
      const users = await ctx.UserGetter.promises.getUsers(query, projection)
      expect(users).to.deep.equal([])
      expect(ctx.find).to.not.have.been.called
    })

    it('should not allow null query', async function (ctx) {
      await expect(
        ctx.UserGetter.promises.getUsers(null, {})
      ).to.be.rejectedWith('no query provided')
    })
  })

  describe('getUserFullEmails', function () {
    it('should get user', async function (ctx) {
      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
      const projection = { email: 1, emails: 1, samlIdentifiers: 1 }
      await ctx.UserGetter.promises.getUserFullEmails(ctx.fakeUser._id)
      ctx.UserGetter.promises.getUser.called.should.equal(true)
      ctx.UserGetter.promises.getUser
        .calledWith(ctx.fakeUser._id, projection)
        .should.equal(true)
    })

    it('should fetch emails data', async function (ctx) {
      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
      const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
        ctx.fakeUser._id
      )

      assert.deepEqual(fullEmails, [
        {
          email: 'email1@foo.bar',
          reversedHostname: 'rab.oof',
          confirmedAt: ctx.fakeUser.emails[0].confirmedAt,
          lastConfirmedAt: ctx.fakeUser.emails[0].lastConfirmedAt,
          emailHasInstitutionLicence: false,
          default: false,
        },
        {
          email: 'email2@foo.bar',
          reversedHostname: 'rab.oof',
          emailHasInstitutionLicence: false,
          default: true,
          lastConfirmedAt: null,
        },
      ])
    })

    it('should merge affiliation data', async function (ctx) {
      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
      const affiliationsData = [
        {
          email: 'email1@foo.bar',
          role: 'Prof',
          cached_confirmed_at: '2019-07-11T18:25:01.639Z',
          cached_reconfirmed_at: '2021-07-11T18:25:01.639Z',
          department: 'Maths',
          entitlement: false,
          inferred: false,
          licence: 'pro_plus',
          institution: {
            name: 'University Name',
            isUniversity: true,
            confirmed: true,
          },
          last_day_to_reconfirm: undefined,
          past_reconfirm_date: false,
          portal: undefined,
        },
      ]
      ctx.getUserAffiliations.resolves(affiliationsData)
      const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
        ctx.fakeUser._id
      )

      assert.deepEqual(fullEmails, [
        {
          email: 'email1@foo.bar',
          reversedHostname: 'rab.oof',
          confirmedAt: ctx.fakeUser.emails[0].confirmedAt,
          lastConfirmedAt: ctx.fakeUser.emails[0].lastConfirmedAt,
          default: false,
          emailHasInstitutionLicence: true,
          affiliation: {
            institution: affiliationsData[0].institution,
            inferred: affiliationsData[0].inferred,
            department: affiliationsData[0].department,
            role: affiliationsData[0].role,
            lastDayToReconfirm: undefined,
            licence: affiliationsData[0].licence,
            inReconfirmNotificationPeriod: false,
            cachedConfirmedAt: '2019-07-11T18:25:01.639Z',
            cachedReconfirmedAt: '2021-07-11T18:25:01.639Z',
            cachedEntitlement: false,
            cachedLastDayToReconfirm: undefined,
            cachedPastReconfirmDate: false,
            pastReconfirmDate: false,
            portal: undefined,
            domainCapturedByGroup: undefined,
          },
        },
        {
          email: 'email2@foo.bar',
          reversedHostname: 'rab.oof',
          emailHasInstitutionLicence: false,
          default: true,
          lastConfirmedAt: null,
        },
      ])
    })

    it('should include domainCapturedByGroup in merged affiliation', async function (ctx) {
      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
      const affiliationsData = [
        {
          email: 'email1@foo.bar',
          role: null,
          cached_confirmed_at: null,
          cached_reconfirmed_at: null,
          department: null,
          entitlement: false,
          inferred: false,
          licence: 'free',
          institution: {
            name: 'University Name',
            isUniversity: true,
            confirmed: true,
          },
          last_day_to_reconfirm: undefined,
          past_reconfirm_date: false,
          portal: undefined,
          group: { _id: 'grp1', domainCaptureEnabled: true },
          domainCapturedByGroup: true,
        },
      ]
      ctx.getUserAffiliations.resolves(affiliationsData)
      const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
        ctx.fakeUser._id
      )
      assert.strictEqual(fullEmails[0].affiliation.domainCapturedByGroup, true)
    })

    it('should include domainCapturedByGroup=false in merged affiliation when not captured', async function (ctx) {
      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
      const affiliationsData = [
        {
          email: 'email1@foo.bar',
          role: null,
          cached_confirmed_at: null,
          cached_reconfirmed_at: null,
          department: null,
          entitlement: false,
          inferred: false,
          licence: 'free',
          institution: {
            name: 'University Name',
            isUniversity: true,
            confirmed: true,
          },
          last_day_to_reconfirm: undefined,
          past_reconfirm_date: false,
          portal: undefined,
          group: { _id: 'grp1', domainCaptureEnabled: true },
          domainCapturedByGroup: false,
        },
      ]
      ctx.getUserAffiliations.resolves(affiliationsData)
      const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
        ctx.fakeUser._id
      )
      assert.strictEqual(fullEmails[0].affiliation.domainCapturedByGroup, false)
    })

    it('should merge SAML identifier', async function (ctx) {
      const fakeSamlIdentifiers = [
        { providerId: 'saml_id', externalUserId: 'whatever' },
      ]
      const fakeUserWithSaml = ctx.fakeUser
      fakeUserWithSaml.emails[0].samlProviderId = 'saml_id'
      fakeUserWithSaml.samlIdentifiers = fakeSamlIdentifiers
      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
      ctx.getUserAffiliations.resolves([])
      const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
        ctx.fakeUser._id
      )

      assert.deepEqual(fullEmails, [
        {
          email: 'email1@foo.bar',
          reversedHostname: 'rab.oof',
          confirmedAt: ctx.fakeUser.emails[0].confirmedAt,
          lastConfirmedAt: ctx.fakeUser.emails[0].lastConfirmedAt,
          default: false,
          emailHasInstitutionLicence: false,
          samlProviderId: 'saml_id',
          samlIdentifier: fakeSamlIdentifiers[0],
        },
        {
          email: 'email2@foo.bar',
          reversedHostname: 'rab.oof',
          emailHasInstitutionLicence: false,
          default: true,
          lastConfirmedAt: null,
        },
      ])
    })

    it('should get user when it has no emails field', async function (ctx) {
      ctx.fakeUserNoEmails = {
        _id: '12390i',
        email: 'email2@foo.bar',
      }
      ctx.UserGetter.promises.getUser = sinon
        .stub()
        .resolves(ctx.fakeUserNoEmails)
      const projection = { email: 1, emails: 1, samlIdentifiers: 1 }
      const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
        ctx.fakeUserNoEmails._id
      )
      ctx.UserGetter.promises.getUser.called.should.equal(true)
      ctx.UserGetter.promises.getUser
        .calledWith(ctx.fakeUserNoEmails._id, projection)
        .should.equal(true)
      assert.deepEqual(fullEmails, [])
    })

    describe('affiliation reconfirmation', function () {
      const institutionNonSSO = {
        id: 1,
        name: 'University Name',
        commonsAccount: true,
        isUniversity: true,
        confirmed: true,
        ssoBeta: false,
        ssoEnabled: false,
        maxConfirmationMonths: 12,
      }
      const institutionSSO = {
        id: 2,
        name: 'SSO University Name',
        isUniversity: true,
        confirmed: true,
        ssoBeta: false,
        ssoEnabled: true,
        maxConfirmationMonths: 12,
      }
      describe('non-SSO institutions', function () {
        const email1 = 'leonard@example-affiliation.com'
        const email2 = 'mccoy@example-affiliation.com'
        const affiliationsData = [
          {
            email: email1,
            role: 'Prof',
            department: 'Medicine',
            inferred: false,
            licence: 'pro_plus',
            institution: institutionNonSSO,
          },
          {
            email: email2,
            role: 'Prof',
            department: 'Medicine',
            inferred: false,
            licence: 'pro_plus',
            institution: institutionNonSSO,
          },
        ]
        it('should flag inReconfirmNotificationPeriod for all affiliations in period', async function (ctx) {
          const { maxConfirmationMonths } = institutionNonSSO
          const confirmed1 = moment()
            .subtract(maxConfirmationMonths + 2, 'months')
            .toDate()
          const lastDayToReconfirm1 = moment(confirmed1)
            .add(maxConfirmationMonths, 'months')
            .toDate()
          const confirmed2 = moment()
            .subtract(maxConfirmationMonths + 1, 'months')
            .toDate()
          const lastDayToReconfirm2 = moment(confirmed2)
            .add(maxConfirmationMonths, 'months')
            .toDate()
          const user = {
            _id: '12390i',
            email: email1,
            emails: [
              {
                email: email1,
                reversedHostname: 'moc.noitailiffa-elpmaxe',
                confirmedAt: confirmed1,
                default: true,
              },
              {
                email: email2,
                reversedHostname: 'moc.noitailiffa-elpmaxe',
                confirmedAt: confirmed2,
              },
            ],
          }
          const affiliations = [...affiliationsData]
          affiliations[0].last_day_to_reconfirm = lastDayToReconfirm1
          affiliations[1].last_day_to_reconfirm = lastDayToReconfirm2
          ctx.getUserAffiliations.resolves(affiliations)
          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
            ctx.fakeUser._id
          )
          expect(
            fullEmails[0].affiliation.inReconfirmNotificationPeriod
          ).to.equal(true)
          expect(
            fullEmails[1].affiliation.inReconfirmNotificationPeriod
          ).to.equal(true)
        })

        it('should not flag affiliations outside of notification period', async function (ctx) {
          const { maxConfirmationMonths } = institutionNonSSO
          const confirmed1 = new Date()
          const lastDayToReconfirm1 = moment(confirmed1)
            .add(maxConfirmationMonths, 'months')
            .toDate()
          const confirmed2 = moment()
            .subtract(maxConfirmationMonths, 'months')
            .add(30, 'days')
            .toDate() // expires in 30 days and reconfirmNotificationDays is set to 14
          const lastDayToReconfirm2 = moment(confirmed2)
            .add(maxConfirmationMonths, 'months')
            .toDate()
          const user = {
            _id: '12390i',
            email: email1,
            emails: [
              {
                email: email1,
                reversedHostname: 'moc.noitailiffa-elpmaxe',
                confirmedAt: confirmed1,
                default: true,
              },
              {
                email: email2,
                reversedHostname: 'moc.noitailiffa-elpmaxe',
                confirmedAt: confirmed2,
              },
            ],
          }
          const affiliations = [...affiliationsData]
          affiliations[0].last_day_to_reconfirm = lastDayToReconfirm1
          affiliations[1].last_day_to_reconfirm = lastDayToReconfirm2
          ctx.getUserAffiliations.resolves(affiliations)
          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
            ctx.fakeUser._id
          )
          expect(
            fullEmails[0].affiliation.inReconfirmNotificationPeriod
          ).to.equal(false)
          expect(
            fullEmails[1].affiliation.inReconfirmNotificationPeriod
          ).to.equal(false)
        })
      })

      describe('SSO institutions', function () {
        it('should flag only linked email, if in notification period', async function (ctx) {
          const { maxConfirmationMonths } = institutionSSO
          const email1 = 'email1@sso.bar'
          const email2 = 'email2@sso.bar'
          const email3 = 'email3@sso.bar'
          const reconfirmedAt = new Date('2019-09-24')
          const lastDayToReconfirm = moment(reconfirmedAt)
            .add(maxConfirmationMonths, 'months')
            .toDate()
          const user = {
            _id: '12390i',
            email: email1,
            emails: [
              {
                email: email1,
                reversedHostname: 'rab.oss',
                confirmedAt: new Date('2019-09-24'),
                reconfirmedAt,
                default: true,
              },
              {
                email: email2,
                reversedHostname: 'rab.oss',
                confirmedAt: new Date('2019-09-24'),
                reconfirmedAt,
                samlProviderId: institutionSSO.id,
              },
              {
                email: email3,
                reversedHostname: 'rab.oss',
                confirmedAt: new Date('2019-09-24'),
                reconfirmedAt,
              },
            ],
            samlIdentifiers: [
              {
                providerId: institutionSSO.id,
                externalUserId: 'abc123',
              },
            ],
          }
          const affiliations = [
            {
              email: email1,
              role: 'Prof',
              department: 'Maths!',
              inferred: false,
              licence: 'pro_plus',
              institution: institutionSSO,
              last_day_to_reconfirm: lastDayToReconfirm,
            },
            {
              email: email2,
              role: 'Prof',
              department: 'Maths',
              inferred: false,
              licence: 'pro_plus',
              institution: institutionSSO,
              last_day_to_reconfirm: lastDayToReconfirm,
            },
            {
              email: email3,
              role: 'Prof',
              department: 'Maths',
              inferred: false,
              licence: 'pro_plus',
              institution: institutionSSO,
              last_day_to_reconfirm: lastDayToReconfirm,
            },
          ]
          ctx.getUserAffiliations.resolves(affiliations)
          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
            ctx.fakeUser._id
          )
          expect(
            fullEmails[0].affiliation.inReconfirmNotificationPeriod
          ).to.equal(false)
          expect(
            fullEmails[1].affiliation.inReconfirmNotificationPeriod
          ).to.equal(true)
          expect(
            fullEmails[2].affiliation.inReconfirmNotificationPeriod
          ).to.equal(false)
        })
      })

      describe('multiple institution affiliations', function () {
        it('should flag each institution', async function (ctx) {
          const { maxConfirmationMonths } = institutionSSO
          const email1 = 'email1@sso.bar'
          const email2 = 'email2@sso.bar'
          const email3 = 'email3@foo.bar'
          const email4 = 'email4@foo.bar'
          const confirmed1 = new Date('2019-09-24T20:25:08.503Z')
          const lastDayToReconfirm1 = moment(confirmed1)
            .add(maxConfirmationMonths, 'months')
            .toDate()
          const confirmed2 = new Date('2019-10-24T20:25:08.503Z')
          const lastDayToReconfirm2 = moment(confirmed2)
            .add(maxConfirmationMonths, 'months')
            .toDate()

          const affiliationsData = [
            {
              email: email1,
              role: 'Prof',
              department: 'Maths',
              inferred: false,
              licence: 'pro_plus',
              institution: institutionSSO,
              last_day_to_reconfirm: lastDayToReconfirm1,
            },
            {
              email: email2,
              role: 'Prof',
              department: 'Maths',
              inferred: false,
              licence: 'pro_plus',
              institution: institutionSSO,
              last_day_to_reconfirm: lastDayToReconfirm1,
            },
            {
              email: email3,
              role: 'Prof',
              department: 'Maths',
              inferred: false,
              licence: 'pro_plus',
              institution: institutionNonSSO,
              last_day_to_reconfirm: lastDayToReconfirm2,
            },
            {
              email: email4,
              role: 'Prof',
              department: 'Maths',
              inferred: false,
              licence: 'pro_plus',
              institution: institutionNonSSO,
              last_day_to_reconfirm: lastDayToReconfirm1,
            },
          ]
          const user = {
            _id: '12390i',
            email: email1,
            emails: [
              {
                email: email1,
                reversedHostname: 'rab.oss',
                confirmedAt: confirmed1,
                default: true,
              },
              {
                email: email2,
                reversedHostname: 'rab.oss',
                confirmedAt: confirmed1,
                samlProviderId: institutionSSO.id,
              },
              {
                email: email3,
                reversedHostname: 'rab.oof',
                confirmedAt: confirmed2,
              },
              {
                email: email4,
                reversedHostname: 'rab.oof',
                confirmedAt: confirmed1,
              },
            ],
            samlIdentifiers: [
              {
                providerId: institutionSSO.id,
                externalUserId: 'abc123',
              },
            ],
          }

          ctx.getUserAffiliations.resolves(affiliationsData)
          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
            ctx.fakeUser._id
          )
          expect(
            fullEmails[0].affiliation.inReconfirmNotificationPeriod
          ).to.to.equal(false)
          expect(
            fullEmails[1].affiliation.inReconfirmNotificationPeriod
          ).to.equal(true)
          expect(
            fullEmails[2].affiliation.inReconfirmNotificationPeriod
          ).to.equal(true)
          expect(
            fullEmails[3].affiliation.inReconfirmNotificationPeriod
          ).to.equal(true)
        })
      })

      describe('reconfirmedAt', function () {
        it('only use confirmedAt when no reconfirmedAt', async function (ctx) {
          const { maxConfirmationMonths } = institutionSSO
          const email1 = 'email1@foo.bar'
          const reconfirmed1 = moment().subtract(
            maxConfirmationMonths * 3,
            'months'
          )
          const lastDayToReconfirm1 = moment(reconfirmed1)
            .add(maxConfirmationMonths, 'months')
            .toDate()
          const email2 = 'email2@foo.bar'
          const reconfirmed2 = moment().subtract(
            maxConfirmationMonths * 2,
            'months'
          )
          const lastDayToReconfirm2 = moment(reconfirmed2)
            .add(maxConfirmationMonths, 'months')
            .toDate()
          const email3 = 'email3@foo.bar'
          const reconfirmed3 = moment().subtract(
            maxConfirmationMonths * 4,
            'months'
          )
          const lastDayToReconfirm3 = moment(reconfirmed3)
            .add(maxConfirmationMonths, 'months')
            .toDate()

          const affiliationsData = [
            {
              email: email1,
              role: 'Prof',
              department: 'Maths',
              inferred: false,
              licence: 'pro_plus',
              institution: institutionNonSSO,
              last_day_to_reconfirm: lastDayToReconfirm1,
            },
            {
              email: email2,
              role: 'Prof',
              department: 'Maths',
              inferred: false,
              licence: 'pro_plus',
              institution: institutionNonSSO,
              last_day_to_reconfirm: lastDayToReconfirm2,
            },
            {
              email: email3,
              role: 'Prof',
              department: 'Maths',
              inferred: false,
              licence: 'pro_plus',
              institution: institutionNonSSO,
              last_day_to_reconfirm: lastDayToReconfirm3,
            },
          ]
          const user = {
            _id: '12390i',
            email: email1,
            emails: [
              {
                email: email1,
                reversedHostname: 'rab.oof',
                confirmedAt: moment().subtract(
                  institutionSSO.maxConfirmationMonths * 2,
                  'months'
                ),
                reconfirmedAt: reconfirmed1,
                default: true,
              },
              {
                email: email2,
                reversedHostname: 'rab.oof',
                confirmedAt: moment().subtract(
                  institutionSSO.maxConfirmationMonths * 3,
                  'months'
                ),
                reconfirmedAt: reconfirmed2,
              },
              {
                email: email3,
                reversedHostname: 'rab.oof',
                confirmedAt: moment().subtract(
                  institutionSSO.maxConfirmationMonths * 4,
                  'months'
                ),
                reconfirmedAt: reconfirmed3,
              },
            ],
          }
          ctx.getUserAffiliations.resolves(affiliationsData)
          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
            ctx.fakeUser._id
          )
          expect(
            fullEmails[0].affiliation.inReconfirmNotificationPeriod
          ).to.equal(true)
          expect(
            fullEmails[1].affiliation.inReconfirmNotificationPeriod
          ).to.equal(true)
          expect(
            fullEmails[2].affiliation.inReconfirmNotificationPeriod
          ).to.equal(true)
        })
      })

      describe('before reconfirmation period expires and within reconfirmation notification period', function () {
        const email = 'leonard@example-affiliation.com'
        it('should flag the email', async function (ctx) {
          const { maxConfirmationMonths } = institutionNonSSO
          const confirmedAt = moment()
            .subtract(maxConfirmationMonths, 'months')
            .subtract(14, 'days')
            .toDate() // expires in 14 days
          const affiliationsData = [
            {
              email,
              role: 'Prof',
              department: 'Medicine',
              inferred: false,
              licence: 'pro_plus',
              institution: institutionNonSSO,
              last_day_to_reconfirm: moment(confirmedAt)
                .add(maxConfirmationMonths, 'months')
                .toDate(),
            },
          ]
          const user = {
            _id: '12390i',
            email,
            emails: [
              {
                email,
                confirmedAt,
                default: true,
              },
            ],
          }
          ctx.getUserAffiliations.resolves(affiliationsData)
          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
            ctx.fakeUser._id
          )
          expect(
            fullEmails[0].affiliation.inReconfirmNotificationPeriod
          ).to.equal(true)
        })
      })

      describe('when no Settings.reconfirmNotificationDays', function () {
        it('should always return inReconfirmNotificationPeriod:false', async function (ctx) {
          const email1 = 'email1@sso.bar'
          const email2 = 'email2@foo.bar'
          const email3 = 'email3@foo.bar'
          const confirmedAtAboutToExpire = moment()
            .subtract(institutionNonSSO.maxConfirmationMonths, 'months')
            .subtract(14, 'days')
            .toDate() // expires in 14 days

          const affiliationsData = [
            {
              email: email1,
              institution: institutionSSO,
            },
            {
              email: email2,
              institution: institutionNonSSO,
            },
            {
              email: email3,
              institution: institutionNonSSO,
            },
          ]
          const user = {
            _id: '12390i',
            email: email1,
            emails: [
              {
                email: email1,
                confirmedAt: confirmedAtAboutToExpire,
                default: true,
                samlProviderId: institutionSSO.id,
              },
              {
                email: email2,
                confirmedAt: new Date('2019-09-24T20:25:08.503Z'),
              },
              {
                email: email3,
                confirmedAt: new Date('2019-10-24T20:25:08.503Z'),
              },
            ],
            samlIdentifiers: [
              {
                providerId: institutionSSO.id,
                externalUserId: 'abc123',
              },
            ],
          }
          ctx.settings.reconfirmNotificationDays = undefined
          ctx.getUserAffiliations.resolves(affiliationsData)
          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
            ctx.fakeUser._id
          )
          expect(
            fullEmails[0].affiliation.inReconfirmNotificationPeriod
          ).to.equal(false)
          expect(
            fullEmails[1].affiliation.inReconfirmNotificationPeriod
          ).to.equal(false)
          expect(
            fullEmails[2].affiliation.inReconfirmNotificationPeriod
          ).to.equal(false)
        })
      })

      it('should flag to show notification if v1 shows as past reconfirmation but v2 does not', async function (ctx) {
        const email = 'abc123@test.com'
        const confirmedAt = new Date()
        const affiliationsData = [
          {
            email,
            licence: 'free',
            institution: institutionNonSSO,
            last_day_to_reconfirm: '2020-07-11T18:25:01.639Z',
            past_reconfirm_date: true,
          },
        ]
        const user = {
          _id: '12390i',
          email,
          emails: [
            {
              email,
              confirmedAt,
              default: true,
            },
          ],
        }
        ctx.getUserAffiliations.resolves(affiliationsData)
        ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
          ctx.fakeUser._id
        )
        expect(
          fullEmails[0].affiliation.inReconfirmNotificationPeriod
        ).to.equal(true)
      })

      it('should flag to show notification if v1 shows as reconfirmation upcoming but v2 does not', async function (ctx) {
        const email = 'abc123@test.com'
        const { maxConfirmationMonths } = institutionNonSSO
        const affiliationsData = [
          {
            email,
            licence: 'free',
            institution: institutionNonSSO,
            last_day_to_reconfirm: moment()
              .subtract(maxConfirmationMonths, 'months')
              .add(3, 'day')
              .toDate(),
          },
        ]
        const user = {
          _id: '12390i',
          email,
          emails: [
            {
              email,
              confirmedAt: new Date(),
              default: true,
            },
          ],
        }
        ctx.getUserAffiliations.resolves(affiliationsData)
        ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
          ctx.fakeUser._id
        )
        expect(
          fullEmails[0].affiliation.inReconfirmNotificationPeriod
        ).to.equal(true)
      })

      it('should flag to show notification if v2 shows as reconfirmation upcoming but v1 does not', async function (ctx) {
        const email = 'abc123@test.com'
        const { maxConfirmationMonths } = institutionNonSSO

        const datePastReconfirmation = moment()
          .subtract(maxConfirmationMonths, 'months')
          .add(3, 'day')
          .toDate()

        const dateNotPastReconfirmation = moment().add(1, 'month').toDate()

        const affiliationsData = [
          {
            email,
            licence: 'free',
            institution: institutionNonSSO,
            last_day_to_reconfirm: dateNotPastReconfirmation,
          },
        ]
        const user = {
          _id: '12390i',
          email,
          emails: [
            {
              email,
              confirmedAt: datePastReconfirmation,
              default: true,
            },
          ],
        }
        ctx.getUserAffiliations.resolves(affiliationsData)
        ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
          ctx.fakeUser._id
        )
        expect(
          fullEmails[0].affiliation.inReconfirmNotificationPeriod
        ).to.equal(true)
      })

      describe('cachedLastDayToReconfirm', function () {
        const email = 'abc123@test.com'
        const confirmedAt = new Date('2019-07-11T18:25:01.639Z')
        const lastDay = '2020-07-11T18:25:01.639Z'
        const affiliationsData = [
          {
            email,
            licence: 'professional',
            institution: institutionSSO,
            last_day_to_reconfirm: lastDay,
            past_reconfirm_date: true,
          },
        ]
        const user = {
          _id: '12390i',
          email,
          emails: [
            {
              email,
              confirmedAt,
              default: true,
            },
          ],
        }

        it('should set cachedLastDayToReconfirm for SSO institutions if email is linked to SSO', async function (ctx) {
          const userLinked = Object.assign({}, user)
          userLinked.emails[0].samlProviderId = institutionSSO.id.toString()
          ctx.getUserAffiliations.resolves(affiliationsData)
          ctx.UserGetter.promises.getUser = sinon.stub().resolves(userLinked)
          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
            ctx.fakeUser._id
          )
          expect(fullEmails[0].affiliation.cachedLastDayToReconfirm).to.equal(
            lastDay
          )
        })

        it('should NOT set cachedLastDayToReconfirm for SSO institutions if email is NOT linked to SSO', async function (ctx) {
          ctx.getUserAffiliations.resolves(affiliationsData)
          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
            ctx.fakeUser._id
          )
          expect(fullEmails[0].affiliation.cachedLastDayToReconfirm).to.equal(
            lastDay
          )
        })

        it('should set cachedLastDayToReconfirm for non-SSO institutions', async function (ctx) {
          ctx.getUserAffiliations.resolves(affiliationsData)
          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
            ctx.fakeUser._id
          )
          expect(fullEmails[0].affiliation.cachedLastDayToReconfirm).to.equal(
            lastDay
          )
        })
      })
    })

    describe('caching full emails data if run inside AsyncLocalStorage context', function () {
      it('should store the data in the AsyncLocalStorage store', async function (ctx) {
        ctx.store = {}
        ctx.AsyncLocalStorage.storage.getStore.returns(ctx.store)
        ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
        ctx.getUserAffiliations.resolves([
          {
            email: 'email1@foo.bar',
            licence: 'professional',
            institution: {},
          },
        ])
        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
          ctx.fakeUser._id
        )
        expect(ctx.UserGetter.promises.getUser).to.have.been.calledOnce
        expect(ctx.getUserAffiliations).to.have.been.calledOnce
        expect(fullEmails).to.be.an('array')
        expect(fullEmails.length).to.equal(2)
        expect(ctx.store.userFullEmails[ctx.fakeUser._id]).to.deep.equal(
          fullEmails
        )
      })

      it('should fetch data from the store if available', async function (ctx) {
        ctx.store = {
          userFullEmails: {
            [ctx.fakeUser._id]: [{ email: '1' }, { email: '2' }],
          },
        }
        ctx.AsyncLocalStorage.storage.getStore.returns(ctx.store)
        ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
          ctx.fakeUser._id,
          ctx.req
        )
        expect(ctx.UserGetter.promises.getUser).to.not.have.been.called
        expect(ctx.getUserAffiliations).to.not.have.been.called
        expect(fullEmails).to.be.an('array')
        expect(fullEmails.length).to.equal(2)
        expect(ctx.store.userFullEmails[ctx.fakeUser._id]).to.deep.equal(
          fullEmails
        )
      })

      it('should not return cached data for different user ids', async function (ctx) {
        ctx.store = {}
        ctx.AsyncLocalStorage.storage.getStore.returns(ctx.store)
        ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
          ctx.fakeUser._id,
          ctx.req
        )
        expect(ctx.UserGetter.promises.getUser).to.have.been.calledOnce
        expect(ctx.getUserAffiliations).to.have.been.calledOnce
        expect(fullEmails).to.be.an('array')
        expect(fullEmails.length).to.equal(2)
        ctx.otherUser = {
          _id: new ObjectId(),
          email: 'other@foo.bar',
          emails: [
            {
              email: 'other@foo.bar',
              reversedHostname: 'rab.oof',
              confirmedAt: new Date(),
              lastConfirmedAt: new Date(),
            },
          ],
        }
        ctx.UserGetter.promises.getUser.resolves(ctx.otherUser)
        ctx.getUserAffiliations.resolves([
          {
            email: 'other@foo.bar',
            licence: 'professional',
            institution: {},
          },
        ])
        const fullEmailsOther = await ctx.UserGetter.promises.getUserFullEmails(
          ctx.otherUser._id,
          ctx.req
        )
        expect(ctx.UserGetter.promises.getUser).to.have.been.calledTwice
        expect(ctx.getUserAffiliations).to.have.been.calledTwice
        expect(fullEmailsOther).to.not.deep.equal(fullEmails)
        expect(fullEmailsOther).to.be.an('array')
        expect(fullEmailsOther.length).to.equal(1)
        expect(ctx.store.userFullEmails[ctx.fakeUser._id]).to.deep.equal(
          fullEmails
        )
        expect(ctx.store.userFullEmails[ctx.otherUser._id]).to.deep.equal(
          fullEmailsOther
        )
      })
    })
  })

  describe('getUserConfirmedEmails', function () {
    beforeEach(function (ctx) {
      ctx.fakeUser = {
        emails: [
          {
            email: 'email1@foo.bar',
            reversedHostname: 'rab.oof',
            confirmedAt: new Date(),
          },
          { email: 'email2@foo.bar', reversedHostname: 'rab.oof' },
          {
            email: 'email3@foo.bar',
            reversedHostname: 'rab.oof',
            confirmedAt: new Date(),
          },
        ],
      }
      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
    })

    it('should get user', async function (ctx) {
      const projection = { emails: 1 }
      await ctx.UserGetter.promises.getUserConfirmedEmails(ctx.fakeUser._id)

      ctx.UserGetter.promises.getUser
        .calledWith(ctx.fakeUser._id, projection)
        .should.equal(true)
    })

    it('should return only confirmed emails', async function (ctx) {
      const confirmedEmails =
        await ctx.UserGetter.promises.getUserConfirmedEmails(ctx.fakeUser._id)

      expect(confirmedEmails.length).to.equal(2)
      expect(confirmedEmails[0].email).to.equal('email1@foo.bar')
      expect(confirmedEmails[1].email).to.equal('email3@foo.bar')
    })
  })

  describe('getUserbyMainEmail', function () {
    it('query user by main email', async function (ctx) {
      const email = 'hello@world.com'
      const projection = { emails: 1 }
      await ctx.UserGetter.promises.getUserByMainEmail(email, projection)
      ctx.findOne.called.should.equal(true)
      ctx.findOne.calledWith({ email }, { projection }).should.equal(true)
    })

    it('return user if found', async function (ctx) {
      const email = 'hello@world.com'
      const user = await ctx.UserGetter.promises.getUserByMainEmail(email)
      user.should.deep.equal(ctx.fakeUser)
    })

    it('trim email', async function (ctx) {
      const email = 'hello@world.com'
      await ctx.UserGetter.promises.getUserByMainEmail(` ${email} `)
      ctx.findOne.called.should.equal(true)
      ctx.findOne.calledWith({ email }).should.equal(true)
    })
  })

  describe('getUserByAnyEmail', function () {
    it('query user for any email', async function (ctx) {
      const email = 'hello@world.com'
      const expectedQuery = {
        emails: { $exists: true },
        'emails.email': email,
      }
      const projection = { emails: 1 }
      const user = await ctx.UserGetter.promises.getUserByAnyEmail(
        ` ${email} `,
        projection
      )
      ctx.findOne.calledWith(expectedQuery, { projection }).should.equal(true)
      user.should.deep.equal(ctx.fakeUser)
    })

    it('query contains $exists:true so partial index is used', async function (ctx) {
      const expectedQuery = {
        emails: { $exists: true },
        'emails.email': '',
      }
      await ctx.UserGetter.promises.getUserByAnyEmail('', {})
      ctx.findOne
        .calledWith(expectedQuery, { projection: {} })
        .should.equal(true)
    })

    it('checks main email as well', async function (ctx) {
      ctx.findOne.resolves(null)
      const email = 'hello@world.com'
      const projection = { emails: 1 }
      await ctx.UserGetter.promises.getUserByAnyEmail(` ${email} `, projection)
      ctx.findOne.calledTwice.should.equal(true)
      ctx.findOne.calledWith({ email }, { projection }).should.equal(true)
    })
  })

  describe('getUsersByHostname', function () {
    it('should find user by hostname', async function (ctx) {
      const hostname = 'bar.foo'
      const expectedQuery = {
        emails: { $exists: true },
        'emails.reversedHostname': hostname.split('').reverse().join(''),
      }
      const projection = { emails: 1 }
      await ctx.UserGetter.promises.getUsersByHostname(hostname, projection)
      ctx.find.calledOnce.should.equal(true)
      ctx.find.calledWith(expectedQuery, { projection }).should.equal(true)
    })
  })

  describe('getUsersByAnyConfirmedEmail', function () {
    it('should find users by confirmed email', async function (ctx) {
      const emails = ['confirmed@example.com']

      await ctx.UserGetter.promises.getUsersByAnyConfirmedEmail(emails)
      expect(ctx.find).to.be.calledOnceWith(
        {
          'emails.email': { $in: emails }, // use the index on emails.email
          emails: {
            $exists: true,
            $elemMatch: {
              email: { $in: emails },
              confirmedAt: { $exists: true },
            },
          },
        },
        { projection: {} }
      )
    })
  })

  describe('getUsersByV1Id', function () {
    it('should find users by list of v1 ids', async function (ctx) {
      const v1Ids = [501]
      const expectedQuery = {
        'overleaf.id': { $in: v1Ids },
      }
      const projection = { emails: 1 }
      await ctx.UserGetter.promises.getUsersByV1Ids(v1Ids, projection)
      ctx.find.calledOnce.should.equal(true)
      ctx.find.calledWith(expectedQuery, { projection }).should.equal(true)
    })
  })

  describe('ensureUniqueEmailAddress', function () {
    beforeEach(function (ctx) {
      ctx.UserGetter.promises.getUserByAnyEmail = sinon.stub()
    })

    it('should return error if existing user is found', async function (ctx) {
      ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.fakeUser)
      await expect(
        ctx.UserGetter.promises.ensureUniqueEmailAddress(ctx.newEmail)
      ).to.be.rejectedWith(Errors.EmailExistsError)
    })

    it('should return null if no user is found', async function (ctx) {
      ctx.UserGetter.promises.getUserByAnyEmail.resolves(null)
      await expect(
        ctx.UserGetter.promises.ensureUniqueEmailAddress(ctx.newEmail)
      ).to.be.fulfilled
    })
  })

  describe('getUserFeatures', function () {
    beforeEach(function (ctx) {
      ctx.Modules.promises.hooks.fire = sinon.stub().resolves()
      ctx.fakeUser.features = {}
    })

    it('should return user features', async function (ctx) {
      ctx.fakeUser.features = { feature1: true, feature2: false }
      const features = await ctx.UserGetter.promises.getUserFeatures(
        new ObjectId()
      )
      expect(features).to.deep.equal(ctx.fakeUser.features)
    })

    it('should return user features when using promises', async function (ctx) {
      ctx.fakeUser.features = { feature1: true, feature2: false }
      const features = await ctx.UserGetter.promises.getUserFeatures(
        ctx.fakeUser._id
      )
      expect(features).to.deep.equal(ctx.fakeUser.features)
    })

    it('should take into account features overrides from modules', async function (ctx) {
      // this case occurs when the user has bought the ai bundle on WF, which should include our error assistant
      const bundleFeatures = { aiUsageQuota: 'unlimited' }
      ctx.fakeUser.features = { aiUsageQuota: 'basic' }
      ctx.Modules.promises.hooks.fire = sinon.stub().resolves([bundleFeatures])
      const features = await ctx.UserGetter.promises.getUserFeatures(
        ctx.fakeUser._id
      )
      expect(features).to.deep.equal(bundleFeatures)
      ctx.Modules.promises.hooks.fire.should.have.been.calledWith(
        'getModuleProvidedFeatures',
        ctx.fakeUser._id
      )
    })

    it('should handle modules not returning any features', async function (ctx) {
      ctx.Modules.promises.hooks.fire = sinon.stub().resolves([])
      ctx.fakeUser.features = { test: true }
      const features = await ctx.UserGetter.promises.getUserFeatures(
        ctx.fakeUser._id
      )
      expect(features).to.deep.equal({ test: true })
      ctx.Modules.promises.hooks.fire.should.have.been.calledWith(
        'getModuleProvidedFeatures',
        ctx.fakeUser._id
      )
    })
  })
})
