RedisManagerTests.js 31 KB

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