@ihazz/bitrix24 0.2.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import bitrix24Module, { collectBitrix24CallbackPaths } from '../index.ts';
3
+
4
+ const runtime = {
5
+ config: { loadConfig: () => ({}) },
6
+ channel: {
7
+ routing: { resolveAgentRoute: vi.fn() },
8
+ reply: {
9
+ finalizeInboundContext: vi.fn(),
10
+ dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
11
+ },
12
+ session: { recordInboundSession: vi.fn() },
13
+ pairing: {
14
+ readAllowFromStore: vi.fn(),
15
+ upsertPairingRequest: vi.fn(),
16
+ buildPairingReply: vi.fn(),
17
+ },
18
+ },
19
+ logging: { getChildLogger: vi.fn() },
20
+ };
21
+
22
+ describe('collectBitrix24CallbackPaths', () => {
23
+ afterEach(() => {
24
+ vi.restoreAllMocks();
25
+ });
26
+
27
+ it('collects unique callback paths from base config and accounts', () => {
28
+ const paths = collectBitrix24CallbackPaths({
29
+ channels: {
30
+ bitrix24: {
31
+ callbackUrl: 'https://example.com/hooks/bitrix24',
32
+ accounts: {
33
+ a: { callbackUrl: 'https://example.com/hooks/bitrix24-a' },
34
+ b: { callbackUrl: 'https://other.example.com/hooks/bitrix24-a' },
35
+ },
36
+ },
37
+ },
38
+ });
39
+
40
+ expect(paths).toEqual(['/hooks/bitrix24', '/hooks/bitrix24-a']);
41
+ });
42
+
43
+ it('skips invalid callback urls without dropping valid ones', () => {
44
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
45
+
46
+ const paths = collectBitrix24CallbackPaths({
47
+ channels: {
48
+ bitrix24: {
49
+ callbackUrl: 'not-a-url',
50
+ accounts: {
51
+ a: { callbackUrl: 'https://example.com/hooks/bitrix24-a' },
52
+ },
53
+ },
54
+ },
55
+ });
56
+
57
+ expect(paths).toEqual(['/hooks/bitrix24-a']);
58
+ expect(warnSpy).toHaveBeenCalledOnce();
59
+ });
60
+ });
61
+
62
+ describe('bitrix24 index module', () => {
63
+ it('registers HTTP routes for every configured callback path', () => {
64
+ const registerChannel = vi.fn();
65
+ const registerHttpRoute = vi.fn();
66
+
67
+ bitrix24Module.register({
68
+ config: {
69
+ channels: {
70
+ bitrix24: {
71
+ callbackUrl: 'https://example.com/hooks/bitrix24',
72
+ accounts: {
73
+ a: { callbackUrl: 'https://example.com/hooks/bitrix24-a' },
74
+ },
75
+ },
76
+ },
77
+ },
78
+ runtime: runtime as never,
79
+ registerChannel,
80
+ registerHttpRoute,
81
+ });
82
+
83
+ expect(registerChannel).toHaveBeenCalledOnce();
84
+ expect(registerHttpRoute).toHaveBeenCalledTimes(2);
85
+ expect(registerHttpRoute).toHaveBeenNthCalledWith(1, expect.objectContaining({
86
+ path: '/hooks/bitrix24',
87
+ auth: 'plugin',
88
+ }));
89
+ expect(registerHttpRoute).toHaveBeenNthCalledWith(2, expect.objectContaining({
90
+ path: '/hooks/bitrix24-a',
91
+ auth: 'plugin',
92
+ }));
93
+ });
94
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { existsSync, unlinkSync } from 'node:fs';
3
- import { MediaService } from '../src/media-service.js';
3
+ import { join } from 'node:path';
4
+ import { MediaService, resolveManagedMediaDir } from '../src/media-service.js';
4
5
  import { Bitrix24Api } from '../src/api.js';
5
6
 
6
7
  const silentLogger = {
@@ -13,12 +14,13 @@ const silentLogger = {
13
14
  // Mock the API
14
15
  function mockApi(): Bitrix24Api {
15
16
  return {
16
- getFileInfo: vi.fn().mockResolvedValue({
17
- DOWNLOAD_URL: 'https://example.com/download/test.txt?token=abc',
17
+ getFileDownloadUrl: vi.fn().mockResolvedValue('https://example.com/download/test.txt?token=abc'),
18
+ uploadFile: vi.fn().mockResolvedValue({
19
+ file: { id: 100, name: 'upload-test.txt' },
20
+ messageId: 100,
21
+ chatId: 42,
22
+ dialogId: 'chat40985',
18
23
  }),
19
- getChatFolder: vi.fn().mockResolvedValue(42),
20
- uploadFile: vi.fn().mockResolvedValue(100),
21
- commitFileToChat: vi.fn().mockResolvedValue(true),
22
24
  } as unknown as Bitrix24Api;
23
25
  }
24
26
 
@@ -26,8 +28,10 @@ describe('MediaService', () => {
26
28
  let service: MediaService;
27
29
  let api: Bitrix24Api;
28
30
  const savedPaths: string[] = [];
31
+ const tempStateDir = '/tmp/openclaw-test-state';
29
32
 
30
33
  beforeEach(() => {
34
+ process.env.OPENCLAW_STATE_DIR = tempStateDir;
31
35
  api = mockApi();
32
36
  service = new MediaService(api, silentLogger);
33
37
  vi.clearAllMocks();
@@ -39,6 +43,27 @@ describe('MediaService', () => {
39
43
  try { unlinkSync(p); } catch { /* ignore */ }
40
44
  }
41
45
  savedPaths.length = 0;
46
+ delete process.env.OPENCLAW_STATE_DIR;
47
+ delete process.env.CLAWDBOT_STATE_DIR;
48
+ });
49
+
50
+ describe('resolveManagedMediaDir', () => {
51
+ it('uses OpenClaw state media directory by default', () => {
52
+ const mediaDir = resolveManagedMediaDir({
53
+ HOME: '/tmp/test-home',
54
+ } as NodeJS.ProcessEnv);
55
+
56
+ expect(mediaDir).toBe('/tmp/test-home/.openclaw/media/bitrix24');
57
+ });
58
+
59
+ it('prefers OPENCLAW_STATE_DIR when it is configured', () => {
60
+ const mediaDir = resolveManagedMediaDir({
61
+ OPENCLAW_STATE_DIR: '/srv/openclaw-state',
62
+ HOME: '/tmp/test-home',
63
+ } as NodeJS.ProcessEnv);
64
+
65
+ expect(mediaDir).toBe('/srv/openclaw-state/media/bitrix24');
66
+ });
42
67
  });
43
68
 
44
69
  describe('downloadMedia', () => {
@@ -52,20 +77,23 @@ describe('MediaService', () => {
52
77
  fileId: '123',
53
78
  fileName: 'test.txt',
54
79
  extension: 'txt',
55
- clientEndpoint: 'https://test.bitrix24.com/rest/',
56
- userToken: 'user_token',
80
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
81
+ bot: { botId: 7, botToken: 'bot_token' },
82
+ dialogId: 'chat40985',
57
83
  });
58
84
 
59
85
  expect(result).not.toBeNull();
60
86
  expect(result!.contentType).toBe('text/plain');
61
87
  expect(result!.name).toBe('test.txt');
62
88
  expect(result!.path).toContain('test.txt');
89
+ expect(result!.path.startsWith(join(tempStateDir, 'media', 'bitrix24'))).toBe(true);
63
90
  expect(existsSync(result!.path)).toBe(true);
64
91
  savedPaths.push(result!.path);
65
92
 
66
- expect(api.getFileInfo).toHaveBeenCalledWith(
67
- 'https://test.bitrix24.com/rest/',
68
- 'user_token',
93
+ expect(api.getFileDownloadUrl).toHaveBeenCalledWith(
94
+ 'https://test.bitrix24.com/rest/1/token/',
95
+ { botId: 7, botToken: 'bot_token' },
96
+ 'chat40985',
69
97
  123,
70
98
  );
71
99
  });
@@ -79,8 +107,9 @@ describe('MediaService', () => {
79
107
  fileId: '456',
80
108
  fileName: 'photo.jpg',
81
109
  extension: 'jpg',
82
- clientEndpoint: 'https://test.bitrix24.com/rest/',
83
- userToken: 'token',
110
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
111
+ bot: { botId: 7, botToken: 'bot_token' },
112
+ dialogId: 'chat40985',
84
113
  });
85
114
 
86
115
  expect(result).not.toBeNull();
@@ -88,8 +117,55 @@ describe('MediaService', () => {
88
117
  if (result) savedPaths.push(result.path);
89
118
  });
90
119
 
120
+ it('cleans up managed downloaded temp files', async () => {
121
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
122
+ new Response(Buffer.from('cleanup me'), { status: 200 }),
123
+ );
124
+
125
+ const result = await service.downloadMedia({
126
+ fileId: '654',
127
+ fileName: 'cleanup.txt',
128
+ extension: 'txt',
129
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
130
+ bot: { botId: 7, botToken: 'bot_token' },
131
+ dialogId: 'chat40985',
132
+ });
133
+
134
+ expect(result).not.toBeNull();
135
+ expect(existsSync(result!.path)).toBe(true);
136
+
137
+ await service.cleanupDownloadedMedia([result!.path]);
138
+
139
+ expect(existsSync(result!.path)).toBe(false);
140
+ });
141
+
142
+ it('rewrites private Bitrix download URLs to webhook origin', async () => {
143
+ (api.getFileDownloadUrl as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
144
+ 'https://192.168.50.50/rest/1/token/download/?token=abc',
145
+ );
146
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
147
+ new Response(Buffer.from('hello world'), { status: 200 }),
148
+ );
149
+
150
+ const result = await service.downloadMedia({
151
+ fileId: '777',
152
+ fileName: 'private.txt',
153
+ extension: 'txt',
154
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
155
+ bot: { botId: 7, botToken: 'bot_token' },
156
+ dialogId: '1',
157
+ });
158
+
159
+ expect(result).not.toBeNull();
160
+ expect(globalThis.fetch).toHaveBeenCalledWith(
161
+ 'https://test.bitrix24.com/rest/1/token/download/?token=abc',
162
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
163
+ );
164
+ if (result) savedPaths.push(result.path);
165
+ });
166
+
91
167
  it('returns null when API fails', async () => {
92
- (api.getFileInfo as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
168
+ (api.getFileDownloadUrl as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
93
169
  new Error('API error'),
94
170
  );
95
171
 
@@ -97,8 +173,9 @@ describe('MediaService', () => {
97
173
  fileId: '789',
98
174
  fileName: 'fail.txt',
99
175
  extension: 'txt',
100
- clientEndpoint: 'https://test.bitrix24.com/rest/',
101
- userToken: 'token',
176
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
177
+ bot: { botId: 7, botToken: 'bot_token' },
178
+ dialogId: 'chat40985',
102
179
  });
103
180
 
104
181
  expect(result).toBeNull();
@@ -106,14 +183,15 @@ describe('MediaService', () => {
106
183
  });
107
184
 
108
185
  it('returns null when no DOWNLOAD_URL', async () => {
109
- (api.getFileInfo as ReturnType<typeof vi.fn>).mockResolvedValueOnce({});
186
+ (api.getFileDownloadUrl as ReturnType<typeof vi.fn>).mockResolvedValueOnce('');
110
187
 
111
188
  const result = await service.downloadMedia({
112
189
  fileId: '789',
113
190
  fileName: 'nourl.txt',
114
191
  extension: 'txt',
115
- clientEndpoint: 'https://test.bitrix24.com/rest/',
116
- userToken: 'token',
192
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
193
+ bot: { botId: 7, botToken: 'bot_token' },
194
+ dialogId: 'chat40985',
117
195
  });
118
196
 
119
197
  expect(result).toBeNull();
@@ -128,8 +206,9 @@ describe('MediaService', () => {
128
206
  fileId: '789',
129
207
  fileName: 'missing.txt',
130
208
  extension: 'txt',
131
- clientEndpoint: 'https://test.bitrix24.com/rest/',
132
- userToken: 'token',
209
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
210
+ bot: { botId: 7, botToken: 'bot_token' },
211
+ dialogId: 'chat40985',
133
212
  });
134
213
 
135
214
  expect(result).toBeNull();
@@ -144,18 +223,41 @@ describe('MediaService', () => {
144
223
  fileId: '100',
145
224
  fileName: 'file.xyz',
146
225
  extension: 'xyz',
147
- clientEndpoint: 'https://test.bitrix24.com/rest/',
148
- userToken: 'token',
226
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
227
+ bot: { botId: 7, botToken: 'bot_token' },
228
+ dialogId: 'chat40985',
149
229
  });
150
230
 
151
231
  expect(result).not.toBeNull();
152
232
  expect(result!.contentType).toBe('application/octet-stream');
153
233
  if (result) savedPaths.push(result.path);
154
234
  });
235
+
236
+ it('prefers response content type when extension is unknown', async () => {
237
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
238
+ new Response(Buffer.from([0xff, 0xd8, 0xff]), {
239
+ status: 200,
240
+ headers: { 'content-type': 'image/jpeg; charset=binary' },
241
+ }),
242
+ );
243
+
244
+ const result = await service.downloadMedia({
245
+ fileId: '101',
246
+ fileName: 'forwarded-file',
247
+ extension: '',
248
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
249
+ bot: { botId: 7, botToken: 'bot_token' },
250
+ dialogId: 'chat40985',
251
+ });
252
+
253
+ expect(result).not.toBeNull();
254
+ expect(result!.contentType).toBe('image/jpeg');
255
+ if (result) savedPaths.push(result.path);
256
+ });
155
257
  });
156
258
 
157
259
  describe('uploadMediaToChat', () => {
158
- it('uploads file through 3-step process', async () => {
260
+ it('uploads file through imbot.v2.File.upload', async () => {
159
261
  // Create a temp file to upload
160
262
  const { writeFileSync, mkdirSync } = await import('node:fs');
161
263
  const { join } = await import('node:path');
@@ -169,35 +271,28 @@ describe('MediaService', () => {
169
271
  const result = await service.uploadMediaToChat({
170
272
  localPath: testPath,
171
273
  fileName: 'upload-test.txt',
172
- chatId: 40985,
173
- clientEndpoint: 'https://test.bitrix24.com/rest/',
174
- botToken: 'bot_token',
274
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
275
+ bot: { botId: 7, botToken: 'bot_token' },
276
+ dialogId: 'chat40985',
277
+ message: 'hello',
175
278
  });
176
279
 
177
- expect(result).toBe(true);
178
- expect(api.getChatFolder).toHaveBeenCalledWith(
179
- 'https://test.bitrix24.com/rest/',
180
- 'bot_token',
181
- 40985,
182
- );
280
+ expect(result).toEqual({ ok: true, messageId: 100 });
183
281
  expect(api.uploadFile).toHaveBeenCalledWith(
184
- 'https://test.bitrix24.com/rest/',
185
- 'bot_token',
186
- 42,
187
- 'upload-test.txt',
188
- expect.any(Buffer),
189
- );
190
- expect(api.commitFileToChat).toHaveBeenCalledWith(
191
- 'https://test.bitrix24.com/rest/',
192
- 'bot_token',
193
- 40985,
194
- 100,
282
+ 'https://test.bitrix24.com/rest/1/token/',
283
+ { botId: 7, botToken: 'bot_token' },
284
+ 'chat40985',
285
+ {
286
+ name: 'upload-test.txt',
287
+ content: Buffer.from('upload content').toString('base64'),
288
+ message: 'hello',
289
+ },
195
290
  );
196
291
  });
197
292
 
198
- it('returns false when getChatFolder fails', async () => {
199
- (api.getChatFolder as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
200
- new Error('folder error'),
293
+ it('returns { ok: false } when upload fails', async () => {
294
+ (api.uploadFile as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
295
+ new Error('upload error'),
201
296
  );
202
297
 
203
298
  const { writeFileSync, mkdirSync } = await import('node:fs');
@@ -212,12 +307,12 @@ describe('MediaService', () => {
212
307
  const result = await service.uploadMediaToChat({
213
308
  localPath: testPath,
214
309
  fileName: 'fail-upload.txt',
215
- chatId: 40985,
216
- clientEndpoint: 'https://test.bitrix24.com/rest/',
217
- botToken: 'bot_token',
310
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
311
+ bot: { botId: 7, botToken: 'bot_token' },
312
+ dialogId: 'chat40985',
218
313
  });
219
314
 
220
- expect(result).toBe(false);
315
+ expect(result).toEqual({ ok: false });
221
316
  expect(silentLogger.error).toHaveBeenCalled();
222
317
  });
223
318
  });
@@ -26,6 +26,14 @@ describe('markdownToBbCode', () => {
26
26
  expect(markdownToBbCode('Use `console.log`')).toBe('Use [CODE]console.log[/CODE]');
27
27
  });
28
28
 
29
+ it('renders slash commands as bold burgundy inline accents', () => {
30
+ expect(markdownToBbCode('Try `/status`')).toBe('Try [B][COLOR=#7A1F3D]/status[/COLOR][/B]');
31
+ });
32
+
33
+ it('renders short acronyms as bold burgundy inline accents', () => {
34
+ expect(markdownToBbCode('Use `API` here')).toBe('Use [B][COLOR=#7A1F3D]API[/COLOR][/B] here');
35
+ });
36
+
29
37
  it('converts code blocks', () => {
30
38
  const md = '```js\nconst x = 1;\n```';
31
39
  const expected = '[CODE]const x = 1;[/CODE]';
@@ -37,10 +45,66 @@ describe('markdownToBbCode', () => {
37
45
  .toBe('[URL=https://google.com]Google[/URL]');
38
46
  });
39
47
 
48
+ it('converts markdown images to lowercase img tag without title text', () => {
49
+ expect(markdownToBbCode('![Заголовок изображения](https://via.placeholder.com/150 "Заголовок изображения")'))
50
+ .toBe('[img size=medium]https://via.placeholder.com/150#image.jpg [/img]');
51
+ });
52
+
53
+ it('keeps supported image extensions unchanged', () => {
54
+ expect(markdownToBbCode('![x](https://example.com/image.png)'))
55
+ .toBe('[img size=medium]https://example.com/image.png [/img]');
56
+ });
57
+
58
+ it('adds image.jpg suffix for unsupported image extensions', () => {
59
+ expect(markdownToBbCode('![x](https://mcgrp.ru/img/2017_05/img_1007484.avif)'))
60
+ .toBe('[img size=medium]https://mcgrp.ru/img/2017_05/img_1007484.avif#image.jpg [/img]');
61
+ });
62
+
40
63
  it('converts blockquotes', () => {
41
64
  expect(markdownToBbCode('> This is a quote')).toBe('>>This is a quote');
42
65
  });
43
66
 
67
+ it('converts nested blockquotes to Bitrix-compatible quoted blocks', () => {
68
+ expect(markdownToBbCode([
69
+ '> цитата уровня 1',
70
+ '>> цитата уровня два',
71
+ ].join('\n'))).toBe([
72
+ '>>цитата уровня 1',
73
+ '>>------------------------------------------------------',
74
+ '>>цитата уровня два',
75
+ '>>------------------------------------------------------',
76
+ ].join('\n'));
77
+ });
78
+
79
+ it('skips empty spacer lines inside nested blockquotes', () => {
80
+ expect(markdownToBbCode([
81
+ '> Это первая цитата уровня 1.',
82
+ '>',
83
+ '>> Это вторая цитата уровня 2, вложенная в первую.',
84
+ ].join('\n'))).toBe([
85
+ '>>Это первая цитата уровня 1.',
86
+ '>>------------------------------------------------------',
87
+ '>>Это вторая цитата уровня 2, вложенная в первую.',
88
+ '>>------------------------------------------------------',
89
+ ].join('\n'));
90
+ });
91
+
92
+ it('converts GFM tables to simplified plain text tables', () => {
93
+ const input = [
94
+ '| Заголовок 1 | Заголовок 2 |',
95
+ '| ----------- | ----------- |',
96
+ '| Ячейка 1 | Ячейка 2 |',
97
+ '| Ячейка 3 | Ячейка 4 |',
98
+ ].join('\n');
99
+
100
+ expect(markdownToBbCode(input)).toBe([
101
+ 'Заголовок 1 | Заголовок 2',
102
+ '_________________________',
103
+ 'Ячейка 1 | Ячейка 2',
104
+ 'Ячейка 3 | Ячейка 4',
105
+ ].join('\n'));
106
+ });
107
+
44
108
  it('handles mixed formatting', () => {
45
109
  const input = '**Bold** and *italic* with `code`';
46
110
  const expected = '[B]Bold[/B] and [I]italic[/I] with [CODE]code[/CODE]';
@@ -0,0 +1,77 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { PollingService } from '../src/polling-service.js';
6
+ import type { Bitrix24Api } from '../src/api.js';
7
+
8
+ const silentLogger = {
9
+ info: vi.fn(),
10
+ warn: vi.fn(),
11
+ error: vi.fn(),
12
+ debug: vi.fn(),
13
+ };
14
+
15
+ describe('PollingService', () => {
16
+ let tempHome = '';
17
+ const originalHome = process.env.HOME;
18
+
19
+ afterEach(() => {
20
+ if (tempHome) {
21
+ rmSync(tempHome, { recursive: true, force: true });
22
+ tempHome = '';
23
+ }
24
+
25
+ if (originalHome === undefined) {
26
+ delete process.env.HOME;
27
+ } else {
28
+ process.env.HOME = originalHome;
29
+ }
30
+
31
+ vi.clearAllMocks();
32
+ });
33
+
34
+ it('persists offset only up to the last successfully processed event', async () => {
35
+ tempHome = mkdtempSync(join(tmpdir(), 'b24-polling-'));
36
+ process.env.HOME = tempHome;
37
+
38
+ const abortController = new AbortController();
39
+ const fetchEvents = vi.fn().mockResolvedValue({
40
+ events: [
41
+ { eventId: 10, type: 'ONE', date: '', data: {} },
42
+ { eventId: 11, type: 'TWO', date: '', data: {} },
43
+ ],
44
+ lastEventId: 11,
45
+ hasMore: false,
46
+ });
47
+
48
+ const api = { fetchEvents } as unknown as Bitrix24Api;
49
+ const onEvent = vi.fn(async (event: { eventId: number }) => {
50
+ if (event.eventId === 11) {
51
+ abortController.abort();
52
+ throw new Error('boom');
53
+ }
54
+ });
55
+
56
+ const service = new PollingService({
57
+ api,
58
+ webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
59
+ bot: { botId: 7, botToken: 'bot_token' },
60
+ accountId: 'default',
61
+ pollingIntervalMs: 10,
62
+ pollingFastIntervalMs: 10,
63
+ onEvent: onEvent as never,
64
+ abortSignal: abortController.signal,
65
+ logger: silentLogger,
66
+ });
67
+
68
+ await service.start();
69
+
70
+ const statePath = join(tempHome, '.openclaw', 'state', 'bitrix24', 'poll-offset-default.json');
71
+ const state = JSON.parse(readFileSync(statePath, 'utf-8')) as { offset: number };
72
+
73
+ expect(fetchEvents).toHaveBeenCalledOnce();
74
+ expect(onEvent).toHaveBeenCalledTimes(2);
75
+ expect(state.offset).toBe(11);
76
+ });
77
+ });
@@ -42,11 +42,11 @@ describe('RateLimiter', () => {
42
42
  expect(limiter.pending).toBe(0);
43
43
  });
44
44
 
45
- it('resolves pending on destroy', async () => {
45
+ it('rejects pending on destroy', async () => {
46
46
  limiter = new RateLimiter({ maxPerSecond: 1 });
47
47
  await limiter.acquire();
48
48
  const p = limiter.acquire();
49
49
  limiter.destroy();
50
- await p; // Should resolve without hanging
50
+ await expect(p).rejects.toThrow('RateLimiter destroyed');
51
51
  });
52
52
  });