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.
This commit is contained in:
Atef Bellaaj 2026-05-14 15:32:28 +02:00
parent 3cc7a8e79e
commit ac4aa80f10
4 changed files with 256 additions and 0 deletions

88
.github/workflows/config-review.yml vendored Normal file
View file

@ -0,0 +1,88 @@
name: Config Migration Tests
on:
pull_request:
paths:
- 'config/**'
- 'api/models/**'
- 'api/db/**'
- 'packages/data-schemas/src/**'
- 'packages/data-provider/src/**'
- 'packages/api/src/acl/**'
- 'packages/api/src/shared-links/**'
env:
NODE_ENV: CI
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
jobs:
test-config:
name: 'Tests: config migrations'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
uses: actions/setup-node@v4
with:
node-version: '20.19'
- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
api/node_modules
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Restore data-provider build cache
id: cache-data-provider
uses: actions/cache@v4
with:
path: packages/data-provider/dist
key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
- name: Build data-provider
if: steps.cache-data-provider.outputs.cache-hit != 'true'
run: npm run build:data-provider
- name: Restore data-schemas build cache
id: cache-data-schemas
uses: actions/cache@v4
with:
path: packages/data-schemas/dist
key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
- name: Build data-schemas
if: steps.cache-data-schemas.outputs.cache-hit != 'true'
run: npm run build:data-schemas
- name: Restore api build cache
id: cache-api
uses: actions/cache@v4
with:
path: packages/api/dist
key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/server-rollup.config.js', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json') }}
- name: Build api
if: steps.cache-api.outputs.cache-hit != 'true'
run: npm run build:api
- name: Create empty auth.json file
run: |
mkdir -p api/data
echo '{}' > api/data/auth.json
- name: Prepare .env.test file
run: cp api/test/.env.test.example api/test/.env.test
- name: Run config migration tests
run: npm run test:config

View file

@ -0,0 +1,149 @@
jest.mock('../connect', () => jest.fn().mockResolvedValue(true));
jest.mock('@librechat/api', () => ({
ensureRequiredCollectionsExist: jest.fn().mockResolvedValue(undefined),
matchModelName: jest.fn(),
findMatchingPattern: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() },
}));
jest.mock('~/cache/getLogStores', () => jest.fn());
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { createMethods } = require('@librechat/data-schemas');
describe('migrate-shared-link-permissions', () => {
let mongoServer;
let SharedLink, AclEntry;
let migrateSharedLinkPermissions;
const testUserId = new mongoose.Types.ObjectId();
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
const migration = require('../migrate-shared-link-permissions');
migrateSharedLinkPermissions = migration.migrateSharedLinkPermissions;
SharedLink = mongoose.models.SharedLink;
AclEntry = mongoose.models.AclEntry;
await createMethods(mongoose).seedDefaultRoles();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await AclEntry.deleteMany({});
await SharedLink.deleteMany({});
jest.restoreAllMocks();
});
async function createLegacyLink(isPublic = true) {
const link = await SharedLink.create({
shareId: `share-${Date.now()}-${Math.random()}`,
conversationId: 'convo1',
user: testUserId,
messages: [],
});
await mongoose.connection.db
.collection('sharedlinks')
.updateOne({ _id: link._id }, { $set: { isPublic } });
return link;
}
test('removes isPublic from all links on success', async () => {
const link1 = await createLegacyLink(true);
const link2 = await createLegacyLink(true);
const result = await migrateSharedLinkPermissions({ dryRun: false, batchSize: 100 });
expect(result.errors).toBe(0);
expect(result.failedLinkCount).toBe(0);
const raw1 = await mongoose.connection.db
.collection('sharedlinks')
.findOne({ _id: link1._id });
const raw2 = await mongoose.connection.db
.collection('sharedlinks')
.findOne({ _id: link2._id });
expect(raw1).not.toHaveProperty('isPublic');
expect(raw2).not.toHaveProperty('isPublic');
});
test('preserves isPublic on links with partial write errors', async () => {
const link1 = await createLegacyLink(true);
const link2 = await createLegacyLink(true);
const link3 = await createLegacyLink(true);
const originalBulkWrite = AclEntry.bulkWrite.bind(AclEntry);
jest.spyOn(AclEntry, 'bulkWrite').mockImplementationOnce(async (ops, options) => {
const failedIndices = [];
const successOps = [];
for (let i = 0; i < ops.length; i++) {
if (ops[i].updateOne.filter.resourceId.equals(link2._id)) {
failedIndices.push(i);
} else {
successOps.push(ops[i]);
}
}
if (successOps.length > 0) {
await originalBulkWrite(successOps, options);
}
const error = new Error('Partial bulk write failure');
error.writeErrors = failedIndices.map((idx) => ({
index: idx,
errmsg: 'Simulated write error',
}));
throw error;
});
const result = await migrateSharedLinkPermissions({ dryRun: false, batchSize: 100 });
expect(result.failedLinkCount).toBe(1);
expect(result.errors).toBeGreaterThan(0);
const raw1 = await mongoose.connection.db
.collection('sharedlinks')
.findOne({ _id: link1._id });
const raw2 = await mongoose.connection.db
.collection('sharedlinks')
.findOne({ _id: link2._id });
const raw3 = await mongoose.connection.db
.collection('sharedlinks')
.findOne({ _id: link3._id });
expect(raw2).toHaveProperty('isPublic', true);
expect(raw1).not.toHaveProperty('isPublic');
expect(raw3).not.toHaveProperty('isPublic');
});
test('preserves isPublic on all batch links when bulk write fails entirely', async () => {
const link1 = await createLegacyLink(true);
const link2 = await createLegacyLink(true);
jest.spyOn(AclEntry, 'bulkWrite').mockRejectedValueOnce(new Error('Connection lost'));
const result = await migrateSharedLinkPermissions({ dryRun: false, batchSize: 100 });
expect(result.failedLinkCount).toBe(2);
const raw1 = await mongoose.connection.db
.collection('sharedlinks')
.findOne({ _id: link1._id });
const raw2 = await mongoose.connection.db
.collection('sharedlinks')
.findOne({ _id: link2._id });
expect(raw1).toHaveProperty('isPublic', true);
expect(raw2).toHaveProperty('isPublic', true);
});
});

18
config/jest.config.js Normal file
View file

@ -0,0 +1,18 @@
const path = require('path');
const apiConfig = require('../api/jest.config');
const apiDir = path.resolve(__dirname, '..', 'api');
const resolvedMapper = Object.fromEntries(
Object.entries(apiConfig.moduleNameMapper).map(([key, value]) => [
key,
value.replace('<rootDir>', apiDir),
]),
);
module.exports = {
...apiConfig,
roots: ['<rootDir>'],
setupFiles: apiConfig.setupFiles.map((f) => path.resolve(apiDir, f)),
moduleNameMapper: resolvedMapper,
};

View file

@ -67,6 +67,7 @@
"test:packages:api": "cd packages/api && npm run test:ci",
"test:packages:data-provider": "cd packages/data-provider && npm run test:ci",
"test:packages:data-schemas": "cd packages/data-schemas && npm run test:ci",
"test:config": "jest --config config/jest.config.js",
"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",