SyncTests.js 80 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553
  1. import nock from 'nock'
  2. import { expect } from 'chai'
  3. import assert from 'node:assert'
  4. import mongodb from 'mongodb-legacy'
  5. import logger from '@overleaf/logger'
  6. import Settings from '@overleaf/settings'
  7. import {
  8. Snapshot,
  9. File,
  10. FileMap,
  11. StringFileData,
  12. Change,
  13. } from 'overleaf-editor-core'
  14. import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
  15. import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
  16. import sinon from 'sinon'
  17. import { getFailure } from './helpers/ProjectHistoryClient.js'
  18. import { fetchNothing, RequestFailedError } from '@overleaf/fetch-utils'
  19. import { _getBlobHashFromString } from '../../../app/js/HashManager.js'
  20. const { ObjectId } = mongodb
  21. const EMPTY_FILE_HASH = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
  22. const MockHistoryStore = () => nock('http://127.0.0.1:3100')
  23. const MockFileStore = () => nock('http://127.0.0.1:3009')
  24. const MockWeb = () => nock('http://127.0.0.1:3000')
  25. describe('Syncing with web and doc-updater', function () {
  26. const historyId = new ObjectId().toString()
  27. let loggerWarn, loggerError
  28. beforeEach(function () {
  29. loggerWarn = sinon.spy(logger, 'warn')
  30. loggerError = sinon.spy(logger, 'error')
  31. })
  32. afterEach(function () {
  33. loggerWarn.restore()
  34. loggerError.restore()
  35. })
  36. beforeEach(async function () {
  37. this.timestamp = new Date()
  38. await ProjectHistoryApp.ensureRunning()
  39. this.project_id = new ObjectId().toString()
  40. this.doc_id = new ObjectId().toString()
  41. this.file_id = new ObjectId().toString()
  42. MockHistoryStore().post('/api/projects').reply(200, {
  43. projectId: historyId,
  44. })
  45. MockWeb()
  46. .get(`/project/${this.project_id}/details`)
  47. .reply(200, {
  48. name: 'Test Project',
  49. overleaf: {
  50. history: {
  51. id: historyId,
  52. },
  53. },
  54. })
  55. await ProjectHistoryClient.initializeProject(historyId)
  56. })
  57. afterEach(function () {
  58. nock.cleanAll()
  59. })
  60. describe('resyncing project history', function () {
  61. describe('without project-history enabled', function () {
  62. beforeEach(function () {
  63. MockWeb().post(`/project/${this.project_id}/history/resync`).reply(404)
  64. })
  65. it('404s if project-history is not enabled', async function () {
  66. try {
  67. await fetchNothing(
  68. `http://127.0.0.1:3054/project/${this.project_id}/resync`,
  69. {
  70. method: 'POST',
  71. }
  72. )
  73. } catch (error) {
  74. if (error instanceof RequestFailedError) {
  75. expect(error.response.status).to.equal(404)
  76. } else {
  77. throw error
  78. }
  79. }
  80. })
  81. })
  82. describe('with project-history enabled', function () {
  83. beforeEach(function () {
  84. MockWeb().post(`/project/${this.project_id}/history/resync`).reply(204)
  85. })
  86. describe('when a doc is missing', function () {
  87. it('should send add doc updates to the history store', async function () {
  88. MockHistoryStore()
  89. .get(`/api/projects/${historyId}/latest/history`)
  90. .reply(200, {
  91. chunk: {
  92. history: {
  93. snapshot: {
  94. files: {
  95. persistedDoc: { hash: EMPTY_FILE_HASH, stringLength: 0 },
  96. },
  97. },
  98. changes: [],
  99. },
  100. startVersion: 0,
  101. },
  102. })
  103. MockHistoryStore()
  104. .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`)
  105. .reply(200, '')
  106. const createBlob = MockHistoryStore()
  107. .put(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`, '')
  108. .reply(201)
  109. const addFile = MockHistoryStore()
  110. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  111. expect(body).to.deep.equal([
  112. {
  113. v2Authors: [],
  114. authors: [],
  115. timestamp: this.timestamp.toJSON(),
  116. operations: [
  117. {
  118. pathname: 'main.tex',
  119. file: {
  120. hash: EMPTY_FILE_HASH,
  121. },
  122. },
  123. ],
  124. origin: { kind: 'test-origin' },
  125. },
  126. ])
  127. return true
  128. })
  129. .query({ end_version: 0 })
  130. .reply(204)
  131. await ProjectHistoryClient.resyncHistory(this.project_id)
  132. const update = {
  133. projectHistoryId: historyId,
  134. resyncProjectStructure: {
  135. docs: [
  136. { path: '/main.tex', doc: this.doc_id },
  137. { path: '/persistedDoc', doc: 'other-doc-id' },
  138. ],
  139. files: [],
  140. },
  141. meta: {
  142. ts: this.timestamp,
  143. },
  144. }
  145. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  146. await ProjectHistoryClient.flushProject(this.project_id)
  147. assert(
  148. createBlob.isDone(),
  149. '/api/projects/:historyId/blobs/:hash should have been called'
  150. )
  151. assert(
  152. addFile.isDone(),
  153. `/api/projects/${historyId}/changes should have been called`
  154. )
  155. })
  156. })
  157. describe('when a file is missing', function () {
  158. it('should send add file updates to the history store', async function () {
  159. MockHistoryStore()
  160. .get(`/api/projects/${historyId}/latest/history`)
  161. .reply(200, {
  162. chunk: {
  163. history: {
  164. snapshot: {
  165. files: {
  166. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  167. },
  168. },
  169. changes: [],
  170. },
  171. startVersion: 0,
  172. },
  173. })
  174. const fileContents = Buffer.from([1, 2, 3])
  175. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  176. MockFileStore()
  177. .get(`/project/${this.project_id}/file/${this.file_id}`)
  178. .reply(200, fileContents)
  179. const headBlob = MockHistoryStore()
  180. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  181. .reply(404)
  182. const createBlob = MockHistoryStore()
  183. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  184. .reply(201)
  185. const addFile = MockHistoryStore()
  186. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  187. expect(body).to.deep.equal([
  188. {
  189. v2Authors: [],
  190. authors: [],
  191. timestamp: this.timestamp.toJSON(),
  192. operations: [
  193. {
  194. pathname: 'test.png',
  195. file: {
  196. hash: fileHash,
  197. },
  198. },
  199. ],
  200. origin: { kind: 'test-origin' },
  201. },
  202. ])
  203. return true
  204. })
  205. .query({ end_version: 0 })
  206. .reply(204)
  207. await ProjectHistoryClient.resyncHistory(this.project_id)
  208. const update = {
  209. projectHistoryId: historyId,
  210. resyncProjectStructure: {
  211. docs: [],
  212. files: [
  213. {
  214. file: this.file_id,
  215. path: '/test.png',
  216. _hash: fileHash,
  217. url: `http://127.0.0.1:3009/project/${this.project_id}/file/${this.file_id}`,
  218. },
  219. { path: '/persistedFile' },
  220. ],
  221. },
  222. meta: {
  223. ts: this.timestamp,
  224. },
  225. }
  226. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  227. await ProjectHistoryClient.flushProject(this.project_id)
  228. assert(!loggerWarn.called, 'no warning logged on 404')
  229. assert(
  230. headBlob.isDone(),
  231. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  232. )
  233. assert(
  234. createBlob.isDone(),
  235. '/api/projects/:historyId/blobs/:hash should have been called'
  236. )
  237. assert(
  238. addFile.isDone(),
  239. `/api/projects/${historyId}/changes should have been called`
  240. )
  241. })
  242. it('should skip HEAD on blob without hash', async function () {
  243. MockHistoryStore()
  244. .get(`/api/projects/${historyId}/latest/history`)
  245. .reply(200, {
  246. chunk: {
  247. history: {
  248. snapshot: {
  249. files: {
  250. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  251. },
  252. },
  253. changes: [],
  254. },
  255. startVersion: 0,
  256. },
  257. })
  258. const fileContents = Buffer.from([1, 2, 3])
  259. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  260. MockFileStore()
  261. .get(`/project/${this.project_id}/file/${this.file_id}`)
  262. .reply(200, fileContents)
  263. const headBlob = MockHistoryStore()
  264. .head(`/api/projects/${historyId}/blobs/undefined`)
  265. .reply(500)
  266. const createBlob = MockHistoryStore()
  267. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  268. .reply(201)
  269. const addFile = MockHistoryStore()
  270. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  271. expect(body).to.deep.equal([
  272. {
  273. v2Authors: [],
  274. authors: [],
  275. timestamp: this.timestamp.toJSON(),
  276. operations: [
  277. {
  278. pathname: 'test.png',
  279. file: {
  280. hash: fileHash,
  281. },
  282. },
  283. ],
  284. origin: { kind: 'test-origin' },
  285. },
  286. ])
  287. return true
  288. })
  289. .query({ end_version: 0 })
  290. .reply(204)
  291. await ProjectHistoryClient.resyncHistory(this.project_id)
  292. const update = {
  293. projectHistoryId: historyId,
  294. resyncProjectStructure: {
  295. docs: [],
  296. files: [
  297. {
  298. file: this.file_id,
  299. path: '/test.png',
  300. url: `http://127.0.0.1:3009/project/${this.project_id}/file/${this.file_id}`,
  301. },
  302. { path: '/persistedFile' },
  303. ],
  304. },
  305. meta: {
  306. ts: this.timestamp,
  307. },
  308. }
  309. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  310. await ProjectHistoryClient.flushProject(this.project_id)
  311. assert(!loggerWarn.called, 'no warning logged on 404')
  312. assert(
  313. !headBlob.isDone(),
  314. 'HEAD /api/projects/:historyId/blobs/:hash should have been skipped'
  315. )
  316. assert(
  317. createBlob.isDone(),
  318. '/api/projects/:historyId/blobs/:hash should have been called'
  319. )
  320. assert(
  321. addFile.isDone(),
  322. `/api/projects/${historyId}/changes should have been called`
  323. )
  324. })
  325. it('should record error when checking blob fails with 500', async function () {
  326. MockHistoryStore()
  327. .get(`/api/projects/${historyId}/latest/history`)
  328. .reply(200, {
  329. chunk: {
  330. history: {
  331. snapshot: {
  332. files: {
  333. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  334. },
  335. },
  336. changes: [],
  337. },
  338. startVersion: 0,
  339. },
  340. })
  341. const fileContents = Buffer.from([1, 2, 3])
  342. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  343. MockFileStore()
  344. .get(`/project/${this.project_id}/file/${this.file_id}`)
  345. .reply(200, fileContents)
  346. const headBlob = MockHistoryStore()
  347. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  348. .reply(500)
  349. const createBlob = MockHistoryStore()
  350. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  351. .reply(201)
  352. const addFile = MockHistoryStore()
  353. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  354. expect(body).to.deep.equal([
  355. {
  356. v2Authors: [],
  357. authors: [],
  358. timestamp: this.timestamp.toJSON(),
  359. operations: [
  360. {
  361. pathname: 'test.png',
  362. file: {
  363. hash: fileHash,
  364. },
  365. },
  366. ],
  367. origin: { kind: 'test-origin' },
  368. },
  369. ])
  370. return true
  371. })
  372. .query({ end_version: 0 })
  373. .reply(204)
  374. await ProjectHistoryClient.resyncHistory(this.project_id)
  375. const update = {
  376. projectHistoryId: historyId,
  377. resyncProjectStructure: {
  378. docs: [],
  379. files: [
  380. {
  381. file: this.file_id,
  382. path: '/test.png',
  383. _hash: fileHash,
  384. url: `http://127.0.0.1:3009/project/${this.project_id}/file/${this.file_id}`,
  385. },
  386. { path: '/persistedFile' },
  387. ],
  388. },
  389. meta: {
  390. ts: this.timestamp,
  391. },
  392. }
  393. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  394. const res = await ProjectHistoryClient.flushProject(this.project_id, {
  395. allowErrors: true,
  396. })
  397. assert(res.statusCode === 500, 'resync should have failed')
  398. assert(
  399. loggerError.calledWithMatch(
  400. sinon.match.any,
  401. 'error checking whether blob exists'
  402. ),
  403. 'error logged on 500'
  404. )
  405. assert(
  406. headBlob.isDone(),
  407. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  408. )
  409. assert(
  410. !createBlob.isDone(),
  411. '/api/projects/:historyId/blobs/:hash should have been skipped'
  412. )
  413. assert(
  414. !addFile.isDone(),
  415. `/api/projects/${historyId}/changes should have been skipped`
  416. )
  417. })
  418. it('should skip blob write when blob exists', async function () {
  419. MockHistoryStore()
  420. .get(`/api/projects/${historyId}/latest/history`)
  421. .reply(200, {
  422. chunk: {
  423. history: {
  424. snapshot: {
  425. files: {
  426. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  427. },
  428. },
  429. changes: [],
  430. },
  431. startVersion: 0,
  432. },
  433. })
  434. const fileContents = Buffer.from([1, 2, 3])
  435. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  436. MockFileStore()
  437. .get(`/project/${this.project_id}/file/${this.file_id}`)
  438. .reply(200, fileContents)
  439. const headBlob = MockHistoryStore()
  440. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  441. .reply(200)
  442. const createBlob = MockHistoryStore()
  443. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  444. .reply(201)
  445. const addFile = MockHistoryStore()
  446. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  447. expect(body).to.deep.equal([
  448. {
  449. v2Authors: [],
  450. authors: [],
  451. timestamp: this.timestamp.toJSON(),
  452. operations: [
  453. {
  454. pathname: 'test.png',
  455. file: {
  456. hash: fileHash,
  457. },
  458. },
  459. ],
  460. origin: { kind: 'test-origin' },
  461. },
  462. ])
  463. return true
  464. })
  465. .query({ end_version: 0 })
  466. .reply(204)
  467. await ProjectHistoryClient.resyncHistory(this.project_id)
  468. const update = {
  469. projectHistoryId: historyId,
  470. resyncProjectStructure: {
  471. docs: [],
  472. files: [
  473. {
  474. file: this.file_id,
  475. path: '/test.png',
  476. _hash: fileHash,
  477. url: `http://127.0.0.1:3009/project/${this.project_id}/file/${this.file_id}`,
  478. },
  479. { path: '/persistedFile' },
  480. ],
  481. },
  482. meta: {
  483. ts: this.timestamp,
  484. },
  485. }
  486. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  487. await ProjectHistoryClient.flushProject(this.project_id)
  488. assert(!loggerWarn.called, 'no warning logged on 404')
  489. assert(
  490. headBlob.isDone(),
  491. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  492. )
  493. assert(
  494. !createBlob.isDone(),
  495. '/api/projects/:historyId/blobs/:hash should have been skipped'
  496. )
  497. assert(
  498. addFile.isDone(),
  499. `/api/projects/${historyId}/changes should have been called`
  500. )
  501. })
  502. it('should add file w/o url', async function () {
  503. MockHistoryStore()
  504. .get(`/api/projects/${historyId}/latest/history`)
  505. .reply(200, {
  506. chunk: {
  507. history: {
  508. snapshot: {
  509. files: {
  510. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  511. },
  512. },
  513. changes: [],
  514. },
  515. startVersion: 0,
  516. },
  517. })
  518. const fileContents = Buffer.from([1, 2, 3])
  519. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  520. MockFileStore()
  521. .get(`/project/${this.project_id}/file/${this.file_id}`)
  522. .reply(200, fileContents)
  523. const headBlob = MockHistoryStore()
  524. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  525. .reply(200)
  526. const createBlob = MockHistoryStore()
  527. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  528. .reply(201)
  529. const addFile = MockHistoryStore()
  530. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  531. expect(body).to.deep.equal([
  532. {
  533. v2Authors: [],
  534. authors: [],
  535. timestamp: this.timestamp.toJSON(),
  536. operations: [
  537. {
  538. pathname: 'test.png',
  539. file: {
  540. hash: fileHash,
  541. },
  542. },
  543. ],
  544. origin: { kind: 'test-origin' },
  545. },
  546. ])
  547. return true
  548. })
  549. .query({ end_version: 0 })
  550. .reply(204)
  551. await ProjectHistoryClient.resyncHistory(this.project_id)
  552. const update = {
  553. projectHistoryId: historyId,
  554. resyncProjectStructure: {
  555. docs: [],
  556. files: [
  557. {
  558. file: this.file_id,
  559. path: '/test.png',
  560. _hash: fileHash,
  561. createdBlob: true,
  562. },
  563. { path: '/persistedFile' },
  564. ],
  565. },
  566. meta: {
  567. ts: this.timestamp,
  568. },
  569. }
  570. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  571. await ProjectHistoryClient.flushProject(this.project_id)
  572. assert(!loggerWarn.called, 'no warning logged on 404')
  573. assert(
  574. headBlob.isDone(),
  575. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  576. )
  577. assert(
  578. !createBlob.isDone(),
  579. '/api/projects/:historyId/blobs/:hash should have been skipped'
  580. )
  581. assert(
  582. addFile.isDone(),
  583. `/api/projects/${historyId}/changes should have been called`
  584. )
  585. })
  586. describe('with filestore disabled', function () {
  587. before(function () {
  588. Settings.apis.filestore.enabled = false
  589. })
  590. after(function () {
  591. Settings.apis.filestore.enabled = true
  592. })
  593. it('should record error when blob is missing', async function () {
  594. MockHistoryStore()
  595. .get(`/api/projects/${historyId}/latest/history`)
  596. .reply(200, {
  597. chunk: {
  598. history: {
  599. snapshot: {
  600. files: {
  601. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  602. },
  603. },
  604. changes: [],
  605. },
  606. startVersion: 0,
  607. },
  608. })
  609. const fileContents = Buffer.from([1, 2, 3])
  610. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  611. MockFileStore()
  612. .get(`/project/${this.project_id}/file/${this.file_id}`)
  613. .reply(200, fileContents)
  614. const headBlob = MockHistoryStore()
  615. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  616. .times(3) // three retries
  617. .reply(404)
  618. const createBlob = MockHistoryStore()
  619. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  620. .reply(201)
  621. const addFile = MockHistoryStore()
  622. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  623. expect(body).to.deep.equal([
  624. {
  625. v2Authors: [],
  626. authors: [],
  627. timestamp: this.timestamp.toJSON(),
  628. operations: [
  629. {
  630. pathname: 'test.png',
  631. file: {
  632. hash: fileHash,
  633. },
  634. },
  635. ],
  636. origin: { kind: 'test-origin' },
  637. },
  638. ])
  639. return true
  640. })
  641. .query({ end_version: 0 })
  642. .reply(204)
  643. await ProjectHistoryClient.resyncHistory(this.project_id)
  644. const update = {
  645. projectHistoryId: historyId,
  646. resyncProjectStructure: {
  647. docs: [],
  648. files: [
  649. {
  650. file: this.file_id,
  651. path: '/test.png',
  652. _hash: fileHash,
  653. url: `http://127.0.0.1:3009/project/${this.project_id}/file/${this.file_id}`,
  654. },
  655. { path: '/persistedFile' },
  656. ],
  657. },
  658. meta: {
  659. ts: this.timestamp,
  660. },
  661. }
  662. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  663. const res = await ProjectHistoryClient.flushProject(
  664. this.project_id,
  665. {
  666. allowErrors: true,
  667. }
  668. )
  669. assert(res.statusCode === 500, 'resync should have failed')
  670. assert(
  671. loggerError.calledWithMatch(
  672. sinon.match.any,
  673. 'blocking filestore read'
  674. ),
  675. 'error logged on 500'
  676. )
  677. assert(
  678. headBlob.isDone(),
  679. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  680. )
  681. assert(
  682. !createBlob.isDone(),
  683. '/api/projects/:historyId/blobs/:hash should have been skipped'
  684. )
  685. assert(
  686. !addFile.isDone(),
  687. `/api/projects/${historyId}/changes should have been skipped`
  688. )
  689. })
  690. })
  691. })
  692. describe('when a file hash mismatches', function () {
  693. it('should remove and re-add file w/o url', async function () {
  694. MockHistoryStore()
  695. .get(`/api/projects/${historyId}/latest/history`)
  696. .reply(200, {
  697. chunk: {
  698. history: {
  699. snapshot: {
  700. files: {
  701. 'test.png': { hash: EMPTY_FILE_HASH, byteLength: 0 },
  702. },
  703. },
  704. changes: [],
  705. },
  706. startVersion: 0,
  707. },
  708. })
  709. const fileContents = Buffer.from([1, 2, 3])
  710. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  711. MockFileStore()
  712. .get(`/project/${this.project_id}/file/${this.file_id}`)
  713. .reply(200, fileContents)
  714. const headBlob = MockHistoryStore()
  715. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  716. .reply(200)
  717. const createBlob = MockHistoryStore()
  718. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  719. .reply(201)
  720. const addFile = MockHistoryStore()
  721. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  722. expect(body).to.deep.equal([
  723. {
  724. v2Authors: [],
  725. authors: [],
  726. timestamp: this.timestamp.toJSON(),
  727. operations: [
  728. {
  729. pathname: 'test.png',
  730. newPathname: '',
  731. },
  732. ],
  733. origin: { kind: 'test-origin' },
  734. },
  735. {
  736. v2Authors: [],
  737. authors: [],
  738. timestamp: this.timestamp.toJSON(),
  739. operations: [
  740. {
  741. pathname: 'test.png',
  742. file: {
  743. hash: fileHash,
  744. },
  745. },
  746. ],
  747. origin: { kind: 'test-origin' },
  748. },
  749. ])
  750. return true
  751. })
  752. .query({ end_version: 0 })
  753. .reply(204)
  754. await ProjectHistoryClient.resyncHistory(this.project_id)
  755. const update = {
  756. projectHistoryId: historyId,
  757. resyncProjectStructure: {
  758. docs: [],
  759. files: [
  760. {
  761. file: this.file_id,
  762. path: '/test.png',
  763. _hash: fileHash,
  764. createdBlob: true,
  765. },
  766. ],
  767. },
  768. meta: {
  769. ts: this.timestamp,
  770. },
  771. }
  772. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  773. await ProjectHistoryClient.flushProject(this.project_id)
  774. assert(!loggerWarn.called, 'no warning logged on 404')
  775. assert(
  776. headBlob.isDone(),
  777. 'HEAD /api/projects/:historyId/blobs/:hash should have been called'
  778. )
  779. assert(
  780. !createBlob.isDone(),
  781. '/api/projects/:historyId/blobs/:hash should have been skipped'
  782. )
  783. assert(
  784. addFile.isDone(),
  785. `/api/projects/${historyId}/changes should have been called`
  786. )
  787. })
  788. })
  789. describe("when a file exists which shouldn't", function () {
  790. it('should send remove file updates to the history store', async function () {
  791. MockHistoryStore()
  792. .get(`/api/projects/${historyId}/latest/history`)
  793. .reply(200, {
  794. chunk: {
  795. history: {
  796. snapshot: {
  797. files: {
  798. docToKeep: { hash: EMPTY_FILE_HASH, stringLength: 0 },
  799. docToDelete: { hash: EMPTY_FILE_HASH, stringLength: 0 },
  800. },
  801. },
  802. changes: [],
  803. },
  804. startVersion: 0,
  805. },
  806. })
  807. MockHistoryStore()
  808. .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`)
  809. .reply(200, '')
  810. .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`)
  811. .reply(200, '') // blob is requested once for each file
  812. const deleteFile = MockHistoryStore()
  813. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  814. expect(body).to.deep.equal([
  815. {
  816. v2Authors: [],
  817. authors: [],
  818. timestamp: this.timestamp.toJSON(),
  819. operations: [
  820. {
  821. pathname: 'docToDelete',
  822. newPathname: '',
  823. },
  824. ],
  825. origin: { kind: 'test-origin' },
  826. },
  827. ])
  828. return true
  829. })
  830. .query({ end_version: 0 })
  831. .reply(204)
  832. await ProjectHistoryClient.resyncHistory(this.project_id)
  833. const update = {
  834. projectHistoryId: historyId,
  835. resyncProjectStructure: {
  836. docs: [{ path: 'docToKeep' }],
  837. files: [],
  838. },
  839. meta: {
  840. ts: this.timestamp,
  841. },
  842. }
  843. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  844. await ProjectHistoryClient.flushProject(this.project_id)
  845. assert(
  846. deleteFile.isDone(),
  847. `/api/projects/${historyId}/changes should have been called`
  848. )
  849. })
  850. })
  851. describe("when a doc's contents is not up to date", function () {
  852. beforeEach(function () {
  853. MockHistoryStore()
  854. .get(`/api/projects/${historyId}/latest/history`)
  855. .reply(200, {
  856. chunk: {
  857. history: {
  858. snapshot: {
  859. files: {
  860. 'main.tex': {
  861. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  862. stringLength: 3,
  863. },
  864. },
  865. },
  866. changes: [],
  867. },
  868. startVersion: 0,
  869. },
  870. })
  871. MockHistoryStore()
  872. .get(
  873. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  874. )
  875. .reply(200, 'a\nb')
  876. })
  877. it('should send test updates to the history store', async function () {
  878. const addFile = MockHistoryStore()
  879. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  880. expect(body).to.deep.equal([
  881. {
  882. v2Authors: [],
  883. authors: [],
  884. timestamp: this.timestamp.toJSON(),
  885. operations: [
  886. {
  887. pathname: 'main.tex',
  888. textOperation: [3, '\nc'],
  889. },
  890. ],
  891. origin: { kind: 'test-origin' },
  892. },
  893. ])
  894. return true
  895. })
  896. .query({ end_version: 0 })
  897. .reply(204)
  898. await ProjectHistoryClient.resyncHistory(this.project_id)
  899. const update1 = {
  900. projectHistoryId: historyId,
  901. resyncProjectStructure: {
  902. docs: [{ path: '/main.tex' }],
  903. files: [],
  904. },
  905. meta: {
  906. ts: this.timestamp,
  907. },
  908. }
  909. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  910. const update2 = {
  911. path: '/main.tex',
  912. projectHistoryId: historyId,
  913. resyncDocContent: {
  914. content: 'a\nb\nc',
  915. },
  916. doc: this.doc_id,
  917. meta: {
  918. ts: this.timestamp,
  919. },
  920. }
  921. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  922. await ProjectHistoryClient.flushProject(this.project_id)
  923. assert(
  924. addFile.isDone(),
  925. `/api/projects/${historyId}/changes should have been called`
  926. )
  927. })
  928. it('should strip non-BMP characters in updates before sending to the history store', async function () {
  929. const addFile = MockHistoryStore()
  930. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  931. expect(body).to.deep.equal([
  932. {
  933. v2Authors: [],
  934. authors: [],
  935. timestamp: this.timestamp.toJSON(),
  936. operations: [
  937. {
  938. pathname: 'main.tex',
  939. textOperation: [3, '\n\uFFFD\uFFFDc'],
  940. },
  941. ],
  942. origin: { kind: 'test-origin' },
  943. },
  944. ])
  945. return true
  946. })
  947. .query({ end_version: 0 })
  948. .reply(204)
  949. await ProjectHistoryClient.resyncHistory(this.project_id)
  950. const update1 = {
  951. projectHistoryId: historyId,
  952. resyncProjectStructure: {
  953. docs: [{ path: '/main.tex' }],
  954. files: [],
  955. },
  956. meta: {
  957. ts: this.timestamp,
  958. },
  959. }
  960. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  961. const update2 = {
  962. path: '/main.tex',
  963. projectHistoryId: historyId,
  964. resyncDocContent: {
  965. content: 'a\nb\n\uD800\uDC00c',
  966. },
  967. doc: this.doc_id,
  968. meta: {
  969. ts: this.timestamp,
  970. },
  971. }
  972. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  973. await ProjectHistoryClient.flushProject(this.project_id)
  974. assert(
  975. addFile.isDone(),
  976. `/api/projects/${historyId}/changes should have been called`
  977. )
  978. })
  979. it('should add comments in the history store', async function () {
  980. const commentId = 'comment-id'
  981. const addComment = MockHistoryStore()
  982. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  983. expect(body).to.deep.equal([
  984. {
  985. v2Authors: [],
  986. authors: [],
  987. timestamp: this.timestamp.toJSON(),
  988. operations: [
  989. {
  990. pathname: 'main.tex',
  991. commentId,
  992. ranges: [{ pos: 1, length: 10 }],
  993. },
  994. ],
  995. origin: { kind: 'test-origin' },
  996. },
  997. ])
  998. return true
  999. })
  1000. .query({ end_version: 0 })
  1001. .reply(204)
  1002. await ProjectHistoryClient.resyncHistory(this.project_id)
  1003. const update1 = {
  1004. projectHistoryId: historyId,
  1005. resyncProjectStructure: {
  1006. docs: [{ path: '/main.tex' }],
  1007. files: [],
  1008. },
  1009. meta: {
  1010. ts: this.timestamp,
  1011. },
  1012. }
  1013. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1014. const update2 = {
  1015. path: '/main.tex',
  1016. projectHistoryId: historyId,
  1017. resyncDocContent: {
  1018. content: 'a\nb',
  1019. ranges: {
  1020. comments: [
  1021. {
  1022. id: commentId,
  1023. op: {
  1024. c: 'a',
  1025. p: 0,
  1026. hpos: 1,
  1027. hlen: 10,
  1028. t: commentId,
  1029. },
  1030. meta: {
  1031. user_id: 'user-id',
  1032. ts: this.timestamp,
  1033. },
  1034. },
  1035. ],
  1036. },
  1037. },
  1038. doc: this.doc_id,
  1039. meta: {
  1040. ts: this.timestamp,
  1041. },
  1042. }
  1043. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1044. await ProjectHistoryClient.flushProject(this.project_id)
  1045. assert(
  1046. addComment.isDone(),
  1047. `/api/projects/${historyId}/changes should have been called`
  1048. )
  1049. })
  1050. it('should add comments in the history store (history-ot)', async function () {
  1051. const commentId = 'comment-id'
  1052. const addComment = MockHistoryStore()
  1053. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1054. expect(body).to.deep.equal([
  1055. {
  1056. v2Authors: [],
  1057. authors: [],
  1058. timestamp: this.timestamp.toJSON(),
  1059. operations: [
  1060. {
  1061. pathname: 'main.tex',
  1062. commentId,
  1063. ranges: [{ pos: 1, length: 10 }],
  1064. },
  1065. ],
  1066. origin: { kind: 'test-origin' },
  1067. },
  1068. ])
  1069. return true
  1070. })
  1071. .query({ end_version: 0 })
  1072. .reply(204)
  1073. await ProjectHistoryClient.resyncHistory(this.project_id)
  1074. const update1 = {
  1075. projectHistoryId: historyId,
  1076. resyncProjectStructure: {
  1077. docs: [{ path: '/main.tex' }],
  1078. files: [],
  1079. },
  1080. meta: {
  1081. ts: this.timestamp,
  1082. },
  1083. }
  1084. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1085. const update2 = {
  1086. path: '/main.tex',
  1087. projectHistoryId: historyId,
  1088. resyncDocContent: {
  1089. content: 'a\nb',
  1090. historyOTRanges: {
  1091. comments: [
  1092. {
  1093. id: commentId,
  1094. ranges: [
  1095. {
  1096. pos: 1,
  1097. length: 10,
  1098. },
  1099. ],
  1100. },
  1101. ],
  1102. },
  1103. },
  1104. doc: this.doc_id,
  1105. meta: {
  1106. ts: this.timestamp,
  1107. },
  1108. }
  1109. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1110. await ProjectHistoryClient.flushProject(this.project_id)
  1111. assert(
  1112. addComment.isDone(),
  1113. `/api/projects/${historyId}/changes should have been called`
  1114. )
  1115. })
  1116. it('should add tracked changes in the history store', async function () {
  1117. const fixTrackedChange = MockHistoryStore()
  1118. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1119. expect(body).to.deep.equal([
  1120. {
  1121. v2Authors: [],
  1122. authors: [],
  1123. timestamp: this.timestamp.toJSON(),
  1124. operations: [
  1125. {
  1126. pathname: 'main.tex',
  1127. textOperation: [
  1128. {
  1129. r: 1,
  1130. tracking: {
  1131. ts: this.timestamp.toJSON(),
  1132. type: 'delete',
  1133. userId: 'user-id',
  1134. },
  1135. },
  1136. {
  1137. r: 1,
  1138. tracking: {
  1139. ts: this.timestamp.toJSON(),
  1140. type: 'insert',
  1141. userId: 'user-id',
  1142. },
  1143. },
  1144. 1,
  1145. ],
  1146. },
  1147. ],
  1148. origin: { kind: 'test-origin' },
  1149. },
  1150. ])
  1151. return true
  1152. })
  1153. .query({ end_version: 0 })
  1154. .reply(204)
  1155. await ProjectHistoryClient.resyncHistory(this.project_id)
  1156. const update1 = {
  1157. projectHistoryId: historyId,
  1158. resyncProjectStructure: {
  1159. docs: [{ path: '/main.tex' }],
  1160. files: [],
  1161. },
  1162. meta: {
  1163. ts: this.timestamp,
  1164. },
  1165. }
  1166. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1167. const update2 = {
  1168. path: '/main.tex',
  1169. projectHistoryId: historyId,
  1170. resyncDocContent: {
  1171. content: 'a\nb',
  1172. ranges: {
  1173. changes: [
  1174. {
  1175. id: 'id1',
  1176. op: {
  1177. d: 'a',
  1178. p: 0,
  1179. },
  1180. metadata: {
  1181. user_id: 'user-id',
  1182. ts: this.timestamp,
  1183. },
  1184. },
  1185. {
  1186. id: 'id2',
  1187. op: {
  1188. i: '\n',
  1189. p: 0,
  1190. hpos: 1,
  1191. },
  1192. metadata: {
  1193. user_id: 'user-id',
  1194. ts: this.timestamp,
  1195. },
  1196. },
  1197. ],
  1198. },
  1199. },
  1200. doc: this.doc_id,
  1201. meta: {
  1202. ts: this.timestamp,
  1203. },
  1204. }
  1205. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1206. await ProjectHistoryClient.flushProject(this.project_id)
  1207. assert(
  1208. fixTrackedChange.isDone(),
  1209. `/api/projects/${historyId}/changes should have been called`
  1210. )
  1211. })
  1212. it('should add tracked changes in the history store (history-ot)', async function () {
  1213. const fixTrackedChange = MockHistoryStore()
  1214. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1215. expect(body).to.deep.equal([
  1216. {
  1217. v2Authors: [],
  1218. authors: [],
  1219. timestamp: this.timestamp.toJSON(),
  1220. operations: [
  1221. {
  1222. pathname: 'main.tex',
  1223. textOperation: [
  1224. {
  1225. r: 1,
  1226. tracking: {
  1227. ts: this.timestamp.toJSON(),
  1228. type: 'delete',
  1229. userId: 'user-id',
  1230. },
  1231. },
  1232. {
  1233. r: 1,
  1234. tracking: {
  1235. ts: this.timestamp.toJSON(),
  1236. type: 'insert',
  1237. userId: 'user-id',
  1238. },
  1239. },
  1240. 1,
  1241. ],
  1242. },
  1243. ],
  1244. origin: { kind: 'test-origin' },
  1245. },
  1246. ])
  1247. return true
  1248. })
  1249. .query({ end_version: 0 })
  1250. .reply(204)
  1251. await ProjectHistoryClient.resyncHistory(this.project_id)
  1252. const update1 = {
  1253. projectHistoryId: historyId,
  1254. resyncProjectStructure: {
  1255. docs: [{ path: '/main.tex' }],
  1256. files: [],
  1257. },
  1258. meta: {
  1259. ts: this.timestamp,
  1260. },
  1261. }
  1262. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1263. const update2 = {
  1264. path: '/main.tex',
  1265. projectHistoryId: historyId,
  1266. resyncDocContent: {
  1267. content: 'a\nb',
  1268. historyOTRanges: {
  1269. trackedChanges: [
  1270. {
  1271. range: { pos: 0, length: 1 },
  1272. tracking: {
  1273. ts: this.timestamp.toJSON(),
  1274. type: 'delete',
  1275. userId: 'user-id',
  1276. },
  1277. },
  1278. {
  1279. range: { pos: 1, length: 1 },
  1280. tracking: {
  1281. ts: this.timestamp.toJSON(),
  1282. type: 'insert',
  1283. userId: 'user-id',
  1284. },
  1285. },
  1286. ],
  1287. },
  1288. },
  1289. doc: this.doc_id,
  1290. meta: {
  1291. ts: this.timestamp,
  1292. },
  1293. }
  1294. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1295. await ProjectHistoryClient.flushProject(this.project_id)
  1296. assert(
  1297. fixTrackedChange.isDone(),
  1298. `/api/projects/${historyId}/changes should have been called`
  1299. )
  1300. })
  1301. })
  1302. describe("when a doc's ranges are out of sync", function () {
  1303. const commentId = 'comment-id'
  1304. beforeEach(function () {
  1305. MockHistoryStore()
  1306. .get(`/api/projects/${historyId}/latest/history`)
  1307. .reply(200, {
  1308. chunk: {
  1309. history: {
  1310. snapshot: {
  1311. files: {
  1312. 'main.tex': {
  1313. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  1314. rangesHash: '0a207c060e61f3b88eaee0a8cd0696f46fb155ec',
  1315. stringLength: 3,
  1316. },
  1317. },
  1318. },
  1319. changes: [],
  1320. },
  1321. startVersion: 0,
  1322. },
  1323. })
  1324. MockHistoryStore()
  1325. .get(
  1326. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  1327. )
  1328. .reply(200, 'a\nb')
  1329. MockHistoryStore()
  1330. .get(
  1331. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155ec`
  1332. )
  1333. .reply(
  1334. 200,
  1335. JSON.stringify({
  1336. comments: [{ id: commentId, ranges: [{ pos: 0, length: 3 }] }],
  1337. trackedChanges: [
  1338. {
  1339. range: { pos: 0, length: 1 },
  1340. tracking: {
  1341. ts: this.timestamp.toJSON(),
  1342. type: 'delete',
  1343. userId: 'user-id',
  1344. },
  1345. },
  1346. {
  1347. range: { pos: 2, length: 1 },
  1348. tracking: {
  1349. ts: this.timestamp.toJSON(),
  1350. type: 'insert',
  1351. userId: 'user-id',
  1352. },
  1353. },
  1354. ],
  1355. })
  1356. )
  1357. })
  1358. it('should fix comments in the history store', async function () {
  1359. const addComment = MockHistoryStore()
  1360. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1361. expect(body).to.deep.equal([
  1362. {
  1363. v2Authors: [],
  1364. authors: [],
  1365. timestamp: this.timestamp.toJSON(),
  1366. operations: [
  1367. {
  1368. pathname: 'main.tex',
  1369. commentId,
  1370. ranges: [{ pos: 1, length: 2 }],
  1371. },
  1372. ],
  1373. origin: { kind: 'test-origin' },
  1374. },
  1375. ])
  1376. return true
  1377. })
  1378. .query({ end_version: 0 })
  1379. .reply(204)
  1380. await ProjectHistoryClient.resyncHistory(this.project_id)
  1381. const update1 = {
  1382. projectHistoryId: historyId,
  1383. resyncProjectStructure: {
  1384. docs: [{ path: '/main.tex' }],
  1385. files: [],
  1386. },
  1387. meta: {
  1388. ts: this.timestamp,
  1389. },
  1390. }
  1391. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1392. const update2 = {
  1393. path: '/main.tex',
  1394. projectHistoryId: historyId,
  1395. resyncDocContent: {
  1396. content: 'a\nb',
  1397. ranges: {
  1398. comments: [
  1399. {
  1400. id: commentId,
  1401. op: {
  1402. c: 'a',
  1403. p: 0,
  1404. hpos: 1,
  1405. hlen: 2,
  1406. t: commentId,
  1407. },
  1408. meta: {
  1409. user_id: 'user-id',
  1410. ts: this.timestamp,
  1411. },
  1412. },
  1413. ],
  1414. changes: [
  1415. {
  1416. id: 'id1',
  1417. op: {
  1418. d: 'a',
  1419. p: 0,
  1420. },
  1421. metadata: {
  1422. user_id: 'user-id',
  1423. ts: this.timestamp,
  1424. },
  1425. },
  1426. {
  1427. id: 'id2',
  1428. op: {
  1429. i: '\n',
  1430. p: 1,
  1431. hpos: 2,
  1432. },
  1433. metadata: {
  1434. user_id: 'user-id',
  1435. ts: this.timestamp,
  1436. },
  1437. },
  1438. ],
  1439. },
  1440. },
  1441. doc: this.doc_id,
  1442. meta: {
  1443. ts: this.timestamp,
  1444. },
  1445. }
  1446. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1447. await ProjectHistoryClient.flushProject(this.project_id)
  1448. assert(
  1449. addComment.isDone(),
  1450. `/api/projects/${historyId}/changes should have been called`
  1451. )
  1452. })
  1453. it('should fix resolved state for comments in the history store', async function () {
  1454. const addComment = MockHistoryStore()
  1455. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1456. expect(body).to.deep.equal([
  1457. {
  1458. v2Authors: [],
  1459. authors: [],
  1460. timestamp: this.timestamp.toJSON(),
  1461. operations: [
  1462. {
  1463. pathname: 'main.tex',
  1464. commentId,
  1465. resolved: true,
  1466. },
  1467. ],
  1468. origin: { kind: 'test-origin' },
  1469. },
  1470. ])
  1471. return true
  1472. })
  1473. .query({ end_version: 0 })
  1474. .reply(204)
  1475. await ProjectHistoryClient.resyncHistory(this.project_id)
  1476. const update1 = {
  1477. projectHistoryId: historyId,
  1478. resyncProjectStructure: {
  1479. docs: [{ path: '/main.tex' }],
  1480. files: [],
  1481. },
  1482. meta: {
  1483. ts: this.timestamp,
  1484. },
  1485. }
  1486. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1487. const update2 = {
  1488. path: '/main.tex',
  1489. projectHistoryId: historyId,
  1490. resyncDocContent: {
  1491. content: 'a\nb',
  1492. resolvedCommentIds: [commentId],
  1493. ranges: {
  1494. comments: [
  1495. {
  1496. id: commentId,
  1497. op: {
  1498. c: 'a',
  1499. p: 0,
  1500. hpos: 0,
  1501. hlen: 3,
  1502. t: commentId,
  1503. },
  1504. meta: {
  1505. user_id: 'user-id',
  1506. ts: this.timestamp,
  1507. },
  1508. },
  1509. ],
  1510. changes: [
  1511. {
  1512. id: 'id1',
  1513. op: {
  1514. d: 'a',
  1515. p: 0,
  1516. },
  1517. metadata: {
  1518. user_id: 'user-id',
  1519. ts: this.timestamp,
  1520. },
  1521. },
  1522. {
  1523. id: 'id2',
  1524. op: {
  1525. i: '\n',
  1526. p: 1,
  1527. hpos: 2,
  1528. },
  1529. metadata: {
  1530. user_id: 'user-id',
  1531. ts: this.timestamp,
  1532. },
  1533. },
  1534. ],
  1535. },
  1536. },
  1537. doc: this.doc_id,
  1538. meta: {
  1539. ts: this.timestamp,
  1540. },
  1541. }
  1542. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1543. await ProjectHistoryClient.flushProject(this.project_id)
  1544. assert(
  1545. addComment.isDone(),
  1546. `/api/projects/${historyId}/changes should have been called`
  1547. )
  1548. })
  1549. it('should fix comments in the history store (history-ot)', async function () {
  1550. const addComment = MockHistoryStore()
  1551. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1552. expect(body).to.deep.equal([
  1553. {
  1554. v2Authors: [],
  1555. authors: [],
  1556. timestamp: this.timestamp.toJSON(),
  1557. operations: [
  1558. {
  1559. pathname: 'main.tex',
  1560. commentId,
  1561. ranges: [{ pos: 1, length: 2 }],
  1562. },
  1563. ],
  1564. origin: { kind: 'test-origin' },
  1565. },
  1566. ])
  1567. return true
  1568. })
  1569. .query({ end_version: 0 })
  1570. .reply(204)
  1571. await ProjectHistoryClient.resyncHistory(this.project_id)
  1572. const update1 = {
  1573. projectHistoryId: historyId,
  1574. resyncProjectStructure: {
  1575. docs: [{ path: '/main.tex' }],
  1576. files: [],
  1577. },
  1578. meta: {
  1579. ts: this.timestamp,
  1580. },
  1581. }
  1582. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1583. const update2 = {
  1584. path: '/main.tex',
  1585. projectHistoryId: historyId,
  1586. resyncDocContent: {
  1587. content: 'a\nb',
  1588. historyOTRanges: {
  1589. comments: [
  1590. {
  1591. id: commentId,
  1592. ranges: [
  1593. {
  1594. pos: 1,
  1595. length: 2,
  1596. },
  1597. ],
  1598. },
  1599. ],
  1600. trackedChanges: [
  1601. {
  1602. range: { pos: 0, length: 1 },
  1603. tracking: {
  1604. ts: this.timestamp.toJSON(),
  1605. type: 'delete',
  1606. userId: 'user-id',
  1607. },
  1608. },
  1609. {
  1610. range: { pos: 2, length: 1 },
  1611. tracking: {
  1612. ts: this.timestamp.toJSON(),
  1613. type: 'insert',
  1614. userId: 'user-id',
  1615. },
  1616. },
  1617. ],
  1618. },
  1619. },
  1620. doc: this.doc_id,
  1621. meta: {
  1622. ts: this.timestamp,
  1623. },
  1624. }
  1625. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1626. await ProjectHistoryClient.flushProject(this.project_id)
  1627. assert(
  1628. addComment.isDone(),
  1629. `/api/projects/${historyId}/changes should have been called`
  1630. )
  1631. })
  1632. it('should fix resolved state for comments in the history store (history-ot)', async function () {
  1633. const addComment = MockHistoryStore()
  1634. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1635. expect(body).to.deep.equal([
  1636. {
  1637. v2Authors: [],
  1638. authors: [],
  1639. timestamp: this.timestamp.toJSON(),
  1640. operations: [
  1641. {
  1642. pathname: 'main.tex',
  1643. commentId,
  1644. resolved: true,
  1645. },
  1646. ],
  1647. origin: { kind: 'test-origin' },
  1648. },
  1649. ])
  1650. return true
  1651. })
  1652. .query({ end_version: 0 })
  1653. .reply(204)
  1654. await ProjectHistoryClient.resyncHistory(this.project_id)
  1655. const update1 = {
  1656. projectHistoryId: historyId,
  1657. resyncProjectStructure: {
  1658. docs: [{ path: '/main.tex' }],
  1659. files: [],
  1660. },
  1661. meta: {
  1662. ts: this.timestamp,
  1663. },
  1664. }
  1665. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1666. const update2 = {
  1667. path: '/main.tex',
  1668. projectHistoryId: historyId,
  1669. resyncDocContent: {
  1670. content: 'a\nb',
  1671. historyOTRanges: {
  1672. comments: [
  1673. {
  1674. id: commentId,
  1675. ranges: [
  1676. {
  1677. pos: 0,
  1678. length: 3,
  1679. },
  1680. ],
  1681. resolved: true,
  1682. },
  1683. ],
  1684. trackedChanges: [
  1685. {
  1686. range: { pos: 0, length: 1 },
  1687. tracking: {
  1688. ts: this.timestamp.toJSON(),
  1689. type: 'delete',
  1690. userId: 'user-id',
  1691. },
  1692. },
  1693. {
  1694. range: { pos: 2, length: 1 },
  1695. tracking: {
  1696. ts: this.timestamp.toJSON(),
  1697. type: 'insert',
  1698. userId: 'user-id',
  1699. },
  1700. },
  1701. ],
  1702. },
  1703. },
  1704. doc: this.doc_id,
  1705. meta: {
  1706. ts: this.timestamp,
  1707. },
  1708. }
  1709. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1710. await ProjectHistoryClient.flushProject(this.project_id)
  1711. assert(
  1712. addComment.isDone(),
  1713. `/api/projects/${historyId}/changes should have been called`
  1714. )
  1715. })
  1716. it('should fix tracked changes in the history store', async function () {
  1717. const fixTrackedChange = MockHistoryStore()
  1718. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1719. expect(body).to.deep.equal([
  1720. {
  1721. v2Authors: [],
  1722. authors: [],
  1723. timestamp: this.timestamp.toJSON(),
  1724. operations: [
  1725. {
  1726. pathname: 'main.tex',
  1727. textOperation: [
  1728. 1,
  1729. {
  1730. r: 1,
  1731. tracking: {
  1732. ts: this.timestamp.toJSON(),
  1733. type: 'insert',
  1734. userId: 'user-id',
  1735. },
  1736. },
  1737. {
  1738. r: 1,
  1739. tracking: {
  1740. type: 'none',
  1741. },
  1742. },
  1743. ],
  1744. },
  1745. ],
  1746. origin: { kind: 'test-origin' },
  1747. },
  1748. ])
  1749. return true
  1750. })
  1751. .query({ end_version: 0 })
  1752. .reply(204)
  1753. await ProjectHistoryClient.resyncHistory(this.project_id)
  1754. const update1 = {
  1755. projectHistoryId: historyId,
  1756. resyncProjectStructure: {
  1757. docs: [{ path: '/main.tex' }],
  1758. files: [],
  1759. },
  1760. meta: {
  1761. ts: this.timestamp,
  1762. },
  1763. }
  1764. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1765. const update2 = {
  1766. path: '/main.tex',
  1767. projectHistoryId: historyId,
  1768. resyncDocContent: {
  1769. content: 'a\nb',
  1770. ranges: {
  1771. comments: [
  1772. {
  1773. id: commentId,
  1774. op: {
  1775. c: 'a',
  1776. p: 0,
  1777. hpos: 0,
  1778. hlen: 3,
  1779. t: commentId,
  1780. },
  1781. meta: {
  1782. user_id: 'user-id',
  1783. ts: this.timestamp,
  1784. },
  1785. },
  1786. ],
  1787. changes: [
  1788. {
  1789. id: 'id1',
  1790. op: {
  1791. d: 'a',
  1792. p: 0,
  1793. },
  1794. metadata: {
  1795. user_id: 'user-id',
  1796. ts: this.timestamp,
  1797. },
  1798. },
  1799. {
  1800. id: 'id2',
  1801. op: {
  1802. i: '\n',
  1803. p: 0,
  1804. hpos: 1,
  1805. },
  1806. metadata: {
  1807. user_id: 'user-id',
  1808. ts: this.timestamp,
  1809. },
  1810. },
  1811. ],
  1812. },
  1813. },
  1814. doc: this.doc_id,
  1815. meta: {
  1816. ts: this.timestamp,
  1817. },
  1818. }
  1819. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1820. await ProjectHistoryClient.flushProject(this.project_id)
  1821. assert(
  1822. fixTrackedChange.isDone(),
  1823. `/api/projects/${historyId}/changes should have been called`
  1824. )
  1825. })
  1826. it('should fix tracked changes in the history store (history-ot)', async function () {
  1827. const fixTrackedChange = MockHistoryStore()
  1828. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1829. expect(body).to.deep.equal([
  1830. {
  1831. v2Authors: [],
  1832. authors: [],
  1833. timestamp: this.timestamp.toJSON(),
  1834. operations: [
  1835. {
  1836. pathname: 'main.tex',
  1837. textOperation: [
  1838. 1,
  1839. {
  1840. r: 1,
  1841. tracking: {
  1842. ts: this.timestamp.toJSON(),
  1843. type: 'insert',
  1844. userId: 'user-id',
  1845. },
  1846. },
  1847. {
  1848. r: 1,
  1849. tracking: {
  1850. type: 'none',
  1851. },
  1852. },
  1853. ],
  1854. },
  1855. ],
  1856. origin: { kind: 'test-origin' },
  1857. },
  1858. ])
  1859. return true
  1860. })
  1861. .query({ end_version: 0 })
  1862. .reply(204)
  1863. await ProjectHistoryClient.resyncHistory(this.project_id)
  1864. const update1 = {
  1865. projectHistoryId: historyId,
  1866. resyncProjectStructure: {
  1867. docs: [{ path: '/main.tex' }],
  1868. files: [],
  1869. },
  1870. meta: {
  1871. ts: this.timestamp,
  1872. },
  1873. }
  1874. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1875. const update2 = {
  1876. path: '/main.tex',
  1877. projectHistoryId: historyId,
  1878. resyncDocContent: {
  1879. content: 'a\nb',
  1880. historyOTRanges: {
  1881. comments: [
  1882. {
  1883. id: commentId,
  1884. ranges: [
  1885. {
  1886. pos: 0,
  1887. length: 3,
  1888. },
  1889. ],
  1890. },
  1891. ],
  1892. trackedChanges: [
  1893. {
  1894. range: { pos: 0, length: 1 },
  1895. tracking: {
  1896. ts: this.timestamp.toJSON(),
  1897. type: 'delete',
  1898. userId: 'user-id',
  1899. },
  1900. },
  1901. {
  1902. range: { pos: 1, length: 1 },
  1903. tracking: {
  1904. ts: this.timestamp.toJSON(),
  1905. type: 'insert',
  1906. userId: 'user-id',
  1907. },
  1908. },
  1909. ],
  1910. },
  1911. },
  1912. doc: this.doc_id,
  1913. meta: {
  1914. ts: this.timestamp,
  1915. },
  1916. }
  1917. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1918. await ProjectHistoryClient.flushProject(this.project_id)
  1919. assert(
  1920. fixTrackedChange.isDone(),
  1921. `/api/projects/${historyId}/changes should have been called`
  1922. )
  1923. })
  1924. it('should fix both comments and tracked changes in the history store (history-ot)', async function () {
  1925. const fixTrackedChange = MockHistoryStore()
  1926. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1927. expect(body).to.deep.equal([
  1928. // not merged due to comment operation using history-ot and tracked-changes operation using sharejs ot
  1929. {
  1930. v2Authors: [],
  1931. authors: [],
  1932. timestamp: this.timestamp.toJSON(),
  1933. operations: [
  1934. {
  1935. pathname: 'main.tex',
  1936. commentId,
  1937. ranges: [{ pos: 1, length: 2 }],
  1938. },
  1939. ],
  1940. origin: { kind: 'test-origin' },
  1941. },
  1942. {
  1943. v2Authors: [],
  1944. authors: [],
  1945. timestamp: this.timestamp.toJSON(),
  1946. operations: [
  1947. {
  1948. pathname: 'main.tex',
  1949. textOperation: [
  1950. 1,
  1951. {
  1952. r: 1,
  1953. tracking: {
  1954. ts: this.timestamp.toJSON(),
  1955. type: 'insert',
  1956. userId: 'user-id',
  1957. },
  1958. },
  1959. {
  1960. r: 1,
  1961. tracking: {
  1962. type: 'none',
  1963. },
  1964. },
  1965. ],
  1966. },
  1967. ],
  1968. origin: { kind: 'test-origin' },
  1969. },
  1970. ])
  1971. return true
  1972. })
  1973. .query({ end_version: 0 })
  1974. .reply(204)
  1975. await ProjectHistoryClient.resyncHistory(this.project_id)
  1976. const update1 = {
  1977. projectHistoryId: historyId,
  1978. resyncProjectStructure: {
  1979. docs: [{ path: '/main.tex' }],
  1980. files: [],
  1981. },
  1982. meta: {
  1983. ts: this.timestamp,
  1984. },
  1985. }
  1986. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1987. const update2 = {
  1988. path: '/main.tex',
  1989. projectHistoryId: historyId,
  1990. resyncDocContent: {
  1991. content: 'a\nb',
  1992. historyOTRanges: {
  1993. comments: [
  1994. {
  1995. id: commentId,
  1996. ranges: [
  1997. {
  1998. pos: 1,
  1999. length: 2,
  2000. },
  2001. ],
  2002. },
  2003. ],
  2004. trackedChanges: [
  2005. {
  2006. range: { pos: 0, length: 1 },
  2007. tracking: {
  2008. ts: this.timestamp.toJSON(),
  2009. type: 'delete',
  2010. userId: 'user-id',
  2011. },
  2012. },
  2013. {
  2014. range: { pos: 1, length: 1 },
  2015. tracking: {
  2016. ts: this.timestamp.toJSON(),
  2017. type: 'insert',
  2018. userId: 'user-id',
  2019. },
  2020. },
  2021. ],
  2022. },
  2023. },
  2024. doc: this.doc_id,
  2025. meta: {
  2026. ts: this.timestamp,
  2027. },
  2028. }
  2029. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  2030. await ProjectHistoryClient.flushProject(this.project_id)
  2031. assert(
  2032. fixTrackedChange.isDone(),
  2033. `/api/projects/${historyId}/changes should have been called`
  2034. )
  2035. })
  2036. })
  2037. describe('resyncProjectStructureOnly', function () {
  2038. it('should handle structure only updates', async function () {
  2039. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  2040. MockHistoryStore()
  2041. .get(`/api/projects/${historyId}/latest/history`)
  2042. .reply(200, {
  2043. chunk: {
  2044. history: {
  2045. snapshot: {
  2046. files: {
  2047. 'main.tex': {
  2048. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  2049. stringLength: 3,
  2050. },
  2051. },
  2052. },
  2053. changes: [],
  2054. },
  2055. startVersion: 0,
  2056. },
  2057. })
  2058. const docContentRequest = MockHistoryStore()
  2059. .get(
  2060. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  2061. )
  2062. .reply(200, 'a\nb')
  2063. MockHistoryStore()
  2064. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  2065. .reply(200)
  2066. const addFile = MockHistoryStore()
  2067. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  2068. expect(body).to.deep.equal([
  2069. {
  2070. v2Authors: [],
  2071. authors: [],
  2072. timestamp: this.timestamp.toJSON(),
  2073. operations: [
  2074. {
  2075. pathname: 'test.png',
  2076. file: {
  2077. hash: fileHash,
  2078. },
  2079. },
  2080. ],
  2081. origin: { kind: 'test-origin' },
  2082. },
  2083. ])
  2084. return true
  2085. })
  2086. .query({ end_version: 0 })
  2087. .reply(204)
  2088. // allow a 2nd resync
  2089. MockWeb()
  2090. .post(`/project/${this.project_id}/history/resync`)
  2091. .reply(204)
  2092. await ProjectHistoryClient.resyncHistory(this.project_id)
  2093. const update = {
  2094. projectHistoryId: historyId,
  2095. resyncProjectStructureOnly: true,
  2096. resyncProjectStructure: {
  2097. docs: [{ path: '/main.tex' }],
  2098. files: [
  2099. {
  2100. file: this.file_id,
  2101. path: '/test.png',
  2102. _hash: fileHash,
  2103. createdBlob: true,
  2104. },
  2105. ],
  2106. },
  2107. meta: {
  2108. ts: this.timestamp,
  2109. },
  2110. }
  2111. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  2112. await ProjectHistoryClient.flushProject(this.project_id)
  2113. // fails when previous resync did not finish
  2114. await ProjectHistoryClient.resyncHistory(this.project_id)
  2115. assert(
  2116. addFile.isDone(),
  2117. `/api/projects/${historyId}/changes should have been called`
  2118. )
  2119. assert(
  2120. !docContentRequest.isDone(),
  2121. 'should not have requested doc content'
  2122. )
  2123. })
  2124. it('should reject partial resync on docs', async function () {
  2125. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  2126. MockHistoryStore()
  2127. .get(`/api/projects/${historyId}/latest/history`)
  2128. .reply(200, {
  2129. chunk: {
  2130. history: {
  2131. snapshot: {
  2132. files: {
  2133. 'main.tex': {
  2134. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  2135. stringLength: 3,
  2136. },
  2137. },
  2138. },
  2139. changes: [],
  2140. },
  2141. startVersion: 0,
  2142. },
  2143. })
  2144. const docContentRequest = MockHistoryStore()
  2145. .get(
  2146. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  2147. )
  2148. .reply(200, 'a\nb')
  2149. MockHistoryStore()
  2150. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  2151. .reply(200)
  2152. const addFile = MockHistoryStore()
  2153. .post(`/api/projects/${historyId}/legacy_changes`)
  2154. .query({ end_version: 0 })
  2155. .reply(204)
  2156. // allow a 2nd resync
  2157. MockWeb()
  2158. .post(`/project/${this.project_id}/history/resync`)
  2159. .reply(204)
  2160. await ProjectHistoryClient.resyncHistory(this.project_id)
  2161. const update = {
  2162. projectHistoryId: historyId,
  2163. resyncProjectStructureOnly: true,
  2164. resyncProjectStructure: {
  2165. docs: [{ path: '/main-renamed.tex' }],
  2166. files: [
  2167. {
  2168. file: this.file_id,
  2169. path: '/test.png',
  2170. _hash: fileHash,
  2171. createdBlob: true,
  2172. },
  2173. ],
  2174. },
  2175. meta: {
  2176. ts: this.timestamp,
  2177. },
  2178. }
  2179. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  2180. const res = await ProjectHistoryClient.flushProject(this.project_id, {
  2181. allowErrors: true,
  2182. })
  2183. expect(res.statusCode).to.equal(500)
  2184. expect(loggerError).to.have.been.calledWith(
  2185. sinon.match({
  2186. err: {
  2187. name: 'NeedFullProjectStructureResyncError',
  2188. message: 'aborting partial resync: touched doc',
  2189. },
  2190. })
  2191. )
  2192. const failure = await new Promise((resolve, reject) => {
  2193. getFailure(this.project_id, (err, failure) => {
  2194. if (err) return reject(err)
  2195. resolve(failure)
  2196. })
  2197. })
  2198. expect(failure).to.include({
  2199. error:
  2200. 'NeedFullProjectStructureResyncError: aborting partial resync: touched doc',
  2201. })
  2202. // fails when previous resync did not finish
  2203. await ProjectHistoryClient.resyncHistory(this.project_id)
  2204. assert(!addFile.isDone(), 'should not have persisted changes')
  2205. assert(
  2206. !docContentRequest.isDone(),
  2207. 'should not have requested doc content'
  2208. )
  2209. })
  2210. })
  2211. })
  2212. })
  2213. // Regression: UpdateCompressor.compressUpdates optimizes a delete+insert
  2214. // at the same position by diffing the two strings and retaining common
  2215. // substrings. The TC retain ops are computed by the SyncManager based on
  2216. // the original (unoptimized) content diff. When the optimized ops retain
  2217. // characters that the original diff deleted, tracked changes on those
  2218. // characters survive — but the TC retains don't clear them, producing a
  2219. // ghost tracked change.
  2220. describe('resync with tracked changes overlapping content diff', function () {
  2221. const TIMESTAMP = '2025-01-01T00:00:00.000Z'
  2222. const persisted = {
  2223. content: 'lblcdhqcmihkrvzlifscqmwytt\n\ndgakoxboqpzdbbjtom',
  2224. trackedChanges: [
  2225. {
  2226. range: { pos: 2, length: 3 },
  2227. tracking: { type: 'insert', userId: 'user-2', ts: TIMESTAMP },
  2228. },
  2229. {
  2230. range: { pos: 12, length: 4 },
  2231. tracking: { type: 'delete', userId: 'user-2', ts: TIMESTAMP },
  2232. },
  2233. {
  2234. range: { pos: 26, length: 3 },
  2235. tracking: { type: 'delete', userId: 'user-2', ts: TIMESTAMP },
  2236. },
  2237. {
  2238. range: { pos: 32, length: 5 },
  2239. tracking: { type: 'delete', userId: 'user-2', ts: TIMESTAMP },
  2240. },
  2241. ],
  2242. }
  2243. const expected = {
  2244. content: 'lblchfjaqdhqcmihkrvzliftt\ndoqpzdbom',
  2245. trackedChanges: [
  2246. {
  2247. range: { pos: 0, length: 4 },
  2248. tracking: { type: 'insert', userId: 'user-2', ts: TIMESTAMP },
  2249. },
  2250. {
  2251. range: { pos: 11, length: 1 },
  2252. tracking: { type: 'delete', userId: 'user-2', ts: TIMESTAMP },
  2253. },
  2254. {
  2255. range: { pos: 17, length: 5 },
  2256. tracking: { type: 'delete', userId: 'user-2', ts: TIMESTAMP },
  2257. },
  2258. {
  2259. range: { pos: 22, length: 3 },
  2260. tracking: { type: 'insert', userId: 'user-1', ts: TIMESTAMP },
  2261. },
  2262. {
  2263. range: { pos: 26, length: 5 },
  2264. tracking: { type: 'delete', userId: 'user-1', ts: TIMESTAMP },
  2265. },
  2266. ],
  2267. }
  2268. it('should not leave ghost tracked changes after resync', async function () {
  2269. const rangesBlob = JSON.stringify({
  2270. comments: [],
  2271. trackedChanges: persisted.trackedChanges,
  2272. })
  2273. const contentHash = _getBlobHashFromString(persisted.content)
  2274. const rangesHash = _getBlobHashFromString(rangesBlob)
  2275. MockHistoryStore()
  2276. .get(`/api/projects/${historyId}/latest/history`)
  2277. .reply(200, {
  2278. chunk: {
  2279. history: {
  2280. snapshot: {
  2281. files: {
  2282. 'main.tex': {
  2283. hash: contentHash,
  2284. stringLength: persisted.content.length,
  2285. rangesHash,
  2286. },
  2287. },
  2288. },
  2289. changes: [],
  2290. },
  2291. startVersion: 0,
  2292. },
  2293. })
  2294. MockHistoryStore()
  2295. .get(`/api/projects/${historyId}/blobs/${contentHash}`)
  2296. .reply(200, persisted.content)
  2297. MockHistoryStore()
  2298. .get(`/api/projects/${historyId}/blobs/${rangesHash}`)
  2299. .reply(200, rangesBlob)
  2300. MockHistoryStore()
  2301. .put(/\/api\/projects\/[^/]+\/blobs\/[0-9a-f]+/)
  2302. .optionally()
  2303. .reply(201)
  2304. const allCapturedChanges = []
  2305. MockHistoryStore()
  2306. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  2307. allCapturedChanges.push(...body)
  2308. return true
  2309. })
  2310. .query(true)
  2311. .times(5)
  2312. .optionally()
  2313. .reply(204)
  2314. MockWeb().post(`/project/${this.project_id}/history/resync`).reply(204)
  2315. await ProjectHistoryClient.resyncHistory(this.project_id)
  2316. await ProjectHistoryClient.pushRawUpdate(this.project_id, {
  2317. projectHistoryId: historyId,
  2318. resyncProjectStructure: {
  2319. docs: [{ path: '/main.tex' }],
  2320. files: [],
  2321. },
  2322. meta: { ts: this.timestamp },
  2323. })
  2324. await ProjectHistoryClient.pushRawUpdate(this.project_id, {
  2325. path: '/main.tex',
  2326. projectHistoryId: historyId,
  2327. resyncDocContent: {
  2328. content: expected.content,
  2329. historyOTRanges: {
  2330. comments: [],
  2331. trackedChanges: expected.trackedChanges,
  2332. },
  2333. },
  2334. doc: this.doc_id,
  2335. meta: { ts: this.timestamp },
  2336. })
  2337. await ProjectHistoryClient.flushProject(this.project_id)
  2338. const fileData = new StringFileData(
  2339. persisted.content,
  2340. [],
  2341. persisted.trackedChanges
  2342. )
  2343. const snapshot = new Snapshot(
  2344. new FileMap({ 'main.tex': new File(fileData) })
  2345. )
  2346. for (const rawChange of allCapturedChanges) {
  2347. Change.fromRaw(rawChange).applyTo(snapshot)
  2348. }
  2349. const file = snapshot.getFile('main.tex')
  2350. assert.strictEqual(file.getContent(), expected.content, 'content')
  2351. assert.deepStrictEqual(
  2352. file.getTrackedChanges().toRaw(),
  2353. expected.trackedChanges,
  2354. `TC mismatch.\n` +
  2355. ` expected: ${JSON.stringify(expected.trackedChanges)}\n` +
  2356. ` actual: ${JSON.stringify(file.getTrackedChanges().toRaw())}`
  2357. )
  2358. })
  2359. })
  2360. })