@auxiora/connector-social 1.0.0
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/LICENSE +191 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/instagram.d.ts +2 -0
- package/dist/instagram.d.ts.map +1 -0
- package/dist/instagram.js +205 -0
- package/dist/instagram.js.map +1 -0
- package/dist/linkedin.d.ts +2 -0
- package/dist/linkedin.d.ts.map +1 -0
- package/dist/linkedin.js +239 -0
- package/dist/linkedin.js.map +1 -0
- package/dist/reddit.d.ts +2 -0
- package/dist/reddit.d.ts.map +1 -0
- package/dist/reddit.js +259 -0
- package/dist/reddit.js.map +1 -0
- package/dist/twitter.d.ts +2 -0
- package/dist/twitter.d.ts.map +1 -0
- package/dist/twitter.js +245 -0
- package/dist/twitter.js.map +1 -0
- package/package.json +25 -0
- package/src/index.ts +4 -0
- package/src/instagram.ts +212 -0
- package/src/linkedin.ts +243 -0
- package/src/reddit.ts +268 -0
- package/src/twitter.ts +253 -0
- package/tests/instagram.test.ts +108 -0
- package/tests/linkedin.test.ts +104 -0
- package/tests/reddit.test.ts +127 -0
- package/tests/twitter.test.ts +120 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/twitter.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { defineConnector } from '@auxiora/connectors';
|
|
2
|
+
import type { TriggerEvent } from '@auxiora/connectors';
|
|
3
|
+
|
|
4
|
+
async function twitterFetch(token: string, path: string, options?: { method?: string; body?: unknown }) {
|
|
5
|
+
const res = await fetch(`https://api.twitter.com/2${path}`, {
|
|
6
|
+
method: options?.method ?? 'GET',
|
|
7
|
+
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
8
|
+
body: options?.body ? JSON.stringify(options.body) : undefined,
|
|
9
|
+
});
|
|
10
|
+
if (!res.ok) throw new Error(`Twitter API error: ${res.status} ${await res.text().catch(() => res.statusText)}`);
|
|
11
|
+
return res.json() as Promise<Record<string, unknown>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function getMyUserId(token: string): Promise<string> {
|
|
15
|
+
const res = await twitterFetch(token, '/users/me');
|
|
16
|
+
const data = res.data as Record<string, unknown>;
|
|
17
|
+
return data.id as string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const twitterConnector = defineConnector({
|
|
21
|
+
id: 'twitter',
|
|
22
|
+
name: 'Twitter / X',
|
|
23
|
+
description: 'Integration with Twitter/X for tweets, mentions, and direct messages',
|
|
24
|
+
version: '1.0.0',
|
|
25
|
+
category: 'social',
|
|
26
|
+
icon: 'twitter',
|
|
27
|
+
|
|
28
|
+
auth: {
|
|
29
|
+
type: 'oauth2',
|
|
30
|
+
oauth2: {
|
|
31
|
+
authUrl: 'https://twitter.com/i/oauth2/authorize',
|
|
32
|
+
tokenUrl: 'https://api.twitter.com/2/oauth2/token',
|
|
33
|
+
scopes: ['tweet.read', 'tweet.write', 'users.read', 'dm.read', 'dm.write', 'offline.access'],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
actions: [
|
|
38
|
+
{
|
|
39
|
+
id: 'timeline-read',
|
|
40
|
+
name: 'Read Timeline',
|
|
41
|
+
description: 'Read the authenticated user timeline',
|
|
42
|
+
trustMinimum: 1,
|
|
43
|
+
trustDomain: 'messaging',
|
|
44
|
+
reversible: false,
|
|
45
|
+
sideEffects: false,
|
|
46
|
+
params: {},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'mentions-list',
|
|
50
|
+
name: 'List Mentions',
|
|
51
|
+
description: 'List recent mentions of the authenticated user',
|
|
52
|
+
trustMinimum: 1,
|
|
53
|
+
trustDomain: 'messaging',
|
|
54
|
+
reversible: false,
|
|
55
|
+
sideEffects: false,
|
|
56
|
+
params: {},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'post-tweet',
|
|
60
|
+
name: 'Post Tweet',
|
|
61
|
+
description: 'Post a new tweet',
|
|
62
|
+
trustMinimum: 3,
|
|
63
|
+
trustDomain: 'messaging',
|
|
64
|
+
reversible: false,
|
|
65
|
+
sideEffects: true,
|
|
66
|
+
params: {
|
|
67
|
+
text: { type: 'string', description: 'Tweet text', required: true },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'reply-tweet',
|
|
72
|
+
name: 'Reply to Tweet',
|
|
73
|
+
description: 'Reply to an existing tweet',
|
|
74
|
+
trustMinimum: 3,
|
|
75
|
+
trustDomain: 'messaging',
|
|
76
|
+
reversible: false,
|
|
77
|
+
sideEffects: true,
|
|
78
|
+
params: {
|
|
79
|
+
tweetId: { type: 'string', description: 'Tweet ID to reply to', required: true },
|
|
80
|
+
text: { type: 'string', description: 'Reply text', required: true },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'delete-tweet',
|
|
85
|
+
name: 'Delete Tweet',
|
|
86
|
+
description: 'Delete an existing tweet',
|
|
87
|
+
trustMinimum: 3,
|
|
88
|
+
trustDomain: 'messaging',
|
|
89
|
+
reversible: false,
|
|
90
|
+
sideEffects: true,
|
|
91
|
+
params: {
|
|
92
|
+
tweetId: { type: 'string', description: 'Tweet ID to delete', required: true },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'search-tweets',
|
|
97
|
+
name: 'Search Tweets',
|
|
98
|
+
description: 'Search for tweets matching a query',
|
|
99
|
+
trustMinimum: 1,
|
|
100
|
+
trustDomain: 'messaging',
|
|
101
|
+
reversible: false,
|
|
102
|
+
sideEffects: false,
|
|
103
|
+
params: {
|
|
104
|
+
query: { type: 'string', description: 'Search query', required: true },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'dm-list',
|
|
109
|
+
name: 'List Direct Messages',
|
|
110
|
+
description: 'List recent direct messages',
|
|
111
|
+
trustMinimum: 1,
|
|
112
|
+
trustDomain: 'messaging',
|
|
113
|
+
reversible: false,
|
|
114
|
+
sideEffects: false,
|
|
115
|
+
params: {},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'dm-send',
|
|
119
|
+
name: 'Send Direct Message',
|
|
120
|
+
description: 'Send a direct message to a user',
|
|
121
|
+
trustMinimum: 3,
|
|
122
|
+
trustDomain: 'messaging',
|
|
123
|
+
reversible: false,
|
|
124
|
+
sideEffects: true,
|
|
125
|
+
params: {
|
|
126
|
+
recipientId: { type: 'string', description: 'Recipient user ID', required: true },
|
|
127
|
+
text: { type: 'string', description: 'Message text', required: true },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
|
|
132
|
+
triggers: [
|
|
133
|
+
{
|
|
134
|
+
id: 'new-mention',
|
|
135
|
+
name: 'New Mention',
|
|
136
|
+
description: 'Triggered when the user is mentioned in a tweet',
|
|
137
|
+
type: 'poll',
|
|
138
|
+
pollIntervalMs: 60_000,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'new-dm',
|
|
142
|
+
name: 'New Direct Message',
|
|
143
|
+
description: 'Triggered when a new direct message is received',
|
|
144
|
+
type: 'poll',
|
|
145
|
+
pollIntervalMs: 120_000,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
|
|
149
|
+
entities: [
|
|
150
|
+
{
|
|
151
|
+
id: 'tweet',
|
|
152
|
+
name: 'Tweet',
|
|
153
|
+
description: 'A tweet on Twitter/X',
|
|
154
|
+
fields: { id: 'string', text: 'string', authorId: 'string', createdAt: 'string', likeCount: 'number', retweetCount: 'number' },
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'direct-message',
|
|
158
|
+
name: 'Direct Message',
|
|
159
|
+
description: 'A direct message on Twitter/X',
|
|
160
|
+
fields: { id: 'string', text: 'string', senderId: 'string', createdAt: 'string' },
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
|
|
164
|
+
async executeAction(actionId: string, params: Record<string, unknown>, token: string): Promise<unknown> {
|
|
165
|
+
switch (actionId) {
|
|
166
|
+
case 'timeline-read': {
|
|
167
|
+
const userId = await getMyUserId(token);
|
|
168
|
+
const res = await twitterFetch(token, `/users/${userId}/timelines/reverse_chronological`);
|
|
169
|
+
return { tweets: res.data };
|
|
170
|
+
}
|
|
171
|
+
case 'mentions-list': {
|
|
172
|
+
const userId = await getMyUserId(token);
|
|
173
|
+
const res = await twitterFetch(token, `/users/${userId}/mentions`);
|
|
174
|
+
return { mentions: res.data };
|
|
175
|
+
}
|
|
176
|
+
case 'post-tweet': {
|
|
177
|
+
const res = await twitterFetch(token, '/tweets', {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
body: { text: params.text },
|
|
180
|
+
});
|
|
181
|
+
const data = res.data as Record<string, unknown>;
|
|
182
|
+
return { tweetId: data.id, status: 'posted' };
|
|
183
|
+
}
|
|
184
|
+
case 'reply-tweet': {
|
|
185
|
+
const res = await twitterFetch(token, '/tweets', {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
body: { text: params.text, reply: { in_reply_to_tweet_id: params.tweetId } },
|
|
188
|
+
});
|
|
189
|
+
const data = res.data as Record<string, unknown>;
|
|
190
|
+
return { tweetId: data.id, status: 'replied' };
|
|
191
|
+
}
|
|
192
|
+
case 'delete-tweet': {
|
|
193
|
+
await twitterFetch(token, `/tweets/${params.tweetId as string}`, { method: 'DELETE' });
|
|
194
|
+
return { tweetId: params.tweetId, status: 'deleted' };
|
|
195
|
+
}
|
|
196
|
+
case 'search-tweets': {
|
|
197
|
+
const query = encodeURIComponent(params.query as string);
|
|
198
|
+
const res = await twitterFetch(token, `/tweets/search/recent?query=${query}`);
|
|
199
|
+
return { tweets: res.data };
|
|
200
|
+
}
|
|
201
|
+
case 'dm-list': {
|
|
202
|
+
const res = await twitterFetch(token, '/dm_events');
|
|
203
|
+
return { messages: res.data };
|
|
204
|
+
}
|
|
205
|
+
case 'dm-send': {
|
|
206
|
+
const res = await twitterFetch(token, `/dm_conversations/with/${params.recipientId as string}/messages`, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
body: { text: params.text },
|
|
209
|
+
});
|
|
210
|
+
const data = res.data as Record<string, unknown>;
|
|
211
|
+
return { messageId: data.dm_event_id ?? data.id, status: 'sent' };
|
|
212
|
+
}
|
|
213
|
+
default:
|
|
214
|
+
throw new Error(`Unknown action: ${actionId}`);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async pollTrigger(triggerId: string, token: string, lastPollAt?: number): Promise<TriggerEvent[]> {
|
|
219
|
+
switch (triggerId) {
|
|
220
|
+
case 'new-mention': {
|
|
221
|
+
const userId = await getMyUserId(token);
|
|
222
|
+
const startTime = lastPollAt ? new Date(lastPollAt).toISOString() : undefined;
|
|
223
|
+
const query = startTime ? `?start_time=${startTime}` : '';
|
|
224
|
+
const res = await twitterFetch(token, `/users/${userId}/mentions${query}`);
|
|
225
|
+
const mentions = (res.data ?? []) as Array<Record<string, unknown>>;
|
|
226
|
+
return mentions.map((m) => ({
|
|
227
|
+
triggerId: 'new-mention',
|
|
228
|
+
connectorId: 'twitter',
|
|
229
|
+
data: m,
|
|
230
|
+
timestamp: m.created_at ? new Date(m.created_at as string).getTime() : Date.now(),
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
case 'new-dm': {
|
|
234
|
+
const res = await twitterFetch(token, '/dm_events?event_types=MessageCreate');
|
|
235
|
+
const events = (res.data ?? []) as Array<Record<string, unknown>>;
|
|
236
|
+
const cutoff = lastPollAt ?? 0;
|
|
237
|
+
return events
|
|
238
|
+
.filter((e) => {
|
|
239
|
+
const ts = e.created_at ? new Date(e.created_at as string).getTime() : 0;
|
|
240
|
+
return ts > cutoff;
|
|
241
|
+
})
|
|
242
|
+
.map((e) => ({
|
|
243
|
+
triggerId: 'new-dm',
|
|
244
|
+
connectorId: 'twitter',
|
|
245
|
+
data: e,
|
|
246
|
+
timestamp: e.created_at ? new Date(e.created_at as string).getTime() : Date.now(),
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
default:
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { instagramConnector } from '../src/instagram.js';
|
|
3
|
+
|
|
4
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
fetchMock = vi.fn();
|
|
7
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function mockResponse(body: unknown) {
|
|
14
|
+
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('Instagram Connector', () => {
|
|
18
|
+
it('should have correct metadata', () => {
|
|
19
|
+
expect(instagramConnector.id).toBe('instagram');
|
|
20
|
+
expect(instagramConnector.name).toBe('Instagram');
|
|
21
|
+
expect(instagramConnector.version).toBe('1.0.0');
|
|
22
|
+
expect(instagramConnector.category).toBe('social');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should use OAuth2 authentication', () => {
|
|
26
|
+
expect(instagramConnector.auth.type).toBe('oauth2');
|
|
27
|
+
expect(instagramConnector.auth.oauth2).toBeDefined();
|
|
28
|
+
expect(instagramConnector.auth.oauth2!.authUrl).toBe('https://api.instagram.com/oauth/authorize');
|
|
29
|
+
expect(instagramConnector.auth.oauth2!.tokenUrl).toBe('https://api.instagram.com/oauth/access_token');
|
|
30
|
+
expect(instagramConnector.auth.oauth2!.scopes).toContain('instagram_basic');
|
|
31
|
+
expect(instagramConnector.auth.oauth2!.scopes.length).toBe(4);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should define all 6 actions', () => {
|
|
35
|
+
expect(instagramConnector.actions).toHaveLength(6);
|
|
36
|
+
const actionIds = instagramConnector.actions.map((a) => a.id);
|
|
37
|
+
expect(actionIds).toContain('feed-read');
|
|
38
|
+
expect(actionIds).toContain('stories-read');
|
|
39
|
+
expect(actionIds).toContain('dm-list');
|
|
40
|
+
expect(actionIds).toContain('dm-send');
|
|
41
|
+
expect(actionIds).toContain('post-schedule');
|
|
42
|
+
expect(actionIds).toContain('profile-get');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should have correct trust and side effect settings', () => {
|
|
46
|
+
const readAction = instagramConnector.actions.find((a) => a.id === 'feed-read');
|
|
47
|
+
expect(readAction!.trustMinimum).toBe(1);
|
|
48
|
+
expect(readAction!.sideEffects).toBe(false);
|
|
49
|
+
|
|
50
|
+
const dmAction = instagramConnector.actions.find((a) => a.id === 'dm-send');
|
|
51
|
+
expect(dmAction!.trustMinimum).toBe(3);
|
|
52
|
+
expect(dmAction!.sideEffects).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should execute feed-read action', async () => {
|
|
56
|
+
// GET /me/media?fields=... -> { data: [] }
|
|
57
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ data: [] }));
|
|
58
|
+
const result = await instagramConnector.executeAction('feed-read', {}, 'token');
|
|
59
|
+
expect(result).toEqual({ posts: [] });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should execute dm-send action', async () => {
|
|
63
|
+
// dm-send returns unavailable status (Instagram Messaging API requires approved access)
|
|
64
|
+
const result = await instagramConnector.executeAction('dm-send', { recipientId: 'u1', text: 'Hi' }, 'token') as any;
|
|
65
|
+
expect(result.status).toBe('unavailable');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should execute post-schedule action', async () => {
|
|
69
|
+
// POST /me/media -> { id: 'container1' } (create media container)
|
|
70
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ id: 'container1' }));
|
|
71
|
+
// POST /me/media_publish -> { id: 'post1' } (publish)
|
|
72
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ id: 'post1' }));
|
|
73
|
+
const result = await instagramConnector.executeAction('post-schedule', { caption: 'Hello', mediaUrl: 'https://example.com/img.jpg' }, 'token') as any;
|
|
74
|
+
expect(result.status).toBe('published');
|
|
75
|
+
expect(result.postId).toBe('post1');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should execute profile-get action', async () => {
|
|
79
|
+
// GET /me?fields=... -> profile object
|
|
80
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ id: 'me123', username: 'testuser', name: 'Test' }));
|
|
81
|
+
const result = await instagramConnector.executeAction('profile-get', {}, 'token') as any;
|
|
82
|
+
expect(result.id).toBe('me123');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should throw for unknown action', async () => {
|
|
86
|
+
await expect(instagramConnector.executeAction('unknown', {}, 'token')).rejects.toThrow('Unknown action');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should return empty events from pollTrigger', async () => {
|
|
90
|
+
// new-dm trigger returns [] directly without fetching
|
|
91
|
+
const events = await instagramConnector.pollTrigger!('new-dm', 'token');
|
|
92
|
+
expect(events).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should define triggers', () => {
|
|
96
|
+
expect(instagramConnector.triggers).toHaveLength(2);
|
|
97
|
+
const triggerIds = instagramConnector.triggers.map((t) => t.id);
|
|
98
|
+
expect(triggerIds).toContain('new-dm');
|
|
99
|
+
expect(triggerIds).toContain('new-comment');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should define entities', () => {
|
|
103
|
+
expect(instagramConnector.entities).toHaveLength(2);
|
|
104
|
+
const entityIds = instagramConnector.entities.map((e) => e.id);
|
|
105
|
+
expect(entityIds).toContain('post');
|
|
106
|
+
expect(entityIds).toContain('story');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { linkedinConnector } from '../src/linkedin.js';
|
|
3
|
+
|
|
4
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
fetchMock = vi.fn();
|
|
7
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function mockResponse(body: unknown) {
|
|
14
|
+
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('LinkedIn Connector', () => {
|
|
18
|
+
it('should have correct metadata', () => {
|
|
19
|
+
expect(linkedinConnector.id).toBe('linkedin');
|
|
20
|
+
expect(linkedinConnector.name).toBe('LinkedIn');
|
|
21
|
+
expect(linkedinConnector.version).toBe('1.0.0');
|
|
22
|
+
expect(linkedinConnector.category).toBe('social');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should use OAuth2 authentication', () => {
|
|
26
|
+
expect(linkedinConnector.auth.type).toBe('oauth2');
|
|
27
|
+
expect(linkedinConnector.auth.oauth2).toBeDefined();
|
|
28
|
+
expect(linkedinConnector.auth.oauth2!.authUrl).toBe('https://www.linkedin.com/oauth/v2/authorization');
|
|
29
|
+
expect(linkedinConnector.auth.oauth2!.tokenUrl).toBe('https://www.linkedin.com/oauth/v2/accessToken');
|
|
30
|
+
expect(linkedinConnector.auth.oauth2!.scopes).toContain('w_member_social');
|
|
31
|
+
expect(linkedinConnector.auth.oauth2!.scopes.length).toBe(4);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should define all 7 actions', () => {
|
|
35
|
+
expect(linkedinConnector.actions).toHaveLength(7);
|
|
36
|
+
const actionIds = linkedinConnector.actions.map((a) => a.id);
|
|
37
|
+
expect(actionIds).toContain('feed-read');
|
|
38
|
+
expect(actionIds).toContain('post-update');
|
|
39
|
+
expect(actionIds).toContain('post-article');
|
|
40
|
+
expect(actionIds).toContain('connections-list');
|
|
41
|
+
expect(actionIds).toContain('messages-list');
|
|
42
|
+
expect(actionIds).toContain('message-send');
|
|
43
|
+
expect(actionIds).toContain('profile-get');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should use correct trust domains', () => {
|
|
47
|
+
const messageAction = linkedinConnector.actions.find((a) => a.id === 'message-send');
|
|
48
|
+
expect(messageAction!.trustDomain).toBe('messaging');
|
|
49
|
+
|
|
50
|
+
const feedAction = linkedinConnector.actions.find((a) => a.id === 'feed-read');
|
|
51
|
+
expect(feedAction!.trustDomain).toBe('integrations');
|
|
52
|
+
|
|
53
|
+
const postAction = linkedinConnector.actions.find((a) => a.id === 'post-update');
|
|
54
|
+
expect(postAction!.trustDomain).toBe('integrations');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should execute post-update action', async () => {
|
|
58
|
+
// GET /me -> { id: 'abc' }
|
|
59
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ id: 'abc' }));
|
|
60
|
+
// POST /ugcPosts -> { id: 'post1' }
|
|
61
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ id: 'post1' }));
|
|
62
|
+
const result = await linkedinConnector.executeAction('post-update', { text: 'Update' }, 'token') as any;
|
|
63
|
+
expect(result.status).toBe('posted');
|
|
64
|
+
expect(result.postId).toBe('post1');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should execute message-send action', async () => {
|
|
68
|
+
// POST /messages -> { id: 'msg1' }
|
|
69
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ id: 'msg1' }));
|
|
70
|
+
const result = await linkedinConnector.executeAction('message-send', { recipientId: 'p1', text: 'Hi' }, 'token') as any;
|
|
71
|
+
expect(result.status).toBe('sent');
|
|
72
|
+
expect(result.messageId).toBe('msg1');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should execute profile-get action', async () => {
|
|
76
|
+
// GET /me -> profile object
|
|
77
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ id: 'me', firstName: 'Test', lastName: 'User' }));
|
|
78
|
+
const result = await linkedinConnector.executeAction('profile-get', {}, 'token') as any;
|
|
79
|
+
expect(result.id).toBe('me');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should throw for unknown action', async () => {
|
|
83
|
+
await expect(linkedinConnector.executeAction('unknown', {}, 'token')).rejects.toThrow('Unknown action');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should return empty events from pollTrigger', async () => {
|
|
87
|
+
const events = await linkedinConnector.pollTrigger!('new-message', 'token');
|
|
88
|
+
expect(events).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should define triggers', () => {
|
|
92
|
+
expect(linkedinConnector.triggers).toHaveLength(2);
|
|
93
|
+
const triggerIds = linkedinConnector.triggers.map((t) => t.id);
|
|
94
|
+
expect(triggerIds).toContain('new-message');
|
|
95
|
+
expect(triggerIds).toContain('post-engagement');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should define entities', () => {
|
|
99
|
+
expect(linkedinConnector.entities).toHaveLength(2);
|
|
100
|
+
const entityIds = linkedinConnector.entities.map((e) => e.id);
|
|
101
|
+
expect(entityIds).toContain('post');
|
|
102
|
+
expect(entityIds).toContain('connection');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { redditConnector } from '../src/reddit.js';
|
|
3
|
+
|
|
4
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
fetchMock = vi.fn();
|
|
7
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function mockResponse(body: unknown) {
|
|
14
|
+
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Reddit listing format with empty children */
|
|
18
|
+
function emptyListing() {
|
|
19
|
+
return { data: { children: [] } };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('Reddit Connector', () => {
|
|
23
|
+
it('should have correct metadata', () => {
|
|
24
|
+
expect(redditConnector.id).toBe('reddit');
|
|
25
|
+
expect(redditConnector.name).toBe('Reddit');
|
|
26
|
+
expect(redditConnector.version).toBe('1.0.0');
|
|
27
|
+
expect(redditConnector.category).toBe('social');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should use OAuth2 authentication', () => {
|
|
31
|
+
expect(redditConnector.auth.type).toBe('oauth2');
|
|
32
|
+
expect(redditConnector.auth.oauth2).toBeDefined();
|
|
33
|
+
expect(redditConnector.auth.oauth2!.authUrl).toBe('https://www.reddit.com/api/v1/authorize');
|
|
34
|
+
expect(redditConnector.auth.oauth2!.tokenUrl).toBe('https://www.reddit.com/api/v1/access_token');
|
|
35
|
+
expect(redditConnector.auth.oauth2!.scopes).toContain('submit');
|
|
36
|
+
expect(redditConnector.auth.oauth2!.scopes).toContain('vote');
|
|
37
|
+
expect(redditConnector.auth.oauth2!.scopes.length).toBe(6);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should define all 8 actions', () => {
|
|
41
|
+
expect(redditConnector.actions).toHaveLength(8);
|
|
42
|
+
const actionIds = redditConnector.actions.map((a) => a.id);
|
|
43
|
+
expect(actionIds).toContain('front-page');
|
|
44
|
+
expect(actionIds).toContain('subreddit-read');
|
|
45
|
+
expect(actionIds).toContain('post-submit');
|
|
46
|
+
expect(actionIds).toContain('comment');
|
|
47
|
+
expect(actionIds).toContain('inbox-read');
|
|
48
|
+
expect(actionIds).toContain('search');
|
|
49
|
+
expect(actionIds).toContain('save-post');
|
|
50
|
+
expect(actionIds).toContain('upvote');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should have correct trust levels', () => {
|
|
54
|
+
const readAction = redditConnector.actions.find((a) => a.id === 'front-page');
|
|
55
|
+
expect(readAction!.trustMinimum).toBe(1);
|
|
56
|
+
|
|
57
|
+
const submitAction = redditConnector.actions.find((a) => a.id === 'post-submit');
|
|
58
|
+
expect(submitAction!.trustMinimum).toBe(3);
|
|
59
|
+
expect(submitAction!.sideEffects).toBe(true);
|
|
60
|
+
|
|
61
|
+
const voteAction = redditConnector.actions.find((a) => a.id === 'upvote');
|
|
62
|
+
expect(voteAction!.trustMinimum).toBe(2);
|
|
63
|
+
expect(voteAction!.reversible).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should mark save-post as reversible', () => {
|
|
67
|
+
const saveAction = redditConnector.actions.find((a) => a.id === 'save-post');
|
|
68
|
+
expect(saveAction!.reversible).toBe(true);
|
|
69
|
+
expect(saveAction!.sideEffects).toBe(true);
|
|
70
|
+
expect(saveAction!.trustMinimum).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should execute front-page action', async () => {
|
|
74
|
+
// GET /best?limit=25 -> listing with no children
|
|
75
|
+
fetchMock.mockResolvedValueOnce(mockResponse(emptyListing()));
|
|
76
|
+
const result = await redditConnector.executeAction('front-page', {}, 'token');
|
|
77
|
+
expect(result).toEqual({ posts: [] });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should execute subreddit-read action', async () => {
|
|
81
|
+
// GET /r/typescript/hot?limit=25 -> listing with no children
|
|
82
|
+
fetchMock.mockResolvedValueOnce(mockResponse(emptyListing()));
|
|
83
|
+
const result = await redditConnector.executeAction('subreddit-read', { subreddit: 'typescript' }, 'token') as any;
|
|
84
|
+
expect(result.posts).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should execute post-submit action', async () => {
|
|
88
|
+
// POST /api/submit -> { json: { data: { id: 'abc123' } } }
|
|
89
|
+
fetchMock.mockResolvedValueOnce(mockResponse({ json: { data: { id: 'abc123' } } }));
|
|
90
|
+
const result = await redditConnector.executeAction('post-submit', { subreddit: 'test', title: 'Title', body: 'Body' }, 'token') as any;
|
|
91
|
+
expect(result.status).toBe('submitted');
|
|
92
|
+
expect(result.postId).toBe('abc123');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should execute upvote action with default direction', async () => {
|
|
96
|
+
// POST /api/vote -> {}
|
|
97
|
+
fetchMock.mockResolvedValueOnce(mockResponse({}));
|
|
98
|
+
const result = await redditConnector.executeAction('upvote', { postId: 'p1' }, 'token') as any;
|
|
99
|
+
expect(result.status).toBe('voted');
|
|
100
|
+
expect(result.postId).toBe('p1');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should throw for unknown action', async () => {
|
|
104
|
+
await expect(redditConnector.executeAction('unknown', {}, 'token')).rejects.toThrow('Unknown action');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should return empty events from pollTrigger', async () => {
|
|
108
|
+
// GET /message/unread?limit=25 -> empty listing
|
|
109
|
+
fetchMock.mockResolvedValueOnce(mockResponse(emptyListing()));
|
|
110
|
+
const events = await redditConnector.pollTrigger!('new-inbox', 'token');
|
|
111
|
+
expect(events).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should define triggers', () => {
|
|
115
|
+
expect(redditConnector.triggers).toHaveLength(2);
|
|
116
|
+
const triggerIds = redditConnector.triggers.map((t) => t.id);
|
|
117
|
+
expect(triggerIds).toContain('new-inbox');
|
|
118
|
+
expect(triggerIds).toContain('subreddit-new');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should define entities', () => {
|
|
122
|
+
expect(redditConnector.entities).toHaveLength(2);
|
|
123
|
+
const entityIds = redditConnector.entities.map((e) => e.id);
|
|
124
|
+
expect(entityIds).toContain('post');
|
|
125
|
+
expect(entityIds).toContain('comment');
|
|
126
|
+
});
|
|
127
|
+
});
|