SyncTests.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. import async from 'async'
  2. import nock from 'nock'
  3. import { expect } from 'chai'
  4. import request from 'request'
  5. import assert from 'assert'
  6. import { ObjectId } from 'mongodb'
  7. import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
  8. import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
  9. const EMPTY_FILE_HASH = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
  10. const MockHistoryStore = () => nock('http://localhost:3100')
  11. const MockFileStore = () => nock('http://localhost:3009')
  12. const MockWeb = () => nock('http://localhost:3000')
  13. describe('Syncing with web and doc-updater', function () {
  14. const historyId = ObjectId().toString()
  15. beforeEach(function (done) {
  16. this.timestamp = new Date()
  17. ProjectHistoryApp.ensureRunning(error => {
  18. if (error) {
  19. throw error
  20. }
  21. this.project_id = ObjectId().toString()
  22. this.doc_id = ObjectId().toString()
  23. this.file_id = ObjectId().toString()
  24. MockHistoryStore().post('/api/projects').reply(200, {
  25. projectId: historyId,
  26. })
  27. MockWeb()
  28. .get(`/project/${this.project_id}/details`)
  29. .reply(200, {
  30. name: 'Test Project',
  31. overleaf: {
  32. history: {
  33. id: historyId,
  34. },
  35. },
  36. })
  37. MockHistoryStore()
  38. .get(`/api/projects/${historyId}/latest/history`)
  39. .reply(200, {
  40. chunk: {
  41. startVersion: 0,
  42. history: {
  43. changes: [],
  44. },
  45. },
  46. })
  47. ProjectHistoryClient.initializeProject(historyId, done)
  48. })
  49. })
  50. afterEach(function () {
  51. nock.cleanAll()
  52. })
  53. describe('resyncing project history', function () {
  54. describe('without project-history enabled', function () {
  55. beforeEach(function () {
  56. MockWeb().post(`/project/${this.project_id}/history/resync`).reply(404)
  57. })
  58. it('404s if project-history is not enabled', function (done) {
  59. request.post(
  60. {
  61. url: `http://localhost:3054/project/${this.project_id}/resync`,
  62. },
  63. (error, res, body) => {
  64. if (error) {
  65. return done(error)
  66. }
  67. expect(res.statusCode).to.equal(404)
  68. done()
  69. }
  70. )
  71. })
  72. })
  73. describe('with project-history enabled', function () {
  74. beforeEach(function () {
  75. MockWeb().post(`/project/${this.project_id}/history/resync`).reply(204)
  76. })
  77. describe('when a doc is missing', function () {
  78. it('should send add doc updates to the history store', function (done) {
  79. MockHistoryStore()
  80. .get(`/api/projects/${historyId}/latest/history`)
  81. .reply(200, {
  82. chunk: {
  83. history: {
  84. snapshot: {
  85. files: {
  86. persistedDoc: { hash: EMPTY_FILE_HASH, stringLength: 0 },
  87. },
  88. },
  89. changes: [],
  90. },
  91. startVersion: 0,
  92. },
  93. })
  94. MockHistoryStore()
  95. .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`)
  96. .reply(200, '')
  97. const createBlob = MockHistoryStore()
  98. .put(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`, '')
  99. .reply(201)
  100. const addFile = MockHistoryStore()
  101. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  102. expect(body).to.deep.equal([
  103. {
  104. v2Authors: [],
  105. authors: [],
  106. timestamp: this.timestamp.toJSON(),
  107. operations: [
  108. {
  109. pathname: 'main.tex',
  110. file: {
  111. hash: EMPTY_FILE_HASH,
  112. },
  113. },
  114. ],
  115. origin: { kind: 'test-origin' },
  116. },
  117. ])
  118. return true
  119. })
  120. .query({ end_version: 0 })
  121. .reply(204)
  122. async.series(
  123. [
  124. cb => {
  125. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  126. },
  127. cb => {
  128. const update = {
  129. projectHistoryId: historyId,
  130. resyncProjectStructure: {
  131. docs: [
  132. { path: '/main.tex', doc: this.doc_id },
  133. { path: '/persistedDoc', doc: 'other-doc-id' },
  134. ],
  135. files: [],
  136. },
  137. meta: {
  138. ts: this.timestamp,
  139. },
  140. }
  141. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  142. },
  143. cb => {
  144. ProjectHistoryClient.flushProject(this.project_id, cb)
  145. },
  146. ],
  147. error => {
  148. if (error) {
  149. throw error
  150. }
  151. assert(
  152. createBlob.isDone(),
  153. '/api/projects/:historyId/blobs/:hash should have been called'
  154. )
  155. assert(
  156. addFile.isDone(),
  157. `/api/projects/${historyId}/changes should have been called`
  158. )
  159. done()
  160. }
  161. )
  162. })
  163. })
  164. describe('when a file is missing', function () {
  165. it('should send add file updates to the history store', function (done) {
  166. MockHistoryStore()
  167. .get(`/api/projects/${historyId}/latest/history`)
  168. .reply(200, {
  169. chunk: {
  170. history: {
  171. snapshot: {
  172. files: {
  173. persistedFile: { hash: EMPTY_FILE_HASH, byteLength: 0 },
  174. },
  175. },
  176. changes: [],
  177. },
  178. startVersion: 0,
  179. },
  180. })
  181. const fileContents = Buffer.from([1, 2, 3])
  182. const fileHash = 'aed2973e4b8a7ff1b30ff5c4751e5a2b38989e74'
  183. MockFileStore()
  184. .get(`/project/${this.project_id}/file/${this.file_id}`)
  185. .reply(200, fileContents)
  186. const createBlob = MockHistoryStore()
  187. .put(`/api/projects/${historyId}/blobs/${fileHash}`, fileContents)
  188. .reply(201)
  189. const addFile = MockHistoryStore()
  190. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  191. expect(body).to.deep.equal([
  192. {
  193. v2Authors: [],
  194. authors: [],
  195. timestamp: this.timestamp.toJSON(),
  196. operations: [
  197. {
  198. pathname: 'test.png',
  199. file: {
  200. hash: fileHash,
  201. },
  202. },
  203. ],
  204. origin: { kind: 'test-origin' },
  205. },
  206. ])
  207. return true
  208. })
  209. .query({ end_version: 0 })
  210. .reply(204)
  211. async.series(
  212. [
  213. cb => {
  214. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  215. },
  216. cb => {
  217. const update = {
  218. projectHistoryId: historyId,
  219. resyncProjectStructure: {
  220. docs: [],
  221. files: [
  222. {
  223. file: this.file_id,
  224. path: '/test.png',
  225. url: `http://localhost:3009/project/${this.project_id}/file/${this.file_id}`,
  226. },
  227. { path: '/persistedFile' },
  228. ],
  229. },
  230. meta: {
  231. ts: this.timestamp,
  232. },
  233. }
  234. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  235. },
  236. cb => {
  237. ProjectHistoryClient.flushProject(this.project_id, cb)
  238. },
  239. ],
  240. error => {
  241. if (error) {
  242. throw error
  243. }
  244. assert(
  245. createBlob.isDone(),
  246. '/api/projects/:historyId/blobs/:hash should have been called'
  247. )
  248. assert(
  249. addFile.isDone(),
  250. `/api/projects/${historyId}/changes should have been called`
  251. )
  252. done()
  253. }
  254. )
  255. })
  256. })
  257. describe("when a file exists which shouldn't", function () {
  258. it('should send remove file updates to the history store', function (done) {
  259. MockHistoryStore()
  260. .get(`/api/projects/${historyId}/latest/history`)
  261. .reply(200, {
  262. chunk: {
  263. history: {
  264. snapshot: {
  265. files: {
  266. docToKeep: { hash: EMPTY_FILE_HASH, stringLength: 0 },
  267. docToDelete: { hash: EMPTY_FILE_HASH, stringLength: 0 },
  268. },
  269. },
  270. changes: [],
  271. },
  272. startVersion: 0,
  273. },
  274. })
  275. MockHistoryStore()
  276. .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`)
  277. .reply(200, '')
  278. .get(`/api/projects/${historyId}/blobs/${EMPTY_FILE_HASH}`)
  279. .reply(200, '') // blob is requested once for each file
  280. const deleteFile = MockHistoryStore()
  281. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  282. expect(body).to.deep.equal([
  283. {
  284. v2Authors: [],
  285. authors: [],
  286. timestamp: this.timestamp.toJSON(),
  287. operations: [
  288. {
  289. pathname: 'docToDelete',
  290. newPathname: '',
  291. },
  292. ],
  293. origin: { kind: 'test-origin' },
  294. },
  295. ])
  296. return true
  297. })
  298. .query({ end_version: 0 })
  299. .reply(204)
  300. async.series(
  301. [
  302. cb => {
  303. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  304. },
  305. cb => {
  306. const update = {
  307. projectHistoryId: historyId,
  308. resyncProjectStructure: {
  309. docs: [{ path: 'docToKeep' }],
  310. files: [],
  311. },
  312. meta: {
  313. ts: this.timestamp,
  314. },
  315. }
  316. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  317. },
  318. cb => {
  319. ProjectHistoryClient.flushProject(this.project_id, cb)
  320. },
  321. ],
  322. error => {
  323. if (error) {
  324. throw error
  325. }
  326. assert(
  327. deleteFile.isDone(),
  328. `/api/projects/${historyId}/changes should have been called`
  329. )
  330. done()
  331. }
  332. )
  333. })
  334. })
  335. describe("when a doc's contents is not up to date", function () {
  336. it('should send test updates to the history store', function (done) {
  337. MockHistoryStore()
  338. .get(`/api/projects/${historyId}/latest/history`)
  339. .reply(200, {
  340. chunk: {
  341. history: {
  342. snapshot: {
  343. files: {
  344. 'main.tex': {
  345. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  346. stringLength: 3,
  347. },
  348. },
  349. },
  350. changes: [],
  351. },
  352. startVersion: 0,
  353. },
  354. })
  355. MockHistoryStore()
  356. .get(
  357. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  358. )
  359. .reply(200, 'a\nb')
  360. const addFile = MockHistoryStore()
  361. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  362. expect(body).to.deep.equal([
  363. {
  364. v2Authors: [],
  365. authors: [],
  366. timestamp: this.timestamp.toJSON(),
  367. operations: [
  368. {
  369. pathname: 'main.tex',
  370. textOperation: [3, '\nc'],
  371. },
  372. ],
  373. origin: { kind: 'test-origin' },
  374. },
  375. ])
  376. return true
  377. })
  378. .query({ end_version: 0 })
  379. .reply(204)
  380. async.series(
  381. [
  382. cb => {
  383. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  384. },
  385. cb => {
  386. const update = {
  387. projectHistoryId: historyId,
  388. resyncProjectStructure: {
  389. docs: [{ path: '/main.tex' }],
  390. files: [],
  391. },
  392. meta: {
  393. ts: this.timestamp,
  394. },
  395. }
  396. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  397. },
  398. cb => {
  399. const update = {
  400. path: '/main.tex',
  401. projectHistoryId: historyId,
  402. resyncDocContent: {
  403. content: 'a\nb\nc',
  404. },
  405. doc: this.doc_id,
  406. meta: {
  407. ts: this.timestamp,
  408. },
  409. }
  410. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  411. },
  412. cb => {
  413. ProjectHistoryClient.flushProject(this.project_id, cb)
  414. },
  415. ],
  416. error => {
  417. if (error) {
  418. throw error
  419. }
  420. assert(
  421. addFile.isDone(),
  422. `/api/projects/${historyId}/changes should have been called`
  423. )
  424. done()
  425. }
  426. )
  427. })
  428. it('should strip non-BMP characters in updates before sending to the history store', function (done) {
  429. MockHistoryStore()
  430. .get(`/api/projects/${historyId}/latest/history`)
  431. .reply(200, {
  432. chunk: {
  433. history: {
  434. snapshot: {
  435. files: {
  436. 'main.tex': {
  437. hash: '0a207c060e61f3b88eaee0a8cd0696f46fb155eb',
  438. stringLength: 3,
  439. },
  440. },
  441. },
  442. changes: [],
  443. },
  444. startVersion: 0,
  445. },
  446. })
  447. MockHistoryStore()
  448. .get(
  449. `/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
  450. )
  451. .reply(200, 'a\nb')
  452. const addFile = MockHistoryStore()
  453. .post(`/api/projects/${historyId}/legacy_changes`, body => {
  454. expect(body).to.deep.equal([
  455. {
  456. v2Authors: [],
  457. authors: [],
  458. timestamp: this.timestamp.toJSON(),
  459. operations: [
  460. {
  461. pathname: 'main.tex',
  462. textOperation: [3, '\n\uFFFD\uFFFDc'],
  463. },
  464. ],
  465. origin: { kind: 'test-origin' },
  466. },
  467. ])
  468. return true
  469. })
  470. .query({ end_version: 0 })
  471. .reply(204)
  472. async.series(
  473. [
  474. cb => {
  475. ProjectHistoryClient.resyncHistory(this.project_id, cb)
  476. },
  477. cb => {
  478. const update = {
  479. projectHistoryId: historyId,
  480. resyncProjectStructure: {
  481. docs: [{ path: '/main.tex' }],
  482. files: [],
  483. },
  484. meta: {
  485. ts: this.timestamp,
  486. },
  487. }
  488. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  489. },
  490. cb => {
  491. const update = {
  492. path: '/main.tex',
  493. projectHistoryId: historyId,
  494. resyncDocContent: {
  495. content: 'a\nb\n\uD800\uDC00c',
  496. },
  497. doc: this.doc_id,
  498. meta: {
  499. ts: this.timestamp,
  500. },
  501. }
  502. ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
  503. },
  504. cb => {
  505. ProjectHistoryClient.flushProject(this.project_id, cb)
  506. },
  507. ],
  508. error => {
  509. if (error) {
  510. throw error
  511. }
  512. assert(
  513. addFile.isDone(),
  514. `/api/projects/${historyId}/changes should have been called`
  515. )
  516. done()
  517. }
  518. )
  519. })
  520. })
  521. })
  522. })
  523. })