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

[web] convert ExportsHandler to fetch utils

GitOrigin-RevId: 96e74a1254e456b4ac8a6d3a2bd6330a806c8693
This commit is contained in:
Andrew Rumble
2026-03-06 09:07:21 +00:00
committed by Copybot
parent f96d37b7e3
commit 87269f3084
4 changed files with 640 additions and 832 deletions
@@ -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'
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
}
}
try {
const exportData = await ExportsHandler.exportProject(exportParams)
logger.debug(
{
userId,
projectId,
brandVariationId,
exportV1Id: exportData.v1_id,
},
'exported project'
)
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
}
}
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 })
}
async function exportDownload(req, res, next) {
const { type, export_id: exportId } = req.params
SessionManager.getLoggedInUserId(req.session)
const exportFileUrl = await ExportsHandler.fetchDownload(exportId, type)
return res.redirect(exportFileUrl)
}
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,
}
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,
})
}
)
},
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 })
})
},
exportDownload(req, res, next) {
const { type, export_id: exportId } = req.params
SessionManager.getLoggedInUserId(req.session)
return ExportsHandler.fetchDownload(
exportId,
type,
function (err, exportFileUrl) {
if (err != null) {
return next(err)
}
return res.redirect(exportFileUrl)
}
)
},
exportDownload: expressify(exportDownload),
exportProject: expressify(exportProject),
exportStatus: expressify(exportStatus),
}
@@ -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)
},
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
)
},
historyVersion(cb) {
return ProjectHistoryHandler.ensureHistoryExistsForProject(
projectId,
function (error) {
if (error != null) {
return callback(error)
}
return ExportsHandler._requestVersion(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,
})
}
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,
})
return callback(err)
}
if (!rootDoc || !rootDoc.path) {
throw new OError('cannot export project without root doc', {
project_id: projectId,
})
}
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)
}
if (exportParams.first_name && exportParams.last_name) {
user.first_name = exportParams.first_name
user.last_name = exportParams.last_name
}
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,
},
},
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
}
},
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,
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,
})
}
throw OError.tag(err, 'error making request to v1 export', {
export: exportData,
})
}
},
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,
})
}
},
async fetchExport(exportId) {
const url = new URL(settings.apis.v1.url)
url.pathname = `/api/v1/overleaf/exports/${exportId}`
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,
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,
})
}
throw OError.tag(err, 'error making request to v1 export', {
export: exportId,
})
}
},
async fetchDownload(exportId, type) {
const url = new URL(settings.apis.v1.url)
url.pathname = `/api/v1/overleaf/exports/${exportId}/${type}_url`
try {
return await fetchString(url, {
basicAuth: {
user: settings.apis.v1.user,
password: settings.apis.v1.pass,
},
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)
}
}
)
},
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)
}
}
)
},
fetchDownload(exportId, type, callback) {
if (callback == null) {
callback = function () {}
}
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)
}
}
)
},
}
@@ -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',
},
})
})
})
@@ -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.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.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)
})
})
})