This commit is contained in:
dangered wolf 2023-08-16 14:57:04 -04:00
commit 2b86939c63
No known key found for this signature in database
GPG key ID: 41E4D37680ED8B58
11 changed files with 222 additions and 139 deletions

View file

@ -1,4 +1,3 @@
{
"FixTweet": "https://github.com/FixTweet/FixTweet",
"FixTweet - Embed videos, polls & more": "https://github.com/FixTweet/FixTweet"
"FixTweet - Recovering from API woes": "https://github.com/FixTweet/FixTweet/issues/333"
}

View file

@ -7,12 +7,14 @@ import { colorFromPalette } from '../helpers/palette';
import { translateTweet } from '../helpers/translate';
import { unescapeText } from '../helpers/utils';
import { processMedia } from '../helpers/media';
import { convertToApiUser } from './user';
import { isGraphQLTweet, isGraphQLTweetNotFoundResponse } from '../utils/graphql';
/* This function does the heavy lifting of processing data from Twitter API
and using it to create FixTweet's streamlined API responses */
const populateTweetProperties = async (
tweet: TweetPartial,
conversation: TimelineBlobPartial,
tweet: GraphQLTweet,
conversation: any, // TimelineBlobPartial,
language: string | undefined
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APITweet> => {
@ -27,54 +29,51 @@ const populateTweetProperties = async (
/* With v2 conversation API we re-add the user object ot the tweet because
Twitter stores it separately in the conversation API. This is to consolidate
it in case a user appears multiple times in a thread. */
tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {};
const user = tweet.user as UserPartial;
const screenName = user?.screen_name || '';
const name = user?.name || '';
const graphQLUser = tweet.core.user_results.result;
const apiUser = convertToApiUser(graphQLUser);
/* Populating a lot of the basics */
apiTweet.url = `${Constants.TWITTER_ROOT}/${screenName}/status/${tweet.id_str}`;
apiTweet.id = tweet.id_str;
apiTweet.text = unescapeText(linkFixer(tweet, tweet.full_text || ''));
apiTweet.url = `${Constants.TWITTER_ROOT}/${apiUser.screen_name}/status/${tweet.rest_id}`;
apiTweet.id = tweet.rest_id;
apiTweet.text = unescapeText(linkFixer(tweet, tweet.legacy.full_text || ''));
apiTweet.author = {
id: tweet.user_id_str,
name: name,
screen_name: screenName,
id: apiUser.id,
name: apiUser.name,
screen_name: apiUser.screen_name,
avatar_url:
(user?.profile_image_url_https || '').replace('_normal', '_200x200') || '',
avatar_color: colorFromPalette(
(apiUser.avatar_url || '').replace('_normal', '_200x200') || '',
avatar_color: '0000FF' /* colorFromPalette(
tweet.user?.profile_image_extensions_media_color?.palette || []
),
banner_url: user?.profile_banner_url || ''
),*/,
banner_url: apiUser.banner_url || ''
};
apiTweet.replies = tweet.reply_count;
apiTweet.retweets = tweet.retweet_count;
apiTweet.likes = tweet.favorite_count;
apiTweet.replies = tweet.legacy.reply_count;
apiTweet.retweets = tweet.legacy.retweet_count;
apiTweet.likes = tweet.legacy.favorite_count;
apiTweet.color = apiTweet.author.avatar_color;
apiTweet.twitter_card = 'tweet';
apiTweet.created_at = tweet.created_at;
apiTweet.created_timestamp = new Date(tweet.created_at).getTime() / 1000;
apiTweet.created_at = tweet.legacy.created_at;
apiTweet.created_timestamp = new Date(tweet.legacy.created_at).getTime() / 1000;
apiTweet.possibly_sensitive = tweet.possibly_sensitive;
apiTweet.possibly_sensitive = tweet.legacy.possibly_sensitive;
if (tweet.ext_views?.state === 'EnabledWithCount') {
apiTweet.views = parseInt(tweet.ext_views.count || '0') ?? null;
if (tweet.views.state === 'EnabledWithCount') {
apiTweet.views = parseInt(tweet.views.count || '0') ?? null;
} else {
apiTweet.views = null;
}
if (tweet.lang !== 'unk') {
apiTweet.lang = tweet.lang;
if (tweet.legacy.lang !== 'unk') {
apiTweet.lang = tweet.legacy.lang;
} else {
apiTweet.lang = null;
}
apiTweet.replying_to = tweet.in_reply_to_screen_name || null;
apiTweet.replying_to_status = tweet.in_reply_to_status_id_str || null;
apiTweet.replying_to = tweet.legacy?.in_reply_to_screen_name || null;
apiTweet.replying_to_status = tweet.legacy?.in_reply_to_status_id_str || null;
const mediaList = Array.from(
tweet.extended_entities?.media || tweet.entities?.media || []
tweet.legacy.extended_entities?.media || tweet.legacy.entities?.media || []
);
// console.log('tweet', JSON.stringify(tweet));
@ -100,13 +99,15 @@ const populateTweetProperties = async (
});
/* Grab color palette data */
/*
if (mediaList[0]?.ext_media_color?.palette) {
apiTweet.color = colorFromPalette(mediaList[0].ext_media_color.palette);
}
*/
/* Handle photos and mosaic if available */
if ((apiTweet.media?.photos?.length || 0) > 1) {
const mosaic = await handleMosaic(apiTweet.media?.photos || [], tweet.id_str);
const mosaic = await handleMosaic(apiTweet.media?.photos || [], tweet.rest_id);
if (typeof apiTweet.media !== 'undefined' && mosaic !== null) {
apiTweet.media.mosaic = mosaic;
}
@ -121,8 +122,9 @@ const populateTweetProperties = async (
}
/* Populate a Twitter card */
if (tweet.card) {
const card = await renderCard(tweet.card);
const card = renderCard(tweet.card);
if (card.external_media) {
apiTweet.twitter_card = 'summary_large_image';
apiTweet.media = apiTweet.media || {};
@ -134,7 +136,7 @@ const populateTweetProperties = async (
}
/* If a language is specified in API or by user, let's try translating it! */
if (typeof language === 'string' && language.length === 2 && language !== tweet.lang) {
if (typeof language === 'string' && language.length === 2 && language !== tweet.legacy.lang) {
const translateAPI = await translateTweet(
tweet,
conversation.guestToken || '',
@ -192,61 +194,50 @@ export const statusAPI = async (
event: FetchEvent,
flags?: InputFlags
): Promise<TweetAPIResponse> => {
let conversation = await fetchConversation(status, event);
let tweet = conversation?.globalObjects?.tweets?.[status] || {};
let wasMediaBlockedNSFW = false;
if (tweet.retweeted_status_id_str) {
tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {};
let res = await fetchConversation(status, event);
const tweet = res.data?.tweetResult?.result;
if (!tweet) {
return { code: 404, message: 'NOT_FOUND' };
}
if (tweet.__typename === 'TweetUnavailable' && tweet.reason === 'NsfwLoggedOut') {
wasMediaBlockedNSFW = true;
res = await fetchConversation(status, event, true);
}
/* Fallback for if Tweet did not load (i.e. NSFW) */
if (typeof tweet.full_text === 'undefined') {
if (conversation.timeline?.instructions?.length > 0) {
/* Try again using elongator API proxy */
console.log('No Tweet was found, loading again from elongator');
conversation = await fetchConversation(status, event, true);
tweet = conversation?.globalObjects?.tweets?.[status] || {};
console.log(JSON.stringify(tweet))
if (typeof tweet.full_text !== 'undefined') {
console.log('Successfully loaded Tweet using elongator');
wasMediaBlockedNSFW = true;
} else if (
typeof tweet.full_text === 'undefined' &&
conversation.timeline?.instructions?.length > 0
) {
console.log(
'Tweet could not be accessed with elongator, must be private/suspended, got tweet ',
tweet,
' conversation ',
conversation
);
writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags);
return { code: 401, message: 'PRIVATE_TWEET' };
}
if (tweet.__typename === 'TweetUnavailable') {
if (tweet.reason === 'Protected') {
writeDataPoint(event, language, wasMediaBlockedNSFW, 'PRIVATE_TWEET', flags);
return { code: 401, message: 'PRIVATE_TWEET' };
} else if (tweet.reason === 'NsfwLoggedOut') {
// API failure as elongator should have handled this
writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
return { code: 500, message: 'API_FAIL' };
} else {
/* {"errors":[{"code":34,"message":"Sorry, that page does not exist."}]} */
if (conversation.errors?.[0]?.code === 34) {
writeDataPoint(event, language, wasMediaBlockedNSFW, 'NOT_FOUND', flags);
return { code: 404, message: 'NOT_FOUND' };
}
/* Commented this the part below out for now since it seems like atm this check doesn't actually do anything */
/* Tweets object is completely missing, smells like API failure */
// if (typeof conversation?.globalObjects?.tweets === 'undefined') {
// writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
// return { code: 500, message: 'API_FAIL' };
// }
/* If we have no idea what happened then just return API error */
// Api failure at parsing status
writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
return { code: 500, message: 'API_FAIL' };
}
}
// If the tweet is not a graphQL tweet something went wrong
if (!isGraphQLTweet(tweet)) {
console.log('Tweet was not a valid tweet', tweet);
writeDataPoint(event, language, wasMediaBlockedNSFW, 'API_FAIL', flags);
return { code: 500, message: 'API_FAIL' };
}
/*
if (tweet.retweeted_status_id_str) {
tweet = conversation?.globalObjects?.tweets?.[tweet.retweeted_status_id_str] || {};
}
*/
if (!tweet) {
return { code: 404, message: 'NOT_FOUND' };
}
const conversation: any[] = [];
/* Creating the response objects */
const response: TweetAPIResponse = { code: 200, message: 'OK' } as TweetAPIResponse;
const apiTweet: APITweet = (await populateTweetProperties(
@ -256,8 +247,7 @@ export const statusAPI = async (
)) as APITweet;
/* We found a quote tweet, let's process that too */
const quoteTweet =
conversation.globalObjects?.tweets?.[tweet.quoted_status_id_str || '0'] || null;
const quoteTweet = tweet.quoted_status_result;
if (quoteTweet) {
apiTweet.quote = (await populateTweetProperties(
quoteTweet,

View file

@ -1,15 +1,8 @@
import { Constants } from '../constants';
import { fetchUser } from '../fetch';
/* This function does the heavy lifting of processing data from Twitter API
and using it to create FixTweet's streamlined API responses */
const populateUserProperties = async (
response: GraphQLUserResponse
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APIUser> => {
export const convertToApiUser = (user: GraphQLUser): APIUser => {
const apiUser = {} as APIUser;
const user = response.data.user.result;
/* Populating a lot of the basics */
apiUser.url = `${Constants.TWITTER_ROOT}/${user.legacy.screen_name}`;
apiUser.id = user.rest_id;
@ -21,6 +14,7 @@ const populateUserProperties = async (
apiUser.screen_name = user.legacy.screen_name;
apiUser.description = user.legacy.description;
apiUser.location = user.legacy.location;
apiUser.banner_url = user.legacy.profile_banner_url;
/*
if (user.is_blue_verified) {
apiUser.verified = 'blue';
@ -51,6 +45,16 @@ const populateUserProperties = async (
return apiUser;
};
/* This function does the heavy lifting of processing data from Twitter API
and using it to create FixTweet's streamlined API responses */
const populateUserProperties = async (
response: GraphQLUserResponse
// eslint-disable-next-line sonarjs/cognitive-complexity
): Promise<APIUser> => {
const user = response.data.user.result;
return convertToApiUser(user);
};
/* API for Twitter profiles (Users)
Used internally by FixTweet's embed service, or
available for free using api.fxtwitter.com. */
@ -60,7 +64,12 @@ export const userAPI = async (
flags?: InputFlags
): Promise<UserAPIResponse> => {
const userResponse = await fetchUser(username, event);
if (!userResponse || !Object.keys(userResponse).length) {
return {
code: 404,
message: 'User not found'
};
}
/* Creating the response objects */
const response: UserAPIResponse = { code: 200, message: 'OK' } as UserAPIResponse;
const apiUser: APIUser = (await populateUserProperties(userResponse)) as APIUser;

View file

@ -20,7 +20,7 @@ export const Constants = {
GUEST_TOKEN_MAX_AGE: 3 * 60 * 60,
/* Twitter Web App actually uses Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA
instead, but accounts marked as 18+ wouldn't show up then */
GUEST_BEARER_TOKEN: `Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw`,
GUEST_BEARER_TOKEN: `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`,
GUEST_FETCH_PARAMETERS: [
'cards_platform=Web-12',
'include_cards=1',

View file

@ -59,6 +59,7 @@ export const handleStatus = async (
case 404:
return returnError(Strings.ERROR_TWEET_NOT_FOUND);
case 500:
console.log(api);
return returnError(Strings.ERROR_API_FAIL);
}

View file

@ -1,7 +1,14 @@
import { Constants } from './constants';
import { generateUserAgent } from './helpers/useragent';
import { isGraphQLTweet } from './utils/graphql';
const API_ATTEMPTS = 16;
const API_ATTEMPTS = 3;
function generateCSRFToken() {
const randomBytes = new Uint8Array(160/2);
crypto.getRandomValues(randomBytes);
return Array.from(randomBytes, byte => byte.toString(16).padStart(2, '0')).join('');
}
export const twitterFetch = async (
url: string,
@ -51,14 +58,11 @@ export const twitterFetch = async (
const cache = caches.default;
while (apiAttempts < API_ATTEMPTS) {
const csrfToken = crypto
.randomUUID()
.replace(
/-/g,
''
); /* Generate a random CSRF token, this doesn't matter, Twitter just cares that header and cookie match */
/* Generate a random CSRF token, Twitter just cares that header and cookie match,
REST can use shorter csrf tokens (32 bytes) but graphql prefers 160 bytes */
const csrfToken = generateCSRFToken();
const headers: { [header: string]: string } = {
const headers: Record<string, string> = {
Authorization: Constants.GUEST_BEARER_TOKEN,
...Constants.BASE_HEADERS
};
@ -188,20 +192,61 @@ export const fetchConversation = async (
status: string,
event: FetchEvent,
useElongator = false
): Promise<TimelineBlobPartial> => {
): Promise<TweetResultsByRestIdResult> => {
return (await twitterFetch(
`${Constants.TWITTER_API_ROOT}/2/timeline/conversation/${status}.json?${Constants.GUEST_FETCH_PARAMETERS}`,
`${
Constants.TWITTER_ROOT
}/i/api/graphql/2ICDjqPd81tulZcYrtpTuQ/TweetResultByRestId?variables=${encodeURIComponent(
JSON.stringify({"tweetId": status,"withCommunity":false,"includePromotedContent":false,"withVoice":false})
)}&features=${encodeURIComponent(
JSON.stringify({
creator_subscriptions_tweet_preview_api_enabled:true,
tweetypie_unmention_optimization_enabled:true,
responsive_web_edit_tweet_api_enabled:true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled:true,
view_counts_everywhere_api_enabled:true,
longform_notetweets_consumption_enabled:true,
responsive_web_twitter_article_tweet_consumption_enabled:false,
tweet_awards_web_tipping_enabled:false,
freedom_of_speech_not_reach_fetch_enabled:true,
standardized_nudges_misinfo:true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled:true,
longform_notetweets_rich_text_read_enabled:true,
longform_notetweets_inline_media_enabled:true,
responsive_web_graphql_exclude_directive_enabled:true,
verified_phone_label_enabled:false,
responsive_web_media_download_video_enabled:false,
responsive_web_graphql_skip_user_profile_image_extensions_enabled:false,
responsive_web_graphql_timeline_navigation_enabled:true,
responsive_web_enhance_cards_enabled:false})
)}&fieldToggles=${encodeURIComponent(
JSON.stringify({
// TODO Figure out what this property does
withArticleRichContentState: false
})
)}`,
event,
useElongator,
(_conversation: unknown) => {
const conversation = _conversation as TimelineBlobPartial;
return !(
typeof conversation.globalObjects === 'undefined' &&
(typeof conversation.errors === 'undefined' ||
conversation.errors?.[0]?.code === 239)
);
const conversation = _conversation as TweetResultsByRestIdResult;
// If we get a not found error it's still a valid response
const tweet = conversation.data?.tweetResult?.result;
if (isGraphQLTweet(tweet)) {
return true;
}
if (tweet?.__typename === 'TweetUnavailable' && tweet.reason === 'Protected') {
return true;
}
if (tweet?.__typename === 'TweetUnavailable' && tweet.reason === 'NsfwLoggedOut') {
return true;
}
if (tweet?.__typename === 'TweetUnavailable') {
return true;
}
// Final clause for checking if it's valid is if there's errors
return Array.isArray(conversation.errors)
}
)) as TimelineBlobPartial;
)) as TweetResultsByRestIdResult;
};
export const fetchUser = async (
@ -231,6 +276,10 @@ export const fetchUser = async (
// Validator function
(_res: unknown) => {
const response = _res as GraphQLUserResponse;
// If _res.data is an empty object, we have no user
if (!Object.keys(response?.data).length) {
return false;
}
return !(
response?.data?.user?.result?.__typename !== 'User' ||
typeof response.data.user.result.legacy === 'undefined'

View file

@ -1,34 +1,44 @@
import { calculateTimeLeftString } from './pollTime';
/* Renders card for polls and non-Twitter video embeds (i.e. YouTube) */
export const renderCard = async (
card: TweetCard
): Promise<{ poll?: APIPoll; external_media?: APIExternalMedia }> => {
const values = card.binding_values;
export const renderCard = (
card: GraphQLTweet['card']
): { poll?: APIPoll; external_media?: APIExternalMedia } => {
// We convert the binding_values array into an object with the legacy format
// TODO Clean this up
const binding_values: Record<string, { string_value?: string; boolean_value?: boolean }> = {};
if (Array.isArray(card.legacy.binding_values)) {
card.legacy.binding_values.forEach(value => {
if (value.key && value.value) {
binding_values[value.key] = value.value;
}
});
}
console.log('rendering card');
if (typeof values !== 'undefined') {
if (typeof values.choice1_count !== 'undefined') {
if (typeof binding_values !== 'undefined') {
if (typeof binding_values.choice1_count !== 'undefined') {
const poll = {} as APIPoll;
poll.ends_at = values.end_datetime_utc?.string_value || '';
poll.ends_at = binding_values.end_datetime_utc?.string_value || '';
poll.time_left_en = calculateTimeLeftString(
new Date(values.end_datetime_utc?.string_value || '')
new Date(binding_values.end_datetime_utc?.string_value || '')
);
const choices: { [label: string]: number } = {
[values.choice1_label?.string_value || '']: parseInt(
values.choice1_count?.string_value || '0'
[binding_values.choice1_label?.string_value || '']: parseInt(
binding_values.choice1_count?.string_value || '0'
),
[values.choice2_label?.string_value || '']: parseInt(
values.choice2_count?.string_value || '0'
[binding_values.choice2_label?.string_value || '']: parseInt(
binding_values.choice2_count?.string_value || '0'
),
[values.choice3_label?.string_value || '']: parseInt(
values.choice3_count?.string_value || '0'
[binding_values.choice3_label?.string_value || '']: parseInt(
binding_values.choice3_count?.string_value || '0'
),
[values.choice4_label?.string_value || '']: parseInt(
values.choice4_count?.string_value || '0'
[binding_values.choice4_label?.string_value || '']: parseInt(
binding_values.choice4_count?.string_value || '0'
)
};
@ -46,17 +56,17 @@ export const renderCard = async (
});
return { poll: poll };
} else if (typeof values.player_url !== 'undefined') {
} else if (typeof binding_values.player_url !== 'undefined' && binding_values.player_url.string_value) {
/* Oh good, a non-Twitter video URL! This enables YouTube embeds and stuff to just work */
return {
external_media: {
type: 'video',
url: values.player_url.string_value,
url: binding_values.player_url.string_value,
width: parseInt(
(values.player_width?.string_value || '1280').replace('px', '')
(binding_values.player_width?.string_value || '1280').replace('px', '')
), // TODO: Replacing px might not be necessary, it's just there as a precaution
height: parseInt(
(values.player_height?.string_value || '720').replace('px', '')
(binding_values.player_height?.string_value || '720').replace('px', '')
)
}
};

View file

@ -1,7 +1,10 @@
/* Helps replace t.co links with their originals */
export const linkFixer = (tweet: TweetPartial, text: string): string => {
if (typeof tweet.entities?.urls !== 'undefined') {
tweet.entities?.urls.forEach((url: TcoExpansion) => {
export const linkFixer = (tweet: GraphQLTweet, text: string): string => {
console.log('got entites', {
entities: tweet.legacy.entities,
})
if (Array.isArray(tweet.legacy.entities?.urls) && tweet.legacy.entities.urls.length) {
tweet.legacy.entities.urls.forEach((url: TcoExpansion) => {
let newURL = url.expanded_url;
if (newURL.match(/^https:\/\/twitter\.com\/i\/web\/status\/\w+/g) !== null) {

View file

@ -2,7 +2,7 @@ import { Constants } from '../constants';
/* Handles translating Tweets when asked! */
export const translateTweet = async (
tweet: TweetPartial,
tweet: GraphQLTweet,
guestToken: string,
language: string
): Promise<TranslationPartial | null> => {
@ -29,7 +29,7 @@ export const translateTweet = async (
try {
apiRequest = await fetch(
`${Constants.TWITTER_ROOT}/i/api/1.1/strato/column/None/tweetId=${tweet.id_str},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`,
`${Constants.TWITTER_ROOT}/i/api/1.1/strato/column/None/tweetId=${tweet.rest_id},destinationLanguage=None,translationSource=Some(Google),feature=None,timeout=None,onlyCached=None/translation/service/translateTweet`,
{
method: 'GET',
headers: headers

7
src/utils/graphql.ts Normal file
View file

@ -0,0 +1,7 @@
export const isGraphQLTweetNotFoundResponse = (response: unknown): response is GraphQLTweetNotFoundResponse => {
return typeof response === 'object' && response !== null && 'errors' in response && Array.isArray(response.errors) && response.errors.length > 0 && 'message' in response.errors[0] && response.errors[0].message === '_Missing: No status found with that ID';
};
export const isGraphQLTweet = (response: unknown): response is GraphQLTweet => {
return typeof response === 'object' && response !== null && '__typename' in response && response.__typename === 'Tweet';
}

View file

@ -115,7 +115,7 @@ test('API fetch video Tweet', async () => {
expect(tweet.url).toEqual('https://twitter.com/Twitter/status/854416760933556224');
expect(tweet.id).toEqual('854416760933556224');
expect(tweet.text).toEqual(
'Get the sauces ready, #NuggsForCarter has 3 million+ Retweets.'
'Get the sauces ready, #NuggsForCarter has 3 million+ Retweets. https://t.co/ydLBtfK3Z3'
);
expect(tweet.author.screen_name?.toLowerCase()).toEqual('twitter');
expect(tweet.author.id).toEqual('783214');
@ -160,7 +160,7 @@ test('API fetch multi-photo Tweet', async () => {
expect(tweet).toBeTruthy();
expect(tweet.url).toEqual('https://twitter.com/Twitter/status/1445094085593866246');
expect(tweet.id).toEqual('1445094085593866246');
expect(tweet.text).toEqual('@netflix');
expect(tweet.text).toEqual('@netflix https://t.co/W0XPnj2qLP');
expect(tweet.author.screen_name?.toLowerCase()).toEqual('twitter');
expect(tweet.author.id).toEqual('783214');
expect(tweet.author.name).toBeTruthy();
@ -270,3 +270,18 @@ test('API fetch user', async () => {
expect(user.birthday.month).toEqual(3);
expect(user.birthday.year).toBeUndefined();
});
test('API fetch user that does not exist', async () => {
const result = await cacheWrapper(
new Request('https://api.fxtwitter.com/usesaahah123', {
method: 'GET',
headers: botHeaders
})
);
expect(result.status).toEqual(404);
const response = (await result.json()) as UserAPIResponse;
expect(response).toBeTruthy();
expect(response.code).toEqual(404);
expect(response.message).toEqual('User not found');
expect(response.user).toBeUndefined();
});