polfan-server-js-client 0.2.110 → 0.2.111

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,339 @@
1
+ import {EventTarget} from "../src/EventTarget";
2
+ import {RoomsManager} from "../src/state-tracker/RoomsManager";
3
+ import {SpacesManager} from "../src/state-tracker/SpacesManager";
4
+ import {WindowState} from "../src/state-tracker/TopicHistoryWindow";
5
+
6
+ /**
7
+ * These tests exercise the reconnect (second `Session`) reconciliation path of
8
+ * the state tracker: collections must keep their identity, surviving rooms and
9
+ * spaces must not be wiped and rebuilt, ephemeral message history must be
10
+ * preserved, and only the entities actually removed on the server may be
11
+ * dropped. See ChatStateTracker managers `handleSession`.
12
+ */
13
+
14
+ type Responder = (data: any) => any;
15
+
16
+ class FakeClient extends EventTarget {
17
+ public readonly sent: { type: string; data: any }[] = [];
18
+ /**
19
+ * When set, commands reject the way the real client rejects them once the
20
+ * connection is gone, instead of hanging forever.
21
+ */
22
+ public offline = false;
23
+ private readonly responders: Record<string, Responder> = {};
24
+
25
+ public respondTo(type: string, responder: Responder): void {
26
+ this.responders[type] = responder;
27
+ }
28
+
29
+ public async send(type: string, data: any): Promise<{ data: any; error: any }> {
30
+ this.sent.push({ type, data });
31
+
32
+ if (this.offline) {
33
+ throw new Error('Connection closed before the command was answered');
34
+ }
35
+
36
+ const responder = this.responders[type];
37
+ return { data: responder ? responder(data) : undefined, error: null };
38
+ }
39
+
40
+ public countSent(type: string): number {
41
+ return this.sent.filter(entry => entry.type === type).length;
42
+ }
43
+ }
44
+
45
+ const flush = async (): Promise<void> => {
46
+ // Drain microtasks scheduled by the fire-and-forget resync chain.
47
+ await new Promise(resolve => setTimeout(resolve, 0));
48
+ await new Promise(resolve => setTimeout(resolve, 0));
49
+ };
50
+
51
+ const createRoom = (id: string, overrides: any = {}): any => ({
52
+ id,
53
+ spaceId: null,
54
+ name: id,
55
+ description: '',
56
+ type: 'Text',
57
+ defaultTopic: { id: `topic-${id}`, messageCount: 0, lastMessage: null },
58
+ recipients: null,
59
+ flags: 0,
60
+ stream: null,
61
+ history: { mode: 'Full' },
62
+ ...overrides,
63
+ });
64
+
65
+ const createSpace = (id: string, roles: any[] = []): any => ({
66
+ id,
67
+ name: id,
68
+ roles,
69
+ defaultRooms: [],
70
+ systemRoom: null,
71
+ });
72
+
73
+ const emitSession = (client: FakeClient, rooms: any[], spaces: any[] = []): void => {
74
+ client.emit('Session', {
75
+ serverVersion: '1.0.0',
76
+ protoVersion: '1.0.0',
77
+ user: { id: 'me' },
78
+ state: { rooms, spaces },
79
+ } as any);
80
+ };
81
+
82
+ const createTracker = () => {
83
+ const client = new FakeClient();
84
+ const tracker: any = { client, getMe: async () => ({ id: 'me' }) };
85
+ tracker.rooms = new RoomsManager(tracker);
86
+ tracker.spaces = new SpacesManager(tracker);
87
+ return { client, tracker };
88
+ };
89
+
90
+ describe('reconnect - RoomsManager', () => {
91
+ test('preserves the rooms list identity and reconciles membership', async () => {
92
+ const { client, tracker } = createTracker();
93
+
94
+ emitSession(client, [createRoom('A'), createRoom('B')]);
95
+ const list = await tracker.rooms.get();
96
+
97
+ expect(list.items.map((r: any) => r.id).sort()).toEqual(['A', 'B']);
98
+
99
+ // Reconnect: B is gone, C joined, A survived (renamed).
100
+ emitSession(client, [createRoom('A', { name: 'A-renamed' }), createRoom('C')]);
101
+ const listAfter = await tracker.rooms.get();
102
+
103
+ expect(listAfter).toBe(list); // same object -> bindings intact
104
+ expect(listAfter.items.map((r: any) => r.id).sort()).toEqual(['A', 'C']);
105
+ expect(listAfter.get('A').name).toBe('A-renamed');
106
+ expect(listAfter.get('B')).toBeUndefined();
107
+ });
108
+
109
+ test('keeps a member collection object across reconnect and refetches it', async () => {
110
+ const { client, tracker } = createTracker();
111
+ client.respondTo('GetRoomMembers', (data: any) => ({
112
+ id: data.id,
113
+ members: [
114
+ { user: { id: 'me' }, spaceMember: null, roles: null, customColor: null, customNick: null, extras: '' },
115
+ { user: { id: 'u1' }, spaceMember: null, roles: null, customColor: null, customNick: null, extras: '' },
116
+ ],
117
+ }));
118
+
119
+ emitSession(client, [createRoom('A')]);
120
+ const membersBefore = await tracker.rooms.getMembers('A');
121
+ expect(membersBefore.items.map((m: any) => m.user.id).sort()).toEqual(['me', 'u1']);
122
+ expect(client.countSent('GetRoomMembers')).toBe(1);
123
+
124
+ // After reconnect u1 has left; the refetch must reconcile in place.
125
+ client.respondTo('GetRoomMembers', (data: any) => ({
126
+ id: data.id,
127
+ members: [
128
+ { user: { id: 'me' }, spaceMember: null, roles: null, customColor: null, customNick: null, extras: '' },
129
+ ],
130
+ }));
131
+
132
+ emitSession(client, [createRoom('A')]);
133
+ const membersAfter = await tracker.rooms.getMembers('A');
134
+
135
+ expect(membersAfter).toBe(membersBefore); // same object -> no blank
136
+ expect(client.countSent('GetRoomMembers')).toBe(2); // guard was dropped -> refetched
137
+ expect(membersAfter.items.map((m: any) => m.user.id)).toEqual(['me']);
138
+ });
139
+ });
140
+
141
+ describe('reconnect - MessagesManager', () => {
142
+ const emitNewMessage = (client: FakeClient, roomId: string, topicId: string, id: string): void => {
143
+ client.emit('NewMessage', {
144
+ message: {
145
+ id,
146
+ type: 'Text',
147
+ content: '',
148
+ location: { roomId, topicId },
149
+ author: { user: { id: 'other' } },
150
+ },
151
+ } as any);
152
+ };
153
+
154
+ test('preserves ephemeral live history across reconnect (no refetch, no loss)', async () => {
155
+ const { client, tracker } = createTracker();
156
+
157
+ emitSession(client, [createRoom('E', { history: { mode: 'Ephemeral' } })]);
158
+
159
+ const history = await tracker.rooms.messages.getRoomHistory('E');
160
+ const window = await history.getMessagesWindow('topic-E');
161
+ expect(window.state).toBe(WindowState.LIVE);
162
+
163
+ emitNewMessage(client, 'E', 'topic-E', 'm1');
164
+ emitNewMessage(client, 'E', 'topic-E', 'm2');
165
+ expect(window.items.map((m: any) => m.id)).toEqual(['m1', 'm2']);
166
+
167
+ client.sent.length = 0;
168
+
169
+ // Reconnect - the whole point: ephemeral context must survive.
170
+ emitSession(client, [createRoom('E', { history: { mode: 'Ephemeral' } })]);
171
+ await flush();
172
+
173
+ const historyAfter = await tracker.rooms.messages.getRoomHistory('E');
174
+ const windowAfter = await historyAfter.getMessagesWindow('topic-E');
175
+
176
+ expect(historyAfter).toBe(history); // history object preserved
177
+ expect(windowAfter).toBe(window); // window object preserved
178
+ expect(windowAfter.items.map((m: any) => m.id)).toEqual(['m1', 'm2']);
179
+ expect(client.countSent('GetMessages')).toBe(0); // never refetched
180
+ });
181
+
182
+ test('refreshes a LATEST window but leaves an untouched LIVE window alone', async () => {
183
+ const { client, tracker } = createTracker();
184
+ client.respondTo('GetMessages', () => ({ messages: [{ id: 'x1' }, { id: 'x2' }] }));
185
+
186
+ // R was opened (pulled to LATEST); L was only ever created (LIVE).
187
+ emitSession(client, [createRoom('R'), createRoom('L')]);
188
+
189
+ const rHistory = await tracker.rooms.messages.getRoomHistory('R');
190
+ const rWindow = await rHistory.getMessagesWindow('topic-R');
191
+ await rWindow.resetToLatest();
192
+ expect(rWindow.state).toBe(WindowState.LATEST);
193
+
194
+ const lHistory = await tracker.rooms.messages.getRoomHistory('L');
195
+ const lWindow = await lHistory.getMessagesWindow('topic-L');
196
+ expect(lWindow.state).toBe(WindowState.LIVE);
197
+
198
+ client.sent.length = 0;
199
+
200
+ emitSession(client, [createRoom('R'), createRoom('L')]);
201
+ await flush();
202
+
203
+ // LATEST window was refreshed with exactly one request; identity kept.
204
+ expect((await tracker.rooms.messages.getRoomHistory('R'))).toBe(rHistory);
205
+ expect(client.sent.filter(e => e.type === 'GetMessages'
206
+ && e.data.location.roomId === 'R').length).toBe(1);
207
+ // LIVE window (never pulled by the app) was left untouched.
208
+ expect(client.sent.filter(e => e.type === 'GetMessages'
209
+ && e.data.location.roomId === 'L').length).toBe(0);
210
+ });
211
+ });
212
+
213
+ describe('reconnect - SpacesManager', () => {
214
+ test('reconciles roles in place and drops only removed spaces', async () => {
215
+ const { client, tracker } = createTracker();
216
+
217
+ const s1Roles = [
218
+ { id: 'r1', name: 'everyone', priority: 0, color: '#fff' },
219
+ { id: 'r2', name: 'mod', priority: 1, color: '#fff' },
220
+ ];
221
+ emitSession(client, [], [createSpace('S1', s1Roles), createSpace('S2')]);
222
+
223
+ const spaces = await tracker.spaces.get();
224
+ const rolesS1 = await tracker.spaces.getRoles('S1');
225
+ expect(spaces.items.map((s: any) => s.id).sort()).toEqual(['S1', 'S2']);
226
+ expect(rolesS1.items.map((r: any) => r.id).sort()).toEqual(['r1', 'r2']);
227
+
228
+ // Reconnect: S2 removed, S3 joined; S1 roles changed (r2 removed, r3 added).
229
+ const s1RolesNew = [
230
+ { id: 'r1', name: 'everyone', priority: 0, color: '#fff' },
231
+ { id: 'r3', name: 'vip', priority: 2, color: '#fff' },
232
+ ];
233
+ emitSession(client, [], [createSpace('S1', s1RolesNew), createSpace('S3')]);
234
+
235
+ const spacesAfter = await tracker.spaces.get();
236
+ const rolesS1After = await tracker.spaces.getRoles('S1');
237
+
238
+ expect(spacesAfter).toBe(spaces);
239
+ expect(spacesAfter.items.map((s: any) => s.id).sort()).toEqual(['S1', 'S3']);
240
+ expect(rolesS1After).toBe(rolesS1); // roles collection identity preserved
241
+ expect(rolesS1After.items.map((r: any) => r.id).sort()).toEqual(['r1', 'r3']);
242
+ });
243
+
244
+ test('keeps a space member collection across reconnect and refetches it', async () => {
245
+ const { client, tracker } = createTracker();
246
+ client.respondTo('GetSpaceMembers', (data: any) => ({
247
+ id: data.id,
248
+ members: [{ user: { id: 'me' } }, { user: { id: 'u1' } }],
249
+ }));
250
+
251
+ emitSession(client, [], [createSpace('S1')]);
252
+ const membersBefore = await tracker.spaces.getMembers('S1');
253
+ expect(client.countSent('GetSpaceMembers')).toBe(1);
254
+
255
+ client.respondTo('GetSpaceMembers', (data: any) => ({
256
+ id: data.id,
257
+ members: [{ user: { id: 'me' } }],
258
+ }));
259
+
260
+ emitSession(client, [], [createSpace('S1')]);
261
+ const membersAfter = await tracker.spaces.getMembers('S1');
262
+
263
+ expect(membersAfter).toBe(membersBefore);
264
+ expect(client.countSent('GetSpaceMembers')).toBe(2);
265
+ expect(membersAfter.items.map((m: any) => m.user.id)).toEqual(['me']);
266
+ });
267
+ });
268
+
269
+ describe('recovery after a request issued while the client was offline', () => {
270
+ const member = (id: string): any => ({
271
+ user: { id }, spaceMember: null, roles: null, customColor: null, customNick: null, extras: '',
272
+ });
273
+
274
+ test('room member list fills in on the next access instead of staying empty', async () => {
275
+ const { client, tracker } = createTracker();
276
+ emitSession(client, [createRoom('A')]);
277
+
278
+ // Opening the room while offline: the command rejects (it must never
279
+ // hang) and the caller sees the failure.
280
+ client.offline = true;
281
+ await expect(tracker.rooms.getMembers('A')).rejects.toThrow('Connection closed');
282
+
283
+ // The failed lookup must not be cached - otherwise every later access
284
+ // would replay the same rejection and the list would stay empty for the
285
+ // rest of the session, even after a successful reconnect.
286
+ client.offline = false;
287
+ client.respondTo('GetRoomMembers', (data: any) => ({
288
+ id: data.id,
289
+ members: [member('me'), member('u1')],
290
+ }));
291
+
292
+ const members = await tracker.rooms.getMembers('A');
293
+
294
+ expect(members.items.map((m: any) => m.user.id).sort()).toEqual(['me', 'u1']);
295
+ });
296
+
297
+ test('a lazy space collection recovers the same way', async () => {
298
+ const { client, tracker } = createTracker();
299
+ emitSession(client, [], [createSpace('S1')]);
300
+
301
+ client.offline = true;
302
+ await expect(tracker.spaces.getRooms('S1')).rejects.toThrow('Connection closed');
303
+
304
+ client.offline = false;
305
+ client.respondTo('GetSpaceRooms', (data: any) => ({
306
+ id: data.id,
307
+ summaries: [{ id: 'R1', name: 'R1', description: '' }],
308
+ }));
309
+
310
+ const rooms = await tracker.spaces.getRooms('S1');
311
+
312
+ expect(rooms.items.map((r: any) => r.id)).toEqual(['R1']);
313
+ });
314
+
315
+ test('a reconnect refetch reconciles a list that changed while offline', async () => {
316
+ const { client, tracker } = createTracker();
317
+ emitSession(client, [createRoom('A')]);
318
+
319
+ client.respondTo('GetRoomMembers', (data: any) => ({
320
+ id: data.id,
321
+ members: [member('me'), member('u1'), member('u2')],
322
+ }));
323
+ const members = await tracker.rooms.getMembers('A');
324
+ expect(members.length).toBe(3);
325
+
326
+ // While disconnected u1 left and u3 joined.
327
+ client.respondTo('GetRoomMembers', (data: any) => ({
328
+ id: data.id,
329
+ members: [member('me'), member('u2'), member('u3')],
330
+ }));
331
+ emitSession(client, [createRoom('A')]);
332
+
333
+ const refreshed = await tracker.rooms.getMembers('A');
334
+
335
+ // Same object (bindings intact) reconciled to the fresh membership.
336
+ expect(refreshed).toBe(members);
337
+ expect(refreshed.items.map((m: any) => m.user.id).sort()).toEqual(['me', 'u2', 'u3']);
338
+ });
339
+ });