Просмотр исходного кода

Migrate node controller unit test to the react version of the project dashboard controller (#11993)

* Migrate node controller unit test to the react version of the project dashboard controller

* change `promise.race` behaviour when fetching initial project list data to use setTimeout with 0ms delay (fake delay)

* rename `this.collabertions` to `this.readAndWrite`

* Change IP matcher to use `await` and `try` + `catch` block to be able to correctly write the test for

GitOrigin-RevId: 72ad9263c099b7ba44dede8304dba1c5ba879b56
M Fahru 3 лет назад
Родитель
Сommit
27723b7878

+ 17 - 10
services/web/app/src/Features/Project/ProjectListController.js

@@ -262,9 +262,15 @@ async function projectListPage(req, res, next) {
     delete req.session.saml
   }
 
+  function fakeDelay() {
+    return new Promise(resolve => {
+      setTimeout(() => resolve(undefined), 0)
+    })
+  }
+
   const prefetchedProjectsBlob = await Promise.race([
     projectsBlobPending,
-    Promise.resolve(undefined),
+    fakeDelay(),
   ])
   Metrics.inc('project-list-prefetch-projects', 1, {
     status: prefetchedProjectsBlob ? 'success' : 'too-slow',
@@ -286,15 +292,16 @@ async function projectListPage(req, res, next) {
 
   // in v2 add notifications for matching university IPs
   if (Settings.overleaf != null && req.ip !== user.lastLoginIp) {
-    NotificationsBuilder.promises
-      .ipMatcherAffiliation(user._id)
-      .create(req.ip)
-      .catch(err => {
-        logger.error(
-          { err },
-          'failed to create institutional IP match notification'
-        )
-      })
+    try {
+      await NotificationsBuilder.promises
+        .ipMatcherAffiliation(user._id)
+        .create(req.ip)
+    } catch (err) {
+      logger.error(
+        { err },
+        'failed to create institutional IP match notification'
+      )
+    }
   }
 
   const hasPaidAffiliation = userAffiliations.some(

+ 583 - 0
services/web/test/unit/src/Project/ProjectListControllerTests.js

@@ -0,0 +1,583 @@
+const SandboxedModule = require('sandboxed-module')
+const path = require('path')
+const sinon = require('sinon')
+const { expect } = require('chai')
+const { ObjectId } = require('mongodb')
+const Errors = require('../../../../app/src/Features/Errors/Errors')
+
+const MODULE_PATH = path.join(
+  __dirname,
+  '../../../../app/src/Features/Project/ProjectListController'
+)
+
+describe('ProjectListController', function () {
+  beforeEach(function () {
+    this.project_id = ObjectId('abcdefabcdefabcdefabcdef')
+
+    this.user = {
+      _id: ObjectId('123456123456123456123456'),
+      email: 'test@overleaf.com',
+      first_name: 'bjkdsjfk',
+      features: {},
+      emails: [{ email: 'test@overleaf.com' }],
+      lastLoginIp: '111.111.111.112',
+    }
+    this.users = {
+      'user-1': {
+        first_name: 'James',
+      },
+      'user-2': {
+        first_name: 'Henry',
+      },
+    }
+    this.users[this.user._id] = this.user // Owner
+    this.usersArr = Object.entries(this.users).map(([key, value]) => ({
+      _id: key,
+      ...value,
+    }))
+    this.tags = [
+      { name: 1, project_ids: ['1', '2', '3'] },
+      { name: 2, project_ids: ['a', '1'] },
+      { name: 3, project_ids: ['a', 'b', 'c', 'd'] },
+    ]
+    this.notifications = [
+      {
+        _id: '1',
+        user_id: '2',
+        templateKey: '3',
+        messageOpts: '4',
+        key: '5',
+      },
+    ]
+    this.settings = {
+      siteUrl: 'https://overleaf.com',
+    }
+    this.LimitationsManager = {
+      promises: {
+        userIsMemberOfGroupSubscription: sinon.stub().resolves(false),
+      },
+    }
+    this.TagsHandler = {
+      promises: {
+        getAllTags: sinon.stub().resolves(this.tags),
+      },
+    }
+    this.NotificationsHandler = {
+      promises: {
+        getUserNotifications: sinon.stub().resolves(this.notifications),
+      },
+    }
+    this.UserModel = {
+      findById: sinon.stub().resolves(this.user),
+    }
+    this.UserPrimaryEmailCheckHandler = {
+      requiresPrimaryEmailCheck: sinon.stub().returns(false),
+    }
+    this.ProjectGetter = {
+      promises: {
+        findAllUsersProjects: sinon.stub(),
+      },
+    }
+    this.ProjectHelper = {
+      isArchived: sinon.stub(),
+      isTrashed: sinon.stub(),
+    }
+    this.SessionManager = {
+      getLoggedInUserId: sinon.stub().returns(this.user._id),
+    }
+    this.UserController = {
+      logout: sinon.stub(),
+    }
+    this.UserGetter = {
+      promises: {
+        getUsers: sinon.stub().resolves(this.usersArr),
+        getUserFullEmails: sinon.stub().resolves([]),
+      },
+    }
+    this.Features = {
+      hasFeature: sinon.stub(),
+    }
+    this.Metrics = {
+      inc: sinon.stub(),
+    }
+    this.SplitTestHandler = {
+      promises: {
+        getAssignment: sinon.stub().resolves({ variant: 'default' }),
+      },
+    }
+    this.SubscriptionViewModelBuilder = {
+      promises: {
+        getBestSubscription: sinon.stub().resolves({ type: 'free' }),
+      },
+    }
+    this.SurveyHandler = {
+      promises: {
+        getSurvey: sinon.stub().resolves({}),
+      },
+    }
+    this.NotificationBuilder = {
+      promises: {
+        ipMatcherAffiliation: sinon.stub().returns({ create: sinon.stub() }),
+      },
+    }
+
+    this.ProjectListController = SandboxedModule.require(MODULE_PATH, {
+      requires: {
+        mongodb: { ObjectId },
+        '@overleaf/settings': this.settings,
+        '@overleaf/metrics': this.Metrics,
+        '../SplitTests/SplitTestHandler': this.SplitTestHandler,
+        '../User/UserController': this.UserController,
+        './ProjectHelper': this.ProjectHelper,
+        '../Subscription/LimitationsManager': this.LimitationsManager,
+        '../Tags/TagsHandler': this.TagsHandler,
+        '../Notifications/NotificationsHandler': this.NotificationsHandler,
+        '../../models/User': { User: this.UserModel },
+        './ProjectGetter': this.ProjectGetter,
+        '../Authentication/SessionManager': this.SessionManager,
+        '../../infrastructure/Features': this.Features,
+        '../User/UserGetter': this.UserGetter,
+        '../Subscription/SubscriptionViewModelBuilder':
+          this.SubscriptionViewModelBuilder,
+        '../../infrastructure/Modules': {
+          promises: {
+            hooks: { fire: sinon.stub().resolves([]) },
+          },
+        },
+        '../Survey/SurveyHandler': this.SurveyHandler,
+        '../User/UserPrimaryEmailCheckHandler':
+          this.UserPrimaryEmailCheckHandler,
+        '../Notifications/NotificationsBuilder': this.NotificationBuilder,
+      },
+    })
+
+    this.req = {
+      query: {},
+      params: {
+        Project_id: this.project_id,
+      },
+      headers: {},
+      session: {
+        user: this.user,
+      },
+      body: {},
+      i18n: {
+        translate() {},
+      },
+    }
+    this.res = {}
+  })
+
+  describe('projectListReactPage', function () {
+    beforeEach(function () {
+      this.projects = [
+        { _id: 1, lastUpdated: 1, owner_ref: 'user-1' },
+        {
+          _id: 2,
+          lastUpdated: 2,
+          owner_ref: 'user-2',
+          lastUpdatedBy: 'user-1',
+        },
+      ]
+      this.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }]
+      this.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }]
+      this.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }]
+      this.tokenReadOnly = [{ _id: 7, lastUpdated: 4, owner_ref: 'user-5' }]
+      this.allProjects = {
+        owned: this.projects,
+        readAndWrite: this.readAndWrite,
+        readOnly: this.readOnly,
+        tokenReadAndWrite: this.tokenReadAndWrite,
+        tokenReadOnly: this.tokenReadOnly,
+      }
+
+      this.ProjectGetter.promises.findAllUsersProjects.resolves(
+        this.allProjects
+      )
+    })
+
+    it('should render the project/list-react page', function (done) {
+      this.res.render = (pageName, opts) => {
+        pageName.should.equal('project/list-react')
+        done()
+      }
+      this.ProjectListController.projectListReactPage(this.req, this.res)
+    })
+
+    it('should send the tags', function (done) {
+      this.res.render = (pageName, opts) => {
+        opts.tags.length.should.equal(this.tags.length)
+        done()
+      }
+      this.ProjectListController.projectListReactPage(this.req, this.res)
+    })
+
+    it('should create trigger ip matcher notifications', function (done) {
+      this.settings.overleaf = true
+      this.req.ip = '111.111.111.111'
+      this.res.render = (pageName, opts) => {
+        this.NotificationBuilder.promises.ipMatcherAffiliation.called.should.equal(
+          true
+        )
+        done()
+      }
+      this.ProjectListController.projectListReactPage(this.req, this.res)
+    })
+
+    it('should send the projects', function (done) {
+      this.res.render = (pageName, opts) => {
+        opts.prefetchedProjectsBlob.projects.length.should.equal(
+          this.projects.length +
+            this.readAndWrite.length +
+            this.readOnly.length +
+            this.tokenReadAndWrite.length +
+            this.tokenReadOnly.length
+        )
+        done()
+      }
+      this.ProjectListController.projectListReactPage(this.req, this.res)
+    })
+
+    it('should send the user', function (done) {
+      this.res.render = (pageName, opts) => {
+        opts.user.should.deep.equal(this.user)
+        done()
+      }
+      this.ProjectListController.projectListReactPage(this.req, this.res)
+    })
+
+    it('should inject the users', function (done) {
+      this.res.render = (pageName, opts) => {
+        const projects = opts.prefetchedProjectsBlob.projects
+
+        projects
+          .filter(p => p.id === '1')[0]
+          .owner.firstName.should.equal(
+            this.users[this.projects.filter(p => p._id === 1)[0].owner_ref]
+              .first_name
+          )
+        projects
+          .filter(p => p.id === '2')[0]
+          .owner.firstName.should.equal(
+            this.users[this.projects.filter(p => p._id === 2)[0].owner_ref]
+              .first_name
+          )
+        projects
+          .filter(p => p.id === '2')[0]
+          .lastUpdatedBy.firstName.should.equal(
+            this.users[this.projects.filter(p => p._id === 2)[0].lastUpdatedBy]
+              .first_name
+          )
+        done()
+      }
+      this.ProjectListController.projectListReactPage(this.req, this.res)
+    })
+
+    it("should send the user's best subscription when saas feature present", function (done) {
+      this.Features.hasFeature.withArgs('saas').returns(true)
+      this.res.render = (pageName, opts) => {
+        expect(opts.usersBestSubscription).to.deep.include({ type: 'free' })
+        done()
+      }
+      this.ProjectListController.projectListReactPage(this.req, this.res)
+    })
+
+    it('should not return a best subscription without saas feature', function (done) {
+      this.Features.hasFeature.withArgs('saas').returns(false)
+      this.res.render = (pageName, opts) => {
+        expect(opts.usersBestSubscription).to.be.undefined
+        done()
+      }
+      this.ProjectListController.projectListReactPage(this.req, this.res)
+    })
+
+    describe('With Institution SSO feature', function () {
+      beforeEach(function (done) {
+        this.institutionEmail = 'test@overleaf.com'
+        this.institutionName = 'Overleaf'
+        this.Features.hasFeature.withArgs('saml').returns(true)
+        this.Features.hasFeature.withArgs('affiliations').returns(true)
+        this.Features.hasFeature.withArgs('overleaf-integration').returns(true)
+        done()
+      })
+      it('should show institution SSO available notification for confirmed domains', function () {
+        this.UserGetter.promises.getUserFullEmails.resolves([
+          {
+            email: 'test@overleaf.com',
+            affiliation: {
+              institution: {
+                id: 1,
+                confirmed: true,
+                name: 'Overleaf',
+                ssoBeta: false,
+                ssoEnabled: true,
+              },
+            },
+          },
+        ])
+        this.res.render = (pageName, opts) => {
+          expect(opts.notificationsInstitution).to.deep.include({
+            email: this.institutionEmail,
+            institutionId: 1,
+            institutionName: this.institutionName,
+            templateKey: 'notification_institution_sso_available',
+          })
+        }
+        this.ProjectListController.projectListReactPage(this.req, this.res)
+      })
+      it('should show a linked notification', function () {
+        this.req.session.saml = {
+          institutionEmail: this.institutionEmail,
+          linked: {
+            hasEntitlement: false,
+            universityName: this.institutionName,
+          },
+        }
+        this.res.render = (pageName, opts) => {
+          expect(opts.notificationsInstitution).to.deep.include({
+            email: this.institutionEmail,
+            institutionName: this.institutionName,
+            templateKey: 'notification_institution_sso_linked',
+          })
+        }
+        this.ProjectListController.projectListReactPage(this.req, this.res)
+      })
+      it('should show a linked another email notification', function () {
+        // when they request to link an email but the institution returns
+        // a different email
+        this.res.render = (pageName, opts) => {
+          expect(opts.notificationsInstitution).to.deep.include({
+            institutionEmail: this.institutionEmail,
+            requestedEmail: 'requested@overleaf.com',
+            templateKey: 'notification_institution_sso_non_canonical',
+          })
+        }
+        this.req.session.saml = {
+          emailNonCanonical: this.institutionEmail,
+          institutionEmail: this.institutionEmail,
+          requestedEmail: 'requested@overleaf.com',
+          linked: {
+            hasEntitlement: false,
+            universityName: this.institutionName,
+          },
+        }
+        this.ProjectListController.projectListReactPage(this.req, this.res)
+      })
+
+      it('should show a notification when intent was to register via SSO but account existed', function () {
+        this.res.render = (pageName, opts) => {
+          expect(opts.notificationsInstitution).to.deep.include({
+            email: this.institutionEmail,
+            templateKey: 'notification_institution_sso_already_registered',
+          })
+        }
+        this.req.session.saml = {
+          institutionEmail: this.institutionEmail,
+          linked: {
+            hasEntitlement: false,
+            universityName: 'Overleaf',
+          },
+          registerIntercept: {
+            id: 1,
+            name: 'Example University',
+          },
+        }
+        this.ProjectListController.projectListReactPage(this.req, this.res)
+      })
+
+      it('should not show a register notification if the flow was abandoned', function () {
+        // could initially start to register with an SSO email and then
+        // abandon flow and login with an existing non-institution SSO email
+        this.res.render = (pageName, opts) => {
+          expect(opts.notificationsInstitution).to.deep.not.include({
+            email: 'test@overleaf.com',
+            templateKey: 'notification_institution_sso_already_registered',
+          })
+        }
+        this.req.session.saml = {
+          registerIntercept: {
+            id: 1,
+            name: 'Example University',
+          },
+        }
+        this.ProjectListController.projectListReactPage(this.req, this.res)
+      })
+
+      it('should show error notification', function () {
+        this.res.render = (pageName, opts) => {
+          expect(opts.notificationsInstitution.length).to.equal(1)
+          expect(opts.notificationsInstitution[0].templateKey).to.equal(
+            'notification_institution_sso_error'
+          )
+          expect(opts.notificationsInstitution[0].error).to.be.instanceof(
+            Errors.SAMLAlreadyLinkedError
+          )
+        }
+        this.req.session.saml = {
+          institutionEmail: this.institutionEmail,
+          error: new Errors.SAMLAlreadyLinkedError(),
+        }
+        this.ProjectListController.projectListReactPage(this.req, this.res)
+      })
+
+      describe('for an unconfirmed domain for an SSO institution', function () {
+        beforeEach(function (done) {
+          this.UserGetter.promises.getUserFullEmails.resolves([
+            {
+              email: 'test@overleaf-uncofirmed.com',
+              affiliation: {
+                institution: {
+                  id: 1,
+                  confirmed: false,
+                  name: 'Overleaf',
+                  ssoBeta: false,
+                  ssoEnabled: true,
+                },
+              },
+            },
+          ])
+          done()
+        })
+        it('should not show institution SSO available notification', function () {
+          this.res.render = (pageName, opts) => {
+            expect(opts.notificationsInstitution.length).to.equal(0)
+          }
+          this.ProjectListController.projectListReactPage(this.req, this.res)
+        })
+      })
+      describe('when linking/logging in initiated on institution side', function () {
+        it('should not show a linked another email notification', function () {
+          // this is only used when initated on Overleaf,
+          // because we keep track of the requested email they tried to link
+          this.res.render = (pageName, opts) => {
+            expect(opts.notificationsInstitution).to.not.deep.include({
+              institutionEmail: this.institutionEmail,
+              requestedEmail: undefined,
+              templateKey: 'notification_institution_sso_non_canonical',
+            })
+          }
+          this.req.session.saml = {
+            emailNonCanonical: this.institutionEmail,
+            institutionEmail: this.institutionEmail,
+            linked: {
+              hasEntitlement: false,
+              universityName: this.institutionName,
+            },
+          }
+          this.ProjectListController.projectListReactPage(this.req, this.res)
+        })
+      })
+      describe('Institution with SSO beta testable', function () {
+        beforeEach(function (done) {
+          this.UserGetter.promises.getUserFullEmails.resolves([
+            {
+              email: 'beta@beta.com',
+              affiliation: {
+                institution: {
+                  id: 2,
+                  confirmed: true,
+                  name: 'Beta University',
+                  ssoBeta: true,
+                  ssoEnabled: false,
+                },
+              },
+            },
+          ])
+          done()
+        })
+        it('should show institution SSO available notification when on a beta testing session', function () {
+          this.req.session.samlBeta = true
+          this.res.render = (pageName, opts) => {
+            expect(opts.notificationsInstitution).to.deep.include({
+              email: 'beta@beta.com',
+              institutionId: 2,
+              institutionName: 'Beta University',
+              templateKey: 'notification_institution_sso_available',
+            })
+          }
+          this.ProjectListController.projectListReactPage(this.req, this.res)
+        })
+        it('should not show institution SSO available notification when not on a beta testing session', function () {
+          this.req.session.samlBeta = false
+          this.res.render = (pageName, opts) => {
+            expect(opts.notificationsInstitution).to.deep.not.include({
+              email: 'test@overleaf.com',
+              institutionId: 1,
+              institutionName: 'Overleaf',
+              templateKey: 'notification_institution_sso_available',
+            })
+          }
+          this.ProjectListController.projectListReactPage(this.req, this.res)
+        })
+      })
+    })
+
+    describe('Without Institution SSO feature', function () {
+      beforeEach(function (done) {
+        this.Features.hasFeature.withArgs('saml').returns(false)
+        done()
+      })
+      it('should not show institution sso available notification', function () {
+        this.res.render = (pageName, opts) => {
+          expect(opts.notificationsInstitution).to.deep.not.include({
+            email: 'test@overleaf.com',
+            institutionId: 1,
+            institutionName: 'Overleaf',
+            templateKey: 'notification_institution_sso_available',
+          })
+        }
+        this.ProjectListController.projectListReactPage(this.req, this.res)
+      })
+    })
+  })
+
+  describe('projectListReactPage with duplicate projects', function () {
+    beforeEach(function () {
+      this.projects = [
+        { _id: 1, lastUpdated: 1, owner_ref: 'user-1' },
+        { _id: 2, lastUpdated: 2, owner_ref: 'user-2' },
+      ]
+      this.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }]
+      this.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }]
+      this.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }]
+      this.tokenReadOnly = [
+        { _id: 6, lastUpdated: 5, owner_ref: 'user-4' }, // Also in tokenReadAndWrite
+        { _id: 7, lastUpdated: 4, owner_ref: 'user-5' },
+      ]
+      this.allProjects = {
+        owned: this.projects,
+        readAndWrite: this.readAndWrite,
+        readOnly: this.readOnly,
+        tokenReadAndWrite: this.tokenReadAndWrite,
+        tokenReadOnly: this.tokenReadOnly,
+      }
+
+      this.ProjectGetter.promises.findAllUsersProjects.resolves(
+        this.allProjects
+      )
+    })
+
+    it('should render the project/list-react page', function (done) {
+      this.res.render = (pageName, opts) => {
+        pageName.should.equal('project/list-react')
+        done()
+      }
+      this.ProjectListController.projectListReactPage(this.req, this.res)
+    })
+
+    it('should omit one of the projects', function (done) {
+      this.res.render = (pageName, opts) => {
+        opts.prefetchedProjectsBlob.projects.length.should.equal(
+          this.projects.length +
+            this.readAndWrite.length +
+            this.readOnly.length +
+            this.tokenReadAndWrite.length +
+            this.tokenReadOnly.length -
+            1
+        )
+        done()
+      }
+      this.ProjectListController.projectListReactPage(this.req, this.res)
+    })
+  })
+})