Skip to content

Commit 42d5a89

Browse files
authored
Merge pull request #732 from objectstack-ai/copilot/enhance-feed-chatter-protocol
2 parents 6fd11b9 + ef4a444 commit 42d5a89

8 files changed

Lines changed: 870 additions & 5 deletions

File tree

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,9 @@ The following renames are planned for packages that implement core service contr
146146
147147
### Deliverables — All Completed
148148

149-
- [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document, Storage Name Mapping (`tableName`/`columnName`)
149+
- [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document, Storage Name Mapping (`tableName`/`columnName`), Feed & Activity Timeline (FeedItem, Comment, Mention, Reaction, FieldChange), Record Subscription (notification channels)
150150
- [x] **Driver Specifications** — Memory, PostgreSQL, MongoDB driver schemas + SQL/NoSQL abstractions
151-
- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page (16 types), Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n, Content Elements
151+
- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page (16 types), Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n, Content Elements, Enhanced Activity Timeline (`RecordActivityProps` unified timeline, `RecordChatterProps` sidebar/drawer)
152152
- [x] **System Protocol** — Manifest, Auth Config, Cache, Logging, Metrics, Tracing, Audit, Encryption, Masking, Migration, Tenant, Translation, Search Engine, HTTP Server, Worker, Job, Object Storage, Notification, Message Queue, Registry Config, Collaboration, Compliance, Change Management, Disaster Recovery, License, Security Context, Core Services, SystemObjectName/SystemFieldName Constants, StorageNameMapping Utilities
153153
- [x] **Automation Protocol** — Flow (autolaunched/screen/schedule), Workflow, State Machine, Trigger Registry, Approval, ETL, Sync, Webhook
154154
- [x] **AI Protocol** — Agent, Agent Action, Conversation, Cost, MCP, Model Registry, NLQ, Orchestration, Predictive, RAG Pipeline, Runtime Ops, Feedback Loop, DevOps Agent, Plugin Development
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
FeedItemType,
4+
MentionSchema,
5+
FieldChangeEntrySchema,
6+
ReactionSchema,
7+
FeedActorSchema,
8+
FeedVisibility,
9+
FeedItemSchema,
10+
FeedFilterMode,
11+
type FeedItem,
12+
type Mention,
13+
type FieldChangeEntry,
14+
type Reaction,
15+
type FeedActor,
16+
} from './feed.zod';
17+
18+
describe('FeedItemType', () => {
19+
it('should accept all valid feed item types', () => {
20+
const types = [
21+
'comment', 'field_change', 'task', 'event', 'email', 'call',
22+
'note', 'file', 'record_create', 'record_delete', 'approval',
23+
'sharing', 'system',
24+
];
25+
types.forEach(type => {
26+
expect(() => FeedItemType.parse(type)).not.toThrow();
27+
});
28+
});
29+
30+
it('should reject invalid types', () => {
31+
expect(() => FeedItemType.parse('unknown')).toThrow();
32+
expect(() => FeedItemType.parse('')).toThrow();
33+
});
34+
});
35+
36+
describe('MentionSchema', () => {
37+
it('should accept a valid user mention', () => {
38+
const mention: Mention = {
39+
type: 'user',
40+
id: 'user_123',
41+
name: 'Jane Doe',
42+
offset: 17,
43+
length: 9,
44+
};
45+
const result = MentionSchema.parse(mention);
46+
expect(result.type).toBe('user');
47+
expect(result.id).toBe('user_123');
48+
expect(result.name).toBe('Jane Doe');
49+
expect(result.offset).toBe(17);
50+
expect(result.length).toBe(9);
51+
});
52+
53+
it('should accept team and record mention types', () => {
54+
expect(() => MentionSchema.parse({ type: 'team', id: 'team_1', name: 'Engineering', offset: 0, length: 12 })).not.toThrow();
55+
expect(() => MentionSchema.parse({ type: 'record', id: 'rec_1', name: 'Acme Corp', offset: 5, length: 9 })).not.toThrow();
56+
});
57+
58+
it('should reject invalid mention type', () => {
59+
expect(() => MentionSchema.parse({ type: 'group', id: '1', name: 'X', offset: 0, length: 1 })).toThrow();
60+
});
61+
62+
it('should reject negative offset', () => {
63+
expect(() => MentionSchema.parse({ type: 'user', id: '1', name: 'X', offset: -1, length: 1 })).toThrow();
64+
});
65+
66+
it('should reject zero length', () => {
67+
expect(() => MentionSchema.parse({ type: 'user', id: '1', name: 'X', offset: 0, length: 0 })).toThrow();
68+
});
69+
70+
it('should reject missing required fields', () => {
71+
expect(() => MentionSchema.parse({})).toThrow();
72+
expect(() => MentionSchema.parse({ type: 'user' })).toThrow();
73+
});
74+
});
75+
76+
describe('FieldChangeEntrySchema', () => {
77+
it('should accept minimal field change', () => {
78+
const result = FieldChangeEntrySchema.parse({ field: 'status' });
79+
expect(result.field).toBe('status');
80+
expect(result.oldValue).toBeUndefined();
81+
expect(result.newValue).toBeUndefined();
82+
});
83+
84+
it('should accept full field change with display values', () => {
85+
const change: FieldChangeEntry = {
86+
field: 'region',
87+
fieldLabel: 'Region',
88+
oldValue: null,
89+
newValue: 'asia_pacific',
90+
oldDisplayValue: '',
91+
newDisplayValue: 'Asia-Pacific',
92+
};
93+
const result = FieldChangeEntrySchema.parse(change);
94+
expect(result.fieldLabel).toBe('Region');
95+
expect(result.newDisplayValue).toBe('Asia-Pacific');
96+
});
97+
98+
it('should reject without field name', () => {
99+
expect(() => FieldChangeEntrySchema.parse({})).toThrow();
100+
});
101+
});
102+
103+
describe('ReactionSchema', () => {
104+
it('should accept valid reaction', () => {
105+
const reaction: Reaction = {
106+
emoji: '👍',
107+
userIds: ['user_1', 'user_2'],
108+
count: 2,
109+
};
110+
const result = ReactionSchema.parse(reaction);
111+
expect(result.emoji).toBe('👍');
112+
expect(result.userIds).toHaveLength(2);
113+
expect(result.count).toBe(2);
114+
});
115+
116+
it('should reject count less than 1', () => {
117+
expect(() => ReactionSchema.parse({ emoji: '👍', userIds: [], count: 0 })).toThrow();
118+
});
119+
120+
it('should reject missing required fields', () => {
121+
expect(() => ReactionSchema.parse({})).toThrow();
122+
expect(() => ReactionSchema.parse({ emoji: '👍' })).toThrow();
123+
});
124+
});
125+
126+
describe('FeedActorSchema', () => {
127+
it('should accept user actor', () => {
128+
const actor: FeedActor = {
129+
type: 'user',
130+
id: 'user_456',
131+
name: 'John Smith',
132+
};
133+
const result = FeedActorSchema.parse(actor);
134+
expect(result.type).toBe('user');
135+
expect(result.name).toBe('John Smith');
136+
});
137+
138+
it('should accept system actor with source', () => {
139+
const result = FeedActorSchema.parse({
140+
type: 'system',
141+
id: 'sys_001',
142+
source: 'Omni',
143+
});
144+
expect(result.type).toBe('system');
145+
expect(result.source).toBe('Omni');
146+
});
147+
148+
it('should accept all actor types', () => {
149+
const types = ['user', 'system', 'service', 'automation'];
150+
types.forEach(type => {
151+
expect(() => FeedActorSchema.parse({ type, id: 'test_1' })).not.toThrow();
152+
});
153+
});
154+
155+
it('should accept actor with avatarUrl', () => {
156+
const result = FeedActorSchema.parse({
157+
type: 'user',
158+
id: 'user_1',
159+
avatarUrl: 'https://example.com/avatar.png',
160+
});
161+
expect(result.avatarUrl).toBe('https://example.com/avatar.png');
162+
});
163+
164+
it('should reject invalid actor type', () => {
165+
expect(() => FeedActorSchema.parse({ type: 'bot', id: '1' })).toThrow();
166+
});
167+
});
168+
169+
describe('FeedVisibility', () => {
170+
it('should accept valid visibility levels', () => {
171+
['public', 'internal', 'private'].forEach(v => {
172+
expect(() => FeedVisibility.parse(v)).not.toThrow();
173+
});
174+
});
175+
176+
it('should reject invalid visibility', () => {
177+
expect(() => FeedVisibility.parse('secret')).toThrow();
178+
});
179+
});
180+
181+
describe('FeedFilterMode', () => {
182+
it('should accept valid filter modes', () => {
183+
['all', 'comments_only', 'changes_only', 'tasks_only'].forEach(mode => {
184+
expect(() => FeedFilterMode.parse(mode)).not.toThrow();
185+
});
186+
});
187+
188+
it('should reject invalid filter mode', () => {
189+
expect(() => FeedFilterMode.parse('custom')).toThrow();
190+
});
191+
});
192+
193+
describe('FeedItemSchema', () => {
194+
const minimalComment: FeedItem = {
195+
id: 'feed_001',
196+
type: 'comment',
197+
object: 'account',
198+
recordId: 'rec_123',
199+
actor: { type: 'user', id: 'user_456', name: 'John Smith' },
200+
body: 'Great progress on this deal!',
201+
createdAt: '2026-01-15T10:30:00Z',
202+
};
203+
204+
it('should accept a minimal comment feed item', () => {
205+
const result = FeedItemSchema.parse(minimalComment);
206+
expect(result.id).toBe('feed_001');
207+
expect(result.type).toBe('comment');
208+
expect(result.object).toBe('account');
209+
expect(result.recordId).toBe('rec_123');
210+
expect(result.body).toBe('Great progress on this deal!');
211+
expect(result.replyCount).toBe(0);
212+
expect(result.visibility).toBe('public');
213+
expect(result.isEdited).toBe(false);
214+
});
215+
216+
it('should accept a comment with mentions', () => {
217+
const result = FeedItemSchema.parse({
218+
...minimalComment,
219+
mentions: [
220+
{ type: 'user', id: 'user_789', name: 'Jane Doe', offset: 17, length: 9 },
221+
],
222+
});
223+
expect(result.mentions).toHaveLength(1);
224+
expect(result.mentions![0].name).toBe('Jane Doe');
225+
});
226+
227+
it('should accept a field_change feed item with changes', () => {
228+
const fieldChange: FeedItem = {
229+
id: 'feed_002',
230+
type: 'field_change',
231+
object: 'account',
232+
recordId: 'rec_123',
233+
actor: { type: 'user', id: 'user_456', name: 'John Smith' },
234+
changes: [
235+
{ field: 'status', oldDisplayValue: 'New', newDisplayValue: 'Active' },
236+
{ field: 'region', oldDisplayValue: '', newDisplayValue: 'Asia-Pacific' },
237+
],
238+
createdAt: '2026-01-15T10:25:00Z',
239+
};
240+
const result = FeedItemSchema.parse(fieldChange);
241+
expect(result.type).toBe('field_change');
242+
expect(result.changes).toHaveLength(2);
243+
expect(result.changes![0].field).toBe('status');
244+
});
245+
246+
it('should accept a threaded reply', () => {
247+
const result = FeedItemSchema.parse({
248+
...minimalComment,
249+
id: 'feed_003',
250+
parentId: 'feed_001',
251+
});
252+
expect(result.parentId).toBe('feed_001');
253+
});
254+
255+
it('should accept edited comment', () => {
256+
const result = FeedItemSchema.parse({
257+
...minimalComment,
258+
isEdited: true,
259+
editedAt: '2026-01-15T11:00:00Z',
260+
});
261+
expect(result.isEdited).toBe(true);
262+
expect(result.editedAt).toBe('2026-01-15T11:00:00Z');
263+
});
264+
265+
it('should accept reactions on a feed item', () => {
266+
const result = FeedItemSchema.parse({
267+
...minimalComment,
268+
reactions: [
269+
{ emoji: '👍', userIds: ['user_789'], count: 1 },
270+
{ emoji: '❤️', userIds: ['user_101', 'user_102'], count: 2 },
271+
],
272+
});
273+
expect(result.reactions).toHaveLength(2);
274+
});
275+
276+
it('should accept internal visibility', () => {
277+
const result = FeedItemSchema.parse({
278+
...minimalComment,
279+
visibility: 'internal',
280+
});
281+
expect(result.visibility).toBe('internal');
282+
});
283+
284+
it('should accept system actor with source', () => {
285+
const result = FeedItemSchema.parse({
286+
...minimalComment,
287+
actor: { type: 'system', id: 'sys_001', source: 'API' },
288+
});
289+
expect(result.actor.type).toBe('system');
290+
expect(result.actor.source).toBe('API');
291+
});
292+
293+
it('should accept all feed item types', () => {
294+
const types = [
295+
'comment', 'field_change', 'task', 'event', 'email', 'call',
296+
'note', 'file', 'record_create', 'record_delete', 'approval',
297+
'sharing', 'system',
298+
];
299+
types.forEach(type => {
300+
expect(() => FeedItemSchema.parse({
301+
id: `feed_${type}`,
302+
type,
303+
object: 'account',
304+
recordId: 'rec_1',
305+
actor: { type: 'user', id: 'user_1' },
306+
createdAt: '2026-01-15T10:00:00Z',
307+
})).not.toThrow();
308+
});
309+
});
310+
311+
it('should apply default values', () => {
312+
const result = FeedItemSchema.parse({
313+
id: 'feed_def',
314+
type: 'note',
315+
object: 'lead',
316+
recordId: 'rec_1',
317+
actor: { type: 'user', id: 'user_1' },
318+
createdAt: '2026-01-15T10:00:00Z',
319+
});
320+
expect(result.replyCount).toBe(0);
321+
expect(result.visibility).toBe('public');
322+
expect(result.isEdited).toBe(false);
323+
});
324+
325+
it('should reject without required fields', () => {
326+
expect(() => FeedItemSchema.parse({})).toThrow();
327+
expect(() => FeedItemSchema.parse({ id: 'x' })).toThrow();
328+
expect(() => FeedItemSchema.parse({ id: 'x', type: 'comment' })).toThrow();
329+
});
330+
331+
it('should reject invalid datetime format', () => {
332+
expect(() => FeedItemSchema.parse({
333+
...minimalComment,
334+
createdAt: 'not-a-date',
335+
})).toThrow();
336+
});
337+
});

0 commit comments

Comments
 (0)