[web] add endpoint for resolving a project for writes from tpds (#35408)

* [web] add endpoint for resolving a project for writes from tpds

* [web] return historyId from new endpoint for resolving projects

* [web] add zod validation to new endpoint for resolving projects

GitOrigin-RevId: 0e607e56442117a4150a08373169c44b53dd4fdf
This commit is contained in:
Jakob Ackermann
2026-09-17 11:26:03 +00:00
committed by Copybot
parent 528e587038
commit b68a013751
7 changed files with 564 additions and 3 deletions
@@ -4,6 +4,7 @@ import sinon from 'sinon'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import MockResponse from '../helpers/MockResponse.mjs'
import MockRequest from '../helpers/MockRequest.mjs'
import { asZodError } from '@overleaf/validation-tools/testUtils.js'
const ObjectId = mongodb.ObjectId
@@ -19,11 +20,16 @@ describe('TpdsController', function () {
entityType: 'doc',
rev: 2,
}
ctx.resolvedProject = {
_id: new ObjectId(),
overleaf: { history: { id: 42, otMigrationStage: 1 } },
}
ctx.TpdsUpdateHandler = {
promises: {
newUpdate: sinon.stub().resolves(ctx.metadata),
deleteUpdate: sinon.stub().resolves(ctx.metadata.entityId),
createFolder: sinon.stub().resolves(),
getOrCreateProject: sinon.stub().resolves(ctx.resolvedProject),
},
}
ctx.UpdateMerger = {
@@ -118,7 +124,7 @@ describe('TpdsController', function () {
ctx.TpdsController = (await import(MODULE_PATH)).default
ctx.user_id = 'dsad29jlkjas'
ctx.user_id = new ObjectId().toString()
})
describe('creating a project', function () {
@@ -151,6 +157,129 @@ describe('TpdsController', function () {
})
})
describe('resolving a project', function () {
beforeEach(function (ctx) {
ctx.req = {
params: { user_id: ctx.user_id },
body: {},
}
})
it('should throw without any input', async function (ctx) {
const next = sinon.stub()
await ctx.TpdsController.resolveProject(ctx.req, {}, next)
expect(next).to.have.been.calledWithMatch({
name: 'InvalidRequestError',
zodError: asZodError({
code: 'invalid_union',
errors: [
[
{
expected: 'string',
code: 'invalid_type',
path: ['projectId'],
message: 'Invalid input: expected string, received undefined',
},
],
[
{
expected: 'string',
code: 'invalid_type',
path: ['projectName'],
message: 'Invalid input: expected string, received undefined',
},
],
],
path: ['body'],
message: 'Invalid input',
}),
})
})
it('should resolve by name', async function (ctx) {
await new Promise(resolve => {
ctx.req.body = { projectName: 'projectName' }
const res = {
json: payload => {
expect(payload).to.deep.equal({
status: 'success',
projectId: ctx.resolvedProject._id.toString(),
historyId: 42,
otMigrationStage: 1,
})
ctx.TpdsUpdateHandler.promises.getOrCreateProject.should.have.been.calledWith(
ctx.user_id,
undefined,
'projectName'
)
resolve()
},
}
ctx.TpdsController.resolveProject(ctx.req, res)
})
})
it('should resolve by id', async function (ctx) {
await new Promise(resolve => {
const projectId = ctx.resolvedProject._id.toString()
ctx.req.body = { projectId }
const res = {
json: payload => {
expect(payload).to.deep.equal({
status: 'success',
projectId: ctx.resolvedProject._id.toString(),
historyId: 42,
otMigrationStage: 1,
})
ctx.TpdsUpdateHandler.promises.getOrCreateProject.should.have.been.calledWith(
ctx.user_id,
projectId,
undefined
)
resolve()
},
}
ctx.TpdsController.resolveProject(ctx.req, res)
})
})
it('should default the otMigrationStage to zero when unset', async function (ctx) {
await new Promise(resolve => {
ctx.TpdsUpdateHandler.promises.getOrCreateProject.resolves({
_id: ctx.resolvedProject._id,
overleaf: { history: { id: 1337 } },
})
ctx.req.body = { projectName: 'projectName' }
const res = {
json: payload => {
expect(payload).to.deep.equal({
status: 'success',
projectId: ctx.resolvedProject._id.toString(),
historyId: 1337,
otMigrationStage: 0,
})
resolve()
},
}
ctx.TpdsController.resolveProject(ctx.req, res)
})
})
it('should indicate in the response when the project could not be resolved', async function (ctx) {
await new Promise(resolve => {
ctx.TpdsUpdateHandler.promises.getOrCreateProject.resolves(null)
ctx.req.body = { projectName: 'projectName' }
const res = {
json: payload => {
expect(payload).to.deep.equal({ status: 'rejected' })
resolve()
},
}
ctx.TpdsController.resolveProject(ctx.req, res)
})
})
})
describe('getting an update', function () {
beforeEach(function (ctx) {
ctx.projectName = 'projectName'
@@ -424,6 +424,84 @@ describe('TpdsUpdateHandler', function () {
expectDropboxUnlinked()
})
})
describe('getting or creating a project', function () {
describe('byId', function () {
describe('with no matching project', function () {
beforeEach(function (ctx) {
ctx.projectId = new ObjectId().toString()
})
resolveProjectById()
expectProjectNotCreated()
expectNoProjectResolved()
})
describe('with one matching active project', function () {
beforeEach(function (ctx) {
ctx.projectId = ctx.projects.active1._id.toString()
})
resolveProjectById()
expectProjectNotCreated()
expectProjectResolved()
})
describe('with one matching archived project', function () {
beforeEach(function (ctx) {
ctx.projectId = ctx.projects.archived1._id.toString()
})
resolveProjectById()
expectProjectNotCreated()
expectNoProjectResolved()
})
describe('with one matching trashed project', function () {
beforeEach(function (ctx) {
ctx.projectId = ctx.projects.trashed._id.toString()
})
resolveProjectById()
expectProjectNotCreated()
expectNoProjectResolved()
})
})
describe('with no matching project', function () {
setupMatchingProjects([])
resolveProjectByName()
expectProjectCreated()
expectProjectResolved()
})
describe('with one matching active project', function () {
setupMatchingProjects(['active1'])
resolveProjectByName()
expectProjectNotCreated()
expectProjectResolved()
})
describe('with one matching archived project', function () {
setupMatchingProjects(['archived1'])
resolveProjectByName()
expectProjectNotCreated()
expectNoProjectResolved()
expectDropboxNotUnlinked()
})
describe('with two matching active projects', function () {
setupMatchingProjects(['active1', 'active2'])
resolveProjectByName()
expectProjectNotCreated()
expectNoProjectResolved()
expectDropboxUnlinked()
})
describe('with one matching active and one matching archived project', function () {
setupMatchingProjects(['active1', 'archived1'])
resolveProjectByName()
expectProjectNotCreated()
expectNoProjectResolved()
expectDropboxUnlinked()
})
})
})
/* Setup helpers */
@@ -512,6 +590,28 @@ function receiveFolderUpdate() {
})
}
function resolveProjectByName() {
beforeEach(async function (ctx) {
ctx.resolvedProject =
await ctx.TpdsUpdateHandler.promises.getOrCreateProject(
ctx.userId,
null, // projectId
ctx.projectName
)
})
}
function resolveProjectById() {
beforeEach(async function (ctx) {
ctx.resolvedProject =
await ctx.TpdsUpdateHandler.promises.getOrCreateProject(
ctx.userId,
ctx.projectId,
'' // projectName
)
})
}
/* Expectations */
function expectProjectCreated() {
@@ -619,6 +719,18 @@ function expectDeleteNotProcessed() {
})
}
function expectProjectResolved() {
it('resolves the project', function (ctx) {
expect(ctx.resolvedProject._id).to.equal(ctx.projects.active1._id)
})
}
function expectNoProjectResolved() {
it('does not resolve a project', function (ctx) {
expect(ctx.resolvedProject).to.not.exist
})
}
function expectProjectDeleted() {
it('deletes the project', function (ctx) {
expect(