TpdsController.test.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. import { expect, vi } from 'vitest'
  2. import mongodb from 'mongodb-legacy'
  3. import sinon from 'sinon'
  4. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  5. import MockResponse from '../helpers/MockResponse.mjs'
  6. import MockRequest from '../helpers/MockRequest.mjs'
  7. const ObjectId = mongodb.ObjectId
  8. const MODULE_PATH =
  9. '../../../../app/src/Features/ThirdPartyDataStore/TpdsController.mjs'
  10. describe('TpdsController', function () {
  11. beforeEach(async function (ctx) {
  12. ctx.metadata = {
  13. projectId: new ObjectId(),
  14. entityId: new ObjectId(),
  15. folderId: new ObjectId(),
  16. entityType: 'doc',
  17. rev: 2,
  18. }
  19. ctx.TpdsUpdateHandler = {
  20. promises: {
  21. newUpdate: sinon.stub().resolves(ctx.metadata),
  22. deleteUpdate: sinon.stub().resolves(ctx.metadata.entityId),
  23. createFolder: sinon.stub().resolves(),
  24. },
  25. }
  26. ctx.UpdateMerger = {
  27. promises: {
  28. mergeUpdate: sinon.stub().resolves(ctx.metadata),
  29. deleteUpdate: sinon.stub().resolves(ctx.metadata.entityId),
  30. },
  31. }
  32. ctx.NotificationsBuilder = {
  33. promises: {
  34. tpdsFileLimit: sinon.stub().returns({ create: sinon.stub() }),
  35. },
  36. }
  37. ctx.SessionManager = {
  38. getLoggedInUserId: sinon.stub().returns('user-id'),
  39. }
  40. ctx.TpdsQueueManager = {
  41. promises: {
  42. getQueues: sinon.stub().resolves('queues'),
  43. },
  44. }
  45. ctx.HttpErrorHandler = {
  46. conflict: sinon.stub(),
  47. }
  48. ctx.newProject = { _id: new ObjectId() }
  49. ctx.ProjectCreationHandler = {
  50. promises: { createBlankProject: sinon.stub().resolves(ctx.newProject) },
  51. }
  52. ctx.ProjectDetailsHandler = {
  53. promises: {
  54. generateUniqueName: sinon.stub().resolves('unique'),
  55. },
  56. }
  57. vi.doMock(
  58. '../../../../app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler',
  59. () => ({
  60. default: ctx.TpdsUpdateHandler,
  61. })
  62. )
  63. vi.doMock(
  64. '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger',
  65. () => ({
  66. default: ctx.UpdateMerger,
  67. })
  68. )
  69. vi.doMock(
  70. '../../../../app/src/Features/Notifications/NotificationsBuilder',
  71. () => ({
  72. default: ctx.NotificationsBuilder,
  73. })
  74. )
  75. vi.doMock(
  76. '../../../../app/src/Features/Authentication/SessionManager',
  77. () => ({
  78. default: ctx.SessionManager,
  79. })
  80. )
  81. vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
  82. default: ctx.HttpErrorHandler,
  83. }))
  84. vi.doMock(
  85. '../../../../app/src/Features/ThirdPartyDataStore/TpdsQueueManager',
  86. () => ({
  87. default: ctx.TpdsQueueManager,
  88. })
  89. )
  90. vi.doMock(
  91. '../../../../app/src/Features/Project/ProjectCreationHandler',
  92. () => ({
  93. default: ctx.ProjectCreationHandler,
  94. })
  95. )
  96. vi.doMock(
  97. '../../../../app/src/Features/Project/ProjectDetailsHandler',
  98. () => ({
  99. default: ctx.ProjectDetailsHandler,
  100. })
  101. )
  102. ctx.TpdsController = (await import(MODULE_PATH)).default
  103. ctx.user_id = 'dsad29jlkjas'
  104. })
  105. describe('creating a project', function () {
  106. it('should yield the new projects id', async function (ctx) {
  107. await new Promise(resolve => {
  108. const res = new MockResponse(vi)
  109. const req = new MockRequest(vi)
  110. req.params.user_id = ctx.user_id
  111. req.body = { projectName: 'foo' }
  112. res.callback = err => {
  113. if (err) resolve(err)
  114. expect(res.body).to.equal(
  115. JSON.stringify({ projectId: ctx.newProject._id.toString() })
  116. )
  117. expect(
  118. ctx.ProjectDetailsHandler.promises.generateUniqueName
  119. ).to.have.been.calledWith(ctx.user_id, 'foo')
  120. expect(
  121. ctx.ProjectCreationHandler.promises.createBlankProject
  122. ).to.have.been.calledWith(
  123. ctx.user_id,
  124. 'unique',
  125. {},
  126. { skipCreatingInTPDS: true }
  127. )
  128. resolve()
  129. }
  130. ctx.TpdsController.createProject(req, res)
  131. })
  132. })
  133. })
  134. describe('getting an update', function () {
  135. beforeEach(function (ctx) {
  136. ctx.projectName = 'projectName'
  137. ctx.path = '/here.txt'
  138. ctx.req = {
  139. params: {
  140. 0: `${ctx.projectName}${ctx.path}`,
  141. user_id: ctx.user_id,
  142. project_id: '',
  143. },
  144. headers: {
  145. 'x-update-source': (ctx.source = 'dropbox'),
  146. },
  147. }
  148. })
  149. it('should process the update with the update receiver by name', async function (ctx) {
  150. await new Promise(resolve => {
  151. const res = {
  152. json: payload => {
  153. expect(payload).to.deep.equal({
  154. status: 'applied',
  155. projectId: ctx.metadata.projectId.toString(),
  156. entityId: ctx.metadata.entityId.toString(),
  157. folderId: ctx.metadata.folderId.toString(),
  158. entityType: ctx.metadata.entityType,
  159. rev: ctx.metadata.rev.toString(),
  160. })
  161. ctx.TpdsUpdateHandler.promises.newUpdate
  162. .calledWith(
  163. ctx.user_id,
  164. '', // projectId
  165. ctx.projectName,
  166. ctx.path,
  167. ctx.req,
  168. ctx.source
  169. )
  170. .should.equal(true)
  171. resolve()
  172. },
  173. }
  174. ctx.TpdsController.mergeUpdate(ctx.req, res)
  175. })
  176. })
  177. it('should indicate in the response when the update was rejected', async function (ctx) {
  178. await new Promise(resolve => {
  179. ctx.TpdsUpdateHandler.promises.newUpdate.resolves(null)
  180. const res = {
  181. json: payload => {
  182. expect(payload).to.deep.equal({ status: 'rejected' })
  183. resolve()
  184. },
  185. }
  186. ctx.TpdsController.mergeUpdate(ctx.req, res)
  187. })
  188. })
  189. it('should process the update with the update receiver by id', async function (ctx) {
  190. await new Promise(resolve => {
  191. const path = '/here.txt'
  192. const req = {
  193. pause() {},
  194. params: { 0: path, user_id: ctx.user_id, project_id: '123' },
  195. session: {
  196. destroy() {},
  197. },
  198. headers: {
  199. 'x-update-source': (ctx.source = 'dropbox'),
  200. },
  201. }
  202. const res = {
  203. json: () => {
  204. ctx.TpdsUpdateHandler.promises.newUpdate.should.have.been.calledWith(
  205. ctx.user_id,
  206. '123',
  207. '', // projectName
  208. '/here.txt',
  209. req,
  210. ctx.source
  211. )
  212. resolve()
  213. },
  214. }
  215. ctx.TpdsController.mergeUpdate(req, res)
  216. })
  217. })
  218. it('should return a 500 error when the update receiver fails', async function (ctx) {
  219. await new Promise(resolve => {
  220. ctx.TpdsUpdateHandler.promises.newUpdate.rejects(new Error())
  221. const res = {
  222. json: sinon.stub(),
  223. }
  224. ctx.TpdsController.mergeUpdate(ctx.req, res, err => {
  225. expect(err).to.exist
  226. expect(res.json).not.to.have.been.called
  227. resolve()
  228. })
  229. })
  230. })
  231. it('should return a 400 error when the project is too big', async function (ctx) {
  232. await new Promise(resolve => {
  233. ctx.TpdsUpdateHandler.promises.newUpdate.rejects({
  234. message: 'project_has_too_many_files',
  235. })
  236. const res = {
  237. sendStatus: status => {
  238. expect(status).to.equal(400)
  239. ctx.NotificationsBuilder.promises.tpdsFileLimit.should.have.been.calledWith(
  240. ctx.user_id
  241. )
  242. resolve()
  243. },
  244. }
  245. ctx.TpdsController.mergeUpdate(ctx.req, res)
  246. })
  247. })
  248. it('should return a 429 error when the update receiver fails due to too many requests error', async function (ctx) {
  249. await new Promise(resolve => {
  250. ctx.TpdsUpdateHandler.promises.newUpdate.rejects(
  251. new Errors.TooManyRequestsError('project on cooldown')
  252. )
  253. const res = {
  254. sendStatus: status => {
  255. expect(status).to.equal(429)
  256. resolve()
  257. },
  258. }
  259. ctx.TpdsController.mergeUpdate(ctx.req, res)
  260. })
  261. })
  262. })
  263. describe('getting a delete update', function () {
  264. it('should process the delete with the update receiver by name', async function (ctx) {
  265. await new Promise(resolve => {
  266. const path = '/projectName/here.txt'
  267. const req = {
  268. params: { 0: path, user_id: ctx.user_id, project_id: '' },
  269. session: {
  270. destroy() {},
  271. },
  272. headers: {
  273. 'x-update-source': (ctx.source = 'dropbox'),
  274. },
  275. }
  276. const res = {
  277. sendStatus: () => {
  278. ctx.TpdsUpdateHandler.promises.deleteUpdate
  279. .calledWith(
  280. ctx.user_id,
  281. '',
  282. 'projectName',
  283. '/here.txt',
  284. ctx.source
  285. )
  286. .should.equal(true)
  287. resolve()
  288. },
  289. }
  290. ctx.TpdsController.deleteUpdate(req, res)
  291. })
  292. })
  293. it('should process the delete with the update receiver by id', async function (ctx) {
  294. await new Promise(resolve => {
  295. const path = '/here.txt'
  296. const req = {
  297. params: { 0: path, user_id: ctx.user_id, project_id: '123' },
  298. session: {
  299. destroy() {},
  300. },
  301. headers: {
  302. 'x-update-source': (ctx.source = 'dropbox'),
  303. },
  304. }
  305. const res = {
  306. sendStatus: () => {
  307. ctx.TpdsUpdateHandler.promises.deleteUpdate.should.have.been.calledWith(
  308. ctx.user_id,
  309. '123',
  310. '', // projectName
  311. '/here.txt',
  312. ctx.source
  313. )
  314. resolve()
  315. },
  316. }
  317. ctx.TpdsController.deleteUpdate(req, res)
  318. })
  319. })
  320. })
  321. describe('updateFolder', function () {
  322. beforeEach(function (ctx) {
  323. ctx.req = {
  324. body: { userId: ctx.user_id, path: '/abc/def/ghi.txt' },
  325. }
  326. ctx.res = {
  327. json: sinon.stub(),
  328. }
  329. })
  330. it("creates a folder if it doesn't exist", async function (ctx) {
  331. await new Promise(resolve => {
  332. const metadata = {
  333. folderId: new ObjectId(),
  334. projectId: new ObjectId(),
  335. path: '/def/ghi.txt',
  336. parentFolderId: new ObjectId(),
  337. }
  338. ctx.TpdsUpdateHandler.promises.createFolder.resolves(metadata)
  339. ctx.res.json.callsFake(body => {
  340. expect(body).to.deep.equal({
  341. entityId: metadata.folderId.toString(),
  342. projectId: metadata.projectId.toString(),
  343. path: metadata.path,
  344. folderId: metadata.parentFolderId.toString(),
  345. })
  346. resolve()
  347. })
  348. ctx.TpdsController.updateFolder(ctx.req, ctx.res)
  349. })
  350. })
  351. it('supports top level folders', async function (ctx) {
  352. await new Promise(resolve => {
  353. const metadata = {
  354. folderId: new ObjectId(),
  355. projectId: new ObjectId(),
  356. path: '/',
  357. parentFolderId: null,
  358. }
  359. ctx.TpdsUpdateHandler.promises.createFolder.resolves(metadata)
  360. ctx.res.json.callsFake(body => {
  361. expect(body).to.deep.equal({
  362. entityId: metadata.folderId.toString(),
  363. projectId: metadata.projectId.toString(),
  364. path: metadata.path,
  365. folderId: null,
  366. })
  367. resolve()
  368. })
  369. ctx.TpdsController.updateFolder(ctx.req, ctx.res)
  370. })
  371. })
  372. it("returns a 409 if the folder couldn't be created", async function (ctx) {
  373. await new Promise(resolve => {
  374. ctx.TpdsUpdateHandler.promises.createFolder.resolves(null)
  375. ctx.HttpErrorHandler.conflict.callsFake((req, res) => {
  376. expect(req).to.equal(ctx.req)
  377. expect(res).to.equal(ctx.res)
  378. resolve()
  379. })
  380. ctx.TpdsController.updateFolder(ctx.req, ctx.res)
  381. })
  382. })
  383. })
  384. describe('parseParams', function () {
  385. it('should take the project name off the start and replace with slash', function (ctx) {
  386. const path = 'noSlashHere'
  387. const req = { params: { 0: path, user_id: ctx.user_id } }
  388. const result = ctx.TpdsController.parseParams(req)
  389. result.userId.should.equal(ctx.user_id)
  390. result.filePath.should.equal('/')
  391. result.projectName.should.equal(path)
  392. })
  393. it('should take the project name off the start and it with no slashes in', function (ctx) {
  394. const path = '/project/file.tex'
  395. const req = { params: { 0: path, user_id: ctx.user_id } }
  396. const result = ctx.TpdsController.parseParams(req)
  397. result.userId.should.equal(ctx.user_id)
  398. result.filePath.should.equal('/file.tex')
  399. result.projectName.should.equal('project')
  400. })
  401. it('should take the project name of and return a slash for the file path', function (ctx) {
  402. const path = '/project_name'
  403. const req = { params: { 0: path, user_id: ctx.user_id } }
  404. const result = ctx.TpdsController.parseParams(req)
  405. result.projectName.should.equal('project_name')
  406. result.filePath.should.equal('/')
  407. })
  408. })
  409. describe('updateProjectContents', function () {
  410. beforeEach(async function (ctx) {
  411. ctx.req = {
  412. params: {
  413. 0: (ctx.path = 'chapters/main.tex'),
  414. project_id: (ctx.project_id = 'project-id-123'),
  415. },
  416. session: {
  417. destroy: sinon.stub(),
  418. },
  419. headers: {
  420. 'x-update-source': (ctx.source = 'github'),
  421. },
  422. }
  423. ctx.res = {
  424. json: sinon.stub(),
  425. sendStatus: sinon.stub(),
  426. }
  427. await ctx.TpdsController.promises.updateProjectContents(ctx.req, ctx.res)
  428. })
  429. it('should merge the update', function (ctx) {
  430. ctx.UpdateMerger.promises.mergeUpdate.should.be.calledWith(
  431. null,
  432. ctx.project_id,
  433. `/${ctx.path}`,
  434. ctx.req,
  435. ctx.source
  436. )
  437. })
  438. it('should return a success', function (ctx) {
  439. ctx.res.json.should.be.calledWith({
  440. entityId: ctx.metadata.entityId.toString(),
  441. rev: ctx.metadata.rev,
  442. })
  443. })
  444. })
  445. describe('deleteProjectContents', function () {
  446. beforeEach(async function (ctx) {
  447. ctx.req = {
  448. params: {
  449. 0: (ctx.path = 'chapters/main.tex'),
  450. project_id: (ctx.project_id = 'project-id-123'),
  451. },
  452. session: {
  453. destroy: sinon.stub(),
  454. },
  455. headers: {
  456. 'x-update-source': (ctx.source = 'github'),
  457. },
  458. }
  459. ctx.res = {
  460. sendStatus: sinon.stub(),
  461. json: sinon.stub(),
  462. }
  463. await ctx.TpdsController.promises.deleteProjectContents(ctx.req, ctx.res)
  464. })
  465. it('should delete the file', function (ctx) {
  466. ctx.UpdateMerger.promises.deleteUpdate.should.be.calledWith(
  467. null,
  468. ctx.project_id,
  469. `/${ctx.path}`,
  470. ctx.source
  471. )
  472. })
  473. it('should return a success', function (ctx) {
  474. ctx.res.json.should.be.calledWith({
  475. entityId: ctx.metadata.entityId,
  476. })
  477. })
  478. })
  479. describe('getQueues', function () {
  480. beforeEach(function (ctx) {
  481. ctx.req = {}
  482. ctx.res = { json: sinon.stub() }
  483. ctx.next = sinon.stub()
  484. })
  485. describe('success', function () {
  486. beforeEach(async function (ctx) {
  487. await new Promise(resolve => {
  488. ctx.res.json.callsFake(() => {
  489. resolve()
  490. })
  491. ctx.TpdsController.getQueues(ctx.req, ctx.res, ctx.next)
  492. })
  493. })
  494. it('should use userId from session', function (ctx) {
  495. ctx.SessionManager.getLoggedInUserId.should.have.been.calledOnce
  496. ctx.TpdsQueueManager.promises.getQueues.should.have.been.calledWith(
  497. 'user-id'
  498. )
  499. })
  500. it('should call json with response', function (ctx) {
  501. ctx.res.json.should.have.been.calledWith('queues')
  502. ctx.next.should.not.have.been.called
  503. })
  504. })
  505. describe('error', function () {
  506. beforeEach(async function (ctx) {
  507. await new Promise(resolve => {
  508. ctx.err = new Error()
  509. ctx.TpdsQueueManager.promises.getQueues = sinon
  510. .stub()
  511. .rejects(ctx.err)
  512. ctx.next.callsFake(() => {
  513. resolve()
  514. })
  515. ctx.TpdsController.getQueues(ctx.req, ctx.res, ctx.next)
  516. })
  517. })
  518. it('should call next with error', function (ctx) {
  519. ctx.res.json.should.not.have.been.called
  520. ctx.next.should.have.been.calledWith(ctx.err)
  521. })
  522. })
  523. })
  524. })