Skip to content

Commit beeab92

Browse files
authored
Merge pull request #21 from imqueue/ai-coder-tests
Improve test coverage to 100%, update dependencies
2 parents 3a28232 + 376be5d commit beeab92

21 files changed

+1599
-647
lines changed

package-lock.json

Lines changed: 68 additions & 625 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@
1414
],
1515
"scripts": {
1616
"prepublishOnly": "./node_modules/.bin/tsc",
17-
"test": "./node_modules/.bin/tsc && ./node_modules/.bin/nyc mocha && ./node_modules/.bin/nyc report --reporter=text-lcov && npm run test-coverage",
17+
"test": "./node_modules/.bin/tsc && ./node_modules/.bin/nyc mocha && ./node_modules/.bin/nyc report --reporter=text-lcov",
1818
"test-fast": "./node_modules/.bin/tsc && ./node_modules/.bin/nyc mocha && /usr/bin/env node -e \"import('open').then(open => open.default('file://`pwd`/coverage/index.html', { wait: false }))\"",
1919
"test-local": "export COVERALLS_REPO_TOKEN=$IMQ_RPC_COVERALLS_TOKEN && npm test && /usr/bin/env node -e \"import('open').then(open => open.default('https://coveralls.io/github/imqueue/imq-rpc', { wait: false }));\"",
20-
"test-coverage": "cat ./coverage/lcov.info | CODECLIMATE_API_HOST=https://codebeat.co/webhooks/code_coverage CODECLIMATE_REPO_TOKEN=c2cc3954-6824-4cdf-8349-12bd9c31955a ./node_modules/.bin/codeclimate-test-reporter",
2120
"clean-typedefs": "find . -name '*.d.ts' -not -wholename '*node_modules*' -not -wholename '*generator*' -type f -delete",
2221
"clean-maps": "find . -name '*.js.map' -not -wholename '*node_modules*' -not -wholename '*generator*' -type f -delete",
2322
"clean-js": "find . -name '*.js' -not -wholename '*node_modules*' -not -wholename '*generator*' -type f -delete",
@@ -38,22 +37,21 @@
3837
"author": "imqueue.com <[email protected]> (https://imqueue.com)",
3938
"license": "GPL-3.0-only",
4039
"dependencies": {
41-
"@imqueue/core": "^2.0.5",
42-
"@types/node": "^24.0.8",
40+
"@imqueue/core": "^2.0.6",
41+
"@types/node": "^24.2.1",
4342
"acorn": "^8.15.0",
4443
"farmhash": "^4.0.2",
4544
"node-machine-id": "^1.1.12",
4645
"reflect-metadata": "^0.2.2",
47-
"typescript": "^5.8.3"
46+
"typescript": "^5.9.2"
4847
},
4948
"devDependencies": {
5049
"@types/chai": "^5.2.2",
5150
"@types/mocha": "^10.0.10",
5251
"@types/mock-require": "^3.0.0",
5352
"@types/sinon": "^17.0.4",
5453
"chai": "^5.2.0",
55-
"codeclimate-test-reporter": "^0.5.1",
56-
"coveralls-next": "^4.2.1",
54+
"coveralls-next": "^5.0.0",
5755
"minimist": "^1.2.8",
5856
"mocha": "^11.7.1",
5957
"mocha-lcov-reporter": "^1.3.0",
@@ -63,7 +61,7 @@
6361
"sinon": "^21.0.0",
6462
"source-map-support": "^0.5.21",
6563
"ts-node": "^10.9.2",
66-
"typedoc": "^0.28.7"
64+
"typedoc": "^0.28.10"
6765
},
6866
"main": "index.js",
6967
"typescript": {

src/IMQService.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export class Description {
5858
types: TypesDescription;
5959
}
6060

61-
let serviceDescription: Description | null = null;
61+
const serviceDescriptions: Map<string, Description> = new Map<string, Description>();
6262

6363
/**
6464
* Returns collection of class methods metadata even those are inherited
@@ -188,10 +188,21 @@ export abstract class IMQService {
188188
}
189189

190190
else if (!description.service.methods[method]) {
191-
response.error = IMQError(
192-
'IMQ_RPC_NO_ACCESS',
193-
`Access to ${this.name}.${method}() denied!`,
194-
new Error().stack, method, args);
191+
// Allow calling runtime-attached methods (own props) even if
192+
// they are not present in the exposed service description.
193+
// Deny access for prototype (class) methods not decorated with @expose.
194+
const isOwn = Object.prototype.hasOwnProperty.call(this, method);
195+
const value: any = (this as any)[method];
196+
const proto = Object.getPrototypeOf(this);
197+
const protoValue = proto && proto[method];
198+
const isSameAsProto = typeof protoValue === 'function' && value === protoValue;
199+
// Allow only truly dynamic own-instance functions (not the same as prototype)
200+
if (!(isOwn && typeof value === 'function' && !isSameAsProto)) {
201+
response.error = IMQError(
202+
'IMQ_RPC_NO_ACCESS',
203+
`Access to ${this.name}.${method}() denied!`,
204+
new Error().stack, method, args);
205+
}
195206
}
196207

197208
else if (!isValidArgsCount(
@@ -318,17 +329,21 @@ export abstract class IMQService {
318329
*/
319330
@expose()
320331
public describe(): Description {
321-
if (!serviceDescription) {
322-
serviceDescription = {
332+
let description = serviceDescriptions.get(this.name) || null;
333+
334+
if (!description) {
335+
description = {
323336
service: {
324337
name: this.name,
325338
methods: getClassMethods(this.constructor.name)
326339
},
327340
types: IMQRPCDescription.typesDescription
328341
};
342+
343+
serviceDescriptions.set(this.name, description);
329344
}
330345

331-
return serviceDescription;
346+
return description;
332347
}
333348

334349
}

src/decorators/logged.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,6 @@ export function logged(options?: ILogger | LoggedDecoratorOptions) {
5050
descriptor: TypedPropertyDescriptor<(...args: any[]) => any>,
5151
) => {
5252
const original = descriptor.value;
53-
const logger = options && (options as LoggedDecoratorOptions).logger
54-
? (options as LoggedDecoratorOptions).logger
55-
: options && (options as ILogger).error ? options :
56-
this?.logger || target?.logger || console;
5753
const level: LoggedLogLevel = (
5854
options &&
5955
(options as LoggedDecoratorOptions).level
@@ -65,9 +61,25 @@ export function logged(options?: ILogger | LoggedDecoratorOptions) {
6561

6662
descriptor.value = async function<T>(...args: any[]): Promise<T|void> {
6763
try {
68-
return original && await original.apply(this || target, args);
64+
const ctx = (typeof this !== 'undefined' && this !== null)
65+
? this
66+
: target;
67+
68+
if (original) {
69+
return await original.apply(ctx, args);
70+
}
6971
} catch (err) {
70-
logger[level](err);
72+
const logger: ILogger = (options && (options as LoggedDecoratorOptions).logger)
73+
? (options as LoggedDecoratorOptions).logger as ILogger
74+
: (options && (options as any).error)
75+
? options as ILogger
76+
: (this && (this as any).logger)
77+
? (this as any).logger as ILogger
78+
: (target && (target as any).logger)
79+
? (target as any).logger as ILogger
80+
: console;
81+
82+
(logger as any)[level](err);
7183

7284
if (doThrow) {
7385
throw err;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*!
2+
* IMQClient console logger fallback branches coverage
3+
*/
4+
import './mocks';
5+
import { expect } from 'chai';
6+
import * as sinon from 'sinon';
7+
import { IMQClient, IMQDelay, IMQMetadata, remote, AFTER_HOOK_ERROR, BEFORE_HOOK_ERROR } from '..';
8+
9+
class ConsoleClient extends IMQClient {
10+
@remote()
11+
public async ok(name?: string, meta?: IMQMetadata, delay?: IMQDelay) {
12+
return this.remoteCall<string>(...arguments);
13+
}
14+
@remote()
15+
public async boom() {
16+
return this.remoteCall<any>(...arguments);
17+
}
18+
}
19+
20+
describe('IMQClient console logger fallbacks', () => {
21+
let client: ConsoleClient;
22+
23+
afterEach(async () => {
24+
try { await client?.destroy(); } catch { /* ignore */ }
25+
sinon.restore();
26+
});
27+
28+
it('should use console logger when BEFORE hook fails', async () => {
29+
const warn = sinon.stub(console, 'warn' as any).callsFake(() => {});
30+
client = new ConsoleClient({ beforeCall: async () => { throw new Error('before oops'); } });
31+
await client.start();
32+
33+
const imq: any = (client as any).imq;
34+
sinon.stub(imq, 'send').callsFake(async (_to: string, request: any) => {
35+
const id = 'C1';
36+
setImmediate(() => imq.emit('message', { to: id, request, data: 'good' }));
37+
return id;
38+
});
39+
40+
const res = await client.ok('x');
41+
expect(res).to.equal('good');
42+
expect(warn.called).to.equal(true);
43+
expect(String(warn.firstCall.args[0])).to.contain(BEFORE_HOOK_ERROR);
44+
});
45+
46+
it('should use console logger when AFTER hook fails (resolve and reject paths)', async () => {
47+
const warn = sinon.stub(console, 'warn' as any).callsFake(() => {});
48+
client = new ConsoleClient({ afterCall: async () => { throw new Error('after oops'); } });
49+
await client.start();
50+
51+
const imq: any = (client as any).imq;
52+
const send = sinon.stub(imq, 'send');
53+
54+
// success path
55+
send.onFirstCall().callsFake(async (_to: string, request: any) => {
56+
const id = 'C2';
57+
setImmediate(() => imq.emit('message', { to: id, request, data: 'S' }));
58+
return id;
59+
});
60+
61+
// reject path
62+
send.onSecondCall().callsFake(async (_to: string, request: any) => {
63+
const id = 'C3';
64+
setImmediate(() => imq.emit('message', { to: id, request, error: new Error('bad') }));
65+
return id;
66+
});
67+
68+
const ok = await client.ok('ok');
69+
expect(ok).to.equal('S');
70+
71+
try { await client.boom(); } catch { /* expected */ }
72+
73+
expect(warn.callCount).to.be.greaterThan(0);
74+
const messages = warn.getCalls().map(c => String(c.args[0])).join(' ');
75+
expect(messages).to.contain(AFTER_HOOK_ERROR);
76+
});
77+
78+
it('should use right-hand console branch in remoteCall when BEFORE hook fails', async () => {
79+
const warn = sinon.stub(console, 'warn' as any).callsFake(() => {});
80+
// Explicitly override default logger to be undefined to force `|| console` take the right branch
81+
client = new ConsoleClient({ beforeCall: async () => { throw new Error('before oops'); }, logger: undefined as any });
82+
await client.start();
83+
84+
const imq: any = (client as any).imq;
85+
sinon.stub(imq, 'send').callsFake(async (_to: string, request: any) => {
86+
const id = 'MB1';
87+
setImmediate(() => imq.emit('message', { to: id, request, data: 'ok' }));
88+
return id;
89+
});
90+
91+
const res = await client.ok('x');
92+
expect(res).to.equal('ok');
93+
expect(warn.called).to.equal(true);
94+
expect(String(warn.firstCall.args[0])).to.contain(BEFORE_HOOK_ERROR);
95+
});
96+
97+
it('should use right-hand console branch in imqCallResolver when AFTER hook fails on resolve', async () => {
98+
const warn = sinon.stub(console, 'warn' as any).callsFake(() => {});
99+
client = new ConsoleClient({ afterCall: async () => { throw new Error('after oops'); }, logger: undefined as any });
100+
await client.start();
101+
102+
const imq: any = (client as any).imq;
103+
sinon.stub(imq, 'send').callsFake(async (_to: string, request: any) => {
104+
const id = 'MB2';
105+
setImmediate(() => imq.emit('message', { to: id, request, data: 'S' }));
106+
return id;
107+
});
108+
109+
const ok = await client.ok('ok');
110+
expect(ok).to.equal('S');
111+
expect(warn.callCount).to.be.greaterThan(0);
112+
const messages = warn.getCalls().map(c => String(c.args[0])).join(' ');
113+
expect(messages).to.contain(AFTER_HOOK_ERROR);
114+
});
115+
116+
it('should use right-hand console branch in imqCallRejector when AFTER hook fails on reject', async () => {
117+
const warn = sinon.stub(console, 'warn' as any).callsFake(() => {});
118+
client = new ConsoleClient({ afterCall: async () => { throw new Error('after oops'); }, logger: undefined as any });
119+
await client.start();
120+
121+
const imq: any = (client as any).imq;
122+
sinon.stub(imq, 'send').callsFake(async (_to: string, request: any) => {
123+
const id = 'MB3';
124+
setImmediate(() => imq.emit('message', { to: id, request, error: new Error('bad') }));
125+
return id;
126+
});
127+
128+
try { await client.boom(); } catch { /* expected */ }
129+
130+
expect(warn.callCount).to.be.greaterThan(0);
131+
const messages = warn.getCalls().map(c => String(c.args[0])).join(' ');
132+
expect(messages).to.contain(AFTER_HOOK_ERROR);
133+
});
134+
});

test/IMQClient.extra.spec.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*!
2+
* IMQClient Extra Unit Tests (non-RPC, using send stubs)
3+
*/
4+
import './mocks';
5+
import { logger } from './mocks';
6+
import { expect } from 'chai';
7+
import * as sinon from 'sinon';
8+
import { IMQClient, IMQDelay, IMQMetadata, remote, AFTER_HOOK_ERROR, BEFORE_HOOK_ERROR } from '..';
9+
10+
class ExtraClient extends IMQClient {
11+
@remote()
12+
public async greet(name?: string, imqMetadata?: IMQMetadata, imqDelay?: IMQDelay) {
13+
return this.remoteCall<string>(...arguments);
14+
}
15+
@remote()
16+
public async fail(imqDelay?: IMQDelay) {
17+
return this.remoteCall<any>(...arguments);
18+
}
19+
}
20+
21+
describe('IMQClient (extra branches without service)', () => {
22+
let client: ExtraClient;
23+
24+
afterEach(async () => {
25+
await client?.destroy();
26+
sinon.restore();
27+
});
28+
29+
it('should warn on BEFORE_HOOK_ERROR and continue call', async function() {
30+
this.timeout(5000);
31+
const warn = sinon.stub(logger, 'warn');
32+
client = new ExtraClient({ logger, beforeCall: async () => { throw new Error('before'); } });
33+
await client.start();
34+
const imq: any = (client as any).imq;
35+
sinon.stub(imq, 'send').callsFake(async (to: string, request: any, delay?: number) => {
36+
const id = 'ID1';
37+
setImmediate(() => imq.emit('message', { to: id, request, data: 'ok' }));
38+
return id;
39+
});
40+
const res = await client.greet('imq');
41+
expect(res).to.equal('ok');
42+
expect(warn.called).to.equal(true);
43+
expect(String(warn.firstCall.args[0])).to.contain(BEFORE_HOOK_ERROR);
44+
});
45+
46+
it('should warn on AFTER_HOOK_ERROR for resolve and reject paths', async () => {
47+
const warn = sinon.stub(logger, 'warn');
48+
client = new ExtraClient({ logger, afterCall: async () => { throw new Error('after'); } });
49+
await client.start();
50+
const imq: any = (client as any).imq;
51+
const send = sinon.stub(imq, 'send');
52+
// success path
53+
send.onFirstCall().callsFake(async (to: string, request: any) => {
54+
const id = 'ID2';
55+
setImmediate(() => imq.emit('message', { to: id, request, data: 'success' }));
56+
return id;
57+
});
58+
// reject path
59+
send.onSecondCall().callsFake(async (to: string, request: any) => {
60+
const id = 'ID3';
61+
setImmediate(() => imq.emit('message', { to: id, request, error: new Error('boom') }));
62+
return id;
63+
});
64+
const ok = await client.greet('ok');
65+
expect(ok).to.equal('success');
66+
try { await client.fail(); } catch (e) { /* expected */ }
67+
// both paths should warn due to afterCall throwing
68+
expect(warn.callCount).to.be.greaterThan(0);
69+
expect(warn.getCalls().map(c => String(c.args[0])).join(' ')).to.contain(AFTER_HOOK_ERROR);
70+
});
71+
72+
it('should emit event when resolver is missing', async () => {
73+
client = new ExtraClient({ logger });
74+
await client.start();
75+
const evt = sinon.spy();
76+
client.on('greet', evt);
77+
(client as any).imq.emit('message', {
78+
to: 'unknown-id',
79+
request: { method: 'greet' },
80+
data: { foo: 'bar' },
81+
});
82+
expect(evt.calledOnce).to.equal(true);
83+
});
84+
85+
it('should sanitize invalid IMQDelay and pass IMQMetadata through request', async () => {
86+
client = new ExtraClient({ logger });
87+
await client.start();
88+
const imq: any = (client as any).imq;
89+
const sendStub = sinon.stub(imq, 'send').callsFake(async (to: string, request: any, delay?: number) => {
90+
expect(delay).to.equal(0);
91+
expect(request.metadata).to.be.instanceOf(IMQMetadata);
92+
const id = 'ID4';
93+
setImmediate(() => imq.emit('message', { to: id, request, data: 'x' }));
94+
return id;
95+
});
96+
const meta = new IMQMetadata({ a: 1 } as any);
97+
const res = await client.greet('z', meta as any, new IMQDelay(-100) as any);
98+
expect(res).to.equal('x');
99+
expect(sendStub.calledOnce).to.equal(true);
100+
});
101+
});

0 commit comments

Comments
 (0)