@rowger_go/chatu 0.1.3
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.
- package/.github/workflows/ci.yml +30 -0
- package/.github/workflows/publish.yml +55 -0
- package/INSTALL.md +285 -0
- package/INSTALL.zh.md +285 -0
- package/LICENSE +21 -0
- package/README.md +293 -0
- package/README.zh.md +293 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1381 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +5 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +334 -0
- package/dist/index.test.js.map +1 -0
- package/dist/sdk/adapters/cache.d.ts +94 -0
- package/dist/sdk/adapters/cache.d.ts.map +1 -0
- package/dist/sdk/adapters/cache.js +158 -0
- package/dist/sdk/adapters/cache.js.map +1 -0
- package/dist/sdk/adapters/cache.test.d.ts +14 -0
- package/dist/sdk/adapters/cache.test.d.ts.map +1 -0
- package/dist/sdk/adapters/cache.test.js +178 -0
- package/dist/sdk/adapters/cache.test.js.map +1 -0
- package/dist/sdk/adapters/default.d.ts +24 -0
- package/dist/sdk/adapters/default.d.ts.map +1 -0
- package/dist/sdk/adapters/default.js +151 -0
- package/dist/sdk/adapters/default.js.map +1 -0
- package/dist/sdk/adapters/webhub.d.ts +336 -0
- package/dist/sdk/adapters/webhub.d.ts.map +1 -0
- package/dist/sdk/adapters/webhub.js +663 -0
- package/dist/sdk/adapters/webhub.js.map +1 -0
- package/dist/sdk/adapters/websocket.d.ts +133 -0
- package/dist/sdk/adapters/websocket.d.ts.map +1 -0
- package/dist/sdk/adapters/websocket.js +314 -0
- package/dist/sdk/adapters/websocket.js.map +1 -0
- package/dist/sdk/core/channel.d.ts +104 -0
- package/dist/sdk/core/channel.d.ts.map +1 -0
- package/dist/sdk/core/channel.js +158 -0
- package/dist/sdk/core/channel.js.map +1 -0
- package/dist/sdk/index.d.ts +27 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +33 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sdk/types/adapters.d.ts +128 -0
- package/dist/sdk/types/adapters.d.ts.map +1 -0
- package/dist/sdk/types/adapters.js +10 -0
- package/dist/sdk/types/adapters.js.map +1 -0
- package/dist/sdk/types/channel.d.ts +270 -0
- package/dist/sdk/types/channel.d.ts.map +1 -0
- package/dist/sdk/types/channel.js +36 -0
- package/dist/sdk/types/channel.js.map +1 -0
- package/docs/channel/01-overview.md +117 -0
- package/docs/channel/02-configuration.md +138 -0
- package/docs/channel/03-capabilities.md +86 -0
- package/docs/channel/04-api-reference.md +394 -0
- package/docs/channel/05-message-protocol.md +194 -0
- package/docs/channel/06-security.md +83 -0
- package/docs/channel/README.md +30 -0
- package/docs/sdk/README.md +13 -0
- package/docs/sdk/v2026.1.29-v2026.2.19.md +630 -0
- package/jest.config.js +19 -0
- package/openclaw.plugin.json +113 -0
- package/package.json +74 -0
- package/run-poll.mjs +209 -0
- package/scripts/reload-plugin.sh +78 -0
- package/src/index.test.ts +432 -0
- package/src/index.ts +1638 -0
- package/src/sdk/adapters/cache.test.ts +205 -0
- package/src/sdk/adapters/cache.ts +193 -0
- package/src/sdk/adapters/default.ts +196 -0
- package/src/sdk/adapters/webhub.ts +857 -0
- package/src/sdk/adapters/websocket.ts +378 -0
- package/src/sdk/core/channel.ts +230 -0
- package/src/sdk/index.ts +36 -0
- package/src/sdk/types/adapters.ts +169 -0
- package/src/sdk/types/channel.ts +346 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T055 — Gateway lifecycle: back-off behavior and abort-signal shutdown tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { computeBackoffMs } from './index';
|
|
6
|
+
|
|
7
|
+
// ─── computeBackoffMs ──────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
describe('computeBackoffMs (exponential back-off formula)', () => {
|
|
10
|
+
const BASE = 2000;
|
|
11
|
+
const MAX = 30_000;
|
|
12
|
+
|
|
13
|
+
it('returns baseMs (no back-off) when consecutiveErrors is 0', () => {
|
|
14
|
+
expect(computeBackoffMs(0, BASE, MAX)).toBe(2000);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('doubles to 4 s after 1 consecutive error', () => {
|
|
18
|
+
expect(computeBackoffMs(1, BASE, MAX)).toBe(4000);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('doubles to 8 s after 2 consecutive errors', () => {
|
|
22
|
+
expect(computeBackoffMs(2, BASE, MAX)).toBe(8000);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('doubles to 16 s after 3 consecutive errors', () => {
|
|
26
|
+
expect(computeBackoffMs(3, BASE, MAX)).toBe(16000);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('caps at maxMs (30 s) after 4+ consecutive errors', () => {
|
|
30
|
+
expect(computeBackoffMs(4, BASE, MAX)).toBe(MAX);
|
|
31
|
+
expect(computeBackoffMs(10, BASE, MAX)).toBe(MAX);
|
|
32
|
+
expect(computeBackoffMs(100, BASE, MAX)).toBe(MAX);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('uses default baseMs = 2000 when not specified', () => {
|
|
36
|
+
expect(computeBackoffMs(1)).toBe(4000);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('uses default maxMs = 30000 when not specified', () => {
|
|
40
|
+
expect(computeBackoffMs(10)).toBe(30_000);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('works with a custom base and max', () => {
|
|
44
|
+
expect(computeBackoffMs(0, 500, 5000)).toBe(500);
|
|
45
|
+
expect(computeBackoffMs(1, 500, 5000)).toBe(1000);
|
|
46
|
+
expect(computeBackoffMs(3, 500, 5000)).toBe(4000);
|
|
47
|
+
expect(computeBackoffMs(4, 500, 5000)).toBe(5000); // capped
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ─── Abort-signal shutdown ─────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Minimal inline poll loop that mirrors the pattern used in index.ts.
|
|
55
|
+
* Runs until abortSignal fires, with an optional onTick callback.
|
|
56
|
+
*/
|
|
57
|
+
async function runMiniPollLoop(opts: {
|
|
58
|
+
abortSignal: AbortSignal;
|
|
59
|
+
intervalMs: number;
|
|
60
|
+
onTick?: () => void;
|
|
61
|
+
maxTicks?: number;
|
|
62
|
+
}): Promise<{ ticks: number; abortedCleanly: boolean }> {
|
|
63
|
+
const { abortSignal, intervalMs, onTick, maxTicks = Infinity } = opts;
|
|
64
|
+
let ticks = 0;
|
|
65
|
+
|
|
66
|
+
while (!abortSignal.aborted && ticks < maxTicks) {
|
|
67
|
+
await new Promise<void>((resolve) => {
|
|
68
|
+
const timer = setTimeout(resolve, intervalMs);
|
|
69
|
+
abortSignal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
70
|
+
});
|
|
71
|
+
if (abortSignal.aborted) break;
|
|
72
|
+
ticks++;
|
|
73
|
+
onTick?.();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { ticks, abortedCleanly: abortSignal.aborted };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('Poll loop abort-signal shutdown', () => {
|
|
80
|
+
jest.setTimeout(5000); // guard against accidental hangs in these tests
|
|
81
|
+
|
|
82
|
+
it('exits immediately when signal is already aborted before loop starts', async () => {
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
controller.abort(); // pre-abort
|
|
85
|
+
const result = await runMiniPollLoop({
|
|
86
|
+
abortSignal: controller.signal,
|
|
87
|
+
intervalMs: 10,
|
|
88
|
+
maxTicks: 100,
|
|
89
|
+
});
|
|
90
|
+
// Loop should not execute any ticks
|
|
91
|
+
expect(result.ticks).toBe(0);
|
|
92
|
+
expect(result.abortedCleanly).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('exits cleanly when signal fires mid-sleep (no dangling timer)', async () => {
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
const ticks: number[] = [];
|
|
98
|
+
|
|
99
|
+
// Abort after a short delay (shorter than the interval so the loop is mid-sleep)
|
|
100
|
+
const abortDelay = 40;
|
|
101
|
+
const loopInterval = 500;
|
|
102
|
+
setTimeout(() => controller.abort(), abortDelay);
|
|
103
|
+
|
|
104
|
+
const start = Date.now();
|
|
105
|
+
const result = await runMiniPollLoop({
|
|
106
|
+
abortSignal: controller.signal,
|
|
107
|
+
intervalMs: loopInterval,
|
|
108
|
+
onTick: () => ticks.push(Date.now()),
|
|
109
|
+
});
|
|
110
|
+
const elapsed = Date.now() - start;
|
|
111
|
+
|
|
112
|
+
// Should have completed quickly (well under one full interval)
|
|
113
|
+
expect(elapsed).toBeLessThan(loopInterval);
|
|
114
|
+
// No ticks should have fired (abort happened before the sleep completed)
|
|
115
|
+
expect(result.ticks).toBe(0);
|
|
116
|
+
expect(result.abortedCleanly).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('processes ticks normally before abort fires', async () => {
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const intervalMs = 20;
|
|
122
|
+
|
|
123
|
+
// Abort after ~2.5 ticks worth of time → expect exactly 2 ticks
|
|
124
|
+
setTimeout(() => controller.abort(), intervalMs * 2.5);
|
|
125
|
+
|
|
126
|
+
const result = await runMiniPollLoop({
|
|
127
|
+
abortSignal: controller.signal,
|
|
128
|
+
intervalMs,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(result.ticks).toBe(2);
|
|
132
|
+
expect(result.abortedCleanly).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('respects maxTicks guard — exits via maxTicks without abort', async () => {
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const result = await runMiniPollLoop({
|
|
138
|
+
abortSignal: controller.signal,
|
|
139
|
+
intervalMs: 5,
|
|
140
|
+
maxTicks: 3,
|
|
141
|
+
});
|
|
142
|
+
expect(result.ticks).toBe(3);
|
|
143
|
+
expect(result.abortedCleanly).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ─── T037: WS connection lifecycle integration ─────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* T037 — WebSocket + cache integration lifecycle tests.
|
|
151
|
+
*
|
|
152
|
+
* Tests the interaction between WebSocketAdapter and MessageCache as used
|
|
153
|
+
* inside wsConnectionLoop (mocked here for unit-test isolation):
|
|
154
|
+
* 1. First connection: adapter.connect() is called; cache.flush() is NOT called
|
|
155
|
+
* 2. Reconnect: onReconnected callback triggers cache.flush()
|
|
156
|
+
* 3. Quick-register: axios.post is called with correct URL + payload; returned
|
|
157
|
+
* channelId/accessToken are usable for subsequent WS adapter instantiation
|
|
158
|
+
*/
|
|
159
|
+
|
|
160
|
+
jest.mock('./sdk/adapters/websocket', () => {
|
|
161
|
+
return {
|
|
162
|
+
WebSocketAdapter: jest.fn().mockImplementation(() => ({
|
|
163
|
+
connect: jest.fn(),
|
|
164
|
+
disconnect: jest.fn(),
|
|
165
|
+
onMessage: jest.fn(),
|
|
166
|
+
onStatusChange: jest.fn(),
|
|
167
|
+
onReconnected: jest.fn(),
|
|
168
|
+
send: jest.fn(),
|
|
169
|
+
})),
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
jest.mock('./sdk/adapters/cache', () => {
|
|
174
|
+
return {
|
|
175
|
+
MessageCache: jest.fn().mockImplementation(() => ({
|
|
176
|
+
enqueue: jest.fn(),
|
|
177
|
+
flush: jest.fn().mockResolvedValue(0),
|
|
178
|
+
ack: jest.fn(),
|
|
179
|
+
get size() { return 0; },
|
|
180
|
+
})),
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
jest.mock('axios', () => ({
|
|
185
|
+
post: jest.fn(),
|
|
186
|
+
get: jest.fn(),
|
|
187
|
+
default: {
|
|
188
|
+
post: jest.fn(),
|
|
189
|
+
get: jest.fn(),
|
|
190
|
+
},
|
|
191
|
+
}), { virtual: true });
|
|
192
|
+
|
|
193
|
+
describe('WS connection lifecycle (T037)', () => {
|
|
194
|
+
const { WebSocketAdapter } = require('./sdk/adapters/websocket');
|
|
195
|
+
const { MessageCache } = require('./sdk/adapters/cache');
|
|
196
|
+
const axios = require('axios');
|
|
197
|
+
|
|
198
|
+
beforeEach(() => {
|
|
199
|
+
jest.clearAllMocks();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('WebSocketAdapter is a constructor that returns an adapter object', () => {
|
|
203
|
+
const adapter = new WebSocketAdapter({ channelId: 'ch-1', accessToken: 'tok', webhubUrl: 'ws://localhost/ws' });
|
|
204
|
+
expect(typeof adapter.connect).toBe('function');
|
|
205
|
+
expect(typeof adapter.onReconnected).toBe('function');
|
|
206
|
+
expect(typeof adapter.onMessage).toBe('function');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('onReconnected triggers cache.flush when registered', async () => {
|
|
210
|
+
const adapter = new WebSocketAdapter({});
|
|
211
|
+
const cache = new MessageCache({});
|
|
212
|
+
|
|
213
|
+
// Simulate what wsConnectionLoop does: register onReconnected → flush cache
|
|
214
|
+
const flushSpy = cache.flush as jest.Mock;
|
|
215
|
+
const reconnectCallback = jest.fn(async () => {
|
|
216
|
+
await cache.flush(jest.fn());
|
|
217
|
+
});
|
|
218
|
+
adapter.onReconnected(reconnectCallback);
|
|
219
|
+
|
|
220
|
+
// Simulate the adapter firing the reconnect callback
|
|
221
|
+
const registeredCallback = (adapter.onReconnected as jest.Mock).mock.calls[0][0];
|
|
222
|
+
await registeredCallback();
|
|
223
|
+
|
|
224
|
+
expect(flushSpy).toHaveBeenCalledTimes(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('cache.enqueue is called when message delivery fails', async () => {
|
|
228
|
+
const cache = new MessageCache({});
|
|
229
|
+
const enqueueSpy = cache.enqueue as jest.Mock;
|
|
230
|
+
|
|
231
|
+
// Simulate failed delivery → enqueue
|
|
232
|
+
const failedMsg = { id: 'msg-1', channelId: 'ch-1', content: 'hello', enqueuedAt: Date.now(), status: 'pending' };
|
|
233
|
+
cache.enqueue(failedMsg);
|
|
234
|
+
|
|
235
|
+
expect(enqueueSpy).toHaveBeenCalledWith(failedMsg);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('quick-register: axios.post called with key+url payload', async () => {
|
|
239
|
+
const axiosPost = axios.post as jest.Mock;
|
|
240
|
+
axiosPost.mockResolvedValue({ data: { success: true, data: { channelId: 'ch-abc', accessToken: 'tok-xyz' } } });
|
|
241
|
+
|
|
242
|
+
const apiUrl = 'http://localhost:3000';
|
|
243
|
+
const key = 'my-channel-key';
|
|
244
|
+
const url = apiUrl;
|
|
245
|
+
|
|
246
|
+
await axios.post(`${apiUrl}/api/channel/quick-register`, { key, url });
|
|
247
|
+
|
|
248
|
+
expect(axiosPost).toHaveBeenCalledWith(
|
|
249
|
+
`${apiUrl}/api/channel/quick-register`,
|
|
250
|
+
{ key, url }
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('quick-register success: returned credentials are used for WS adapter', async () => {
|
|
255
|
+
const axiosPost = axios.post as jest.Mock;
|
|
256
|
+
const channelId = 'ch-from-qr';
|
|
257
|
+
const accessToken = 'tok-from-qr';
|
|
258
|
+
axiosPost.mockResolvedValue({ data: { success: true, data: { channelId, accessToken } } });
|
|
259
|
+
|
|
260
|
+
const resp = await axios.post('http://example.com/api/channel/quick-register', { key: 'k', url: 'http://u' });
|
|
261
|
+
const { channelId: retId, accessToken: retTok } = resp.data.data;
|
|
262
|
+
|
|
263
|
+
// Use returned credentials to create a WS adapter (matches index.ts behavior)
|
|
264
|
+
const adapter = new WebSocketAdapter({ channelId: retId, accessToken: retTok, webhubUrl: 'ws://example.com/api/channel/ws' });
|
|
265
|
+
adapter.connect();
|
|
266
|
+
|
|
267
|
+
expect((adapter.connect as jest.Mock)).toHaveBeenCalledTimes(1);
|
|
268
|
+
expect(retId).toBe(channelId);
|
|
269
|
+
expect(retTok).toBe(accessToken);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ─── T042: Streaming relay (relayStreamChunk / relayStreamDone) ────────────
|
|
274
|
+
|
|
275
|
+
import { relayStreamChunk, relayStreamDone } from './index';
|
|
276
|
+
|
|
277
|
+
describe('Streaming relay (T042)', () => {
|
|
278
|
+
const API_URL = 'http://localhost:3000';
|
|
279
|
+
const ACCESS_TOKEN = 'wh_test_token_abc';
|
|
280
|
+
const MESSAGE_ID = 'msg-stream-001';
|
|
281
|
+
|
|
282
|
+
let mockFetch: jest.Mock;
|
|
283
|
+
|
|
284
|
+
beforeEach(() => {
|
|
285
|
+
mockFetch = jest.fn();
|
|
286
|
+
(global as any).fetch = mockFetch;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
afterEach(() => {
|
|
290
|
+
jest.restoreAllMocks();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ── relayStreamChunk ──────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
describe('relayStreamChunk', () => {
|
|
296
|
+
it('POSTs chunk to /api/channel/stream/chunk with Bearer token', async () => {
|
|
297
|
+
mockFetch.mockResolvedValue({
|
|
298
|
+
ok: true,
|
|
299
|
+
json: async () => ({ ok: true }),
|
|
300
|
+
text: async () => '',
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const result = await relayStreamChunk(API_URL, ACCESS_TOKEN, MESSAGE_ID, 0, 'Hello ');
|
|
304
|
+
|
|
305
|
+
expect(result.ok).toBe(true);
|
|
306
|
+
|
|
307
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
308
|
+
`${API_URL}/api/channel/stream/chunk`,
|
|
309
|
+
expect.objectContaining({
|
|
310
|
+
method: 'POST',
|
|
311
|
+
headers: expect.objectContaining({
|
|
312
|
+
Authorization: `Bearer ${ACCESS_TOKEN}`,
|
|
313
|
+
'Content-Type': 'application/json',
|
|
314
|
+
}),
|
|
315
|
+
body: JSON.stringify({ messageId: MESSAGE_ID, seq: 0, delta: 'Hello ' }),
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('returns ok: false when server responds with non-2xx', async () => {
|
|
321
|
+
mockFetch.mockResolvedValue({
|
|
322
|
+
ok: false,
|
|
323
|
+
status: 401,
|
|
324
|
+
text: async () => '{"error":"INVALID_TOKEN"}',
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const result = await relayStreamChunk(API_URL, ACCESS_TOKEN, MESSAGE_ID, 1, 'world');
|
|
328
|
+
|
|
329
|
+
expect(result.ok).toBe(false);
|
|
330
|
+
expect(result.error).toContain('401');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('returns ok: false when fetch throws (network error)', async () => {
|
|
334
|
+
mockFetch.mockRejectedValue(new Error('Network failure'));
|
|
335
|
+
|
|
336
|
+
const result = await relayStreamChunk(API_URL, ACCESS_TOKEN, MESSAGE_ID, 0, 'test');
|
|
337
|
+
|
|
338
|
+
expect(result.ok).toBe(false);
|
|
339
|
+
expect(result.error).toContain('Network failure');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('sends seq and delta correctly for each chunk index', async () => {
|
|
343
|
+
mockFetch.mockResolvedValue({ ok: true, text: async () => '' });
|
|
344
|
+
|
|
345
|
+
await relayStreamChunk(API_URL, ACCESS_TOKEN, MESSAGE_ID, 5, 'delta-chunk');
|
|
346
|
+
|
|
347
|
+
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
348
|
+
expect(callBody.seq).toBe(5);
|
|
349
|
+
expect(callBody.delta).toBe('delta-chunk');
|
|
350
|
+
expect(callBody.messageId).toBe(MESSAGE_ID);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ── relayStreamDone ───────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
describe('relayStreamDone', () => {
|
|
357
|
+
it('POSTs to /api/channel/stream/done with Bearer token', async () => {
|
|
358
|
+
mockFetch.mockResolvedValue({
|
|
359
|
+
ok: true,
|
|
360
|
+
text: async () => '',
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const result = await relayStreamDone(API_URL, ACCESS_TOKEN, MESSAGE_ID, 3);
|
|
364
|
+
|
|
365
|
+
expect(result.ok).toBe(true);
|
|
366
|
+
|
|
367
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
368
|
+
`${API_URL}/api/channel/stream/done`,
|
|
369
|
+
expect.objectContaining({
|
|
370
|
+
method: 'POST',
|
|
371
|
+
headers: expect.objectContaining({
|
|
372
|
+
Authorization: `Bearer ${ACCESS_TOKEN}`,
|
|
373
|
+
}),
|
|
374
|
+
body: JSON.stringify({ messageId: MESSAGE_ID, totalSeq: 3 }),
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('returns ok: false when server responds with non-2xx', async () => {
|
|
380
|
+
mockFetch.mockResolvedValue({
|
|
381
|
+
ok: false,
|
|
382
|
+
status: 400,
|
|
383
|
+
text: async () => '{"error":"MISSING_FIELDS"}',
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const result = await relayStreamDone(API_URL, ACCESS_TOKEN, MESSAGE_ID, 3);
|
|
387
|
+
|
|
388
|
+
expect(result.ok).toBe(false);
|
|
389
|
+
expect(result.error).toContain('400');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('returns ok: false when fetch throws', async () => {
|
|
393
|
+
mockFetch.mockRejectedValue(new TypeError('fetch failed'));
|
|
394
|
+
|
|
395
|
+
const result = await relayStreamDone(API_URL, ACCESS_TOKEN, MESSAGE_ID, 5);
|
|
396
|
+
|
|
397
|
+
expect(result.ok).toBe(false);
|
|
398
|
+
expect(result.error).toBeDefined();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('sends totalSeq correctly', async () => {
|
|
402
|
+
mockFetch.mockResolvedValue({ ok: true, text: async () => '' });
|
|
403
|
+
|
|
404
|
+
await relayStreamDone(API_URL, ACCESS_TOKEN, MESSAGE_ID, 7);
|
|
405
|
+
|
|
406
|
+
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
407
|
+
expect(callBody.totalSeq).toBe(7);
|
|
408
|
+
expect(callBody.messageId).toBe(MESSAGE_ID);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ── Sequential chunk→done relay ───────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
describe('sequential chunk + done relay', () => {
|
|
415
|
+
it('sends 3 chunks then done, fetch called 4 times in order', async () => {
|
|
416
|
+
mockFetch.mockResolvedValue({ ok: true, text: async () => '' });
|
|
417
|
+
|
|
418
|
+
await relayStreamChunk(API_URL, ACCESS_TOKEN, MESSAGE_ID, 0, 'Hello');
|
|
419
|
+
await relayStreamChunk(API_URL, ACCESS_TOKEN, MESSAGE_ID, 1, ' ');
|
|
420
|
+
await relayStreamChunk(API_URL, ACCESS_TOKEN, MESSAGE_ID, 2, 'World');
|
|
421
|
+
await relayStreamDone(API_URL, ACCESS_TOKEN, MESSAGE_ID, 3);
|
|
422
|
+
|
|
423
|
+
expect(mockFetch).toHaveBeenCalledTimes(4);
|
|
424
|
+
|
|
425
|
+
const urls = mockFetch.mock.calls.map((c: any[]) => c[0] as string);
|
|
426
|
+
expect(urls[0]).toContain('/stream/chunk');
|
|
427
|
+
expect(urls[1]).toContain('/stream/chunk');
|
|
428
|
+
expect(urls[2]).toContain('/stream/chunk');
|
|
429
|
+
expect(urls[3]).toContain('/stream/done');
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
});
|