forked from draftbit/twitter-lite
-
Notifications
You must be signed in to change notification settings - Fork 0
/
twitter.test.js
375 lines (335 loc) · 11.9 KB
/
twitter.test.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const Twitter = require('../twitter');
const {
TWITTER_CONSUMER_KEY,
TWITTER_CONSUMER_SECRET,
ACCESS_TOKEN,
ACCESS_TOKEN_SECRET,
} = process.env;
const STRING_WITH_SPECIAL_CHARS = "`!@#$%^&*()-_=+[{]}\\|;:'\",<.>/? ✓";
const DIRECT_MESSAGE_RECIPIENT_ID = '1253003423055843328'; // https://twitter.com/twlitetest
const TEST_IMAGE = fs.readFileSync(path.join(__dirname, 'test.gif'));
function newClient(subdomain = 'api') {
return new Twitter({
subdomain,
consumer_key: TWITTER_CONSUMER_KEY,
consumer_secret: TWITTER_CONSUMER_SECRET,
access_token_key: ACCESS_TOKEN,
access_token_secret: ACCESS_TOKEN_SECRET,
});
}
// Used when testing DMs to avoid getting flagged for abuse
function randomString() {
return Math.random().toString(36).substr(2, 11);
}
function htmlEscape(string) {
return string
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
describe('core', () => {
it('should default export to be a function', () => {
expect(new Twitter()).toBeInstanceOf(Twitter);
});
it('should return the API URL', () => {
expect(new Twitter().url).toEqual('https://api.twitter.com/1.1');
});
it('should return a stream API URL', () => {
const options = { subdomain: 'stream' };
expect(new Twitter(options).url).toEqual('https://stream.twitter.com/1.1');
});
});
describe('auth', () => {
it('should fail on invalid access_token_secret', async () => {
const client = new Twitter({
subdomain: 'api',
consumer_key: TWITTER_CONSUMER_KEY,
consumer_secret: TWITTER_CONSUMER_SECRET,
access_token_key: ACCESS_TOKEN,
access_token_secret: 'xyz',
});
expect.assertions(1);
try {
await client.get('account/verify_credentials');
} catch (e) {
expect(e).toMatchObject({
errors: [{ code: 32, message: 'Could not authenticate you.' }],
});
}
});
it('should fail on invalid or expired token', async () => {
const client = new Twitter({
subdomain: 'api',
consumer_key: 'xyz',
consumer_secret: 'xyz',
access_token_key: 'xyz',
access_token_secret: 'xyz',
});
expect.assertions(1);
try {
await client.get('account/verify_credentials');
} catch (e) {
expect(e).toMatchObject({
errors: [{ code: 89, message: 'Invalid or expired token.' }],
});
}
});
it('should verify credentials with correct tokens', async () => {
const client = newClient();
const response = await client.get('account/verify_credentials');
expect(response).toHaveProperty('screen_name');
});
it('should use bearer token successfully', async () => {
const user = new Twitter({
consumer_key: TWITTER_CONSUMER_KEY,
consumer_secret: TWITTER_CONSUMER_SECRET,
});
const response = await user.getBearerToken();
expect(response).toMatchObject({
token_type: 'bearer',
});
const app = new Twitter({
bearer_token: response.access_token,
});
const rateLimits = await app.get('application/rate_limit_status', {
resources: 'statuses',
});
// This rate limit is 75 for user auth and 300 for app auth
expect(
rateLimits.resources.statuses['/statuses/retweeters/ids'].limit,
).toEqual(300);
});
});
describe('rate limits', () => {
let client;
beforeAll(() => (client = newClient()));
it(
'should get rate limited',
async () => {
expect.assertions(2); // assume we were rate limited by a previous test and go straight to `catch`
try {
const response = await client.get('help/languages');
// Since this didn't throw, we'll be running 2 more assertions below
expect.assertions(4);
expect(response).toHaveProperty('0.code');
expect(response._headers.get('x-rate-limit-limit')).toEqual('15');
let remaining = response._headers.get('x-rate-limit-remaining');
while (
remaining-- >= -1 // force exceeding the rate limit
)
await client.get('help/languages');
} catch (e) {
expect(e.errors[0]).toHaveProperty('code', 88); // Rate limit exceeded
expect(e._headers.get('x-rate-limit-remaining')).toEqual('0');
}
},
10 * 1000,
);
});
describe('posting', () => {
let client;
beforeAll(() => (client = newClient()));
it('should DM user, including special characters', async () => {
const message = randomString(); // prevent overzealous abuse detection
// POST with JSON body and no parameters per https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event
const response = await client.post('direct_messages/events/new', {
event: {
type: 'message_create',
message_create: {
target: {
recipient_id: DIRECT_MESSAGE_RECIPIENT_ID,
},
message_data: {
text: message + STRING_WITH_SPECIAL_CHARS,
// https://developer.twitter.com/en/docs/direct-messages/sending-and-receiving/api-reference/new-event#message-data-object
// says "URL encode as necessary", but applying encodeURIComponent results in verbatim %NN being sent
},
},
},
});
expect(response).toMatchObject({
event: {
type: 'message_create',
id: expect.stringMatching(/^\d+$/),
created_timestamp: expect.any(String),
message_create: {
message_data: {
text: htmlEscape(message + STRING_WITH_SPECIAL_CHARS),
},
},
},
});
});
it('should send typing indicator and parse empty response', async () => {
// https://developer.twitter.com/en/docs/direct-messages/typing-indicator-and-read-receipts/api-reference/new-typing-indicator
const response = await client.post('direct_messages/indicate_typing', {
recipient_id: DIRECT_MESSAGE_RECIPIENT_ID,
});
expect(response).toEqual({ _headers: expect.any(Object) });
});
it('should post status update with escaped characters, then delete it', async () => {
const message = randomString(); // prevent overzealous abuse detection
// https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update
const response = await client.post('statuses/update', {
status: STRING_WITH_SPECIAL_CHARS + message + STRING_WITH_SPECIAL_CHARS,
});
expect(response).toMatchObject({
text: htmlEscape(
STRING_WITH_SPECIAL_CHARS + message + STRING_WITH_SPECIAL_CHARS,
),
});
const id = response.id_str;
const deleted = await client.post('statuses/destroy', {
id,
});
expect(deleted).toMatchObject({
id_str: id,
});
});
});
describe('uploading', () => {
let uploadClient;
beforeAll(() => (uploadClient = newClient('upload')));
it('should upload a picture, and add alt text to it', async () => {
// Upload picture
const base64Image = new Buffer(TEST_IMAGE).toString('base64');
const mediaUploadResponse = await uploadClient.post('media/upload', {
media_data: base64Image,
});
expect(mediaUploadResponse).toMatchObject({
media_id_string: expect.any(String),
});
// Set alt text
const imageAltString = 'Animated picture of a dancing banana';
await uploadClient.post('media/metadata/create', {
media_id: mediaUploadResponse.media_id_string,
alt_text: { text: imageAltString },
});
});
});
describe('putting', () => {
let client;
beforeAll(() => (client = newClient()));
/**
* For this test you need to have opted to receive messages from anyone at https://twitter.com/settings/safety
* and your demo app needs to have access to read, write, and direct messages.
*/
it('can update welcome message', async () => {
const newWelcomeMessage = await client.post(
'direct_messages/welcome_messages/new',
{
welcome_message: {
name: 'simple_welcome-message 01',
message_data: {
text: 'Welcome!',
},
},
},
);
const updatedWelcomeMessage = await client.put(
'direct_messages/welcome_messages/update',
{
id: newWelcomeMessage.welcome_message.id,
},
{
message_data: {
text: 'Welcome!!!',
},
},
);
expect(updatedWelcomeMessage.welcome_message.message_data.text).toEqual(
'Welcome!!!',
);
});
});
describe('misc', () => {
let client;
beforeAll(() => (client = newClient()));
it('should get full text of retweeted tweet', async () => {
const response = await client.get('statuses/show', {
id: '1019171288533749761', // a retweet by @dandv of @naval
tweet_mode: 'extended',
});
// This is @naval's original tweet
expect(response.retweeted_status.full_text).toEqual(
'@jdburns4 “Retirement” occurs when you stop sacrificing today for an imagined tomorrow. You can retire when your passive income exceeds your burn rate, or when you can make a living doing what you love.',
);
// For the retweet, "truncated" comes misleadingly set to "false" from the API, and the "full_text" is limited to 140 chars
expect(response.truncated).toEqual(false);
expect(response.full_text).toEqual(
'RT @naval: @jdburns4 “Retirement” occurs when you stop sacrificing today for an imagined tomorrow. You can retire when your passive income…',
);
});
it('should have favorited at least one tweet ever', async () => {
const response = await client.get('favorites/list');
expect(response[0]).toHaveProperty('id_str');
});
it('should fail to follow unspecified user', async () => {
expect.assertions(1);
try {
await client.post('friendships/create');
} catch (e) {
expect(e).toMatchObject({
errors: [{ code: 108, message: 'Cannot find specified user.' }],
});
}
});
it('should follow user', async () => {
const response = await client.post('friendships/create', {
screen_name: 'mdo',
});
expect(response).toMatchObject({
name: 'Mark Otto',
});
});
it('should unfollow user', async () => {
const response = await client.post('friendships/destroy', {
user_id: '15008676',
});
expect(response).toMatchObject({
name: 'Dan Dascalescu',
});
});
it('should get details about 100 users with 18-character ids', async () => {
const userIds = [
...Array(99).fill('928759224599040001'),
'711030662728437760',
].join(',');
const expectedIds = [
{ id_str: '928759224599040001' },
{ id_str: '711030662728437760' },
];
// Use POST per https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-users-lookup
const usersPost = await client.post('users/lookup', {
user_id: userIds,
});
delete usersPost._headers; // to not confuse Jest - https://github.com/facebook/jest/issues/5998#issuecomment-446827454
expect(usersPost).toMatchObject(expectedIds);
// Check if GET worked the same
const usersGet = await client.get('users/lookup', { user_id: userIds });
expect(usersGet.map((u) => u)).toMatchObject(expectedIds); // map(u => u) is an alternative to deleting _headers
});
it('should be unable to get details about suspended user', async () => {
const nonexistentScreenName = randomString() + randomString();
try {
// https://twitter.com/fuckyou is actually a suspended user, but the API doesn't differentiate from nonexistent users
await client.get('users/lookup', {
screen_name: `fuckyou,${nonexistentScreenName}`,
});
} catch (e) {
expect(e).toMatchObject({
errors: [{ code: 17, message: 'No user matches for specified terms.' }],
});
}
});
it('should get timeline', async () => {
const response = await client.get('statuses/user_timeline', {
screen_name: 'twitterapi',
count: 2,
});
expect(response).toHaveLength(2);
});
});