[chat] migrate to express with strict zod schema (#35742)

GitOrigin-RevId: edffacde0c069768def849f45364d519d9f76f1e
This commit is contained in:
Jakob Ackermann
2026-09-17 11:36:35 +00:00
committed by Copybot
parent 5ccbd1eb5c
commit 94cad14caf
20 changed files with 1207 additions and 955 deletions
+81 -1
View File
@@ -1,3 +1,5 @@
const assert = require('node:assert/strict')
function asZodError(...def) {
return {
name: 'ZodError',
@@ -5,4 +7,82 @@ function asZodError(...def) {
}
}
module.exports = { asZodError }
/**
* Parses (if needed) a validation-error response body and asserts that it
* matches the given status and names the given field.
*
* handleValidationError (see ./handleValidationError.js) responds with
* `{ error: fromError(err.zodError).toString(), statusCode }`, and
* fromError's message includes the offending field's dotted schema path
* (e.g. `at "params.projectId"`, or the failing key name for an
* unrecognized key), so asserting the field name appears in `body.error`
* pins the test to the intended failure rather than merely the status code.
*
* @param {string|{statusCode: number, error: string}} rawOrParsedBody - the
* response body, either as raw JSON text or already parsed into an object
* (some HTTP client wrappers pre-parse the body for the caller)
* @param {number} status
* @param {string} fieldSubstring
*/
function assertValidationErrorBody(rawOrParsedBody, status, fieldSubstring) {
assert.ok(rawOrParsedBody != null, 'expected a response body')
const body =
typeof rawOrParsedBody === 'string'
? JSON.parse(rawOrParsedBody)
: rawOrParsedBody
assert.equal(body.statusCode, status)
assert.equal(typeof body.error, 'string')
assert.ok(
body.error.includes(fieldSubstring),
`expected error message ${JSON.stringify(body.error)} to include ${JSON.stringify(fieldSubstring)}`
)
}
/**
* Asserts that a request was actually rejected by the zod validation layer
* because of the specific field named in `fieldSubstring` -- not just any
* error/response that happens to produce the same status code. See
* assertValidationErrorBody() above.
*
* @param {any} errorOrResult - either the object thrown/rejected by a failed
* request, e.g. a RequestFailedError from @overleaf/fetch-utils (exposes
* `.response.status` and `.body` as raw text), or a resolved
* `{ response, body }` pair from an HTTP client wrapper that never rejects
* on non-2xx responses and has already parsed `body` from JSON. Either way
* expected to expose `.response.status` and `.body`
* @param {number} status - the expected HTTP status code
* @param {string} fieldSubstring - a substring expected in the error
* message identifying the rejected field (e.g. a param/body/query key)
*/
function expectValidationError(errorOrResult, status, fieldSubstring) {
assert.equal(
errorOrResult && errorOrResult.response && errorOrResult.response.status,
status,
`expected response.status === ${status}`
)
assertValidationErrorBody(errorOrResult.body, status, fieldSubstring)
}
/**
* Same as expectValidationError, but for a response object that exposes
* `statusCode` and `body` directly rather than nested under `.response`
* (e.g. a raw node:http response).
*
* @param {any} res - expected to expose `.statusCode` and `.body` directly
* @param {number} status
* @param {string} fieldSubstring
*/
function expectValidationErrorRaw(res, status, fieldSubstring) {
assert.equal(
res && res.statusCode,
status,
`expected a response with statusCode === ${status}`
)
assertValidationErrorBody(res.body, status, fieldSubstring)
}
module.exports = {
asZodError,
expectValidationError,
expectValidationErrorRaw,
}
+132 -16
View File
@@ -1,26 +1,142 @@
// Metrics must be initialized before importing anything else
import '@overleaf/metrics/initialize.js'
import metrics from '@overleaf/metrics'
import logger from '@overleaf/logger'
import settings from '@overleaf/settings'
import express from 'express'
import { handleValidationError } from '@overleaf/validation-tools'
import { mongoClient } from './app/js/mongodb.js'
import { createServer } from './app/js/server.js'
import * as MessageHttpController from './app/js/Features/Messages/MessageHttpController.js'
import { expressify } from './app/js/util/promises.js'
logger.initialize('chat')
metrics.open_sockets.monitor()
metrics.leaked_sockets.monitor(logger)
const app = express()
app.use(metrics.http.monitor(logger))
metrics.injectMetricsRoute(app)
app.use(express.json())
app.get(
'/project/:projectId/messages',
expressify(MessageHttpController.getGlobalMessages)
)
app.post(
'/project/:projectId/messages',
expressify(MessageHttpController.sendGlobalMessage)
)
app.get(
'/project/:projectId/messages/:messageId',
expressify(MessageHttpController.getGlobalMessage)
)
app.delete(
'/project/:projectId/messages/:messageId',
expressify(MessageHttpController.deleteGlobalMessage)
)
app.post(
'/project/:projectId/messages/:messageId/edit',
expressify(MessageHttpController.editGlobalMessage)
)
app.post(
'/project/:projectId/thread/:threadId/messages',
expressify(MessageHttpController.sendMessage)
)
app.get(
'/project/:projectId/thread/:threadId/messages/:messageId',
expressify(MessageHttpController.getThreadMessage)
)
app.delete(
'/project/:projectId/thread/:threadId/messages/:messageId',
expressify(MessageHttpController.deleteMessage)
)
app.post(
'/project/:projectId/thread/:threadId/messages/:messageId/edit',
expressify(MessageHttpController.editMessage)
)
app.delete(
'/project/:projectId/thread/:threadId/user/:userId/messages/:messageId',
expressify(MessageHttpController.deleteUserMessage)
)
app.post(
'/project/:projectId/thread/:threadId/resolve',
expressify(MessageHttpController.resolveThread)
)
app.post(
'/project/:projectId/thread/:threadId/reopen',
expressify(MessageHttpController.reopenThread)
)
app.get(
'/project/:projectId/thread/:threadId',
expressify(MessageHttpController.getThread)
)
app.delete(
'/project/:projectId/thread/:threadId',
expressify(MessageHttpController.deleteThread)
)
app.get(
'/project/:projectId/threads',
expressify(MessageHttpController.getThreads)
)
app.get(
'/project/:projectId/resolved-thread-ids',
expressify(MessageHttpController.getResolvedThreadIds)
)
app.delete(
'/project/:projectId',
expressify(MessageHttpController.destroyProject)
)
app.post(
'/project/:projectId/duplicate-comment-threads',
expressify(MessageHttpController.duplicateCommentThreads)
)
app.post(
'/project/:projectId/generate-thread-data',
expressify(MessageHttpController.generateThreadData)
)
app.post(
'/project/:projectId/clone-comment-threads',
expressify(MessageHttpController.cloneCommentThreads)
)
// express also serves HEAD /status via this GET route
app.get('/status', expressify(MessageHttpController.getStatus))
// Return a 404 for unknown routes
app.use((req, res) => {
res.status(404).json({ message: `Not found` })
})
app.use(handleValidationError)
// Handle any unexpected errors
app.use((err, req, res, next) => {
logger.error({ err, req }, 'request errored')
res.status(500).json({ message: `Internal error: ${err.message}` })
})
const port = settings.internal.chat.port
const host = settings.internal.chat.host
mongoClient
.connect()
.then(async () => {
const { server } = await createServer()
server.listen(port, host, function (err) {
if (err) {
logger.fatal({ err }, `Cannot bind to ${host}:${port}. Exiting.`)
process.exit(1)
}
logger.debug(`Chat starting up, listening on ${host}:${port}`)
if (import.meta.main) {
// Called directly
mongoClient
.connect()
.then(() => {
app.listen(port, host, function (err) {
if (err) {
logger.fatal({ err }, `Cannot bind to ${host}:${port}. Exiting.`)
process.exit(1)
}
logger.debug(`Chat starting up, listening on ${host}:${port}`)
})
})
})
.catch(err => {
logger.fatal({ err }, 'Cannot connect to mongo. Exiting.')
process.exit(1)
})
.catch(err => {
logger.fatal({ err }, 'Cannot connect to mongo. Exiting.')
process.exit(1)
})
}
export default app
@@ -1,136 +1,28 @@
import logger from '@overleaf/logger'
import { parseReq } from '@overleaf/validation-tools'
import { promiseMapWithLimit } from '@overleaf/promise-utils'
import * as MessageManager from './MessageManager.js'
import * as MessageFormatter from './MessageFormatter.js'
import * as ThreadManager from '../Threads/ThreadManager.js'
import { ObjectId } from '../../mongodb.js'
import { promiseMapWithLimit } from '@overleaf/promise-utils'
import * as Schemas from './MessageHttpSchemas.js'
const DEFAULT_MESSAGE_LIMIT = 50
const MAX_MESSAGE_LENGTH = 10 * 1024 // 10kb, about 1,500 words
function readContext(context, req) {
req.body = context.requestBody
req.params = context.params.path
req.query = context.params.query
if (typeof req.params.projectId !== 'undefined') {
if (!ObjectId.isValid(req.params.projectId)) {
context.res.status(400).setBody('Invalid projectId')
}
}
if (typeof req.params.threadId !== 'undefined') {
if (!ObjectId.isValid(req.params.threadId)) {
context.res.status(400).setBody('Invalid threadId')
}
}
}
/**
* @param context
* @param {(req: unknown, res: unknown) => Promise<unknown>} ControllerMethod
* @returns {Promise<*>}
*/
export async function callMessageHttpController(context, ControllerMethod) {
const req = {}
readContext(context, req)
if (context.res.statusCode !== 400) {
return await ControllerMethod(req, context.res)
} else {
return context.res.body
}
}
export async function getGlobalMessages(context) {
return await callMessageHttpController(context, _getGlobalMessages)
}
export async function getGlobalMessage(context) {
return await callMessageHttpController(context, _getGlobalMessage)
}
export async function sendGlobalMessage(context) {
return await callMessageHttpController(context, _sendGlobalMessage)
}
export async function sendMessage(context) {
return await callMessageHttpController(context, _sendThreadMessage)
}
export async function getThreads(context) {
return await callMessageHttpController(context, _getAllThreads)
}
export async function getThread(context) {
return await callMessageHttpController(context, _getThread)
}
export async function getThreadMessage(context) {
return await callMessageHttpController(context, _getThreadMessage)
}
export async function resolveThread(context) {
return await callMessageHttpController(context, _resolveThread)
}
export async function reopenThread(context) {
return await callMessageHttpController(context, _reopenThread)
}
export async function deleteThread(context) {
return await callMessageHttpController(context, _deleteThread)
}
export async function editMessage(context) {
return await callMessageHttpController(context, _editMessage)
}
export async function editGlobalMessage(context) {
return await callMessageHttpController(context, _editGlobalMessage)
}
export async function deleteMessage(context) {
return await callMessageHttpController(context, _deleteMessage)
}
export async function deleteUserMessage(context) {
return await callMessageHttpController(context, _deleteUserMessage)
}
export async function deleteGlobalMessage(context) {
return await callMessageHttpController(context, _deleteGlobalMessage)
}
export async function getResolvedThreadIds(context) {
return await callMessageHttpController(context, _getResolvedThreadIds)
}
export async function destroyProject(context) {
return await callMessageHttpController(context, _destroyProject)
}
export async function duplicateCommentThreads(context) {
return await callMessageHttpController(context, _duplicateCommentThreads)
}
export async function generateThreadData(context) {
return await callMessageHttpController(context, _generateThreadData)
}
export async function cloneCommentThreads(context) {
return await callMessageHttpController(context, _cloneCommentThreads)
}
export async function getStatus(context) {
export async function getStatus(req, res) {
const message = 'chat is alive'
context.res.status(200).setBody(message)
return message
res.send(message)
}
const _getGlobalMessages = async (req, res) => {
await _getMessages(ThreadManager.GLOBAL_THREAD, req, res)
export async function getGlobalMessages(req, res) {
const {
params: { projectId },
query: { before, limit },
} = parseReq(req, Schemas.getGlobalMessages)
await _getMessages(projectId, ThreadManager.GLOBAL_THREAD, before, limit, res)
}
const _getGlobalMessage = async (req, res) => {
const { projectId, messageId } = req.params
export async function getGlobalMessage(req, res) {
const {
params: { projectId, messageId },
} = parseReq(req, Schemas.getGlobalMessage)
logger.debug({ projectId, messageId }, 'getting single global message')
try {
const room = await ThreadManager.findThread(
@@ -141,23 +33,25 @@ const _getGlobalMessage = async (req, res) => {
const message = await MessageManager.getMessage(room._id, messageId)
const formattedMsg = MessageFormatter.formatMessageForClientSide(message)
res.status(200).setBody(formattedMsg)
res.json(formattedMsg)
} catch (error) {
if (
error instanceof ThreadManager.MissingThreadError ||
error instanceof MessageManager.MissingMessageError
) {
res.status(404)
res.sendStatus(404)
return
}
throw error
}
}
async function _sendGlobalMessage(req, res) {
const { user_id: userId, content } = req.body
const { projectId } = req.params
return await _sendMessage(
export async function sendGlobalMessage(req, res) {
const {
params: { projectId },
body: { user_id: userId, content },
} = parseReq(req, Schemas.sendGlobalMessage)
await _sendMessage(
userId,
projectId,
content,
@@ -166,14 +60,18 @@ async function _sendGlobalMessage(req, res) {
)
}
async function _sendThreadMessage(req, res) {
const { user_id: userId, content } = req.body
const { projectId, threadId } = req.params
return await _sendMessage(userId, projectId, content, threadId, res)
export async function sendMessage(req, res) {
const {
params: { projectId, threadId },
body: { user_id: userId, content },
} = parseReq(req, Schemas.sendMessage)
await _sendMessage(userId, projectId, content, threadId, res)
}
const _getAllThreads = async (req, res) => {
const { projectId } = req.params
export async function getThreads(req, res) {
const {
params: { projectId },
} = parseReq(req, Schemas.getThreads)
logger.debug({ projectId }, 'getting all threads')
const rooms = await ThreadManager.findAllThreadRooms(projectId)
const roomIds = rooms.map(r => r._id)
@@ -182,9 +80,11 @@ const _getAllThreads = async (req, res) => {
res.json(threads)
}
const _generateThreadData = async (req, res) => {
const { projectId } = req.params
const { threads } = req.body
export async function generateThreadData(req, res) {
const {
params: { projectId },
body: { threads },
} = parseReq(req, Schemas.generateThreadData)
logger.debug({ projectId }, 'getting all threads')
const rooms = await ThreadManager.findThreadsById(projectId, threads)
const roomIds = rooms.map(r => r._id)
@@ -194,8 +94,10 @@ const _generateThreadData = async (req, res) => {
res.json(threadData)
}
const _getThread = async (req, res) => {
const { projectId, threadId } = req.params
export async function getThread(req, res) {
const {
params: { projectId, threadId },
} = parseReq(req, Schemas.getThread)
logger.debug({ projectId, threadId }, 'getting specific thread')
try {
const room = await ThreadManager.findThread(projectId, threadId)
@@ -204,21 +106,23 @@ const _getThread = async (req, res) => {
const thread = threads[threadId] || null
if (!thread) {
res.status(404)
res.sendStatus(404)
return
}
res.json(thread)
} catch (error) {
if (error instanceof ThreadManager.MissingThreadError) {
res.status(404)
res.sendStatus(404)
return
}
throw error
}
}
const _getThreadMessage = async (req, res) => {
const { projectId, threadId, messageId } = req.params
export async function getThreadMessage(req, res) {
const {
params: { projectId, threadId, messageId },
} = parseReq(req, Schemas.getThreadMessage)
logger.debug(
{ projectId, threadId, messageId },
'getting single thread message'
@@ -228,45 +132,53 @@ const _getThreadMessage = async (req, res) => {
const message = await MessageManager.getMessage(room._id, messageId)
const formattedMsg = MessageFormatter.formatMessageForClientSide(message)
res.status(200).setBody(formattedMsg)
res.json(formattedMsg)
} catch (error) {
if (
error instanceof ThreadManager.MissingThreadError ||
error instanceof MessageManager.MissingMessageError
) {
res.status(404)
res.sendStatus(404)
return
}
throw error
}
}
const _resolveThread = async (req, res) => {
const { projectId, threadId } = req.params
const { user_id: userId } = req.body
export async function resolveThread(req, res) {
const {
params: { projectId, threadId },
body: { user_id: userId },
} = parseReq(req, Schemas.resolveThread)
logger.debug({ userId, projectId, threadId }, 'marking thread as resolved')
await ThreadManager.resolveThread(projectId, threadId, userId)
res.status(204)
res.sendStatus(204)
}
const _reopenThread = async (req, res) => {
const { projectId, threadId } = req.params
export async function reopenThread(req, res) {
const {
params: { projectId, threadId },
} = parseReq(req, Schemas.reopenThread)
logger.debug({ projectId, threadId }, 'reopening thread')
await ThreadManager.reopenThread(projectId, threadId)
res.status(204)
res.sendStatus(204)
}
const _deleteThread = async (req, res) => {
const { projectId, threadId } = req.params
export async function deleteThread(req, res) {
const {
params: { projectId, threadId },
} = parseReq(req, Schemas.deleteThread)
logger.debug({ projectId, threadId }, 'deleting thread')
const roomId = await ThreadManager.deleteThread(projectId, threadId)
await MessageManager.deleteAllMessagesInRoom(roomId)
res.status(204)
res.sendStatus(204)
}
const _editMessage = async (req, res) => {
const { content, userId } = req.body
const { projectId, threadId, messageId } = req.params
export async function editMessage(req, res) {
const {
params: { projectId, threadId, messageId },
body: { content, userId },
} = parseReq(req, Schemas.editMessage)
logger.debug({ projectId, threadId, messageId, content }, 'editing message')
const room = await ThreadManager.findOrCreateThread(projectId, threadId)
const found = await MessageManager.updateMessage(
@@ -277,15 +189,17 @@ const _editMessage = async (req, res) => {
Date.now()
)
if (!found) {
res.status(404)
res.sendStatus(404)
return
}
res.status(204)
res.sendStatus(204)
}
const _editGlobalMessage = async (req, res) => {
const { content, userId } = req.body
const { projectId, messageId } = req.params
export async function editGlobalMessage(req, res) {
const {
params: { projectId, messageId },
body: { content, userId },
} = parseReq(req, Schemas.editGlobalMessage)
logger.debug({ projectId, messageId, content }, 'editing global message')
const room = await ThreadManager.findOrCreateThread(
projectId,
@@ -299,45 +213,55 @@ const _editGlobalMessage = async (req, res) => {
Date.now()
)
if (!found) {
res.status(404)
res.sendStatus(404)
return
}
res.status(204)
res.sendStatus(204)
}
const _deleteMessage = async (req, res) => {
const { projectId, threadId, messageId } = req.params
export async function deleteMessage(req, res) {
const {
params: { projectId, threadId, messageId },
} = parseReq(req, Schemas.deleteMessage)
logger.debug({ projectId, threadId, messageId }, 'deleting message')
const room = await ThreadManager.findOrCreateThread(projectId, threadId)
await MessageManager.deleteMessage(room._id, messageId)
res.status(204)
res.sendStatus(204)
}
const _deleteUserMessage = async (req, res) => {
const { projectId, threadId, userId, messageId } = req.params
export async function deleteUserMessage(req, res) {
const {
params: { projectId, threadId, userId, messageId },
} = parseReq(req, Schemas.deleteUserMessage)
const room = await ThreadManager.findOrCreateThread(projectId, threadId)
await MessageManager.deleteUserMessage(userId, room._id, messageId)
res.status(204)
res.sendStatus(204)
}
const _deleteGlobalMessage = async (req, res) => {
const { projectId, messageId } = req.params
export async function deleteGlobalMessage(req, res) {
const {
params: { projectId, messageId },
} = parseReq(req, Schemas.deleteGlobalMessage)
const room = await ThreadManager.findOrCreateThread(
projectId,
ThreadManager.GLOBAL_THREAD
)
await MessageManager.deleteMessage(room._id, messageId)
res.status(204)
res.sendStatus(204)
}
const _getResolvedThreadIds = async (req, res) => {
const { projectId } = req.params
export async function getResolvedThreadIds(req, res) {
const {
params: { projectId },
} = parseReq(req, Schemas.getResolvedThreadIds)
const resolvedThreadIds = await ThreadManager.getResolvedThreadIds(projectId)
res.json({ resolvedThreadIds })
}
const _destroyProject = async (req, res) => {
const { projectId } = req.params
export async function destroyProject(req, res) {
const {
params: { projectId },
} = parseReq(req, Schemas.destroyProject)
logger.debug({ projectId }, 'destroying project')
const rooms = await ThreadManager.findAllThreadRoomsAndGlobalThread(projectId)
const roomIds = rooms.map(r => r._id)
@@ -345,25 +269,10 @@ const _destroyProject = async (req, res) => {
await MessageManager.deleteAllMessagesInRooms(roomIds)
logger.debug({ projectId }, 'deleting all threads in project')
await ThreadManager.deleteAllThreadsInProject(projectId)
res.status(204)
res.sendStatus(204)
}
async function _sendMessage(userId, projectId, content, clientThreadId, res) {
if (!ObjectId.isValid(userId)) {
const message = 'Invalid userId'
res.status(400).setBody(message)
return message
}
if (!content) {
const message = 'No content provided'
res.status(400).setBody(message)
return message
}
if (content.length > MAX_MESSAGE_LENGTH) {
const message = `Content too long (> ${MAX_MESSAGE_LENGTH} bytes)`
res.status(400).setBody(message)
return message
}
logger.debug(
{ clientThreadId, projectId, userId, content },
'new message received'
@@ -381,22 +290,10 @@ async function _sendMessage(userId, projectId, content, clientThreadId, res) {
message = MessageFormatter.formatMessageForClientSide(message)
message.room_id = projectId
res.status(201).setBody(message)
res.status(201).json(message)
}
async function _getMessages(clientThreadId, req, res) {
let before, limit
const { projectId } = req.params
if (req.query.before) {
before = parseInt(req.query.before, 10)
} else {
before = null
}
if (req.query.limit) {
limit = parseInt(req.query.limit, 10)
} else {
limit = DEFAULT_MESSAGE_LIMIT
}
async function _getMessages(projectId, clientThreadId, before, limit, res) {
logger.debug(
{ limit, before, projectId, clientThreadId },
'get message request received'
@@ -413,12 +310,14 @@ async function _getMessages(clientThreadId, req, res) {
let messages = await MessageManager.getMessages(threadObjectId, limit, before)
messages = MessageFormatter.formatMessagesForClientSide(messages)
logger.debug({ projectId, messages }, 'got messages')
res.status(200).setBody(messages)
res.json(messages)
}
async function _duplicateCommentThreads(req, res) {
const { projectId } = req.params
const { threads } = req.body
export async function duplicateCommentThreads(req, res) {
const {
params: { projectId },
body: { threads },
} = parseReq(req, Schemas.duplicateCommentThreads)
const result = {}
for (const id of threads) {
logger.debug({ projectId, thread: id }, 'duplicating thread')
@@ -442,9 +341,11 @@ async function _duplicateCommentThreads(req, res) {
res.json({ newThreads: result })
}
async function _cloneCommentThreads(req, res) {
const { projectId: sourceProjectId } = req.params
const { targetProjectId } = req.body
export async function cloneCommentThreads(req, res) {
const {
params: { projectId: sourceProjectId },
body: { targetProjectId },
} = parseReq(req, Schemas.cloneCommentThreads)
const rooms = await ThreadManager.cloneThreads(
sourceProjectId,
targetProjectId
@@ -452,5 +353,5 @@ async function _cloneCommentThreads(req, res) {
await promiseMapWithLimit(10, rooms, async ({ from, to }) => {
await MessageManager.duplicateRoomToOtherRoom(from, to)
})
res.status(204)
res.sendStatus(204)
}
@@ -0,0 +1,98 @@
import { z, zz } from '@overleaf/validation-tools'
export const MAX_MESSAGE_LENGTH = 10 * 1024 // 10kb, about 1,500 words
const DEFAULT_MESSAGE_LIMIT = 50
const projectParams = z.object({ projectId: zz.objectId() })
const threadParams = projectParams.extend({ threadId: zz.objectId() })
const messageParams = projectParams.extend({ messageId: zz.objectId() })
const threadMessageParams = threadParams.extend({ messageId: zz.objectId() })
const userMessageParams = threadMessageParams.extend({ userId: zz.objectId() })
const messageContent = z
.string({ error: 'No content provided' })
.min(1, 'No content provided')
.max(MAX_MESSAGE_LENGTH, `Content too long (> ${MAX_MESSAGE_LENGTH} bytes)`)
const sendMessageBody = z.strictObject({
user_id: zz.objectId(),
content: messageContent,
})
// web sends `userId` (camelCase) when editing, unlike `user_id` when sending.
const editMessageBody = z.strictObject({
content: messageContent,
userId: zz.objectId().optional(),
})
const threadsBody = z.strictObject({ threads: z.array(zz.objectId()) })
export const getGlobalMessages = z.object({
params: projectParams,
query: z.strictObject({
before: z.coerce.number().int().optional(),
limit: z.coerce.number().int().default(DEFAULT_MESSAGE_LIMIT),
}),
})
export const sendGlobalMessage = z.object({
params: projectParams,
body: sendMessageBody,
})
export const getGlobalMessage = z.object({ params: messageParams })
export const deleteGlobalMessage = z.object({ params: messageParams })
export const editGlobalMessage = z.object({
params: messageParams,
body: editMessageBody,
})
export const sendMessage = z.object({
params: threadParams,
body: sendMessageBody,
})
export const getThreadMessage = z.object({ params: threadMessageParams })
export const deleteMessage = z.object({ params: threadMessageParams })
export const editMessage = z.object({
params: threadMessageParams,
body: editMessageBody,
})
export const deleteUserMessage = z.object({ params: userMessageParams })
export const resolveThread = z.object({
params: threadParams,
body: z.strictObject({ user_id: zz.objectId() }),
})
export const reopenThread = z.object({ params: threadParams })
export const getThread = z.object({ params: threadParams })
export const deleteThread = z.object({ params: threadParams })
export const getThreads = z.object({ params: projectParams })
export const getResolvedThreadIds = z.object({ params: projectParams })
export const destroyProject = z.object({ params: projectParams })
export const duplicateCommentThreads = z.object({
params: projectParams,
body: threadsBody,
})
export const generateThreadData = z.object({
params: projectParams,
body: threadsBody,
})
export const cloneCommentThreads = z.object({
params: projectParams,
body: z.strictObject({ targetProjectId: zz.objectId() }),
})
-51
View File
@@ -1,51 +0,0 @@
import http from 'node:http'
import metrics from '@overleaf/metrics'
import logger from '@overleaf/logger'
import express from 'express'
import exegesisExpress from 'exegesis-express'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import * as messagesController from './Features/Messages/MessageHttpController.js'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
logger.initialize('chat')
metrics.open_sockets.monitor()
metrics.leaked_sockets.monitor(logger)
export async function createServer() {
const app = express()
app.use(metrics.http.monitor(logger))
metrics.injectMetricsRoute(app)
// See https://github.com/exegesis-js/exegesis/blob/master/docs/Options.md
const options = {
controllers: { messagesController },
ignoreServers: true,
allowMissingControllers: false,
}
// const exegesisMiddleware = await exegesisExpress.middleware(
const exegesisMiddleware = await exegesisExpress.middleware(
path.resolve(__dirname, '../../chat.yaml'),
options
)
// If you have any body parsers, this should go before them.
app.use(exegesisMiddleware)
// Return a 404
app.use((req, res) => {
res.status(404).json({ message: `Not found` })
})
// Handle any unexpected errors
app.use((err, req, res, next) => {
res.status(500).json({ message: `Internal error: ${err.message}` })
})
const server = http.createServer(app)
return { app, server }
}
-539
View File
@@ -1,539 +0,0 @@
openapi: 3.1.0
x-stoplight:
id: okoe8mh50pjec
info:
title: chat
version: "1.0"
servers:
- url: "http://chat:3010"
x-exegesis-controller: messagesController
paths:
"/project/{projectId}/messages":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
get:
summary: Get Global messages
tags: []
responses:
"201":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Message"
operationId: getGlobalMessages
description: Get global messages for the project with Project ID provided
parameters:
- schema:
type: string
in: query
name: before
- schema:
type: string
in: query
name: limit
post:
summary: Send Global message
operationId: sendGlobalMessage
responses:
"201":
description: OK
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
examples:
example-1:
value:
user_id: string
content: string
description: "UserID and Content of the message to be posted. "
description: Send global message for the project with Project ID provided
"/project/{projectId}/messages/{messageId}":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
- schema:
type: string
name: messageId
in: path
required: true
get:
summary: Get single global message
tags: []
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
"404":
description: Message not found
operationId: getGlobalMessage
description: Get a single global message by message ID for the project with Project ID provided
delete:
summary: Delete global message
operationId: deleteGlobalMessage
responses:
"204":
description: No Content
description: "Delete global message"
"/project/{projectId}/thread/{threadId}/messages":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
- schema:
type: string
name: threadId
in: path
required: true
post:
summary: Send message
operationId: sendMessage
responses:
"201":
description: Created
description: Add a message to the thread with thread ID provided from the Project with Project ID provided.
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
description: |-
JSON object with :
- user_id: Id of the user
- content: Content of the message
"/project/{projectId}/threads":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
get:
summary: Get Threads
tags: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Thread"
examples: {}
"404":
description: Not Found
operationId: getThreads
description: Get the list of threads for the project with Project ID provided
"/project/{projectId}/thread/{threadId}/messages/{messageId}/edit":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
- schema:
type: string
name: threadId
in: path
required: true
- schema:
type: string
name: messageId
in: path
required: true
post:
summary: Edit message
operationId: editMessage
responses:
"204":
description: No Content
"404":
description: Not Found
requestBody:
content:
application/json:
schema:
type: object
properties:
content:
type: string
user_id:
type: string
readOnly: true
required:
- content
examples: {}
description: |-
JSON object with :
- content: Content of the message to edit
- user_id: Id of the user (optional)
description: |
Update message with Message ID provided from the Thread ID and Project ID provided
"/project/{projectId}/messages/{messageId}/edit":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
- schema:
type: string
name: messageId
in: path
required: true
post:
summary: Edit global message
operationId: editGlobalMessage
responses:
"204":
description: No Content
"404":
description: Not Found
requestBody:
content:
application/json:
schema:
type: object
properties:
content:
type: string
user_id:
type: string
readOnly: true
required:
- content
examples: {}
description: |-
JSON object with :
- content: Content of the message to edit
- user_id: Id of the user (optional)
description: |
Update global message with Message ID provided from the Project ID provided
"/project/{projectId}/thread/{threadId}/messages/{messageId}":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
- schema:
type: string
name: threadId
in: path
required: true
- schema:
type: string
name: messageId
in: path
required: true
get:
summary: Get thread message
tags: []
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
"404":
description: Message not found
operationId: getThreadMessage
description: Get a specific message by message ID from the thread with Thread ID and Project ID provided
delete:
summary: Delete message
operationId: deleteMessage
responses:
"204":
description: No Content
description: "Delete message with Message ID provided, from the Thread with ThreadID and ProjectID provided"
"/project/{projectId}/thread/{threadId}/user/{userId}/messages/{messageId}":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
- schema:
type: string
name: threadId
in: path
required: true
- schema:
type: string
name: userId
in: path
required: true
- schema:
type: string
name: messageId
in: path
required: true
delete:
summary: Delete message written by a given user
operationId: deleteUserMessage
responses:
"204":
description: No Content
"/project/{projectId}/thread/{threadId}/resolve":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
- schema:
type: string
name: threadId
in: path
required: true
post:
summary: Resolve Thread
operationId: resolveThread
responses:
"204":
description: No Content
requestBody:
content:
application/json:
schema:
type: object
properties:
user_id:
type: string
required:
- user_id
description: |-
JSON object with :
- user_id: Id of the user.
description: Mark Thread with ThreadID and ProjectID provided owned by the user with UserID provided as resolved.
"/project/{projectId}/thread/{threadId}/reopen":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
- schema:
type: string
name: threadId
in: path
required: true
post:
summary: Reopen Thread
operationId: reopenThread
responses:
"204":
description: No Content
description: |-
Reopen Thread with ThreadID and ProjectID provided.
i.e unmark it as resolved.
"/project/{projectId}/thread/{threadId}":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
- schema:
type: string
name: threadId
in: path
required: true
get:
summary: Get Thread
tags: []
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/Thread"
"404":
description: Thread not found
operationId: getThread
description: Get a thread with ThreadID and ProjectID provided
delete:
summary: Delete thread
operationId: deleteThread
responses:
"204":
description: No Content
description: Delete thread with ThreadID and ProjectID provided
"/project/{projectId}/resolved-thread-ids":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
get:
summary: Get resolved thread ids
operationId: getResolvedThreadIds
responses:
"200":
description: Resolved thread ids
"/project/{projectId}":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
delete:
summary: Destroy project
operationId: destroyProject
responses:
"204":
description: No Content
description: "Delete all threads from Project with Project ID provided, and all messages in those threads."
/status:
get:
summary: Check status
tags: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: string
description: chat is alive
operationId: getStatus
description: Check that the Chat service is alive
head:
summary: Check status
tags: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: string
description: chat is alive
operationId: getStatus
description: Check that the Chat service is alive
"/project/{projectId}/duplicate-comment-threads":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
post:
summary: Duplicate comment threads
operationId: duplicateCommentThreads
requestBody:
content:
application/json:
schema:
type: object
properties:
threads:
type: array
items:
type: string
responses:
"200":
content:
application/json:
schema:
type: object
properties:
newThreads:
type: object
description: Mapping of old thread ids to their duplicated thread ids
description: Duplicate a list of comment threads
"/project/{projectId}/generate-thread-data":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
post:
summary: Generate thread data to load into the frontend
operationId: generateThreadData
requestBody:
content:
application/json:
schema:
type: object
properties:
threads:
type: array
items:
type: string
responses:
"200":
content:
application/json:
schema:
type: object
description: Load threads and generate a json blob containing all messages in all the threads
"/project/{projectId}/clone-comment-threads":
parameters:
- schema:
type: string
name: projectId
in: path
required: true
post:
summary: Clone a projects comment threads
operationId: cloneCommentThreads
requestBody:
content:
application/json:
schema:
type: object
properties:
targetProjectId:
type: string
responses:
"204":
description: OK
description: Clone a projects comment threads
components:
schemas:
Message:
title: Message
x-stoplight:
id: ue9n1vvezlutw
type: object
examples:
- user_id: string
- content: string
properties:
user_id:
type: string
content:
type: string
required:
- user_id
- content
Thread:
title: Thread
x-stoplight:
id: 0ppt3jw4h5bua
type: array
items:
$ref: "#/components/schemas/Message"
+1 -2
View File
@@ -21,8 +21,8 @@
"@overleaf/mongo-utils": "*",
"@overleaf/promise-utils": "*",
"@overleaf/settings": "*",
"@overleaf/validation-tools": "workspace:*",
"async": "^3.2.5",
"exegesis-express": "4.0.0",
"express": "4.22.1",
"mongodb": "6.12.0"
},
@@ -30,7 +30,6 @@
"@overleaf/migrations": "workspace:*",
"@types/node": "^24.5.2",
"acorn": "^7.1.1",
"ajv": "^6.12.0",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"eslint": "^10.4.0",
@@ -1,5 +1,6 @@
import { ObjectId } from '../../../app/js/mongodb.js'
import { expect } from 'chai'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import * as ChatClient from './helpers/ChatClient.js'
import * as ChatApp from './helpers/ChatApp.js'
@@ -90,4 +91,102 @@ describe('Cloning comment threads', async function () {
expect(threads[this.newThreadId].messages.length).to.equal(3)
})
})
describe('with an orphaned (non-existent) thread', async function () {
it('should report an error for that thread without failing the request', async function () {
const missingThreadId = new ObjectId().toString()
const { response, body: result } =
await ChatClient.duplicateCommentThreads(projectId, [missingThreadId])
expect(response.statusCode).to.equal(200)
expect(result.newThreads[missingThreadId]).to.deep.equal({
error: 'not found',
})
})
})
describe('with a malformed thread id in the body', async function () {
it('should return a graceful error', async function () {
const { response } = await ChatClient.duplicateCommentThreads(projectId, [
'malformed-thread-id',
])
expectValidationErrorRaw(response, 400, 'threads')
})
})
describe('with a malformed projectId', async function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.duplicateCommentThreads(
'malformed-project',
[this.thread1Id]
)
expectValidationErrorRaw(response, 404, 'projectId')
})
})
})
describe('Cloning comment threads to another project', async function () {
const sourceProjectId = new ObjectId().toString()
const targetProjectId = new ObjectId().toString()
const userId = new ObjectId().toString()
const threadId = new ObjectId().toString()
before(async function () {
await ChatApp.ensureRunning()
const { response } = await ChatClient.sendMessage(
sourceProjectId,
threadId,
userId,
'source message'
)
expect(response.statusCode).to.equal(201)
})
describe('with a valid source and target project', function () {
before(async function () {
const { response } = await ChatClient.cloneCommentThreads(
sourceProjectId,
targetProjectId
)
expect(response.statusCode).to.equal(204)
})
it('should copy the threads and messages into the target project', async function () {
const { response, body: threads } =
await ChatClient.getThreads(targetProjectId)
expect(response.statusCode).to.equal(200)
const threadIds = Object.keys(threads)
expect(threadIds.length).to.equal(1)
expect(threads[threadIds[0]].messages.length).to.equal(1)
expect(threads[threadIds[0]].messages[0].content).to.equal(
'source message'
)
})
it('should not remove the threads from the source project', async function () {
const { response, body: threads } =
await ChatClient.getThreads(sourceProjectId)
expect(response.statusCode).to.equal(200)
expect(threads[threadId].messages.length).to.equal(1)
})
})
describe('with a malformed source projectId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.cloneCommentThreads(
'malformed-project',
targetProjectId
)
expectValidationErrorRaw(response, 404, 'projectId')
})
})
describe('with a malformed target projectId', function () {
it('should return a graceful error', async function () {
const { response } = await ChatClient.cloneCommentThreads(
sourceProjectId,
'malformed-target-project'
)
expectValidationErrorRaw(response, 400, 'targetProjectId')
})
})
})
@@ -1,5 +1,6 @@
import { ObjectId } from '../../../app/js/mongodb.js'
import { expect } from 'chai'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import * as ChatClient from './helpers/ChatClient.js'
import * as ChatApp from './helpers/ChatApp.js'
@@ -44,4 +45,114 @@ describe('Deleting a message', async function () {
expect(threads[threadId].messages.length).to.equal(1)
})
})
describe('in a thread, with a malformed messageId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.deleteMessage(
projectId,
threadId,
'malformed-message-id'
)
expectValidationErrorRaw(response, 404, 'messageId')
})
})
describe('in a thread, with a malformed threadId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.deleteMessage(
projectId,
'malformed-thread-id',
new ObjectId().toString()
)
expectValidationErrorRaw(response, 404, 'threadId')
})
})
describe('globally', async function () {
let messageId
before(async function () {
const { response, body: message } = await ChatClient.sendGlobalMessage(
projectId,
userId,
'deleted global message'
)
expect(response.statusCode).to.equal(201)
messageId = message.id
const { response: response2 } = await ChatClient.deleteGlobalMessage(
projectId,
messageId
)
expect(response2.statusCode).to.equal(204)
})
it('should then remove the message from the global messages', async function () {
const { response, body: messages } =
await ChatClient.getGlobalMessages(projectId)
expect(response.statusCode).to.equal(200)
expect(messages.map(m => m.id)).to.not.include(messageId)
})
})
describe('globally, with a malformed messageId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.deleteGlobalMessage(
projectId,
'malformed-message-id'
)
expectValidationErrorRaw(response, 404, 'messageId')
})
})
describe('as a specific user', async function () {
let messageId
const otherUserId = new ObjectId().toString()
before(async function () {
const { response, body: message } = await ChatClient.sendMessage(
projectId,
threadId,
userId,
'message owned by userId'
)
expect(response.statusCode).to.equal(201)
messageId = message.id
})
it('should not remove the message when deleted by a different user', async function () {
const { response } = await ChatClient.deleteUserMessage(
projectId,
threadId,
otherUserId,
messageId
)
expect(response.statusCode).to.equal(204)
const { body: threads } = await ChatClient.getThreads(projectId)
expect(threads[threadId].messages.map(m => m.id)).to.include(messageId)
})
it('should remove the message when deleted by its owner', async function () {
const { response } = await ChatClient.deleteUserMessage(
projectId,
threadId,
userId,
messageId
)
expect(response.statusCode).to.equal(204)
const { body: threads } = await ChatClient.getThreads(projectId)
expect(threads[threadId].messages.map(m => m.id)).to.not.include(
messageId
)
})
})
describe('as a specific user, with a malformed userId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.deleteUserMessage(
projectId,
threadId,
'malformed-user',
new ObjectId().toString()
)
expectValidationErrorRaw(response, 404, 'userId')
})
})
})
@@ -1,5 +1,6 @@
import { ObjectId } from '../../../app/js/mongodb.js'
import { expect } from 'chai'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import * as ChatClient from './helpers/ChatClient.js'
import * as ChatApp from './helpers/ChatApp.js'
@@ -35,4 +36,24 @@ describe('Deleting a thread', async function () {
expect(Object.keys(threads).length).to.equal(0)
})
})
describe('with a malformed threadId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.deleteThread(
projectId,
'malformed-thread-id'
)
expectValidationErrorRaw(response, 404, 'threadId')
})
})
describe('with a malformed projectId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.deleteThread(
'malformed-project',
new ObjectId().toString()
)
expectValidationErrorRaw(response, 404, 'projectId')
})
})
})
@@ -1,5 +1,6 @@
import { ObjectId } from '../../../app/js/mongodb.js'
import { expect } from 'chai'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import * as ChatClient from './helpers/ChatClient.js'
import * as ChatApp from './helpers/ChatApp.js'
@@ -63,4 +64,11 @@ describe('Destroying a project', async function () {
expect(globalThreadMessage).to.be.null
})
})
describe('with a malformed projectId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.destroyProject('malformed-project')
expectValidationErrorRaw(response, 404, 'projectId')
})
})
})
@@ -1,8 +1,10 @@
import { ObjectId } from '../../../app/js/mongodb.js'
import { expect } from 'chai'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import * as ChatClient from './helpers/ChatClient.js'
import * as ChatApp from './helpers/ChatApp.js'
import { MAX_MESSAGE_LENGTH } from '../../../app/js/Features/Messages/MessageHttpSchemas.js'
describe('Editing a message', async function () {
let projectId, userId, threadId
@@ -31,6 +33,71 @@ describe('Editing a message', async function () {
messageId = message.id
})
describe('with a malformed messageId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.editMessage(
projectId,
threadId,
'malformed-message-id',
newContent
)
expectValidationErrorRaw(response, 404, 'messageId')
})
})
describe('with a malformed projectId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.editMessage(
'malformed-project',
threadId,
messageId,
newContent
)
expectValidationErrorRaw(response, 404, 'projectId')
})
})
describe('with a malformed threadId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.editMessage(
projectId,
'malformed-thread-id',
messageId,
newContent
)
expectValidationErrorRaw(response, 404, 'threadId')
})
})
describe('with no content', function () {
it('should return a graceful error', async function () {
const { response } = await ChatClient.editMessage(
projectId,
threadId,
messageId,
''
)
expectValidationErrorRaw(response, 400, 'No content provided')
})
})
describe('with very long content', function () {
it('should return a graceful error', async function () {
const tooLongContent = '-'.repeat(MAX_MESSAGE_LENGTH + 1)
const { response } = await ChatClient.editMessage(
projectId,
threadId,
messageId,
tooLongContent
)
expectValidationErrorRaw(
response,
400,
`Content too long (> ${MAX_MESSAGE_LENGTH} bytes)`
)
})
})
describe('without user', function () {
beforeEach(async function () {
const { response } = await ChatClient.editMessage(
@@ -93,4 +160,100 @@ describe('Editing a message', async function () {
})
})
})
describe('globally', async function () {
const content = 'global message'
const newContent = 'updated global message'
let messageId
beforeEach(async function () {
projectId = new ObjectId().toString()
userId = new ObjectId().toString()
const { response, body: message } = await ChatClient.sendGlobalMessage(
projectId,
userId,
content
)
expect(response.statusCode).to.equal(201)
expect(message.id).to.exist
messageId = message.id
})
describe('with a malformed messageId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.editGlobalMessage(
projectId,
'malformed-message-id',
newContent
)
expectValidationErrorRaw(response, 404, 'messageId')
})
})
describe('with a malformed projectId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.editGlobalMessage(
'malformed-project',
messageId,
newContent
)
expectValidationErrorRaw(response, 404, 'projectId')
})
})
describe('with very long content', function () {
it('should return a graceful error', async function () {
const tooLongContent = '-'.repeat(MAX_MESSAGE_LENGTH + 1)
const { response } = await ChatClient.editGlobalMessage(
projectId,
messageId,
tooLongContent
)
expectValidationErrorRaw(
response,
400,
`Content too long (> ${MAX_MESSAGE_LENGTH} bytes)`
)
})
})
describe('without user', function () {
beforeEach(async function () {
const { response } = await ChatClient.editGlobalMessage(
projectId,
messageId,
newContent
)
expect(response.statusCode).to.equal(204)
})
it('should then list the updated message in the global messages', async function () {
const { response, body: messages } =
await ChatClient.getGlobalMessages(projectId)
expect(response.statusCode).to.equal(200)
expect(messages.length).to.equal(1)
expect(messages[0].content).to.equal(newContent)
})
})
describe('with another user', function () {
beforeEach(async function () {
const { response } = await ChatClient.editGlobalMessageWithUser(
projectId,
messageId,
new ObjectId(),
newContent
)
expect(response.statusCode).to.equal(404)
})
it('should then list the old message in the global messages', async function () {
const { response, body: messages } =
await ChatClient.getGlobalMessages(projectId)
expect(response.statusCode).to.equal(200)
expect(messages.length).to.equal(1)
expect(messages[0].content).to.equal(content)
})
})
})
})
@@ -0,0 +1,132 @@
import { ObjectId } from '../../../app/js/mongodb.js'
import { expect } from 'chai'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import * as ChatClient from './helpers/ChatClient.js'
import * as ChatApp from './helpers/ChatApp.js'
describe('Getting a message', async function () {
before(async function () {
await ChatApp.ensureRunning()
})
describe('globally', async function () {
const projectId = new ObjectId().toString()
const userId = new ObjectId().toString()
const content = 'global message'
let messageId
before(async function () {
const { response, body: message } = await ChatClient.sendGlobalMessage(
projectId,
userId,
content
)
expect(response.statusCode).to.equal(201)
messageId = message.id
})
it('should return the message', async function () {
const { response, body: message } = await ChatClient.getGlobalMessage(
projectId,
messageId
)
expect(response.statusCode).to.equal(200)
expect(message.content).to.equal(content)
expect(message.user_id).to.equal(userId)
})
describe('when the message does not exist', function () {
it('should return 404', async function () {
const { response } = await ChatClient.getGlobalMessage(
projectId,
new ObjectId().toString()
)
expect(response.statusCode).to.equal(404)
})
})
describe('with a malformed projectId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.getGlobalMessage(
'malformed-project',
messageId
)
expectValidationErrorRaw(response, 404, 'projectId')
})
})
describe('with a malformed messageId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.getGlobalMessage(
projectId,
'malformed-message-id'
)
expectValidationErrorRaw(response, 404, 'messageId')
})
})
})
describe('in a thread', async function () {
const projectId = new ObjectId().toString()
const threadId = new ObjectId().toString()
const userId = new ObjectId().toString()
const content = 'thread message'
let messageId
before(async function () {
const { response, body: message } = await ChatClient.sendMessage(
projectId,
threadId,
userId,
content
)
expect(response.statusCode).to.equal(201)
messageId = message.id
})
it('should return the message', async function () {
const { response, body: message } = await ChatClient.getThreadMessage(
projectId,
threadId,
messageId
)
expect(response.statusCode).to.equal(200)
expect(message.content).to.equal(content)
expect(message.user_id).to.equal(userId)
})
describe('when the message does not exist', function () {
it('should return 404', async function () {
const { response } = await ChatClient.getThreadMessage(
projectId,
threadId,
new ObjectId().toString()
)
expect(response.statusCode).to.equal(404)
})
})
describe('with a malformed threadId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.getThreadMessage(
projectId,
'malformed-thread-id',
messageId
)
expectValidationErrorRaw(response, 404, 'threadId')
})
})
describe('with a malformed messageId', function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.getThreadMessage(
projectId,
threadId,
'malformed-message-id'
)
expectValidationErrorRaw(response, 404, 'messageId')
})
})
})
})
@@ -1,5 +1,6 @@
import { ObjectId } from '../../../app/js/mongodb.js'
import { expect } from 'chai'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import * as ChatClient from './helpers/ChatClient.js'
import * as ChatApp from './helpers/ChatApp.js'
@@ -64,4 +65,24 @@ describe('Getting a thread', async function () {
expect(response.statusCode).to.equal(404)
})
})
describe('with a malformed threadId', function () {
it('should return a not found error naming the field', async function () {
const { response } = await ChatClient.getThread(
projectId,
'malformed-thread-id'
)
expectValidationErrorRaw(response, 404, 'threadId')
})
})
describe('with a malformed projectId', function () {
it('should return a not found error naming the field', async function () {
const { response } = await ChatClient.getThread(
'malformed-project',
threadId
)
expectValidationErrorRaw(response, 404, 'projectId')
})
})
})
@@ -1,5 +1,6 @@
import { ObjectId } from '../../../app/js/mongodb.js'
import { expect } from 'chai'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import * as ChatClient from './helpers/ChatClient.js'
import * as ChatApp from './helpers/ChatApp.js'
@@ -8,7 +9,7 @@ async function getCount() {
return await ChatClient.getMetric(line => {
return (
line.includes('timer_http_request_count') &&
line.includes('path="project_{projectId}_messages"') &&
line.includes('path="project_projectId_messages"') &&
line.includes('method="POST"')
)
})
@@ -161,4 +162,31 @@ describe('Getting messages', async function () {
expect(thread1.messages[1].user_id).to.equal(userId1)
})
})
describe('with a malformed thread id in the list', function () {
it('should return a graceful error', async function () {
const projectId = new ObjectId().toString()
const { response } = await ChatClient.generateThreadData(projectId, [
'malformed-thread-id',
])
expectValidationErrorRaw(response, 400, 'threads')
})
})
describe('with a malformed projectId', function () {
it('should return a not found error for threads', async function () {
const { response } = await ChatClient.asyncRequest({
method: 'get',
url: `/project/malformed-project/threads`,
json: true,
})
expectValidationErrorRaw(response, 404, 'projectId')
})
it('should return a not found error for global messages', async function () {
const { response } =
await ChatClient.getGlobalMessages('malformed-project')
expectValidationErrorRaw(response, 404, 'projectId')
})
})
})
@@ -1,5 +1,6 @@
import { ObjectId } from '../../../app/js/mongodb.js'
import { expect } from 'chai'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import * as ChatClient from './helpers/ChatClient.js'
import * as ChatApp from './helpers/ChatApp.js'
@@ -111,4 +112,43 @@ describe('Resolving a thread', async function () {
expect(body.resolvedThreadIds).not.to.include(threadId)
})
})
describe('with a malformed threadId', function () {
it('should return a not found error when resolving', async function () {
const { response } = await ChatClient.resolveThread(
projectId,
'malformed-thread-id',
userId
)
expectValidationErrorRaw(response, 404, 'threadId')
})
it('should return a not found error when reopening', async function () {
const { response } = await ChatClient.reopenThread(
projectId,
'malformed-thread-id'
)
expectValidationErrorRaw(response, 404, 'threadId')
})
})
describe('with a malformed userId when resolving', function () {
it('should return a graceful error', async function () {
const threadId = new ObjectId().toString()
const { response } = await ChatClient.resolveThread(
projectId,
threadId,
'malformed-user'
)
expectValidationErrorRaw(response, 400, 'user_id')
})
})
describe('with a malformed projectId', function () {
it('should return a not found error for resolved-thread-ids', async function () {
const { response } =
await ChatClient.getResolvedThreadIds('malformed-project')
expectValidationErrorRaw(response, 404, 'projectId')
})
})
})
@@ -1,8 +1,10 @@
import { ObjectId } from '../../../app/js/mongodb.js'
import { expect } from 'chai'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import * as ChatClient from './helpers/ChatClient.js'
import * as ChatApp from './helpers/ChatApp.js'
import { MAX_MESSAGE_LENGTH } from '../../../app/js/Features/Messages/MessageHttpSchemas.js'
describe('Sending a message', async function () {
before(async function () {
@@ -80,63 +82,113 @@ describe('Sending a message', async function () {
'malformed-user',
'content'
)
expect(response.statusCode).to.equal(400)
expect(body).to.equal('Invalid userId')
expectValidationErrorRaw(response, 400, 'user_id')
expect(body.error).to.include('Invalid Mongo ObjectId')
})
})
describe('with a malformed projectId', async function () {
it('should return a graceful error', async function () {
const { response, body } = await ChatClient.sendMessage(
it('should return a not found error', async function () {
const { response } = await ChatClient.sendMessage(
'malformed-project',
threadId,
userId,
'content'
)
expect(response.statusCode).to.equal(400)
expect(body).to.equal('Invalid projectId')
expectValidationErrorRaw(response, 404, 'projectId')
})
})
describe('with a malformed threadId', async function () {
it('should return a graceful error', async function () {
const { response, body } = await ChatClient.sendMessage(
it('should return a not found error', async function () {
const { response } = await ChatClient.sendMessage(
projectId,
'malformed-thread-id',
userId,
'content'
)
expect(response.statusCode).to.equal(400)
expect(body).to.equal('Invalid threadId')
expectValidationErrorRaw(response, 404, 'threadId')
})
})
describe('with no content', async function () {
it('should return a graceful error', async function () {
const { response, body } = await ChatClient.sendMessage(
const { response } = await ChatClient.sendMessage(
projectId,
threadId,
userId,
null
)
expect(response.statusCode).to.equal(400)
// Exegesis is responding with validation errors. I can´t find a way to choose the validation error yet.
// expect(body).to.equal('No content provided')
expect(body.message).to.equal('Validation errors')
expectValidationErrorRaw(response, 400, 'No content provided')
})
})
describe('with very long content', async function () {
it('should return a graceful error', async function () {
const content = '-'.repeat(10 * 1024 + 1)
const { response, body } = await ChatClient.sendMessage(
const content = '-'.repeat(MAX_MESSAGE_LENGTH + 1)
const { response } = await ChatClient.sendMessage(
projectId,
threadId,
userId,
content
)
expect(response.statusCode).to.equal(400)
expect(body).to.equal('Content too long (> 10240 bytes)')
expectValidationErrorRaw(
response,
400,
`Content too long (> ${MAX_MESSAGE_LENGTH} bytes)`
)
})
})
describe('with an unknown field in the body', async function () {
it('should return a graceful error', async function () {
const { response } = await ChatClient.asyncRequest({
method: 'post',
url: `/project/${projectId}/thread/${threadId}/messages`,
json: {
user_id: userId,
content: 'content',
unknownField: 'x',
},
})
expectValidationErrorRaw(response, 400, 'unknownField')
})
})
describe('with a malformed limit', async function () {
it('should return a graceful error', async function () {
const { response } = await ChatClient.asyncRequest({
method: 'get',
url: `/project/${projectId}/messages?limit=nope`,
json: true,
})
expectValidationErrorRaw(response, 400, 'limit')
})
})
})
describe('globally: failure cases', async function () {
const projectId = new ObjectId().toString()
describe('with a malformed userId', async function () {
it('should return a graceful error', async function () {
const { response } = await ChatClient.sendGlobalMessage(
projectId,
'malformed-user',
'content'
)
expectValidationErrorRaw(response, 400, 'user_id')
})
})
describe('with a malformed projectId', async function () {
it('should return a not found error', async function () {
const { response } = await ChatClient.sendGlobalMessage(
'malformed-project',
new ObjectId().toString(),
'content'
)
expectValidationErrorRaw(response, 404, 'projectId')
})
})
})
@@ -1,4 +1,4 @@
import { createServer } from '../../../../app/js/server.js'
import app from '../../../../app.js'
import { promisify } from 'node:util'
import './MongoHelper.js'
import testLogRecorder from '@overleaf/logger/test-log-recorder.js'
@@ -9,7 +9,6 @@ let serverPromise = null
export async function ensureRunning() {
if (!serverPromise) {
const { app } = await createServer()
const startServer = promisify(app.listen.bind(app))
serverPromise = startServer(3010, '127.0.0.1')
}
@@ -4,7 +4,7 @@ const request = Request.defaults({
baseUrl: 'http://127.0.0.1:3010',
})
async function asyncRequest(options) {
export async function asyncRequest(options) {
return await new Promise((resolve, reject) => {
request(options, (err, response, body) => {
if (err) {
@@ -35,6 +35,47 @@ export async function getGlobalMessages(projectId) {
})
}
export async function getGlobalMessage(projectId, messageId) {
return await asyncRequest({
method: 'get',
url: `/project/${projectId}/messages/${messageId}`,
json: true,
})
}
export async function deleteGlobalMessage(projectId, messageId) {
return await asyncRequest({
method: 'delete',
url: `/project/${projectId}/messages/${messageId}`,
})
}
export async function editGlobalMessage(projectId, messageId, content) {
return await asyncRequest({
method: 'post',
url: `/project/${projectId}/messages/${messageId}/edit`,
json: {
content,
},
})
}
export async function editGlobalMessageWithUser(
projectId,
messageId,
userId,
content
) {
return await asyncRequest({
method: 'post',
url: `/project/${projectId}/messages/${messageId}/edit`,
json: {
content,
userId,
},
})
}
export async function sendMessage(projectId, threadId, userId, content) {
return await asyncRequest({
method: 'post',
@@ -54,6 +95,14 @@ export async function getThread(projectId, threadId) {
})
}
export async function getThreadMessage(projectId, threadId, messageId) {
return await asyncRequest({
method: 'get',
url: `/project/${projectId}/thread/${threadId}/messages/${messageId}`,
json: true,
})
}
export async function getThreads(projectId) {
return await asyncRequest({
method: 'get',
@@ -146,6 +195,18 @@ export async function deleteMessage(projectId, threadId, messageId) {
})
}
export async function deleteUserMessage(
projectId,
threadId,
userId,
messageId
) {
return await asyncRequest({
method: 'delete',
url: `/project/${projectId}/thread/${threadId}/user/${userId}/messages/${messageId}`,
})
}
export async function destroyProject(projectId) {
return await asyncRequest({
method: 'delete',
@@ -172,3 +233,13 @@ export async function generateThreadData(projectId, threads) {
},
})
}
export async function cloneCommentThreads(sourceProjectId, targetProjectId) {
return await asyncRequest({
method: 'post',
url: `/project/${sourceProjectId}/clone-comment-threads`,
json: {
targetProjectId,
},
})
}
+9 -106
View File
@@ -297,18 +297,6 @@ __metadata:
languageName: node
linkType: hard
"@apidevtools/json-schema-ref-parser@npm:^9.0.3":
version: 9.1.2
resolution: "@apidevtools/json-schema-ref-parser@npm:9.1.2"
dependencies:
"@jsdevtools/ono": "npm:^7.1.3"
"@types/json-schema": "npm:^7.0.6"
call-me-maybe: "npm:^1.0.1"
js-yaml: "npm:^4.1.0"
checksum: 10c0/ebf952eb2e00bf0919f024e72897e047fd5012f0a9e47ac361873f6de0a733b9334513cdbc73205a6b43ac4a652b8c87f55e489c39b2d60bd0bc1cb2b411e218
languageName: node
linkType: hard
"@arrows/array@npm:^1.4.1":
version: 1.4.1
resolution: "@arrows/array@npm:1.4.1"
@@ -4816,13 +4804,6 @@ __metadata:
languageName: node
linkType: hard
"@jsdevtools/ono@npm:^7.1.3":
version: 7.1.3
resolution: "@jsdevtools/ono@npm:7.1.3"
checksum: 10c0/a9f7e3e8e3bc315a34959934a5e2f874c423cf4eae64377d3fc9de0400ed9f36cb5fd5ebce3300d2e8f4085f557c4a8b591427a583729a87841fda46e6c216b9
languageName: node
linkType: hard
"@jsonjoy.com/base64@npm:17.67.0":
version: 17.67.0
resolution: "@jsonjoy.com/base64@npm:17.67.0"
@@ -6928,14 +6909,13 @@ __metadata:
"@overleaf/mongo-utils": "npm:*"
"@overleaf/promise-utils": "npm:*"
"@overleaf/settings": "npm:*"
"@overleaf/validation-tools": "workspace:*"
"@types/node": "npm:^24.5.2"
acorn: "npm:^7.1.1"
ajv: "npm:^6.12.0"
async: "npm:^3.2.5"
chai: "npm:^4.3.6"
chai-as-promised: "npm:^7.1.1"
eslint: "npm:^10.4.0"
exegesis-express: "npm:4.0.0"
express: "npm:4.22.1"
mocha: "npm:^11.1.0"
mocha-junit-reporter: "npm:^2.2.1"
@@ -10971,7 +10951,7 @@ __metadata:
languageName: node
linkType: hard
"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.6, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9":
"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9":
version: 7.0.15
resolution: "@types/json-schema@npm:7.0.15"
checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db
@@ -12793,7 +12773,7 @@ __metadata:
languageName: node
linkType: hard
"ajv-formats@npm:^2.1.0, ajv-formats@npm:^2.1.1":
"ajv-formats@npm:^2.1.1":
version: 2.1.1
resolution: "ajv-formats@npm:2.1.1"
dependencies:
@@ -12827,7 +12807,7 @@ __metadata:
languageName: node
linkType: hard
"ajv@npm:^6.12.0, ajv@npm:^6.12.3, ajv@npm:^6.12.5":
"ajv@npm:^6.12.3, ajv@npm:^6.12.5":
version: 6.14.0
resolution: "ajv@npm:6.14.0"
dependencies:
@@ -12851,7 +12831,7 @@ __metadata:
languageName: node
linkType: hard
"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.12.0, ajv@npm:^8.3.0, ajv@npm:^8.9.0":
"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.12.0, ajv@npm:^8.9.0":
version: 8.18.0
resolution: "ajv@npm:8.18.0"
dependencies:
@@ -14493,13 +14473,6 @@ __metadata:
languageName: node
linkType: hard
"call-me-maybe@npm:^1.0.1":
version: 1.0.2
resolution: "call-me-maybe@npm:1.0.2"
checksum: 10c0/8eff5dbb61141ebb236ed71b4e9549e488bcb5451c48c11e5667d5c75b0532303788a1101e6978cafa2d0c8c1a727805599c2741e3e0982855c9f1d78cd06c9f
languageName: node
linkType: hard
"callsites@npm:^3.0.0":
version: 3.1.0
resolution: "callsites@npm:3.1.0"
@@ -15437,7 +15410,7 @@ __metadata:
languageName: node
linkType: hard
"content-type@npm:^1.0.4, content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5":
"content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5":
version: 1.0.5
resolution: "content-type@npm:1.0.5"
checksum: 10c0/b76ebed15c000aee4678c3707e0860cb6abd4e680a598c0a26e17f0bfae723ec9cc2802f0ff1bc6e4d80603719010431d2231018373d4dde10f9ccff9dadf5af
@@ -16514,13 +16487,6 @@ __metadata:
languageName: node
linkType: hard
"deep-freeze@npm:0.0.1":
version: 0.0.1
resolution: "deep-freeze@npm:0.0.1"
checksum: 10c0/b32c878395df6ca0e07d065750e14bc1651ec76e99490bca25e5844f7321676d7045d4eb4123892a0d4f08c38e4b7fa46d6e834782c095723447c0ee2ad0340b
languageName: node
linkType: hard
"deep-is@npm:^0.1.3":
version: 0.1.4
resolution: "deep-is@npm:0.1.4"
@@ -18258,13 +18224,6 @@ __metadata:
languageName: node
linkType: hard
"events-listener@npm:^1.1.0":
version: 1.1.0
resolution: "events-listener@npm:1.1.0"
checksum: 10c0/85a2be3d27cef22edb6e8bccfd2d87c9f9f6854a834129958c7a0410284b6c9c69efff40554ba8ae3f4c1dc9b036c5a07c5d5df8b31041b3b93722c91dc3b140
languageName: node
linkType: hard
"events-universal@npm:^1.0.0":
version: 1.0.1
resolution: "events-universal@npm:1.0.1"
@@ -18344,39 +18303,6 @@ __metadata:
languageName: node
linkType: hard
"exegesis-express@npm:4.0.0":
version: 4.0.0
resolution: "exegesis-express@npm:4.0.0"
dependencies:
exegesis: "npm:^4.1.0"
checksum: 10c0/09e32a03f0bbafc09cc19d213fdc61584cda52f7f465b1cd4faba55a8c87eb956c00bb0a72f53770799669e95c4d6af525983c639ab920333d3b0397592e8241
languageName: node
linkType: hard
"exegesis@npm:^4.1.0":
version: 4.3.0
resolution: "exegesis@npm:4.3.0"
dependencies:
"@apidevtools/json-schema-ref-parser": "npm:^9.0.3"
ajv: "npm:^8.3.0"
ajv-formats: "npm:^2.1.0"
body-parser: "npm:^1.18.3"
content-type: "npm:^1.0.4"
deep-freeze: "npm:0.0.1"
events-listener: "npm:^1.1.0"
glob: "npm:^10.3.10"
json-ptr: "npm:^3.0.1"
json-schema-traverse: "npm:^1.0.0"
lodash: "npm:^4.17.11"
openapi3-ts: "npm:^3.1.1"
promise-breaker: "npm:^6.0.0"
qs: "npm:^6.6.0"
raw-body: "npm:^2.3.3"
semver: "npm:^7.0.0"
checksum: 10c0/944ec9df00378ef19eb37a52aff60244e3bb98878e5979d7efb98b8454b144d9bb7ec08ea8d23e54430d97e2d0bf9e59341f85a471e1288a607cf13ceabfdbe1
languageName: node
linkType: hard
"exifr@npm:^7.0.0":
version: 7.1.3
resolution: "exifr@npm:7.1.3"
@@ -19681,7 +19607,7 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:^10.3.10, glob@npm:^10.4.2, glob@npm:^10.4.5":
"glob@npm:^10.4.2, glob@npm:^10.4.5":
version: 10.5.0
resolution: "glob@npm:10.5.0"
dependencies:
@@ -22289,13 +22215,6 @@ __metadata:
languageName: node
linkType: hard
"json-ptr@npm:^3.0.1":
version: 3.1.1
resolution: "json-ptr@npm:3.1.1"
checksum: 10c0/f5fd7eb60a8ad52e3531eea8285d22a67bf67a16249da032cd42fabcac386135adbe8c64a5588f5b755e77b9e646ca5cc97782fe09c5cef0b6d24ebdd1e1c277
languageName: node
linkType: hard
"json-schema-traverse@npm:^0.4.1":
version: 0.4.1
resolution: "json-schema-traverse@npm:0.4.1"
@@ -25776,15 +25695,6 @@ __metadata:
languageName: node
linkType: hard
"openapi3-ts@npm:^3.1.1":
version: 3.2.0
resolution: "openapi3-ts@npm:3.2.0"
dependencies:
yaml: "npm:^2.2.1"
checksum: 10c0/3b9a663bf71f9292880c970a80f6f1a8db0ee475451c03b4fd336da957a24372349594d7868ce0a60b3a0875844a1f0e906e8fec8ef4220c06aa70670bfa3148
languageName: node
linkType: hard
"optionator@npm:^0.9.3":
version: 0.9.4
resolution: "optionator@npm:0.9.4"
@@ -28070,13 +27980,6 @@ __metadata:
languageName: node
linkType: hard
"promise-breaker@npm:^6.0.0":
version: 6.0.0
resolution: "promise-breaker@npm:6.0.0"
checksum: 10c0/fcd00836dd96d34d3376964bc3a9482251248cef00f0dcf1f2485e7d9af44f9c48b0431cf4dc780fd5a7137b8e0861e3c6557667811522e93978f6bffab66cfe
languageName: node
linkType: hard
"promise.prototype.finally@npm:^3.1.2":
version: 3.1.8
resolution: "promise.prototype.finally@npm:3.1.8"
@@ -28543,7 +28446,7 @@ __metadata:
languageName: node
linkType: hard
"raw-body@npm:^2.3.0, raw-body@npm:^2.3.3, raw-body@npm:~2.5.3":
"raw-body@npm:^2.3.0, raw-body@npm:~2.5.3":
version: 2.5.3
resolution: "raw-body@npm:2.5.3"
dependencies:
@@ -34810,7 +34713,7 @@ __metadata:
languageName: node
linkType: hard
"yaml@npm:^2.0.0, yaml@npm:^2.2.1, yaml@npm:^2.3.4":
"yaml@npm:^2.0.0, yaml@npm:^2.3.4":
version: 2.8.3
resolution: "yaml@npm:2.8.3"
bin: