RedisManagerTests.js 34 KB

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