OwnershipTransferHandler.test.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. import { vi, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs'
  4. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  5. import mongodb from 'mongodb-legacy'
  6. const { ObjectId } = mongodb
  7. const MODULE_PATH =
  8. '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs'
  9. describe('OwnershipTransferHandler', function () {
  10. beforeEach(async function (ctx) {
  11. ctx.user = { _id: new ObjectId(), email: 'owner@example.com' }
  12. ctx.collaborator = {
  13. _id: new ObjectId(),
  14. email: 'collaborator@example.com',
  15. }
  16. ctx.readOnlyCollaborator = {
  17. _id: new ObjectId(),
  18. email: 'readonly@example.com',
  19. }
  20. ctx.reviewer = {
  21. _id: new ObjectId(),
  22. email: 'reviewer@example.com',
  23. }
  24. ctx.project = {
  25. _id: new ObjectId(),
  26. name: 'project',
  27. owner_ref: ctx.user._id,
  28. collaberator_refs: [ctx.collaborator._id],
  29. readOnly_refs: [ctx.readOnlyCollaborator._id],
  30. reviewer_refs: [ctx.reviewer._id],
  31. }
  32. ctx.ProjectGetter = {
  33. promises: {
  34. getProject: sinon.stub().resolves(ctx.project),
  35. },
  36. }
  37. ctx.ProjectModel = {
  38. find: sinon.stub().resolves([]),
  39. updateOne: sinon.stub().returns({
  40. exec: sinon.stub().resolves(),
  41. }),
  42. }
  43. ctx.UserGetter = {
  44. promises: {
  45. getUser: sinon.stub().resolves(),
  46. },
  47. }
  48. ctx.TpdsUpdateSender = {
  49. promises: {
  50. moveEntity: sinon.stub().resolves(),
  51. },
  52. }
  53. ctx.TpdsProjectFlusher = {
  54. promises: {
  55. flushProjectToTpds: sinon.stub().resolves(),
  56. },
  57. }
  58. ctx.CollaboratorsHandler = {
  59. promises: {
  60. removeUserFromProject: sinon.stub().resolves(),
  61. addUserIdToProject: sinon.stub().resolves(),
  62. },
  63. }
  64. ctx.EmailHandler = {
  65. promises: {
  66. sendEmail: sinon.stub().resolves(),
  67. },
  68. }
  69. ctx.ProjectAuditLogHandler = {
  70. promises: {
  71. addEntry: sinon.stub().resolves(),
  72. },
  73. }
  74. ctx.TagsHandler = {
  75. promises: {
  76. createTag: sinon.stub().resolves(),
  77. addProjectsToTag: sinon.stub().resolves(),
  78. },
  79. }
  80. ctx.LimitationsManager = {
  81. promises: {
  82. canAddXEditCollaborators: sinon.stub().resolves(true),
  83. },
  84. }
  85. vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  86. vi.importActual('../../../../app/src/Features/Errors/Errors.js')
  87. )
  88. vi.doMock('../../../../app/src/Features/Project/ProjectGetter.mjs', () => ({
  89. default: ctx.ProjectGetter,
  90. }))
  91. vi.doMock('../../../../app/src/models/Project.mjs', () => ({
  92. Project: ctx.ProjectModel,
  93. }))
  94. vi.doMock('../../../../app/src/Features/Tags/TagsHandler.mjs', () => ({
  95. default: ctx.TagsHandler,
  96. }))
  97. vi.doMock('../../../../app/src/Features/User/UserGetter.mjs', () => ({
  98. default: ctx.UserGetter,
  99. }))
  100. vi.doMock(
  101. '../../../../app/src/Features/ThirdPartyDataStore/TpdsProjectFlusher.mjs',
  102. () => ({
  103. default: ctx.TpdsProjectFlusher,
  104. })
  105. )
  106. vi.doMock(
  107. '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
  108. () => ({
  109. default: ctx.ProjectAuditLogHandler,
  110. })
  111. )
  112. vi.doMock('../../../../app/src/Features/Email/EmailHandler.mjs', () => ({
  113. default: ctx.EmailHandler,
  114. }))
  115. vi.doMock(
  116. '../../../../app/src/Features/Collaborators/CollaboratorsHandler.mjs',
  117. () => ({
  118. default: ctx.CollaboratorsHandler,
  119. })
  120. )
  121. vi.doMock(
  122. '../../../../app/src/Features/Analytics/AnalyticsManager',
  123. () => ({
  124. default: {
  125. recordEventForUserInBackground: (ctx.recordEventForUserInBackground =
  126. sinon.stub()),
  127. },
  128. })
  129. )
  130. vi.doMock(
  131. '../../../../app/src/Features/Subscription/LimitationsManager.mjs',
  132. () => ({
  133. default: ctx.LimitationsManager,
  134. })
  135. )
  136. ctx.handler = (await import(MODULE_PATH)).default
  137. })
  138. describe('transferOwnership', function () {
  139. beforeEach(function (ctx) {
  140. ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
  141. ctx.UserGetter.promises.getUser
  142. .withArgs(ctx.collaborator._id)
  143. .resolves(ctx.collaborator)
  144. ctx.UserGetter.promises.getUser
  145. .withArgs(ctx.readOnlyCollaborator._id)
  146. .resolves(ctx.readOnlyCollaborator)
  147. ctx.UserGetter.promises.getUser
  148. .withArgs(ctx.reviewer._id)
  149. .resolves(ctx.reviewer)
  150. })
  151. it("should return a not found error if the project can't be found", async function (ctx) {
  152. ctx.ProjectGetter.promises.getProject.resolves(null)
  153. await expect(
  154. ctx.handler.promises.transferOwnership('abc', ctx.collaborator._id)
  155. ).to.be.rejectedWith(Errors.ProjectNotFoundError)
  156. })
  157. it("should return a not found error if the user can't be found", async function (ctx) {
  158. ctx.UserGetter.promises.getUser
  159. .withArgs(ctx.collaborator._id)
  160. .resolves(null)
  161. await expect(
  162. ctx.handler.promises.transferOwnership(
  163. ctx.project._id,
  164. ctx.collaborator._id
  165. )
  166. ).to.be.rejectedWith(Errors.UserNotFoundError)
  167. })
  168. it('should return an error if user cannot be removed as collaborator ', async function (ctx) {
  169. ctx.CollaboratorsHandler.promises.removeUserFromProject.rejects(
  170. new Error('user-cannot-be-removed')
  171. )
  172. await expect(
  173. ctx.handler.promises.transferOwnership(
  174. ctx.project._id,
  175. ctx.collaborator._id
  176. )
  177. ).to.be.rejected
  178. })
  179. it('should transfer ownership of the project', async function (ctx) {
  180. await ctx.handler.promises.transferOwnership(
  181. ctx.project._id,
  182. ctx.collaborator._id
  183. )
  184. expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
  185. { _id: ctx.project._id },
  186. sinon.match({ $set: { owner_ref: ctx.collaborator._id } })
  187. )
  188. })
  189. it('should check collaborator limits after ownership transfer', async function (ctx) {
  190. await ctx.handler.promises.transferOwnership(
  191. ctx.project._id,
  192. ctx.collaborator._id
  193. )
  194. expect(ctx.ProjectModel.updateOne).to.have.been.calledBefore(
  195. ctx.LimitationsManager.promises.canAddXEditCollaborators
  196. )
  197. expect(
  198. ctx.LimitationsManager.promises.canAddXEditCollaborators
  199. ).to.have.been.calledBefore(
  200. ctx.CollaboratorsHandler.promises.addUserIdToProject
  201. )
  202. })
  203. describe('when there are edit collaborator slots available', function () {
  204. beforeEach(function (ctx) {
  205. ctx.LimitationsManager.promises.canAddXEditCollaborators.resolves(true)
  206. })
  207. it('should give old owner read/write permissions when transferring to a read-only collaborator', async function (ctx) {
  208. await ctx.handler.promises.transferOwnership(
  209. ctx.project._id,
  210. ctx.readOnlyCollaborator._id
  211. )
  212. expect(
  213. ctx.CollaboratorsHandler.promises.addUserIdToProject
  214. ).to.have.been.calledWith(
  215. ctx.project._id,
  216. ctx.readOnlyCollaborator._id,
  217. ctx.user._id,
  218. PrivilegeLevels.READ_AND_WRITE,
  219. undefined
  220. )
  221. expect(
  222. ctx.LimitationsManager.promises.canAddXEditCollaborators
  223. ).to.have.been.calledWith(ctx.project._id, 1)
  224. })
  225. it('should give old owner read/write permissions when transferring to an editor', async function (ctx) {
  226. await ctx.handler.promises.transferOwnership(
  227. ctx.project._id,
  228. ctx.collaborator._id
  229. )
  230. expect(
  231. ctx.CollaboratorsHandler.promises.addUserIdToProject
  232. ).to.have.been.calledWith(
  233. ctx.project._id,
  234. ctx.collaborator._id,
  235. ctx.user._id,
  236. PrivilegeLevels.READ_AND_WRITE,
  237. undefined
  238. )
  239. expect(
  240. ctx.LimitationsManager.promises.canAddXEditCollaborators
  241. ).to.have.been.calledWith(ctx.project._id, 1)
  242. })
  243. it('should give old owner read/write permissions when transferring to a reviewer', async function (ctx) {
  244. await ctx.handler.promises.transferOwnership(
  245. ctx.project._id,
  246. ctx.reviewer._id
  247. )
  248. expect(
  249. ctx.CollaboratorsHandler.promises.addUserIdToProject
  250. ).to.have.been.calledWith(
  251. ctx.project._id,
  252. ctx.reviewer._id,
  253. ctx.user._id,
  254. PrivilegeLevels.READ_AND_WRITE,
  255. undefined
  256. )
  257. expect(
  258. ctx.LimitationsManager.promises.canAddXEditCollaborators
  259. ).to.have.been.calledWith(ctx.project._id, 1)
  260. })
  261. it('should give old owner read/write permissions when transferring to a non-collaborator', async function (ctx) {
  262. ctx.project.collaberator_refs = []
  263. ctx.project.readOnly_refs = []
  264. ctx.project.reviewer_refs = []
  265. const newOwner = {
  266. _id: new ObjectId(),
  267. email: 'admin@example.com',
  268. }
  269. ctx.UserGetter.promises.getUser
  270. .withArgs(newOwner._id)
  271. .resolves(newOwner)
  272. await ctx.handler.promises.transferOwnership(
  273. ctx.project._id,
  274. newOwner._id,
  275. { allowTransferToNonCollaborators: true }
  276. )
  277. expect(
  278. ctx.CollaboratorsHandler.promises.addUserIdToProject
  279. ).to.have.been.calledWith(
  280. ctx.project._id,
  281. newOwner._id,
  282. ctx.user._id,
  283. PrivilegeLevels.READ_AND_WRITE,
  284. undefined
  285. )
  286. expect(
  287. ctx.LimitationsManager.promises.canAddXEditCollaborators
  288. ).to.have.been.calledWith(ctx.project._id, 1)
  289. })
  290. })
  291. describe('when there are no edit collaborator slots available', function () {
  292. beforeEach(function (ctx) {
  293. ctx.LimitationsManager.promises.canAddXEditCollaborators.resolves(false)
  294. })
  295. it('should give old owner read-only with pending editor flag when transferring to an editor', async function (ctx) {
  296. await ctx.handler.promises.transferOwnership(
  297. ctx.project._id,
  298. ctx.collaborator._id
  299. )
  300. expect(
  301. ctx.CollaboratorsHandler.promises.addUserIdToProject
  302. ).to.have.been.calledWith(
  303. ctx.project._id,
  304. ctx.collaborator._id,
  305. ctx.user._id,
  306. PrivilegeLevels.READ_ONLY,
  307. { pendingEditor: true }
  308. )
  309. })
  310. it('should give old owner read-only with pending editor flag when transferring to a reviewer', async function (ctx) {
  311. await ctx.handler.promises.transferOwnership(
  312. ctx.project._id,
  313. ctx.reviewer._id
  314. )
  315. expect(
  316. ctx.CollaboratorsHandler.promises.addUserIdToProject
  317. ).to.have.been.calledWith(
  318. ctx.project._id,
  319. ctx.reviewer._id,
  320. ctx.user._id,
  321. PrivilegeLevels.READ_ONLY,
  322. { pendingEditor: true }
  323. )
  324. })
  325. it('should give old owner read-only with pending editor flag when transferring to a read-only collaborator', async function (ctx) {
  326. await ctx.handler.promises.transferOwnership(
  327. ctx.project._id,
  328. ctx.readOnlyCollaborator._id
  329. )
  330. expect(
  331. ctx.CollaboratorsHandler.promises.addUserIdToProject
  332. ).to.have.been.calledWith(
  333. ctx.project._id,
  334. ctx.readOnlyCollaborator._id,
  335. ctx.user._id,
  336. PrivilegeLevels.READ_ONLY,
  337. { pendingEditor: true }
  338. )
  339. })
  340. it('should give old owner read-only with pending editor flag when transferring to a non-collaborator', async function (ctx) {
  341. ctx.project.collaberator_refs = []
  342. ctx.project.readOnly_refs = []
  343. ctx.project.reviewer_refs = []
  344. const newOwner = {
  345. _id: new ObjectId(),
  346. email: 'newowner@example.com',
  347. }
  348. ctx.UserGetter.promises.getUser
  349. .withArgs(newOwner._id)
  350. .resolves(newOwner)
  351. await ctx.handler.promises.transferOwnership(
  352. ctx.project._id,
  353. newOwner._id,
  354. { allowTransferToNonCollaborators: true }
  355. )
  356. expect(
  357. ctx.CollaboratorsHandler.promises.addUserIdToProject
  358. ).to.have.been.calledWith(
  359. ctx.project._id,
  360. newOwner._id,
  361. ctx.user._id,
  362. PrivilegeLevels.READ_ONLY,
  363. { pendingEditor: true }
  364. )
  365. })
  366. })
  367. it('should do nothing if transferring back to the owner', async function (ctx) {
  368. await ctx.handler.promises.transferOwnership(
  369. ctx.project._id,
  370. ctx.user._id
  371. )
  372. expect(ctx.ProjectModel.updateOne).not.to.have.been.called
  373. })
  374. it("should remove the user from the project's collaborators", async function (ctx) {
  375. await ctx.handler.promises.transferOwnership(
  376. ctx.project._id,
  377. ctx.collaborator._id
  378. )
  379. expect(
  380. ctx.CollaboratorsHandler.promises.removeUserFromProject
  381. ).to.have.been.calledWith(ctx.project._id, ctx.collaborator._id)
  382. })
  383. it('should transfer ownership of the project to a reviewer', async function (ctx) {
  384. await ctx.handler.promises.transferOwnership(
  385. ctx.project._id,
  386. ctx.reviewer._id
  387. )
  388. expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
  389. { _id: ctx.project._id },
  390. sinon.match({ $set: { owner_ref: ctx.reviewer._id } })
  391. )
  392. })
  393. it('should flush the project to tpds', async function (ctx) {
  394. await ctx.handler.promises.transferOwnership(
  395. ctx.project._id,
  396. ctx.collaborator._id
  397. )
  398. expect(
  399. ctx.TpdsProjectFlusher.promises.flushProjectToTpds
  400. ).to.have.been.calledWith(ctx.project._id)
  401. })
  402. it('should send an email notification', async function (ctx) {
  403. await ctx.handler.promises.transferOwnership(
  404. ctx.project._id,
  405. ctx.collaborator._id
  406. )
  407. expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
  408. 'ownershipTransferConfirmationPreviousOwner',
  409. {
  410. to: ctx.user.email,
  411. project: ctx.project,
  412. newOwner: ctx.collaborator,
  413. }
  414. )
  415. expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
  416. 'ownershipTransferConfirmationNewOwner',
  417. {
  418. to: ctx.collaborator.email,
  419. project: ctx.project,
  420. previousOwner: ctx.user,
  421. }
  422. )
  423. })
  424. it('should not send an email notification with the skipEmails option', async function (ctx) {
  425. await ctx.handler.promises.transferOwnership(
  426. ctx.project._id,
  427. ctx.collaborator._id,
  428. { skipEmails: true }
  429. )
  430. expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
  431. })
  432. it('should track the change in BigQuery', async function (ctx) {
  433. const sessionUserId = new ObjectId()
  434. await ctx.handler.promises.transferOwnership(
  435. ctx.project._id,
  436. ctx.collaborator._id,
  437. { sessionUserId }
  438. )
  439. expect(ctx.recordEventForUserInBackground).to.have.been.calledWith(
  440. ctx.user._id,
  441. 'project-ownership-transfer',
  442. {
  443. projectId: ctx.project._id,
  444. newOwnerId: ctx.collaborator._id,
  445. }
  446. )
  447. })
  448. it('should write an entry in the audit log', async function (ctx) {
  449. const sessionUserId = new ObjectId()
  450. const ipAddress = '1.2.3.4'
  451. await ctx.handler.promises.transferOwnership(
  452. ctx.project._id,
  453. ctx.collaborator._id,
  454. { sessionUserId, ipAddress }
  455. )
  456. expect(
  457. ctx.ProjectAuditLogHandler.promises.addEntry
  458. ).to.have.been.calledWith(
  459. ctx.project._id,
  460. 'transfer-ownership',
  461. sessionUserId,
  462. ipAddress,
  463. {
  464. previousOwnerId: ctx.user._id,
  465. newOwnerId: ctx.collaborator._id,
  466. }
  467. )
  468. })
  469. it('should decline to transfer ownership to a non-collaborator', async function (ctx) {
  470. ctx.project.collaberator_refs = []
  471. ctx.project.readOnly_refs = []
  472. await expect(
  473. ctx.handler.promises.transferOwnership(
  474. ctx.project._id,
  475. ctx.collaborator._id
  476. )
  477. ).to.be.rejectedWith(Errors.UserNotCollaboratorError)
  478. })
  479. })
  480. describe('transferAllProjectsToUser', function () {
  481. const fromUserEmail = 'user.one@example.com'
  482. const ipAddress = '1.2.3.4'
  483. let fromUserId, toUserId
  484. beforeEach(function () {
  485. fromUserId = new ObjectId().toString()
  486. toUserId = new ObjectId().toString()
  487. })
  488. describe('with missing user', function () {
  489. it('should throw an error', async function (ctx) {
  490. ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves(null)
  491. ctx.UserGetter.promises.getUser
  492. .withArgs(toUserId)
  493. .resolves({ _id: new ObjectId(toUserId) })
  494. await expect(
  495. ctx.handler.promises.transferAllProjectsToUser({
  496. toUserId,
  497. fromUserId,
  498. ipAddress,
  499. })
  500. ).to.be.rejectedWith(/missing source user/)
  501. ctx.UserGetter.promises.getUser
  502. .withArgs(fromUserId)
  503. .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
  504. ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves(null)
  505. await expect(
  506. ctx.handler.promises.transferAllProjectsToUser({
  507. fromUserId,
  508. toUserId,
  509. ipAddress,
  510. })
  511. ).to.be.rejectedWith(/missing destination user/)
  512. })
  513. })
  514. describe('with the same id', function () {
  515. it('should throw an error', async function (ctx) {
  516. ctx.UserGetter.promises.getUser
  517. .withArgs(fromUserId)
  518. .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
  519. await expect(
  520. ctx.handler.promises.transferAllProjectsToUser({
  521. fromUserId,
  522. toUserId: fromUserId,
  523. ipAddress,
  524. })
  525. ).to.be.rejectedWith(/rejecting transfer between identical users/)
  526. })
  527. })
  528. describe('happy path', function () {
  529. let tag, fromUserEmail, projects
  530. beforeEach(function (ctx) {
  531. tag = {
  532. _id: new ObjectId(),
  533. name: 'some-tag-name',
  534. }
  535. projects = [
  536. { _id: 'project-1' },
  537. { _id: 'project-2' },
  538. { _id: 'project-3' },
  539. ]
  540. ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves({
  541. _id: new ObjectId(fromUserId),
  542. email: fromUserEmail,
  543. })
  544. ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves({
  545. _id: new ObjectId(toUserId),
  546. })
  547. ctx.ProjectModel.find.resolves(projects)
  548. ctx.TagsHandler.promises.createTag.resolves({
  549. _id: tag._id,
  550. name: 'some-tag-name',
  551. })
  552. ctx.TagsHandler.promises.addProjectsToTag.resolves()
  553. })
  554. it('creates a tag', async function (ctx) {
  555. await ctx.handler.promises.transferAllProjectsToUser({
  556. fromUserId,
  557. toUserId,
  558. ipAddress,
  559. })
  560. expect(ctx.TagsHandler.promises.createTag).to.have.been.calledWith(
  561. toUserId,
  562. `transferred-from-${fromUserEmail}`,
  563. '#434AF0',
  564. { truncate: true }
  565. )
  566. })
  567. it('returns a projectCount, and tag name', async function (ctx) {
  568. const result = await ctx.handler.promises.transferAllProjectsToUser({
  569. fromUserId,
  570. toUserId,
  571. ipAddress,
  572. })
  573. expect(result.projectCount).to.equal(projects.length)
  574. expect(result.newTagName).to.equal('some-tag-name')
  575. })
  576. it('gets the user records', async function (ctx) {
  577. await ctx.handler.promises.transferAllProjectsToUser({
  578. fromUserId,
  579. toUserId,
  580. ipAddress,
  581. })
  582. expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
  583. fromUserId
  584. )
  585. expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
  586. toUserId
  587. )
  588. })
  589. it('gets the list of affected projects', async function (ctx) {
  590. await ctx.handler.promises.transferAllProjectsToUser({
  591. fromUserId,
  592. toUserId,
  593. ipAddress,
  594. })
  595. expect(ctx.ProjectModel.find).to.have.been.calledWith({
  596. owner_ref: fromUserId,
  597. })
  598. })
  599. it('transfers all of the projects', async function (ctx) {
  600. await ctx.handler.promises.transferAllProjectsToUser({
  601. fromUserId,
  602. toUserId,
  603. ipAddress,
  604. })
  605. expect(ctx.ProjectModel.updateOne.callCount).to.equal(3)
  606. expect(ctx.TagsHandler.promises.addProjectsToTag.callCount).to.equal(1)
  607. for (const project of projects) {
  608. expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
  609. { _id: project._id },
  610. sinon.match({ $set: { owner_ref: toUserId } })
  611. )
  612. }
  613. expect(
  614. ctx.TagsHandler.promises.addProjectsToTag
  615. ).to.have.been.calledWith(
  616. toUserId,
  617. tag._id,
  618. projects.map(p => p._id)
  619. )
  620. })
  621. })
  622. })
  623. })