ConversionController.test.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. import sinon from 'sinon'
  2. import { vi, describe, it, beforeEach, expect } from 'vitest'
  3. import Path from 'node:path'
  4. import { PassThrough } from 'node:stream'
  5. import * as Errors from '../../../app/js/Errors.js'
  6. const MODULE_PATH = Path.join(
  7. import.meta.dirname,
  8. '../../../app/js/ConversionController'
  9. )
  10. describe('ConversionController', function () {
  11. beforeEach(async function (ctx) {
  12. ctx.conversionDir = '/path/to/conversion/result'
  13. ctx.zipPath = '/path/to/conversion/result/output.zip'
  14. ctx.zipStat = { size: 1234 }
  15. ctx.documentPath = '/compiles/output-uuid/output-uuid.docx'
  16. ctx.documentStat = { size: 5678 }
  17. ctx.Settings = {
  18. enablePandocConversions: true,
  19. path: { compilesDir: '/compiles', outputDir: '/output' },
  20. }
  21. ctx.OutputCacheManager = {
  22. CACHE_SUBDIR: 'generated-files',
  23. promises: {
  24. generateBuildId: sinon.stub().resolves('00000000001-0000000000000001'),
  25. },
  26. }
  27. ctx.ConversionOutputCleaner = {
  28. scheduleCleanup: sinon.stub(),
  29. }
  30. ctx.parsedRequest = { rootResourcePath: 'main.tex' }
  31. ctx.ConversionManager = {
  32. promises: {
  33. convertToLaTeXWithLock: sinon.stub().resolves(ctx.zipPath),
  34. convertLaTeXToDocumentInDirWithLock: sinon
  35. .stub()
  36. .resolves(ctx.documentPath),
  37. },
  38. }
  39. ctx.ResourceWriter = {
  40. promises: {
  41. syncResourcesToDisk: sinon.stub().resolves(),
  42. },
  43. }
  44. ctx.RequestParser = {
  45. promises: {
  46. parse: sinon.stub().resolves(ctx.parsedRequest),
  47. },
  48. }
  49. ctx.fs = {
  50. stat: sinon.stub().resolves(ctx.zipStat),
  51. unlink: sinon.stub().resolves(),
  52. rm: sinon.stub().resolves(),
  53. mkdir: sinon.stub().resolves(),
  54. copyFile: sinon.stub().resolves(),
  55. }
  56. ctx.readStream = new PassThrough()
  57. ctx.fsSync = {
  58. createReadStream: sinon.stub().returns(ctx.readStream),
  59. }
  60. ctx.pipeline = sinon.stub().resolves()
  61. vi.doMock('node:fs/promises', () => ({
  62. default: ctx.fs,
  63. }))
  64. vi.doMock('node:fs', () => ({
  65. default: ctx.fsSync,
  66. }))
  67. vi.doMock('node:stream/promises', () => ({
  68. pipeline: ctx.pipeline,
  69. }))
  70. vi.doMock('@overleaf/settings', () => ({
  71. default: ctx.Settings,
  72. }))
  73. vi.doMock('../../../app/js/ConversionManager', () => ({
  74. default: ctx.ConversionManager,
  75. }))
  76. vi.doMock('../../../app/js/ResourceWriter', () => ({
  77. default: ctx.ResourceWriter,
  78. }))
  79. vi.doMock('../../../app/js/RequestParser', () => ({
  80. default: ctx.RequestParser,
  81. }))
  82. vi.doMock('../../../app/js/OutputCacheManager', () => ({
  83. default: ctx.OutputCacheManager,
  84. }))
  85. vi.doMock('../../../app/js/ConversionOutputCleaner', () => ({
  86. default: ctx.ConversionOutputCleaner,
  87. }))
  88. vi.doMock('../../../app/js/Errors', () => Errors)
  89. ctx.res = new PassThrough()
  90. ctx.res.attachment = sinon.stub()
  91. ctx.res.setHeader = sinon.stub()
  92. ctx.res.json = sinon.stub()
  93. ctx.ConversionController = (await import(MODULE_PATH)).default
  94. })
  95. describe('convertDocumentToLaTeX', function () {
  96. describe('when conversions are disabled', function () {
  97. beforeEach(async function (ctx) {
  98. ctx.Settings.enablePandocConversions = false
  99. ctx.req = {
  100. file: { path: '/path/to/uploaded/file.docx' },
  101. query: { type: 'docx' },
  102. }
  103. ctx.res.sendStatus = sinon.stub()
  104. await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
  105. })
  106. it('should remove the uploaded file', function (ctx) {
  107. sinon.assert.calledWith(ctx.fs.unlink, ctx.req.file.path)
  108. })
  109. it('should return 404', function (ctx) {
  110. sinon.assert.calledWith(ctx.res.sendStatus, 404)
  111. })
  112. it('should not call the conversion manager', function (ctx) {
  113. sinon.assert.notCalled(
  114. ctx.ConversionManager.promises.convertToLaTeXWithLock
  115. )
  116. })
  117. })
  118. describe('when conversionType is missing', function () {
  119. beforeEach(async function (ctx) {
  120. ctx.req = {
  121. file: { path: '/path/to/uploaded/file.docx' },
  122. query: {},
  123. }
  124. ctx.res.sendStatus = sinon.stub()
  125. await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
  126. })
  127. it('should remove the uploaded file', function (ctx) {
  128. sinon.assert.calledWith(ctx.fs.unlink, ctx.req.file.path)
  129. })
  130. it('should return 400', function (ctx) {
  131. sinon.assert.calledWith(ctx.res.sendStatus, 400)
  132. })
  133. it('should not call the conversion manager', function (ctx) {
  134. sinon.assert.notCalled(
  135. ctx.ConversionManager.promises.convertToLaTeXWithLock
  136. )
  137. })
  138. })
  139. describe('when conversionType is unsupported', function () {
  140. beforeEach(async function (ctx) {
  141. ctx.req = {
  142. file: { path: '/path/to/uploaded/file.docx' },
  143. query: { type: 'invalid' },
  144. }
  145. ctx.res.sendStatus = sinon.stub()
  146. await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
  147. })
  148. it('should remove the uploaded file', function (ctx) {
  149. sinon.assert.calledWith(ctx.fs.unlink, ctx.req.file.path)
  150. })
  151. it('should return 400', function (ctx) {
  152. sinon.assert.calledWith(ctx.res.sendStatus, 400)
  153. })
  154. it('should not call the conversion manager', function (ctx) {
  155. sinon.assert.notCalled(
  156. ctx.ConversionManager.promises.convertToLaTeXWithLock
  157. )
  158. })
  159. })
  160. describe('successfully', function () {
  161. beforeEach(async function (ctx) {
  162. ctx.req = {
  163. file: { path: '/path/to/uploaded/file.docx' },
  164. query: { type: 'docx' },
  165. }
  166. await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
  167. })
  168. it('should call the conversion manager with the uploaded file path and type', function (ctx) {
  169. sinon.assert.calledWith(
  170. ctx.ConversionManager.promises.convertToLaTeXWithLock,
  171. sinon.match(
  172. /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
  173. ),
  174. ctx.req.file.path,
  175. 'docx'
  176. )
  177. })
  178. it('should look up the generated zip file size', function (ctx) {
  179. sinon.assert.calledWith(ctx.fs.stat, ctx.zipPath)
  180. })
  181. it('should set the response headers for a zip file download', function (ctx) {
  182. sinon.assert.calledWith(
  183. ctx.res.setHeader,
  184. 'Content-Length',
  185. ctx.zipStat.size
  186. )
  187. sinon.assert.calledWith(ctx.res.attachment, 'conversion.zip')
  188. sinon.assert.calledWith(
  189. ctx.res.setHeader,
  190. 'X-Content-Type-Options',
  191. 'nosniff'
  192. )
  193. })
  194. it('should stream the generated zip file to the response', function (ctx) {
  195. sinon.assert.calledWith(ctx.fsSync.createReadStream, ctx.zipPath)
  196. sinon.assert.calledWith(ctx.pipeline, ctx.readStream, ctx.res)
  197. })
  198. it('should clean up the generated zip file', function (ctx) {
  199. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir)
  200. })
  201. })
  202. describe('with conversionType=markdown', function () {
  203. beforeEach(async function (ctx) {
  204. ctx.req = {
  205. file: { path: '/path/to/uploaded/file.md' },
  206. query: { type: 'markdown' },
  207. }
  208. await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
  209. })
  210. it('should call the conversion manager with the uploaded file path and markdown type', function (ctx) {
  211. sinon.assert.calledWith(
  212. ctx.ConversionManager.promises.convertToLaTeXWithLock,
  213. sinon.match(
  214. /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
  215. ),
  216. ctx.req.file.path,
  217. 'markdown'
  218. )
  219. })
  220. })
  221. describe('unsuccessfully', function () {
  222. describe('on streaming error', function () {
  223. it('should propagate the error and still clean up', async function (ctx) {
  224. ctx.pipeline.rejects(new Error('mock stream error'))
  225. const res = new PassThrough()
  226. res.attachment = sinon.stub()
  227. res.setHeader = sinon.stub()
  228. const req = {
  229. file: { path: '/path/to/uploaded/file.docx' },
  230. query: { type: 'docx' },
  231. }
  232. await expect(
  233. ctx.ConversionController.convertDocumentToLaTeX(req, res)
  234. ).to.be.rejectedWith('mock stream error')
  235. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir)
  236. })
  237. })
  238. describe('on a user-facing ConversionError', function () {
  239. beforeEach(async function (ctx) {
  240. ctx.jsonStub = sinon.stub()
  241. ctx.res.status = sinon.stub().returns({ json: ctx.jsonStub })
  242. ctx.req = {
  243. file: { path: '/path/to/uploaded/file.docx' },
  244. query: { type: 'docx' },
  245. }
  246. ctx.ConversionManager.promises.convertToLaTeXWithLock.rejects(
  247. new Errors.ConversionError('Non-zero exit code from pandoc', {
  248. type: 'docx',
  249. exitCode: 64,
  250. stderr: 'parse error at line 5',
  251. })
  252. )
  253. await ctx.ConversionController.convertDocumentToLaTeX(
  254. ctx.req,
  255. ctx.res
  256. )
  257. })
  258. it('should return 422 with the pandoc stderr in the response body', function (ctx) {
  259. sinon.assert.calledWith(ctx.res.status, 422)
  260. sinon.assert.calledWith(ctx.jsonStub, {
  261. error: 'parse error at line 5',
  262. exitCode: 64,
  263. })
  264. })
  265. it('should remove the uploaded file', function (ctx) {
  266. sinon.assert.calledWith(ctx.fs.unlink, ctx.req.file.path)
  267. })
  268. })
  269. describe('on a non-user-facing ConversionError', function () {
  270. beforeEach(async function (ctx) {
  271. ctx.jsonStub = sinon.stub()
  272. ctx.res.status = sinon.stub().returns({ json: ctx.jsonStub })
  273. ctx.req = {
  274. file: { path: '/path/to/uploaded/file.docx' },
  275. query: { type: 'docx' },
  276. }
  277. ctx.ConversionManager.promises.convertToLaTeXWithLock.rejects(
  278. new Errors.ConversionError('Non-zero exit code from pandoc', {
  279. type: 'docx',
  280. exitCode: 62,
  281. stderr: 'internal pandoc bug',
  282. })
  283. )
  284. await ctx.ConversionController.convertDocumentToLaTeX(
  285. ctx.req,
  286. ctx.res
  287. )
  288. })
  289. it('should return 422 without surfacing stderr', function (ctx) {
  290. sinon.assert.calledWith(ctx.res.status, 422)
  291. sinon.assert.calledWith(ctx.jsonStub, {})
  292. })
  293. it('should remove the uploaded file', function (ctx) {
  294. sinon.assert.calledWith(ctx.fs.unlink, ctx.req.file.path)
  295. })
  296. })
  297. })
  298. })
  299. describe('convertProjectToDocument', function () {
  300. beforeEach(function (ctx) {
  301. ctx.req = {
  302. body: {},
  303. params: { project_id: 'test-project-id', user_id: 'test-user-id' },
  304. query: { type: 'docx' },
  305. }
  306. ctx.fs.stat.resolves(ctx.documentStat)
  307. })
  308. describe('when conversions are disabled', function () {
  309. beforeEach(async function (ctx) {
  310. ctx.Settings.enablePandocConversions = false
  311. ctx.res.sendStatus = sinon.stub()
  312. await ctx.ConversionController.convertProjectToDocument(
  313. ctx.req,
  314. ctx.res,
  315. sinon.stub()
  316. )
  317. })
  318. it('should return 404', function (ctx) {
  319. sinon.assert.calledWith(ctx.res.sendStatus, 404)
  320. })
  321. it('should not sync resources or call the conversion manager', function (ctx) {
  322. sinon.assert.notCalled(ctx.ResourceWriter.promises.syncResourcesToDisk)
  323. sinon.assert.notCalled(
  324. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock
  325. )
  326. })
  327. })
  328. describe('when an unsupported type is requested', function () {
  329. beforeEach(async function (ctx) {
  330. ctx.req.query = { type: 'unsupported' }
  331. ctx.res.sendStatus = sinon.stub()
  332. await ctx.ConversionController.convertProjectToDocument(
  333. ctx.req,
  334. ctx.res,
  335. sinon.stub()
  336. )
  337. })
  338. it('should return 400', function (ctx) {
  339. sinon.assert.calledWith(ctx.res.sendStatus, 400)
  340. })
  341. it('should not sync resources or call the conversion manager', function (ctx) {
  342. sinon.assert.notCalled(ctx.ResourceWriter.promises.syncResourcesToDisk)
  343. sinon.assert.notCalled(
  344. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock
  345. )
  346. })
  347. })
  348. const uuidDirPattern =
  349. /^\/compiles\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
  350. describe('successfully (default streaming response)', function () {
  351. beforeEach(async function (ctx) {
  352. await ctx.ConversionController.convertProjectToDocument(
  353. ctx.req,
  354. ctx.res,
  355. sinon.stub()
  356. )
  357. })
  358. it('should sync resources to a unique conversion directory', function (ctx) {
  359. sinon.assert.calledWith(
  360. ctx.ResourceWriter.promises.syncResourcesToDisk,
  361. sinon.match({ rootResourcePath: 'main.tex' }),
  362. sinon.match(uuidDirPattern)
  363. )
  364. })
  365. it('should call convertLaTeXToDocumentInDirWithLock with docx type', function (ctx) {
  366. sinon.assert.calledWith(
  367. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock,
  368. sinon.match(
  369. /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
  370. ),
  371. sinon.match(uuidDirPattern),
  372. 'main.tex',
  373. 'docx'
  374. )
  375. })
  376. it('should set the Content-Length header from the document stat', function (ctx) {
  377. sinon.assert.calledWith(
  378. ctx.res.setHeader,
  379. 'Content-Length',
  380. ctx.documentStat.size
  381. )
  382. })
  383. it('should set the attachment filename', function (ctx) {
  384. sinon.assert.calledWith(ctx.res.attachment, 'output.docx')
  385. })
  386. it('should set X-Content-Type-Options header', function (ctx) {
  387. sinon.assert.calledWith(
  388. ctx.res.setHeader,
  389. 'X-Content-Type-Options',
  390. 'nosniff'
  391. )
  392. })
  393. it('should stream the document to the response', function (ctx) {
  394. sinon.assert.calledWith(ctx.fsSync.createReadStream, ctx.documentPath)
  395. sinon.assert.calledWith(ctx.pipeline, ctx.readStream, ctx.res)
  396. })
  397. it('should not move the document or schedule cleanup', function (ctx) {
  398. sinon.assert.notCalled(ctx.fs.copyFile)
  399. sinon.assert.notCalled(ctx.ConversionOutputCleaner.scheduleCleanup)
  400. })
  401. it('should clean up the conversion directory', function (ctx) {
  402. sinon.assert.calledWith(ctx.fs.rm, sinon.match(uuidDirPattern), {
  403. recursive: true,
  404. force: true,
  405. })
  406. })
  407. })
  408. describe('successfully (responseFormat=json)', function () {
  409. beforeEach(async function (ctx) {
  410. ctx.req.query.responseFormat = 'json'
  411. await ctx.ConversionController.convertProjectToDocument(
  412. ctx.req,
  413. ctx.res,
  414. sinon.stub()
  415. )
  416. })
  417. it('should move the document into the conversion output build dir', function (ctx) {
  418. const outputBuildDirPattern =
  419. /^\/output\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/generated-files\/[0-9a-f]+-[0-9a-f]+$/
  420. sinon.assert.calledWith(
  421. ctx.fs.mkdir,
  422. sinon.match(outputBuildDirPattern),
  423. { recursive: true }
  424. )
  425. sinon.assert.calledWith(
  426. ctx.fs.copyFile,
  427. ctx.documentPath,
  428. sinon.match(filePath => {
  429. return (
  430. filePath.startsWith('/output/') &&
  431. filePath.endsWith('/output.docx')
  432. )
  433. })
  434. )
  435. })
  436. it('should schedule cleanup of the conversion output dir', function (ctx) {
  437. sinon.assert.calledWith(
  438. ctx.ConversionOutputCleaner.scheduleCleanup,
  439. sinon.match(
  440. /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
  441. )
  442. )
  443. })
  444. it('should respond with the conversion id, build id, and file name', function (ctx) {
  445. sinon.assert.calledWith(
  446. ctx.res.json,
  447. sinon.match({
  448. conversionId: sinon.match(
  449. /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
  450. ),
  451. buildId: sinon.match(/^[0-9a-f]+-[0-9a-f]+$/),
  452. file: 'output.docx',
  453. })
  454. )
  455. })
  456. it('should not stream the document', function (ctx) {
  457. sinon.assert.notCalled(ctx.fsSync.createReadStream)
  458. sinon.assert.notCalled(ctx.pipeline)
  459. })
  460. it('should clean up the working conversion directory', function (ctx) {
  461. sinon.assert.calledWith(ctx.fs.rm, sinon.match(uuidDirPattern), {
  462. recursive: true,
  463. force: true,
  464. })
  465. })
  466. })
  467. describe('with conversionType=markdown', function () {
  468. beforeEach(async function (ctx) {
  469. ctx.req.query = { type: 'markdown', projectName: 'My_Project' }
  470. ctx.fs.stat.resolves(ctx.documentStat)
  471. await ctx.ConversionController.convertProjectToDocument(
  472. ctx.req,
  473. ctx.res,
  474. sinon.stub()
  475. )
  476. })
  477. it('should call convertLaTeXToDocumentInDirWithLock with type=markdown', function (ctx) {
  478. sinon.assert.calledWith(
  479. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock,
  480. sinon.match(
  481. /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
  482. ),
  483. sinon.match(
  484. /^\/compiles\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
  485. ),
  486. 'main.tex',
  487. 'markdown'
  488. )
  489. })
  490. it('should set the attachment filename with .zip extension', function (ctx) {
  491. sinon.assert.calledWith(ctx.res.attachment, 'output.zip')
  492. })
  493. })
  494. describe('when conversion fails', function () {
  495. beforeEach(async function (ctx) {
  496. ctx.next = sinon.stub()
  497. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock.rejects(
  498. new Error('mock conversion error')
  499. )
  500. await ctx.ConversionController.convertProjectToDocument(
  501. ctx.req,
  502. ctx.res,
  503. ctx.next
  504. )
  505. })
  506. it('should pass the error to next', function (ctx) {
  507. sinon.assert.calledOnce(ctx.next)
  508. expect(ctx.next.firstCall.args[0]).to.be.instanceOf(Error)
  509. })
  510. it('should still clean up the conversion directory', function (ctx) {
  511. sinon.assert.calledWith(ctx.fs.rm, sinon.match(uuidDirPattern), {
  512. recursive: true,
  513. force: true,
  514. })
  515. })
  516. })
  517. describe('when conversion fails with a user-facing ConversionError', function () {
  518. beforeEach(async function (ctx) {
  519. ctx.next = sinon.stub()
  520. ctx.jsonStub = sinon.stub()
  521. ctx.res.status = sinon.stub().returns({ json: ctx.jsonStub })
  522. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock.rejects(
  523. new Errors.ConversionError(
  524. 'pandoc latex-to-document conversion failed',
  525. {
  526. type: 'docx',
  527. exitCode: 64,
  528. stderr: 'parse error at line 5',
  529. }
  530. )
  531. )
  532. await ctx.ConversionController.convertProjectToDocument(
  533. ctx.req,
  534. ctx.res,
  535. ctx.next
  536. )
  537. })
  538. it('should return 422 with the pandoc stderr in the response body', function (ctx) {
  539. sinon.assert.calledWith(ctx.res.status, 422)
  540. sinon.assert.calledWith(ctx.jsonStub, {
  541. error: 'parse error at line 5',
  542. exitCode: 64,
  543. })
  544. })
  545. it('should not call next', function (ctx) {
  546. sinon.assert.notCalled(ctx.next)
  547. })
  548. it('should still clean up the conversion directory', function (ctx) {
  549. sinon.assert.calledWith(ctx.fs.rm, sinon.match(uuidDirPattern), {
  550. recursive: true,
  551. force: true,
  552. })
  553. })
  554. })
  555. describe('when conversion fails with a non-user-facing ConversionError', function () {
  556. beforeEach(async function (ctx) {
  557. ctx.next = sinon.stub()
  558. ctx.jsonStub = sinon.stub()
  559. ctx.res.status = sinon.stub().returns({ json: ctx.jsonStub })
  560. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock.rejects(
  561. new Errors.ConversionError(
  562. 'pandoc latex-to-document conversion failed',
  563. {
  564. type: 'docx',
  565. exitCode: 62,
  566. stderr: 'internal pandoc bug',
  567. }
  568. )
  569. )
  570. await ctx.ConversionController.convertProjectToDocument(
  571. ctx.req,
  572. ctx.res,
  573. ctx.next
  574. )
  575. })
  576. it('should return 422 without surfacing stderr', function (ctx) {
  577. sinon.assert.calledWith(ctx.res.status, 422)
  578. sinon.assert.calledWith(ctx.jsonStub, {})
  579. })
  580. it('should not call next', function (ctx) {
  581. sinon.assert.notCalled(ctx.next)
  582. })
  583. it('should still clean up the conversion directory', function (ctx) {
  584. sinon.assert.calledWith(ctx.fs.rm, sinon.match(uuidDirPattern), {
  585. recursive: true,
  586. force: true,
  587. })
  588. })
  589. })
  590. })
  591. })