RedisManagerTests.js 35 KB

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