From bcdd1bca2a462f39a6c3b8d7bfbaebb4538302f6 Mon Sep 17 00:00:00 2001 From: Louis Grasset Date: Thu, 23 Nov 2023 03:14:10 +0100 Subject: [PATCH 1/6] test(services): cover tweet-getter-service --- .../__tests__/tweets-getter.service.spec.ts | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/services/__tests__/tweets-getter.service.spec.ts b/src/services/__tests__/tweets-getter.service.spec.ts index 93f0c51..3e044d7 100644 --- a/src/services/__tests__/tweets-getter.service.spec.ts +++ b/src/services/__tests__/tweets-getter.service.spec.ts @@ -1,5 +1,6 @@ import { Scraper, Tweet } from "@the-convocation/twitter-scraper"; +import { isTweetCached } from "../../helpers/tweet/index.js"; import { tweetsGetterService } from "../tweets-getter.service.js"; jest.mock("../../constants.js", () => ({})); @@ -57,12 +58,18 @@ const makeTweetMock = (update: Partial): Tweet => { }; class MockTwitterClient { + constructor(tweetCount?: number) { + this.tweetCount = tweetCount || 200; + } + + private readonly tweetCount: number; + public async *getTweets( user: string, maxTweets?: number, ): AsyncGenerator { // Mocking the asynchronous generator function - for (let i = 0; i < (maxTweets || 200); i++) { + for (let i = 0; i < (this.tweetCount ?? maxTweets); i++) { yield { ...makeTweetMock({ username: user }), id: i.toString(), @@ -71,10 +78,38 @@ class MockTwitterClient { } } +jest.mock("../../helpers/tweet/index.js", () => { + const originalModule = jest.requireActual("../../helpers/tweet/index.js"); + return { + ...originalModule, + isTweetCached: jest.fn(), + }; +}); + +const isTweetCachedMock = isTweetCached as jest.Mock; + describe("tweetsGetterService", () => { - it("should return a list of tweets", async () => { - const client = new MockTwitterClient(); - const tweets = await tweetsGetterService(client as Scraper); - expect(tweets).toHaveLength(200); + describe("when tweets are not cached", () => { + beforeEach(() => { + isTweetCachedMock.mockReturnValue(false); + }); + + it("should be kept", async () => { + const client = new MockTwitterClient(3); + const tweets = await tweetsGetterService(client as unknown as Scraper); + expect(tweets).toHaveLength(3); + }); + }); + + describe("when tweets are cached", () => { + beforeEach(() => { + isTweetCachedMock.mockReturnValue(true); + }); + + it("should be skipped", async () => { + const client = new MockTwitterClient(3); + const tweets = await tweetsGetterService(client as unknown as Scraper); + expect(tweets).toHaveLength(0); + }); }); }); From 8937da79f414e35d785f5788bdb7f8d05a18df24 Mon Sep 17 00:00:00 2001 From: Louis Grasset Date: Thu, 23 Nov 2023 03:22:26 +0100 Subject: [PATCH 2/6] ci(ts-jest): rely on `ts-jest/presets/default-esm` preset --- meta/testing/jest.config.cjs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/meta/testing/jest.config.cjs b/meta/testing/jest.config.cjs index 20eed35..da8c5eb 100644 --- a/meta/testing/jest.config.cjs +++ b/meta/testing/jest.config.cjs @@ -11,14 +11,7 @@ module.exports = { setupFilesAfterEnv: ['/meta/testing/jest.setup.cjs'], testRegex: '(/__tests__/.*\\.spec)\\.ts?$', testTimeout: 10000, - transform: { - "^.+\\.ts?$": ["ts-jest", { - useESM: true, - }], - "^.+\\.js?$": ["ts-jest", { - useESM: true, - }] - }, + preset: "ts-jest/presets/default-esm", verbose: false, maxWorkers: "50%" }; From e03f157ef53481368b24df41715053cae367797d Mon Sep 17 00:00:00 2001 From: Louis Grasset Date: Thu, 23 Nov 2023 05:46:23 +0100 Subject: [PATCH 3/6] ci(ts-jest): add transformIgnorePatterns --- meta/testing/jest.config.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/meta/testing/jest.config.cjs b/meta/testing/jest.config.cjs index da8c5eb..9cac7e4 100644 --- a/meta/testing/jest.config.cjs +++ b/meta/testing/jest.config.cjs @@ -13,5 +13,6 @@ module.exports = { testTimeout: 10000, preset: "ts-jest/presets/default-esm", verbose: false, - maxWorkers: "50%" + maxWorkers: "50%", + transformIgnorePatterns: [] }; From 1c11fd0a3376ce0c314a184470e1616da879e0ba Mon Sep 17 00:00:00 2001 From: Louis Grasset Date: Thu, 23 Nov 2023 05:46:42 +0100 Subject: [PATCH 4/6] test(ora): add mock --- src/__tests__/__mocks__/ora.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/__tests__/__mocks__/ora.ts diff --git a/src/__tests__/__mocks__/ora.ts b/src/__tests__/__mocks__/ora.ts new file mode 100644 index 0000000..95747bc --- /dev/null +++ b/src/__tests__/__mocks__/ora.ts @@ -0,0 +1,12 @@ +module.exports = { + name: "ora", + default: jest.fn(() => ({ + start: jest + .fn() + .mockImplementation(() => ({ text: "", succeed: jest.fn() })), + succeed: jest.fn(), + fail: jest.fn(), + warn: jest.fn(), + })), + __esModule: true, +}; From eff6976107d58a5802a81bba0f6e263993297d6e Mon Sep 17 00:00:00 2001 From: Louis Grasset Date: Thu, 23 Nov 2023 05:47:55 +0100 Subject: [PATCH 5/6] test(services): extract tweet-getter-service tooling --- .../__tests__/helpers/make-tweet-mock.ts | 44 ++++++++++ .../__tests__/mocks/twitter-client.ts | 24 ++++++ .../__tests__/tweets-getter.service.spec.ts | 86 +------------------ 3 files changed, 72 insertions(+), 82 deletions(-) create mode 100644 src/services/__tests__/helpers/make-tweet-mock.ts create mode 100644 src/services/__tests__/mocks/twitter-client.ts diff --git a/src/services/__tests__/helpers/make-tweet-mock.ts b/src/services/__tests__/helpers/make-tweet-mock.ts new file mode 100644 index 0000000..27e580c --- /dev/null +++ b/src/services/__tests__/helpers/make-tweet-mock.ts @@ -0,0 +1,44 @@ +import { Tweet } from "@the-convocation/twitter-scraper"; + +export const makeTweetMock = (update: Partial = {}): Tweet => { + const text = update.text || "Hello World"; + return { + id: Math.floor( + 1000000000000000000 + Math.random() * 9000000000000000000, + ).toString(), + conversationId: undefined, + hashtags: [], + html: text, + inReplyToStatus: undefined, + inReplyToStatusId: undefined, + isQuoted: undefined, + isReply: undefined, + isRetweet: undefined, + permanentUrl: undefined, + photos: [], + quotedStatus: undefined, + quotedStatusId: undefined, + text: text, + timestamp: Date.now(), + urls: [], + userId: "userId", + username: "username", + sensitiveContent: undefined, + ...update, + // Rest, not used in the service + likes: undefined, + isPin: undefined, + isSelfThread: undefined, + mentions: [], + name: undefined, + place: undefined, + thread: [], + timeParsed: undefined, + replies: 0, + retweets: 0, + retweetedStatus: undefined, + retweetedStatusId: undefined, + videos: [], + views: undefined, + }; +}; diff --git a/src/services/__tests__/mocks/twitter-client.ts b/src/services/__tests__/mocks/twitter-client.ts new file mode 100644 index 0000000..b3eddbe --- /dev/null +++ b/src/services/__tests__/mocks/twitter-client.ts @@ -0,0 +1,24 @@ +import { Tweet } from "@the-convocation/twitter-scraper"; + +import { makeTweetMock } from "../helpers/make-tweet-mock.js"; + +export class MockTwitterClient { + constructor(tweetCount?: number) { + this.tweetCount = tweetCount || 200; + } + + private readonly tweetCount: number; + + public async *getTweets( + user: string, + maxTweets?: number, + ): AsyncGenerator { + // Mocking the asynchronous generator function + for (let i = 0; i < (this.tweetCount ?? maxTweets); i++) { + yield { + ...makeTweetMock({ username: user }), + id: i.toString(), + } as Tweet; + } + } +} diff --git a/src/services/__tests__/tweets-getter.service.spec.ts b/src/services/__tests__/tweets-getter.service.spec.ts index 3e044d7..f9c2cac 100644 --- a/src/services/__tests__/tweets-getter.service.spec.ts +++ b/src/services/__tests__/tweets-getter.service.spec.ts @@ -1,90 +1,12 @@ -import { Scraper, Tweet } from "@the-convocation/twitter-scraper"; +import { Scraper } from "@the-convocation/twitter-scraper"; import { isTweetCached } from "../../helpers/tweet/index.js"; import { tweetsGetterService } from "../tweets-getter.service.js"; +import { MockTwitterClient } from "./mocks/twitter-client.js"; +jest.mock("ora"); jest.mock("../../constants.js", () => ({})); - -jest.mock("ora", () => ({ - default: jest.fn(() => ({ - start: jest - .fn() - .mockImplementation(() => ({ text: "", succeed: jest.fn() })), - })), - __esModule: true, -})); - -const makeTweetMock = (update: Partial): Tweet => { - const text = update.text || "Hello World"; - return { - id: Math.floor( - 1000000000000000000 + Math.random() * 9000000000000000000, - ).toString(), - conversationId: undefined, - hashtags: [], - html: text, - inReplyToStatus: undefined, - inReplyToStatusId: undefined, - isQuoted: undefined, - isReply: undefined, - isRetweet: undefined, - permanentUrl: undefined, - photos: [], - quotedStatus: undefined, - quotedStatusId: undefined, - text: text, - timestamp: Date.now(), - urls: [], - userId: "userId", - username: "username", - sensitiveContent: undefined, - ...update, - // Rest, not used in the service - likes: undefined, - isPin: undefined, - isSelfThread: undefined, - mentions: [], - name: undefined, - place: undefined, - thread: [], - timeParsed: undefined, - replies: 0, - retweets: 0, - retweetedStatus: undefined, - retweetedStatusId: undefined, - videos: [], - views: undefined, - }; -}; - -class MockTwitterClient { - constructor(tweetCount?: number) { - this.tweetCount = tweetCount || 200; - } - - private readonly tweetCount: number; - - public async *getTweets( - user: string, - maxTweets?: number, - ): AsyncGenerator { - // Mocking the asynchronous generator function - for (let i = 0; i < (this.tweetCount ?? maxTweets); i++) { - yield { - ...makeTweetMock({ username: user }), - id: i.toString(), - } as Tweet; - } - } -} - -jest.mock("../../helpers/tweet/index.js", () => { - const originalModule = jest.requireActual("../../helpers/tweet/index.js"); - return { - ...originalModule, - isTweetCached: jest.fn(), - }; -}); +jest.mock("../../helpers/tweet/is-tweet-cached.js"); const isTweetCachedMock = isTweetCached as jest.Mock; From add7f099f60bb6d09d305a2005dd8369b03c50fe Mon Sep 17 00:00:00 2001 From: Louis Grasset Date: Thu, 23 Nov 2023 05:48:48 +0100 Subject: [PATCH 6/6] test(services): cover bluesky-sender-service --- .../__tests__/bluesky-sender.service.spec.ts | 177 ++++++++++++++++++ src/services/bluesky-sender.service.ts | 5 +- 2 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 src/services/__tests__/bluesky-sender.service.spec.ts diff --git a/src/services/__tests__/bluesky-sender.service.spec.ts b/src/services/__tests__/bluesky-sender.service.spec.ts new file mode 100644 index 0000000..867063c --- /dev/null +++ b/src/services/__tests__/bluesky-sender.service.spec.ts @@ -0,0 +1,177 @@ +import { BskyAgent } from "@atproto/api"; +import ora from "ora"; + +import { makeBlobFromFile } from "../../helpers/medias/__tests__/helpers/make-blob-from-file.js"; +import { Media } from "../../types/index.js"; +import { BlueskyPost } from "../../types/post.js"; +import { blueskySenderService } from "../bluesky-sender.service.js"; +import { mediaDownloaderService } from "../media-downloader.service.js"; +import { makeTweetMock } from "./helpers/make-tweet-mock.js"; + +jest.mock("ora"); +jest.mock("../../constants.js", () => { + return {}; +}); +jest.mock("../../helpers/cache/save-post-to-cache.js", () => ({ + savePostToCache: jest.fn().mockImplementation(() => Promise.resolve()), +})); +jest.mock("../../helpers/tweet/is-tweet-cached.js"); + +jest.mock("../media-downloader.service.js", () => ({ + mediaDownloaderService: jest.fn(), +})); +const mediaDownloaderServiceMock = mediaDownloaderService as jest.Mock; +const client = new BskyAgent({ + service: `https://bsky.social`, +}); + +const postSpy = jest + .fn() + .mockImplementation(() => Promise.resolve({ uri: "uri", cid: "cid" })); +client.post = postSpy; + +const uploadBlobSpy = jest.fn().mockImplementation(() => + Promise.resolve({ + data: { + blob: { + original: { + $type: "blob", + ref: "blobRef", + mimeType: "image/png", + size: "1024", + }, + }, + }, + }), +); +client.uploadBlob = uploadBlobSpy; + +const log = ora(); + +const post = { + tweet: makeTweetMock({ text: "Tweet text" }), + chunks: ["Tweet text"], + username: "username", + quotePost: undefined, + replyPost: undefined, +} as BlueskyPost; + +const media: Media = { + type: "image", + id: "id", + url: "https://sample-videos.com/img/Sample-png-image-100kb.png", + alt_text: "alt text", +}; + +const embedMedia = { + alt: "alt text", + image: { + $type: "blob", + mimeType: "image/png", + ref: "blobRef", + size: "1024", + }, +}; + +describe("blueskySenderService", () => { + beforeEach(() => { + postSpy.mockClear(); + uploadBlobSpy.mockClear(); + }); + + it("should send the post", async () => { + await blueskySenderService(client, post, [], log); + + expect(postSpy).toHaveBeenCalledTimes(1); + }); + + describe("when the post has some media", () => { + beforeAll(() => { + mediaDownloaderServiceMock.mockResolvedValue( + makeBlobFromFile("image-png.png", "image/png"), + ); + }); + + it("should send the post with its media ", async () => { + const media: Media = { + type: "image", + id: "id", + url: "https://avatars.githubusercontent.com/u/9489181", + alt_text: "alt text", + }; + await blueskySenderService(client, post, [media], log); + + expect(uploadBlobSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledWith({ + $type: "app.bsky.feed.post", + createdAt: new Date(post.tweet.timestamp!).toISOString(), + text: "Tweet text", + facets: undefined, + embed: { + $type: "app.bsky.embed.images", + images: [embedMedia], + }, + }); + }); + }); + + describe("when the tweet as more than 4 images", () => { + it("should send the post with only the first 4 images", async () => { + await blueskySenderService( + client, + post, + [media, media, media, media, media], + log, + ); + expect(uploadBlobSpy).toHaveBeenCalledTimes(4); + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledWith({ + $type: "app.bsky.feed.post", + createdAt: new Date(post.tweet.timestamp!).toISOString(), + text: "Tweet text", + facets: undefined, + embed: { + $type: "app.bsky.embed.images", + images: [embedMedia, embedMedia, embedMedia, embedMedia], + }, + }); + }); + }); + + describe("when the tweet as a video", () => { + beforeAll(() => { + mediaDownloaderServiceMock.mockResolvedValue( + makeBlobFromFile("video-mp4.mp4", "video/mp4"), + ); + }); + + it("should send the post without media ", async () => { + const mediaVideo: Media = { + type: "video", + id: "id", + preview: "preview", + url: "https://sample-videos.com/video123/mp4/360/big_buck_bunny_360p_1mb.mp4", + }; + await blueskySenderService(client, post, [mediaVideo], log); + + expect(uploadBlobSpy).toHaveBeenCalledTimes(0); + expect(postSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledWith({ + $type: "app.bsky.feed.post", + createdAt: new Date(post.tweet.timestamp!).toISOString(), + text: "Tweet text", + facets: undefined, + embed: undefined, + }); + }); + }); + + describe("when no post is given", () => { + it("should skip", async () => { + await blueskySenderService(client, null, [], log); + + expect(postSpy).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/src/services/bluesky-sender.service.ts b/src/services/bluesky-sender.service.ts index b71ee20..63a32e2 100644 --- a/src/services/bluesky-sender.service.ts +++ b/src/services/bluesky-sender.service.ts @@ -1,4 +1,4 @@ -import bsky, { BskyAgent } from "@atproto/api"; +import { BskyAgent, RichText } from "@atproto/api"; import { Ora } from "ora"; import { DEBUG, VOID } from "../constants.js"; @@ -40,6 +40,7 @@ export const blueskySenderService = async ( if (!media.url) { continue; } + if ( (media.type === "image" && mediaAttachments.length < BLUESKY_MEDIA_IMAGES_MAX_COUNT) || @@ -109,7 +110,7 @@ export const blueskySenderService = async ( * If the tweet is long, each child chunk will reference the previous one as replyId. */ for (const chunk of post.chunks) { - const richText = new bsky.RichText({ text: chunk }); + const richText = new RichText({ text: chunk }); await richText.detectFacets(client); const data: {