RedisManagerTests.js 32 KB

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