mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 16:23:44 +00:00
* feat: Add granular access control to shared links via ACL system * fix(shared-links): preserve isPublic on failed migration grants Transient ACL failures during auto-migration permanently stranded links — $unset ran unconditionally, removing the legacy flag that triggers retry. Now only $unset isPublic after all grants succeed. * fix(config): skip isPublic unset for failed ACL grants Bulk migration unconditionally removed isPublic from all links, even those whose ACL writes failed. Failed links then lost the legacy marker needed for auto-migration retry. Now tracks failed link IDs per-batch and excludes them from the $unset step. Also adds sharedLink to AccessRole resourceType schema enum — was missing, only worked because seedDefaultRoles uses findOneAndUpdate which bypasses validation. * ci(config): add jest config and PR workflow for migration tests config/__tests__/ specs depend on api/jest.config.js module mappings but had no dedicated runner. Adds config/jest.config.js extending api config with absolutized paths, npm test:config script, and a GitHub Actions workflow triggered by changes to config/, api/models/, api/db/, or packages/ ACL code. * fix(permissions): honor boolean sharedLinks config SHARED_LINKS has no USE permission, so boolean config produced an empty update payload — gate conditions only matched object form, making `sharedLinks: false` a no-op on existing perms. * fix(share): resolve role before creating shared link Role lookup between create and grant left an orphaned link without ACL entries if getRoleByName threw — retry then hit "Share already exists" with no recovery path. * fix: Restore Public ACL Access Checks * fix: Type Public ACL Lookup * fix: Preserve Private Legacy Shared Links * chore: Promote Shared Link Permission Migration * fix: Address Shared Link Review Findings * fix: Repair Shared Link CI Follow-Up * fix: Narrow Shared Link Mongoose Test Mock * fix: Address Shared Link Review Follow-Ups * fix: Close Shared Link Review Gaps * fix: Guard Missing Shared Link Permission Backfill * test: Add Shared Link Mock E2E * test: Stabilize Shared Link Mock E2E --------- Co-authored-by: Danny Avila <danny@librechat.ai>
603 lines
20 KiB
JavaScript
603 lines
20 KiB
JavaScript
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 {
|
|
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('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' });
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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()}`,
|
|
};
|
|
},
|
|
});
|