From 610398d099e489c6dceb28a2d1e72dca9c919685 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 29 Jan 2026 09:46:42 +0000 Subject: [PATCH] Merge pull request #30323 from overleaf/ar/promisify-DocstoreManager [web] promisify DocstoreManager GitOrigin-RevId: 351b9868a1c29066b6c98d92e5b513e10f4f6764 --- .../src/Features/Docstore/DocstoreManager.mjs | 511 +++++++++------- .../src/Docstore/DocstoreManager.test.mjs | 574 +++++++++--------- 2 files changed, 551 insertions(+), 534 deletions(-) diff --git a/services/web/app/src/Features/Docstore/DocstoreManager.mjs b/services/web/app/src/Features/Docstore/DocstoreManager.mjs index d6b58b40ab..e8f58ad48f 100644 --- a/services/web/app/src/Features/Docstore/DocstoreManager.mjs +++ b/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} + */ +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) - } - } - ) -} - -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 } - ) +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 + } +} + +/** + * + * @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} + */ +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} + */ +async function archiveProject(projectId) { + await _operateOnProject(projectId, 'archive') +} +/** + * + * @param {string|ObjectId} projectId + * @return {Promise} + */ +async function unarchiveProject(projectId) { + await _operateOnProject(projectId, 'unarchive') +} +/** + * + * @param {string|ObjectId} projectId + * @return {Promise} + */ +async function destroyProject(projectId) { + await _operateOnProject(projectId, 'destroy') } -function unarchiveProject(projectId, callback) { - _operateOnProject(projectId, 'unarchive', callback) -} - -function destroyProject(projectId, callback) { - _operateOnProject(projectId, 'destroy', callback) -} - -function _operateOnProject(projectId, method, callback) { - const url = `${settings.apis.docstore.url}/project/${projectId}/${method}` +/** + * + * @param {string|ObjectId} projectId + * @param {string} method + * @return {Promise} + * @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, }, } diff --git a/services/web/test/unit/src/Docstore/DocstoreManager.test.mjs b/services/web/test/unit/src/Docstore/DocstoreManager.test.mjs index a20ac50c5a..2303617075 100644 --- a/services/web/test/unit/src/Docstore/DocstoreManager.test.mjs +++ b/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, + } - ctx.requestDefaults.calledWith({ jar: false }).should.equal(true) + vi.doMock('@overleaf/fetch-utils', () => FetchUtils) - ctx.project_id = 'project-id-123' - ctx.doc_id = 'doc-id-123' + DocstoreManager = (await import(modulePath)).default + + 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 }