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

Merge pull request #30323 from overleaf/ar/promisify-DocstoreManager

[web] promisify DocstoreManager

GitOrigin-RevId: 351b9868a1c29066b6c98d92e5b513e10f4f6764
Andrew Rumble 6 месяцев назад
Родитель
Сommit
610398d099

+ 272 - 229
services/web/app/src/Features/Docstore/DocstoreManager.mjs

@@ -1,91 +1,108 @@
-import { promisify } from 'node:util'
-import { promisifyMultiResult, callbackify } from '@overleaf/promise-utils'
+// @ts-check
+import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
 import OError from '@overleaf/o-error'
 import logger from '@overleaf/logger'
 import settings from '@overleaf/settings'
 import Errors from '../Errors/Errors.js'
-import { fetchJson } from '@overleaf/fetch-utils'
-import Request from 'request'
-const request = Request.defaults({ jar: false })
+import {
+  fetchJson,
+  fetchNothing,
+  RequestFailedError,
+} from '@overleaf/fetch-utils'
+import path from 'node:path'
+
+/**
+ * @import { ObjectId } from 'mongodb'
+ */
 
 const TIMEOUT = 30 * 1000 // request timeout
 
-function deleteDoc(projectId, docId, name, deletedAt, callback) {
-  const url = `${settings.apis.docstore.url}/project/${projectId}/doc/${docId}`
+/**
+ *
+ * @param {string | ObjectId} projectId
+ * @param {string | ObjectId} docId
+ * @param {string} name
+ * @param {Date} deletedAt
+ * @return {Promise<void>}
+ */
+async function deleteDoc(projectId, docId, name, deletedAt) {
+  const url = new URL(settings.apis.docstore.url)
+  url.pathname = path.posix.join(
+    'project',
+    projectId.toString(),
+    'doc',
+    docId.toString()
+  )
   const docMetaData = { deleted: true, deletedAt, name }
-  const options = { url, json: docMetaData, timeout: TIMEOUT }
-  request.patch(options, (error, res) => {
-    if (error) {
-      return callback(error)
-    }
-    if (res.statusCode >= 200 && res.statusCode < 300) {
-      callback(null)
-    } else if (res.statusCode === 404) {
-      error = new Errors.NotFoundError({
-        message: 'tried to delete doc not in docstore',
-        info: {
-          projectId,
-          docId,
-        },
-      })
-      callback(error) // maybe suppress the error when delete doc which is not present?
-    } else {
-      error = new OError(
-        `docstore api responded with non-success code: ${res.statusCode}`,
+  const options = {
+    json: docMetaData,
+    signal: AbortSignal.timeout(TIMEOUT),
+    method: 'PATCH',
+  }
+  try {
+    await fetchNothing(url, options)
+  } catch (error) {
+    if (error instanceof RequestFailedError) {
+      if (error.response.status === 404) {
+        // maybe suppress the error when delete doc which is not present?
+        throw new Errors.NotFoundError({
+          message: 'tried to delete doc not in docstore',
+          info: {
+            projectId,
+            docId,
+          },
+        })
+      }
+      throw new OError(
+        `docstore api responded with non-success code: ${error.response.status}`,
         {
           projectId,
           docId,
         }
       )
-      callback(error)
     }
-  })
+    throw error
+  }
 }
 
 /**
  * @param {string} projectId
  */
-function getAllDocs(projectId, callback) {
-  const url = `${settings.apis.docstore.url}/project/${projectId}/doc`
-  request.get(
-    {
-      url,
-      timeout: TIMEOUT,
-      json: true,
-    },
-    (error, res, docs) => {
-      if (error) {
-        return callback(error)
-      }
-      if (res.statusCode >= 200 && res.statusCode < 300) {
-        callback(null, docs)
-      } else {
-        error = new OError(
-          `docstore api responded with non-success code: ${res.statusCode}`,
-          { projectId }
-        )
-        callback(error)
-      }
+async function getAllDocs(projectId) {
+  const url = new URL(settings.apis.docstore.url)
+  url.pathname = path.posix.join('project', projectId.toString(), 'doc')
+  try {
+    return await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
+  } catch (error) {
+    if (error instanceof RequestFailedError) {
+      throw new OError(
+        `docstore api responded with non-success code: ${error.response.status}`,
+        { projectId }
+      )
     }
-  )
+    throw error
+  }
 }
 
-function getAllDeletedDocs(projectId, callback) {
-  const url = `${settings.apis.docstore.url}/project/${projectId}/doc-deleted`
-  request.get({ url, timeout: TIMEOUT, json: true }, (error, res, docs) => {
-    if (error) {
-      callback(OError.tag(error, 'could not get deleted docs from docstore'))
-    } else if (res.statusCode === 200) {
-      callback(null, docs)
-    } else {
-      callback(
-        new OError(
-          `docstore api responded with non-success code: ${res.statusCode}`,
-          { projectId }
-        )
+/**
+ *
+ * @param {string|ObjectId} projectId
+ * @return {Promise<*>}
+ */
+async function getAllDeletedDocs(projectId) {
+  const url = new URL(settings.apis.docstore.url)
+  url.pathname = path.posix.join('project', projectId.toString(), 'doc-deleted')
+  try {
+    return await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
+  } catch (error) {
+    if (error instanceof RequestFailedError) {
+      throw new OError(
+        `docstore api responded with non-success code: ${error.response.status}`,
+        { projectId }
       )
     }
-  })
+    throw OError.tag(error, 'could not get deleted docs from docstore')
+  }
 }
 
 /**
@@ -106,231 +123,257 @@ async function getTrackedChangesUserIds(projectId) {
 
 /**
  * @param {string} projectId
- * @param {Callback} callback
  */
-function getAllRanges(projectId, callback) {
-  const url = `${settings.apis.docstore.url}/project/${projectId}/ranges`
-  request.get(
-    {
-      url,
-      timeout: TIMEOUT,
-      json: true,
-    },
-    (error, res, docs) => {
-      if (error) {
-        return callback(error)
-      }
-      if (res.statusCode >= 200 && res.statusCode < 300) {
-        callback(null, docs)
-      } else {
-        error = new OError(
-          `docstore api responded with non-success code: ${res.statusCode}`,
-          { projectId }
-        )
-        callback(error)
-      }
+async function getAllRanges(projectId) {
+  const url = new URL(settings.apis.docstore.url)
+  url.pathname = path.posix.join('project', projectId, 'ranges')
+  try {
+    return await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
+  } catch (error) {
+    if (error instanceof RequestFailedError) {
+      throw new OError(
+        `docstore api responded with non-success code: ${error.response.status}`,
+        { projectId }
+      )
     }
-  )
+    throw error
+  }
 }
 
-function getDoc(projectId, docId, options, callback) {
-  if (options == null) {
-    options = {}
-  }
-  if (typeof options === 'function') {
-    callback = options
-    options = {}
-  }
-  const requestParams = { timeout: TIMEOUT, json: true }
+/**
+ *
+ * @param {string | ObjectId} projectId
+ * @param {string | ObjectId} docId
+ * @param {{ peek?: boolean, include_deleted?: boolean }} options
+ * @return {Promise<{lines: *, rev: *, version: *, ranges: *}>}
+ */
+async function getDoc(projectId, docId, options = {}) {
+  const url = new URL(settings.apis.docstore.url)
   if (options.peek) {
-    requestParams.url = `${settings.apis.docstore.url}/project/${projectId}/doc/${docId}/peek`
+    url.pathname = path.posix.join(
+      'project',
+      projectId.toString(),
+      'doc',
+      docId.toString(),
+      'peek'
+    )
   } else {
-    requestParams.url = `${settings.apis.docstore.url}/project/${projectId}/doc/${docId}`
+    url.pathname = path.posix.join(
+      'project',
+      projectId.toString(),
+      'doc',
+      docId.toString()
+    )
   }
   if (options.include_deleted) {
-    requestParams.qs = { include_deleted: 'true' }
+    url.searchParams.set('include_deleted', 'true')
   }
-  request.get(requestParams, (error, res, doc) => {
-    if (error) {
-      return callback(error)
+  try {
+    const doc = await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
+    logger.debug(
+      { docId, projectId, version: doc.version, rev: doc.rev },
+      'got doc from docstore api'
+    )
+    return {
+      lines: doc.lines,
+      rev: doc.rev,
+      version: doc.version,
+      ranges: doc.ranges,
     }
-    if (res.statusCode >= 200 && res.statusCode < 300) {
-      logger.debug(
-        { docId, projectId, version: doc.version, rev: doc.rev },
-        'got doc from docstore api'
-      )
-      callback(null, doc.lines, doc.rev, doc.version, doc.ranges)
-    } else if (res.statusCode === 404) {
-      error = new Errors.NotFoundError({
-        message: 'doc not found in docstore',
-        info: {
-          projectId,
-          docId,
-        },
-      })
-      callback(error)
-    } else {
-      error = new OError(
-        `docstore api responded with non-success code: ${res.statusCode}`,
+  } catch (error) {
+    if (error instanceof RequestFailedError) {
+      if (error.response.status === 404) {
+        throw new Errors.NotFoundError({
+          message: 'doc not found in docstore',
+          info: {
+            projectId,
+            docId,
+          },
+        })
+      }
+      throw new OError(
+        `docstore api responded with non-success code: ${error.response.status}`,
         {
           projectId,
           docId,
         }
       )
-      callback(error)
     }
-  })
+    throw error
+  }
 }
 
-function isDocDeleted(projectId, docId, callback) {
-  const url = `${settings.apis.docstore.url}/project/${projectId}/doc/${docId}/deleted`
-  request.get({ url, timeout: TIMEOUT, json: true }, (err, res, body) => {
-    if (err) {
-      callback(err)
-    } else if (res.statusCode === 200) {
-      callback(null, body.deleted)
-    } else if (res.statusCode === 404) {
-      callback(
-        new Errors.NotFoundError({
+/**
+ *
+ * @param {string} projectId
+ * @param {string} docId
+ * @return {Promise<boolean>}
+ */
+async function isDocDeleted(projectId, docId) {
+  const url = new URL(settings.apis.docstore.url)
+  url.pathname = path.posix.join('project', projectId, 'doc', docId, 'deleted')
+  try {
+    const doc = await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
+    return doc.deleted
+  } catch (error) {
+    if (error instanceof RequestFailedError) {
+      if (error.response.status === 404) {
+        throw new Errors.NotFoundError({
           message: 'doc does not exist in project',
           info: { projectId, docId },
         })
-      )
-    } else {
-      callback(
-        new OError(
-          `docstore api responded with non-success code: ${res.statusCode}`,
-          { projectId, docId }
-        )
+      }
+      throw new OError(
+        `docstore api responded with non-success code: ${error.response.status}`,
+        { projectId, docId }
       )
     }
-  })
+    throw error
+  }
 }
 
-function updateDoc(projectId, docId, lines, version, ranges, callback) {
-  const url = `${settings.apis.docstore.url}/project/${projectId}/doc/${docId}`
-  request.post(
-    {
-      url,
-      timeout: TIMEOUT,
+/**
+ *
+ * @param {string} projectId
+ * @param {string} docId
+ * @param {string[]} lines
+ * @param {number} version
+ * @param ranges
+ * @return {Promise<{modified: *, rev: *}>}
+ */
+async function updateDoc(projectId, docId, lines, version, ranges) {
+  const url = new URL(settings.apis.docstore.url)
+  url.pathname = path.posix.join('project', projectId, 'doc', docId)
+  try {
+    const result = await fetchJson(url, {
+      method: 'POST',
+      signal: AbortSignal.timeout(TIMEOUT),
       json: {
         lines,
         version,
         ranges,
       },
-    },
-    (error, res, result) => {
-      if (error) {
-        return callback(error)
-      }
-      if (res.statusCode >= 200 && res.statusCode < 300) {
-        logger.debug(
-          { projectId, docId },
-          'update doc in docstore url finished'
-        )
-        callback(null, result.modified, result.rev)
-      } else {
-        error = new OError(
-          `docstore api responded with non-success code: ${res.statusCode}`,
-          { projectId, docId }
-        )
-        callback(error)
-      }
+    })
+    logger.debug({ projectId, docId }, 'update doc in docstore url finished')
+    return { modified: result.modified, rev: result.rev }
+  } catch (error) {
+    if (error instanceof RequestFailedError) {
+      throw new OError(
+        `docstore api responded with non-success code: ${error.response.status}`,
+        { projectId, docId }
+      )
     }
-  )
+    throw error
+  }
 }
 
 /**
  * Asks docstore whether any doc in the project has ranges
  *
- * @param {string} proejctId
- * @param {Callback} callback
+ * @param {string} projectId
  */
-function projectHasRanges(projectId, callback) {
-  const url = `${settings.apis.docstore.url}/project/${projectId}/has-ranges`
-  request.get({ url, timeout: TIMEOUT, json: true }, (err, res, body) => {
-    if (err) {
-      return callback(err)
-    }
-    if (res.statusCode >= 200 && res.statusCode < 300) {
-      callback(null, body.projectHasRanges)
-    } else {
-      callback(
-        new OError(
-          `docstore api responded with non-success code: ${res.statusCode}`,
-          { projectId }
-        )
+async function projectHasRanges(projectId) {
+  const url = new URL(settings.apis.docstore.url)
+  url.pathname = path.posix.join('project', projectId, 'has-ranges')
+  try {
+    const body = await fetchJson(url, { signal: AbortSignal.timeout(TIMEOUT) })
+    return body.projectHasRanges
+  } catch (error) {
+    if (error instanceof RequestFailedError) {
+      throw new OError(
+        `docstore api responded with non-success code: ${error.response.status}`,
+        { projectId }
       )
     }
-  })
+    throw error
+  }
 }
 
-function archiveProject(projectId, callback) {
-  _operateOnProject(projectId, 'archive', callback)
+/**
+ *
+ * @param {string|ObjectId} projectId
+ * @return {Promise<void>}
+ */
+async function archiveProject(projectId) {
+  await _operateOnProject(projectId, 'archive')
 }
-
-function unarchiveProject(projectId, callback) {
-  _operateOnProject(projectId, 'unarchive', callback)
+/**
+ *
+ * @param {string|ObjectId} projectId
+ * @return {Promise<void>}
+ */
+async function unarchiveProject(projectId) {
+  await _operateOnProject(projectId, 'unarchive')
 }
-
-function destroyProject(projectId, callback) {
-  _operateOnProject(projectId, 'destroy', callback)
+/**
+ *
+ * @param {string|ObjectId} projectId
+ * @return {Promise<void>}
+ */
+async function destroyProject(projectId) {
+  await _operateOnProject(projectId, 'destroy')
 }
 
-function _operateOnProject(projectId, method, callback) {
-  const url = `${settings.apis.docstore.url}/project/${projectId}/${method}`
+/**
+ *
+ * @param {string|ObjectId} projectId
+ * @param {string} method
+ * @return {Promise<void>}
+ * @private
+ */
+async function _operateOnProject(projectId, method) {
+  const url = new URL(settings.apis.docstore.url)
+  url.pathname = path.posix.join('project', projectId.toString(), method)
   logger.debug({ projectId }, `calling ${method} for project in docstore`)
-  // use default timeout for archiving/unarchiving/destroying
-  request.post(url, (err, res, docs) => {
-    if (err) {
-      OError.tag(err, `error calling ${method} project in docstore`, {
-        projectId,
-      })
-      return callback(err)
-    }
-
-    if (res.statusCode >= 200 && res.statusCode < 300) {
-      callback()
-    } else {
+  try {
+    // use default timeout for archiving/unarchiving/destroying
+    await fetchNothing(url, {
+      method: 'POST',
+    })
+  } catch (err) {
+    if (err instanceof RequestFailedError) {
       const error = new Error(
-        `docstore api responded with non-success code: ${res.statusCode}`
+        `docstore api responded with non-success code: ${err.response.status}`
       )
       logger.warn(
         { err: error, projectId },
         `error calling ${method} project in docstore`
       )
-      callback(error)
+      throw error
     }
-  })
+    throw OError.tag(err, `error calling ${method} project in docstore`, {
+      projectId,
+    })
+  }
 }
 
 export default {
-  deleteDoc,
-  getAllDocs,
-  getAllDeletedDocs,
-  getAllRanges,
-  getDoc,
+  deleteDoc: callbackify(deleteDoc),
+  getAllDocs: callbackify(getAllDocs),
+  getAllDeletedDocs: callbackify(getAllDeletedDocs),
+  getAllRanges: callbackify(getAllRanges),
+  getDoc: callbackifyMultiResult(getDoc, ['lines', 'rev', 'version', 'ranges']),
   getCommentThreadIds: callbackify(getCommentThreadIds),
   getTrackedChangesUserIds: callbackify(getTrackedChangesUserIds),
-  isDocDeleted,
-  updateDoc,
-  projectHasRanges,
-  archiveProject,
-  unarchiveProject,
-  destroyProject,
+  isDocDeleted: callbackify(isDocDeleted),
+  updateDoc: callbackifyMultiResult(updateDoc, ['modified', 'rev']),
+  projectHasRanges: callbackify(projectHasRanges),
+  archiveProject: callbackify(archiveProject),
+  unarchiveProject: callbackify(unarchiveProject),
+  destroyProject: callbackify(destroyProject),
   promises: {
-    deleteDoc: promisify(deleteDoc),
-    getAllDocs: promisify(getAllDocs),
-    getAllDeletedDocs: promisify(getAllDeletedDocs),
-    getAllRanges: promisify(getAllRanges),
-    getDoc: promisifyMultiResult(getDoc, ['lines', 'rev', 'version', 'ranges']),
+    deleteDoc,
+    getAllDocs,
+    getAllDeletedDocs,
+    getAllRanges,
+    getDoc,
     getCommentThreadIds,
     getTrackedChangesUserIds,
-    isDocDeleted: promisify(isDocDeleted),
-    updateDoc: promisifyMultiResult(updateDoc, ['modified', 'rev']),
-    projectHasRanges: promisify(projectHasRanges),
-    archiveProject: promisify(archiveProject),
-    unarchiveProject: promisify(unarchiveProject),
-    destroyProject: promisify(destroyProject),
+    isDocDeleted,
+    updateDoc,
+    projectHasRanges,
+    archiveProject,
+    unarchiveProject,
+    destroyProject,
   },
 }

+ 274 - 300
services/web/test/unit/src/Docstore/DocstoreManager.test.mjs

@@ -1,7 +1,8 @@
-import { beforeAll, beforeEach, describe, it, vi, expect } from 'vitest'
-import sinon from 'sinon'
+import { assert, beforeAll, beforeEach, describe, it, vi, expect } from 'vitest'
 import Errors from '../../../../app/src/Features/Errors/Errors.js'
 import tk from 'timekeeper'
+import { RequestFailedError } from '@overleaf/fetch-utils'
+
 const modulePath = '../../../../app/src/Features/Docstore/DocstoreManager'
 
 vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
@@ -9,31 +10,33 @@ vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
 )
 
 describe('DocstoreManager', function () {
-  beforeEach(async function (ctx) {
-    ctx.requestDefaults = sinon.stub().returns((ctx.request = sinon.stub()))
+  let DocstoreManager, FetchUtils, projectId, docId, settings
 
-    vi.doMock('request', () => ({
-      default: {
-        defaults: ctx.requestDefaults,
+  beforeEach(async function () {
+    settings = {
+      apis: {
+        docstore: {
+          url: 'http://docstore.overleaf.com',
+        },
       },
-    }))
+    }
 
     vi.doMock('@overleaf/settings', () => ({
-      default: (ctx.settings = {
-        apis: {
-          docstore: {
-            url: 'docstore.overleaf.com',
-          },
-        },
-      }),
+      default: settings,
     }))
 
-    ctx.DocstoreManager = (await import(modulePath)).default
+    FetchUtils = {
+      fetchNothing: vi.fn().mockResolvedValue(),
+      fetchJson: vi.fn().mockResolvedValue({}),
+      RequestFailedError,
+    }
+
+    vi.doMock('@overleaf/fetch-utils', () => FetchUtils)
 
-    ctx.requestDefaults.calledWith({ jar: false }).should.equal(true)
+    DocstoreManager = (await import(modulePath)).default
 
-    ctx.project_id = 'project-id-123'
-    ctx.doc_id = 'doc-id-123'
+    projectId = 'project-id-123'
+    docId = 'doc-id-123'
   })
 
   describe('deleteDoc', function () {
@@ -46,43 +49,40 @@ describe('DocstoreManager', function () {
         tk.reset()
       })
 
-      beforeEach(async function (ctx) {
-        ctx.request.patch = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 204 }, '')
-        await ctx.DocstoreManager.promises.deleteDoc(
-          ctx.project_id,
-          ctx.doc_id,
+      beforeEach(async function () {
+        await DocstoreManager.promises.deleteDoc(
+          projectId,
+          docId,
           'wombat.tex',
           new Date()
         )
       })
 
-      it('should delete the doc in the docstore api', function (ctx) {
-        ctx.request.patch
-          .calledWith({
-            url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`,
-            json: { deleted: true, deletedAt: new Date(), name: 'wombat.tex' },
-            timeout: 30 * 1000,
-          })
-          .should.equal(true)
+      it('should delete the doc in the docstore api', function () {
+        const url = new URL(settings.apis.docstore.url)
+        url.pathname = `/project/${projectId}/doc/${docId}`
+        expect(FetchUtils.fetchNothing).toHaveBeenCalledWith(url, {
+          json: { deleted: true, deletedAt: new Date(), name: 'wombat.tex' },
+          signal: expect.anything(),
+          method: 'PATCH',
+        })
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.patch = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 500 }, '')
+      beforeEach(function () {
+        FetchUtils.fetchNothing.mockImplementation((url, opts) => {
+          throw new RequestFailedError(url, opts, { status: 500 })
+        })
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
 
         try {
-          await ctx.DocstoreManager.promises.deleteDoc(
-            ctx.project_id,
-            ctx.doc_id,
+          await DocstoreManager.promises.deleteDoc(
+            projectId,
+            docId,
             'main.tex',
             new Date()
           )
@@ -99,18 +99,18 @@ describe('DocstoreManager', function () {
     })
 
     describe('with a missing (404) response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.patch = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 404 }, '')
+      beforeEach(function () {
+        FetchUtils.fetchNothing.mockImplementation((url, opts) => {
+          throw new RequestFailedError(url, opts, { status: 404 })
+        })
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
         try {
-          await ctx.DocstoreManager.promises.deleteDoc(
-            ctx.project_id,
-            ctx.doc_id,
+          await DocstoreManager.promises.deleteDoc(
+            projectId,
+            docId,
             'main.tex',
             new Date()
           )
@@ -128,74 +128,72 @@ describe('DocstoreManager', function () {
   })
 
   describe('updateDoc', function () {
-    beforeEach(function (ctx) {
-      ctx.lines = ['mock', 'doc', 'lines']
-      ctx.rev = 5
-      ctx.version = 42
-      ctx.ranges = { mock: 'ranges' }
-      ctx.modified = true
+    let lines, modified, ranges, rev, updateDocResponse, version
+    beforeEach(function () {
+      lines = ['mock', 'doc', 'lines']
+      rev = 5
+      version = 42
+      ranges = { mock: 'ranges' }
+      modified = true
     })
 
     describe('with a successful response code', async function () {
-      beforeEach(async function (ctx) {
-        ctx.request.post = sinon
-          .stub()
-          .callsArgWith(
-            1,
-            null,
-            { statusCode: 204 },
-            { modified: ctx.modified, rev: ctx.rev }
-          )
-        ctx.updateDocResponse = await ctx.DocstoreManager.promises.updateDoc(
-          ctx.project_id,
-          ctx.doc_id,
-          ctx.lines,
-          ctx.version,
-          ctx.ranges
+      beforeEach(async function () {
+        FetchUtils.fetchJson.mockResolvedValue({
+          modified,
+          rev,
+        })
+        updateDocResponse = await DocstoreManager.promises.updateDoc(
+          projectId,
+          docId,
+          lines,
+          version,
+          ranges
         )
       })
 
-      it('should update the doc in the docstore api', function (ctx) {
-        ctx.request.post
-          .calledWith({
-            url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`,
-            timeout: 30 * 1000,
+      it('should update the doc in the docstore api', function () {
+        expect(FetchUtils.fetchJson).toHaveBeenCalledWith(
+          new URL(
+            `${settings.apis.docstore.url}/project/${projectId}/doc/${docId}`
+          ),
+          {
+            signal: expect.anything(),
+            method: 'POST',
             json: {
-              lines: ctx.lines,
-              version: ctx.version,
-              ranges: ctx.ranges,
+              lines,
+              version,
+              ranges,
             },
-          })
-          .should.equal(true)
+          }
+        )
       })
 
-      it('should return the modified status and revision', function (ctx) {
-        expect(ctx.updateDocResponse).to.haveOwnProperty(
-          'modified',
-          ctx.modified
-        )
-        expect(ctx.updateDocResponse).to.haveOwnProperty('rev', ctx.rev)
+      it('should return the modified status and revision', function () {
+        expect(updateDocResponse).to.haveOwnProperty('modified', modified)
+        expect(updateDocResponse).to.haveOwnProperty('rev', rev)
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.post = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 500 }, '')
+      beforeEach(function () {
+        FetchUtils.fetchJson.mockImplementation((url, opts) => {
+          throw new RequestFailedError(url, opts, { status: 500 })
+        })
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
 
         try {
-          await ctx.DocstoreManager.promises.updateDoc(
-            ctx.project_id,
-            ctx.doc_id,
-            ctx.lines,
-            ctx.version,
-            ctx.ranges
+          await DocstoreManager.promises.updateDoc(
+            projectId,
+            docId,
+            lines,
+            version,
+            ranges
           )
+          assert.fail('updateDoc should have thrown an error')
         } catch (err) {
           error = err
         }
@@ -210,56 +208,60 @@ describe('DocstoreManager', function () {
   })
 
   describe('getDoc', function () {
-    beforeEach(function (ctx) {
-      ctx.doc = {
-        lines: (ctx.lines = ['mock', 'doc', 'lines']),
-        rev: (ctx.rev = 5),
-        version: (ctx.version = 42),
-        ranges: (ctx.ranges = { mock: 'ranges' }),
+    let doc, getDocResponse, lines, ranges, rev, version
+    beforeEach(function () {
+      lines = ['mock', 'doc', 'lines']
+      rev = 5
+      version = 42
+      ranges = { mock: 'ranges' }
+      doc = {
+        lines,
+        rev,
+        version,
+        ranges,
       }
     })
 
     describe('with a successful response code', function () {
-      beforeEach(async function (ctx) {
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 204 }, ctx.doc)
-        ctx.getDocResponse = await ctx.DocstoreManager.promises.getDoc(
-          ctx.project_id,
-          ctx.doc_id
+      beforeEach(async function () {
+        FetchUtils.fetchJson.mockResolvedValue(doc)
+        getDocResponse = await DocstoreManager.promises.getDoc(projectId, docId)
+      })
+
+      it('should get the doc from the docstore api', function () {
+        expect(FetchUtils.fetchJson).toHaveBeenCalledWith(
+          new URL(
+            `${settings.apis.docstore.url}/project/${projectId}/doc/${docId}`
+          ),
+          {
+            signal: expect.anything(),
+          }
         )
       })
 
-      it('should get the doc from the docstore api', function (ctx) {
-        ctx.request.get.should.have.been.calledWith({
-          url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`,
-          timeout: 30 * 1000,
-          json: true,
-        })
-      })
-
-      it('should resolve with the lines, version and rev', function (ctx) {
-        expect(ctx.getDocResponse).to.eql({
-          lines: ctx.lines,
-          rev: ctx.rev,
-          version: ctx.version,
-          ranges: ctx.ranges,
+      it('should resolve with the lines, version and rev', function () {
+        expect(getDocResponse).to.eql({
+          lines,
+          rev,
+          version,
+          ranges,
         })
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 500 }, '')
+      beforeEach(function () {
+        FetchUtils.fetchJson.mockImplementation((url, opts) => {
+          throw new RequestFailedError(url, opts, { status: 500 })
+        })
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
 
         try {
-          await ctx.DocstoreManager.promises.getDoc(ctx.project_id, ctx.doc_id)
+          await DocstoreManager.promises.getDoc(projectId, docId)
+          assert.fail('getDoc should have thrown an error')
         } catch (err) {
           error = err
         }
@@ -273,67 +275,68 @@ describe('DocstoreManager', function () {
     })
 
     describe('with include_deleted=true', function () {
-      beforeEach(async function (ctx) {
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 204 }, ctx.doc)
-        ctx.getDocResponse = await ctx.DocstoreManager.promises.getDoc(
-          ctx.project_id,
-          ctx.doc_id,
+      beforeEach(async function () {
+        FetchUtils.fetchJson.mockResolvedValue(doc)
+        getDocResponse = await DocstoreManager.promises.getDoc(
+          projectId,
+          docId,
           { include_deleted: true }
         )
       })
 
-      it('should get the doc from the docstore api (including deleted)', function (ctx) {
-        ctx.request.get.should.have.been.calledWith({
-          url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`,
-          qs: { include_deleted: 'true' },
-          timeout: 30 * 1000,
-          json: true,
-        })
+      it('should get the doc from the docstore api (including deleted)', function () {
+        expect(FetchUtils.fetchJson).toHaveBeenCalledWith(
+          new URL(
+            `${settings.apis.docstore.url}/project/${projectId}/doc/${docId}?include_deleted=true`
+          ),
+          {
+            signal: expect.anything(),
+          }
+        )
       })
 
-      it('should resolve with the lines, version and rev', function (ctx) {
-        expect(ctx.getDocResponse).to.eql({
-          lines: ctx.lines,
-          rev: ctx.rev,
-          version: ctx.version,
-          ranges: ctx.ranges,
+      it('should resolve with the lines, version and rev', function () {
+        expect(getDocResponse).to.eql({
+          lines,
+          rev,
+          version,
+          ranges,
         })
       })
     })
 
     describe('with peek=true', function () {
-      beforeEach(async function (ctx) {
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 204 }, ctx.doc)
-        await ctx.DocstoreManager.promises.getDoc(ctx.project_id, ctx.doc_id, {
+      beforeEach(async function () {
+        await DocstoreManager.promises.getDoc(projectId, docId, {
           peek: true,
         })
       })
 
-      it('should call the docstore peek url', function (ctx) {
-        ctx.request.get.should.have.been.calledWith({
-          url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}/peek`,
-          timeout: 30 * 1000,
-          json: true,
-        })
+      it('should call the docstore peek url', function () {
+        expect(FetchUtils.fetchJson).toHaveBeenCalledWith(
+          new URL(
+            `${settings.apis.docstore.url}/project/${projectId}/doc/${docId}/peek`
+          ),
+          {
+            signal: expect.anything(),
+          }
+        )
       })
     })
 
     describe('with a missing (404) response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 404 }, '')
+      beforeEach(function () {
+        FetchUtils.fetchJson.mockImplementation((url, opts) => {
+          throw new RequestFailedError(url, opts, { status: 404 })
+        })
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
 
         try {
-          await ctx.DocstoreManager.promises.getDoc(ctx.project_id, ctx.doc_id)
+          await DocstoreManager.promises.getDoc(projectId, docId)
+          assert.fail('getDoc should have thrown an error')
         } catch (err) {
           error = err
         }
@@ -347,47 +350,40 @@ describe('DocstoreManager', function () {
   describe('getAllDocs', function () {
     describe('with a successful response code', function () {
       let getAllDocsResult
-      beforeEach(async function (ctx) {
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(
-            1,
-            null,
-            { statusCode: 204 },
-            (ctx.docs = [{ _id: 'mock-doc-id' }])
-          )
-        getAllDocsResult = await ctx.DocstoreManager.promises.getAllDocs(
-          ctx.project_id
+      let docs
+      beforeEach(async function () {
+        docs = [{ _id: 'mock-doc-id' }]
+        FetchUtils.fetchJson.mockResolvedValue(docs)
+        getAllDocsResult = await DocstoreManager.promises.getAllDocs(projectId)
+      })
+
+      it('should get all the project docs in the docstore api', function () {
+        expect(FetchUtils.fetchJson).toBeCalledWith(
+          new URL(`${settings.apis.docstore.url}/project/${projectId}/doc`),
+          {
+            signal: expect.anything(),
+          }
         )
       })
 
-      it('should get all the project docs in the docstore api', function (ctx) {
-        ctx.request.get
-          .calledWith({
-            url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc`,
-            timeout: 30 * 1000,
-            json: true,
-          })
-          .should.equal(true)
-      })
-
-      it('should return the docs', function (ctx) {
-        expect(getAllDocsResult).to.eql(ctx.docs)
+      it('should return the docs', function () {
+        expect(getAllDocsResult).to.eql(docs)
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 500 }, '')
+      beforeEach(function () {
+        FetchUtils.fetchJson.mockImplementation((url, opts) => {
+          throw new RequestFailedError(url, opts, { status: 500 })
+        })
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
 
         try {
-          await ctx.DocstoreManager.promises.getAllDocs(ctx.project_id)
+          await DocstoreManager.promises.getAllDocs(projectId)
+          assert.fail('getAllDocs should have thrown an error')
         } catch (err) {
           error = err
         }
@@ -404,40 +400,41 @@ describe('DocstoreManager', function () {
   describe('getAllDeletedDocs', function () {
     describe('with a successful response code', function () {
       let getAllDeletedDocsResponse
-      beforeEach(async function (ctx) {
-        ctx.docs = [{ _id: 'mock-doc-id', name: 'foo.tex' }]
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 200 }, ctx.docs)
+      let docs
+      beforeEach(async function () {
+        docs = [{ _id: 'mock-doc-id', name: 'foo.tex' }]
+        FetchUtils.fetchJson.mockResolvedValue(docs)
         getAllDeletedDocsResponse =
-          await ctx.DocstoreManager.promises.getAllDeletedDocs(ctx.project_id)
+          await DocstoreManager.promises.getAllDeletedDocs(projectId)
       })
 
-      it('should get all the project docs in the docstore api', function (ctx) {
-        ctx.request.get.should.have.been.calledWith({
-          url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc-deleted`,
-          timeout: 30 * 1000,
-          json: true,
-        })
+      it('should get all the project docs in the docstore api', function () {
+        expect(FetchUtils.fetchJson).toHaveBeenCalledWith(
+          new URL(
+            `${settings.apis.docstore.url}/project/${projectId}/doc-deleted`
+          ),
+          {
+            signal: expect.anything(),
+          }
+        )
       })
 
-      it('should resolve with the docs', function (ctx) {
-        expect(getAllDeletedDocsResponse).to.eql(ctx.docs)
+      it('should resolve with the docs', function () {
+        expect(getAllDeletedDocsResponse).to.eql(docs)
       })
     })
 
     describe('with an error', function () {
-      beforeEach(async function (ctx) {
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(1, new Error('connect failed'))
+      beforeEach(async function () {
+        FetchUtils.fetchJson.mockRejectedValue(new Error('connect failed'))
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
 
         try {
-          await ctx.DocstoreManager.promises.getAllDocs(ctx.project_id)
+          await DocstoreManager.promises.getAllDeletedDocs(projectId)
+          assert.fail('getAllDeletedDocs should have thrown an error')
         } catch (err) {
           error = err
         }
@@ -448,17 +445,18 @@ describe('DocstoreManager', function () {
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 500 })
+      beforeEach(function () {
+        FetchUtils.fetchJson.mockImplementation((url, opts) => {
+          throw new RequestFailedError(url, opts, { status: 500 })
+        })
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
 
         try {
-          await ctx.DocstoreManager.promises.getAllDocs(ctx.project_id)
+          await DocstoreManager.promises.getAllDeletedDocs(projectId)
+          assert.fail('getAllDeletedDocs should have thrown an error')
         } catch (err) {
           error = err
         }
@@ -475,47 +473,41 @@ describe('DocstoreManager', function () {
   describe('getAllRanges', function () {
     describe('with a successful response code', function () {
       let getAllRangesResult
-      beforeEach(async function (ctx) {
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(
-            1,
-            null,
-            { statusCode: 204 },
-            (ctx.docs = [{ _id: 'mock-doc-id', ranges: 'mock-ranges' }])
-          )
-        getAllRangesResult = await ctx.DocstoreManager.promises.getAllRanges(
-          ctx.project_id
+      let docs
+      beforeEach(async function () {
+        docs = [{ _id: 'mock-doc-id', ranges: 'mock-ranges' }]
+        FetchUtils.fetchJson.mockResolvedValue(docs)
+        getAllRangesResult =
+          await DocstoreManager.promises.getAllRanges(projectId)
+      })
+
+      it('should get all the project doc ranges in the docstore api', function () {
+        expect(FetchUtils.fetchJson).toHaveBeenCalledWith(
+          new URL(`${settings.apis.docstore.url}/project/${projectId}/ranges`),
+          {
+            signal: expect.anything(),
+          }
         )
       })
 
-      it('should get all the project doc ranges in the docstore api', function (ctx) {
-        ctx.request.get
-          .calledWith({
-            url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/ranges`,
-            timeout: 30 * 1000,
-            json: true,
-          })
-          .should.equal(true)
-      })
-
-      it('should return the docs', async function (ctx) {
-        expect(getAllRangesResult).to.eql(ctx.docs)
+      it('should return the docs', async function () {
+        expect(getAllRangesResult).to.eql(docs)
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.get = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 500 }, '')
+      beforeEach(function () {
+        FetchUtils.fetchJson.mockImplementation((url, opts) => {
+          throw new RequestFailedError(url, opts, { status: 500 })
+        })
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
 
         try {
-          await ctx.DocstoreManager.promises.getAllRanges(ctx.project_id)
+          await DocstoreManager.promises.getAllRanges(projectId)
+          assert.fail('getAllRanges should have thrown an error')
         } catch (err) {
           error = err
         }
@@ -531,31 +523,25 @@ describe('DocstoreManager', function () {
 
   describe('archiveProject', function () {
     describe('with a successful response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.post = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 204 })
-      })
-
-      it('should resolve', async function (ctx) {
-        await expect(
-          ctx.DocstoreManager.promises.archiveProject(ctx.project_id)
-        ).to.eventually.be.fulfilled
+      it('should resolve', async function () {
+        await expect(DocstoreManager.promises.archiveProject(projectId)).to
+          .eventually.be.fulfilled
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.post = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 500 })
+      beforeEach(function () {
+        FetchUtils.fetchNothing.mockImplementation((url, opts) => {
+          throw new RequestFailedError(url, opts, { status: 500 })
+        })
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
 
         try {
-          await ctx.DocstoreManager.promises.archiveProject(ctx.project_id)
+          await DocstoreManager.promises.archiveProject(projectId)
+          assert.fail('archiveProject should have thrown an error')
         } catch (err) {
           error = err
         }
@@ -571,31 +557,25 @@ describe('DocstoreManager', function () {
 
   describe('unarchiveProject', function () {
     describe('with a successful response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.post = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 204 })
-      })
-
-      it('should resolve', async function (ctx) {
-        await expect(
-          ctx.DocstoreManager.promises.unarchiveProject(ctx.project_id)
-        ).to.eventually.be.fulfilled
+      it('should resolve', async function () {
+        await expect(DocstoreManager.promises.unarchiveProject(projectId)).to
+          .eventually.be.fulfilled
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.post = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 500 })
+      beforeEach(function () {
+        FetchUtils.fetchNothing.mockImplementation((url, opts) => {
+          throw new RequestFailedError(url, opts, { status: 500 })
+        })
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
 
         try {
-          await ctx.DocstoreManager.promises.unarchiveProject(ctx.project_id)
+          await DocstoreManager.promises.unarchiveProject(projectId)
+          assert.fail('unarchiveProject should have thrown an error')
         } catch (err) {
           error = err
         }
@@ -611,31 +591,25 @@ describe('DocstoreManager', function () {
 
   describe('destroyProject', function () {
     describe('with a successful response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.post = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 204 })
-      })
-
-      it('should resolve', async function (ctx) {
-        await expect(
-          ctx.DocstoreManager.promises.destroyProject(ctx.project_id)
-        ).to.eventually.be.fulfilled
+      it('should resolve', async function () {
+        await expect(DocstoreManager.promises.destroyProject(projectId)).to
+          .eventually.be.fulfilled
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function (ctx) {
-        ctx.request.post = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 500 })
+      beforeEach(function () {
+        FetchUtils.fetchNothing.mockImplementation((url, opts) => {
+          throw new RequestFailedError(url, opts, { status: 500 })
+        })
       })
 
-      it('should reject with an error', async function (ctx) {
+      it('should reject with an error', async function () {
         let error
 
         try {
-          await ctx.DocstoreManager.promises.destroyProject(ctx.project_id)
+          await DocstoreManager.promises.destroyProject(projectId)
+          assert.fail('destroyProject should have thrown an error')
         } catch (err) {
           error = err
         }