ConversionController.test.js 21 KB

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