Skip to content

Commit

Permalink
README & error parsing improvements (PLhery#23)
Browse files Browse the repository at this point in the history
* chore(CI): Removed parallel limits of CI tests

* fix(CI): Removed some redondunt tests and lower fetch requirements for some tests

* fix(CI): Forgot to change the tweet number check in stream test :D

* fix(CI): Put all streams timeouts to 2min

* Logged tries

* debug(CI): Fix-debug: logged tries

* fix(CI): Timeout for retries

* fix(CI): Fixed ms to s

* fix(CI): Reverted to serial tests

* fix(CI): Trigger the CI

* Better error message (error formatting)

* feat(Errors): Format the errors in a dedicated function

* feat: Error enums for v1&v2, helpers in ApiResponseError objects

* fix: Fixed error handling for v2 and introduce an interface for throw errors by API

* fix: Auto add user agent

* fix: Awaits 1 sec after the streams to close before reconnecting

* doc: Changed description

* doc: Try some README improvements

* doc: Re-worked "why" section of README

* doc: v2 streaming example

* fix: Removed include RT from tweet search

Co-authored-by: Louis Béranger <beranger.louis.bio@gmail.com>
alkihis and Louis Béranger authored Apr 22, 2021
1 parent d912adb commit a24bd36
Showing 13 changed files with 318 additions and 108 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
@@ -23,4 +23,4 @@ jobs:
CONSUMER_TOKEN: ${{ secrets.CONSUMER_TOKEN }}
CONSUMER_SECRET: ${{ secrets.CONSUMER_SECRET }}
OAUTH_TOKEN: ${{ secrets.OAUTH_TOKEN }}
OAUTH_SECRET: ${{ secrets.OAUTH_SECRET }}
OAUTH_SECRET: ${{ secrets.OAUTH_SECRET }}
46 changes: 35 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,45 @@
# Twitter API v2

Strongly typed, full-featured, right protected, versatile yet powerful Twitter API v1.1 and v2 client for Node.js.
Strongly typed, full-featured, light, versatile yet powerful Twitter API v1.1 and v2 client for Node.js.

## Highlights

**Ready for v2 and good ol' v1.1 Twitter API**

**Light: No dependencies, 11.7kb minified+gzipped**

**Bundled types for request parameters and responses**

**Streaming support**

**Pagination utils**

**Media upload helpers**

## Why?

- The main libraries (twit/twitter) were not updated in a while
- I don't think a Twitter library need many dependencies
Sometimes, you just want to quickly bootstrap an application using the Twitter API.
Even if they're a lot a available librairies on the JavaScript ecosystem, they usually just
provide wrappers around HTTP methods, and some of them are bloated with many dependencies.

`twitter-api-v2` meant to provide full endpoint wrapping, from method name to response data,
using descriptive typings for read/write/DMs rights, request parameters and response payload.

A small feature comparaison with other libs:

| Package | API version(s) | Response typings | Media helpers | Pagination | Subdependencies | Size (gzip) |
| -------------- | -------------- | ---------------- | ------------- | ---------- | --------------- | -------------:|
| twitter-api-v2 | v1.1, v2, labs |||| 0 | ~11.7 kB |
| twit | v1.1 |||| 51 | ~214.5 kB |
| twitter | v1.1 |||| 50 | ~182.1 kB |
| twitter-lite | v1.1, v2 ||* || 4 | ~5.3 kB |
| twitter-v2 | v2 |||| 7 | ~4.5 kB |

They caused me some frustration:
- They don't support video upload in a simple way
- They don't explain well the "link" auth process
- They don't support yet Twitter API V2
- They could have more helpers (for pagination, rate limit, ...)
- Typings could make the difference between read/write app
\**No support for `media/upload`, cannot send a `multipart/form-data` encoded-body without tricks*

## Goals
## Features

Here's the feature highlights of `twitter-api-v2`:
Here's the detailed feature list of `twitter-api-v2`:

### Basics:
- Support for v1.1 and **v2 of Twitter API**
@@ -37,6 +60,7 @@ Here's the feature highlights of `twitter-api-v2`:
- Dedicated methods that wraps API v1.1 & v2 endpoints, with **typed arguments** and fully **typed responses**
*(WIP - not all public endpoints are available)*
- Bundled parsing of rate limit headers
- Typed errors, meaningful error messages, error enumerations for both v1.1 and v2

### Type-safe first:
- **Typings for tweet, user, media entities (and more) are bundled in this package!**
41 changes: 41 additions & 0 deletions doc/examples.md
Original file line number Diff line number Diff line change
@@ -49,4 +49,45 @@ await twitterClient.v2.get('tweets/search/recent', {query: 'nodeJS', max_results
const tweets = await twitterClient.get('https://api.twitter.com/2/tweets/search/recent?query=nodeJS&max_results=100');
```


## Full examples

### Streaming: Bot that listens for tweets that mention them, and replies with a reversed tweet content

```ts
const client = new TwitterApi({ appKey, appSecret, accessToken, accessSecret });
const me = await client.currentUser();

// Erase previous rules
const rules = await client.v2.streamRules();
if (rules.data.length) {
await client.v2.updateStreamRules({
delete: { ids: rules.data.map(rule => rule.id) },
});
}

// Add my search rule
await client.v2.updateStreamRules({
add: [{ value: '@' + me.screen_name }],
});

const stream = await client.v2.searchStream({
'tweet.fields': ['in_reply_to_user_id'],
});

for await (const { data: tweet } of stream) {
// If the bot is not directly mentionned, ignore
if (tweet.in_reply_to_user_id !== me.id_str) {
continue;
}

// Remove the beginning mentions
const realText = tweet.text.replace(/^@\w+ (@\w+ )*/, '');
const reversedText = realText.split('').reverse().join('');

client.v1.reply(reversedText, tweet.id)
.catch((err: Error) => console.log(err.message));
}
```

***WIP — TODO***
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "twitter-api-v2",
"version": "0.4.0",
"description": "Strongly typed, full-featured, right protected, versatile yet powerful Twitter API v1.1 and v2 client for Node.js.",
"description": "Strongly typed, full-featured, light, versatile yet powerful Twitter API v1.1 and v2 client for Node.js.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"keywords": [
32 changes: 30 additions & 2 deletions src/client-mixins/request-maker.mixin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ApiRequestError, ApiResponseError, TwitterRateLimit, TwitterResponse } from '../types';
import { ApiRequestError, ApiResponseError, ErrorV1, ErrorV2, TwitterRateLimit, TwitterResponse } from '../types';
import TweetStream from '../stream/TweetStream';
import { URLSearchParams } from 'url';
import { request, RequestOptions } from 'https';
@@ -139,6 +139,11 @@ export abstract class ClientRequestMaker {
method = method.toUpperCase();
headers = headers ?? {};

// Add user agent header (Twitter recommands it)
if (!headers['x-user-agent']) {
headers['x-user-agent'] = 'Node.twitter-api-v2';
}

const query = RequestParamHelpers.formatQueryToString(rawQuery);
url = RequestParamHelpers.mergeUrlQueryIntoObject(url, query);

@@ -376,12 +381,35 @@ export class RequestHandlerHelper<T> {
});
}

protected formatV1Errors(errors: ErrorV1[]) {
return errors
.map(({ code, message }) => `${message} (Twitter code ${code})`)
.join(', ');
}

protected formatV2Error(error: ErrorV2) {
return `${error.title}: ${error.detail} (see ${error.type})`;
}

protected createResponseError({ res, data, rateLimit, code }: IBuildErrorParams): ApiResponseError {
if (TwitterApiV2Settings.debug) {
console.log('Request failed with code', code, ', data:', data, 'response headers:', res.headers);
}

return new ApiResponseError(`Request failed with code ${code}.`, {
// Errors formatting.
let errorString = `Request failed with code ${code}`;
if (data?.errors?.length) {
const errors = data.errors as (ErrorV1 | ErrorV2)[];

if ('code' in errors[0]) {
errorString += ' - ' + this.formatV1Errors(errors as ErrorV1[]);
}
else {
errorString += ' - ' + this.formatV2Error(data as ErrorV2);
}
}

return new ApiResponseError(errorString, {
code,
data,
headers: res.headers,
172 changes: 170 additions & 2 deletions src/types/errors.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import type { TwitterRateLimit, TwitterResponse } from '../types';
import type { ClientRequest, IncomingMessage, IncomingHttpHeaders } from 'http';
import type { ErrorV2 } from './v2';

export interface ErrorV1 {
code: number;
message: string;
}

/** Errors included in response payload with a OK HTTP status (code ~= 200) */
export interface InlineErrorV2 {
value?: string;
detail: string;
title: string;
resource_type?: string;
parameter?: string;
resource_id?: string;
reason?: string;
type: string;
}

/** Error payload thrown when HTTP code is not OK */
export interface ErrorV2 {
detail: string;
title: string;
type: string;
errors: {
message: string;
parameters?: { [parameterName: string]: string[] };
}[];
}

export type TRequestError = TwitterApiRequestError | TwitterApiError;

@@ -12,7 +39,7 @@ export interface TwitterErrorPayload<T = any> {
}

export interface TwitterApiErrorData {
errors: ErrorV2[];
errors: (ErrorV1 | ErrorV2)[];
title?: string;
detail?: string;
type?: string;
@@ -77,6 +104,7 @@ interface IBuildApiResponseError {

export class ApiResponseError extends ApiError implements TwitterApiError, IBuildApiResponseError {
type = ETwitterApiError.Response as const;
/** HTTP error code */
code: number;
request: ClientRequest;
response: IncomingMessage;
@@ -96,4 +124,144 @@ export class ApiResponseError extends ApiError implements TwitterApiError, IBuil
this.rateLimit = options.rateLimit;
this.data = options.data;
}

/** Check for presence of one of given v1/v2 error codes. */
hasErrorCode(...codes: (EApiV1ErrorCode | number)[] | (EApiV2ErrorCode | string)[]) {
const errors = this.errors;

// No errors
if (!errors?.length) {
return false;
}

// v1 errors
if ('code' in errors[0]) {
const v1errors = errors as ErrorV1[];
return v1errors.some(error => (codes as number[]).includes(error.code));
}

// v2 error
const v2error = this.data as ErrorV2;
return (codes as string[]).includes(v2error.type);
}

get errors(): (ErrorV1 | ErrorV2)[] | undefined {
return this.data?.errors;
}

get rateLimitError() {
return this.code === 420 || this.code === 429;
}

get isAuthError() {
if (this.code === 401) {
return true;
}

return this.hasErrorCode(
EApiV1ErrorCode.AuthTimestampInvalid,
EApiV1ErrorCode.AuthenticationFail,
EApiV1ErrorCode.BadAuthenticationData,
EApiV1ErrorCode.InvalidOrExpiredToken,
);
}
}

export enum EApiV1ErrorCode {
// Location errors
InvalidCoordinates = 3,
NoLocationFound = 13,

// Authentification failures
AuthenticationFail = 32,
InvalidOrExpiredToken = 89,
UnableToVerifyCredentials = 99,
AuthTimestampInvalid = 135,
BadAuthenticationData = 215,

// Resources not found or visible
NoUserMatch = 17,
UserNotFound = 50,
ResourceNotFound = 34,
TweetNotFound = 144,
TweetNotVisible = 179,
NotAllowedResource = 220,
MediaIdNotFound = 325,
TweetNoLongerAvailable = 421,
TweetViolatedRules = 422,

// Account errors
TargetUserSuspended = 63,
YouAreSuspended = 64,
AccountUpdateFailed = 120,
NoSelfSpamReport = 36,
NoSelfMute = 271,
AccountLocked = 326,

// Application live errors / Twitter errors
RateLimitExceeded = 88,
NoDMRightForApp = 93,
OverCapacity = 130,
InternalError = 131,
TooManyFollowings = 161,
TweetLimitExceeded = 185,
DuplicatedTweet = 187,
TooManySpamReports = 205,
RequestLooksLikeSpam = 226,
NoWriteRightForApp = 261,
TweetActionsDisabled = 425,
TweetRepliesRestricted = 433,

// Invalid request parameters
NamedParameterMissing = 38,
InvalidAttachmentUrl = 44,
TweetTextTooLong = 186,
MissingUrlParameter = 195,
NoMultipleGifs = 323,
InvalidMediaIds = 324,
InvalidUrl = 407,
TooManyTweetAttachments = 386,

// Already sent/deleted item
StatusAlreadyFavorited = 139,
FollowRequestAlreadySent = 160,
CannotUnmuteANonMutedAccount = 272,
TweetAlreadyRetweeted = 327,
ReplyToDeletedTweet = 385,

// DM Errors
DMReceiverNotFollowingYou = 150,
UnableToSendDM = 151,
MustAllowDMFromAnyone = 214,
CannotSendDMToThisUser = 349,
DMTextTooLong = 354,

// Appication misconfiguration
SubscriptionAlreadyExists = 355,
CallbackUrlNotApproved = 415,
SuspendedApplication = 416,
OobOauthIsNotAllowed = 417,
}

export enum EApiV2ErrorCode {
// Request errors
InvalidRequest = 'https://api.twitter.com/2/problems/invalid-request',
ClientForbidden = 'https://api.twitter.com/2/problems/client-forbidden',
UnsupportedAuthentication = 'https://api.twitter.com/2/problems/unsupported-authentication',

// Stream rules errors
InvalidRules = 'https://api.twitter.com/2/problems/invalid-rules',
TooManyRules = 'https://api.twitter.com/2/problems/rule-cap',
DuplicatedRules = 'https://api.twitter.com/2/problems/duplicate-rules',

// Twitter errors
RateLimitExceeded = 'https://api.twitter.com/2/problems/usage-capped',
ConnectionError = 'https://api.twitter.com/2/problems/streaming-connection',
ClientDisconnected = 'https://api.twitter.com/2/problems/client-disconnected',
TwitterDisconnectedYou = 'https://api.twitter.com/2/problems/operational-disconnect',

// Resource errors
ResourceNotFound = 'https://api.twitter.com/2/problems/resource-not-found',
ResourceUnauthorized = 'https://api.twitter.com/2/problems/not-authorized-for-resource',
DisallowedResource = 'https://api.twitter.com/2/problems/disallowed-resource',
}
14 changes: 7 additions & 7 deletions src/types/v2/shared.v2.types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { ErrorV2 } from './tweet.definition.v2';
import type { InlineErrorV2 } from '../errors.types';

export type MetaV2<M> = { meta: M, errors?: ErrorV2[] };
export type DataV2<D> = { data: D, errors?: ErrorV2[] };
export type IncludeV2<I> = { includes?: I, errors?: ErrorV2[] };
export type MetaV2<M> = { meta: M, errors?: InlineErrorV2[] };
export type DataV2<D> = { data: D, errors?: InlineErrorV2[] };
export type IncludeV2<I> = { includes?: I, errors?: InlineErrorV2[] };

export type DataAndMetaV2<D, M> = { data: D, meta: M, errors?: ErrorV2[] };
export type DataAndIncludeV2<D, I> = { data: D, includes?: I, errors?: ErrorV2[] };
export type DataMetaAndIncludeV2<D, M, I> = { data: D, meta: M, includes?: I, errors?: ErrorV2[] };
export type DataAndMetaV2<D, M> = { data: D, meta: M, errors?: InlineErrorV2[] };
export type DataAndIncludeV2<D, I> = { data: D, includes?: I, errors?: InlineErrorV2[] };
export type DataMetaAndIncludeV2<D, M, I> = { data: D, meta: M, includes?: I, errors?: InlineErrorV2[] };

export interface SentMeta {
/** The time when the request body was returned. */
10 changes: 0 additions & 10 deletions src/types/v2/tweet.definition.v2.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import type { UserV2 } from './user.v2.types';

export interface ErrorV2 {
value?: string;
detail: string;
title: string;
resource_type?: string;
parameter?: string;
resource_id?: string;
type: string;
}

export interface PlaceV2 {
full_name: string;
id: string;
21 changes: 0 additions & 21 deletions test/media-upload.test.ts
Original file line number Diff line number Diff line change
@@ -25,13 +25,6 @@ describe('Media upload for v1.1 API', () => {
expect(fromPath).to.have.length.greaterThan(0);
}).timeout(maxTimeout);

it('Upload a JPG image from buffer', async () => {
// Upload media (from buffer)
const fromBuffer = await client.v1.uploadMedia(await fs.promises.readFile(jpgImg), { type: 'jpg' });
expect(fromBuffer).to.be.an('string');
expect(fromBuffer).to.have.length.greaterThan(0);
}).timeout(maxTimeout);

it('Upload a JPG image from file handle', async () => {
// Upload media (from fileHandle)
const fromHandle = await client.v1.uploadMedia(await fs.promises.open(jpgImg, 'r'), { type: 'jpg' })
@@ -60,20 +53,6 @@ describe('Media upload for v1.1 API', () => {
expect(fromBuffer).to.have.length.greaterThan(0);
}).timeout(maxTimeout);

it('Upload a GIF image from file handle', async () => {
// Upload media (from fileHandle)
const fromHandle = await client.v1.uploadMedia(await fs.promises.open(gifImg, 'r'), { type: 'gif' })
expect(fromHandle).to.be.an('string');
expect(fromHandle).to.have.length.greaterThan(0);
}).timeout(maxTimeout);

it('Upload a GIF image from numbered file handle', async () => {
// Upload media (from numbered fileHandle)
const fromNumberFh = await client.v1.uploadMedia(fs.openSync(gifImg, 'r'), { type: 'gif', maxConcurrentUploads: 1 });
expect(fromNumberFh).to.be.an('string');
expect(fromNumberFh).to.have.length.greaterThan(0);
}).timeout(maxTimeout);

it('Upload a MP4 video from path', async () => {
const video = await client.v1.uploadMedia(mp4vid);
expect(video).to.be.an('string');
71 changes: 26 additions & 45 deletions test/stream.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,14 @@
import 'mocha';
import { expect } from 'chai';
import { TwitterApi, ETwitterStreamEvent, ApiResponseError } from '../src';
import { TwitterApi, ETwitterStreamEvent } from '../src';
import { getAppClient, getUserClient } from '../src/test/utils';

// OAuth 1.0a
const clientOauth = getUserClient();

async function retryUntilNoRateLimitError<T>(callback: () => Promise<T>): Promise<T> {
while (true) {
try {
return await callback();
} catch (e) {
if (e instanceof ApiResponseError && e.code === 429) {
// Sleeps for 1 second
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}

// Error is not a rate limit error, throw it.
throw e;
}
}
}

describe('Tweet stream API v1.1', () => {
it('Should stream 5 tweets without any network error for statuses/filter', async () => {
const streamv1Filter = await retryUntilNoRateLimitError(() => clientOauth.v1.filterStream({ track: 'JavaScript' }));
it('Should stream 3 tweets without any network error for statuses/filter using events', async () => {
const streamv1Filter = await clientOauth.v1.filterStream({ track: 'JavaScript' });

const numberOfTweets = await new Promise<number>((resolve, reject) => {
let numberOfTweets = 0;
@@ -36,7 +19,7 @@ describe('Tweet stream API v1.1', () => {
streamv1Filter.on(ETwitterStreamEvent.Data, event => {
numberOfTweets++;

if (numberOfTweets >= 5) {
if (numberOfTweets >= 3) {
resolve(numberOfTweets);
}
});
@@ -45,8 +28,8 @@ describe('Tweet stream API v1.1', () => {
streamv1Filter.close();
});

expect(numberOfTweets).to.equal(5);
}).timeout(1000 * 60);
expect(numberOfTweets).to.equal(3);
}).timeout(1000 * 120);
});

describe('Tweet stream API v2', () => {
@@ -56,39 +39,37 @@ describe('Tweet stream API v2', () => {
clientBearer = await getAppClient();
});

it('Should stream 5 tweets without any network error for sample/stream', async () => {
const streamv2Filter = await retryUntilNoRateLimitError(() => clientBearer.v2.getStream('tweets/sample/stream'));
beforeEach(async () => {
await new Promise(resolve => setTimeout(resolve, 1000));
});

const numberOfTweets = await new Promise<number>((resolve, reject) => {
let numberOfTweets = 0;
it('Should stream 3 tweets without any network error for sample/stream using async iterator', async () => {
const streamv2Sample = await clientBearer.v2.getStream('tweets/sample/stream');

// Awaits for a tweet
streamv2Filter.on(ETwitterStreamEvent.ConnectionError, reject);
streamv2Filter.on(ETwitterStreamEvent.ConnectionClosed, reject);
streamv2Filter.on(ETwitterStreamEvent.Data, event => {
numberOfTweets++;
let numberOfTweets = 0;

if (numberOfTweets >= 5) {
resolve(numberOfTweets);
}
});
streamv2Filter.on(ETwitterStreamEvent.DataKeepAlive, () => console.log('Received keep alive event'));
}).finally(() => {
streamv2Filter.close();
});
for await (const _ of streamv2Sample) {
numberOfTweets++;

if (numberOfTweets >= 3) {
break;
}
}

streamv2Sample.close();

expect(numberOfTweets).to.equal(5);
expect(numberOfTweets).to.equal(3);
}).timeout(1000 * 120);

it('In 15 seconds, should have the same tweets registred by async iterator and event handler', async () => {
const streamV2 = await retryUntilNoRateLimitError(() => clientBearer.v2.sampleStream());
it('In 10 seconds, should have the same tweets registred by async iterator and event handler', async () => {
const streamV2 = await clientBearer.v2.sampleStream();

const eventTweetIds = [] as string[];
const itTweetIds = [] as string[];

await Promise.race([
// 30 seconds timeout
new Promise(resolve => setTimeout(resolve, 15 * 1000)),
// 10 seconds timeout
new Promise(resolve => setTimeout(resolve, 10 * 1000)),
(async function() {
streamV2.on(ETwitterStreamEvent.Data, tweet => eventTweetIds.push(tweet.data.id));

3 changes: 1 addition & 2 deletions test/tweet.v1.test.ts
Original file line number Diff line number Diff line change
@@ -33,12 +33,11 @@ describe('Tweets endpoints for v1.1 API', () => {

it('.get - Get 2 tweets of a specific user', async () => {
// Using raw HTTP method and URL
const response1 = await client.get('https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=jack&count=2&include_rts=false');
const response1 = await client.get('https://api.twitter.com/1.1/statuses/user_timeline.json?screen_name=jack&count=2');
// Using query parser
const response2 = await client.v1.get('statuses/user_timeline.json', {
screen_name: 'jack',
count: 2,
include_rts: false,
});

for (const response of [response1, response2]) {
4 changes: 2 additions & 2 deletions test/tweet.v2.test.ts
Original file line number Diff line number Diff line change
@@ -36,7 +36,7 @@ describe('Tweets endpoints for v2 API', () => {

}).timeout(60 * 1000);

it('.search - Search and fetch tweets using tweet searcher', async () => {
it('.search - Search and fetch tweets using tweet searcher and consume 200 tweets', async () => {
const nodeJs = await client.v2.search('nodeJS');

const originalLength = nodeJs.tweets.length;
@@ -51,7 +51,7 @@ describe('Tweets endpoints for v2 API', () => {

for await (const tweet of nodeJs) {
ids.push(tweet.id);
if (i > 1000) {
if (i > 200) {
break;
}

8 changes: 4 additions & 4 deletions test/user.v2.test.ts
Original file line number Diff line number Diff line change
@@ -100,8 +100,8 @@ describe('Users endpoints for v2 API', () => {
const followInfo = await readWrite.v2.follow(currentUser.id_str, '12');
expect(followInfo.data.following).to.equal(true);

// Sleep 5 seconds
await new Promise(resolve => setTimeout(resolve, 1000 * 5));
// Sleep 2 seconds
await new Promise(resolve => setTimeout(resolve, 1000 * 2));

// Unfollow jack
const unfollowInfo = await readWrite.v2.unfollow(currentUser.id_str, '12');
@@ -116,8 +116,8 @@ describe('Users endpoints for v2 API', () => {
const blockInfo = await readWrite.v2.block(currentUser.id_str, '12');
expect(blockInfo.data.blocking).to.equal(true);

// Sleep 5 seconds
await new Promise(resolve => setTimeout(resolve, 1000 * 5));
// Sleep 2 seconds
await new Promise(resolve => setTimeout(resolve, 1000 * 2));

// unblock jack
const unblockInfo = await readWrite.v2.unblock(currentUser.id_str, '12');

0 comments on commit a24bd36

Please sign in to comment.