projects.test.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814
  1. 'use strict'
  2. const { expect } = require('chai')
  3. const fs = require('node:fs')
  4. const { Readable } = require('node:stream')
  5. const HTTPStatus = require('http-status')
  6. const fetch = require('node-fetch')
  7. const sinon = require('sinon')
  8. const cleanup = require('../storage/support/cleanup')
  9. const fixtures = require('../storage/support/fixtures')
  10. const testFiles = require('../storage/support/test_files')
  11. const {
  12. zipStore,
  13. BlobStore,
  14. persistChanges,
  15. redisBuffer,
  16. blobHash,
  17. } = require('../../../../storage')
  18. const { expectHttpError } = require('./support/expect_response')
  19. const testServer = require('./support/test_server')
  20. const { createEmptyProject } = require('./support/test_projects')
  21. const {
  22. File,
  23. Snapshot,
  24. Change,
  25. AddFileOperation,
  26. EditFileOperation,
  27. TextOperation,
  28. Operation,
  29. } = require('overleaf-editor-core')
  30. const testProjects = require('./support/test_projects')
  31. const { ObjectId } = require('mongodb')
  32. describe('project controller', function () {
  33. beforeEach(cleanup.everything)
  34. beforeEach(fixtures.create)
  35. describe('initializeProject', function () {
  36. it('can initialize a new project', async function () {
  37. const projectId = await testProjects.createEmptyProject()
  38. expect(projectId).to.be.a('string')
  39. })
  40. })
  41. describe('createZip', function () {
  42. let importSnapshot
  43. let createZip
  44. before(function () {
  45. importSnapshot =
  46. testServer.basicAuthClient.apis.ProjectImport.importSnapshot1
  47. createZip = testServer.basicAuthClient.apis.Project.createZip
  48. })
  49. beforeEach(function () {
  50. // Don't start the work in the background in this test --- it is flaky.
  51. sinon.stub(zipStore, 'storeZip').resolves()
  52. })
  53. afterEach(function () {
  54. zipStore.storeZip.restore()
  55. })
  56. it('creates a URL to a zip file', async function () {
  57. // Create a test blob.
  58. const testProjectId = fixtures.docs.uninitializedProject.id
  59. const response = await fetch(
  60. testServer.url(
  61. `/api/projects/${testProjectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  62. ),
  63. {
  64. method: 'PUT',
  65. body: fs.createReadStream(testFiles.path('hello.txt')),
  66. headers: {
  67. Authorization: testServer.basicAuthHeader,
  68. },
  69. }
  70. )
  71. expect(response.ok).to.be.true
  72. // Import a project with the test blob.
  73. const testFilePathname = 'hello.txt'
  74. const testSnapshot = new Snapshot()
  75. testSnapshot.addFile(
  76. testFilePathname,
  77. File.fromHash(testFiles.HELLO_TXT_HASH)
  78. )
  79. const importResponse = await importSnapshot({
  80. project_id: testProjectId,
  81. snapshot: testSnapshot.toRaw(),
  82. })
  83. expect(importResponse.obj.projectId).to.equal(testProjectId)
  84. const createZipResponse = await createZip({
  85. project_id: testProjectId,
  86. version: 0,
  87. })
  88. expect(createZipResponse.status).to.equal(HTTPStatus.OK)
  89. const zipInfo = createZipResponse.obj
  90. expect(zipInfo.zipUrl).to.match(
  91. /^http:\/\/gcs:9090\/download\/storage\/v1\/b\/overleaf-test-zips/
  92. )
  93. expect(zipStore.storeZip.calledOnce).to.be.true
  94. })
  95. })
  96. describe('getLatestZip', function () {
  97. it('returns a zip of the latest snapshot', async function () {
  98. const projectId = fixtures.docs.uninitializedProject.id
  99. const uploadResponse = await fetch(
  100. testServer.url(
  101. `/api/projects/${projectId}/blobs/${testFiles.HELLO_TXT_HASH}`
  102. ),
  103. {
  104. method: 'PUT',
  105. body: fs.createReadStream(testFiles.path('hello.txt')),
  106. headers: { Authorization: testServer.basicAuthHeader },
  107. }
  108. )
  109. expect(uploadResponse.ok).to.be.true
  110. const snapshot = new Snapshot()
  111. snapshot.addFile('hello.txt', File.fromHash(testFiles.HELLO_TXT_HASH))
  112. const importResponse =
  113. await testServer.basicAuthClient.apis.ProjectImport.importSnapshot1({
  114. project_id: projectId,
  115. snapshot: snapshot.toRaw(),
  116. })
  117. expect(importResponse.obj.projectId).to.equal(projectId)
  118. const downloadClient =
  119. await testServer.createClientForDownloadZip(projectId)
  120. const zipResponse = await downloadClient.apis.Project.getLatestZip({
  121. project_id: projectId,
  122. })
  123. expect(zipResponse.status).to.equal(HTTPStatus.OK)
  124. expect(zipResponse.headers['x-history-version']).to.equal('0')
  125. expect(zipResponse.headers['content-type']).to.equal(
  126. 'application/octet-stream'
  127. )
  128. expect(zipResponse.headers['content-disposition']).to.equal(
  129. 'attachment; filename=project.zip'
  130. )
  131. const testFile = File.fromHash(testFiles.HELLO_TXT_HASH)
  132. const testChange = new Change(
  133. [Operation.addFile('main.tex', testFile)],
  134. new Date()
  135. )
  136. const importchangesResponse =
  137. await testServer.basicAuthClient.apis.ProjectImport.importChanges1({
  138. project_id: projectId,
  139. end_version: 0,
  140. changes: [testChange.toRaw()],
  141. })
  142. expect(importchangesResponse.status).to.equal(HTTPStatus.CREATED)
  143. expect(importchangesResponse.obj).to.deep.equal({ resyncNeeded: false })
  144. const zipResponse2 = await downloadClient.apis.Project.getLatestZip({
  145. project_id: projectId,
  146. })
  147. expect(zipResponse2.status).to.equal(HTTPStatus.OK)
  148. expect(zipResponse2.headers['x-history-version']).to.equal('1')
  149. })
  150. it('returns 404 for an unknown project', async function () {
  151. const unknownProjectId = new ObjectId().toString()
  152. const downloadClient =
  153. await testServer.createClientForDownloadZip(unknownProjectId)
  154. await expectHttpError(
  155. downloadClient.apis.Project.getLatestZip({
  156. project_id: unknownProjectId,
  157. }),
  158. HTTPStatus.NOT_FOUND
  159. )
  160. })
  161. })
  162. describe('blob stats', function () {
  163. let populatedPostgresProjectId,
  164. populatedMongoProjectId,
  165. emptyPostgresProjectId,
  166. emptyMongoProjectId
  167. async function populateProject(projectId) {
  168. const files = {
  169. [testFiles.GRAPH_PNG_HASH]: testFiles.path('graph.png'),
  170. [testFiles.HELLO_TXT_HASH]: testFiles.path('hello.txt'),
  171. }
  172. for (const [hash, path] of Object.entries(files)) {
  173. const response = await fetch(
  174. testServer.url(`/api/projects/${projectId}/blobs/${hash}`),
  175. {
  176. method: 'PUT',
  177. body: fs.createReadStream(path),
  178. headers: {
  179. Authorization: testServer.basicAuthHeader,
  180. },
  181. }
  182. )
  183. expect(response.status).to.equal(201)
  184. }
  185. }
  186. beforeEach(async function () {
  187. emptyPostgresProjectId = await testProjects.createEmptyProject()
  188. emptyMongoProjectId = await testProjects.createEmptyProject(
  189. new ObjectId().toString()
  190. )
  191. populatedPostgresProjectId = await testProjects.createEmptyProject()
  192. await populateProject(populatedPostgresProjectId)
  193. populatedMongoProjectId = await testProjects.createEmptyProject(
  194. new ObjectId().toString()
  195. )
  196. await populateProject(populatedMongoProjectId)
  197. })
  198. describe('getProjectBlobsStats', function () {
  199. it('handles empty postgres project', async function () {
  200. const { body } =
  201. await testServer.basicAuthClient.apis.Project.getProjectBlobsStats({
  202. body: { projectIds: [emptyPostgresProjectId] },
  203. })
  204. expect(body).to.deep.equal([
  205. {
  206. projectId: emptyPostgresProjectId,
  207. textBlobBytes: 0,
  208. binaryBlobBytes: 0,
  209. totalBytes: 0,
  210. nTextBlobs: 0,
  211. nBinaryBlobs: 0,
  212. },
  213. ])
  214. })
  215. it('handles populated postgres project', async function () {
  216. const { body } =
  217. await testServer.basicAuthClient.apis.Project.getProjectBlobsStats({
  218. body: { projectIds: [populatedPostgresProjectId] },
  219. })
  220. expect(body).to.deep.equal([
  221. {
  222. projectId: populatedPostgresProjectId,
  223. textBlobBytes: testFiles.HELLO_TXT_BYTE_LENGTH,
  224. binaryBlobBytes: testFiles.GRAPH_PNG_BYTE_LENGTH,
  225. totalBytes:
  226. testFiles.HELLO_TXT_BYTE_LENGTH + testFiles.GRAPH_PNG_BYTE_LENGTH,
  227. nTextBlobs: 1,
  228. nBinaryBlobs: 1,
  229. },
  230. ])
  231. })
  232. it('handles empty mongo project', async function () {
  233. const { body } =
  234. await testServer.basicAuthClient.apis.Project.getProjectBlobsStats({
  235. body: { projectIds: [emptyMongoProjectId] },
  236. })
  237. expect(body).to.deep.equal([
  238. {
  239. projectId: emptyMongoProjectId,
  240. textBlobBytes: 0,
  241. binaryBlobBytes: 0,
  242. totalBytes: 0,
  243. nTextBlobs: 0,
  244. nBinaryBlobs: 0,
  245. },
  246. ])
  247. })
  248. it('handles populated mongo project', async function () {
  249. const { body } =
  250. await testServer.basicAuthClient.apis.Project.getProjectBlobsStats({
  251. body: { projectIds: [populatedMongoProjectId] },
  252. })
  253. expect(body).to.deep.equal([
  254. {
  255. projectId: populatedMongoProjectId,
  256. textBlobBytes: testFiles.HELLO_TXT_BYTE_LENGTH,
  257. binaryBlobBytes: testFiles.GRAPH_PNG_BYTE_LENGTH,
  258. totalBytes:
  259. testFiles.HELLO_TXT_BYTE_LENGTH + testFiles.GRAPH_PNG_BYTE_LENGTH,
  260. nTextBlobs: 1,
  261. nBinaryBlobs: 1,
  262. },
  263. ])
  264. })
  265. it('handles batch of projects', async function () {
  266. const { body } =
  267. await testServer.basicAuthClient.apis.Project.getProjectBlobsStats({
  268. body: {
  269. projectIds: [
  270. populatedPostgresProjectId,
  271. populatedMongoProjectId,
  272. emptyPostgresProjectId,
  273. emptyMongoProjectId,
  274. ],
  275. },
  276. })
  277. expect(body).to.deep.equal([
  278. {
  279. projectId: populatedPostgresProjectId,
  280. textBlobBytes: testFiles.HELLO_TXT_BYTE_LENGTH,
  281. binaryBlobBytes: testFiles.GRAPH_PNG_BYTE_LENGTH,
  282. totalBytes:
  283. testFiles.HELLO_TXT_BYTE_LENGTH + testFiles.GRAPH_PNG_BYTE_LENGTH,
  284. nTextBlobs: 1,
  285. nBinaryBlobs: 1,
  286. },
  287. {
  288. projectId: populatedMongoProjectId,
  289. textBlobBytes: testFiles.HELLO_TXT_BYTE_LENGTH,
  290. binaryBlobBytes: testFiles.GRAPH_PNG_BYTE_LENGTH,
  291. totalBytes:
  292. testFiles.HELLO_TXT_BYTE_LENGTH + testFiles.GRAPH_PNG_BYTE_LENGTH,
  293. nTextBlobs: 1,
  294. nBinaryBlobs: 1,
  295. },
  296. {
  297. projectId: emptyPostgresProjectId,
  298. textBlobBytes: 0,
  299. binaryBlobBytes: 0,
  300. totalBytes: 0,
  301. nTextBlobs: 0,
  302. nBinaryBlobs: 0,
  303. },
  304. {
  305. projectId: emptyMongoProjectId,
  306. textBlobBytes: 0,
  307. binaryBlobBytes: 0,
  308. totalBytes: 0,
  309. nTextBlobs: 0,
  310. nBinaryBlobs: 0,
  311. },
  312. ])
  313. })
  314. })
  315. describe('getBlobStats', function () {
  316. it('handles empty list of hashes', async function () {
  317. const { body } =
  318. await testServer.basicAuthClient.apis.Project.getBlobStats({
  319. project_id: populatedPostgresProjectId,
  320. body: { blobHashes: [] },
  321. })
  322. expect(body).to.deep.equal({
  323. projectId: populatedPostgresProjectId,
  324. textBlobBytes: 0,
  325. binaryBlobBytes: 0,
  326. totalBytes: 0,
  327. nTextBlobs: 0,
  328. nBinaryBlobs: 0,
  329. })
  330. })
  331. it('handles a mix of text and binary blobs', async function () {
  332. const { body } =
  333. await testServer.basicAuthClient.apis.Project.getBlobStats({
  334. project_id: populatedPostgresProjectId,
  335. body: {
  336. blobHashes: [testFiles.HELLO_TXT_HASH, testFiles.GRAPH_PNG_HASH],
  337. },
  338. })
  339. expect(body).to.deep.equal({
  340. projectId: populatedPostgresProjectId,
  341. textBlobBytes: testFiles.HELLO_TXT_BYTE_LENGTH,
  342. binaryBlobBytes: testFiles.GRAPH_PNG_BYTE_LENGTH,
  343. totalBytes:
  344. testFiles.HELLO_TXT_BYTE_LENGTH + testFiles.GRAPH_PNG_BYTE_LENGTH,
  345. nTextBlobs: 1,
  346. nBinaryBlobs: 1,
  347. })
  348. })
  349. it('handles only text blobs', async function () {
  350. const { body } =
  351. await testServer.basicAuthClient.apis.Project.getBlobStats({
  352. project_id: populatedPostgresProjectId,
  353. body: {
  354. blobHashes: [testFiles.HELLO_TXT_HASH],
  355. },
  356. })
  357. expect(body).to.deep.equal({
  358. projectId: populatedPostgresProjectId,
  359. textBlobBytes: testFiles.HELLO_TXT_BYTE_LENGTH,
  360. binaryBlobBytes: 0,
  361. totalBytes: testFiles.HELLO_TXT_BYTE_LENGTH,
  362. nTextBlobs: 1,
  363. nBinaryBlobs: 0,
  364. })
  365. })
  366. it('handles only binary blobs', async function () {
  367. const { body } =
  368. await testServer.basicAuthClient.apis.Project.getBlobStats({
  369. project_id: populatedPostgresProjectId,
  370. body: {
  371. blobHashes: [testFiles.GRAPH_PNG_HASH],
  372. },
  373. })
  374. expect(body).to.deep.equal({
  375. projectId: populatedPostgresProjectId,
  376. textBlobBytes: 0,
  377. binaryBlobBytes: testFiles.GRAPH_PNG_BYTE_LENGTH,
  378. totalBytes: testFiles.GRAPH_PNG_BYTE_LENGTH,
  379. nTextBlobs: 0,
  380. nBinaryBlobs: 1,
  381. })
  382. })
  383. it('handles non-existent blobs', async function () {
  384. const { body } =
  385. await testServer.basicAuthClient.apis.Project.getBlobStats({
  386. project_id: populatedPostgresProjectId,
  387. body: {
  388. blobHashes: [testFiles.STRING_AB_HASH],
  389. },
  390. })
  391. expect(body).to.deep.equal({
  392. projectId: populatedPostgresProjectId,
  393. textBlobBytes: 0,
  394. binaryBlobBytes: 0,
  395. totalBytes: 0,
  396. nTextBlobs: 0,
  397. nBinaryBlobs: 0,
  398. })
  399. })
  400. it('throws an error for bad hashes', async function () {
  401. await expectHttpError(
  402. testServer.basicAuthClient.apis.Project.getBlobStats({
  403. project_id: populatedPostgresProjectId,
  404. body: {
  405. blobHashes: ['non-existent-hash'],
  406. },
  407. }),
  408. HTTPStatus.INTERNAL_SERVER_ERROR
  409. )
  410. })
  411. it('handles a request with a large number of blobs', async function () {
  412. const projectId = await testProjects.createEmptyProject()
  413. const blobHashes = []
  414. let expectedTextBytes = 0
  415. let expectedBinaryBytes = 0
  416. const nTextBlobs = 10
  417. const nBinaryBlobs = 10
  418. for (let i = 0; i < nTextBlobs; i++) {
  419. const content = `text blob ${i}`
  420. const hash = blobHash.fromString(content)
  421. blobHashes.push(hash)
  422. expectedTextBytes += content.length
  423. const res = await fetch(
  424. testServer.url(`/api/projects/${projectId}/blobs/${hash}`),
  425. {
  426. method: 'PUT',
  427. body: content,
  428. headers: { Authorization: testServer.basicAuthHeader },
  429. }
  430. )
  431. expect(res.status).to.equal(HTTPStatus.CREATED)
  432. }
  433. for (let i = 0; i < nBinaryBlobs; i++) {
  434. const content = Buffer.from([0, i, i + 1, i + 2])
  435. const hash = await blobHash.fromStream(
  436. content.length,
  437. Readable.from(content)
  438. )
  439. blobHashes.push(hash)
  440. expectedBinaryBytes += content.length
  441. const res = await fetch(
  442. testServer.url(`/api/projects/${projectId}/blobs/${hash}`),
  443. {
  444. method: 'PUT',
  445. body: content,
  446. headers: {
  447. Authorization: testServer.basicAuthHeader,
  448. 'Content-Type': 'application/octet-stream',
  449. },
  450. }
  451. )
  452. expect(res.status).to.equal(HTTPStatus.CREATED)
  453. }
  454. const { body } =
  455. await testServer.basicAuthClient.apis.Project.getBlobStats({
  456. project_id: projectId,
  457. body: { blobHashes },
  458. })
  459. expect(body).to.deep.equal({
  460. projectId,
  461. textBlobBytes: expectedTextBytes,
  462. binaryBlobBytes: expectedBinaryBytes,
  463. totalBytes: expectedTextBytes + expectedBinaryBytes,
  464. nTextBlobs,
  465. nBinaryBlobs,
  466. })
  467. })
  468. })
  469. })
  470. // eslint-disable-next-line mocha/no-pending-tests
  471. describe.skip('getLatestContent', function () {
  472. // TODO: remove this endpoint entirely, see
  473. // https://github.com/overleaf/write_latex/pull/5120#discussion_r244291862
  474. })
  475. describe('project with changes', function () {
  476. let projectId
  477. beforeEach(async function () {
  478. // used to provide a limit which forces us to persist all of the changes.
  479. const farFuture = new Date()
  480. farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
  481. const limits = {
  482. minChangeTimestamp: farFuture,
  483. maxChangeTimestamp: farFuture,
  484. }
  485. const changes = [
  486. new Change(
  487. [new AddFileOperation('test.tex', File.fromString('ab'))],
  488. new Date(),
  489. []
  490. ),
  491. new Change(
  492. [new AddFileOperation('other.tex', File.fromString('hello'))],
  493. new Date(),
  494. []
  495. ),
  496. ]
  497. projectId = await createEmptyProject()
  498. await persistChanges(projectId, changes, limits, 0)
  499. })
  500. describe('getLatestHashedContent', function () {
  501. it('returns a snapshot', async function () {
  502. const response =
  503. await testServer.basicAuthClient.apis.Project.getLatestHashedContent({
  504. project_id: projectId,
  505. })
  506. expect(response.status).to.equal(HTTPStatus.OK)
  507. const snapshot = Snapshot.fromRaw(response.obj)
  508. expect(snapshot.countFiles()).to.equal(2)
  509. expect(snapshot.getFile('test.tex').getHash()).to.equal(
  510. testFiles.STRING_AB_HASH
  511. )
  512. })
  513. })
  514. describe('getChanges', function () {
  515. it('returns all changes when not given a limit', async function () {
  516. const response =
  517. await testServer.basicAuthClient.apis.Project.getChanges({
  518. project_id: projectId,
  519. })
  520. expect(response.status).to.equal(HTTPStatus.OK)
  521. const { changes, hasMore } = response.obj
  522. expect(changes.length).to.equal(2)
  523. const filenames = changes
  524. .flatMap(change => change.operations)
  525. .map(operation => operation.pathname)
  526. expect(filenames).to.deep.equal(['test.tex', 'other.tex'])
  527. expect(hasMore).to.be.false
  528. })
  529. it('returns only requested changes', async function () {
  530. const response =
  531. await testServer.basicAuthClient.apis.Project.getChanges({
  532. project_id: projectId,
  533. since: 1,
  534. })
  535. expect(response.status).to.equal(HTTPStatus.OK)
  536. const { changes, hasMore } = response.obj
  537. expect(changes.length).to.equal(1)
  538. const filenames = changes
  539. .flatMap(change => change.operations)
  540. .map(operation => operation.pathname)
  541. expect(filenames).to.deep.equal(['other.tex'])
  542. expect(hasMore).to.be.false
  543. })
  544. it('rejects negative versions', async function () {
  545. await expect(
  546. testServer.basicAuthClient.apis.Project.getChanges({
  547. project_id: projectId,
  548. since: -1,
  549. })
  550. ).to.be.rejectedWith('request failed with status 400')
  551. })
  552. it('rejects out of bounds versions', async function () {
  553. await expect(
  554. testServer.basicAuthClient.apis.Project.getChanges({
  555. project_id: projectId,
  556. since: 20,
  557. })
  558. ).to.be.rejectedWith('request failed with status 400')
  559. })
  560. })
  561. describe('project with many chunks', function () {
  562. let projectId, changes
  563. beforeEach(async function () {
  564. // used to provide a limit which forces us to persist all of the changes.
  565. const farFuture = new Date()
  566. farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
  567. const limits = {
  568. minChangeTimestamp: farFuture,
  569. maxChangeTimestamp: farFuture,
  570. maxChunkChanges: 5,
  571. }
  572. projectId = await createEmptyProject()
  573. const blobStore = new BlobStore(projectId)
  574. const blob = await blobStore.putString('')
  575. changes = [
  576. new Change(
  577. [new AddFileOperation('test.tex', File.createLazyFromBlobs(blob))],
  578. new Date(),
  579. []
  580. ),
  581. ]
  582. for (let i = 0; i < 20; i++) {
  583. const textOperation = new TextOperation()
  584. textOperation.retain(i)
  585. textOperation.insert('x')
  586. changes.push(
  587. new Change(
  588. [new EditFileOperation('test.tex', textOperation)],
  589. new Date(),
  590. []
  591. )
  592. )
  593. }
  594. await persistChanges(projectId, changes, limits, 0)
  595. })
  596. it('returns the first chunk when not given a limit', async function () {
  597. const response =
  598. await testServer.basicAuthClient.apis.Project.getChanges({
  599. project_id: projectId,
  600. })
  601. expect(response.status).to.equal(HTTPStatus.OK)
  602. expect(response.obj).to.deep.equal({
  603. changes: changes.slice(0, 5).map(c => c.toRaw()),
  604. hasMore: true,
  605. })
  606. })
  607. it('returns only requested changes', async function () {
  608. const response =
  609. await testServer.basicAuthClient.apis.Project.getChanges({
  610. project_id: projectId,
  611. since: 12,
  612. })
  613. expect(response.status).to.equal(HTTPStatus.OK)
  614. expect(response.obj).to.deep.equal({
  615. changes: changes.slice(12, 15).map(c => c.toRaw()),
  616. hasMore: true,
  617. })
  618. })
  619. it('returns changes in the latest chunk', async function () {
  620. const response =
  621. await testServer.basicAuthClient.apis.Project.getChanges({
  622. project_id: projectId,
  623. since: 20,
  624. })
  625. expect(response.status).to.equal(HTTPStatus.OK)
  626. expect(response.obj).to.deep.equal({
  627. changes: changes.slice(20).map(c => c.toRaw()),
  628. hasMore: false,
  629. })
  630. })
  631. })
  632. })
  633. describe('getLatestHistoryRaw', function () {
  634. it('should handles read', async function () {
  635. const projectId = fixtures.docs.initializedProject.id
  636. const response =
  637. await testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistoryRaw(
  638. {
  639. project_id: projectId,
  640. readOnly: 'true',
  641. }
  642. )
  643. expect(response.body).to.deep.equal({
  644. startVersion: 0,
  645. endVersion: 1,
  646. endTimestamp: '2032-01-01T00:00:00.000Z',
  647. })
  648. })
  649. })
  650. describe('deleteProject', function () {
  651. it('deletes the project chunks', async function () {
  652. const projectId = fixtures.docs.initializedProject.id
  653. const historyResponse =
  654. await testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistory(
  655. {
  656. project_id: projectId,
  657. }
  658. )
  659. expect(historyResponse.status).to.equal(HTTPStatus.OK)
  660. expect(historyResponse.body).to.have.property('chunk')
  661. const deleteResponse =
  662. await testServer.basicAuthClient.apis.Project.deleteProject({
  663. project_id: projectId,
  664. })
  665. expect(deleteResponse.status).to.equal(HTTPStatus.NO_CONTENT)
  666. await expectHttpError(
  667. testServer.pseudoJwtBasicAuthClient.apis.Project.getLatestHistory({
  668. project_id: projectId,
  669. }),
  670. HTTPStatus.NOT_FOUND
  671. )
  672. })
  673. it('deletes the project blobs', async function () {
  674. const projectId = fixtures.docs.initializedProject.id
  675. const token = testServer.createTokenForProject(projectId)
  676. const authHeaders = { Authorization: `Bearer ${token}` }
  677. const hash = testFiles.HELLO_TXT_HASH
  678. const fileContents = await fs.promises.readFile(
  679. testFiles.path('hello.txt')
  680. )
  681. const blobUrl = testServer.url(`/api/projects/${projectId}/blobs/${hash}`)
  682. const response1 = await fetch(blobUrl, {
  683. method: 'PUT',
  684. headers: authHeaders,
  685. body: fileContents,
  686. })
  687. expect(response1.ok).to.be.true
  688. const response2 = await fetch(blobUrl, { headers: authHeaders })
  689. const payload = await response2.text()
  690. expect(payload).to.equal(fileContents.toString())
  691. const deleteResponse =
  692. await testServer.basicAuthClient.apis.Project.deleteProject({
  693. project_id: projectId,
  694. })
  695. expect(deleteResponse.status).to.equal(HTTPStatus.NO_CONTENT)
  696. const response3 = await fetch(blobUrl, { headers: authHeaders })
  697. expect(response3.status).to.equal(HTTPStatus.NOT_FOUND)
  698. })
  699. it('deletes the project from the redis buffer', async function () {
  700. const projectId = await createEmptyProject()
  701. const blobStore = new BlobStore(projectId)
  702. const blob = await blobStore.putString('this is a test')
  703. const snapshot = new Snapshot()
  704. const change = new Change(
  705. [new AddFileOperation('test.tex', File.createLazyFromBlobs(blob))],
  706. new Date(),
  707. []
  708. )
  709. await redisBuffer.queueChanges(projectId, snapshot, 0, [change])
  710. const changesBefore = await redisBuffer.getNonPersistedChanges(
  711. projectId,
  712. 0
  713. )
  714. expect(changesBefore.length).to.equal(1)
  715. const deleteResponse =
  716. await testServer.basicAuthClient.apis.Project.deleteProject({
  717. project_id: projectId,
  718. })
  719. expect(deleteResponse.status).to.equal(HTTPStatus.NO_CONTENT)
  720. const changesAfter = await redisBuffer.getNonPersistedChanges(
  721. projectId,
  722. 0
  723. )
  724. expect(changesAfter.length).to.equal(0)
  725. const finalState = await redisBuffer.getState(projectId)
  726. expect(finalState).to.deep.equal({
  727. changes: [],
  728. expireTime: null,
  729. headSnapshot: null,
  730. headVersion: null,
  731. persistTime: null,
  732. persistedVersion: null,
  733. })
  734. })
  735. it('deletes an empty project from the redis buffer', async function () {
  736. const projectId = await createEmptyProject()
  737. const deleteResponse =
  738. await testServer.basicAuthClient.apis.Project.deleteProject({
  739. project_id: projectId,
  740. })
  741. expect(deleteResponse.status).to.equal(HTTPStatus.NO_CONTENT)
  742. const changesAfter = await redisBuffer.getNonPersistedChanges(
  743. projectId,
  744. 0
  745. )
  746. expect(changesAfter.length).to.equal(0)
  747. })
  748. })
  749. })