RedisManagerTests.js 33 KB

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