SyncManagerTests.js 31 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036
  1. import sinon from 'sinon'
  2. import { expect } from 'chai'
  3. import mongodb from 'mongodb-legacy'
  4. import tk from 'timekeeper'
  5. import { strict as esmock } from 'esmock'
  6. const { ObjectId } = mongodb
  7. const MODULE_PATH = '../../../../app/js/SyncManager.js'
  8. const timestamp = new Date()
  9. const resyncProjectStructureUpdate = (docs, files) => ({
  10. resyncProjectStructure: { docs, files },
  11. meta: {
  12. ts: timestamp,
  13. },
  14. })
  15. const docContentSyncUpdate = (doc, content) => ({
  16. path: doc.path,
  17. doc: doc.doc,
  18. resyncDocContent: {
  19. content,
  20. },
  21. meta: {
  22. ts: timestamp,
  23. },
  24. })
  25. describe('SyncManager', function () {
  26. beforeEach(async function () {
  27. this.now = new Date()
  28. tk.freeze(this.now)
  29. this.projectId = new ObjectId().toString()
  30. this.historyId = 'mock-overleaf-id'
  31. this.syncState = { origin: { kind: 'history-resync' } }
  32. this.db = {
  33. projectHistorySyncState: {
  34. findOne: sinon.stub().resolves(this.syncState),
  35. updateOne: sinon.stub().resolves(),
  36. },
  37. }
  38. this.extendLock = sinon.stub().resolves()
  39. this.LockManager = {
  40. promises: {
  41. runWithLock: sinon.stub().callsFake(async (key, runner) => {
  42. await runner(this.extendLock)
  43. }),
  44. },
  45. }
  46. this.UpdateCompressor = {
  47. diffAsShareJsOps: sinon.stub(),
  48. }
  49. this.UpdateTranslator = {
  50. isTextUpdate: sinon.stub(),
  51. _convertPathname: sinon.stub(),
  52. }
  53. this.WebApiManager = {
  54. promises: {
  55. getHistoryId: sinon.stub(),
  56. requestResync: sinon.stub().resolves(),
  57. },
  58. }
  59. this.WebApiManager.promises.getHistoryId
  60. .withArgs(this.projectId)
  61. .resolves(this.historyId)
  62. this.ErrorRecorder = {
  63. promises: {
  64. record: sinon.stub().resolves(),
  65. recordSyncStart: sinon.stub().resolves(),
  66. },
  67. }
  68. this.RedisManager = {}
  69. this.SnapshotManager = {
  70. promises: {
  71. getLatestSnapshot: sinon.stub(),
  72. },
  73. }
  74. this.HistoryStoreManager = {
  75. getBlobStore: sinon.stub(),
  76. _getBlobHashFromString: sinon.stub().returns('random-hash'),
  77. }
  78. this.HashManager = {
  79. _getBlobHashFromString: sinon.stub(),
  80. }
  81. this.Metrics = { inc: sinon.stub() }
  82. this.Settings = {
  83. redis: {
  84. lock: {
  85. key_schema: {
  86. projectHistoryLock({ project_id: projectId }) {
  87. return `ProjectHistoryLock:${projectId}`
  88. },
  89. },
  90. },
  91. },
  92. }
  93. this.SyncManager = await esmock(MODULE_PATH, {
  94. '../../../../app/js/LockManager.js': this.LockManager,
  95. '../../../../app/js/UpdateCompressor.js': this.UpdateCompressor,
  96. '../../../../app/js/UpdateTranslator.js': this.UpdateTranslator,
  97. '../../../../app/js/mongodb.js': { ObjectId, db: this.db },
  98. '../../../../app/js/WebApiManager.js': this.WebApiManager,
  99. '../../../../app/js/ErrorRecorder.js': this.ErrorRecorder,
  100. '../../../../app/js/RedisManager.js': this.RedisManager,
  101. '../../../../app/js/SnapshotManager.js': this.SnapshotManager,
  102. '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager,
  103. '../../../../app/js/HashManager.js': this.HashManager,
  104. '@overleaf/metrics': this.Metrics,
  105. '@overleaf/settings': this.Settings,
  106. })
  107. })
  108. afterEach(function () {
  109. tk.reset()
  110. })
  111. describe('startResync', function () {
  112. describe('if a sync is not in progress', function () {
  113. beforeEach(async function () {
  114. this.db.projectHistorySyncState.findOne.resolves({})
  115. await this.SyncManager.promises.startResync(this.projectId)
  116. })
  117. it('takes the project lock', function () {
  118. expect(this.LockManager.promises.runWithLock).to.have.been.calledWith(
  119. `ProjectHistoryLock:${this.projectId}`
  120. )
  121. })
  122. it('gets the sync state from mongo', function () {
  123. expect(this.db.projectHistorySyncState.findOne).to.have.been.calledWith(
  124. { project_id: new ObjectId(this.projectId) }
  125. )
  126. })
  127. it('requests a resync from web', function () {
  128. expect(
  129. this.WebApiManager.promises.requestResync
  130. ).to.have.been.calledWith(this.projectId)
  131. })
  132. it('sets the sync state in mongo and prevents it expiring', function () {
  133. expect(
  134. this.db.projectHistorySyncState.updateOne
  135. ).to.have.been.calledWith(
  136. {
  137. project_id: new ObjectId(this.projectId),
  138. },
  139. sinon.match({
  140. $set: {
  141. resyncProjectStructure: true,
  142. resyncDocContents: [],
  143. origin: { kind: 'history-resync' },
  144. },
  145. $currentDate: { lastUpdated: true },
  146. $inc: { resyncCount: 1 },
  147. $unset: { expiresAt: true },
  148. }),
  149. {
  150. upsert: true,
  151. }
  152. )
  153. })
  154. })
  155. describe('if project structure sync is in progress', function () {
  156. beforeEach(function () {
  157. const syncState = { resyncProjectStructure: true }
  158. this.db.projectHistorySyncState.findOne.resolves(syncState)
  159. })
  160. it('returns an error if already syncing', async function () {
  161. await expect(
  162. this.SyncManager.promises.startResync(this.projectId)
  163. ).to.be.rejectedWith('sync ongoing')
  164. })
  165. })
  166. describe('if doc content sync in is progress', function () {
  167. beforeEach(async function () {
  168. const syncState = { resyncDocContents: ['/foo.tex'] }
  169. this.db.projectHistorySyncState.findOne.resolves(syncState)
  170. })
  171. it('returns an error if already syncing', async function () {
  172. await expect(
  173. this.SyncManager.promises.startResync(this.projectId)
  174. ).to.be.rejectedWith('sync ongoing')
  175. })
  176. })
  177. })
  178. describe('setResyncState', function () {
  179. describe('when the sync is starting', function () {
  180. beforeEach(function () {
  181. this.syncState = {
  182. toRaw() {
  183. return {
  184. resyncProjectStructure: true,
  185. resyncDocContents: [],
  186. origin: { kind: 'history-resync' },
  187. }
  188. },
  189. isSyncOngoing: sinon.stub().returns(true),
  190. }
  191. })
  192. it('sets the sync state in mongo and prevents it expiring', async function () {
  193. // SyncState is a private class of SyncManager
  194. // we know the interface however:
  195. await this.SyncManager.promises.setResyncState(
  196. this.projectId,
  197. this.syncState
  198. )
  199. expect(
  200. this.db.projectHistorySyncState.updateOne
  201. ).to.have.been.calledWith(
  202. { project_id: new ObjectId(this.projectId) },
  203. sinon.match({
  204. $set: this.syncState.toRaw(),
  205. $currentDate: { lastUpdated: true },
  206. $inc: { resyncCount: 1 },
  207. $unset: { expiresAt: true },
  208. }),
  209. { upsert: true }
  210. )
  211. })
  212. })
  213. describe('when the sync is ending', function () {
  214. beforeEach(function () {
  215. this.syncState = {
  216. toRaw() {
  217. return {
  218. resyncProjectStructure: false,
  219. resyncDocContents: [],
  220. origin: { kind: 'history-resync' },
  221. }
  222. },
  223. isSyncOngoing: sinon.stub().returns(false),
  224. }
  225. })
  226. it('sets the sync state entry in mongo to expire', async function () {
  227. await this.SyncManager.promises.setResyncState(
  228. this.projectId,
  229. this.syncState
  230. )
  231. expect(
  232. this.db.projectHistorySyncState.updateOne
  233. ).to.have.been.calledWith(
  234. { project_id: new ObjectId(this.projectId) },
  235. sinon.match({
  236. $set: {
  237. resyncProjectStructure: false,
  238. resyncDocContents: [],
  239. origin: { kind: 'history-resync' },
  240. expiresAt: new Date(this.now.getTime() + 90 * 24 * 3600 * 1000),
  241. },
  242. $currentDate: { lastUpdated: true },
  243. }),
  244. { upsert: true }
  245. )
  246. })
  247. })
  248. describe('when the new sync state is null', function () {
  249. it('does not update the sync state in mongo', async function () {
  250. // SyncState is a private class of SyncManager
  251. // we know the interface however:
  252. await this.SyncManager.promises.setResyncState(this.projectId, null)
  253. expect(this.db.projectHistorySyncState.updateOne).to.not.have.been
  254. .called
  255. })
  256. })
  257. })
  258. describe('skipUpdatesDuringSync', function () {
  259. describe('if a sync is not in progress', function () {
  260. beforeEach(async function () {
  261. this.db.projectHistorySyncState.findOne.resolves({})
  262. this.updates = ['some', 'mock', 'updates']
  263. this.result = await this.SyncManager.promises.skipUpdatesDuringSync(
  264. this.projectId,
  265. this.updates
  266. )
  267. })
  268. it('returns all updates', function () {
  269. expect(this.result.updates).to.deep.equal(this.updates)
  270. })
  271. it('should not return any newSyncState', function () {
  272. expect(this.result.syncState).to.be.null
  273. })
  274. })
  275. describe('if a sync in is progress', function () {
  276. beforeEach(function () {
  277. this.renameUpdate = {
  278. pathname: 'old.tex',
  279. newPathname: 'new.tex',
  280. }
  281. this.projectStructureSyncUpdate = {
  282. resyncProjectStructure: {
  283. docs: [{ path: 'new.tex' }],
  284. files: [],
  285. },
  286. }
  287. this.textUpdate = {
  288. doc: new ObjectId(),
  289. op: [{ i: 'a', p: 4 }],
  290. meta: {
  291. pathname: 'new.tex',
  292. doc_length: 4,
  293. },
  294. }
  295. this.docContentSyncUpdate = {
  296. path: 'new.tex',
  297. resyncDocContent: {
  298. content: 'a',
  299. },
  300. }
  301. this.UpdateTranslator.isTextUpdate
  302. .withArgs(this.renameUpdate)
  303. .returns(false)
  304. this.UpdateTranslator.isTextUpdate
  305. .withArgs(this.projectStructureSyncUpdate)
  306. .returns(false)
  307. this.UpdateTranslator.isTextUpdate
  308. .withArgs(this.docContentSyncUpdate)
  309. .returns(false)
  310. this.UpdateTranslator.isTextUpdate
  311. .withArgs(this.textUpdate)
  312. .returns(true)
  313. const syncState = {
  314. resyncProjectStructure: true,
  315. resyncDocContents: [],
  316. origin: { kind: 'history-resync' },
  317. }
  318. this.db.projectHistorySyncState.findOne.resolves(syncState)
  319. })
  320. it('remove updates before a project structure sync update', async function () {
  321. const updates = [
  322. this.renameUpdate,
  323. this.textUpdate,
  324. this.projectStructureSyncUpdate,
  325. ]
  326. const { updates: filteredUpdates, syncState } =
  327. await this.SyncManager.promises.skipUpdatesDuringSync(
  328. this.projectId,
  329. updates
  330. )
  331. expect(filteredUpdates).to.deep.equal([this.projectStructureSyncUpdate])
  332. expect(syncState.toRaw()).to.deep.equal({
  333. resyncProjectStructure: false,
  334. resyncDocContents: ['new.tex'],
  335. origin: { kind: 'history-resync' },
  336. })
  337. })
  338. it('allow project structure updates after project structure sync update', async function () {
  339. const updates = [this.projectStructureSyncUpdate, this.renameUpdate]
  340. const { updates: filteredUpdates, syncState } =
  341. await this.SyncManager.promises.skipUpdatesDuringSync(
  342. this.projectId,
  343. updates
  344. )
  345. expect(filteredUpdates).to.deep.equal([
  346. this.projectStructureSyncUpdate,
  347. this.renameUpdate,
  348. ])
  349. expect(syncState.toRaw()).to.deep.equal({
  350. resyncProjectStructure: false,
  351. resyncDocContents: ['new.tex'],
  352. origin: { kind: 'history-resync' },
  353. })
  354. })
  355. it('remove text updates for a doc before doc sync update', async function () {
  356. const updates = [
  357. this.projectStructureSyncUpdate,
  358. this.textUpdate,
  359. this.docContentSyncUpdate,
  360. ]
  361. const { updates: filteredUpdates, syncState } =
  362. await this.SyncManager.promises.skipUpdatesDuringSync(
  363. this.projectId,
  364. updates
  365. )
  366. expect(filteredUpdates).to.deep.equal([
  367. this.projectStructureSyncUpdate,
  368. this.docContentSyncUpdate,
  369. ])
  370. expect(syncState.toRaw()).to.deep.equal({
  371. resyncProjectStructure: false,
  372. resyncDocContents: [],
  373. origin: { kind: 'history-resync' },
  374. })
  375. })
  376. it('allow text updates for a doc after doc sync update', async function () {
  377. const updates = [
  378. this.projectStructureSyncUpdate,
  379. this.docContentSyncUpdate,
  380. this.textUpdate,
  381. ]
  382. const { updates: filteredUpdates, syncState } =
  383. await this.SyncManager.promises.skipUpdatesDuringSync(
  384. this.projectId,
  385. updates
  386. )
  387. expect(filteredUpdates).to.deep.equal([
  388. this.projectStructureSyncUpdate,
  389. this.docContentSyncUpdate,
  390. this.textUpdate,
  391. ])
  392. expect(syncState.toRaw()).to.deep.equal({
  393. resyncProjectStructure: false,
  394. resyncDocContents: [],
  395. origin: { kind: 'history-resync' },
  396. })
  397. })
  398. })
  399. })
  400. describe('expandSyncUpdates', function () {
  401. beforeEach(function () {
  402. this.persistedDoc = {
  403. doc: { data: { hash: 'abcdef' } },
  404. path: 'main.tex',
  405. content: 'asdf',
  406. }
  407. this.persistedFile = {
  408. file: { data: { hash: '123456789a' } },
  409. path: '1.png',
  410. }
  411. this.fileMap = {
  412. 'main.tex': {
  413. isEditable: sinon.stub().returns(true),
  414. content: this.persistedDoc.content,
  415. },
  416. '1.png': {
  417. isEditable: sinon.stub().returns(false),
  418. data: { hash: this.persistedFile.file.data.hash },
  419. },
  420. }
  421. this.UpdateTranslator._convertPathname
  422. .withArgs('main.tex')
  423. .returns('main.tex')
  424. this.UpdateTranslator._convertPathname
  425. .withArgs('/main.tex')
  426. .returns('main.tex')
  427. this.UpdateTranslator._convertPathname
  428. .withArgs('another.tex')
  429. .returns('another.tex')
  430. this.UpdateTranslator._convertPathname.withArgs('1.png').returns('1.png')
  431. this.UpdateTranslator._convertPathname.withArgs('2.png').returns('2.png')
  432. this.SnapshotManager.promises.getLatestSnapshot.resolves(this.fileMap)
  433. })
  434. it('returns updates if no sync updates are queued', async function () {
  435. const updates = ['some', 'mock', 'updates']
  436. const expandedUpdates = await this.SyncManager.promises.expandSyncUpdates(
  437. this.projectId,
  438. this.historyId,
  439. updates,
  440. this.extendLock
  441. )
  442. expect(expandedUpdates).to.equal(updates)
  443. expect(this.SnapshotManager.promises.getLatestSnapshot).to.not.have.been
  444. .called
  445. expect(this.extendLock).to.not.have.been.called
  446. })
  447. describe('expanding project structure sync updates', function () {
  448. it('queues nothing for expected docs and files', async function () {
  449. const updates = [
  450. resyncProjectStructureUpdate(
  451. [this.persistedDoc],
  452. [this.persistedFile]
  453. ),
  454. ]
  455. const expandedUpdates =
  456. await this.SyncManager.promises.expandSyncUpdates(
  457. this.projectId,
  458. this.historyId,
  459. updates,
  460. this.extendLock
  461. )
  462. expect(expandedUpdates).to.deep.equal([])
  463. expect(this.extendLock).to.have.been.called
  464. })
  465. it('queues file removes for unexpected files', async function () {
  466. const updates = [resyncProjectStructureUpdate([this.persistedDoc], [])]
  467. const expandedUpdates =
  468. await this.SyncManager.promises.expandSyncUpdates(
  469. this.projectId,
  470. this.historyId,
  471. updates,
  472. this.extendLock
  473. )
  474. expect(expandedUpdates).to.deep.equal([
  475. {
  476. pathname: this.persistedFile.path,
  477. new_pathname: '',
  478. meta: {
  479. resync: true,
  480. ts: timestamp,
  481. origin: { kind: 'history-resync' },
  482. },
  483. },
  484. ])
  485. expect(this.extendLock).to.have.been.called
  486. })
  487. it('queues doc removes for unexpected docs', async function () {
  488. const updates = [resyncProjectStructureUpdate([], [this.persistedFile])]
  489. const expandedUpdates =
  490. await this.SyncManager.promises.expandSyncUpdates(
  491. this.projectId,
  492. this.historyId,
  493. updates,
  494. this.extendLock
  495. )
  496. expect(expandedUpdates).to.deep.equal([
  497. {
  498. pathname: this.persistedDoc.path,
  499. new_pathname: '',
  500. meta: {
  501. resync: true,
  502. ts: timestamp,
  503. origin: { kind: 'history-resync' },
  504. },
  505. },
  506. ])
  507. expect(this.extendLock).to.have.been.called
  508. })
  509. it('queues file additions for missing files', async function () {
  510. const newFile = {
  511. path: '2.png',
  512. file: {},
  513. url: 'filestore/2.png',
  514. }
  515. const updates = [
  516. resyncProjectStructureUpdate(
  517. [this.persistedDoc],
  518. [this.persistedFile, newFile]
  519. ),
  520. ]
  521. const expandedUpdates =
  522. await this.SyncManager.promises.expandSyncUpdates(
  523. this.projectId,
  524. this.historyId,
  525. updates,
  526. this.extendLock
  527. )
  528. expect(expandedUpdates).to.deep.equal([
  529. {
  530. pathname: newFile.path,
  531. file: newFile.file,
  532. url: newFile.url,
  533. meta: {
  534. resync: true,
  535. ts: timestamp,
  536. origin: { kind: 'history-resync' },
  537. },
  538. },
  539. ])
  540. expect(this.extendLock).to.have.been.called
  541. })
  542. it('queues blank doc additions for missing docs', async function () {
  543. const newDoc = {
  544. path: 'another.tex',
  545. doc: new ObjectId().toString(),
  546. }
  547. const updates = [
  548. resyncProjectStructureUpdate(
  549. [this.persistedDoc, newDoc],
  550. [this.persistedFile]
  551. ),
  552. ]
  553. const expandedUpdates =
  554. await this.SyncManager.promises.expandSyncUpdates(
  555. this.projectId,
  556. this.historyId,
  557. updates,
  558. this.extendLock
  559. )
  560. expect(expandedUpdates).to.deep.equal([
  561. {
  562. pathname: newDoc.path,
  563. doc: newDoc.doc,
  564. docLines: '',
  565. meta: {
  566. resync: true,
  567. ts: timestamp,
  568. origin: { kind: 'history-resync' },
  569. },
  570. },
  571. ])
  572. expect(this.extendLock).to.have.been.called
  573. })
  574. it('removes and re-adds files if whether they are binary differs', async function () {
  575. const fileWichWasADoc = {
  576. path: this.persistedDoc.path,
  577. url: 'filestore/2.png',
  578. _hash: 'other-hash',
  579. }
  580. const updates = [
  581. resyncProjectStructureUpdate(
  582. [],
  583. [fileWichWasADoc, this.persistedFile]
  584. ),
  585. ]
  586. const expandedUpdates =
  587. await this.SyncManager.promises.expandSyncUpdates(
  588. this.projectId,
  589. this.historyId,
  590. updates,
  591. this.extendLock
  592. )
  593. expect(expandedUpdates).to.deep.equal([
  594. {
  595. pathname: fileWichWasADoc.path,
  596. new_pathname: '',
  597. meta: {
  598. resync: true,
  599. ts: timestamp,
  600. origin: { kind: 'history-resync' },
  601. },
  602. },
  603. {
  604. pathname: fileWichWasADoc.path,
  605. file: fileWichWasADoc.file,
  606. url: fileWichWasADoc.url,
  607. meta: {
  608. resync: true,
  609. ts: timestamp,
  610. origin: { kind: 'history-resync' },
  611. },
  612. },
  613. ])
  614. expect(this.extendLock).to.have.been.called
  615. })
  616. it("does not remove and re-add files if the expected file doesn't have a hash", async function () {
  617. const fileWichWasADoc = {
  618. path: this.persistedDoc.path,
  619. url: 'filestore/2.png',
  620. }
  621. const updates = [
  622. resyncProjectStructureUpdate(
  623. [],
  624. [fileWichWasADoc, this.persistedFile]
  625. ),
  626. ]
  627. const expandedUpdates =
  628. await this.SyncManager.promises.expandSyncUpdates(
  629. this.projectId,
  630. this.historyId,
  631. updates,
  632. this.extendLock
  633. )
  634. expect(expandedUpdates).to.deep.equal([])
  635. expect(this.extendLock).to.have.been.called
  636. })
  637. it('does not remove and re-add editable files if there is a binary file with same hash', async function () {
  638. const binaryFile = {
  639. file: Object().toString(),
  640. // The paths in the resyncProjectStructureUpdate must have a leading slash ('/')
  641. // The other unit tests in this file are incorrectly missing the leading slash.
  642. // The leading slash is present in web where the paths are created with
  643. // ProjectEntityHandler.getAllEntitiesFromProject in ProjectEntityUpdateHandler.resyncProjectHistory.
  644. path: '/' + this.persistedDoc.path,
  645. url: 'filestore/12345',
  646. _hash: 'abcdef',
  647. }
  648. this.fileMap['main.tex'].data = { hash: 'abcdef' }
  649. const updates = [
  650. resyncProjectStructureUpdate([], [binaryFile, this.persistedFile]),
  651. ]
  652. const expandedUpdates =
  653. await this.SyncManager.promises.expandSyncUpdates(
  654. this.projectId,
  655. this.historyId,
  656. updates,
  657. this.extendLock
  658. )
  659. expect(expandedUpdates).to.deep.equal([])
  660. expect(this.extendLock).to.have.been.called
  661. })
  662. it('removes and re-adds binary files if they do not have same hash', async function () {
  663. const persistedFileWithNewContent = {
  664. _hash: 'anotherhashvalue',
  665. hello: 'world',
  666. path: '1.png',
  667. url: 'filestore-new-url',
  668. }
  669. const updates = [
  670. resyncProjectStructureUpdate(
  671. [this.persistedDoc],
  672. [persistedFileWithNewContent]
  673. ),
  674. ]
  675. const expandedUpdates =
  676. await this.SyncManager.promises.expandSyncUpdates(
  677. this.projectId,
  678. this.historyId,
  679. updates,
  680. this.extendLock
  681. )
  682. expect(expandedUpdates).to.deep.equal([
  683. {
  684. pathname: persistedFileWithNewContent.path,
  685. new_pathname: '',
  686. meta: {
  687. resync: true,
  688. ts: timestamp,
  689. origin: { kind: 'history-resync' },
  690. },
  691. },
  692. {
  693. pathname: persistedFileWithNewContent.path,
  694. file: persistedFileWithNewContent.file,
  695. url: persistedFileWithNewContent.url,
  696. meta: {
  697. resync: true,
  698. ts: timestamp,
  699. origin: { kind: 'history-resync' },
  700. },
  701. },
  702. ])
  703. expect(this.extendLock).to.have.been.called
  704. })
  705. it('preserves other updates', async function () {
  706. const update = 'mock-update'
  707. const updates = [
  708. update,
  709. resyncProjectStructureUpdate(
  710. [this.persistedDoc],
  711. [this.persistedFile]
  712. ),
  713. ]
  714. const expandedUpdates =
  715. await this.SyncManager.promises.expandSyncUpdates(
  716. this.projectId,
  717. this.historyId,
  718. updates,
  719. this.extendLock
  720. )
  721. expect(expandedUpdates).to.deep.equal([update])
  722. expect(this.extendLock).to.have.been.called
  723. })
  724. })
  725. describe('expanding doc contents sync updates', function () {
  726. it('returns errors from diffAsShareJsOps', async function () {
  727. const diffError = new Error('test')
  728. this.UpdateCompressor.diffAsShareJsOps.throws(diffError)
  729. const updates = [
  730. resyncProjectStructureUpdate(
  731. [this.persistedDoc],
  732. [this.persistedFile]
  733. ),
  734. docContentSyncUpdate(this.persistedDoc, this.persistedDoc.content),
  735. ]
  736. await expect(
  737. this.SyncManager.promises.expandSyncUpdates(
  738. this.projectId,
  739. this.historyId,
  740. updates,
  741. this.extendLock
  742. )
  743. ).to.be.rejectedWith(diffError)
  744. expect(this.extendLock).to.have.been.called
  745. })
  746. it('handles an update for a file that is missing from the snapshot', async function () {
  747. const updates = [docContentSyncUpdate('not-in-snapshot.txt', 'test')]
  748. await expect(
  749. this.SyncManager.promises.expandSyncUpdates(
  750. this.projectId,
  751. this.historyId,
  752. updates,
  753. this.extendLock
  754. )
  755. ).to.be.rejectedWith('unrecognised file: not in snapshot')
  756. })
  757. it('queues nothing for in docs whose contents is in sync', async function () {
  758. const updates = [
  759. resyncProjectStructureUpdate(
  760. [this.persistedDoc],
  761. [this.persistedFile]
  762. ),
  763. docContentSyncUpdate(this.persistedDoc, this.persistedDoc.content),
  764. ]
  765. this.UpdateCompressor.diffAsShareJsOps.returns([])
  766. const expandedUpdates =
  767. await this.SyncManager.promises.expandSyncUpdates(
  768. this.projectId,
  769. this.historyId,
  770. updates,
  771. this.extendLock
  772. )
  773. expect(expandedUpdates).to.deep.equal([])
  774. expect(this.extendLock).to.have.been.called
  775. })
  776. it('queues text updates for docs whose contents is out of sync', async function () {
  777. const updates = [
  778. resyncProjectStructureUpdate(
  779. [this.persistedDoc],
  780. [this.persistedFile]
  781. ),
  782. docContentSyncUpdate(this.persistedDoc, 'a'),
  783. ]
  784. this.UpdateCompressor.diffAsShareJsOps.returns([{ d: 'sdf', p: 1 }])
  785. const expandedUpdates =
  786. await this.SyncManager.promises.expandSyncUpdates(
  787. this.projectId,
  788. this.historyId,
  789. updates,
  790. this.extendLock
  791. )
  792. expect(expandedUpdates).to.deep.equal([
  793. {
  794. doc: this.persistedDoc.doc,
  795. op: [{ d: 'sdf', p: 1 }],
  796. meta: {
  797. pathname: this.persistedDoc.path,
  798. doc_length: 4,
  799. resync: true,
  800. ts: timestamp,
  801. origin: { kind: 'history-resync' },
  802. },
  803. },
  804. ])
  805. expect(this.extendLock).to.have.been.called
  806. })
  807. it('queues text updates for docs created by project structure sync', async function () {
  808. this.UpdateCompressor.diffAsShareJsOps.returns([{ i: 'a', p: 0 }])
  809. const newDoc = {
  810. path: 'another.tex',
  811. doc: new ObjectId().toString(),
  812. content: 'a',
  813. }
  814. const updates = [
  815. resyncProjectStructureUpdate(
  816. [this.persistedDoc, newDoc],
  817. [this.persistedFile]
  818. ),
  819. docContentSyncUpdate(newDoc, newDoc.content),
  820. ]
  821. const expandedUpdates =
  822. await this.SyncManager.promises.expandSyncUpdates(
  823. this.projectId,
  824. this.historyId,
  825. updates,
  826. this.extendLock
  827. )
  828. expect(expandedUpdates).to.deep.equal([
  829. {
  830. pathname: newDoc.path,
  831. doc: newDoc.doc,
  832. docLines: '',
  833. meta: {
  834. resync: true,
  835. ts: timestamp,
  836. origin: { kind: 'history-resync' },
  837. },
  838. },
  839. {
  840. doc: newDoc.doc,
  841. op: [{ i: 'a', p: 0 }],
  842. meta: {
  843. pathname: newDoc.path,
  844. doc_length: 0,
  845. resync: true,
  846. ts: timestamp,
  847. origin: { kind: 'history-resync' },
  848. },
  849. },
  850. ])
  851. expect(this.extendLock).to.have.been.called
  852. })
  853. it('skips text updates for docs when hashes match', async function () {
  854. this.fileMap['main.tex'].getHash = sinon.stub().returns('special-hash')
  855. this.HashManager._getBlobHashFromString.returns('special-hash')
  856. const updates = [
  857. resyncProjectStructureUpdate(
  858. [this.persistedDoc],
  859. [this.persistedFile]
  860. ),
  861. docContentSyncUpdate(this.persistedDoc, 'hello'),
  862. ]
  863. const expandedUpdates =
  864. await this.SyncManager.promises.expandSyncUpdates(
  865. this.projectId,
  866. this.historyId,
  867. updates,
  868. this.extendLock
  869. )
  870. expect(expandedUpdates).to.deep.equal([])
  871. expect(this.extendLock).to.have.been.called
  872. })
  873. it('computes text updates for docs when hashes differ', async function () {
  874. this.fileMap['main.tex'].getHash = sinon.stub().returns('first-hash')
  875. this.HashManager._getBlobHashFromString.returns('second-hash')
  876. this.UpdateCompressor.diffAsShareJsOps.returns([
  877. { i: 'test diff', p: 0 },
  878. ])
  879. const updates = [
  880. resyncProjectStructureUpdate(
  881. [this.persistedDoc],
  882. [this.persistedFile]
  883. ),
  884. docContentSyncUpdate(this.persistedDoc, 'hello'),
  885. ]
  886. const expandedUpdates =
  887. await this.SyncManager.promises.expandSyncUpdates(
  888. this.projectId,
  889. this.historyId,
  890. updates,
  891. this.extendLock
  892. )
  893. expect(expandedUpdates).to.deep.equal([
  894. {
  895. doc: this.persistedDoc.doc,
  896. op: [{ i: 'test diff', p: 0 }],
  897. meta: {
  898. pathname: this.persistedDoc.path,
  899. doc_length: 4,
  900. resync: true,
  901. ts: timestamp,
  902. origin: { kind: 'history-resync' },
  903. },
  904. },
  905. ])
  906. expect(this.extendLock).to.have.been.called
  907. })
  908. describe('for docs whose contents is out of sync', function () {
  909. beforeEach(async function () {
  910. const updates = [
  911. resyncProjectStructureUpdate(
  912. [this.persistedDoc],
  913. [this.persistedFile]
  914. ),
  915. docContentSyncUpdate(this.persistedDoc, 'a'),
  916. ]
  917. const file = { getContent: sinon.stub().returns('stored content') }
  918. this.fileMap['main.tex'].load = sinon.stub().resolves(file)
  919. this.UpdateCompressor.diffAsShareJsOps.returns([{ d: 'sdf', p: 1 }])
  920. this.expandedUpdates =
  921. await this.SyncManager.promises.expandSyncUpdates(
  922. this.projectId,
  923. this.historyId,
  924. updates,
  925. this.extendLock
  926. )
  927. })
  928. it('loads content from the history service when needed', function () {
  929. expect(this.expandedUpdates).to.deep.equal([
  930. {
  931. doc: this.persistedDoc.doc,
  932. op: [{ d: 'sdf', p: 1 }],
  933. meta: {
  934. pathname: this.persistedDoc.path,
  935. doc_length: 'stored content'.length,
  936. resync: true,
  937. ts: timestamp,
  938. origin: { kind: 'history-resync' },
  939. },
  940. },
  941. ])
  942. expect(this.extendLock).to.have.been.called
  943. })
  944. })
  945. })
  946. })
  947. })