SyncManagerTests.js 33 KB

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