Skip to main content
Glama
database-migration.test.ts28.4 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { WithId } from '@medplum/core'; import { allOk, badRequest, createReference, getReferenceString, parseSearchRequest } from '@medplum/core'; import type { AsyncJob, Login, Practitioner, Project, ProjectMembership, User } from '@medplum/fhirtypes'; import type { Queue } from 'bullmq'; import { randomUUID } from 'crypto'; import express from 'express'; import type { Pool, PoolClient } from 'pg'; import request from 'supertest'; import { initApp, initAppServices, shutdownApp } from './app'; import { getConfig, loadTestConfig } from './config/loader'; import { DatabaseMode, getDatabasePool } from './database'; import type { Repository } from './fhir/repo'; import { getSystemRepo } from './fhir/repo'; import { globalLogger } from './logger'; import * as migrationSql from './migration-sql'; import type { CustomPostDeployMigration, CustomPostDeployMigrationJobData, PostDeployJobData, } from './migrations/data/types'; import * as migrateModule from './migrations/migrate'; import { getPendingPostDeployMigration, maybeStartPostDeployMigration } from './migrations/migration-utils'; import { getLatestPostDeployMigrationVersion, MigrationVersion } from './migrations/migration-versions'; import type { MigrationAction, MigrationActionResult } from './migrations/types'; import { generateAccessToken } from './oauth/keys'; import { createTestProject, withTestContext } from './test.setup'; import * as version from './util/version'; import { PostDeployMigrationQueueName, prepareCustomMigrationJobData } from './workers/post-deploy-migration'; import type { ReindexJobData } from './workers/reindex'; import { getReindexQueue, prepareReindexJobData, ReindexJob } from './workers/reindex'; import { queueRegistry } from './workers/utils'; const DEFAULT_SERVER_VERSION = '3.3.0'; const DEFAULT_POST_DEPLOY_VERSION = 0; const mockValues = { serverVersion: DEFAULT_SERVER_VERSION, postDeployVersion: DEFAULT_POST_DEPLOY_VERSION, }; const mockGetPostDeployVersion = jest .fn<ReturnType<typeof migrationSql.getPostDeployVersion>, Parameters<typeof migrationSql.getPostDeployVersion>>() .mockImplementation(async () => { return mockValues.postDeployVersion; }); const mockMarkPostDeployMigrationCompleted = jest .fn< ReturnType<typeof migrationSql.markPostDeployMigrationCompleted>, Parameters<typeof migrationSql.markPostDeployMigrationCompleted> >() .mockImplementation(async (_pool: Pool | PoolClient, dataVersion: number) => { if (!Number.isInteger(dataVersion)) { throw new Error('Invalid data version in mocked markPostDeployMigrationCompleted: ' + dataVersion); } return dataVersion; }); jest.mock('./migrations/data/v1', () => { const { prepareCustomMigrationJobData, runCustomMigration } = jest.requireActual('./workers/post-deploy-migration'); const migration: CustomPostDeployMigration = { type: 'custom', prepareJobData: (asyncJob) => prepareCustomMigrationJobData(asyncJob), run: function (repo, jobData) { return runCustomMigration(repo, jobData, async () => { const results: MigrationActionResult[] = []; results.push({ name: 'nothing', durationMs: 5 }); return results; }); }, }; return { migration }; }); jest.mock('./migrations/data/index', () => { return { v1: jest.requireMock('./migrations/data/v1'), }; }); function getQueueAddSpy(): jest.MockedFunctionDeep<Queue<PostDeployJobData>['add']> { const queue = queueRegistry.get<PostDeployJobData>(PostDeployMigrationQueueName); if (!queue) { throw new Error(`Job queue ${PostDeployMigrationQueueName} not available`); } return jest.mocked(queue.add); } function getReindexQueueAddSpy(): jest.MockedFunctionDeep<Queue<ReindexJobData>['add']> { const queue = getReindexQueue(); if (!queue) { throw new Error(`Reindex job queue not available`); } return jest.mocked(queue.add); } function setMigrationsConfig(preDeploy: boolean, postDeploy: boolean): void { const config = getConfig(); config.database.runMigrations = preDeploy; config.database.disableRunPostDeployMigrations = !postDeploy; } async function expungePostDeployMigrationAsyncJob(repo: Repository): Promise<void> { const jobs = (await repo.searchResources(parseSearchRequest('AsyncJob?type=data-migration'))) as WithId<AsyncJob>[]; await repo.expungeResources( 'AsyncJob', jobs.map((job) => job.id) ); } describe('Database migrations', () => { beforeAll(async () => { console.log = jest.fn(); jest.spyOn(migrationSql, 'getPostDeployVersion').mockImplementation(mockGetPostDeployVersion); jest .spyOn(migrationSql, 'markPostDeployMigrationCompleted') .mockImplementation(mockMarkPostDeployMigrationCompleted); jest.spyOn(version, 'getServerVersion').mockImplementation(() => mockValues.serverVersion); await loadTestConfig(); // We want a clean history of post-deploy migration AsyncJob. init and shutdown the app // to facilitate expunging all relevant AsyncJob await initAppServices(getConfig()); await expungePostDeployMigrationAsyncJob(getSystemRepo()); await shutdownApp(); }); beforeEach(() => { jest.clearAllMocks(); // By default, disable both pre-deploy and post-deploy migrations setMigrationsConfig(false, false); // Reset mocked return values as well mockValues.serverVersion = DEFAULT_SERVER_VERSION; mockValues.postDeployVersion = DEFAULT_POST_DEPLOY_VERSION; }); describe('Database startup check', () => { beforeEach(() => { // Enable pre and post-deploy migrations since those are the code paths being tested setMigrationsConfig(true, true); }); afterEach(async () => { await expungePostDeployMigrationAsyncJob(getSystemRepo()); await shutdownApp(); }); test('Current version is greater than `requiredBefore`', () => withTestContext(async () => { mockValues.serverVersion = '4.0.0'; process.env.MEDPLUM_ENABLE_STRICT_MIGRATION_VERSION_CHECKS = 'true'; await expect(initAppServices(getConfig())).rejects.toThrow( new Error( 'Unable to run this version of Medplum server. Pending post-deploy migration v1 requires server at version 3.3.0 <= version < 4.0.0, but current server version is 4.0.0' ) ); delete process.env.MEDPLUM_ENABLE_STRICT_MIGRATION_VERSION_CHECKS; })); // 3.2.0 is less than the v1.serverVersion in the post-deploy migration manifest file, // but it should be effectively treated the same as other versions that are less than // v1.requiredBefore. test.each(['3.2.0', '3.3.0', '3.3.1', '3.4.0'])( 'Current version greater than or equal to required version and less than `requiredBefore` -- version %s', async (serverVersion) => withTestContext(async () => { const loggerInfoSpy = jest.spyOn(globalLogger, 'info'); mockValues.serverVersion = serverVersion; mockValues.postDeployVersion = 0; jest.spyOn(process, 'exit').mockImplementation(() => { throw new Error('Process exited with exit code 1'); }); await expect(initAppServices(getConfig())).resolves.toBeUndefined(); const queueAddSpy = getQueueAddSpy(); expect(queueAddSpy).toHaveBeenCalledTimes(1); const jobData = queueAddSpy.mock.calls[0][1]; const asyncJob = await getSystemRepo().readResource<AsyncJob>('AsyncJob', jobData.asyncJobId); expect(jobData).toEqual( expect.objectContaining<CustomPostDeployMigrationJobData>({ ...prepareCustomMigrationJobData(asyncJob), // requestId and traceId will likely be different since in the mocked v1 migration, // the call to prepareJobData is not within `withTestContext` requestId: expect.any(String), traceId: expect.any(String), }) ); expect(asyncJob).toEqual( expect.objectContaining({ type: 'data-migration', dataVersion: 1, }) ); loggerInfoSpy.mockRestore(); }) ); }); describe("maybeStartDataMigrations -- Schema migrations didn't run", () => { beforeEach(async () => { await initAppServices(getConfig()); }); afterEach(async () => { await expungePostDeployMigrationAsyncJob(getSystemRepo()); await shutdownApp(); }); test('Schema migrations did not run', () => withTestContext(async () => { await expect(maybeStartPostDeployMigration()).rejects.toThrow( 'Cannot run post-deploy migration since pre-deploy migrations are disabled' ); })); }); describe('maybeStartPostDeployMigration -- pre-deploy migrations ran', () => { let queueAddSpy: jest.SpyInstance; beforeEach(async () => { setMigrationsConfig(true, false); await initAppServices(getConfig()); queueAddSpy = getQueueAddSpy(); queueAddSpy.mockClear(); }); afterEach(async () => { await expungePostDeployMigrationAsyncJob(getSystemRepo()); await shutdownApp(); }); test('No data migration in progress -- start migration job', () => withTestContext(async () => { mockValues.serverVersion = '3.3.0'; const asyncJob = await maybeStartPostDeployMigration(); if (!asyncJob) { throw new Error('Expected to start post-deploy migration'); } expect(asyncJob).toMatchObject<WithId<AsyncJob>>({ id: expect.any(String), type: 'data-migration', resourceType: 'AsyncJob', status: 'accepted', request: expect.any(String), requestTime: expect.any(String), dataVersion: 1, minServerVersion: '3.3.0', }); const expectedJobData = prepareCustomMigrationJobData(asyncJob); expect(queueAddSpy).toHaveBeenCalledTimes(1); expect(queueAddSpy.mock.lastCall[1]).toEqual(expectedJobData); })); test('No pending data migration', () => withTestContext(async () => { const lastVersion = getLatestPostDeployMigrationVersion(); mockValues.postDeployVersion = lastVersion; await expect(maybeStartPostDeployMigration()).resolves.toBeUndefined(); expect(queueAddSpy).not.toHaveBeenCalled(); })); test('Existing AsyncJob that gets requeued and completes', () => withTestContext(async () => { const asyncJob = await getSystemRepo().createResource<AsyncJob>({ resourceType: 'AsyncJob', type: 'data-migration', status: 'accepted', requestTime: new Date().toISOString(), request: 'mock-job', dataVersion: 1, minServerVersion: '3.3.0', }); await expect(maybeStartPostDeployMigration()).resolves.toMatchObject({ id: asyncJob.id, type: 'data-migration', status: 'accepted', }); const expectedJobData = prepareCustomMigrationJobData(asyncJob); expect(queueAddSpy).toHaveBeenCalledTimes(1); expect(queueAddSpy.mock.lastCall[1]).toEqual(expectedJobData); })); test('Existing data migration job in a project is ignored', () => withTestContext(async () => { const project = await getSystemRepo().createResource<Project>({ resourceType: 'Project' }); // Not using system repo to create the job so that AsyncJob is in a project const projectAsyncJob = await getSystemRepo().createResource<AsyncJob>({ resourceType: 'AsyncJob', meta: { project: project.id, }, type: 'data-migration', status: 'accepted', requestTime: new Date().toISOString(), request: 'mock-job', dataVersion: 1, minServerVersion: '3.3.0', }); const asyncJob = await maybeStartPostDeployMigration(); if (!asyncJob) { throw new Error('Expected to start post-deploy migration'); } expect(asyncJob).toMatchObject<WithId<AsyncJob>>({ id: expect.any(String), type: 'data-migration', resourceType: 'AsyncJob', status: 'accepted', request: expect.any(String), requestTime: expect.any(String), dataVersion: 1, minServerVersion: '3.3.0', }); const expectedJobData = prepareCustomMigrationJobData(asyncJob); expect(queueAddSpy).toHaveBeenCalledTimes(1); expect(queueAddSpy.mock.lastCall[1]).toEqual(expectedJobData); // The project AsyncJob should not be found/returned expect(asyncJob.id).toBeDefined(); expect(asyncJob.id).not.toStrictEqual(projectAsyncJob.id); })); test('Multiple data migration jobs with accepted status', () => withTestContext(async () => { const systemRepo = getSystemRepo(); await systemRepo.createResource<AsyncJob>({ resourceType: 'AsyncJob', type: 'data-migration', status: 'accepted', requestTime: new Date().toISOString(), request: 'mock-job', dataVersion: 1, minServerVersion: '3.3.0', }); await systemRepo.createResource<AsyncJob>({ resourceType: 'AsyncJob', type: 'data-migration', status: 'accepted', requestTime: new Date().toISOString(), request: 'mock-job', dataVersion: 1, minServerVersion: '3.3.0', }); await expect(maybeStartPostDeployMigration()).rejects.toThrow( 'Unable to start post-deploy migration since there are more than one existing data-migration AsyncJob with accepted status' ); expect(queueAddSpy).not.toHaveBeenCalled(); })); test('Asserted version is less than or equal to current version', () => withTestContext(async () => { mockValues.postDeployVersion = 2; await expect(maybeStartPostDeployMigration(1)).resolves.toBeUndefined(); expect(queueAddSpy).not.toHaveBeenCalled(); })); test('Asserted version is greater than current version AND there is NO pending migration', () => withTestContext(async () => { mockValues.postDeployVersion = 1; await expect(maybeStartPostDeployMigration(2)).rejects.toThrow( 'Requested post-deploy migration v2, but there are no pending post-deploy migrations.' ); expect(queueAddSpy).not.toHaveBeenCalled(); })); test('Asserted version is greater than current data version AND not the pending version', () => withTestContext(async () => { mockValues.postDeployVersion = 0; await expect( getSystemRepo().searchOne<AsyncJob>( parseSearchRequest('AsyncJob', { type: 'data-migration', status: 'accepted' }) ) ).resolves.toBeUndefined(); expect(await getPendingPostDeployMigration(getDatabasePool(DatabaseMode.WRITER))).toStrictEqual(1); await expect(maybeStartPostDeployMigration(2)).rejects.toThrow( 'Requested post-deploy migration v2, but the pending post-deploy migration is v1.' ); expect(queueAddSpy).not.toHaveBeenCalled(); })); }); describe('Reindex post-deploy migration', () => { beforeEach(async () => { setMigrationsConfig(true, false); await initAppServices(getConfig()); }); afterEach(async () => { await expungePostDeployMigrationAsyncJob(getSystemRepo()); await shutdownApp(); }); test.each([ [false, 0], [true, 0], ])('with version too low and post-deploy auto-run %s', (postDeploy, expectedQueueCalls) => withTestContext(async () => { mockValues.serverVersion = '3.2.4'; setMigrationsConfig(true, postDeploy); const systemRepo = getSystemRepo(); let asyncJob = await systemRepo.createResource<AsyncJob>({ resourceType: 'AsyncJob', type: 'data-migration', status: 'accepted', requestTime: new Date().toISOString(), request: '/admin/super/migrate', dataVersion: 1, minServerVersion: '3.3.0', }); const jobData = prepareReindexJobData(['ImmunizationEvaluation'], asyncJob.id); const result = await new ReindexJob().execute(undefined, jobData); asyncJob = await systemRepo.readResource('AsyncJob', asyncJob.id); expect(asyncJob.status).toStrictEqual('accepted'); expect(result).toStrictEqual('ineligible'); expect(getReindexQueueAddSpy()).not.toHaveBeenCalled(); expect(getQueueAddSpy()).toHaveBeenCalledTimes(expectedQueueCalls); }) ); test.each([ ['3.3.0', false, 0], ['4.0.0', true, 1], ])('with sufficient version %s and post-deploy auto-run %s', (serverVersion, postDeploy, expectedQueueCalls) => withTestContext(async () => { mockValues.serverVersion = serverVersion; setMigrationsConfig(true, postDeploy); const systemRepo = getSystemRepo(); let asyncJob = await systemRepo.createResource<AsyncJob>({ resourceType: 'AsyncJob', type: 'data-migration', status: 'accepted', requestTime: new Date().toISOString(), request: '/admin/super/migrate', dataVersion: 1, minServerVersion: '3.3.0', }); expect(mockMarkPostDeployMigrationCompleted).toHaveBeenCalledTimes(0); const jobData = prepareReindexJobData(['MedicinalProductContraindication'], asyncJob.id); await new ReindexJob().execute(undefined, jobData); asyncJob = await systemRepo.readResource('AsyncJob', asyncJob.id); expect(asyncJob.status).toStrictEqual('completed'); expect(asyncJob.output).toMatchObject({ resourceType: 'Parameters', parameter: [ { name: 'result', part: expect.arrayContaining([ expect.objectContaining({ name: 'resourceType', valueCode: 'MedicinalProductContraindication' }), expect.objectContaining({ name: 'count', valueInteger: 0 }), ]), }, ], }); // Make sure we call `markDataMigrationComplete` after the reindex job if it's a data migration expect(mockMarkPostDeployMigrationCompleted).toHaveBeenCalledTimes(1); expect(getReindexQueueAddSpy()).not.toHaveBeenCalled(); expect(getQueueAddSpy()).toHaveBeenCalledTimes(expectedQueueCalls); }) ); test.each([ [true, 55], [false, 55], [true, undefined], [false, undefined], ])('Skips only if in firstBoot mode [%s] and has dataVersion [%s]', async (firstBootMode, dataVersion) => { mockValues.postDeployVersion = firstBootMode ? MigrationVersion.FIRST_BOOT : MigrationVersion.NONE; const systemRepo = getSystemRepo(); let asyncJob = await systemRepo.createResource<AsyncJob>({ resourceType: 'AsyncJob', status: 'accepted', dataVersion, requestTime: new Date().toISOString(), request: '/admin/super/reindex', }); let jobData: ReindexJobData = {} as unknown as ReindexJobData; await withTestContext(async () => { jobData = prepareReindexJobData(['ValueSet'], asyncJob.id); }); const reindexJob = new ReindexJob(systemRepo); const searchSpy = jest.spyOn(systemRepo, 'search').mockResolvedValueOnce({ resourceType: 'Bundle', type: 'searchset', entry: [], }); await expect(reindexJob.execute(undefined, jobData)).resolves.toBe('finished'); asyncJob = await systemRepo.readResource('AsyncJob', asyncJob.id); if (firstBootMode && dataVersion) { expect(asyncJob.status).toStrictEqual('completed'); expect(asyncJob.output?.parameter).toEqual([{ name: 'skipped', valueString: 'In firstBoot mode' }]); expect(searchSpy).not.toHaveBeenCalled(); } else { expect(asyncJob.status).toStrictEqual('completed'); expect(asyncJob.output?.parameter).toEqual([ { name: 'result', part: expect.arrayContaining([ expect.objectContaining({ name: 'resourceType', valueCode: 'ValueSet' }), expect.objectContaining({ name: 'count', valueInteger: 0 }), ]), }, ]); expect(searchSpy).toHaveBeenCalledTimes(1); } }); }); // Use a separate top-level describe since the other top-level // has a beforeEach that initializes and shuts down the app repeatedly. // Here, we want one long-running app describe('Super Admin routes', () => { const app = express(); let adminAccessToken: string; let project: Project; beforeAll(async () => { const config = await loadTestConfig(); await initApp(app, config); await expungePostDeployMigrationAsyncJob(getSystemRepo()); ({ project } = await createTestProject({ withClient: true, superAdmin: true })); const systemRepo = getSystemRepo(); const practitioner1 = await systemRepo.createResource<Practitioner>({ resourceType: 'Practitioner' }); const user1 = await systemRepo.createResource<User>({ resourceType: 'User', firstName: 'Super', lastName: 'Admin', email: `super${randomUUID()}@example.com`, passwordHash: 'abc', }); const membership1 = await systemRepo.createResource<ProjectMembership>({ resourceType: 'ProjectMembership', project: createReference(project), user: createReference(user1), profile: createReference(practitioner1), }); const login1 = await systemRepo.createResource<Login>({ resourceType: 'Login', authMethod: 'client', user: createReference(user1), membership: createReference(membership1), authTime: new Date().toISOString(), scope: 'openid', }); adminAccessToken = await generateAccessToken({ login_id: login1.id, sub: user1.id, username: user1.id, profile: getReferenceString(practitioner1), scope: 'openid', }); }); beforeEach(async () => { jest.clearAllMocks(); }); afterEach(async () => { await expungePostDeployMigrationAsyncJob(getSystemRepo()); }); afterAll(async () => { await shutdownApp(); }); describe('Manually run post-deploy migration', () => { beforeEach(() => { setMigrationsConfig(true, false); }); test.each<[boolean, boolean, 'fail' | 'pass']>([ [false, false, 'fail'], [false, true, 'fail'], [true, false, 'pass'], [true, true, 'pass'], ])( 'Manually run post-deploy migration with pre-deploy[%s] and post-deploy[%s]', async (preDeploy, postDeploy, expectedToFailOrPass) => { setMigrationsConfig(preDeploy, postDeploy); const res2 = await request(app) .post('/admin/super/migrate') .set('Authorization', 'Bearer ' + adminAccessToken) .set('Prefer', 'respond-async') .type('json') .send({}); const queueAdd = getQueueAddSpy(); if (expectedToFailOrPass === 'pass') { expect(res2.status).toStrictEqual(202); expect(res2.headers['content-location']).toBeDefined(); expect(queueAdd).toHaveBeenCalledTimes(1); } else { expect(res2.status).toStrictEqual(400); const queueAdd = getQueueAddSpy(); expect(queueAdd).not.toHaveBeenCalled(); } } ); test.each([true, 'true', 'false', false, 'v1', 'v2'])( 'with invalid dataVersion specified %s', async (dataVersion) => { const res1 = await request(app) .post('/admin/super/migrate') .set('Authorization', 'Bearer ' + adminAccessToken) .set('Prefer', 'respond-async') .type('json') .send({ dataVersion }); expect(res1.status).toStrictEqual(400); expect(res1.headers['content-location']).not.toBeDefined(); expect(res1.body).toMatchObject(badRequest('dataVersion must be an integer')); } ); test('with dataVersion less than or equal to current version', async () => { mockValues.postDeployVersion = 1; const res1 = await request(app) .post('/admin/super/migrate') .set('Authorization', 'Bearer ' + adminAccessToken) .set('Prefer', 'respond-async') .type('json') .send({ dataVersion: 1 }); // Since the version is less than or equal to the current version, // nothing to do, so no AsyncJob was created and no content-location header // should be set expect(res1.body).toMatchObject(allOk); expect(res1.status).toStrictEqual(200); expect(res1.headers['content-location']).not.toBeDefined(); }); }); describe('Set data version', () => { beforeAll(async () => { console.log = jest.fn(); }); test('Set data version -- Valid dataVersion', async () => { expect(mockMarkPostDeployMigrationCompleted).toHaveBeenCalledTimes(0); const res1 = await request(app) .post('/admin/super/setdataversion') .set('Authorization', 'Bearer ' + adminAccessToken) .type('json') .send({ dataVersion: 1337 }); expect(res1.status).toStrictEqual(200); expect(res1.body).toMatchObject(allOk); expect(mockMarkPostDeployMigrationCompleted).toHaveBeenCalledTimes(1); }); test.each([undefined, 'v1', '3.3.0'])('Set data version -- invalid dataVersion - %s', async (dataVersion) => { const res1 = await request(app) .post('/admin/super/setdataversion') .set('Authorization', 'Bearer ' + adminAccessToken) .type('json') .send({ dataVersion }); expect(res1.status).toStrictEqual(400); expect(res1.body).toMatchObject(badRequest('dataVersion must be an integer')); }); }); describe('Reconcile schema drift', () => { let generateMigrationActionsSpy: jest.SpyInstance<ReturnType<typeof migrateModule.generateMigrationActions>>; beforeEach(() => { generateMigrationActionsSpy = jest.spyOn(migrateModule, 'generateMigrationActions'); }); afterEach(() => { generateMigrationActionsSpy.mockRestore(); }); test('Nothing to do', async () => { generateMigrationActionsSpy.mockResolvedValueOnce([]); const queueAddSpy = getQueueAddSpy(); expect(queueAddSpy).toHaveBeenCalledTimes(0); const res1 = await request(app) .post('/admin/super/reconcile-db-schema-drift') .set('Authorization', 'Bearer ' + adminAccessToken) .set('Prefer', 'respond-async') .type('json'); expect(queueAddSpy).toHaveBeenCalledTimes(0); expect(res1.status).toStrictEqual(200); expect(res1.headers['content-location']).not.toBeDefined(); }); test('Has schema drift', async () => { const pendingActions: MigrationAction[] = [ { type: 'ANALYZE_TABLE', tableName: 'AsyncJob', }, ]; generateMigrationActionsSpy.mockResolvedValueOnce(pendingActions); const queueAddSpy = getQueueAddSpy(); expect(queueAddSpy).toHaveBeenCalledTimes(0); const res1 = await request(app) .post('/admin/super/reconcile-db-schema-drift') .set('Authorization', 'Bearer ' + adminAccessToken) .set('Prefer', 'respond-async') .type('json'); expect(queueAddSpy).toHaveBeenCalledTimes(1); const jobData = queueAddSpy.mock.calls[0][1]; expect(jobData).toMatchObject({ type: 'dynamic', migrationActions: pendingActions, }); expect(res1.status).toStrictEqual(202); expect(res1.headers['content-location']).toBeDefined(); }); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server