From ac4aa80f1061304c2cf0b7180663755aab454f2b Mon Sep 17 00:00:00 2001 From: Atef Bellaaj Date: Thu, 14 May 2026 15:32:28 +0200 Subject: [PATCH] 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. --- .github/workflows/config-review.yml | 88 +++++++++++ .../migrate-shared-link-permissions.spec.js | 149 ++++++++++++++++++ config/jest.config.js | 18 +++ package.json | 1 + 4 files changed, 256 insertions(+) create mode 100644 .github/workflows/config-review.yml create mode 100644 config/__tests__/migrate-shared-link-permissions.spec.js create mode 100644 config/jest.config.js diff --git a/.github/workflows/config-review.yml b/.github/workflows/config-review.yml new file mode 100644 index 0000000000..e08662aa1c --- /dev/null +++ b/.github/workflows/config-review.yml @@ -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 diff --git a/config/__tests__/migrate-shared-link-permissions.spec.js b/config/__tests__/migrate-shared-link-permissions.spec.js new file mode 100644 index 0000000000..ebc6183803 --- /dev/null +++ b/config/__tests__/migrate-shared-link-permissions.spec.js @@ -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); + }); +}); diff --git a/config/jest.config.js b/config/jest.config.js new file mode 100644 index 0000000000..f3bccbb890 --- /dev/null +++ b/config/jest.config.js @@ -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('', apiDir), + ]), +); + +module.exports = { + ...apiConfig, + roots: [''], + setupFiles: apiConfig.setupFiles.map((f) => path.resolve(apiDir, f)), + moduleNameMapper: resolvedMapper, +}; diff --git a/package.json b/package.json index 8d80fe1ca7..d160ae6a92 100644 --- a/package.json +++ b/package.json @@ -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",