Skip to content

Commit 5cc9ab9

Browse files
committed
test: add additional unit tests for CLN & Eclair
1 parent c223dba commit 5cc9ab9

8 files changed

+113
-61
lines changed

src/lib/lightning/clightning/clightningService.spec.ts

+24-15
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import { debug } from 'electron-log';
22
import { defaultStateBalances, defaultStateInfo, getNetwork } from 'utils/tests';
3-
import { clightningService } from './';
43
import * as clightningApi from './clightningApi';
4+
import { CLightningService } from './clightningService';
55
import * as CLN from './types';
66

7-
jest.mock('electron-log');
87
jest.mock('./clightningApi');
98

109
const clightningApiMock = clightningApi as jest.Mocked<typeof clightningApi>;
1110

1211
describe('CLightningService', () => {
1312
const node = getNetwork().nodes.lightning[1];
13+
let clightningService: CLightningService;
14+
15+
beforeEach(() => {
16+
clightningService = new CLightningService();
17+
});
1418

1519
it('should get node info', async () => {
1620
const infoResponse: Partial<CLN.GetInfoResponse> = {
@@ -253,11 +257,6 @@ describe('CLightningService', () => {
253257
});
254258

255259
describe('subscribeChannelEvents', () => {
256-
afterEach(() => {
257-
// Clean up any resources or mock implementations after each test
258-
jest.restoreAllMocks();
259-
});
260-
261260
it('should create a channel cache, set interval, and call checkChannels', async () => {
262261
jest.useFakeTimers();
263262
const mockCallback = jest.fn();
@@ -279,6 +278,7 @@ describe('CLightningService', () => {
279278
);
280279

281280
expect(clightningService.channelCaches).toBeDefined();
281+
jest.useRealTimers();
282282
});
283283

284284
it('should do nothing if node has already subscribed to channel event', async () => {
@@ -288,9 +288,18 @@ describe('CLightningService', () => {
288288
jest.spyOn(clightningService, 'checkChannels').mockReturnValue(Promise.resolve());
289289

290290
await clightningService.subscribeChannelEvents(node, mockCallback);
291+
jest.advanceTimersByTime(30 * 1000);
292+
293+
expect(setInterval).toHaveBeenCalledTimes(1);
294+
expect(clightningService.checkChannels).toHaveBeenCalledTimes(1);
295+
296+
// the second time should not call setInterval or checkChannels again
297+
await clightningService.subscribeChannelEvents(node, mockCallback);
298+
299+
expect(setInterval).toHaveBeenCalledTimes(1);
300+
expect(clightningService.checkChannels).toHaveBeenCalledTimes(1);
291301

292-
expect(setInterval).toHaveBeenCalledTimes(0);
293-
expect(clightningService.checkChannels).toHaveBeenCalledTimes(0);
302+
jest.useRealTimers();
294303
});
295304

296305
it('should throw an error when the implementation is not c-lightning', async () => {
@@ -333,8 +342,8 @@ describe('CLightningService', () => {
333342
[node.ports.rest!]: {
334343
intervalId: setInterval(() => {}, 1000),
335344
channels: [
336-
{ channelID: '01ff', pending: true, status: 'Opening' },
337-
{ channelID: '04bb', pending: false, status: 'Open' },
345+
{ channelId: '01ff', status: 'Opening' },
346+
{ channelId: '04bb', status: 'Open' },
338347
],
339348
},
340349
};
@@ -352,8 +361,8 @@ describe('CLightningService', () => {
352361
[node.ports.rest!]: {
353362
intervalId: setInterval(() => {}, 1000),
354363
channels: [
355-
{ channelID: '01ff', pending: true, status: 'Opening' },
356-
{ channelID: '04bb', pending: false, status: 'Open' },
364+
{ channelId: '01ff', status: 'Opening' },
365+
{ channelId: '04bb', status: 'Open' },
357366
],
358367
},
359368
};
@@ -385,8 +394,8 @@ describe('CLightningService', () => {
385394
[node.ports.rest!]: {
386395
intervalId: setInterval(() => {}, 1000),
387396
channels: [
388-
{ channelID: '01ff', pending: true, status: 'Opening' },
389-
{ channelID: '04bb', pending: false, status: 'Open' },
397+
{ channelId: '01ff', status: 'Opening' },
398+
{ channelId: '04bb', status: 'Open' },
390399
],
391400
},
392401
};

src/lib/lightning/clightning/clightningService.ts

+24-21
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,20 @@ const ChannelStateToStatus: Record<CLN.ChannelState, PLN.LightningNodeChannel['s
1919
CLOSED: 'Closed',
2020
};
2121

22-
class CLightningService implements LightningService {
22+
export interface CachedChannelStatus {
23+
channelId: string;
24+
status: PLN.LightningNodeChannel['status'];
25+
}
26+
27+
export class CLightningService implements LightningService {
28+
// Cache of channel states for each node, in order to simulate channel event streaming
29+
channelCaches: {
30+
[nodePort: number]: {
31+
intervalId: NodeJS.Timeout;
32+
channels: CachedChannelStatus[];
33+
};
34+
} = {};
35+
2336
async getInfo(node: LightningNode): Promise<PLN.LightningNodeInfo> {
2437
const info = await httpGet<CLN.GetInfoResponse>(node, 'getinfo');
2538
return {
@@ -201,13 +214,6 @@ class CLightningService implements LightningService {
201214
debug('addListenerToNode CLN on port: ', node.ports.rest);
202215
}
203216

204-
channelCaches: {
205-
[nodePort: number]: {
206-
intervalId: NodeJS.Timeout;
207-
channels: CLN.ChannelPoll[];
208-
};
209-
} = {};
210-
211217
async removeListener(node: LightningNode): Promise<void> {
212218
const nodePort = this.getNodePort(node);
213219
const cache = this.channelCaches[nodePort];
@@ -244,21 +250,20 @@ class CLightningService implements LightningService {
244250
const apiChannels = response.map(channel => {
245251
const status = ChannelStateToStatus[channel.state];
246252
return {
247-
channelID: channel.channelId,
248-
pending: status !== 'Open' && status !== 'Closed',
253+
channelId: channel.channelId,
249254
status,
250255
};
251256
});
252257

253-
const cache = this.channelCaches[this.getNodePort(node)];
258+
const cache = this.channelCaches[this.getNodePort(node)] || { channels: [] };
254259
const uniqueChannels = this.getUniqueChannels(cache.channels, apiChannels);
255260
uniqueChannels.forEach(channel => {
256-
if (channel.pending) {
257-
callback({ type: 'Pending' });
258-
} else if (channel.status === 'Open') {
261+
if (channel.status === 'Open') {
259262
callback({ type: 'Open' });
260263
} else if (channel.status === 'Closed') {
261264
callback({ type: 'Closed' });
265+
} else {
266+
callback({ type: 'Pending' });
262267
}
263268
});
264269
// edge case for empty apiChannels but cache channels is not empty
@@ -270,18 +275,16 @@ class CLightningService implements LightningService {
270275
};
271276

272277
getUniqueChannels = (
273-
cacheChannels: CLN.ChannelPoll[],
274-
apiChannels: CLN.ChannelPoll[],
275-
): CLN.ChannelPoll[] => {
276-
const uniqueChannels: CLN.ChannelPoll[] = [];
278+
cacheChannels: CachedChannelStatus[],
279+
apiChannels: CachedChannelStatus[],
280+
): CachedChannelStatus[] => {
281+
const uniqueChannels: CachedChannelStatus[] = [];
277282
// Check channels in apiChannels that are not in cacheChannels
278283
for (const channel of apiChannels) {
279284
if (
280285
!cacheChannels.some(
281286
cacheCh =>
282-
cacheCh.channelID === channel.channelID &&
283-
cacheCh.pending === channel.pending &&
284-
cacheCh.status === channel.status,
287+
cacheCh.channelId === channel.channelId && cacheCh.status === channel.status,
285288
)
286289
) {
287290
uniqueChannels.push(channel);

src/lib/lightning/clightning/types.ts

-6
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,3 @@ export interface PayResponse {
132132
paymentPreimage: string;
133133
bolt11: string;
134134
}
135-
136-
export interface ChannelPoll {
137-
channelID: string;
138-
pending: boolean;
139-
status: string;
140-
}

src/lib/lightning/eclair/eclairApi.spec.ts

+58-10
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { EclairNode } from 'shared/types';
2+
import WebSocket from 'ws';
23
import * as ipc from 'lib/ipc/ipcService';
34
import { getNetwork } from 'utils/tests';
4-
import { httpPost, setupListener, getListener } from './eclairApi';
5-
import WebSocket from 'ws';
5+
import { getListener, httpPost, removeListener, setupListener } from './eclairApi';
66

77
jest.mock('ws');
88
jest.mock('lib/ipc/ipcService');
99

1010
const ipcMock = ipc as jest.Mocked<typeof ipc>;
11+
const webSocketMock = WebSocket as unknown as jest.Mock<typeof WebSocket>;
1112

1213
describe('EclairApi', () => {
1314
const node = getNetwork().nodes.lightning[2] as EclairNode;
@@ -63,11 +64,14 @@ describe('EclairApi', () => {
6364
it('should setup a listener for the provided EclairNode', () => {
6465
jest.useFakeTimers();
6566

66-
const webSocketMock = jest
67-
.spyOn(WebSocket.prototype, 'ping')
68-
.mockImplementation(() => {
69-
return { ping: jest.fn() };
70-
});
67+
const pingMock = jest.fn();
68+
webSocketMock.mockImplementationOnce(
69+
() =>
70+
({
71+
ping: pingMock,
72+
readyState: WebSocket.OPEN,
73+
} as any),
74+
);
7175

7276
const listener = setupListener(node);
7377
expect(listener).not.toBe(null);
@@ -77,8 +81,52 @@ describe('EclairApi', () => {
7781
jest.advanceTimersByTime(50e3);
7882

7983
// Verify ping is called within the interval
80-
expect(
81-
(webSocketMock.mock.instances[0] as unknown as WebSocket).ping,
82-
).toHaveBeenCalled();
84+
expect(pingMock).toHaveBeenCalled();
85+
});
86+
87+
it('should should not send ping on a closed socket', () => {
88+
jest.useFakeTimers();
89+
90+
const pingMock = jest.fn();
91+
webSocketMock.mockImplementationOnce(
92+
() =>
93+
({
94+
ping: pingMock,
95+
readyState: WebSocket.CLOSED,
96+
} as any),
97+
);
98+
99+
const listener = setupListener(node);
100+
expect(listener).not.toBe(null);
101+
expect(listener.on).not.toBe(null);
102+
103+
// Fast-forward time to trigger the interval
104+
jest.advanceTimersByTime(50e3);
105+
106+
// Verify ping is called within the interval
107+
expect(pingMock).not.toHaveBeenCalled();
108+
});
109+
110+
it('should remove a listener for the provided LightningNode', () => {
111+
// make sure the listener is cached
112+
const listener = getListener(node);
113+
expect(listener).not.toBe(null);
114+
115+
removeListener(node);
116+
117+
// it should return a different listener for the same node after removing it
118+
const listener2 = getListener(node);
119+
expect(listener2).not.toBe(null);
120+
expect(listener2).not.toBe(listener);
121+
});
122+
123+
it('should do nothing when removing a listener that does not exist', () => {
124+
// Use a different port to ensure the listener isn't cached
125+
const otherNode = {
126+
...node,
127+
ports: { ...node.ports, rest: 1234 },
128+
};
129+
// removing a listener that doesn't exist should not throw
130+
expect(() => removeListener(otherNode)).not.toThrow();
83131
});
84132
});

src/lib/lightning/eclair/eclairApi.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import WebSocket from 'ws';
21
import { ipcChannels } from 'shared';
32
import { EclairNode, LightningNode } from 'shared/types';
3+
import WebSocket from 'ws';
44
import { createIpcSender } from 'lib/ipc/ipcService';
55
import { eclairCredentials } from 'utils/constants';
66
import * as ELN from './types';
@@ -87,7 +87,7 @@ const listen = (options: ELN.ConfigOptions): ELN.EclairWebSocket => {
8787
});
8888
// ping every 50s to keep it alive
8989
setInterval(() => {
90-
if (socket) {
90+
if (socket.readyState === WebSocket.OPEN) {
9191
socket.ping();
9292
}
9393
}, 50e3);

src/lib/lightning/eclair/eclairService.spec.ts

+1-5
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ describe('EclairService', () => {
451451

452452
const mockCallback = jest.fn();
453453

454-
(eclairApi.getListener as jest.Mock).mockReturnValue(mockListener);
454+
jest.spyOn(eclairApi, 'getListener').mockReturnValue(mockListener);
455455

456456
await eclairService.subscribeChannelEvents(node, mockCallback);
457457

@@ -496,15 +496,11 @@ describe('EclairService', () => {
496496
});
497497

498498
it('should add listener to node', async () => {
499-
const mockSetupListener = jest.fn();
500-
(eclairApi.setupListener as jest.Mock).mockReturnValue(mockSetupListener);
501499
eclairService.addListenerToNode(node);
502500
expect(eclairApi.setupListener).toHaveBeenCalled();
503501
});
504502

505503
it('should remove Listener', async () => {
506-
const mockRemoveListener = jest.fn();
507-
(eclairApi.removeListener as jest.Mock).mockReturnValue(mockRemoveListener);
508504
eclairService.removeListener(node);
509505
expect(eclairApi.removeListener).toHaveBeenCalled();
510506
});

src/lib/lightning/eclair/eclairService.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { debug } from 'electron-log';
2-
import { BitcoinNode, LightningNode, OpenChannelOptions, EclairNode } from 'shared/types';
2+
import { BitcoinNode, EclairNode, LightningNode, OpenChannelOptions } from 'shared/types';
33
import { bitcoindService } from 'lib/bitcoin';
44
import { LightningService } from 'types';
55
import { waitFor } from 'utils/async';
66
import { toSats } from 'utils/units';
77
import * as PLN from '../types';
8-
import { httpPost, setupListener, getListener, removeListener } from './eclairApi';
8+
import { getListener, httpPost, removeListener, setupListener } from './eclairApi';
99
import * as ELN from './types';
1010

1111
const ChannelStateToStatus: Record<ELN.ChannelState, PLN.LightningNodeChannel['status']> =
@@ -266,6 +266,7 @@ class EclairService implements LightningService {
266266
// listen for incoming channel messages
267267
listener?.on('message', async (data: any) => {
268268
const response = JSON.parse(data.toString());
269+
debug('Received Eclair WebSocket message:', response);
269270
switch (response.type) {
270271
case 'channel-created':
271272
callback({ type: 'Pending' });

src/lib/lightning/lnd/lndService.ts

+1
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ class LndService implements LightningService {
195195
callback: (event: PLN.LightningNodeChannelEvent) => void,
196196
): Promise<void> {
197197
const cb = (data: LND.ChannelEventUpdate) => {
198+
debug('Received LND ChannelEventUpdate message:', data);
198199
if (data.pendingOpenChannel) {
199200
callback({ type: 'Pending' });
200201
} else if (data.activeChannel?.fundingTxidBytes) {

0 commit comments

Comments
 (0)