CompileManagerTests.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. const { expect } = require('chai')
  2. const sinon = require('sinon')
  3. const SandboxedModule = require('sandboxed-module')
  4. const MODULE_PATH = '../../../../app/src/Features/Compile/CompileManager.js'
  5. describe('CompileManager', function () {
  6. beforeEach(function () {
  7. this.rateLimiter = {
  8. consume: sinon.stub().resolves(),
  9. }
  10. this.RateLimiter = {
  11. RateLimiter: sinon.stub().returns(this.rateLimiter),
  12. }
  13. this.timer = {
  14. done: sinon.stub(),
  15. }
  16. this.Metrics = {
  17. Timer: sinon.stub().returns(this.timer),
  18. inc: sinon.stub(),
  19. }
  20. this.CompileManager = SandboxedModule.require(MODULE_PATH, {
  21. requires: {
  22. '@overleaf/settings': (this.settings = {
  23. apis: { clsi: { defaultBackendClass: 'e2' } },
  24. redis: { web: { host: 'localhost', port: 42 } },
  25. rateLimit: { autoCompile: {} },
  26. }),
  27. '../../infrastructure/RedisWrapper': {
  28. client: () => (this.rclient = { auth() {} }),
  29. },
  30. '../Project/ProjectRootDocManager': (this.ProjectRootDocManager = {}),
  31. '../Project/ProjectGetter': (this.ProjectGetter = {}),
  32. '../User/UserGetter': (this.UserGetter = {}),
  33. './ClsiManager': (this.ClsiManager = {}),
  34. '../../infrastructure/RateLimiter': this.RateLimiter,
  35. '@overleaf/metrics': this.Metrics,
  36. '../SplitTests/SplitTestHandler': {
  37. getAssignmentForMongoUser: (this.getAssignmentForMongoUser = sinon
  38. .stub()
  39. .yields(null, {
  40. variant: 'default',
  41. })),
  42. },
  43. },
  44. })
  45. this.project_id = 'mock-project-id-123'
  46. this.user_id = 'mock-user-id-123'
  47. this.callback = sinon.stub()
  48. this.limits = {
  49. timeout: 42,
  50. compileGroup: 'standard',
  51. }
  52. })
  53. describe('compile', function () {
  54. beforeEach(function () {
  55. this.CompileManager._checkIfRecentlyCompiled = sinon
  56. .stub()
  57. .callsArgWith(2, null, false)
  58. this.ProjectRootDocManager.ensureRootDocumentIsSet = sinon
  59. .stub()
  60. .callsArgWith(1, null)
  61. this.CompileManager.getProjectCompileLimits = sinon
  62. .stub()
  63. .callsArgWith(1, null, this.limits)
  64. this.ClsiManager.sendRequest = sinon
  65. .stub()
  66. .callsArgWith(
  67. 3,
  68. null,
  69. (this.status = 'mock-status'),
  70. (this.outputFiles = 'mock output files'),
  71. (this.output = 'mock output')
  72. )
  73. })
  74. describe('succesfully', function () {
  75. beforeEach(function () {
  76. this.CompileManager._checkIfAutoCompileLimitHasBeenHit = (
  77. isAutoCompile,
  78. compileGroup,
  79. cb
  80. ) => cb(null, true)
  81. this.CompileManager.compile(
  82. this.project_id,
  83. this.user_id,
  84. {},
  85. this.callback
  86. )
  87. })
  88. it('should check the project has not been recently compiled', function () {
  89. this.CompileManager._checkIfRecentlyCompiled
  90. .calledWith(this.project_id, this.user_id)
  91. .should.equal(true)
  92. })
  93. it('should ensure that the root document is set', function () {
  94. this.ProjectRootDocManager.ensureRootDocumentIsSet
  95. .calledWith(this.project_id)
  96. .should.equal(true)
  97. })
  98. it('should get the project compile limits', function () {
  99. this.CompileManager.getProjectCompileLimits
  100. .calledWith(this.project_id)
  101. .should.equal(true)
  102. })
  103. it('should run the compile with the compile limits', function () {
  104. this.ClsiManager.sendRequest
  105. .calledWith(this.project_id, this.user_id, {
  106. timeout: this.limits.timeout,
  107. compileGroup: 'standard',
  108. })
  109. .should.equal(true)
  110. })
  111. it('should call the callback with the output', function () {
  112. this.callback
  113. .calledWith(null, this.status, this.outputFiles, this.output)
  114. .should.equal(true)
  115. })
  116. it('should time the compile', function () {
  117. this.timer.done.called.should.equal(true)
  118. })
  119. })
  120. describe('when the project has been recently compiled', function () {
  121. it('should return', function (done) {
  122. this.CompileManager._checkIfAutoCompileLimitHasBeenHit = (
  123. isAutoCompile,
  124. compileGroup,
  125. cb
  126. ) => cb(null, true)
  127. this.CompileManager._checkIfRecentlyCompiled = sinon
  128. .stub()
  129. .callsArgWith(2, null, true)
  130. this.CompileManager.compile(
  131. this.project_id,
  132. this.user_id,
  133. {},
  134. (err, status) => {
  135. if (err) {
  136. return done(err)
  137. }
  138. status.should.equal('too-recently-compiled')
  139. done()
  140. }
  141. )
  142. })
  143. })
  144. describe('should check the rate limit', function () {
  145. it('should return', function (done) {
  146. this.CompileManager._checkIfAutoCompileLimitHasBeenHit = sinon
  147. .stub()
  148. .callsArgWith(2, null, false)
  149. this.CompileManager.compile(
  150. this.project_id,
  151. this.user_id,
  152. {},
  153. (err, status) => {
  154. if (err) {
  155. return done(err)
  156. }
  157. status.should.equal('autocompile-backoff')
  158. done()
  159. }
  160. )
  161. })
  162. })
  163. })
  164. describe('getProjectCompileLimits', function () {
  165. beforeEach(function () {
  166. this.features = {
  167. compileTimeout: (this.timeout = 42),
  168. compileGroup: (this.group = 'priority'),
  169. }
  170. this.ProjectGetter.getProject = sinon
  171. .stub()
  172. .callsArgWith(
  173. 2,
  174. null,
  175. (this.project = { owner_ref: (this.owner_id = 'owner-id-123') })
  176. )
  177. this.UserGetter.getUser = sinon
  178. .stub()
  179. .callsArgWith(
  180. 2,
  181. null,
  182. (this.user = { features: this.features, analyticsId: 'abc' })
  183. )
  184. this.CompileManager.getProjectCompileLimits(
  185. this.project_id,
  186. this.callback
  187. )
  188. })
  189. it('should look up the owner of the project', function () {
  190. this.ProjectGetter.getProject
  191. .calledWith(this.project_id, { owner_ref: 1 })
  192. .should.equal(true)
  193. })
  194. it("should look up the owner's features", function () {
  195. this.UserGetter.getUser
  196. .calledWith(this.project.owner_ref, {
  197. _id: 1,
  198. alphaProgram: 1,
  199. analyticsId: 1,
  200. betaProgram: 1,
  201. features: 1,
  202. splitTests: 1,
  203. signUpDate: 1,
  204. })
  205. .should.equal(true)
  206. })
  207. it('should return the limits', function () {
  208. this.callback
  209. .calledWith(null, {
  210. timeout: this.timeout,
  211. compileGroup: this.group,
  212. compileBackendClass: 'e2',
  213. ownerAnalyticsId: 'abc',
  214. showFasterCompilesFeedbackUI: false,
  215. })
  216. .should.equal(true)
  217. })
  218. })
  219. describe('getProjectCompileLimits with reduced compile timeout', function () {
  220. beforeEach(function () {
  221. this.getAssignmentForMongoUser.callsFake((user, test, cb) => {
  222. if (test === 'compile-backend-class-n2d') {
  223. cb(null, { variant: 'n2d' })
  224. }
  225. if (test === 'compile-timeout-20s') {
  226. cb(null, { variant: '20s' })
  227. }
  228. })
  229. this.features = {
  230. compileTimeout: (this.timeout = 60),
  231. compileGroup: (this.group = 'standard'),
  232. }
  233. this.ProjectGetter.getProject = sinon
  234. .stub()
  235. .callsArgWith(
  236. 2,
  237. null,
  238. (this.project = { owner_ref: (this.owner_id = 'owner-id-123') })
  239. )
  240. this.UserGetter.getUser = sinon
  241. .stub()
  242. .callsArgWith(
  243. 2,
  244. null,
  245. (this.user = { features: this.features, analyticsId: 'abc' })
  246. )
  247. this.CompileManager.getProjectCompileLimits(
  248. this.project_id,
  249. this.callback
  250. )
  251. })
  252. describe('user is in the n2d group and compile-timeout-20s split test variant', function () {
  253. describe('user has a timeout of more than 60s', function () {
  254. beforeEach(function () {
  255. this.features.compileTimeout = 120
  256. })
  257. it('should keep the users compile timeout', function () {
  258. this.CompileManager.getProjectCompileLimits(
  259. this.project_id,
  260. this.callback
  261. )
  262. this.callback
  263. .calledWith(null, sinon.match({ timeout: 120 }))
  264. .should.equal(true)
  265. })
  266. })
  267. describe('user registered before the cut off date', function () {
  268. beforeEach(function () {
  269. this.features.compileTimeout = 60
  270. const signUpDate = new Date(
  271. this.CompileManager.NEW_COMPILE_TIMEOUT_ENFORCED_CUTOFF
  272. )
  273. signUpDate.setDate(signUpDate.getDate() - 1)
  274. this.user.signUpDate = signUpDate
  275. })
  276. it('should keep the users compile timeout', function () {
  277. this.CompileManager.getProjectCompileLimits(
  278. this.project_id,
  279. this.callback
  280. )
  281. this.callback
  282. .calledWith(null, sinon.match({ timeout: 60 }))
  283. .should.equal(true)
  284. })
  285. })
  286. describe('user registered after the cut off date', function () {
  287. beforeEach(function () {
  288. this.timeout = 60
  289. const signUpDate = new Date(
  290. this.CompileManager.NEW_COMPILE_TIMEOUT_ENFORCED_CUTOFF
  291. )
  292. signUpDate.setDate(signUpDate.getDate() + 1)
  293. this.user.signUpDate = signUpDate
  294. })
  295. it('should reduce compile timeout to 20s', function () {
  296. this.CompileManager.getProjectCompileLimits(
  297. this.project_id,
  298. this.callback
  299. )
  300. this.callback
  301. .calledWith(null, sinon.match({ timeout: 20 }))
  302. .should.equal(true)
  303. })
  304. })
  305. })
  306. })
  307. describe('compileBackendClass', function () {
  308. beforeEach(function () {
  309. this.features = {
  310. compileTimeout: 42,
  311. compileGroup: 'standard',
  312. }
  313. this.ProjectGetter.getProject = sinon
  314. .stub()
  315. .yields(null, { owner_ref: 'owner-id-123' })
  316. this.UserGetter.getUser = sinon
  317. .stub()
  318. .yields(null, { features: this.features, analyticsId: 'abc' })
  319. })
  320. describe('with standard compile', function () {
  321. beforeEach(function () {
  322. this.features.compileGroup = 'standard'
  323. })
  324. describe('default', function () {
  325. beforeEach(function () {
  326. this.getAssignmentForMongoUser.yields(null, {
  327. variant: 'default',
  328. })
  329. })
  330. it('should return the e2 class and disable the ui', function (done) {
  331. this.CompileManager.getProjectCompileLimits(
  332. this.project_id,
  333. (err, { compileBackendClass, showFasterCompilesFeedbackUI }) => {
  334. if (err) return done(err)
  335. expect(compileBackendClass).to.equal('e2')
  336. expect(showFasterCompilesFeedbackUI).to.equal(false)
  337. done()
  338. }
  339. )
  340. })
  341. })
  342. describe('n2d variant', function () {
  343. beforeEach(function () {
  344. this.getAssignmentForMongoUser.yields(null, {
  345. variant: 'n2d',
  346. })
  347. })
  348. it('should return the n2d class and disable the ui', function (done) {
  349. this.CompileManager.getProjectCompileLimits(
  350. this.project_id,
  351. (err, { compileBackendClass, showFasterCompilesFeedbackUI }) => {
  352. if (err) return done(err)
  353. expect(compileBackendClass).to.equal('n2d')
  354. expect(showFasterCompilesFeedbackUI).to.equal(false)
  355. done()
  356. }
  357. )
  358. })
  359. })
  360. })
  361. describe('with priority compile', function () {
  362. beforeEach(function () {
  363. this.features.compileGroup = 'priority'
  364. })
  365. describe('split test not active', function () {
  366. beforeEach(function () {
  367. this.getAssignmentForMongoUser.yields(null, {
  368. analytics: { segmentation: {} },
  369. variant: 'default',
  370. })
  371. })
  372. it('should return the default class and disable ui', function (done) {
  373. this.CompileManager.getProjectCompileLimits(
  374. this.project_id,
  375. (err, { compileBackendClass, showFasterCompilesFeedbackUI }) => {
  376. if (err) return done(err)
  377. expect(compileBackendClass).to.equal('e2')
  378. expect(showFasterCompilesFeedbackUI).to.equal(false)
  379. done()
  380. }
  381. )
  382. })
  383. })
  384. describe('split test active', function () {
  385. describe('default variant', function () {
  386. beforeEach(function () {
  387. this.getAssignmentForMongoUser.yields(null, {
  388. analytics: { segmentation: { splitTest: 'foo' } },
  389. variant: 'default',
  390. })
  391. })
  392. it('should return the default class and enable ui', function (done) {
  393. this.CompileManager.getProjectCompileLimits(
  394. this.project_id,
  395. (err, { compileBackendClass, showFasterCompilesFeedbackUI }) => {
  396. if (err) return done(err)
  397. expect(compileBackendClass).to.equal('e2')
  398. expect(showFasterCompilesFeedbackUI).to.equal(true)
  399. done()
  400. }
  401. )
  402. })
  403. })
  404. describe('c2d variant', function () {
  405. beforeEach(function () {
  406. this.getAssignmentForMongoUser.yields(null, {
  407. analytics: { segmentation: { splitTest: 'foo' } },
  408. variant: 'c2d',
  409. })
  410. })
  411. it('should return the c2d class and enable ui', function (done) {
  412. this.CompileManager.getProjectCompileLimits(
  413. this.project_id,
  414. (err, { compileBackendClass, showFasterCompilesFeedbackUI }) => {
  415. if (err) return done(err)
  416. expect(compileBackendClass).to.equal('c2d')
  417. expect(showFasterCompilesFeedbackUI).to.equal(true)
  418. done()
  419. }
  420. )
  421. })
  422. })
  423. })
  424. })
  425. })
  426. describe('deleteAuxFiles', function () {
  427. beforeEach(function () {
  428. this.CompileManager.getProjectCompileLimits = sinon
  429. .stub()
  430. .callsArgWith(
  431. 1,
  432. null,
  433. (this.limits = { compileGroup: 'mock-compile-group' })
  434. )
  435. this.ClsiManager.deleteAuxFiles = sinon.stub().callsArg(3)
  436. this.CompileManager.deleteAuxFiles(
  437. this.project_id,
  438. this.user_id,
  439. this.callback
  440. )
  441. })
  442. it('should look up the compile group to use', function () {
  443. this.CompileManager.getProjectCompileLimits
  444. .calledWith(this.project_id)
  445. .should.equal(true)
  446. })
  447. it('should delete the aux files', function () {
  448. this.ClsiManager.deleteAuxFiles
  449. .calledWith(this.project_id, this.user_id, this.limits)
  450. .should.equal(true)
  451. })
  452. it('should call the callback', function () {
  453. this.callback.called.should.equal(true)
  454. })
  455. })
  456. describe('_checkIfRecentlyCompiled', function () {
  457. describe('when the key exists in redis', function () {
  458. beforeEach(function () {
  459. this.rclient.set = sinon.stub().callsArgWith(5, null, null)
  460. this.CompileManager._checkIfRecentlyCompiled(
  461. this.project_id,
  462. this.user_id,
  463. this.callback
  464. )
  465. })
  466. it('should try to set the key', function () {
  467. this.rclient.set
  468. .calledWith(
  469. `compile:${this.project_id}:${this.user_id}`,
  470. true,
  471. 'EX',
  472. this.CompileManager.COMPILE_DELAY,
  473. 'NX'
  474. )
  475. .should.equal(true)
  476. })
  477. it('should call the callback with true', function () {
  478. this.callback.calledWith(null, true).should.equal(true)
  479. })
  480. })
  481. describe('when the key does not exist in redis', function () {
  482. beforeEach(function () {
  483. this.rclient.set = sinon.stub().callsArgWith(5, null, 'OK')
  484. this.CompileManager._checkIfRecentlyCompiled(
  485. this.project_id,
  486. this.user_id,
  487. this.callback
  488. )
  489. })
  490. it('should try to set the key', function () {
  491. this.rclient.set
  492. .calledWith(
  493. `compile:${this.project_id}:${this.user_id}`,
  494. true,
  495. 'EX',
  496. this.CompileManager.COMPILE_DELAY,
  497. 'NX'
  498. )
  499. .should.equal(true)
  500. })
  501. it('should call the callback with false', function () {
  502. this.callback.calledWith(null, false).should.equal(true)
  503. })
  504. })
  505. })
  506. describe('_checkIfAutoCompileLimitHasBeenHit', function () {
  507. it('should be able to compile if it is not an autocompile', function (done) {
  508. this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  509. false,
  510. 'everyone',
  511. (err, canCompile) => {
  512. if (err) {
  513. return done(err)
  514. }
  515. canCompile.should.equal(true)
  516. done()
  517. }
  518. )
  519. })
  520. it('should be able to compile if rate limit has remaining', function (done) {
  521. this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  522. true,
  523. 'everyone',
  524. (err, canCompile) => {
  525. if (err) {
  526. return done(err)
  527. }
  528. expect(this.rateLimiter.consume).to.have.been.calledWith('global')
  529. canCompile.should.equal(true)
  530. done()
  531. }
  532. )
  533. })
  534. it('should be not able to compile if rate limit has no remianing', function (done) {
  535. this.rateLimiter.consume.rejects({ remainingPoints: 0 })
  536. this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  537. true,
  538. 'everyone',
  539. (err, canCompile) => {
  540. if (err) {
  541. return done(err)
  542. }
  543. canCompile.should.equal(false)
  544. done()
  545. }
  546. )
  547. })
  548. it('should return false if there is an error in the rate limit', function (done) {
  549. this.rateLimiter.consume.rejects(new Error('BOOM!'))
  550. this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  551. true,
  552. 'everyone',
  553. (err, canCompile) => {
  554. if (err) {
  555. return done(err)
  556. }
  557. canCompile.should.equal(false)
  558. done()
  559. }
  560. )
  561. })
  562. })
  563. describe('wordCount', function () {
  564. beforeEach(function () {
  565. this.CompileManager.getProjectCompileLimits = sinon
  566. .stub()
  567. .callsArgWith(
  568. 1,
  569. null,
  570. (this.limits = { compileGroup: 'mock-compile-group' })
  571. )
  572. this.ClsiManager.wordCount = sinon.stub().callsArg(4)
  573. this.CompileManager.wordCount(
  574. this.project_id,
  575. this.user_id,
  576. false,
  577. this.callback
  578. )
  579. })
  580. it('should look up the compile group to use', function () {
  581. this.CompileManager.getProjectCompileLimits
  582. .calledWith(this.project_id)
  583. .should.equal(true)
  584. })
  585. it('should call wordCount for project', function () {
  586. this.ClsiManager.wordCount
  587. .calledWith(this.project_id, this.user_id, false, this.limits)
  588. .should.equal(true)
  589. })
  590. it('should call the callback', function () {
  591. this.callback.called.should.equal(true)
  592. })
  593. })
  594. })