test: stabilize librechat suite

This commit is contained in:
Ravi Kumar L 2026-06-21 17:41:43 +02:00
parent cef05dd443
commit 225d50bebe
13 changed files with 199 additions and 142 deletions

View file

@ -27,23 +27,27 @@ describe('Convos Routes', () => {
deleteConvoSharedLinksWithCleanup,
} = require('@librechat/api');
beforeAll(() => {
convosRouter = require('../convos');
app = express();
app.use(express.json());
const createApp = (userId = 'test-user-123') => {
const testApp = express();
testApp.use(express.json());
/** Mock authenticated user */
app.use((req, res, next) => {
req.user = { id: 'test-user-123' };
testApp.use((req, res, next) => {
req.user = { id: userId };
next();
});
app.use('/api/convos', convosRouter);
testApp.use('/api/convos', convosRouter);
return testApp;
};
beforeAll(() => {
convosRouter = require('../convos');
});
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
app = createApp();
});
describe('DELETE /all', () => {
@ -146,13 +150,7 @@ describe('Convos Routes', () => {
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'));
const app2 = createApp('test-user-456');
deleteConvos.mockResolvedValue({ deletedCount: 7 });
deleteToolCalls.mockResolvedValue({ deletedCount: 12 });

View file

@ -11,6 +11,8 @@ jest.mock('@librechat/agents', () => ({
jest.mock('@librechat/api', () => ({
unescapeLaTeX: jest.fn((x) => x),
countTokens: jest.fn().mockResolvedValue(10),
sendFeedbackScore: jest.fn().mockResolvedValue(undefined),
traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`),
}));
jest.mock('@librechat/data-schemas', () => ({
@ -49,6 +51,7 @@ jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next()
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, res, next) => next(),
validateMessageReq: (req, res, next) => next(),
configMiddleware: (req, res, next) => next(),
}));
jest.mock('~/db/models', () => ({

View file

@ -25,6 +25,16 @@ jest.mock('@librechat/api', () => ({
GenerationJobManager: mockGenerationJobManager,
}));
jest.mock('@librechat/api/telemetry', () => ({
createSseStreamTelemetry: jest.fn(() => ({
recordHeadersFlushed: jest.fn(),
recordWrite: jest.fn(),
recordFinalEventEmitted: jest.fn(),
recordErrorEventEmitted: jest.fn(),
recordSubscribeFailed: jest.fn(),
})),
}));
jest.mock('~/models', () => ({
saveMessage: jest.fn(),
}));
@ -67,7 +77,8 @@ function mockSubscribeSuccess() {
describe('SSE stream tenant isolation', () => {
beforeEach(() => {
jest.clearAllMocks();
Object.values(mockGenerationJobManager).forEach((mock) => mock.mockReset());
mockGenerationJobManager.getActiveJobIdsForUser.mockResolvedValue([]);
mockUserId = 'user-123';
mockTenantId = undefined;
});

View file

@ -11,14 +11,15 @@ const mockLogger = {
debug: jest.fn(),
};
let mockOpenIDCallbackAuthenticatorOptions;
const mockOAuthHandler = jest.fn((_req, res) => res.status(204).end());
const mockOpenIDCallbackMiddleware = jest.fn((_req, _res, next) => next());
let mockOpenIDCallbackAuthenticatorOptions;
const mockCreateOpenIDCallbackAuthenticator = jest.fn((options) => {
const setMockOpenIDCallbackAuthenticator = (options) => {
mockOpenIDCallbackAuthenticatorOptions = options;
return mockOpenIDCallbackMiddleware;
});
const mockBuildOAuthFailureLog = jest.fn(({ provider, req, err, info, defaultMessage }) => ({
};
const mockCreateOpenIDCallbackAuthenticator = jest.fn(setMockOpenIDCallbackAuthenticator);
const buildMockOAuthFailureLog = ({ provider, req, err, info, defaultMessage }) => ({
provider,
code: err?.code ?? info?.code ?? info?.error ?? req.query?.error,
name: err?.name ?? info?.name,
@ -37,17 +38,17 @@ const mockBuildOAuthFailureLog = jest.fn(({ provider, req, err, info, defaultMes
path: req.path,
forwarded_for: req.headers?.['x-forwarded-for'],
user_agent: req.headers?.['user-agent'],
}));
const mockGetOAuthFailureMessage = jest.fn(
(req) =>
req.session?.messages?.pop() ??
req.query?.error_description ??
req.query?.error ??
'OAuth authentication failed',
);
const mockRedirectToAuthFailure = jest.fn((res, { clientDomain, authFailedError }) =>
res.redirect(`${clientDomain}/login?redirect=false&error=${authFailedError}`),
);
});
const mockBuildOAuthFailureLog = jest.fn(buildMockOAuthFailureLog);
const getMockOAuthFailureMessage = (req) =>
req.session?.messages?.pop() ??
req.query?.error_description ??
req.query?.error ??
'OAuth authentication failed';
const mockGetOAuthFailureMessage = jest.fn(getMockOAuthFailureMessage);
const redirectMockToAuthFailure = (res, { clientDomain, authFailedError }) =>
res.redirect(`${clientDomain}/login?redirect=false&error=${authFailedError}`);
const mockRedirectToAuthFailure = jest.fn(redirectMockToAuthFailure);
const mockPassportAuthenticate = jest.fn(() => (_req, _res, next) => next());
jest.mock('passport', () => ({
@ -105,8 +106,11 @@ afterAll(() => {
});
function getOAuthRouter() {
jest.resetModules();
return require('./oauth');
let router;
jest.isolateModules(() => {
router = require('./oauth');
});
return router;
}
function createApp(sessionMessages) {
@ -126,19 +130,17 @@ function createApp(sessionMessages) {
describe('OAuth route failure logging', () => {
beforeEach(() => {
mockLogger.warn.mockClear();
mockLogger.error.mockClear();
mockLogger.info.mockClear();
mockLogger.debug.mockClear();
mockOAuthHandler.mockClear();
mockOpenIDCallbackMiddleware.mockClear();
mockBuildOAuthFailureLog.mockClear();
mockGetOAuthFailureMessage.mockClear();
mockRedirectToAuthFailure.mockClear();
mockPassportAuthenticate.mockClear();
jest.resetModules();
jest.clearAllMocks();
process.env.DOMAIN_CLIENT = 'http://client.test';
mockOpenIDCallbackAuthenticatorOptions = undefined;
mockPassportAuthenticate.mockImplementation(() => (_req, _res, next) => next());
mockCreateOpenIDCallbackAuthenticator.mockImplementation(setMockOpenIDCallbackAuthenticator);
mockOpenIDCallbackMiddleware.mockImplementation((_req, _res, next) => next());
mockOAuthHandler.mockImplementation((_req, res) => res.status(204).end());
mockBuildOAuthFailureLog.mockImplementation(buildMockOAuthFailureLog);
mockGetOAuthFailureMessage.mockImplementation(getMockOAuthFailureMessage);
mockRedirectToAuthFailure.mockImplementation(redirectMockToAuthFailure);
});
it('wires the package OpenID callback middleware into the route', async () => {

View file

@ -56,6 +56,10 @@ function setTestUser(app, user) {
}
beforeAll(async () => {
if (mongoose.connection.readyState !== 0) {
await mongoose.disconnect();
}
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
@ -97,8 +101,10 @@ beforeAll(async () => {
// Set default user
currentTestUser = testUsers.owner;
// Import routes after middleware is set up
promptRoutes = require('./prompts');
// Import routes after middleware is set up and test-local mocks are registered.
jest.isolateModules(() => {
promptRoutes = require('./prompts');
});
app.use('/api/prompts', promptRoutes);
});

View file

@ -1,45 +1,33 @@
jest.mock(
'@librechat/data-schemas',
() => ({
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
getTenantId: jest.fn(() => undefined),
DEFAULT_SESSION_EXPIRY: 900000,
DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000,
}),
{ virtual: true },
);
jest.mock(
'librechat-data-provider',
() => ({
ErrorTypes: {},
SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' },
errorsToString: jest.fn(),
}),
{ virtual: true },
);
jest.mock(
'@librechat/api',
() => ({
isEnabled: jest.fn((val) => val === 'true' || val === true),
checkEmailConfig: jest.fn(),
isEmailDomainAllowed: jest.fn(),
math: jest.fn((val, fallback) => (val ? Number(val) : fallback)),
shouldUseSecureCookie: jest.fn(() => false),
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
setCloudFrontCookies: jest.fn(() => true),
getCloudFrontConfig: jest.fn(() => ({
domain: 'https://cdn.example.com',
imageSigning: 'cookies',
cookieDomain: '.example.com',
privateKey: 'test-private-key',
keyPairId: 'K123ABC',
})),
parseCloudFrontCookieScope: jest.fn(() => null),
CLOUDFRONT_SCOPE_COOKIE: 'LibreChat-CloudFront-Scope',
}),
{ virtual: true },
);
jest.mock('~/models', () => ({
const dataSchemasMock = {
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
getTenantId: jest.fn(() => undefined),
DEFAULT_SESSION_EXPIRY: 900000,
DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000,
};
const dataProviderMock = {
ErrorTypes: {},
SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' },
errorsToString: jest.fn(),
};
const apiMock = {
isEnabled: jest.fn((val) => val === 'true' || val === true),
checkEmailConfig: jest.fn(),
isEmailDomainAllowed: jest.fn(),
math: jest.fn((val, fallback) => (val ? Number(val) : fallback)),
shouldUseSecureCookie: jest.fn(() => false),
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
setCloudFrontCookies: jest.fn(() => true),
getCloudFrontConfig: jest.fn(() => ({
domain: 'https://cdn.example.com',
imageSigning: 'cookies',
cookieDomain: '.example.com',
privateKey: 'test-private-key',
keyPairId: 'K123ABC',
})),
parseCloudFrontCookieScope: jest.fn(() => null),
CLOUDFRONT_SCOPE_COOKIE: 'LibreChat-CloudFront-Scope',
};
const modelsMock = {
findUser: jest.fn(),
findToken: jest.fn(),
createUser: jest.fn(),
@ -54,8 +42,8 @@ jest.mock('~/models', () => ({
generateToken: jest.fn(),
deleteUserById: jest.fn(),
generateRefreshToken: jest.fn(),
}));
jest.mock('~/strategies/validators', () => ({
};
const validatorsMock = {
registerSchema: {
safeParse: jest.fn((user) => ({
success: true,
@ -68,9 +56,18 @@ jest.mock('~/strategies/validators', () => ({
},
})),
},
}));
jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() }));
jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() }));
};
const configMock = { getAppConfig: jest.fn() };
const serverUtilsMock = { sendEmail: jest.fn() };
jest.resetModules();
jest.doMock('@librechat/data-schemas', () => dataSchemasMock);
jest.doMock('librechat-data-provider', () => dataProviderMock);
jest.doMock('@librechat/api', () => apiMock);
jest.doMock('~/models', () => modelsMock);
jest.doMock('~/strategies/validators', () => validatorsMock);
jest.doMock('~/server/services/Config', () => configMock);
jest.doMock('~/server/utils', () => serverUtilsMock);
const {
checkEmailConfig,
@ -80,9 +77,9 @@ const {
setCloudFrontCookies,
getCloudFrontConfig,
parseCloudFrontCookieScope,
} = require('@librechat/api');
} = apiMock;
const jwt = require('jsonwebtoken');
const { logger, getTenantId } = require('@librechat/data-schemas');
const { logger, getTenantId } = dataSchemasMock;
const {
findUser,
findToken,
@ -95,9 +92,9 @@ const {
createSession,
createToken,
deleteTokens,
} = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const { sendEmail } = require('~/server/utils');
} = modelsMock;
const { getAppConfig } = configMock;
const { sendEmail } = serverUtilsMock;
const bcrypt = require('bcryptjs');
const {
setOpenIDAuthTokens,
@ -130,6 +127,36 @@ function mockRequest(sessionData = {}, cookies = {}) {
};
}
const originalEnv = { ...process.env };
const defaultCloudFrontCookieConfig = {
domain: 'https://cdn.example.com',
imageSigning: 'cookies',
cookieDomain: '.example.com',
privateKey: 'test-private-key',
keyPairId: 'K123ABC',
};
beforeEach(() => {
jest.clearAllMocks();
process.env = {
...originalEnv,
JWT_REFRESH_SECRET: 'test-refresh-secret',
OPENID_REUSE_TOKENS: 'true',
};
checkEmailConfig.mockReturnValue(false);
shouldUseSecureCookie.mockReturnValue(false);
isEmailDomainAllowed.mockReturnValue(true);
resolveAppConfigForUser.mockResolvedValue({});
setCloudFrontCookies.mockReturnValue(true);
getCloudFrontConfig.mockReturnValue(defaultCloudFrontCookieConfig);
parseCloudFrontCookieScope.mockReturnValue(null);
getTenantId.mockReturnValue(undefined);
});
afterAll(() => {
process.env = originalEnv;
});
describe('setOpenIDAuthTokens', () => {
const env = process.env;

View file

@ -1,9 +1,9 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const express = require('express');
const request = require('supertest');
const zlib = require('zlib');
const staticCache = require('../staticCache');
const binaryParser = (res, callback) => {
const chunks = [];
@ -18,15 +18,9 @@ describe('staticCache', () => {
let indexFile;
let manifestFile;
let swFile;
let staticCache;
beforeAll(() => {
// Create a test directory and files
testDir = path.join(__dirname, 'test-static');
if (!fs.existsSync(testDir)) {
fs.mkdirSync(testDir, { recursive: true });
}
// Create test files
const createTestFiles = () => {
testFile = path.join(testDir, 'test.js');
indexFile = path.join(testDir, 'index.html');
manifestFile = path.join(testDir, 'manifest.json');
@ -62,6 +56,10 @@ describe('staticCache', () => {
const distImagesDir = path.join(testDir, 'dist', 'images');
fs.mkdirSync(distImagesDir, { recursive: true });
fs.writeFileSync(path.join(distImagesDir, 'logo.png'), 'fake-png-data');
};
beforeAll(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'librechat-static-cache-'));
});
afterAll(() => {
@ -79,6 +77,15 @@ describe('staticCache', () => {
delete process.env.STATIC_CACHE_S_MAX_AGE;
delete process.env.STATIC_CACHE_MAX_AGE;
delete process.env.ENABLE_STATIC_ASSET_BROTLI;
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
fs.mkdirSync(testDir, { recursive: true });
createTestFiles();
jest.resetModules();
staticCache = require('../staticCache');
});
describe('cache headers in production', () => {
beforeEach(() => {

View file

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import type { FileConfigInput } from 'librechat-data-provider';
import UploadSkillDialog from '../UploadSkillDialog';
@ -70,12 +70,14 @@ jest.mock('~/utils', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
}));
function getFileInput(container: HTMLElement): HTMLInputElement {
const input = container.querySelector('input[type="file"]');
if (!(input instanceof HTMLInputElement)) {
throw new Error('Upload input was not rendered');
}
return input;
async function getFileInput(baseElement: HTMLElement): Promise<HTMLInputElement> {
return await waitFor(() => {
const input = baseElement.querySelector('input[type="file"]');
if (!(input instanceof HTMLInputElement)) {
throw new Error('Upload input was not rendered');
}
return input;
});
}
describe('UploadSkillDialog', () => {
@ -106,13 +108,13 @@ describe('UploadSkillDialog', () => {
expect(screen.getByText('File size must not exceed 1.06 MB')).toBeInTheDocument();
});
it('rejects files above the configured skill import limit before upload', () => {
const { container } = render(<UploadSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
it('rejects files above the configured skill import limit before upload', async () => {
const { baseElement } = render(<UploadSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
const file = new File([new Uint8Array(1024 * 1024 + 1)], 'too-large.skill', {
type: 'application/zip',
});
fireEvent.change(getFileInput(container), {
fireEvent.change(await getFileInput(baseElement), {
target: {
files: [file],
},
@ -125,14 +127,14 @@ describe('UploadSkillDialog', () => {
});
});
it('uploads files exactly at the configured skill import limit', () => {
it('uploads files exactly at the configured skill import limit', async () => {
const appendSpy = jest.spyOn(FormData.prototype, 'append');
const { container } = render(<UploadSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
const { baseElement } = render(<UploadSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
const file = new File([new Uint8Array(1024 * 1024)], 'exact-limit.skill', {
type: 'application/zip',
});
fireEvent.change(getFileInput(container), {
fireEvent.change(await getFileInput(baseElement), {
target: {
files: [file],
},
@ -144,14 +146,14 @@ describe('UploadSkillDialog', () => {
appendSpy.mockRestore();
});
it('uploads files under the configured skill import limit', () => {
it('uploads files under the configured skill import limit', async () => {
const appendSpy = jest.spyOn(FormData.prototype, 'append');
const { container } = render(<UploadSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
const { baseElement } = render(<UploadSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
const file = new File([new Uint8Array(1024)], 'small.skill', {
type: 'application/zip',
});
fireEvent.change(getFileInput(container), {
fireEvent.change(await getFileInput(baseElement), {
target: {
files: [file],
},

View file

@ -78,8 +78,8 @@
"test:all": "npm run test:client && npm run test:api && npm run test:packages:api && npm run test:packages:data-provider && npm run test:packages:data-schemas",
"e2e:update": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.local.ts --update-snapshots",
"e2e:report": "npx playwright show-report e2e/playwright-report",
"lint:fix": "eslint --fix \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"",
"lint": "eslint \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"",
"lint:fix": "eslint --fix --no-error-on-unmatched-pattern \"**/*.{js,jsx,ts,tsx}\"",
"lint": "eslint --no-error-on-unmatched-pattern \"**/*.{js,jsx,ts,tsx}\"",
"sort-imports": "node scripts/sort-imports.mts",
"sort-imports:check": "node scripts/sort-imports.mts --check",
"format": "npx prettier --write \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"",

View file

@ -178,6 +178,7 @@ type CreateRunFn = (params: {
requestBody: Record<string, unknown>;
user: Record<string, unknown>;
tenantId?: string;
appConfig?: AppConfig;
tokenCounter?: (message: unknown) => number;
}) => Promise<{
Graph?: unknown;

View file

@ -62,9 +62,7 @@ export function buildLangfuseConfig({
normalizeString(fanout?.collectorUrl) ??
normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
const baseUrl =
fanoutEnabled && fanoutCollectorUrl
? fanoutCollectorUrl
: normalizeString(config?.baseUrl);
fanoutEnabled && fanoutCollectorUrl ? fanoutCollectorUrl : normalizeString(config?.baseUrl);
if (baseUrl) {
langfuse.baseUrl = baseUrl;

View file

@ -1,4 +1,4 @@
import mongoose, { Types } from 'mongoose';
import mongoose, { Mongoose, Types } from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
import type { IConfig } from '~/types';
@ -6,24 +6,25 @@ import { createConfigMethods } from './config';
import configSchema from '~/schema/config';
let mongoServer: MongoMemoryServer;
let testMongoose: Mongoose;
let methods: ReturnType<typeof createConfigMethods>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
if (!mongoose.models.Config) {
mongoose.model<IConfig>('Config', configSchema);
}
methods = createConfigMethods(mongoose);
testMongoose = new mongoose.Mongoose();
await testMongoose.connect(mongoServer.getUri());
testMongoose.model<IConfig>('Config', configSchema.clone());
await testMongoose.models.Config.init();
methods = createConfigMethods(testMongoose as unknown as typeof mongoose);
});
afterAll(async () => {
await mongoose.disconnect();
await testMongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.models.Config.deleteMany({});
await testMongoose.models.Config.deleteMany({});
});
describe('upsertConfig tombstone preservation', () => {
@ -61,7 +62,7 @@ describe('upsertConfig tombstone preservation', () => {
10,
);
const count = await mongoose.models.Config.countDocuments({});
const count = await testMongoose.models.Config.countDocuments({});
expect(count).toBe(1);
});
@ -486,7 +487,7 @@ describe('expectEmpty atomic guard', () => {
});
expect(result).toBeTruthy();
const remaining = await mongoose.models.Config.countDocuments({});
const remaining = await testMongoose.models.Config.countDocuments({});
expect(remaining).toBe(0);
});
@ -504,12 +505,12 @@ describe('expectEmpty atomic guard', () => {
});
expect(result).toBeNull();
const remaining = await mongoose.models.Config.countDocuments({});
const remaining = await testMongoose.models.Config.countDocuments({});
expect(remaining).toBe(1);
});
it('deleteConfig with expectEmpty returns null when tombstones is non-empty (doc preserved)', async () => {
await mongoose.models.Config.create({
await testMongoose.models.Config.create({
principalType: PrincipalType.ROLE,
principalId: 'admin',
principalModel: PrincipalModel.ROLE,
@ -525,7 +526,7 @@ describe('expectEmpty atomic guard', () => {
});
expect(result).toBeNull();
const remaining = await mongoose.models.Config.countDocuments({});
const remaining = await testMongoose.models.Config.countDocuments({});
expect(remaining).toBe(1);
});

View file

@ -21,10 +21,11 @@ let methods: ReturnType<typeof createSystemGrantMethods>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
SystemGrant =
mongoose.models.SystemGrant || mongoose.model<t.ISystemGrant>('SystemGrant', systemGrantSchema);
await SystemGrant.init();
methods = createSystemGrantMethods(mongoose);
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {