Skip to content

Commit 6cac6b2

Browse files
Merge pull request #93 from telivity-otaip/feat/platform-ui
feat(platform-ui): add Platform UI dashboard + developer playground
2 parents 5a4db5b + 142278f commit 6cac6b2

33 files changed

Lines changed: 3675 additions & 178 deletions
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Integration tests for /api/platform/* — read-only telemetry endpoints.
3+
*
4+
* Uses Fastify inject (no real HTTP) with MockOtaAdapter so tests don't
5+
* pull in the live Duffel client at boot.
6+
*/
7+
8+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
9+
import type { FastifyInstance } from 'fastify';
10+
import { MockOtaAdapter } from '../mock-ota-adapter.js';
11+
import { buildApp } from '../server.js';
12+
13+
let app: FastifyInstance;
14+
15+
beforeAll(async () => {
16+
app = await buildApp({
17+
adapter: new MockOtaAdapter(),
18+
initResolver: false,
19+
// The platform routes opt out of rate limiting via per-route config;
20+
// this guard makes sure tests don't accidentally trip the global cap.
21+
security: { rateLimit: false, helmet: false, cors: false },
22+
});
23+
await app.ready();
24+
});
25+
26+
afterAll(async () => {
27+
await app.close();
28+
});
29+
30+
describe('GET /api/platform/agents', () => {
31+
it('returns the discovered agent list with domain rollups', async () => {
32+
const res = await app.inject({ method: 'GET', url: '/api/platform/agents' });
33+
expect(res.statusCode).toBe(200);
34+
const body = res.json() as {
35+
agents: Array<{ id: string; stage: string; contract_status: string; version: string }>;
36+
domain_groups: Record<string, { total: number; active: number; stub: number }>;
37+
totals: { total: number; active: number; stub: number };
38+
};
39+
expect(body.agents.length).toBeGreaterThan(0);
40+
expect(body.totals.total).toBe(body.agents.length);
41+
expect(body.totals.active + body.totals.stub).toBe(body.totals.total);
42+
// Every domain bucket totals match its members
43+
for (const [stage, bucket] of Object.entries(body.domain_groups)) {
44+
const members = body.agents.filter((a) => a.stage === stage);
45+
expect(members.length).toBe(bucket.total);
46+
expect(members.filter((a) => a.contract_status === 'active').length).toBe(bucket.active);
47+
}
48+
});
49+
50+
it('marks v0.0.0 agents as stubs', async () => {
51+
const res = await app.inject({ method: 'GET', url: '/api/platform/agents' });
52+
const body = res.json() as { agents: Array<{ version: string; contract_status: string }> };
53+
for (const a of body.agents) {
54+
if (a.version === '0.0.0') expect(a.contract_status).toBe('stub');
55+
else expect(a.contract_status).toBe('active');
56+
}
57+
});
58+
});
59+
60+
describe('GET /api/platform/adapters', () => {
61+
it('lists every documented adapter with env-derived configured flags', async () => {
62+
const res = await app.inject({ method: 'GET', url: '/api/platform/adapters' });
63+
expect(res.statusCode).toBe(200);
64+
const body = res.json() as {
65+
adapters: Array<{ id: string; name: string; configured: boolean; env_vars: string[] }>;
66+
};
67+
const ids = body.adapters.map((a) => a.id);
68+
expect(ids).toEqual(
69+
expect.arrayContaining(['amadeus', 'sabre', 'navitaire', 'trippro', 'duffel', 'haip', 'hotelbeds']),
70+
);
71+
// The hotelbeds adapter is "configured" iff both API_KEY and SECRET are set
72+
const hb = body.adapters.find((a) => a.id === 'hotelbeds')!;
73+
const hbExpected =
74+
Boolean(process.env['HOTELBEDS_API_KEY']) && Boolean(process.env['HOTELBEDS_SECRET']);
75+
expect(hb.configured).toBe(hbExpected);
76+
});
77+
});
78+
79+
describe('GET /api/platform/health', () => {
80+
it('returns uptime, node version, and a request count after the call lands', async () => {
81+
const res = await app.inject({ method: 'GET', url: '/api/platform/health' });
82+
expect(res.statusCode).toBe(200);
83+
const body = res.json() as {
84+
status: string;
85+
uptime_seconds: number;
86+
node_version: string;
87+
otaip_version: string;
88+
last_request_at: string | null;
89+
request_count: number;
90+
};
91+
expect(body.status).toBe('ok');
92+
expect(body.uptime_seconds).toBeGreaterThanOrEqual(0);
93+
expect(body.node_version).toMatch(/^v\d+\.\d+\.\d+/);
94+
expect(body.request_count).toBeGreaterThan(0);
95+
expect(body.last_request_at).not.toBeNull();
96+
});
97+
});
98+
99+
describe('GET /api/platform/stats', () => {
100+
it('returns aggregate counts that agree with the agent + adapter endpoints', async () => {
101+
const [statsRes, agentsRes, adaptersRes] = await Promise.all([
102+
app.inject({ method: 'GET', url: '/api/platform/stats' }),
103+
app.inject({ method: 'GET', url: '/api/platform/agents' }),
104+
app.inject({ method: 'GET', url: '/api/platform/adapters' }),
105+
]);
106+
expect(statsRes.statusCode).toBe(200);
107+
const stats = statsRes.json() as {
108+
agents: { total: number; active: number; stub: number };
109+
adapters: { total: number; configured: number };
110+
};
111+
const agents = (agentsRes.json() as { totals: { total: number } }).totals;
112+
const adapters = (adaptersRes.json() as { adapters: Array<{ configured: boolean }> }).adapters;
113+
expect(stats.agents.total).toBe(agents.total);
114+
expect(stats.adapters.total).toBe(adapters.length);
115+
expect(stats.adapters.configured).toBe(adapters.filter((a) => a.configured).length);
116+
});
117+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Integration tests for /api/playground/* — interactive API explorer.
3+
*
4+
* `initResolver: true` so the AirportCodeResolver agent is exercised
5+
* end-to-end. The mock OTA adapter handles the search-mode test path.
6+
*/
7+
8+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
9+
import type { FastifyInstance } from 'fastify';
10+
import { MockOtaAdapter } from '../mock-ota-adapter.js';
11+
import { buildApp } from '../server.js';
12+
13+
let app: FastifyInstance;
14+
15+
beforeAll(async () => {
16+
app = await buildApp({
17+
adapter: new MockOtaAdapter(),
18+
initResolver: true,
19+
security: { rateLimit: false, helmet: false, cors: false },
20+
});
21+
await app.ready();
22+
}, 60_000);
23+
24+
afterAll(async () => {
25+
await app.close();
26+
});
27+
28+
describe('GET /api/playground/catalog', () => {
29+
it('returns the discovered agents plus the executable whitelist', async () => {
30+
const res = await app.inject({ method: 'GET', url: '/api/playground/catalog' });
31+
expect(res.statusCode).toBe(200);
32+
const body = res.json() as {
33+
agents: Array<{ id: string; name: string }>;
34+
executable_ids: string[];
35+
schemas: Record<string, { description: string; example_input: unknown }>;
36+
};
37+
expect(body.agents.length).toBeGreaterThan(0);
38+
expect(body.executable_ids).toContain('0.1');
39+
expect(body.schemas['0.1']).toBeDefined();
40+
expect(body.schemas['0.1']?.example_input).toEqual({ code: 'JFK', code_type: 'iata' });
41+
});
42+
});
43+
44+
describe('POST /api/playground/search', () => {
45+
it('runs a search through the configured adapter', async () => {
46+
const res = await app.inject({
47+
method: 'POST',
48+
url: '/api/playground/search',
49+
payload: {
50+
origin: 'JFK',
51+
destination: 'LAX',
52+
date: '2026-08-15',
53+
passengers: 1,
54+
},
55+
});
56+
expect(res.statusCode).toBe(200);
57+
const body = res.json() as {
58+
offers: unknown[];
59+
totalFound: number;
60+
sources: string[];
61+
duration_ms: number;
62+
};
63+
expect(Array.isArray(body.offers)).toBe(true);
64+
expect(body.totalFound).toBe(body.offers.length);
65+
expect(typeof body.duration_ms).toBe('number');
66+
});
67+
68+
it('rejects malformed origin via AJV body schema', async () => {
69+
const res = await app.inject({
70+
method: 'POST',
71+
url: '/api/playground/search',
72+
payload: { origin: 'JF', destination: 'LAX', date: '2026-08-15', passengers: 1 },
73+
});
74+
expect(res.statusCode).toBe(400);
75+
});
76+
});
77+
78+
describe('POST /api/playground/agent', () => {
79+
it('executes the AirportCodeResolver (whitelisted)', async () => {
80+
const res = await app.inject({
81+
method: 'POST',
82+
url: '/api/playground/agent',
83+
payload: {
84+
agent_id: '0.1',
85+
input: { code: 'JFK', code_type: 'iata' },
86+
},
87+
});
88+
expect(res.statusCode).toBe(200);
89+
const body = res.json() as {
90+
agent_id: string;
91+
output: { data: { resolved_airport: { iata_code: string | null } | null } };
92+
duration_ms: number;
93+
};
94+
expect(body.agent_id).toBe('0.1');
95+
expect(body.output.data.resolved_airport?.iata_code).toBe('JFK');
96+
}, 30_000);
97+
98+
it('returns 501 with a clear message for unwired agents', async () => {
99+
const res = await app.inject({
100+
method: 'POST',
101+
url: '/api/playground/agent',
102+
payload: { agent_id: '99.99', input: {} },
103+
});
104+
expect(res.statusCode).toBe(501);
105+
const body = res.json() as { error: string; agent_id: string; hint: string };
106+
expect(body.agent_id).toBe('99.99');
107+
expect(body.error).toContain('not yet wired');
108+
expect(body.hint).toContain('executable_ids');
109+
});
110+
});
111+
112+
describe('POST /api/playground/adapter', () => {
113+
it('runs the adapter health check', async () => {
114+
const res = await app.inject({
115+
method: 'POST',
116+
url: '/api/playground/adapter',
117+
payload: { operation: 'isAvailable' },
118+
});
119+
expect(res.statusCode).toBe(200);
120+
const body = res.json() as { operation: string; output: boolean };
121+
expect(body.operation).toBe('isAvailable');
122+
expect(typeof body.output).toBe('boolean');
123+
});
124+
125+
it('rejects an unknown operation', async () => {
126+
const res = await app.inject({
127+
method: 'POST',
128+
url: '/api/playground/adapter',
129+
payload: { operation: 'book' },
130+
});
131+
expect(res.statusCode).toBe(400);
132+
});
133+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* GET /api/platform/* — read-only telemetry for the Platform UI dashboard.
3+
*
4+
* Routes are registered with a high per-route rate limit (5 000/min) so
5+
* dashboard polling cannot trip the global default 100/min cap.
6+
*/
7+
8+
import type { FastifyInstance } from 'fastify';
9+
import type { PlatformService } from '../services/platform-service.js';
10+
11+
const HIGH_RATE_LIMIT = { max: 5_000, timeWindow: '1 minute' };
12+
13+
export function registerPlatformRoutes(
14+
app: FastifyInstance,
15+
platform: PlatformService,
16+
): void {
17+
app.get('/api/platform/agents', { config: { rateLimit: HIGH_RATE_LIMIT } }, async (_req, reply) => {
18+
return reply.send(platform.agents());
19+
});
20+
21+
app.get('/api/platform/adapters', { config: { rateLimit: HIGH_RATE_LIMIT } }, async (_req, reply) => {
22+
return reply.send({ adapters: platform.adapters() });
23+
});
24+
25+
app.get('/api/platform/health', { config: { rateLimit: HIGH_RATE_LIMIT } }, async (_req, reply) => {
26+
return reply.send(platform.health());
27+
});
28+
29+
app.get('/api/platform/stats', { config: { rateLimit: HIGH_RATE_LIMIT } }, async (_req, reply) => {
30+
return reply.send(platform.stats());
31+
});
32+
}

0 commit comments

Comments
 (0)