RedisManagerTests.js 33 KB

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