ConversionController.test.js 21 KB

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