@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.
@@ -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
+ });