RedisManagerTests.js 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141
  1. const sinon = require('sinon')
  2. const { expect } = require('chai')
  3. const SandboxedModule = require('sandboxed-module')
  4. const Errors = require('../../../../app/js/Errors')
  5. const crypto = require('crypto')
  6. const tk = require('timekeeper')
  7. const MODULE_PATH = '../../../../app/js/RedisManager.js'
  8. describe('RedisManager', function () {
  9. beforeEach(function () {
  10. this.multi = { exec: sinon.stub().yields() }
  11. this.rclient = { multi: () => this.multi, srem: sinon.stub().yields() }
  12. tk.freeze(new Date())
  13. this.RedisManager = SandboxedModule.require(MODULE_PATH, {
  14. requires: {
  15. '@overleaf/settings': (this.settings = {
  16. documentupdater: { logHashErrors: { write: true, read: true } },
  17. redis: {
  18. documentupdater: {
  19. key_schema: {
  20. blockingKey({ doc_id: docId }) {
  21. return `Blocking:${docId}`
  22. },
  23. docLines({ doc_id: docId }) {
  24. return `doclines:${docId}`
  25. },
  26. docOps({ doc_id: docId }) {
  27. return `DocOps:${docId}`
  28. },
  29. docVersion({ doc_id: docId }) {
  30. return `DocVersion:${docId}`
  31. },
  32. docHash({ doc_id: docId }) {
  33. return `DocHash:${docId}`
  34. },
  35. projectKey({ doc_id: docId }) {
  36. return `ProjectId:${docId}`
  37. },
  38. pendingUpdates({ doc_id: docId }) {
  39. return `PendingUpdates:${docId}`
  40. },
  41. docsInProject({ project_id: projectId }) {
  42. return `DocsIn:${projectId}`
  43. },
  44. ranges({ doc_id: docId }) {
  45. return `Ranges:${docId}`
  46. },
  47. pathname({ doc_id: docId }) {
  48. return `Pathname:${docId}`
  49. },
  50. projectHistoryId({ doc_id: docId }) {
  51. return `ProjectHistoryId:${docId}`
  52. },
  53. projectState({ project_id: projectId }) {
  54. return `ProjectState:${projectId}`
  55. },
  56. projectBlock({ project_id: projectId }) {
  57. return `ProjectBlock:${projectId}`
  58. },
  59. unflushedTime({ doc_id: docId }) {
  60. return `UnflushedTime:${docId}`
  61. },
  62. lastUpdatedBy({ doc_id: docId }) {
  63. return `lastUpdatedBy:${docId}`
  64. },
  65. lastUpdatedAt({ doc_id: docId }) {
  66. return `lastUpdatedAt:${docId}`
  67. },
  68. historyRangesSupport() {
  69. return 'HistoryRangesSupport'
  70. },
  71. resolvedCommentIds({ doc_id: docId }) {
  72. return `ResolvedCommentIds:${docId}`
  73. },
  74. },
  75. },
  76. },
  77. }),
  78. '@overleaf/redis-wrapper': {
  79. createClient: () => this.rclient,
  80. },
  81. './Metrics': (this.metrics = {
  82. inc: sinon.stub(),
  83. summary: sinon.stub(),
  84. Timer: class Timer {
  85. constructor() {
  86. this.start = new Date()
  87. }
  88. done() {
  89. const timeSpan = new Date() - this.start
  90. return timeSpan
  91. }
  92. },
  93. }),
  94. './Errors': Errors,
  95. },
  96. })
  97. this.docId = 'doc-id-123'
  98. this.project_id = 'project-id-123'
  99. this.projectHistoryId = '123'
  100. this.historyRangesSupport = false
  101. this.callback = sinon.stub()
  102. })
  103. afterEach(function () {
  104. tk.reset()
  105. })
  106. describe('getDoc', function () {
  107. beforeEach(function () {
  108. this.lines = ['one', 'two', 'three', 'これは'] // include some utf8
  109. this.jsonlines = JSON.stringify(this.lines)
  110. this.version = 42
  111. this.hash = crypto
  112. .createHash('sha1')
  113. .update(this.jsonlines, 'utf8')
  114. .digest('hex')
  115. this.ranges = { comments: 'mock', entries: 'mock' }
  116. this.resolvedCommentIds = ['comment-1']
  117. this.json_ranges = JSON.stringify(this.ranges)
  118. this.unflushed_time = 12345
  119. this.pathname = '/a/b/c.tex'
  120. this.rclient.mget = sinon
  121. .stub()
  122. .yields(null, [
  123. this.jsonlines,
  124. this.version,
  125. this.hash,
  126. this.project_id,
  127. this.json_ranges,
  128. this.pathname,
  129. this.projectHistoryId.toString(),
  130. this.unflushed_time,
  131. ])
  132. this.rclient.sismember = sinon.stub()
  133. this.rclient.sismember
  134. .withArgs('HistoryRangesSupport', this.docId)
  135. .yields(null, 0)
  136. this.rclient.smembers = sinon.stub()
  137. this.rclient.smembers
  138. .withArgs(`ResolvedCommentIds:${this.docId}`)
  139. .yields(null, this.resolvedCommentIds)
  140. })
  141. describe('successfully', function () {
  142. beforeEach(function () {
  143. this.RedisManager.getDoc(this.project_id, this.docId, this.callback)
  144. })
  145. it('should get all the details in one call to redis', function () {
  146. this.rclient.mget
  147. .calledWith(
  148. `doclines:${this.docId}`,
  149. `DocVersion:${this.docId}`,
  150. `DocHash:${this.docId}`,
  151. `ProjectId:${this.docId}`,
  152. `Ranges:${this.docId}`,
  153. `Pathname:${this.docId}`,
  154. `ProjectHistoryId:${this.docId}`,
  155. `UnflushedTime:${this.docId}`,
  156. `lastUpdatedAt:${this.docId}`,
  157. `lastUpdatedBy:${this.docId}`
  158. )
  159. .should.equal(true)
  160. })
  161. it('should return the document', function () {
  162. this.callback.should.have.been.calledWithExactly(
  163. null,
  164. this.lines,
  165. this.version,
  166. this.ranges,
  167. this.pathname,
  168. this.projectHistoryId,
  169. this.unflushed_time,
  170. this.lastUpdatedAt,
  171. this.lastUpdatedBy,
  172. this.historyRangesSupport,
  173. this.resolvedCommentIds
  174. )
  175. })
  176. it('should not log any errors', function () {
  177. this.logger.error.calledWith().should.equal(false)
  178. })
  179. })
  180. describe('with a corrupted document', function () {
  181. beforeEach(function () {
  182. this.badHash = 'INVALID-HASH-VALUE'
  183. this.rclient.mget = sinon
  184. .stub()
  185. .yields(null, [
  186. this.jsonlines,
  187. this.version,
  188. this.badHash,
  189. this.project_id,
  190. this.json_ranges,
  191. ])
  192. this.RedisManager.getDoc(this.project_id, this.docId, this.callback)
  193. })
  194. it('should log a hash error', function () {
  195. this.logger.error.calledWith().should.equal(true)
  196. })
  197. it('should return the document', function () {
  198. this.callback
  199. .calledWith(null, this.lines, this.version, this.ranges)
  200. .should.equal(true)
  201. })
  202. })
  203. describe('with a slow request to redis', function () {
  204. beforeEach(function () {
  205. this.clock = sinon.useFakeTimers()
  206. this.rclient.mget = (...args) => {
  207. const cb = args.pop()
  208. this.clock.tick(6000)
  209. cb(null, [
  210. this.jsonlines,
  211. this.version,
  212. this.another_project_id,
  213. this.json_ranges,
  214. this.pathname,
  215. this.unflushed_time,
  216. ])
  217. }
  218. this.RedisManager.getDoc(this.project_id, this.docId, this.callback)
  219. })
  220. afterEach(function () {
  221. this.clock.restore()
  222. })
  223. it('should return an error', function () {
  224. this.callback
  225. .calledWith(sinon.match.instanceOf(Error))
  226. .should.equal(true)
  227. })
  228. })
  229. describe('getDoc with an invalid project id', function () {
  230. beforeEach(function () {
  231. this.another_project_id = 'project-id-456'
  232. this.rclient.mget = sinon
  233. .stub()
  234. .yields(null, [
  235. this.jsonlines,
  236. this.version,
  237. this.hash,
  238. this.another_project_id,
  239. this.json_ranges,
  240. this.pathname,
  241. this.unflushed_time,
  242. ])
  243. this.RedisManager.getDoc(this.project_id, this.docId, this.callback)
  244. })
  245. it('should return an error', function () {
  246. this.callback
  247. .calledWith(sinon.match.instanceOf(Errors.NotFoundError))
  248. .should.equal(true)
  249. })
  250. })
  251. describe('with history ranges support', function () {
  252. beforeEach(function () {
  253. this.rclient.sismember
  254. .withArgs('HistoryRangesSupport', this.docId)
  255. .yields(null, 1)
  256. this.RedisManager.getDoc(this.project_id, this.docId, this.callback)
  257. })
  258. it('should return the document with the history ranges flag set', function () {
  259. this.callback.should.have.been.calledWithExactly(
  260. null,
  261. this.lines,
  262. this.version,
  263. this.ranges,
  264. this.pathname,
  265. this.projectHistoryId,
  266. this.unflushed_time,
  267. this.lastUpdatedAt,
  268. this.lastUpdatedBy,
  269. true,
  270. this.resolvedCommentIds
  271. )
  272. })
  273. })
  274. })
  275. describe('getPreviousDocOpsTests', function () {
  276. describe('with a start and an end value', function () {
  277. beforeEach(function () {
  278. this.first_version_in_redis = 30
  279. this.version = 70
  280. this.length = this.version - this.first_version_in_redis
  281. this.start = 50
  282. this.end = 60
  283. this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
  284. this.jsonOps = this.ops.map(op => JSON.stringify(op))
  285. this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
  286. this.rclient.get = sinon
  287. .stub()
  288. .callsArgWith(1, null, this.version.toString())
  289. this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps)
  290. this.RedisManager.getPreviousDocOps(
  291. this.docId,
  292. this.start,
  293. this.end,
  294. this.callback
  295. )
  296. })
  297. it('should get the length of the existing doc ops', function () {
  298. this.rclient.llen.calledWith(`DocOps:${this.docId}`).should.equal(true)
  299. })
  300. it('should get the current version of the doc', function () {
  301. this.rclient.get
  302. .calledWith(`DocVersion:${this.docId}`)
  303. .should.equal(true)
  304. })
  305. it('should get the appropriate docs ops', function () {
  306. this.rclient.lrange
  307. .calledWith(
  308. `DocOps:${this.docId}`,
  309. this.start - this.first_version_in_redis,
  310. this.end - this.first_version_in_redis
  311. )
  312. .should.equal(true)
  313. })
  314. it('should return the docs with the doc ops deserialized', function () {
  315. this.callback.calledWith(null, this.ops).should.equal(true)
  316. })
  317. })
  318. describe('with an end value of -1', function () {
  319. beforeEach(function () {
  320. this.first_version_in_redis = 30
  321. this.version = 70
  322. this.length = this.version - this.first_version_in_redis
  323. this.start = 50
  324. this.end = -1
  325. this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
  326. this.jsonOps = this.ops.map(op => JSON.stringify(op))
  327. this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
  328. this.rclient.get = sinon
  329. .stub()
  330. .callsArgWith(1, null, this.version.toString())
  331. this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps)
  332. this.RedisManager.getPreviousDocOps(
  333. this.docId,
  334. this.start,
  335. this.end,
  336. this.callback
  337. )
  338. })
  339. it('should get the appropriate docs ops to the end of list', function () {
  340. this.rclient.lrange
  341. .calledWith(
  342. `DocOps:${this.docId}`,
  343. this.start - this.first_version_in_redis,
  344. -1
  345. )
  346. .should.equal(true)
  347. })
  348. it('should return the docs with the doc ops deserialized', function () {
  349. this.callback.calledWith(null, this.ops).should.equal(true)
  350. })
  351. })
  352. describe('when the requested range is not in Redis', function () {
  353. beforeEach(function () {
  354. this.first_version_in_redis = 30
  355. this.version = 70
  356. this.length = this.version - this.first_version_in_redis
  357. this.start = 20
  358. this.end = -1
  359. this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
  360. this.jsonOps = this.ops.map(op => JSON.stringify(op))
  361. this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
  362. this.rclient.get = sinon
  363. .stub()
  364. .callsArgWith(1, null, this.version.toString())
  365. this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps)
  366. this.RedisManager.getPreviousDocOps(
  367. this.docId,
  368. this.start,
  369. this.end,
  370. this.callback
  371. )
  372. })
  373. it('should return an error', function () {
  374. this.callback
  375. .calledWith(sinon.match.instanceOf(Errors.OpRangeNotAvailableError))
  376. .should.equal(true)
  377. })
  378. it('should log out the problem as a debug message', function () {
  379. this.logger.debug.called.should.equal(true)
  380. })
  381. })
  382. describe('with a slow request to redis', function () {
  383. beforeEach(function () {
  384. this.first_version_in_redis = 30
  385. this.version = 70
  386. this.length = this.version - this.first_version_in_redis
  387. this.start = 50
  388. this.end = 60
  389. this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
  390. this.jsonOps = this.ops.map(op => JSON.stringify(op))
  391. this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
  392. this.rclient.get = sinon
  393. .stub()
  394. .callsArgWith(1, null, this.version.toString())
  395. this.clock = sinon.useFakeTimers()
  396. this.rclient.lrange = (key, start, end, cb) => {
  397. this.clock.tick(6000)
  398. cb(null, this.jsonOps)
  399. }
  400. this.RedisManager.getPreviousDocOps(
  401. this.docId,
  402. this.start,
  403. this.end,
  404. this.callback
  405. )
  406. })
  407. afterEach(function () {
  408. this.clock.restore()
  409. })
  410. it('should return an error', function () {
  411. this.callback
  412. .calledWith(sinon.match.instanceOf(Error))
  413. .should.equal(true)
  414. })
  415. })
  416. })
  417. describe('updateDocument', function () {
  418. beforeEach(function () {
  419. this.lines = ['one', 'two', 'three', 'これは']
  420. this.ops = [{ op: [{ i: 'foo', p: 4 }] }, { op: [{ i: 'bar', p: 8 }] }]
  421. this.version = 42
  422. this.hash = crypto
  423. .createHash('sha1')
  424. .update(JSON.stringify(this.lines), 'utf8')
  425. .digest('hex')
  426. this.ranges = { comments: 'mock', entries: 'mock' }
  427. this.updateMeta = { user_id: 'last-author-fake-id' }
  428. this.doc_update_list_length = sinon.stub()
  429. this.project_update_list_length = sinon.stub()
  430. this.RedisManager.getDocVersion = sinon.stub()
  431. this.multi.mset = sinon.stub()
  432. this.multi.set = sinon.stub()
  433. this.multi.rpush = sinon.stub()
  434. this.multi.expire = sinon.stub()
  435. this.multi.ltrim = sinon.stub()
  436. this.multi.del = sinon.stub()
  437. this.multi.exec = sinon
  438. .stub()
  439. .callsArgWith(0, null, [
  440. null,
  441. null,
  442. null,
  443. null,
  444. this.doc_update_list_length,
  445. null,
  446. null,
  447. ])
  448. })
  449. describe('with a consistent version', function () {
  450. beforeEach(function () {
  451. this.RedisManager.getDocVersion
  452. .withArgs(this.docId)
  453. .yields(null, this.version - this.ops.length)
  454. this.RedisManager.updateDocument(
  455. this.project_id,
  456. this.docId,
  457. this.lines,
  458. this.version,
  459. this.ops,
  460. this.ranges,
  461. this.updateMeta,
  462. this.callback
  463. )
  464. })
  465. it('should get the current doc version to check for consistency', function () {
  466. this.RedisManager.getDocVersion
  467. .calledWith(this.docId)
  468. .should.equal(true)
  469. })
  470. it('should set most details in a single MSET call', function () {
  471. this.multi.mset
  472. .calledWith({
  473. [`doclines:${this.docId}`]: JSON.stringify(this.lines),
  474. [`DocVersion:${this.docId}`]: this.version,
  475. [`DocHash:${this.docId}`]: this.hash,
  476. [`Ranges:${this.docId}`]: JSON.stringify(this.ranges),
  477. [`lastUpdatedAt:${this.docId}`]: Date.now(),
  478. [`lastUpdatedBy:${this.docId}`]: 'last-author-fake-id',
  479. })
  480. .should.equal(true)
  481. })
  482. it('should set the unflushed time', function () {
  483. this.multi.set
  484. .calledWith(`UnflushedTime:${this.docId}`, Date.now(), 'NX')
  485. .should.equal(true)
  486. })
  487. it('should push the doc op into the doc ops list', function () {
  488. this.multi.rpush
  489. .calledWith(
  490. `DocOps:${this.docId}`,
  491. JSON.stringify(this.ops[0]),
  492. JSON.stringify(this.ops[1])
  493. )
  494. .should.equal(true)
  495. })
  496. it('should renew the expiry ttl on the doc ops array', function () {
  497. this.multi.expire
  498. .calledWith(`DocOps:${this.docId}`, this.RedisManager.DOC_OPS_TTL)
  499. .should.equal(true)
  500. })
  501. it('should truncate the list to 100 members', function () {
  502. this.multi.ltrim
  503. .calledWith(
  504. `DocOps:${this.docId}`,
  505. -this.RedisManager.DOC_OPS_MAX_LENGTH,
  506. -1
  507. )
  508. .should.equal(true)
  509. })
  510. it('should call the callback', function () {
  511. this.callback.should.have.been.called
  512. })
  513. it('should not log any errors', function () {
  514. this.logger.error.calledWith().should.equal(false)
  515. })
  516. describe('with a doc using project history only', function () {
  517. beforeEach(function () {
  518. this.RedisManager.getDocVersion
  519. .withArgs(this.docId)
  520. .yields(null, this.version - this.ops.length)
  521. this.RedisManager.updateDocument(
  522. this.project_id,
  523. this.docId,
  524. this.lines,
  525. this.version,
  526. this.ops,
  527. this.ranges,
  528. this.updateMeta,
  529. this.callback
  530. )
  531. })
  532. it('should call the callback', function () {
  533. this.callback.should.have.been.called
  534. })
  535. })
  536. })
  537. describe('with an inconsistent version', function () {
  538. beforeEach(function () {
  539. this.RedisManager.getDocVersion
  540. .withArgs(this.docId)
  541. .yields(null, this.version - this.ops.length - 1)
  542. this.RedisManager.updateDocument(
  543. this.project_id,
  544. this.docId,
  545. this.lines,
  546. this.version,
  547. this.ops,
  548. this.ranges,
  549. this.updateMeta,
  550. this.callback
  551. )
  552. })
  553. it('should not call multi.exec', function () {
  554. this.multi.exec.called.should.equal(false)
  555. })
  556. it('should call the callback with an error', function () {
  557. this.callback
  558. .calledWith(sinon.match.instanceOf(Error))
  559. .should.equal(true)
  560. })
  561. })
  562. describe('with no updates', function () {
  563. beforeEach(function () {
  564. this.RedisManager.getDocVersion
  565. .withArgs(this.docId)
  566. .yields(null, this.version)
  567. this.RedisManager.updateDocument(
  568. this.project_id,
  569. this.docId,
  570. this.lines,
  571. this.version,
  572. [],
  573. this.ranges,
  574. this.updateMeta,
  575. this.callback
  576. )
  577. })
  578. it('should set the unflushed time (potential ranges changes)', function () {
  579. this.multi.set
  580. .calledWith(`UnflushedTime:${this.docId}`, Date.now(), 'NX')
  581. .should.equal(true)
  582. })
  583. it('should not try to enqueue doc updates', function () {
  584. this.multi.rpush.called.should.equal(false)
  585. })
  586. it('should still set the doclines', function () {
  587. this.multi.mset
  588. .calledWith({
  589. [`doclines:${this.docId}`]: JSON.stringify(this.lines),
  590. [`DocVersion:${this.docId}`]: this.version,
  591. [`DocHash:${this.docId}`]: this.hash,
  592. [`Ranges:${this.docId}`]: JSON.stringify(this.ranges),
  593. [`lastUpdatedAt:${this.docId}`]: Date.now(),
  594. [`lastUpdatedBy:${this.docId}`]: 'last-author-fake-id',
  595. })
  596. .should.equal(true)
  597. })
  598. })
  599. describe('with empty ranges', function () {
  600. beforeEach(function () {
  601. this.RedisManager.getDocVersion
  602. .withArgs(this.docId)
  603. .yields(null, this.version - this.ops.length)
  604. this.RedisManager.updateDocument(
  605. this.project_id,
  606. this.docId,
  607. this.lines,
  608. this.version,
  609. this.ops,
  610. {},
  611. this.updateMeta,
  612. this.callback
  613. )
  614. })
  615. it('should set empty ranges', function () {
  616. this.multi.mset
  617. .calledWith({
  618. [`doclines:${this.docId}`]: JSON.stringify(this.lines),
  619. [`DocVersion:${this.docId}`]: this.version,
  620. [`DocHash:${this.docId}`]: this.hash,
  621. [`Ranges:${this.docId}`]: null,
  622. [`lastUpdatedAt:${this.docId}`]: Date.now(),
  623. [`lastUpdatedBy:${this.docId}`]: 'last-author-fake-id',
  624. })
  625. .should.equal(true)
  626. })
  627. })
  628. describe('with null bytes in the serialized doc lines', function () {
  629. beforeEach(function () {
  630. this.RedisManager.getDocVersion
  631. .withArgs(this.docId)
  632. .yields(null, this.version - this.ops.length)
  633. this.stringifyStub = sinon
  634. .stub(JSON, 'stringify')
  635. .callsFake(() => '["bad bytes! \u0000 <- here"]')
  636. this.RedisManager.updateDocument(
  637. this.project_id,
  638. this.docId,
  639. this.lines,
  640. this.version,
  641. this.ops,
  642. this.ranges,
  643. this.updateMeta,
  644. this.callback
  645. )
  646. })
  647. afterEach(function () {
  648. this.stringifyStub.restore()
  649. })
  650. it('should log an error', function () {
  651. this.logger.error.called.should.equal(true)
  652. })
  653. it('should call the callback with an error', function () {
  654. this.callback
  655. .calledWith(sinon.match.instanceOf(Error))
  656. .should.equal(true)
  657. })
  658. })
  659. describe('with ranges that are too big', function () {
  660. beforeEach(function () {
  661. this.RedisManager.getDocVersion
  662. .withArgs(this.docId)
  663. .yields(null, this.version - this.ops.length)
  664. this.RedisManager._serializeRanges = sinon
  665. .stub()
  666. .yields(new Error('ranges are too large'))
  667. this.RedisManager.updateDocument(
  668. this.project_id,
  669. this.docId,
  670. this.lines,
  671. this.version,
  672. this.ops,
  673. this.ranges,
  674. this.updateMeta,
  675. this.callback
  676. )
  677. })
  678. it('should log an error', function () {
  679. this.logger.error.called.should.equal(true)
  680. })
  681. it('should call the callback with the error', function () {
  682. this.callback
  683. .calledWith(sinon.match.instanceOf(Error))
  684. .should.equal(true)
  685. })
  686. })
  687. describe('without user id from meta', function () {
  688. beforeEach(function () {
  689. this.RedisManager.getDocVersion
  690. .withArgs(this.docId)
  691. .yields(null, this.version - this.ops.length)
  692. this.RedisManager.updateDocument(
  693. this.project_id,
  694. this.docId,
  695. this.lines,
  696. this.version,
  697. this.ops,
  698. this.ranges,
  699. {},
  700. this.callback
  701. )
  702. })
  703. it('should unset last updater', function () {
  704. this.multi.mset
  705. .calledWith({
  706. [`doclines:${this.docId}`]: JSON.stringify(this.lines),
  707. [`DocVersion:${this.docId}`]: this.version,
  708. [`DocHash:${this.docId}`]: this.hash,
  709. [`Ranges:${this.docId}`]: JSON.stringify(this.ranges),
  710. [`lastUpdatedAt:${this.docId}`]: Date.now(),
  711. [`lastUpdatedBy:${this.docId}`]: undefined,
  712. })
  713. .should.equal(true)
  714. })
  715. })
  716. })
  717. describe('putDocInMemory', function () {
  718. beforeEach(function () {
  719. this.multi.mset = sinon.stub()
  720. this.multi.sadd = sinon.stub()
  721. this.multi.del = sinon.stub()
  722. this.multi.exists = sinon.stub()
  723. this.multi.exec.onCall(0).yields(null, [0])
  724. this.rclient.sadd = sinon.stub().yields()
  725. this.lines = ['one', 'two', 'three', 'これは']
  726. this.version = 42
  727. this.hash = crypto
  728. .createHash('sha1')
  729. .update(JSON.stringify(this.lines), 'utf8')
  730. .digest('hex')
  731. this.ranges = { comments: 'mock', entries: 'mock' }
  732. this.resolvedCommentIds = ['comment-1']
  733. this.pathname = '/a/b/c.tex'
  734. })
  735. describe('with non-empty ranges', function () {
  736. beforeEach(function (done) {
  737. this.RedisManager.putDocInMemory(
  738. this.project_id,
  739. this.docId,
  740. this.lines,
  741. this.version,
  742. this.ranges,
  743. this.resolvedCommentIds,
  744. this.pathname,
  745. this.projectHistoryId,
  746. this.historyRangesSupport,
  747. done
  748. )
  749. })
  750. it('should set all the details in a single MSET call', function () {
  751. this.multi.mset
  752. .calledWith({
  753. [`doclines:${this.docId}`]: JSON.stringify(this.lines),
  754. [`ProjectId:${this.docId}`]: this.project_id,
  755. [`DocVersion:${this.docId}`]: this.version,
  756. [`DocHash:${this.docId}`]: this.hash,
  757. [`Ranges:${this.docId}`]: JSON.stringify(this.ranges),
  758. [`Pathname:${this.docId}`]: this.pathname,
  759. [`ProjectHistoryId:${this.docId}`]: this.projectHistoryId,
  760. })
  761. .should.equal(true)
  762. })
  763. it('should add the docId to the project set', function () {
  764. this.multi.sadd
  765. .calledWith(`DocsIn:${this.project_id}`, this.docId)
  766. .should.equal(true)
  767. })
  768. it('should not log any errors', function () {
  769. this.logger.error.calledWith().should.equal(false)
  770. })
  771. it('should remove the document from the HistoryRangesSupport set in Redis', function () {
  772. this.rclient.srem.should.have.been.calledWith(
  773. 'HistoryRangesSupport',
  774. this.docId
  775. )
  776. })
  777. it('should not store the resolved comments in Redis', function () {
  778. this.multi.sadd.should.not.have.been.calledWith(
  779. `ResolvedCommentIds:${this.docId}`
  780. )
  781. })
  782. })
  783. describe('with empty ranges', function () {
  784. beforeEach(function (done) {
  785. this.RedisManager.putDocInMemory(
  786. this.project_id,
  787. this.docId,
  788. this.lines,
  789. this.version,
  790. {},
  791. [],
  792. this.pathname,
  793. this.projectHistoryId,
  794. this.historyRangesSupport,
  795. done
  796. )
  797. })
  798. it('should unset ranges', function () {
  799. this.multi.mset.should.have.been.calledWith({
  800. [`doclines:${this.docId}`]: JSON.stringify(this.lines),
  801. [`ProjectId:${this.docId}`]: this.project_id,
  802. [`DocVersion:${this.docId}`]: this.version,
  803. [`DocHash:${this.docId}`]: this.hash,
  804. [`Ranges:${this.docId}`]: null,
  805. [`Pathname:${this.docId}`]: this.pathname,
  806. [`ProjectHistoryId:${this.docId}`]: this.projectHistoryId,
  807. })
  808. })
  809. })
  810. describe('with null bytes in the serialized doc lines', function () {
  811. beforeEach(function () {
  812. this.stringifyStub = sinon
  813. .stub(JSON, 'stringify')
  814. .callsFake(() => '["bad bytes! \u0000 <- here"]')
  815. this.RedisManager.putDocInMemory(
  816. this.project_id,
  817. this.docId,
  818. this.lines,
  819. this.version,
  820. this.ranges,
  821. this.resolvedCommentIds,
  822. this.pathname,
  823. this.projectHistoryId,
  824. this.historyRangesSupport,
  825. this.callback
  826. )
  827. })
  828. afterEach(function () {
  829. this.stringifyStub.restore()
  830. })
  831. it('should log an error', function () {
  832. this.logger.error.called.should.equal(true)
  833. })
  834. it('should call the callback with an error', function () {
  835. this.callback
  836. .calledWith(sinon.match.instanceOf(Error))
  837. .should.equal(true)
  838. })
  839. })
  840. describe('with ranges that are too big', function () {
  841. beforeEach(function () {
  842. this.RedisManager._serializeRanges = sinon
  843. .stub()
  844. .yields(new Error('ranges are too large'))
  845. this.RedisManager.putDocInMemory(
  846. this.project_id,
  847. this.docId,
  848. this.lines,
  849. this.version,
  850. this.ranges,
  851. this.resolvedCommentIds,
  852. this.pathname,
  853. this.projectHistoryId,
  854. this.historyRangesSupport,
  855. this.callback
  856. )
  857. })
  858. it('should log an error', function () {
  859. this.logger.error.called.should.equal(true)
  860. })
  861. it('should call the callback with the error', function () {
  862. this.callback
  863. .calledWith(sinon.match.instanceOf(Error))
  864. .should.equal(true)
  865. })
  866. })
  867. describe('with history ranges support', function () {
  868. beforeEach(function (done) {
  869. this.historyRangesSupport = true
  870. this.RedisManager.putDocInMemory(
  871. this.project_id,
  872. this.docId,
  873. this.lines,
  874. this.version,
  875. this.ranges,
  876. this.resolvedCommentIds,
  877. this.pathname,
  878. this.projectHistoryId,
  879. this.historyRangesSupport,
  880. done
  881. )
  882. })
  883. it('should add the document to the HistoryRangesSupport set in Redis', function () {
  884. this.rclient.sadd.should.have.been.calledWith(
  885. 'HistoryRangesSupport',
  886. this.docId
  887. )
  888. })
  889. it('should store the resolved comments in Redis', function () {
  890. this.multi.del.should.have.been.calledWith(
  891. `ResolvedCommentIds:${this.docId}`
  892. )
  893. this.multi.sadd.should.have.been.calledWith(
  894. `ResolvedCommentIds:${this.docId}`,
  895. ...this.resolvedCommentIds
  896. )
  897. })
  898. })
  899. describe('when the project is blocked', function () {
  900. beforeEach(function (done) {
  901. this.multi.exec.onCall(0).yields(null, [1])
  902. this.RedisManager.putDocInMemory(
  903. this.project_id,
  904. this.docId,
  905. this.lines,
  906. this.version,
  907. this.ranges,
  908. this.resolvedCommentIds,
  909. this.pathname,
  910. this.projectHistoryId,
  911. this.historyRangesSupport,
  912. err => {
  913. this.error = err
  914. done()
  915. }
  916. )
  917. })
  918. it('should throw an error', function () {
  919. expect(this.error.message).to.equal('Project blocked from loading docs')
  920. })
  921. it('should not store the doc', function () {
  922. expect(this.multi.mset).to.not.have.been.called
  923. })
  924. })
  925. })
  926. describe('removeDocFromMemory', function () {
  927. beforeEach(function (done) {
  928. this.multi.strlen = sinon.stub()
  929. this.multi.del = sinon.stub()
  930. this.multi.srem = sinon.stub()
  931. this.multi.exec.yields()
  932. this.RedisManager.removeDocFromMemory(this.project_id, this.docId, done)
  933. })
  934. it('should check the length of the current doclines', function () {
  935. this.multi.strlen.calledWith(`doclines:${this.docId}`).should.equal(true)
  936. })
  937. it('should delete the details in a singe call', function () {
  938. this.multi.del
  939. .calledWith(
  940. `doclines:${this.docId}`,
  941. `ProjectId:${this.docId}`,
  942. `DocVersion:${this.docId}`,
  943. `DocHash:${this.docId}`,
  944. `Ranges:${this.docId}`,
  945. `Pathname:${this.docId}`,
  946. `ProjectHistoryId:${this.docId}`,
  947. `UnflushedTime:${this.docId}`,
  948. `lastUpdatedAt:${this.docId}`,
  949. `lastUpdatedBy:${this.docId}`,
  950. `ResolvedCommentIds:${this.docId}`
  951. )
  952. .should.equal(true)
  953. })
  954. it('should remove the docId from the project set', function () {
  955. this.multi.srem
  956. .calledWith(`DocsIn:${this.project_id}`, this.docId)
  957. .should.equal(true)
  958. })
  959. it('should remove the docId from the HistoryRangesSupport set', function () {
  960. this.rclient.srem.should.have.been.calledWith(
  961. 'HistoryRangesSupport',
  962. this.docId
  963. )
  964. })
  965. })
  966. describe('clearProjectState', function () {
  967. beforeEach(function (done) {
  968. this.rclient.del = sinon.stub().callsArg(1)
  969. this.RedisManager.clearProjectState(this.project_id, done)
  970. })
  971. it('should delete the project state', function () {
  972. this.rclient.del
  973. .calledWith(`ProjectState:${this.project_id}`)
  974. .should.equal(true)
  975. })
  976. })
  977. describe('renameDoc', function () {
  978. beforeEach(function () {
  979. this.rclient.rpush = sinon.stub().yields()
  980. this.rclient.set = sinon.stub().yields()
  981. this.update = {
  982. id: this.docId,
  983. pathname: (this.pathname = 'pathname'),
  984. newPathname: (this.newPathname = 'new-pathname'),
  985. }
  986. })
  987. describe('the document is cached in redis', function () {
  988. beforeEach(function () {
  989. this.RedisManager.getDoc = sinon
  990. .stub()
  991. .callsArgWith(2, null, 'lines', 'version')
  992. this.RedisManager.renameDoc(
  993. this.project_id,
  994. this.docId,
  995. this.userId,
  996. this.update,
  997. this.projectHistoryId,
  998. this.callback
  999. )
  1000. })
  1001. it('update the cached pathname', function () {
  1002. this.rclient.set
  1003. .calledWith(`Pathname:${this.docId}`, this.newPathname)
  1004. .should.equal(true)
  1005. })
  1006. })
  1007. describe('the document is not cached in redis', function () {
  1008. beforeEach(function () {
  1009. this.RedisManager.getDoc = sinon
  1010. .stub()
  1011. .callsArgWith(2, null, null, null)
  1012. this.RedisManager.renameDoc(
  1013. this.project_id,
  1014. this.docId,
  1015. this.userId,
  1016. this.update,
  1017. this.projectHistoryId,
  1018. this.callback
  1019. )
  1020. })
  1021. it('does not update the cached pathname', function () {
  1022. this.rclient.set.called.should.equal(false)
  1023. })
  1024. })
  1025. describe('getDocVersion', function () {
  1026. beforeEach(function () {
  1027. this.version = 12345
  1028. this.rclient.mget = sinon
  1029. .stub()
  1030. .withArgs(`DocVersion:${this.docId}`)
  1031. .callsArgWith(1, null, [`${this.version}`])
  1032. this.RedisManager.getDocVersion(this.docId, this.callback)
  1033. })
  1034. it('should return the document version', function () {
  1035. this.callback.calledWithExactly(null, this.version).should.equal(true)
  1036. })
  1037. })
  1038. })
  1039. })