/** * GET /api/agents/registry — proxies ml/serving's manifest list with a short * in-process cache. Tests stub global fetch and verify caching + 502 fallback. */ import { describe, it, expect, vi, beforeAll, afterEach, beforeEach } from 'vitest'; import express from 'express'; import * as http from 'http'; vi.mock('../../middleware/session.js', () => ({ sessionMiddleware: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as any).userId = 'user-1'; next(); }, })); const REGISTRY_PAYLOAD = { agents: [ { id: 'overdue-task', version: '1.0.0', pref_schema: { type: 'object' } }, { id: 'momentum', version: '1.0.0', pref_schema: { type: 'object' } }, ], }; function get(url: string): Promise<{ status: number; body: any }> { return new Promise((resolve, reject) => { const u = new URL(url); http.get({ hostname: u.hostname, port: Number(u.port), path: u.pathname }, (res) => { let data = ''; res.on('data', (c) => { data += c; }); res.on('end', () => { try { resolve({ status: res.statusCode ?? 0, body: data ? JSON.parse(data) : null }); } catch { resolve({ status: res.statusCode ?? 0, body: data }); } }); }).on('error', reject); }); } describe('GET /api/agents/registry', () => { let server: http.Server; let baseUrl: string; let savedFetch: typeof globalThis.fetch; let resetCache: () => void; beforeAll(async () => { const mod = await import('../agent-registry.js'); const router = mod.default; resetCache = mod._resetRegistryCache; const app = express(); app.use('/api/agents', router); server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)); }); const addr = server.address() as { port: number }; baseUrl = `http://localhost:${addr.port}`; savedFetch = globalThis.fetch; }); beforeEach(() => { resetCache(); }); afterEach(() => { globalThis.fetch = savedFetch; }); it('proxies ml/serving manifests', async () => { const fetchMock = vi.fn(async () => new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 }), ); globalThis.fetch = fetchMock as unknown as typeof fetch; const r = await get(`${baseUrl}/api/agents/registry`); expect(r.status).toBe(200); expect(r.body).toEqual(REGISTRY_PAYLOAD); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('caches across calls within the TTL', async () => { const fetchMock = vi.fn(async () => new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 }), ); globalThis.fetch = fetchMock as unknown as typeof fetch; await get(`${baseUrl}/api/agents/registry`); await get(`${baseUrl}/api/agents/registry`); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('returns 502 when ml/serving fails', async () => { globalThis.fetch = vi.fn(async () => new Response('boom', { status: 500 })) as unknown as typeof fetch; const r = await get(`${baseUrl}/api/agents/registry`); expect(r.status).toBe(502); expect(r.body.error).toBe('ml/serving unavailable'); }); it('does not cache failures', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(new Response('boom', { status: 500 })) .mockResolvedValueOnce(new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 })); globalThis.fetch = fetchMock as unknown as typeof fetch; const first = await get(`${baseUrl}/api/agents/registry`); expect(first.status).toBe(502); const second = await get(`${baseUrl}/api/agents/registry`); expect(second.status).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(2); }); });