@dev-crew-berlin/enter-js-utils 0.97.5 → 0.98.10
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/dist/api-client/api-base.d.ts +24 -3
- package/dist/api-client/api-base.js +146 -15
- package/dist/api-client/api-client.test.js +748 -0
- package/dist/api-client/index.d.ts +11 -5
- package/dist/api-client/index.js +25 -5
- package/dist/generated/api-schema.d.ts +618 -267
- package/dist/models/attendee.d.ts +4 -1
- package/dist/models/attendee.js +4 -0
- package/package.json +7 -7
- package/dist/ui/button.stories.d.ts +0 -16
- package/dist/ui/checkin-count-indicator.stories.d.ts +0 -14
- package/dist/ui/checkin-progress-bar.stories.d.ts +0 -12
- package/dist/ui/companion-info.stories.d.ts +0 -7
- package/dist/ui/enter-logo.stories.d.ts +0 -7
- package/dist/ui/form-elements/input.stories.d.ts +0 -8
- package/dist/ui/form-elements/label.stories.d.ts +0 -6
- package/dist/ui/form-elements/search-input.stories.d.ts +0 -7
- package/dist/ui/form-elements/segmented-control.stories.d.ts +0 -6
- package/dist/ui/form-elements/select.stories.d.ts +0 -8
- package/dist/ui/guest-card.stories.d.ts +0 -14
- package/dist/ui/icons/add-guest-icon.stories.d.ts +0 -7
- package/dist/ui/icons/caret-icon.stories.d.ts +0 -7
- package/dist/ui/icons/filter-icon.stories.d.ts +0 -7
- package/dist/ui/icons/search-icon.stories.d.ts +0 -7
- package/dist/ui/icons/settings-icon.stories.d.ts +0 -7
- package/dist/ui/icons/sort-list-icon.stories.d.ts +0 -7
- package/dist/ui/tag.stories.d.ts +0 -6
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import APIBase from './api-base';
|
|
3
|
+
import API from './index';
|
|
4
|
+
class TestAPI extends APIBase {
|
|
5
|
+
async *subscribe(endpoint, options = {}) {
|
|
6
|
+
yield* this.stream(endpoint, options);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function makeAPI() {
|
|
10
|
+
return new TestAPI({
|
|
11
|
+
credentials: {
|
|
12
|
+
accessToken: 'token',
|
|
13
|
+
url: 'https://api.example.com'
|
|
14
|
+
},
|
|
15
|
+
requesterId: 'req-1',
|
|
16
|
+
deviceName: 'test',
|
|
17
|
+
onLogout: vi.fn()
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function sseChunk(messages) {
|
|
21
|
+
const text = messages.map(m => {
|
|
22
|
+
let s = '';
|
|
23
|
+
if (m.id !== undefined) s += `id: ${m.id}\n`;
|
|
24
|
+
if (m.event !== undefined) s += `event: ${m.event}\n`;
|
|
25
|
+
s += `data: ${m.data}\n\n`;
|
|
26
|
+
return s;
|
|
27
|
+
}).join('');
|
|
28
|
+
return new TextEncoder().encode(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Creates a stream that enqueues chunks but does NOT close automatically.
|
|
32
|
+
// Returns an explicit close() handle — the caller decides when the server
|
|
33
|
+
// "shuts the connection", making the cause of any reconnect visible at the
|
|
34
|
+
// call site rather than hidden inside a helper.
|
|
35
|
+
// If the abort signal fires, the stream errors so in-progress reads throw.
|
|
36
|
+
function makeStream(chunks, signal) {
|
|
37
|
+
let ctrl;
|
|
38
|
+
const stream = new ReadableStream({
|
|
39
|
+
start(controller) {
|
|
40
|
+
ctrl = controller;
|
|
41
|
+
if (signal?.aborted) {
|
|
42
|
+
controller.error(new DOMException('Aborted', 'AbortError'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
signal?.addEventListener('abort', () => controller.error(new DOMException('Aborted', 'AbortError')), {
|
|
46
|
+
once: true
|
|
47
|
+
});
|
|
48
|
+
for (const chunk of chunks) controller.enqueue(chunk);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
stream,
|
|
53
|
+
push: chunk => ctrl.enqueue(chunk),
|
|
54
|
+
close: () => ctrl.close()
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Stream that hangs until the signal fires — intentionally never closed.
|
|
59
|
+
function makeSilentStream(signal) {
|
|
60
|
+
return makeStream([], signal).stream;
|
|
61
|
+
}
|
|
62
|
+
function okResponse(body) {
|
|
63
|
+
return new Response(body, {
|
|
64
|
+
status: 200
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function unauthorizedResponse() {
|
|
68
|
+
return new Response(null, {
|
|
69
|
+
status: 401,
|
|
70
|
+
statusText: 'Unauthorized'
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Flush the microtask queue by yielding many times.
|
|
75
|
+
async function flushMicrotasks(rounds = 30) {
|
|
76
|
+
for (let i = 0; i < rounds; i++) await Promise.resolve();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Flush pending microtasks (so the generator reaches its sleep() call and
|
|
80
|
+
// registers the setTimeout), then advance fake timers by ms, then flush again
|
|
81
|
+
// so the generator processes the reconnect before we assert.
|
|
82
|
+
async function advanceTime(ms) {
|
|
83
|
+
await flushMicrotasks();
|
|
84
|
+
await vi.advanceTimersByTimeAsync(ms);
|
|
85
|
+
await flushMicrotasks();
|
|
86
|
+
}
|
|
87
|
+
describe('resilientStream', () => {
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
vi.useFakeTimers();
|
|
90
|
+
});
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
vi.useRealTimers();
|
|
93
|
+
vi.restoreAllMocks();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ─── basic event delivery ────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
it('yields parsed JSON events', async () => {
|
|
99
|
+
const payload = {
|
|
100
|
+
type: 'attendee.updated',
|
|
101
|
+
id: '42'
|
|
102
|
+
};
|
|
103
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
104
|
+
const {
|
|
105
|
+
stream
|
|
106
|
+
} = makeStream([sseChunk([{
|
|
107
|
+
data: JSON.stringify(payload)
|
|
108
|
+
}])], init.signal);
|
|
109
|
+
return Promise.resolve(okResponse(stream));
|
|
110
|
+
});
|
|
111
|
+
const abortController = new AbortController();
|
|
112
|
+
const api = makeAPI();
|
|
113
|
+
const gen = api.subscribe('/test', {
|
|
114
|
+
signal: abortController.signal
|
|
115
|
+
});
|
|
116
|
+
const response = await gen.next();
|
|
117
|
+
expect(response.value).toEqual(payload);
|
|
118
|
+
expect(response.done).toBe(false);
|
|
119
|
+
abortController.abort();
|
|
120
|
+
await gen.return(undefined);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ─── reconnect after stream end ──────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
it('reconnects after stream ends normally', async () => {
|
|
126
|
+
const event1 = {
|
|
127
|
+
n: 1
|
|
128
|
+
};
|
|
129
|
+
const event2 = {
|
|
130
|
+
n: 2
|
|
131
|
+
};
|
|
132
|
+
const events = [event1, event2];
|
|
133
|
+
let connectionCount = 0;
|
|
134
|
+
let closeStream;
|
|
135
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
136
|
+
connectionCount++;
|
|
137
|
+
const {
|
|
138
|
+
stream,
|
|
139
|
+
close
|
|
140
|
+
} = makeStream([sseChunk([{
|
|
141
|
+
data: JSON.stringify(events.shift())
|
|
142
|
+
}])], init.signal);
|
|
143
|
+
closeStream = close;
|
|
144
|
+
return Promise.resolve(okResponse(stream));
|
|
145
|
+
});
|
|
146
|
+
const abortController = new AbortController();
|
|
147
|
+
const api = makeAPI();
|
|
148
|
+
const gen = api.subscribe('/test', {
|
|
149
|
+
signal: abortController.signal
|
|
150
|
+
});
|
|
151
|
+
const response1 = await gen.next();
|
|
152
|
+
expect(response1.value).toEqual(event1);
|
|
153
|
+
|
|
154
|
+
// Closing the stream is what triggers the reconnect
|
|
155
|
+
// the generator exits the read loop and enters backoff.
|
|
156
|
+
closeStream();
|
|
157
|
+
|
|
158
|
+
// Dont await here so we can advance time and dont have to wait for backoff
|
|
159
|
+
const response2Promise = gen.next();
|
|
160
|
+
await advanceTime(3000); // past max jittered initial backoff
|
|
161
|
+
const response2 = await response2Promise;
|
|
162
|
+
expect(response2.value).toEqual(event2);
|
|
163
|
+
expect(connectionCount).toBe(2);
|
|
164
|
+
abortController.abort();
|
|
165
|
+
await gen.return(undefined);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ─── reconnect after fetch error ─────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
it('reconnects after fetch network error', async () => {
|
|
171
|
+
const event = {
|
|
172
|
+
ok: true
|
|
173
|
+
};
|
|
174
|
+
let connectionCount = 0;
|
|
175
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
176
|
+
connectionCount++;
|
|
177
|
+
if (connectionCount === 1) return Promise.reject(new Error('Network failure'));
|
|
178
|
+
const {
|
|
179
|
+
stream
|
|
180
|
+
} = makeStream([sseChunk([{
|
|
181
|
+
data: JSON.stringify(event)
|
|
182
|
+
}])], init.signal);
|
|
183
|
+
return Promise.resolve(okResponse(stream));
|
|
184
|
+
});
|
|
185
|
+
const abortController = new AbortController();
|
|
186
|
+
const api = makeAPI();
|
|
187
|
+
const gen = api.subscribe('/test', {
|
|
188
|
+
signal: abortController.signal
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// fails on first attempt and hides retry, but takes some backoff time before retrying
|
|
192
|
+
const response1Promise = gen.next();
|
|
193
|
+
await advanceTime(3000);
|
|
194
|
+
const response1 = await response1Promise;
|
|
195
|
+
expect(response1.value).toEqual(event);
|
|
196
|
+
expect(connectionCount).toBe(2);
|
|
197
|
+
abortController.abort();
|
|
198
|
+
await gen.return(undefined);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ─── Last-Event-ID ───────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
it('sends Last-Event-ID header on reconnect', async () => {
|
|
204
|
+
const event = {
|
|
205
|
+
x: 1
|
|
206
|
+
};
|
|
207
|
+
const capturedInits = [];
|
|
208
|
+
// First connection delivers one event with a cursor; second is silent.
|
|
209
|
+
const streamChunks = [[sseChunk([{
|
|
210
|
+
id: 'cursor-99',
|
|
211
|
+
data: JSON.stringify(event)
|
|
212
|
+
}])], []];
|
|
213
|
+
let closeStream;
|
|
214
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
215
|
+
capturedInits.push(init);
|
|
216
|
+
const {
|
|
217
|
+
stream,
|
|
218
|
+
close
|
|
219
|
+
} = makeStream(streamChunks.shift() ?? [], init.signal);
|
|
220
|
+
closeStream = close;
|
|
221
|
+
return Promise.resolve(okResponse(stream));
|
|
222
|
+
});
|
|
223
|
+
const abortController = new AbortController();
|
|
224
|
+
const api = makeAPI();
|
|
225
|
+
const gen = api.subscribe('/test', {
|
|
226
|
+
signal: abortController.signal
|
|
227
|
+
});
|
|
228
|
+
const response1 = await gen.next();
|
|
229
|
+
expect(response1.value).toEqual(event);
|
|
230
|
+
|
|
231
|
+
// Close first stream — reconnect trigger
|
|
232
|
+
closeStream();
|
|
233
|
+
const response2Promise = gen.next();
|
|
234
|
+
await advanceTime(3000);
|
|
235
|
+
expect(capturedInits.length).toBeGreaterThanOrEqual(2);
|
|
236
|
+
const secondHeaders = capturedInits[1]?.headers;
|
|
237
|
+
expect(secondHeaders?.['Last-Event-ID']).toBe('cursor-99');
|
|
238
|
+
abortController.abort();
|
|
239
|
+
await response2Promise;
|
|
240
|
+
});
|
|
241
|
+
it('does not send Last-Event-ID on first connect', async () => {
|
|
242
|
+
const capturedInits = [];
|
|
243
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
244
|
+
capturedInits.push(init);
|
|
245
|
+
return Promise.resolve(okResponse(makeSilentStream(init.signal)));
|
|
246
|
+
});
|
|
247
|
+
const abortController = new AbortController();
|
|
248
|
+
const api = makeAPI();
|
|
249
|
+
const pending = api.subscribe('/test', {
|
|
250
|
+
signal: abortController.signal
|
|
251
|
+
}).next();
|
|
252
|
+
await flushMicrotasks();
|
|
253
|
+
const firstHeaders = capturedInits[0]?.headers;
|
|
254
|
+
expect(firstHeaders?.['Last-Event-ID']).toBeUndefined();
|
|
255
|
+
abortController.abort();
|
|
256
|
+
await pending;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ─── heartbeat watchdog ───────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
it('fires watchdog after 45s of silence and triggers reconnect', async () => {
|
|
262
|
+
let connectionCount = 0;
|
|
263
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
264
|
+
connectionCount++;
|
|
265
|
+
return Promise.resolve(okResponse(makeSilentStream(init.signal)));
|
|
266
|
+
});
|
|
267
|
+
const abortController = new AbortController();
|
|
268
|
+
const api = makeAPI();
|
|
269
|
+
const gen = api.subscribe('/test', {
|
|
270
|
+
signal: abortController.signal
|
|
271
|
+
});
|
|
272
|
+
const nextPromise = gen.next();
|
|
273
|
+
await flushMicrotasks();
|
|
274
|
+
expect(connectionCount).toBe(1);
|
|
275
|
+
|
|
276
|
+
// Fire the 45s watchdog then let the backoff sleep through
|
|
277
|
+
await advanceTime(45_001);
|
|
278
|
+
await advanceTime(3_000);
|
|
279
|
+
expect(connectionCount).toBeGreaterThanOrEqual(2);
|
|
280
|
+
abortController.abort();
|
|
281
|
+
await nextPromise;
|
|
282
|
+
});
|
|
283
|
+
it('heartbeats reset the watchdog so it does not fire during activity', async () => {
|
|
284
|
+
const realEvent = {
|
|
285
|
+
data: 'live'
|
|
286
|
+
};
|
|
287
|
+
let connectionCount = 0;
|
|
288
|
+
let pushChunk;
|
|
289
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
290
|
+
connectionCount++;
|
|
291
|
+
const {
|
|
292
|
+
stream,
|
|
293
|
+
push
|
|
294
|
+
} = makeStream([], init.signal);
|
|
295
|
+
pushChunk = push;
|
|
296
|
+
return Promise.resolve(okResponse(stream));
|
|
297
|
+
});
|
|
298
|
+
const abortController = new AbortController();
|
|
299
|
+
const api = makeAPI();
|
|
300
|
+
const gen = api.subscribe('/test', {
|
|
301
|
+
signal: abortController.signal
|
|
302
|
+
});
|
|
303
|
+
const responsePromise = gen.next();
|
|
304
|
+
await flushMicrotasks();
|
|
305
|
+
|
|
306
|
+
// Advance to just under the 45s watchdog — it has not fired yet
|
|
307
|
+
await advanceTime(44_000);
|
|
308
|
+
expect(connectionCount).toBe(1);
|
|
309
|
+
|
|
310
|
+
// Heartbeat arrives and resets the watchdog to another 45s from now
|
|
311
|
+
pushChunk(sseChunk([{
|
|
312
|
+
event: 'heartbeat',
|
|
313
|
+
data: '{}'
|
|
314
|
+
}]));
|
|
315
|
+
await flushMicrotasks();
|
|
316
|
+
|
|
317
|
+
// Advance another 44s — total 88s elapsed but watchdog was reset at 44s,
|
|
318
|
+
// so it still hasn't fired
|
|
319
|
+
await advanceTime(44_000);
|
|
320
|
+
expect(connectionCount).toBe(1);
|
|
321
|
+
|
|
322
|
+
// Real event arrives — generator yields it
|
|
323
|
+
pushChunk(sseChunk([{
|
|
324
|
+
data: JSON.stringify(realEvent)
|
|
325
|
+
}]));
|
|
326
|
+
const response = await responsePromise;
|
|
327
|
+
expect(response.value).toEqual(realEvent);
|
|
328
|
+
expect(connectionCount).toBe(1);
|
|
329
|
+
abortController.abort();
|
|
330
|
+
await gen.return(undefined);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ─── heartbeat filtering ──────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
it('never yields heartbeat events to the consumer', async () => {
|
|
336
|
+
const realEvent = {
|
|
337
|
+
type: 'real'
|
|
338
|
+
};
|
|
339
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
340
|
+
const {
|
|
341
|
+
stream
|
|
342
|
+
} = makeStream([sseChunk([{
|
|
343
|
+
event: 'heartbeat',
|
|
344
|
+
data: '{}'
|
|
345
|
+
}, {
|
|
346
|
+
data: JSON.stringify(realEvent)
|
|
347
|
+
}, {
|
|
348
|
+
event: 'heartbeat',
|
|
349
|
+
data: '{}'
|
|
350
|
+
}])], init.signal);
|
|
351
|
+
return Promise.resolve(okResponse(stream));
|
|
352
|
+
});
|
|
353
|
+
const abortController = new AbortController();
|
|
354
|
+
const api = makeAPI();
|
|
355
|
+
const gen = api.subscribe('/test', {
|
|
356
|
+
signal: abortController.signal
|
|
357
|
+
});
|
|
358
|
+
const response = await gen.next();
|
|
359
|
+
expect(response.value).toEqual(realEvent);
|
|
360
|
+
abortController.abort();
|
|
361
|
+
await gen.return(undefined);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ─── reset handling ───────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
it('calls onReset for server reset event and does not yield it', async () => {
|
|
367
|
+
const realEvent = {
|
|
368
|
+
type: 'real'
|
|
369
|
+
};
|
|
370
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
371
|
+
const {
|
|
372
|
+
stream
|
|
373
|
+
} = makeStream([sseChunk([{
|
|
374
|
+
event: 'reset',
|
|
375
|
+
data: '{}'
|
|
376
|
+
}, {
|
|
377
|
+
data: JSON.stringify(realEvent)
|
|
378
|
+
}])], init.signal);
|
|
379
|
+
return Promise.resolve(okResponse(stream));
|
|
380
|
+
});
|
|
381
|
+
const onReset = vi.fn();
|
|
382
|
+
const abortController = new AbortController();
|
|
383
|
+
const api = makeAPI();
|
|
384
|
+
const gen = api.subscribe('/test', {
|
|
385
|
+
onReset,
|
|
386
|
+
signal: abortController.signal
|
|
387
|
+
});
|
|
388
|
+
const response = await gen.next();
|
|
389
|
+
expect(response.value).toEqual(realEvent);
|
|
390
|
+
expect(onReset).toHaveBeenCalledTimes(1);
|
|
391
|
+
abortController.abort();
|
|
392
|
+
await gen.return(undefined);
|
|
393
|
+
});
|
|
394
|
+
it('calls onReset when reconnecting without a stored cursor', async () => {
|
|
395
|
+
// No id fields → cursor never stored → onReset fires on reconnect
|
|
396
|
+
const events = ['{"n":1}', '{"n":2}'];
|
|
397
|
+
let closeStream;
|
|
398
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
399
|
+
const {
|
|
400
|
+
stream,
|
|
401
|
+
close
|
|
402
|
+
} = makeStream([sseChunk([{
|
|
403
|
+
data: events.shift() ?? ''
|
|
404
|
+
}])], init.signal);
|
|
405
|
+
closeStream = close;
|
|
406
|
+
return Promise.resolve(okResponse(stream));
|
|
407
|
+
});
|
|
408
|
+
const onReset = vi.fn();
|
|
409
|
+
const abortController = new AbortController();
|
|
410
|
+
const api = makeAPI();
|
|
411
|
+
const gen = api.subscribe('/test', {
|
|
412
|
+
onReset,
|
|
413
|
+
signal: abortController.signal
|
|
414
|
+
});
|
|
415
|
+
const response1 = await gen.next();
|
|
416
|
+
expect(response1.value).toEqual({
|
|
417
|
+
n: 1
|
|
418
|
+
});
|
|
419
|
+
expect(onReset).not.toHaveBeenCalled(); // no onReset on first connect
|
|
420
|
+
|
|
421
|
+
// Closing the first stream (no cursor stored) triggers onReset on reconnect
|
|
422
|
+
closeStream();
|
|
423
|
+
const response2Promise = gen.next();
|
|
424
|
+
await advanceTime(3000);
|
|
425
|
+
const response2 = await response2Promise;
|
|
426
|
+
expect(response2.value).toEqual({
|
|
427
|
+
n: 2
|
|
428
|
+
});
|
|
429
|
+
expect(onReset).toHaveBeenCalledTimes(1);
|
|
430
|
+
abortController.abort();
|
|
431
|
+
await gen.return(undefined);
|
|
432
|
+
});
|
|
433
|
+
it('does not call onReset on the first connect', async () => {
|
|
434
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
435
|
+
const {
|
|
436
|
+
stream
|
|
437
|
+
} = makeStream([sseChunk([{
|
|
438
|
+
data: '{"n":1}'
|
|
439
|
+
}])], init.signal);
|
|
440
|
+
return Promise.resolve(okResponse(stream));
|
|
441
|
+
});
|
|
442
|
+
const onReset = vi.fn();
|
|
443
|
+
const abortController = new AbortController();
|
|
444
|
+
const api = makeAPI();
|
|
445
|
+
const gen = api.subscribe('/test', {
|
|
446
|
+
onReset,
|
|
447
|
+
signal: abortController.signal
|
|
448
|
+
});
|
|
449
|
+
await gen.next();
|
|
450
|
+
expect(onReset).not.toHaveBeenCalled();
|
|
451
|
+
abortController.abort();
|
|
452
|
+
await gen.return(undefined);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ─── clean termination ────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
it('does not reconnect after consumer break', async () => {
|
|
458
|
+
let connectionCount = 0;
|
|
459
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
460
|
+
connectionCount++;
|
|
461
|
+
const {
|
|
462
|
+
stream
|
|
463
|
+
} = makeStream([sseChunk([{
|
|
464
|
+
data: '{"n":1}'
|
|
465
|
+
}, {
|
|
466
|
+
data: '{"n":2}'
|
|
467
|
+
}])], init.signal);
|
|
468
|
+
return Promise.resolve(okResponse(stream));
|
|
469
|
+
});
|
|
470
|
+
const api = makeAPI();
|
|
471
|
+
const gen = api.subscribe('/test');
|
|
472
|
+
const response1 = await gen.next();
|
|
473
|
+
expect(response1.value.n).toBe(1);
|
|
474
|
+
|
|
475
|
+
// Consumer breaks — generator terminates cleanly; no server close needed
|
|
476
|
+
await gen.return(undefined);
|
|
477
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
478
|
+
await flushMicrotasks();
|
|
479
|
+
expect(connectionCount).toBe(1);
|
|
480
|
+
});
|
|
481
|
+
it('does not reconnect when external AbortSignal is fired', async () => {
|
|
482
|
+
let connectionCount = 0;
|
|
483
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
484
|
+
connectionCount++;
|
|
485
|
+
return Promise.resolve(okResponse(makeSilentStream(init.signal)));
|
|
486
|
+
});
|
|
487
|
+
const abortController = new AbortController();
|
|
488
|
+
const api = makeAPI();
|
|
489
|
+
const pending = api.subscribe('/test', {
|
|
490
|
+
signal: abortController.signal
|
|
491
|
+
}).next();
|
|
492
|
+
await flushMicrotasks();
|
|
493
|
+
expect(connectionCount).toBe(1);
|
|
494
|
+
abortController.abort();
|
|
495
|
+
await pending; // resolves as {done: true}
|
|
496
|
+
|
|
497
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
498
|
+
await flushMicrotasks();
|
|
499
|
+
expect(connectionCount).toBe(1);
|
|
500
|
+
});
|
|
501
|
+
it('does not reconnect after a 401 response', async () => {
|
|
502
|
+
let connectionCount = 0;
|
|
503
|
+
vi.stubGlobal('fetch', () => {
|
|
504
|
+
connectionCount++;
|
|
505
|
+
return Promise.resolve(unauthorizedResponse());
|
|
506
|
+
});
|
|
507
|
+
const api = makeAPI();
|
|
508
|
+
const gen = api.subscribe('/test');
|
|
509
|
+
const response = await gen.next();
|
|
510
|
+
expect(response.done).toBe(true);
|
|
511
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
512
|
+
await flushMicrotasks();
|
|
513
|
+
expect(connectionCount).toBe(1);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ─── backoff behaviour ────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
it('backoff grows across successive reconnects', async () => {
|
|
519
|
+
// Pin jitter to maximum (Math.random() = 1 → multiplier = 1.5) so timing is deterministic:
|
|
520
|
+
// 1st sleep = 1_000 ms (initial backoffMs, jitter applied after)
|
|
521
|
+
// 2nd sleep = Math.min(1_000 × 2, 30_000) × 1.5 = 3_000 ms
|
|
522
|
+
vi.spyOn(Math, 'random').mockReturnValue(1);
|
|
523
|
+
let connectionCount = 0;
|
|
524
|
+
let closeStream;
|
|
525
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
526
|
+
connectionCount++;
|
|
527
|
+
const {
|
|
528
|
+
stream,
|
|
529
|
+
close
|
|
530
|
+
} = makeStream([], init.signal);
|
|
531
|
+
closeStream = close;
|
|
532
|
+
return Promise.resolve(okResponse(stream));
|
|
533
|
+
});
|
|
534
|
+
const abortController = new AbortController();
|
|
535
|
+
const api = makeAPI();
|
|
536
|
+
const gen = api.subscribe('/test', {
|
|
537
|
+
signal: abortController.signal
|
|
538
|
+
});
|
|
539
|
+
gen.next();
|
|
540
|
+
await flushMicrotasks();
|
|
541
|
+
|
|
542
|
+
// 1st disconnect → 1_000 ms backoff
|
|
543
|
+
closeStream();
|
|
544
|
+
await advanceTime(999);
|
|
545
|
+
expect(connectionCount).toBe(1); // not yet
|
|
546
|
+
|
|
547
|
+
await advanceTime(2); // crosses 1_000 ms
|
|
548
|
+
expect(connectionCount).toBe(2);
|
|
549
|
+
|
|
550
|
+
// 2nd disconnect → 3_000 ms backoff (grew)
|
|
551
|
+
closeStream();
|
|
552
|
+
await advanceTime(2_999);
|
|
553
|
+
expect(connectionCount).toBe(2); // not yet
|
|
554
|
+
|
|
555
|
+
await advanceTime(2); // crosses 3_000 ms
|
|
556
|
+
expect(connectionCount).toBe(3);
|
|
557
|
+
abortController.abort();
|
|
558
|
+
await gen.return(undefined);
|
|
559
|
+
});
|
|
560
|
+
it('resets backoff after a connection that lived longer than the threshold', async () => {
|
|
561
|
+
let connectionCount = 0;
|
|
562
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
563
|
+
connectionCount++;
|
|
564
|
+
return Promise.resolve(okResponse(makeSilentStream(init.signal)));
|
|
565
|
+
});
|
|
566
|
+
const abortController = new AbortController();
|
|
567
|
+
const api = makeAPI();
|
|
568
|
+
const gen = api.subscribe('/test', {
|
|
569
|
+
signal: abortController.signal
|
|
570
|
+
});
|
|
571
|
+
gen.next();
|
|
572
|
+
await flushMicrotasks();
|
|
573
|
+
expect(connectionCount).toBe(1);
|
|
574
|
+
|
|
575
|
+
// Watchdog fires at 45 s (connection lived > 10 s threshold → backoff resets)
|
|
576
|
+
await advanceTime(45_001);
|
|
577
|
+
|
|
578
|
+
// Backoff should have been reset to ~1 s; advance 2 s to cover jitter
|
|
579
|
+
await advanceTime(2_000);
|
|
580
|
+
|
|
581
|
+
// Second fetch triggered
|
|
582
|
+
expect(connectionCount).toBeGreaterThanOrEqual(2);
|
|
583
|
+
abortController.abort();
|
|
584
|
+
await gen.return(undefined);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// ─── connection state callbacks ───────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
it('emits connected then reconnecting on connection loss', async () => {
|
|
590
|
+
// First connection delivers one event; second is silent (only the state
|
|
591
|
+
// sequence matters here, not a second event).
|
|
592
|
+
const streamChunks = [[sseChunk([{
|
|
593
|
+
data: '{"n":1}'
|
|
594
|
+
}])], []];
|
|
595
|
+
let closeStream;
|
|
596
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
597
|
+
const {
|
|
598
|
+
stream,
|
|
599
|
+
close
|
|
600
|
+
} = makeStream(streamChunks.shift() ?? [], init.signal);
|
|
601
|
+
closeStream = close;
|
|
602
|
+
return Promise.resolve(okResponse(stream));
|
|
603
|
+
});
|
|
604
|
+
const states = [];
|
|
605
|
+
const abortController = new AbortController();
|
|
606
|
+
const api = makeAPI();
|
|
607
|
+
const gen = api.subscribe('/test', {
|
|
608
|
+
signal: abortController.signal,
|
|
609
|
+
onConnectionChange: s => states.push(s)
|
|
610
|
+
});
|
|
611
|
+
const response1 = await gen.next();
|
|
612
|
+
expect(response1.value).toEqual({
|
|
613
|
+
n: 1
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// Closing the first stream triggers 'reconnecting', then 'connected' on the second
|
|
617
|
+
closeStream();
|
|
618
|
+
const response2Promise = gen.next();
|
|
619
|
+
await advanceTime(3000);
|
|
620
|
+
expect(states).toContain('connected');
|
|
621
|
+
expect(states).toContain('reconnecting');
|
|
622
|
+
expect(states.indexOf('connected')).toBeLessThan(states.indexOf('reconnecting'));
|
|
623
|
+
abortController.abort();
|
|
624
|
+
await response2Promise;
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// ─── initialLastEventId ───────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
it('sends initialLastEventId as Last-Event-ID on the very first connect', async () => {
|
|
630
|
+
const capturedInits = [];
|
|
631
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
632
|
+
capturedInits.push(init);
|
|
633
|
+
return Promise.resolve(okResponse(makeSilentStream(init.signal)));
|
|
634
|
+
});
|
|
635
|
+
const abortController = new AbortController();
|
|
636
|
+
const api = makeAPI();
|
|
637
|
+
const pending = api.subscribe('/test', {
|
|
638
|
+
signal: abortController.signal,
|
|
639
|
+
initialLastEventId: 'seed-cursor'
|
|
640
|
+
}).next();
|
|
641
|
+
await flushMicrotasks();
|
|
642
|
+
const firstHeaders = capturedInits[0]?.headers;
|
|
643
|
+
expect(firstHeaders?.['Last-Event-ID']).toBe('seed-cursor');
|
|
644
|
+
abortController.abort();
|
|
645
|
+
await pending;
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
function makeFullAPI() {
|
|
649
|
+
return new API({
|
|
650
|
+
credentials: {
|
|
651
|
+
accessToken: 'token',
|
|
652
|
+
url: 'https://api.example.com'
|
|
653
|
+
},
|
|
654
|
+
requesterId: 'req-1',
|
|
655
|
+
deviceName: 'test',
|
|
656
|
+
onLogout: vi.fn()
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
describe('getAttendeeList', () => {
|
|
660
|
+
afterEach(() => {
|
|
661
|
+
vi.restoreAllMocks();
|
|
662
|
+
});
|
|
663
|
+
it('returns eventCursor: null when X-Event-Cursor header is absent', async () => {
|
|
664
|
+
vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify([{
|
|
665
|
+
id: 'att-1'
|
|
666
|
+
}]), {
|
|
667
|
+
status: 200
|
|
668
|
+
})));
|
|
669
|
+
const api = makeFullAPI();
|
|
670
|
+
const result = await api.getAttendeeList({
|
|
671
|
+
instanceName: 'test-instance'
|
|
672
|
+
});
|
|
673
|
+
expect(result.success).toBe(true);
|
|
674
|
+
if (!result.success) return;
|
|
675
|
+
expect(result.data.eventCursor).toBeNull();
|
|
676
|
+
expect(result.data.attendees).toHaveLength(1);
|
|
677
|
+
expect(result.data.attendees[0].id).toBe('att-1');
|
|
678
|
+
});
|
|
679
|
+
it('returns eventCursor from X-Event-Cursor response header', async () => {
|
|
680
|
+
vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify([{
|
|
681
|
+
id: 'att-1'
|
|
682
|
+
}]), {
|
|
683
|
+
status: 200,
|
|
684
|
+
headers: {
|
|
685
|
+
'X-Event-Cursor': 'abc123'
|
|
686
|
+
}
|
|
687
|
+
})));
|
|
688
|
+
const api = makeFullAPI();
|
|
689
|
+
const result = await api.getAttendeeList({
|
|
690
|
+
instanceName: 'test-instance'
|
|
691
|
+
});
|
|
692
|
+
expect(result.success).toBe(true);
|
|
693
|
+
if (!result.success) return;
|
|
694
|
+
expect(result.data.eventCursor).toBe('abc123');
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
describe('createEvents', () => {
|
|
698
|
+
afterEach(() => {
|
|
699
|
+
vi.restoreAllMocks();
|
|
700
|
+
});
|
|
701
|
+
it('returns created and eventCursors populated from response body', async () => {
|
|
702
|
+
vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify({
|
|
703
|
+
created: 2,
|
|
704
|
+
event_cursors: {
|
|
705
|
+
'uuid-1': 'cursor-abc',
|
|
706
|
+
'uuid-2': 'cursor-def'
|
|
707
|
+
}
|
|
708
|
+
}), {
|
|
709
|
+
status: 201
|
|
710
|
+
})));
|
|
711
|
+
const api = makeFullAPI();
|
|
712
|
+
const result = await api.createEvents([]);
|
|
713
|
+
expect(result.success).toBe(true);
|
|
714
|
+
if (!result.success) return;
|
|
715
|
+
expect(result.data.created).toBe(2);
|
|
716
|
+
expect(result.data.eventCursors).toEqual({
|
|
717
|
+
'uuid-1': 'cursor-abc',
|
|
718
|
+
'uuid-2': 'cursor-def'
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
it('returns empty eventCursors when field is absent', async () => {
|
|
722
|
+
vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify({
|
|
723
|
+
created: 1
|
|
724
|
+
}), {
|
|
725
|
+
status: 201
|
|
726
|
+
})));
|
|
727
|
+
const api = makeFullAPI();
|
|
728
|
+
const result = await api.createEvents([]);
|
|
729
|
+
expect(result.success).toBe(true);
|
|
730
|
+
if (!result.success) return;
|
|
731
|
+
expect(result.data.created).toBe(1);
|
|
732
|
+
expect(result.data.eventCursors).toEqual({});
|
|
733
|
+
});
|
|
734
|
+
it('returns empty eventCursors when field is empty', async () => {
|
|
735
|
+
vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify({
|
|
736
|
+
created: 0,
|
|
737
|
+
event_cursors: {}
|
|
738
|
+
}), {
|
|
739
|
+
status: 201
|
|
740
|
+
})));
|
|
741
|
+
const api = makeFullAPI();
|
|
742
|
+
const result = await api.createEvents([]);
|
|
743
|
+
expect(result.success).toBe(true);
|
|
744
|
+
if (!result.success) return;
|
|
745
|
+
expect(result.data.created).toBe(0);
|
|
746
|
+
expect(result.data.eventCursors).toEqual({});
|
|
747
|
+
});
|
|
748
|
+
});
|