CompileManagerTests.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  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: {
  24. clsi: { submissionBackendClass: 'n2d' },
  25. },
  26. redis: { web: { host: '127.0.0.1', port: 42 } },
  27. rateLimit: { autoCompile: {} },
  28. }),
  29. '../../infrastructure/RedisWrapper': {
  30. client: () =>
  31. (this.rclient = {
  32. auth() {},
  33. }),
  34. },
  35. '../Project/ProjectRootDocManager': (this.ProjectRootDocManager = {
  36. promises: {},
  37. }),
  38. '../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
  39. '../User/UserGetter': (this.UserGetter = { promises: {} }),
  40. './ClsiManager': (this.ClsiManager = { promises: {} }),
  41. '../../infrastructure/RateLimiter': this.RateLimiter,
  42. '@overleaf/metrics': this.Metrics,
  43. '../Analytics/UserAnalyticsIdCache': (this.UserAnalyticsIdCache = {
  44. get: sinon.stub().resolves('abc'),
  45. }),
  46. '../SplitTests/SplitTestHandler': (this.SplitTestHandler = {
  47. promises: {},
  48. }),
  49. },
  50. })
  51. this.project_id = 'mock-project-id-123'
  52. this.user_id = 'mock-user-id-123'
  53. this.callback = sinon.stub()
  54. this.limits = {
  55. timeout: 42,
  56. compileGroup: 'standard',
  57. }
  58. })
  59. describe('compile', function () {
  60. beforeEach(function () {
  61. this.CompileManager._checkIfRecentlyCompiled = sinon
  62. .stub()
  63. .resolves(false)
  64. this.ProjectRootDocManager.promises.ensureRootDocumentIsSet = sinon
  65. .stub()
  66. .resolves()
  67. this.CompileManager.promises.getProjectCompileLimits = sinon
  68. .stub()
  69. .resolves(this.limits)
  70. this.ClsiManager.promises.sendRequest = sinon.stub().resolves({
  71. status: (this.status = 'mock-status'),
  72. outputFiles: (this.outputFiles = []),
  73. clsiServerId: (this.output = 'mock output'),
  74. })
  75. })
  76. describe('succesfully', function () {
  77. let result
  78. beforeEach(async function () {
  79. this.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
  80. isAutoCompile,
  81. compileGroup
  82. ) => true
  83. this.ProjectGetter.promises.getProject = sinon
  84. .stub()
  85. .resolves(
  86. (this.project = { owner_ref: (this.owner_id = 'owner-id-123') })
  87. )
  88. this.UserGetter.promises.getUser = sinon.stub().resolves(
  89. (this.user = {
  90. features: { compileTimeout: '20s', compileGroup: 'standard' },
  91. analyticsId: 'abc',
  92. })
  93. )
  94. result = await this.CompileManager.promises.compile(
  95. this.project_id,
  96. this.user_id,
  97. {}
  98. )
  99. })
  100. it('should check the project has not been recently compiled', function () {
  101. this.CompileManager._checkIfRecentlyCompiled
  102. .calledWith(this.project_id, this.user_id)
  103. .should.equal(true)
  104. })
  105. it('should ensure that the root document is set', function () {
  106. this.ProjectRootDocManager.promises.ensureRootDocumentIsSet
  107. .calledWith(this.project_id)
  108. .should.equal(true)
  109. })
  110. it('should get the project compile limits', function () {
  111. this.CompileManager.promises.getProjectCompileLimits
  112. .calledWith(this.project_id)
  113. .should.equal(true)
  114. })
  115. it('should run the compile with the compile limits', function () {
  116. this.ClsiManager.promises.sendRequest
  117. .calledWith(this.project_id, this.user_id, {
  118. timeout: this.limits.timeout,
  119. compileGroup: 'standard',
  120. buildId: sinon.match(/[a-f0-9]+-[a-f0-9]+/),
  121. })
  122. .should.equal(true)
  123. })
  124. it('should resolve with the output', function () {
  125. expect(result).to.haveOwnProperty('status', this.status)
  126. expect(result).to.haveOwnProperty('clsiServerId', this.output)
  127. expect(result).to.haveOwnProperty('outputFiles', this.outputFiles)
  128. })
  129. it('should time the compile', function () {
  130. this.timer.done.called.should.equal(true)
  131. })
  132. })
  133. describe('when the project has been recently compiled', function () {
  134. it('should return', async function () {
  135. this.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
  136. isAutoCompile,
  137. compileGroup
  138. ) => true
  139. this.CompileManager._checkIfRecentlyCompiled = sinon
  140. .stub()
  141. .resolves(true)
  142. const { status } = await this.CompileManager.promises.compile(
  143. this.project_id,
  144. this.user_id,
  145. {}
  146. )
  147. status.should.equal('too-recently-compiled')
  148. })
  149. })
  150. describe('should check the rate limit', function () {
  151. it('should return', async function () {
  152. this.CompileManager._checkIfAutoCompileLimitHasBeenHit = sinon
  153. .stub()
  154. .resolves(false)
  155. const { status } = await this.CompileManager.promises.compile(
  156. this.project_id,
  157. this.user_id,
  158. {}
  159. )
  160. expect(status).to.equal('autocompile-backoff')
  161. })
  162. })
  163. })
  164. describe('getProjectCompileLimits', function () {
  165. beforeEach(async function () {
  166. this.features = {
  167. compileTimeout: (this.timeout = 42),
  168. compileGroup: (this.group = 'priority'),
  169. }
  170. this.ProjectGetter.promises.getProject = sinon
  171. .stub()
  172. .resolves(
  173. (this.project = { owner_ref: (this.owner_id = 'owner-id-123') })
  174. )
  175. this.UserGetter.promises.getUser = sinon
  176. .stub()
  177. .resolves((this.user = { features: this.features, analyticsId: 'abc' }))
  178. try {
  179. const result =
  180. await this.CompileManager.promises.getProjectCompileLimits(
  181. this.project_id
  182. )
  183. this.callback(null, result)
  184. } catch (error) {
  185. this.callback(error)
  186. }
  187. })
  188. it('should look up the owner of the project', function () {
  189. this.ProjectGetter.promises.getProject
  190. .calledWith(this.project_id, { owner_ref: 1 })
  191. .should.equal(true)
  192. })
  193. it("should look up the owner's features", function () {
  194. this.UserGetter.promises.getUser
  195. .calledWith(this.project.owner_ref, {
  196. _id: 1,
  197. alphaProgram: 1,
  198. analyticsId: 1,
  199. betaProgram: 1,
  200. features: 1,
  201. })
  202. .should.equal(true)
  203. })
  204. it('should return the limits', function () {
  205. this.callback
  206. .calledWith(null, {
  207. timeout: this.timeout,
  208. compileGroup: this.group,
  209. compileBackendClass: 'c2d',
  210. ownerAnalyticsId: 'abc',
  211. })
  212. .should.equal(true)
  213. })
  214. })
  215. describe('compileBackendClass', function () {
  216. beforeEach(function () {
  217. this.features = {
  218. compileTimeout: 42,
  219. compileGroup: 'standard',
  220. }
  221. this.ProjectGetter.promises.getProject = sinon
  222. .stub()
  223. .resolves({ owner_ref: 'owner-id-123' })
  224. this.UserGetter.promises.getUser = sinon
  225. .stub()
  226. .resolves({ features: this.features, analyticsId: 'abc' })
  227. })
  228. describe('with priority compile', function () {
  229. beforeEach(function () {
  230. this.features.compileGroup = 'priority'
  231. })
  232. it('should return the default class', async function () {
  233. const { compileBackendClass } =
  234. await this.CompileManager.promises.getProjectCompileLimits(
  235. this.project_id
  236. )
  237. expect(compileBackendClass).to.equal('c2d')
  238. })
  239. })
  240. })
  241. describe('deleteAuxFiles', function () {
  242. let result
  243. beforeEach(async function () {
  244. this.CompileManager.promises.getProjectCompileLimits = sinon
  245. .stub()
  246. .resolves((this.limits = { compileGroup: 'mock-compile-group' }))
  247. this.ClsiManager.promises.deleteAuxFiles = sinon.stub().resolves('test')
  248. result = await this.CompileManager.promises.deleteAuxFiles(
  249. this.project_id,
  250. this.user_id
  251. )
  252. })
  253. it('should look up the compile group to use', function () {
  254. this.CompileManager.promises.getProjectCompileLimits
  255. .calledWith(this.project_id)
  256. .should.equal(true)
  257. })
  258. it('should delete the aux files', function () {
  259. this.ClsiManager.promises.deleteAuxFiles
  260. .calledWith(this.project_id, this.user_id, this.limits)
  261. .should.equal(true)
  262. })
  263. it('should resolve', function () {
  264. expect(result).not.to.be.undefined
  265. })
  266. })
  267. describe('_checkIfRecentlyCompiled', function () {
  268. describe('when the key exists in redis', function () {
  269. let result
  270. beforeEach(async function () {
  271. this.rclient.set = sinon.stub().resolves(null)
  272. result = await this.CompileManager._checkIfRecentlyCompiled(
  273. this.project_id,
  274. this.user_id
  275. )
  276. })
  277. it('should try to set the key', function () {
  278. this.rclient.set
  279. .calledWith(
  280. `compile:${this.project_id}:${this.user_id}`,
  281. true,
  282. 'EX',
  283. this.CompileManager.COMPILE_DELAY,
  284. 'NX'
  285. )
  286. .should.equal(true)
  287. })
  288. it('should resolve with true', function () {
  289. result.should.equal(true)
  290. })
  291. })
  292. describe('when the key does not exist in redis', function () {
  293. let result
  294. beforeEach(async function () {
  295. this.rclient.set = sinon.stub().resolves('OK')
  296. result = await this.CompileManager._checkIfRecentlyCompiled(
  297. this.project_id,
  298. this.user_id
  299. )
  300. })
  301. it('should try to set the key', function () {
  302. this.rclient.set
  303. .calledWith(
  304. `compile:${this.project_id}:${this.user_id}`,
  305. true,
  306. 'EX',
  307. this.CompileManager.COMPILE_DELAY,
  308. 'NX'
  309. )
  310. .should.equal(true)
  311. })
  312. it('should resolve with false', function () {
  313. result.should.equal(false)
  314. })
  315. })
  316. })
  317. describe('_checkIfAutoCompileLimitHasBeenHit', function () {
  318. it('should be able to compile if it is not an autocompile', async function () {
  319. const canCompile =
  320. await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  321. false,
  322. 'everyone'
  323. )
  324. expect(canCompile).to.equal(true)
  325. })
  326. it('should be able to compile if rate limit has remaining', async function () {
  327. const canCompile =
  328. await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  329. true,
  330. 'everyone'
  331. )
  332. expect(this.rateLimiter.consume).to.have.been.calledWith('global')
  333. expect(canCompile).to.equal(true)
  334. })
  335. it('should be not able to compile if rate limit has no remianing', async function () {
  336. this.rateLimiter.consume.rejects({ remainingPoints: 0 })
  337. const canCompile =
  338. await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  339. true,
  340. 'everyone'
  341. )
  342. expect(canCompile).to.equal(false)
  343. })
  344. it('should return false if there is an error in the rate limit', async function () {
  345. this.rateLimiter.consume.rejects(new Error('BOOM!'))
  346. const canCompile =
  347. await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  348. true,
  349. 'everyone'
  350. )
  351. expect(canCompile).to.equal(false)
  352. })
  353. })
  354. describe('wordCount', function () {
  355. let result
  356. const wordCount = 1
  357. beforeEach(async function () {
  358. this.CompileManager.promises.getProjectCompileLimits = sinon
  359. .stub()
  360. .resolves((this.limits = { compileGroup: 'mock-compile-group' }))
  361. this.ClsiManager.promises.wordCount = sinon.stub().resolves(wordCount)
  362. result = await this.CompileManager.promises.wordCount(
  363. this.project_id,
  364. this.user_id,
  365. false
  366. )
  367. })
  368. it('should look up the compile group to use', function () {
  369. this.CompileManager.promises.getProjectCompileLimits
  370. .calledWith(this.project_id)
  371. .should.equal(true)
  372. })
  373. it('should call wordCount for project', function () {
  374. this.ClsiManager.promises.wordCount
  375. .calledWith(this.project_id, this.user_id, false, this.limits)
  376. .should.equal(true)
  377. })
  378. it('should resolve with the wordCount from the ClsiManager', function () {
  379. expect(result).to.equal(wordCount)
  380. })
  381. })
  382. })