DocumentManagerTests.js 39 KB

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