Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 116 additions & 103 deletions cli/e2e/scenarios/import-and-expose-basic.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,142 +13,155 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, test, expect } from 'vitest';
import { getTempHome, runCli, runCliExpectSuccess } from '../helpers/cli-runner.js';
import { GATEWAY_URL } from '../helpers/setup.js';
import * as path from 'node:path';
import { afterAll, describe, expect, test } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getTempHome, login, runCli, runCliExpectSuccess, runCliJson } from '../helpers/cli-runner.js';
import { deleteServiceIfPresent, deleteServicesByNameVersion } from '../helpers/cleanup.js';
import { callMcpTool, initializeMcp, mcpUrl, waitForMcpTools } from '../helpers/mcp-client.js';

const OPEN_METEO_SPEC = path.resolve(import.meta.dirname, '../../../dev/open-meteo-openapi.yml');
const OPEN_METEO_SERVICE_NAME = 'Open-Meteo APIs';
const OPEN_METEO_SERVICE_VERSION = '1.0';
const OPEN_METEO_BACKEND = 'https://api.open-meteo.com';
const OPEN_METEO_EXPECTED_TOOLS = 1;
const OPEN_METEO_TOOL_NAME = 'get_v1_forecast';

interface Service {
id: string;
name: string;
version: string;
type: string;
operations?: unknown[];
}

interface ImportResult {
service: Service;
}

interface Exposition {
service?: Service;
configurationPlan?: {
backendEndpoint?: string;
};
}

describe.sequential('Scenario: Import and expose an OpenAPI service via MCP', () => {
let serviceId: string | undefined;
let service: Service | undefined;

function requireServiceId(): string {
if (!serviceId) {
throw new Error('Open-Meteo service was not imported');
}
return serviceId;
}

const CONTROL_PLANE = 'http://localhost:5555';
const ARTIFACT_FILE = path.resolve(import.meta.dirname, '../../../dev/open-meteo-openapi.yml');
function requireService(): Service {
if (!service) {
throw new Error('Open-Meteo service metadata was not loaded');
}
return service;
}

/**
* Full end-to-end scenario: login → import OpenAPI → verify service → verify
* exposition → send a real MCP HTTP request → cleanup → logout.
*
* Tests MUST run sequentially because each step depends on the previous one.
*/
describe('Scenario: Import and expose an OpenAPI service via MCP', () => {

let serviceId: string;

// ── Step 1 ─────────────────────────────────────────────────────────────────
test('login with username and password', async () => {
const result = await runCli(
'login',
'-u', 'e2euser',
'-p', 'e2e-password',
'-s', CONTROL_PLANE,
'-k',
);
afterAll(async () => {
await deleteServiceIfPresent(serviceId);
});

expect(result.exitCode).toBe(0);
expect(result.stdout + result.stderr).toContain('Login successful');
test('logs in and removes stale Open-Meteo service', async () => {
await login();
await deleteServicesByNameVersion(OPEN_METEO_SERVICE_NAME, OPEN_METEO_SERVICE_VERSION);
});

// ── Step 2 ─────────────────────────────────────────────────────────────────
test('import OpenAPI artifact with backend endpoint', async () => {
const result = await runCli(
test('imports Open-Meteo with backend endpoint', async () => {
const imported = await runCliJson<ImportResult>(
'import',
'-f', ARTIFACT_FILE,
'--be', 'https://api.open-meteo.com',
'-o', 'json',
'-f', OPEN_METEO_SPEC,
'--be', OPEN_METEO_BACKEND,
);

expect(result.exitCode).toBe(0);
const importedServiceId = imported.service.id;
serviceId = importedServiceId;
expect(imported.service.name).toBe(OPEN_METEO_SERVICE_NAME);
expect(importedServiceId).toBeTruthy();
});

// The JSON output should contain the service info
const output = result.stdout;
const data = JSON.parse(output);
test('validates imported service metadata and operations', async () => {
const importedServiceId = requireServiceId();
service = await runCliJson<Service>('service', 'get', importedServiceId);

expect(data.service).toBeDefined();
expect(data.service.name).toContain('Open-Meteo');
serviceId = data.service.id;
expect(serviceId).toBeTruthy();
expect(service.id).toBe(importedServiceId);
expect(service.name).toBe(OPEN_METEO_SERVICE_NAME);
expect(service.version).toBe(OPEN_METEO_SERVICE_VERSION);
expect(service.type).toBe('REST');
expect(service.operations ?? []).toHaveLength(OPEN_METEO_EXPECTED_TOOLS);
});

// ── Step 3 ─────────────────────────────────────────────────────────────────
test('service list returns the imported service', async () => {
const result = await runCliExpectSuccess('service', 'list', '-o', 'json');
test('validates active exposition backend configuration', async () => {
const importedServiceId = requireServiceId();
const expositions = await runCliJson<Exposition[]>('expo', 'list');
const exposition = expositions.find(item => item.service?.id === importedServiceId);

const services = JSON.parse(result.stdout);
expect(Array.isArray(services)).toBe(true);

const found = services.find((s: any) => s.id === serviceId);
expect(found).toBeDefined();
expect(found.name).toContain('Open-Meteo');
expect(found.type).toBe('REST');
expect(exposition).toBeDefined();
expect(exposition?.configurationPlan?.backendEndpoint).toBe(OPEN_METEO_BACKEND);
});

// ── Step 4 ─────────────────────────────────────────────────────────────────
test('expo list shows an active exposition', async () => {
const result = await runCliExpectSuccess('expo', 'list', '-o', 'json');
test('initializes MCP endpoint through gateway', async () => {
const endpoint = mcpUrl(OPEN_METEO_SERVICE_NAME, OPEN_METEO_SERVICE_VERSION);
const initialized = await initializeMcp(endpoint);

const expositions = JSON.parse(result.stdout);
expect(Array.isArray(expositions)).toBe(true);
expect(expositions.length).toBeGreaterThanOrEqual(1);
expect(initialized.body.result?.serverInfo).toBeDefined();
});

const expo = expositions.find((e: any) => e.service?.id === serviceId);
expect(expo).toBeDefined();
expect(expo.configurationPlan.backendEndpoint).toBe('https://api.open-meteo.com');
test('lists Open-Meteo MCP tool', async () => {
const importedService = requireService();
const endpoint = mcpUrl(OPEN_METEO_SERVICE_NAME, OPEN_METEO_SERVICE_VERSION);
const tools = await waitForMcpTools(endpoint, {
exact: OPEN_METEO_EXPECTED_TOOLS,
include: [OPEN_METEO_TOOL_NAME],
});

expect(tools).toHaveLength(importedService.operations?.length ?? 0);
});

// ── Step 5 ─────────────────────────────────────────────────────────────────
test('MCP endpoint responds to an initialize request', async () => {
// Build the MCP endpoint URL: /mcp/{org}/{serviceName}/{serviceVersion}
const mcpUrl = `${GATEWAY_URL}/mcp/e2eorg/Open-Meteo+APIs/1.0`;

const response = await fetch(mcpUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'e2e-test', version: '0.0.1' },
},
}),
test('calls Open-Meteo forecast tool through MCP', async () => {
const endpoint = mcpUrl(OPEN_METEO_SERVICE_NAME, OPEN_METEO_SERVICE_VERSION);
const toolCall = await callMcpTool(endpoint, OPEN_METEO_TOOL_NAME, {
latitude: 48.8566,
longitude: 2.3522,
current_weather: true,
timezone: 'Europe/Paris',
});

expect(response.status).toBe(200);
expect(toolCall.error).toBeUndefined();
expect(toolCall.result?.isError).not.toBe(true);
expect(toolCall.result?.content?.[0]?.text).toEqual(expect.any(String));

const body = await response.json();
expect(body.jsonrpc).toBe('2.0');
expect(body.id).toBe(1);
expect(body.result).toBeDefined();
expect(body.result.serverInfo).toBeDefined();
const backendPayload = JSON.parse(toolCall.result.content[0].text);
expect(backendPayload.latitude).toEqual(expect.any(Number));
expect(backendPayload.longitude).toEqual(expect.any(Number));
});

// ── Step 6 (cleanup) ──────────────────────────────────────────────────────
test('cleanup: delete the imported service', async () => {
expect(serviceId).toBeTruthy();
test('deletes imported Open-Meteo service before logout', async () => {
const importedServiceId = requireServiceId();

const result = await runCliExpectSuccess('service', 'delete', serviceId, '-f');
expect(result.exitCode).toBe(0);
await runCliExpectSuccess('service', 'delete', importedServiceId, '-f');
serviceId = undefined;
service = undefined;

// Verify it's gone
const listResult = await runCliExpectSuccess('service', 'list', '-o', 'json');
if (listResult.stdout) {
const services = JSON.parse(listResult.stdout);
const found = services.find((s: any) => s.id === serviceId);
expect(found).toBeUndefined();
}
const deletedService = await runCli('service', 'get', importedServiceId, '-o', 'json');
expect(deletedService.exitCode).not.toBe(0);
});

// ── Step 7 (logout) ──────────────────────────────────────────────────────
test('logout: logout and check no config remains', async () => {
expect(serviceId).toBeTruthy();
test('logs out and removes CLI config', async () => {
const configPath = path.join(getTempHome(), '.reshapr', 'config');

await expect(fs.promises.access(configPath)).resolves.toBeUndefined();

const result = await runCliExpectSuccess('logout');
expect(result.exitCode).toBe(0);
expect(result.stdout + result.stderr).toContain('logged out successfully');

// Verify we no longer have a file at home/.reshapr/config file
const configPath = path.join(getTempHome(), '.reshapr', 'config');
const exists = await fs.promises.access(configPath).then(() => true).catch(() => false);
expect(exists).toBe(false);
});
});