DocumentConversionManager.test.mjs 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import { describe, expect, vi, beforeEach } from 'vitest'
  2. import sinon from 'sinon'
  3. import FormData from 'form-data'
  4. import { FileTooLargeError } from '../../../../app/src/Features/Errors/Errors.js'
  5. const MODULE_PATH =
  6. '../../../../app/src/Features/Uploads/DocumentConversionManager.mjs'
  7. describe('DocumentConversionManager', function () {
  8. beforeEach(async function (ctx) {
  9. ctx.fs = {
  10. createReadStream: sinon.stub().returns('mocked-read-stream'),
  11. createWriteStream: sinon.stub().returns('mocked-write-stream'),
  12. }
  13. ctx.fsPromises = {
  14. unlink: sinon.stub().resolves(),
  15. }
  16. ctx.fetchUtils = {
  17. fetchStreamWithResponse: sinon.stub().resolves(),
  18. }
  19. ctx.nodeStream = {
  20. pipeline: sinon.stub().resolves(),
  21. }
  22. ctx.CompileManager = {
  23. promises: {
  24. _getUserCompileLimits: sinon.stub().resolves({
  25. compileBackendClass: 'test-backend-class',
  26. compileGroup: 'test-compile-group',
  27. }),
  28. },
  29. }
  30. ctx.Settings = {
  31. maxUploadSize: 100,
  32. path: {
  33. dumpFolder: '/path/to/dump/folder',
  34. },
  35. apis: {
  36. clsi: {
  37. url: 'http://mock-clsi-url',
  38. },
  39. },
  40. }
  41. vi.doMock('node:fs', () => ({
  42. default: ctx.fs,
  43. }))
  44. vi.doMock('node:fs/promises', () => ({
  45. default: ctx.fsPromises,
  46. }))
  47. vi.doMock('@overleaf/fetch-utils', () => ({
  48. fetchStreamWithResponse: ctx.fetchUtils.fetchStreamWithResponse,
  49. }))
  50. vi.doMock('node:stream/promises', () => ({
  51. pipeline: ctx.nodeStream.pipeline,
  52. }))
  53. vi.doMock('@overleaf/settings', () => ({
  54. default: ctx.Settings,
  55. }))
  56. vi.doMock(
  57. '../../../../app/src/Features/Compile/CompileManager.mjs',
  58. () => ({
  59. default: ctx.CompileManager,
  60. })
  61. )
  62. ctx.DocumentConversionManager = (await import(MODULE_PATH)).default
  63. })
  64. describe('convertDocxToLaTeXZipArchive', function () {
  65. describe('successfully', function () {
  66. beforeEach(async function (ctx) {
  67. ctx.path = '/path/to/input.docx'
  68. ctx.userId = 'test-user-id'
  69. ctx.outputPath = '/path/to/output.zip'
  70. ctx.response = {
  71. headers: {
  72. get: sinon.stub().returns(null),
  73. },
  74. }
  75. ctx.response.headers.get.withArgs('Content-Length').returns('50')
  76. ctx.fetchUtils.fetchStreamWithResponse.resolves({
  77. stream: 'mocked-fetch-stream',
  78. response: ctx.response,
  79. })
  80. ctx.result =
  81. await ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive(
  82. ctx.path,
  83. ctx.userId
  84. )
  85. })
  86. it('should call fetchStreamWithResponse with the correct URL and form data', function (ctx) {
  87. const expectedUrl = new URL(ctx.Settings.apis.clsi.url)
  88. expectedUrl.pathname = '/convert/docx-to-latex'
  89. expectedUrl.searchParams.set(
  90. 'compileBackendClass',
  91. 'test-backend-class'
  92. )
  93. expectedUrl.searchParams.set('compileGroup', 'test-compile-group')
  94. sinon.assert.calledWith(
  95. ctx.fetchUtils.fetchStreamWithResponse,
  96. sinon.match(url => url.toString() === expectedUrl.toString()),
  97. {
  98. method: 'POST',
  99. body: sinon.match.instanceOf(FormData),
  100. signal: sinon.match.instanceOf(AbortSignal),
  101. }
  102. )
  103. })
  104. it('should pipe result into the output file', function (ctx) {
  105. sinon.assert.calledWith(
  106. ctx.nodeStream.pipeline,
  107. 'mocked-fetch-stream',
  108. 'mocked-write-stream'
  109. )
  110. })
  111. it('should return a path to the output file', function (ctx) {
  112. expect(ctx.result).to.match(
  113. /\/path\/to\/dump\/folder\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.zip/
  114. )
  115. })
  116. })
  117. describe('when an error occurs during conversion', function () {
  118. beforeEach(async function (ctx) {
  119. ctx.path = '/path/to/input.docx'
  120. ctx.userId = 'test-user-id'
  121. ctx.fetchUtils.fetchStreamWithResponse.rejects(
  122. new Error('Conversion failed')
  123. )
  124. await expect(
  125. ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive(
  126. ctx.path,
  127. ctx.userId
  128. )
  129. ).to.be.rejectedWith('document conversion failed')
  130. })
  131. it('should attempt to clean up the output file', function (ctx) {
  132. sinon.assert.calledWith(
  133. ctx.fsPromises.unlink,
  134. sinon.match(
  135. /\/path\/to\/dump\/folder\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.zip/
  136. )
  137. )
  138. })
  139. })
  140. describe('when the converted archive is too large', function () {
  141. beforeEach(async function (ctx) {
  142. ctx.path = '/path/to/input.docx'
  143. ctx.userId = 'test-user-id'
  144. ctx.stream = {
  145. destroy: sinon.stub(),
  146. }
  147. ctx.response = {
  148. headers: {
  149. get: sinon.stub(),
  150. },
  151. }
  152. ctx.response.headers.get.withArgs('Content-Length').returns('150')
  153. ctx.fetchUtils.fetchStreamWithResponse.resolves({
  154. stream: ctx.stream,
  155. response: ctx.response,
  156. })
  157. await expect(
  158. ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive(
  159. ctx.path,
  160. ctx.userId
  161. )
  162. ).to.be.rejectedWith(sinon.match.instanceOf(FileTooLargeError))
  163. })
  164. it('should abort the request', function (ctx) {
  165. expect(
  166. ctx.fetchUtils.fetchStreamWithResponse.firstCall.args[1].signal
  167. .aborted
  168. ).to.equal(true)
  169. })
  170. it('should destroy the response stream', function (ctx) {
  171. sinon.assert.calledOnce(ctx.stream.destroy)
  172. })
  173. it('should not write the oversized archive to disk', function (ctx) {
  174. sinon.assert.notCalled(ctx.fs.createWriteStream)
  175. sinon.assert.notCalled(ctx.nodeStream.pipeline)
  176. })
  177. it('should attempt to clean up the output path', function (ctx) {
  178. sinon.assert.calledWith(
  179. ctx.fsPromises.unlink,
  180. sinon.match(
  181. /\/path\/to\/dump\/folder\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.zip/
  182. )
  183. )
  184. })
  185. })
  186. describe('when the Content-Length header is missing', function () {
  187. beforeEach(async function (ctx) {
  188. ctx.path = '/path/to/input.docx'
  189. ctx.userId = 'test-user-id'
  190. ctx.response = {
  191. headers: {
  192. get: sinon.stub().returns(null),
  193. },
  194. }
  195. ctx.fetchUtils.fetchStreamWithResponse.resolves({
  196. stream: 'mocked-fetch-stream',
  197. response: ctx.response,
  198. })
  199. await expect(
  200. ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive(
  201. ctx.path,
  202. ctx.userId
  203. )
  204. ).to.be.rejectedWith('document conversion failed')
  205. })
  206. it('should not write the archive to disk', function (ctx) {
  207. sinon.assert.notCalled(ctx.fs.createWriteStream)
  208. sinon.assert.notCalled(ctx.nodeStream.pipeline)
  209. })
  210. it('should attempt to clean up the output path', function (ctx) {
  211. sinon.assert.calledWith(
  212. ctx.fsPromises.unlink,
  213. sinon.match(
  214. /\/path\/to\/dump\/folder\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.zip/
  215. )
  216. )
  217. })
  218. })
  219. })
  220. })