Files
wehub-resource-sync e115934061
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:08:12 +08:00

693 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const request = require('supertest');
const MOCKS = '../__test-utils__/convos-route-mocks';
jest.mock('@librechat/agents', () => require(MOCKS).agents());
jest.mock('@librechat/api', () => require(MOCKS).api());
jest.mock('@librechat/data-schemas', () => require(MOCKS).dataSchemas());
jest.mock('librechat-data-provider', () => require(MOCKS).dataProvider());
jest.mock('~/models', () => require(MOCKS).sharedModels());
jest.mock('~/server/middleware/requireJwtAuth', () => require(MOCKS).requireJwtAuth());
jest.mock('~/server/middleware', () => require(MOCKS).middlewarePassthrough());
jest.mock('~/server/utils/import/fork', () => require(MOCKS).forkUtils());
jest.mock('~/server/utils/import', () => require(MOCKS).importUtils());
jest.mock('~/cache/getLogStores', () => require(MOCKS).logStores());
jest.mock('~/server/routes/files/multer', () => require(MOCKS).multerSetup());
jest.mock('multer', () => require(MOCKS).multerLib());
jest.mock('~/server/services/Endpoints/azureAssistants', () => require(MOCKS).assistantEndpoint());
jest.mock('~/server/services/Endpoints/assistants', () => require(MOCKS).assistantEndpoint());
describe('Convos Routes', () => {
let app;
let convosRouter;
const { deleteToolCalls, deleteConvos, saveConvo } = require('~/models');
const {
deleteAgentCheckpoints,
deleteAllSharedLinksWithCleanup,
deleteConvoSharedLinksWithCleanup,
} = require('@librechat/api');
beforeAll(() => {
convosRouter = require('../convos');
app = express();
app.use(express.json());
/** Mock authenticated user */
app.use((req, res, next) => {
req.user = { id: 'test-user-123' };
next();
});
app.use('/api/convos', convosRouter);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('DELETE /all', () => {
it('prunes the deleted conversations agent checkpoints (bulk, ids from deleteConvos)', async () => {
// HITL: a paused conversation's durable checkpoint must not outlive the conversation.
const conversationIds = ['conv-a', 'conv-b'];
deleteConvos.mockResolvedValue({ deletedCount: 2, conversationIds });
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
deleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 0 });
const response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(201);
expect(deleteAgentCheckpoints).toHaveBeenCalledTimes(1);
expect(deleteAgentCheckpoints.mock.calls[0][0]).toEqual(conversationIds);
});
it('should delete all conversations, tool calls, and shared links for a user', async () => {
const mockDbResponse = {
deletedCount: 5,
message: 'All conversations deleted successfully',
};
deleteConvos.mockResolvedValue(mockDbResponse);
deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
deleteAllSharedLinksWithCleanup.mockResolvedValue({
message: 'All shared links deleted successfully',
deletedCount: 3,
});
const response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(201);
expect(response.body).toEqual(mockDbResponse);
/** Verify deleteConvos was called with correct userId */
expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {});
expect(deleteConvos).toHaveBeenCalledTimes(1);
/** Verify deleteToolCalls was called with correct userId */
expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123');
expect(deleteToolCalls).toHaveBeenCalledTimes(1);
/** Verify deleteAllSharedLinksWithCleanup was called with correct userId */
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledTimes(1);
});
it('should call deleteAllSharedLinksWithCleanup even when no conversations exist', async () => {
const mockDbResponse = {
deletedCount: 0,
message: 'No conversations to delete',
};
deleteConvos.mockResolvedValue(mockDbResponse);
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
deleteAllSharedLinksWithCleanup.mockResolvedValue({
message: 'All shared links deleted successfully',
deletedCount: 0,
});
const response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(201);
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
});
it('should return 500 if deleteConvos fails', async () => {
const errorMessage = 'Database connection error';
deleteConvos.mockRejectedValue(new Error(errorMessage));
const response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(500);
expect(response.text).toBe('Error clearing conversations');
/** Verify error was logged */
const { logger } = require('@librechat/data-schemas');
expect(logger.error).toHaveBeenCalledWith('Error clearing conversations', expect.any(Error));
});
it('should return 500 if deleteToolCalls fails', async () => {
deleteConvos.mockResolvedValue({ deletedCount: 5 });
deleteToolCalls.mockRejectedValue(new Error('Tool calls deletion failed'));
const response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(500);
expect(response.text).toBe('Error clearing conversations');
});
it('should return 500 if deleteAllSharedLinksWithCleanup fails', async () => {
deleteConvos.mockResolvedValue({ deletedCount: 5 });
deleteToolCalls.mockResolvedValue({ deletedCount: 10 });
deleteAllSharedLinksWithCleanup.mockRejectedValue(new Error('Shared links deletion failed'));
const response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(500);
expect(response.text).toBe('Error clearing conversations');
});
it('should handle multiple users independently', async () => {
/** First user */
deleteConvos.mockResolvedValue({ deletedCount: 3 });
deleteToolCalls.mockResolvedValue({ deletedCount: 5 });
deleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 2 });
let response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(201);
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
jest.clearAllMocks();
/** Second user (simulate different user by modifying middleware) */
const app2 = express();
app2.use(express.json());
app2.use((req, res, next) => {
req.user = { id: 'test-user-456' };
next();
});
app2.use('/api/convos', require('../convos'));
deleteConvos.mockResolvedValue({ deletedCount: 7 });
deleteToolCalls.mockResolvedValue({ deletedCount: 12 });
deleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 4 });
response = await request(app2).delete('/api/convos/all');
expect(response.status).toBe(201);
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-456');
});
it('should execute deletions in correct sequence', async () => {
const executionOrder = [];
deleteConvos.mockImplementation(() => {
executionOrder.push('deleteConvos');
return Promise.resolve({ deletedCount: 5 });
});
deleteToolCalls.mockImplementation(() => {
executionOrder.push('deleteToolCalls');
return Promise.resolve({ deletedCount: 10 });
});
deleteAllSharedLinksWithCleanup.mockImplementation(() => {
executionOrder.push('deleteAllSharedLinksWithCleanup');
return Promise.resolve({ deletedCount: 3 });
});
await request(app).delete('/api/convos/all');
/** Verify all three functions were called */
expect(executionOrder).toEqual([
'deleteConvos',
'deleteToolCalls',
'deleteAllSharedLinksWithCleanup',
]);
});
it('should maintain data integrity by cleaning up shared links when conversations are deleted', async () => {
/** This test ensures that orphaned shared links are prevented */
const mockConvosDeleted = { deletedCount: 10 };
const mockToolCallsDeleted = { deletedCount: 15 };
const mockSharedLinksDeleted = {
message: 'All shared links deleted successfully',
deletedCount: 8,
};
deleteConvos.mockResolvedValue(mockConvosDeleted);
deleteToolCalls.mockResolvedValue(mockToolCallsDeleted);
deleteAllSharedLinksWithCleanup.mockResolvedValue(mockSharedLinksDeleted);
const response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(201);
/** Verify that shared links cleanup was called for the same user */
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123');
/** Verify no shared links remain for deleted conversations */
expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledAfter(deleteConvos);
});
});
describe('DELETE /', () => {
it('should delete a single conversation, tool calls, and associated shared links', async () => {
const mockConversationId = 'conv-123';
const mockDbResponse = {
deletedCount: 1,
message: 'Conversation deleted successfully',
};
deleteConvos.mockResolvedValue(mockDbResponse);
deleteToolCalls.mockResolvedValue({ deletedCount: 3 });
deleteConvoSharedLinksWithCleanup.mockResolvedValue({
message: 'Shared links deleted successfully',
deletedCount: 1,
});
const response = await request(app)
.delete('/api/convos')
.send({
arg: {
conversationId: mockConversationId,
},
});
expect(response.status).toBe(201);
expect(response.body).toEqual(mockDbResponse);
/** Verify deleteConvos was called with correct parameters */
expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {
conversationId: mockConversationId,
});
/** Verify deleteToolCalls was called */
expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123', mockConversationId);
/** Verify deleteConvoSharedLinksWithCleanup was called */
expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith(
'test-user-123',
mockConversationId,
);
});
it('should not call deleteConvoSharedLinksWithCleanup when no conversationId provided', async () => {
deleteConvos.mockResolvedValue({ deletedCount: 0 });
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
const response = await request(app)
.delete('/api/convos')
.send({
arg: {
source: 'button',
},
});
expect(response.status).toBe(200);
expect(deleteConvoSharedLinksWithCleanup).not.toHaveBeenCalled();
});
it('should handle deletion of conversation without shared links', async () => {
const mockConversationId = 'conv-no-shares';
deleteConvos.mockResolvedValue({ deletedCount: 1 });
deleteToolCalls.mockResolvedValue({ deletedCount: 0 });
deleteConvoSharedLinksWithCleanup.mockResolvedValue({
message: 'Shared links deleted successfully',
deletedCount: 0,
});
const response = await request(app)
.delete('/api/convos')
.send({
arg: {
conversationId: mockConversationId,
},
});
expect(response.status).toBe(201);
expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith(
'test-user-123',
mockConversationId,
);
});
it('should return 400 when no parameters provided', async () => {
const response = await request(app).delete('/api/convos').send({
arg: {},
});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'no parameters provided' });
expect(deleteConvos).not.toHaveBeenCalled();
expect(deleteConvoSharedLinksWithCleanup).not.toHaveBeenCalled();
});
it('should return 400 when request body is empty (DoS prevention)', async () => {
const response = await request(app).delete('/api/convos').send({});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'no parameters provided' });
expect(deleteConvos).not.toHaveBeenCalled();
});
it('should return 400 when arg is null (DoS prevention)', async () => {
const response = await request(app).delete('/api/convos').send({ arg: null });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'no parameters provided' });
expect(deleteConvos).not.toHaveBeenCalled();
});
it('should return 400 when arg is undefined (DoS prevention)', async () => {
const response = await request(app).delete('/api/convos').send({ arg: undefined });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'no parameters provided' });
expect(deleteConvos).not.toHaveBeenCalled();
});
it('should return 400 when request body is null (DoS prevention)', async () => {
const response = await request(app)
.delete('/api/convos')
.set('Content-Type', 'application/json')
.send('null');
expect(response.status).toBe(400);
expect(deleteConvos).not.toHaveBeenCalled();
});
it('should return 500 if deleteConvoSharedLinksWithCleanup fails', async () => {
const mockConversationId = 'conv-error';
deleteConvos.mockResolvedValue({ deletedCount: 1 });
deleteToolCalls.mockResolvedValue({ deletedCount: 2 });
deleteConvoSharedLinksWithCleanup.mockRejectedValue(
new Error('Failed to delete shared links'),
);
const response = await request(app)
.delete('/api/convos')
.send({
arg: {
conversationId: mockConversationId,
},
});
expect(response.status).toBe(500);
expect(response.text).toBe('Error clearing conversations');
});
it('should execute deletions in correct sequence for single conversation', async () => {
const mockConversationId = 'conv-sequence';
const executionOrder = [];
deleteConvos.mockImplementation(() => {
executionOrder.push('deleteConvos');
return Promise.resolve({ deletedCount: 1 });
});
deleteToolCalls.mockImplementation(() => {
executionOrder.push('deleteToolCalls');
return Promise.resolve({ deletedCount: 2 });
});
deleteConvoSharedLinksWithCleanup.mockImplementation(() => {
executionOrder.push('deleteConvoSharedLinksWithCleanup');
return Promise.resolve({ deletedCount: 1 });
});
await request(app)
.delete('/api/convos')
.send({
arg: {
conversationId: mockConversationId,
},
});
expect(executionOrder).toEqual([
'deleteConvos',
'deleteToolCalls',
'deleteConvoSharedLinksWithCleanup',
]);
});
it('should prevent orphaned shared links when deleting single conversation', async () => {
const mockConversationId = 'conv-with-shares';
deleteConvos.mockResolvedValue({ deletedCount: 1 });
deleteToolCalls.mockResolvedValue({ deletedCount: 4 });
deleteConvoSharedLinksWithCleanup.mockResolvedValue({
message: 'Shared links deleted successfully',
deletedCount: 2,
});
const response = await request(app)
.delete('/api/convos')
.send({
arg: {
conversationId: mockConversationId,
},
});
expect(response.status).toBe(201);
/** Verify shared links were deleted for the specific conversation */
expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith(
'test-user-123',
mockConversationId,
);
/** Verify it was called after the conversation was deleted */
expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledAfter(deleteConvos);
});
});
describe('POST /archive', () => {
it('should archive a conversation successfully', async () => {
const mockConversationId = 'conv-123';
const mockArchivedConvo = {
conversationId: mockConversationId,
title: 'Test Conversation',
isArchived: true,
user: 'test-user-123',
};
saveConvo.mockResolvedValue(mockArchivedConvo);
const response = await request(app)
.post('/api/convos/archive')
.send({
arg: {
conversationId: mockConversationId,
isArchived: true,
},
});
expect(response.status).toBe(200);
expect(response.body).toEqual(mockArchivedConvo);
expect(saveConvo).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'test-user-123' }),
{ conversationId: mockConversationId, isArchived: true },
{ context: `POST /api/convos/archive ${mockConversationId}` },
);
});
it('should unarchive a conversation successfully', async () => {
const mockConversationId = 'conv-456';
const mockUnarchivedConvo = {
conversationId: mockConversationId,
title: 'Unarchived Conversation',
isArchived: false,
user: 'test-user-123',
};
saveConvo.mockResolvedValue(mockUnarchivedConvo);
const response = await request(app)
.post('/api/convos/archive')
.send({
arg: {
conversationId: mockConversationId,
isArchived: false,
},
});
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUnarchivedConvo);
expect(saveConvo).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'test-user-123' }),
{ conversationId: mockConversationId, isArchived: false },
{ context: `POST /api/convos/archive ${mockConversationId}` },
);
});
it('should return 400 when conversationId is missing', async () => {
const response = await request(app)
.post('/api/convos/archive')
.send({
arg: {
isArchived: true,
},
});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'conversationId is required' });
expect(saveConvo).not.toHaveBeenCalled();
});
it('should return 400 when isArchived is not a boolean', async () => {
const response = await request(app)
.post('/api/convos/archive')
.send({
arg: {
conversationId: 'conv-123',
isArchived: 'true',
},
});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'isArchived must be a boolean' });
expect(saveConvo).not.toHaveBeenCalled();
});
it('should return 400 when isArchived is undefined', async () => {
const response = await request(app)
.post('/api/convos/archive')
.send({
arg: {
conversationId: 'conv-123',
},
});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'isArchived must be a boolean' });
expect(saveConvo).not.toHaveBeenCalled();
});
it('should return 500 when saveConvo fails', async () => {
const mockConversationId = 'conv-error';
saveConvo.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.post('/api/convos/archive')
.send({
arg: {
conversationId: mockConversationId,
isArchived: true,
},
});
expect(response.status).toBe(500);
expect(response.text).toBe('Error archiving conversation');
const { logger } = require('@librechat/data-schemas');
expect(logger.error).toHaveBeenCalledWith('Error archiving conversation', expect.any(Error));
});
it('should handle empty arg object', async () => {
const response = await request(app).post('/api/convos/archive').send({
arg: {},
});
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'conversationId is required' });
});
});
describe('POST /convos/pin', () => {
const mockConversationId = 'conv-123';
it('should pin a conversation', async () => {
const mockPinnedConvo = { conversationId: mockConversationId, pinned: true };
saveConvo.mockResolvedValue(mockPinnedConvo);
const response = await request(app).post('/api/convos/pin').send({ arg: mockPinnedConvo });
expect(response.status).toBe(200);
expect(response.body).toEqual(mockPinnedConvo);
expect(saveConvo).toHaveBeenCalledWith(
{ userId: 'test-user-123' },
{ conversationId: mockConversationId, pinned: true },
{ context: `POST /api/convos/pin ${mockConversationId}` },
);
});
it('should unpin a conversation', async () => {
const mockUnpinnedConvo = { conversationId: mockConversationId, pinned: false };
saveConvo.mockResolvedValue(mockUnpinnedConvo);
const response = await request(app).post('/api/convos/pin').send({ arg: mockUnpinnedConvo });
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUnpinnedConvo);
expect(saveConvo).toHaveBeenCalledWith(
{ userId: 'test-user-123' },
{ conversationId: mockConversationId, pinned: false },
{ context: `POST /api/convos/pin ${mockConversationId}` },
);
});
it('should return 400 when conversationId is missing', async () => {
const response = await request(app)
.post('/api/convos/pin')
.send({ arg: { pinned: true } });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'conversationId is required' });
expect(saveConvo).not.toHaveBeenCalled();
});
it('should return 400 when pinned is not a boolean', async () => {
const response = await request(app)
.post('/api/convos/pin')
.send({ arg: { conversationId: mockConversationId, pinned: 'yes' } });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'pinned must be a boolean' });
expect(saveConvo).not.toHaveBeenCalled();
});
it('should return 400 when pinned is missing', async () => {
const response = await request(app)
.post('/api/convos/pin')
.send({ arg: { conversationId: mockConversationId } });
expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'pinned is required' });
expect(saveConvo).not.toHaveBeenCalled();
});
it('should return 500 when saveConvo fails', async () => {
saveConvo.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.post('/api/convos/pin')
.send({ arg: { conversationId: mockConversationId, pinned: true } });
expect(response.status).toBe(500);
});
});
});
/**
* Custom Jest matcher to verify function call order
*/
expect.extend({
toHaveBeenCalledAfter(received, other) {
const receivedCalls = received.mock.invocationCallOrder;
const otherCalls = other.mock.invocationCallOrder;
if (receivedCalls.length === 0) {
return {
pass: false,
message: () =>
`Expected ${received.getMockName()} to have been called after ${other.getMockName()}, but ${received.getMockName()} was never called`,
};
}
if (otherCalls.length === 0) {
return {
pass: false,
message: () =>
`Expected ${received.getMockName()} to have been called after ${other.getMockName()}, but ${other.getMockName()} was never called`,
};
}
const lastReceivedCall = receivedCalls[receivedCalls.length - 1];
const firstOtherCall = otherCalls[0];
const pass = lastReceivedCall > firstOtherCall;
return {
pass,
message: () =>
pass
? `Expected ${received.getMockName()} not to have been called after ${other.getMockName()}`
: `Expected ${received.getMockName()} to have been called after ${other.getMockName()}`,
};
},
});