Просмотр исходного кода

Merge pull request #31663 from overleaf/ar-convert-exportshandler-to-fetch-utils

[web] convert ExportsHandler to fetch utils

GitOrigin-RevId: 96e74a1254e456b4ac8a6d3a2bd6330a806c8693
Andrew Rumble 7 месяцев назад
Родитель
Сommit
87269f3084

+ 96 - 115
services/web/app/src/Features/Exports/ExportsController.mjs

@@ -1,128 +1,109 @@
-/* eslint-disable
-    max-len,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * DS207: Consider shorter variations of null checks
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
 import ExportsHandler from './ExportsHandler.mjs'
-
+import { expressify } from '@overleaf/promise-utils'
 import SessionManager from '../Authentication/SessionManager.mjs'
 import logger from '@overleaf/logger'
+import OError from '@overleaf/o-error'
 
-export default {
-  exportProject(req, res, next) {
-    const { project_id: projectId, brand_variation_id: brandVariationId } =
-      req.params
-    const userId = SessionManager.getLoggedInUserId(req.session)
-    const exportParams = {
-      project_id: projectId,
-      brand_variation_id: brandVariationId,
-      user_id: userId,
-    }
+async function exportProject(req, res, next) {
+  const { project_id: projectId, brand_variation_id: brandVariationId } =
+    req.params
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const exportParams = {
+    project_id: projectId,
+    brand_variation_id: brandVariationId,
+    user_id: userId,
+  }
 
-    if (req.body) {
-      if (req.body.firstName) {
-        exportParams.first_name = req.body.firstName.trim()
-      }
-      if (req.body.lastName) {
-        exportParams.last_name = req.body.lastName.trim()
-      }
-      // additional parameters for gallery exports
-      if (req.body.title) {
-        exportParams.title = req.body.title.trim()
-      }
-      if (req.body.description) {
-        exportParams.description = req.body.description.trim()
-      }
-      if (req.body.author) {
-        exportParams.author = req.body.author.trim()
-      }
-      if (req.body.license) {
-        exportParams.license = req.body.license.trim()
-      }
-      if (req.body.showSource != null) {
-        exportParams.show_source = req.body.showSource
-      }
+  if (req.body) {
+    if (req.body.firstName) {
+      exportParams.first_name = req.body.firstName.trim()
+    }
+    if (req.body.lastName) {
+      exportParams.last_name = req.body.lastName.trim()
+    }
+    // additional parameters for gallery exports
+    if (req.body.title) {
+      exportParams.title = req.body.title.trim()
+    }
+    if (req.body.description) {
+      exportParams.description = req.body.description.trim()
+    }
+    if (req.body.author) {
+      exportParams.author = req.body.author.trim()
+    }
+    if (req.body.license) {
+      exportParams.license = req.body.license.trim()
+    }
+    if (req.body.showSource != null) {
+      exportParams.show_source = req.body.showSource
     }
+  }
 
-    return ExportsHandler.exportProject(
-      exportParams,
-      function (err, exportData) {
-        if (err != null) {
-          if (err.forwardResponse != null) {
-            logger.debug(
-              { responseError: err.forwardResponse },
-              'forwarding response'
-            )
-            const statusCode = err.forwardResponse.status || 500
-            return res.status(statusCode).json(err.forwardResponse)
-          } else {
-            return next(err)
-          }
-        }
-        logger.debug(
-          {
-            userId,
-            projectId,
-            brandVariationId,
-            exportV1Id: exportData.v1_id,
-          },
-          'exported project'
-        )
-        return res.json({
-          export_v1_id: exportData.v1_id,
-          message: exportData.message,
-        })
-      }
+  try {
+    const exportData = await ExportsHandler.exportProject(exportParams)
+    logger.debug(
+      {
+        userId,
+        projectId,
+        brandVariationId,
+        exportV1Id: exportData.v1_id,
+      },
+      'exported project'
     )
-  },
-
-  exportStatus(req, res) {
-    const { export_id: exportId } = req.params
-    return ExportsHandler.fetchExport(exportId, function (err, exportJson) {
-      let json
-      if (err != null) {
-        json = {
-          status_summary: 'failed',
-          status_detail: err.toString,
-        }
-        res.json({ export_json: json })
-        return err
-      }
-      const parsedExport = JSON.parse(exportJson)
-      json = {
-        status_summary: parsedExport.status_summary,
-        status_detail: parsedExport.status_detail,
-        partner_submission_id: parsedExport.partner_submission_id,
-        v2_user_email: parsedExport.v2_user_email,
-        v2_user_first_name: parsedExport.v2_user_first_name,
-        v2_user_last_name: parsedExport.v2_user_last_name,
-        title: parsedExport.title,
-        token: parsedExport.token,
-      }
-      return res.json({ export_json: json })
+    return res.json({
+      export_v1_id: exportData.v1_id,
+      message: exportData.message,
     })
-  },
+  } catch (err) {
+    const info = OError.getFullInfo(err)
+    if (info?.forwardResponse) {
+      logger.debug(
+        { responseError: info.forwardResponse },
+        'forwarding response'
+      )
+      const statusCode = info.forwardResponse.status || 500
+      return res.status(statusCode).json(info.forwardResponse)
+    }
+    throw err
+  }
+}
 
-  exportDownload(req, res, next) {
-    const { type, export_id: exportId } = req.params
+async function exportStatus(req, res) {
+  const { export_id: exportId } = req.params
+  let exportJson
+  try {
+    exportJson = await ExportsHandler.fetchExport(exportId)
+  } catch (err) {
+    const json = {
+      status_summary: 'failed',
+      status_detail: String(err),
+    }
+    return res.json({ export_json: json })
+  }
+  const parsedExport = JSON.parse(exportJson)
+  const json = {
+    status_summary: parsedExport.status_summary,
+    status_detail: parsedExport.status_detail,
+    partner_submission_id: parsedExport.partner_submission_id,
+    v2_user_email: parsedExport.v2_user_email,
+    v2_user_first_name: parsedExport.v2_user_first_name,
+    v2_user_last_name: parsedExport.v2_user_last_name,
+    title: parsedExport.title,
+    token: parsedExport.token,
+  }
+  return res.json({ export_json: json })
+}
 
-    SessionManager.getLoggedInUserId(req.session)
-    return ExportsHandler.fetchDownload(
-      exportId,
-      type,
-      function (err, exportFileUrl) {
-        if (err != null) {
-          return next(err)
-        }
+async function exportDownload(req, res, next) {
+  const { type, export_id: exportId } = req.params
 
-        return res.redirect(exportFileUrl)
-      }
-    )
-  },
+  SessionManager.getLoggedInUserId(req.session)
+  const exportFileUrl = await ExportsHandler.fetchDownload(exportId, type)
+  return res.redirect(exportFileUrl)
+}
+
+export default {
+  exportDownload: expressify(exportDownload),
+  exportProject: expressify(exportProject),
+  exportStatus: expressify(exportStatus),
 }

+ 169 - 238
services/web/app/src/Features/Exports/ExportsHandler.mjs

@@ -1,16 +1,3 @@
-/* eslint-disable
-    n/handle-callback-err,
-    max-len,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * DS207: Consider shorter variations of null checks
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
 import OError from '@overleaf/o-error'
 import ProjectGetter from '../Project/ProjectGetter.mjs'
 import ProjectHistoryHandler from '../Project/ProjectHistoryHandler.mjs'
@@ -19,39 +6,25 @@ import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
 import UserGetter from '../User/UserGetter.mjs'
 import logger from '@overleaf/logger'
 import settings from '@overleaf/settings'
-import async from 'async'
-import Request from 'request'
+import {
+  fetchString,
+  fetchJson,
+  RequestFailedError,
+} from '@overleaf/fetch-utils'
 let ExportsHandler
-const request = Request.defaults()
 
 export default ExportsHandler = {
-  exportProject(exportParams, callback) {
-    if (callback == null) {
-      callback = function () {}
-    }
-    return ExportsHandler._buildExport(
-      exportParams,
-      function (err, exportData) {
-        if (err != null) {
-          return callback(err)
-        }
-        return ExportsHandler._requestExport(exportData, function (err, body) {
-          if (err != null) {
-            return callback(err)
-          }
-          exportData.v1_id = body.exportId
-          exportData.message = body.message
-          // TODO: possibly store the export data in Mongo
-          return callback(null, exportData)
-        })
-      }
-    )
+  async exportProject(exportParams) {
+    const exportData = await ExportsHandler._buildExport(exportParams)
+    const body = await ExportsHandler._requestExport(exportData)
+
+    exportData.v1_id = body.exportId
+    exportData.message = body.message
+    // TODO: possibly store the export data in Mongo
+    return exportData
   },
 
-  _buildExport(exportParams, callback) {
-    if (callback == null) {
-      callback = function () {}
-    }
+  async _buildExport(exportParams) {
     const {
       project_id: projectId,
       user_id: userId,
@@ -62,221 +35,179 @@ export default ExportsHandler = {
       license,
       show_source: showSource,
     } = exportParams
-    const jobs = {
-      project(cb) {
-        return ProjectGetter.getProject(projectId, cb)
+
+    let project, rootDoc, user, historyVersion
+
+    try {
+      project = await ProjectGetter.promises.getProject(projectId)
+
+      await ProjectRootDocManager.promises.ensureRootDocumentIsValid(projectId)
+
+      rootDoc = await ProjectLocator.promises.findRootDoc({
+        project,
+        project_id: projectId,
+      })
+
+      user = await UserGetter.promises.getUser(userId, {
+        first_name: 1,
+        last_name: 1,
+        email: 1,
+        overleaf: 1,
+      })
+      await ProjectHistoryHandler.promises.ensureHistoryExistsForProject(
+        projectId
+      )
+      historyVersion = await ExportsHandler._requestVersion(projectId)
+    } catch (err) {
+      throw OError.tag(err, 'error building project export', {
+        project_id: projectId,
+        user_id: userId,
+        brand_variation_id: brandVariationId,
+      })
+    }
+
+    if (!rootDoc || !rootDoc.path) {
+      throw new OError('cannot export project without root doc', {
+        project_id: projectId,
+      })
+    }
+
+    if (exportParams.first_name && exportParams.last_name) {
+      user.first_name = exportParams.first_name
+      user.last_name = exportParams.last_name
+    }
+
+    return {
+      project: {
+        id: projectId,
+        rootDocPath: rootDoc.path ? rootDoc.path.fileSystem : undefined,
+        historyId: project.overleaf?.history?.id,
+        historyVersion,
+        v1ProjectId: project.overleaf != null ? project.overleaf.id : undefined,
+        metadata: {
+          compiler: project.compiler,
+          imageName: project.imageName,
+          title,
+          description,
+          author,
+          license,
+          showSource,
+        },
       },
-      rootDoc: [
-        'project',
-        (results, cb) =>
-          ProjectRootDocManager.ensureRootDocumentIsValid(
-            projectId,
-            function (error) {
-              if (error != null) {
-                return callback(error)
-              }
-              return ProjectLocator.findRootDoc(
-                { project: results.project, project_id: projectId },
-                cb
-              )
-            }
-          ),
-      ],
-      user(cb) {
-        return UserGetter.getUser(
-          userId,
-          { first_name: 1, last_name: 1, email: 1, overleaf: 1 },
-          cb
-        )
+      user: {
+        id: userId,
+        firstName: user.first_name,
+        lastName: user.last_name,
+        email: user.email,
+        orcidId: null, // until v2 gets ORCID
+        v1UserId: user.overleaf != null ? user.overleaf.id : undefined,
       },
-      historyVersion(cb) {
-        return ProjectHistoryHandler.ensureHistoryExistsForProject(
-          projectId,
-          function (error) {
-            if (error != null) {
-              return callback(error)
-            }
-            return ExportsHandler._requestVersion(projectId, cb)
-          }
-        )
+      destination: {
+        brandVariationId,
       },
+      options: {
+        callbackUrl: null,
+      }, // for now, until we want v1 to call us back
     }
+  },
 
-    return async.auto(jobs, function (err, results) {
-      if (err != null) {
-        OError.tag(err, 'error building project export', {
-          project_id: projectId,
-          user_id: userId,
-          brand_variation_id: brandVariationId,
+  async _requestExport(exportData) {
+    try {
+      return await fetchJson(
+        new URL('/api/v1/overleaf/exports', settings.apis.v1.url),
+        {
+          basicAuth: {
+            user: settings.apis.v1.user,
+            password: settings.apis.v1.pass,
+          },
+          json: exportData,
+          signal: AbortSignal.timeout(settings.apis.v1.timeout),
+          method: 'POST',
+        }
+      )
+    } catch (err) {
+      if (err instanceof RequestFailedError) {
+        logger.warn(
+          { export: exportData },
+          `v1 export returned failure; forwarding: ${err.body}`
+        )
+        // pass the v1 error along for the publish modal to handle
+        throw OError.tag(err, 'v1 export returned failure', {
+          forwardResponse: err.body,
         })
-        return callback(err)
       }
+      throw OError.tag(err, 'error making request to v1 export', {
+        export: exportData,
+      })
+    }
+  },
 
-      const { project, rootDoc, user, historyVersion } = results
-      if (!rootDoc || rootDoc[1] == null) {
-        err = new OError('cannot export project without root doc', {
-          project_id: projectId,
-        })
-        return callback(err)
+  async _requestVersion(projectId) {
+    const url = new URL(settings.apis.project_history.url)
+    url.pathname = `/project/${projectId}/version`
+    try {
+      const body = await fetchJson(url)
+      return body.version
+    } catch (err) {
+      if (err instanceof RequestFailedError) {
+        throw new OError(
+          'project history version returned a failure status code',
+          { project_id: projectId, statusCode: err.response.status }
+        )
       }
+      throw OError.tag(err, 'error making request to project history', {
+        project_id: projectId,
+      })
+    }
+  },
 
-      if (exportParams.first_name && exportParams.last_name) {
-        user.first_name = exportParams.first_name
-        user.last_name = exportParams.last_name
-      }
+  async fetchExport(exportId) {
+    const url = new URL(settings.apis.v1.url)
+    url.pathname = `/api/v1/overleaf/exports/${exportId}`
 
-      const exportData = {
-        project: {
-          id: projectId,
-          rootDocPath: rootDoc[1] != null ? rootDoc[1].fileSystem : undefined,
-          historyId: project.overleaf?.history?.id,
-          historyVersion,
-          v1ProjectId:
-            project.overleaf != null ? project.overleaf.id : undefined,
-          metadata: {
-            compiler: project.compiler,
-            imageName: project.imageName,
-            title,
-            description,
-            author,
-            license,
-            showSource,
-          },
+    try {
+      return await fetchString(url, {
+        basicAuth: {
+          user: settings.apis.v1.user,
+          password: settings.apis.v1.pass,
         },
-        user: {
-          id: userId,
-          firstName: user.first_name,
-          lastName: user.last_name,
-          email: user.email,
-          orcidId: null, // until v2 gets ORCID
-          v1UserId: user.overleaf != null ? user.overleaf.id : undefined,
-        },
-        destination: {
-          brandVariationId,
-        },
-        options: {
-          callbackUrl: null,
-        }, // for now, until we want v1 to call us back
+        signal: AbortSignal.timeout(settings.apis.v1.timeout),
+      })
+    } catch (err) {
+      if (err instanceof RequestFailedError) {
+        throw new OError('v1 export returned a failure status code', {
+          export: exportId,
+          statusCode: err.response.status,
+        })
       }
-      return callback(null, exportData)
-    })
-  },
-
-  _requestExport(exportData, callback) {
-    if (callback == null) {
-      callback = function () {}
+      throw OError.tag(err, 'error making request to v1 export', {
+        export: exportId,
+      })
     }
-    return request.post(
-      {
-        url: `${settings.apis.v1.url}/api/v1/overleaf/exports`,
-        auth: { user: settings.apis.v1.user, pass: settings.apis.v1.pass },
-        json: exportData,
-        timeout: settings.apis.v1.timeout,
-      },
-      function (err, res, body) {
-        if (err != null) {
-          OError.tag(err, 'error making request to v1 export', {
-            export: exportData,
-          })
-          return callback(err)
-        } else if (res.statusCode >= 200 && res.statusCode < 300) {
-          return callback(null, body)
-        } else {
-          logger.warn(
-            { export: exportData },
-            `v1 export returned failure; forwarding: ${body}`
-          )
-          // pass the v1 error along for the publish modal to handle
-          const err = { forwardResponse: body }
-          return callback(err)
-        }
-      }
-    )
   },
 
-  _requestVersion(projectId, callback) {
-    if (callback == null) {
-      callback = function () {}
-    }
-    return request.get(
-      {
-        url: `${settings.apis.project_history.url}/project/${projectId}/version`,
-        json: true,
-      },
-      function (err, res, body) {
-        if (err != null) {
-          OError.tag(err, 'error making request to project history', {
-            project_id: projectId,
-          })
-          return callback(err)
-        } else if (res.statusCode >= 200 && res.statusCode < 300) {
-          return callback(null, body.version)
-        } else {
-          err = new OError(
-            'project history version returned a failure status code',
-            { project_id: projectId, statusCode: res.statusCode }
-          )
-          return callback(err)
-        }
-      }
-    )
-  },
+  async fetchDownload(exportId, type) {
+    const url = new URL(settings.apis.v1.url)
+    url.pathname = `/api/v1/overleaf/exports/${exportId}/${type}_url`
 
-  fetchExport(exportId, callback) {
-    if (callback == null) {
-      callback = function () {}
-    }
-    return request.get(
-      {
-        url: `${settings.apis.v1.url}/api/v1/overleaf/exports/${exportId}`,
-        auth: { user: settings.apis.v1.user, pass: settings.apis.v1.pass },
-        timeout: settings.apis.v1.timeout,
-      },
-      function (err, res, body) {
-        if (err != null) {
-          OError.tag(err, 'error making request to v1 export', {
-            export: exportId,
-          })
-          return callback(err)
-        } else if (res.statusCode >= 200 && res.statusCode < 300) {
-          return callback(null, body)
-        } else {
-          err = new OError('v1 export returned a failure status code', {
-            export: exportId,
-            statusCode: res.statusCode,
-          })
-          return callback(err)
-        }
+    try {
+      return await fetchString(url, {
+        basicAuth: {
+          user: settings.apis.v1.user,
+          password: settings.apis.v1.pass,
+        },
+        signal: AbortSignal.timeout(settings.apis.v1.timeout),
+      })
+    } catch (err) {
+      if (err instanceof RequestFailedError) {
+        throw new OError('v1 export returned a failure status code', {
+          export: exportId,
+          statusCode: err.response.status,
+        })
       }
-    )
-  },
-
-  fetchDownload(exportId, type, callback) {
-    if (callback == null) {
-      callback = function () {}
+      throw OError.tag(err, 'error making request to v1 export', {
+        export: exportId,
+      })
     }
-    return request.get(
-      {
-        url: `${settings.apis.v1.url}/api/v1/overleaf/exports/${exportId}/${type}_url`,
-        auth: { user: settings.apis.v1.user, pass: settings.apis.v1.pass },
-        timeout: settings.apis.v1.timeout,
-      },
-      function (err, res, body) {
-        if (err != null) {
-          OError.tag(err, 'error making request to v1 export', {
-            export: exportId,
-          })
-          return callback(err)
-        } else if (res.statusCode >= 200 && res.statusCode < 300) {
-          return callback(null, body)
-        } else {
-          err = new OError('v1 export returned a failure status code', {
-            export: exportId,
-            statusCode: res.statusCode,
-          })
-          return callback(err)
-        }
-      }
-    )
   },
 }

+ 97 - 103
services/web/test/unit/src/Exports/ExportsController.test.mjs

@@ -1,5 +1,6 @@
 import { expect, vi } from 'vitest'
 import sinon from 'sinon'
+import OError from '@overleaf/o-error'
 const modulePath = new URL(
   '../../../../app/src/Features/Exports/ExportsController.mjs',
   import.meta.url
@@ -66,56 +67,54 @@ describe('ExportsController', function () {
 
   describe('without gallery fields', function () {
     it('should ask the handler to perform the export', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.handler.exportProject = sinon
-          .stub()
-          .yields(null, { iAmAnExport: true, v1_id: 897 })
-        const expected = {
-          project_id: projectId,
-          user_id: userId,
-          brand_variation_id: brandVariationId,
-          first_name: firstName,
-          last_name: lastName,
-        }
-        return ctx.controller.exportProject(ctx.req, {
-          json: body => {
-            expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected)
-            expect(body).to.deep.equal({
-              export_v1_id: 897,
-              message: undefined,
-            })
-            return resolve()
-          },
-        })
+      ctx.handler.exportProject = sinon
+        .stub()
+        .resolves({ iAmAnExport: true, v1_id: 897 })
+      const expected = {
+        project_id: projectId,
+        user_id: userId,
+        brand_variation_id: brandVariationId,
+        first_name: firstName,
+        last_name: lastName,
+      }
+      const res = {
+        json: sinon.stub(),
+      }
+
+      await ctx.controller.exportProject(ctx.req, res)
+      expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected)
+      expect(res.json.args[0][0]).to.deep.equal({
+        export_v1_id: 897,
+        message: undefined,
       })
     })
   })
 
   describe('with a message from v1', function () {
     it('should ask the handler to perform the export', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.handler.exportProject = sinon.stub().yields(null, {
-          iAmAnExport: true,
-          v1_id: 897,
-          message: 'RESUBMISSION',
-        })
-        const expected = {
-          project_id: projectId,
-          user_id: userId,
-          brand_variation_id: brandVariationId,
-          first_name: firstName,
-          last_name: lastName,
-        }
-        return ctx.controller.exportProject(ctx.req, {
-          json: body => {
-            expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected)
-            expect(body).to.deep.equal({
-              export_v1_id: 897,
-              message: 'RESUBMISSION',
-            })
-            return resolve()
-          },
-        })
+      ctx.handler.exportProject = sinon.stub().resolves({
+        iAmAnExport: true,
+        v1_id: 897,
+        message: 'RESUBMISSION',
+      })
+      const expected = {
+        project_id: projectId,
+        user_id: userId,
+        brand_variation_id: brandVariationId,
+        first_name: firstName,
+        last_name: lastName,
+      }
+
+      const res = {
+        json: sinon.stub(),
+      }
+
+      await ctx.controller.exportProject(ctx.req, res)
+
+      expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected)
+      expect(res.json.args[0][0]).to.deep.equal({
+        export_v1_id: 897,
+        message: 'RESUBMISSION',
       })
     })
   })
@@ -130,56 +129,52 @@ describe('ExportsController', function () {
     })
 
     it('should ask the handler to perform the export', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.handler.exportProject = sinon
-          .stub()
-          .yields(null, { iAmAnExport: true, v1_id: 897 })
-        const expected = {
-          project_id: projectId,
-          user_id: userId,
-          brand_variation_id: brandVariationId,
-          first_name: firstName,
-          last_name: lastName,
-          title,
-          description,
-          author,
-          license,
-          show_source: showSource,
-        }
-        return ctx.controller.exportProject(ctx.req, {
-          json: body => {
-            expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected)
-            expect(body).to.deep.equal({
-              export_v1_id: 897,
-              message: undefined,
-            })
-            return resolve()
-          },
-        })
+      ctx.handler.exportProject = sinon
+        .stub()
+        .resolves({ iAmAnExport: true, v1_id: 897 })
+      const expected = {
+        project_id: projectId,
+        user_id: userId,
+        brand_variation_id: brandVariationId,
+        first_name: firstName,
+        last_name: lastName,
+        title,
+        description,
+        author,
+        license,
+        show_source: showSource,
+      }
+
+      const res = {
+        json: sinon.stub(),
+      }
+
+      await ctx.controller.exportProject(ctx.req, res)
+      expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected)
+      expect(res.json.args[0][0]).to.deep.equal({
+        export_v1_id: 897,
+        message: undefined,
       })
     })
   })
 
   describe('with an error return from v1 to forward to the publish modal', function () {
     it('should forward the response onward', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.error_json = { status: 422, message: 'nope' }
-        ctx.handler.exportProject = sinon
-          .stub()
-          .yields({ forwardResponse: ctx.error_json })
-        ctx.controller.exportProject(ctx.req, ctx.res, ctx.next)
-        expect(ctx.res.json.args[0][0]).to.deep.equal(ctx.error_json)
-        expect(ctx.res.status.args[0][0]).to.equal(ctx.error_json.status)
-        return resolve()
-      })
+      ctx.error_json = { status: 422, message: 'nope' }
+      ctx.handler.exportProject = sinon.stub().rejects(
+        OError.tag(new Error('original error'), 'v1 error', {
+          forwardResponse: ctx.error_json,
+        })
+      )
+      await ctx.controller.exportProject(ctx.req, ctx.res, ctx.next)
+      expect(ctx.res.json.args[0][0]).to.deep.equal(ctx.error_json)
+      expect(ctx.res.status.args[0][0]).to.equal(ctx.error_json.status)
     })
   })
 
   it('should ask the handler to return the status of an export', async function (ctx) {
-    await new Promise(resolve => {
-      ctx.handler.fetchExport = sinon.stub().yields(
-        null,
-        `{
+    ctx.handler.fetchExport = sinon.stub().resolves(
+      `{
   "id":897,
   "status_summary":"completed",
   "status_detail":"all done",
@@ -190,26 +185,25 @@ describe('ExportsController', function () {
   "title":"my project",
   "token":"token"
   }`
-      )
+    )
 
-      ctx.req.params = { project_id: projectId, export_id: 897 }
-      return ctx.controller.exportStatus(ctx.req, {
-        json: body => {
-          expect(body).to.deep.equal({
-            export_json: {
-              status_summary: 'completed',
-              status_detail: 'all done',
-              partner_submission_id: 'abc123',
-              v2_user_email: 'la@tex.com',
-              v2_user_first_name: 'Arthur',
-              v2_user_last_name: 'Author',
-              title: 'my project',
-              token: 'token',
-            },
-          })
-          return resolve()
-        },
-      })
+    const res = {
+      json: sinon.stub(),
+    }
+
+    ctx.req.params = { project_id: projectId, export_id: 897 }
+    await ctx.controller.exportStatus(ctx.req, res)
+    expect(res.json.args[0][0]).to.deep.equal({
+      export_json: {
+        status_summary: 'completed',
+        status_detail: 'all done',
+        partner_submission_id: 'abc123',
+        v2_user_email: 'la@tex.com',
+        v2_user_first_name: 'Arthur',
+        v2_user_last_name: 'Author',
+        title: 'my project',
+        token: 'token',
+      },
     })
   })
 })

+ 262 - 360
services/web/test/unit/src/Exports/ExportsHandler.test.mjs

@@ -1,50 +1,53 @@
 import { expect, vi } from 'vitest'
 import sinon from 'sinon'
+import { RequestFailedError } from '@overleaf/fetch-utils'
+import OError from '@overleaf/o-error'
 const modulePath = '../../../../app/src/Features/Exports/ExportsHandler.mjs'
 
 describe('ExportsHandler', function () {
   beforeEach(async function (ctx) {
-    ctx.stubRequest = {}
-    ctx.request = {
-      defaults: () => {
-        return ctx.stubRequest
-      },
-    }
-
     vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
-      default: (ctx.ProjectGetter = {}),
+      default: (ctx.ProjectGetter = { promises: {} }),
     }))
 
     vi.doMock(
       '../../../../app/src/Features/Project/ProjectHistoryHandler',
       () => ({
-        default: (ctx.ProjectHistoryHandler = {}),
+        default: (ctx.ProjectHistoryHandler = {
+          promises: {},
+        }),
       })
     )
 
+    ctx.fetchUtils = {
+      fetchJson: sinon.stub().resolves(),
+      fetchString: sinon.stub().resolves(),
+      RequestFailedError,
+    }
+
+    vi.doMock('@overleaf/fetch-utils', () => ctx.fetchUtils)
+
     vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
-      default: (ctx.ProjectLocator = {}),
+      default: (ctx.ProjectLocator = {
+        promises: {},
+      }),
     }))
 
     vi.doMock(
       '../../../../app/src/Features/Project/ProjectRootDocManager',
       () => ({
-        default: (ctx.ProjectRootDocManager = {}),
+        default: (ctx.ProjectRootDocManager = { promises: {} }),
       })
     )
 
     vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
-      default: (ctx.UserGetter = {}),
+      default: (ctx.UserGetter = { promises: {} }),
     }))
 
     vi.doMock('@overleaf/settings', () => ({
       default: (ctx.settings = {}),
     }))
 
-    vi.doMock('request', () => ({
-      default: ctx.request,
-    }))
-
     ctx.ExportsHandler = (await import(modulePath)).default
     ctx.project_id = 'project-id-123'
     ctx.project_history_id = 987
@@ -65,32 +68,22 @@ describe('ExportsHandler', function () {
       license: ctx.license,
       show_source: ctx.show_source,
     }
-    ctx.callback = sinon.stub()
   })
 
   describe('exportProject', function () {
     beforeEach(function (ctx) {
       ctx.export_data = { iAmAnExport: true }
       ctx.response_body = { iAmAResponseBody: true }
-      ctx.ExportsHandler._buildExport = sinon
-        .stub()
-        .yields(null, ctx.export_data)
+      ctx.ExportsHandler._buildExport = sinon.stub().resolves(ctx.export_data)
       ctx.ExportsHandler._requestExport = sinon
         .stub()
-        .yields(null, ctx.response_body)
+        .resolves(ctx.response_body)
     })
 
     describe('when all goes well', function () {
+      let exportData
       beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.ExportsHandler.exportProject(
-            ctx.export_params,
-            (error, exportData) => {
-              ctx.callback(error, exportData)
-              resolve()
-            }
-          )
-        })
+        exportData = await ctx.ExportsHandler.exportProject(ctx.export_params)
       })
 
       it('should build the export', function (ctx) {
@@ -106,51 +99,46 @@ describe('ExportsHandler', function () {
       })
 
       it('should return the export', function (ctx) {
-        ctx.callback.calledWith(null, ctx.export_data).should.equal(true)
+        expect(exportData).to.equal(ctx.export_data)
       })
     })
 
     describe("when request can't be built", function () {
       beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.ExportsHandler._buildExport = sinon
-            .stub()
-            .yields(new Error('cannot export project without root doc'))
-          ctx.ExportsHandler.exportProject(
-            ctx.export_params,
-            (error, exportData) => {
-              ctx.callback(error, exportData)
-              resolve()
-            }
-          )
-        })
+        ctx.ExportsHandler._buildExport = sinon
+          .stub()
+          .rejects(new Error('cannot export project without root doc'))
       })
 
-      it('should return an error', function (ctx) {
-        expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
+      it('should reject with an error', async function (ctx) {
+        await expect(
+          ctx.ExportsHandler.exportProject(ctx.export_params)
+        ).to.be.rejectedWith(Error)
       })
     })
 
     describe('when export request returns an error to forward to the user', function () {
+      let exportData
+      let exportError
+
       beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.error_json = { status: 422, message: 'nope' }
-          ctx.ExportsHandler._requestExport = sinon
-            .stub()
-            .yields(null, { forwardResponse: ctx.error_json })
-          ctx.ExportsHandler.exportProject(
-            ctx.export_params,
-            (error, exportData) => {
-              ctx.callback(error, exportData)
-              resolve()
-            }
-          )
-        })
+        ctx.error_json = { status: 422, message: 'nope' }
+        const error = new Error('error message')
+        ctx.ExportsHandler._requestExport = sinon.stub().rejects(
+          OError.tag(error, 'v1 error', {
+            forwardResponse: ctx.error_json,
+          })
+        )
+        try {
+          exportData = await ctx.ExportsHandler.exportProject(ctx.export_params)
+        } catch (error) {
+          exportError = error
+        }
       })
 
-      it('should return success and the response to forward', function (ctx) {
-        expect(ctx.callback.args[0][0]).not.to.be.instanceOf(Error)
-        ctx.callback.calledWith(null, {
+      it('should throw with an error containing a response to forward', function (ctx) {
+        expect(exportData).to.be.undefined
+        expect(OError.getFullInfo(exportError)).to.eql({
           forwardResponse: ctx.error_json,
         })
       })
@@ -159,63 +147,53 @@ describe('ExportsHandler', function () {
 
   describe('_buildExport', function () {
     beforeEach(async function (ctx) {
-      await new Promise(resolve => {
-        ctx.project = {
-          id: ctx.project_id,
-          rootDoc_id: 'doc1_id',
-          compiler: 'pdflatex',
-          imageName: 'mock-image-name',
-          overleaf: {
-            id: ctx.project_history_id, // for projects imported from v1
-            history: {
-              id: ctx.project_history_id,
-            },
-          },
-        }
-        ctx.user = {
-          id: ctx.user_id,
-          first_name: 'Arthur',
-          last_name: 'Author',
-          email: 'arthur.author@arthurauthoring.org',
-          overleaf: {
-            id: 876,
+      ctx.project = {
+        id: ctx.project_id,
+        rootDoc_id: 'doc1_id',
+        compiler: 'pdflatex',
+        imageName: 'mock-image-name',
+        overleaf: {
+          id: ctx.project_history_id, // for projects imported from v1
+          history: {
+            id: ctx.project_history_id,
           },
-        }
-        ctx.rootDocPath = 'main.tex'
-        ctx.historyVersion = 777
-        ctx.ProjectGetter.getProject = sinon.stub().yields(null, ctx.project)
-        ctx.ProjectHistoryHandler.ensureHistoryExistsForProject = sinon
-          .stub()
-          .yields(null)
-        ctx.ProjectLocator.findRootDoc = sinon
-          .stub()
-          .yields(null, [null, { fileSystem: 'main.tex' }])
-        ctx.ProjectRootDocManager.ensureRootDocumentIsValid = sinon
-          .stub()
-          .callsArgWith(1, null)
-        ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user)
-        ctx.ExportsHandler._requestVersion = sinon
-          .stub()
-          .yields(null, ctx.historyVersion)
-        resolve()
-      })
+        },
+      }
+      ctx.user = {
+        id: ctx.user_id,
+        first_name: 'Arthur',
+        last_name: 'Author',
+        email: 'arthur.author@arthurauthoring.org',
+        overleaf: {
+          id: 876,
+        },
+      }
+      ctx.rootDocPath = 'main.tex'
+      ctx.historyVersion = 777
+      ctx.ProjectGetter.promises.getProject = sinon.stub().resolves(ctx.project)
+      ctx.ProjectHistoryHandler.promises.ensureHistoryExistsForProject = sinon
+        .stub()
+        .resolves()
+      ctx.ProjectLocator.promises.findRootDoc = sinon
+        .stub()
+        .resolves({ element: null, path: { fileSystem: 'main.tex' } })
+      ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid = sinon
+        .stub()
+        .resolves()
+      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.user)
+      ctx.ExportsHandler._requestVersion = sinon
+        .stub()
+        .resolves(ctx.historyVersion)
     })
 
     describe('when all goes well', function () {
+      let exportData
       beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.ExportsHandler._buildExport(
-            ctx.export_params,
-            (error, exportData) => {
-              ctx.callback(error, exportData)
-              resolve()
-            }
-          )
-        })
+        exportData = await ctx.ExportsHandler._buildExport(ctx.export_params)
       })
 
       it('should ensure the project has history', function (ctx) {
-        ctx.ProjectHistoryHandler.ensureHistoryExistsForProject.called.should.equal(
+        ctx.ProjectHistoryHandler.promises.ensureHistoryExistsForProject.called.should.equal(
           true
         )
       })
@@ -257,25 +235,18 @@ describe('ExportsHandler', function () {
             callbackUrl: null,
           },
         }
-        ctx.callback.calledWith(null, expectedExportData).should.equal(true)
+        expect(exportData).to.eql(expectedExportData)
       })
     })
 
     describe('when we send replacement user first and last name', function () {
+      let exportData
       beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.custom_first_name = 'FIRST'
-          ctx.custom_last_name = 'LAST'
-          ctx.export_params.first_name = ctx.custom_first_name
-          ctx.export_params.last_name = ctx.custom_last_name
-          ctx.ExportsHandler._buildExport(
-            ctx.export_params,
-            (error, exportData) => {
-              ctx.callback(error, exportData)
-              resolve()
-            }
-          )
-        })
+        ctx.custom_first_name = 'FIRST'
+        ctx.custom_last_name = 'LAST'
+        ctx.export_params.first_name = ctx.custom_first_name
+        ctx.export_params.last_name = ctx.custom_last_name
+        exportData = await ctx.ExportsHandler._buildExport(ctx.export_params)
       })
 
       it('should send the data from the user input', function (ctx) {
@@ -311,51 +282,37 @@ describe('ExportsHandler', function () {
             callbackUrl: null,
           },
         }
-        ctx.callback.calledWith(null, expectedExportData).should.equal(true)
+        expect(exportData).to.eql(expectedExportData)
       })
     })
 
     describe('when project is not found', function () {
       beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.ProjectGetter.getProject = sinon
-            .stub()
-            .yields(new Error('project not found'))
-          ctx.ExportsHandler._buildExport(
-            ctx.export_params,
-            (error, exportData) => {
-              ctx.callback(error, exportData)
-              resolve()
-            }
-          )
-        })
+        ctx.ProjectGetter.promises.getProject = sinon
+          .stub()
+          .rejects(new Error('project not found'))
       })
 
-      it('should return an error', function (ctx) {
-        expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
+      it('should return an error', async function (ctx) {
+        await expect(
+          ctx.ExportsHandler._buildExport(ctx.export_params)
+        ).to.be.rejectedWith(Error)
       })
     })
 
     describe('when project has no root doc', function () {
       describe('when a root doc can be set automatically', function () {
+        let exportData
         beforeEach(async function (ctx) {
-          await new Promise(resolve => {
-            ctx.project.rootDoc_id = null
-            ctx.ProjectLocator.findRootDoc = sinon
-              .stub()
-              .yields(null, [null, { fileSystem: 'other.tex' }])
-            ctx.ExportsHandler._buildExport(
-              ctx.export_params,
-              (error, exportData) => {
-                ctx.callback(error, exportData)
-                resolve()
-              }
-            )
-          })
+          ctx.project.rootDoc_id = null
+          ctx.ProjectLocator.promises.findRootDoc = sinon
+            .stub()
+            .resolves({ path: { fileSystem: 'other.tex' } })
+          exportData = await ctx.ExportsHandler._buildExport(ctx.export_params)
         })
 
         it('should set a root doc', function (ctx) {
-          ctx.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal(
+          ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid.called.should.equal(
             true
           )
         })
@@ -393,32 +350,25 @@ describe('ExportsHandler', function () {
               callbackUrl: null,
             },
           }
-          ctx.callback.calledWith(null, expectedExportData).should.equal(true)
+          expect(exportData).to.eql(expectedExportData)
         })
       })
     })
 
     describe('when project has an invalid root doc', function () {
       describe('when a new root doc can be set automatically', function () {
+        let exportData
         beforeEach(async function (ctx) {
-          await new Promise(resolve => {
-            ctx.fakeDoc_id = '1a2b3c4d5e6f'
-            ctx.project.rootDoc_id = ctx.fakeDoc_id
-            ctx.ProjectLocator.findRootDoc = sinon
-              .stub()
-              .yields(null, [null, { fileSystem: 'other.tex' }])
-            ctx.ExportsHandler._buildExport(
-              ctx.export_params,
-              (error, exportData) => {
-                ctx.callback(error, exportData)
-                resolve()
-              }
-            )
-          })
+          ctx.fakeDoc_id = '1a2b3c4d5e6f'
+          ctx.project.rootDoc_id = ctx.fakeDoc_id
+          ctx.ProjectLocator.promises.findRootDoc = sinon
+            .stub()
+            .resolves({ path: { fileSystem: 'other.tex' } })
+          exportData = await ctx.ExportsHandler._buildExport(ctx.export_params)
         })
 
         it('should set a valid root doc', function (ctx) {
-          ctx.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal(
+          ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid.called.should.equal(
             true
           )
         })
@@ -456,281 +406,233 @@ describe('ExportsHandler', function () {
               callbackUrl: null,
             },
           }
-          ctx.callback.calledWith(null, expectedExportData).should.equal(true)
+          expect(exportData).to.eql(expectedExportData)
         })
       })
 
       describe('when no root doc can be identified', function () {
-        beforeEach(async function (ctx) {
-          await new Promise(resolve => {
-            ctx.ProjectLocator.findRootDoc = sinon
-              .stub()
-              .yields(null, [null, null])
-            ctx.ExportsHandler._buildExport(
-              ctx.export_params,
-              (error, exportData) => {
-                ctx.callback(error, exportData)
-                resolve()
-              }
-            )
-          })
+        beforeEach(function (ctx) {
+          ctx.ProjectLocator.promises.findRootDoc = sinon
+            .stub()
+            .resolves({ element: null, path: null })
         })
 
-        it('should return an error', function (ctx) {
-          expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
+        it('should return an error', async function (ctx) {
+          await expect(
+            ctx.ExportsHandler._buildExport(ctx.export_params)
+          ).to.be.rejectedWith(Error)
         })
       })
     })
 
     describe('when user is not found', function () {
       beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.UserGetter.getUser = sinon
-            .stub()
-            .yields(new Error('user not found'))
-          ctx.ExportsHandler._buildExport(
-            ctx.export_params,
-            (error, exportData) => {
-              ctx.callback(error, exportData)
-              resolve()
-            }
-          )
-        })
+        ctx.UserGetter.promises.getUser = sinon
+          .stub()
+          .rejects(new Error('user not found'))
       })
 
-      it('should return an error', function (ctx) {
-        expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
+      it('should return an error', async function (ctx) {
+        await expect(
+          ctx.ExportsHandler._buildExport(ctx.export_params)
+        ).to.be.rejectedWith(Error)
       })
     })
 
     describe('when project history request fails', function () {
-      beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.ExportsHandler._requestVersion = sinon
-            .stub()
-            .yields(new Error('project history call failed'))
-          ctx.ExportsHandler._buildExport(
-            ctx.export_params,
-            (error, exportData) => {
-              ctx.callback(error, exportData)
-              resolve()
-            }
-          )
-        })
+      beforeEach(function (ctx) {
+        ctx.ExportsHandler._requestVersion = sinon
+          .stub()
+          .rejects(new Error('project history call failed'))
       })
 
-      it('should return an error', function (ctx) {
-        expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
+      it('should return an error', async function (ctx) {
+        await expect(
+          ctx.ExportsHandler._buildExport(ctx.export_params)
+        ).to.be.rejectedWith(Error)
       })
     })
   })
 
   describe('_requestExport', function () {
-    beforeEach(async function (ctx) {
-      await new Promise(resolve => {
-        ctx.settings.apis = {
-          v1: {
-            url: 'http://127.0.0.1:5000',
-            user: 'overleaf',
-            pass: 'pass',
-            timeout: 15000,
-          },
-        }
-        ctx.export_data = { iAmAnExport: true }
-        ctx.export_id = 4096
-        ctx.stubPost = sinon
-          .stub()
-          .yields(null, { statusCode: 200 }, { exportId: ctx.export_id })
-        resolve()
-      })
+    beforeEach(function (ctx) {
+      ctx.settings.apis = {
+        v1: {
+          url: 'http://127.0.0.1:5000',
+          user: 'overleaf',
+          pass: 'pass',
+          timeout: 15000,
+        },
+      }
+      ctx.export_data = { iAmAnExport: true }
+      ctx.export_id = 4096
+      ctx.fetchUtils.fetchJson.resolves({ exportId: ctx.export_id })
     })
 
     describe('when all goes well', function () {
+      let response
       beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.stubRequest.post = ctx.stubPost
-          ctx.ExportsHandler._requestExport(
-            ctx.export_data,
-            (error, exportV1Id) => {
-              ctx.callback(error, exportV1Id)
-              resolve()
-            }
-          )
-        })
+        response = await ctx.ExportsHandler._requestExport(ctx.export_data)
       })
 
       it('should issue the request', function (ctx) {
-        expect(ctx.stubPost.getCall(0).args[0]).to.deep.equal({
-          url: ctx.settings.apis.v1.url + '/api/v1/overleaf/exports',
-          auth: {
-            user: ctx.settings.apis.v1.user,
-            pass: ctx.settings.apis.v1.pass,
-          },
-          json: ctx.export_data,
-          timeout: 15000,
-        })
+        expect(ctx.fetchUtils.fetchJson).to.have.been.calledWith(
+          new URL('/api/v1/overleaf/exports', ctx.settings.apis.v1.url),
+          {
+            basicAuth: {
+              user: ctx.settings.apis.v1.user,
+              password: ctx.settings.apis.v1.pass,
+            },
+            json: ctx.export_data,
+            method: 'POST',
+            signal: sinon.match.instanceOf(AbortSignal),
+          }
+        )
       })
 
       it('should return the body with v1 export id', function (ctx) {
-        ctx.callback
-          .calledWith(null, { exportId: ctx.export_id })
-          .should.equal(true)
+        expect(response).to.eql({ exportId: ctx.export_id })
       })
     })
 
     describe('when the request fails', function () {
-      beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.stubRequest.post = sinon
-            .stub()
-            .yields(new Error('export request failed'))
-          ctx.ExportsHandler._requestExport(
-            ctx.export_data,
-            (error, exportV1Id) => {
-              ctx.callback(error, exportV1Id)
-              resolve()
-            }
-          )
-        })
+      beforeEach(function (ctx) {
+        ctx.fetchUtils.fetchJson = sinon
+          .stub()
+          .rejects(new Error('export request failed'))
       })
 
-      it('should return an error', function (ctx) {
-        expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
+      it('should return an error', async function (ctx) {
+        await expect(
+          ctx.ExportsHandler._requestExport(ctx.export_data)
+        ).to.be.rejectedWith(Error)
       })
     })
 
     describe('when the request returns an error response to forward', function () {
+      let response
+      let error
       beforeEach(async function (ctx) {
         ctx.error_code = 422
         ctx.error_json = { status: ctx.error_code, message: 'nope' }
-        ctx.stubRequest.post = sinon
+        ctx.fetchUtils.fetchJson = sinon
           .stub()
-          .yields(null, { statusCode: ctx.error_code }, ctx.error_json)
-
-        await new Promise(resolve => {
-          ctx.ExportsHandler._requestExport(
-            ctx.export_data,
-            (error, exportV1Id) => {
-              ctx.callback(error, exportV1Id)
-              resolve()
-            }
+          .rejects(
+            new RequestFailedError(
+              new URL('/api/v1/overleaf/exports', ctx.settings.apis.v1.url),
+              {},
+              { status: ctx.error_code },
+              ctx.error_json
+            )
           )
-        })
+
+        try {
+          response = await ctx.ExportsHandler._requestExport(ctx.export_data)
+        } catch (err) {
+          error = err
+        }
       })
 
-      it('should return success and the response to forward', function (ctx) {
-        expect(ctx.callback.args[0][0]).not.to.be.instanceOf(Error)
-        ctx.callback.calledWith(null, {
-          forwardResponse: ctx.error_json,
-        })
+      it('should throw with an error containing the response to forward', function (ctx) {
+        expect(response).to.be.undefined
+        expect(OError.getFullInfo(error)).to.have.property(
+          'forwardResponse',
+          ctx.error_json
+        )
       })
     })
   })
 
   describe('fetchExport', function () {
-    beforeEach(async function (ctx) {
-      await new Promise(resolve => {
-        ctx.settings.apis = {
-          v1: {
-            url: 'http://127.0.0.1:5000',
-            user: 'overleaf',
-            pass: 'pass',
-            timeout: 15000,
-          },
-        }
-        ctx.export_id = 897
-        ctx.body = '{"id":897, "status_summary":"completed"}'
-        ctx.stubGet = sinon
-          .stub()
-          .yields(null, { statusCode: 200 }, { body: ctx.body })
-        resolve()
-      })
+    beforeEach(function (ctx) {
+      ctx.settings.apis = {
+        v1: {
+          url: 'http://127.0.0.1:5000',
+          user: 'overleaf',
+          pass: 'pass',
+          timeout: 15000,
+        },
+      }
+      ctx.export_id = 897
+      ctx.body = '{"id":897, "status_summary":"completed"}'
+      ctx.fetchUtils.fetchString = sinon
+        .stub()
+        .resolves(JSON.stringify({ body: ctx.body }))
     })
 
     describe('when all goes well', function () {
+      let exportResponse
       beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.stubRequest.get = ctx.stubGet
-          ctx.ExportsHandler.fetchExport(ctx.export_id, (error, body) => {
-            ctx.callback(error, body)
-            resolve()
-          })
-        })
+        exportResponse = await ctx.ExportsHandler.fetchExport(ctx.export_id)
       })
 
       it('should issue the request', function (ctx) {
-        expect(ctx.stubGet.getCall(0).args[0]).to.deep.equal({
-          url:
-            ctx.settings.apis.v1.url +
-            '/api/v1/overleaf/exports/' +
-            ctx.export_id,
-          auth: {
-            user: ctx.settings.apis.v1.user,
-            pass: ctx.settings.apis.v1.pass,
-          },
-          timeout: 15000,
-        })
+        expect(ctx.fetchUtils.fetchString).to.have.been.calledWith(
+          new URL(
+            '/api/v1/overleaf/exports/' + ctx.export_id,
+            ctx.settings.apis.v1.url
+          ),
+          {
+            basicAuth: {
+              user: ctx.settings.apis.v1.user,
+              password: ctx.settings.apis.v1.pass,
+            },
+            signal: sinon.match.instanceOf(AbortSignal),
+          }
+        )
       })
 
       it('should return the v1 export id', function (ctx) {
-        ctx.callback.calledWith(null, { body: ctx.body }).should.equal(true)
+        expect(exportResponse).to.eql(JSON.stringify({ body: ctx.body }))
       })
     })
   })
 
   describe('fetchDownload', function () {
     beforeEach(async function (ctx) {
-      await new Promise(resolve => {
-        ctx.settings.apis = {
-          v1: {
-            url: 'http://127.0.0.1:5000',
-            user: 'overleaf',
-            pass: 'pass',
-            timeout: 15000,
-          },
-        }
-        ctx.export_id = 897
-        ctx.body =
-          'https://writelatex-conversions-dev.s3.amazonaws.com/exports/ieee_latexqc/tnb/2912/xggmprcrpfwbsnqzqqmvktddnrbqkqkr.zip?X-Amz-Expires=14400&X-Amz-Date=20180730T181003Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAJDGDIJFGLNVGZH6A/20180730/us-east-1/s3/aws4_request&X-Amz-SignedHeaders=host&X-Amz-Signature=dec990336913cef9933f0e269afe99722d7ab2830ebf2c618a75673ee7159fee'
-        ctx.stubGet = sinon
-          .stub()
-          .yields(null, { statusCode: 200 }, { body: ctx.body })
-        resolve()
-      })
+      ctx.settings.apis = {
+        v1: {
+          url: 'http://127.0.0.1:5000',
+          user: 'overleaf',
+          pass: 'pass',
+          timeout: 15000,
+        },
+      }
+      ctx.export_id = 897
+      ctx.body =
+        'https://writelatex-conversions-dev.s3.amazonaws.com/exports/ieee_latexqc/tnb/2912/xggmprcrpfwbsnqzqqmvktddnrbqkqkr.zip?X-Amz-Expires=14400&X-Amz-Date=20180730T181003Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAJDGDIJFGLNVGZH6A/20180730/us-east-1/s3/aws4_request&X-Amz-SignedHeaders=host&X-Amz-Signature=dec990336913cef9933f0e269afe99722d7ab2830ebf2c618a75673ee7159fee'
+      ctx.fetchUtils.fetchString = sinon.stub().resolves(ctx.body)
     })
 
     describe('when all goes well', function () {
+      let downloadResponse
       beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.stubRequest.get = ctx.stubGet
-          ctx.ExportsHandler.fetchDownload(
-            ctx.export_id,
-            'zip',
-            (error, body) => {
-              ctx.callback(error, body)
-              resolve()
-            }
-          )
-        })
+        downloadResponse = await ctx.ExportsHandler.fetchDownload(
+          ctx.export_id,
+          'zip'
+        )
       })
 
       it('should issue the request', function (ctx) {
-        expect(ctx.stubGet.getCall(0).args[0]).to.deep.equal({
-          url:
+        expect(ctx.fetchUtils.fetchString).to.have.been.calledWith(
+          new URL(
             ctx.settings.apis.v1.url +
-            '/api/v1/overleaf/exports/' +
-            ctx.export_id +
-            '/zip_url',
-          auth: {
-            user: ctx.settings.apis.v1.user,
-            pass: ctx.settings.apis.v1.pass,
-          },
-          timeout: 15000,
-        })
+              '/api/v1/overleaf/exports/' +
+              ctx.export_id +
+              '/zip_url'
+          ),
+          {
+            basicAuth: {
+              user: ctx.settings.apis.v1.user,
+              password: ctx.settings.apis.v1.pass,
+            },
+            signal: sinon.match.instanceOf(AbortSignal),
+          }
+        )
       })
 
       it('should return the v1 export id', function (ctx) {
-        ctx.callback.calledWith(null, { body: ctx.body }).should.equal(true)
+        expect(downloadResponse).to.eql(ctx.body)
       })
     })
   })