SyncManagerTests.js 33 KB

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