diff --git a/.cursor/rules/project-structure.mdc b/.cursor/rules/project-structure.mdc index 82a23c2..57f6c95 100644 --- a/.cursor/rules/project-structure.mdc +++ b/.cursor/rules/project-structure.mdc @@ -28,8 +28,9 @@ The main entry point is [index.js](mdc:index.js), which exports all framework co ## Application Types -1. **KoaApplication** - Standard HTTP web server -2. **SocketApplication** - Socket.io server -3. **WebSocketApplication** - Native WebSocket server +1. **KoaApplication** - Standard HTTP web server (extends Application) +2. **SocketApplication** - TCP socket server (extends Application) +3. **WebSocketApplication** - Native WebSocket server (extends SocketApplication) -All applications extend the base [Application](mdc:src/apps/app.js) class. +KoaApplication and SocketApplication extend the base [Application](mdc:src/apps/app.js) class. +WebSocketApplication extends [SocketApplication](mdc:src/apps/socket.js), inheriting connection management methods. diff --git a/.cursor/rules/socket-application.mdc b/.cursor/rules/socket-application.mdc new file mode 100644 index 0000000..584fcff --- /dev/null +++ b/.cursor/rules/socket-application.mdc @@ -0,0 +1,126 @@ +--- +globs: *.js,*.ts +description: SocketApplication and WebSocketApplication usage patterns and class hierarchy +--- + +# Socket Application Patterns + +## Class Hierarchy + +``` +Application (base) + └── SocketApplication (TCP socket, extends Application) + └── WebSocketApplication (WebSocket, extends SocketApplication) +``` + +`WebSocketApplication` extends [SocketApplication](mdc:src/apps/socket.js), not `Application` directly. +Connection management methods (`sendByConnectionId`, `closeByConnectionId`, `getConnection`, `ping`) are defined once in `SocketApplication` and inherited by `WebSocketApplication`. + +## SocketApplication Setup + +TCP socket server using `net.createServer`: + +```javascript +const { SocketApplication, Router } = require("@axiosleo/koapp"); + +const router = new Router("/", { /* ... */ }); + +const app = new SocketApplication({ + routers: [router], + port: 8081, + ping: { + open: true, + interval: 1000 * 10, + data: "this is a ping message", + }, +}); + +app.start(); +``` + +Message protocol: JSON payload terminated by `@@@@@@` delimiter. + +``` +{"path":"/test","method":"GET","query":{"test":123}}@@@@@@ +``` + +## WebSocketApplication Setup + +WebSocket server using the `ws` library: + +```javascript +const { WebSocketApplication, Router } = require("@axiosleo/koapp"); + +const router = new Router("/", { /* ... */ }); + +const app = new WebSocketApplication({ + routers: [router], + port: 8081, + ping: { + open: false, + interval: 1000 * 3, + data: "this is a ping message", + }, +}); + +app.start(); +``` + +Message protocol: plain JSON string (no delimiter). + +``` +{"path":"/test","method":"GET","query":{"test":123}} +``` + +## Ping Configuration + +Both applications support automatic heartbeat via `pingConfig`: + +| Field | Type | Default | Description | +|------------|---------|-----------------|-------------------------------| +| `open` | boolean | `false` | Enable periodic ping | +| `interval` | number | `300000` (5min) | Milliseconds between pings | +| `data` | any | `"this is a ping message"` | Payload sent with each ping | + +## Connection Management (inherited from SocketApplication) + +All methods below are available on both `SocketApplication` and `WebSocketApplication`: + +- `app.send(connection, data, msg, code)` — Send to a connection object (overridden per subclass) +- `app.sendByConnectionId(id, data, msg, code)` — Send by connection ID +- `app.close(connection)` — Close a connection object (overridden per subclass) +- `app.closeByConnectionId(id)` — Close by connection ID +- `app.getConnection(id)` — Get connection object by ID, returns `null` if not found +- `app.ping(id)` — Send a ping to a specific connection +- `app.broadcast(data, msg, code, connections)` — Broadcast to all or specific connections (overridden per subclass) + +Pass `connections = null` to `broadcast` to send to all active connections. + +## Protocol Differences + +| Aspect | SocketApplication | WebSocketApplication | +|------------------|-------------------------------|-------------------------------| +| Transport | TCP (`net`) | WebSocket (`ws`) | +| `send()` | `connection.write(data + '@@@@@@')` | `connection.send(data)` | +| `close()` | `connection.end()` | `connection.close()` | +| Response handler | Appends `@@@@@@` delimiter | No delimiter | + +## Application Events + +Both applications emit the following events: + +- `starting` — When the application begins initialization +- `response` — After each response is sent (handled by internal response handler) +- `connection` — When a new client connects (emitted on `app.event`) +- `listen` — When the server starts listening (emitted on `app.event`) + +## Context Properties + +Inside route handlers, the context object includes: + +- `context.app` — The application instance +- `context.socket` — The raw connection object +- `context.connection_id` — Unique ID for this connection +- `context.query` — Parsed query parameters +- `context.body` — Parsed request body +- `context.headers` — Request headers (WebSocket only) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1d23d4..c7ba290 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: operating-system: [macos-latest, ubuntu-latest] - node-version: [16, 18, 20] + node-version: [16, 18, 20, 22, 24] name: Node.js ${{ matrix.node-version }} Test on ${{ matrix.operating-system }} steps: diff --git a/eslint.config.mjs b/eslint.config.mjs index b3e8137..ffc9dc2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,7 +24,7 @@ export default [...compat.extends("eslint:recommended"), { beforeEach: true, }, - ecmaVersion: 2018, + ecmaVersion: 2020, sourceType: "commonjs", parserOptions: { diff --git a/examples/websocket.server.js b/examples/websocket.server.js index 8831e7f..c736b70 100644 --- a/examples/websocket.server.js +++ b/examples/websocket.server.js @@ -1,3 +1,4 @@ +const { debug } = require('@axiosleo/cli-tool'); const { WebSocketApplication } = require('../src/apps'); const root = require('./api.router'); @@ -11,4 +12,10 @@ const app = new WebSocketApplication({ } }); +setInterval(() => { + debug.log('send message'); + const res = app.broadcast('Hello, world!', 'ok', 0, null); + debug.log('send message result:', res); +}, 1000); + app.start(); diff --git a/index.d.ts b/index.d.ts index 2aa9360..f60b2f0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -481,6 +481,8 @@ interface KoaContext< TBody = any, TQuery = any, > extends AppContext { + /** Application instance */ + app: KoaApplication; /** Route parameters */ params?: TParams; /** Application configuration */ @@ -533,6 +535,10 @@ export interface SocketContext< TBody = any, TQuery = any, > extends AppContext { + /** Application instance */ + app: SocketApplication; + /** Connection ID */ + connection_id: string; /** Route parameters */ params?: TParams; /** Application configuration */ @@ -580,6 +586,10 @@ export interface WebSocketContext< TBody = any, TQuery = any, > extends AppContext { + /** Application instance */ + app: WebSocketApplication; + /** Connection ID */ + connection_id: string; /** Route parameters */ params?: TParams; /** Application configuration */ @@ -1236,7 +1246,7 @@ export declare abstract class Application extends EventEmitter { /** Application identifier */ app_id: string; /** Application configuration */ - config: Configuration; + config: AppConfiguration; constructor(config: AppConfiguration); @@ -1297,10 +1307,19 @@ export declare class SocketClient { close(): void; } +export type PingConfig = { open: boolean; interval: number; data: any }; + /** * Socket-based application */ export declare class SocketApplication extends Application { + config: AppConfiguration & { + ping?: PingConfig; + }; + pingConfig: PingConfig; + /** Active connections indexed by connection ID */ + connections: Record; + constructor(config: SocketAppConfiguration); /** @@ -1322,12 +1341,60 @@ export declare class SocketApplication extends Application { code?: number, connections?: Socket[], ): void; + + /** + * Send data to a specific connection + * @param connection Connection to send to + * @param data Data to send + * @param msg Message + * @param code Status code + */ + send(connection: Socket, data?: any, msg?: string, code?: number): boolean; + + /** + * Send data to a specific connection by connection ID + * @param connection_id Connection ID to send to + * @param data Data to send + * @param msg Message + * @param code Status code + */ + sendByConnectionId( + connection_id: string, + data?: any, + msg?: string, + code?: number, + ): boolean; + + /** + * Close a specific connection + * @param connection Connection to close + */ + close(connection: Socket): boolean; + + /** + * Close a specific connection by connection ID + */ + closeByConnectionId(connection_id: string): boolean; + + /** + * Get a specific connection by connection ID + * @param connection_id Connection ID to get + * @returns Connection or null if not found + */ + getConnection(connection_id: string): Socket | null; + + /** + * Ping a specific connection + * @param connection_id Connection ID to ping + */ + ping(connection_id: string): boolean; } /** * WebSocket-based application */ -export declare class WebSocketApplication extends Application { +export declare class WebSocketApplication extends SocketApplication { + pingConfig: { open: boolean; interval: number; data: any }; constructor(config: WebSocketAppConfiguration); /** @@ -1349,6 +1416,23 @@ export declare class WebSocketApplication extends Application { code?: number, connections?: WebSocket[], ): void; + + /** + * Send data to a specific connection + * @param connection Connection to send to + * @param data Data to send + * @param msg Message + * @param code Status code + */ + send(connection: WebSocket, data?: any, msg?: string, code?: number): boolean; + + /** + * Close a specific connection + * @param connection Connection to close + */ + close(connection: WebSocket): boolean; + + getConnection(connection_id: string): WebSocket | null; } // ======================================== diff --git a/src/apps/socket.js b/src/apps/socket.js index b329d9b..38bafb9 100644 --- a/src/apps/socket.js +++ b/src/apps/socket.js @@ -11,7 +11,7 @@ const operator = require('../workflows/socket.workflow'); const { _assign } = require('@axiosleo/cli-tool/src/helper/obj'); const { _sleep } = require('@axiosleo/cli-tool/src/helper/cmd'); -const dispatcher = ({ app, app_id, workflow, connection }) => { +const dispatcher = ({ app, app_id, workflow, connection, connection_id }) => { return async (ctx) => { let context = initContext({ app, @@ -21,6 +21,7 @@ const dispatcher = ({ app, app_id, workflow, connection }) => { app_id, }); context.socket = connection; + context.connection_id = connection_id; context.query = ctx.query || {}; context.body = ctx.body || {}; try { @@ -72,8 +73,8 @@ class SocketApplication extends Application { this.connections = {}; this.on('response', handleRes); this.workflow = new Workflow(operator); - this.ping = {}; - _assign(this.ping, { + this.pingConfig = {}; + _assign(this.pingConfig, { open: false, interval: 1000 * 60 * 5, data: 'this is a ping message' @@ -100,7 +101,8 @@ class SocketApplication extends Application { app: self, app_id: self.app_id, workflow: self.workflow, - connection + connection, + connection_id }); process.nextTick(callback, context); } catch (err) { @@ -123,11 +125,11 @@ class SocketApplication extends Application { debug.error('[Socket App]', 'socket server error:', err); } }); - if (this.ping.open) { + if (this.pingConfig.open) { const self = this; printer.info('[Socket App] ping is open.'); process.nextTick(() => { - ping.call(self, self.ping.data, self.ping.interval); + ping.call(self, self.pingConfig.data, self.pingConfig.interval); }); } @@ -157,6 +159,57 @@ class SocketApplication extends Application { Object.keys(connections).map((id) => connections[id].write(data)); } } + + send(connection = null, data = '', msg = 'ok', code = 0) { + if (connection) { + data = JSON.stringify({ + request_id: _uuid_salt(this.app_id), + timestamp: (new Date()).getTime(), + code, + message: msg, + data: data + }); + connection.write(data + '@@@@@@'); + return true; + } + return false; + } + + close(connection = null) { + if (connection) { + connection.end(); + return true; + } + return false; + } + + sendByConnectionId(connection_id = null, data = '', msg = 'ok', code = 0) { + if (connection_id && this.connections[connection_id]) { + return this.send(this.connections[connection_id], data, msg, code); + } + return false; + } + + closeByConnectionId(connection_id = null) { + if (connection_id && this.connections[connection_id]) { + return this.close(this.connections[connection_id]); + } + return false; + } + + getConnection(connection_id = null) { + if (connection_id && this.connections[connection_id]) { + return this.connections[connection_id]; + } + return null; + } + + ping(connection_id = null) { + if (connection_id && this.connections[connection_id]) { + return this.send(this.connections[connection_id], 'ping', 'ok', 0); + } + return false; + } } module.exports = SocketApplication; diff --git a/src/apps/websocket.js b/src/apps/websocket.js index 94230b8..7ed4418 100644 --- a/src/apps/websocket.js +++ b/src/apps/websocket.js @@ -1,14 +1,11 @@ 'use strict'; const { WebSocketServer } = require('ws'); -const EventEmitter = require('events'); -const Application = require('./app'); -const { debug, printer, Workflow } = require('@axiosleo/cli-tool'); +const SocketApplication = require('./socket'); +const { debug, printer } = require('@axiosleo/cli-tool'); const { _uuid_salt } = require('../utils'); const { initContext } = require('../core'); const is = require('@axiosleo/cli-tool/src/helper/is'); -const operator = require('../workflows/socket.workflow'); -const { _assign } = require('@axiosleo/cli-tool/src/helper/obj'); const { _sleep } = require('@axiosleo/cli-tool/src/helper/cmd'); /** @@ -16,9 +13,11 @@ const { _sleep } = require('@axiosleo/cli-tool/src/helper/cmd'); * @param {{request: import('http').IncomingMessage}} param0 * @returns */ -const dispatcher = ({ app, app_id, workflow, connection, request }) => { +const dispatcher = ({ app, app_id, workflow, connection_id, connection, request }) => { return async (ctx) => { - const url = new URL(request.url, `ws://localhost:${app.port}`); + const host = request.headers.host || `localhost:${app.port}`; + const protocol = request.headers['x-forwarded-proto'] === 'https' ? 'wss' : 'ws'; + const url = new URL(request.url, `${protocol}://${host}`); let context = initContext({ app, method: request.method ? request.method.toUpperCase() : 'GET', @@ -26,6 +25,7 @@ const dispatcher = ({ app, app_id, workflow, connection, request }) => { app_id, }); context.socket = connection; + context.connection_id = connection_id; context.query = Object.fromEntries(url.searchParams); context.body = ctx || {}; context.headers = request.headers; @@ -69,27 +69,20 @@ async function ping(data, interval) { }); } -class WebSocketApplication extends Application { +class WebSocketApplication extends SocketApplication { /** * * @param {import('../../').WebSocketAppConfiguration} options */ constructor(options) { super(options); - - this.event = new EventEmitter(); - this.port = this.config.port || 8081; - this.connections = {}; + this.removeAllListeners('response'); this.on('response', handleRes); - this.workflow = new Workflow(operator); - this.ping = {}; - _assign(this.ping, { - open: false, - interval: 1000 * 60 * 5, - data: 'this is a ping message' - }, this.config.ping || {}); - delete options.ping; - this.websocketOptions = options; + this.websocketOptions = { ...options }; + delete this.websocketOptions.ping; + delete this.websocketOptions.routers; + delete this.websocketOptions.debug; + delete this.websocketOptions.app_id; } async start() { @@ -116,6 +109,7 @@ class WebSocketApplication extends Application { app_id: self.app_id, workflow: self.workflow, connection: ws, + connection_id, request }); process.nextTick(callback, context); @@ -140,11 +134,11 @@ class WebSocketApplication extends Application { } }); - if (this.ping.open) { + if (this.pingConfig.open) { const self = this; printer.info('[Socket App] ping is open.'); process.nextTick(() => { - ping.call(self, self.ping.data, self.ping.interval); + ping.call(self, self.pingConfig.data, self.pingConfig.interval); }); } } @@ -168,6 +162,29 @@ class WebSocketApplication extends Application { Object.keys(connections).map((id) => connections[id].send(data)); } } + + send(connection = null, data = '', msg = 'ok', code = 0) { + if (connection) { + data = JSON.stringify({ + request_id: _uuid_salt(this.app_id), + timestamp: (new Date()).getTime(), + code, + message: msg, + data: data + }); + connection.send(data); + return true; + } + return false; + } + + close(connection = null) { + if (connection) { + connection.close(); + return true; + } + return false; + } } module.exports = WebSocketApplication; diff --git a/src/workflows/socket.workflow.js b/src/workflows/socket.workflow.js index a8d59ae..d864230 100644 --- a/src/workflows/socket.workflow.js +++ b/src/workflows/socket.workflow.js @@ -15,7 +15,7 @@ const { _debug } = require('../utils'); */ function receive(context) { try { - context.app?.emit?.('receive', context); + context.app.emit('receive', context); const router = getRouteInfo(context.app.routes, context.pathinfo, context.method); if (!router) { error(404, 'Not Found'); @@ -34,7 +34,7 @@ function receive(context) { */ function validate(context) { try { - context.app?.emit?.('validate', context); + context.app.emit('validate', context); if (context.router && context.router.validators) { const { params, query, body } = context.router.validators; const check = {}; @@ -78,7 +78,7 @@ function validate(context) { */ async function middleware(context) { try { - context.app?.emit?.('middleware', context); + context.app.emit('middleware', context); // exec middleware by routes configuration if (context.router && context.router.middlewares && context.router.middlewares.length > 0) { await _foreach(context.router.middlewares, async (middleware) => { @@ -97,7 +97,7 @@ async function middleware(context) { */ async function handle(context) { try { - context.app?.emit?.('handle', context); + context.app.emit('handle', context); if (context.router && context.router.handlers && context.router.handlers.length > 0) { await _foreach(context.router.handlers, async (handler) => { @@ -132,7 +132,7 @@ function response(context) { message: context.response.message, data: {} }); - } else if (context.app?.config?.debug) { + } else if (context.app.config.debug) { error = context.response; response = new HttpResponse({ format: 'json', @@ -161,12 +161,12 @@ function response(context) { // eslint-disable-next-line no-console console.log(error); } - if (context.app?.config?.debug && !error) { + if (context.app.config.debug && !error) { let tmp = context.response.stack.split(os.EOL); let t = tmp.find((s) => !s.startsWith('Error:') && s.indexOf('node_modules') === -1); _debug(context, t); } - context.app?.emit?.('response', context); + context.app.emit('response', context); } /** @@ -175,13 +175,13 @@ function response(context) { */ async function after(context) { try { - context.app?.emit?.('request_end', context); + context.app.emit('request_end', context); if (context.router && context.router.afters && context.router.afters.length > 0) { await _foreach(context.router.afters, async (after) => { try { await after(context); } catch (err) { - context.app?.emit?.('after_error', context, err); + context.app.emit('after_error', context, err); } }); } diff --git a/tests/app.tests.js b/tests/app.tests.js new file mode 100644 index 0000000..3fb3b45 --- /dev/null +++ b/tests/app.tests.js @@ -0,0 +1,69 @@ +'use strict'; + +const { expect } = require('chai'); +const Application = require('../src/apps/app'); +const { Router } = require('../src/router'); + +describe('Application', () => { + describe('constructor', () => { + it('should create with default config', () => { + const app = new Application({}); + expect(app.config).to.not.be.undefined; + expect(app.config.debug).to.equal(false); + expect(app.app_id).to.be.a('string'); + expect(app.routes).to.be.an('object'); + }); + + it('should generate unique app_id when not provided', () => { + const app1 = new Application({}); + const app2 = new Application({}); + expect(app1.app_id).to.be.a('string'); + expect(app2.app_id).to.be.a('string'); + expect(app1.app_id).to.not.equal(app2.app_id); + }); + + it('should use custom app_id from config', () => { + const app = new Application({ app_id: 'my-custom-id' }); + expect(app.app_id).to.equal('my-custom-id'); + }); + + it('should set debug from config', () => { + const app = new Application({ debug: true }); + expect(app.config.debug).to.equal(true); + }); + + it('should resolve routers', () => { + const router = new Router('/api'); + router.get('/test', async () => {}); + const app = new Application({ routers: [router] }); + expect(app.routes).to.be.an('object'); + expect(Object.keys(app.routes).length).to.be.greaterThan(0); + }); + + it('should be an EventEmitter', () => { + const app = new Application({}); + expect(app.on).to.be.a('function'); + expect(app.emit).to.be.a('function'); + }); + + it('should emit starting event', (done) => { + const app = new Application({}); + app.on('starting', () => { + done(); + }); + app.emit('starting'); + }); + }); + + describe('start()', () => { + it('should throw "not implemented"', async () => { + const app = new Application({}); + try { + await app.start(); + expect.fail('should have thrown'); + } catch (e) { + expect(e.message).to.equal('not implemented'); + } + }); + }); +}); diff --git a/tests/controller.tests.js b/tests/controller.tests.js new file mode 100644 index 0000000..11b3707 --- /dev/null +++ b/tests/controller.tests.js @@ -0,0 +1,117 @@ +'use strict'; + +const { expect } = require('chai'); +const Controller = require('../src/controller'); +const { HttpResponse } = require('../src/response'); + +describe('Controller', () => { + let ctrl; + + beforeEach(() => { + ctrl = new Controller(); + }); + + describe('response()', () => { + it('should throw HttpResponse', () => { + try { + ctrl.response({ data: 'test' }, '200;OK', 200); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.data).to.deep.equal({ data: 'test' }); + expect(e.code).to.equal('200;OK'); + expect(e.status).to.equal(200); + } + }); + + it('should pass headers', () => { + try { + ctrl.response({}, '200;OK', 200, { 'X-Test': '1' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e.headers).to.deep.equal({ 'X-Test': '1' }); + } + }); + }); + + describe('result()', () => { + it('should throw HttpResponse with notResolve', () => { + try { + ctrl.result('data', 201, { 'X-H': 'v' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.data).to.equal('data'); + expect(e.status).to.equal(201); + expect(e.notResolve).to.be.true; + } + }); + }); + + describe('success()', () => { + it('should throw HttpResponse with 200', () => { + try { + ctrl.success({ ok: true }); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.code).to.equal('200;Success'); + expect(e.status).to.equal(200); + } + }); + + it('should use empty default data', () => { + try { + ctrl.success(); + expect.fail('should have thrown'); + } catch (e) { + expect(e.data).to.deep.equal({}); + } + }); + }); + + describe('failed()', () => { + it('should throw HttpResponse with custom code and status', () => { + try { + ctrl.failed({ err: 'bad' }, '400;Bad Request', 400); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.code).to.equal('400;Bad Request'); + expect(e.status).to.equal(400); + } + }); + + it('should use default status 501', () => { + try { + ctrl.failed(); + expect.fail('should have thrown'); + } catch (e) { + expect(e.status).to.equal(501); + } + }); + }); + + describe('error()', () => { + it('should throw HttpResponse with status;msg code', () => { + try { + ctrl.error(403, 'Forbidden'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.code).to.equal('403;Forbidden'); + expect(e.status).to.equal(403); + } + }); + }); + + describe('log()', () => { + it('should not throw', () => { + expect(() => ctrl.log('test message')).to.not.throw(); + }); + + it('should accept multiple arguments', () => { + expect(() => ctrl.log('msg', { data: 1 }, [1, 2])).to.not.throw(); + }); + }); +}); diff --git a/tests/core.tests.js b/tests/core.tests.js new file mode 100644 index 0000000..fdf4644 --- /dev/null +++ b/tests/core.tests.js @@ -0,0 +1,189 @@ +'use strict'; + +const { expect } = require('chai'); +const { resolveRouters, getRouteInfo, initContext } = require('../src/core'); +const { Router } = require('../src/router'); + +describe('core', () => { + describe('initContext()', () => { + it('should create context with defaults', () => { + const ctx = initContext(); + expect(ctx.app).to.be.null; + expect(ctx.app_id).to.equal(''); + expect(ctx.curr).to.deep.equal({}); + expect(ctx.step_data).to.deep.equal({}); + expect(ctx.method).to.equal(''); + expect(ctx.pathinfo).to.equal('/'); + expect(ctx.request_id).to.be.a('string'); + }); + + it('should accept custom options', () => { + const mockApp = { name: 'test' }; + const ctx = initContext({ + app: mockApp, + method: 'POST', + pathinfo: '/api/test', + app_id: 'my-app' + }); + expect(ctx.app).to.equal(mockApp); + expect(ctx.method).to.equal('POST'); + expect(ctx.pathinfo).to.equal('/api/test'); + expect(ctx.app_id).to.equal('my-app'); + expect(ctx.request_id).to.be.a('string'); + }); + + it('should generate unique request_id', () => { + const ctx1 = initContext(); + const ctx2 = initContext(); + expect(ctx1.request_id).to.not.equal(ctx2.request_id); + }); + }); + + describe('resolveRouters()', () => { + it('should handle single router (non-array)', () => { + const router = new Router('/api', { method: 'GET', handlers: [async () => {}] }); + const tree = resolveRouters(router); + expect(tree).to.be.an('object'); + }); + + it('should handle array of routers', () => { + const r1 = new Router('/api', { method: 'GET', handlers: [async () => {}] }); + const r2 = new Router('/admin', { method: 'GET', handlers: [async () => {}] }); + const tree = resolveRouters([r1, r2]); + expect(tree).to.be.an('object'); + }); + + it('should handle empty array', () => { + const tree = resolveRouters([]); + expect(tree).to.deep.equal({}); + }); + + it('should resolve routers with middlewares', () => { + const mw = async () => {}; + const router = new Router('/api', { + middlewares: [mw], + }); + router.get('/test', async () => {}); + const tree = resolveRouters(router); + const info = getRouteInfo(tree, '/api/test', 'GET'); + expect(info).to.not.be.null; + expect(info.middlewares).to.include(mw); + }); + + it('should resolve routers with afters', () => { + const afterFn = async () => {}; + const router = new Router('/api', { + afters: [afterFn], + }); + router.get('/test', async () => {}); + const tree = resolveRouters(router); + const info = getRouteInfo(tree, '/api/test', 'GET'); + expect(info).to.not.be.null; + expect(info.afters).to.include(afterFn); + }); + }); + + describe('getRouteInfo()', () => { + it('should return null for non-existent route', () => { + const router = new Router('/api'); + router.get('/users', async () => {}); + const tree = resolveRouters(router); + const info = getRouteInfo(tree, '/nonexistent', 'GET'); + expect(info).to.be.null; + }); + + it('should return null when method does not match', () => { + const router = new Router('/api'); + router.get('/users', async () => {}); + const tree = resolveRouters(router); + const info = getRouteInfo(tree, '/api/users', 'POST'); + expect(info).to.be.null; + }); + + it('should match ANY method routes', () => { + const router = new Router('/api'); + router.any('/catch', async () => {}); + const tree = resolveRouters(router); + + expect(getRouteInfo(tree, '/api/catch', 'GET')).to.not.be.null; + expect(getRouteInfo(tree, '/api/catch', 'POST')).to.not.be.null; + expect(getRouteInfo(tree, '/api/catch', 'DELETE')).to.not.be.null; + }); + + it('should match wildcard *** catch-all route', () => { + const router = new Router(); + router.any('/***', async () => {}); + router.get('/specific', async () => {}); + const tree = resolveRouters(router); + + const specific = getRouteInfo(tree, '/specific', 'GET'); + expect(specific).to.not.be.null; + + const catchAll = getRouteInfo(tree, '/anything/at/all', 'GET'); + expect(catchAll).to.not.be.null; + }); + + it('should extract path params', () => { + const router = new Router('/api'); + router.get('/users/{:userId}/posts/{:postId}', async () => {}); + const tree = resolveRouters(router); + const info = getRouteInfo(tree, '/api/users/42/posts/99', 'GET'); + expect(info).to.not.be.null; + expect(info.params.userId).to.equal('42'); + expect(info.params.postId).to.equal('99'); + }); + + it('should handle root path', () => { + const router = new Router(); + router.get('/', async () => {}); + const tree = resolveRouters(router); + const info = getRouteInfo(tree, '/', 'GET'); + expect(info).to.not.be.null; + }); + + it('should handle multi-method routes', () => { + const router = new Router(); + router.push('GET|POST', '/multi', async () => {}); + const tree = resolveRouters(router); + expect(getRouteInfo(tree, '/multi', 'GET')).to.not.be.null; + expect(getRouteInfo(tree, '/multi', 'POST')).to.not.be.null; + expect(getRouteInfo(tree, '/multi', 'DELETE')).to.be.null; + }); + + it('should fall back to *** default when no match', () => { + const handler = async () => {}; + const root = new Router(); + root.any('/***', handler); + root.get('/api/test', async () => {}); + const tree = resolveRouters(root); + + const info = getRouteInfo(tree, '/api/unknown/path', 'GET'); + expect(info).to.not.be.null; + }); + + it('should return default validators when route has none', () => { + const router = new Router(); + router.get('/test', async () => {}); + const tree = resolveRouters(router); + const info = getRouteInfo(tree, '/test', 'GET'); + expect(info).to.not.be.null; + expect(info.validators).to.deep.equal({ params: {}, body: {}, query: {} }); + }); + + it('should handle nested sub-routers', () => { + const root = new Router('/api'); + const v1 = new Router('/v1'); + v1.get('/users', async () => {}); + root.add(v1); + const tree = resolveRouters(root); + const info = getRouteInfo(tree, '/api/v1/users', 'GET'); + expect(info).to.not.be.null; + }); + + it('should return null for completely empty tree', () => { + const tree = resolveRouters([]); + const info = getRouteInfo(tree, '/anything', 'GET'); + expect(info).to.be.null; + }); + }); +}); diff --git a/tests/model.tests.js b/tests/model.tests.js new file mode 100644 index 0000000..1d17c17 --- /dev/null +++ b/tests/model.tests.js @@ -0,0 +1,150 @@ +'use strict'; + +const { expect } = require('chai'); +const Model = require('../src/model'); +const { HttpError } = require('../src/response'); + +describe('Model', () => { + describe('constructor', () => { + it('should create empty model with no args', () => { + const model = new Model(); + expect(model).to.be.instanceOf(Model); + expect(model.count()).to.equal(0); + }); + + it('should assign properties from obj', () => { + const model = new Model({ name: 'test', value: 42 }); + expect(model.name).to.equal('test'); + expect(model.value).to.equal(42); + }); + + it('should pass with valid rules', () => { + const model = new Model({ name: 'test' }, { name: 'required' }); + expect(model.name).to.equal('test'); + }); + + it('should throw HttpError with invalid rules', () => { + try { + new Model({}, { name: 'required' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpError); + expect(e.status).to.equal(400); + expect(e.message).to.be.a('string'); + } + }); + + it('should throw HttpError with custom messages', () => { + try { + new Model({}, { email: 'required' }, { required: ':attribute is missing' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpError); + expect(e.message).to.equal('email is missing'); + } + }); + + it('should handle null obj', () => { + const model = new Model(null); + expect(model.count()).to.equal(0); + }); + }); + + describe('static create()', () => { + it('should create model instance', () => { + const model = Model.create({ a: 1, b: 2 }); + expect(model).to.be.instanceOf(Model); + expect(model.a).to.equal(1); + expect(model.b).to.equal(2); + }); + + it('should throw HttpError on validation failure', () => { + try { + Model.create({}, { param: 'required' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpError); + expect(e.status).to.equal(400); + } + }); + }); + + describe('toJson()', () => { + it('should serialize to JSON string', () => { + const model = new Model({ name: 'test', count: 5 }); + const json = model.toJson(); + expect(json).to.be.a('string'); + const parsed = JSON.parse(json); + expect(parsed.name).to.equal('test'); + expect(parsed.count).to.equal(5); + }); + }); + + describe('toObj()', () => { + it('should return plain object', () => { + const model = new Model({ x: 1 }); + const obj = model.toObj(); + expect(obj).to.deep.equal({ x: 1 }); + expect(obj).not.to.be.instanceOf(Model); + }); + + it('should deep-clone nested models', () => { + const model = new Model({ + sub: new Model({ a: 'A' }) + }); + const obj = model.toObj(); + expect(obj.sub).to.deep.equal({ a: 'A' }); + }); + }); + + describe('properties()', () => { + it('should return array of property names', () => { + const model = new Model({ a: 1, b: 2, c: 3 }); + const props = model.properties(); + expect(props).to.deep.equal(['a', 'b', 'c']); + }); + + it('should return empty array for empty model', () => { + const model = new Model(); + expect(model.properties()).to.deep.equal([]); + }); + }); + + describe('count()', () => { + it('should return number of properties', () => { + const model = new Model({ a: 1, b: 2 }); + expect(model.count()).to.equal(2); + }); + + it('should return 0 for empty model', () => { + const model = new Model(); + expect(model.count()).to.equal(0); + }); + }); + + describe('validate()', () => { + it('should return passing validation', () => { + const model = new Model({ name: 'test' }); + const validation = model.validate({ name: 'required' }); + expect(validation.fails()).to.be.false; + }); + + it('should return failing validation', () => { + const model = new Model({ name: '' }); + const validation = model.validate({ name: 'required' }); + expect(validation.fails()).to.be.true; + expect(validation.errors.all()).to.have.property('name'); + }); + + it('should accept custom messages', () => { + const model = new Model({}); + const validation = model.validate( + { name: 'required' }, + { required: ':attribute is mandatory' } + ); + expect(validation.fails()).to.be.true; + const errors = validation.errors.all(); + expect(errors.name[0]).to.equal('name is mandatory'); + }); + }); +}); diff --git a/tests/response.tests.js b/tests/response.tests.js new file mode 100644 index 0000000..7abf8a6 --- /dev/null +++ b/tests/response.tests.js @@ -0,0 +1,217 @@ +'use strict'; + +const { expect } = require('chai'); +const { + HttpResponse, + HttpError, + response, + result, + success, + failed, + error +} = require('../src/response'); + +describe('response module', () => { + describe('HttpResponse', () => { + it('should create with default values', () => { + const res = new HttpResponse(); + expect(res).to.be.instanceOf(Error); + expect(res.format).to.equal('text'); + expect(res.headers).to.deep.equal({}); + expect(res.status).to.equal(200); + expect(res.data).to.be.null; + expect(res.code).to.equal(''); + expect(res.message).to.equal(''); + expect(res.notResolve).to.be.false; + expect(res.stack).to.be.a('string'); + }); + + it('should create with custom config', () => { + const res = new HttpResponse({ + format: 'json', + headers: { 'X-Custom': 'test' }, + status: 404, + data: { foo: 'bar' }, + code: '404;Not Found', + message: 'Not Found', + notResolve: true + }); + expect(res.format).to.equal('json'); + expect(res.headers).to.deep.equal({ 'X-Custom': 'test' }); + expect(res.status).to.equal(404); + expect(res.data).to.deep.equal({ foo: 'bar' }); + expect(res.code).to.equal('404;Not Found'); + expect(res.message).to.equal('Not Found'); + expect(res.notResolve).to.be.true; + }); + + it('should override default values with config', () => { + const res = new HttpResponse({ status: 500, format: 'json' }); + expect(res.status).to.equal(500); + expect(res.format).to.equal('json'); + expect(res.data).to.be.null; + }); + }); + + describe('HttpError', () => { + it('should create with status and message', () => { + const err = new HttpError(400, 'Bad Request'); + expect(err).to.be.instanceOf(Error); + expect(err.status).to.equal(400); + expect(err.message).to.equal('Bad Request'); + expect(err.headers).to.deep.equal({}); + expect(err.stack).to.be.a('string'); + }); + + it('should create with custom headers', () => { + const err = new HttpError(401, 'Unauthorized', { 'WWW-Authenticate': 'Bearer' }); + expect(err.status).to.equal(401); + expect(err.message).to.equal('Unauthorized'); + expect(err.headers).to.deep.equal({ 'WWW-Authenticate': 'Bearer' }); + }); + }); + + describe('result()', () => { + it('should throw HttpResponse with notResolve true', () => { + try { + result({ test: 1 }, 201, { 'X-Header': 'val' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.data).to.deep.equal({ test: 1 }); + expect(e.status).to.equal(201); + expect(e.headers).to.deep.equal({ 'X-Header': 'val' }); + expect(e.notResolve).to.be.true; + } + }); + + it('should use default status and headers', () => { + try { + result('data'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.data).to.equal('data'); + expect(e.status).to.equal(200); + expect(e.headers).to.deep.equal({}); + } + }); + }); + + describe('response()', () => { + it('should throw HttpResponse with json format', () => { + try { + response({ msg: 'test' }, '200;OK', 200, { 'Content-Type': 'application/json' }, 'json'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.data).to.deep.equal({ msg: 'test' }); + expect(e.code).to.equal('200;OK'); + expect(e.status).to.equal(200); + expect(e.format).to.equal('json'); + expect(e.headers).to.deep.equal({ 'Content-Type': 'application/json' }); + } + }); + + it('should use defaults', () => { + try { + response('data'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.code).to.equal('200;Success'); + expect(e.status).to.equal(200); + expect(e.format).to.equal('json'); + expect(e.headers).to.deep.equal({}); + } + }); + }); + + describe('success()', () => { + it('should throw HttpResponse with 200 status', () => { + try { + success({ result: 'ok' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.data).to.deep.equal({ result: 'ok' }); + expect(e.code).to.equal('200;Success'); + expect(e.status).to.equal(200); + } + }); + + it('should pass custom headers', () => { + try { + success({}, { 'X-Custom': 'yes' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e.headers).to.deep.equal({ 'X-Custom': 'yes' }); + } + }); + + it('should use default empty data', () => { + try { + success(); + expect.fail('should have thrown'); + } catch (e) { + expect(e.data).to.deep.equal({}); + } + }); + }); + + describe('failed()', () => { + it('should throw HttpResponse with 501 default', () => { + try { + failed({ error: 'something' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.data).to.deep.equal({ error: 'something' }); + expect(e.code).to.equal('501;Internal Server Error'); + expect(e.status).to.equal(501); + } + }); + + it('should accept custom code and status', () => { + try { + failed({ data: 'err' }, '403;Forbidden', 403); + expect.fail('should have thrown'); + } catch (e) { + expect(e.code).to.equal('403;Forbidden'); + expect(e.status).to.equal(403); + } + }); + + it('should accept custom headers', () => { + try { + failed({}, '400;Bad', 400, { 'X-Err': '1' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e.headers).to.deep.equal({ 'X-Err': '1' }); + } + }); + }); + + describe('error()', () => { + it('should build code string from status and msg', () => { + try { + error(404, 'Not Found'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(HttpResponse); + expect(e.code).to.equal('404;Not Found'); + expect(e.status).to.equal(404); + expect(e.data).to.deep.equal({}); + } + }); + + it('should pass custom headers', () => { + try { + error(500, 'Server Error', { 'X-Debug': 'true' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e.headers).to.deep.equal({ 'X-Debug': 'true' }); + } + }); + }); +}); diff --git a/tests/router.tests.js b/tests/router.tests.js new file mode 100644 index 0000000..f0b08f5 --- /dev/null +++ b/tests/router.tests.js @@ -0,0 +1,244 @@ +'use strict'; + +const { expect } = require('chai'); +const { Router } = require('../src/router'); +const { resolveRouters, getRouteInfo } = require('../src/core'); + +describe('Router', () => { + describe('constructor', () => { + it('should create with defaults', () => { + const router = new Router(); + expect(router.prefix).to.equal(''); + expect(router.method).to.equal(''); + expect(router.handlers).to.deep.equal([]); + expect(router.middlewares).to.deep.equal([]); + expect(router.validators).to.deep.equal({}); + expect(router.subRouters).to.deep.equal({}); + expect(router.routers).to.deep.equal([]); + }); + + it('should set prefix', () => { + const router = new Router('/api'); + expect(router.prefix).to.equal('/api'); + }); + + it('should uppercase method from options', () => { + const router = new Router('/test', { method: 'get' }); + expect(router.method).to.equal('GET'); + }); + + it('should handle null prefix', () => { + const router = new Router(null); + expect(router.prefix).to.equal(''); + }); + + it('should handle null options', () => { + const router = new Router('/test', null); + expect(router.prefix).to.equal('/test'); + }); + }); + + describe('get()', () => { + it('should register GET route and return this', () => { + const handler = async () => {}; + const router = new Router('/api'); + const ret = router.get('/users', handler); + expect(ret).to.equal(router); + + const routes = resolveRouters(router); + const info = getRouteInfo(routes, '/api/users', 'GET'); + expect(info).to.not.be.null; + expect(info.methods).to.include('GET'); + }); + + it('should register GET route with validators', () => { + const handler = async () => {}; + const validators = { query: { rules: { page: 'integer' } } }; + const router = new Router('/api'); + router.get('/list', handler, validators); + + const routes = resolveRouters(router); + const info = getRouteInfo(routes, '/api/list', 'GET'); + expect(info).to.not.be.null; + expect(info.validators).to.have.property('query'); + }); + }); + + describe('post()', () => { + it('should register POST route', () => { + const handler = async () => {}; + const router = new Router('/api'); + const ret = router.post('/users', handler); + expect(ret).to.equal(router); + + const routes = resolveRouters(router); + const info = getRouteInfo(routes, '/api/users', 'POST'); + expect(info).to.not.be.null; + expect(info.methods).to.include('POST'); + }); + }); + + describe('put()', () => { + it('should register PUT route', () => { + const handler = async () => {}; + const router = new Router('/api'); + const ret = router.put('/users/:id', handler); + expect(ret).to.equal(router); + + const routes = resolveRouters(router); + const info = getRouteInfo(routes, '/api/users/1', 'PUT'); + expect(info).to.not.be.null; + expect(info.methods).to.include('PUT'); + }); + }); + + describe('patch()', () => { + it('should register PATCH route', () => { + const handler = async () => {}; + const router = new Router('/api'); + const ret = router.patch('/users/:id', handler); + expect(ret).to.equal(router); + + const routes = resolveRouters(router); + const info = getRouteInfo(routes, '/api/users/1', 'PATCH'); + expect(info).to.not.be.null; + expect(info.methods).to.include('PATCH'); + }); + }); + + describe('delete()', () => { + it('should register DELETE route', () => { + const handler = async () => {}; + const router = new Router('/api'); + const ret = router.delete('/users/:id', handler); + expect(ret).to.equal(router); + + const routes = resolveRouters(router); + const info = getRouteInfo(routes, '/api/users/1', 'DELETE'); + expect(info).to.not.be.null; + expect(info.methods).to.include('DELETE'); + }); + }); + + describe('any()', () => { + it('should register ANY route', () => { + const handler = async () => {}; + const router = new Router('/api'); + const ret = router.any('/catch', handler); + expect(ret).to.equal(router); + + const routes = resolveRouters(router); + const info = getRouteInfo(routes, '/api/catch', 'GET'); + expect(info).to.not.be.null; + expect(info.methods).to.include('ANY'); + }); + }); + + describe('push()', () => { + it('should register route with method and handler', () => { + const handler = async () => {}; + const router = new Router('/api'); + const ret = router.push('GET|POST', '/multi', handler); + expect(ret).to.equal(router); + + const routes = resolveRouters(router); + expect(getRouteInfo(routes, '/api/multi', 'GET')).to.not.be.null; + expect(getRouteInfo(routes, '/api/multi', 'POST')).to.not.be.null; + }); + + it('should register route with validators', () => { + const handler = async () => {}; + const validators = { params: { rules: { id: 'required' } } }; + const router = new Router('/api'); + router.push('GET', '/items/:id', handler, validators); + + const routes = resolveRouters(router); + const info = getRouteInfo(routes, '/api/items/123', 'GET'); + expect(info.validators).to.have.property('params'); + }); + }); + + describe('new()', () => { + it('should create and add sub-router', () => { + const router = new Router('/api'); + const ret = router.new('/v1', { method: 'GET', handlers: [async () => {}] }); + expect(ret).to.equal(router); + expect(router.routers.length).to.be.greaterThan(0); + }); + }); + + describe('add()', () => { + it('should add Router instance directly', () => { + const root = new Router('/api'); + const sub = new Router('/v1', { method: 'GET', handlers: [async () => {}] }); + const ret = root.add(sub); + expect(ret).to.equal(root); + expect(root.routers).to.include(sub); + }); + + it('should add multiple routers at once', () => { + const root = new Router('/api'); + const sub1 = new Router('/v1'); + const sub2 = new Router('/v2'); + root.add(sub1, sub2); + expect(root.routers).to.include(sub1); + expect(root.routers).to.include(sub2); + }); + + it('should add router under string prefix', () => { + const root = new Router('/api'); + const sub = new Router('/users', { method: 'GET', handlers: [async () => {}] }); + root.add('/v1', sub); + + const routes = resolveRouters(root); + const info = getRouteInfo(routes, '/api/v1/users', 'GET'); + expect(info).to.not.be.null; + }); + + it('should reuse existing sub-router for same prefix', () => { + const root = new Router('/api'); + const sub1 = new Router('/a', { method: 'GET', handlers: [async () => {}] }); + const sub2 = new Router('/b', { method: 'POST', handlers: [async () => {}] }); + root.add('/v1', sub1); + root.add('/v1', sub2); + + const routes = resolveRouters(root); + expect(getRouteInfo(routes, '/api/v1/a', 'GET')).to.not.be.null; + expect(getRouteInfo(routes, '/api/v1/b', 'POST')).to.not.be.null; + }); + + it('should handle null/undefined prefix', () => { + const root = new Router('/api'); + const sub = new Router('/test', { method: 'GET', handlers: [async () => {}] }); + root.add(null, sub); + + const routes = resolveRouters(root); + const info = getRouteInfo(routes, '/api/test', 'GET'); + expect(info).to.not.be.null; + }); + }); + + describe('method chaining', () => { + it('should chain multiple methods', () => { + const handler = async () => {}; + const router = new Router('/api'); + const ret = router + .get('/a', handler) + .post('/b', handler) + .put('/c', handler) + .patch('/d', handler) + .delete('/e', handler) + .any('/f', handler); + + expect(ret).to.equal(router); + + const routes = resolveRouters(router); + expect(getRouteInfo(routes, '/api/a', 'GET')).to.not.be.null; + expect(getRouteInfo(routes, '/api/b', 'POST')).to.not.be.null; + expect(getRouteInfo(routes, '/api/c', 'PUT')).to.not.be.null; + expect(getRouteInfo(routes, '/api/d', 'PATCH')).to.not.be.null; + expect(getRouteInfo(routes, '/api/e', 'DELETE')).to.not.be.null; + expect(getRouteInfo(routes, '/api/f', 'GET')).to.not.be.null; + }); + }); +}); diff --git a/tests/sse.tests.js b/tests/sse.tests.js new file mode 100644 index 0000000..8e9f2e7 --- /dev/null +++ b/tests/sse.tests.js @@ -0,0 +1,261 @@ +'use strict'; + +const { expect } = require('chai'); +const { PassThrough } = require('stream'); +const { KoaSSE, KoaSSEMiddleware } = require('../src/middlewares/sse'); + +function createMockKoaCtx() { + const headers = {}; + const socket = { + setTimeout: () => {}, + setNoDelay: () => {}, + setKeepAlive: () => {}, + destroy: () => {}, + }; + return { + req: { socket }, + res: { + headersSent: false, + end: () => {}, + }, + set: (key, value) => { headers[key] = value; }, + _headers: headers, + socket, + body: null, + response: {}, + sse: null, + }; +} + +describe('KoaSSE', () => { + describe('constructor', () => { + it('should create SSE stream with correct headers', () => { + const ctx = createMockKoaCtx(); + const options = { pingInterval: 1000, closeEvent: 'close' }; + const sse = new KoaSSE(ctx, options); + expect(sse).to.be.instanceOf(KoaSSE); + expect(ctx._headers['Content-Type']).to.equal('text/event-stream'); + expect(ctx._headers['Cache-Control']).to.equal('no-cache, no-transform'); + expect(ctx._headers['Connection']).to.equal('keep-alive'); + sse.destroy(); + }); + }); + + describe('keepAlive()', () => { + it('should push heartbeat comment', (done) => { + const ctx = createMockKoaCtx(); + const sse = new KoaSSE(ctx, { closeEvent: 'close' }); + const chunks = []; + sse.on('data', (chunk) => { + chunks.push(chunk.toString()); + if (chunks.length >= 2) { + expect(chunks[1]).to.equal(':\n\n'); + sse.destroy(); + done(); + } + }); + sse.keepAlive(); + }); + }); + + describe('_transform()', () => { + it('should handle string data', (done) => { + const ctx = createMockKoaCtx(); + const sse = new KoaSSE(ctx, { closeEvent: 'close' }); + const chunks = []; + sse.on('data', (chunk) => { + chunks.push(chunk.toString()); + }); + sse.write('hello'); + setTimeout(() => { + const output = chunks.join(''); + expect(output).to.include('data:hello\n\n'); + sse.destroy(); + done(); + }, 50); + }); + + it('should handle object data with id and event', (done) => { + const ctx = createMockKoaCtx(); + const sse = new KoaSSE(ctx, { closeEvent: 'close' }); + const chunks = []; + sse.on('data', (chunk) => { + chunks.push(chunk.toString()); + }); + sse.write({ id: '123', event: 'message', data: 'test' }); + setTimeout(() => { + const output = chunks.join(''); + expect(output).to.include('id:123\n'); + expect(output).to.include('event:message\n'); + expect(output).to.include('data:test\n\n'); + sse.destroy(); + done(); + }, 50); + }); + + it('should handle object data with nested object', (done) => { + const ctx = createMockKoaCtx(); + const sse = new KoaSSE(ctx, { closeEvent: 'close' }); + const chunks = []; + sse.on('data', (chunk) => { + chunks.push(chunk.toString()); + }); + sse.write({ data: { key: 'value' } }); + setTimeout(() => { + const output = chunks.join(''); + expect(output).to.include('data:{"key":"value"}\n\n'); + sse.destroy(); + done(); + }, 50); + }); + + it('should handle object data without id or event', (done) => { + const ctx = createMockKoaCtx(); + const sse = new KoaSSE(ctx, { closeEvent: 'close' }); + const chunks = []; + sse.on('data', (chunk) => { + chunks.push(chunk.toString()); + }); + sse.write({ data: 'plain' }); + setTimeout(() => { + const output = chunks.join(''); + expect(output).to.not.include('id:'); + expect(output).to.not.include('event:'); + expect(output).to.include('data:plain\n\n'); + sse.destroy(); + done(); + }, 50); + }); + }); + + describe('send()', () => { + it('should write data via send', (done) => { + const ctx = createMockKoaCtx(); + const sse = new KoaSSE(ctx, { closeEvent: 'close' }); + const chunks = []; + sse.on('data', (chunk) => { + chunks.push(chunk.toString()); + }); + sse.send('test-data'); + setTimeout(() => { + const output = chunks.join(''); + expect(output).to.include('data:test-data\n\n'); + sse.destroy(); + done(); + }, 50); + }); + + it('should not throw on destroyed stream', (done) => { + const ctx = createMockKoaCtx(); + const sse = new KoaSSE(ctx, { closeEvent: 'close' }); + sse.destroy(); + setTimeout(() => { + expect(() => sse.send('data')).to.not.throw(); + done(); + }, 50); + }); + }); + + describe('close()', () => { + it('should end stream with close event', (done) => { + const ctx = createMockKoaCtx(); + const sse = new KoaSSE(ctx, { closeEvent: 'done' }); + const chunks = []; + sse.on('data', (chunk) => { + chunks.push(chunk.toString()); + }); + sse.on('end', () => { + const output = chunks.join(''); + expect(output).to.include('event:done\n'); + done(); + }); + sse.close(); + }); + }); +}); + +describe('KoaSSEMiddleware', () => { + let timers = []; + const origSetInterval = global.setInterval; + + before(() => { + global.setInterval = function (...args) { + const id = origSetInterval.apply(this, args); + timers.push(id); + return id; + }; + }); + + afterEach(() => { + timers.forEach(id => clearInterval(id)); + timers = []; + }); + + after(() => { + global.setInterval = origSetInterval; + }); + + it('should return a function', () => { + const mw = KoaSSEMiddleware(); + expect(mw).to.be.a('function'); + }); + + it('should set ctx.sse and ctx.response.sse', async () => { + const mw = KoaSSEMiddleware({ pingInterval: 100000, closeEvent: 'close' }); + const ctx = createMockKoaCtx(); + await mw(ctx, async () => {}); + expect(ctx.sse).to.be.instanceOf(KoaSSE); + expect(ctx.response.sse).to.be.instanceOf(KoaSSE); + ctx.sse.destroy(); + }); + + it('should set body to sse stream when no body', async () => { + const mw = KoaSSEMiddleware({ pingInterval: 100000, closeEvent: 'close' }); + const ctx = createMockKoaCtx(); + await mw(ctx, async () => {}); + expect(ctx.body).to.equal(ctx.sse); + ctx.sse.destroy(); + }); + + it('should pipe body into sse when body is Writable', async () => { + const mw = KoaSSEMiddleware({ pingInterval: 100000, closeEvent: 'close' }); + const ctx = createMockKoaCtx(); + const stream = new PassThrough(); + await mw(ctx, async () => { + ctx.body = stream; + }); + expect(ctx.body).to.not.equal(stream); + ctx.sse.destroy(); + stream.destroy(); + }); + + it('should send existing body through sse', async () => { + const mw = KoaSSEMiddleware({ pingInterval: 100000, closeEvent: 'close' }); + const ctx = createMockKoaCtx(); + await mw(ctx, async () => { + ctx.body = 'existing data'; + }); + expect(ctx.body).to.be.instanceOf(KoaSSE); + ctx.sse.destroy(); + }); + + it('should call next and skip when headers already sent', async () => { + const mw = KoaSSEMiddleware({ pingInterval: 100000, closeEvent: 'close' }); + const ctx = createMockKoaCtx(); + ctx.res.headersSent = true; + let nextCalled = false; + await mw(ctx, async () => { nextCalled = true; }); + expect(nextCalled).to.be.true; + expect(ctx.sse).to.be.null; + }); + + it('should skip error log when ctx.sse exists and headers sent', async () => { + const mw = KoaSSEMiddleware({ pingInterval: 100000, closeEvent: 'close' }); + const ctx = createMockKoaCtx(); + ctx.sse = {}; + ctx.res.headersSent = true; + let nextCalled = false; + await mw(ctx, async () => { nextCalled = true; }); + expect(nextCalled).to.be.true; + }); +}); diff --git a/tests/utils.tests.js b/tests/utils.tests.js new file mode 100644 index 0000000..706258a --- /dev/null +++ b/tests/utils.tests.js @@ -0,0 +1,148 @@ +'use strict'; + +const { expect } = require('chai'); +const { validate: uuidValidate } = require('uuid'); +const { _uuid, _uuid_salt, _debug } = require('../src/utils'); + +describe('utils', () => { + describe('_uuid()', () => { + it('should return a valid UUID v4', () => { + const id = _uuid(); + expect(id).to.be.a('string'); + expect(uuidValidate(id)).to.be.true; + }); + + it('should return unique values', () => { + const ids = new Set(Array.from({ length: 10 }, () => _uuid())); + expect(ids.size).to.equal(10); + }); + }); + + describe('_uuid_salt()', () => { + it('should return a string', () => { + const id = _uuid_salt(); + expect(id).to.be.a('string'); + }); + + it('should return a valid UUID', () => { + const id = _uuid_salt(); + expect(uuidValidate(id)).to.be.true; + }); + + it('should accept a valid UUID as salt', () => { + const salt = _uuid(); + const id = _uuid_salt(salt); + expect(id).to.be.a('string'); + expect(uuidValidate(id)).to.be.true; + }); + + it('should handle non-UUID salt', () => { + const id = _uuid_salt('not-a-uuid'); + expect(id).to.be.a('string'); + expect(uuidValidate(id)).to.be.true; + }); + + it('should handle empty string salt', () => { + const id = _uuid_salt(''); + expect(id).to.be.a('string'); + expect(uuidValidate(id)).to.be.true; + }); + }); + + describe('_debug()', () => { + it('should not throw with minimal context and error flag', () => { + const context = { + request_id: 'test-id', + method: 'GET', + url: '/test', + query: {}, + params: {}, + body: {}, + response: { status: 200, message: 'OK', data: 'test' } + }; + expect(() => _debug(context, null, true)).to.not.throw(); + }); + + it('should handle context with non-empty params', () => { + const context = { + request_id: 'test-id', + method: 'POST', + url: '/test', + query: { a: '1' }, + params: { id: '123' }, + body: { name: 'test' }, + response: { status: 200, message: 'OK', data: {} } + }; + expect(() => _debug(context, null, true)).to.not.throw(); + }); + + it('should handle context with router info', () => { + const context = { + request_id: 'test-id', + method: 'GET', + url: '/api/test', + query: {}, + params: {}, + body: {}, + router: { + pathinfo: '/api/test', + validators: { params: { rules: { id: 'required' } } } + }, + response: { status: 200, message: 'OK', data: {} } + }; + expect(() => _debug(context, null, true)).to.not.throw(); + }); + + it('should handle location string', () => { + const context = { + request_id: 'test-id', + method: 'GET', + url: '/test', + query: {}, + params: {}, + body: {}, + response: { status: 200, message: 'OK', data: 'data' } + }; + expect(() => _debug(context, ' at Object. (/test.js:1:1)', true)).to.not.throw(); + }); + + it('should skip node:internal locations', () => { + const context = { + request_id: 'test-id', + method: 'GET', + url: '/test', + query: {}, + params: {}, + body: {}, + response: { status: 200, message: 'OK', data: null } + }; + expect(() => _debug(context, 'node:internal/something', true)).to.not.throw(); + }); + + it('should print response info when error is falsy', () => { + const context = { + request_id: 'test-id', + method: 'GET', + url: '/test', + query: {}, + params: {}, + body: {}, + response: { status: 200, message: 'OK', data: { result: 'ok' } } + }; + expect(() => _debug(context, null, false)).to.not.throw(); + }); + + it('should handle string params/query/body', () => { + const context = { + request_id: 'test-id', + method: 'GET', + url: '/test', + query: 'raw-query', + params: 'raw-params', + body: 'raw-body', + response: { status: 200, message: 'OK', data: {} } + }; + expect(() => _debug(context, null, true)).to.not.throw(); + }); + }); +}); diff --git a/tests/workflow.tests.js b/tests/workflow.tests.js new file mode 100644 index 0000000..97eeb81 --- /dev/null +++ b/tests/workflow.tests.js @@ -0,0 +1,447 @@ +'use strict'; + +const { expect } = require('chai'); +const EventEmitter = require('events'); +const { Router } = require('../src/router'); +const { resolveRouters } = require('../src/core'); +const { HttpResponse, HttpError } = require('../src/response'); +const koaWorkflow = require('../src/workflows/koa.workflow'); +const socketWorkflow = require('../src/workflows/socket.workflow'); + +function createMockApp(options = {}) { + const router = new Router(); + router.get('/test', async () => {}); + router.any('/error', async () => { throw new Error('handler error'); }); + router.get('/validated', async () => {}, { + params: { rules: { id: 'required' } }, + query: { rules: { page: 'required' } }, + body: { rules: { name: 'required' } } + }); + const emitter = new EventEmitter(); + const app = Object.assign(emitter, { + routes: resolveRouters(router), + config: { debug: options.debug || false, ...options }, + }); + return app; +} + +function createKoaContext(overrides = {}) { + const app = overrides.app || createMockApp(); + return { + app, + koa: { + path: overrides.path || '/test', + method: overrides.method || 'GET', + request: { + body: overrides.body || {}, + query: overrides.query || {}, + headers: overrides.headers || {}, + }, + }, + ...overrides, + }; +} + +function createSocketContext(overrides = {}) { + const app = overrides.app || createMockApp(); + return { + app, + pathinfo: overrides.pathinfo || '/test', + method: overrides.method || 'GET', + query: overrides.query || {}, + body: overrides.body || {}, + ...overrides, + }; +} + +describe('koa.workflow', () => { + describe('receive()', () => { + it('should resolve route and set context fields', () => { + const ctx = createKoaContext(); + const result = koaWorkflow.receive(ctx); + expect(result).to.be.undefined; + expect(ctx.router).to.not.be.null; + expect(ctx.params).to.be.an('object'); + expect(ctx.method).to.equal('GET'); + expect(ctx.body).to.deep.equal({}); + expect(ctx.query).to.deep.equal({}); + }); + + it('should return "response" on 404', () => { + const ctx = createKoaContext({ path: '/nonexistent' }); + const result = koaWorkflow.receive(ctx); + expect(result).to.equal('response'); + expect(ctx.response).to.be.instanceOf(HttpResponse); + }); + + it('should handle query serialization', () => { + const ctx = createKoaContext({ query: { a: '1', b: '2' } }); + koaWorkflow.receive(ctx); + expect(ctx.query).to.deep.equal({ a: '1', b: '2' }); + }); + }); + + describe('validate()', () => { + it('should pass with no validators', () => { + const ctx = createKoaContext(); + koaWorkflow.receive(ctx); + const result = koaWorkflow.validate(ctx); + expect(result).to.be.undefined; + }); + + it('should pass when no router', () => { + const ctx = createKoaContext(); + ctx.router = null; + const result = koaWorkflow.validate(ctx); + expect(result).to.be.undefined; + }); + + it('should return "response" on validation failure', () => { + const app = createMockApp(); + const ctx = createKoaContext({ app, path: '/validated', method: 'GET' }); + koaWorkflow.receive(ctx); + ctx.params = {}; + ctx.query = {}; + ctx.body = {}; + const result = koaWorkflow.validate(ctx); + expect(result).to.equal('response'); + expect(ctx.response).to.be.instanceOf(HttpResponse); + }); + }); + + describe('middleware()', () => { + it('should execute middlewares', async () => { + let called = false; + const ctx = createKoaContext(); + koaWorkflow.receive(ctx); + ctx.router.middlewares = [async () => { called = true; }]; + const result = await koaWorkflow.middleware(ctx); + expect(result).to.be.undefined; + expect(called).to.be.true; + }); + + it('should handle no middlewares', async () => { + const ctx = createKoaContext(); + koaWorkflow.receive(ctx); + ctx.router.middlewares = []; + const result = await koaWorkflow.middleware(ctx); + expect(result).to.be.undefined; + }); + + it('should return "response" on middleware error', async () => { + const ctx = createKoaContext(); + koaWorkflow.receive(ctx); + ctx.router.middlewares = [async () => { throw new Error('mw error'); }]; + const result = await koaWorkflow.middleware(ctx); + expect(result).to.equal('response'); + expect(ctx.response).to.be.instanceOf(Error); + }); + + it('should handle null router', async () => { + const ctx = createKoaContext(); + ctx.router = null; + const result = await koaWorkflow.middleware(ctx); + expect(result).to.be.undefined; + }); + }); + + describe('handle()', () => { + it('should execute handlers', async () => { + let called = false; + const ctx = createKoaContext(); + koaWorkflow.receive(ctx); + ctx.router.handlers = [async () => { called = true; }]; + const result = await koaWorkflow.handle(ctx); + expect(result).to.be.undefined; + expect(called).to.be.true; + }); + + it('should catch handler errors', async () => { + const ctx = createKoaContext(); + koaWorkflow.receive(ctx); + ctx.router.handlers = [async () => { throw new Error('test'); }]; + const result = await koaWorkflow.handle(ctx); + expect(result).to.be.undefined; + expect(ctx.response).to.be.instanceOf(Error); + }); + + it('should handle no handlers', async () => { + const ctx = createKoaContext(); + koaWorkflow.receive(ctx); + ctx.router.handlers = []; + const result = await koaWorkflow.handle(ctx); + expect(result).to.be.undefined; + }); + + it('should handle null router', async () => { + const ctx = createKoaContext(); + ctx.router = null; + const result = await koaWorkflow.handle(ctx); + expect(result).to.be.undefined; + }); + }); + + describe('response()', () => { + it('should return early when no response', () => { + const ctx = createKoaContext(); + ctx.response = null; + koaWorkflow.response(ctx); + expect(ctx.response).to.be.null; + }); + + it('should handle HttpResponse', () => { + const ctx = createKoaContext(); + ctx.response = new HttpResponse({ status: 200, data: 'ok', format: 'json', code: '200;OK' }); + koaWorkflow.response(ctx); + expect(ctx.response).to.be.instanceOf(HttpResponse); + expect(ctx.response.status).to.equal(200); + }); + + it('should handle HttpError', () => { + const ctx = createKoaContext(); + ctx.response = new HttpError(400, 'Bad Request'); + koaWorkflow.response(ctx); + expect(ctx.response).to.be.instanceOf(HttpResponse); + expect(ctx.response.status).to.equal(400); + }); + + it('should handle generic Error in debug mode', () => { + const app = createMockApp({ debug: true }); + const ctx = createKoaContext({ app }); + ctx.response = new Error('something broke'); + ctx.method = 'GET'; + ctx.url = '/test'; + ctx.request_id = 'test-id'; + koaWorkflow.response(ctx); + expect(ctx.response).to.be.instanceOf(HttpResponse); + expect(ctx.response.status).to.equal(500); + }); + + it('should handle generic Error in non-debug mode', () => { + const app = createMockApp({ debug: false }); + const ctx = createKoaContext({ app }); + ctx.response = new Error('something broke'); + ctx.method = 'GET'; + ctx.url = '/test'; + ctx.request_id = 'test-id'; + koaWorkflow.response(ctx); + expect(ctx.response).to.be.instanceOf(HttpResponse); + expect(ctx.response.status).to.equal(500); + expect(ctx.response.data).to.equal('Internal Server Error'); + }); + + it('should handle HttpResponse in debug mode', () => { + const app = createMockApp({ debug: true }); + const ctx = createKoaContext({ app }); + ctx.response = new HttpResponse({ status: 200, data: 'ok', code: '200;OK' }); + ctx.method = 'GET'; + ctx.url = '/test'; + ctx.request_id = 'test-id'; + koaWorkflow.response(ctx); + expect(ctx.response.status).to.equal(200); + }); + }); + + describe('after()', () => { + it('should execute after hooks', async () => { + let called = false; + const ctx = createKoaContext(); + koaWorkflow.receive(ctx); + ctx.router.afters = [async () => { called = true; }]; + await koaWorkflow.after(ctx); + expect(called).to.be.true; + }); + + it('should handle no after hooks', async () => { + const ctx = createKoaContext(); + koaWorkflow.receive(ctx); + ctx.router.afters = []; + await koaWorkflow.after(ctx); + }); + + it('should handle errors in after hooks gracefully', async () => { + let errorEmitted = false; + const ctx = createKoaContext(); + ctx.app.on('after_error', () => { errorEmitted = true; }); + koaWorkflow.receive(ctx); + ctx.router.afters = [async () => { throw new Error('after error'); }]; + await koaWorkflow.after(ctx); + expect(errorEmitted).to.be.true; + }); + + it('should handle null router', async () => { + const ctx = createKoaContext(); + ctx.router = null; + await koaWorkflow.after(ctx); + }); + }); +}); + +describe('socket.workflow', () => { + describe('receive()', () => { + it('should resolve route and set context fields', () => { + const ctx = createSocketContext(); + const result = socketWorkflow.receive(ctx); + expect(result).to.be.undefined; + expect(ctx.router).to.not.be.null; + expect(ctx.params).to.be.an('object'); + }); + + it('should return "response" on 404', () => { + const ctx = createSocketContext({ pathinfo: '/nonexistent' }); + const result = socketWorkflow.receive(ctx); + expect(result).to.equal('response'); + expect(ctx.response).to.be.instanceOf(HttpResponse); + }); + }); + + describe('validate()', () => { + it('should pass with no validators', () => { + const ctx = createSocketContext(); + socketWorkflow.receive(ctx); + const result = socketWorkflow.validate(ctx); + expect(result).to.be.undefined; + }); + + it('should return "response" on validation failure', () => { + const app = createMockApp(); + const ctx = createSocketContext({ app, pathinfo: '/validated', method: 'GET' }); + socketWorkflow.receive(ctx); + ctx.params = {}; + ctx.query = {}; + ctx.body = {}; + const result = socketWorkflow.validate(ctx); + expect(result).to.equal('response'); + expect(ctx.response).to.be.instanceOf(HttpResponse); + }); + + it('should pass when no router', () => { + const ctx = createSocketContext(); + ctx.router = null; + const result = socketWorkflow.validate(ctx); + expect(result).to.be.undefined; + }); + }); + + describe('middleware()', () => { + it('should execute middlewares', async () => { + let called = false; + const ctx = createSocketContext(); + socketWorkflow.receive(ctx); + ctx.router.middlewares = [async () => { called = true; }]; + const result = await socketWorkflow.middleware(ctx); + expect(result).to.be.undefined; + expect(called).to.be.true; + }); + + it('should return "response" on error', async () => { + const ctx = createSocketContext(); + socketWorkflow.receive(ctx); + ctx.router.middlewares = [async () => { throw new Error('fail'); }]; + const result = await socketWorkflow.middleware(ctx); + expect(result).to.equal('response'); + }); + }); + + describe('handle()', () => { + it('should execute handlers', async () => { + let called = false; + const ctx = createSocketContext(); + socketWorkflow.receive(ctx); + ctx.router.handlers = [async () => { called = true; }]; + await socketWorkflow.handle(ctx); + expect(called).to.be.true; + }); + + it('should catch handler errors', async () => { + const ctx = createSocketContext(); + socketWorkflow.receive(ctx); + ctx.router.handlers = [async () => { throw new Error('err'); }]; + await socketWorkflow.handle(ctx); + expect(ctx.response).to.be.instanceOf(Error); + }); + }); + + describe('response()', () => { + it('should return early when no response', () => { + const ctx = createSocketContext(); + ctx.response = null; + socketWorkflow.response(ctx); + expect(ctx.response).to.be.null; + }); + + it('should handle HttpResponse', () => { + const ctx = createSocketContext(); + ctx.response = new HttpResponse({ status: 200, data: 'ok' }); + socketWorkflow.response(ctx); + expect(ctx.response).to.be.instanceOf(HttpResponse); + }); + + it('should handle HttpError', () => { + const ctx = createSocketContext(); + ctx.response = new HttpError(400, 'Bad'); + socketWorkflow.response(ctx); + expect(ctx.response).to.be.instanceOf(HttpResponse); + expect(ctx.response.status).to.equal(400); + }); + + it('should handle generic Error in non-debug mode', () => { + const app = createMockApp({ debug: false }); + const ctx = createSocketContext({ app }); + ctx.response = new Error('broke'); + ctx.url = '/test'; + ctx.request_id = 'test-id'; + socketWorkflow.response(ctx); + expect(ctx.response).to.be.instanceOf(HttpResponse); + expect(ctx.response.status).to.equal(500); + }); + + it('should handle generic Error in debug mode', () => { + const app = createMockApp({ debug: true }); + const ctx = createSocketContext({ app }); + ctx.response = new Error('broke'); + ctx.url = '/test'; + ctx.request_id = 'test-id'; + socketWorkflow.response(ctx); + expect(ctx.response).to.be.instanceOf(HttpResponse); + expect(ctx.response.status).to.equal(500); + }); + + it('should handle HttpResponse in debug mode', () => { + const app = createMockApp({ debug: true }); + const ctx = createSocketContext({ app }); + ctx.response = new HttpResponse({ status: 201, data: 'created', code: '201;Created' }); + ctx.url = '/test'; + ctx.request_id = 'test-id'; + socketWorkflow.response(ctx); + expect(ctx.response.status).to.equal(201); + }); + }); + + describe('after()', () => { + it('should execute after hooks', async () => { + let called = false; + const ctx = createSocketContext(); + socketWorkflow.receive(ctx); + ctx.router.afters = [async () => { called = true; }]; + await socketWorkflow.after(ctx); + expect(called).to.be.true; + }); + + it('should handle errors in after hooks', async () => { + let errorEmitted = false; + const ctx = createSocketContext(); + ctx.app.on('after_error', () => { errorEmitted = true; }); + socketWorkflow.receive(ctx); + ctx.router.afters = [async () => { throw new Error('err'); }]; + await socketWorkflow.after(ctx); + expect(errorEmitted).to.be.true; + }); + + it('should handle null router', async () => { + const ctx = createSocketContext(); + ctx.router = null; + await socketWorkflow.after(ctx); + }); + }); +});