DocumentManagerTests.js 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365
  1. const sinon = require('sinon')
  2. const { expect } = require('chai')
  3. const modulePath = '../../../../app/js/DocumentManager.js'
  4. const SandboxedModule = require('sandboxed-module')
  5. const Errors = require('../../../../app/js/Errors')
  6. const tk = require('timekeeper')
  7. describe('DocumentManager', function () {
  8. beforeEach(function () {
  9. tk.freeze(new Date())
  10. this.Metrics = {
  11. Timer: class Timer {},
  12. inc: sinon.stub(),
  13. }
  14. this.Metrics.Timer.prototype.done = sinon.stub()
  15. this.RedisManager = {
  16. promises: {
  17. clearUnflushedTime: sinon.stub().resolves(),
  18. getDoc: sinon.stub(),
  19. getPreviousDocOps: sinon.stub(),
  20. putDocInMemory: sinon.stub().resolves(),
  21. removeDocFromMemory: sinon.stub().resolves(),
  22. renameDoc: sinon.stub().resolves(),
  23. updateCommentState: sinon.stub().resolves(),
  24. updateDocument: sinon.stub().resolves(),
  25. },
  26. }
  27. this.ProjectHistoryRedisManager = {
  28. promises: {
  29. queueOps: sinon.stub().resolves(),
  30. queueResyncDocContent: sinon.stub().resolves(),
  31. },
  32. }
  33. this.PersistenceManager = {
  34. promises: {
  35. getDoc: sinon.stub(),
  36. setDoc: sinon.stub().resolves(),
  37. },
  38. }
  39. this.HistoryManager = {
  40. flushProjectChangesAsync: sinon.stub(),
  41. }
  42. this.DiffCodec = {
  43. diffAsShareJsOp: sinon.stub(),
  44. }
  45. this.UpdateManager = {
  46. promises: {
  47. applyUpdate: sinon.stub().resolves(),
  48. },
  49. }
  50. this.HistoryOTUpdateManager = {
  51. applyUpdate: sinon.stub().resolves(),
  52. }
  53. this.RangesManager = {
  54. acceptChanges: sinon.stub(),
  55. deleteComment: sinon.stub(),
  56. }
  57. this.Settings = {
  58. max_doc_length: 2 * 1024 * 1024, // 2mb
  59. }
  60. this.DocumentManager = SandboxedModule.require(modulePath, {
  61. requires: {
  62. './RedisManager': this.RedisManager,
  63. './ProjectHistoryRedisManager': this.ProjectHistoryRedisManager,
  64. './PersistenceManager': this.PersistenceManager,
  65. './HistoryManager': this.HistoryManager,
  66. './Metrics': this.Metrics,
  67. './DiffCodec': this.DiffCodec,
  68. './UpdateManager': this.UpdateManager,
  69. './HistoryOTUpdateManager': this.HistoryOTUpdateManager,
  70. './RangesManager': this.RangesManager,
  71. './Errors': Errors,
  72. '@overleaf/settings': this.Settings,
  73. },
  74. })
  75. this.project_id = 'project-id-123'
  76. this.projectHistoryId = 'history-id-123'
  77. this.doc_id = 'doc-id-123'
  78. this.user_id = 1234
  79. this.lines = ['one', 'two', 'three']
  80. this.version = 42
  81. this.ranges = { comments: 'mock', entries: 'mock' }
  82. this.resolvedCommentIds = ['comment-1']
  83. this.pathname = '/a/b/c.tex'
  84. this.unflushedTime = Date.now()
  85. this.lastUpdatedAt = Date.now()
  86. this.lastUpdatedBy = 'last-author-id'
  87. this.source = 'external-source'
  88. this.historyRangesSupport = false
  89. })
  90. afterEach(function () {
  91. tk.reset()
  92. })
  93. describe('flushAndDeleteDoc', function () {
  94. describe('successfully', function () {
  95. beforeEach(async function () {
  96. this.DocumentManager.promises.flushDocIfLoaded = sinon.stub().resolves()
  97. await this.DocumentManager.promises.flushAndDeleteDoc(
  98. this.project_id,
  99. this.doc_id,
  100. {}
  101. )
  102. })
  103. it('should flush the doc', function () {
  104. this.DocumentManager.promises.flushDocIfLoaded
  105. .calledWith(this.project_id, this.doc_id)
  106. .should.equal(true)
  107. })
  108. it('should remove the doc from redis', function () {
  109. this.RedisManager.promises.removeDocFromMemory
  110. .calledWith(this.project_id, this.doc_id)
  111. .should.equal(true)
  112. })
  113. })
  114. describe('when a flush error occurs', function () {
  115. beforeEach(async function () {
  116. this.DocumentManager.promises.flushDocIfLoaded = sinon
  117. .stub()
  118. .rejects(new Error('boom!'))
  119. await expect(
  120. this.DocumentManager.promises.flushAndDeleteDoc(
  121. this.project_id,
  122. this.doc_id,
  123. {}
  124. )
  125. ).to.be.rejected
  126. })
  127. it('should not remove the doc from redis', function () {
  128. this.RedisManager.promises.removeDocFromMemory.called.should.equal(
  129. false
  130. )
  131. })
  132. describe('when ignoring flush errors', function () {
  133. it('should remove the doc from redis', async function () {
  134. await this.DocumentManager.promises.flushAndDeleteDoc(
  135. this.project_id,
  136. this.doc_id,
  137. { ignoreFlushErrors: true }
  138. )
  139. this.RedisManager.promises.removeDocFromMemory.called.should.equal(
  140. true
  141. )
  142. })
  143. })
  144. })
  145. })
  146. describe('flushDocIfLoaded', function () {
  147. describe('when the doc is in Redis', function () {
  148. beforeEach(async function () {
  149. this.RedisManager.promises.getDoc.resolves({
  150. lines: this.lines,
  151. version: this.version,
  152. ranges: this.ranges,
  153. pathname: this.pathname,
  154. projectHistoryId: this.projectHistoryId,
  155. unflushedTime: this.unflushedTime,
  156. lastUpdatedAt: this.lastUpdatedAt,
  157. lastUpdatedBy: this.lastUpdatedBy,
  158. })
  159. await this.DocumentManager.promises.flushDocIfLoaded(
  160. this.project_id,
  161. this.doc_id
  162. )
  163. })
  164. it('should get the doc from redis', function () {
  165. this.RedisManager.promises.getDoc
  166. .calledWith(this.project_id, this.doc_id)
  167. .should.equal(true)
  168. })
  169. it('should write the doc lines to the persistence layer', function () {
  170. this.PersistenceManager.promises.setDoc.should.have.been.calledWith(
  171. this.project_id,
  172. this.doc_id,
  173. this.lines,
  174. this.version,
  175. this.ranges,
  176. this.lastUpdatedAt,
  177. this.lastUpdatedBy
  178. )
  179. })
  180. })
  181. describe('when the document is not in Redis', function () {
  182. beforeEach(async function () {
  183. this.RedisManager.promises.getDoc.resolves({
  184. lines: null,
  185. version: null,
  186. ranges: null,
  187. })
  188. await this.DocumentManager.promises.flushDocIfLoaded(
  189. this.project_id,
  190. this.doc_id
  191. )
  192. })
  193. it('should get the doc from redis', function () {
  194. this.RedisManager.promises.getDoc
  195. .calledWith(this.project_id, this.doc_id)
  196. .should.equal(true)
  197. })
  198. it('should not write anything to the persistence layer', function () {
  199. this.PersistenceManager.promises.setDoc.called.should.equal(false)
  200. })
  201. })
  202. })
  203. describe('getDocAndRecentOps', function () {
  204. describe('with a previous version specified', function () {
  205. beforeEach(async function () {
  206. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  207. lines: this.lines,
  208. version: this.version,
  209. ranges: this.ranges,
  210. pathname: this.pathname,
  211. projectHistoryId: this.projectHistoryId,
  212. type: 'sharejs-text-ot',
  213. })
  214. this.RedisManager.promises.getPreviousDocOps.resolves(this.ops)
  215. this.result = await this.DocumentManager.promises.getDocAndRecentOps(
  216. this.project_id,
  217. this.doc_id,
  218. this.fromVersion
  219. )
  220. })
  221. it('should get the doc', function () {
  222. this.DocumentManager.promises.getDoc
  223. .calledWith(this.project_id, this.doc_id)
  224. .should.equal(true)
  225. })
  226. it('should get the doc ops', function () {
  227. this.RedisManager.promises.getPreviousDocOps
  228. .calledWith(this.doc_id, this.fromVersion, this.version)
  229. .should.equal(true)
  230. })
  231. it('should return the doc info', function () {
  232. expect(this.result).to.deep.equal({
  233. lines: this.lines,
  234. version: this.version,
  235. ops: this.ops,
  236. ranges: this.ranges,
  237. pathname: this.pathname,
  238. projectHistoryId: this.projectHistoryId,
  239. type: 'sharejs-text-ot',
  240. })
  241. })
  242. })
  243. describe('with no previous version specified', function () {
  244. beforeEach(async function () {
  245. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  246. lines: this.lines,
  247. version: this.version,
  248. ranges: this.ranges,
  249. pathname: this.pathname,
  250. projectHistoryId: this.projectHistoryId,
  251. type: 'sharejs-text-ot',
  252. })
  253. this.RedisManager.promises.getPreviousDocOps.resolves(this.ops)
  254. this.result = await this.DocumentManager.promises.getDocAndRecentOps(
  255. this.project_id,
  256. this.doc_id,
  257. -1
  258. )
  259. })
  260. it('should get the doc', function () {
  261. this.DocumentManager.promises.getDoc
  262. .calledWith(this.project_id, this.doc_id)
  263. .should.equal(true)
  264. })
  265. it('should not need to get the doc ops', function () {
  266. this.RedisManager.promises.getPreviousDocOps.called.should.equal(false)
  267. })
  268. it('should return the doc info', function () {
  269. expect(this.result).to.deep.equal({
  270. lines: this.lines,
  271. version: this.version,
  272. ops: [],
  273. ranges: this.ranges,
  274. pathname: this.pathname,
  275. projectHistoryId: this.projectHistoryId,
  276. type: 'sharejs-text-ot',
  277. })
  278. })
  279. })
  280. })
  281. describe('getDoc', function () {
  282. describe('when the doc exists in Redis', function () {
  283. beforeEach(async function () {
  284. this.RedisManager.promises.getDoc.resolves({
  285. lines: this.lines,
  286. version: this.version,
  287. ranges: this.ranges,
  288. resolvedCommentIds: this.resolvedCommentIds,
  289. pathname: this.pathname,
  290. projectHistoryId: this.projectHistoryId,
  291. unflushedTime: this.unflushedTime,
  292. lastUpdatedAt: this.lastUpdatedAt,
  293. lastUpdatedBy: this.lastUpdatedBy,
  294. historyRangesSupport: this.historyRangesSupport,
  295. })
  296. this.result = await this.DocumentManager.promises.getDoc(
  297. this.project_id,
  298. this.doc_id
  299. )
  300. })
  301. it('should get the doc from Redis', function () {
  302. this.RedisManager.promises.getDoc
  303. .calledWith(this.project_id, this.doc_id)
  304. .should.equal(true)
  305. })
  306. it('should return the doc info', function () {
  307. expect(this.result).to.deep.equal({
  308. lines: this.lines,
  309. version: this.version,
  310. ranges: this.ranges,
  311. resolvedCommentIds: this.resolvedCommentIds,
  312. pathname: this.pathname,
  313. projectHistoryId: this.projectHistoryId,
  314. unflushedTime: this.unflushedTime,
  315. alreadyLoaded: true,
  316. historyRangesSupport: this.historyRangesSupport,
  317. type: 'sharejs-text-ot',
  318. })
  319. })
  320. })
  321. describe('when the doc does not exist in Redis', function () {
  322. beforeEach(async function () {
  323. this.RedisManager.promises.getDoc.resolves({
  324. lines: null,
  325. version: null,
  326. ranges: null,
  327. pathname: null,
  328. projectHistoryId: null,
  329. })
  330. this.PersistenceManager.promises.getDoc.resolves({
  331. lines: this.lines,
  332. version: this.version,
  333. ranges: this.ranges,
  334. resolvedCommentIds: this.resolvedCommentIds,
  335. pathname: this.pathname,
  336. projectHistoryId: this.projectHistoryId,
  337. historyRangesSupport: this.historyRangesSupport,
  338. })
  339. this.result = await this.DocumentManager.promises.getDoc(
  340. this.project_id,
  341. this.doc_id
  342. )
  343. })
  344. it('should try to get the doc from Redis', function () {
  345. this.RedisManager.promises.getDoc
  346. .calledWith(this.project_id, this.doc_id)
  347. .should.equal(true)
  348. })
  349. it('should get the doc from the PersistenceManager', function () {
  350. this.PersistenceManager.promises.getDoc
  351. .calledWith(this.project_id, this.doc_id)
  352. .should.equal(true)
  353. })
  354. it('should set the doc in Redis', function () {
  355. this.RedisManager.promises.putDocInMemory
  356. .calledWith(
  357. this.project_id,
  358. this.doc_id,
  359. this.lines,
  360. this.version,
  361. this.ranges,
  362. this.resolvedCommentIds,
  363. this.pathname,
  364. this.projectHistoryId,
  365. this.historyRangesSupport
  366. )
  367. .should.equal(true)
  368. })
  369. it('should return doc info', function () {
  370. expect(this.result).to.deep.equal({
  371. lines: this.lines,
  372. version: this.version,
  373. ranges: this.ranges,
  374. resolvedCommentIds: this.resolvedCommentIds,
  375. pathname: this.pathname,
  376. projectHistoryId: this.projectHistoryId,
  377. unflushedTime: null,
  378. alreadyLoaded: false,
  379. historyRangesSupport: this.historyRangesSupport,
  380. type: 'sharejs-text-ot',
  381. })
  382. })
  383. })
  384. })
  385. describe('setDoc', function () {
  386. describe('with plain tex lines', function () {
  387. beforeEach(function () {
  388. this.beforeLines = ['before', 'lines']
  389. this.afterLines = ['after', 'lines']
  390. this.ops = [
  391. { i: 'foo', p: 4 },
  392. { d: 'bar', p: 42 },
  393. ]
  394. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  395. lines: this.beforeLines,
  396. version: this.version,
  397. ranges: this.ranges,
  398. resolvedCommentIds: this.resolvedCommentIds,
  399. pathname: this.pathname,
  400. projectHistoryId: this.projectHistoryId,
  401. unflushedTime: this.unflushedTime,
  402. alreadyLoaded: true,
  403. })
  404. this.DiffCodec.diffAsShareJsOp.returns(this.ops)
  405. this.DocumentManager.promises.flushDocIfLoaded = sinon.stub().resolves()
  406. this.DocumentManager.promises.flushAndDeleteDoc = sinon
  407. .stub()
  408. .resolves()
  409. })
  410. describe('when not loaded but with the same content', function () {
  411. beforeEach(async function () {
  412. this.DiffCodec.diffAsShareJsOp.returns([])
  413. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  414. lines: this.beforeLines,
  415. version: this.version,
  416. ranges: this.ranges,
  417. resolvedCommentIds: this.resolvedCommentIds,
  418. pathname: this.pathname,
  419. projectHistoryId: this.projectHistoryId,
  420. unflushedTime: this.unflushedTime,
  421. alreadyLoaded: false,
  422. })
  423. await this.DocumentManager.promises.setDoc(
  424. this.project_id,
  425. this.doc_id,
  426. this.beforeLines,
  427. this.source,
  428. this.user_id,
  429. false,
  430. true
  431. )
  432. })
  433. it('should not apply the diff as a ShareJS op', function () {
  434. this.UpdateManager.promises.applyUpdate.called.should.equal(false)
  435. })
  436. it('should increment the external update metric', function () {
  437. this.Metrics.inc
  438. .calledWith('external-update', 1, {
  439. status: 'noop',
  440. method: 'evict',
  441. path: this.source,
  442. })
  443. .should.equal(true)
  444. })
  445. it('should flush and delete the doc from redis', function () {
  446. this.DocumentManager.promises.flushAndDeleteDoc
  447. .calledWith(this.project_id, this.doc_id)
  448. .should.equal(true)
  449. })
  450. })
  451. describe('when already loaded with the same content', function () {
  452. beforeEach(async function () {
  453. this.DiffCodec.diffAsShareJsOp.returns([])
  454. await this.DocumentManager.promises.setDoc(
  455. this.project_id,
  456. this.doc_id,
  457. this.beforeLines,
  458. this.source,
  459. this.user_id,
  460. false,
  461. true
  462. )
  463. })
  464. it('should not apply the diff as a ShareJS op', function () {
  465. this.UpdateManager.promises.applyUpdate.called.should.equal(false)
  466. })
  467. it('should increment the external update metric', function () {
  468. this.Metrics.inc
  469. .calledWith('external-update', 1, {
  470. status: 'noop',
  471. method: 'flush',
  472. path: this.source,
  473. })
  474. .should.equal(true)
  475. })
  476. it('should flush the doc to Mongo', function () {
  477. this.DocumentManager.promises.flushDocIfLoaded
  478. .calledWith(this.project_id, this.doc_id)
  479. .should.equal(true)
  480. })
  481. })
  482. describe('when already loaded', function () {
  483. beforeEach(async function () {
  484. await this.DocumentManager.promises.setDoc(
  485. this.project_id,
  486. this.doc_id,
  487. this.afterLines,
  488. this.source,
  489. this.user_id,
  490. false,
  491. true
  492. )
  493. })
  494. it('should get the current doc lines', function () {
  495. this.DocumentManager.promises.getDoc
  496. .calledWith(this.project_id, this.doc_id)
  497. .should.equal(true)
  498. })
  499. it('should return a diff of the old and new lines', function () {
  500. this.DiffCodec.diffAsShareJsOp
  501. .calledWith(this.beforeLines, this.afterLines)
  502. .should.equal(true)
  503. })
  504. it('should apply the diff as a ShareJS op', function () {
  505. this.UpdateManager.promises.applyUpdate
  506. .calledWith(this.project_id, this.doc_id, {
  507. doc: this.doc_id,
  508. v: this.version,
  509. op: this.ops,
  510. meta: {
  511. type: 'external',
  512. source: this.source,
  513. user_id: this.user_id,
  514. },
  515. })
  516. .should.equal(true)
  517. })
  518. it('should increment the external update metric', function () {
  519. this.Metrics.inc
  520. .calledWith('external-update', 1, {
  521. status: 'diff',
  522. method: 'flush',
  523. path: this.source,
  524. })
  525. .should.equal(true)
  526. })
  527. it('should flush the doc to Mongo', function () {
  528. this.DocumentManager.promises.flushDocIfLoaded
  529. .calledWith(this.project_id, this.doc_id)
  530. .should.equal(true)
  531. })
  532. it('should not flush the project history', function () {
  533. this.HistoryManager.flushProjectChangesAsync.called.should.equal(
  534. false
  535. )
  536. })
  537. })
  538. describe('when not already loaded', function () {
  539. beforeEach(async function () {
  540. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  541. lines: this.beforeLines,
  542. version: this.version,
  543. pathname: this.pathname,
  544. unflushedTime: null,
  545. alreadyLoaded: false,
  546. })
  547. await this.DocumentManager.promises.setDoc(
  548. this.project_id,
  549. this.doc_id,
  550. this.afterLines,
  551. this.source,
  552. this.user_id,
  553. false,
  554. true
  555. )
  556. })
  557. it('should flush and delete the doc from the doc updater', function () {
  558. this.DocumentManager.promises.flushAndDeleteDoc
  559. .calledWith(this.project_id, this.doc_id, {})
  560. .should.equal(true)
  561. })
  562. it('should increment the external update metric', function () {
  563. this.Metrics.inc
  564. .calledWith('external-update', 1, {
  565. status: 'diff',
  566. method: 'evict',
  567. path: this.source,
  568. })
  569. .should.equal(true)
  570. })
  571. it('should not flush the project history', function () {
  572. this.HistoryManager.flushProjectChangesAsync
  573. .calledWithExactly(this.project_id)
  574. .should.equal(true)
  575. })
  576. })
  577. describe('without new lines', function () {
  578. beforeEach(async function () {
  579. await expect(
  580. this.DocumentManager.promises.setDoc(
  581. this.project_id,
  582. this.doc_id,
  583. null,
  584. this.source,
  585. this.user_id,
  586. false,
  587. true
  588. )
  589. ).to.be.rejectedWith('No lines were provided to setDoc')
  590. })
  591. it('should not try to get the doc lines', function () {
  592. this.DocumentManager.promises.getDoc.called.should.equal(false)
  593. })
  594. })
  595. describe('with the undoing flag', function () {
  596. beforeEach(async function () {
  597. // Copy ops so we don't interfere with other tests
  598. this.ops = [
  599. { i: 'foo', p: 4 },
  600. { d: 'bar', p: 42 },
  601. ]
  602. this.DiffCodec.diffAsShareJsOp.returns(this.ops)
  603. await this.DocumentManager.promises.setDoc(
  604. this.project_id,
  605. this.doc_id,
  606. this.afterLines,
  607. this.source,
  608. this.user_id,
  609. true,
  610. true
  611. )
  612. })
  613. it('should set the undo flag on each op', function () {
  614. this.ops.map(op => op.u.should.equal(true))
  615. })
  616. })
  617. describe('with the external flag', function () {
  618. beforeEach(async function () {
  619. this.undoing = false
  620. // Copy ops so we don't interfere with other tests
  621. this.ops = [
  622. { i: 'foo', p: 4 },
  623. { d: 'bar', p: 42 },
  624. ]
  625. this.DiffCodec.diffAsShareJsOp.returns(this.ops)
  626. await this.DocumentManager.promises.setDoc(
  627. this.project_id,
  628. this.doc_id,
  629. this.afterLines,
  630. this.source,
  631. this.user_id,
  632. this.undoing,
  633. true
  634. )
  635. })
  636. it('should add the external type to update metadata', function () {
  637. this.UpdateManager.promises.applyUpdate
  638. .calledWith(this.project_id, this.doc_id, {
  639. doc: this.doc_id,
  640. v: this.version,
  641. op: this.ops,
  642. meta: {
  643. type: 'external',
  644. source: this.source,
  645. user_id: this.user_id,
  646. },
  647. })
  648. .should.equal(true)
  649. })
  650. })
  651. describe('without the external flag', function () {
  652. beforeEach(async function () {
  653. this.undoing = false
  654. // Copy ops so we don't interfere with other tests
  655. this.ops = [
  656. { i: 'foo', p: 4 },
  657. { d: 'bar', p: 42 },
  658. ]
  659. this.DiffCodec.diffAsShareJsOp.returns(this.ops)
  660. await this.DocumentManager.promises.setDoc(
  661. this.project_id,
  662. this.doc_id,
  663. this.afterLines,
  664. this.source,
  665. this.user_id,
  666. this.undoing,
  667. false
  668. )
  669. })
  670. it('should not add the external type to update metadata', function () {
  671. this.UpdateManager.promises.applyUpdate
  672. .calledWith(this.project_id, this.doc_id, {
  673. doc: this.doc_id,
  674. v: this.version,
  675. op: this.ops,
  676. meta: {
  677. source: this.source,
  678. user_id: this.user_id,
  679. },
  680. })
  681. .should.equal(true)
  682. })
  683. })
  684. })
  685. })
  686. describe('acceptChanges', function () {
  687. beforeEach(function () {
  688. this.change_id = 'mock-change-id'
  689. this.change_ids = [
  690. 'mock-change-id-1',
  691. 'mock-change-id-2',
  692. 'mock-change-id-3',
  693. 'mock-change-id-4',
  694. ]
  695. this.version = 34
  696. this.lines = ['original', 'lines']
  697. this.ranges = { entries: 'mock', comments: 'mock' }
  698. this.updated_ranges = { entries: 'updated', comments: 'updated' }
  699. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  700. lines: this.lines,
  701. version: this.version,
  702. ranges: this.ranges,
  703. })
  704. this.RangesManager.acceptChanges.returns(this.updated_ranges)
  705. })
  706. describe('successfully with a single change', function () {
  707. beforeEach(async function () {
  708. await this.DocumentManager.promises.acceptChanges(
  709. this.project_id,
  710. this.doc_id,
  711. [this.change_id]
  712. )
  713. })
  714. it("should get the document's current ranges", function () {
  715. this.DocumentManager.promises.getDoc
  716. .calledWith(this.project_id, this.doc_id)
  717. .should.equal(true)
  718. })
  719. it('should apply the accept change to the ranges', function () {
  720. this.RangesManager.acceptChanges.should.have.been.calledWith(
  721. this.project_id,
  722. this.doc_id,
  723. [this.change_id],
  724. this.ranges
  725. )
  726. })
  727. it('should save the updated ranges', function () {
  728. this.RedisManager.promises.updateDocument
  729. .calledWith(
  730. this.project_id,
  731. this.doc_id,
  732. this.lines,
  733. this.version,
  734. [],
  735. this.updated_ranges,
  736. {}
  737. )
  738. .should.equal(true)
  739. })
  740. })
  741. describe('successfully with multiple changes', function () {
  742. beforeEach(async function () {
  743. await this.DocumentManager.promises.acceptChanges(
  744. this.project_id,
  745. this.doc_id,
  746. this.change_ids
  747. )
  748. })
  749. it('should apply the accept change to the ranges', function () {
  750. this.RangesManager.acceptChanges
  751. .calledWith(
  752. this.project_id,
  753. this.doc_id,
  754. this.change_ids,
  755. this.ranges
  756. )
  757. .should.equal(true)
  758. })
  759. })
  760. describe('when the doc is not found', function () {
  761. beforeEach(async function () {
  762. this.DocumentManager.promises.getDoc = sinon
  763. .stub()
  764. .resolves({ lines: null, version: null, ranges: null })
  765. await expect(
  766. this.DocumentManager.promises.acceptChanges(
  767. this.project_id,
  768. this.doc_id,
  769. [this.change_id]
  770. )
  771. ).to.be.rejectedWith(Errors.NotFoundError)
  772. })
  773. it('should not save anything', function () {
  774. this.RedisManager.promises.updateDocument.called.should.equal(false)
  775. })
  776. })
  777. })
  778. describe('getComment', function () {
  779. beforeEach(function () {
  780. this.ranges.comments = [
  781. {
  782. id: 'mock-comment-id-1',
  783. },
  784. {
  785. id: 'mock-comment-id-2',
  786. },
  787. ]
  788. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  789. lines: this.lines,
  790. version: this.version,
  791. ranges: this.ranges,
  792. })
  793. })
  794. describe('when comment exists', function () {
  795. beforeEach(async function () {
  796. await expect(
  797. this.DocumentManager.promises.getComment(
  798. this.project_id,
  799. this.doc_id,
  800. 'mock-comment-id-1'
  801. )
  802. ).to.eventually.deep.equal({
  803. comment: { id: 'mock-comment-id-1' },
  804. })
  805. })
  806. it("should get the document's current ranges", function () {
  807. this.DocumentManager.promises.getDoc
  808. .calledWith(this.project_id, this.doc_id)
  809. .should.equal(true)
  810. })
  811. })
  812. describe('when comment doesnt exists', function () {
  813. beforeEach(async function () {
  814. await expect(
  815. this.DocumentManager.promises.getComment(
  816. this.project_id,
  817. this.doc_id,
  818. 'mock-comment-id-x'
  819. )
  820. ).to.be.rejectedWith(Errors.NotFoundError)
  821. })
  822. it("should get the document's current ranges", function () {
  823. this.DocumentManager.promises.getDoc
  824. .calledWith(this.project_id, this.doc_id)
  825. .should.equal(true)
  826. })
  827. })
  828. describe('when the doc is not found', function () {
  829. beforeEach(async function () {
  830. this.DocumentManager.promises.getDoc = sinon
  831. .stub()
  832. .resolves({ lines: null, version: null, ranges: null })
  833. await expect(
  834. this.DocumentManager.promises.acceptChanges(
  835. this.project_id,
  836. this.doc_id,
  837. [this.change_id]
  838. )
  839. ).to.be.rejectedWith(Errors.NotFoundError)
  840. })
  841. })
  842. })
  843. describe('deleteComment', function () {
  844. beforeEach(function () {
  845. this.comment_id = 'mock-comment-id'
  846. this.version = 34
  847. this.lines = ['original', 'lines']
  848. this.ranges = { comments: ['one', 'two', 'three'] }
  849. this.resolvedCommentIds = ['comment1']
  850. this.updated_ranges = { comments: ['one', 'three'] }
  851. this.historyRangesSupport = true
  852. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  853. lines: this.lines,
  854. version: this.version,
  855. ranges: this.ranges,
  856. resolvedCommentIds: this.resolvedCommentIds,
  857. pathname: this.pathname,
  858. projectHistoryId: this.projectHistoryId,
  859. unflushedTime: Date.now() - 1e9,
  860. alreadyLoaded: true,
  861. historyRangesSupport: this.historyRangesSupport,
  862. })
  863. this.RangesManager.deleteComment.returns(this.updated_ranges)
  864. })
  865. describe('successfully', function () {
  866. beforeEach(async function () {
  867. await this.DocumentManager.promises.deleteComment(
  868. this.project_id,
  869. this.doc_id,
  870. this.comment_id,
  871. this.user_id
  872. )
  873. })
  874. it("should get the document's current ranges", function () {
  875. this.DocumentManager.promises.getDoc
  876. .calledWith(this.project_id, this.doc_id)
  877. .should.equal(true)
  878. })
  879. it('should delete the comment from the ranges', function () {
  880. this.RangesManager.deleteComment
  881. .calledWith(this.comment_id, this.ranges)
  882. .should.equal(true)
  883. })
  884. it('should save the updated ranges', function () {
  885. this.RedisManager.promises.updateDocument
  886. .calledWith(
  887. this.project_id,
  888. this.doc_id,
  889. this.lines,
  890. this.version,
  891. [],
  892. this.updated_ranges,
  893. {}
  894. )
  895. .should.equal(true)
  896. })
  897. it('should unset the comment resolved state', function () {
  898. this.RedisManager.promises.updateCommentState.should.have.been.calledWith(
  899. this.doc_id,
  900. this.comment_id,
  901. false
  902. )
  903. })
  904. it('should queue the delete comment operation', function () {
  905. this.ProjectHistoryRedisManager.promises.queueOps.should.have.been.calledWith(
  906. this.project_id,
  907. JSON.stringify({
  908. pathname: this.pathname,
  909. deleteComment: this.comment_id,
  910. meta: {
  911. ts: new Date(),
  912. user_id: this.user_id,
  913. },
  914. })
  915. )
  916. })
  917. })
  918. describe('when the doc is not found', function () {
  919. beforeEach(async function () {
  920. this.DocumentManager.promises.getDoc = sinon
  921. .stub()
  922. .resolves({ lines: null, version: null, ranges: null })
  923. await expect(
  924. this.DocumentManager.promises.acceptChanges(
  925. this.project_id,
  926. this.doc_id,
  927. [this.comment_id]
  928. )
  929. ).to.be.rejectedWith(Errors.NotFoundError)
  930. })
  931. it('should not save anything', function () {
  932. this.RedisManager.promises.updateDocument.called.should.equal(false)
  933. })
  934. })
  935. })
  936. describe('getDocAndFlushIfOld', function () {
  937. beforeEach(function () {
  938. this.DocumentManager.promises.flushDocIfLoaded = sinon.stub().resolves()
  939. })
  940. describe('when the doc is in Redis', function () {
  941. describe('and has changes to be flushed', function () {
  942. beforeEach(async function () {
  943. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  944. lines: this.lines,
  945. version: this.version,
  946. ranges: this.ranges,
  947. projectHistoryId: this.projectHistoryId,
  948. pathname: this.pathname,
  949. unflushedTime: Date.now() - 1e9,
  950. alreadyLoaded: true,
  951. })
  952. this.result = await this.DocumentManager.promises.getDocAndFlushIfOld(
  953. this.project_id,
  954. this.doc_id
  955. )
  956. })
  957. it('should get the doc', function () {
  958. this.DocumentManager.promises.getDoc
  959. .calledWith(this.project_id, this.doc_id)
  960. .should.equal(true)
  961. })
  962. it('should flush the doc', function () {
  963. this.DocumentManager.promises.flushDocIfLoaded
  964. .calledWith(this.project_id, this.doc_id)
  965. .should.equal(true)
  966. })
  967. it('should return the lines and versions', function () {
  968. expect(this.result).to.deep.equal({
  969. lines: this.lines,
  970. version: this.version,
  971. })
  972. })
  973. })
  974. describe("and has only changes that don't need to be flushed", function () {
  975. beforeEach(async function () {
  976. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  977. lines: this.lines,
  978. version: this.version,
  979. ranges: this.ranges,
  980. pathname: this.pathname,
  981. unflushedTime: Date.now() - 100,
  982. alreadyLoaded: true,
  983. })
  984. this.result = await this.DocumentManager.promises.getDocAndFlushIfOld(
  985. this.project_id,
  986. this.doc_id
  987. )
  988. })
  989. it('should get the doc', function () {
  990. this.DocumentManager.promises.getDoc
  991. .calledWith(this.project_id, this.doc_id)
  992. .should.equal(true)
  993. })
  994. it('should not flush the doc', function () {
  995. this.DocumentManager.promises.flushDocIfLoaded.called.should.equal(
  996. false
  997. )
  998. })
  999. it('should return the lines and versions', function () {
  1000. expect(this.result).to.deep.equal({
  1001. lines: this.lines,
  1002. version: this.version,
  1003. })
  1004. })
  1005. })
  1006. })
  1007. describe('when the doc is not in Redis', function () {
  1008. beforeEach(async function () {
  1009. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  1010. lines: this.lines,
  1011. version: this.version,
  1012. ranges: this.ranges,
  1013. alreadyLoaded: false,
  1014. })
  1015. this.result = await this.DocumentManager.promises.getDocAndFlushIfOld(
  1016. this.project_id,
  1017. this.doc_id
  1018. )
  1019. })
  1020. it('should get the doc', function () {
  1021. this.DocumentManager.promises.getDoc
  1022. .calledWith(this.project_id, this.doc_id)
  1023. .should.equal(true)
  1024. })
  1025. it('should not flush the doc', function () {
  1026. this.DocumentManager.promises.flushDocIfLoaded.called.should.equal(
  1027. false
  1028. )
  1029. })
  1030. it('should return the lines and versions', function () {
  1031. expect(this.result).to.deep.equal({
  1032. lines: this.lines,
  1033. version: this.version,
  1034. })
  1035. })
  1036. })
  1037. })
  1038. describe('renameDoc', function () {
  1039. beforeEach(function () {
  1040. this.update = 'some-update'
  1041. })
  1042. describe('successfully', function () {
  1043. beforeEach(async function () {
  1044. await this.DocumentManager.promises.renameDoc(
  1045. this.project_id,
  1046. this.doc_id,
  1047. this.user_id,
  1048. this.update,
  1049. this.projectHistoryId
  1050. )
  1051. })
  1052. it('should rename the document', function () {
  1053. this.RedisManager.promises.renameDoc
  1054. .calledWith(
  1055. this.project_id,
  1056. this.doc_id,
  1057. this.user_id,
  1058. this.update,
  1059. this.projectHistoryId
  1060. )
  1061. .should.equal(true)
  1062. })
  1063. })
  1064. })
  1065. describe('resyncDocContents', function () {
  1066. describe('when doc is loaded in redis', function () {
  1067. beforeEach(async function () {
  1068. this.pathnameFromProjectStructureUpdate = '/foo/bar.tex'
  1069. this.RedisManager.promises.getDoc.resolves({
  1070. lines: this.lines,
  1071. version: this.version,
  1072. ranges: this.ranges,
  1073. resolvedCommentIds: this.resolvedCommentIds,
  1074. pathname: this.pathname,
  1075. projectHistoryId: this.projectHistoryId,
  1076. historyRangesSupport: this.historyRangesSupport,
  1077. })
  1078. await this.DocumentManager.promises.resyncDocContents(
  1079. this.project_id,
  1080. this.doc_id,
  1081. this.pathnameFromProjectStructureUpdate
  1082. )
  1083. })
  1084. it('gets the doc contents from redis', function () {
  1085. this.RedisManager.promises.getDoc
  1086. .calledWith(this.project_id, this.doc_id)
  1087. .should.equal(true)
  1088. })
  1089. it('queues a resync doc content update', function () {
  1090. this.ProjectHistoryRedisManager.promises.queueResyncDocContent
  1091. .calledWith(
  1092. this.project_id,
  1093. this.projectHistoryId,
  1094. this.doc_id,
  1095. this.lines,
  1096. this.ranges,
  1097. this.resolvedCommentIds,
  1098. this.version,
  1099. this.pathnameFromProjectStructureUpdate,
  1100. this.historyRangesSupport
  1101. )
  1102. .should.equal(true)
  1103. })
  1104. })
  1105. describe('when doc is not loaded in redis', function () {
  1106. beforeEach(async function () {
  1107. this.pathnameFromProjectStructureUpdate = '/foo/bar.tex'
  1108. this.RedisManager.promises.getDoc.resolves({})
  1109. this.PersistenceManager.promises.getDoc.resolves({
  1110. lines: this.lines,
  1111. version: this.version,
  1112. ranges: this.ranges,
  1113. resolvedCommentIds: this.resolvedCommentIds,
  1114. pathname: this.pathname,
  1115. projectHistoryId: this.projectHistoryId,
  1116. historyRangesSupport: this.historyRangesSupport,
  1117. })
  1118. await this.DocumentManager.promises.resyncDocContents(
  1119. this.project_id,
  1120. this.doc_id,
  1121. this.pathnameFromProjectStructureUpdate
  1122. )
  1123. })
  1124. it('tries to get the doc contents from redis', function () {
  1125. this.RedisManager.promises.getDoc
  1126. .calledWith(this.project_id, this.doc_id)
  1127. .should.equal(true)
  1128. })
  1129. it('gets the doc contents from web', function () {
  1130. this.PersistenceManager.promises.getDoc
  1131. .calledWith(this.project_id, this.doc_id, { peek: true })
  1132. .should.equal(true)
  1133. })
  1134. it('queues a resync doc content update', function () {
  1135. this.ProjectHistoryRedisManager.promises.queueResyncDocContent
  1136. .calledWith(
  1137. this.project_id,
  1138. this.projectHistoryId,
  1139. this.doc_id,
  1140. this.lines,
  1141. this.ranges,
  1142. this.resolvedCommentIds,
  1143. this.version,
  1144. this.pathnameFromProjectStructureUpdate,
  1145. this.historyRangesSupport
  1146. )
  1147. .should.equal(true)
  1148. })
  1149. })
  1150. describe('when a doc has no ranges in docstore', function () {
  1151. beforeEach(async function () {
  1152. this.pathnameFromProjectStructureUpdate = '/foo/bar.tex'
  1153. this.RedisManager.promises.getDoc.resolves({})
  1154. this.PersistenceManager.promises.getDoc.resolves({
  1155. lines: this.lines,
  1156. version: this.version,
  1157. ranges: undefined,
  1158. resolvedCommentIds: [],
  1159. pathname: this.pathname,
  1160. projectHistoryId: this.projectHistoryId,
  1161. historyRangesSupport: this.historyRangesSupport,
  1162. })
  1163. await this.DocumentManager.promises.resyncDocContents(
  1164. this.project_id,
  1165. this.doc_id,
  1166. this.pathnameFromProjectStructureUpdate
  1167. )
  1168. })
  1169. it('gets the doc contents from web', function () {
  1170. this.PersistenceManager.promises.getDoc
  1171. .calledWith(this.project_id, this.doc_id, { peek: true })
  1172. .should.equal(true)
  1173. })
  1174. it('queues a resync doc content update with an empty ranges object', function () {
  1175. this.ProjectHistoryRedisManager.promises.queueResyncDocContent
  1176. .calledWith(
  1177. this.project_id,
  1178. this.projectHistoryId,
  1179. this.doc_id,
  1180. this.lines,
  1181. {},
  1182. [],
  1183. this.version,
  1184. this.pathnameFromProjectStructureUpdate,
  1185. this.historyRangesSupport
  1186. )
  1187. .should.equal(true)
  1188. })
  1189. })
  1190. })
  1191. describe('appendToDoc', function () {
  1192. describe('sucessfully', function () {
  1193. beforeEach(async function () {
  1194. this.lines = ['one', 'two', 'three']
  1195. this.DocumentManager.promises.setDoc = sinon
  1196. .stub()
  1197. .resolves({ rev: '123' })
  1198. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  1199. lines: this.lines,
  1200. })
  1201. this.result = await this.DocumentManager.promises.appendToDoc(
  1202. this.project_id,
  1203. this.doc_id,
  1204. ['four', 'five', 'six'],
  1205. this.source,
  1206. this.user_id
  1207. )
  1208. })
  1209. it('should call setDoc with concatenated lines', function () {
  1210. this.DocumentManager.promises.setDoc
  1211. .calledWith(
  1212. this.project_id,
  1213. this.doc_id,
  1214. ['one', 'two', 'three', 'four', 'five', 'six'],
  1215. this.source,
  1216. this.user_id,
  1217. false,
  1218. false
  1219. )
  1220. .should.equal(true)
  1221. })
  1222. it('should return output from setDoc', function () {
  1223. this.result.should.deep.equal({ rev: '123' })
  1224. })
  1225. })
  1226. describe('when doc would become too big', function () {
  1227. beforeEach(async function () {
  1228. this.Settings.max_doc_length = 100
  1229. this.lines = ['one', 'two', 'three']
  1230. this.DocumentManager.promises.setDoc = sinon
  1231. .stub()
  1232. .resolves({ rev: '123' })
  1233. this.DocumentManager.promises.getDoc = sinon.stub().resolves({
  1234. lines: this.lines,
  1235. })
  1236. })
  1237. it('should fail with FileTooLarge error', async function () {
  1238. expect(
  1239. this.DocumentManager.promises.appendToDoc(
  1240. this.project_id,
  1241. this.doc_id,
  1242. ['x'.repeat(1000)],
  1243. this.source,
  1244. this.user_id
  1245. )
  1246. ).to.eventually.be.rejectedWith(Errors.FileTooLargeError)
  1247. })
  1248. })
  1249. })
  1250. })