SyncTests.js 74 KB

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