diff --git a/changelog.md b/changelog.md index d518355f4..bc8b35fa0 100644 --- a/changelog.md +++ b/changelog.md @@ -9,8 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `route( "/gateways" ).toAiGateway()`: a routing DSL terminator exposing a BoxLang AI Gateway over + HTTP, alongside `toAi()` and `toMCP()`. Registers the sub-route family a gateway surface needs: + `GET/POST {pattern}[/:gateway]/events` (the platform's URL-verification handshake and its inbound + events, on one URL because that is what a platform is given), + `GET {pattern}/interactions/:requestID` and `POST {pattern}/interactions/:requestID/decisions` for + human-in-the-loop approvals, and `GET {pattern}/info`. Pin a mount to one gateway with + `toAiGateway( "slack" )`, or leave the name out and one mount serves every gateway registered in + `aiGatewayRegistry()`. Pass a `session` (a WireBox ID or a live `GatewaySession`) and every inbound + message is dispatched as an agent turn and acked `202` immediately, without waiting on the turn: + a platform webhook times out in seconds while an agent turn does not. Without one, inbound events + are verified and parsed only. BoxLang only, and requires the `bxai` module. ([bx-ai#286](https://github.com/ortus-boxlang/bx-ai/pull/286)) + ### Fixed +- A route `response` closure that rendered the response itself (`event.renderData(...)`) had its + status code and content type flattened back onto the route's static `statusCode` by the router's + own `renderData()` call. Render data set during the closure is now left alone, so a closure can + answer with a per-request status. Render data an interceptor set before the route ran is + unaffected. + - `BoxLangProvider` did not convert CacheBox's minute-based timeouts before handing them to BoxLang's cache, which reads a bare number as seconds, so every timeout expired sixty times too soon. A region moved from `CacheBoxProvider` to `BoxLangProvider` kept a 10 minute object for 10 seconds. diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index 22b4b8384..50513c77c 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -816,6 +816,9 @@ component any aiRunnable = "", boolean mcp = "false", string mcpServer = "", + boolean gateway = "false", + string gatewayName = "", + any gatewaySession = "", array middleware = [], array withoutMiddleware = [], boolean cache = "false", @@ -1273,6 +1276,10 @@ component // MCP Routing "mcp" : false, // Flag indicating this is an MCP server route "mcpServer" : "", // The MCP server name to expose + // AI Gateway Routing + "gateway" : false, // Flag indicating this is an AI gateway route + "gatewayName" : "", // The gateway name this route is pinned to, empty for a :gateway placeholder mount + "gatewaySession" : "", // The GatewaySession WireBox ID or instance inbound messages dispatch into // Route-Level Caching - overrides the handler's own cache="true" annotation when true "cache" : false, // Flag indicating this route caches its output "cacheTimeout" : "", // Cache timeout, in minutes. Blank uses the cache provider's default @@ -2427,8 +2434,8 @@ component /** * Verifies that BoxLang is the active runtime and that the bxai module is installed. - * Used as a guard by toAi() and toMCP() at route-registration time so misconfigurations - * are caught on startup rather than at request time. + * Used as a guard by toAi(), toMCP() and toAiGateway() at route-registration time so + * misconfigurations are caught on startup rather than at request time. * * @throws BoxLangRequiredException If BoxLang is not the active runtime * @throws ModuleNotFoundException If the bxai module is not installed @@ -2437,14 +2444,14 @@ component if ( !server.keyExists( "boxlang" ) ) { throw( type : "BoxLangRequiredException", - message: "BoxLang is required for AI/MCP routing. toAi() and toMCP() are BoxLang-only features." + message: "BoxLang is required for AI routing. toAi(), toMCP() and toAiGateway() are BoxLang-only features." ); } if ( !getModuleList().keyArray().findNoCase( "bxai" ) ) { throw( type : "ModuleNotFoundException", - message: "The BoxLang AI module (bxai) is required for AI/MCP routing. Install it via: box install bxai" + message: "The BoxLang AI module (bxai) is required for AI routing. Install it via: box install bxai" ); } } @@ -2465,7 +2472,7 @@ component * void function stream( function onChunk, any input={}, struct params={}, struct options={} ) * * Any route modifiers already set (withCondition, withDomain, withSSL, etc.) are inherited - * by all five sub-routes. + * by every sub-route. * *
 	 * // Using WireBox ID — resolved lazily at request time
@@ -2837,6 +2844,349 @@ component
 		return this;
 	}
 
+	/**
+	 * Terminates the route by registering the family of sub-routes that expose a BoxLang AI
+	 * Gateway over HTTP: inbound platform events, the URL-verification handshake platforms
+	 * perform before they will POST anywhere, and the human-in-the-loop interaction endpoints.
+	 *
+	 * Given a base pattern (e.g. "/gateways"), the following sub-routes are registered:
+	 *
+	 *   POST {pattern}[/:gateway]/events                 → verify, parse, and dispatch  [202 JSON]
+	 *   GET  {pattern}[/:gateway]/events                 → the platform's URL handshake [gateway's own]
+	 *   GET  {pattern}/interactions/:requestID           → poll a pending interaction   [JSON]
+	 *   POST {pattern}/interactions/:requestID/decisions → submit a human's decision    [JSON]
+	 *   GET  {pattern}/info                              → registered gateways          [JSON]
+	 *
+	 * GET and POST deliberately share the `/events` path — one route answering both verbs — since
+	 * a platform is given ONE URL to store and verifies it with a GET before it ever POSTs to it.
+	 *
+	 * Pass a `gateway` name to pin the mount to one gateway. Leave it out and the terminator
+	 * inserts its own `:gateway` placeholder, so a single mount serves every gateway registered
+	 * in `aiGatewayRegistry()`.
+	 *
+	 * Pass a `session` — a WireBox ID or a live GatewaySession from `aiGatewaySession()` — and
+	 * every inbound message is dispatched as an agent turn and acked `202` immediately, without
+	 * waiting on the turn: a platform webhook times out in seconds while an agent turn does not.
+	 * The response reports which thread each message landed on so the caller can correlate the
+	 * reply that arrives later. Leave it out and inbound events are verified and parsed only,
+	 * returning the normalized messages for the application to dispatch itself.
+	 *
+	 * Any route modifiers already set (withCondition, withDomain, withSSL, etc.) are inherited
+	 * by all five sub-routes.
+	 *
+	 * 
+	 * // One mount serving every registered gateway, dispatching into a session by WireBox ID
+	 * route( "/gateways" ).toAiGateway( session: "SupportAgentSession" );
+	 *
+	 * // Pinned to a single gateway: POST/GET /webhooks/slack/events
+	 * route( "/webhooks/slack" ).toAiGateway( "slack", "SupportAgentSession" );
+	 *
+	 * // Verify and parse only, dispatching nothing
+	 * route( "/gateways" ).toAiGateway();
+	 * 
+ * + * @gateway The registered gateway name to pin this mount to, or empty for a `:gateway` placeholder mount + * @session A WireBox ID or a live GatewaySession to dispatch into, or empty to parse without dispatching + * + * @return Router instance for chaining + * + * @throws BoxLangRequiredException If BoxLang is not the active runtime + * @throws ModuleNotFoundException If the bxai module is not installed + * @throws InvalidArgumentException If session is not a WireBox ID string or an object + */ + function toAiGateway( string gateway = "", any session = "" ){ + // Guard: BoxLang + bxai must be present at route-registration time + ensureBoxLang() + + // Validate argument type + if ( + ( !isSimpleValue( arguments.session ) && !isObject( arguments.session ) ) || + isNumeric( arguments.session ) + ) { + throw( + type : "InvalidArgumentException", + message: "The 'session' argument must be a WireBox ID string or a GatewaySession instance" + ) + } + + // Capture base path and route name from the current fluent state, exactly as toAi() does, + // then build each sub-route as its own explicit routeArgs struct. + var basePath = variables.thisRoute.pattern + var baseName = len( variables.thisRoute.name ) ? variables.thisRoute.name : basePath + + // A pinned mount needs no placeholder; an unpinned one takes the gateway name from the URL. + var gatewaySegment = len( trim( arguments.gateway ) ) ? "" : "/:gateway" + + // Shared modifiers forwarded to every sub-route (mirrors toAi()/resources()). + // `gateway` here is the route-metadata FLAG — the name it was pinned to is `gatewayName`. + var sharedArgs = { + condition : variables.thisRoute.condition, + domain : variables.thisRoute.domain, + ssl : variables.thisRoute.ssl, + headers : variables.thisRoute.headers, + module : variables.thisRoute.module, + namespace : variables.thisRoute.namespace, + meta : variables.thisRoute.meta, + gateway : true, + gatewayName : arguments.gateway, + gatewaySession : arguments.session, + statusCode : 200 + } + + // Captured in local variables so the closures can close over them + var pinnedGateway = arguments.gateway + var capturedSession = arguments.session + + // ===================================================================================== + // EVENTS ROUTE + // ===================================================================================== + + // GET/POST {base}[/:gateway]/events — one route, because a platform is given ONE URL and + // verifies it with a GET before it ever POSTs to it. Two routes sharing a pattern would + // merge into one anyway (see addRoute), so the verb branch lives inside the closure. + var routeArgs = sharedArgs + .copy() + .append( { + "pattern" : "#basePath##gatewaySegment#/events", + "name" : "#baseName#.gateway.events", + "verbs" : "GET,POST", + "response" : ( event, rc, prc ) => { + var gatewayName = resolveGatewayName( pinnedGateway, rc ) + + // The platform's URL-verification handshake, answered by the gateway itself + if ( event.getHTTPMethod() == "GET" ) { + return writeGatewayResult( + event, + bxModules.bxai.models.gateway.http.GatewayRequestProcessor::processHandshake( + gatewayName, + rc + ) + ); + } + + var gatewaySession = resolveGatewaySession( capturedSession ) + var result = ""; + + // Passed positionally rather than as a null-valued named argument, so a mount + // with no session is genuinely a parse-only call. + if ( isNull( gatewaySession ) ) { + result = bxModules.bxai.models.gateway.http.GatewayRequestProcessor::processInbound( + gatewayName, + event.getHTTPContent(), + getGatewayRequestHeaders() + ); + } else { + result = bxModules.bxai.models.gateway.http.GatewayRequestProcessor::processInbound( + gatewayName, + event.getHTTPContent(), + getGatewayRequestHeaders(), + gatewaySession + ); + } + + return writeGatewayResult( event, result ); + } + } ) + + // process a with closure if not empty + if ( !variables.withClosure.isEmpty() ) { + processWith( routeArgs ) + } + // Register the route + addRoute( argumentCollection = routeArgs ) + + // ===================================================================================== + // INTERACTION ROUTE + // ===================================================================================== + + // GET {base}/interactions/:requestID — poll a pending human-in-the-loop interaction + routeArgs = sharedArgs + .copy() + .append( { + "pattern" : "#basePath#/interactions/:requestID", + "name" : "#baseName#.gateway.interaction", + "verbs" : "GET", + "response" : ( event, rc, prc ) => { + var result = bxModules.bxai.models.gateway.http.GatewayRequestProcessor::readInteraction( + rc.requestID ?: "" + ); + return writeGatewayResult( event, result ); + } + } ) + + // process a with closure if not empty + if ( !variables.withClosure.isEmpty() ) { + processWith( routeArgs ) + } + // Register the route + addRoute( argumentCollection = routeArgs ) + + // ===================================================================================== + // DECISION ROUTE + // ===================================================================================== + + // POST {base}/interactions/:requestID/decisions — submit a human's signed decision + routeArgs = sharedArgs + .copy() + .append( { + "pattern" : "#basePath#/interactions/:requestID/decisions", + "name" : "#baseName#.gateway.decision", + "verbs" : "POST", + "response" : ( event, rc, prc ) => { + var result = bxModules.bxai.models.gateway.http.GatewayRequestProcessor::submitDecision( + rc.requestID ?: "", + event.getHTTPContent(), + getGatewayRequestHeaders() + ); + return writeGatewayResult( event, result ); + } + } ) + + // process a with closure if not empty + if ( !variables.withClosure.isEmpty() ) { + processWith( routeArgs ) + } + // Register the route + addRoute( argumentCollection = routeArgs ) + + // ===================================================================================== + // INFO ROUTE + // ===================================================================================== + + // GET {base}/info — what this mount serves, and which gateways are registered behind it + routeArgs = sharedArgs + .copy() + .append( { + "pattern" : "#basePath#/info", + "name" : "#baseName#.gateway.info", + "verbs" : "GET", + "response" : ( event, rc, prc ) => { + var registered = aiGatewayRegistry().listGateways() + if ( len( pinnedGateway ) ) { + registered = registered.filter( ( key, gateway ) => key == pinnedGateway ) + } + + return { + "pattern" : basePath, + "gateway" : pinnedGateway, + "dispatches" : !isNull( resolveGatewaySession( capturedSession ) ), + "gateways" : registered, + "endpoints" : [ + { + "verb" : "POST", + "path" : basePath & gatewaySegment & "/events", + "description" : "Inbound platform event" + }, + { + "verb" : "GET", + "path" : basePath & gatewaySegment & "/events", + "description" : "Platform URL verification handshake" + }, + { + "verb" : "GET", + "path" : basePath & "/interactions/:requestID", + "description" : "Poll a pending human interaction" + }, + { + "verb" : "POST", + "path" : basePath & "/interactions/:requestID/decisions", + "description" : "Submit a human decision" + }, + { + "verb" : "GET", + "path" : basePath & "/info", + "description" : "Endpoint metadata" + } + ] + } + } + } ) + + // process a with closure if not empty + if ( !variables.withClosure.isEmpty() ) { + processWith( routeArgs ) + } + // Register the route + addRoute( argumentCollection = routeArgs ) + + // Reset fluent state for the next route definition + variables.thisRoute = initRouteDefinition() + + return this; + } + + /** + * Which gateway a toAiGateway() sub-route is talking to: the name the mount was pinned to + * always wins, so a `:gateway` placeholder (or a stray `gateway` value in the request + * collection) can never redirect a pinned mount at another gateway. + * + * @pinnedGateway The name passed to toAiGateway(), empty for a placeholder mount + * @rc The request collection, carrying the matched `:gateway` placeholder + */ + private string function resolveGatewayName( required string pinnedGateway, required struct rc ){ + return len( arguments.pinnedGateway ) ? arguments.pinnedGateway : ( arguments.rc.gateway ?: "" ); + } + + /** + * Resolve the GatewaySession a toAiGateway() route dispatches into: a live instance is used + * as-is, a WireBox ID is resolved per request (so registering the route never forces the + * session to be constructed), and an empty value means "parse, don't dispatch". + * + * @session The `session` argument toAiGateway() was given + * + * @return The GatewaySession, or null when the route dispatches nothing + */ + private any function resolveGatewaySession( required any session ){ + if ( isObject( arguments.session ) ) { + return arguments.session; + } + if ( !len( trim( arguments.session ) ) ) { + return javacast( "null", "" ); + } + return getInstance( arguments.session ); + } + + /** + * The inbound request headers, for gateway signature verification. Defensive in exactly the + * way RequestContext.getHTTPHeader() is: read from a thread there is no request to read. + */ + private struct function getGatewayRequestHeaders(){ + try { + return getHTTPRequestData( false ).headers; + } catch ( any e ) { + return {}; + } + } + + /** + * Render a bx-ai gateway result as the response. + * + * Both the status code and the content type come back from the gateway per request — a 401 + * on a bad signature, a plain-text challenge echo on a handshake — so this renders directly + * instead of returning a body for the route's static `statusCode` to wrap. + * + * @event The request context + * @result The `{ statusCode, body, contentType, headers }` result from GatewayRequestProcessor + */ + private any function writeGatewayResult( required event, required struct result ){ + var contentType = arguments.result.contentType ?: "application/json"; + var thisEvent = arguments.event; + var resultHeaders = arguments.result.headers ?: {}; + + resultHeaders.each( ( key, value ) => { + thisEvent.setHTTPHeader( name = key, value = value ); + } ); + + thisEvent.renderData( + type = findNoCase( "json", contentType ) ? "JSON" : "PLAIN", + data = arguments.result.body ?: {}, + contentType = contentType, + statusCode = arguments.result.statusCode ?: 200 + ); + + return ""; + } + /** * Composes the base URL for the server using the following composition: * - protocol diff --git a/system/web/services/RoutingService.cfc b/system/web/services/RoutingService.cfc index 7450a97f2..fe98fcdc0 100644 --- a/system/web/services/RoutingService.cfc +++ b/system/web/services/RoutingService.cfc @@ -429,6 +429,18 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { verbs : routeResults.route.verbs } ); + } else if ( routeResults.route.gateway ?: false ) { + var loggedGateway = len( routeResults.route.gatewayName ) ? routeResults.route.gatewayName : ( + routeResults.params.gateway ?: "" + ); + getLogger().debug( + "Executing AI gateway route: #routeResults.route.pattern#", + { + route : routeResults.route.pattern, + gateway : loggedGateway, + verbs : routeResults.route.verbs + } + ); } } renderResponse( routeResults.route, arguments.event ); @@ -899,11 +911,27 @@ component extends="coldbox.system.web.services.BaseService" accessors="true" { } // Closure/Lambda else { + // Tracked so a closure that renders the response ITSELF is not flattened back onto the + // route's static statusCode by the renderData() call at the bottom of this method. An AI + // gateway route is the case in point: its status code and content type come back from the + // gateway per request (a 401 on a bad signature, a plain-text challenge on a handshake), + // not from the route definition. Only renderData set DURING the closure counts — one an + // interceptor set before the route ran is left to whatever set it. + var priorRenderData = event.getRenderData(); + var hadRenderData = !priorRenderData.isEmpty(); + theResponse = aRoute.response( event, event.getCollection(), event.getPrivateCollection() ); + + // The closure rendered the response itself — nothing left to marshall here + var closureRenderData = event.getRenderData(); + if ( !hadRenderData && !closureRenderData.isEmpty() ) { + event.noExecution(); + return; + } } // render it out diff --git a/tests/specs/web/routing/RouterGatewayTest.cfc b/tests/specs/web/routing/RouterGatewayTest.cfc new file mode 100644 index 000000000..328d358aa --- /dev/null +++ b/tests/specs/web/routing/RouterGatewayTest.cfc @@ -0,0 +1,210 @@ +/** + * AI Gateway Routing Tests — covers the toAiGateway() terminator + */ +component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { + + boolean function notBoxlang(){ + return !isBoxLang() + } + + /*********************************** LIFE CYCLE Methods ***********************************/ + + function beforeAll(){ + super.beforeAll() + // Controller mock with bxai module registered so ensureBoxLang() passes + variables.controller = createMock( "coldbox.system.web.Controller" ) + .init( expandPath( "/coldbox/test-harness" ), "cbController" ) + .setSetting( "AppMapping", "" ) + .setSetting( "RoutingAppMapping", "/" ) + } + + /*********************************** BDD SUITES ***********************************/ + + function run( testResults, testBox ){ + if ( notBoxlang() ) { + return; + } + + describe( "AI Gateway Routing — toAiGateway()", function(){ + beforeEach( function(){ + variables.router = createMock( "coldbox.system.web.routing.Router" ) + .init() + .setController( controller ) + .setLogBox( controller.getLogBox() ) + .setLog( controller.getLogBox().getLogger( this ) ) + .setCacheBox( controller.getCacheBox() ) + .setWireBox( controller.getWireBox() ) + } ) + + story( "I want to expose a gateway surface behind a base route pattern", function(){ + given( "no gateway name", function(){ + then( "it should register the sub-route family with a :gateway placeholder", function(){ + router.route( "/gateways" ).toAiGateway() + + var routes = router.getRoutes() + var patterns = routes.map( ( r ) => r.pattern ) + + expect( routes ).toHaveLength( 4 ) + expect( patterns ).toInclude( "gateways/:gateway/events/" ) + expect( patterns ).toInclude( "gateways/interactions/:requestID/" ) + expect( patterns ).toInclude( "gateways/interactions/:requestID/decisions/" ) + expect( patterns ).toInclude( "gateways/info/" ) + } ) + } ) + + given( "a pinned gateway name", function(){ + then( "the events route should carry no placeholder", function(){ + router.route( "/webhooks/slack" ).toAiGateway( "slack" ) + + var patterns = router.getRoutes().map( ( r ) => r.pattern ) + + expect( patterns ).toInclude( "webhooks/slack/events/" ) + expect( patterns ).notToInclude( "webhooks/slack/:gateway/events/" ) + } ) + + then( "every sub-route should carry gateway=true and the gateway name", function(){ + router.route( "/webhooks/slack" ).toAiGateway( "slack" ) + + var routes = router.getRoutes() + routes.each( ( r ) => { + expect( r.gateway ).toBeTrue() + expect( r.gatewayName ).toBe( "slack" ) + } ) + } ) + } ) + + given( "a session WireBox id", function(){ + then( "every sub-route should carry it as gatewaySession", function(){ + router.route( "/gateways" ).toAiGateway( session = "SupportAgentSession" ) + + var routes = router.getRoutes() + routes.each( ( r ) => { + expect( r.gatewaySession ).toBe( "SupportAgentSession" ) + } ) + } ) + } ) + + given( "a registered gateway route family", function(){ + then( "the events route should answer both GET and POST", function(){ + router.route( "/gateways" ).toAiGateway() + + var routes = router.getRoutes() + var eventsRoute = routes.filter( ( r ) => r.pattern == "gateways/:gateway/events/" )[ 1 ] + + expect( eventsRoute.verbs ).toBe( "GET,POST" ) + } ) + + then( "interactions should be GET, decisions POST, and info GET", function(){ + router.route( "/gateways" ).toAiGateway() + + var byPattern = {} + var routes = router.getRoutes() + routes.each( ( r ) => { + byPattern[ r.pattern ] = r; + } ) + + expect( byPattern[ "gateways/interactions/:requestID/" ].verbs ).toBe( "GET" ) + expect( byPattern[ "gateways/interactions/:requestID/decisions/" ].verbs ).toBe( "POST" ) + expect( byPattern[ "gateways/info/" ].verbs ).toBe( "GET" ) + } ) + } ) + + given( "a named base route", function(){ + then( "sub-routes should inherit the base name as a prefix", function(){ + router.route( pattern = "/gateways", name = "gw" ).toAiGateway() + + var names = router.getRoutes().map( ( r ) => r.name ) + + expect( names ).toInclude( "gw.gateway.events" ) + expect( names ).toInclude( "gw.gateway.interaction" ) + expect( names ).toInclude( "gw.gateway.decision" ) + expect( names ).toInclude( "gw.gateway.info" ) + } ) + } ) + + given( "a base route with withSSL() set", function(){ + then( "all sub-routes should inherit ssl=true", function(){ + router + .route( "/gateways" ) + .withSSL() + .toAiGateway() + + var routes = router.getRoutes() + routes.each( ( r ) => { + expect( r.ssl ).toBeTrue() + } ) + } ) + } ) + + given( "a base route with withCondition() set", function(){ + then( "all sub-routes should inherit the condition", function(){ + router + .route( "/gateways" ) + .withCondition( ( route, params, event ) => true ) + .toAiGateway() + + var routes = router.getRoutes() + routes.each( ( r ) => { + expect( isClosure( r.condition ) || isCustomFunction( r.condition ) ).toBeTrue() + } ) + } ) + } ) + } ) + + story( "I want argument validation on toAiGateway()", function(){ + given( "a numeric value as session", function(){ + then( "it should throw InvalidArgumentException", function(){ + expect( function(){ + router.route( "/gateways" ).toAiGateway( "slack", 123 ) + } ).toThrow( "InvalidArgumentException" ) + } ) + } ) + + given( "an array as session", function(){ + then( "it should throw InvalidArgumentException", function(){ + expect( function(){ + router.route( "/gateways" ).toAiGateway( "slack", [] ) + } ).toThrow( "InvalidArgumentException" ) + } ) + } ) + } ) + + story( "I want the gateway name and session resolved per request", function(){ + beforeEach( function(){ + makePublic( router, "resolveGatewayName" ) + makePublic( router, "resolveGatewaySession" ) + } ) + + given( "a pinned gateway name", function(){ + then( "it wins over anything in the request collection", function(){ + expect( router.resolveGatewayName( "slack", { "gateway" : "spoofed" } ) ).toBe( "slack" ) + } ) + } ) + + given( "no pinned gateway name", function(){ + then( "the matched :gateway placeholder is used", function(){ + expect( router.resolveGatewayName( "", { "gateway" : "telegram" } ) ).toBe( "telegram" ) + } ) + + then( "an absent placeholder resolves to an empty name", function(){ + expect( router.resolveGatewayName( "", {} ) ).toBe( "" ) + } ) + } ) + + given( "no session", function(){ + then( "it resolves to null, meaning parse without dispatching", function(){ + expect( isNull( router.resolveGatewaySession( "" ) ) ).toBeTrue() + } ) + } ) + + given( "a live session instance", function(){ + then( "it is used as-is, with no WireBox lookup", function(){ + var fakeSession = createStub() + expect( router.resolveGatewaySession( fakeSession ) ).toBe( fakeSession ) + } ) + } ) + } ) + } ) + } + +}