SyncTests.js 99 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110
  1. import nock from 'nock'
  2. import { expect } from 'chai'
  3. import assert from 'node:assert'
  4. import logger from '@overleaf/logger'
  5. import Settings from '@overleaf/settings'
  6. import {
  7. Snapshot,
  8. File,
  9. FileMap,
  10. StringFileData,
  11. Change,
  12. } from 'overleaf-editor-core'
  13. import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
  14. import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
  15. import sinon from 'sinon'
  16. import { getFailure } from './helpers/ProjectHistoryClient.js'
  17. import { fetchNothing, RequestFailedError } from '@overleaf/fetch-utils'
  18. import { _getBlobHashFromString } from '../../../app/js/HashManager.js'
  19. import { db, ObjectId } from '../../../app/js/mongodb.js'
  20. const EMPTY_FILE_HASH = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
  21. const MockHistoryStore = () => nock('http://127.0.0.1:3100')
  22. const MockFileStore = () => nock('http://127.0.0.1:3009')
  23. const MockWeb = () => nock('http://127.0.0.1:3000')
  24. describe('Syncing with web and doc-updater', function () {
  25. const historyId = new ObjectId().toString()
  26. let loggerWarn, loggerError
  27. beforeEach(function () {
  28. loggerWarn = sinon.spy(logger, 'warn')
  29. loggerError = sinon.spy(logger, 'error')
  30. })
  31. afterEach(function () {
  32. loggerWarn.restore()
  33. loggerError.restore()
  34. })
  35. beforeEach(async function () {
  36. this.timestamp = new Date()
  37. await ProjectHistoryApp.ensureRunning()
  38. this.project_id = new ObjectId().toString()
  39. this.doc_id = new ObjectId().toString()
  40. this.file_id = new ObjectId().toString()
  41. await db.projects.insertOne({ _id: new ObjectId(this.project_id) })
  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}/legacy_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}/legacy_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}/legacy_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}/legacy_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}/legacy_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}/legacy_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}/legacy_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}/legacy_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}/legacy_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 beforeResync = new Date()
  879. const addFile = MockHistoryStore()
  880. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  881. expect(body).to.deep.equal([
  882. {
  883. v2Authors: [],
  884. authors: [],
  885. timestamp: this.timestamp.toJSON(),
  886. operations: [
  887. {
  888. pathname: 'main.tex',
  889. textOperation: [3, '\nc'],
  890. },
  891. ],
  892. origin: { kind: 'test-origin' },
  893. },
  894. ])
  895. return true
  896. })
  897. .query({ end_version: 0 })
  898. .reply(204)
  899. await ProjectHistoryClient.resyncHistory(this.project_id)
  900. const update1 = {
  901. projectHistoryId: historyId,
  902. resyncProjectStructure: {
  903. docs: [{ path: '/main.tex' }],
  904. files: [],
  905. },
  906. meta: {
  907. ts: this.timestamp,
  908. },
  909. }
  910. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  911. const update2 = {
  912. path: '/main.tex',
  913. projectHistoryId: historyId,
  914. resyncDocContent: {
  915. content: 'a\nb\nc',
  916. },
  917. doc: this.doc_id,
  918. meta: {
  919. ts: this.timestamp,
  920. },
  921. }
  922. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  923. await ProjectHistoryClient.flushProject(this.project_id)
  924. assert(
  925. addFile.isDone(),
  926. `/api/projects/${historyId}/legacy_changes should have been called`
  927. )
  928. const project = await db.projects.findOne({
  929. _id: new ObjectId(this.project_id),
  930. })
  931. assert(
  932. project.overleaf.history.lastResyncedAt > beforeResync,
  933. 'lastResyncedAt should have been updated when resync finished'
  934. )
  935. })
  936. it('should strip non-BMP characters in updates before sending to the history store', async function () {
  937. const addFile = MockHistoryStore()
  938. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  939. expect(body).to.deep.equal([
  940. {
  941. v2Authors: [],
  942. authors: [],
  943. timestamp: this.timestamp.toJSON(),
  944. operations: [
  945. {
  946. pathname: 'main.tex',
  947. textOperation: [3, '\n\uFFFD\uFFFDc'],
  948. },
  949. ],
  950. origin: { kind: 'test-origin' },
  951. },
  952. ])
  953. return true
  954. })
  955. .query({ end_version: 0 })
  956. .reply(204)
  957. await ProjectHistoryClient.resyncHistory(this.project_id)
  958. const update1 = {
  959. projectHistoryId: historyId,
  960. resyncProjectStructure: {
  961. docs: [{ path: '/main.tex' }],
  962. files: [],
  963. },
  964. meta: {
  965. ts: this.timestamp,
  966. },
  967. }
  968. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  969. const update2 = {
  970. path: '/main.tex',
  971. projectHistoryId: historyId,
  972. resyncDocContent: {
  973. content: 'a\nb\n\uD800\uDC00c',
  974. },
  975. doc: this.doc_id,
  976. meta: {
  977. ts: this.timestamp,
  978. },
  979. }
  980. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  981. await ProjectHistoryClient.flushProject(this.project_id)
  982. assert(
  983. addFile.isDone(),
  984. `/api/projects/${historyId}/legacy_changes should have been called`
  985. )
  986. })
  987. it('should add comments in the history store', async function () {
  988. const commentId = 'comment-id'
  989. const addComment = MockHistoryStore()
  990. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  991. expect(body).to.deep.equal([
  992. {
  993. v2Authors: [],
  994. authors: [],
  995. timestamp: this.timestamp.toJSON(),
  996. operations: [
  997. {
  998. pathname: 'main.tex',
  999. commentId,
  1000. ranges: [{ pos: 1, length: 10 }],
  1001. },
  1002. ],
  1003. origin: { kind: 'test-origin' },
  1004. },
  1005. ])
  1006. return true
  1007. })
  1008. .query({ end_version: 0 })
  1009. .reply(204)
  1010. await ProjectHistoryClient.resyncHistory(this.project_id)
  1011. const update1 = {
  1012. projectHistoryId: historyId,
  1013. resyncProjectStructure: {
  1014. docs: [{ path: '/main.tex' }],
  1015. files: [],
  1016. },
  1017. meta: {
  1018. ts: this.timestamp,
  1019. },
  1020. }
  1021. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1022. const update2 = {
  1023. path: '/main.tex',
  1024. projectHistoryId: historyId,
  1025. resyncDocContent: {
  1026. content: 'a\nb',
  1027. ranges: {
  1028. comments: [
  1029. {
  1030. id: commentId,
  1031. op: {
  1032. c: 'a',
  1033. p: 0,
  1034. hpos: 1,
  1035. hlen: 10,
  1036. t: commentId,
  1037. },
  1038. meta: {
  1039. user_id: 'user-id',
  1040. ts: this.timestamp,
  1041. },
  1042. },
  1043. ],
  1044. },
  1045. },
  1046. doc: this.doc_id,
  1047. meta: {
  1048. ts: this.timestamp,
  1049. },
  1050. }
  1051. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1052. await ProjectHistoryClient.flushProject(this.project_id)
  1053. assert(
  1054. addComment.isDone(),
  1055. `/api/projects/${historyId}/legacy_changes should have been called`
  1056. )
  1057. })
  1058. it('should add comments in the history store (history-ot)', async function () {
  1059. const commentId = 'comment-id'
  1060. const addComment = MockHistoryStore()
  1061. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1062. expect(body).to.deep.equal([
  1063. {
  1064. v2Authors: [],
  1065. authors: [],
  1066. timestamp: this.timestamp.toJSON(),
  1067. operations: [
  1068. {
  1069. pathname: 'main.tex',
  1070. commentId,
  1071. ranges: [{ pos: 1, length: 10 }],
  1072. },
  1073. ],
  1074. origin: { kind: 'test-origin' },
  1075. },
  1076. ])
  1077. return true
  1078. })
  1079. .query({ end_version: 0 })
  1080. .reply(204)
  1081. await ProjectHistoryClient.resyncHistory(this.project_id)
  1082. const update1 = {
  1083. projectHistoryId: historyId,
  1084. resyncProjectStructure: {
  1085. docs: [{ path: '/main.tex' }],
  1086. files: [],
  1087. },
  1088. meta: {
  1089. ts: this.timestamp,
  1090. },
  1091. }
  1092. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1093. const update2 = {
  1094. path: '/main.tex',
  1095. projectHistoryId: historyId,
  1096. resyncDocContent: {
  1097. content: 'a\nb',
  1098. historyOTRanges: {
  1099. comments: [
  1100. {
  1101. id: commentId,
  1102. ranges: [
  1103. {
  1104. pos: 1,
  1105. length: 10,
  1106. },
  1107. ],
  1108. },
  1109. ],
  1110. },
  1111. },
  1112. doc: this.doc_id,
  1113. meta: {
  1114. ts: this.timestamp,
  1115. },
  1116. }
  1117. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1118. await ProjectHistoryClient.flushProject(this.project_id)
  1119. assert(
  1120. addComment.isDone(),
  1121. `/api/projects/${historyId}/legacy_changes should have been called`
  1122. )
  1123. })
  1124. it('should add tracked changes in the history store', async function () {
  1125. const fixTrackedChange = MockHistoryStore()
  1126. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1127. expect(body).to.deep.equal([
  1128. {
  1129. v2Authors: [],
  1130. authors: [],
  1131. timestamp: this.timestamp.toJSON(),
  1132. operations: [
  1133. {
  1134. pathname: 'main.tex',
  1135. textOperation: [
  1136. {
  1137. r: 1,
  1138. tracking: {
  1139. ts: this.timestamp.toJSON(),
  1140. type: 'delete',
  1141. userId: 'user-id',
  1142. },
  1143. },
  1144. {
  1145. r: 1,
  1146. tracking: {
  1147. ts: this.timestamp.toJSON(),
  1148. type: 'insert',
  1149. userId: 'user-id',
  1150. },
  1151. },
  1152. 1,
  1153. ],
  1154. },
  1155. ],
  1156. origin: { kind: 'test-origin' },
  1157. },
  1158. ])
  1159. return true
  1160. })
  1161. .query({ end_version: 0 })
  1162. .reply(204)
  1163. await ProjectHistoryClient.resyncHistory(this.project_id)
  1164. const update1 = {
  1165. projectHistoryId: historyId,
  1166. resyncProjectStructure: {
  1167. docs: [{ path: '/main.tex' }],
  1168. files: [],
  1169. },
  1170. meta: {
  1171. ts: this.timestamp,
  1172. },
  1173. }
  1174. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1175. const update2 = {
  1176. path: '/main.tex',
  1177. projectHistoryId: historyId,
  1178. resyncDocContent: {
  1179. content: 'a\nb',
  1180. ranges: {
  1181. changes: [
  1182. {
  1183. id: 'id1',
  1184. op: {
  1185. d: 'a',
  1186. p: 0,
  1187. },
  1188. metadata: {
  1189. user_id: 'user-id',
  1190. ts: this.timestamp,
  1191. },
  1192. },
  1193. {
  1194. id: 'id2',
  1195. op: {
  1196. i: '\n',
  1197. p: 0,
  1198. hpos: 1,
  1199. },
  1200. metadata: {
  1201. user_id: 'user-id',
  1202. ts: this.timestamp,
  1203. },
  1204. },
  1205. ],
  1206. },
  1207. },
  1208. doc: this.doc_id,
  1209. meta: {
  1210. ts: this.timestamp,
  1211. },
  1212. }
  1213. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1214. await ProjectHistoryClient.flushProject(this.project_id)
  1215. assert(
  1216. fixTrackedChange.isDone(),
  1217. `/api/projects/${historyId}/legacy_changes should have been called`
  1218. )
  1219. })
  1220. it('should add tracked changes in the history store (history-ot)', async function () {
  1221. const fixTrackedChange = MockHistoryStore()
  1222. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1223. expect(body).to.deep.equal([
  1224. {
  1225. v2Authors: [],
  1226. authors: [],
  1227. timestamp: this.timestamp.toJSON(),
  1228. operations: [
  1229. {
  1230. pathname: 'main.tex',
  1231. textOperation: [
  1232. {
  1233. r: 1,
  1234. tracking: {
  1235. ts: this.timestamp.toJSON(),
  1236. type: 'delete',
  1237. userId: 'user-id',
  1238. },
  1239. },
  1240. {
  1241. r: 1,
  1242. tracking: {
  1243. ts: this.timestamp.toJSON(),
  1244. type: 'insert',
  1245. userId: 'user-id',
  1246. },
  1247. },
  1248. 1,
  1249. ],
  1250. },
  1251. ],
  1252. origin: { kind: 'test-origin' },
  1253. },
  1254. ])
  1255. return true
  1256. })
  1257. .query({ end_version: 0 })
  1258. .reply(204)
  1259. await ProjectHistoryClient.resyncHistory(this.project_id)
  1260. const update1 = {
  1261. projectHistoryId: historyId,
  1262. resyncProjectStructure: {
  1263. docs: [{ path: '/main.tex' }],
  1264. files: [],
  1265. },
  1266. meta: {
  1267. ts: this.timestamp,
  1268. },
  1269. }
  1270. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1271. const update2 = {
  1272. path: '/main.tex',
  1273. projectHistoryId: historyId,
  1274. resyncDocContent: {
  1275. content: 'a\nb',
  1276. historyOTRanges: {
  1277. trackedChanges: [
  1278. {
  1279. range: { pos: 0, length: 1 },
  1280. tracking: {
  1281. ts: this.timestamp.toJSON(),
  1282. type: 'delete',
  1283. userId: 'user-id',
  1284. },
  1285. },
  1286. {
  1287. range: { pos: 1, length: 1 },
  1288. tracking: {
  1289. ts: this.timestamp.toJSON(),
  1290. type: 'insert',
  1291. userId: 'user-id',
  1292. },
  1293. },
  1294. ],
  1295. },
  1296. },
  1297. doc: this.doc_id,
  1298. meta: {
  1299. ts: this.timestamp,
  1300. },
  1301. }
  1302. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1303. await ProjectHistoryClient.flushProject(this.project_id)
  1304. assert(
  1305. fixTrackedChange.isDone(),
  1306. `/api/projects/${historyId}/legacy_changes should have been called`
  1307. )
  1308. })
  1309. })
  1310. describe("when a doc's blob content is corrupted", function () {
  1311. beforeEach(function () {
  1312. MockHistoryStore()
  1313. .get(`/api/projects/${historyId}/latest/history`)
  1314. .reply(200, {
  1315. chunk: {
  1316. history: {
  1317. snapshot: {
  1318. files: {
  1319. 'main.tex': {
  1320. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  1321. stringLength: 10,
  1322. },
  1323. },
  1324. },
  1325. changes: [
  1326. {
  1327. operations: [
  1328. {
  1329. pathname: 'main.tex',
  1330. // Retain 10 chars, but blob content is only 3 chars.
  1331. // This simulates a corrupted history where the
  1332. // operation doesn't match the blob content, causing
  1333. // an ApplyError when the file is loaded eagerly.
  1334. textOperation: [10],
  1335. },
  1336. ],
  1337. timestamp: '2026-01-01T00:00:00.000Z',
  1338. authors: [],
  1339. v2Authors: [],
  1340. },
  1341. ],
  1342. },
  1343. startVersion: 0,
  1344. },
  1345. })
  1346. // Blob returns valid content, but it doesn't match the operation
  1347. MockHistoryStore()
  1348. .get(
  1349. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  1350. )
  1351. .reply(200, 'a\nb')
  1352. })
  1353. it('should remove and re-add the file during hard resync', async function () {
  1354. const addFile = MockHistoryStore()
  1355. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1356. // The corrupted file is removed and re-added as two changes
  1357. expect(body).to.have.length(2)
  1358. // First change: remove the corrupted file
  1359. expect(body[0].operations).to.have.length(1)
  1360. expect(body[0].operations[0]).to.deep.include({
  1361. pathname: 'main.tex',
  1362. newPathname: '',
  1363. })
  1364. // Second change: re-add with new blob from docstore
  1365. expect(body[1].operations).to.have.length(1)
  1366. expect(body[1].operations[0].pathname).to.equal('main.tex')
  1367. expect(body[1].operations[0].file).to.have.property('hash')
  1368. return true
  1369. })
  1370. .query({ end_version: 1 })
  1371. .reply(204)
  1372. // The re-added file needs its blob to be stored
  1373. MockHistoryStore()
  1374. .put(new RegExp(`/api/projects/${historyId}/blobs/`))
  1375. .reply(201)
  1376. await ProjectHistoryClient.hardResyncHistory(this.project_id, {
  1377. recoverCorruptedFiles: true,
  1378. })
  1379. const update1 = {
  1380. projectHistoryId: historyId,
  1381. resyncProjectStructure: {
  1382. docs: [{ path: '/main.tex' }],
  1383. files: [],
  1384. },
  1385. meta: {
  1386. ts: this.timestamp,
  1387. },
  1388. }
  1389. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1390. const update2 = {
  1391. path: '/main.tex',
  1392. projectHistoryId: historyId,
  1393. resyncDocContent: {
  1394. content: 'hello world',
  1395. },
  1396. doc: this.doc_id,
  1397. meta: {
  1398. ts: this.timestamp,
  1399. },
  1400. }
  1401. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1402. await ProjectHistoryClient.flushProject(this.project_id)
  1403. assert(
  1404. addFile.isDone(),
  1405. `/api/projects/${historyId}/legacy_changes should have been called`
  1406. )
  1407. sinon.assert.calledWithMatch(
  1408. loggerWarn,
  1409. sinon.match({
  1410. projectId: this.project_id,
  1411. pathname: 'main.tex',
  1412. }),
  1413. 'failed to load file from history during hard resync, removing and re-adding from docstore'
  1414. )
  1415. })
  1416. it('should restore comments and tracked changes from docstore after hard resync re-add', async function () {
  1417. const commentId = 'comment-id-1'
  1418. const addFile = MockHistoryStore()
  1419. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1420. // Expect three changes: remove, re-add, then comment sync
  1421. expect(body).to.have.length(3)
  1422. // First change: remove the corrupted file
  1423. expect(body[0].operations).to.have.length(1)
  1424. expect(body[0].operations[0]).to.deep.include({
  1425. pathname: 'main.tex',
  1426. newPathname: '',
  1427. })
  1428. // Second change: re-add with new blob from docstore
  1429. expect(body[1].operations).to.have.length(1)
  1430. expect(body[1].operations[0].pathname).to.equal('main.tex')
  1431. expect(body[1].operations[0].file).to.have.property('hash')
  1432. // Third change: restore comment from docstore ranges
  1433. expect(body[2].operations).to.have.length(1)
  1434. expect(body[2].operations[0]).to.deep.include({
  1435. pathname: 'main.tex',
  1436. commentId,
  1437. })
  1438. expect(body[2].operations[0].ranges).to.deep.equal([
  1439. { pos: 0, length: 5 },
  1440. ])
  1441. return true
  1442. })
  1443. .query({ end_version: 1 })
  1444. .reply(204)
  1445. // The re-added file needs its blob to be stored
  1446. MockHistoryStore()
  1447. .put(new RegExp(`/api/projects/${historyId}/blobs/`))
  1448. .reply(201)
  1449. await ProjectHistoryClient.hardResyncHistory(this.project_id, {
  1450. recoverCorruptedFiles: true,
  1451. })
  1452. const update1 = {
  1453. projectHistoryId: historyId,
  1454. resyncProjectStructure: {
  1455. docs: [{ path: '/main.tex' }],
  1456. files: [],
  1457. },
  1458. meta: {
  1459. ts: this.timestamp,
  1460. },
  1461. }
  1462. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1463. const update2 = {
  1464. path: '/main.tex',
  1465. projectHistoryId: historyId,
  1466. resyncDocContent: {
  1467. content: 'hello world',
  1468. ranges: {
  1469. comments: [
  1470. {
  1471. id: commentId,
  1472. op: {
  1473. c: 'hello',
  1474. p: 0,
  1475. hpos: 0,
  1476. hlen: 5,
  1477. t: commentId,
  1478. },
  1479. meta: {
  1480. user_id: 'user-id',
  1481. ts: this.timestamp,
  1482. },
  1483. },
  1484. ],
  1485. changes: [],
  1486. },
  1487. },
  1488. doc: this.doc_id,
  1489. meta: {
  1490. ts: this.timestamp,
  1491. },
  1492. }
  1493. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1494. await ProjectHistoryClient.flushProject(this.project_id)
  1495. assert(
  1496. addFile.isDone(),
  1497. `/api/projects/${historyId}/legacy_changes should have been called`
  1498. )
  1499. })
  1500. it('should propagate the error during soft resync', async function () {
  1501. await ProjectHistoryClient.resyncHistory(this.project_id)
  1502. const update1 = {
  1503. projectHistoryId: historyId,
  1504. resyncProjectStructure: {
  1505. docs: [{ path: '/main.tex' }],
  1506. files: [],
  1507. },
  1508. meta: {
  1509. ts: this.timestamp,
  1510. },
  1511. }
  1512. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1513. const update2 = {
  1514. path: '/main.tex',
  1515. projectHistoryId: historyId,
  1516. resyncDocContent: {
  1517. content: 'hello world',
  1518. },
  1519. doc: this.doc_id,
  1520. meta: {
  1521. ts: this.timestamp,
  1522. },
  1523. }
  1524. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1525. // Soft resync should fail — the error is propagated so it can be
  1526. // retried later (the blob store may be temporarily unavailable).
  1527. const { statusCode } = await ProjectHistoryClient.flushProject(
  1528. this.project_id,
  1529. { allowErrors: true }
  1530. )
  1531. expect(statusCode).to.equal(500)
  1532. })
  1533. it('should propagate the error during hard resync without recoverCorruptedFiles flag', async function () {
  1534. await ProjectHistoryClient.hardResyncHistory(this.project_id)
  1535. const update1 = {
  1536. projectHistoryId: historyId,
  1537. resyncProjectStructure: {
  1538. docs: [{ path: '/main.tex' }],
  1539. files: [],
  1540. },
  1541. meta: {
  1542. ts: this.timestamp,
  1543. },
  1544. }
  1545. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1546. const update2 = {
  1547. path: '/main.tex',
  1548. projectHistoryId: historyId,
  1549. resyncDocContent: {
  1550. content: 'hello world',
  1551. },
  1552. doc: this.doc_id,
  1553. meta: {
  1554. ts: this.timestamp,
  1555. },
  1556. }
  1557. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1558. // Hard resync without the recoverCorruptedFiles flag should not
  1559. // recover — recovery is too destructive to be the default behaviour.
  1560. const { statusCode } = await ProjectHistoryClient.flushProject(
  1561. this.project_id,
  1562. { allowErrors: true }
  1563. )
  1564. expect(statusCode).to.equal(500)
  1565. })
  1566. })
  1567. describe("when a doc's blob store is temporarily unavailable", function () {
  1568. beforeEach(function () {
  1569. MockHistoryStore()
  1570. .get(`/api/projects/${historyId}/latest/history`)
  1571. .reply(200, {
  1572. chunk: {
  1573. history: {
  1574. snapshot: {
  1575. files: {
  1576. 'main.tex': {
  1577. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  1578. stringLength: 3,
  1579. },
  1580. },
  1581. },
  1582. changes: [],
  1583. },
  1584. startVersion: 0,
  1585. },
  1586. })
  1587. // Blob returns a 500 simulating a transient server error
  1588. MockHistoryStore()
  1589. .get(
  1590. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  1591. )
  1592. .reply(500, 'Internal Server Error')
  1593. })
  1594. it('should propagate transient errors even during hard resync', async function () {
  1595. await ProjectHistoryClient.hardResyncHistory(this.project_id)
  1596. const update1 = {
  1597. projectHistoryId: historyId,
  1598. resyncProjectStructure: {
  1599. docs: [{ path: '/main.tex' }],
  1600. files: [],
  1601. },
  1602. meta: {
  1603. ts: this.timestamp,
  1604. },
  1605. }
  1606. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1607. const update2 = {
  1608. path: '/main.tex',
  1609. projectHistoryId: historyId,
  1610. resyncDocContent: {
  1611. content: 'hello world',
  1612. },
  1613. doc: this.doc_id,
  1614. meta: {
  1615. ts: this.timestamp,
  1616. },
  1617. }
  1618. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1619. // Even hard resync should not recover from transient errors (5xx).
  1620. // The error should propagate so it can be retried.
  1621. const { statusCode } = await ProjectHistoryClient.flushProject(
  1622. this.project_id,
  1623. { allowErrors: true }
  1624. )
  1625. expect(statusCode).to.equal(500)
  1626. })
  1627. })
  1628. describe("when a doc's ranges are out of sync", function () {
  1629. const commentId = 'comment-id'
  1630. beforeEach(function () {
  1631. MockHistoryStore()
  1632. .get(`/api/projects/${historyId}/latest/history`)
  1633. .reply(200, {
  1634. chunk: {
  1635. history: {
  1636. snapshot: {
  1637. files: {
  1638. 'main.tex': {
  1639. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  1640. rangesHash: '0a207c060e61f3b88eaee0a8cd0696f46fb155ec',
  1641. stringLength: 3,
  1642. },
  1643. },
  1644. },
  1645. changes: [],
  1646. },
  1647. startVersion: 0,
  1648. },
  1649. })
  1650. MockHistoryStore()
  1651. .get(
  1652. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  1653. )
  1654. .reply(200, 'a\nb')
  1655. MockHistoryStore()
  1656. .get(
  1657. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155ec`
  1658. )
  1659. .reply(
  1660. 200,
  1661. JSON.stringify({
  1662. comments: [{ id: commentId, ranges: [{ pos: 0, length: 3 }] }],
  1663. trackedChanges: [
  1664. {
  1665. range: { pos: 0, length: 1 },
  1666. tracking: {
  1667. ts: this.timestamp.toJSON(),
  1668. type: 'delete',
  1669. userId: 'user-id',
  1670. },
  1671. },
  1672. {
  1673. range: { pos: 2, length: 1 },
  1674. tracking: {
  1675. ts: this.timestamp.toJSON(),
  1676. type: 'insert',
  1677. userId: 'user-id',
  1678. },
  1679. },
  1680. ],
  1681. })
  1682. )
  1683. })
  1684. it('should fix comments in the history store', async function () {
  1685. const addComment = MockHistoryStore()
  1686. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1687. expect(body).to.deep.equal([
  1688. {
  1689. v2Authors: [],
  1690. authors: [],
  1691. timestamp: this.timestamp.toJSON(),
  1692. operations: [
  1693. {
  1694. pathname: 'main.tex',
  1695. commentId,
  1696. ranges: [{ pos: 1, length: 2 }],
  1697. },
  1698. ],
  1699. origin: { kind: 'test-origin' },
  1700. },
  1701. ])
  1702. return true
  1703. })
  1704. .query({ end_version: 0 })
  1705. .reply(204)
  1706. await ProjectHistoryClient.resyncHistory(this.project_id)
  1707. const update1 = {
  1708. projectHistoryId: historyId,
  1709. resyncProjectStructure: {
  1710. docs: [{ path: '/main.tex' }],
  1711. files: [],
  1712. },
  1713. meta: {
  1714. ts: this.timestamp,
  1715. },
  1716. }
  1717. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1718. const update2 = {
  1719. path: '/main.tex',
  1720. projectHistoryId: historyId,
  1721. resyncDocContent: {
  1722. content: 'a\nb',
  1723. ranges: {
  1724. comments: [
  1725. {
  1726. id: commentId,
  1727. op: {
  1728. c: 'a',
  1729. p: 0,
  1730. hpos: 1,
  1731. hlen: 2,
  1732. t: commentId,
  1733. },
  1734. meta: {
  1735. user_id: 'user-id',
  1736. ts: this.timestamp,
  1737. },
  1738. },
  1739. ],
  1740. changes: [
  1741. {
  1742. id: 'id1',
  1743. op: {
  1744. d: 'a',
  1745. p: 0,
  1746. },
  1747. metadata: {
  1748. user_id: 'user-id',
  1749. ts: this.timestamp,
  1750. },
  1751. },
  1752. {
  1753. id: 'id2',
  1754. op: {
  1755. i: '\n',
  1756. p: 1,
  1757. hpos: 2,
  1758. },
  1759. metadata: {
  1760. user_id: 'user-id',
  1761. ts: this.timestamp,
  1762. },
  1763. },
  1764. ],
  1765. },
  1766. },
  1767. doc: this.doc_id,
  1768. meta: {
  1769. ts: this.timestamp,
  1770. },
  1771. }
  1772. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1773. await ProjectHistoryClient.flushProject(this.project_id)
  1774. assert(
  1775. addComment.isDone(),
  1776. `/api/projects/${historyId}/legacy_changes should have been called`
  1777. )
  1778. })
  1779. it('should fix resolved state for comments in the history store', async function () {
  1780. const addComment = MockHistoryStore()
  1781. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1782. expect(body).to.deep.equal([
  1783. {
  1784. v2Authors: [],
  1785. authors: [],
  1786. timestamp: this.timestamp.toJSON(),
  1787. operations: [
  1788. {
  1789. pathname: 'main.tex',
  1790. commentId,
  1791. resolved: true,
  1792. },
  1793. ],
  1794. origin: { kind: 'test-origin' },
  1795. },
  1796. ])
  1797. return true
  1798. })
  1799. .query({ end_version: 0 })
  1800. .reply(204)
  1801. await ProjectHistoryClient.resyncHistory(this.project_id)
  1802. const update1 = {
  1803. projectHistoryId: historyId,
  1804. resyncProjectStructure: {
  1805. docs: [{ path: '/main.tex' }],
  1806. files: [],
  1807. },
  1808. meta: {
  1809. ts: this.timestamp,
  1810. },
  1811. }
  1812. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1813. const update2 = {
  1814. path: '/main.tex',
  1815. projectHistoryId: historyId,
  1816. resyncDocContent: {
  1817. content: 'a\nb',
  1818. resolvedCommentIds: [commentId],
  1819. ranges: {
  1820. comments: [
  1821. {
  1822. id: commentId,
  1823. op: {
  1824. c: 'a',
  1825. p: 0,
  1826. hpos: 0,
  1827. hlen: 3,
  1828. t: commentId,
  1829. },
  1830. meta: {
  1831. user_id: 'user-id',
  1832. ts: this.timestamp,
  1833. },
  1834. },
  1835. ],
  1836. changes: [
  1837. {
  1838. id: 'id1',
  1839. op: {
  1840. d: 'a',
  1841. p: 0,
  1842. },
  1843. metadata: {
  1844. user_id: 'user-id',
  1845. ts: this.timestamp,
  1846. },
  1847. },
  1848. {
  1849. id: 'id2',
  1850. op: {
  1851. i: '\n',
  1852. p: 1,
  1853. hpos: 2,
  1854. },
  1855. metadata: {
  1856. user_id: 'user-id',
  1857. ts: this.timestamp,
  1858. },
  1859. },
  1860. ],
  1861. },
  1862. },
  1863. doc: this.doc_id,
  1864. meta: {
  1865. ts: this.timestamp,
  1866. },
  1867. }
  1868. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1869. await ProjectHistoryClient.flushProject(this.project_id)
  1870. assert(
  1871. addComment.isDone(),
  1872. `/api/projects/${historyId}/legacy_changes should have been called`
  1873. )
  1874. })
  1875. it('should fix comments in the history store (history-ot)', async function () {
  1876. const addComment = MockHistoryStore()
  1877. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1878. expect(body).to.deep.equal([
  1879. {
  1880. v2Authors: [],
  1881. authors: [],
  1882. timestamp: this.timestamp.toJSON(),
  1883. operations: [
  1884. {
  1885. pathname: 'main.tex',
  1886. commentId,
  1887. ranges: [{ pos: 1, length: 2 }],
  1888. },
  1889. ],
  1890. origin: { kind: 'test-origin' },
  1891. },
  1892. ])
  1893. return true
  1894. })
  1895. .query({ end_version: 0 })
  1896. .reply(204)
  1897. await ProjectHistoryClient.resyncHistory(this.project_id)
  1898. const update1 = {
  1899. projectHistoryId: historyId,
  1900. resyncProjectStructure: {
  1901. docs: [{ path: '/main.tex' }],
  1902. files: [],
  1903. },
  1904. meta: {
  1905. ts: this.timestamp,
  1906. },
  1907. }
  1908. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1909. const update2 = {
  1910. path: '/main.tex',
  1911. projectHistoryId: historyId,
  1912. resyncDocContent: {
  1913. content: 'a\nb',
  1914. historyOTRanges: {
  1915. comments: [
  1916. {
  1917. id: commentId,
  1918. ranges: [
  1919. {
  1920. pos: 1,
  1921. length: 2,
  1922. },
  1923. ],
  1924. },
  1925. ],
  1926. trackedChanges: [
  1927. {
  1928. range: { pos: 0, length: 1 },
  1929. tracking: {
  1930. ts: this.timestamp.toJSON(),
  1931. type: 'delete',
  1932. userId: 'user-id',
  1933. },
  1934. },
  1935. {
  1936. range: { pos: 2, length: 1 },
  1937. tracking: {
  1938. ts: this.timestamp.toJSON(),
  1939. type: 'insert',
  1940. userId: 'user-id',
  1941. },
  1942. },
  1943. ],
  1944. },
  1945. },
  1946. doc: this.doc_id,
  1947. meta: {
  1948. ts: this.timestamp,
  1949. },
  1950. }
  1951. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  1952. await ProjectHistoryClient.flushProject(this.project_id)
  1953. assert(
  1954. addComment.isDone(),
  1955. `/api/projects/${historyId}/legacy_changes should have been called`
  1956. )
  1957. })
  1958. it('should fix resolved state for comments in the history store (history-ot)', async function () {
  1959. const addComment = MockHistoryStore()
  1960. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  1961. expect(body).to.deep.equal([
  1962. {
  1963. v2Authors: [],
  1964. authors: [],
  1965. timestamp: this.timestamp.toJSON(),
  1966. operations: [
  1967. {
  1968. pathname: 'main.tex',
  1969. commentId,
  1970. resolved: true,
  1971. },
  1972. ],
  1973. origin: { kind: 'test-origin' },
  1974. },
  1975. ])
  1976. return true
  1977. })
  1978. .query({ end_version: 0 })
  1979. .reply(204)
  1980. await ProjectHistoryClient.resyncHistory(this.project_id)
  1981. const update1 = {
  1982. projectHistoryId: historyId,
  1983. resyncProjectStructure: {
  1984. docs: [{ path: '/main.tex' }],
  1985. files: [],
  1986. },
  1987. meta: {
  1988. ts: this.timestamp,
  1989. },
  1990. }
  1991. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  1992. const update2 = {
  1993. path: '/main.tex',
  1994. projectHistoryId: historyId,
  1995. resyncDocContent: {
  1996. content: 'a\nb',
  1997. historyOTRanges: {
  1998. comments: [
  1999. {
  2000. id: commentId,
  2001. ranges: [
  2002. {
  2003. pos: 0,
  2004. length: 3,
  2005. },
  2006. ],
  2007. resolved: true,
  2008. },
  2009. ],
  2010. trackedChanges: [
  2011. {
  2012. range: { pos: 0, length: 1 },
  2013. tracking: {
  2014. ts: this.timestamp.toJSON(),
  2015. type: 'delete',
  2016. userId: 'user-id',
  2017. },
  2018. },
  2019. {
  2020. range: { pos: 2, length: 1 },
  2021. tracking: {
  2022. ts: this.timestamp.toJSON(),
  2023. type: 'insert',
  2024. userId: 'user-id',
  2025. },
  2026. },
  2027. ],
  2028. },
  2029. },
  2030. doc: this.doc_id,
  2031. meta: {
  2032. ts: this.timestamp,
  2033. },
  2034. }
  2035. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  2036. await ProjectHistoryClient.flushProject(this.project_id)
  2037. assert(
  2038. addComment.isDone(),
  2039. `/api/projects/${historyId}/legacy_changes should have been called`
  2040. )
  2041. })
  2042. it('should fix tracked changes in the history store', async function () {
  2043. const fixTrackedChange = MockHistoryStore()
  2044. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  2045. expect(body).to.deep.equal([
  2046. {
  2047. v2Authors: [],
  2048. authors: [],
  2049. timestamp: this.timestamp.toJSON(),
  2050. operations: [
  2051. {
  2052. pathname: 'main.tex',
  2053. textOperation: [
  2054. 1,
  2055. {
  2056. r: 1,
  2057. tracking: {
  2058. ts: this.timestamp.toJSON(),
  2059. type: 'insert',
  2060. userId: 'user-id',
  2061. },
  2062. },
  2063. {
  2064. r: 1,
  2065. tracking: {
  2066. type: 'none',
  2067. },
  2068. },
  2069. ],
  2070. },
  2071. ],
  2072. origin: { kind: 'test-origin' },
  2073. },
  2074. ])
  2075. return true
  2076. })
  2077. .query({ end_version: 0 })
  2078. .reply(204)
  2079. await ProjectHistoryClient.resyncHistory(this.project_id)
  2080. const update1 = {
  2081. projectHistoryId: historyId,
  2082. resyncProjectStructure: {
  2083. docs: [{ path: '/main.tex' }],
  2084. files: [],
  2085. },
  2086. meta: {
  2087. ts: this.timestamp,
  2088. },
  2089. }
  2090. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  2091. const update2 = {
  2092. path: '/main.tex',
  2093. projectHistoryId: historyId,
  2094. resyncDocContent: {
  2095. content: 'a\nb',
  2096. ranges: {
  2097. comments: [
  2098. {
  2099. id: commentId,
  2100. op: {
  2101. c: 'a',
  2102. p: 0,
  2103. hpos: 0,
  2104. hlen: 3,
  2105. t: commentId,
  2106. },
  2107. meta: {
  2108. user_id: 'user-id',
  2109. ts: this.timestamp,
  2110. },
  2111. },
  2112. ],
  2113. changes: [
  2114. {
  2115. id: 'id1',
  2116. op: {
  2117. d: 'a',
  2118. p: 0,
  2119. },
  2120. metadata: {
  2121. user_id: 'user-id',
  2122. ts: this.timestamp,
  2123. },
  2124. },
  2125. {
  2126. id: 'id2',
  2127. op: {
  2128. i: '\n',
  2129. p: 0,
  2130. hpos: 1,
  2131. },
  2132. metadata: {
  2133. user_id: 'user-id',
  2134. ts: this.timestamp,
  2135. },
  2136. },
  2137. ],
  2138. },
  2139. },
  2140. doc: this.doc_id,
  2141. meta: {
  2142. ts: this.timestamp,
  2143. },
  2144. }
  2145. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  2146. await ProjectHistoryClient.flushProject(this.project_id)
  2147. assert(
  2148. fixTrackedChange.isDone(),
  2149. `/api/projects/${historyId}/legacy_changes should have been called`
  2150. )
  2151. })
  2152. it('should fix tracked changes in the history store (history-ot)', async function () {
  2153. const fixTrackedChange = MockHistoryStore()
  2154. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  2155. expect(body).to.deep.equal([
  2156. {
  2157. v2Authors: [],
  2158. authors: [],
  2159. timestamp: this.timestamp.toJSON(),
  2160. operations: [
  2161. {
  2162. pathname: 'main.tex',
  2163. textOperation: [
  2164. 1,
  2165. {
  2166. r: 1,
  2167. tracking: {
  2168. ts: this.timestamp.toJSON(),
  2169. type: 'insert',
  2170. userId: 'user-id',
  2171. },
  2172. },
  2173. {
  2174. r: 1,
  2175. tracking: {
  2176. type: 'none',
  2177. },
  2178. },
  2179. ],
  2180. },
  2181. ],
  2182. origin: { kind: 'test-origin' },
  2183. },
  2184. ])
  2185. return true
  2186. })
  2187. .query({ end_version: 0 })
  2188. .reply(204)
  2189. await ProjectHistoryClient.resyncHistory(this.project_id)
  2190. const update1 = {
  2191. projectHistoryId: historyId,
  2192. resyncProjectStructure: {
  2193. docs: [{ path: '/main.tex' }],
  2194. files: [],
  2195. },
  2196. meta: {
  2197. ts: this.timestamp,
  2198. },
  2199. }
  2200. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  2201. const update2 = {
  2202. path: '/main.tex',
  2203. projectHistoryId: historyId,
  2204. resyncDocContent: {
  2205. content: 'a\nb',
  2206. historyOTRanges: {
  2207. comments: [
  2208. {
  2209. id: commentId,
  2210. ranges: [
  2211. {
  2212. pos: 0,
  2213. length: 3,
  2214. },
  2215. ],
  2216. },
  2217. ],
  2218. trackedChanges: [
  2219. {
  2220. range: { pos: 0, length: 1 },
  2221. tracking: {
  2222. ts: this.timestamp.toJSON(),
  2223. type: 'delete',
  2224. userId: 'user-id',
  2225. },
  2226. },
  2227. {
  2228. range: { pos: 1, length: 1 },
  2229. tracking: {
  2230. ts: this.timestamp.toJSON(),
  2231. type: 'insert',
  2232. userId: 'user-id',
  2233. },
  2234. },
  2235. ],
  2236. },
  2237. },
  2238. doc: this.doc_id,
  2239. meta: {
  2240. ts: this.timestamp,
  2241. },
  2242. }
  2243. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  2244. await ProjectHistoryClient.flushProject(this.project_id)
  2245. assert(
  2246. fixTrackedChange.isDone(),
  2247. `/api/projects/${historyId}/legacy_changes should have been called`
  2248. )
  2249. })
  2250. it('should fix both comments and tracked changes in the history store (history-ot)', async function () {
  2251. const fixTrackedChange = MockHistoryStore()
  2252. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  2253. expect(body).to.deep.equal([
  2254. // not merged due to comment operation using history-ot and tracked-changes operation using sharejs ot
  2255. {
  2256. v2Authors: [],
  2257. authors: [],
  2258. timestamp: this.timestamp.toJSON(),
  2259. operations: [
  2260. {
  2261. pathname: 'main.tex',
  2262. commentId,
  2263. ranges: [{ pos: 1, length: 2 }],
  2264. },
  2265. ],
  2266. origin: { kind: 'test-origin' },
  2267. },
  2268. {
  2269. v2Authors: [],
  2270. authors: [],
  2271. timestamp: this.timestamp.toJSON(),
  2272. operations: [
  2273. {
  2274. pathname: 'main.tex',
  2275. textOperation: [
  2276. 1,
  2277. {
  2278. r: 1,
  2279. tracking: {
  2280. ts: this.timestamp.toJSON(),
  2281. type: 'insert',
  2282. userId: 'user-id',
  2283. },
  2284. },
  2285. {
  2286. r: 1,
  2287. tracking: {
  2288. type: 'none',
  2289. },
  2290. },
  2291. ],
  2292. },
  2293. ],
  2294. origin: { kind: 'test-origin' },
  2295. },
  2296. ])
  2297. return true
  2298. })
  2299. .query({ end_version: 0 })
  2300. .reply(204)
  2301. await ProjectHistoryClient.resyncHistory(this.project_id)
  2302. const update1 = {
  2303. projectHistoryId: historyId,
  2304. resyncProjectStructure: {
  2305. docs: [{ path: '/main.tex' }],
  2306. files: [],
  2307. },
  2308. meta: {
  2309. ts: this.timestamp,
  2310. },
  2311. }
  2312. await ProjectHistoryClient.pushRawUpdate(this.project_id, update1)
  2313. const update2 = {
  2314. path: '/main.tex',
  2315. projectHistoryId: historyId,
  2316. resyncDocContent: {
  2317. content: 'a\nb',
  2318. historyOTRanges: {
  2319. comments: [
  2320. {
  2321. id: commentId,
  2322. ranges: [
  2323. {
  2324. pos: 1,
  2325. length: 2,
  2326. },
  2327. ],
  2328. },
  2329. ],
  2330. trackedChanges: [
  2331. {
  2332. range: { pos: 0, length: 1 },
  2333. tracking: {
  2334. ts: this.timestamp.toJSON(),
  2335. type: 'delete',
  2336. userId: 'user-id',
  2337. },
  2338. },
  2339. {
  2340. range: { pos: 1, length: 1 },
  2341. tracking: {
  2342. ts: this.timestamp.toJSON(),
  2343. type: 'insert',
  2344. userId: 'user-id',
  2345. },
  2346. },
  2347. ],
  2348. },
  2349. },
  2350. doc: this.doc_id,
  2351. meta: {
  2352. ts: this.timestamp,
  2353. },
  2354. }
  2355. await ProjectHistoryClient.pushRawUpdate(this.project_id, update2)
  2356. await ProjectHistoryClient.flushProject(this.project_id)
  2357. assert(
  2358. fixTrackedChange.isDone(),
  2359. `/api/projects/${historyId}/legacy_changes should have been called`
  2360. )
  2361. })
  2362. })
  2363. describe('resyncProjectStructureOnly', function () {
  2364. it('should handle structure only updates', async function () {
  2365. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  2366. MockHistoryStore()
  2367. .get(`/api/projects/${historyId}/latest/history`)
  2368. .reply(200, {
  2369. chunk: {
  2370. history: {
  2371. snapshot: {
  2372. files: {
  2373. 'main.tex': {
  2374. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  2375. stringLength: 3,
  2376. },
  2377. },
  2378. },
  2379. changes: [],
  2380. },
  2381. startVersion: 0,
  2382. },
  2383. })
  2384. const docContentRequest = MockHistoryStore()
  2385. .get(
  2386. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  2387. )
  2388. .reply(200, 'a\nb')
  2389. MockHistoryStore()
  2390. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  2391. .reply(200)
  2392. const addFile = MockHistoryStore()
  2393. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  2394. expect(body).to.deep.equal([
  2395. {
  2396. v2Authors: [],
  2397. authors: [],
  2398. timestamp: this.timestamp.toJSON(),
  2399. operations: [
  2400. {
  2401. pathname: 'test.png',
  2402. file: {
  2403. hash: fileHash,
  2404. },
  2405. },
  2406. ],
  2407. origin: { kind: 'test-origin' },
  2408. },
  2409. ])
  2410. return true
  2411. })
  2412. .query({ end_version: 0 })
  2413. .reply(204)
  2414. // allow a 2nd resync
  2415. MockWeb()
  2416. .post(`/project/${this.project_id}/history/resync`)
  2417. .reply(204)
  2418. await ProjectHistoryClient.resyncHistory(this.project_id)
  2419. const update = {
  2420. projectHistoryId: historyId,
  2421. resyncProjectStructureOnly: true,
  2422. resyncProjectStructure: {
  2423. docs: [{ path: '/main.tex' }],
  2424. files: [
  2425. {
  2426. file: this.file_id,
  2427. path: '/test.png',
  2428. _hash: fileHash,
  2429. createdBlob: true,
  2430. },
  2431. ],
  2432. },
  2433. meta: {
  2434. ts: this.timestamp,
  2435. },
  2436. }
  2437. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  2438. await ProjectHistoryClient.flushProject(this.project_id)
  2439. // fails when previous resync did not finish
  2440. await ProjectHistoryClient.resyncHistory(this.project_id)
  2441. assert(
  2442. addFile.isDone(),
  2443. `/api/projects/${historyId}/legacy_changes should have been called`
  2444. )
  2445. assert(
  2446. !docContentRequest.isDone(),
  2447. 'should not have requested doc content'
  2448. )
  2449. })
  2450. it('should reject partial resync on docs', async function () {
  2451. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  2452. MockHistoryStore()
  2453. .get(`/api/projects/${historyId}/latest/history`)
  2454. .reply(200, {
  2455. chunk: {
  2456. history: {
  2457. snapshot: {
  2458. files: {
  2459. 'main.tex': {
  2460. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  2461. stringLength: 3,
  2462. },
  2463. },
  2464. },
  2465. changes: [],
  2466. },
  2467. startVersion: 0,
  2468. },
  2469. })
  2470. const docContentRequest = MockHistoryStore()
  2471. .get(
  2472. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  2473. )
  2474. .reply(200, 'a\nb')
  2475. MockHistoryStore()
  2476. .head(`/api/projects/${historyId}/blobs/${fileHash}`)
  2477. .reply(200)
  2478. const addFile = MockHistoryStore()
  2479. .post(`/api/projects/${historyId}/legacy_changes`)
  2480. .query({ end_version: 0 })
  2481. .reply(204)
  2482. // allow a 2nd resync
  2483. MockWeb()
  2484. .post(`/project/${this.project_id}/history/resync`)
  2485. .reply(204)
  2486. await ProjectHistoryClient.resyncHistory(this.project_id)
  2487. const update = {
  2488. projectHistoryId: historyId,
  2489. resyncProjectStructureOnly: true,
  2490. resyncProjectStructure: {
  2491. docs: [{ path: '/main-renamed.tex' }],
  2492. files: [
  2493. {
  2494. file: this.file_id,
  2495. path: '/test.png',
  2496. _hash: fileHash,
  2497. createdBlob: true,
  2498. },
  2499. ],
  2500. },
  2501. meta: {
  2502. ts: this.timestamp,
  2503. },
  2504. }
  2505. await ProjectHistoryClient.pushRawUpdate(this.project_id, update)
  2506. const res = await ProjectHistoryClient.flushProject(this.project_id, {
  2507. allowErrors: true,
  2508. })
  2509. expect(res.statusCode).to.equal(500)
  2510. expect(loggerError).to.have.been.calledWith(
  2511. sinon.match({
  2512. err: {
  2513. name: 'NeedFullProjectStructureResyncError',
  2514. message: 'aborting partial resync: touched doc',
  2515. },
  2516. })
  2517. )
  2518. const failure = await new Promise((resolve, reject) => {
  2519. getFailure(this.project_id, (err, failure) => {
  2520. if (err) return reject(err)
  2521. resolve(failure)
  2522. })
  2523. })
  2524. expect(failure).to.include({
  2525. error:
  2526. 'NeedFullProjectStructureResyncError: aborting partial resync: touched doc',
  2527. })
  2528. // fails when previous resync did not finish
  2529. await ProjectHistoryClient.resyncHistory(this.project_id)
  2530. assert(!addFile.isDone(), 'should not have persisted changes')
  2531. assert(
  2532. !docContentRequest.isDone(),
  2533. 'should not have requested doc content'
  2534. )
  2535. })
  2536. })
  2537. describe('stuck sync state (missing resyncDocContent updates)', function () {
  2538. beforeEach(function () {
  2539. MockHistoryStore()
  2540. .get(`/api/projects/${historyId}/latest/history`)
  2541. .reply(200, {
  2542. chunk: {
  2543. history: {
  2544. snapshot: {
  2545. files: {
  2546. 'main.tex': {
  2547. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  2548. stringLength: 3,
  2549. },
  2550. },
  2551. },
  2552. changes: [],
  2553. },
  2554. startVersion: 0,
  2555. },
  2556. })
  2557. MockHistoryStore()
  2558. .get(
  2559. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  2560. )
  2561. .reply(200, 'a\nb')
  2562. })
  2563. it('should get stuck when resyncDocContent update is missing', async function () {
  2564. // Step 1: Start a resync
  2565. await ProjectHistoryClient.resyncHistory(this.project_id)
  2566. // Step 2: Push resyncProjectStructure update (lists docs to sync)
  2567. // but do NOT push the corresponding resyncDocContent update
  2568. const structureUpdate = {
  2569. projectHistoryId: historyId,
  2570. resyncProjectStructure: {
  2571. docs: [{ path: '/main.tex' }],
  2572. files: [],
  2573. },
  2574. meta: {
  2575. ts: this.timestamp,
  2576. },
  2577. }
  2578. await ProjectHistoryClient.pushRawUpdate(
  2579. this.project_id,
  2580. structureUpdate
  2581. )
  2582. // Step 3: Flush — processes structure update, adds /main.tex to resyncDocContents
  2583. // This succeeds (204) but leaves sync in an incomplete state
  2584. await ProjectHistoryClient.flushProject(this.project_id)
  2585. // Step 4: Verify the sync state is stuck
  2586. const syncState = await ProjectHistoryClient.getSyncState(
  2587. this.project_id
  2588. )
  2589. expect(syncState).to.not.be.null
  2590. expect(syncState.resyncProjectStructure).to.equal(false)
  2591. expect(syncState.resyncDocContents).to.deep.equal(['/main.tex'])
  2592. expect(syncState.resyncPendingSince).to.be.a('date')
  2593. // Sync thinks it's ongoing because resyncDocContents is not empty
  2594. // Step 5: Push a normal text update (simulating a user edit)
  2595. const textUpdate = {
  2596. doc: this.doc_id,
  2597. op: [{ p: 3, i: '\nc' }],
  2598. v: 1,
  2599. meta: {
  2600. ts: this.timestamp,
  2601. pathname: '/main.tex',
  2602. doc_length: 3,
  2603. },
  2604. }
  2605. await ProjectHistoryClient.pushRawUpdate(this.project_id, textUpdate)
  2606. // Step 6: Flush again — the text update should be silently skipped
  2607. // because the doc is in resyncDocContents (sync "ongoing")
  2608. await ProjectHistoryClient.flushProject(this.project_id)
  2609. // Step 7: Verify the sync state is STILL stuck (text update was skipped)
  2610. const syncStateAfter = await ProjectHistoryClient.getSyncState(
  2611. this.project_id
  2612. )
  2613. expect(syncStateAfter.resyncProjectStructure).to.equal(false)
  2614. expect(syncStateAfter.resyncDocContents).to.deep.equal(['/main.tex'])
  2615. // Step 7b: Verify resync-pending reports ongoing but not yet stuck
  2616. const pending = await ProjectHistoryClient.getResyncPending(
  2617. this.project_id
  2618. )
  2619. expect(pending.resyncPending).to.equal(true)
  2620. expect(pending.syncStuck).to.equal(false)
  2621. // Step 8: Verify a normal resync FAILS because sync is "ongoing"
  2622. try {
  2623. await fetchNothing(
  2624. `http://127.0.0.1:3054/project/${this.project_id}/resync`,
  2625. {
  2626. method: 'POST',
  2627. json: { origin: { kind: 'test-origin' } },
  2628. }
  2629. )
  2630. assert.fail('resync should have failed')
  2631. } catch (error) {
  2632. if (error instanceof RequestFailedError) {
  2633. expect(error.response.status).to.equal(500)
  2634. } else {
  2635. throw error
  2636. }
  2637. }
  2638. })
  2639. it('should recover with a hard resync (force=true)', async function () {
  2640. // Set up the stuck state (same as above)
  2641. await ProjectHistoryClient.resyncHistory(this.project_id)
  2642. const structureUpdate = {
  2643. projectHistoryId: historyId,
  2644. resyncProjectStructure: {
  2645. docs: [{ path: '/main.tex' }],
  2646. files: [],
  2647. },
  2648. meta: {
  2649. ts: this.timestamp,
  2650. },
  2651. }
  2652. await ProjectHistoryClient.pushRawUpdate(
  2653. this.project_id,
  2654. structureUpdate
  2655. )
  2656. await ProjectHistoryClient.flushProject(this.project_id)
  2657. // Verify stuck
  2658. const syncState = await ProjectHistoryClient.getSyncState(
  2659. this.project_id
  2660. )
  2661. expect(syncState.resyncDocContents).to.deep.equal(['/main.tex'])
  2662. // Mock the web resync endpoint for the hard resync
  2663. MockWeb()
  2664. .post(`/project/${this.project_id}/history/resync`)
  2665. .reply(204)
  2666. // Hard resync should clear the stuck state and succeed
  2667. const response = await fetchNothing(
  2668. `http://127.0.0.1:3054/project/${this.project_id}/resync?force=true`,
  2669. {
  2670. method: 'POST',
  2671. json: { origin: { kind: 'test-origin' } },
  2672. }
  2673. )
  2674. expect(response.status).to.equal(204)
  2675. // After hard resync, sync state should be cleared or reset
  2676. const syncStateAfter = await ProjectHistoryClient.getSyncState(
  2677. this.project_id
  2678. )
  2679. // Hard resync clears and restarts — at this point the new resync
  2680. // should be in progress (resyncProjectStructure = true) or cleared
  2681. // depending on whether web sent updates
  2682. if (syncStateAfter) {
  2683. expect(syncStateAfter.resyncProjectStructure).to.equal(true)
  2684. }
  2685. })
  2686. it('should auto-recover when stuck (null resyncPendingSince)', async function () {
  2687. // Inject a stuck sync state: ongoing but no resyncPendingSince
  2688. // (legacy state from before the timestamp field was added)
  2689. await ProjectHistoryClient.injectStuckSyncState(this.project_id, [
  2690. '/main.tex',
  2691. ])
  2692. // Verify the state is recognised as stuck
  2693. const pending = await ProjectHistoryClient.getResyncPending(
  2694. this.project_id
  2695. )
  2696. expect(pending.resyncPending).to.equal(true)
  2697. expect(pending.syncStuck).to.equal(true)
  2698. // Mock the web callback that requestResync triggers
  2699. MockWeb()
  2700. .post(`/project/${this.project_id}/history/resync`)
  2701. .reply(204)
  2702. // A plain resync (no force) should detect stuck, clear, and restart
  2703. await ProjectHistoryClient.resyncHistory(this.project_id)
  2704. // stuckClearCount incremented and a new structure sync started
  2705. const syncState = await ProjectHistoryClient.getSyncState(
  2706. this.project_id
  2707. )
  2708. expect(syncState.stuckClearCount).to.equal(1)
  2709. expect(syncState.resyncProjectStructure).to.equal(true)
  2710. expect(syncState.lastStuckDocPaths).to.deep.equal(['/main.tex'])
  2711. })
  2712. })
  2713. })
  2714. })
  2715. // Regression: UpdateCompressor.compressUpdates optimizes a delete+insert
  2716. // at the same position by diffing the two strings and retaining common
  2717. // substrings. The TC retain ops are computed by the SyncManager based on
  2718. // the original (unoptimized) content diff. When the optimized ops retain
  2719. // characters that the original diff deleted, tracked changes on those
  2720. // characters survive — but the TC retains don't clear them, producing a
  2721. // ghost tracked change.
  2722. describe('resync with tracked changes overlapping content diff', function () {
  2723. const TIMESTAMP = '2025-01-01T00:00:00.000Z'
  2724. const persisted = {
  2725. content: 'lblcdhqcmihkrvzlifscqmwytt\n\ndgakoxboqpzdbbjtom',
  2726. trackedChanges: [
  2727. {
  2728. range: { pos: 2, length: 3 },
  2729. tracking: { type: 'insert', userId: 'user-2', ts: TIMESTAMP },
  2730. },
  2731. {
  2732. range: { pos: 12, length: 4 },
  2733. tracking: { type: 'delete', userId: 'user-2', ts: TIMESTAMP },
  2734. },
  2735. {
  2736. range: { pos: 26, length: 3 },
  2737. tracking: { type: 'delete', userId: 'user-2', ts: TIMESTAMP },
  2738. },
  2739. {
  2740. range: { pos: 32, length: 5 },
  2741. tracking: { type: 'delete', userId: 'user-2', ts: TIMESTAMP },
  2742. },
  2743. ],
  2744. }
  2745. const expected = {
  2746. content: 'lblchfjaqdhqcmihkrvzliftt\ndoqpzdbom',
  2747. trackedChanges: [
  2748. {
  2749. range: { pos: 0, length: 4 },
  2750. tracking: { type: 'insert', userId: 'user-2', ts: TIMESTAMP },
  2751. },
  2752. {
  2753. range: { pos: 11, length: 1 },
  2754. tracking: { type: 'delete', userId: 'user-2', ts: TIMESTAMP },
  2755. },
  2756. {
  2757. range: { pos: 17, length: 5 },
  2758. tracking: { type: 'delete', userId: 'user-2', ts: TIMESTAMP },
  2759. },
  2760. {
  2761. range: { pos: 22, length: 3 },
  2762. tracking: { type: 'insert', userId: 'user-1', ts: TIMESTAMP },
  2763. },
  2764. {
  2765. range: { pos: 26, length: 5 },
  2766. tracking: { type: 'delete', userId: 'user-1', ts: TIMESTAMP },
  2767. },
  2768. ],
  2769. }
  2770. it('should not leave ghost tracked changes after resync', async function () {
  2771. const rangesBlob = JSON.stringify({
  2772. comments: [],
  2773. trackedChanges: persisted.trackedChanges,
  2774. })
  2775. const contentHash = _getBlobHashFromString(persisted.content)
  2776. const rangesHash = _getBlobHashFromString(rangesBlob)
  2777. MockHistoryStore()
  2778. .get(`/api/projects/${historyId}/latest/history`)
  2779. .reply(200, {
  2780. chunk: {
  2781. history: {
  2782. snapshot: {
  2783. files: {
  2784. 'main.tex': {
  2785. hash: contentHash,
  2786. stringLength: persisted.content.length,
  2787. rangesHash,
  2788. },
  2789. },
  2790. },
  2791. changes: [],
  2792. },
  2793. startVersion: 0,
  2794. },
  2795. })
  2796. MockHistoryStore()
  2797. .get(`/api/projects/${historyId}/blobs/${contentHash}`)
  2798. .reply(200, persisted.content)
  2799. MockHistoryStore()
  2800. .get(`/api/projects/${historyId}/blobs/${rangesHash}`)
  2801. .reply(200, rangesBlob)
  2802. MockHistoryStore()
  2803. .put(/\/api\/projects\/[^/]+\/blobs\/[0-9a-f]+/)
  2804. .optionally()
  2805. .reply(201)
  2806. const allCapturedChanges = []
  2807. MockHistoryStore()
  2808. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  2809. allCapturedChanges.push(...body)
  2810. return true
  2811. })
  2812. .query(true)
  2813. .times(5)
  2814. .optionally()
  2815. .reply(204)
  2816. MockWeb().post(`/project/${this.project_id}/history/resync`).reply(204)
  2817. await ProjectHistoryClient.resyncHistory(this.project_id)
  2818. await ProjectHistoryClient.pushRawUpdate(this.project_id, {
  2819. projectHistoryId: historyId,
  2820. resyncProjectStructure: {
  2821. docs: [{ path: '/main.tex' }],
  2822. files: [],
  2823. },
  2824. meta: { ts: this.timestamp },
  2825. })
  2826. await ProjectHistoryClient.pushRawUpdate(this.project_id, {
  2827. path: '/main.tex',
  2828. projectHistoryId: historyId,
  2829. resyncDocContent: {
  2830. content: expected.content,
  2831. historyOTRanges: {
  2832. comments: [],
  2833. trackedChanges: expected.trackedChanges,
  2834. },
  2835. },
  2836. doc: this.doc_id,
  2837. meta: { ts: this.timestamp },
  2838. })
  2839. await ProjectHistoryClient.flushProject(this.project_id)
  2840. const fileData = new StringFileData(
  2841. persisted.content,
  2842. [],
  2843. persisted.trackedChanges
  2844. )
  2845. const snapshot = new Snapshot(
  2846. new FileMap({ 'main.tex': new File(fileData) })
  2847. )
  2848. for (const rawChange of allCapturedChanges) {
  2849. Change.fromRaw(rawChange).applyTo(snapshot)
  2850. }
  2851. const file = snapshot.getFile('main.tex')
  2852. assert.strictEqual(file.getContent(), expected.content, 'content')
  2853. assert.deepStrictEqual(
  2854. file.getTrackedChanges().toRaw(),
  2855. expected.trackedChanges,
  2856. `TC mismatch.\n` +
  2857. ` expected: ${JSON.stringify(expected.trackedChanges)}\n` +
  2858. ` actual: ${JSON.stringify(file.getTrackedChanges().toRaw())}`
  2859. )
  2860. })
  2861. })
  2862. })