@objectstack/service-feed 3.0.7

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,507 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect } from 'vitest';
4
+ import { InMemoryFeedAdapter } from './in-memory-feed-adapter';
5
+ import type { IFeedService, CreateFeedItemInput } from '@objectstack/spec/contracts';
6
+
7
+ /** Helper to create a standard comment input. */
8
+ function commentInput(overrides: Partial<CreateFeedItemInput> = {}): CreateFeedItemInput {
9
+ return {
10
+ object: 'account',
11
+ recordId: 'rec_123',
12
+ type: 'comment',
13
+ actor: { type: 'user', id: 'user_1', name: 'Alice' },
14
+ body: 'Hello world',
15
+ ...overrides,
16
+ };
17
+ }
18
+
19
+ describe('InMemoryFeedAdapter', () => {
20
+ // ==========================================
21
+ // Contract compliance
22
+ // ==========================================
23
+
24
+ it('should implement IFeedService contract', () => {
25
+ const feed: IFeedService = new InMemoryFeedAdapter();
26
+ expect(typeof feed.listFeed).toBe('function');
27
+ expect(typeof feed.createFeedItem).toBe('function');
28
+ expect(typeof feed.updateFeedItem).toBe('function');
29
+ expect(typeof feed.deleteFeedItem).toBe('function');
30
+ expect(typeof feed.getFeedItem).toBe('function');
31
+ expect(typeof feed.addReaction).toBe('function');
32
+ expect(typeof feed.removeReaction).toBe('function');
33
+ expect(typeof feed.subscribe).toBe('function');
34
+ expect(typeof feed.unsubscribe).toBe('function');
35
+ expect(typeof feed.getSubscription).toBe('function');
36
+ });
37
+
38
+ // ==========================================
39
+ // Feed CRUD
40
+ // ==========================================
41
+
42
+ it('should start with zero items', () => {
43
+ const feed = new InMemoryFeedAdapter();
44
+ expect(feed.getItemCount()).toBe(0);
45
+ });
46
+
47
+ it('should create a feed item and return it', async () => {
48
+ const feed = new InMemoryFeedAdapter();
49
+ const item = await feed.createFeedItem(commentInput());
50
+
51
+ expect(item.id).toBeDefined();
52
+ expect(item.type).toBe('comment');
53
+ expect(item.object).toBe('account');
54
+ expect(item.recordId).toBe('rec_123');
55
+ expect(item.actor.id).toBe('user_1');
56
+ expect(item.body).toBe('Hello world');
57
+ expect(item.visibility).toBe('public');
58
+ expect(item.replyCount).toBe(0);
59
+ expect(item.isEdited).toBe(false);
60
+ expect(item.createdAt).toBeDefined();
61
+ expect(feed.getItemCount()).toBe(1);
62
+ });
63
+
64
+ it('should get a feed item by ID', async () => {
65
+ const feed = new InMemoryFeedAdapter();
66
+ const item = await feed.createFeedItem(commentInput());
67
+
68
+ const fetched = await feed.getFeedItem(item.id);
69
+ expect(fetched).toEqual(item);
70
+ });
71
+
72
+ it('should return null for unknown feed item ID', async () => {
73
+ const feed = new InMemoryFeedAdapter();
74
+ const result = await feed.getFeedItem('nonexistent');
75
+ expect(result).toBeNull();
76
+ });
77
+
78
+ it('should update a feed item body and mark as edited', async () => {
79
+ const feed = new InMemoryFeedAdapter();
80
+ const item = await feed.createFeedItem(commentInput());
81
+
82
+ const updated = await feed.updateFeedItem(item.id, { body: 'Updated text' });
83
+ expect(updated.body).toBe('Updated text');
84
+ expect(updated.isEdited).toBe(true);
85
+ expect(updated.editedAt).toBeDefined();
86
+ expect(updated.updatedAt).toBeDefined();
87
+ });
88
+
89
+ it('should update feed item visibility', async () => {
90
+ const feed = new InMemoryFeedAdapter();
91
+ const item = await feed.createFeedItem(commentInput());
92
+
93
+ const updated = await feed.updateFeedItem(item.id, { visibility: 'internal' });
94
+ expect(updated.visibility).toBe('internal');
95
+ });
96
+
97
+ it('should throw when updating a non-existent feed item', async () => {
98
+ const feed = new InMemoryFeedAdapter();
99
+ await expect(feed.updateFeedItem('nonexistent', { body: 'x' }))
100
+ .rejects.toThrow(/Feed item not found/);
101
+ });
102
+
103
+ it('should delete a feed item', async () => {
104
+ const feed = new InMemoryFeedAdapter();
105
+ const item = await feed.createFeedItem(commentInput());
106
+
107
+ await feed.deleteFeedItem(item.id);
108
+ expect(feed.getItemCount()).toBe(0);
109
+ expect(await feed.getFeedItem(item.id)).toBeNull();
110
+ });
111
+
112
+ it('should throw when deleting a non-existent feed item', async () => {
113
+ const feed = new InMemoryFeedAdapter();
114
+ await expect(feed.deleteFeedItem('nonexistent'))
115
+ .rejects.toThrow(/Feed item not found/);
116
+ });
117
+
118
+ it('should enforce maxItems limit', async () => {
119
+ const feed = new InMemoryFeedAdapter({ maxItems: 2 });
120
+
121
+ await feed.createFeedItem(commentInput({ body: 'first' }));
122
+ await feed.createFeedItem(commentInput({ body: 'second' }));
123
+
124
+ await expect(feed.createFeedItem(commentInput({ body: 'third' })))
125
+ .rejects.toThrow(/Maximum feed item limit reached/);
126
+ });
127
+
128
+ // ==========================================
129
+ // Feed Listing & Filtering
130
+ // ==========================================
131
+
132
+ it('should list feed items for a record in reverse chronological order', async () => {
133
+ const feed = new InMemoryFeedAdapter();
134
+ await feed.createFeedItem(commentInput({ body: 'first' }));
135
+ await feed.createFeedItem(commentInput({ body: 'second' }));
136
+ await feed.createFeedItem(commentInput({ body: 'third' }));
137
+
138
+ const result = await feed.listFeed({ object: 'account', recordId: 'rec_123' });
139
+ expect(result.items).toHaveLength(3);
140
+ expect(result.total).toBe(3);
141
+ expect(result.hasMore).toBe(false);
142
+ // Reverse chronological: third, second, first
143
+ expect(result.items[0].body).toBe('third');
144
+ expect(result.items[2].body).toBe('first');
145
+ });
146
+
147
+ it('should not return items from other records', async () => {
148
+ const feed = new InMemoryFeedAdapter();
149
+ await feed.createFeedItem(commentInput({ recordId: 'rec_A' }));
150
+ await feed.createFeedItem(commentInput({ recordId: 'rec_B' }));
151
+
152
+ const result = await feed.listFeed({ object: 'account', recordId: 'rec_A' });
153
+ expect(result.items).toHaveLength(1);
154
+ expect(result.items[0].recordId).toBe('rec_A');
155
+ });
156
+
157
+ it('should filter comments only', async () => {
158
+ const feed = new InMemoryFeedAdapter();
159
+ await feed.createFeedItem(commentInput({ type: 'comment', body: 'comment' }));
160
+ await feed.createFeedItem(commentInput({ type: 'field_change' }));
161
+
162
+ const result = await feed.listFeed({
163
+ object: 'account',
164
+ recordId: 'rec_123',
165
+ filter: 'comments_only',
166
+ });
167
+ expect(result.items).toHaveLength(1);
168
+ expect(result.items[0].type).toBe('comment');
169
+ });
170
+
171
+ it('should filter changes only', async () => {
172
+ const feed = new InMemoryFeedAdapter();
173
+ await feed.createFeedItem(commentInput({ type: 'comment' }));
174
+ await feed.createFeedItem(commentInput({ type: 'field_change' }));
175
+
176
+ const result = await feed.listFeed({
177
+ object: 'account',
178
+ recordId: 'rec_123',
179
+ filter: 'changes_only',
180
+ });
181
+ expect(result.items).toHaveLength(1);
182
+ expect(result.items[0].type).toBe('field_change');
183
+ });
184
+
185
+ it('should filter tasks only', async () => {
186
+ const feed = new InMemoryFeedAdapter();
187
+ await feed.createFeedItem(commentInput({ type: 'comment' }));
188
+ await feed.createFeedItem(commentInput({ type: 'task' }));
189
+
190
+ const result = await feed.listFeed({
191
+ object: 'account',
192
+ recordId: 'rec_123',
193
+ filter: 'tasks_only',
194
+ });
195
+ expect(result.items).toHaveLength(1);
196
+ expect(result.items[0].type).toBe('task');
197
+ });
198
+
199
+ it('should paginate with limit and cursor', async () => {
200
+ const feed = new InMemoryFeedAdapter();
201
+ await feed.createFeedItem(commentInput({ body: 'A' }));
202
+ await feed.createFeedItem(commentInput({ body: 'B' }));
203
+ await feed.createFeedItem(commentInput({ body: 'C' }));
204
+
205
+ // First page
206
+ const page1 = await feed.listFeed({
207
+ object: 'account',
208
+ recordId: 'rec_123',
209
+ limit: 2,
210
+ });
211
+ expect(page1.items).toHaveLength(2);
212
+ expect(page1.hasMore).toBe(true);
213
+ expect(page1.nextCursor).toBeDefined();
214
+
215
+ // Second page
216
+ const page2 = await feed.listFeed({
217
+ object: 'account',
218
+ recordId: 'rec_123',
219
+ limit: 2,
220
+ cursor: page1.nextCursor,
221
+ });
222
+ expect(page2.items).toHaveLength(1);
223
+ expect(page2.hasMore).toBe(false);
224
+ });
225
+
226
+ // ==========================================
227
+ // Threading
228
+ // ==========================================
229
+
230
+ it('should support threaded replies and track reply count', async () => {
231
+ const feed = new InMemoryFeedAdapter();
232
+ const parent = await feed.createFeedItem(commentInput({ body: 'parent' }));
233
+
234
+ await feed.createFeedItem(commentInput({
235
+ body: 'reply 1',
236
+ parentId: parent.id,
237
+ }));
238
+
239
+ const updatedParent = await feed.getFeedItem(parent.id);
240
+ expect(updatedParent!.replyCount).toBe(1);
241
+ });
242
+
243
+ it('should decrement reply count on reply deletion', async () => {
244
+ const feed = new InMemoryFeedAdapter();
245
+ const parent = await feed.createFeedItem(commentInput({ body: 'parent' }));
246
+ const reply = await feed.createFeedItem(commentInput({
247
+ body: 'reply',
248
+ parentId: parent.id,
249
+ }));
250
+
251
+ await feed.deleteFeedItem(reply.id);
252
+
253
+ const updatedParent = await feed.getFeedItem(parent.id);
254
+ expect(updatedParent!.replyCount).toBe(0);
255
+ });
256
+
257
+ it('should throw when creating a reply with invalid parent', async () => {
258
+ const feed = new InMemoryFeedAdapter();
259
+ await expect(
260
+ feed.createFeedItem(commentInput({ parentId: 'nonexistent' })),
261
+ ).rejects.toThrow(/Parent feed item not found/);
262
+ });
263
+
264
+ // ==========================================
265
+ // Reactions
266
+ // ==========================================
267
+
268
+ it('should add a reaction to a feed item', async () => {
269
+ const feed = new InMemoryFeedAdapter();
270
+ const item = await feed.createFeedItem(commentInput());
271
+
272
+ const reactions = await feed.addReaction(item.id, '👍', 'user_1');
273
+ expect(reactions).toHaveLength(1);
274
+ expect(reactions[0].emoji).toBe('👍');
275
+ expect(reactions[0].userIds).toEqual(['user_1']);
276
+ expect(reactions[0].count).toBe(1);
277
+ });
278
+
279
+ it('should add multiple users to the same reaction', async () => {
280
+ const feed = new InMemoryFeedAdapter();
281
+ const item = await feed.createFeedItem(commentInput());
282
+
283
+ await feed.addReaction(item.id, '👍', 'user_1');
284
+ const reactions = await feed.addReaction(item.id, '👍', 'user_2');
285
+
286
+ expect(reactions[0].userIds).toEqual(['user_1', 'user_2']);
287
+ expect(reactions[0].count).toBe(2);
288
+ });
289
+
290
+ it('should support multiple emoji types on the same item', async () => {
291
+ const feed = new InMemoryFeedAdapter();
292
+ const item = await feed.createFeedItem(commentInput());
293
+
294
+ await feed.addReaction(item.id, '👍', 'user_1');
295
+ const reactions = await feed.addReaction(item.id, '❤️', 'user_1');
296
+
297
+ expect(reactions).toHaveLength(2);
298
+ });
299
+
300
+ it('should throw when adding duplicate reaction', async () => {
301
+ const feed = new InMemoryFeedAdapter();
302
+ const item = await feed.createFeedItem(commentInput());
303
+
304
+ await feed.addReaction(item.id, '👍', 'user_1');
305
+ await expect(feed.addReaction(item.id, '👍', 'user_1'))
306
+ .rejects.toThrow(/Reaction already exists/);
307
+ });
308
+
309
+ it('should remove a reaction', async () => {
310
+ const feed = new InMemoryFeedAdapter();
311
+ const item = await feed.createFeedItem(commentInput());
312
+
313
+ await feed.addReaction(item.id, '👍', 'user_1');
314
+ await feed.addReaction(item.id, '👍', 'user_2');
315
+
316
+ const reactions = await feed.removeReaction(item.id, '👍', 'user_1');
317
+ expect(reactions[0].userIds).toEqual(['user_2']);
318
+ expect(reactions[0].count).toBe(1);
319
+ });
320
+
321
+ it('should remove reaction entry when last user removes', async () => {
322
+ const feed = new InMemoryFeedAdapter();
323
+ const item = await feed.createFeedItem(commentInput());
324
+
325
+ await feed.addReaction(item.id, '👍', 'user_1');
326
+ const reactions = await feed.removeReaction(item.id, '👍', 'user_1');
327
+
328
+ expect(reactions).toHaveLength(0);
329
+ });
330
+
331
+ it('should throw when removing a non-existent reaction', async () => {
332
+ const feed = new InMemoryFeedAdapter();
333
+ const item = await feed.createFeedItem(commentInput());
334
+
335
+ await expect(feed.removeReaction(item.id, '👍', 'user_1'))
336
+ .rejects.toThrow(/Reaction not found/);
337
+ });
338
+
339
+ it('should throw when adding/removing reaction on non-existent item', async () => {
340
+ const feed = new InMemoryFeedAdapter();
341
+
342
+ await expect(feed.addReaction('nonexistent', '👍', 'user_1'))
343
+ .rejects.toThrow(/Feed item not found/);
344
+ await expect(feed.removeReaction('nonexistent', '👍', 'user_1'))
345
+ .rejects.toThrow(/Feed item not found/);
346
+ });
347
+
348
+ // ==========================================
349
+ // Subscriptions
350
+ // ==========================================
351
+
352
+ it('should start with zero subscriptions', () => {
353
+ const feed = new InMemoryFeedAdapter();
354
+ expect(feed.getSubscriptionCount()).toBe(0);
355
+ });
356
+
357
+ it('should subscribe to record notifications', async () => {
358
+ const feed = new InMemoryFeedAdapter();
359
+
360
+ const sub = await feed.subscribe({
361
+ object: 'account',
362
+ recordId: 'rec_123',
363
+ userId: 'user_1',
364
+ events: ['comment', 'field_change'],
365
+ channels: ['in_app', 'email'],
366
+ });
367
+
368
+ expect(sub.object).toBe('account');
369
+ expect(sub.recordId).toBe('rec_123');
370
+ expect(sub.userId).toBe('user_1');
371
+ expect(sub.events).toEqual(['comment', 'field_change']);
372
+ expect(sub.channels).toEqual(['in_app', 'email']);
373
+ expect(sub.active).toBe(true);
374
+ expect(sub.createdAt).toBeDefined();
375
+ expect(feed.getSubscriptionCount()).toBe(1);
376
+ });
377
+
378
+ it('should use default events and channels', async () => {
379
+ const feed = new InMemoryFeedAdapter();
380
+
381
+ const sub = await feed.subscribe({
382
+ object: 'account',
383
+ recordId: 'rec_123',
384
+ userId: 'user_1',
385
+ });
386
+
387
+ expect(sub.events).toEqual(['all']);
388
+ expect(sub.channels).toEqual(['in_app']);
389
+ });
390
+
391
+ it('should update existing subscription instead of creating duplicate', async () => {
392
+ const feed = new InMemoryFeedAdapter();
393
+
394
+ await feed.subscribe({
395
+ object: 'account',
396
+ recordId: 'rec_123',
397
+ userId: 'user_1',
398
+ events: ['comment'],
399
+ });
400
+
401
+ const updated = await feed.subscribe({
402
+ object: 'account',
403
+ recordId: 'rec_123',
404
+ userId: 'user_1',
405
+ events: ['comment', 'field_change'],
406
+ });
407
+
408
+ expect(feed.getSubscriptionCount()).toBe(1);
409
+ expect(updated.events).toEqual(['comment', 'field_change']);
410
+ });
411
+
412
+ it('should get a subscription by record and user', async () => {
413
+ const feed = new InMemoryFeedAdapter();
414
+
415
+ await feed.subscribe({
416
+ object: 'account',
417
+ recordId: 'rec_123',
418
+ userId: 'user_1',
419
+ });
420
+
421
+ const sub = await feed.getSubscription('account', 'rec_123', 'user_1');
422
+ expect(sub).not.toBeNull();
423
+ expect(sub!.userId).toBe('user_1');
424
+ });
425
+
426
+ it('should return null for non-existent subscription', async () => {
427
+ const feed = new InMemoryFeedAdapter();
428
+ const sub = await feed.getSubscription('account', 'rec_123', 'user_1');
429
+ expect(sub).toBeNull();
430
+ });
431
+
432
+ it('should unsubscribe from record notifications', async () => {
433
+ const feed = new InMemoryFeedAdapter();
434
+
435
+ await feed.subscribe({
436
+ object: 'account',
437
+ recordId: 'rec_123',
438
+ userId: 'user_1',
439
+ });
440
+
441
+ const result = await feed.unsubscribe('account', 'rec_123', 'user_1');
442
+ expect(result).toBe(true);
443
+ expect(feed.getSubscriptionCount()).toBe(0);
444
+ });
445
+
446
+ it('should return false when unsubscribing without existing subscription', async () => {
447
+ const feed = new InMemoryFeedAdapter();
448
+ const result = await feed.unsubscribe('account', 'rec_123', 'user_1');
449
+ expect(result).toBe(false);
450
+ });
451
+
452
+ // ==========================================
453
+ // Edge cases
454
+ // ==========================================
455
+
456
+ it('should create feed items with mentions', async () => {
457
+ const feed = new InMemoryFeedAdapter();
458
+
459
+ const item = await feed.createFeedItem(commentInput({
460
+ body: 'Hello @jane',
461
+ mentions: [{ type: 'user', id: 'user_2', name: 'Jane', offset: 6, length: 5 }],
462
+ }));
463
+
464
+ expect(item.mentions).toHaveLength(1);
465
+ expect(item.mentions![0].name).toBe('Jane');
466
+ });
467
+
468
+ it('should create feed items with field changes', async () => {
469
+ const feed = new InMemoryFeedAdapter();
470
+
471
+ const item = await feed.createFeedItem({
472
+ object: 'account',
473
+ recordId: 'rec_123',
474
+ type: 'field_change',
475
+ actor: { type: 'user', id: 'user_1' },
476
+ changes: [
477
+ { field: 'status', oldDisplayValue: 'New', newDisplayValue: 'Active' },
478
+ ],
479
+ });
480
+
481
+ expect(item.type).toBe('field_change');
482
+ expect(item.changes).toHaveLength(1);
483
+ expect(item.changes![0].field).toBe('status');
484
+ });
485
+
486
+ it('should return unique feed item IDs', async () => {
487
+ const feed = new InMemoryFeedAdapter();
488
+
489
+ const item1 = await feed.createFeedItem(commentInput({ body: 'A' }));
490
+ const item2 = await feed.createFeedItem(commentInput({ body: 'B' }));
491
+ const item3 = await feed.createFeedItem(commentInput({ body: 'C' }));
492
+
493
+ expect(item1.id).not.toBe(item2.id);
494
+ expect(item2.id).not.toBe(item3.id);
495
+ });
496
+
497
+ it('should persist reaction state in the feed item', async () => {
498
+ const feed = new InMemoryFeedAdapter();
499
+ const item = await feed.createFeedItem(commentInput());
500
+
501
+ await feed.addReaction(item.id, '👍', 'user_1');
502
+
503
+ const fetched = await feed.getFeedItem(item.id);
504
+ expect(fetched!.reactions).toHaveLength(1);
505
+ expect(fetched!.reactions![0].emoji).toBe('👍');
506
+ });
507
+ });