diff --git a/.gitignore b/.gitignore index 05e96ed..5b4bfdc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ .DS_Store .env* !.env.example +coverage/ # Temporary files cache*.json diff --git a/meta/testing/jest.config.cjs b/meta/testing/jest.config.cjs index 7a82052..04aacc7 100644 --- a/meta/testing/jest.config.cjs +++ b/meta/testing/jest.config.cjs @@ -11,6 +11,9 @@ module.exports = { transform: { "^.+\\.ts?$": ["ts-jest", { useESM: true, + }], + "^.+\\.js?$": ["ts-jest", { + useESM: true, }] }, verbose: false, diff --git a/src/helpers/medias/__tests__/files/image-gif.gif b/src/helpers/medias/__tests__/files/image-gif.gif new file mode 100644 index 0000000..6c68e32 Binary files /dev/null and b/src/helpers/medias/__tests__/files/image-gif.gif differ diff --git a/src/helpers/medias/__tests__/files/image-jpeg.jpeg b/src/helpers/medias/__tests__/files/image-jpeg.jpeg new file mode 100644 index 0000000..c455962 Binary files /dev/null and b/src/helpers/medias/__tests__/files/image-jpeg.jpeg differ diff --git a/src/helpers/medias/__tests__/files/image-jpg.jpg b/src/helpers/medias/__tests__/files/image-jpg.jpg new file mode 100644 index 0000000..929985c Binary files /dev/null and b/src/helpers/medias/__tests__/files/image-jpg.jpg differ diff --git a/src/helpers/medias/__tests__/files/image-png.png b/src/helpers/medias/__tests__/files/image-png.png new file mode 100644 index 0000000..7eac084 Binary files /dev/null and b/src/helpers/medias/__tests__/files/image-png.png differ diff --git a/src/helpers/medias/__tests__/files/image-webp.webp b/src/helpers/medias/__tests__/files/image-webp.webp new file mode 100644 index 0000000..62cb780 Binary files /dev/null and b/src/helpers/medias/__tests__/files/image-webp.webp differ diff --git a/src/helpers/medias/__tests__/files/video-mp4.mp4 b/src/helpers/medias/__tests__/files/video-mp4.mp4 new file mode 100644 index 0000000..e21f2a1 Binary files /dev/null and b/src/helpers/medias/__tests__/files/video-mp4.mp4 differ diff --git a/src/helpers/medias/__tests__/helpers/make-blob-from-file.ts b/src/helpers/medias/__tests__/helpers/make-blob-from-file.ts new file mode 100644 index 0000000..aaf0a4f --- /dev/null +++ b/src/helpers/medias/__tests__/helpers/make-blob-from-file.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; + +export const makeBlobFromFile = async ( + fileName: string, + mimeType: string, +): Promise => { + const file = readFileSync(resolve(__dirname, `../files/${fileName}`)); + return new Blob([file], { type: mimeType }); +}; + +const makeUint8ArrayFromBlob = async (blob: Blob): Promise => { + const arrayBuffer = await blob.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + return new Uint8Array(buffer); +}; + +export const makeUint8ArrayFromFile = async ( + fileName: string, + mimeType: string, +): Promise => { + const blob = await makeBlobFromFile(fileName, mimeType); + return await makeUint8ArrayFromBlob(blob); +}; diff --git a/src/helpers/medias/__tests__/parse-blob-for-bluesky.spec.ts b/src/helpers/medias/__tests__/parse-blob-for-bluesky.spec.ts new file mode 100644 index 0000000..5276d96 --- /dev/null +++ b/src/helpers/medias/__tests__/parse-blob-for-bluesky.spec.ts @@ -0,0 +1,51 @@ +import { parseBlobForBluesky } from "../parse-blob-for-bluesky.js"; +import { + makeBlobFromFile, + makeUint8ArrayFromFile, +} from "./helpers/make-blob-from-file.js"; + +let imageBlob: Blob; +let bskyBlobData: Uint8Array; +const initBlobsForMime = async (mimeType: string) => { + const [type, extension] = mimeType.split("/"); + const fileName = `${type}-${extension}.${extension}`; + + bskyBlobData = await makeUint8ArrayFromFile(fileName, mimeType); + imageBlob = await makeBlobFromFile(fileName, mimeType); +}; + +describe("parseBlobForBluesky", () => { + describe("when the mime type is not supported", () => { + it.each` + mimeType + ${"video/mp4"} + ${"image/webp"} + `("should reject for $mimeType", async ({ mimeType }) => { + await initBlobsForMime(mimeType); + + await expect(parseBlobForBluesky(imageBlob)).rejects.toContain( + "not supported", + ); + }); + }); + + describe("when the mime type is supported", () => { + it.each` + mimeType + ${"image/jpeg"} + ${"image/jpg"} + ${"image/png"} + `( + "should resolve bluesky compatible blob for $mimeType", + async ({ mimeType }) => { + await initBlobsForMime(mimeType); + + const result = await parseBlobForBluesky(imageBlob); + expect(result).toStrictEqual({ + blobData: bskyBlobData, + mimeType, + }); + }, + ); + }); +}); diff --git a/src/helpers/medias/parse-blob-for-bluesky.ts b/src/helpers/medias/parse-blob-for-bluesky.ts index 6d73130..826ca90 100644 --- a/src/helpers/medias/parse-blob-for-bluesky.ts +++ b/src/helpers/medias/parse-blob-for-bluesky.ts @@ -9,7 +9,7 @@ interface BlueskyBlob { */ export const parseBlobForBluesky = async (blob: Blob): Promise => { return new Promise((resolve, reject) => { - const allowedMimeTypes = ["image/jpeg", "image/png"]; + const allowedMimeTypes = ["image/jpeg", "image/jpg", "image/png"]; const mimeType = blob.type; blob.arrayBuffer().then((ab) => { diff --git a/src/helpers/post/__tests__/get-post-excerpt.spec.ts b/src/helpers/post/__tests__/get-post-excerpt.spec.ts new file mode 100644 index 0000000..3a11b4f --- /dev/null +++ b/src/helpers/post/__tests__/get-post-excerpt.spec.ts @@ -0,0 +1,10 @@ +import { getPostExcerpt } from "../get-post-excerpt.js"; + +describe("getPostExcerpt", () => { + it("should return the 25 first characters and remove break lines", () => { + const result = getPostExcerpt( + "This is a test from\n the Potato Inc. tech lead", + ); + expect(result).toBe("« This is a test from the P... »"); + }); +}); diff --git a/src/helpers/tweet/__tests__/format-tweet-text.spec.ts b/src/helpers/tweet/__tests__/format-tweet-text.spec.ts index cbe32b3..3c46b34 100644 --- a/src/helpers/tweet/__tests__/format-tweet-text.spec.ts +++ b/src/helpers/tweet/__tests__/format-tweet-text.spec.ts @@ -3,12 +3,25 @@ import { Tweet } from "@the-convocation/twitter-scraper"; import { formatTweetText } from "../format-tweet-text.js"; describe("formatTweetText", () => { - it("should keep untouched a tweet without links", () => { - const tweet: Tweet = { - text: "This is a tweet without links.", - urls: [], - } as unknown as Tweet; - const result = formatTweetText(tweet); - expect(result).toStrictEqual("This is a tweet without links."); + describe("when the tweet has links", () => { + it("should remove the t.co links from the text", () => { + const tweet: Tweet = { + text: "This is a tweet with a link", + urls: ["https://t.co/abc123"], + } as unknown as Tweet; + const result = formatTweetText(tweet); + expect(result).toStrictEqual("This is a tweet with a link"); + }); + }); + + describe("when the tweet has media links", () => { + it("should remove the t.co links from the text", () => { + const tweet: Tweet = { + text: "This is a tweet with a media https://t.co/media123", + urls: [], + } as unknown as Tweet; + const result = formatTweetText(tweet); + expect(result).toStrictEqual("This is a tweet with a media"); + }); }); }); diff --git a/src/helpers/tweet/__tests__/get-eligible-tweet.spec.ts b/src/helpers/tweet/__tests__/get-eligible-tweet.spec.ts new file mode 100644 index 0000000..4fc91e4 --- /dev/null +++ b/src/helpers/tweet/__tests__/get-eligible-tweet.spec.ts @@ -0,0 +1,54 @@ +import { Tweet } from "@the-convocation/twitter-scraper"; + +import { getEligibleTweet } from "../get-eligible-tweet.js"; +import { keepRecentTweets, keepSelfQuotes, keepSelfReplies } from "../index.js"; + +jest.mock("../../../constants.js", () => { + return { + TWITTER_HANDLE: "username", + }; +}); +jest.mock("../index.js", () => { + return { + keepSelfReplies: jest.fn(), + keepSelfQuotes: jest.fn(), + keepRecentTweets: jest.fn(), + }; +}); + +describe("getEligibleTweet", () => { + it.each` + isNotRetweet | isSelfReply | isSelfQuote | isRecentTweet | keep + ${false} | ${false} | ${false} | ${false} | ${false} + ${true} | ${true} | ${false} | ${false} | ${false} + ${true} | ${false} | ${true} | ${false} | ${false} + ${true} | ${false} | ${false} | ${true} | ${false} + ${true} | ${true} | ${true} | ${true} | ${true} + `( + "should only return keep tweet when all conditions are met", + async ({ isNotRetweet, isSelfReply, isSelfQuote, isRecentTweet, keep }) => { + // Set mocks values + (keepSelfReplies as jest.Mock).mockReturnValue(isSelfReply); + (keepSelfQuotes as jest.Mock).mockReturnValue(isSelfQuote); + (keepRecentTweets as jest.Mock).mockReturnValue(isRecentTweet); + + // Run test + const result = await getEligibleTweet({ + isRetweet: !isNotRetweet, + } as unknown as Tweet); + + // We're only checking for the keep status + expect(result).toStrictEqual( + keep + ? { + inReplyToStatus: undefined, + inReplyToStatusId: undefined, + isRetweet: !isNotRetweet, + quotedStatus: undefined, + quotedStatusId: undefined, + } + : undefined, + ); + }, + ); +}); diff --git a/src/helpers/tweet/__tests__/get-tweet-id-from-permalink.spec.ts b/src/helpers/tweet/__tests__/get-tweet-id-from-permalink.spec.ts new file mode 100644 index 0000000..46df325 --- /dev/null +++ b/src/helpers/tweet/__tests__/get-tweet-id-from-permalink.spec.ts @@ -0,0 +1,10 @@ +import { getTweetIdFromPermalink } from "../get-tweet-id-from-permalink.js"; + +describe("getTweetIdFromPermalink", () => { + it("should return the tweet id", () => { + const result = getTweetIdFromPermalink( + "https://twitter.com/username/status/1234567890123456789", + ); + expect(result).toBe("1234567890123456789"); + }); +}); diff --git a/src/helpers/tweet/__tests__/is-tweet-cached.spec.ts b/src/helpers/tweet/__tests__/is-tweet-cached.spec.ts new file mode 100644 index 0000000..34021a9 --- /dev/null +++ b/src/helpers/tweet/__tests__/is-tweet-cached.spec.ts @@ -0,0 +1,21 @@ +import { Tweet } from "@the-convocation/twitter-scraper"; + +import { isTweetCached } from "../is-tweet-cached.js"; + +describe("isTweetCached", () => { + it("should return true if the tweet is cached", () => { + const result = isTweetCached( + { id: "1234567890123456789" } as unknown as Tweet, + { + "1234567890123456789": { + mastodon: "mastodonId", + bluesky: { + cid: "cid", + rkey: "rkey", + }, + }, + }, + ); + expect(result).toBe(true); + }); +}); diff --git a/src/helpers/tweet/__tests__/keep-recent-tweets.spec.ts b/src/helpers/tweet/__tests__/keep-recent-tweets.spec.ts new file mode 100644 index 0000000..3c2cf1c --- /dev/null +++ b/src/helpers/tweet/__tests__/keep-recent-tweets.spec.ts @@ -0,0 +1,23 @@ +import { Tweet } from "@the-convocation/twitter-scraper"; + +import { keepRecentTweets } from "../keep-recent-tweets.js"; + +describe("keepRecentTweets", () => { + describe("when the tweet is recent", () => { + it("should return true", () => { + const result = keepRecentTweets({ + timestamp: Date.now(), + } as unknown as Tweet); + expect(result).toBe(true); + }); + }); + + describe("when the tweet is old", () => { + it("should return false", () => { + const result = keepRecentTweets({ + timestamp: new Date("1997-01-01").getTime(), + } as unknown as Tweet); + expect(result).toBe(false); + }); + }); +}); diff --git a/src/helpers/tweet/__tests__/keep-self-quotes.spec.ts b/src/helpers/tweet/__tests__/keep-self-quotes.spec.ts new file mode 100644 index 0000000..ae03cde --- /dev/null +++ b/src/helpers/tweet/__tests__/keep-self-quotes.spec.ts @@ -0,0 +1,37 @@ +import { Tweet } from "@the-convocation/twitter-scraper"; + +import { keepSelfQuotes } from "../keep-self-quotes.js"; + +jest.mock("../../../constants.js", () => { + return { + TWITTER_HANDLE: "username", + }; +}); + +describe("keepSelfQuotes", () => { + describe("when the tweet is a quote", () => { + it("should return true when is from the same user", async () => { + const result = await keepSelfQuotes({ + quotedStatus: { username: "username" }, + } as unknown as Tweet); + + expect(result).toBe(true); + }); + + it("should return false when is from a different user", async () => { + const result = await keepSelfQuotes({ + quotedStatus: { username: "potatoinc" }, + } as unknown as Tweet); + + expect(result).toBe(false); + }); + }); + + describe("when the tweet is not a quote", () => { + it("should return true when is from the same user", async () => { + const result = await keepSelfQuotes({} as unknown as Tweet); + + expect(result).toBe(true); + }); + }); +}); diff --git a/src/helpers/tweet/__tests__/keep-self-replies.spec.ts b/src/helpers/tweet/__tests__/keep-self-replies.spec.ts new file mode 100644 index 0000000..148cfbe --- /dev/null +++ b/src/helpers/tweet/__tests__/keep-self-replies.spec.ts @@ -0,0 +1,37 @@ +import { Tweet } from "@the-convocation/twitter-scraper"; + +import { keepSelfReplies } from "../keep-self-replies.js"; + +jest.mock("../../../constants.js", () => { + return { + TWITTER_HANDLE: "username", + }; +}); + +describe("keepSelfReplies", () => { + describe("when the tweet is a reply", () => { + it("should return true when is to the same user", async () => { + const result = await keepSelfReplies({ + inReplyToStatus: { username: "username" }, + } as unknown as Tweet); + + expect(result).toBe(true); + }); + + it("should return false when is to a different user", async () => { + const result = await keepSelfReplies({ + inReplyToStatus: { username: "potatoinc" }, + } as unknown as Tweet); + + expect(result).toBe(false); + }); + }); + + describe("when the tweet is not a reply", () => { + it("should return true when is from the same user", async () => { + const result = await keepSelfReplies({} as unknown as Tweet); + + expect(result).toBe(true); + }); + }); +}); diff --git a/src/helpers/tweet/format-tweet-text.ts b/src/helpers/tweet/format-tweet-text.ts index d8e9407..994b657 100644 --- a/src/helpers/tweet/format-tweet-text.ts +++ b/src/helpers/tweet/format-tweet-text.ts @@ -1,7 +1,7 @@ import { Tweet } from "@the-convocation/twitter-scraper"; export const formatTweetText = (tweet: Tweet): string => { - let text = tweet.text || ""; + let text = tweet.text ?? ""; // Replace urls tweet.urls.forEach((url) => { @@ -12,5 +12,5 @@ export const formatTweetText = (tweet: Tweet): string => { text = text.replaceAll(/https:\/\/t\.co\/\w+/g, ""); // Return formatted - return text; + return text.trim(); }; diff --git a/src/helpers/tweet/get-eligible-tweet.ts b/src/helpers/tweet/get-eligible-tweet.ts index 01aea45..43bc0f7 100644 --- a/src/helpers/tweet/get-eligible-tweet.ts +++ b/src/helpers/tweet/get-eligible-tweet.ts @@ -28,7 +28,7 @@ export const getEligibleTweet = async ( if (DEBUG && keep) { console.log( `✅ : ${tweet.id}: from:@${tweet.username}: ${getPostExcerpt( - tweet.text || "", + tweet.text ?? "", )}`, ); } diff --git a/src/helpers/url/__tests__/get-redirections.spec.ts b/src/helpers/url/__tests__/get-redirections.spec.ts new file mode 100644 index 0000000..c0ec9e0 --- /dev/null +++ b/src/helpers/url/__tests__/get-redirections.spec.ts @@ -0,0 +1,16 @@ +import { getRedirectedUrl } from "../get-redirection.js"; + +describe("getRedirectedUrl", () => { + describe("when the url is redirected", () => { + it("should return the final url", async () => { + const result = await getRedirectedUrl("https://t.co/bbJgfyzcJR"); + expect(result).toStrictEqual("https://github.com/"); + }); + }); + describe("when the url is not redirected", () => { + it("should return null", async () => { + const result = await getRedirectedUrl("https://t.co/_____null_____"); + expect(result).toStrictEqual(null); + }); + }); +}); diff --git a/src/helpers/url/__tests__/shortened-urls-replacer.spec.ts b/src/helpers/url/__tests__/shortened-urls-replacer.spec.ts new file mode 100644 index 0000000..fff7d9a --- /dev/null +++ b/src/helpers/url/__tests__/shortened-urls-replacer.spec.ts @@ -0,0 +1,40 @@ +import { shortenedUrlsReplacer } from "../shortened-urls-replacer.js"; + +jest.mock("../get-redirection.js", () => { + return { + getRedirectedUrl: jest.fn((url: string) => { + const number = url.match(/\/(\d+)/)?.[1]; + return Promise.resolve(number ? `https://example.com/${number}` : null); + }), + }; +}); + +describe("shortenedUrlsReplacer", () => { + it("should replace all urls from a given text with urls", async () => { + const result = await shortenedUrlsReplacer( + "Some text then url1 https://t.co/1 and url2 https://t.co/2.", + ); + + expect(result).toStrictEqual( + "Some text then url1 https://example.com/1 and url2 https://example.com/2.", + ); + }); + + it("should not change the text if there is no url", async () => { + const result = await shortenedUrlsReplacer("Some text then the end."); + + expect(result).toStrictEqual("Some text then the end."); + }); + + describe("when some urls are not resolved properly", () => { + it("should only replace the resolved ones", async () => { + const result = await shortenedUrlsReplacer( + "Some text then url1 https://t.co/1 and url2 https://t.co/broken.", + ); + + expect(result).toStrictEqual( + "Some text then url1 https://example.com/1 and url2 https://t.co/broken.", + ); + }); + }); +}); diff --git a/src/helpers/url/shortened-urls-replacer.ts b/src/helpers/url/shortened-urls-replacer.ts index 4e1d670..acea962 100644 --- a/src/helpers/url/shortened-urls-replacer.ts +++ b/src/helpers/url/shortened-urls-replacer.ts @@ -11,14 +11,16 @@ export const shortenedUrlsReplacer = async (text: string): Promise => { if (!matches.length) { return text; } - // Get all original urls const replacedItems = await Promise.all( - matches.map(async (match) => (await getRedirectedUrl(match[0])) ?? ""), + matches.map(async (match) => await getRedirectedUrl(match[0])), ); - // Replace shortened urls to original ones + // Replace shortened urls to original ones, remove non-resolved ones. return matches.reduce((description, match, index) => { - return description.replace(TWITTER_URL_SHORTENER, replacedItems[index]); + const resolvedUrl = replacedItems[index]; + return resolvedUrl + ? description.replace(match[0], resolvedUrl) + : description; }, text); };