SendingUpdatesTests.js 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709
  1. import { expect } from 'chai'
  2. import Settings from '@overleaf/settings'
  3. import assert from 'node:assert'
  4. import crypto from 'node:crypto'
  5. import mongodb from 'mongodb-legacy'
  6. import nock from 'nock'
  7. import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
  8. import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
  9. const { ObjectId } = mongodb
  10. const MockHistoryStore = () => nock('http://127.0.0.1:3100')
  11. const MockFileStore = () => nock('http://127.0.0.1:3009')
  12. const MockWeb = () => nock('http://127.0.0.1:3000')
  13. // Some helper methods to make the tests more compact
  14. function slTextUpdate(historyId, doc, userId, v, ts, op) {
  15. return {
  16. projectHistoryId: historyId,
  17. doc: doc.id,
  18. op,
  19. v,
  20. meta: {
  21. user_id: userId,
  22. ts: ts.getTime(),
  23. pathname: doc.pathname,
  24. doc_length: doc.length,
  25. },
  26. }
  27. }
  28. function slAddDocUpdate(historyId, doc, userId, ts, docLines, ranges = {}) {
  29. return {
  30. projectHistoryId: historyId,
  31. pathname: doc.pathname,
  32. ranges,
  33. docLines,
  34. doc: doc.id,
  35. meta: { user_id: userId, ts: ts.getTime() },
  36. }
  37. }
  38. function slAddDocUpdateWithVersion(
  39. historyId,
  40. doc,
  41. userId,
  42. ts,
  43. docLines,
  44. projectVersion,
  45. ranges = {}
  46. ) {
  47. const result = slAddDocUpdate(historyId, doc, userId, ts, docLines, ranges)
  48. result.version = projectVersion
  49. return result
  50. }
  51. function slAddFileUpdate(historyId, file, userId, ts, projectId) {
  52. return {
  53. projectHistoryId: historyId,
  54. pathname: file.pathname,
  55. url: `http://127.0.0.1:3009/project/${projectId}/file/${file.id}`,
  56. file: file.id,
  57. ranges: undefined,
  58. meta: { user_id: userId, ts: ts.getTime() },
  59. }
  60. }
  61. function createdBlobFileUpdate(historyId, file, userId, ts, projectId) {
  62. return {
  63. projectHistoryId: historyId,
  64. pathname: file.pathname,
  65. createdBlob: true,
  66. url: null,
  67. file: file.id,
  68. hash: file.hash,
  69. ranges: undefined,
  70. meta: { user_id: userId, ts: ts.getTime() },
  71. }
  72. }
  73. function slRenameUpdate(historyId, doc, userId, ts, pathname, newPathname) {
  74. return {
  75. projectHistoryId: historyId,
  76. pathname,
  77. new_pathname: newPathname,
  78. doc: doc.id,
  79. meta: { user_id: userId, ts: ts.getTime() },
  80. }
  81. }
  82. function olUpdate(doc, userId, ts, operations, v) {
  83. return {
  84. v2Authors: [userId],
  85. timestamp: ts.toJSON(),
  86. authors: [],
  87. operations,
  88. v2DocVersions: {
  89. [doc.id]: {
  90. pathname: doc.pathname.replace(/^\//, ''), // Strip leading /
  91. v: v || 1,
  92. },
  93. },
  94. }
  95. }
  96. function olTextOperation(doc, textOperation) {
  97. return {
  98. pathname: doc.pathname.replace(/^\//, ''), // Strip leading /
  99. textOperation,
  100. }
  101. }
  102. function olAddCommentOperation(doc, commentId, pos, length) {
  103. return {
  104. pathname: doc.pathname.replace(/^\//, ''), // Strip leading /
  105. commentId,
  106. ranges: [{ pos, length }],
  107. }
  108. }
  109. function olTextUpdate(doc, userId, ts, textOperation, v) {
  110. return olUpdate(doc, userId, ts, [olTextOperation(doc, textOperation)], v)
  111. }
  112. function olTextUpdates(doc, userId, ts, textOperations, v) {
  113. return olUpdate(
  114. doc,
  115. userId,
  116. ts,
  117. textOperations.map(textOperation => olTextOperation(doc, textOperation)),
  118. v
  119. )
  120. }
  121. function olRenameUpdate(doc, userId, ts, pathname, newPathname) {
  122. return {
  123. v2Authors: [userId],
  124. timestamp: ts.toJSON(),
  125. authors: [],
  126. operations: [
  127. {
  128. pathname,
  129. newPathname,
  130. },
  131. ],
  132. }
  133. }
  134. function olAddDocUpdate(doc, userId, ts, fileHash, rangesHash = undefined) {
  135. const update = {
  136. v2Authors: [userId],
  137. timestamp: ts.toJSON(),
  138. authors: [],
  139. operations: [
  140. {
  141. pathname: doc.pathname.replace(/^\//, ''), // Strip leading /
  142. file: {
  143. hash: fileHash,
  144. },
  145. },
  146. ],
  147. }
  148. if (rangesHash) {
  149. update.operations[0].file.rangesHash = rangesHash
  150. }
  151. return update
  152. }
  153. function olAddDocUpdateWithVersion(
  154. doc,
  155. userId,
  156. ts,
  157. fileHash,
  158. version,
  159. rangesHash = undefined
  160. ) {
  161. const result = olAddDocUpdate(doc, userId, ts, fileHash, rangesHash)
  162. result.projectVersion = version
  163. return result
  164. }
  165. function olAddFileUpdate(file, userId, ts, fileHash) {
  166. return {
  167. v2Authors: [userId],
  168. timestamp: ts.toJSON(),
  169. authors: [],
  170. operations: [
  171. {
  172. pathname: file.pathname.replace(/^\//, ''), // Strip leading /
  173. file: {
  174. hash: fileHash,
  175. },
  176. },
  177. ],
  178. }
  179. }
  180. describe('Sending Updates', function () {
  181. const historyId = new ObjectId().toString()
  182. beforeEach(async function () {
  183. this.timestamp = new Date()
  184. await ProjectHistoryApp.ensureRunning()
  185. this.userId = new ObjectId().toString()
  186. this.projectId = new ObjectId().toString()
  187. this.docId = new ObjectId().toString()
  188. this.doc = {
  189. id: this.docId,
  190. pathname: '/main.tex',
  191. length: 5,
  192. }
  193. MockHistoryStore().post('/api/projects').reply(200, {
  194. projectId: historyId,
  195. })
  196. MockWeb()
  197. .get(`/project/${this.projectId}/details`)
  198. .reply(200, {
  199. name: 'Test Project',
  200. overleaf: {
  201. history: {
  202. id: historyId,
  203. },
  204. },
  205. })
  206. await ProjectHistoryClient.initializeProject(historyId)
  207. })
  208. afterEach(function () {
  209. nock.cleanAll()
  210. })
  211. describe('basic update types', function () {
  212. beforeEach(function () {
  213. MockHistoryStore()
  214. .get(`/api/projects/${historyId}/latest/history`)
  215. .reply(200, {
  216. chunk: {
  217. startVersion: 0,
  218. history: {
  219. snapshot: {},
  220. changes: [],
  221. },
  222. },
  223. })
  224. })
  225. it('should send add doc updates to the history store', async function () {
  226. const fileHash = '0a207c060e61f3b88eaee0a8cd0696f46fb155eb'
  227. const createBlob = MockHistoryStore()
  228. .put(`/api/projects/${historyId}/blobs/${fileHash}`, 'a\nb')
  229. .reply(201)
  230. const addFile = MockHistoryStore()
  231. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  232. expect(body).to.deep.equal([
  233. olAddDocUpdate(this.doc, this.userId, this.timestamp, fileHash),
  234. ])
  235. return true
  236. })
  237. .query({ end_version: 0 })
  238. .reply(204)
  239. await ProjectHistoryClient.pushRawUpdate(
  240. this.projectId,
  241. slAddDocUpdate(historyId, this.doc, this.userId, this.timestamp, 'a\nb')
  242. )
  243. await ProjectHistoryClient.flushProject(this.projectId)
  244. assert(
  245. createBlob.isDone(),
  246. '/api/projects/:historyId/blobs/:hash should have been called'
  247. )
  248. assert(
  249. addFile.isDone(),
  250. `/api/projects/${historyId}/changes should have been called`
  251. )
  252. })
  253. it('should send ranges to the history store', async function () {
  254. const fileHash = '49e886093b3eacbc12b99a1eb5aeaa44a6b9d90e'
  255. const rangesHash = 'fa9a429ff518bc9e5b2507a96ff0646b566eca65'
  256. const historyRanges = {
  257. trackedChanges: [
  258. {
  259. range: { pos: 4, length: 3 },
  260. tracking: {
  261. type: 'delete',
  262. userId: 'user-id-1',
  263. ts: '2024-01-01T00:00:00.000Z',
  264. },
  265. },
  266. ],
  267. comments: [
  268. {
  269. ranges: [{ pos: 0, length: 3 }],
  270. id: 'comment-id-1',
  271. },
  272. ],
  273. }
  274. // We need to set up the ranges mock first, as we will call it last..
  275. const createRangesBlob = MockHistoryStore()
  276. .put(`/api/projects/${historyId}/blobs/${rangesHash}`, historyRanges)
  277. .reply(201)
  278. const createBlob = MockHistoryStore()
  279. .put(`/api/projects/${historyId}/blobs/${fileHash}`, 'foo barbaz')
  280. .reply(201)
  281. const addFile = MockHistoryStore()
  282. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  283. expect(body).to.deep.equal([
  284. olAddDocUpdate(
  285. this.doc,
  286. this.userId,
  287. this.timestamp,
  288. fileHash,
  289. rangesHash
  290. ),
  291. ])
  292. return true
  293. })
  294. .query({ end_version: 0 })
  295. .reply(204)
  296. await ProjectHistoryClient.pushRawUpdate(
  297. this.projectId,
  298. slAddDocUpdate(
  299. historyId,
  300. this.doc,
  301. this.userId,
  302. this.timestamp,
  303. 'foo barbaz',
  304. {
  305. changes: [
  306. {
  307. op: { p: 4, d: 'bar' },
  308. metadata: {
  309. ts: 1704067200000,
  310. user_id: 'user-id-1',
  311. },
  312. },
  313. ],
  314. comments: [
  315. {
  316. op: {
  317. p: 0,
  318. c: 'foo',
  319. t: 'comment-id-1',
  320. },
  321. metadata: { resolved: false },
  322. },
  323. ],
  324. }
  325. )
  326. )
  327. await ProjectHistoryClient.flushProject(this.projectId)
  328. assert(
  329. createBlob.isDone(),
  330. '/api/projects/:historyId/blobs/:hash should have been called to create content blob'
  331. )
  332. assert(
  333. createRangesBlob.isDone(),
  334. '/api/projects/:historyId/blobs/:hash should have been called to create ranges blob'
  335. )
  336. assert(
  337. addFile.isDone(),
  338. `/api/projects/${historyId}/changes should have been called`
  339. )
  340. })
  341. it('should strip non-BMP characters in add doc updates before sending to the history store', async function () {
  342. const fileHash = '11509fe05a41f9cdc51ea081342b5a4fc7c8d0fc'
  343. const createBlob = MockHistoryStore()
  344. .put(
  345. `/api/projects/${historyId}/blobs/${fileHash}`,
  346. 'a\nb\uFFFD\uFFFDc'
  347. )
  348. .reply(201)
  349. const addFile = MockHistoryStore()
  350. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  351. expect(body).to.deep.equal([
  352. olAddDocUpdate(this.doc, this.userId, this.timestamp, fileHash),
  353. ])
  354. return true
  355. })
  356. .query({ end_version: 0 })
  357. .reply(204)
  358. await ProjectHistoryClient.pushRawUpdate(
  359. this.projectId,
  360. slAddDocUpdate(
  361. historyId,
  362. this.doc,
  363. this.userId,
  364. this.timestamp,
  365. 'a\nb\uD800\uDC00c'
  366. )
  367. )
  368. await ProjectHistoryClient.flushProject(this.projectId)
  369. assert(
  370. createBlob.isDone(),
  371. '/api/projects/:historyId/blobs/:hash should have been called'
  372. )
  373. assert(
  374. addFile.isDone(),
  375. `/api/projects/${historyId}/changes should have been called`
  376. )
  377. })
  378. it('should send text updates to the history store', async function () {
  379. const createChange = MockHistoryStore()
  380. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  381. expect(body).to.deep.equal([
  382. olTextUpdate(this.doc, this.userId, this.timestamp, [3, '\nc', 2]),
  383. ])
  384. return true
  385. })
  386. .query({ end_version: 0 })
  387. .reply(204)
  388. await ProjectHistoryClient.pushRawUpdate(
  389. this.projectId,
  390. slTextUpdate(historyId, this.doc, this.userId, 1, this.timestamp, [
  391. { p: 3, i: '\nc' },
  392. ])
  393. )
  394. await ProjectHistoryClient.flushProject(this.projectId)
  395. assert(
  396. createChange.isDone(),
  397. `/api/projects/${historyId}/changes should have been called`
  398. )
  399. })
  400. it('should send renames to the history store', async function () {
  401. const createChange = MockHistoryStore()
  402. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  403. expect(body).to.deep.equal([
  404. olRenameUpdate(
  405. this.doc,
  406. this.userId,
  407. this.timestamp,
  408. 'main.tex',
  409. 'main2.tex'
  410. ),
  411. ])
  412. return true
  413. })
  414. .query({ end_version: 0 })
  415. .reply(204)
  416. await ProjectHistoryClient.pushRawUpdate(
  417. this.projectId,
  418. slRenameUpdate(
  419. historyId,
  420. this.doc,
  421. this.userId,
  422. this.timestamp,
  423. '/main.tex',
  424. '/main2.tex'
  425. )
  426. )
  427. await ProjectHistoryClient.flushProject(this.projectId)
  428. assert(
  429. createChange.isDone(),
  430. `/api/projects/${historyId}/changes should have been called`
  431. )
  432. })
  433. it('should not get file from filestore if no url provided', async function () {
  434. const file = {
  435. id: new ObjectId().toString(),
  436. pathname: '/test.png',
  437. contents: Buffer.from([1, 2, 3]),
  438. hash: 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74',
  439. }
  440. const fileStoreRequest = MockFileStore()
  441. .get(`/project/${this.projectId}/file/${file.id}`)
  442. .reply(200, file.contents)
  443. const checkBlob = MockHistoryStore()
  444. .head(`/api/projects/${historyId}/blobs/${file.hash}`)
  445. .reply(200)
  446. const addFile = MockHistoryStore()
  447. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  448. expect(body).to.deep.equal([
  449. olAddFileUpdate(file, this.userId, this.timestamp, file.hash),
  450. ])
  451. return true
  452. })
  453. .query({ end_version: 0 })
  454. .reply(204)
  455. await ProjectHistoryClient.pushRawUpdate(
  456. this.projectId,
  457. createdBlobFileUpdate(
  458. historyId,
  459. file,
  460. this.userId,
  461. this.timestamp,
  462. this.projectId
  463. )
  464. )
  465. await ProjectHistoryClient.flushProject(this.projectId)
  466. assert(
  467. !fileStoreRequest.isDone(),
  468. 'filestore should not have been called'
  469. )
  470. assert(
  471. checkBlob.isDone(),
  472. `HEAD /api/projects/${historyId}/blobs/${file.hash} should have been called`
  473. )
  474. assert(
  475. addFile.isDone(),
  476. `/api/projects/${historyId}/latest/files should have been called`
  477. )
  478. })
  479. it('should send add file updates to the history store', async function () {
  480. const file = {
  481. id: new ObjectId().toString(),
  482. pathname: '/test.png',
  483. contents: Buffer.from([1, 2, 3]),
  484. hash: 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74',
  485. }
  486. const fileStoreRequest = MockFileStore()
  487. .get(`/project/${this.projectId}/file/${file.id}`)
  488. .reply(200, file.contents)
  489. const createBlob = MockHistoryStore()
  490. .put(`/api/projects/${historyId}/blobs/${file.hash}`, file.contents)
  491. .reply(201)
  492. const addFile = MockHistoryStore()
  493. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  494. expect(body).to.deep.equal([
  495. olAddFileUpdate(file, this.userId, this.timestamp, file.hash),
  496. ])
  497. return true
  498. })
  499. .query({ end_version: 0 })
  500. .reply(204)
  501. await ProjectHistoryClient.pushRawUpdate(
  502. this.projectId,
  503. slAddFileUpdate(
  504. historyId,
  505. file,
  506. this.userId,
  507. this.timestamp,
  508. this.projectId
  509. )
  510. )
  511. await ProjectHistoryClient.flushProject(this.projectId)
  512. assert(
  513. fileStoreRequest.isDone(),
  514. `/project/${this.projectId}/file/${file.id} should have been called`
  515. )
  516. assert(
  517. createBlob.isDone(),
  518. `/api/projects/${historyId}/latest/files should have been called`
  519. )
  520. assert(
  521. addFile.isDone(),
  522. `/api/projects/${historyId}/latest/files should have been called`
  523. )
  524. })
  525. it('should send a stub to the history store when the file is large', async function () {
  526. const fileContents = Buffer.alloc(Settings.maxFileSizeInBytes + 1, 'X')
  527. const fileSize = Buffer.byteLength(fileContents)
  528. const fileHash = crypto
  529. .createHash('sha1')
  530. .update('blob ' + fileSize + '\x00')
  531. .update(fileContents, 'utf8')
  532. .digest('hex')
  533. const file = {
  534. id: new ObjectId().toString(),
  535. pathname: '/large.png',
  536. contents: fileContents,
  537. hash: fileHash,
  538. }
  539. const stubContents = [
  540. 'FileTooLargeError v1',
  541. 'File too large to be stored in history service',
  542. `id project-${this.projectId}-file-${file.id}`,
  543. `size ${fileSize} bytes`,
  544. `hash ${fileHash}`,
  545. '\0', // null byte to make this a binary file
  546. ].join('\n')
  547. const stubHash = crypto
  548. .createHash('sha1')
  549. .update('blob ' + Buffer.byteLength(stubContents) + '\x00')
  550. .update(stubContents, 'utf8')
  551. .digest('hex')
  552. const stub = {
  553. id: file.id,
  554. pathname: file.pathname,
  555. contents: stubContents,
  556. hash: stubHash,
  557. }
  558. const fileStoreRequest = MockFileStore()
  559. .get(`/project/${this.projectId}/file/${file.id}`)
  560. .reply(200, file.contents)
  561. const createBlob = MockHistoryStore()
  562. .put(`/api/projects/${historyId}/blobs/${stub.hash}`, stub.contents)
  563. .reply(201)
  564. const addFile = MockHistoryStore()
  565. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  566. expect(body).to.deep.equal([
  567. olAddFileUpdate(stub, this.userId, this.timestamp, stub.hash),
  568. ])
  569. return true
  570. })
  571. .query({ end_version: 0 })
  572. .reply(204)
  573. await ProjectHistoryClient.pushRawUpdate(
  574. this.projectId,
  575. slAddFileUpdate(
  576. historyId,
  577. file,
  578. this.userId,
  579. this.timestamp,
  580. this.projectId
  581. )
  582. )
  583. await ProjectHistoryClient.flushProject(this.projectId)
  584. assert(
  585. addFile.isDone(),
  586. `/api/projects/${historyId}/latest/files should have been called`
  587. )
  588. assert(
  589. createBlob.isDone(),
  590. `/api/projects/${historyId}/latest/files should have been called`
  591. )
  592. assert(
  593. fileStoreRequest.isDone(),
  594. `/project/${this.projectId}/file/${file.id} should have been called`
  595. )
  596. })
  597. it('should handle comment ops', async function () {
  598. const createChange = MockHistoryStore()
  599. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  600. expect(body).to.deep.equal([
  601. olUpdate(this.doc, this.userId, this.timestamp, [
  602. olTextOperation(this.doc, [3, '\nc', 2]),
  603. olAddCommentOperation(this.doc, 'comment-id-1', 3, 2),
  604. ]),
  605. olUpdate(
  606. this.doc,
  607. this.userId,
  608. this.timestamp,
  609. [olAddCommentOperation(this.doc, 'comment-id-2', 2, 1)],
  610. 2
  611. ),
  612. ])
  613. return true
  614. })
  615. .query({ end_version: 0 })
  616. .reply(204)
  617. await ProjectHistoryClient.pushRawUpdate(
  618. this.projectId,
  619. slTextUpdate(historyId, this.doc, this.userId, 1, this.timestamp, [
  620. { p: 3, i: '\nc' },
  621. { p: 3, c: '\nc', t: 'comment-id-1' },
  622. ])
  623. )
  624. await ProjectHistoryClient.pushRawUpdate(
  625. this.projectId,
  626. slTextUpdate(historyId, this.doc, this.userId, 2, this.timestamp, [
  627. { p: 2, c: 'b', t: 'comment-id-2' },
  628. ])
  629. )
  630. await ProjectHistoryClient.flushProject(this.projectId)
  631. assert(
  632. createChange.isDone(),
  633. `/api/projects/${historyId}/changes should have been called`
  634. )
  635. })
  636. it('should be able to process lots of updates in batches', async function () {
  637. const BATCH_SIZE = 500
  638. const createFirstChangeBatch = MockHistoryStore()
  639. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  640. expect(body).to.deep.equal([
  641. olTextUpdate(
  642. this.doc,
  643. this.userId,
  644. this.timestamp,
  645. ['a'.repeat(BATCH_SIZE), 6],
  646. BATCH_SIZE - 1
  647. ),
  648. ])
  649. return true
  650. })
  651. .query({ end_version: 0 })
  652. .reply(204)
  653. const createSecondChangeBatch = MockHistoryStore()
  654. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  655. expect(body).to.deep.equal([
  656. olTextUpdate(
  657. this.doc,
  658. this.userId,
  659. this.timestamp,
  660. ['a'.repeat(50), BATCH_SIZE + 6],
  661. BATCH_SIZE - 1 + 50
  662. ),
  663. ])
  664. return true
  665. })
  666. .query({ end_version: 500 })
  667. .reply(204)
  668. // these need mocking again for the second batch
  669. MockHistoryStore()
  670. .get(`/api/projects/${historyId}/latest/history`)
  671. .reply(200, {
  672. chunk: {
  673. startVersion: BATCH_SIZE,
  674. history: {
  675. snapshot: {},
  676. changes: [],
  677. },
  678. },
  679. })
  680. MockWeb()
  681. .get(`/project/${this.projectId}/details`)
  682. .reply(200, {
  683. name: 'Test Project',
  684. overleaf: {
  685. history: {
  686. id: historyId,
  687. },
  688. },
  689. })
  690. // Push updates in a loop instead of using async.times
  691. for (let n = 0; n < BATCH_SIZE + 50; n++) {
  692. this.doc.length += 1
  693. await ProjectHistoryClient.pushRawUpdate(
  694. this.projectId,
  695. slTextUpdate(historyId, this.doc, this.userId, n, this.timestamp, [
  696. { p: 0, i: 'a' },
  697. ])
  698. )
  699. }
  700. await ProjectHistoryClient.flushProject(this.projectId)
  701. assert(
  702. createFirstChangeBatch.isDone(),
  703. `/api/projects/${historyId}/changes should have been called for the first batch`
  704. )
  705. assert(
  706. createSecondChangeBatch.isDone(),
  707. `/api/projects/${historyId}/changes should have been called for the second batch`
  708. )
  709. })
  710. })
  711. describe('compressing updates', function () {
  712. beforeEach(function () {
  713. MockHistoryStore()
  714. .get(`/api/projects/${historyId}/latest/history`)
  715. .reply(200, {
  716. chunk: {
  717. startVersion: 0,
  718. history: {
  719. snapshot: {},
  720. changes: [],
  721. },
  722. },
  723. })
  724. })
  725. it('should concat adjacent text updates', async function () {
  726. const createChange = MockHistoryStore()
  727. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  728. expect(body).to.deep.equal([
  729. olTextUpdate(
  730. this.doc,
  731. this.userId,
  732. this.timestamp,
  733. [3, 'foobaz', 2],
  734. 2
  735. ),
  736. ])
  737. return true
  738. })
  739. .query({ end_version: 0 })
  740. .reply(204)
  741. await ProjectHistoryClient.pushRawUpdate(
  742. this.projectId,
  743. slTextUpdate(historyId, this.doc, this.userId, 1, this.timestamp, [
  744. { p: 3, i: 'foobar' },
  745. { p: 6, d: 'bar' },
  746. ])
  747. )
  748. this.doc.length += 3
  749. await ProjectHistoryClient.pushRawUpdate(
  750. this.projectId,
  751. slTextUpdate(historyId, this.doc, this.userId, 2, this.timestamp, [
  752. { p: 6, i: 'baz' },
  753. ])
  754. )
  755. await ProjectHistoryClient.flushProject(this.projectId)
  756. assert(
  757. createChange.isDone(),
  758. `/api/projects/${historyId}/changes should have been called`
  759. )
  760. })
  761. it('should take the timestamp of the first update', async function () {
  762. const timestamp1 = new Date(this.timestamp)
  763. const timestamp2 = new Date(this.timestamp.getTime() + 10000)
  764. const createChange = MockHistoryStore()
  765. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  766. expect(body).to.deep.equal([
  767. olTextUpdate(
  768. this.doc,
  769. this.userId,
  770. timestamp1,
  771. [3, 'foobaz', 2],
  772. 2
  773. ),
  774. ])
  775. return true
  776. })
  777. .query({ end_version: 0 })
  778. .reply(204)
  779. await ProjectHistoryClient.pushRawUpdate(
  780. this.projectId,
  781. slTextUpdate(historyId, this.doc, this.userId, 1, timestamp1, [
  782. { p: 3, i: 'foo' },
  783. ])
  784. )
  785. this.doc.length += 3
  786. await ProjectHistoryClient.pushRawUpdate(
  787. this.projectId,
  788. slTextUpdate(historyId, this.doc, this.userId, 2, timestamp2, [
  789. { p: 6, i: 'baz' },
  790. ])
  791. )
  792. await ProjectHistoryClient.flushProject(this.projectId)
  793. assert(
  794. createChange.isDone(),
  795. `/api/projects/${historyId}/changes should have been called`
  796. )
  797. })
  798. it('should not concat updates more than 60 seconds apart', async function () {
  799. const timestamp1 = new Date(this.timestamp)
  800. const timestamp2 = new Date(this.timestamp.getTime() + 120000)
  801. const createChange = MockHistoryStore()
  802. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  803. expect(body).to.deep.equal([
  804. olTextUpdate(this.doc, this.userId, timestamp1, [3, 'foo', 2], 1),
  805. olTextUpdate(this.doc, this.userId, timestamp2, [6, 'baz', 2], 2),
  806. ])
  807. return true
  808. })
  809. .query({ end_version: 0 })
  810. .reply(204)
  811. await ProjectHistoryClient.pushRawUpdate(
  812. this.projectId,
  813. slTextUpdate(historyId, this.doc, this.userId, 1, timestamp1, [
  814. { p: 3, i: 'foo' },
  815. ])
  816. )
  817. this.doc.length += 3
  818. await ProjectHistoryClient.pushRawUpdate(
  819. this.projectId,
  820. slTextUpdate(historyId, this.doc, this.userId, 2, timestamp2, [
  821. { p: 6, i: 'baz' },
  822. ])
  823. )
  824. await ProjectHistoryClient.flushProject(this.projectId)
  825. assert(
  826. createChange.isDone(),
  827. `/api/projects/${historyId}/changes should have been called`
  828. )
  829. })
  830. it('should not concat updates with different user_ids', async function () {
  831. const userId1 = new ObjectId().toString()
  832. const userId2 = new ObjectId().toString()
  833. const createChange = MockHistoryStore()
  834. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  835. expect(body).to.deep.equal([
  836. olTextUpdate(this.doc, userId1, this.timestamp, [3, 'foo', 2], 1),
  837. olTextUpdate(this.doc, userId2, this.timestamp, [6, 'baz', 2], 2),
  838. ])
  839. return true
  840. })
  841. .query({ end_version: 0 })
  842. .reply(204)
  843. await ProjectHistoryClient.pushRawUpdate(
  844. this.projectId,
  845. slTextUpdate(historyId, this.doc, userId1, 1, this.timestamp, [
  846. { p: 3, i: 'foo' },
  847. ])
  848. )
  849. this.doc.length += 3
  850. await ProjectHistoryClient.pushRawUpdate(
  851. this.projectId,
  852. slTextUpdate(historyId, this.doc, userId2, 2, this.timestamp, [
  853. { p: 6, i: 'baz' },
  854. ])
  855. )
  856. await ProjectHistoryClient.flushProject(this.projectId)
  857. assert(
  858. createChange.isDone(),
  859. `/api/projects/${historyId}/changes should have been called`
  860. )
  861. })
  862. it('should not concat updates with different docs', async function () {
  863. const doc1 = {
  864. id: new ObjectId().toString(),
  865. pathname: '/doc1.tex',
  866. length: 10,
  867. }
  868. const doc2 = {
  869. id: new ObjectId().toString(),
  870. pathname: '/doc2.tex',
  871. length: 10,
  872. }
  873. const createChange = MockHistoryStore()
  874. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  875. expect(body).to.deep.equal([
  876. olTextUpdate(doc1, this.userId, this.timestamp, [3, 'foo', 7], 1),
  877. olTextUpdate(doc2, this.userId, this.timestamp, [6, 'baz', 4], 2),
  878. ])
  879. return true
  880. })
  881. .query({ end_version: 0 })
  882. .reply(204)
  883. await ProjectHistoryClient.pushRawUpdate(
  884. this.projectId,
  885. slTextUpdate(historyId, doc1, this.userId, 1, this.timestamp, [
  886. { p: 3, i: 'foo' },
  887. ])
  888. )
  889. await ProjectHistoryClient.pushRawUpdate(
  890. this.projectId,
  891. slTextUpdate(historyId, doc2, this.userId, 2, this.timestamp, [
  892. { p: 6, i: 'baz' },
  893. ])
  894. )
  895. await ProjectHistoryClient.flushProject(this.projectId)
  896. assert(
  897. createChange.isDone(),
  898. `/api/projects/${historyId}/changes should have been called`
  899. )
  900. })
  901. it('should not send updates without any ops', async function () {
  902. const createChange = MockHistoryStore()
  903. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  904. expect(body).to.deep.equal([])
  905. return true
  906. })
  907. .query({ end_version: 0 })
  908. .reply(204)
  909. // These blank ops can get sent by doc-updater on setDocs from Dropbox that don't change anything
  910. await ProjectHistoryClient.pushRawUpdate(
  911. this.projectId,
  912. slTextUpdate(historyId, this.doc, this.userId, 1, this.timestamp, [])
  913. )
  914. await ProjectHistoryClient.flushProject(this.projectId)
  915. assert(
  916. !createChange.isDone(),
  917. `/api/projects/${historyId}/changes should not have been called`
  918. )
  919. })
  920. it('should not send ops that compress to nothing', async function () {
  921. const createChange = MockHistoryStore()
  922. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  923. expect(body).to.deep.equal([])
  924. return true
  925. })
  926. .query({ end_version: 0 })
  927. .reply(204)
  928. await ProjectHistoryClient.pushRawUpdate(
  929. this.projectId,
  930. slTextUpdate(historyId, this.doc, this.userId, 1, this.timestamp, [
  931. { i: 'foo', p: 3 },
  932. ])
  933. )
  934. this.doc.length += 3
  935. await ProjectHistoryClient.pushRawUpdate(
  936. this.projectId,
  937. slTextUpdate(historyId, this.doc, this.userId, 2, this.timestamp, [
  938. { d: 'foo', p: 3 },
  939. ])
  940. )
  941. await ProjectHistoryClient.flushProject(this.projectId)
  942. assert(
  943. !createChange.isDone(),
  944. `/api/projects/${historyId}/changes should not have been called`
  945. )
  946. })
  947. it('should not send ops from a diff that are blank', async function () {
  948. this.doc.length = 300
  949. // Test case taken from a real life document where it was generating blank insert and
  950. // delete ops from a diff, and the blank delete was erroring on the OL history from
  951. // a text operation like [42, 0, 512], where the 0 was invalid.
  952. const createChange = MockHistoryStore()
  953. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  954. expect(body).to.deep.equal([
  955. olTextUpdates(this.doc, this.userId, this.timestamp, [
  956. [
  957. 87,
  958. -1,
  959. 67,
  960. '|l|ll|}\n\\hline',
  961. -4,
  962. 30,
  963. ' \\hline',
  964. 87,
  965. ' \\\\ \\hline',
  966. 24,
  967. ],
  968. ]),
  969. ])
  970. return true
  971. })
  972. .query({ end_version: 0 })
  973. .reply(204)
  974. await ProjectHistoryClient.pushRawUpdate(
  975. this.projectId,
  976. slTextUpdate(historyId, this.doc, this.userId, 1, this.timestamp, [
  977. {
  978. p: 73,
  979. d: '\\begin{table}[h]\n\\centering\n\\caption{My caption}\n\\label{my-label}\n\\begin{tabular}{lll}\n & A & B \\\\\nLiter t up & 2 & 1 \\\\\nLiter Whiskey & 1 & 2 \\\\\nPris pr. liter & 200 & 250\n\\end{tabular}\n\\end{table}',
  980. },
  981. {
  982. p: 73,
  983. i: '\\begin{table}[]\n\\centering\n\\caption{My caption}\n\\label{my-label}\n\\begin{tabular}{|l|ll|}\n\\hline\n & A & B \\\\ \\hline\nLiter t up & 2 & 1 \\\\\nLiter Whiskey & 1 & 2 \\\\\nPris pr. liter & 200 & 250 \\\\ \\hline\n\\end{tabular}\n\\end{table}',
  984. },
  985. ])
  986. )
  987. await ProjectHistoryClient.flushProject(this.projectId)
  988. assert(
  989. createChange.isDone(),
  990. `/api/projects/${historyId}/changes should have been called`
  991. )
  992. })
  993. it('should not concat text updates across project structure ops', async function () {
  994. const newDoc = {
  995. id: new ObjectId().toString(),
  996. pathname: '/main.tex',
  997. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  998. docLines: 'a\nb',
  999. }
  1000. MockHistoryStore()
  1001. .put(`/api/projects/${historyId}/blobs/${newDoc.hash}`)
  1002. .reply(201)
  1003. const createChange = MockHistoryStore()
  1004. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1005. expect(body).to.deep.equal([
  1006. olTextUpdate(
  1007. this.doc,
  1008. this.userId,
  1009. this.timestamp,
  1010. [3, 'foo', 2],
  1011. 1
  1012. ),
  1013. olAddDocUpdate(newDoc, this.userId, this.timestamp, newDoc.hash),
  1014. olTextUpdate(
  1015. this.doc,
  1016. this.userId,
  1017. this.timestamp,
  1018. [6, 'baz', 2],
  1019. 2
  1020. ),
  1021. ])
  1022. return true
  1023. })
  1024. .query({ end_version: 0 })
  1025. .reply(204)
  1026. await ProjectHistoryClient.pushRawUpdate(
  1027. this.projectId,
  1028. slTextUpdate(historyId, this.doc, this.userId, 1, this.timestamp, [
  1029. { p: 3, i: 'foobar' },
  1030. { p: 6, d: 'bar' },
  1031. ])
  1032. )
  1033. this.doc.length += 3
  1034. await ProjectHistoryClient.pushRawUpdate(
  1035. this.projectId,
  1036. slAddDocUpdate(
  1037. historyId,
  1038. newDoc,
  1039. this.userId,
  1040. this.timestamp,
  1041. newDoc.docLines
  1042. )
  1043. )
  1044. await ProjectHistoryClient.pushRawUpdate(
  1045. this.projectId,
  1046. slTextUpdate(historyId, this.doc, this.userId, 2, this.timestamp, [
  1047. { p: 6, i: 'baz' },
  1048. ])
  1049. )
  1050. await ProjectHistoryClient.flushProject(this.projectId)
  1051. assert(
  1052. createChange.isDone(),
  1053. `/api/projects/${historyId}/changes should have been called`
  1054. )
  1055. })
  1056. it('should track the doc length when splitting ops', async function () {
  1057. this.doc.length = 10
  1058. const createChange = MockHistoryStore()
  1059. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1060. expect(body).to.deep.equal([
  1061. olTextUpdate(this.doc, this.userId, this.timestamp, [3, -3, 4], 1),
  1062. olTextUpdate(
  1063. this.doc,
  1064. this.userId,
  1065. this.timestamp,
  1066. [3, 'barbaz', 4],
  1067. 2
  1068. ), // This has a base length of 10
  1069. ])
  1070. return true
  1071. })
  1072. .query({ end_version: 0 })
  1073. .reply(204)
  1074. await ProjectHistoryClient.pushRawUpdate(
  1075. this.projectId,
  1076. slTextUpdate(historyId, this.doc, this.userId, 1, this.timestamp, [
  1077. { p: 3, d: 'foo' },
  1078. { p: 3, i: 'bar' }, // Make sure the length of the op generated from this is 7, not 10
  1079. ])
  1080. )
  1081. await ProjectHistoryClient.pushRawUpdate(
  1082. this.projectId,
  1083. slTextUpdate(historyId, this.doc, this.userId, 2, this.timestamp, [
  1084. { p: 6, i: 'baz' },
  1085. ])
  1086. )
  1087. await ProjectHistoryClient.flushProject(this.projectId)
  1088. assert(
  1089. createChange.isDone(),
  1090. `/api/projects/${historyId}/changes should have been called`
  1091. )
  1092. })
  1093. })
  1094. describe('with bad pathnames', function () {
  1095. beforeEach(function () {
  1096. MockHistoryStore()
  1097. .get(`/api/projects/${historyId}/latest/history`)
  1098. .reply(200, {
  1099. chunk: {
  1100. startVersion: 0,
  1101. history: {
  1102. snapshot: {},
  1103. changes: [],
  1104. },
  1105. },
  1106. })
  1107. })
  1108. it('should replace \\\\ with _ and workaround * in pathnames', async function () {
  1109. const doc = {
  1110. id: this.doc.id,
  1111. pathname: '\\main.tex',
  1112. hash: 'b07b6b7a27667965f733943737124395c7577bea',
  1113. docLines: 'aaabbbccc',
  1114. length: 9,
  1115. }
  1116. MockHistoryStore()
  1117. .put(`/api/projects/${historyId}/blobs/${doc.hash}`)
  1118. .reply(201)
  1119. const createChange = MockHistoryStore()
  1120. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1121. expect(body).to.deep.equal([
  1122. olAddDocUpdate(
  1123. { id: doc.id, pathname: '_main.tex' },
  1124. this.userId,
  1125. this.timestamp,
  1126. doc.hash
  1127. ),
  1128. olRenameUpdate(
  1129. { id: doc.id, pathname: '_main.tex' },
  1130. this.userId,
  1131. this.timestamp,
  1132. '_main.tex',
  1133. '_main2.tex'
  1134. ),
  1135. olTextUpdate(
  1136. { id: doc.id, pathname: '_main2.tex' },
  1137. this.userId,
  1138. this.timestamp,
  1139. [3, 'foo', 6],
  1140. 2
  1141. ),
  1142. olRenameUpdate(
  1143. { id: doc.id, pathname: '_main2.tex' },
  1144. this.userId,
  1145. this.timestamp,
  1146. '_main2.tex',
  1147. '_main__ASTERISK__.tex'
  1148. ),
  1149. ])
  1150. return true
  1151. })
  1152. .query({ end_version: 0 })
  1153. .reply(204)
  1154. await ProjectHistoryClient.pushRawUpdate(
  1155. this.projectId,
  1156. slAddDocUpdate(
  1157. historyId,
  1158. doc,
  1159. this.userId,
  1160. this.timestamp,
  1161. doc.docLines
  1162. )
  1163. )
  1164. await ProjectHistoryClient.pushRawUpdate(
  1165. this.projectId,
  1166. slRenameUpdate(
  1167. historyId,
  1168. doc,
  1169. this.userId,
  1170. this.timestamp,
  1171. '/\\main.tex',
  1172. '/\\main2.tex'
  1173. )
  1174. )
  1175. doc.pathname = '\\main2.tex'
  1176. await ProjectHistoryClient.pushRawUpdate(
  1177. this.projectId,
  1178. slTextUpdate(historyId, doc, this.userId, 2, this.timestamp, [
  1179. { p: 3, i: 'foo' },
  1180. ])
  1181. )
  1182. await ProjectHistoryClient.pushRawUpdate(
  1183. this.projectId,
  1184. slRenameUpdate(
  1185. historyId,
  1186. doc,
  1187. this.userId,
  1188. this.timestamp,
  1189. '/\\main2.tex',
  1190. '/\\main*.tex'
  1191. )
  1192. )
  1193. doc.pathname = '\\main*.tex'
  1194. await ProjectHistoryClient.flushProject(this.projectId)
  1195. assert(
  1196. createChange.isDone(),
  1197. `/api/projects/${historyId}/changes should have been called`
  1198. )
  1199. })
  1200. it('should workaround pathnames beginning with spaces', async function () {
  1201. const doc = {
  1202. id: this.doc.id,
  1203. pathname: 'main.tex',
  1204. hash: 'b07b6b7a27667965f733943737124395c7577bea',
  1205. docLines: 'aaabbbccc',
  1206. length: 9,
  1207. }
  1208. MockHistoryStore()
  1209. .put(`/api/projects/${historyId}/blobs/${doc.hash}`)
  1210. .reply(201)
  1211. const createChange = MockHistoryStore()
  1212. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1213. expect(body).to.deep.equal([
  1214. olAddDocUpdate(
  1215. { id: doc.id, pathname: 'main.tex' },
  1216. this.userId,
  1217. this.timestamp,
  1218. doc.hash
  1219. ),
  1220. olRenameUpdate(
  1221. { id: doc.id },
  1222. this.userId,
  1223. this.timestamp,
  1224. 'main.tex',
  1225. 'foo/__SPACE__main.tex'
  1226. ),
  1227. ])
  1228. return true
  1229. })
  1230. .query({ end_version: 0 })
  1231. .reply(204)
  1232. await ProjectHistoryClient.pushRawUpdate(
  1233. this.projectId,
  1234. slAddDocUpdate(
  1235. historyId,
  1236. doc,
  1237. this.userId,
  1238. this.timestamp,
  1239. doc.docLines
  1240. )
  1241. )
  1242. await ProjectHistoryClient.pushRawUpdate(
  1243. this.projectId,
  1244. slRenameUpdate(
  1245. historyId,
  1246. doc,
  1247. this.userId,
  1248. this.timestamp,
  1249. '/main.tex',
  1250. '/foo/ main.tex'
  1251. )
  1252. )
  1253. doc.pathname = '/foo/ main.tex'
  1254. await ProjectHistoryClient.flushProject(this.projectId)
  1255. assert(
  1256. createChange.isDone(),
  1257. `/api/projects/${historyId}/changes should have been called`
  1258. )
  1259. })
  1260. })
  1261. describe('with bad response from filestore', function () {
  1262. beforeEach(function () {
  1263. MockHistoryStore()
  1264. .get(`/api/projects/${historyId}/latest/history`)
  1265. .reply(200, {
  1266. chunk: {
  1267. startVersion: 0,
  1268. history: {
  1269. snapshot: {},
  1270. changes: [],
  1271. },
  1272. },
  1273. })
  1274. })
  1275. it('should return a 500 if the filestore returns a 500', async function () {
  1276. const file = {
  1277. id: new ObjectId().toString(),
  1278. pathname: '/test.png',
  1279. contents: Buffer.from([1, 2, 3]),
  1280. hash: 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74',
  1281. }
  1282. const fileStoreRequest = MockFileStore()
  1283. .get(`/project/${this.projectId}/file/${file.id}`)
  1284. .reply(500)
  1285. const createBlob = MockHistoryStore()
  1286. .put(`/api/projects/${historyId}/blobs/${file.hash}`, file.contents)
  1287. .reply(201)
  1288. const addFile = MockHistoryStore()
  1289. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1290. expect(body).to.deep.equal([
  1291. olAddFileUpdate(file, this.userId, this.timestamp, file.hash),
  1292. ])
  1293. return true
  1294. })
  1295. .query({ end_version: 0 })
  1296. .reply(204)
  1297. await ProjectHistoryClient.pushRawUpdate(
  1298. this.projectId,
  1299. slAddFileUpdate(
  1300. historyId,
  1301. file,
  1302. this.userId,
  1303. this.timestamp,
  1304. this.projectId
  1305. )
  1306. )
  1307. const res = await ProjectHistoryClient.flushProject(this.projectId, {
  1308. allowErrors: true,
  1309. })
  1310. expect(res.statusCode).to.equal(500)
  1311. assert(
  1312. fileStoreRequest.isDone(),
  1313. `/project/${this.projectId}/file/${file.id} should have been called`
  1314. )
  1315. assert(
  1316. !createBlob.isDone(),
  1317. `/api/projects/${historyId}/latest/files should not have been called`
  1318. )
  1319. assert(
  1320. !addFile.isDone(),
  1321. `/api/projects/${historyId}/latest/files should not have been called`
  1322. )
  1323. })
  1324. it('should return a 500 if the filestore request errors', async function () {
  1325. const file = {
  1326. id: new ObjectId().toString(),
  1327. pathname: '/test.png',
  1328. contents: Buffer.from([1, 2, 3]),
  1329. hash: 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74',
  1330. }
  1331. const fileStoreRequest = MockFileStore()
  1332. .get(`/project/${this.projectId}/file/${file.id}`)
  1333. .replyWithError('oh no!')
  1334. const createBlob = MockHistoryStore()
  1335. .put(`/api/projects/${historyId}/blobs/${file.hash}`, file.contents)
  1336. .reply(201)
  1337. const addFile = MockHistoryStore()
  1338. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1339. expect(body).to.deep.equal([
  1340. olAddFileUpdate(file, this.userId, this.timestamp, file.hash),
  1341. ])
  1342. return true
  1343. })
  1344. .query({ end_version: 0 })
  1345. .reply(204)
  1346. await ProjectHistoryClient.pushRawUpdate(
  1347. this.projectId,
  1348. slAddFileUpdate(
  1349. historyId,
  1350. file,
  1351. this.userId,
  1352. this.timestamp,
  1353. this.projectId
  1354. )
  1355. )
  1356. const res = await ProjectHistoryClient.flushProject(this.projectId, {
  1357. allowErrors: true,
  1358. })
  1359. expect(res.statusCode).to.equal(500)
  1360. assert(
  1361. fileStoreRequest.isDone(),
  1362. `/project/${this.projectId}/file/${file.id} should have been called`
  1363. )
  1364. assert(
  1365. !createBlob.isDone(),
  1366. `/api/projects/${historyId}/latest/files should not have been called`
  1367. )
  1368. assert(
  1369. !addFile.isDone(),
  1370. `/api/projects/${historyId}/latest/files should not have been called`
  1371. )
  1372. })
  1373. })
  1374. describe('with an existing projectVersion field', function () {
  1375. beforeEach(function () {
  1376. MockHistoryStore()
  1377. .get(`/api/projects/${historyId}/latest/history`)
  1378. .reply(200, {
  1379. chunk: {
  1380. startVersion: 0,
  1381. history: {
  1382. snapshot: { projectVersion: '100.0' },
  1383. changes: [],
  1384. },
  1385. },
  1386. })
  1387. })
  1388. it('should discard project structure updates which have already been applied', async function () {
  1389. const newDoc = []
  1390. for (let i = 0; i <= 2; i++) {
  1391. newDoc[i] = {
  1392. id: new ObjectId().toString(),
  1393. pathname: `/main${i}.tex`,
  1394. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  1395. docLines: 'a\nb',
  1396. }
  1397. }
  1398. MockHistoryStore()
  1399. .put(`/api/projects/${historyId}/blobs/${newDoc[0].hash}`)
  1400. .times(3)
  1401. .reply(201)
  1402. const createChange = MockHistoryStore()
  1403. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1404. expect(body).to.deep.equal([
  1405. olAddDocUpdateWithVersion(
  1406. newDoc[1],
  1407. this.userId,
  1408. this.timestamp,
  1409. newDoc[1].hash,
  1410. '101.0'
  1411. ),
  1412. olAddDocUpdateWithVersion(
  1413. newDoc[2],
  1414. this.userId,
  1415. this.timestamp,
  1416. newDoc[2].hash,
  1417. '102.0'
  1418. ),
  1419. ])
  1420. return true
  1421. })
  1422. .query({ end_version: 0 })
  1423. .reply(204)
  1424. await ProjectHistoryClient.pushRawUpdate(
  1425. this.projectId,
  1426. slAddDocUpdateWithVersion(
  1427. historyId,
  1428. newDoc[0],
  1429. this.userId,
  1430. this.timestamp,
  1431. newDoc[0].docLines,
  1432. '100.0'
  1433. )
  1434. )
  1435. await ProjectHistoryClient.pushRawUpdate(
  1436. this.projectId,
  1437. slAddDocUpdateWithVersion(
  1438. historyId,
  1439. newDoc[1],
  1440. this.userId,
  1441. this.timestamp,
  1442. newDoc[1].docLines,
  1443. '101.0'
  1444. )
  1445. )
  1446. await ProjectHistoryClient.pushRawUpdate(
  1447. this.projectId,
  1448. slAddDocUpdateWithVersion(
  1449. historyId,
  1450. newDoc[2],
  1451. this.userId,
  1452. this.timestamp,
  1453. newDoc[2].docLines,
  1454. '102.0'
  1455. )
  1456. )
  1457. await ProjectHistoryClient.flushProject(this.projectId)
  1458. assert(
  1459. createChange.isDone(),
  1460. `/api/projects/${historyId}/changes should have been called`
  1461. )
  1462. })
  1463. })
  1464. describe('with an existing docVersions field', function () {
  1465. beforeEach(function () {
  1466. MockHistoryStore()
  1467. .get(`/api/projects/${historyId}/latest/history`)
  1468. .reply(200, {
  1469. chunk: {
  1470. startVersion: 0,
  1471. history: {
  1472. snapshot: { v2DocVersions: { [this.doc.id]: { v: 100 } } }, // version 100 below already applied
  1473. changes: [],
  1474. },
  1475. },
  1476. })
  1477. })
  1478. it('should discard doc updates which have already been applied', async function () {
  1479. const createChange = MockHistoryStore()
  1480. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1481. expect(body).to.deep.equal([
  1482. olTextUpdate(
  1483. this.doc,
  1484. this.userId,
  1485. this.timestamp,
  1486. [6, 'baz', 2],
  1487. 101
  1488. ),
  1489. ])
  1490. return true
  1491. })
  1492. .query({ end_version: 0 })
  1493. .reply(204)
  1494. await ProjectHistoryClient.pushRawUpdate(
  1495. this.projectId,
  1496. slTextUpdate(historyId, this.doc, this.userId, 100, this.timestamp, [
  1497. { p: 3, i: 'foobar' }, // these ops should be skipped
  1498. { p: 6, d: 'bar' },
  1499. ])
  1500. )
  1501. this.doc.length += 3
  1502. await ProjectHistoryClient.pushRawUpdate(
  1503. this.projectId,
  1504. slTextUpdate(historyId, this.doc, this.userId, 101, this.timestamp, [
  1505. { p: 6, i: 'baz' }, // this op should be applied
  1506. ])
  1507. )
  1508. await ProjectHistoryClient.flushProject(this.projectId)
  1509. assert(
  1510. createChange.isDone(),
  1511. `/api/projects/${historyId}/changes should have been called`
  1512. )
  1513. })
  1514. })
  1515. })