From cca6d981353c259bb8a92260e2d5cb3153abf380 Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Thu, 11 Dec 2025 16:11:19 +0200 Subject: [PATCH 01/43] fix: follow unfollow race condition - [CU-869b9w865] (#189) --- src/common/filters/http-response.filter.ts | 24 +-- src/tweets/tweets.repository.ts | 180 ++++++++++++++------- src/users/users.controller.ts | 7 + src/users/users.repository.ts | 80 ++++++--- 4 files changed, 187 insertions(+), 104 deletions(-) diff --git a/src/common/filters/http-response.filter.ts b/src/common/filters/http-response.filter.ts index c35639e1..9941c36b 100644 --- a/src/common/filters/http-response.filter.ts +++ b/src/common/filters/http-response.filter.ts @@ -214,35 +214,23 @@ export class HttpExceptionFilter implements ExceptionFilter { status: number; response: ApiErrorResponse; } { - let status = HttpStatus.BAD_REQUEST; - let code = 'DB_ERROR'; - let message = 'An unexpected error occurred'; - const meta = exception.meta; + let status = HttpStatus.INTERNAL_SERVER_ERROR; + const code = 'DB_ERROR'; + const message = 'An unexpected error occurred'; switch (exception.code) { case 'P2002': { // Unique constraint - // may get triggered on some edge cases - status = HttpStatus.CONFLICT; - code = 'ALREADY_EXISTS'; - const target = meta?.target as string[]; - message = target - ? `Unique constraint failed on the fields: (${target.join(', ')})` - : 'Record already exists'; + this.logger.error(exception.message); break; } case 'P2025': // Record not found - status = HttpStatus.NOT_FOUND; - code = 'NOT_FOUND'; - message = 'The record you are trying to access does not exist'; + this.logger.error(`Record not found: ${exception.message}`); break; case 'P2003': // Foreign key violations - // this would normally not be hit - status = HttpStatus.BAD_REQUEST; - code = 'INVALID_RELATION'; - message = 'Operation depends on a record that does not exist'; + this.logger.error(`Foreign key violation: ${exception.message}`); break; default: diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts index ecb852bf..ff233b42 100644 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; import { TweetDto, UserInteractionDto } from './dtos'; @@ -15,7 +15,7 @@ import { CompactAuthorWithId } from './dtos/compact-author.dto'; import { TIMELINE_MAX_SIZE } from './timeline/constants'; import { PeopleSearchFilter } from 'src/search/dtos'; import { TweetsBackfill } from './timeline/interfaces'; -import { MAX_TWEET_DEPTH } from './constants'; +import { MAX_TWEET_DEPTH, TWEETS_ERROR_CODES, TWEETS_ERROR_MESSAGES } from './constants'; import { DeletedTweet, TweetOrDeleted } from './types'; import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; @@ -382,87 +382,147 @@ export class TweetsRepository { } async likeTweet(userId: bigint, tweetId: bigint) { - await this.prisma.$transaction(async (tx) => { - await tx.like.create({ - data: { - userId, - tweetId, - }, - }); + await this.prisma + .$transaction(async (tx) => { + await tx.like.create({ + data: { + userId, + tweetId, + }, + }); - await tx.tweet.update({ - where: { id: tweetId }, - data: { - likeCount: { - increment: 1, + await tx.tweet.update({ + where: { id: tweetId }, + data: { + likeCount: { + increment: 1, + }, }, - }, + }); + }) + .catch((e) => { + //unique constraint + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new HttpException( + { + message: TWEETS_ERROR_MESSAGES.CONFLICTING_LIKE, + code: TWEETS_ERROR_CODES.CONFLICTING_LIKE, + }, + HttpStatus.CONFLICT, + ); + } else { + throw e; + } }); - }); } async unlikeTweet(userId: bigint, tweetId: bigint) { - await this.prisma.$transaction(async (tx) => { - await tx.like.delete({ - where: { - userId_tweetId: { - userId, - tweetId, + await this.prisma + .$transaction(async (tx) => { + await tx.like.delete({ + where: { + userId_tweetId: { + userId, + tweetId, + }, }, - }, - }); + }); - await tx.tweet.update({ - where: { id: tweetId }, - data: { - likeCount: { - decrement: 1, + await tx.tweet.update({ + where: { id: tweetId }, + data: { + likeCount: { + decrement: 1, + }, }, - }, + }); + }) + .catch((e) => { + // record not found + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { + throw new HttpException( + { + message: TWEETS_ERROR_MESSAGES.CONFLICTING_LIKE, + code: TWEETS_ERROR_CODES.CONFLICTING_LIKE, + }, + HttpStatus.CONFLICT, + ); + } else { + throw e; + } }); - }); } async retweetTweet(userId: bigint, tweetId: bigint) { - await this.prisma.$transaction(async (tx) => { - await tx.retweet.create({ - data: { - userId, - tweetId, - }, - }); + await this.prisma + .$transaction(async (tx) => { + await tx.retweet.create({ + data: { + userId, + tweetId, + }, + }); - await tx.tweet.update({ - where: { id: tweetId }, - data: { - retweetCount: { - increment: 1, + await tx.tweet.update({ + where: { id: tweetId }, + data: { + retweetCount: { + increment: 1, + }, }, - }, + }); + }) + .catch((e) => { + //unique constraint + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new HttpException( + { + message: TWEETS_ERROR_MESSAGES.CONFLICTING_RETWEET, + code: TWEETS_ERROR_CODES.CONFLICTING_RETWEET, + }, + HttpStatus.CONFLICT, + ); + } else { + throw e; + } }); - }); } async unretweetTweet(userId: bigint, tweetId: bigint) { - await this.prisma.$transaction(async (tx) => { - await tx.retweet.delete({ - where: { - userId_tweetId: { - userId, - tweetId, + await this.prisma + .$transaction(async (tx) => { + await tx.retweet.delete({ + where: { + userId_tweetId: { + userId, + tweetId, + }, }, - }, - }); + }); - await tx.tweet.update({ - where: { id: tweetId }, - data: { - retweetCount: { - decrement: 1, + await tx.tweet.update({ + where: { id: tweetId }, + data: { + retweetCount: { + decrement: 1, + }, }, - }, + }); + }) + .catch((e) => { + //record not found + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { + throw new HttpException( + { + message: TWEETS_ERROR_MESSAGES.CONFLICTING_RETWEET, + code: TWEETS_ERROR_CODES.CONFLICTING_RETWEET, + }, + HttpStatus.CONFLICT, + ); + } else { + throw e; + } }); - }); } async hasUserLikedTweet(userId: bigint, tweetId: bigint): Promise { diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 992c02dc..d6004bcb 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -5,6 +5,7 @@ import { FollowingUserDto } from './dtos'; import { JwtAuthGuard } from 'src/auth/guards'; import { User } from 'src/auth/decorators'; import type { RequestUser } from 'src/common/interfaces'; +import { Throttle } from '@nestjs/throttler'; @Controller('users') export class UsersController { @@ -12,6 +13,12 @@ export class UsersController { @Post(':username/following') @UseGuards(JwtAuthGuard) + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) async followUser(@Param('username') username: string, @User() user: RequestUser) { await this.usersService.followUser(BigInt(user.id), username); return { message: 'Followed user successfully' }; diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 6813ac84..8df81304 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -453,35 +453,63 @@ export class UsersRepository { } async followUser(followerId: bigint, followedId: bigint) { - await this.prisma.$transaction([ - this.prisma.follow.create({ - data: { followerId, followedId }, - }), - this.prisma.user.update({ - where: { id: followerId }, - data: { followingCount: { increment: 1 } }, - }), - this.prisma.user.update({ - where: { id: followedId }, - data: { followersCount: { increment: 1 } }, - }), - ]); + await this.prisma + .$transaction([ + this.prisma.follow.create({ + data: { followerId, followedId }, + }), + this.prisma.user.update({ + where: { id: followerId }, + data: { followingCount: { increment: 1 } }, + }), + this.prisma.user.update({ + where: { id: followedId }, + data: { followersCount: { increment: 1 } }, + }), + ]) + .catch((e) => { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new HttpException( + { + message: USERS_ERROR_MESSAGES.ALREADY_FOLLOWING, + code: USERS_ERROR_CODES.ALREADY_FOLLOWING, + }, + HttpStatus.CONFLICT, + ); + } else { + throw e; + } + }); } async unfollowUser(followerId: bigint, followedId: bigint) { - await this.prisma.$transaction([ - this.prisma.follow.delete({ - where: { followerId_followedId: { followerId, followedId } }, - }), - this.prisma.user.update({ - where: { id: followerId }, - data: { followingCount: { decrement: 1 } }, - }), - this.prisma.user.update({ - where: { id: followedId }, - data: { followersCount: { decrement: 1 } }, - }), - ]); + await this.prisma + .$transaction([ + this.prisma.follow.delete({ + where: { followerId_followedId: { followerId, followedId } }, + }), + this.prisma.user.update({ + where: { id: followerId }, + data: { followingCount: { decrement: 1 } }, + }), + this.prisma.user.update({ + where: { id: followedId }, + data: { followersCount: { decrement: 1 } }, + }), + ]) + .catch((e) => { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { + throw new HttpException( + { + message: USERS_ERROR_MESSAGES.ALREADY_NOT_FOLLOWING, + code: USERS_ERROR_CODES.ALREADY_NOT_FOLLOWING, + }, + HttpStatus.CONFLICT, + ); + } else { + throw e; + } + }); } async getUserIdsFollowedBy(userId: bigint): Promise { const follows = await this.prisma.follow.findMany({ From 051bf84f24e63ad1d2a48615b68c087b633bd800 Mon Sep 17 00:00:00 2001 From: Tasneem Mohammed <149891742+Tasneemmohammed0@users.noreply.github.com> Date: Thu, 11 Dec 2025 20:49:55 +0200 Subject: [PATCH 02/43] refactor(search): support combined trending keywords and hashtags in search suggestions - [CU-869bf0n0y] (#194) Signed-off-by: Tasneemmhammed0 --- api-spec/complete-spec/main.tsp | 6 ++--- api-spec/implemented-spec/main.tsp | 6 ++--- bruno/collections/auth/check-username.bru | 2 +- bruno/collections/auth/logout.bru | 2 +- ...t-top-hashtags.bru => get-suggestions.bru} | 6 ++--- .../migration.sql | 5 ++++ prisma/schema.prisma | 2 ++ src/search/search.controller.ts | 4 ++-- src/search/search.service.ts | 8 ++++++- src/search/utils/search-query.util.ts | 2 +- src/trending/trending.repository.ts | 16 +++++++++---- src/trending/trending.service.ts | 23 ++++++++++++++++--- test/search/utils/search-query.util.spec.ts | 2 +- 13 files changed, 61 insertions(+), 23 deletions(-) rename bruno/collections/search/{get-top-hashtags.bru => get-suggestions.bru} (65%) create mode 100644 prisma/migrations/20251211104312_add_trending_keyword_index/migration.sql diff --git a/api-spec/complete-spec/main.tsp b/api-spec/complete-spec/main.tsp index 521738b3..3ea0bbdc 100644 --- a/api-spec/complete-spec/main.tsp +++ b/api-spec/complete-spec/main.tsp @@ -2215,15 +2215,15 @@ namespace Conversations { @route("/search") @useAuth(BearerAuth) namespace Search { - @summary("Get top 3 relevant hashtags for the current search query (If it contains #), or suggested hashtags whilst typing anyform of a post") + @summary("Get top 3 relevant trending words for the current search query (If it contains #), or suggested hashtags whilst typing anyform of a post") @get - @route("/hashtags/top") + @route("/suggestions") op getTopHashtags( @doc("The search query") @query query: string, ): - | DataResponse + | DataResponse | NotFoundResponse | UnauthorizedResponse | InternalServerErrorResponse; diff --git a/api-spec/implemented-spec/main.tsp b/api-spec/implemented-spec/main.tsp index e1a1f4c3..3c8443cf 100644 --- a/api-spec/implemented-spec/main.tsp +++ b/api-spec/implemented-spec/main.tsp @@ -2153,15 +2153,15 @@ namespace Devices { @route("/search") @useAuth(BearerAuth) namespace Search { - @summary("Get top 3 relevant hashtags for the current search query (If it contains #), or suggested hashtags whilst typing anyform of a post") + @summary("Get top 3 relevant trending words for the current search query (If it contains #), or suggested hashtags whilst typing anyform of a post") @get - @route("/hashtags/top") + @route("/suggestions") op getTopHashtags( @doc("The search query") @query query: string, ): - | DataResponse + | DataResponse | NotFoundResponse | UnauthorizedResponse | InternalServerErrorResponse; diff --git a/bruno/collections/auth/check-username.bru b/bruno/collections/auth/check-username.bru index 6cc41f6b..a0637eaa 100644 --- a/bruno/collections/auth/check-username.bru +++ b/bruno/collections/auth/check-username.bru @@ -1,7 +1,7 @@ meta { name: check-username type: http - seq: 13 + seq: 14 } get { diff --git a/bruno/collections/auth/logout.bru b/bruno/collections/auth/logout.bru index 9214a934..2b10fdc0 100644 --- a/bruno/collections/auth/logout.bru +++ b/bruno/collections/auth/logout.bru @@ -1,7 +1,7 @@ meta { name: logout type: http - seq: 14 + seq: 13 } post { diff --git a/bruno/collections/search/get-top-hashtags.bru b/bruno/collections/search/get-suggestions.bru similarity index 65% rename from bruno/collections/search/get-top-hashtags.bru rename to bruno/collections/search/get-suggestions.bru index 0888d55a..580c1cfe 100644 --- a/bruno/collections/search/get-top-hashtags.bru +++ b/bruno/collections/search/get-suggestions.bru @@ -1,17 +1,17 @@ meta { - name: get-top-hashtags + name: get-suggestions type: http seq: 3 } get { - url: http://localhost:3000/search/tweets?query=life + url: http://localhost:3000/search/suggestions?query=su body: none auth: bearer } params:query { - query: life + query: su } auth:bearer { diff --git a/prisma/migrations/20251211104312_add_trending_keyword_index/migration.sql b/prisma/migrations/20251211104312_add_trending_keyword_index/migration.sql new file mode 100644 index 00000000..54ed862d --- /dev/null +++ b/prisma/migrations/20251211104312_add_trending_keyword_index/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "trending_keywords_keyword_count_idx" ON "trending_keywords"("keyword", "count" DESC); + +-- CreateIndex +CREATE INDEX "trending_keywords_keyword_idx" ON "trending_keywords"("keyword"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc5b6f29..783593d2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -244,6 +244,8 @@ model TrendingKeyword { @@unique([keyword, isHashtag]) @@index([isHashtag, keyword, count(sort: Desc)], map: "trending_hashtags_search_idx") + @@index([keyword, count(sort: Desc)], map: "trending_keywords_keyword_count_idx") + @@index([keyword], map: "trending_keywords_keyword_idx") @@map("trending_keywords") } diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts index d016ef04..63130872 100644 --- a/src/search/search.controller.ts +++ b/src/search/search.controller.ts @@ -41,10 +41,10 @@ export class SearchController { ); } - @Get('hashtags/top') + @Get('/suggestions') @UseGuards(JwtAuthGuard) async getTopHashtags(@Query() queryDto: QueryDto) { - return this.trendingService.getTrendingHashtags(queryDto.query, 3); + return this.trendingService.getTrendingWords(queryDto.query, 3); } @Get('users') diff --git a/src/search/search.service.ts b/src/search/search.service.ts index 94ff9ff5..aeb67c1f 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -70,7 +70,13 @@ export class SearchService { ) { const { query, tab, peopleFilter, excludeMutedAndBlocked } = searchTweetsQueryDto; - const rawQuery = decodeURIComponent(query); + let rawQuery: string; + try { + rawQuery = decodeURIComponent(query); + } catch { + // If decoding fails, use the original query + rawQuery = query; + } if (!rawQuery || rawQuery.trim() === '') { return { diff --git a/src/search/utils/search-query.util.ts b/src/search/utils/search-query.util.ts index eb2bd056..f74761d8 100644 --- a/src/search/utils/search-query.util.ts +++ b/src/search/utils/search-query.util.ts @@ -10,7 +10,7 @@ export function prepareSearchQuery(query: string): string { .trim() .toLowerCase() .replace(/[#]/g, '') - .replace(/[^\p{L}\p{N}\s]/gu, ' ') // Remove special characters except letters, numbers, and spaces + .replace(/[^\p{L}\p{N}\s]/gu, '') // Remove special characters except letters, numbers, and spaces .trim(); if (!cleaned) return ''; diff --git a/src/trending/trending.repository.ts b/src/trending/trending.repository.ts index 63be5daf..cfd55947 100644 --- a/src/trending/trending.repository.ts +++ b/src/trending/trending.repository.ts @@ -61,21 +61,29 @@ export class TrendingRepository { }); } - async getTopHashtagsByKeyword(query: string, limit: number): Promise { + async getTopWords( + query: string, + limit: number, + isHashtagQuery: boolean = false, + ): Promise<{ keyword: string; isHashtag: boolean }[]> { + // Escape sql wildcards % and _ + query = query.replace(/[%_]/g, '\\$&'); + const results = await this.prisma.trendingKeyword.findMany({ where: { - isHashtag: true, keyword: { startsWith: query.toLowerCase(), }, + ...(isHashtagQuery && { isHashtag: true }), }, select: { keyword: true, - count: true, + isHashtag: true, }, orderBy: { count: 'desc' }, take: limit, }); - return results.map((result) => result.keyword); + + return results; } } diff --git a/src/trending/trending.service.ts b/src/trending/trending.service.ts index f239f942..0153fca9 100644 --- a/src/trending/trending.service.ts +++ b/src/trending/trending.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { TrendingRepository } from './trending.repository'; import { Prisma } from '@prisma/client'; import { PlainHashtag } from 'src/tweets/interfaces'; +import { extractHashtag, isSingleHashtagQuery } from 'src/search/utils/search-query.util'; @Injectable() export class TrendingService { @@ -24,12 +25,28 @@ export class TrendingService { return await this.TrendingRepository.getHashtagId(hashtag); } - async getTrendingHashtags(query: string, limit: number): Promise { - if (!query || query.trim() === '') { + async getTrendingWords(query: string, limit: number): Promise { + if (!query || query.trim() === '' || !/[a-z0-9]/i.test(query)) { return []; } - const hashtags = await this.TrendingRepository.getTopHashtagsByKeyword(query, limit); + let rawQuery: string; + try { + rawQuery = decodeURIComponent(query); + } catch { + // If decoding fails, use the original query + rawQuery = query; + } + + let isHashtagQuery = false; + // If the query is a hashtag, remove the leading '#' + if (isSingleHashtagQuery(rawQuery)) { + isHashtagQuery = true; + rawQuery = extractHashtag(rawQuery); + } + + const results = await this.TrendingRepository.getTopWords(rawQuery, limit, isHashtagQuery); + const hashtags = results.map((word) => (word.isHashtag ? `#${word.keyword}` : word.keyword)); return hashtags; } } diff --git a/test/search/utils/search-query.util.spec.ts b/test/search/utils/search-query.util.spec.ts index 065e9978..77761cea 100644 --- a/test/search/utils/search-query.util.spec.ts +++ b/test/search/utils/search-query.util.spec.ts @@ -10,7 +10,7 @@ describe('prepareSearchQuery', () => { }); it('should remove special characters except underscores', () => { - expect(prepareSearchQuery('hello@world#test')).toBe('hello:* | worldtest:*'); + expect(prepareSearchQuery('hello@world#test')).toBe('helloworldtest:*'); }); it('should handle multiple spaces', () => { From 868c0b36a6487806b7fe60bc37acaa05fd633d06 Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Thu, 11 Dec 2025 21:02:39 +0200 Subject: [PATCH 03/43] fix: reply counts update on database on deletion, and update in timeline cache - [CU-869beu635] (#190) --- .../timeline/timeline-following.bru | 6 +-- .../migration.sql | 36 +++++++++++++ src/tweets/tweets.repository.ts | 30 +++++------ src/tweets/tweets.service.ts | 53 +++++++++++++++---- 4 files changed, 94 insertions(+), 31 deletions(-) create mode 100644 prisma/migrations/20251211133222_fix_retweet_reply_counts_ont_tweet/migration.sql diff --git a/bruno/collections/timeline/timeline-following.bru b/bruno/collections/timeline/timeline-following.bru index 2789dc53..3ca72014 100644 --- a/bruno/collections/timeline/timeline-following.bru +++ b/bruno/collections/timeline/timeline-following.bru @@ -5,14 +5,14 @@ meta { } get { - url: http://localhost:3000/timeline/following?cursor=eyJjcmVhdGVkQXQiOiIyMDI1LTEyLTA3VDE4OjAzOjU0LjU3M1oiLCJpZCI6IjY1MDUifQ==&limit=30 + url: http://localhost:3000/timeline/following?cursor&limit=20 body: none auth: bearer } params:query { - cursor: eyJjcmVhdGVkQXQiOiIyMDI1LTEyLTA3VDE4OjAzOjU0LjU3M1oiLCJpZCI6IjY1MDUifQ== - limit: 30 + cursor: + limit: 20 } auth:bearer { diff --git a/prisma/migrations/20251211133222_fix_retweet_reply_counts_ont_tweet/migration.sql b/prisma/migrations/20251211133222_fix_retweet_reply_counts_ont_tweet/migration.sql new file mode 100644 index 00000000..c4f7470e --- /dev/null +++ b/prisma/migrations/20251211133222_fix_retweet_reply_counts_ont_tweet/migration.sql @@ -0,0 +1,36 @@ +WITH + replies AS ( + SELECT reply_to_tweet_id AS id, COUNT(*) AS cnt + FROM tweets + WHERE reply_to_tweet_id IS NOT NULL + GROUP BY reply_to_tweet_id + ), + retweets AS ( + SELECT tweet_id AS id, COUNT(*) AS cnt + FROM retweets + GROUP BY tweet_id + ), + quotes AS ( + SELECT quoted_tweet_id AS id, COUNT(*) AS cnt + FROM tweets + WHERE quoted_tweet_id IS NOT NULL + GROUP BY quoted_tweet_id + ), + counts AS ( + SELECT + t.id, + COALESCE(r.cnt, 0) AS reply_cnt, + COALESCE(rt.cnt, 0) + COALESCE(q.cnt, 0) AS retweet_cnt + FROM tweets t + LEFT JOIN replies r ON t.id = r.id + LEFT JOIN retweets rt ON t.id = rt.id + LEFT JOIN quotes q ON t.id = q.id + ) +UPDATE tweets +SET + reply_count = counts.reply_cnt, + retweet_count = counts.retweet_cnt +FROM counts +WHERE tweets.id = counts.id; + +-- the left joins are to ensure tweets with zero replies/retweets/quotes are counted as zero \ No newline at end of file diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts index ff233b42..f973b175 100644 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -281,14 +281,16 @@ export class TweetsRepository { async checkExistingTweet(tweetId: bigint): Promise<{ exists: boolean; replyToTweetId: bigint | null; + quoteToTweetId: bigint | null; }> { const tweet = await this.prisma.tweet.findUnique({ where: { id: tweetId, isDeleted: false }, - select: { id: true, replyToTweetId: true }, + select: { id: true, replyToTweetId: true, quotedTweetId: true }, }); return { exists: !!tweet, replyToTweetId: tweet ? tweet.replyToTweetId : null, + quoteToTweetId: tweet ? tweet.quotedTweetId : null, }; } @@ -300,24 +302,18 @@ export class TweetsRepository { return !!tweet; } - async deleteTweet(tweetId: bigint) { - await this.prisma.$transaction(async (tx) => { - await tx.tweet.update({ - where: { id: tweetId }, - data: { isDeleted: true }, - }); + async deleteTweet(tweetId: bigint, prismaClient: Prisma.TransactionClient) { + await prismaClient.tweet.update({ + where: { id: tweetId }, + data: { isDeleted: true }, + }); - await tx.retweet.deleteMany({ - where: { - tweetId, - }, - }); + await prismaClient.retweet.deleteMany({ + where: { tweetId }, + }); - await tx.like.deleteMany({ - where: { - tweetId, - }, - }); + await prismaClient.like.deleteMany({ + where: { tweetId }, }); } diff --git a/src/tweets/tweets.service.ts b/src/tweets/tweets.service.ts index dd59a8ee..d93f082b 100644 --- a/src/tweets/tweets.service.ts +++ b/src/tweets/tweets.service.ts @@ -213,14 +213,28 @@ export class TweetsService { }, }); - this.logger.log( - `Dispathced fanout on write job for tweet ID: ${tweetId} by user ID: ${userId}`, + this.logger.debug( + `Dispatched fanout on write job for tweet ID: ${tweetId} by user ID: ${userId}`, ); } + // these never happen together (validated earlier) if (createTweetDto.replyToTweetId) { + this.logger.debug( + `Incrementing reply count cache for tweet ID: ${createTweetDto.replyToTweetId}`, + ); + await this.redisService.safeIncr( + REDIS_TIMELINE_KEYS.getTweetRepliesCountKey(BigInt(createTweetDto.replyToTweetId)), + COUNT_CACHE_TTL, + ); + } + + if (createTweetDto.quoteToTweetId) { + this.logger.debug( + `Incrementing retweet count cache for tweet ID: ${createTweetDto.quoteToTweetId}`, + ); await this.redisService.safeIncr( - REDIS_TIMELINE_KEYS.getTweetRepliesCountKey(tweetId), + REDIS_TIMELINE_KEYS.getTweetRetweetsCountKey(BigInt(createTweetDto.quoteToTweetId)), COUNT_CACHE_TTL, ); } @@ -237,7 +251,8 @@ export class TweetsService { } async deleteTweet(tweetId: bigint, userId: bigint) { - const { exists, replyToTweetId } = await this.tweetsRepository.checkExistingTweet(tweetId); + const { exists, replyToTweetId, quoteToTweetId } = + await this.tweetsRepository.checkExistingTweet(tweetId); if (!exists) { throw new HttpException( { @@ -258,17 +273,33 @@ export class TweetsService { ); } - await this.tweetsRepository.deleteTweet(tweetId); - this.logger.log(`User ${userId} deleted tweet ${tweetId} successfully`); + await this.prisma.$transaction(async (tx) => { + await this.tweetsRepository.deleteTweet(tweetId, tx); + if (replyToTweetId) { + await this.tweetsRepository.updateTweetReplyCount(replyToTweetId, false, tx); + } + }); + this.logger.debug(`User ${userId} deleted tweet ${tweetId} successfully`); await this.invalidateTweetCache(tweetId); + // these never happen together (validated on creation) if (replyToTweetId) { + this.logger.log(`Decrementing reply count cache for tweet ID: ${replyToTweetId}`); + await this.redisService.safeDecr( + REDIS_TIMELINE_KEYS.getTweetRepliesCountKey(replyToTweetId), + COUNT_CACHE_TTL, + ); + } + + if (quoteToTweetId) { + this.logger.log(`Decrementing retweet count cache for tweet ID: ${quoteToTweetId}`); await this.redisService.safeDecr( - REDIS_TIMELINE_KEYS.getTweetRetweetsCountKey(replyToTweetId), + REDIS_TIMELINE_KEYS.getTweetRetweetsCountKey(quoteToTweetId), COUNT_CACHE_TTL, ); } + return { message: 'Tweet deleted successfully' }; } @@ -788,7 +819,7 @@ export class TweetsService { id: relation.id.toString(), })); - this.logger.log(`Fetched ${items.length} ${type} for tweet ID: ${tweetId}`); + this.logger.debug(`Fetched ${items.length} ${type} for tweet ID: ${tweetId}`); return { items, pagination }; } @@ -839,7 +870,7 @@ export class TweetsService { }; }); - this.logger.log(`Fetched ${items.length} ${type} for tweet ID: ${tweetId}`); + this.logger.debug(`Fetched ${items.length} ${type} for tweet ID: ${tweetId}`); // eslint-disable-next-line @typescript-eslint/no-unused-vars const safeItems = items.map(({ userId, ...rest }) => rest); @@ -1069,7 +1100,7 @@ export class TweetsService { const cachedSummary = await this.redisService.getex(cacheKey, TWEET_SUMMARY_CACHE_TTL); if (cachedSummary) { - this.logger.log(`Returning cached summary for tweet ${tweetId}`); + this.logger.debug(`Returning cached summary for tweet ${tweetId}`); return { id: tweet.id.toString(), summary: cachedSummary, @@ -1081,7 +1112,7 @@ export class TweetsService { // Cache the summary with TTL await this.redisService.set(cacheKey, summary, TWEET_SUMMARY_CACHE_TTL); - this.logger.log(`Cached summary for tweet ${tweetId} with TTL ${TWEET_SUMMARY_CACHE_TTL}s`); + this.logger.debug(`Cached summary for tweet ${tweetId} with TTL ${TWEET_SUMMARY_CACHE_TTL}s`); return { id: tweet.id.toString(), From aaf9ba4df642b01d7c72296e5375afef3095c6bd Mon Sep 17 00:00:00 2001 From: Ahmed Sobhy <48056730+AhmedSobhy01@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:29:49 +0200 Subject: [PATCH 04/43] refactor: restrict video formats to MP4 and MOV only - [CU-869bf6kk1] (#197) --- api-spec/complete-spec/main.tsp | 2 +- api-spec/implemented-spec/main.tsp | 2 +- src/media/constants/media.constant.ts | 4 ++-- src/media/utils/detect-media-type.util.ts | 2 +- .../utils/detect-media-type.util.spec.ts | 22 ------------------- 5 files changed, 5 insertions(+), 27 deletions(-) diff --git a/api-spec/complete-spec/main.tsp b/api-spec/complete-spec/main.tsp index 3ea0bbdc..569f35f4 100644 --- a/api-spec/complete-spec/main.tsp +++ b/api-spec/complete-spec/main.tsp @@ -584,7 +584,7 @@ model UploadImageRequest { } model UploadVideoRequest { - @doc("Video file to upload (mp4, mov, webm, mkv). Max size: 10MB") + @doc("Video file to upload (mp4, mov). Max size: 10MB") file: HttpPart; @doc("Alt text for accessibility (optional)") diff --git a/api-spec/implemented-spec/main.tsp b/api-spec/implemented-spec/main.tsp index 3c8443cf..a0fbf28e 100644 --- a/api-spec/implemented-spec/main.tsp +++ b/api-spec/implemented-spec/main.tsp @@ -693,7 +693,7 @@ model UploadImageRequest { } model UploadVideoRequest { - @doc("Video file to upload (mp4, mov, webm, mkv). Max size: 10MB") + @doc("Video file to upload (mp4, mov). Max size: 10MB") file: HttpPart; @doc("Alt text for accessibility (optional)") diff --git a/src/media/constants/media.constant.ts b/src/media/constants/media.constant.ts index 77d7703b..78f582b1 100644 --- a/src/media/constants/media.constant.ts +++ b/src/media/constants/media.constant.ts @@ -4,7 +4,7 @@ export const MAX_WIDTH = 2048; export const MAX_HEIGHT = 2048; export const MAX_VIDEO_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp']; -export const VIDEO_EXTENSIONS = ['mp4', 'mkv', 'webm', 'mov']; +export const VIDEO_EXTENSIONS = ['mp4', 'mov']; export const GIF_EXTENSIONS = ['gif']; export const ALLOWED_EXTENSIONS = [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS, ...GIF_EXTENSIONS]; export const PENDING_MEDIA_CLEANUP_THRESHOLD_HOURS = 24; // 0 hours for testing purposes @@ -24,5 +24,5 @@ export const MEDIA_MESSAGES = { INVALID_URL: 'The provided URL is invalid.', UNAUTHORIZED_DELETE: 'Unauthorized attempt to delete media.', ALLOWED_IMAGE_TYPES: 'Only image files are allowed (jpg, jpeg, png, webp).', - ALLOWED_VIDEO_TYPES: 'Only video files are allowed (mp4, mkv, webm, mov).', + ALLOWED_VIDEO_TYPES: 'Only video files are allowed (mp4, mov).', } as const; diff --git a/src/media/utils/detect-media-type.util.ts b/src/media/utils/detect-media-type.util.ts index 1984e6bc..c8058222 100644 --- a/src/media/utils/detect-media-type.util.ts +++ b/src/media/utils/detect-media-type.util.ts @@ -44,7 +44,7 @@ export function detectMediaType(file: Express.Multer.File): MediaType { throw new BadRequestException( createValidationError('file', { - unsupportedMediaType: `Unsupported file type: ${ext || mimeType}. Only image (jpg, jpeg, png, gif) and video (mp4, mkv, webm) files are allowed.`, + unsupportedMediaType: `Unsupported file type: ${ext || mimeType}. Only image (jpg, jpeg, png, gif) and video (mp4, mov) files are allowed.`, }), ); } diff --git a/test/media/utils/detect-media-type.util.spec.ts b/test/media/utils/detect-media-type.util.spec.ts index 92ae77f8..5e957a02 100644 --- a/test/media/utils/detect-media-type.util.spec.ts +++ b/test/media/utils/detect-media-type.util.spec.ts @@ -117,28 +117,6 @@ describe('detectMediaType', () => { expect(result).toBe(MediaType.VIDEO); }); - it('should detect MKV video from .mkv extension', () => { - const file = createMockFile({ - originalname: 'movie.mkv', - mimetype: 'video/x-matroska', - }); - - const result = detectMediaType(file); - - expect(result).toBe(MediaType.VIDEO); - }); - - it('should detect WebM video from .webm extension', () => { - const file = createMockFile({ - originalname: 'clip.webm', - mimetype: 'video/webm', - }); - - const result = detectMediaType(file); - - expect(result).toBe(MediaType.VIDEO); - }); - it('should detect MOV video from .mov extension', () => { const file = createMockFile({ originalname: 'video.mov', From e22f451c6e4aaef33fb4277e8daa2706d3509002 Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Thu, 11 Dec 2025 23:20:47 +0200 Subject: [PATCH 05/43] fix: decrement retweet counts on deleting quotes - [CU-869bf6x6m] (#198) --- src/tweets/tweets.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tweets/tweets.service.ts b/src/tweets/tweets.service.ts index d93f082b..ffef90a8 100644 --- a/src/tweets/tweets.service.ts +++ b/src/tweets/tweets.service.ts @@ -278,6 +278,9 @@ export class TweetsService { if (replyToTweetId) { await this.tweetsRepository.updateTweetReplyCount(replyToTweetId, false, tx); } + if (quoteToTweetId) { + await this.tweetsRepository.updateTweetRetweetCount(quoteToTweetId, false, tx); + } }); this.logger.debug(`User ${userId} deleted tweet ${tweetId} successfully`); From 03dee8d3fb1145cd01d50ae135c1d1960097b749 Mon Sep 17 00:00:00 2001 From: anas-ibrahem <139391509+anas-ibrahem@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:33:16 +0200 Subject: [PATCH 06/43] feat: ai summary localization and new provider - [CU-869beakr9] (#195) Co-authored-by: Loay Ahmed --- api-spec/complete-spec/main.tsp | 9 +- api-spec/implemented-spec/main.tsp | 7 +- .../collections/tweets/get-tweet-summary.bru | 6 +- package.json | 2 +- pnpm-lock.yaml | 86 ++++++++++++++++--- .../content-parsing.service.ts | 58 ++++++++++--- src/tweets/tweets.controller.ts | 14 ++- src/tweets/tweets.service.ts | 12 +-- 8 files changed, 157 insertions(+), 37 deletions(-) diff --git a/api-spec/complete-spec/main.tsp b/api-spec/complete-spec/main.tsp index 569f35f4..48cbca83 100644 --- a/api-spec/complete-spec/main.tsp +++ b/api-spec/complete-spec/main.tsp @@ -1977,8 +1977,13 @@ namespace Tweets { @get @route("/{id}/summary") @summary("Get AI-generated summary of a tweet") - @doc("Generates a concise summary of the tweet content using Gemini 2.0 Flash Lite") - op getTweetSummary(@path id: string): + @doc("Generates a concise summary of the tweet content using AI") + op getTweetSummary( + @path id: string, + @query + @doc("Language locale for the summary (ar-EG for Arabic, en-US for English). Defaults to en-US if not provided.") + locale?: "ar-EG" | "en-US", + ): | DataResponse | NotFoundResponse | UnauthorizedResponse diff --git a/api-spec/implemented-spec/main.tsp b/api-spec/implemented-spec/main.tsp index a0fbf28e..8f305066 100644 --- a/api-spec/implemented-spec/main.tsp +++ b/api-spec/implemented-spec/main.tsp @@ -1990,7 +1990,12 @@ namespace Tweets { @route("/{id}/summary") @summary("Get AI-generated summary of a tweet") @doc("Generates a concise summary of the tweet content using Gemini 2.0 Flash Lite") - op getTweetSummary(@path id: string): + op getTweetSummary( + @path id: string, + @query + @doc("Language locale for the summary (ar-EG for Arabic, en-US for English). Defaults to en-US if not provided.") + locale?: "ar-EG" | "en-US", + ): | DataResponse | NotFoundResponse | UnauthorizedResponse diff --git a/bruno/collections/tweets/get-tweet-summary.bru b/bruno/collections/tweets/get-tweet-summary.bru index 80317b35..0d864bea 100644 --- a/bruno/collections/tweets/get-tweet-summary.bru +++ b/bruno/collections/tweets/get-tweet-summary.bru @@ -5,11 +5,15 @@ meta { } get { - url: http://localhost:3000/tweets/1/summary + url: http://localhost:3000/tweets/1/summary?ar-EG body: none auth: bearer } +params:query { + ar-EG: +} + auth:bearer { token: {{access_token}} } diff --git a/package.json b/package.json index 201768a0..f9eba139 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "@aws-sdk/client-s3": "^3.920.0", "@aws-sdk/lib-storage": "^3.920.0", "@faker-js/faker": "^10.1.0", - "@google/generative-ai": "^0.24.1", "@nestjs/axios": "^4.0.1", "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.0.1", @@ -68,6 +67,7 @@ "faker-js": "^1.0.0", "firebase-admin": "^13.6.0", "google-auth-library": "^10.4.2", + "groq-sdk": "^0.37.0", "intl-messageformat": "^10.7.18", "ioredis": "^5.8.1", "multer": "^2.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87b69e03..033abcfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: '@faker-js/faker': specifier: ^10.1.0 version: 10.1.0 - '@google/generative-ai': - specifier: ^0.24.1 - version: 0.24.1 '@nestjs/axios': specifier: ^4.0.1 version: 4.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.12.2)(rxjs@7.8.2) @@ -98,6 +95,9 @@ importers: google-auth-library: specifier: ^10.4.2 version: 10.5.0 + groq-sdk: + specifier: ^0.37.0 + version: 0.37.0 intl-messageformat: specifier: ^10.7.18 version: 10.7.18 @@ -823,10 +823,6 @@ packages: resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} - '@google/generative-ai@0.24.1': - resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==} - engines: {node: '>=18.0.0'} - '@fastify/busboy@3.2.0': resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} @@ -2115,6 +2111,12 @@ packages: '@types/multer@2.0.0': resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@22.18.8': resolution: {integrity: sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==} @@ -2488,6 +2490,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -3400,6 +3406,9 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data@2.3.3: resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} engines: {node: '>= 0.12'} @@ -3412,6 +3421,10 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -3566,6 +3579,9 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + groq-sdk@0.37.0: + resolution: {integrity: sha512-lT72pcT8b/X5XrzdKf+rWVzUGW1OQSKESmL8fFN5cTbsf02gq6oFam4SVeNtzELt9cYE2Pt3pdGgSImuTbHFDg==} + gtoken@7.1.0: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} @@ -3637,6 +3653,9 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -5160,6 +5179,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -5264,6 +5286,10 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -6601,8 +6627,6 @@ snapshots: '@faker-js/faker@10.1.0': {} - '@google/generative-ai@0.24.1': {} - '@fastify/busboy@3.2.0': {} '@firebase/app-check-interop-types@0.3.3': {} @@ -8279,6 +8303,15 @@ snapshots: dependencies: '@types/express': 5.0.3 + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 22.18.8 + form-data: 4.0.4 + + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@22.18.8': dependencies: undici-types: 6.21.0 @@ -8668,7 +8701,6 @@ snapshots: abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 - optional: true accepts@1.3.8: dependencies: @@ -8703,6 +8735,10 @@ snapshots: agent-base@7.1.4: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ajv-draft-04@1.0.0(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -9447,8 +9483,7 @@ snapshots: etag@1.8.1: {} - event-target-shim@5.0.1: - optional: true + event-target-shim@5.0.1: {} eventemitter2@6.4.9: {} @@ -9666,6 +9701,8 @@ snapshots: typescript: 5.8.3 webpack: 5.100.2 + form-data-encoder@1.7.2: {} + form-data@2.3.3: dependencies: asynckit: 0.4.0 @@ -9690,6 +9727,11 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -9903,6 +9945,18 @@ snapshots: graphemer@1.4.0: {} + groq-sdk@0.37.0: + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + gtoken@7.1.0: dependencies: gaxios: 6.7.1 @@ -9993,6 +10047,10 @@ snapshots: human-signals@2.1.0: {} + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + husky@9.1.7: {} iconv-lite@0.6.3: @@ -11726,6 +11784,8 @@ snapshots: uint8array-extras@1.5.0: {} + undici-types@5.26.5: {} + undici-types@6.21.0: {} unicorn-magic@0.3.0: {} @@ -11837,6 +11897,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-streams-polyfill@4.0.0-beta.3: {} + webidl-conversions@3.0.1: {} webpack-node-externals@3.0.0: {} diff --git a/src/content-parsing/content-parsing.service.ts b/src/content-parsing/content-parsing.service.ts index 0526d6b7..05cc3351 100644 --- a/src/content-parsing/content-parsing.service.ts +++ b/src/content-parsing/content-parsing.service.ts @@ -1,16 +1,16 @@ import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/client'; -import { GoogleGenerativeAI } from '@google/generative-ai'; import { ParsedContent } from 'src/common/interfaces/parsed-content.interface'; import { TrendingService } from 'src/trending/trending.service'; import { PlainHashtag, PlainMention } from 'src/tweets/interfaces'; import { UsersService } from 'src/users/users.service'; +import Groq from 'groq-sdk'; @Injectable() export class ContentParsingService { private readonly logger = new Logger(ContentParsingService.name); - private readonly genAI: GoogleGenerativeAI; + private groq: Groq; constructor( @Inject(forwardRef(() => UsersService)) @@ -23,7 +23,7 @@ export class ContentParsingService { this.logger.error('SUMMARY_API_KEY is not configured'); throw new Error('SUMMARY_API_KEY environment variable is required'); } - this.genAI = new GoogleGenerativeAI(apiKey); + this.groq = new Groq({ apiKey: apiKey }); } /** @@ -130,21 +130,53 @@ export class ContentParsingService { * @param content The tweet content to summarize * @returns A concise summary of the tweet */ - async generateTweetSummary(content: string): Promise { + async generateTweetSummary(content: string, langcode?: string): Promise { try { - const model = this.genAI.getGenerativeModel({ model: 'gemini-2.0-flash-lite' }); - - const prompt = `Summarize the following tweet in a concise manner (Summary To Be SHORTER than tweet) (1 sentence or 2 for really long tweets):\n\n${content}`; - - const result = await model.generateContent(prompt); - const response = result.response; - const summary = response.text(); - - this.logger.log(`Generated summary for tweet content`); + const modelId = 'openai/gpt-oss-120b'; + + let language = 'en-US'; + if (langcode) { + language = langcode; + } + + const englishPrompt = ` + Summarize the following tweet in english in a very simple and concise way. + The summary MUST start with: "The tweet is talking about ..." + Keep it shorter than the original tweet. + + Tweet: + ${content} + `; + + const arabicPrompt = ` + لخص التغريدة التالية باللهجة المصرية بطريقة بسيطة ومختصرة جداً. + يجب أن يبدأ الملخص بعبارة: "التغريدة تتحدث عن ..." + ويجب أن يكون أقصر من التغريدة الأصلية. + + التغريدة: + ${content} + `; + + const prompt = language.startsWith('ar') ? arabicPrompt : englishPrompt; + + const completion = await this.groq.chat.completions.create({ + messages: [ + { + role: 'user', + content: prompt, + }, + ], + model: modelId, + }); + + const summary = completion.choices[0]?.message?.content || ''; + + this.logger.log(`Generated summary for tweet content using ${modelId}`); return summary.trim(); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorStack = error instanceof Error ? error.stack : undefined; + this.logger.error(`Failed to generate tweet summary: ${errorMessage}`, errorStack); throw new Error('Failed to generate tweet summary'); } diff --git a/src/tweets/tweets.controller.ts b/src/tweets/tweets.controller.ts index 9708b12a..ba3bf20f 100644 --- a/src/tweets/tweets.controller.ts +++ b/src/tweets/tweets.controller.ts @@ -109,7 +109,17 @@ export class TweetsController { @Get(':id/summary') @UseGuards(JwtAuthGuard) - async getTweetSummary(@Param('id', ParseBigIntPipe) tweetId: bigint) { - return await this.tweetsService.getTweetSummary(tweetId); + async getTweetSummary( + @Param('id', ParseBigIntPipe) tweetId: bigint, + @Query('locale') langcode?: string, + ) { + const AVAILABLE_LANGUAGES = ['en-US', 'ar-EG']; + if (langcode && AVAILABLE_LANGUAGES.indexOf(langcode) === -1) { + langcode = 'en-US'; + } else if (!langcode) { + langcode = 'en-US'; + } + + return await this.tweetsService.getTweetSummary(tweetId, langcode); } } diff --git a/src/tweets/tweets.service.ts b/src/tweets/tweets.service.ts index ffef90a8..d99b1009 100644 --- a/src/tweets/tweets.service.ts +++ b/src/tweets/tweets.service.ts @@ -1075,9 +1075,11 @@ export class TweetsService { await deletionPipeline.exec(); } - async getTweetSummary(tweetId: bigint) { + async getTweetSummary( + tweetId: bigint, + langcode: string, + ): Promise<{ id: string; summary: string }> { const tweet = await this.checkIfTweetExists(tweetId); - if (tweet.isDeleted) { throw new HttpException( { @@ -1099,11 +1101,11 @@ export class TweetsService { } // Check Redis cache first - const cacheKey = `tweet:summary:${tweetId.toString()}`; + const cacheKey = `tweet:summary:${tweetId.toString()}:${langcode}`; const cachedSummary = await this.redisService.getex(cacheKey, TWEET_SUMMARY_CACHE_TTL); if (cachedSummary) { - this.logger.debug(`Returning cached summary for tweet ${tweetId}`); + this.logger.log(`Returning cached summary for tweet ${tweetId} with lang ${langcode}`); return { id: tweet.id.toString(), summary: cachedSummary, @@ -1111,7 +1113,7 @@ export class TweetsService { } // Generate new summary if not cached - const summary = await this.contentParsingService.generateTweetSummary(tweet.content); + const summary = await this.contentParsingService.generateTweetSummary(tweet.content, langcode); // Cache the summary with TTL await this.redisService.set(cacheKey, summary, TWEET_SUMMARY_CACHE_TTL); From ab4e2616b2dac688a358e4c5c9a2cb1a73071941 Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Fri, 12 Dec 2025 15:58:02 +0200 Subject: [PATCH 07/43] feat: extend deduplication of tweet ids to be across requests - [CU-869bf6d5n] (#196) Co-authored-by: Loay Ahmed --- src/common/interfaces/cursor.interfaces.ts | 6 +++ .../timeline/constants/timeline.constants.ts | 2 + src/tweets/timeline/timeline.service.ts | 38 +++++++++++-------- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/common/interfaces/cursor.interfaces.ts b/src/common/interfaces/cursor.interfaces.ts index 5968aa35..62348c9b 100644 --- a/src/common/interfaces/cursor.interfaces.ts +++ b/src/common/interfaces/cursor.interfaces.ts @@ -22,6 +22,12 @@ export type FeedCursor = { id: string; }; +export type TimelineCursor = { + createdAt: Date; + id: string; + seenIds: string[]; +}; + export type NotificationCursor = { latestEventAt: Date; id: string; diff --git a/src/tweets/timeline/constants/timeline.constants.ts b/src/tweets/timeline/constants/timeline.constants.ts index ed6fdf14..dc713f7b 100644 --- a/src/tweets/timeline/constants/timeline.constants.ts +++ b/src/tweets/timeline/constants/timeline.constants.ts @@ -9,3 +9,5 @@ export const AUTHOR_COMPACT_DATA_CACHE_TTL = 86400; // 1 days in seconds export const USER_FOLLOWINGS_CACHE_TTL = 432000 as const; // 5 days in seconds export const USER_MUTED_CACHE_TTL = 432000 as const; // 5 days in seconds + +export const SEEN_IDS_CURSOR_LIMIT = 100 as const; diff --git a/src/tweets/timeline/timeline.service.ts b/src/tweets/timeline/timeline.service.ts index a2a93cea..de4a35fa 100644 --- a/src/tweets/timeline/timeline.service.ts +++ b/src/tweets/timeline/timeline.service.ts @@ -1,7 +1,7 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { RedisService } from 'src/redis/redis.service'; import { TweetsRepository } from '../tweets.repository'; -import { FeedCursor } from 'src/common/interfaces'; +import { FeedCursor, TimelineCursor } from 'src/common/interfaces'; import { decodeCompositeCursor, paginateComposite } from 'src/common/utils'; import { PAGINATION_DEFAULT_LIMIT, @@ -12,6 +12,7 @@ import { TweetDto, CompactAuthorWithId } from '../dtos'; import { AUTHOR_COMPACT_DATA_CACHE_TTL, COUNT_CACHE_TTL, + SEEN_IDS_CURSOR_LIMIT, TIMELINE_EMPTY_PLACEHOLDER_TTL, TWEET_STATIC_DATA_CACHE_TTL, } from './constants'; @@ -33,10 +34,14 @@ export class TimelineService { async getTimeline(userId: bigint, cursor: string | undefined, limit: number) { this.logger.debug(`Fetching following timeline for user ID: ${userId}`); - let decoded: FeedCursor | undefined; + let decoded: TimelineCursor | undefined; + let seenSetCrossRequest = new Set(); // this is to deduplicate ids across different requests, so that a repost and the original tweet are NOT in the same timeline if (cursor) { try { - decoded = decodeCompositeCursor(cursor); + decoded = decodeCompositeCursor(cursor); + if (decoded?.seenIds && decoded.seenIds.length > 0) { + seenSetCrossRequest = new Set(decoded.seenIds); + } } catch { throw new HttpException( { @@ -68,10 +73,21 @@ export class TimelineService { } } + let seenIdsNextCursor = [ + Array.from(seenSetCrossRequest), + ...timeline.slice(0, limit).map((t) => t.id.toString()), + ].flat(); + const pagination = paginateComposite(timeline, limit, cursor, (tweet) => ({ createdAt: tweet.createdAt, id: tweet.id.toString(), + seenIds: seenIdsNextCursor, })); + + if (seenIdsNextCursor.length > SEEN_IDS_CURSOR_LIMIT) { + seenIdsNextCursor = seenIdsNextCursor.slice(seenIdsNextCursor.length - SEEN_IDS_CURSOR_LIMIT); + } + return { items: timeline, pagination, @@ -90,16 +106,6 @@ export class TimelineService { // 8 - hydrate these from redis or backfill from db (only the static data is required, no counters or interactions) // 9 - assemble and return - // note: i will filter timeline tweets for people I follow, not muted and accounts are active(i need to reach db for this sadly) - // why? it's easier that way instead of cleaning the cache on every mute/block/deactivate, the rare case of blocking/muting/deactivating all active people you follow to the point that the timeline becomes short is not worth the extra work - - // TODO invalidating user dto on deactivate and update (another PR after this), and counter updates - - //not the best, send authorids to be checked for unfollow/mute, and send the tweetids to check for deleted/deactivated accounts to fitler - // this while getting more keys to ensure a full page after filtering - - // i will remove retweets on write because retweet removal is not read-time filterable - // this is inconsistency I know, but yeah, irl the fanout would be only for nonpower users, so purging would be a better appraoch for a cleaner cache async timelineCacheHit( userId: bigint, decodedCursor: FeedCursor | undefined, @@ -854,13 +860,13 @@ export class TimelineService { } private deduplicateTimelineItems(items: string[]): string[] { - const seenTweetIds = new Set(); const uniqueItems: string[] = []; + const seenIdsInBatch = new Set(); for (const item of items) { const tweetId = item.split(':')[1]; - if (!seenTweetIds.has(tweetId)) { - seenTweetIds.add(tweetId); + if (!seenIdsInBatch.has(tweetId)) { + seenIdsInBatch.add(tweetId); uniqueItems.push(item); } } From 6721988f2a0a133fa1883512c398018ed21375aa Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Sat, 13 Dec 2025 01:11:08 +0200 Subject: [PATCH 08/43] feat: for-you - [CU-869becnwf] (#187) Signed-off-by: Tasneemmhammed0 Co-authored-by: Tasneemmhammed0 --- api-spec/complete-spec/main.tsp | 8 +- api-spec/implemented-spec/main.tsp | 13 + .../timeline/timeline-following.bru | 4 +- .../constants/redis-timeline-keys.constant.ts | 23 +- src/tweets/dtos/tweet.dto.ts | 1 - .../timeline/constants/timeline.constants.ts | 5 + .../for-you-feed-cache.interface.ts | 9 + src/tweets/timeline/timeline.controller.ts | 12 + src/tweets/timeline/timeline.service.ts | 513 +++++++++++++++++- src/tweets/tweets.repository.ts | 101 ++++ src/tweets/tweets.service.ts | 7 + src/users/users.repository.ts | 11 + test/tweets/tweets.service.spec.ts | 2 +- 13 files changed, 698 insertions(+), 11 deletions(-) create mode 100644 src/tweets/timeline/interfaces/for-you-feed-cache.interface.ts diff --git a/api-spec/complete-spec/main.tsp b/api-spec/complete-spec/main.tsp index 48cbca83..5ce8f448 100644 --- a/api-spec/complete-spec/main.tsp +++ b/api-spec/complete-spec/main.tsp @@ -140,6 +140,12 @@ model UnauthorizedResponse { ...Body; } +@doc("standard 410 Gone response") +model GoneResponse { + ...Response<410>; + ...Body; +} + @doc("Standard 403 Forbidden response") model ForbiddenResponse { ...Response<403>; @@ -1885,7 +1891,7 @@ namespace Timeline { @summary("Get the personalized 'For You' feed for the authenticated user") op getForYouFeed( ...CursorPaginationParameters, - ): DataResponseWithPagination | UnauthorizedResponse | InternalServerErrorResponse; + ): DataResponseWithPagination | UnauthorizedResponse | InternalServerErrorResponse | GoneResponse; @get @route("/following") diff --git a/api-spec/implemented-spec/main.tsp b/api-spec/implemented-spec/main.tsp index 8f305066..1ec77cfa 100644 --- a/api-spec/implemented-spec/main.tsp +++ b/api-spec/implemented-spec/main.tsp @@ -247,6 +247,12 @@ model UnauthorizedResponse { ...Body; } +@doc("standard 410 Gone response") +model GoneResponse { + ...Response<410>; + ...Body; +} + @doc("Standard 403 Forbidden response") model ForbiddenResponse { ...Response<403>; @@ -1792,6 +1798,13 @@ namespace Timeline { op getFollowingFeed( ...CursorPaginationParameters, ): DataResponseWithPagination | UnauthorizedResponse | InternalServerErrorResponse; + + @get + @route("/for-you") + @summary("Get the personalized 'For You' feed for the authenticated user") + op getForYouFeed( + ...CursorPaginationParameters, + ): DataResponseWithPagination | UnauthorizedResponse | InternalServerErrorResponse | GoneResponse; } @tag("Notifications") diff --git a/bruno/collections/timeline/timeline-following.bru b/bruno/collections/timeline/timeline-following.bru index 3ca72014..5aba09a5 100644 --- a/bruno/collections/timeline/timeline-following.bru +++ b/bruno/collections/timeline/timeline-following.bru @@ -5,14 +5,14 @@ meta { } get { - url: http://localhost:3000/timeline/following?cursor&limit=20 + url: http://localhost:3000/timeline/following?cursor&limit=2 body: none auth: bearer } params:query { cursor: - limit: 20 + limit: 2 } auth:bearer { diff --git a/src/common/constants/redis-timeline-keys.constant.ts b/src/common/constants/redis-timeline-keys.constant.ts index 163cc1b6..56e437b8 100644 --- a/src/common/constants/redis-timeline-keys.constant.ts +++ b/src/common/constants/redis-timeline-keys.constant.ts @@ -1,6 +1,22 @@ // functions to get the keys for timeline caches -export const REDIS_TIMELINE_KEYS = { +export const REDIS_TIMELINE_KEYS: { + getUserTimelineKey: (userId: bigint) => string; + getTimelineTweetItem: (authorId: bigint, tweetId: bigint) => string; + getTimelineRetweetItem: (authorId: bigint, tweetId: bigint, retweeterId: bigint) => string; + getUserTimelineEmptyPlaceholderKey: (userId: bigint) => string; + getTweetStaticDataKey: (tweetId: bigint) => string; + getAuthorDataKey: (authorId: bigint) => string; + getTweetLikesCountKey: (tweetId: bigint) => string; + getTweetRetweetsCountKey: (tweetId: bigint) => string; + getTweetRepliesCountKey: (tweetId: bigint) => string; + getUserTweetInteractionKey: (userId: bigint, tweetId: bigint) => string; + getUserInteractionsKey: (userId: bigint) => string; + getUserInteractionsLikeItem: (tweetId: bigint) => string; + getUserInteractionsRetweetItem: (tweetId: bigint) => string; + getForYouFeedKey: (userId: bigint) => string; + getForYouSeenKey: (userId: bigint) => string; +} = { getUserTimelineKey: (userId: bigint): string => { return `timeline:${userId}`; }, @@ -31,4 +47,9 @@ export const REDIS_TIMELINE_KEYS = { getUserInteractionsLikeItem: (tweetId: bigint): string => `like:${tweetId}`, getUserInteractionsRetweetItem: (tweetId: bigint): string => `retweet:${tweetId}`, + + // For You feed keys + getForYouFeedKey: (userId: bigint): string => `foryou:${userId}:feed`, + + getForYouSeenKey: (userId: bigint): string => `foryou:${userId}:seen`, }; diff --git a/src/tweets/dtos/tweet.dto.ts b/src/tweets/dtos/tweet.dto.ts index cbd994c3..7eb5c351 100644 --- a/src/tweets/dtos/tweet.dto.ts +++ b/src/tweets/dtos/tweet.dto.ts @@ -2,7 +2,6 @@ import { TweetEntitiesDto } from './tweet-entitites.dto'; import { MediaResponseDto } from 'src/media/dtos/media-response.dto'; import { DeletedTweet } from '../types'; import { CompactAuthorDto } from './compact-author.dto'; - export class TweetDto { id: string; author: CompactAuthorDto; diff --git a/src/tweets/timeline/constants/timeline.constants.ts b/src/tweets/timeline/constants/timeline.constants.ts index dc713f7b..e235a2f3 100644 --- a/src/tweets/timeline/constants/timeline.constants.ts +++ b/src/tweets/timeline/constants/timeline.constants.ts @@ -11,3 +11,8 @@ export const USER_FOLLOWINGS_CACHE_TTL = 432000 as const; // 5 days in seconds export const USER_MUTED_CACHE_TTL = 432000 as const; // 5 days in seconds export const SEEN_IDS_CURSOR_LIMIT = 100 as const; + +export const FOR_YOU_FEED_FRESH_TTL = 2 * 60; // 2 minutes +export const FOR_YOU_FEED_SCROLL_TTL = 1 * 60 * 60; // 1 hours (sliding window) +export const FOR_YOU_SEEN_CACHE_TTL = 30 * 60; // 30 minutes (works with scrolling only, resets on refresh) +export const FOR_YOU_FEED_SIZE = 800; diff --git a/src/tweets/timeline/interfaces/for-you-feed-cache.interface.ts b/src/tweets/timeline/interfaces/for-you-feed-cache.interface.ts new file mode 100644 index 00000000..c050aac5 --- /dev/null +++ b/src/tweets/timeline/interfaces/for-you-feed-cache.interface.ts @@ -0,0 +1,9 @@ +export interface ForYouFeedCache { + tweets: Array<{ + id: string; + score: number; + retweeterId?: string; + }>; + + generatedAt: number; // timestamp in milliseconds +} diff --git a/src/tweets/timeline/timeline.controller.ts b/src/tweets/timeline/timeline.controller.ts index bfc1abea..4cd0df7b 100644 --- a/src/tweets/timeline/timeline.controller.ts +++ b/src/tweets/timeline/timeline.controller.ts @@ -21,4 +21,16 @@ export class TimelineController { ...timelineTweets, }; } + + @Get('for-you') + @HttpCode(200) + async getForYouTimeline(@Query() pagination: PaginationQueryDto, @User() user: RequestUser) { + const userId = BigInt(user.id); + const { limit, cursor } = pagination; + const timelineTweets = await this.timelineService.getForYouFeed(userId, cursor, limit); + return { + message: 'For You Timeline retrieved successfully', + ...timelineTweets, + }; + } } diff --git a/src/tweets/timeline/timeline.service.ts b/src/tweets/timeline/timeline.service.ts index de4a35fa..52b81f4a 100644 --- a/src/tweets/timeline/timeline.service.ts +++ b/src/tweets/timeline/timeline.service.ts @@ -1,6 +1,7 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { RedisService } from 'src/redis/redis.service'; import { TweetsRepository } from '../tweets.repository'; +import { UsersRepository } from 'src/users/users.repository'; import { FeedCursor, TimelineCursor } from 'src/common/interfaces'; import { decodeCompositeCursor, paginateComposite } from 'src/common/utils'; import { @@ -12,6 +13,10 @@ import { TweetDto, CompactAuthorWithId } from '../dtos'; import { AUTHOR_COMPACT_DATA_CACHE_TTL, COUNT_CACHE_TTL, + FOR_YOU_FEED_FRESH_TTL, + FOR_YOU_FEED_SCROLL_TTL, + FOR_YOU_FEED_SIZE, + FOR_YOU_SEEN_CACHE_TTL, SEEN_IDS_CURSOR_LIMIT, TIMELINE_EMPTY_PLACEHOLDER_TTL, TWEET_STATIC_DATA_CACHE_TTL, @@ -19,6 +24,7 @@ import { import { DynamicDataFromCache, StaticDataFromCache } from './interfaces'; import { CachedStaticTweet } from '../interfaces'; import { REDIS_TIMELINE_KEYS } from 'src/common/constants/redis-timeline-keys.constant'; +import { ForYouFeedCache } from './interfaces/for-you-feed-cache.interface'; @Injectable() export class TimelineService { @@ -28,6 +34,7 @@ export class TimelineService { constructor( private readonly redisService: RedisService, private readonly tweetsRepository: TweetsRepository, + private readonly usersRepository: UsersRepository, ) { this.redisClient = redisService.getClient(); } @@ -58,7 +65,7 @@ export class TimelineService { REDIS_TIMELINE_KEYS.getUserTimelineKey(userId), ); if (timelineKeyExists) { - timeline = await this.timelineCacheHit(userId, decoded, limit + 1); + timeline = await this.timelineCacheHit(userId, decoded, limit + 1, seenSetCrossRequest); } else { // empty placeholder avoids the query on a cache miss, this gets removed on fanout of any new tweet/retweet if ( @@ -69,7 +76,7 @@ export class TimelineService { timeline = []; } else { await this.timelineCacheMiss(userId, decoded); - timeline = await this.timelineCacheHit(userId, decoded, limit + 1); + timeline = await this.timelineCacheHit(userId, decoded, limit + 1, seenSetCrossRequest); } } @@ -110,6 +117,7 @@ export class TimelineService { userId: bigint, decodedCursor: FeedCursor | undefined, limit: number = PAGINATION_DEFAULT_LIMIT, + seenSetCrossRequest: Set, ): Promise { const isEmpty = await this.redisClient.exists(`timeline:${userId}:empty`); if (isEmpty) { @@ -127,7 +135,12 @@ export class TimelineService { while (validTweets.length < limit && attempts < maxAttempts) { attempts++; - const timelineObjects = await this.getIdsFromTimelineSet(userId, currentCursor, batchSize); + const timelineObjects = await this.getIdsFromTimelineSet( + userId, + currentCursor, + batchSize, + seenSetCrossRequest, + ); if (!timelineObjects || timelineObjects.length === 0) { break; } @@ -244,18 +257,20 @@ export class TimelineService { userId: bigint, decodedCursor: FeedCursor | undefined, limit: number, + seenSetCrossRequest: Set, ): Promise { // paginated ids const timelineKey = REDIS_TIMELINE_KEYS.getUserTimelineKey(userId); const maxScore = decodedCursor ? new Date(decodedCursor.createdAt).getTime() : '+inf'; + const bufferMultiplier = seenSetCrossRequest && seenSetCrossRequest.size > 0 ? 3 : 1; const items = await this.redisClient.zrevrangebyscore( timelineKey, maxScore, '-inf', 'LIMIT', 0, - limit, + limit * bufferMultiplier, ); if (!items || items.length === 0) { @@ -269,7 +284,14 @@ export class TimelineService { ); } - return items; + let filteredItems = items; + if (seenSetCrossRequest && seenSetCrossRequest.size > 0) { + filteredItems = items.filter((item) => { + const tweetId = item.split(':')[1]; + return !seenSetCrossRequest.has(tweetId); + }); + } + return filteredItems; } /** @@ -905,4 +927,485 @@ export class TimelineService { return new Date(Number(score)); } + + /** + * Get the For You feed for a user + * + * Behavior: + * - Refreshing: + * - Refreshing from generation time up to 5 minutes shows unseen tweets in cache to prevent excess regeneration + * - a refresh after 5 minutes regenerates the new feed and resets seen cache + * - Scrolling: + * - scrolling uses the cached feed and a sets a sliding window ttl of 1 hour on scroll + * - Refreshing and exhausting the seen cache regenerates again, while scrolling to the end just returns empty (what about excessive refreshing then scrolling?) + */ + async getForYouFeed(userId: bigint, cursor: string | undefined, limit: number) { + this.logger.debug(`Fetching For You feed for user ID: ${userId}`); + + const isRefresh = !cursor; + + // 1. Decode cursor + let decodedCursor: { score: number; id: string } | undefined; + if (cursor) { + try { + decodedCursor = decodeCompositeCursor<{ score: number; id: string }>(cursor); + } catch { + throw new HttpException( + { + message: PAGINATION_ERROR_MESSAGES.INVALID_CURSOR, + code: PAGINATION_ERROR_CODES.INVALID_CURSOR, + }, + HttpStatus.BAD_REQUEST, + ); + } + } + + // 2. Get or generate ranked feed + const feedKey = REDIS_TIMELINE_KEYS.getForYouFeedKey(userId); + let rankedFeed = await this.getCachedForYouFeed(feedKey); + + // user left for more than FOR_YOU_FEED_SCROLL_TTL seconds, cache is gone + if (!rankedFeed && !isRefresh) { + this.logger.debug( + `For You feed cache expired for user ID: ${userId} while scrolling, returning 410`, + ); + throw new HttpException( + { + message: 'For You feed expired, please refresh to get new content.', + code: 'FOR_YOU_FEED_EXPIRED', + }, + HttpStatus.GONE, + ); + } + + const shouldGenerate = + !rankedFeed || + (isRefresh && Date.now() - rankedFeed.generatedAt > FOR_YOU_FEED_FRESH_TTL * 1000); + + if (shouldGenerate) { + this.logger.debug(`Generating new For You feed for user ID: ${userId}`); + const tweets = await this.generateForYouFeed(userId); + rankedFeed = { + tweets, + generatedAt: Date.now(), + }; + await this.redisClient.del(REDIS_TIMELINE_KEYS.getForYouSeenKey(userId)); // reset seen cache + + await this.cacheForYouFeed(feedKey, rankedFeed); + } else if (!isRefresh) { + // scrolling resets ttl on cached feed + await this.redisClient.expire(feedKey, FOR_YOU_FEED_SCROLL_TTL); + } + + // 3. Get seen tweets (only used for refresh) + const seenKey = REDIS_TIMELINE_KEYS.getForYouSeenKey(userId); + const seenTweetIds = await this.redisClient.smembers(seenKey); + const seenSet = new Set(seenTweetIds); + + // 4. Get feed items based on cursor + let feedAfterCursor = rankedFeed!.tweets; // sure we have a ranked feed here + if (decodedCursor && rankedFeed) { + feedAfterCursor = this.applyCursorToFeed(rankedFeed, decodedCursor); + } + + let tweetsToShow: Array<{ id: string; score: number }>; + + if (isRefresh) { + tweetsToShow = feedAfterCursor.filter((t) => !seenSet.has(t.id)); + if (tweetsToShow.length === 0) { + this.logger.debug(`No unseen tweets on refresh for user ${userId}, clearing seen cache`); + await this.redisClient.del(seenKey); + tweetsToShow = feedAfterCursor.slice(0, limit + 1); + } + } else { + tweetsToShow = feedAfterCursor; + // regeneration happens only on refresh, scrolling can reach the end + if (tweetsToShow.length === 0) { + this.logger.debug(`User ${userId} reached end of feed while scrolling`); + return { + items: [], + pagination: { + cursor: cursor || null, + nextCursor: null, + hasNextPage: false, + }, + }; + } + } + + // 6. Fetch and validate tweet data + const tweetsToFetch = tweetsToShow.slice(0, limit + 1); // Get +1 for cursor + const validTweets = await this.fetchAndValidateForYouTweets(userId, tweetsToFetch, limit + 1); + + // 7. Update seen cache (only for tweets we're actually showing) + if (validTweets.length > 0) { + const shownTweetIds = validTweets.slice(0, limit).map((t) => t.id); + await this.redisClient.sadd(seenKey, ...shownTweetIds); + await this.redisClient.expire(seenKey, FOR_YOU_SEEN_CACHE_TTL); + } + + // 8. Return with pagination + const pagination = paginateComposite(validTweets, limit, cursor, (tweet) => { + const tweetInFeed = rankedFeed?.tweets.find((t) => t.id === tweet.id); + return { + score: tweetInFeed ? tweetInFeed.score : 0, + id: tweet.id, + }; + }); + + return { + items: validTweets.slice(0, limit), + pagination, + }; + } + + private applyCursorToFeed( + rankedFeed: ForYouFeedCache, + cursor?: { score: number; id: string }, + ): { id: string; score: number; retweeterId?: string }[] { + if (!cursor) return rankedFeed.tweets; + + return rankedFeed.tweets.filter((t) => { + if (t.score < cursor.score) return true; + if (t.score === cursor.score && t.id <= cursor.id) return true; + return false; + }); + } + + private async getCachedForYouFeed(feedKey: string): Promise { + const cachedFeed = await this.redisClient.get(feedKey); + if (!cachedFeed) { + return null; + } + return JSON.parse(cachedFeed) as ForYouFeedCache; + } + + private async cacheForYouFeed(feedKey: string, rankedFeed: ForYouFeedCache): Promise { + await this.redisClient.set(feedKey, JSON.stringify(rankedFeed), 'EX', FOR_YOU_FEED_SCROLL_TTL); + } + + /** + * Fetch and validate For You tweets in batches + */ + private async fetchAndValidateForYouTweets( + userId: bigint, + unseenTweets: Array<{ id: string; score: number; retweeterId?: string }>, + limit: number, + ): Promise { + const validTweets: TweetDto[] = []; + const batchSize = limit * 2; + const maxAttempts = 5; + let attempts = 0; + let startIndex = 0; + + while ( + validTweets.length < limit && + attempts < maxAttempts && + startIndex < unseenTweets.length + ) { + attempts++; + + // Get batch from unseen tweets + const batchTweets = unseenTweets.slice(startIndex, startIndex + batchSize); + if (batchTweets.length === 0) { + break; + } + + // Get tweet data from DB + const tweetsFromDb = await this.tweetsRepository.getTweetsByIds( + batchTweets.map((t) => BigInt(t.id)), + ); + const tweetMap = new Map(tweetsFromDb.map((t) => [t.id, t])); + + const existingBatch = batchTweets.filter((item) => tweetMap.has(item.id)); + + // Deduplicate + const seenInBatch = new Set(); + const uniqueFiltered = existingBatch.filter((item) => { + if (seenInBatch.has(item.id)) return false; + seenInBatch.add(item.id); + return true; + }); + + this.logger.debug( + `For You: Processing ${uniqueFiltered.length} items for user ${userId} in batch ${attempts}`, + ); + + if (uniqueFiltered.length > 0) { + // Build timeline items format for hydration + const orderedTimelineItems: string[] = uniqueFiltered.map((item) => { + const tweet = tweetMap.get(item.id)!; // sure to exist + if (item.retweeterId) { + return REDIS_TIMELINE_KEYS.getTimelineRetweetItem( + BigInt(tweet.authorId), + BigInt(tweet.id), + BigInt(item.retweeterId), + ); + } + return REDIS_TIMELINE_KEYS.getTimelineTweetItem(BigInt(tweet.authorId), BigInt(tweet.id)); + }); + + // Hydrate static data + const { tweets, authors, missingTweetIds, missingAuthorIds } = + await this.hydrateStaticData(orderedTimelineItems); + + const { tweets: backfilledTweets, authors: backfilledAuthors } = + await this.backFillStaticDataToCache(missingTweetIds, missingAuthorIds); + + for (const tweet of backfilledTweets) { + tweets.set(tweet.id, tweet); + } + for (const author of backfilledAuthors) { + authors.set(author.id, author); + } + + // Hydrate dynamic data + const dynamicData = await this.getAndBackfillTweetDynamicData(tweets, userId); + + // Hydrate quoted tweets + const quoteTweetIdSet = new Set(); + for (const tweet of tweets.values()) { + if (tweet.quoteToTweetId) { + quoteTweetIdSet.add(tweet.quoteToTweetId); + } + } + + if (quoteTweetIdSet.size > 0) { + const { tweets: quoteTweetsMap, authors: quoteAuthorsMap } = + await this.hydrateStaticQuoteData(Array.from(quoteTweetIdSet)); + + for (const [tweetId, tweet] of quoteTweetsMap.entries()) { + tweets.set(tweetId, tweet); + } + for (const [authorId, author] of quoteAuthorsMap.entries()) { + authors.set(authorId, author); + } + } + + // Assemble tweets + const assembledBatch = this.assembleTimelineTweets( + orderedTimelineItems, + tweets, + authors, + dynamicData, + ); + + validTweets.push(...assembledBatch); + } + + if (validTweets.length >= limit) { + break; + } + + startIndex += batchSize; + } + + return validTweets.slice(0, limit); + } + + /** + * Generate ranked For You feed by combining: + * 1. Tweets from users the person follows (from cached Following timeline) + * 2. Tweets matching user's interests (from DB) + * Then rank by recency and engagement + */ + private async generateForYouFeed( + userId: bigint, + ): Promise> { + this.logger.debug(`Generating For You feed for user ${userId}`); + + // 1. Get user interests from database + const userInterests = await this.tweetsRepository.getUserInterests(userId); + this.logger.debug( + `User ${userId} has ${userInterests.length} interests: ${userInterests.join(', ')}`, + ); + + // 2. Get tweets from Following timeline (populate cache if needed) + const timelineKey = REDIS_TIMELINE_KEYS.getUserTimelineKey(userId); + const timelineKeyExists = await this.redisClient.exists(timelineKey); + + if (!timelineKeyExists) { + // Check for empty placeholder first + const emptyPlaceholderExists = await this.redisClient.exists( + REDIS_TIMELINE_KEYS.getUserTimelineEmptyPlaceholderKey(userId), + ); + if (!emptyPlaceholderExists) { + // Populate the cache + await this.timelineCacheMiss(userId, undefined); + } + } + + // 2. Get tweet candidates from the Following timeline cache + const followingTweets = await this.getFollowingTimelineCandidates(userId, FOR_YOU_FEED_SIZE); + const followingTweetIds = new Set(followingTweets.map((t) => t.id)); // Keep track of which IDs came from this source + + this.logger.debug( + `Got ${followingTweets.length} candidate tweets from Following timeline for user ${userId}`, + ); + + // 3. Get interest-based tweets from db + const interestTweets = + userInterests.length > 0 + ? await this.tweetsRepository.getTweetsMatchingInterests(userInterests, FOR_YOU_FEED_SIZE) + : []; + this.logger.debug(`Got ${interestTweets.length} interest-based tweets for user ${userId}`); + + // 4. Deduplicate candidates (following tweets take priority) + const candidatesMap = new Map< + string, + { id: string; authorId: string; createdAt: Date; retweeterId?: string } + >(); + for (const tweet of followingTweets) { + candidatesMap.set(tweet.id, tweet); + } + for (const tweet of interestTweets) { + if (!candidatesMap.has(tweet.id)) { + candidatesMap.set(tweet.id, tweet); + } + } + + const candidates = Array.from(candidatesMap.values()); + this.logger.debug(`Total ${candidates.length} unique candidate tweets for user ${userId}`); + + if (candidates.length === 0) { + return []; + } + + const candidateTweetIds = candidates.map((c) => BigInt(c.id)); + const candidateAuthorIds = new Set(candidates.map((c) => BigInt(c.authorId))); + + const [validAuthorIds, validTweetIds] = await Promise.all([ + this.tweetsRepository.filterNonMutedAuthors(userId, Array.from(candidateAuthorIds)), + this.tweetsRepository.filterValidTweets(candidateTweetIds), + ]); + + validAuthorIds.push(userId); + + const validAuthorSet = new Set(validAuthorIds.map((id) => id.toString())); + const validTweetSet = new Set(validTweetIds.map((id) => id.toString())); + + const validCandidates = candidates.filter( + (c) => validTweetSet.has(c.id) && validAuthorSet.has(c.authorId), + ); + + this.logger.debug( + `Filtered to ${validCandidates.length} valid candidates after removing muted/blocked/deleted content for user ${userId}`, + ); + + if (validCandidates.length === 0) { + return []; + } + + // 5. Get engagement counts for ranking + const tweetIds = validCandidates.map((c) => BigInt(c.id)); + const countsMap = await this.tweetsRepository.getTweetCounts(tweetIds); + + // 6. Get following IDs for personalization boost on interest tweets + const followingIds = await this.usersRepository.getFollowingIds(userId); + const followingSet = new Set(followingIds.map((id) => id.toString())); + + const scored = validCandidates.map((candidate) => { + const counts = countsMap.get(candidate.id); + const now = Date.now(); + const ageInHours = (now - candidate.createdAt.getTime()) / (1000 * 60 * 60); + + let score = 0; + + // exp decay with 24hr half life + const recencyScore = Math.exp(-ageInHours / 24) * 100; + score += recencyScore; + + if (counts) { + const likeValue = Math.log1p(counts.likeCounts); + const retweetValue = Math.log1p(counts.retweetCounts) * 5; + const replyValue = Math.log1p(counts.replyCounts) * 2; + + score += (likeValue + retweetValue + replyValue) * 5; + } + + const isFromFollowingTimeline = followingTweetIds.has(candidate.id); + const isAuthorFollowed = followingSet.has(candidate.authorId); + if (isFromFollowingTimeline || isAuthorFollowed) { + score += 30; + } + + return { + id: candidate.id, + score: Math.round(score * 1000) / 1000, + }; + }); + + scored.sort((a, b) => { + if (b.score !== a.score) { + return b.score - a.score; + } + // tie breaker + return b.id.localeCompare(a.id); + }); + + const validCandidateMap = validCandidates.reduce((map, candidate) => { + map.set(candidate.id, candidate); + return map; + }, new Map()); // for easier access + + const rankedFeed = scored.slice(0, FOR_YOU_FEED_SIZE).map((scored) => { + const candidate = validCandidateMap.get(scored.id); + return { + id: scored.id, + score: scored.score, + retweeterId: candidate?.retweeterId, + }; + }); + + this.logger.debug( + `Generated ranked For You feed with ${rankedFeed.length} tweets for user ${userId}`, + ); + + return rankedFeed; + } + private async getFollowingTimelineCandidates( + userId: bigint, + limit: number, + ): Promise<{ id: string; authorId: string; createdAt: Date }[]> { + const timelineKey = REDIS_TIMELINE_KEYS.getUserTimelineKey(userId); + + if (!(await this.redisClient.exists(timelineKey))) { + if ( + !(await this.redisClient.exists( + REDIS_TIMELINE_KEYS.getUserTimelineEmptyPlaceholderKey(userId), + )) + ) { + await this.timelineCacheMiss(userId, undefined); + } + } + + const membersAndScores = await this.redisClient.zrevrange( + timelineKey, + 0, + limit - 1, + 'WITHSCORES', + ); + + const candidates: { id: string; authorId: string; createdAt: Date; retweeterId?: string }[] = + []; + if (membersAndScores) { + for (let i = 0; i < membersAndScores.length; i += 2) { + const member = membersAndScores[i]; + const score = membersAndScores[i + 1]; + const parts = member.split(':'); + const [authorId, tweetId, actionType] = parts; + const candidate: { id: string; authorId: string; createdAt: Date; retweeterId?: string } = { + id: tweetId, + authorId: authorId, + createdAt: new Date(Number(score)), + }; + if (actionType === 'R' && parts[3]) { + candidate.retweeterId = parts[3]; + } + + candidates.push(candidate); + } + } + return candidates; + } } diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts index f973b175..6c303455 100644 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -1410,6 +1410,23 @@ export class TweetsRepository { return validFollows.map((f) => f.followedId); } + async filterNonMutedAuthors(userId: bigint, authorIds: bigint[]): Promise { + const validAuthors = await this.prisma.user.findMany({ + where: { + id: { in: authorIds }, + deletedAt: null, + mutedBy: { + none: { + userId: userId, + }, + }, + }, + select: { id: true }, + }); + + return validAuthors.map((f) => f.id); + } + /** * Filters tweet IDs to return only those not deleted * @param tweetIds Array of tweet IDs to validate @@ -1480,4 +1497,88 @@ export class TweetsRepository { retweeterId: row.retweeterId, })); } + + /** + * Get user's interests from their profile + */ + async getUserInterests(userId: bigint): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { interests: true }, + }); + + return user?.interests || []; + } + + /** + * Get recent tweets from users the person follows + */ + async getRecentTweetsFromFollowing( + userId: bigint, + limit: number, + ): Promise> { + const following = await this.prisma.follow.findMany({ + where: { followerId: userId }, + select: { followedId: true }, + }); + + if (following.length === 0) { + return []; + } + + const followingIds = following.map((f) => f.followedId); + + const tweets = await this.prisma.tweet.findMany({ + where: { + userId: { in: followingIds }, + isDeleted: false, + }, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + userId: true, + createdAt: true, + }, + }); + + return tweets.map((t) => ({ + id: t.id.toString(), + authorId: t.userId.toString(), + createdAt: t.createdAt, + })); + } + + /** + * Get tweets matching user's interests (from tweet.class field) + */ + async getTweetsMatchingInterests( + interests: string[], + limit: number, + ): Promise> { + if (interests.length === 0) { + return []; + } + + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + // Use = ANY() to match class against array of interests + const tweets = await this.prisma.$queryRaw< + Array<{ id: bigint; user_id: bigint; created_at: Date }> + >` + SELECT id, user_id, created_at + FROM tweets + WHERE class = ANY(${interests}::text[]) + AND is_deleted = false + AND created_at >= ${sevenDaysAgo}::timestamp + ORDER BY created_at DESC + LIMIT ${limit} + `; + + return tweets.map((t) => ({ + id: t.id.toString(), + authorId: t.user_id.toString(), + createdAt: t.created_at, + })); + } } diff --git a/src/tweets/tweets.service.ts b/src/tweets/tweets.service.ts index d99b1009..8ed88e1a 100644 --- a/src/tweets/tweets.service.ts +++ b/src/tweets/tweets.service.ts @@ -239,6 +239,13 @@ export class TweetsService { ); } + if (createTweetDto.replyToTweetId) { + await this.redisService.safeIncr( + REDIS_TIMELINE_KEYS.getTweetRepliesCountKey(tweetId), + COUNT_CACHE_TTL, + ); + } + return this.formatTweetDto( tweet, mentions, diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 8df81304..f341c966 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -606,6 +606,17 @@ export class UsersRepository { return !!follow; } + /** + * Get all user IDs that a given user follows + */ + async getFollowingIds(userId: bigint): Promise { + const follows = await this.prisma.follow.findMany({ + where: { followerId: userId }, + select: { followedId: true }, + }); + return follows.map((f) => f.followedId); + } + /** * Blocks a user and removes any existing follow relationships between the users. */ diff --git a/test/tweets/tweets.service.spec.ts b/test/tweets/tweets.service.spec.ts index 5da5baf9..8da163e8 100644 --- a/test/tweets/tweets.service.spec.ts +++ b/test/tweets/tweets.service.spec.ts @@ -3,7 +3,6 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { TweetsService } from 'src/tweets/tweets.service'; import { TweetsRepository } from 'src/tweets/tweets.repository'; import { UsersRepository } from 'src/users/users.repository'; -import { RedisService } from 'src/redis/redis.service'; import { TWEETS_ERROR_CODES, TWEETS_ERROR_MESSAGES } from 'src/tweets/constants'; import { USERS_ERROR_CODES, USERS_ERROR_MESSAGES } from 'src/users/constants'; import { PAGINATION_ERROR_CODES, PAGINATION_ERROR_MESSAGES } from 'src/common/constants'; @@ -16,6 +15,7 @@ import { DomainEventsService } from 'src/events/domain-events.service'; import { MediaType } from '@prisma/client'; import { getQueueToken } from '@nestjs/bullmq'; import { PeopleSearchFilter } from 'src/search/dtos'; +import { RedisService } from 'src/redis/redis.service'; import { TrendingService } from 'src/trending/trending.service'; const encodeCompositeCursor = (cursorObject: object): string => { From a69de99fded81a76fd748c45ff266402399c117f Mon Sep 17 00:00:00 2001 From: Mostafa Ahmed Abdelglel <139139781+galelo04@users.noreply.github.com> Date: Sat, 13 Dec 2025 10:11:17 +0200 Subject: [PATCH 09/43] feat: delete notifications - [CU-869bdyg09] (#181) --- prisma/schema.prisma | 2 +- src/notifications/notifications.repository.ts | 4 ++++ src/notifications/notifications.service.ts | 1 + src/tweets/tweets.repository.ts | 13 +++++++++++-- src/tweets/tweets.service.ts | 3 ++- src/users/users.repository.ts | 3 +++ src/users/users.service.ts | 2 +- 7 files changed, 23 insertions(+), 5 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 783593d2..89999461 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -161,7 +161,7 @@ model Tweet { replyToTweet Tweet? @relation("tweets_reply_to_tweet_idTotweets", fields: [replyToTweetId], references: [id], onDelete: NoAction, onUpdate: NoAction) replies Tweet[] @relation("tweets_reply_to_tweet_idTotweets") rootTweet Tweet? @relation("tweets_root_tweet_idTotweets", fields: [rootTweetId], references: [id], onDelete: NoAction, onUpdate: NoAction) - threadTweets Tweet[] @relation("tweets_root_tweet_idTotweets") + threadTweets Tweet[] @relation("tweets_root_tweet_idTotweets") user User @relation(fields: [userId], references: [id], onDelete: NoAction, onUpdate: NoAction) @@index([rootTweetId], map: "tweets_root_tweet_id_idx") diff --git a/src/notifications/notifications.repository.ts b/src/notifications/notifications.repository.ts index b903785f..6969a294 100644 --- a/src/notifications/notifications.repository.ts +++ b/src/notifications/notifications.repository.ts @@ -69,6 +69,10 @@ export class NotificationsRepository { }; } + async deleteByOptions(options: NotificationTriggerOptions) { + return await this.prisma.notification.deleteMany({ where: options }); + } + async findByIdForPush(notificationId: bigint) { return await this.prisma.notification.findUnique({ where: { id: notificationId }, diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 808dbe16..47178238 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -19,6 +19,7 @@ export class NotificationsService { private readonly usersRepository: UsersRepository, @InjectQueue('notifications') private readonly notificationsQueue: Queue, ) {} + async trigger(options: NotificationTriggerOptions) { this.logger.log( `Triggering notification of type ${options.type} from actor ${options.actorId} to receiver ${options.receiverId}`, diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts index 6c303455..be3b72e6 100644 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -307,11 +307,12 @@ export class TweetsRepository { where: { id: tweetId }, data: { isDeleted: true }, }); - await prismaClient.retweet.deleteMany({ where: { tweetId }, }); - + await prismaClient.notification.deleteMany({ + where: { tweetId }, + }); await prismaClient.like.deleteMany({ where: { tweetId }, }); @@ -424,6 +425,10 @@ export class TweetsRepository { }, }); + await tx.notification.deleteMany({ + where: { tweetId, actorId: userId, type: 'LIKE' }, + }); + await tx.tweet.update({ where: { id: tweetId }, data: { @@ -496,6 +501,10 @@ export class TweetsRepository { }, }); + await tx.notification.deleteMany({ + where: { tweetId, actorId: userId, type: 'RETWEET' }, + }); + await tx.tweet.update({ where: { id: tweetId }, data: { diff --git a/src/tweets/tweets.service.ts b/src/tweets/tweets.service.ts index 8ed88e1a..23af32f8 100644 --- a/src/tweets/tweets.service.ts +++ b/src/tweets/tweets.service.ts @@ -494,7 +494,8 @@ export class TweetsService { COUNT_CACHE_TTL, ); - this.logger.debug(`User ${userId} unliked tweet ${tweetId} successfully`); + this.logger.log(`User ${userId} unliked tweet ${tweetId} successfully`); + return { message: 'Tweet unliked successfully' }; } diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index f341c966..9225a218 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -496,6 +496,9 @@ export class UsersRepository { where: { id: followedId }, data: { followersCount: { decrement: 1 } }, }), + this.prisma.notification.deleteMany({ + where: { receiverId: followedId, actorId: followerId, type: 'FOLLOW' }, + }), ]) .catch((e) => { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { diff --git a/src/users/users.service.ts b/src/users/users.service.ts index bc181693..1b3dc9a3 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -492,8 +492,8 @@ export class UsersService { } await this.usersRepository.unfollowUser(followerId, followedId); - this.logger.log(`User ID: ${followerId} unfollowed User ID: ${followedId}`); + this.logger.log(`User ID: ${followerId} unfollowed User ID: ${followedId}`); return { message: 'User unfollowed successfully.' }; } From af83ec98851bde52d996625cb25fe53d27101706 Mon Sep 17 00:00:00 2001 From: Loay Ahmed Date: Sat, 13 Dec 2025 12:55:48 +0200 Subject: [PATCH 10/43] perf: improve seed.ts condition run - [CU-869bfkh7y] (#202) Co-authored-by: anas-ibrahem --- package.json | 2 +- prisma/seed.ts | 2534 ++++++++++++++++++++++++------------------------ 2 files changed, 1273 insertions(+), 1263 deletions(-) diff --git a/package.json b/package.json index f9eba139..ecb20abf 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "db:migrate": "prisma migrate deploy", "db:create": "prisma migrate dev", "db:generate": "prisma generate", - "db:reset": "prisma migrate reset --force && prisma db seed", + "db:reset": "prisma migrate reset --force", "db:seed": "prisma db seed", "db:seed-timeline": "ts-node prisma/timeline-seed.ts", "db:seed-large-timeline": "ts-node prisma/seed-large-timeline.ts", diff --git a/prisma/seed.ts b/prisma/seed.ts index f32e4e36..db4df0f5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -2,543 +2,551 @@ import { PrismaClient, NotificationType } from '@prisma/client'; import * as fs from 'fs'; import * as path from 'path'; -const prisma = new PrismaClient(); - -async function main() { - await prisma.tweet.updateMany({ - data: { quotedTweetId: null, replyToTweetId: null }, - }); - await prisma.conversation.updateMany({ - data: { lastMessageId: null }, - }); - await prisma.conversationParticipant.updateMany({ - data: { lastSeenMessageId: null }, - }); - await prisma.notification.deleteMany(); - await prisma.conversationParticipant.deleteMany(); - await prisma.message.deleteMany(); - await prisma.conversation.deleteMany(); - await prisma.like.deleteMany(); - await prisma.retweet.deleteMany(); - await prisma.tweetMedia.deleteMany(); - await prisma.media.deleteMany(); - await prisma.tweetHashtag.deleteMany(); - await prisma.trendingKeyword.deleteMany(); - await prisma.tweetMention.deleteMany(); - await prisma.tweet.deleteMany(); - await prisma.mute.deleteMany(); - await prisma.block.deleteMany(); - await prisma.follow.deleteMany(); - await prisma.refreshToken.deleteMany(); - await prisma.userDevice.deleteMany(); - await prisma.userExternalAccount.deleteMany(); - await prisma.profile.deleteMany(); - await prisma.user.deleteMany(); - await prisma.country.deleteMany(); - - await prisma.$executeRaw`ALTER SEQUENCE "users_id_seq" RESTART WITH 1;`; - await prisma.$executeRaw`ALTER SEQUENCE "refresh_tokens_id_seq" RESTART WITH 1;`; - await prisma.$executeRaw`ALTER SEQUENCE "user_devices_id_seq" RESTART WITH 1;`; - await prisma.$executeRaw`ALTER SEQUENCE "tweets_id_seq" RESTART WITH 1;`; - await prisma.$executeRaw`ALTER SEQUENCE "trending_keywords_id_seq" RESTART WITH 1;`; - await prisma.$executeRaw`ALTER SEQUENCE "messages_id_seq" RESTART WITH 1;`; - await prisma.$executeRaw`ALTER SEQUENCE "conversations_id_seq" RESTART WITH 1;`; - await prisma.$executeRaw`ALTER SEQUENCE "notifications_id_seq" RESTART WITH 1;`; - await prisma.$executeRaw`ALTER SEQUENCE "media_id_seq" RESTART WITH 1;`; - await prisma.$executeRaw`ALTER SEQUENCE "countries_id_seq" RESTART WITH 1;`; - - const countriesPath = path.join(__dirname, 'countries.json'); - const countriesData = JSON.parse(fs.readFileSync(countriesPath, 'utf-8')) as Array<{ - name: string; - 'alpha-2': string; - 'country-code': string; - }>; - - for (const country of countriesData) { - await prisma.country.create({ - data: { - name: country.name, - code: country['alpha-2'], - }, - }); - } +if (process.env.SEED_ENV === 'true') { + console.log('Started seeding database...'); + const prisma = new PrismaClient(); - const egypt = await prisma.country.findFirst({ where: { code: 'EG' } }); - const usa = await prisma.country.findFirst({ where: { code: 'US' } }); - const uk = await prisma.country.findFirst({ where: { code: 'GB' } }); - const canada = await prisma.country.findFirst({ where: { code: 'CA' } }); - const germany = await prisma.country.findFirst({ where: { code: 'DE' } }); - const france = await prisma.country.findFirst({ where: { code: 'FR' } }); - - const usersToCreate = [ - { - username: 'OmarHassan', - email: 'omar@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2003-08-04'), - countryId: egypt?.id, - profile: { create: { displayName: 'Omar Hassan' } }, - }, - { - username: 'notnowomar', - email: 'omarg@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2003-12-04'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Gamal', - bio: 'NOT a Frontend enthusiast.', - location: 'October, Egypt', + async function main() { + await prisma.tweet.updateMany({ + data: { quotedTweetId: null, replyToTweetId: null }, + }); + await prisma.conversation.updateMany({ + data: { lastMessageId: null }, + }); + await prisma.conversationParticipant.updateMany({ + data: { lastSeenMessageId: null }, + }); + await prisma.notification.deleteMany(); + await prisma.conversationParticipant.deleteMany(); + await prisma.message.deleteMany(); + await prisma.conversation.deleteMany(); + await prisma.like.deleteMany(); + await prisma.retweet.deleteMany(); + await prisma.tweetMedia.deleteMany(); + await prisma.media.deleteMany(); + await prisma.tweetHashtag.deleteMany(); + await prisma.trendingKeyword.deleteMany(); + await prisma.tweetMention.deleteMany(); + await prisma.tweet.deleteMany(); + await prisma.mute.deleteMany(); + await prisma.block.deleteMany(); + await prisma.follow.deleteMany(); + await prisma.refreshToken.deleteMany(); + await prisma.userDevice.deleteMany(); + await prisma.userExternalAccount.deleteMany(); + await prisma.profile.deleteMany(); + await prisma.user.deleteMany(); + await prisma.country.deleteMany(); + + await prisma.$executeRaw`ALTER SEQUENCE "users_id_seq" RESTART WITH 1;`; + await prisma.$executeRaw`ALTER SEQUENCE "refresh_tokens_id_seq" RESTART WITH 1;`; + await prisma.$executeRaw`ALTER SEQUENCE "user_devices_id_seq" RESTART WITH 1;`; + await prisma.$executeRaw`ALTER SEQUENCE "tweets_id_seq" RESTART WITH 1;`; + await prisma.$executeRaw`ALTER SEQUENCE "trending_keywords_id_seq" RESTART WITH 1;`; + await prisma.$executeRaw`ALTER SEQUENCE "messages_id_seq" RESTART WITH 1;`; + await prisma.$executeRaw`ALTER SEQUENCE "conversations_id_seq" RESTART WITH 1;`; + await prisma.$executeRaw`ALTER SEQUENCE "notifications_id_seq" RESTART WITH 1;`; + await prisma.$executeRaw`ALTER SEQUENCE "media_id_seq" RESTART WITH 1;`; + await prisma.$executeRaw`ALTER SEQUENCE "countries_id_seq" RESTART WITH 1;`; + + const countriesPath = path.join(__dirname, 'countries.json'); + const countriesData = JSON.parse(fs.readFileSync(countriesPath, 'utf-8')) as Array<{ + name: string; + 'alpha-2': string; + 'country-code': string; + }>; + + for (const country of countriesData) { + await prisma.country.create({ + data: { + name: country.name, + code: country['alpha-2'], + }, + }); + } + + const egypt = await prisma.country.findFirst({ where: { code: 'EG' } }); + const usa = await prisma.country.findFirst({ where: { code: 'US' } }); + const uk = await prisma.country.findFirst({ where: { code: 'GB' } }); + const canada = await prisma.country.findFirst({ where: { code: 'CA' } }); + const germany = await prisma.country.findFirst({ where: { code: 'DE' } }); + const france = await prisma.country.findFirst({ where: { code: 'FR' } }); + + const usersToCreate = [ + { + username: 'OmarHassan', + email: 'omar@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2003-08-04'), + countryId: egypt?.id, + profile: { create: { displayName: 'Omar Hassan' } }, + }, + { + username: 'notnowomar', + email: 'omarg@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2003-12-04'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Gamal', + bio: 'NOT a Frontend enthusiast.', + location: 'October, Egypt', + }, + }, + }, + { + username: 'Tasneem', + email: 'tasneem@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2004-08-04'), + phone: '01001013205', + countryId: egypt?.id, + profile: { create: { displayName: 'Tasneem', bio: 'Life is good.' } }, + }, + { + username: 'anasbrahim', + email: 'anas@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2004-08-04'), + phone: '01005013203', + countryId: egypt?.id, + profile: { create: { displayName: 'Anas' } }, + }, + { + username: 'gelgel', + email: 'mostafa@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2003-12-05'), + phone: '01005013209', + countryId: egypt?.id, + profile: { create: { displayName: 'Mostafa' } }, + }, + { + username: 'Layla', + email: 'layla@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2002-05-15'), + countryId: usa?.id, + profile: { create: { displayName: 'Layla El-Sayed', bio: 'Designer & Photographer 📸' } }, + }, + { + username: 'kimo', + email: 'karim@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2003-11-20'), + countryId: uk?.id, + profile: { create: { displayName: 'karim', bio: 'Just here for the memes.' } }, + }, + { + username: 'SaraA', + email: 'sara@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2001-03-10'), + phone: '01001234567', + countryId: canada?.id, + profile: { create: { displayName: 'Sara Ahmed', bio: 'Backend dev & coffee addict ☕' } }, + }, + { + username: 'ZakiDev', + email: 'ahmedz@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2004-07-22'), + countryId: germany?.id, + profile: { create: { displayName: 'Ahmed Zaki', bio: 'Learning GraphQL daily.' } }, + }, + { + username: 'NourCodes', + email: 'nour@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2002-09-18'), + countryId: france?.id, + profile: { create: { displayName: 'Nour', bio: 'Full-stack explorer.' } }, + }, + { + username: 'YoussefTech', + email: 'youssef@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2003-02-14'), + phone: '01009876543', + countryId: egypt?.id, + profile: { create: { displayName: 'Youssef', bio: 'AI enthusiast 🤖' } }, + }, + { + username: 'FatmaDesign', + email: 'fatma@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2004-11-30'), + countryId: usa?.id, + profile: { create: { displayName: 'Fatma', bio: 'UI/UX magic maker.' } }, + }, + { + username: 'reactjs', + email: 'react@gmail.com', + passwordHash: '$2a$10$faoFdN3VO845Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2004-11-30'), + countryId: usa?.id, + profile: { + create: { displayName: 'React', bio: 'The library for web and native user interfaces' }, }, }, - }, - { - username: 'Tasneem', - email: 'tasneem@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2004-08-04'), - phone: '01001013205', - countryId: egypt?.id, - profile: { create: { displayName: 'Tasneem', bio: 'Life is good.' } }, - }, - { - username: 'anasbrahim', - email: 'anas@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2004-08-04'), - phone: '01005013203', - countryId: egypt?.id, - profile: { create: { displayName: 'Anas' } }, - }, - { - username: 'gelgel', - email: 'mostafa@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2003-12-05'), - phone: '01005013209', - countryId: egypt?.id, - profile: { create: { displayName: 'Mostafa' } }, - }, - { - username: 'Layla', - email: 'layla@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2002-05-15'), - countryId: usa?.id, - profile: { create: { displayName: 'Layla El-Sayed', bio: 'Designer & Photographer 📸' } }, - }, - { - username: 'kimo', - email: 'karim@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2003-11-20'), - countryId: uk?.id, - profile: { create: { displayName: 'karim', bio: 'Just here for the memes.' } }, - }, - { - username: 'SaraA', - email: 'sara@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2001-03-10'), - phone: '01001234567', - countryId: canada?.id, - profile: { create: { displayName: 'Sara Ahmed', bio: 'Backend dev & coffee addict ☕' } }, - }, - { - username: 'ZakiDev', - email: 'ahmedz@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2004-07-22'), - countryId: germany?.id, - profile: { create: { displayName: 'Ahmed Zaki', bio: 'Learning GraphQL daily.' } }, - }, - { - username: 'NourCodes', - email: 'nour@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2002-09-18'), - countryId: france?.id, - profile: { create: { displayName: 'Nour', bio: 'Full-stack explorer.' } }, - }, - { - username: 'YoussefTech', - email: 'youssef@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2003-02-14'), - phone: '01009876543', - countryId: egypt?.id, - profile: { create: { displayName: 'Youssef', bio: 'AI enthusiast 🤖' } }, - }, - { - username: 'FatmaDesign', - email: 'fatma@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2004-11-30'), - countryId: usa?.id, - profile: { create: { displayName: 'Fatma', bio: 'UI/UX magic maker.' } }, - }, - { - username: 'reactjs', - email: 'react@gmail.com', - passwordHash: '$2a$10$faoFdN3VO845Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2004-11-30'), - countryId: usa?.id, - profile: { - create: { displayName: 'React', bio: 'The library for web and native user interfaces' }, - }, - }, - { - username: 'vel_rea_en', - email: 'velvelreact@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2004-11-30'), - countryId: usa?.id, - profile: { - create: { displayName: 'Velvel React (EN)', bio: 'Idk who is velvet react just testing.' }, - }, - }, - { - username: 'sumit_saurabh', - email: 'sumit@gmail.com', - passwordHash: '$2a$10$faoFdN3VO8938Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2004-11-30'), - countryId: usa?.id, - profile: { - create: { displayName: 'Sumit Saurabh | Javascrip | React', bio: 'Senior full stack dev' }, - }, - }, - - { - username: 'omar_gamal', - email: 'omar1@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2002-03-15'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Gamal', - bio: 'Backend enjoyer.', - location: 'Cairo, Egypt', + { + username: 'vel_rea_en', + email: 'velvelreact@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2004-11-30'), + countryId: usa?.id, + profile: { + create: { + displayName: 'Velvel React (EN)', + bio: 'Idk who is velvet react just testing.', + }, }, }, - }, - { - username: 'omarry', - email: 'omar2@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2001-07-02'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Youssef', - bio: 'Bug creator, bug fixer.', - location: 'Alexandria, Egypt', + { + username: 'sumit_saurabh', + email: 'sumit@gmail.com', + passwordHash: '$2a$10$faoFdN3VO8938Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2004-11-30'), + countryId: usa?.id, + profile: { + create: { + displayName: 'Sumit Saurabh | Javascrip | React', + bio: 'Senior full stack dev', + }, }, }, - }, - { - username: 'omardev', - email: 'omar3@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('1999-10-10'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Dev', - bio: 'Code > Sleep.', - location: 'Giza, Egypt', + + { + username: 'omar_gamal', + email: 'omar1@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2002-03-15'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Gamal', + bio: 'Backend enjoyer.', + location: 'Cairo, Egypt', + }, }, }, - }, - { - username: 'omartix', - email: 'omar4@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2000-05-23'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Tarek', - bio: 'Tech addict.', - location: 'Nasr City, Egypt', + { + username: 'omarry', + email: 'omar2@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2001-07-02'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Youssef', + bio: 'Bug creator, bug fixer.', + location: 'Alexandria, Egypt', + }, }, }, - }, - { - username: 'omar_codes', - email: 'omar5@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2003-02-18'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Salah', - bio: 'Rust-curious.', - location: 'Maadi, Egypt', + { + username: 'omardev', + email: 'omar3@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('1999-10-10'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Dev', + bio: 'Code > Sleep.', + location: 'Giza, Egypt', + }, }, }, - }, - { - username: 'omarszn', - email: 'omar6@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2004-06-30'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Hassan', - bio: 'Night coder.', - location: 'Heliopolis, Egypt', + { + username: 'omartix', + email: 'omar4@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2000-05-23'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Tarek', + bio: 'Tech addict.', + location: 'Nasr City, Egypt', + }, }, }, - }, - { - username: 'omar_exe', - email: 'omar7@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('1998-09-09'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Khaled', - bio: 'Always compiling.', - location: 'Zamalek, Egypt', + { + username: 'omar_codes', + email: 'omar5@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2003-02-18'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Salah', + bio: 'Rust-curious.', + location: 'Maadi, Egypt', + }, }, }, - }, - { - username: 'omarlab', - email: 'omar8@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2003-01-21'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Lab', - bio: 'Testing in prod.', - location: 'Dokki, Egypt', + { + username: 'omarszn', + email: 'omar6@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2004-06-30'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Hassan', + bio: 'Night coder.', + location: 'Heliopolis, Egypt', + }, }, }, - }, - { - username: 'theomar', - email: 'omar9@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2001-12-12'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'The Omar', - bio: 'Digital survivor.', - location: 'Haram, Egypt', + { + username: 'omar_exe', + email: 'omar7@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('1998-09-09'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Khaled', + bio: 'Always compiling.', + location: 'Zamalek, Egypt', + }, }, }, - }, - { - username: 'omarlol', - email: 'omar10@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2005-07-07'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Abdelrahman', - bio: 'Learning Linux.', - location: 'Faisal, Egypt', + { + username: 'omarlab', + email: 'omar8@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2003-01-21'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Lab', + bio: 'Testing in prod.', + location: 'Dokki, Egypt', + }, }, }, - }, - { - username: 'omarx', - email: 'omar11@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('1997-04-18'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar X', - bio: 'Minimalist dev.', - location: 'Sheikh Zayed, Egypt', + { + username: 'theomar', + email: 'omar9@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2001-12-12'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'The Omar', + bio: 'Digital survivor.', + location: 'Haram, Egypt', + }, }, }, - }, - { - username: 'omar_ai', - email: 'omar12@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2000-11-11'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar AI', - bio: 'Machine learning fan.', - location: 'New Cairo, Egypt', + { + username: 'omarlol', + email: 'omar10@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2005-07-07'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Abdelrahman', + bio: 'Learning Linux.', + location: 'Faisal, Egypt', + }, }, }, - }, - { - username: 'devomar', - email: 'omar13@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2002-02-02'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Dev Omar', - bio: 'Building cool stuff.', - location: 'Rehab, Egypt', + { + username: 'omarx', + email: 'omar11@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('1997-04-18'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar X', + bio: 'Minimalist dev.', + location: 'Sheikh Zayed, Egypt', + }, }, }, - }, - { - username: 'omarzz', - email: 'omar14@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2003-08-08'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Z', - bio: 'Learning everyday.', - location: 'Shorouk, Egypt', + { + username: 'omar_ai', + email: 'omar12@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2000-11-11'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar AI', + bio: 'Machine learning fan.', + location: 'New Cairo, Egypt', + }, }, }, - }, - { - username: 'omartech', - email: 'omar15@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('1996-03-03'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Tech', - bio: 'Cloud curious.', - location: 'Madinaty, Egypt', + { + username: 'devomar', + email: 'omar13@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2002-02-02'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Dev Omar', + bio: 'Building cool stuff.', + location: 'Rehab, Egypt', + }, }, }, - }, - { - username: 'omarxo', - email: 'omar16@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2004-09-14'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Mostafa', - bio: 'API wizard.', - location: 'Badr City, Egypt', + { + username: 'omarzz', + email: 'omar14@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2003-08-08'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Z', + bio: 'Learning everyday.', + location: 'Shorouk, Egypt', + }, }, }, - }, - { - username: 'realomar', - email: 'omar17@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('1999-06-01'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Real Omar', - bio: 'Real bugs, real fixes.', - location: 'Obour, Egypt', + { + username: 'omartech', + email: 'omar15@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('1996-03-03'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Tech', + bio: 'Cloud curious.', + location: 'Madinaty, Egypt', + }, }, }, - }, - { - username: 'omarscript', - email: 'omar18@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2000-09-21'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Script', - bio: 'Automation nerd.', - location: 'Helwan, Egypt', + { + username: 'omarxo', + email: 'omar16@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2004-09-14'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Mostafa', + bio: 'API wizard.', + location: 'Badr City, Egypt', + }, }, }, - }, - { - username: 'omarvibes', - email: 'omar19@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2002-12-19'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Fahmy', - bio: 'Good vibes only.', - location: 'Garden City, Egypt', + { + username: 'realomar', + email: 'omar17@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('1999-06-01'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Real Omar', + bio: 'Real bugs, real fixes.', + location: 'Obour, Egypt', + }, }, }, - }, - { - username: 'omarflux', - email: 'omar20@gmail.com', - passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', - birthdate: new Date('2001-01-01'), - countryId: egypt?.id, - profile: { - create: { - displayName: 'Omar Flux', - bio: 'Always iterating.', - location: 'Tagamo3, Egypt', + { + username: 'omarscript', + email: 'omar18@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2000-09-21'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Script', + bio: 'Automation nerd.', + location: 'Helwan, Egypt', + }, }, }, - }, - ]; - for (const user of usersToCreate) { - await prisma.user.create({ data: user }); - } + { + username: 'omarvibes', + email: 'omar19@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2002-12-19'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Fahmy', + bio: 'Good vibes only.', + location: 'Garden City, Egypt', + }, + }, + }, + { + username: 'omarflux', + email: 'omar20@gmail.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2001-01-01'), + countryId: egypt?.id, + profile: { + create: { + displayName: 'Omar Flux', + bio: 'Always iterating.', + location: 'Tagamo3, Egypt', + }, + }, + }, + ]; + for (const user of usersToCreate) { + await prisma.user.create({ data: user }); + } + + await prisma.follow.createMany({ + data: [ + { followerId: 1, followedId: 2, withNotifications: true }, + { followerId: 1, followedId: 4 }, + { followerId: 1, followedId: 6 }, + { followerId: 1, followedId: 8 }, + { followerId: 1, followedId: 10 }, + { followerId: 2, followedId: 1 }, + { followerId: 2, followedId: 3 }, + { followerId: 2, followedId: 4 }, + { followerId: 2, followedId: 9 }, + { followerId: 3, followedId: 2 }, + { followerId: 3, followedId: 6 }, + { followerId: 3, followedId: 11 }, + { followerId: 4, followedId: 1 }, + { followerId: 4, followedId: 2 }, + { followerId: 4, followedId: 5 }, + { followerId: 4, followedId: 12 }, + { followerId: 5, followedId: 4 }, + { followerId: 5, followedId: 7 }, + { followerId: 5, followedId: 8 }, + { followerId: 5, followedId: 2 }, + { followerId: 6, followedId: 1 }, + { followerId: 6, followedId: 3 }, + { followerId: 6, followedId: 10 }, + { followerId: 6, followedId: 2 }, + { followerId: 7, followedId: 2 }, + { followerId: 7, followedId: 5 }, + { followerId: 7, followedId: 9 }, + { followerId: 8, followedId: 1, withNotifications: true }, + { followerId: 8, followedId: 4 }, + { followerId: 8, followedId: 6 }, + { followerId: 8, followedId: 2 }, + { followerId: 9, followedId: 2 }, + { followerId: 9, followedId: 7 }, + { followerId: 10, followedId: 1 }, + { followerId: 10, followedId: 6 }, + { followerId: 11, followedId: 3 }, + { followerId: 11, followedId: 4 }, + { followerId: 12, followedId: 6 }, + { followerId: 12, followedId: 8 }, + ], + }); - await prisma.follow.createMany({ - data: [ - { followerId: 1, followedId: 2, withNotifications: true }, - { followerId: 1, followedId: 4 }, - { followerId: 1, followedId: 6 }, - { followerId: 1, followedId: 8 }, - { followerId: 1, followedId: 10 }, - { followerId: 2, followedId: 1 }, - { followerId: 2, followedId: 3 }, - { followerId: 2, followedId: 4 }, - { followerId: 2, followedId: 9 }, - { followerId: 3, followedId: 2 }, - { followerId: 3, followedId: 6 }, - { followerId: 3, followedId: 11 }, - { followerId: 4, followedId: 1 }, - { followerId: 4, followedId: 2 }, - { followerId: 4, followedId: 5 }, - { followerId: 4, followedId: 12 }, - { followerId: 5, followedId: 4 }, - { followerId: 5, followedId: 7 }, - { followerId: 5, followedId: 8 }, - { followerId: 5, followedId: 2 }, - { followerId: 6, followedId: 1 }, - { followerId: 6, followedId: 3 }, - { followerId: 6, followedId: 10 }, - { followerId: 6, followedId: 2 }, - { followerId: 7, followedId: 2 }, - { followerId: 7, followedId: 5 }, - { followerId: 7, followedId: 9 }, - { followerId: 8, followedId: 1, withNotifications: true }, - { followerId: 8, followedId: 4 }, - { followerId: 8, followedId: 6 }, - { followerId: 8, followedId: 2 }, - { followerId: 9, followedId: 2 }, - { followerId: 9, followedId: 7 }, - { followerId: 10, followedId: 1 }, - { followerId: 10, followedId: 6 }, - { followerId: 11, followedId: 3 }, - { followerId: 11, followedId: 4 }, - { followerId: 12, followedId: 6 }, - { followerId: 12, followedId: 8 }, - ], - }); - - await prisma.$executeRawUnsafe(` + await prisma.$executeRawUnsafe(` UPDATE "users" u SET "followers_count" = sub.count FROM ( @@ -549,7 +557,7 @@ async function main() { WHERE u.id = sub.user_id; `); - await prisma.$executeRawUnsafe(` + await prisma.$executeRawUnsafe(` UPDATE "users" u SET "following_count" = sub.count FROM ( @@ -560,775 +568,775 @@ async function main() { WHERE u.id = sub.user_id; `); - await prisma.block.createMany({ - data: [ - { userId: 3, blockedId: 5 }, - { userId: 7, blockedId: 11 }, - { userId: 9, blockedId: 12 }, - ], - }); - - await prisma.mute.createMany({ - data: [ - { userId: 1, mutedId: 7 }, - { userId: 6, mutedId: 10 }, - { userId: 4, mutedId: 2 }, - ], - }); - - const getHashtag = async (tag: string) => { - return prisma.trendingKeyword.upsert({ - where: { keyword_isHashtag: { keyword: tag, isHashtag: true } }, - update: { count: { increment: 1 } }, - create: { keyword: tag, isHashtag: true, count: 1 }, - }); - }; - - // Create media records - const media1 = await prisma.media.create({ - data: { - userId: 2, - type: 'IMAGE', - url: 'https://cdn.raven.cmp27.space/tweets/4846e19b-72b3-4d42-b7d5-b624a362dd38.jpg', - width: 736, - height: 414, - pending: false, - }, - }); - - const media2 = await prisma.media.create({ - data: { - userId: 2, - type: 'IMAGE', - url: 'https://cdn.raven.cmp27.space/tweets/c33fe331-4851-41f6-97c7-e2f6eab63619.jpg', - width: 736, - height: 414, - pending: false, - }, - }); - - const media3 = await prisma.media.create({ - data: { - userId: 2, - type: 'IMAGE', - url: 'https://cdn.raven.cmp27.space/tweets/68424de7-60a2-4e05-b8de-7c0fe7b58217.jpg', - width: 736, - height: 414, - pending: false, - }, - }); - - const media4 = await prisma.media.create({ - data: { - userId: 2, - type: 'IMAGE', - url: 'https://cdn.raven.cmp27.space/tweets/cd1a97a0-2020-4713-9dbc-faa6d1043e0b.png', - width: 734, - height: 145, - pending: false, - }, - }); - - const media5 = await prisma.media.create({ - data: { - userId: 2, - type: 'IMAGE', - url: 'https://cdn.raven.cmp27.space/tweets/9e6b75bf-c4b2-4025-b198-56d21a90103c.png', - width: 734, - height: 145, - pending: false, - }, - }); - - const media6 = await prisma.media.create({ - data: { - userId: 2, - type: 'VIDEO', - url: 'https://cdn.raven.cmp27.space/tweets/9c62e434-909f-489f-88d8-b93481eb7cb2.mkv', - width: 0, - height: 0, - pending: false, - }, - }); - - const media7 = await prisma.media.create({ - data: { - userId: 2, - type: 'VIDEO', - url: 'https://cdn.raven.cmp27.space/tweets/66d768fb-0b71-4082-94e7-79b8d1cfb27c.mkv', - width: 0, - height: 0, - pending: false, - }, - }); - - let tsHashtag = await getHashtag('typescript'); - const nestHashtag = await getHashtag('nestjs'); - const authHashtag = await getHashtag('auth'); - - const anasTweet1 = await prisma.tweet.create({ - data: { - userId: 4, - content: - 'Just deployed my first app with #nestjs. The developer experience is amazing compared to Express. #typescript @OmarHassan what do you think?', - hasHashtags: true, - hasMentions: true, - hasMedia: true, - tweetHashtags: { - create: [ - { hashtagId: nestHashtag.id, startPosition: 32 }, - { hashtagId: tsHashtag.id, startPosition: 98 }, - ], - }, - tweetMentions: { - create: [{ userId: 1, startPosition: 110 }], - }, - tweetMedia: { - create: [ - { mediaId: media4.id, order: 0 }, - { mediaId: media5.id, order: 1 }, - ], - }, - }, - }); - - const omarGReply1 = await prisma.tweet.create({ - data: { - userId: 2, - content: 'Totally agree! The module system keeps everything so clean. @anasbrahim', - replyToTweetId: anasTweet1.id, - rootTweetId: anasTweet1.rootTweetId || anasTweet1.id, - hasMentions: true, - tweetMentions: { - create: [{ userId: 4, startPosition: 60 }], - }, - }, - }); - - const omarHReply1 = await prisma.tweet.create({ - data: { - userId: 1, - content: 'How are you handling authentication? Passport.js strategies? #auth', - replyToTweetId: anasTweet1.id, - rootTweetId: anasTweet1.rootTweetId || anasTweet1.id, - hasHashtags: true, - tweetHashtags: { - create: [{ hashtagId: authHashtag.id, startPosition: 61 }], - }, - }, - }); - - const anasReply2 = await prisma.tweet.create({ - data: { - userId: 4, - content: 'Yep, using passport-jwt. It integrated surprisingly easily. Thanks @OmarHassan!', - replyToTweetId: omarHReply1.id, - rootTweetId: anasTweet1.rootTweetId || anasTweet1.id, - hasMentions: true, - tweetMentions: { - create: [{ userId: 1, startPosition: 66 }], - }, - }, - }); - - const saraReply1 = await prisma.tweet.create({ - data: { - userId: 8, - content: 'Loving this thread! For scalable auth, consider JWT with refresh tokens.', - replyToTweetId: anasTweet1.id, - rootTweetId: anasTweet1.rootTweetId || anasTweet1.id, - tweetMedia: { - create: [{ mediaId: media6.id, order: 0 }], - }, - hasMedia: true, - }, - }); - - const foodHashtag = await getHashtag('foodie'); - const cairoHashtag = await getHashtag('cairo'); - const egyptHashtag = await getHashtag('egypt'); - - const laylaTweet1 = await prisma.tweet.create({ - data: { - userId: 6, - content: - 'Found the best koshary place in downtown #cairo! Must-visit for every #foodie in #egypt 🤤 @Tasneem', - hasMedia: true, - hasHashtags: true, - hasMentions: true, - tweetHashtags: { - create: [ - { hashtagId: cairoHashtag.id, startPosition: 41 }, - { hashtagId: foodHashtag.id, startPosition: 70 }, - { hashtagId: egyptHashtag.id, startPosition: 81 }, - ], - }, - tweetMentions: { - create: [{ userId: 3, startPosition: 90 }], - }, - tweetMedia: { - create: [ - { mediaId: media1.id, order: 0 }, - { mediaId: media2.id, order: 1 }, - { mediaId: media3.id, order: 2 }, - ], - }, - }, - }); - - const tasneemReply1 = await prisma.tweet.create({ - data: { - userId: 3, - content: 'Omg where is this?? Looks incredible! @Layla tag me next time!', - replyToTweetId: laylaTweet1.id, - rootTweetId: laylaTweet1.rootTweetId || laylaTweet1.id, - hasMentions: true, - tweetMentions: { - create: [{ userId: 6, startPosition: 38 }], - }, - }, - }); - - const fatmaReply1 = await prisma.tweet.create({ - data: { - userId: 12, - content: 'Koshary is life! Adding to my list. 😍', - replyToTweetId: laylaTweet1.id, - rootTweetId: laylaTweet1.rootTweetId || laylaTweet1.id, - }, - }); - - const memeHashtag = await getHashtag('memes'); - const internetHashtag = await getHashtag('internet'); - - const karimTweet1 = await prisma.tweet.create({ - data: { - userId: 7, - content: 'Is it just me or is the #internet extra slow today? Share your pain! #memes', - hasHashtags: true, - tweetHashtags: { - create: [ - { hashtagId: internetHashtag.id, startPosition: 24 }, - { hashtagId: memeHashtag.id, startPosition: 69 }, - ], - }, - }, - }); - - const gelgelQuoteTweet = await prisma.tweet.create({ - data: { - userId: 5, - content: 'Definitely not just you. My downloads are crawling. @Kimo this is your fault! 😂', - quotedTweetId: karimTweet1.id, - hasMentions: true, - tweetMentions: { - create: [{ userId: 7, startPosition: 52 }], - }, - }, - }); - - const youssefReply1 = await prisma.tweet.create({ - data: { - userId: 11, - content: 'ISP woes unite us all. Time for Starlink? 🚀', - replyToTweetId: karimTweet1.id, - rootTweetId: karimTweet1.rootTweetId || karimTweet1.id, - }, - }); - - const uiuxHashtag = await getHashtag('uiux'); - const designHashtag = await getHashtag('design'); - tsHashtag = await getHashtag('typescript'); - - const fatmaTweet1 = await prisma.tweet.create({ - data: { - userId: 12, - content: - 'Quick tip for better #uiux: Always test with real users. What’s your go-to tool? #design #typescript @ZakiDev', - hasHashtags: true, - hasMentions: true, - hasMedia: true, - tweetHashtags: { - create: [ - { hashtagId: uiuxHashtag.id, startPosition: 21 }, - { hashtagId: designHashtag.id, startPosition: 81 }, - { hashtagId: tsHashtag.id, startPosition: 89 }, - ], - }, - tweetMentions: { - create: [{ userId: 9, startPosition: 101 }], - }, - tweetMedia: { - create: [{ mediaId: media6.id, order: 0 }], - }, - }, - }); - - const ahmedZReply1 = await prisma.tweet.create({ - data: { - userId: 9, - content: 'Figma all the way, but user testing is overrated sometimes. @FatmaDesign', - replyToTweetId: fatmaTweet1.id, - rootTweetId: fatmaTweet1.rootTweetId || fatmaTweet1.id, - hasMentions: true, - tweetMentions: { - create: [{ userId: 12, startPosition: 60 }], - }, - }, - }); - - const aiHashtag = await getHashtag('ai'); - - const youssefTweet1 = await prisma.tweet.create({ - data: { - userId: 11, - content: 'AI is changing everything. Excited for the future! #ai @YoussefTech self-promo 😏', - hasHashtags: true, - hasMedia: true, - tweetHashtags: { - create: [{ hashtagId: aiHashtag.id, startPosition: 51 }], - }, - tweetMedia: { - create: [{ mediaId: media7.id, order: 0 }], - }, - }, - }); - - const graphqlHashtag = await getHashtag('graphql'); - - const nourTweet1 = await prisma.tweet.create({ - data: { - userId: 10, - content: 'Diving deep into #graphql today. Resolvers got me hooked! @NourCodes', - hasHashtags: true, - tweetHashtags: { - create: [{ hashtagId: graphqlHashtag.id, startPosition: 17 }], - }, - tweetMedia: { - create: [{ mediaId: media2.id, order: 0 }], - }, - hasMedia: true, - }, - }); - - const quote1 = await prisma.tweet.create({ - data: { - userId: 6, - content: 'This! NestJS makes backend development actually enjoyable.', - quotedTweetId: anasTweet1.id, - }, - }); - - const quote2 = await prisma.tweet.create({ - data: { - userId: 10, - content: 'Been using NestJS for 6 months now, can confirm the hype is real! @anasbrahim', - quotedTweetId: anasTweet1.id, - hasMentions: true, - tweetMentions: { - create: [{ userId: 4, startPosition: 66 }], - }, - tweetMedia: { - create: [{ mediaId: media2.id, order: 0 }], - }, - hasMedia: true, - }, - }); - - const quote3 = await prisma.tweet.create({ - data: { - userId: 8, - content: 'Express served us well, but NestJS is the future. Time to migrate!', - quotedTweetId: anasTweet1.id, - }, - }); - - const quote4 = await prisma.tweet.create({ - data: { - userId: 1, - content: 'Adding this to my Cairo food tour list! #foodie', - quotedTweetId: laylaTweet1.id, - hasHashtags: true, - tweetHashtags: { - create: [{ hashtagId: foodHashtag.id, startPosition: 40 }], - }, - }, - }); - - const quote5 = await prisma.tweet.create({ - data: { - userId: 11, - content: 'Egyptian street food hits different. Always. 🇪🇬', - quotedTweetId: laylaTweet1.id, - tweetMedia: { - create: [{ mediaId: media2.id, order: 0 }], - }, - hasMedia: true, - }, - }); - - const quote6 = await prisma.tweet.create({ - data: { - userId: 4, - content: 'My mouth is watering just looking at this @Layla', - quotedTweetId: laylaTweet1.id, - hasMentions: true, - tweetMentions: { - create: [{ userId: 6, startPosition: 42 }], - }, - tweetMedia: { - create: [{ mediaId: media2.id, order: 0 }], - }, - hasMedia: true, - }, - }); - - const quote7 = await prisma.tweet.create({ - data: { - userId: 12, - content: 'Story of my life with Egyptian internet providers 😭', - quotedTweetId: karimTweet1.id, - }, - }); - - const quote8 = await prisma.tweet.create({ - data: { - userId: 2, - content: 'Time to switch to a better ISP? Anyone got recommendations?', - quotedTweetId: karimTweet1.id, - }, - }); - - const quote9 = await prisma.tweet.create({ - data: { - userId: 3, - content: - "User testing saved my last project from disaster. Can't emphasize this enough! #uiux", - quotedTweetId: fatmaTweet1.id, - hasHashtags: true, - tweetHashtags: { - create: [{ hashtagId: uiuxHashtag.id, startPosition: 79 }], - }, - }, - }); - - const quote10 = await prisma.tweet.create({ - data: { - userId: 7, - content: 'Maze.co is my go-to for quick user testing sessions.', - quotedTweetId: fatmaTweet1.id, - }, - }); - - const quote11 = await prisma.tweet.create({ - data: { - userId: 9, - content: "The AI revolution is here and it's incredible to witness! #ai", - quotedTweetId: youssefTweet1.id, - hasHashtags: true, - tweetHashtags: { - create: [{ hashtagId: aiHashtag.id, startPosition: 58 }], - }, - }, - }); - - const quote12 = await prisma.tweet.create({ - data: { - userId: 1, - content: "Can't wait to see what AI brings to backend development @YoussefTech", - quotedTweetId: youssefTweet1.id, - hasMentions: true, - tweetMentions: { - create: [{ userId: 11, startPosition: 56 }], - }, - }, - }); - - const quote13 = await prisma.tweet.create({ - data: { - userId: 4, - content: 'GraphQL changed how I think about APIs. Worth the learning curve! #graphql', - quotedTweetId: nourTweet1.id, - hasHashtags: true, - tweetHashtags: { - create: [{ hashtagId: graphqlHashtag.id, startPosition: 66 }], - }, - tweetMedia: { - create: [{ mediaId: media3.id, order: 0 }], - }, - hasMedia: true, - }, - }); - - const quote14 = await prisma.tweet.create({ - data: { - userId: 8, - content: 'Resolvers are powerful once you understand the pattern @NourCodes', - quotedTweetId: nourTweet1.id, - hasMentions: true, - tweetMentions: { - create: [{ userId: 10, startPosition: 55 }], - }, - }, - }); - - await prisma.like.createMany({ - data: [ - { userId: 1, tweetId: anasTweet1.id }, - { userId: 2, tweetId: anasTweet1.id }, - { userId: 5, tweetId: anasTweet1.id }, - { userId: 8, tweetId: anasTweet1.id }, - { userId: 4, tweetId: omarGReply1.id }, - { userId: 1, tweetId: omarGReply1.id }, - { userId: 10, tweetId: omarHReply1.id }, - { userId: 4, tweetId: anasReply2.id }, - { userId: 1, tweetId: anasReply2.id }, - { userId: 8, tweetId: saraReply1.id }, - { userId: 1, tweetId: laylaTweet1.id }, - { userId: 2, tweetId: laylaTweet1.id }, - { userId: 3, tweetId: laylaTweet1.id }, - { userId: 7, tweetId: laylaTweet1.id }, - { userId: 4, tweetId: tasneemReply1.id }, - { userId: 6, tweetId: tasneemReply1.id }, - { userId: 12, tweetId: fatmaReply1.id }, - { userId: 2, tweetId: karimTweet1.id }, - { userId: 4, tweetId: karimTweet1.id }, - { userId: 5, tweetId: karimTweet1.id }, - { userId: 9, tweetId: karimTweet1.id }, - { userId: 7, tweetId: gelgelQuoteTweet.id }, - { userId: 3, tweetId: youssefReply1.id }, - { userId: 6, tweetId: fatmaTweet1.id }, - { userId: 1, tweetId: fatmaTweet1.id }, - { userId: 9, tweetId: ahmedZReply1.id }, - { userId: 12, tweetId: ahmedZReply1.id }, - { userId: 2, tweetId: youssefTweet1.id }, - { userId: 8, tweetId: youssefTweet1.id }, - { userId: 4, tweetId: nourTweet1.id }, - { userId: 11, tweetId: nourTweet1.id }, - ], - }); - - await prisma.retweet.createMany({ - data: [ - { userId: 2, tweetId: laylaTweet1.id }, - { userId: 3, tweetId: anasTweet1.id }, - { userId: 8, tweetId: anasTweet1.id }, - { userId: 5, tweetId: karimTweet1.id }, - { userId: 10, tweetId: fatmaTweet1.id }, - ], - }); - - await prisma.tweet.updateMany({ - where: { - id: { in: [anasTweet1.id, omarGReply1.id, omarHReply1.id, anasReply2.id, saraReply1.id] }, - }, - data: { likeCount: { increment: 1 } }, - }); - await prisma.tweet.update({ - where: { id: anasTweet1.id }, - data: { likeCount: 4, retweetCount: 2, replyCount: 4 }, - }); - await prisma.tweet.update({ - where: { id: omarGReply1.id }, - data: { likeCount: 2, replyCount: 0 }, - }); - await prisma.tweet.update({ - where: { id: omarHReply1.id }, - data: { likeCount: 1, replyCount: 1 }, - }); - await prisma.tweet.update({ where: { id: anasReply2.id }, data: { likeCount: 2 } }); - await prisma.tweet.update({ where: { id: saraReply1.id }, data: { likeCount: 1 } }); - await prisma.tweet.update({ - where: { id: laylaTweet1.id }, - data: { likeCount: 4, retweetCount: 1, replyCount: 2 }, - }); - await prisma.tweet.update({ where: { id: tasneemReply1.id }, data: { likeCount: 2 } }); - await prisma.tweet.update({ where: { id: fatmaReply1.id }, data: { likeCount: 1 } }); - await prisma.tweet.update({ - where: { id: karimTweet1.id }, - data: { likeCount: 4, retweetCount: 1 }, - }); - await prisma.tweet.update({ where: { id: gelgelQuoteTweet.id }, data: { likeCount: 1 } }); - await prisma.tweet.update({ where: { id: youssefReply1.id }, data: { likeCount: 1 } }); - await prisma.tweet.update({ - where: { id: fatmaTweet1.id }, - data: { likeCount: 2, replyCount: 1 }, - }); - await prisma.tweet.update({ where: { id: ahmedZReply1.id }, data: { likeCount: 2 } }); - await prisma.tweet.update({ where: { id: youssefTweet1.id }, data: { likeCount: 2 } }); - await prisma.tweet.update({ where: { id: nourTweet1.id }, data: { likeCount: 2 } }); - - await prisma.tweet.update({ - where: { id: anasTweet1.id }, - data: { retweetCount: { increment: 3 } }, - }); - - await prisma.tweet.update({ - where: { id: laylaTweet1.id }, - data: { retweetCount: { increment: 3 } }, - }); - - await prisma.tweet.update({ - where: { id: karimTweet1.id }, - data: { retweetCount: { increment: 2 } }, - }); - - await prisma.tweet.update({ - where: { id: fatmaTweet1.id }, - data: { retweetCount: { increment: 2 } }, - }); - - await prisma.tweet.update({ - where: { id: youssefTweet1.id }, - data: { retweetCount: { increment: 2 } }, - }); - - await prisma.tweet.update({ - where: { id: nourTweet1.id }, - data: { retweetCount: { increment: 2 } }, - }); - - await prisma.like.createMany({ - data: [ - { userId: 4, tweetId: quote1.id }, - { userId: 1, tweetId: quote1.id }, - { userId: 2, tweetId: quote2.id }, - { userId: 4, tweetId: quote2.id }, - { userId: 5, tweetId: quote3.id }, - { userId: 6, tweetId: quote4.id }, - { userId: 3, tweetId: quote4.id }, - { userId: 1, tweetId: quote5.id }, - { userId: 6, tweetId: quote6.id }, - { userId: 7, tweetId: quote7.id }, - { userId: 5, tweetId: quote8.id }, - { userId: 12, tweetId: quote9.id }, - { userId: 9, tweetId: quote9.id }, - { userId: 8, tweetId: quote10.id }, - { userId: 11, tweetId: quote11.id }, - { userId: 8, tweetId: quote11.id }, - { userId: 11, tweetId: quote12.id }, - { userId: 10, tweetId: quote13.id }, - { userId: 9, tweetId: quote13.id }, - { userId: 10, tweetId: quote14.id }, - ], - }); - - await prisma.tweet.update({ where: { id: quote1.id }, data: { likeCount: 2 } }); - await prisma.tweet.update({ where: { id: quote2.id }, data: { likeCount: 2 } }); - await prisma.tweet.update({ where: { id: quote3.id }, data: { likeCount: 1 } }); - await prisma.tweet.update({ where: { id: quote4.id }, data: { likeCount: 2 } }); - await prisma.tweet.update({ where: { id: quote5.id }, data: { likeCount: 1 } }); - await prisma.tweet.update({ where: { id: quote6.id }, data: { likeCount: 1 } }); - await prisma.tweet.update({ where: { id: quote7.id }, data: { likeCount: 1 } }); - await prisma.tweet.update({ where: { id: quote8.id }, data: { likeCount: 1 } }); - await prisma.tweet.update({ where: { id: quote9.id }, data: { likeCount: 2 } }); - await prisma.tweet.update({ where: { id: quote10.id }, data: { likeCount: 1 } }); - await prisma.tweet.update({ where: { id: quote11.id }, data: { likeCount: 2 } }); - await prisma.tweet.update({ where: { id: quote12.id }, data: { likeCount: 1 } }); - await prisma.tweet.update({ where: { id: quote13.id }, data: { likeCount: 2 } }); - await prisma.tweet.update({ where: { id: quote14.id }, data: { likeCount: 1 } }); - - const privateConv1 = await prisma.conversation.create({ - data: { - creatorId: 6, - conversationParticipants: { - create: [{ userId: 6 }, { userId: 3, lastSeenMessageId: null }], - }, - }, - }); - - await prisma.message.create({ - data: { - content: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", - conversationId: privateConv1.id, - userId: 6, - messageEntities: { - text: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", - }, - }, - }); - - await prisma.message.create({ - data: { - content: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", - conversationId: privateConv1.id, - userId: 3, - messageEntities: { - text: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", - }, - }, - }); - - const privMsg3 = await prisma.message.create({ - data: { - content: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", - conversationId: privateConv1.id, - userId: 6, - messageEntities: { - text: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", - }, - }, - }); - - await prisma.conversation.update({ - where: { id: privateConv1.id }, - data: { lastMessageId: privMsg3.id }, - }); - - await prisma.notification.createMany({ - data: [ - { actorId: 1, receiverId: 2, type: NotificationType.FOLLOW, seen: true }, - { actorId: 1, receiverId: 4, type: NotificationType.FOLLOW }, - { actorId: 1, receiverId: 6, type: NotificationType.FOLLOW }, - { actorId: 1, receiverId: 8, type: NotificationType.FOLLOW }, - { actorId: 2, receiverId: 1, type: NotificationType.FOLLOW }, - { actorId: 2, receiverId: 3, type: NotificationType.FOLLOW }, - { actorId: 2, receiverId: 4, type: NotificationType.FOLLOW }, - { actorId: 2, receiverId: 9, type: NotificationType.FOLLOW }, - { actorId: 3, receiverId: 2, type: NotificationType.FOLLOW }, - { actorId: 3, receiverId: 6, type: NotificationType.FOLLOW }, - { actorId: 3, receiverId: 11, type: NotificationType.FOLLOW }, - { actorId: 1, receiverId: 4, type: NotificationType.LIKE, tweetId: anasTweet1.id }, - { actorId: 2, receiverId: 4, type: NotificationType.LIKE, tweetId: anasTweet1.id }, - { actorId: 5, receiverId: 4, type: NotificationType.LIKE, tweetId: anasTweet1.id }, - { actorId: 8, receiverId: 4, type: NotificationType.LIKE, tweetId: anasTweet1.id }, - { actorId: 1, receiverId: 2, type: NotificationType.LIKE, tweetId: omarGReply1.id }, - { actorId: 4, receiverId: 2, type: NotificationType.LIKE, tweetId: omarGReply1.id }, - { actorId: 10, receiverId: 1, type: NotificationType.LIKE, tweetId: omarHReply1.id }, - { actorId: 1, receiverId: 6, type: NotificationType.LIKE, tweetId: laylaTweet1.id }, - { actorId: 3, receiverId: 6, type: NotificationType.LIKE, tweetId: laylaTweet1.id }, - { actorId: 7, receiverId: 6, type: NotificationType.LIKE, tweetId: laylaTweet1.id }, - { actorId: 2, receiverId: 4, type: NotificationType.REPLY, tweetId: omarGReply1.id }, - { actorId: 1, receiverId: 4, type: NotificationType.REPLY, tweetId: omarHReply1.id }, - { actorId: 4, receiverId: 1, type: NotificationType.REPLY, tweetId: anasReply2.id }, - { actorId: 8, receiverId: 4, type: NotificationType.REPLY, tweetId: saraReply1.id }, - { actorId: 3, receiverId: 6, type: NotificationType.REPLY, tweetId: tasneemReply1.id }, - { actorId: 12, receiverId: 6, type: NotificationType.REPLY, tweetId: fatmaReply1.id }, - { actorId: 1, receiverId: 4, type: NotificationType.MENTION, tweetId: anasTweet1.id }, - { actorId: 2, receiverId: 4, type: NotificationType.MENTION, tweetId: omarGReply1.id }, - { actorId: 4, receiverId: 1, type: NotificationType.MENTION, tweetId: anasReply2.id }, - { actorId: 3, receiverId: 6, type: NotificationType.MENTION, tweetId: laylaTweet1.id }, - { actorId: 2, receiverId: 6, type: NotificationType.RETWEET, tweetId: laylaTweet1.id }, - { actorId: 3, receiverId: 4, type: NotificationType.RETWEET, tweetId: anasTweet1.id }, - { actorId: 8, receiverId: 4, type: NotificationType.RETWEET, tweetId: anasTweet1.id }, - { actorId: 5, receiverId: 7, type: NotificationType.QUOTE, tweetId: gelgelQuoteTweet.id }, - { actorId: 10, receiverId: 12, type: NotificationType.RETWEET, tweetId: fatmaTweet1.id }, - { actorId: 4, receiverId: 1, type: NotificationType.MESSAGE }, - { actorId: 4, receiverId: 2, type: NotificationType.MESSAGE }, - { actorId: 1, receiverId: 4, type: NotificationType.MESSAGE }, - { actorId: 1, receiverId: 2, type: NotificationType.MESSAGE }, - { actorId: 2, receiverId: 4, type: NotificationType.MESSAGE }, - { actorId: 2, receiverId: 1, type: NotificationType.MESSAGE }, - { actorId: 6, receiverId: 3, type: NotificationType.MESSAGE }, - { actorId: 8, receiverId: 12, type: NotificationType.MESSAGE }, - { actorId: 8, receiverId: 9, type: NotificationType.MESSAGE }, - { actorId: 12, receiverId: 8, type: NotificationType.MESSAGE }, - { actorId: 12, receiverId: 9, type: NotificationType.MENTION }, - ], - }); -} + await prisma.block.createMany({ + data: [ + { userId: 3, blockedId: 5 }, + { userId: 7, blockedId: 11 }, + { userId: 9, blockedId: 12 }, + ], + }); + + await prisma.mute.createMany({ + data: [ + { userId: 1, mutedId: 7 }, + { userId: 6, mutedId: 10 }, + { userId: 4, mutedId: 2 }, + ], + }); + + const getHashtag = async (tag: string) => { + return prisma.trendingKeyword.upsert({ + where: { keyword_isHashtag: { keyword: tag, isHashtag: true } }, + update: { count: { increment: 1 } }, + create: { keyword: tag, isHashtag: true, count: 1 }, + }); + }; + + // Create media records + const media1 = await prisma.media.create({ + data: { + userId: 2, + type: 'IMAGE', + url: 'https://cdn.raven.cmp27.space/tweets/4846e19b-72b3-4d42-b7d5-b624a362dd38.jpg', + width: 736, + height: 414, + pending: false, + }, + }); + + const media2 = await prisma.media.create({ + data: { + userId: 2, + type: 'IMAGE', + url: 'https://cdn.raven.cmp27.space/tweets/c33fe331-4851-41f6-97c7-e2f6eab63619.jpg', + width: 736, + height: 414, + pending: false, + }, + }); + + const media3 = await prisma.media.create({ + data: { + userId: 2, + type: 'IMAGE', + url: 'https://cdn.raven.cmp27.space/tweets/68424de7-60a2-4e05-b8de-7c0fe7b58217.jpg', + width: 736, + height: 414, + pending: false, + }, + }); + + const media4 = await prisma.media.create({ + data: { + userId: 2, + type: 'IMAGE', + url: 'https://cdn.raven.cmp27.space/tweets/cd1a97a0-2020-4713-9dbc-faa6d1043e0b.png', + width: 734, + height: 145, + pending: false, + }, + }); + + const media5 = await prisma.media.create({ + data: { + userId: 2, + type: 'IMAGE', + url: 'https://cdn.raven.cmp27.space/tweets/9e6b75bf-c4b2-4025-b198-56d21a90103c.png', + width: 734, + height: 145, + pending: false, + }, + }); + + const media6 = await prisma.media.create({ + data: { + userId: 2, + type: 'VIDEO', + url: 'https://cdn.raven.cmp27.space/tweets/9c62e434-909f-489f-88d8-b93481eb7cb2.mkv', + width: 0, + height: 0, + pending: false, + }, + }); + + const media7 = await prisma.media.create({ + data: { + userId: 2, + type: 'VIDEO', + url: 'https://cdn.raven.cmp27.space/tweets/66d768fb-0b71-4082-94e7-79b8d1cfb27c.mkv', + width: 0, + height: 0, + pending: false, + }, + }); + + let tsHashtag = await getHashtag('typescript'); + const nestHashtag = await getHashtag('nestjs'); + const authHashtag = await getHashtag('auth'); + + const anasTweet1 = await prisma.tweet.create({ + data: { + userId: 4, + content: + 'Just deployed my first app with #nestjs. The developer experience is amazing compared to Express. #typescript @OmarHassan what do you think?', + hasHashtags: true, + hasMentions: true, + hasMedia: true, + tweetHashtags: { + create: [ + { hashtagId: nestHashtag.id, startPosition: 32 }, + { hashtagId: tsHashtag.id, startPosition: 98 }, + ], + }, + tweetMentions: { + create: [{ userId: 1, startPosition: 110 }], + }, + tweetMedia: { + create: [ + { mediaId: media4.id, order: 0 }, + { mediaId: media5.id, order: 1 }, + ], + }, + }, + }); + + const omarGReply1 = await prisma.tweet.create({ + data: { + userId: 2, + content: 'Totally agree! The module system keeps everything so clean. @anasbrahim', + replyToTweetId: anasTweet1.id, + rootTweetId: anasTweet1.rootTweetId || anasTweet1.id, + hasMentions: true, + tweetMentions: { + create: [{ userId: 4, startPosition: 60 }], + }, + }, + }); + + const omarHReply1 = await prisma.tweet.create({ + data: { + userId: 1, + content: 'How are you handling authentication? Passport.js strategies? #auth', + replyToTweetId: anasTweet1.id, + rootTweetId: anasTweet1.rootTweetId || anasTweet1.id, + hasHashtags: true, + tweetHashtags: { + create: [{ hashtagId: authHashtag.id, startPosition: 61 }], + }, + }, + }); + + const anasReply2 = await prisma.tweet.create({ + data: { + userId: 4, + content: 'Yep, using passport-jwt. It integrated surprisingly easily. Thanks @OmarHassan!', + replyToTweetId: omarHReply1.id, + rootTweetId: anasTweet1.rootTweetId || anasTweet1.id, + hasMentions: true, + tweetMentions: { + create: [{ userId: 1, startPosition: 66 }], + }, + }, + }); + + const saraReply1 = await prisma.tweet.create({ + data: { + userId: 8, + content: 'Loving this thread! For scalable auth, consider JWT with refresh tokens.', + replyToTweetId: anasTweet1.id, + rootTweetId: anasTweet1.rootTweetId || anasTweet1.id, + tweetMedia: { + create: [{ mediaId: media6.id, order: 0 }], + }, + hasMedia: true, + }, + }); + + const foodHashtag = await getHashtag('foodie'); + const cairoHashtag = await getHashtag('cairo'); + const egyptHashtag = await getHashtag('egypt'); + + const laylaTweet1 = await prisma.tweet.create({ + data: { + userId: 6, + content: + 'Found the best koshary place in downtown #cairo! Must-visit for every #foodie in #egypt 🤤 @Tasneem', + hasMedia: true, + hasHashtags: true, + hasMentions: true, + tweetHashtags: { + create: [ + { hashtagId: cairoHashtag.id, startPosition: 41 }, + { hashtagId: foodHashtag.id, startPosition: 70 }, + { hashtagId: egyptHashtag.id, startPosition: 81 }, + ], + }, + tweetMentions: { + create: [{ userId: 3, startPosition: 90 }], + }, + tweetMedia: { + create: [ + { mediaId: media1.id, order: 0 }, + { mediaId: media2.id, order: 1 }, + { mediaId: media3.id, order: 2 }, + ], + }, + }, + }); + + const tasneemReply1 = await prisma.tweet.create({ + data: { + userId: 3, + content: 'Omg where is this?? Looks incredible! @Layla tag me next time!', + replyToTweetId: laylaTweet1.id, + rootTweetId: laylaTweet1.rootTweetId || laylaTweet1.id, + hasMentions: true, + tweetMentions: { + create: [{ userId: 6, startPosition: 38 }], + }, + }, + }); + + const fatmaReply1 = await prisma.tweet.create({ + data: { + userId: 12, + content: 'Koshary is life! Adding to my list. 😍', + replyToTweetId: laylaTweet1.id, + rootTweetId: laylaTweet1.rootTweetId || laylaTweet1.id, + }, + }); + + const memeHashtag = await getHashtag('memes'); + const internetHashtag = await getHashtag('internet'); + + const karimTweet1 = await prisma.tweet.create({ + data: { + userId: 7, + content: 'Is it just me or is the #internet extra slow today? Share your pain! #memes', + hasHashtags: true, + tweetHashtags: { + create: [ + { hashtagId: internetHashtag.id, startPosition: 24 }, + { hashtagId: memeHashtag.id, startPosition: 69 }, + ], + }, + }, + }); + + const gelgelQuoteTweet = await prisma.tweet.create({ + data: { + userId: 5, + content: 'Definitely not just you. My downloads are crawling. @Kimo this is your fault! 😂', + quotedTweetId: karimTweet1.id, + hasMentions: true, + tweetMentions: { + create: [{ userId: 7, startPosition: 52 }], + }, + }, + }); + + const youssefReply1 = await prisma.tweet.create({ + data: { + userId: 11, + content: 'ISP woes unite us all. Time for Starlink? 🚀', + replyToTweetId: karimTweet1.id, + rootTweetId: karimTweet1.rootTweetId || karimTweet1.id, + }, + }); + + const uiuxHashtag = await getHashtag('uiux'); + const designHashtag = await getHashtag('design'); + tsHashtag = await getHashtag('typescript'); + + const fatmaTweet1 = await prisma.tweet.create({ + data: { + userId: 12, + content: + 'Quick tip for better #uiux: Always test with real users. What’s your go-to tool? #design #typescript @ZakiDev', + hasHashtags: true, + hasMentions: true, + hasMedia: true, + tweetHashtags: { + create: [ + { hashtagId: uiuxHashtag.id, startPosition: 21 }, + { hashtagId: designHashtag.id, startPosition: 81 }, + { hashtagId: tsHashtag.id, startPosition: 89 }, + ], + }, + tweetMentions: { + create: [{ userId: 9, startPosition: 101 }], + }, + tweetMedia: { + create: [{ mediaId: media6.id, order: 0 }], + }, + }, + }); + + const ahmedZReply1 = await prisma.tweet.create({ + data: { + userId: 9, + content: 'Figma all the way, but user testing is overrated sometimes. @FatmaDesign', + replyToTweetId: fatmaTweet1.id, + rootTweetId: fatmaTweet1.rootTweetId || fatmaTweet1.id, + hasMentions: true, + tweetMentions: { + create: [{ userId: 12, startPosition: 60 }], + }, + }, + }); + + const aiHashtag = await getHashtag('ai'); + + const youssefTweet1 = await prisma.tweet.create({ + data: { + userId: 11, + content: + 'AI is changing everything. Excited for the future! #ai @YoussefTech self-promo 😏', + hasHashtags: true, + hasMedia: true, + tweetHashtags: { + create: [{ hashtagId: aiHashtag.id, startPosition: 51 }], + }, + tweetMedia: { + create: [{ mediaId: media7.id, order: 0 }], + }, + }, + }); + + const graphqlHashtag = await getHashtag('graphql'); + + const nourTweet1 = await prisma.tweet.create({ + data: { + userId: 10, + content: 'Diving deep into #graphql today. Resolvers got me hooked! @NourCodes', + hasHashtags: true, + tweetHashtags: { + create: [{ hashtagId: graphqlHashtag.id, startPosition: 17 }], + }, + tweetMedia: { + create: [{ mediaId: media2.id, order: 0 }], + }, + hasMedia: true, + }, + }); + + const quote1 = await prisma.tweet.create({ + data: { + userId: 6, + content: 'This! NestJS makes backend development actually enjoyable.', + quotedTweetId: anasTweet1.id, + }, + }); + + const quote2 = await prisma.tweet.create({ + data: { + userId: 10, + content: 'Been using NestJS for 6 months now, can confirm the hype is real! @anasbrahim', + quotedTweetId: anasTweet1.id, + hasMentions: true, + tweetMentions: { + create: [{ userId: 4, startPosition: 66 }], + }, + tweetMedia: { + create: [{ mediaId: media2.id, order: 0 }], + }, + hasMedia: true, + }, + }); + + const quote3 = await prisma.tweet.create({ + data: { + userId: 8, + content: 'Express served us well, but NestJS is the future. Time to migrate!', + quotedTweetId: anasTweet1.id, + }, + }); + + const quote4 = await prisma.tweet.create({ + data: { + userId: 1, + content: 'Adding this to my Cairo food tour list! #foodie', + quotedTweetId: laylaTweet1.id, + hasHashtags: true, + tweetHashtags: { + create: [{ hashtagId: foodHashtag.id, startPosition: 40 }], + }, + }, + }); + + const quote5 = await prisma.tweet.create({ + data: { + userId: 11, + content: 'Egyptian street food hits different. Always. 🇪🇬', + quotedTweetId: laylaTweet1.id, + tweetMedia: { + create: [{ mediaId: media2.id, order: 0 }], + }, + hasMedia: true, + }, + }); + + const quote6 = await prisma.tweet.create({ + data: { + userId: 4, + content: 'My mouth is watering just looking at this @Layla', + quotedTweetId: laylaTweet1.id, + hasMentions: true, + tweetMentions: { + create: [{ userId: 6, startPosition: 42 }], + }, + tweetMedia: { + create: [{ mediaId: media2.id, order: 0 }], + }, + hasMedia: true, + }, + }); + + const quote7 = await prisma.tweet.create({ + data: { + userId: 12, + content: 'Story of my life with Egyptian internet providers 😭', + quotedTweetId: karimTweet1.id, + }, + }); + + const quote8 = await prisma.tweet.create({ + data: { + userId: 2, + content: 'Time to switch to a better ISP? Anyone got recommendations?', + quotedTweetId: karimTweet1.id, + }, + }); + + const quote9 = await prisma.tweet.create({ + data: { + userId: 3, + content: + "User testing saved my last project from disaster. Can't emphasize this enough! #uiux", + quotedTweetId: fatmaTweet1.id, + hasHashtags: true, + tweetHashtags: { + create: [{ hashtagId: uiuxHashtag.id, startPosition: 79 }], + }, + }, + }); + + const quote10 = await prisma.tweet.create({ + data: { + userId: 7, + content: 'Maze.co is my go-to for quick user testing sessions.', + quotedTweetId: fatmaTweet1.id, + }, + }); + + const quote11 = await prisma.tweet.create({ + data: { + userId: 9, + content: "The AI revolution is here and it's incredible to witness! #ai", + quotedTweetId: youssefTweet1.id, + hasHashtags: true, + tweetHashtags: { + create: [{ hashtagId: aiHashtag.id, startPosition: 58 }], + }, + }, + }); + + const quote12 = await prisma.tweet.create({ + data: { + userId: 1, + content: "Can't wait to see what AI brings to backend development @YoussefTech", + quotedTweetId: youssefTweet1.id, + hasMentions: true, + tweetMentions: { + create: [{ userId: 11, startPosition: 56 }], + }, + }, + }); + + const quote13 = await prisma.tweet.create({ + data: { + userId: 4, + content: 'GraphQL changed how I think about APIs. Worth the learning curve! #graphql', + quotedTweetId: nourTweet1.id, + hasHashtags: true, + tweetHashtags: { + create: [{ hashtagId: graphqlHashtag.id, startPosition: 66 }], + }, + tweetMedia: { + create: [{ mediaId: media3.id, order: 0 }], + }, + hasMedia: true, + }, + }); + + const quote14 = await prisma.tweet.create({ + data: { + userId: 8, + content: 'Resolvers are powerful once you understand the pattern @NourCodes', + quotedTweetId: nourTweet1.id, + hasMentions: true, + tweetMentions: { + create: [{ userId: 10, startPosition: 55 }], + }, + }, + }); + + await prisma.like.createMany({ + data: [ + { userId: 1, tweetId: anasTweet1.id }, + { userId: 2, tweetId: anasTweet1.id }, + { userId: 5, tweetId: anasTweet1.id }, + { userId: 8, tweetId: anasTweet1.id }, + { userId: 4, tweetId: omarGReply1.id }, + { userId: 1, tweetId: omarGReply1.id }, + { userId: 10, tweetId: omarHReply1.id }, + { userId: 4, tweetId: anasReply2.id }, + { userId: 1, tweetId: anasReply2.id }, + { userId: 8, tweetId: saraReply1.id }, + { userId: 1, tweetId: laylaTweet1.id }, + { userId: 2, tweetId: laylaTweet1.id }, + { userId: 3, tweetId: laylaTweet1.id }, + { userId: 7, tweetId: laylaTweet1.id }, + { userId: 4, tweetId: tasneemReply1.id }, + { userId: 6, tweetId: tasneemReply1.id }, + { userId: 12, tweetId: fatmaReply1.id }, + { userId: 2, tweetId: karimTweet1.id }, + { userId: 4, tweetId: karimTweet1.id }, + { userId: 5, tweetId: karimTweet1.id }, + { userId: 9, tweetId: karimTweet1.id }, + { userId: 7, tweetId: gelgelQuoteTweet.id }, + { userId: 3, tweetId: youssefReply1.id }, + { userId: 6, tweetId: fatmaTweet1.id }, + { userId: 1, tweetId: fatmaTweet1.id }, + { userId: 9, tweetId: ahmedZReply1.id }, + { userId: 12, tweetId: ahmedZReply1.id }, + { userId: 2, tweetId: youssefTweet1.id }, + { userId: 8, tweetId: youssefTweet1.id }, + { userId: 4, tweetId: nourTweet1.id }, + { userId: 11, tweetId: nourTweet1.id }, + ], + }); + + await prisma.retweet.createMany({ + data: [ + { userId: 2, tweetId: laylaTweet1.id }, + { userId: 3, tweetId: anasTweet1.id }, + { userId: 8, tweetId: anasTweet1.id }, + { userId: 5, tweetId: karimTweet1.id }, + { userId: 10, tweetId: fatmaTweet1.id }, + ], + }); + + await prisma.tweet.updateMany({ + where: { + id: { in: [anasTweet1.id, omarGReply1.id, omarHReply1.id, anasReply2.id, saraReply1.id] }, + }, + data: { likeCount: { increment: 1 } }, + }); + await prisma.tweet.update({ + where: { id: anasTweet1.id }, + data: { likeCount: 4, retweetCount: 2, replyCount: 4 }, + }); + await prisma.tweet.update({ + where: { id: omarGReply1.id }, + data: { likeCount: 2, replyCount: 0 }, + }); + await prisma.tweet.update({ + where: { id: omarHReply1.id }, + data: { likeCount: 1, replyCount: 1 }, + }); + await prisma.tweet.update({ where: { id: anasReply2.id }, data: { likeCount: 2 } }); + await prisma.tweet.update({ where: { id: saraReply1.id }, data: { likeCount: 1 } }); + await prisma.tweet.update({ + where: { id: laylaTweet1.id }, + data: { likeCount: 4, retweetCount: 1, replyCount: 2 }, + }); + await prisma.tweet.update({ where: { id: tasneemReply1.id }, data: { likeCount: 2 } }); + await prisma.tweet.update({ where: { id: fatmaReply1.id }, data: { likeCount: 1 } }); + await prisma.tweet.update({ + where: { id: karimTweet1.id }, + data: { likeCount: 4, retweetCount: 1 }, + }); + await prisma.tweet.update({ where: { id: gelgelQuoteTweet.id }, data: { likeCount: 1 } }); + await prisma.tweet.update({ where: { id: youssefReply1.id }, data: { likeCount: 1 } }); + await prisma.tweet.update({ + where: { id: fatmaTweet1.id }, + data: { likeCount: 2, replyCount: 1 }, + }); + await prisma.tweet.update({ where: { id: ahmedZReply1.id }, data: { likeCount: 2 } }); + await prisma.tweet.update({ where: { id: youssefTweet1.id }, data: { likeCount: 2 } }); + await prisma.tweet.update({ where: { id: nourTweet1.id }, data: { likeCount: 2 } }); + + await prisma.tweet.update({ + where: { id: anasTweet1.id }, + data: { retweetCount: { increment: 3 } }, + }); + + await prisma.tweet.update({ + where: { id: laylaTweet1.id }, + data: { retweetCount: { increment: 3 } }, + }); + + await prisma.tweet.update({ + where: { id: karimTweet1.id }, + data: { retweetCount: { increment: 2 } }, + }); + + await prisma.tweet.update({ + where: { id: fatmaTweet1.id }, + data: { retweetCount: { increment: 2 } }, + }); + + await prisma.tweet.update({ + where: { id: youssefTweet1.id }, + data: { retweetCount: { increment: 2 } }, + }); + + await prisma.tweet.update({ + where: { id: nourTweet1.id }, + data: { retweetCount: { increment: 2 } }, + }); + + await prisma.like.createMany({ + data: [ + { userId: 4, tweetId: quote1.id }, + { userId: 1, tweetId: quote1.id }, + { userId: 2, tweetId: quote2.id }, + { userId: 4, tweetId: quote2.id }, + { userId: 5, tweetId: quote3.id }, + { userId: 6, tweetId: quote4.id }, + { userId: 3, tweetId: quote4.id }, + { userId: 1, tweetId: quote5.id }, + { userId: 6, tweetId: quote6.id }, + { userId: 7, tweetId: quote7.id }, + { userId: 5, tweetId: quote8.id }, + { userId: 12, tweetId: quote9.id }, + { userId: 9, tweetId: quote9.id }, + { userId: 8, tweetId: quote10.id }, + { userId: 11, tweetId: quote11.id }, + { userId: 8, tweetId: quote11.id }, + { userId: 11, tweetId: quote12.id }, + { userId: 10, tweetId: quote13.id }, + { userId: 9, tweetId: quote13.id }, + { userId: 10, tweetId: quote14.id }, + ], + }); + + await prisma.tweet.update({ where: { id: quote1.id }, data: { likeCount: 2 } }); + await prisma.tweet.update({ where: { id: quote2.id }, data: { likeCount: 2 } }); + await prisma.tweet.update({ where: { id: quote3.id }, data: { likeCount: 1 } }); + await prisma.tweet.update({ where: { id: quote4.id }, data: { likeCount: 2 } }); + await prisma.tweet.update({ where: { id: quote5.id }, data: { likeCount: 1 } }); + await prisma.tweet.update({ where: { id: quote6.id }, data: { likeCount: 1 } }); + await prisma.tweet.update({ where: { id: quote7.id }, data: { likeCount: 1 } }); + await prisma.tweet.update({ where: { id: quote8.id }, data: { likeCount: 1 } }); + await prisma.tweet.update({ where: { id: quote9.id }, data: { likeCount: 2 } }); + await prisma.tweet.update({ where: { id: quote10.id }, data: { likeCount: 1 } }); + await prisma.tweet.update({ where: { id: quote11.id }, data: { likeCount: 2 } }); + await prisma.tweet.update({ where: { id: quote12.id }, data: { likeCount: 1 } }); + await prisma.tweet.update({ where: { id: quote13.id }, data: { likeCount: 2 } }); + await prisma.tweet.update({ where: { id: quote14.id }, data: { likeCount: 1 } }); + + const privateConv1 = await prisma.conversation.create({ + data: { + creatorId: 6, + conversationParticipants: { + create: [{ userId: 6 }, { userId: 3, lastSeenMessageId: null }], + }, + }, + }); + + await prisma.message.create({ + data: { + content: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", + conversationId: privateConv1.id, + userId: 6, + messageEntities: { + text: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", + }, + }, + }); + + await prisma.message.create({ + data: { + content: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", + conversationId: privateConv1.id, + userId: 3, + messageEntities: { + text: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", + }, + }, + }); + + const privMsg3 = await prisma.message.create({ + data: { + content: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", + conversationId: privateConv1.id, + userId: 6, + messageEntities: { + text: "Tasneem, that koshary spot is at Abou Tarek! Let's go this weekend?", + }, + }, + }); + + await prisma.conversation.update({ + where: { id: privateConv1.id }, + data: { lastMessageId: privMsg3.id }, + }); + + await prisma.notification.createMany({ + data: [ + { actorId: 1, receiverId: 2, type: NotificationType.FOLLOW, seen: true }, + { actorId: 1, receiverId: 4, type: NotificationType.FOLLOW }, + { actorId: 1, receiverId: 6, type: NotificationType.FOLLOW }, + { actorId: 1, receiverId: 8, type: NotificationType.FOLLOW }, + { actorId: 2, receiverId: 1, type: NotificationType.FOLLOW }, + { actorId: 2, receiverId: 3, type: NotificationType.FOLLOW }, + { actorId: 2, receiverId: 4, type: NotificationType.FOLLOW }, + { actorId: 2, receiverId: 9, type: NotificationType.FOLLOW }, + { actorId: 3, receiverId: 2, type: NotificationType.FOLLOW }, + { actorId: 3, receiverId: 6, type: NotificationType.FOLLOW }, + { actorId: 3, receiverId: 11, type: NotificationType.FOLLOW }, + { actorId: 1, receiverId: 4, type: NotificationType.LIKE, tweetId: anasTweet1.id }, + { actorId: 2, receiverId: 4, type: NotificationType.LIKE, tweetId: anasTweet1.id }, + { actorId: 5, receiverId: 4, type: NotificationType.LIKE, tweetId: anasTweet1.id }, + { actorId: 8, receiverId: 4, type: NotificationType.LIKE, tweetId: anasTweet1.id }, + { actorId: 1, receiverId: 2, type: NotificationType.LIKE, tweetId: omarGReply1.id }, + { actorId: 4, receiverId: 2, type: NotificationType.LIKE, tweetId: omarGReply1.id }, + { actorId: 10, receiverId: 1, type: NotificationType.LIKE, tweetId: omarHReply1.id }, + { actorId: 1, receiverId: 6, type: NotificationType.LIKE, tweetId: laylaTweet1.id }, + { actorId: 3, receiverId: 6, type: NotificationType.LIKE, tweetId: laylaTweet1.id }, + { actorId: 7, receiverId: 6, type: NotificationType.LIKE, tweetId: laylaTweet1.id }, + { actorId: 2, receiverId: 4, type: NotificationType.REPLY, tweetId: omarGReply1.id }, + { actorId: 1, receiverId: 4, type: NotificationType.REPLY, tweetId: omarHReply1.id }, + { actorId: 4, receiverId: 1, type: NotificationType.REPLY, tweetId: anasReply2.id }, + { actorId: 8, receiverId: 4, type: NotificationType.REPLY, tweetId: saraReply1.id }, + { actorId: 3, receiverId: 6, type: NotificationType.REPLY, tweetId: tasneemReply1.id }, + { actorId: 12, receiverId: 6, type: NotificationType.REPLY, tweetId: fatmaReply1.id }, + { actorId: 1, receiverId: 4, type: NotificationType.MENTION, tweetId: anasTweet1.id }, + { actorId: 2, receiverId: 4, type: NotificationType.MENTION, tweetId: omarGReply1.id }, + { actorId: 4, receiverId: 1, type: NotificationType.MENTION, tweetId: anasReply2.id }, + { actorId: 3, receiverId: 6, type: NotificationType.MENTION, tweetId: laylaTweet1.id }, + { actorId: 2, receiverId: 6, type: NotificationType.RETWEET, tweetId: laylaTweet1.id }, + { actorId: 3, receiverId: 4, type: NotificationType.RETWEET, tweetId: anasTweet1.id }, + { actorId: 8, receiverId: 4, type: NotificationType.RETWEET, tweetId: anasTweet1.id }, + { actorId: 5, receiverId: 7, type: NotificationType.QUOTE, tweetId: gelgelQuoteTweet.id }, + { actorId: 10, receiverId: 12, type: NotificationType.RETWEET, tweetId: fatmaTweet1.id }, + { actorId: 4, receiverId: 1, type: NotificationType.MESSAGE }, + { actorId: 4, receiverId: 2, type: NotificationType.MESSAGE }, + { actorId: 1, receiverId: 4, type: NotificationType.MESSAGE }, + { actorId: 1, receiverId: 2, type: NotificationType.MESSAGE }, + { actorId: 2, receiverId: 4, type: NotificationType.MESSAGE }, + { actorId: 2, receiverId: 1, type: NotificationType.MESSAGE }, + { actorId: 6, receiverId: 3, type: NotificationType.MESSAGE }, + { actorId: 8, receiverId: 12, type: NotificationType.MESSAGE }, + { actorId: 8, receiverId: 9, type: NotificationType.MESSAGE }, + { actorId: 12, receiverId: 8, type: NotificationType.MESSAGE }, + { actorId: 12, receiverId: 9, type: NotificationType.MENTION }, + ], + }); + } -if (process.env.SEED_ENV === 'true') { main() .then(async () => { await prisma.$disconnect(); @@ -1338,4 +1346,6 @@ if (process.env.SEED_ENV === 'true') { await prisma.$disconnect(); process.exit(1); }); +} else { + console.log('Seeding is skipped in production environment.'); } From b5dd1c7666ccf02ca22bd179e8456567473d8987 Mon Sep 17 00:00:00 2001 From: Tasneem Mohammed <149891742+Tasneemmohammed0@users.noreply.github.com> Date: Sat, 13 Dec 2025 13:15:20 +0200 Subject: [PATCH 11/43] fix: followers and following counts not updated after blocks - [CU-869bf83h5] (#199) Signed-off-by: Tasneemmhammed0 Co-authored-by: Loay Ahmed --- .../users/follows/get-user-following.bru | 11 ++- .../migration.sql | 19 +++++ src/users/users.repository.ts | 77 ++++++++++++++++--- 3 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 prisma/migrations/20251212105014_update_followers_following_count_after_blocks_update/migration.sql diff --git a/bruno/collections/users/follows/get-user-following.bru b/bruno/collections/users/follows/get-user-following.bru index 173fec12..33b6ea01 100644 --- a/bruno/collections/users/follows/get-user-following.bru +++ b/bruno/collections/users/follows/get-user-following.bru @@ -5,16 +5,11 @@ meta { } get { - url: {{base_url}}/users/:username/following?limit=2&cursor=eyJmb2xsb3dlcklkIjoiNCIsImZvbGxvd2VkSWQiOiIyIn0= + url: {{base_url}}/users/:username/following body: none auth: bearer } -params:query { - limit: 2 - cursor: eyJmb2xsb3dlcklkIjoiNCIsImZvbGxvd2VkSWQiOiIyIn0= -} - params:path { username: notnowomar } @@ -23,6 +18,10 @@ auth:bearer { token: {{access_token}} } +vars:pre-request { + base_url: http://localhost:3000 +} + settings { encodeUrl: true timeout: 0 diff --git a/prisma/migrations/20251212105014_update_followers_following_count_after_blocks_update/migration.sql b/prisma/migrations/20251212105014_update_followers_following_count_after_blocks_update/migration.sql new file mode 100644 index 00000000..694dc4cc --- /dev/null +++ b/prisma/migrations/20251212105014_update_followers_following_count_after_blocks_update/migration.sql @@ -0,0 +1,19 @@ +-- Update followers_count +UPDATE users u +SET followers_count = sub.cnt +FROM ( + SELECT followed_id, COUNT(*) AS cnt + FROM follows + GROUP BY followed_id +) sub +WHERE u.id = sub.followed_id; + +-- Update following_count +UPDATE users u +SET following_count = sub.cnt +FROM ( + SELECT follower_id, COUNT(*) AS cnt + FROM follows + GROUP BY follower_id +) sub +WHERE u.id = sub.follower_id; \ No newline at end of file diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 9225a218..b8e36f26 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -632,15 +632,74 @@ export class UsersRepository { }, }); - // Remove follow relationships in both directions - await tx.follow.deleteMany({ - where: { - OR: [ - { followerId: userId, followedId: blockedId }, - { followerId: blockedId, followedId: userId }, - ], - }, - }); + // Decrement following and followers counts if there was a follow relationship + const [followFromUserToBlocked, followFromBlockedToUser] = await Promise.all([ + tx.follow.findUnique({ + where: { + followerId_followedId: { + followerId: userId, + followedId: blockedId, + }, + }, + }), + + tx.follow.findUnique({ + where: { + followerId_followedId: { + followerId: blockedId, + followedId: userId, + }, + }, + }), + ]); + + if (followFromUserToBlocked) { + // Decrement following count for userId and follower count for blockedId + await Promise.all([ + tx.user.update({ + where: { id: userId }, + data: { followingCount: { decrement: 1 } }, + }), + + tx.user.update({ + where: { id: blockedId }, + data: { followersCount: { decrement: 1 } }, + }), + + tx.follow.delete({ + where: { + followerId_followedId: { + followerId: userId, + followedId: blockedId, + }, + }, + }), + ]); + } + + if (followFromBlockedToUser) { + // Decrement following count for blockedId and follower count for userId + await Promise.all([ + tx.user.update({ + where: { id: blockedId }, + data: { followingCount: { decrement: 1 } }, + }), + + tx.user.update({ + where: { id: userId }, + data: { followersCount: { decrement: 1 } }, + }), + + tx.follow.delete({ + where: { + followerId_followedId: { + followerId: blockedId, + followedId: userId, + }, + }, + }), + ]); + } }); } From 859ff2a2174f8e1a7d93b258345847ca9b809ee5 Mon Sep 17 00:00:00 2001 From: Mostafa Ahmed Abdelglel <139139781+galelo04@users.noreply.github.com> Date: Sat, 13 Dec 2025 13:25:02 +0200 Subject: [PATCH 12/43] perf: add a short rate limiter for db writes endpoints - [CU-869beug3r] (#191) Co-authored-by: OmarHassan2003 Co-authored-by: Loay Ahmed --- src/app.module.ts | 10 ++++- src/common/constants/rate-limit.constants.ts | 4 ++ src/common/guards/ip-throttler.guard.ts | 21 ---------- src/common/guards/request-throttler.guard.ts | 43 ++++++++++++++++++++ src/users/me/me.controller.ts | 2 +- src/users/me/settings/settings.controller.ts | 4 +- src/users/users.controller.ts | 2 +- 7 files changed, 59 insertions(+), 27 deletions(-) delete mode 100644 src/common/guards/ip-throttler.guard.ts create mode 100644 src/common/guards/request-throttler.guard.ts diff --git a/src/app.module.ts b/src/app.module.ts index 13e2b0ba..5e3bd085 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,7 +29,6 @@ import { AppLogger } from './logger/logger.service'; import { ConversationsModule } from './conversations/conversations.module'; import { SearchModule } from './search/search.module'; import { AvatarUrlInterceptor } from './common/interceptors/avatar.interceptor'; -import { IpThrottlerGuard } from './common/guards/ip-throttler.guard'; import { TimelineModule } from './tweets/timeline/timeline.module'; import { TweetAnalyzeModule } from './tweet-analyze/tweet-analyze.module'; import { SessionsModule } from './sessions/sessions.module'; @@ -39,6 +38,7 @@ import { SseController } from './sse/sse.controller'; import cors from 'cors'; import { EventsModule } from './events/events.module'; import { FirebaseModule } from './firebase/firebase.module'; +import { RequestThrottlerGuard } from './common/guards/request-throttler.guard'; @Module({ imports: [ @@ -46,6 +46,12 @@ import { FirebaseModule } from './firebase/firebase.module'; ThrottlerModule.forRoot({ throttlers: [ { + name: 'short', + ttl: RATE_LIMIT.WRITE.TTL, + limit: RATE_LIMIT.WRITE.LIMIT, + }, + { + name: 'default', ttl: RATE_LIMIT.GLOBAL.TTL, limit: RATE_LIMIT.GLOBAL.LIMIT, }, @@ -92,7 +98,7 @@ import { FirebaseModule } from './firebase/firebase.module'; providers: [ { provide: APP_GUARD, - useClass: IpThrottlerGuard, + useClass: RequestThrottlerGuard, }, { provide: APP_FILTER, diff --git a/src/common/constants/rate-limit.constants.ts b/src/common/constants/rate-limit.constants.ts index 349fcf7f..3e063f6f 100644 --- a/src/common/constants/rate-limit.constants.ts +++ b/src/common/constants/rate-limit.constants.ts @@ -7,4 +7,8 @@ export const RATE_LIMIT = { LIMIT: 5, // max 5 attempts WINDOW_MS: 60000, // 1 minute }, + WRITE: { + LIMIT: 10, // max 10 requests + TTL: 60000, // 1 minute + }, }; diff --git a/src/common/guards/ip-throttler.guard.ts b/src/common/guards/ip-throttler.guard.ts deleted file mode 100644 index dc42e7ea..00000000 --- a/src/common/guards/ip-throttler.guard.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ThrottlerGuard } from '@nestjs/throttler'; -import { Injectable, Logger } from '@nestjs/common'; -import { Request } from 'express'; - -@Injectable() -export class IpThrottlerGuard extends ThrottlerGuard { - private readonly logger = new Logger(IpThrottlerGuard.name); - - protected getTracker(req: Record): Promise { - const request = req as unknown as Request; - - const forwarded = request.headers['x-forwarded-for']; - const headerIp = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : undefined; - - const ip = headerIp || request.ip || request.socket?.remoteAddress || 'unknown'; - - this.logger.log(`Receiving request from a client with IP: ${ip}`); - - return Promise.resolve(ip); - } -} diff --git a/src/common/guards/request-throttler.guard.ts b/src/common/guards/request-throttler.guard.ts new file mode 100644 index 00000000..a27ea5fb --- /dev/null +++ b/src/common/guards/request-throttler.guard.ts @@ -0,0 +1,43 @@ +// common/guards/request-throttler.guard.ts +import { Injectable, Logger } from '@nestjs/common'; +import { ThrottlerGuard, ThrottlerRequest } from '@nestjs/throttler'; +import { Request } from 'express'; + +@Injectable() +export class RequestThrottlerGuard extends ThrottlerGuard { + private readonly logger = new Logger(RequestThrottlerGuard.name); + + protected async handleRequest(requestProps: ThrottlerRequest): Promise { + const { context, throttler } = requestProps; + this.logger.debug(`Processing request with throttler: ${throttler.name ?? 'default'}`); + + const request = context.switchToHttp().getRequest(); + const method = request.method; + const throttlerName = throttler.name ?? 'default'; + + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + if (throttlerName === 'default') { + return true; // Skip this specific throttler check + } + } else if (method === 'GET') { + if (throttlerName === 'short') { + return true; // Skip this specific throttler check + } + } + + return super.handleRequest(requestProps); + } + + protected async getTracker(req: Record): Promise { + const request = req as unknown as Request; + + const forwarded = request.headers['x-forwarded-for']; + const headerIp = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : undefined; + + const ip = headerIp || request.ip || request.socket?.remoteAddress || 'unknown'; + + this.logger.debug(`Request from IP: ${ip}`); + + return Promise.resolve(ip); + } +} diff --git a/src/users/me/me.controller.ts b/src/users/me/me.controller.ts index e933ffaf..84202f23 100644 --- a/src/users/me/me.controller.ts +++ b/src/users/me/me.controller.ts @@ -32,7 +32,7 @@ export class MeController { @Put('password') @Throttle({ - default: { + short: { limit: RATE_LIMIT.PASSWORD_CHANGE.LIMIT, ttl: RATE_LIMIT.PASSWORD_CHANGE.WINDOW_MS, }, diff --git a/src/users/me/settings/settings.controller.ts b/src/users/me/settings/settings.controller.ts index 0b98b68d..9d2d700e 100644 --- a/src/users/me/settings/settings.controller.ts +++ b/src/users/me/settings/settings.controller.ts @@ -61,7 +61,7 @@ export class SettingsController { @Put('email') @Throttle({ - default: { + short: { limit: SettingsController.EMAIL_UPDATE_LIMIT, ttl: SettingsController.EMAIL_UPDATE_WINDOW, }, @@ -98,7 +98,7 @@ export class SettingsController { @Patch('username') @UseGuards(JwtAuthGuard) - @Throttle({ default: { limit: 10, ttl: 60_000 } }) + @Throttle({ short: { limit: 10, ttl: 60_000 } }) async updateUsername(@Body() updateUsernameDto: UpdateUsernameDto, @User() user: RequestUser) { const userId = BigInt(user.id); return this.settingsService.updateUsername(userId, updateUsernameDto); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index d6004bcb..6a9f64cf 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -14,7 +14,7 @@ export class UsersController { @Post(':username/following') @UseGuards(JwtAuthGuard) @Throttle({ - default: { + short: { limit: 10, ttl: 60, }, From 7494993411b0e63d698e807d6c0abaa08d5bcf09 Mon Sep 17 00:00:00 2001 From: anas-ibrahem <139391509+anas-ibrahem@users.noreply.github.com> Date: Sat, 13 Dec 2025 15:24:51 +0200 Subject: [PATCH 13/43] fix: hashtag db storage - [CU-869bfh7r4] (#201) --- .../migration.sql | 43 +++++++++++++++++++ prisma/schema.prisma | 33 ++++++++------ prisma/timeline-seed.ts | 19 +++++++- .../content-parsing.service.ts | 2 +- src/trending/trending.repository.ts | 18 ++++---- src/trending/trending.service.ts | 4 +- 6 files changed, 93 insertions(+), 26 deletions(-) create mode 100644 prisma/migrations/20251212000000_separate_hashtags_from_trending_keywords/migration.sql diff --git a/prisma/migrations/20251212000000_separate_hashtags_from_trending_keywords/migration.sql b/prisma/migrations/20251212000000_separate_hashtags_from_trending_keywords/migration.sql new file mode 100644 index 00000000..ec740298 --- /dev/null +++ b/prisma/migrations/20251212000000_separate_hashtags_from_trending_keywords/migration.sql @@ -0,0 +1,43 @@ +-- AlterTable: Remove TweetHashtag relation from TrendingKeyword (structure only, data preserved) +-- CreateTable: Create new hashtags table for non-trending hashtag usage +CREATE TABLE "hashtags" ( + "id" BIGSERIAL NOT NULL, + "keyword" VARCHAR(100) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "hashtags_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "hashtags_keyword_key" ON "hashtags"("keyword"); + +-- Migrate hashtag data from trending_keywords to hashtags table +-- Only migrate hashtags (isHashtag = true) from trending_keywords +INSERT INTO "hashtags" ("keyword", "created_at") +SELECT DISTINCT "keyword", MIN("created_at") +FROM "trending_keywords" +WHERE "is_hashtag" = true +GROUP BY "keyword"; + +-- Store old hashtag_id mapping in a temporary table for reference +CREATE TEMP TABLE "temp_hashtag_id_mapping" AS +SELECT tk.id as old_id, h.id as new_id +FROM "trending_keywords" tk +INNER JOIN "hashtags" h ON tk.keyword = h.keyword +WHERE tk."is_hashtag" = true; + +-- Drop the foreign key constraint on tweet_hashtags +ALTER TABLE "tweet_hashtags" DROP CONSTRAINT "tweet_hashtags_hashtag_id_fkey"; + +-- Update tweet_hashtags to reference the new hashtags table +UPDATE "tweet_hashtags" th +SET "hashtag_id" = m.new_id +FROM "temp_hashtag_id_mapping" m +WHERE th."hashtag_id" = m.old_id; + +-- AddForeignKey: Add foreign key to reference the new hashtags table +ALTER TABLE "tweet_hashtags" ADD CONSTRAINT "tweet_hashtags_hashtag_id_fkey" FOREIGN KEY ("hashtag_id") REFERENCES "hashtags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- Clear all trending_keywords to start fresh for scoring purposes +-- The hashtag data is now safely stored in the hashtags table +DELETE FROM "trending_keywords"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 89999461..59153d98 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -233,14 +233,13 @@ model Like { } model TrendingKeyword { - id BigInt @id @default(autoincrement()) - keyword String @db.VarChar(100) + id BigInt @id @default(autoincrement()) + keyword String @db.VarChar(100) category Categories? - lastUpdatedAt DateTime? @map("last_updated_at") - isHashtag Boolean @default(false) @map("is_hashtag") - count Int @default(1) - createdAt DateTime @default(now()) @map("created_at") - tweetHashtags TweetHashtag[] + lastUpdatedAt DateTime? @map("last_updated_at") + isHashtag Boolean @default(false) @map("is_hashtag") + count Int @default(1) + createdAt DateTime @default(now()) @map("created_at") @@unique([keyword, isHashtag]) @@index([isHashtag, keyword, count(sort: Desc)], map: "trending_hashtags_search_idx") @@ -249,12 +248,22 @@ model TrendingKeyword { @@map("trending_keywords") } +model Hashtag { + id BigInt @id @default(autoincrement()) + keyword String @unique @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") + tweetHashtags TweetHashtag[] + + @@index([keyword]) + @@map("hashtags") +} + model TweetHashtag { - tweetId BigInt @map("tweet_id") - hashtagId BigInt @map("hashtag_id") - startPosition Int @map("starting_index") - hashtag TrendingKeyword @relation(fields: [hashtagId], references: [id], onDelete: NoAction, onUpdate: NoAction) - tweet Tweet @relation(fields: [tweetId], references: [id], onDelete: Cascade, onUpdate: NoAction) + tweetId BigInt @map("tweet_id") + hashtagId BigInt @map("hashtag_id") + startPosition Int @map("starting_index") + hashtag Hashtag @relation(fields: [hashtagId], references: [id], onDelete: NoAction, onUpdate: NoAction) + tweet Tweet @relation(fields: [tweetId], references: [id], onDelete: Cascade, onUpdate: NoAction) @@id([hashtagId, tweetId, startPosition]) @@index([tweetId, hashtagId]) diff --git a/prisma/timeline-seed.ts b/prisma/timeline-seed.ts index 9306f991..ec70e420 100644 --- a/prisma/timeline-seed.ts +++ b/prisma/timeline-seed.ts @@ -5,7 +5,21 @@ const prisma = new PrismaClient(); async function getOrCreateHashtag(tag: string) { const keyword = tag.toLowerCase(); - return prisma.trendingKeyword.upsert({ + + // Create hashtag in hashtags table for tweet linking + const hashtag = await prisma.hashtag.upsert({ + where: { + keyword, + }, + update: {}, + create: { + keyword, + }, + }); + + // Also create/increment in trending_keywords for scoring + // this uses trending_keywords + await prisma.trendingKeyword.upsert({ where: { keyword_isHashtag: { keyword, @@ -21,6 +35,8 @@ async function getOrCreateHashtag(tag: string) { count: 1, }, }); + + return hashtag; } async function main() { @@ -31,6 +47,7 @@ async function main() { await prisma.retweet.deleteMany({}); await prisma.tweet.deleteMany({}); await prisma.follow.deleteMany({}); + await prisma.hashtag.deleteMany({}); await prisma.trendingKeyword.deleteMany({}); // Step 2: Create the 5 specified user accounts. diff --git a/src/content-parsing/content-parsing.service.ts b/src/content-parsing/content-parsing.service.ts index 05cc3351..0c80601e 100644 --- a/src/content-parsing/content-parsing.service.ts +++ b/src/content-parsing/content-parsing.service.ts @@ -51,7 +51,7 @@ export class ContentParsingService { plainMentions, tx, ); - const hashtags = await this.trendingService.createOrIncrementHashtags(plainHashtags, tx); + const hashtags = await this.trendingService.createOrGetHashtags(plainHashtags, tx); return { mentions, hashtags }; } diff --git a/src/trending/trending.repository.ts b/src/trending/trending.repository.ts index cfd55947..702f2687 100644 --- a/src/trending/trending.repository.ts +++ b/src/trending/trending.repository.ts @@ -13,7 +13,7 @@ export class TrendingRepository { * @param prismaClient * @returns Array of hashtag ids in the same order as input */ - async createOrIncrementHashtags( + async createOrGetHashtags( hashtags: PlainHashtag[], prismaClient: Prisma.TransactionClient = this.prisma, ): Promise<(PlainHashtag & { hashtagId: bigint })[]> { @@ -24,11 +24,12 @@ export class TrendingRepository { // counts once per tweet, lowercase const keywords = Array.from(new Set(hashtags.map((hashtag) => hashtag.keyword.toLowerCase()))); + // Create or get hashtags in the new hashtags table (for tweet linking and searching) const results = await prismaClient.$queryRaw<{ id: bigint; keyword: string }[]>` - INSERT INTO "trending_keywords" (keyword, "is_hashtag", count) - VALUES ${Prisma.join(keywords.map((keyword) => Prisma.sql`(${keyword}, true, 1)`))} - ON CONFLICT (keyword, "is_hashtag") - DO UPDATE SET count = "trending_keywords".count + 1 + INSERT INTO "hashtags" (keyword) + VALUES ${Prisma.join(keywords.map((keyword) => Prisma.sql`(${keyword})`))} + ON CONFLICT (keyword) + DO UPDATE SET keyword = EXCLUDED.keyword RETURNING id, keyword `; @@ -50,13 +51,10 @@ export class TrendingRepository { * @returns - The ID of the hashtag if it exists, or null if it does not. */ async getHashtagId(hashtag: string): Promise<{ id: bigint } | null> { - return await this.prisma.trendingKeyword.findUnique({ + return await this.prisma.hashtag.findUnique({ select: { id: true }, where: { - keyword_isHashtag: { - keyword: hashtag.toLowerCase(), - isHashtag: true, - }, + keyword: hashtag.toLowerCase(), }, }); } diff --git a/src/trending/trending.service.ts b/src/trending/trending.service.ts index 0153fca9..db883958 100644 --- a/src/trending/trending.service.ts +++ b/src/trending/trending.service.ts @@ -14,11 +14,11 @@ export class TrendingService { * @param tx transaction client passed from the create tweet function in tweet service * @returns The actual IDs for the keywords along with the given starting position and keyword, creating new IDs for non-existing keywords */ - async createOrIncrementHashtags( + async createOrGetHashtags( hashtags: PlainHashtag[], tx: Prisma.TransactionClient, ): Promise<(PlainHashtag & { hashtagId: bigint })[]> { - return await this.TrendingRepository.createOrIncrementHashtags(hashtags, tx); + return await this.TrendingRepository.createOrGetHashtags(hashtags, tx); } async getHashtagId(hashtag: string): Promise<{ id: bigint } | null> { From 615ce64da8c3936a53bbf6261639c3c18cbf6fa8 Mon Sep 17 00:00:00 2001 From: Mostafa Ahmed Abdelglel <139139781+galelo04@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:37:13 +0200 Subject: [PATCH 14/43] feat: guest users - [CU-869bev5er] (#192) --- src/auth/guards/jwt-auth.guard.ts | 32 +++++++++++++++++-- .../decorators/optional-auth.decorator.ts | 4 +++ src/tweets/tweets.controller.ts | 4 ++- src/tweets/tweets.repository.ts | 27 +++++++++------- src/tweets/tweets.service.ts | 6 +++- src/users/users.controller.ts | 4 ++- src/users/users.repository.ts | 19 +++++------ 7 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 src/common/decorators/optional-auth.decorator.ts diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts index 2155290e..d540b447 100644 --- a/src/auth/guards/jwt-auth.guard.ts +++ b/src/auth/guards/jwt-auth.guard.ts @@ -1,5 +1,33 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { OPTIONAL_AUTH_KEY } from 'src/common/decorators/optional-auth.decorator'; +import { RequestUser } from 'src/common/interfaces'; @Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') {} +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + return super.canActivate(context); + } + + handleRequest(err: any, user: any, _info: any, context: ExecutionContext): any { + const isOptional = this.reflector.getAllAndOverride(OPTIONAL_AUTH_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (user) { + return user as RequestUser; + } + + if (isOptional) { + return null; + } + + throw (err as Error) || new UnauthorizedException(); + } +} diff --git a/src/common/decorators/optional-auth.decorator.ts b/src/common/decorators/optional-auth.decorator.ts new file mode 100644 index 00000000..29060b9b --- /dev/null +++ b/src/common/decorators/optional-auth.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const OPTIONAL_AUTH_KEY = 'isOptionalAuth'; +export const OptionalAuth = () => SetMetadata(OPTIONAL_AUTH_KEY, true); diff --git a/src/tweets/tweets.controller.ts b/src/tweets/tweets.controller.ts index ba3bf20f..6fdec657 100644 --- a/src/tweets/tweets.controller.ts +++ b/src/tweets/tweets.controller.ts @@ -5,6 +5,7 @@ import { User } from 'src/auth/decorators'; import type { RequestUser } from 'src/common/interfaces'; import { ParseBigIntPipe } from 'src/common/pipes'; import { CreateTweetDto } from './dtos'; +import { OptionalAuth } from 'src/common/decorators/optional-auth.decorator'; @Controller('tweets') @UseGuards(JwtAuthGuard) @@ -50,8 +51,9 @@ export class TweetsController { @Get(':id') @UseGuards(JwtAuthGuard) + @OptionalAuth() async getTweet(@User() user: RequestUser, @Param('id', ParseBigIntPipe) tweetId: bigint) { - const userId = BigInt(user.id); + const userId = user ? BigInt(user.id) : null; return await this.tweetsService.getTweet(tweetId, userId); } diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts index be3b72e6..fb2389bd 100644 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -19,7 +19,7 @@ import { MAX_TWEET_DEPTH, TWEETS_ERROR_CODES, TWEETS_ERROR_MESSAGES } from './co import { DeletedTweet, TweetOrDeleted } from './types'; import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; -export const tweetInclude = (currentUserId: bigint) => +export const tweetInclude = (currentUserId: bigint | null) => ({ user: { select: { @@ -33,16 +33,19 @@ export const tweetInclude = (currentUserId: bigint) => }, }, }, - _count: { - select: { - likes: { - where: { userId: currentUserId }, - }, - retweets: { - where: { userId: currentUserId }, + ...(currentUserId && { + _count: { + select: { + likes: { + where: { userId: currentUserId }, + }, + retweets: { + where: { userId: currentUserId }, + }, }, }, - }, + }), + tweetMentions: { select: { startPosition: true, @@ -210,8 +213,8 @@ export class TweetsRepository { replyCount: tweet.replyCount, retweetCount: tweet.retweetCount, likeCount: tweet.likeCount, - isLiked: tweet._count.likes > 0, - isRetweeted: tweet._count.retweets > 0, + isLiked: tweet._count ? tweet._count.likes > 0 : false, + isRetweeted: tweet._count ? tweet._count.retweets > 0 : false, entities: { mentions: tweet.tweetMentions.map((mention) => ({ username: mention.user.username, @@ -556,7 +559,7 @@ export class TweetsRepository { async getDetailedTweetById( tweetId: bigint, - currentUserId: bigint, + currentUserId: bigint | null, ): Promise { const tweet = await this.prisma.tweet.findUnique({ where: { id: tweetId, isDeleted: false }, diff --git a/src/tweets/tweets.service.ts b/src/tweets/tweets.service.ts index 23af32f8..19581342 100644 --- a/src/tweets/tweets.service.ts +++ b/src/tweets/tweets.service.ts @@ -707,7 +707,7 @@ export class TweetsService { return { items, pagination }; } - async getTweet(tweetId: bigint, currentUserId: bigint): Promise { + async getTweet(tweetId: bigint, currentUserId: bigint | null): Promise { const tweet = await this.tweetsRepository.getDetailedTweetById(tweetId, currentUserId); if (!tweet) { @@ -720,6 +720,10 @@ export class TweetsService { ); } + if (!currentUserId) { + return { ...tweet, rootTweet: null, parentTweets: [], hasMoreParents: false }; + } + let rootTweet: TweetDto | DeletedTweet | null = null; let parentTweets: (TweetDto | DeletedTweet)[] = []; let hasMoreParents = false; diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 6a9f64cf..addd476f 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -5,6 +5,7 @@ import { FollowingUserDto } from './dtos'; import { JwtAuthGuard } from 'src/auth/guards'; import { User } from 'src/auth/decorators'; import type { RequestUser } from 'src/common/interfaces'; +import { OptionalAuth } from 'src/common/decorators/optional-auth.decorator'; import { Throttle } from '@nestjs/throttler'; @Controller('users') @@ -35,8 +36,9 @@ export class UsersController { // TODO: This should be optional guard (if logged in, provide more details (just the relations)) @UseGuards(JwtAuthGuard) + @OptionalAuth() async getUserProfile(@Param('username') username: string, @User() user: RequestUser) { - const currentUserId = BigInt(user.id); + const currentUserId = user ? BigInt(user.id) : undefined; return this.usersService.getUserProfile(username, currentUserId); } diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index b8e36f26..04af587f 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -287,15 +287,16 @@ export class UsersRepository { // TODO: Get mutual followers count and names } - const relationship: UserRelationshipDto | null = isMyProfile - ? null - : { - blocking: isBlocking, - blockedBy: isBlockedBy, - following: isFollowing, - follower: isFollower, - muted: isMuted, - }; + const relationship: UserRelationshipDto | null = + isMyProfile || !currentUserId + ? null + : { + blocking: isBlocking, + blockedBy: isBlockedBy, + following: isFollowing, + follower: isFollower, + muted: isMuted, + }; return { username: user.username, From 9dea2c2aeb45f682aa10da8d443709ef12cecdff Mon Sep 17 00:00:00 2001 From: Tasneem Mohammed <149891742+Tasneemmohammed0@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:44:43 +0200 Subject: [PATCH 15/43] feat: add upload gif endpoint - [CU-869bfbqqy] (#200) Signed-off-by: Tasneemmhammed0 Co-authored-by: Mostafa Ahmed Abdelglel <139139781+galelo04@users.noreply.github.com> --- api-spec/complete-spec/main.tsp | 40 +++++++++++-- api-spec/implemented-spec/main.tsp | 40 +++++++++++-- bruno/collections/media/upload-gif.bru | 26 +++++++++ src/media/constants/media.constant.ts | 6 +- src/media/dtos/upload-gif.dto.ts | 6 ++ src/media/dtos/uploaded-gif-response.ts | 7 +++ src/media/interfaces/index.ts | 1 + src/media/interfaces/media.interface.ts | 21 +++++++ src/media/media.controller.ts | 7 +++ src/media/media.service.ts | 77 ++++++++++++++++++++++++- 10 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 bruno/collections/media/upload-gif.bru create mode 100644 src/media/dtos/upload-gif.dto.ts create mode 100644 src/media/dtos/uploaded-gif-response.ts create mode 100644 src/media/interfaces/index.ts create mode 100644 src/media/interfaces/media.interface.ts diff --git a/api-spec/complete-spec/main.tsp b/api-spec/complete-spec/main.tsp index 5ce8f448..bbbd3fe6 100644 --- a/api-spec/complete-spec/main.tsp +++ b/api-spec/complete-spec/main.tsp @@ -600,6 +600,28 @@ model UploadVideoRequest { folder: HttpPart<"avatars" | "banners" | "tweets" | "messages">; } +model UploadGifBody { + @doc("Tenor GIF ID to attach") + tenorId: string; +} + +model UploadGifResponse { + @doc("Unique identifier for the uploaded GIF") + id: string; + + @doc("URL to access the uploaded GIF") + url: string; + + @doc("Width of the GIF in pixels") + width: integer; + + @doc("Height of the GIF in pixels") + height: integer; + + @doc("Alt text for accessibility (if provided)") + altText?: string; +} + @doc("The type of media file.") enum MediaType { IMAGE, @@ -1986,6 +2008,7 @@ namespace Tweets { @doc("Generates a concise summary of the tweet content using AI") op getTweetSummary( @path id: string, + @query @doc("Language locale for the summary (ar-EG for Arabic, en-US for English). Defaults to en-US if not provided.") locale?: "ar-EG" | "en-US", @@ -2098,7 +2121,7 @@ namespace Onboarding { @get @route("/follow-suggestions") @summary("Get follow suggestions for the authenticated user") - @doc(""" + @doc(""" **Suggestion Algorithm:** - Prioritizes users with mutual followers (friends of friends) - Secondary sorting by follower count (popularity) @@ -2233,11 +2256,7 @@ namespace Search { @doc("The search query") @query query: string, - ): - | DataResponse - | NotFoundResponse - | UnauthorizedResponse - | InternalServerErrorResponse; + ): DataResponse | NotFoundResponse | UnauthorizedResponse | InternalServerErrorResponse; @summary("Get suggested users from following/follower list, used in mentions") @get @@ -2406,6 +2425,15 @@ namespace Media { | BadRequestResponse | UnauthorizedResponse | InternalServerErrorResponse; + + @post + @route("/upload/gif") + @summary("Upload a GIF by tenor ID") + op uploadGif(@body body: UploadGifBody): + | DataResponse + | BadRequestResponse + | UnauthorizedResponse + | InternalServerErrorResponse; } @tag("Devices") diff --git a/api-spec/implemented-spec/main.tsp b/api-spec/implemented-spec/main.tsp index 1ec77cfa..9e28256b 100644 --- a/api-spec/implemented-spec/main.tsp +++ b/api-spec/implemented-spec/main.tsp @@ -709,6 +709,28 @@ model UploadVideoRequest { folder: HttpPart<"avatars" | "banners" | "tweets" | "messages">; } +model UploadGifBody { + @doc("Tenor GIF ID to attach") + tenorId: string; +} + +model UploadGifResponse { + @doc("Unique identifier for the uploaded GIF") + id: string; + + @doc("URL to access the uploaded GIF") + url: string; + + @doc("Width of the GIF in pixels") + width: integer; + + @doc("Height of the GIF in pixels") + height: integer; + + @doc("Alt text for accessibility (if provided)") + altText?: string; +} + @doc("Request body for updating profile (at least one property must be provided)") model UpdateProfileRequest { @doc("JSON string containing profile data fields (It is not required if either the avatar or banner is provided)") @@ -1888,6 +1910,15 @@ namespace Media { | BadRequestResponse | UnauthorizedResponse | InternalServerErrorResponse; + + @post + @route("/upload/gif") + @summary("Upload a GIF by tenor ID") + op uploadGif(@body body: UploadGifBody): + | DataResponse + | BadRequestResponse + | UnauthorizedResponse + | InternalServerErrorResponse; } @tag("Tweets") @@ -2005,6 +2036,7 @@ namespace Tweets { @doc("Generates a concise summary of the tweet content using Gemini 2.0 Flash Lite") op getTweetSummary( @path id: string, + @query @doc("Language locale for the summary (ar-EG for Arabic, en-US for English). Defaults to en-US if not provided.") locale?: "ar-EG" | "en-US", @@ -2178,11 +2210,7 @@ namespace Search { @doc("The search query") @query query: string, - ): - | DataResponse - | NotFoundResponse - | UnauthorizedResponse - | InternalServerErrorResponse; + ): DataResponse | NotFoundResponse | UnauthorizedResponse | InternalServerErrorResponse; @summary("Get suggested users from following/follower list, used in mentions") @get @@ -2227,4 +2255,4 @@ namespace Search { | NotFoundResponse | UnauthorizedResponse | InternalServerErrorResponse; -} \ No newline at end of file +} diff --git a/bruno/collections/media/upload-gif.bru b/bruno/collections/media/upload-gif.bru new file mode 100644 index 00000000..d96f5e7f --- /dev/null +++ b/bruno/collections/media/upload-gif.bru @@ -0,0 +1,26 @@ +meta { + name: upload-gif + type: http + seq: 3 +} + +post { + url: http://localhost:3000/media/upload/gif + body: json + auth: bearer +} + +auth:bearer { + token: {{access_token}} +} + +body:json { + { + "tenorId": "fdmkfme" + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/src/media/constants/media.constant.ts b/src/media/constants/media.constant.ts index 78f582b1..2b94abb6 100644 --- a/src/media/constants/media.constant.ts +++ b/src/media/constants/media.constant.ts @@ -7,7 +7,7 @@ export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp']; export const VIDEO_EXTENSIONS = ['mp4', 'mov']; export const GIF_EXTENSIONS = ['gif']; export const ALLOWED_EXTENSIONS = [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS, ...GIF_EXTENSIONS]; -export const PENDING_MEDIA_CLEANUP_THRESHOLD_HOURS = 24; // 0 hours for testing purposes +export const PENDING_MEDIA_CLEANUP_THRESHOLD_HOURS = 24; export const MEDIA_CODES = { MEDIA_UPLOAD_SAVE_FAILED: 'MEDIA_UPLOAD_SAVE_FAILED', @@ -15,6 +15,8 @@ export const MEDIA_CODES = { MEDIA_NOT_FOUND: 'MEDIA_NOT_FOUND', INVALID_URL: 'INVALID_URL', UNAUTHORIZED_DELETE: 'UNAUTHORIZED_DELETE', + GIF_UPLOAD_FAILED: 'GIF_UPLOAD_FAILED', + GIF_NOT_FOUND: 'GIF_NOT_FOUND', } as const; export const MEDIA_MESSAGES = { @@ -24,5 +26,7 @@ export const MEDIA_MESSAGES = { INVALID_URL: 'The provided URL is invalid.', UNAUTHORIZED_DELETE: 'Unauthorized attempt to delete media.', ALLOWED_IMAGE_TYPES: 'Only image files are allowed (jpg, jpeg, png, webp).', + GIF_UPLOAD_FAILED: 'Failed to upload GIF from Tenor.', + GIF_NOT_FOUND: 'GIF not found on Tenor with the provided ID.', ALLOWED_VIDEO_TYPES: 'Only video files are allowed (mp4, mov).', } as const; diff --git a/src/media/dtos/upload-gif.dto.ts b/src/media/dtos/upload-gif.dto.ts new file mode 100644 index 00000000..15b3a9e4 --- /dev/null +++ b/src/media/dtos/upload-gif.dto.ts @@ -0,0 +1,6 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +export class UploadGif { + @IsString() + @IsNotEmpty() + tenorId: string; +} diff --git a/src/media/dtos/uploaded-gif-response.ts b/src/media/dtos/uploaded-gif-response.ts new file mode 100644 index 00000000..7dd6238b --- /dev/null +++ b/src/media/dtos/uploaded-gif-response.ts @@ -0,0 +1,7 @@ +export class UploadedGifResponse { + id: string; + url: string; + width: number; + height: number; + altText?: string; +} diff --git a/src/media/interfaces/index.ts b/src/media/interfaces/index.ts new file mode 100644 index 00000000..524ee804 --- /dev/null +++ b/src/media/interfaces/index.ts @@ -0,0 +1 @@ +export * from './media.interface'; diff --git a/src/media/interfaces/media.interface.ts b/src/media/interfaces/media.interface.ts new file mode 100644 index 00000000..f78f6ef5 --- /dev/null +++ b/src/media/interfaces/media.interface.ts @@ -0,0 +1,21 @@ +export interface GifMediaFormat { + url: string; + dims: number[]; + size: number; +} + +export interface GifResult { + id: string; + title: string; + media_formats: { + gif: GifMediaFormat; + tinygif: GifMediaFormat; + nanogif: GifMediaFormat; + }; + content_description: string; +} + +export interface TenorResponse { + results: GifResult[]; + next: string; +} diff --git a/src/media/media.controller.ts b/src/media/media.controller.ts index 22d880dd..938ad950 100644 --- a/src/media/media.controller.ts +++ b/src/media/media.controller.ts @@ -7,6 +7,7 @@ import { JwtAuthGuard } from 'src/auth/guards'; import type { RequestUser } from 'src/common/interfaces'; import { imageFileFilter, videoFileFilter } from './validators/media-file.validator'; import { UploadMedia } from './dtos/upload-media.dto'; +import { UploadGif } from './dtos/upload-gif.dto'; @Controller('media') export class MediaController { @@ -43,4 +44,10 @@ export class MediaController { ) { return this.mediaService.uploadMedia(BigInt(user.id), file, body.folder, body.altText); } + + @Post('upload/gif') + @UseGuards(JwtAuthGuard) + async uploadGif(@User() user: RequestUser, @Body() body: UploadGif) { + return this.mediaService.uploadGif(BigInt(user.id), body.tenorId); + } } diff --git a/src/media/media.service.ts b/src/media/media.service.ts index 562334b8..587f250f 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -9,7 +9,8 @@ import { MediaType } from '@prisma/client'; import { processImage } from './utils/process-image.util'; import { MEDIA_CODES, MEDIA_MESSAGES, PENDING_MEDIA_CLEANUP_THRESHOLD_HOURS } from './constants'; import { Cron, CronExpression } from '@nestjs/schedule'; - +import { TenorResponse } from './interfaces'; +import { UploadedGifResponse } from './dtos/uploaded-gif-response'; @Injectable() export class MediaService { private readonly logger = new Logger(MediaService.name); @@ -235,6 +236,80 @@ export class MediaService { return { ...items, message: 'Media uploaded successfully.' }; } + async uploadGif(currentUserId: bigint, tenorId: string): Promise { + const tenorApiKey = process.env.RAVEN_TENOR_KEY; + if (!tenorApiKey) { + this.logger.error('Tenor API key is not configured'); + throw new HttpException( + { + message: MEDIA_MESSAGES.GIF_UPLOAD_FAILED, + code: MEDIA_CODES.GIF_UPLOAD_FAILED, + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + const tenorUrl = `https://tenor.googleapis.com/v2/posts?key=${tenorApiKey}&ids=${tenorId}&client_key=my_app`; + this.logger.log(`Fetching GIF from Tenor with ID: ${tenorId}`); + + const tenorResponse = await fetch(tenorUrl); + + if (!tenorResponse.ok) { + throw new HttpException( + { + message: MEDIA_MESSAGES.GIF_UPLOAD_FAILED, + code: MEDIA_CODES.GIF_UPLOAD_FAILED, + }, + HttpStatus.BAD_REQUEST, + ); + } + + const tenorData = (await tenorResponse.json()) as TenorResponse; + + if ((tenorData && !tenorData.results) || tenorData.results.length === 0) { + throw new HttpException( + { + message: MEDIA_MESSAGES.GIF_NOT_FOUND, + code: MEDIA_CODES.GIF_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ); + } + + const gifData = tenorData.results[0]; + + // Get the GIF URL and dimensions from the response + const gifUrl = gifData.media_formats.gif.url; + const [width, height] = gifData.media_formats.gif.dims; + + this.logger.log(`GIF URL from Tenor: ${gifUrl}`); + + // Save metadata to database + const mediaDto: MediaDto = { + userId: currentUserId, + url: gifUrl, + type: MediaType.GIF, + width, + height, + altText: gifData.content_description, + pending: true, + }; + + const savedMedia = await this.mediaRepository.saveMedia(mediaDto); + + const uploadedGifResponse = { + id: savedMedia.id.toString(), + url: gifUrl, + width, + height, + altText: gifData.content_description, + }; + + this.logger.log(`GIF metadata saved with ID: ${savedMedia.id}`); + + return uploadedGifResponse; + } + /** * Cleanup pending media that has exceeded the threshold time. * Runs weekly to delete orphaned media from failed tweet creations. From 0556dbf3a0d3da4f7b02a3f5576b756afee29203 Mon Sep 17 00:00:00 2001 From: anas-ibrahem <139391509+anas-ibrahem@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:48:24 +0200 Subject: [PATCH 16/43] fix: AI summary short tweets - [CU-869beakr9] (#203) Co-authored-by: Loay Ahmed Co-authored-by: Mostafa Ahmed Abdelglel <139139781+galelo04@users.noreply.github.com> --- .../collections/tweets/get-tweet-summary.bru | 4 ++-- .../content-parsing.service.ts | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/bruno/collections/tweets/get-tweet-summary.bru b/bruno/collections/tweets/get-tweet-summary.bru index 0d864bea..eccada9a 100644 --- a/bruno/collections/tweets/get-tweet-summary.bru +++ b/bruno/collections/tweets/get-tweet-summary.bru @@ -5,13 +5,13 @@ meta { } get { - url: http://localhost:3000/tweets/1/summary?ar-EG + url: http://localhost:3000/tweets/6/summary?locale=ar-EG body: none auth: bearer } params:query { - ar-EG: + locale: ar-EG } auth:bearer { diff --git a/src/content-parsing/content-parsing.service.ts b/src/content-parsing/content-parsing.service.ts index 0c80601e..d1519a6b 100644 --- a/src/content-parsing/content-parsing.service.ts +++ b/src/content-parsing/content-parsing.service.ts @@ -126,7 +126,6 @@ export class ContentParsingService { } /** - * Generate a summary of a tweet using Gemini 2.0 Flash Lite * @param content The tweet content to summarize * @returns A concise summary of the tweet */ @@ -140,18 +139,26 @@ export class ContentParsingService { } const englishPrompt = ` - Summarize the following tweet in english in a very simple and concise way. - The summary MUST start with: "The tweet is talking about ..." - Keep it shorter than the original tweet. + You are generating a short explanation for users in the app UI. + + Summarize the following tweet in a simple, user-friendly sentence. + The sentence MUST start with: "The tweet is talking about ..." + + The goal is clarity for users, not strict character length comparison. + If the tweet is very short, empty, or unclear, still provide a brief meaningful explanation. Tweet: ${content} `; const arabicPrompt = ` - لخص التغريدة التالية باللهجة المصرية بطريقة بسيطة ومختصرة جداً. - يجب أن يبدأ الملخص بعبارة: "التغريدة تتحدث عن ..." - ويجب أن يكون أقصر من التغريدة الأصلية. + أنت تقوم بإنشاء شرح قصير لعرضه للمستخدم داخل واجهة التطبيق. + + لخص التغريدة التالية باللهجة المصرية بجملة بسيطة وواضحة. + يجب أن يبدأ الشرح بعبارة: "التغريدة تتحدث عن ..." + + الهدف هو التوضيح للمستخدم، وليس الالتزام بعدد أحرف أقل من التغريدة. + إذا كانت التغريدة قصيرة جداً أو غير واضحة، قدّم شرحاً مختصراً مفيداً. التغريدة: ${content} From 8bb0c7386a4b5eebcd53c53a243ad5272c4ab886 Mon Sep 17 00:00:00 2001 From: Omar Hassan <149178126+OmarHassan2003@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:55:45 +0200 Subject: [PATCH 17/43] feat: implement explore tabs endpoints - [CU-869beacd6] (#183) Co-authored-by: omargamal10 Co-authored-by: anas-ibrahem Co-authored-by: Loay Ahmed Co-authored-by: Ahmed Sobhy <48056730+AhmedSobhy01@users.noreply.github.com> Co-authored-by: anas-ibrahem <139391509+anas-ibrahem@users.noreply.github.com> Co-authored-by: Mostafa Ahmed Abdelglel <139139781+galelo04@users.noreply.github.com> --- api-spec/complete-spec/main.tsp | 14 +- api-spec/implemented-spec/main.tsp | 15 +- .../Trending/entertainment top keywords.bru | 20 + bruno/collections/Trending/folder.bru | 8 + .../get overall trending keywords.bru | 20 + bruno/collections/Trending/update-scores.bru | 16 + bruno/collections/explore/explore-foryou.bru | 15 + bruno/collections/explore/folder.bru | 8 + .../migration.sql | 49 ++ .../migration.sql | 10 + .../migration.sql | 2 + prisma/schema.prisma | 33 +- prisma/seed-for-you-test.ts | 555 ++++++++---------- prisma/seed.ts | 8 +- src/app.module.ts | 2 + src/explore/explore.controller.ts | 40 ++ src/explore/explore.module.ts | 13 + src/explore/explore.repository.ts | 283 +++++++++ src/explore/explore.service.ts | 103 ++++ src/trending/constants/trending.constants.ts | 4 + src/trending/dtos/index.ts | 1 + src/trending/dtos/update-trend-scores.dto.ts | 41 ++ src/trending/trending.controller.spec.ts | 12 + src/trending/trending.controller.ts | 13 +- src/trending/trending.repository.ts | 100 +++- src/trending/trending.service.spec.ts | 17 +- src/trending/trending.service.ts | 211 ++++++- .../interfaces/classification.interface.ts | 22 +- src/tweet-analyze/tweet-analyze.module.ts | 3 +- src/tweet-analyze/tweet-analyze.service.ts | 203 ++++--- src/tweets/timeline/timeline.service.ts | 4 +- src/tweets/tweets.repository.ts | 3 +- 32 files changed, 1423 insertions(+), 425 deletions(-) create mode 100644 bruno/collections/Trending/entertainment top keywords.bru create mode 100644 bruno/collections/Trending/folder.bru create mode 100644 bruno/collections/Trending/get overall trending keywords.bru create mode 100644 bruno/collections/Trending/update-scores.bru create mode 100644 bruno/collections/explore/explore-foryou.bru create mode 100644 bruno/collections/explore/folder.bru create mode 100644 prisma/migrations/20251206231838_add_trending_keyword_scores/migration.sql create mode 100644 prisma/migrations/20251206233838_drop_trending_keyword_category/migration.sql create mode 100644 prisma/migrations/20251210001004_add_class_createdat_index_to_tweets/migration.sql create mode 100644 src/explore/explore.controller.ts create mode 100644 src/explore/explore.module.ts create mode 100644 src/explore/explore.repository.ts create mode 100644 src/explore/explore.service.ts create mode 100644 src/trending/constants/trending.constants.ts create mode 100644 src/trending/dtos/index.ts create mode 100644 src/trending/dtos/update-trend-scores.dto.ts diff --git a/api-spec/complete-spec/main.tsp b/api-spec/complete-spec/main.tsp index bbbd3fe6..cc30b432 100644 --- a/api-spec/complete-spec/main.tsp +++ b/api-spec/complete-spec/main.tsp @@ -716,13 +716,17 @@ model Tweet { @doc("The full object of the tweet being quoted, for easy display.") quotedTweet: CompactTweet | null; // Hydrated object for a quoted tweet. -} -model ProfileTweet extends Tweet { - @doc("Indicates if this tweet is a repost (retweet) by the profile owner.") - isRepost: boolean; + repostedBy: { + @doc("Username of the user who reposted this tweet") + username: string; + + @doc("Display name of the user who reposted this tweet") + displayName: string; + } } + model CreatedTweet { id: string; content?: string; @@ -1412,7 +1416,7 @@ namespace Users { @get @route("/{username}/tweets") op getUserTweets(@path username: string, ...CursorPaginationParameters): - | DataResponseWithPagination + | DataResponseWithPagination | UnauthorizedResponse | NotFoundResponse | InternalServerErrorResponse; diff --git a/api-spec/implemented-spec/main.tsp b/api-spec/implemented-spec/main.tsp index 9e28256b..146c005e 100644 --- a/api-spec/implemented-spec/main.tsp +++ b/api-spec/implemented-spec/main.tsp @@ -892,13 +892,18 @@ model Tweet { @doc("The full object of the tweet being quoted, for easy display.") quotedTweet: CompactTweet | null; // Hydrated object for a quoted tweet. -} -model ProfileTweet extends Tweet { - @doc("Indicates if this tweet is a repost (retweet) by the profile owner.") - isRepost: boolean; + repostedBy: { + @doc("The username of the user who reposted this tweet.") + username: string; + + @doc("The display name of the user who reposted this tweet.") + displayName: string; + + } } + model TweetWithoutQuotedTweet { ...CompactTweet; @@ -1694,7 +1699,7 @@ namespace Users { @get @route("/{username}/tweets") op getUserTweets(@path username: string, ...CursorPaginationParameters): - | DataResponseWithPagination + | DataResponseWithPagination | UnauthorizedResponse | NotFoundResponse | InternalServerErrorResponse; diff --git a/bruno/collections/Trending/entertainment top keywords.bru b/bruno/collections/Trending/entertainment top keywords.bru new file mode 100644 index 00000000..118d3ceb --- /dev/null +++ b/bruno/collections/Trending/entertainment top keywords.bru @@ -0,0 +1,20 @@ +meta { + name: entertainment top keywords + type: http + seq: 3 +} + +get { + url: http://localhost:3000/explore/sports + body: none + auth: bearer +} + +auth:bearer { + token: {{access_token}} +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/collections/Trending/folder.bru b/bruno/collections/Trending/folder.bru new file mode 100644 index 00000000..844afd32 --- /dev/null +++ b/bruno/collections/Trending/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Trending + seq: 15 +} + +auth { + mode: inherit +} diff --git a/bruno/collections/Trending/get overall trending keywords.bru b/bruno/collections/Trending/get overall trending keywords.bru new file mode 100644 index 00000000..5ce264c7 --- /dev/null +++ b/bruno/collections/Trending/get overall trending keywords.bru @@ -0,0 +1,20 @@ +meta { + name: get overall trending keywords + type: http + seq: 2 +} + +get { + url: http://localhost:3000/explore/trending + body: none + auth: bearer +} + +auth:bearer { + token: {{access_token}} +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/collections/Trending/update-scores.bru b/bruno/collections/Trending/update-scores.bru new file mode 100644 index 00000000..475d00c0 --- /dev/null +++ b/bruno/collections/Trending/update-scores.bru @@ -0,0 +1,16 @@ +meta { + name: update-scores + type: http + seq: 1 +} + +post { + url: http://localhost:3000/trending/update-scores + body: json + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/collections/explore/explore-foryou.bru b/bruno/collections/explore/explore-foryou.bru new file mode 100644 index 00000000..54fc5b2b --- /dev/null +++ b/bruno/collections/explore/explore-foryou.bru @@ -0,0 +1,15 @@ +meta { + name: explore-foryou + type: http + seq: 1 +} + +get { + url: http://localhost:3000/explore/for-you + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/bruno/collections/explore/folder.bru b/bruno/collections/explore/folder.bru new file mode 100644 index 00000000..b3120f13 --- /dev/null +++ b/bruno/collections/explore/folder.bru @@ -0,0 +1,8 @@ +meta { + name: explore + seq: 17 +} + +auth { + mode: inherit +} diff --git a/prisma/migrations/20251206231838_add_trending_keyword_scores/migration.sql b/prisma/migrations/20251206231838_add_trending_keyword_scores/migration.sql new file mode 100644 index 00000000..ca806efd --- /dev/null +++ b/prisma/migrations/20251206231838_add_trending_keyword_scores/migration.sql @@ -0,0 +1,49 @@ +/* + Warnings: + + - You are about to drop the column `category` on the `trending_keywords` table. All the data in the column will be lost. + - You are about to drop the column `count` on the `trending_keywords` table. All the data in the column will be lost. + - You are about to drop the column `search_document` on the `tweets` table. All the data in the column will be lost. + +*/ +-- AlterEnum +ALTER TYPE "Categories" ADD VALUE 'GENERAL'; + +-- AlterTable +ALTER TABLE "trending_keywords" + ADD COLUMN "overall_score" DOUBLE PRECISION NOT NULL DEFAULT 0.0; + +-- CreateTable +CREATE TABLE "trending_keyword_categories" ( + "id" BIGSERIAL NOT NULL, + "trendingKeywordId" BIGINT NOT NULL, + "category" "Categories" NOT NULL, + "score" DOUBLE PRECISION NOT NULL DEFAULT 0.0, + "count" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "trending_keyword_categories_pkey" PRIMARY KEY ("id") +); + +-- Copy existing category data into the new table +INSERT INTO "trending_keyword_categories" + ("trendingKeywordId", "category", "score", "count") +SELECT + "id" AS "trendingKeywordId", + "category", + 0.0 AS "score", + "count" +FROM "trending_keywords" +WHERE "category" IS NOT NULL; + + +-- CreateIndex +CREATE INDEX "trending_keyword_categories_trendingKeywordId_idx" ON "trending_keyword_categories"("trendingKeywordId"); + +-- CreateIndex +CREATE UNIQUE INDEX "trending_keyword_categories_trendingKeywordId_category_key" ON "trending_keyword_categories"("trendingKeywordId", "category"); + +-- CreateIndex +CREATE INDEX "trending_keywords_last_updated_at_idx" ON "trending_keywords"("last_updated_at"); + +-- AddForeignKey +ALTER TABLE "trending_keyword_categories" ADD CONSTRAINT "trending_keyword_categories_trendingKeywordId_fkey" FOREIGN KEY ("trendingKeywordId") REFERENCES "trending_keywords"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251206233838_drop_trending_keyword_category/migration.sql b/prisma/migrations/20251206233838_drop_trending_keyword_category/migration.sql new file mode 100644 index 00000000..faa634de --- /dev/null +++ b/prisma/migrations/20251206233838_drop_trending_keyword_category/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `category` on the `trending_keywords` table. All the data in the column will be lost. + - You are about to drop the column `search_document` on the `tweets` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "trending_keywords" DROP COLUMN "category"; + diff --git a/prisma/migrations/20251210001004_add_class_createdat_index_to_tweets/migration.sql b/prisma/migrations/20251210001004_add_class_createdat_index_to_tweets/migration.sql new file mode 100644 index 00000000..df7bfde9 --- /dev/null +++ b/prisma/migrations/20251210001004_add_class_createdat_index_to_tweets/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "tweets_class_created_at_id_idx" ON "tweets"("class", "created_at" DESC, "id" DESC); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 59153d98..042e2e15 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -169,6 +169,7 @@ model Tweet { @@index([quotedTweetId], map: "tweets_quoted_tweet_id_idx") @@index([userId, createdAt], map: "tweets_user_id_created_at_idx") @@index([createdAt(sort: Desc), id(sort: Desc)], map: "tweets_cursor_idx") + @@index([class, createdAt(sort: Desc), id(sort: Desc)], map: "tweets_class_created_at_id_idx") @@map("tweets") } @@ -233,28 +234,43 @@ model Like { } model TrendingKeyword { - id BigInt @id @default(autoincrement()) - keyword String @db.VarChar(100) - category Categories? - lastUpdatedAt DateTime? @map("last_updated_at") - isHashtag Boolean @default(false) @map("is_hashtag") - count Int @default(1) - createdAt DateTime @default(now()) @map("created_at") + id BigInt @id @default(autoincrement()) + keyword String @db.VarChar(100) + categoryScores TrendingKeywordCategory[] @relation("keyword_scores") + lastUpdatedAt DateTime? @map("last_updated_at") + isHashtag Boolean @default(false) @map("is_hashtag") + count Int @default(1) @map("count") + overallScore Float @default(0.0) @map("overall_score") + createdAt DateTime @default(now()) @map("created_at") @@unique([keyword, isHashtag]) @@index([isHashtag, keyword, count(sort: Desc)], map: "trending_hashtags_search_idx") + @@index([lastUpdatedAt]) @@index([keyword, count(sort: Desc)], map: "trending_keywords_keyword_count_idx") @@index([keyword], map: "trending_keywords_keyword_idx") @@map("trending_keywords") } +model TrendingKeywordCategory { + id BigInt @id @default(autoincrement()) + trendingKeywordId BigInt + category Categories + score Float @default(0.0) + categoryOccurenceCount Int @default(0) @map("count") + + keyword TrendingKeyword @relation(fields: [trendingKeywordId], references: [id], onDelete: Cascade, name: "keyword_scores") + + @@unique([trendingKeywordId, category]) + @@index([trendingKeywordId]) + @@map("trending_keyword_categories") + } + model Hashtag { id BigInt @id @default(autoincrement()) keyword String @unique @db.VarChar(100) createdAt DateTime @default(now()) @map("created_at") tweetHashtags TweetHashtag[] - @@index([keyword]) @@map("hashtags") } @@ -427,6 +443,7 @@ enum Categories { NEWS SPORTS ENTERTAINMENT + GENERAL } enum NotificationDeliveryStatus { diff --git a/prisma/seed-for-you-test.ts b/prisma/seed-for-you-test.ts index 3843f913..13cb54eb 100644 --- a/prisma/seed-for-you-test.ts +++ b/prisma/seed-for-you-test.ts @@ -1,351 +1,262 @@ import { PrismaClient } from '@prisma/client'; -import { faker } from '@faker-js/faker'; -import * as bcrypt from 'bcrypt'; const prisma = new PrismaClient(); -// --- CONFIGURATION --- -const NUM_AUTHORS = 100; // Users who will be creating content -const NUM_TWEETS_PER_AUTHOR = 50; // Each author will create many tweets -const PERCENT_OF_AUTHORS_TO_FOLLOW = 0.3; // Our test user will follow 30% of all authors - -// Available tweet categories/interests -const TWEET_CATEGORIES = [ - 'technology', - 'sports', - 'politics', - 'entertainment', - 'science', - 'gaming', - 'music', - 'food', - 'travel', - 'fitness', - 'fashion', - 'art', - 'business', - 'education', - 'health', -]; - -// User interests (subset of categories) -const USER_INTERESTS = ['technology', 'gaming', 'science', 'music']; - async function main() { - // DO NOT USE IN PRODUCTION AS IT DELETES EVERYTHING, THIS IS FOR TESTING ONLY - await prisma.retweet.deleteMany(); - await prisma.like.deleteMany(); - await prisma.tweetHashtag.deleteMany(); - await prisma.tweetMention.deleteMany(); - await prisma.tweetMedia.deleteMany(); - - await prisma.follow.deleteMany(); - await prisma.mute.deleteMany(); - await prisma.block.deleteMany(); - - await prisma.refreshToken.deleteMany(); - await prisma.userDevice.deleteMany(); - - await prisma.userExternalAccount.deleteMany(); - - await prisma.message.deleteMany(); - await prisma.conversationParticipant.deleteMany(); - await prisma.conversation.deleteMany(); - - await prisma.notification.deleteMany(); - await prisma.media.deleteMany(); - - await prisma.profile.deleteMany(); - await prisma.session.deleteMany(); - await prisma.tweet.deleteMany(); - await prisma.user.deleteMany(); - - // 2. Create the single user we will use for testing For You feed - const passwordHash = await bcrypt.hash('test1234', 10); - const testUser = await prisma.user.create({ - data: { - username: 'testuser', - email: 'testuser@test.com', - passwordHash, - birthdate: new Date('1990-01-01'), - interests: USER_INTERESTS, // User's interests for For You feed - profile: { create: { displayName: 'Test User For You' } }, + const testUser = await prisma.user.upsert({ + where: { email: 'fy@test.com' }, + update: {}, + create: { + username: 'fytester', + email: 'fy@test.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('2000-01-01'), + interests: ['SPORTS', 'TECH', 'ENTERTAINMENT'], + profile: { create: { displayName: 'FY Tester' } }, }, }); - // Create some additional named users - const omar = await prisma.user.create({ - data: { - username: 'omar', - email: 'omar@test.com', - passwordHash, - birthdate: new Date('1990-01-01'), - interests: ['technology', 'gaming'], - profile: { create: { displayName: 'Omar' } }, + const u1 = await prisma.user.upsert({ + where: { email: 'u1@test.com' }, + update: {}, + create: { + username: 'sportsu', + email: 'u1@test.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('1995-05-15'), + interests: ['SPORTS'], + profile: { create: { displayName: 'Sports' } }, }, }); - const loay = await prisma.user.create({ - data: { - username: 'loay', - email: 'loay@test.com', - passwordHash, - birthdate: new Date('1990-01-01'), - interests: ['sports', 'fitness'], - profile: { create: { displayName: 'Loay' } }, + const u2 = await prisma.user.upsert({ + where: { email: 'u2@test.com' }, + update: {}, + create: { + username: 'techu', + email: 'u2@test.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('1990-03-20'), + interests: ['TECH'], + profile: { create: { displayName: 'Tech' } }, }, }); - const tasneem = await prisma.user.create({ - data: { - username: 'tasneem', - email: 'tasneem@test.com', - passwordHash, - birthdate: new Date('1990-01-01'), - interests: ['art', 'music', 'fashion'], - profile: { create: { displayName: 'Tasneem' } }, + const u3 = await prisma.user.upsert({ + where: { email: 'u3@test.com' }, + update: {}, + create: { + username: 'entu', + email: 'u3@test.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('1998-07-10'), + interests: ['ENTERTAINMENT'], + profile: { create: { displayName: 'Ent' } }, }, }); - // 3. Generate a large number of "author" users - const authorsToCreate = []; - for (let i = 0; i < NUM_AUTHORS; i++) { - // Each author has 1-3 random interests - const numInterests = faker.number.int({ min: 1, max: 3 }); - const authorInterests = faker.helpers.arrayElements(TWEET_CATEGORIES, numInterests); - - authorsToCreate.push({ - username: - faker.internet - .username() - .toLowerCase() - .replace(/[^a-z0-9_]/g, '') + `_${i}`, - email: `author_${i}_${faker.string.alphanumeric(5)}@test.com`.toLowerCase(), - birthdate: faker.date.birthdate({ min: 18, max: 65, mode: 'age' }), - passwordHash, - interests: authorInterests, - }); - } - await prisma.user.createMany({ data: authorsToCreate, skipDuplicates: true }); - - const allAuthors = await prisma.user.findMany({ - where: { id: { notIn: [testUser.id, omar.id, loay.id, tasneem.id] } }, + const u4 = await prisma.user.upsert({ + where: { email: 'u4@test.com' }, + update: {}, + create: { + username: 'foodu', + email: 'u4@test.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('1992-11-25'), + interests: ['FOOD'], + profile: { create: { displayName: 'Food' } }, + }, }); - // Create profiles for all authors - await prisma.profile.createMany({ - data: allAuthors.map((author) => ({ - userId: author.id, - displayName: faker.person.fullName(), - })), + const u5 = await prisma.user.upsert({ + where: { email: 'u5@test.com' }, + update: {}, + create: { + username: 'genu', + email: 'u5@test.com', + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', + birthdate: new Date('1988-02-14'), + interests: ['GENERAL'], + profile: { create: { displayName: 'Gen' } }, + }, }); - // 4. Make the test user follow a portion of authors - const numAuthorsToFollow = Math.floor(NUM_AUTHORS * PERCENT_OF_AUTHORS_TO_FOLLOW); - - // Shuffle authors to get a random subset to follow - const shuffledAuthors = faker.helpers.shuffle(allAuthors); - const authorsToFollow = shuffledAuthors.slice(0, numAuthorsToFollow); - - const followsToCreate = authorsToFollow.map((author) => ({ - followerId: testUser.id, - followedId: author.id, - })); - - await prisma.follow.createMany({ data: followsToCreate }); - - // Update follower/following counts - await prisma.user.update({ - where: { id: testUser.id }, - data: { followingCount: numAuthorsToFollow }, + await prisma.follow.upsert({ + where: { followerId_followedId: { followerId: testUser.id, followedId: u1.id } }, + update: {}, + create: { followerId: testUser.id, followedId: u1.id }, }); - for (const author of authorsToFollow) { - await prisma.user.update({ - where: { id: author.id }, - data: { followersCount: { increment: 1 } }, - }); - } - - // 5. Create tweets with categories (class field) - const tweetsToCreate = []; - const followedAuthorIds = new Set(authorsToFollow.map((a) => a.id)); - - for (const author of allAuthors) { - const isFollowed = followedAuthorIds.has(author.id); - - for (let i = 0; i < NUM_TWEETS_PER_AUTHOR; i++) { - // Pick a single category for the tweet (class is a String, not array) - const category = faker.helpers.arrayElement(TWEET_CATEGORIES); - - // Generate content related to the category - const content = generateCategoryContent(category); - - // Tweets from followed users are more recent (last 7 days) - // Tweets from non-followed users span last 14 days (for interest-based discovery) - const maxDays = isFollowed ? 7 : 14; - - tweetsToCreate.push({ - userId: author.id, - content, - class: category, // Single category string for interest matching - createdAt: faker.date.recent({ days: maxDays }), - }); - } - } - - // Batch insert tweets for performance - const chunkSize = 5000; - for (let i = 0; i < tweetsToCreate.length; i += chunkSize) { - const chunk = tweetsToCreate.slice(i, i + chunkSize); - await prisma.tweet.createMany({ data: chunk }); - } - - // 6. Add some engagement (likes/retweets) to make ranking interesting - - const allTweets = await prisma.tweet.findMany({ - select: { id: true }, - take: 1000, // Only add engagement to some tweets - orderBy: { createdAt: 'desc' }, + await prisma.follow.upsert({ + where: { followerId_followedId: { followerId: testUser.id, followedId: u2.id } }, + update: {}, + create: { followerId: testUser.id, followedId: u2.id }, }); - // Add random likes - const likesToCreate = []; - for (const tweet of allTweets) { - const numLikes = faker.number.int({ min: 0, max: 50 }); - const likers = faker.helpers.arrayElements(allAuthors, Math.min(numLikes, allAuthors.length)); - - for (const liker of likers) { - likesToCreate.push({ - userId: liker.id, - tweetId: tweet.id, - }); - } - } - - // Insert likes in chunks - for (let i = 0; i < likesToCreate.length; i += chunkSize) { - const chunk = likesToCreate.slice(i, i + chunkSize); - await prisma.like.createMany({ data: chunk, skipDuplicates: true }); + const tweetData = [ + { + userId: u1.id, + content: 'NBA game last night!', + class: 'SPORTS', + likeCount: 150, + retweetCount: 45, + }, + { + userId: u1.id, + content: 'World Cup predictions?', + class: 'SPORTS', + likeCount: 200, + retweetCount: 80, + }, + { + userId: u1.id, + content: 'NFL playoffs heating up', + class: 'SPORTS', + likeCount: 75, + retweetCount: 20, + }, + { + userId: u1.id, + content: 'Tennis Grand Slam soon', + class: 'SPORTS', + likeCount: 50, + retweetCount: 10, + }, + { + userId: u1.id, + content: 'Swimming records broken', + class: 'SPORTS', + likeCount: 120, + retweetCount: 35, + }, + { + userId: u2.id, + content: 'New AI model is amazing', + class: 'TECH', + likeCount: 300, + retweetCount: 100, + }, + { + userId: u2.id, + content: 'MacBook Pro review', + class: 'TECH', + likeCount: 180, + retweetCount: 55, + }, + { + userId: u2.id, + content: 'Cybersecurity tips 2024', + class: 'TECH', + likeCount: 90, + retweetCount: 40, + }, + { + userId: u2.id, + content: 'Foldables or AR glasses?', + class: 'TECH', + likeCount: 65, + retweetCount: 15, + }, + { + userId: u2.id, + content: 'Building robot Arduino', + class: 'TECH', + likeCount: 45, + retweetCount: 8, + }, + { + userId: u3.id, + content: 'New Marvel movie WOW', + class: 'ENTERTAINMENT', + likeCount: 500, + retweetCount: 200, + }, + { + userId: u3.id, + content: 'Taylor Swift new album', + class: 'ENTERTAINMENT', + likeCount: 800, + retweetCount: 350, + }, + { + userId: u3.id, + content: 'Best TV shows to binge', + class: 'ENTERTAINMENT', + likeCount: 150, + retweetCount: 45, + }, + { + userId: u3.id, + content: 'Broadway is back', + class: 'ENTERTAINMENT', + likeCount: 70, + retweetCount: 20, + }, + { + userId: u3.id, + content: 'New video game release', + class: 'ENTERTAINMENT', + likeCount: 250, + retweetCount: 90, + }, + { + userId: u4.id, + content: 'Best pizza recipe', + class: 'FOOD', + likeCount: 100, + retweetCount: 30, + }, + { + userId: u4.id, + content: 'Sushi masterclass soon', + class: 'FOOD', + likeCount: 80, + retweetCount: 25, + }, + { + userId: u4.id, + content: 'Healthy meal prep ideas', + class: 'FOOD', + likeCount: 60, + retweetCount: 15, + }, + { + userId: u5.id, + content: 'Good morning everyone!', + class: 'GENERAL', + likeCount: 20, + retweetCount: 5, + }, + { + userId: u5.id, + content: 'Random thought today', + class: 'GENERAL', + likeCount: 35, + retweetCount: 10, + }, + { + userId: u5.id, + content: 'Finished my project!', + class: 'GENERAL', + likeCount: 15, + retweetCount: 3, + }, + ]; + + const now = new Date(); + for (let i = 0; i < tweetData.length; i++) { + const data = tweetData[i]; + await prisma.tweet.create({ + data: { + userId: data.userId, + content: data.content, + class: data.class, + likeCount: data.likeCount, + retweetCount: data.retweetCount, + createdAt: new Date(now.getTime() - i * 3600000), + }, + }); } - - // Update like counts - await prisma.$executeRaw` - UPDATE tweets - SET like_count = (SELECT COUNT(*) FROM likes WHERE likes.tweet_id = tweets.id) - `; -} - -function generateCategoryContent(category: string): string { - const templates: Record string> = { - technology: () => - faker.helpers.arrayElement([ - `Just discovered ${faker.company.buzzNoun()} - this is going to change everything! #tech`, - `Hot take: ${faker.company.buzzPhrase()} is overrated`, - `Finally upgraded to the new ${faker.commerce.product()}. Thoughts? 🤔`, - `The future of ${faker.company.buzzNoun()} is here and I'm here for it`, - ]), - sports: () => - faker.helpers.arrayElement([ - `What a game last night! ${faker.person.lastName()} was on fire 🔥`, - `Hot take: ${faker.person.lastName()} is the GOAT, no debate`, - `Training day! Getting ready for the weekend 💪`, - `That referee call was absolutely ridiculous`, - ]), - politics: () => - faker.helpers.arrayElement([ - `Important policy discussion happening right now`, - `We need to talk about ${faker.company.buzzNoun()} reform`, - `Democracy requires engaged citizens`, - `Local elections matter more than you think`, - ]), - entertainment: () => - faker.helpers.arrayElement([ - `Just watched ${faker.lorem.words(3)} - no spoilers but WOW`, - `This new show is absolutely binge-worthy`, - `Unpopular opinion: the sequel was better`, - `Celebrity news: ${faker.person.fullName()} spotted in ${faker.location.city()}`, - ]), - science: () => - faker.helpers.arrayElement([ - `Fascinating new research on ${faker.science.chemicalElement().name}`, - `The universe never stops amazing me 🌌`, - `Science fact of the day: ${faker.lorem.sentence()}`, - `New breakthrough in ${faker.science.chemicalElement().name} research!`, - ]), - gaming: () => - faker.helpers.arrayElement([ - `Finally beat that boss after 100 tries 🎮`, - `New game announcement has me hyped!`, - `Looking for squad members tonight, who's in?`, - `This indie game deserves more attention`, - ]), - music: () => - faker.helpers.arrayElement([ - `This new album is on repeat all day 🎵`, - `Unpopular opinion: ${faker.music.genre()} is underrated`, - `Concert last night was absolutely incredible`, - `New song dropped and I can't stop listening`, - ]), - food: () => - faker.helpers.arrayElement([ - `Made ${faker.food.dish()} for dinner tonight 🍽️`, - `This restaurant is seriously underrated`, - `Recipe thread coming soon!`, - `Food coma hitting hard right now`, - ]), - travel: () => - faker.helpers.arrayElement([ - `Exploring ${faker.location.city()} and loving every minute ✈️`, - `Travel tip: always pack light`, - `This view is absolutely breathtaking`, - `Bucket list destination: checked off!`, - ]), - fitness: () => - faker.helpers.arrayElement([ - `New PR today! Hard work pays off 💪`, - `Rest day but still staying active`, - `This workout routine changed my life`, - `Meal prep Sunday is the best Sunday`, - ]), - fashion: () => - faker.helpers.arrayElement([ - `Today's outfit is giving everything`, - `Fall fashion is my favorite season`, - `This brand just dropped something amazing`, - `Vintage finds are the best finds`, - ]), - art: () => - faker.helpers.arrayElement([ - `New piece finished! What do you think? 🎨`, - `Art museums are my happy place`, - `This artist deserves more recognition`, - `Creative block is real but we push through`, - ]), - business: () => - faker.helpers.arrayElement([ - `Market update: interesting moves today 📈`, - `Entrepreneurship lesson of the day`, - `Networking event was actually worth it`, - `This startup is one to watch`, - ]), - education: () => - faker.helpers.arrayElement([ - `Learning never stops 📚`, - `This online course changed my perspective`, - `Education should be accessible to everyone`, - `Study tip: ${faker.lorem.sentence()}`, - ]), - health: () => - faker.helpers.arrayElement([ - `Mental health reminder: take breaks`, - `Self-care isn't selfish`, - `This wellness tip actually works`, - `Hydration check! Drink water 💧`, - ]), - }; - - return templates[category]?.() || faker.lorem.sentence(); } if (process.env.SEED_ENV === 'true') { diff --git a/prisma/seed.ts b/prisma/seed.ts index db4df0f5..456c21d8 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -585,10 +585,10 @@ if (process.env.SEED_ENV === 'true') { }); const getHashtag = async (tag: string) => { - return prisma.trendingKeyword.upsert({ - where: { keyword_isHashtag: { keyword: tag, isHashtag: true } }, - update: { count: { increment: 1 } }, - create: { keyword: tag, isHashtag: true, count: 1 }, + return prisma.hashtag.upsert({ + where: { keyword: tag }, + update: {}, + create: { keyword: tag }, }); }; diff --git a/src/app.module.ts b/src/app.module.ts index 5e3bd085..fa78b5ce 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -38,6 +38,7 @@ import { SseController } from './sse/sse.controller'; import cors from 'cors'; import { EventsModule } from './events/events.module'; import { FirebaseModule } from './firebase/firebase.module'; +import { ExploreModule } from './explore/explore.module'; import { RequestThrottlerGuard } from './common/guards/request-throttler.guard'; @Module({ @@ -93,6 +94,7 @@ import { RequestThrottlerGuard } from './common/guards/request-throttler.guard'; NotificationsModule, EventsModule, FirebaseModule, + ExploreModule, ], controllers: [HealthController], providers: [ diff --git a/src/explore/explore.controller.ts b/src/explore/explore.controller.ts new file mode 100644 index 00000000..eba68cdf --- /dev/null +++ b/src/explore/explore.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from 'src/auth/guards'; +import { ExploreService } from './explore.service'; +import { User } from 'src/auth/decorators'; +import type { RequestUser } from 'src/common/interfaces'; + +@Controller('explore') +export class ExploreController { + constructor(private readonly exploreService: ExploreService) {} + + @Get('for-you') + @UseGuards(JwtAuthGuard) + async getForYouCategories(@User() user: RequestUser) { + return await this.exploreService.getForYouCategories(BigInt(user.id)); + } + + @Get('trending') + @UseGuards(JwtAuthGuard) + async getTrendingTabKeywords() { + return this.exploreService.getTrendingTabKeywords(); + } + + @Get('entertainment') + @UseGuards(JwtAuthGuard) + async getEntertainmentKeywords() { + return this.exploreService.getEntertainmentKeywords(); + } + + @Get('news') + @UseGuards(JwtAuthGuard) + async getNewsKeywords() { + return this.exploreService.getNewsKeywords(); + } + + @Get('sports') + @UseGuards(JwtAuthGuard) + async getSportsKeywords() { + return this.exploreService.getSportsKeywords(); + } +} diff --git a/src/explore/explore.module.ts b/src/explore/explore.module.ts new file mode 100644 index 00000000..c1b90939 --- /dev/null +++ b/src/explore/explore.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ExploreController } from './explore.controller'; +import { ExploreService } from './explore.service'; +import { ExploreRepository } from './explore.repository'; +import { PrismaModule } from 'src/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [ExploreController], + providers: [ExploreService, ExploreRepository], + exports: [ExploreService], +}) +export class ExploreModule {} diff --git a/src/explore/explore.repository.ts b/src/explore/explore.repository.ts new file mode 100644 index 00000000..3d70464b --- /dev/null +++ b/src/explore/explore.repository.ts @@ -0,0 +1,283 @@ +import { Injectable } from '@nestjs/common'; +import { Categories, Prisma } from '@prisma/client'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { TweetDto } from 'src/tweets/dtos'; + +const tweetInclude = (currentUserId: bigint) => + ({ + user: { + select: { + username: true, + id: true, + profile: { + select: { + displayName: true, + avatarUrl: true, + }, + }, + }, + }, + _count: { + select: { + likes: { + where: { userId: currentUserId }, + }, + retweets: { + where: { userId: currentUserId }, + }, + }, + }, + tweetMentions: { + select: { + startPosition: true, + user: { + select: { + username: true, + }, + }, + }, + }, + tweetHashtags: { + select: { + startPosition: true, + hashtag: { + select: { + keyword: true, + }, + }, + }, + }, + tweetMedia: { + select: { + order: true, + media: { + select: { + url: true, + type: true, + altText: true, + width: true, + height: true, + }, + }, + }, + orderBy: { order: 'asc' as const }, + }, + }) satisfies Prisma.TweetInclude; + +type BaseTweetWithIncludes = Prisma.TweetGetPayload<{ + include: ReturnType; +}>; + +type TweetWithIncludes = BaseTweetWithIncludes & { + quotedTweet?: (BaseTweetWithIncludes & { quotedTweet?: null }) | null; +}; + +@Injectable() +export class ExploreRepository { + constructor(private readonly prisma: PrismaService) {} + + async getUserInterests(userId: bigint): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { interests: true }, + }); + return user?.interests ?? []; + } + + /** + * Fetches top N tweets per category for the given interests using LATERAL join + * More efficient than ROW_NUMBER - stops scanning after finding N tweets per category + */ + async getTweetsByCategories( + currentUserId: bigint, + categories: string[], + limitPerCategory: number = 5, + ): Promise> { + if (categories.length === 0) { + return new Map(); + } + + // LATERAL join - efficiently gets top N per category without scanning entire dataset + const rankedTweetIds = await this.prisma.$queryRaw<{ id: bigint; class: string }[]>` + SELECT t.id, t.class + FROM UNNEST(${categories}::text[]) AS category(name) + CROSS JOIN LATERAL ( + SELECT t.id, t.class + FROM tweets t + INNER JOIN users u ON t.user_id = u.id + WHERE t.class = category.name + AND t.is_deleted = false + AND t.reply_to_tweet_id IS NULL + AND u.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM blocks b + WHERE (b.user_id = ${currentUserId} AND b.blocked_id = u.id) + OR (b.user_id = u.id AND b.blocked_id = ${currentUserId}) + ) + AND NOT EXISTS ( + SELECT 1 FROM mutes m + WHERE m.user_id = ${currentUserId} AND m.muted_id = u.id + ) + ORDER BY t.created_at DESC + LIMIT ${limitPerCategory} + ) t + `; + + if (rankedTweetIds.length === 0) { + return new Map(); + } + + const tweetIds = rankedTweetIds.map((t) => t.id); + const tweets = await this.prisma.tweet.findMany({ + where: { id: { in: tweetIds } }, + include: { + ...tweetInclude(currentUserId), + quotedTweet: { + include: tweetInclude(currentUserId), + }, + }, + }); + + const categoryMap = new Map(); + const tweetMap = new Map(tweets.map((t) => [t.id.toString(), t])); + + for (const category of categories) { + categoryMap.set(category.toLowerCase(), []); + } + + for (const { id, class: category } of rankedTweetIds) { + const tweet = tweetMap.get(id.toString()); + if (tweet) { + const categoryKey = category.toLowerCase(); + const categoryTweets = categoryMap.get(categoryKey) ?? []; + categoryTweets.push(this.mapToTweetDto(tweet as TweetWithIncludes)); + categoryMap.set(categoryKey, categoryTweets); + } + } + + return categoryMap; + } + + private mapToTweetDto(tweet: TweetWithIncludes): TweetDto { + return { + id: tweet.id.toString(), + author: { + username: tweet.user.username, + displayName: tweet.user.profile?.displayName ?? '', + avatarUrl: tweet.user.profile?.avatarUrl, + }, + rootTweetId: null, + content: tweet.content ?? '', + createdAt: tweet.createdAt, + replyCount: tweet.replyCount, + retweetCount: tweet.retweetCount, + likeCount: tweet.likeCount, + isLiked: tweet._count.likes > 0, + isRetweeted: tweet._count.retweets > 0, + entities: { + mentions: tweet.tweetMentions.map((mention) => ({ + username: mention.user.username, + startPosition: mention.startPosition, + })), + hashtags: tweet.tweetHashtags.map((hashtag) => ({ + hashtag: hashtag.hashtag.keyword, + startPosition: hashtag.startPosition, + })), + }, + media: tweet.tweetMedia?.map((media) => ({ + url: media.media.url, + type: media.media.type, + altText: media.media.altText, + width: media.media.width ?? 0, + height: media.media.height ?? 0, + })), + replyToTweetId: tweet.replyToTweetId?.toString() ?? null, + quoteToTweetId: tweet.quotedTweetId?.toString() ?? null, + quotedTweet: tweet.quotedTweet ? this.mapToTweetDto(tweet.quotedTweet) : undefined, + }; + } + + async getTrendingKeywords() { + const keywords = await this.prisma.trendingKeyword.findMany({ + orderBy: { overallScore: 'desc' }, + take: 30, + include: { + categoryScores: { + orderBy: { score: 'desc' }, + take: 1, + }, + }, + }); + + return keywords.map((keyword) => ({ + ...keyword, + topCategory: keyword.categoryScores[0], + })); + } + + async getEntertainmentKeywords() { + const keywords = await this.prisma.trendingKeywordCategory.findMany({ + where: { category: Categories.ENTERTAINMENT }, + orderBy: { score: 'desc' }, + take: 30, + include: { + keyword: { + select: { + keyword: true, + isHashtag: true, + }, + }, + }, + }); + + return keywords.map((k) => ({ + ...k, + keyword: k.keyword.keyword, + isHashtag: k.keyword.isHashtag, + })); + } + + async getNewsKeywords() { + const keywords = await this.prisma.trendingKeywordCategory.findMany({ + where: { category: Categories.NEWS }, + orderBy: { score: 'desc' }, + take: 30, + include: { + keyword: { + select: { + keyword: true, + isHashtag: true, + }, + }, + }, + }); + + return keywords.map((k) => ({ + ...k, + keyword: k.keyword.keyword, + isHashtag: k.keyword.isHashtag, + })); + } + + async getSportsKeywords() { + const keywords = await this.prisma.trendingKeywordCategory.findMany({ + where: { category: Categories.SPORTS }, + orderBy: { score: 'desc' }, + take: 30, + include: { + keyword: { + select: { + keyword: true, + isHashtag: true, + }, + }, + }, + }); + + return keywords.map((k) => ({ + ...k, + keyword: k.keyword.keyword, + isHashtag: k.keyword.isHashtag, + })); + } +} diff --git a/src/explore/explore.service.ts b/src/explore/explore.service.ts new file mode 100644 index 00000000..da4df819 --- /dev/null +++ b/src/explore/explore.service.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@nestjs/common'; +import { ExploreRepository } from './explore.repository'; +import { TweetDto } from 'src/tweets/dtos'; + +export interface ForYouCategory { + category: string; + tweets: TweetDto[]; +} + +@Injectable() +export class ExploreService { + constructor(private readonly exploreRepository: ExploreRepository) {} + + async getForYouCategories(userId: bigint): Promise { + const userInterests = await this.exploreRepository.getUserInterests(userId); + + if (!userInterests || userInterests.length === 0) { + return []; + } + + // Fetch all tweets for all categories in a single query + const categoryTweetsMap = await this.exploreRepository.getTweetsByCategories( + userId, + userInterests, + 5, + ); + + // Convert map to array, filtering out empty categories + const categories: ForYouCategory[] = []; + for (const interest of userInterests) { + const tweets = categoryTweetsMap.get(interest.toLowerCase()) ?? []; + if (tweets.length > 0) { + categories.push({ + category: interest.toLowerCase(), + tweets, + }); + } + } + + return categories; + } + + private mapCategory(category: string): string { + switch (category) { + case 'ENTERTAINMENT': + return 'entertainment'; + case 'SPORTS': + return 'sports'; + case 'NEWS': + return 'news'; + default: + return 'general'; + } + } + + async getTrendingTabKeywords(): Promise< + { hashtag: string; tweetsCount: number; category: string }[] + > { + const trendingKeywords = await this.exploreRepository.getTrendingKeywords(); + const filteredKeywords = trendingKeywords.map((keyword) => ({ + hashtag: keyword.isHashtag ? '#' + keyword.keyword : keyword.keyword, + tweetsCount: keyword.count, + category: this.mapCategory(keyword.topCategory?.category), + })); + + return filteredKeywords; + } + + async getEntertainmentKeywords(): Promise< + { hashtag: string; tweetsCount: number; category: string }[] + > { + const trendingKeywords = await this.exploreRepository.getEntertainmentKeywords(); + const filteredKeywords = trendingKeywords.map((keyword) => ({ + hashtag: keyword.isHashtag ? '#' + keyword.keyword : keyword.keyword, + tweetsCount: keyword.categoryOccurenceCount, + category: this.mapCategory(keyword.category), + })); + + return filteredKeywords; + } + + async getNewsKeywords(): Promise<{ hashtag: string; tweetsCount: number; category: string }[]> { + const trendingKeywords = await this.exploreRepository.getNewsKeywords(); + const filteredKeywords = trendingKeywords.map((keyword) => ({ + hashtag: keyword.isHashtag ? '#' + keyword.keyword : keyword.keyword, + tweetsCount: keyword.categoryOccurenceCount, + category: this.mapCategory(keyword.category), + })); + + return filteredKeywords; + } + + async getSportsKeywords(): Promise<{ hashtag: string; tweetsCount: number; category: string }[]> { + const trendingKeywords = await this.exploreRepository.getSportsKeywords(); + const filteredKeywords = trendingKeywords.map((keyword) => ({ + hashtag: keyword.isHashtag ? '#' + keyword.keyword : keyword.keyword, + tweetsCount: keyword.categoryOccurenceCount, + category: this.mapCategory(keyword.category), + })); + + return filteredKeywords; + } +} diff --git a/src/trending/constants/trending.constants.ts b/src/trending/constants/trending.constants.ts new file mode 100644 index 00000000..de09743b --- /dev/null +++ b/src/trending/constants/trending.constants.ts @@ -0,0 +1,4 @@ +export const SCALE_DOWN_FACTOR = 0.9; +export const RETENTION_HOURS = 24; +export const IGNORING_THRESHOLD = 0.05; +export const BATCH_SIZE = 200; diff --git a/src/trending/dtos/index.ts b/src/trending/dtos/index.ts new file mode 100644 index 00000000..69722509 --- /dev/null +++ b/src/trending/dtos/index.ts @@ -0,0 +1 @@ +export * from './update-trend-scores.dto'; diff --git a/src/trending/dtos/update-trend-scores.dto.ts b/src/trending/dtos/update-trend-scores.dto.ts new file mode 100644 index 00000000..0b5bc143 --- /dev/null +++ b/src/trending/dtos/update-trend-scores.dto.ts @@ -0,0 +1,41 @@ +import { IsArray, IsNumber, IsString, ValidateNested, IsNotEmpty } from 'class-validator'; +import { Type } from 'class-transformer'; + +class ModelTopicDto { + @IsString() + @IsNotEmpty() + topic: string; + + @IsNumber() + trend_score: number; + + @IsNumber() + occurence_in_category: number; +} + +class ModelItemDto { + @IsString() + @IsNotEmpty() + keyword: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ModelTopicDto) + top_related_topics: ModelTopicDto[]; +} + +class BatchMetaDto { + @IsNumber() + total_tweets: number; +} + +export class UpdateTrendScoresDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ModelItemDto) + trending_keywords: ModelItemDto[]; + + @ValidateNested() + @Type(() => BatchMetaDto) + batch_meta: BatchMetaDto; +} diff --git a/src/trending/trending.controller.spec.ts b/src/trending/trending.controller.spec.ts index aa1111e9..d77bc640 100644 --- a/src/trending/trending.controller.spec.ts +++ b/src/trending/trending.controller.spec.ts @@ -1,12 +1,24 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TrendingController } from './trending.controller'; +import { TrendingService } from './trending.service'; describe('TrendingController', () => { let controller: TrendingController; + const mockTrendingService = { + updateTrendScores: jest.fn(), + createOrIncrementHashtags: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [TrendingController], + providers: [ + { + provide: TrendingService, + useValue: mockTrendingService, + }, + ], }).compile(); controller = module.get(TrendingController); diff --git a/src/trending/trending.controller.ts b/src/trending/trending.controller.ts index 3a122c85..050fe7ec 100644 --- a/src/trending/trending.controller.ts +++ b/src/trending/trending.controller.ts @@ -1,4 +1,13 @@ -import { Controller } from '@nestjs/common'; +import { Body, Controller, Post } from '@nestjs/common'; +import { TrendingService } from './trending.service'; +import { UpdateTrendScoresDto } from './dtos'; @Controller('trending') -export class TrendingController {} +export class TrendingController { + constructor(private readonly trendingService: TrendingService) {} + // For testing purposes only + @Post('update-scores') + async updateTrendScores(@Body() updateTrendScoresDto: UpdateTrendScoresDto) { + return this.trendingService.updateTrendScores(updateTrendScoresDto); + } +} diff --git a/src/trending/trending.repository.ts b/src/trending/trending.repository.ts index 702f2687..894a95ef 100644 --- a/src/trending/trending.repository.ts +++ b/src/trending/trending.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { Prisma, Categories } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; import { PlainHashtag } from 'src/tweets/interfaces'; @@ -59,6 +59,51 @@ export class TrendingRepository { }); } + async scaleDownAllScores(factor: number): Promise { + await this.prisma.$transaction([ + this.prisma.trendingKeyword.updateMany({ + data: { overallScore: { multiply: factor } }, + }), + this.prisma.trendingKeywordCategory.updateMany({ + data: { score: { multiply: factor } }, + }), + ]); + } + + async findKeywordByKeywordAndType( + keyword: string, + isHashtag: boolean, + tx: Prisma.TransactionClient = this.prisma, + ) { + return tx.trendingKeyword.findUnique({ + where: { keyword_isHashtag: { keyword, isHashtag } }, + include: { categoryScores: true }, + }); + } + + async createKeywordWithCategories( + data: { + keyword: string; + isHashtag: boolean; + overallScore: number; + count: number; + lastUpdatedAt: Date; + categoryScores: Prisma.TrendingKeywordCategoryCreateWithoutKeywordInput[]; + }, + tx: Prisma.TransactionClient = this.prisma, + ) { + return tx.trendingKeyword.create({ + data: { + keyword: data.keyword, + isHashtag: data.isHashtag, + overallScore: data.overallScore, + count: data.count, + lastUpdatedAt: data.lastUpdatedAt, + categoryScores: { create: data.categoryScores }, + }, + }); + } + async getTopWords( query: string, limit: number, @@ -66,7 +111,6 @@ export class TrendingRepository { ): Promise<{ keyword: string; isHashtag: boolean }[]> { // Escape sql wildcards % and _ query = query.replace(/[%_]/g, '\\$&'); - const results = await this.prisma.trendingKeyword.findMany({ where: { keyword: { @@ -84,4 +128,56 @@ export class TrendingRepository { return results; } + + async upsertKeywordCategory( + data: { + trendingKeywordId: bigint; + category: Categories; + score: number; + categoryOccurenceCount: number; + }, + tx: Prisma.TransactionClient = this.prisma, + ) { + return tx.trendingKeywordCategory.upsert({ + where: { + trendingKeywordId_category: { + trendingKeywordId: data.trendingKeywordId, + category: data.category, + }, + }, + update: { + score: data.score, + categoryOccurenceCount: data.categoryOccurenceCount, + }, + create: { + trendingKeywordId: data.trendingKeywordId, + category: data.category, + score: data.score, + categoryOccurenceCount: data.categoryOccurenceCount, + }, + }); + } + + async updateKeyword( + id: bigint, + data: { overallScore: number; count: number; lastUpdatedAt: Date }, + tx: Prisma.TransactionClient = this.prisma, + ) { + return tx.trendingKeyword.update({ + where: { id }, + data, + }); + } + + async deleteOldKeywords(cutoffDate: Date): Promise { + await this.prisma.trendingKeyword.deleteMany({ + where: { lastUpdatedAt: { lt: cutoffDate } }, + }); + } + + async deleteLowScoreCategories(threshold: number): Promise { + await this.prisma.trendingKeywordCategory.deleteMany({ + where: { score: { lt: threshold } }, + }); + } } diff --git a/src/trending/trending.service.spec.ts b/src/trending/trending.service.spec.ts index 780195b3..266c8974 100644 --- a/src/trending/trending.service.spec.ts +++ b/src/trending/trending.service.spec.ts @@ -1,14 +1,25 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TrendingService } from './trending.service'; import { TrendingRepository } from './trending.repository'; +import { PrismaService } from 'src/prisma/prisma.service'; describe('TrendingService', () => { let service: TrendingService; const mockTrendingRepository = { - getOrCreateHashtagIds: jest.fn(), + createOrIncrementHashtags: jest.fn(), + scaleDownAllScores: jest.fn(), + findKeywordByKeywordAndType: jest.fn(), + createKeywordWithCategories: jest.fn(), + upsertKeywordCategory: jest.fn(), + updateKeyword: jest.fn(), + deleteOldKeywords: jest.fn(), + deleteLowScoreCategories: jest.fn(), + runInTransaction: jest.fn(), }; + const mockPrismaService = {}; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -17,6 +28,10 @@ describe('TrendingService', () => { provide: TrendingRepository, useValue: mockTrendingRepository, }, + { + provide: PrismaService, + useValue: mockPrismaService, + }, ], }).compile(); diff --git a/src/trending/trending.service.ts b/src/trending/trending.service.ts index db883958..5aabab69 100644 --- a/src/trending/trending.service.ts +++ b/src/trending/trending.service.ts @@ -1,12 +1,29 @@ import { Injectable } from '@nestjs/common'; import { TrendingRepository } from './trending.repository'; -import { Prisma } from '@prisma/client'; +import { Categories, Prisma } from '@prisma/client'; import { PlainHashtag } from 'src/tweets/interfaces'; +import { UpdateTrendScoresDto } from './dtos'; +import { + BATCH_SIZE, + IGNORING_THRESHOLD, + RETENTION_HOURS, + SCALE_DOWN_FACTOR, +} from './constants/trending.constants'; +import { PrismaService } from 'src/prisma/prisma.service'; + +type ModelTopic = { topic: string; trend_score: number; occurence_in_category: number }; +type ModelItem = { + keyword: string; + top_related_topics: ModelTopic[]; +}; import { extractHashtag, isSingleHashtagQuery } from 'src/search/utils/search-query.util'; @Injectable() export class TrendingService { - constructor(private readonly TrendingRepository: TrendingRepository) {} + constructor( + private readonly TrendingRepository: TrendingRepository, + private readonly prisma: PrismaService, + ) {} /** * @@ -49,4 +66,194 @@ export class TrendingService { const hashtags = results.map((word) => (word.isHashtag ? `#${word.keyword}` : word.keyword)); return hashtags; } + + async updateTrendScores(data: UpdateTrendScoresDto): Promise<{ message: string }> { + await this.applyModelResults(data); + return { message: 'Trend scores updated successfully' }; + } + + private async applyModelResults(data: UpdateTrendScoresDto, now = new Date()): Promise { + const items = data.trending_keywords; + + await this.TrendingRepository.scaleDownAllScores(SCALE_DOWN_FACTOR); + + for (let i = 0; i < items.length; i += BATCH_SIZE) { + const batch = items.slice(i, i + BATCH_SIZE); + + await this.prisma.$transaction(async (tx) => { + for (const item of batch) { + await this.processKeywordItem(item, now, tx); + } + }); + } + + const cutoff = new Date(now.getTime() - RETENTION_HOURS * 60 * 60 * 1000); + await this.TrendingRepository.deleteOldKeywords(cutoff); + await this.TrendingRepository.deleteLowScoreCategories(IGNORING_THRESHOLD); + } + + private async processKeywordItem( + item: ModelItem, + now: Date, + tx: Prisma.TransactionClient, + ): Promise { + const isHashtag = item.keyword.startsWith('#'); + const keyword = isHashtag ? item.keyword.slice(1).toLowerCase() : item.keyword.toLowerCase(); + + const incomingOcc = item.top_related_topics.reduce( + (sum, topic) => sum + topic.occurence_in_category, + 0, + ); + + const incomingScoreByCategory = new Map(); + const incomingOccByCategory = new Map(); + + for (const t of item.top_related_topics ?? []) { + const catKey = t.topic.toUpperCase() as Categories; + + incomingScoreByCategory.set( + catKey, + (incomingScoreByCategory.get(catKey) ?? 0) + t.trend_score, + ); + + incomingOccByCategory.set( + catKey, + (incomingOccByCategory.get(catKey) ?? 0) + t.occurence_in_category, + ); + } + + const existing = await this.TrendingRepository.findKeywordByKeywordAndType( + keyword, + isHashtag, + tx, + ); + + if (!existing) { + await this.createNewKeyword( + keyword, + isHashtag, + incomingScoreByCategory, + incomingOccByCategory, + incomingOcc, + now, + tx, + ); + return; + } + + await this.updateExistingKeyword( + existing, + incomingScoreByCategory, + incomingOccByCategory, + incomingOcc, + now, + tx, + ); + } + + private async createNewKeyword( + keyword: string, + isHashtag: boolean, + incomingScoreByCategory: Map, + incomingOccByCategory: Map, + incomingOcc: number, + now: Date, + tx: Prisma.TransactionClient, + ): Promise { + const categoryCreates: Prisma.TrendingKeywordCategoryCreateWithoutKeywordInput[] = Array.from( + incomingScoreByCategory.keys(), + ).map((category) => ({ + category, + score: incomingScoreByCategory.get(category) ?? 0, + categoryOccurenceCount: incomingOccByCategory.get(category) ?? 0, + })); + + const overall = categoryCreates.reduce((sum, cat) => sum + (cat.score ?? 0), 0); + + await this.TrendingRepository.createKeywordWithCategories( + { + keyword, + isHashtag, + overallScore: overall, + count: incomingOcc, + lastUpdatedAt: now, + categoryScores: categoryCreates, + }, + tx, + ); + } + + private async updateExistingKeyword( + existing: { + id: bigint; + count: number; + categoryScores: { category: Categories; score: number; categoryOccurenceCount: number }[]; + }, + incomingScoreByCategory: Map, + incomingOccByCategory: Map, + incomingOcc: number, + now: Date, + tx: Prisma.TransactionClient, + ): Promise { + const existingScoreByCategory = new Map(); + const existingOccByCategory = new Map(); + + for (const categoryScore of existing.categoryScores) { + existingScoreByCategory.set(categoryScore.category, categoryScore.score); + existingOccByCategory.set(categoryScore.category, categoryScore.categoryOccurenceCount); + } + + const allCategories = new Set([ + ...existingScoreByCategory.keys(), + ...incomingScoreByCategory.keys(), + ]); + + const updatedCategories: { + category: Categories; + score: number; + occCount: number; + }[] = []; + + for (const category of allCategories) { + const oldScore = existingScoreByCategory.get(category) ?? 0; + const oldOcc = existingOccByCategory.get(category) ?? 0; + + const incomingScore = incomingScoreByCategory.get(category) ?? 0; + const incomingOccCat = incomingOccByCategory.get(category) ?? 0; + + const newScore = oldScore + incomingScore; + const newOccCount = oldOcc + incomingOccCat; + + updatedCategories.push({ + category, + score: newScore, + occCount: newOccCount, + }); + } + + const newOverall = updatedCategories.reduce((sum, x) => sum + x.score, 0); + const newOccurrence = (existing.count || 0) + incomingOcc; + + for (const updatedCategory of updatedCategories) { + await this.TrendingRepository.upsertKeywordCategory( + { + trendingKeywordId: existing.id, + category: updatedCategory.category, + score: updatedCategory.score, + categoryOccurenceCount: updatedCategory.occCount, + }, + tx, + ); + } + + await this.TrendingRepository.updateKeyword( + existing.id, + { + overallScore: newOverall, + count: newOccurrence, + lastUpdatedAt: now, + }, + tx, + ); + } } diff --git a/src/tweet-analyze/interfaces/classification.interface.ts b/src/tweet-analyze/interfaces/classification.interface.ts index dd10fd20..77074701 100644 --- a/src/tweet-analyze/interfaces/classification.interface.ts +++ b/src/tweet-analyze/interfaces/classification.interface.ts @@ -3,7 +3,7 @@ export interface TweetToClassify { content: string; } -export interface ClassificationRequest { +export interface ModelApiRequest { tweets: TweetToClassify[]; } @@ -12,6 +12,24 @@ export interface ClassifiedTweet { class: string; } -export interface ClassificationResponse { +export interface ModelTopic { + topic: string; + trend_score: number; + occurence_in_category: number; +} + +export interface TrendingKeyword { + keyword: string; + general_trend_score: number; + top_related_topics: ModelTopic[]; +} + +export interface BatchMeta { + total_tweets: number; +} + +export interface ModelApiResponse { + batch_meta: BatchMeta; + trending_keywords: TrendingKeyword[]; tweets_detail: ClassifiedTweet[]; } diff --git a/src/tweet-analyze/tweet-analyze.module.ts b/src/tweet-analyze/tweet-analyze.module.ts index ae606cb5..dff93298 100644 --- a/src/tweet-analyze/tweet-analyze.module.ts +++ b/src/tweet-analyze/tweet-analyze.module.ts @@ -4,9 +4,10 @@ import { TweetAnalyzeService } from './tweet-analyze.service'; import { TweetAnalyzeRepository } from './tweet-analyze.repository'; import { PrismaModule } from 'src/prisma/prisma.module'; import { RedisModule } from 'src/redis/redis.module'; +import { TrendingModule } from 'src/trending/trending.module'; @Module({ - imports: [HttpModule, PrismaModule, RedisModule], + imports: [HttpModule, PrismaModule, RedisModule, TrendingModule], providers: [TweetAnalyzeService, TweetAnalyzeRepository], exports: [TweetAnalyzeService], }) diff --git a/src/tweet-analyze/tweet-analyze.service.ts b/src/tweet-analyze/tweet-analyze.service.ts index e38f8e1f..f37f02ef 100644 --- a/src/tweet-analyze/tweet-analyze.service.ts +++ b/src/tweet-analyze/tweet-analyze.service.ts @@ -2,17 +2,24 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { TweetAnalyzeRepository } from './tweet-analyze.repository'; -import { ClassificationRequest, ClassificationResponse, ClassifiedTweet } from './interfaces'; +import { + ModelApiRequest, + ModelApiResponse, + ClassifiedTweet, + TrendingKeyword, + BatchMeta, +} from './interfaces'; import { firstValueFrom } from 'rxjs'; import { RedisService } from 'src/redis/redis.service'; +import { TrendingService } from 'src/trending/trending.service'; @Injectable() export class TweetAnalyzeService implements OnModuleInit { private readonly logger = new Logger(TweetAnalyzeService.name); - private readonly classifyEnabled: boolean; + private readonly analyzeEnabled: boolean; private readonly intervalMinutes: number; private readonly requestLimit: number; - private readonly classificationApiUrl: string; + private readonly analyzeApiUrl: string; private readonly LOCK_KEY = 'tweet-analyze:lock'; private readonly LOCK_TTL_SECONDS = 300; // 5 minutes @@ -21,106 +28,137 @@ export class TweetAnalyzeService implements OnModuleInit { private readonly httpService: HttpService, private readonly repository: TweetAnalyzeRepository, private readonly redisService: RedisService, + private readonly trendingService: TrendingService, ) { - this.classifyEnabled = this.configService.get('CLASSIFY_TWEETS') === 'true'; + this.analyzeEnabled = this.configService.get('CLASSIFY_TWEETS') === 'true'; this.intervalMinutes = parseInt( this.configService.get('CLASSIFICATION_INTERVAL_MINUTES') || '5', ); this.requestLimit = parseInt(this.configService.get('CLASSIFY_REQ_LIMIT') || '50'); - this.classificationApiUrl = this.configService.get( - 'CLASSIFICATION_API_URL', - '/analyze', - ); + this.analyzeApiUrl = this.configService.get('CLASSIFICATION_API_URL', '/analyze'); this.logger.log( - `Tweet Analyze Service initialized - Enabled: ${this.classifyEnabled}, ` + - `Interval: ${this.intervalMinutes} minutes, Request Limit: ${this.requestLimit}`, + `Tweet Analysis Service initialized - Enabled: ${this.analyzeEnabled}, ` + + `Interval: ${this.intervalMinutes} min, Request Limit: ${this.requestLimit}/batch`, ); } onModuleInit() { - if (this.classifyEnabled) { + if (this.analyzeEnabled) { const intervalMs = this.intervalMinutes * 60 * 1000; - this.logger.log( - `Starting classification cron job with ${this.intervalMinutes} minute interval`, - ); + this.logger.log(`Starting tweet analysis cron job (interval: ${this.intervalMinutes} min)`); setInterval(() => { - this.classifyTweets().catch((error) => { + this.analyzeTweets().catch((error) => { this.logger.error( - 'Error occurred during scheduled tweet classification', + 'Scheduled tweet analysis failed', error instanceof Error ? error.stack : String(error), ); }); }, intervalMs); } else { - this.logger.log('Classification job is disabled'); + this.logger.log('Tweet analysis cron job is disabled'); } } - async classifyTweets() { - if (!this.classifyEnabled) { - this.logger.debug('Tweet classification is disabled, skipping...'); + async analyzeTweets() { + if (!this.analyzeEnabled) { + this.logger.debug('Tweet analysis is disabled, skipping'); return; } - // Try to acquire distributed lock const lockAcquired = await this.acquireLock(); if (!lockAcquired) { - this.logger.debug('Another instance is already running classification job, skipping...'); + this.logger.debug('Analysis job already running in another instance, skipping'); return; } - this.logger.log('Starting tweet classification job...'); + this.logger.log('=== Starting Tweet Analysis Job ==='); try { - const tweetsToClassify = await this.getTweetsToClassify(); + const tweetsToAnalyze = await this.getTweetsToAnalyze(); - if (tweetsToClassify.length === 0) { - this.logger.log('No tweets to classify'); + if (tweetsToAnalyze.length === 0) { + this.logger.log('No tweets to analyze'); return; } - this.logger.log(`Found ${tweetsToClassify.length} tweets to classify`); + this.logger.log(`Retrieved ${tweetsToAnalyze.length} tweets for analysis`); - const batches = this.splitIntoBatches(tweetsToClassify, this.requestLimit); + const batches = this.splitIntoBatches(tweetsToAnalyze, this.requestLimit); - this.logger.log(`Processing ${batches.length} batch(es) with limit ${this.requestLimit}`); + this.logger.log( + `Split into ${batches.length} batch(es) (limit: ${this.requestLimit} tweets/batch)`, + ); + let totalAnalyzedTweets = 0; let allBatchesSucceeded = true; + + // Accumulate trending data across all batches + const accumulatedTrendingKeywords: TrendingKeyword[] = []; + let totalTweetsInAllBatches = 0; + for (let i = 0; i < batches.length; i++) { const batch = batches[i]; - this.logger.log(`Processing batch ${i + 1}/${batches.length} with ${batch.length} tweets`); + this.logger.log(`Processing batch ${i + 1}/${batches.length} (${batch.length} tweets)`); try { - await this.processBatch(batch); - this.logger.log(`Batch ${i + 1}/${batches.length} completed successfully`); + const batchResult = await this.processBatch(batch); + totalAnalyzedTweets += batchResult.analyzedTweetsCount; + + // Accumulate trending keywords from this batch + if (batchResult.trending_keywords) { + accumulatedTrendingKeywords.push(...batchResult.trending_keywords); + } + if (batchResult.batch_meta) { + totalTweetsInAllBatches += batchResult.batch_meta.total_tweets; + } + + this.logger.log( + `Batch ${i + 1}/${batches.length} completed ` + + `(analyzed: ${batchResult.analyzedTweetsCount}, keywords: ${batchResult.trending_keywords?.length || 0})`, + ); } catch (error) { this.logger.error( - `Failed to process batch ${i + 1}/${batches.length}`, + `Batch ${i + 1}/${batches.length} failed`, error instanceof Error ? error.stack : String(error), ); this.logger.warn( - `Stopping classification job after batch ${i + 1} failure. ` + - `${batches.length - i - 1} remaining batch(es) will be retried in next run.`, + `Stopping after batch ${i + 1} failure. ` + + `${batches.length - i - 1} batch(es) will retry in next run`, ); allBatchesSucceeded = false; break; } } + // Update trending scores once with accumulated data from all batches + if (accumulatedTrendingKeywords.length > 0) { + this.logger.log( + `Updating trending scores with ${accumulatedTrendingKeywords.length} accumulated keywords`, + ); + await this.trendingService.updateTrendScores({ + batch_meta: { total_tweets: totalTweetsInAllBatches }, + trending_keywords: accumulatedTrendingKeywords, + }); + this.logger.log('Trending scores updated successfully'); + } + if (allBatchesSucceeded) { - this.logger.log('Tweet classification job completed successfully'); + this.logger.log( + `=== Tweet Analysis Job Completed Successfully (${totalAnalyzedTweets} tweets) ===`, + ); } else { - this.logger.warn('Tweet classification job stopped due to batch failure'); + this.logger.warn( + `=== Tweet Analysis Job Stopped (${totalAnalyzedTweets} tweets processed) ===`, + ); } } catch (error) { this.logger.error( - 'Tweet classification job failed', + 'Tweet analysis job failed', error instanceof Error ? error.stack : String(error), ); } finally { - // Always release the lock when done await this.releaseLock(); } } @@ -135,10 +173,14 @@ export class TweetAnalyzeService implements OnModuleInit { this.LOCK_TTL_SECONDS, 'NX', ); - return result === 'OK'; + const acquired = result === 'OK'; + if (acquired) { + this.logger.debug(`Acquired distributed lock (TTL: ${this.LOCK_TTL_SECONDS}s)`); + } + return acquired; } catch (error) { this.logger.error( - 'Failed to acquire lock', + 'Failed to acquire distributed lock', error instanceof Error ? error.stack : String(error), ); return false; @@ -148,22 +190,26 @@ export class TweetAnalyzeService implements OnModuleInit { private async releaseLock(): Promise { try { await this.redisService.del(this.LOCK_KEY); - this.logger.debug('Released classification lock'); + this.logger.debug('Released distributed lock'); } catch (error) { this.logger.error( - 'Failed to release lock', + 'Failed to release distributed lock', error instanceof Error ? error.stack : String(error), ); } } - private async getTweetsToClassify(): Promise> { + private async getTweetsToAnalyze(): Promise> { + this.logger.debug('Fetching tweets to analyze from repository'); const tweets = await this.repository.findTweetsToClassify(); - return tweets.filter((tweet) => tweet.content !== null) as Array<{ + const validTweets = tweets.filter((tweet) => tweet.content !== null) as Array<{ id: bigint; content: string; }>; + + this.logger.debug(`Found ${validTweets.length} valid tweets with content`); + return validTweets; } private splitIntoBatches(items: T[], batchSize: number): T[][] { @@ -174,51 +220,76 @@ export class TweetAnalyzeService implements OnModuleInit { return batches; } - private async processBatch(tweets: Array<{ id: bigint; content: string }>): Promise { - const requestPayload: ClassificationRequest = { + private async processBatch(tweets: Array<{ id: bigint; content: string }>): Promise<{ + analyzedTweetsCount: number; + batch_meta: BatchMeta | null; + trending_keywords: TrendingKeyword[] | null; + }> { + const requestPayload: ModelApiRequest = { tweets: tweets.map((tweet) => ({ id: tweet.id.toString(), content: tweet.content, })), }; - this.logger.debug( - `Sending ${tweets.length} tweets to classification API: ${this.classificationApiUrl}`, - ); + this.logger.debug(`Sending ${tweets.length} tweets to analysis API`); const response = await firstValueFrom( - this.httpService.post(this.classificationApiUrl, requestPayload), + this.httpService.post(this.analyzeApiUrl, requestPayload), ); - const classifiedTweets = response.data.tweets_detail; + const { batch_meta, trending_keywords, tweets_detail } = response.data; - if (!classifiedTweets || classifiedTweets.length === 0) { - this.logger.warn('Classification API returned no results'); - return; + if (!tweets_detail || tweets_detail.length === 0) { + this.logger.warn('Analysis API returned no tweet results'); + return { + analyzedTweetsCount: 0, + batch_meta: null, + trending_keywords: null, + }; } - this.logger.log(`Received ${classifiedTweets.length} classified tweets from API`); + this.logger.debug( + `Received response: ${tweets_detail.length} tweets, ${trending_keywords?.length || 0} keywords`, + ); - await this.updateTweetClassifications(classifiedTweets); + await this.updateTweetAnalysis(tweets_detail); + + return { + analyzedTweetsCount: tweets_detail.length, + batch_meta: batch_meta || null, + trending_keywords: trending_keywords || null, + }; } - private async updateTweetClassifications( - classifiedTweets: Array, - ): Promise { - for (const classified of classifiedTweets) { + private async updateTweetAnalysis(analyzedTweets: Array): Promise { + this.logger.debug(`Updating ${analyzedTweets.length} tweets in database`); + + let successCount = 0; + let failCount = 0; + + for (const analyzed of analyzedTweets) { try { - const tweetId = BigInt(classified.id); - await this.repository.updateTweetClass(tweetId, classified.class); + const tweetId = BigInt(analyzed.id); + await this.repository.updateTweetClass(tweetId, analyzed.class); + successCount++; - this.logger.debug(`Updated tweet ${classified.id} with class: ${classified.class}`); + this.logger.debug(`Updated tweet ${analyzed.id} → class: ${analyzed.class}`); } catch (error) { + failCount++; this.logger.error( - `Failed to update tweet ${classified.id}`, + `Failed to update tweet ${analyzed.id}`, error instanceof Error ? error.stack : String(error), ); } } - this.logger.log(`Successfully updated ${classifiedTweets.length} tweets`); + if (failCount > 0) { + this.logger.warn( + `Tweet update completed with errors (success: ${successCount}, failed: ${failCount})`, + ); + } else { + this.logger.debug(`All ${successCount} tweets updated successfully`); + } } } diff --git a/src/tweets/timeline/timeline.service.ts b/src/tweets/timeline/timeline.service.ts index 52b81f4a..37ba2245 100644 --- a/src/tweets/timeline/timeline.service.ts +++ b/src/tweets/timeline/timeline.service.ts @@ -101,9 +101,7 @@ export class TimelineService { }; } - // reviewer, don't delete these comments please they keep me sane, I will delete them myself (or not) - // this should be exactly like for you, just the extra step to get the ids from multiple sorted sets instead of one - // 1 - check the empty placeholder to fail fast (no following tweets, or no interests at all for for you) + // 1 - check the empty placeholder to fail fast (no following tweets) // 2 - get the actual ids (tweets and authors) from redis sorted set (timeline, paginated) (pagination) // 3 - hydrate all static data from redis (tweets and authors), get back the missing ones too // 4 - backfill the missing ones from db to redis diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts index fb2389bd..11d99319 100644 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -188,7 +188,7 @@ export class TweetsRepository { mapToTweetDto( tweet: TweetWithIncludes, - context: { isRepost?: boolean; repostedBy?: { username: string; displayName: string } } = {}, + context: { repostedBy?: { username: string; displayName: string } } = {}, ): TweetDto { let quotedTweet: TweetDto | DeletedTweet | undefined = undefined; if (tweet.quotedTweet) { @@ -1075,7 +1075,6 @@ export class TweetsRepository { replyToTweetId: tweet.replyToTweetId?.toString() ?? null, quoteToTweetId: tweet.quotedTweetId?.toString() ?? null, rootTweetId: tweet.rootTweetId?.toString() ?? null, - isRepost: false, repostedBy: undefined, })); } From eea6e9a29e1b996cc71ad8e5de21ffdbc8f86078 Mon Sep 17 00:00:00 2001 From: Omar Hassan <149178126+OmarHassan2003@users.noreply.github.com> Date: Sat, 13 Dec 2025 21:05:07 +0200 Subject: [PATCH 18/43] fix: return person who saw message with conversation seen update - [CU-869bfnkck] (#204) --- src/conversations/gateways/dm.gateway.ts | 1 + test/conversations/gateways/dm.gateway.spec.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/conversations/gateways/dm.gateway.ts b/src/conversations/gateways/dm.gateway.ts index 2cae3099..02e995b3 100644 --- a/src/conversations/gateways/dm.gateway.ts +++ b/src/conversations/gateways/dm.gateway.ts @@ -245,6 +245,7 @@ export class DmGateway implements OnGatewayConnection, OnGatewayDisconnect { this.server.to(payload.conversationId).emit('conversation_seen_update', { conversationId: payload.conversationId, username, + performerUsername: user.username, lastSeenMessageId, unseenCount, seenAt, diff --git a/test/conversations/gateways/dm.gateway.spec.ts b/test/conversations/gateways/dm.gateway.spec.ts index a3ee4bfe..a5556b16 100644 --- a/test/conversations/gateways/dm.gateway.spec.ts +++ b/test/conversations/gateways/dm.gateway.spec.ts @@ -186,6 +186,7 @@ describe('DmGateway', () => { lastSeenMessageId: '12', unseenCount: 0, seenAt: expect.any(Date) as Date, + performerUsername: mockUser.username, }); }); From 2c4057345ba02422aa1b2a95d175ce9caf7bfbf1 Mon Sep 17 00:00:00 2001 From: Mostafa Ahmed Abdelglel <139139781+galelo04@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:55:15 +0200 Subject: [PATCH 19/43] refactor: user responses from multiple sources - [CU-869bahm3n] (#146) Signed-off-by: Tasneemmhammed0 Co-authored-by: Tasneemmhammed0 Co-authored-by: Loay Ahmed --- api-spec/complete-spec/main.tsp | 77 ++- api-spec/implemented-spec/main.tsp | 78 ++- bruno/collections/tweets/retweet-tweet.bru | 7 +- .../users/get-user-relationship.bru | 19 + src/conversations/conversations.service.ts | 22 +- src/search/dtos/user-search-result.dto.ts | 2 +- .../mappers/user-search-result.mapper.ts | 3 +- src/tweets/dtos/author.dto.ts | 6 +- src/tweets/dtos/compact-author.dto.ts | 5 +- src/tweets/dtos/index.ts | 1 - src/tweets/dtos/tweet.dto.ts | 2 +- src/tweets/dtos/user-interaction.dto.ts | 12 - src/tweets/tweets.repository.ts | 80 ++- src/tweets/tweets.service.ts | 1 + src/users/dtos/bio-entities.dto.ts | 2 +- src/users/dtos/compact-user.dto.ts | 6 +- src/users/dtos/following-user.dto.ts | 7 - src/users/dtos/index.ts | 1 - src/users/dtos/relationship-dto.ts | 7 + src/users/dtos/user-profile-response.dto.ts | 25 +- src/users/me/settings/settings.service.ts | 27 +- src/users/users.controller.ts | 13 +- src/users/users.repository.ts | 51 +- src/users/users.service.ts | 135 ++--- .../conversations.service.spec.ts | 39 +- test/tweets/tweets.service.spec.ts | 3 + .../me/settings/settings.service.spec.ts | 286 ++++------ test/users/users.service.spec.ts | 495 ++++++++++++++---- 28 files changed, 835 insertions(+), 577 deletions(-) create mode 100644 bruno/collections/users/get-user-relationship.bru delete mode 100644 src/tweets/dtos/user-interaction.dto.ts mode change 100644 => 100755 src/tweets/tweets.repository.ts delete mode 100644 src/users/dtos/following-user.dto.ts create mode 100644 src/users/dtos/relationship-dto.ts mode change 100644 => 100755 test/conversations/conversations.service.spec.ts mode change 100644 => 100755 test/users/users.service.spec.ts diff --git a/api-spec/complete-spec/main.tsp b/api-spec/complete-spec/main.tsp index cc30b432..54c971de 100644 --- a/api-spec/complete-spec/main.tsp +++ b/api-spec/complete-spec/main.tsp @@ -311,6 +311,9 @@ model CompactUser { @doc("URL to the user's avatar image") avatarUrl: string | null; + + @doc("Relationship state between the authenticated user and this profile user, including blocks and mutes.") + relationship: UserRelationship; } @doc("Public-facing user profile (visible to others)") @@ -402,15 +405,7 @@ model UserMetadataWithLastSeen { @doc("Author metadata for tweets with relationship status") model TweetAuthor { ...UserMetadata; - - @doc("True if the current user has blocked this author") - isBlocked: boolean; - - @doc("True if the current user has muted this author") - isMuted: boolean; - - @doc("True if the current user is following this author") - isFollowing: boolean; + relationship: UserRelationship; } model UserMetadataWithFollowInfo { @@ -432,16 +427,11 @@ model UserMetadataWithFollowing { @doc("User metadata with biography information") model UserMetadataWithBio { - ...UserMetadataWithFollowInfo; - - @doc("The user's biography with structured entities") - bio: UserBio | null; - - @doc("True if the current user has blocked this user") - isBlocked: boolean; + ...UserMetadata; + ...UserBio; - @doc("True if the current user has muted this user") - isMuted: boolean; + @doc("Relationship state between the authenticated user and this profile user, including blocks and mutes.") + relationship: UserRelationship; } @doc("Block relationship state between two users") @@ -456,28 +446,25 @@ model BlockRelationship { @doc("Relationship state between the current user and the subject user. Defines blocking and muting status") model UserRelationship { @doc("True if the current user has blocked this user, preventing them from viewing their profile or interacting.") - blocking: boolean; + blocking?: boolean; @doc("True if this user has blocked the current user.") - blockedBy: boolean; + blockedBy?: boolean; @doc("True if the current user has muted this user, hiding their posts from the feed without unfollowing.") - muted: boolean; + muted?: boolean; @doc("Is the user followed by the current logged-in user") - following: boolean; + following?: boolean; @doc("Is the user following the current logged-in user") - follower: boolean; + follower?: boolean; } @doc("Complete user profile with full account details and relationship context.") model UserProfile { ...PublicUser; - @doc("Relationship state between the authenticated user and this profile user, including blocks and mutes.") - relationship: UserRelationship | null; // null if get my profile - @doc("The number of users this user is following") followingCount: int64; @@ -717,16 +704,21 @@ model Tweet { @doc("The full object of the tweet being quoted, for easy display.") quotedTweet: CompactTweet | null; // Hydrated object for a quoted tweet. - repostedBy: { - @doc("Username of the user who reposted this tweet") - username: string; + @doc("User who reposted this tweet.") + repostedBy?: TweetReposter; +} - @doc("Display name of the user who reposted this tweet") - displayName: string; - } +model TweetReposter { + + @doc("Username of the user who reposted this tweet") + username: string; + + @doc("Display name of the user who reposted this tweet") + displayName: string; } + model CreatedTweet { id: string; content?: string; @@ -1028,13 +1020,6 @@ model MessageCreatedResponse { }>; } -model FollowingUser { - ...CompactUser; - isFollowing: boolean; - followsYou: boolean; - isBlocked: boolean; -} - model AccountInfo { username: string; email: string | null; @@ -1071,8 +1056,8 @@ enum InterestCode { TRAVEL, } -alias FollowingListResponse = DataResponseWithPagination; -alias FollowersListResponse = DataResponseWithPagination; +alias FollowingListResponse = DataResponseWithPagination; +alias FollowersListResponse = DataResponseWithPagination; alias BlockedListResponse = DataResponseWithPagination; alias MutedListResponse = DataResponseWithPagination; alias SessionListResponse = DataResponse; @@ -1396,6 +1381,16 @@ namespace Oauth { @route("/users") @useAuth(BearerAuth) namespace Users { + @route("/{username}/relationship") + @get + @summary("Get relationship status with a user by their username") + op getUserRelationship(@path username: string): + | DataResponse + | BadRequestResponse + | NotFoundResponse + | UnauthorizedResponse + | InternalServerErrorResponse; + @summary("Get a user's profile preview.") @get @route("/{username}/preview") diff --git a/api-spec/implemented-spec/main.tsp b/api-spec/implemented-spec/main.tsp index 146c005e..eba483ff 100644 --- a/api-spec/implemented-spec/main.tsp +++ b/api-spec/implemented-spec/main.tsp @@ -357,13 +357,6 @@ model MessageCreatedResponse { }>; } -model FollowingUser { - ...CompactUser; - isFollowing: boolean; - followsYou: boolean; - isBlocked: boolean; -} - model AccountInfo { username: string; email: string | null; @@ -402,8 +395,8 @@ enum InterestCode { alias MutedListResponse = DataResponseWithPagination; alias BlockedListResponse = DataResponseWithPagination; -alias FollowingListResponse = DataResponseWithPagination; -alias FollowersListResponse = DataResponseWithPagination; +alias FollowingListResponse = DataResponseWithPagination; +alias FollowersListResponse = DataResponseWithPagination; alias UnfollowUserResponse = MessageResponse<"user unfollowed successfully">; alias FollowUserResponse = MessageResponse<"user followed successfully">; alias BlockUserResponse = MessageResponse<"user blocked successfully">; @@ -487,9 +480,6 @@ model BioEntities { model UserProfile { ...PublicUser; - @doc("Relationship state between the authenticated user and this profile user, including blocks and mutes.") - relationship: UserRelationship | null; // null if get my profile - @doc("The number of users this user is following") followingCount: int64; @@ -532,6 +522,9 @@ model CompactUser { @doc("URL to the user's avatar image") avatarUrl: string | null; + + @doc("Relationship state between the authenticated user and this profile user, including blocks and mutes.") + relationship: UserRelationship; } @doc("Public-facing user profile (visible to others)") @@ -559,19 +552,19 @@ model PublicUser { @doc("Relationship state between the current user and the subject user. Defines blocking and muting status") model UserRelationship { @doc("True if the current user has blocked this user, preventing them from viewing their profile or interacting.") - blocking: boolean; + blocking?: boolean; @doc("True if this user has blocked the current user.") - blockedBy: boolean; + blockedBy?: boolean; @doc("True if the current user has muted this user, hiding their posts from the feed without unfollowing.") - muted: boolean; + muted?: boolean; @doc("Is the user followed by the current logged-in user") - following: boolean; + following?: boolean; @doc("Is the user following the current logged-in user") - follower: boolean; + follower?: boolean; } @doc("Extended user metadata displayed in hover cards and user previews") @@ -635,15 +628,7 @@ model UserMetadataWithLastSeen { @doc("Author metadata for tweets with relationship status") model TweetAuthor { ...UserMetadata; - - @doc("True if the current user has blocked this author") - isBlocked: boolean; - - @doc("True if the current user has muted this author") - isMuted: boolean; - - @doc("True if the current user is following this author") - isFollowing: boolean; + relationship: UserRelationship; } model UserMetadataWithFollowInfo { @@ -665,23 +650,18 @@ model UserMetadataWithFollowing { @doc("User metadata with biography information") model UserMetadataWithBio { - ...UserMetadataWithFollowInfo; - - @doc("The user's biography with structured entities") - bio: UserBio | null; - - @doc("True if the current user has blocked this user") - isBlocked: boolean; + ...UserMetadata; + ...UserBio; - @doc("True if the current user has muted this user") - isMuted: boolean; + @doc("Relationship state between the authenticated user and this profile user, including blocks and mutes.") + relationship: UserRelationship; } @doc("User's biography with structured entities") model UserBio { @doc("The user's bio text (max 160 characters)") @maxLength(160) - text: string | null; + bio: string | null; @doc("Structured entities found in the bio (mentions and hashtags)") bioEntities: BioEntities | null; @@ -893,17 +873,21 @@ model Tweet { @doc("The full object of the tweet being quoted, for easy display.") quotedTweet: CompactTweet | null; // Hydrated object for a quoted tweet. - repostedBy: { - @doc("The username of the user who reposted this tweet.") - username: string; + @doc("User who reposted this tweet.") + repostedBy?: TweetReposter; +} - @doc("The display name of the user who reposted this tweet.") - displayName: string; +model TweetReposter { - } + @doc("The username of the user who reposted this tweet.") + username: string; + + @doc("The display name of the user who reposted this tweet.") + displayName: string; } + model TweetWithoutQuotedTweet { ...CompactTweet; @@ -1607,6 +1591,16 @@ namespace Me { @route("/users") @useAuth(BearerAuth) namespace Users { + @route("/{username}/relationship") + @get + @summary("Get relationship status with a user by their username") + op getUserRelationship(@path username: string): + | DataResponse + | BadRequestResponse + | NotFoundResponse + | UnauthorizedResponse + | InternalServerErrorResponse; + namespace Follows { @summary("List a user's followers") @get diff --git a/bruno/collections/tweets/retweet-tweet.bru b/bruno/collections/tweets/retweet-tweet.bru index 71ece0f5..c2cc11c5 100644 --- a/bruno/collections/tweets/retweet-tweet.bru +++ b/bruno/collections/tweets/retweet-tweet.bru @@ -7,9 +7,14 @@ meta { post { url: http://localhost:3000/tweets/1/retweet body: none - auth: inherit + auth: bearer +} + +auth:bearer { + token: {{access_token}} } settings { encodeUrl: true + timeout: 0 } diff --git a/bruno/collections/users/get-user-relationship.bru b/bruno/collections/users/get-user-relationship.bru new file mode 100644 index 00000000..bc506f19 --- /dev/null +++ b/bruno/collections/users/get-user-relationship.bru @@ -0,0 +1,19 @@ +meta { + name: get-user-relationship + type: http + seq: 7 +} + +get { + url: http://localhost:3000/users/:username/relationship + body: none + auth: inherit +} + +params:path { + username: +} + +settings { + encodeUrl: true +} diff --git a/src/conversations/conversations.service.ts b/src/conversations/conversations.service.ts index 6cd41fda..fcd9c519 100644 --- a/src/conversations/conversations.service.ts +++ b/src/conversations/conversations.service.ts @@ -64,11 +64,18 @@ export class ConversationsService { decoded, ); - const blockedUsers = await this.usersRepository.getUserBlocks(userId); - const blockedBy = await this.usersRepository.getUserBlockedBy(userId); + const blockRelations = await this.usersRepository.getUserBlockRelations(userId); - const blockedUserIds = new Set(blockedUsers.map((block) => block.blockedId.toString())); - const blockedByUserIds = new Set(blockedBy.map((block) => block.userId.toString())); + const blockedUserIds = new Set( + blockRelations.map((block) => { + if (block.userId === userId) return block.blockedId.toString(); + }), + ); + const blockedByUserIds = new Set( + blockRelations.map((block) => { + if (block.blockedId === userId) return block.userId.toString(); + }), + ); const conversationsWithBlockStatus = userConversations.map((conversation) => { const otherParticipant = conversation.conversationParticipants.find( @@ -132,11 +139,8 @@ export class ConversationsService { otherUser.id, ); - const blockedUsers = await this.usersRepository.getUserBlocks(userId); - const blockedBy = await this.usersRepository.getUserBlockedBy(userId); - - const isBlocking = blockedUsers.some((block) => block.blockedId === otherUser.id); - const isBlockedBy = blockedBy.some((block) => block.userId === otherUser.id); + const isBlocking = await this.usersRepository.isBlocked(userId, otherUser.id); + const isBlockedBy = await this.usersRepository.isBlocked(otherUser.id, userId); if (!conversationData) { if (isBlocking || isBlockedBy) { diff --git a/src/search/dtos/user-search-result.dto.ts b/src/search/dtos/user-search-result.dto.ts index 418d10f1..132b1ce2 100644 --- a/src/search/dtos/user-search-result.dto.ts +++ b/src/search/dtos/user-search-result.dto.ts @@ -1,5 +1,5 @@ -import { UserRelationshipDto } from 'src/users/dtos'; import { CompactUserDto } from 'src/users/dtos/compact-user.dto'; +import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; export type UserSearchResultItem = CompactUserDto & { bannerUrl: string | null; diff --git a/src/search/mappers/user-search-result.mapper.ts b/src/search/mappers/user-search-result.mapper.ts index 41573937..4081d6c7 100644 --- a/src/search/mappers/user-search-result.mapper.ts +++ b/src/search/mappers/user-search-result.mapper.ts @@ -1,6 +1,7 @@ import { JsonArray, JsonObject } from '@prisma/client/runtime/binary'; -import { BioEntitiesDto, UserRelationshipDto } from 'src/users/dtos'; +import { BioEntitiesDto } from 'src/users/dtos'; import { UserSearchResultItem } from '../dtos/user-search-result.dto'; +import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; export function mapToUserSearchResultDto( items: { diff --git a/src/tweets/dtos/author.dto.ts b/src/tweets/dtos/author.dto.ts index 65040c16..b9b9c127 100644 --- a/src/tweets/dtos/author.dto.ts +++ b/src/tweets/dtos/author.dto.ts @@ -1,8 +1,8 @@ +import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; + export class AuthorDto { username: string; displayName: string; avatarUrl: string | null | undefined; - isBlocked: boolean; - isFollowing: boolean; - isMuted: boolean; + relationship: UserRelationshipDto | null; } diff --git a/src/tweets/dtos/compact-author.dto.ts b/src/tweets/dtos/compact-author.dto.ts index 5f2bd4be..06581b37 100644 --- a/src/tweets/dtos/compact-author.dto.ts +++ b/src/tweets/dtos/compact-author.dto.ts @@ -1,5 +1,8 @@ import { AuthorDto } from './author.dto'; -export type CompactAuthorDto = Omit; +export type CompactAuthorDto = Omit< + AuthorDto, + 'relationship' | 'isBlocked' | 'isFollowing' | 'isMuted' +>; export type CompactAuthorWithId = CompactAuthorDto & { id: string }; diff --git a/src/tweets/dtos/index.ts b/src/tweets/dtos/index.ts index 7151188c..49cc23e2 100644 --- a/src/tweets/dtos/index.ts +++ b/src/tweets/dtos/index.ts @@ -3,5 +3,4 @@ export * from './create-tweet.dto'; export * from './tweet.dto'; export * from './tweet-entitites.dto'; export * from './get-tweet-response.dto'; -export * from './user-interaction.dto'; export * from './compact-author.dto'; diff --git a/src/tweets/dtos/tweet.dto.ts b/src/tweets/dtos/tweet.dto.ts index 7eb5c351..1c3d02cd 100644 --- a/src/tweets/dtos/tweet.dto.ts +++ b/src/tweets/dtos/tweet.dto.ts @@ -27,4 +27,4 @@ export class TweetDto { repostedBy?: Retweeter; } -type Retweeter = Omit; +export type Retweeter = Omit; diff --git a/src/tweets/dtos/user-interaction.dto.ts b/src/tweets/dtos/user-interaction.dto.ts deleted file mode 100644 index ba6972ef..00000000 --- a/src/tweets/dtos/user-interaction.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { BioDto } from 'src/users/dtos'; - -export class UserInteractionDto { - username: string; - displayName: string; - avatarUrl: string; - isFollowing: boolean; - isFollower: boolean; - isBlocked: boolean; - isMuted: boolean; - bio: BioDto | null; -} diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts old mode 100644 new mode 100755 index 11d99319..67614b1a --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -1,7 +1,7 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; -import { TweetDto, UserInteractionDto } from './dtos'; +import { AuthorDto, TweetDto } from './dtos'; import { FeedCursor } from 'src/common/interfaces/cursor.interfaces'; import { FeedSkeleton } from './interfaces'; import { CreateTweetData } from './interfaces/create-tweet-data.interface'; @@ -14,25 +14,38 @@ import { CachedStaticTweet } from './interfaces/cached-static-tweet'; import { CompactAuthorWithId } from './dtos/compact-author.dto'; import { TIMELINE_MAX_SIZE } from './timeline/constants'; import { PeopleSearchFilter } from 'src/search/dtos'; +import { CompactUserDto } from 'src/users/dtos/compact-user.dto'; import { TweetsBackfill } from './timeline/interfaces'; import { MAX_TWEET_DEPTH, TWEETS_ERROR_CODES, TWEETS_ERROR_MESSAGES } from './constants'; import { DeletedTweet, TweetOrDeleted } from './types'; -import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; -export const tweetInclude = (currentUserId: bigint | null) => +export const authorSelect = (currentUserId: bigint | null) => ({ - user: { + username: true, + profile: { select: { - username: true, - id: true, - profile: { - select: { - displayName: true, - avatarUrl: true, - }, - }, + displayName: true, + avatarUrl: true, }, }, + ...(currentUserId && { + followers: { where: { followerId: currentUserId } }, + following: { where: { followedId: currentUserId } }, + blockedBy: { where: { userId: currentUserId } }, + mutedBy: { where: { userId: currentUserId } }, + blockedUsers: { where: { blockedId: currentUserId } }, + }), + }) satisfies Prisma.UserSelect; + +export type RawAuthor = Prisma.UserGetPayload<{ + select: ReturnType; +}>; + +export const tweetInclude = (currentUserId: bigint | null) => + ({ + user: { + select: authorSelect(currentUserId), + }, ...(currentUserId && { _count: { select: { @@ -186,6 +199,21 @@ export class TweetsRepository { })); } + mapToAuthorDto(user: RawAuthor): AuthorDto { + return { + username: user.username, + displayName: user.profile?.displayName ?? '', + avatarUrl: user.profile?.avatarUrl, + relationship: { + following: user.followers ? user.followers.length > 0 : false, + follower: user.following ? user.following.length > 0 : false, + blocking: user.blockedBy ? user.blockedBy.length > 0 : false, + muted: user.mutedBy ? user.mutedBy.length > 0 : false, + blockedBy: user.blockedUsers ? user.blockedUsers.length > 0 : false, + }, + }; + } + mapToTweetDto( tweet: TweetWithIncludes, context: { repostedBy?: { username: string; displayName: string } } = {}, @@ -203,11 +231,7 @@ export class TweetsRepository { return { id: tweet.id.toString(), - author: { - username: tweet.user.username, - displayName: tweet.user.profile?.displayName ?? '', - avatarUrl: tweet.user.profile?.avatarUrl || DEFAULT_PROFILE_PICTURE, - }, + author: this.mapToAuthorDto(tweet.user), content: tweet.content ?? '', createdAt: tweet.createdAt, replyCount: tweet.replyCount, @@ -818,6 +842,7 @@ export class TweetsRepository { following: { where: { followedId: currentUserId } }, blockedBy: { where: { userId: currentUserId } }, mutedBy: { where: { userId: currentUserId } }, + blockedUsers: { where: { blockedId: currentUserId } }, }, }, } as const; @@ -848,20 +873,19 @@ export class TweetsRepository { const rawDtos = interactions.map((record) => { const user = record.user; - const dto = plainToInstance(UserInteractionDto, { + const dto = plainToInstance(CompactUserDto, { username: user.username, displayName: user.profile?.displayName ?? '', avatarUrl: user.profile?.avatarUrl, - bio: user.profile?.bio - ? { - text: user.profile.bio, - bioEntities: user.profile?.bioEntities as unknown as BioEntitiesDto, - } - : null, - isFollowing: user.followers.length > 0, - isFollower: user.following.length > 0, - isBlocked: user.blockedBy.length > 0, - isMuted: user.mutedBy.length > 0, + bio: user.profile?.bio ?? null, + bioEntities: (user.profile?.bioEntities as unknown as BioEntitiesDto) ?? null, + relationship: { + following: user.followers.length > 0, + follower: user.following.length > 0, + blocking: user.blockedBy.length > 0, + muted: user.mutedBy.length > 0, + blocked: user.blockedUsers.length > 0, + }, }); return { diff --git a/src/tweets/tweets.service.ts b/src/tweets/tweets.service.ts index 19581342..1b2b196c 100644 --- a/src/tweets/tweets.service.ts +++ b/src/tweets/tweets.service.ts @@ -354,6 +354,7 @@ export class TweetsService { : null, quotedTweet: createTweetDto.quoteToTweetId ? referencedTweet || undefined : undefined, replyToTweet: createTweetDto.replyToTweetId ? referencedTweet || undefined : undefined, + repostedBy: undefined, }; } diff --git a/src/users/dtos/bio-entities.dto.ts b/src/users/dtos/bio-entities.dto.ts index 9de600fd..13f5d5d5 100644 --- a/src/users/dtos/bio-entities.dto.ts +++ b/src/users/dtos/bio-entities.dto.ts @@ -6,6 +6,6 @@ export class BioEntitiesDto { } export class BioDto { - text: string; + bio: string; bioEntities: BioEntitiesDto; } diff --git a/src/users/dtos/compact-user.dto.ts b/src/users/dtos/compact-user.dto.ts index 185426b8..0f4fba54 100644 --- a/src/users/dtos/compact-user.dto.ts +++ b/src/users/dtos/compact-user.dto.ts @@ -1,9 +1,7 @@ +import { AuthorDto } from 'src/tweets/dtos'; import { BioEntitiesDto } from './bio-entities.dto'; -export class CompactUserDto { - username: string; - displayName: string; +export class CompactUserDto extends AuthorDto { bio: string | null; bioEntities: BioEntitiesDto | null; - avatarUrl: string | null; } diff --git a/src/users/dtos/following-user.dto.ts b/src/users/dtos/following-user.dto.ts deleted file mode 100644 index bc872239..00000000 --- a/src/users/dtos/following-user.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CompactUserDto } from './compact-user.dto'; - -export class FollowingUserDto extends CompactUserDto { - isFollowing: boolean; - followsYou: boolean; - isBlocked: boolean; -} diff --git a/src/users/dtos/index.ts b/src/users/dtos/index.ts index 9b003ae6..d9270d0d 100644 --- a/src/users/dtos/index.ts +++ b/src/users/dtos/index.ts @@ -13,6 +13,5 @@ export * from './user-profile-response.dto'; export * from './validate-password.dto'; export * from './verify-email-update.dto'; export * from './bio-entities.dto'; -export * from './following-user.dto'; export * from './update-interests.dto'; export * from './interests-response.dto'; diff --git a/src/users/dtos/relationship-dto.ts b/src/users/dtos/relationship-dto.ts new file mode 100644 index 00000000..86496448 --- /dev/null +++ b/src/users/dtos/relationship-dto.ts @@ -0,0 +1,7 @@ +export class UserRelationshipDto { + blocking?: boolean; + blockedBy?: boolean; + following?: boolean; + follower?: boolean; + muted?: boolean; +} diff --git a/src/users/dtos/user-profile-response.dto.ts b/src/users/dtos/user-profile-response.dto.ts index f7d37531..127654e3 100644 --- a/src/users/dtos/user-profile-response.dto.ts +++ b/src/users/dtos/user-profile-response.dto.ts @@ -1,22 +1,6 @@ -import { BioEntitiesDto } from './bio-entities.dto'; +import { CompactUserDto } from './compact-user.dto'; -export class UserRelationshipDto { - blocking: boolean; - blockedBy: boolean; - following: boolean; - follower: boolean; - muted: boolean; -} - -export class UserProfileResponseDto { - username: string; - displayName: string; - bio: string | null; - - // Should be adjusted after implementing rich text bios - bioEntities: BioEntitiesDto | null; - - avatarUrl: string | null | undefined; +export class UserProfileResponseDto extends CompactUserDto { bannerUrl: string | null; location: string | null; websiteUrl: string | null; @@ -25,14 +9,13 @@ export class UserProfileResponseDto { // TODO: If I block the user, this will be null joinedAt: Date; - // Won't be returned for the authenticated user's own profile - relationship?: UserRelationshipDto | null; - followingCount: number; followersCount: number; mutualsCount?: number | null; mutualUsers?: MutualUserDto[] | null; + phone?: string; + languageCode?: string; email?: string; } diff --git a/src/users/me/settings/settings.service.ts b/src/users/me/settings/settings.service.ts index ce5781be..bb12e3bb 100644 --- a/src/users/me/settings/settings.service.ts +++ b/src/users/me/settings/settings.service.ts @@ -28,6 +28,7 @@ import { generateAndStoreOtp } from 'src/auth/utils'; import { createValidationError, decodeCompositeCursor, paginateComposite } from 'src/common/utils'; import { BlocksCursor, MutesCursor } from 'src/common/interfaces'; import { PAGINATION_ERROR_CODES, PAGINATION_ERROR_MESSAGES } from 'src/common/constants'; +import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; interface CachedEmailUpdateData { userId: string; @@ -270,7 +271,11 @@ export class SettingsService { } } - const mutedUsers = await this.usersService.getUserMutes(userId, limit + 1, decoded); + const { mutedUsers, relationMap } = await this.usersService.getUserMutes( + userId, + limit + 1, + decoded, + ); const pagination = paginateComposite(mutedUsers, limit, prevCursor, (item) => ({ userId: item.userId.toString(), @@ -280,6 +285,13 @@ export class SettingsService { const items = mutedUsers.map((b) => ({ ...b.mutedUser.profile, username: b.mutedUser.username, + relationship: (relationMap.get(b.mutedId) as UserRelationshipDto) || { + blockedBy: false, + blocking: false, + muted: true, + following: false, + follower: false, + }, })); return { items, pagination }; @@ -302,7 +314,11 @@ export class SettingsService { } } - const blockedUsers = await this.usersService.getUserBlocks(userId, limit + 1, decoded); + const { blockedUsers, relationMap } = await this.usersService.getUserBlocks( + userId, + limit + 1, + decoded, + ); const pagination = paginateComposite(blockedUsers, limit, prevCursor, (item) => ({ userId: item.userId.toString(), @@ -312,6 +328,13 @@ export class SettingsService { const items = blockedUsers.map((b) => ({ ...b.blockedUser.profile, username: b.blockedUser.username, + relationship: (relationMap.get(b.blockedId) as UserRelationshipDto) || { + blockedBy: false, + blocking: true, + muted: false, + following: false, + follower: false, + }, })); return { items, pagination }; diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index addd476f..562e46a2 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,10 +1,10 @@ import { Controller, Delete, Get, HttpCode, Param, Post, Query, UseGuards } from '@nestjs/common'; import { UsersService } from './users.service'; import { plainToInstance } from 'class-transformer'; -import { FollowingUserDto } from './dtos'; import { JwtAuthGuard } from 'src/auth/guards'; import { User } from 'src/auth/decorators'; import type { RequestUser } from 'src/common/interfaces'; +import { CompactUserDto } from './dtos/compact-user.dto'; import { OptionalAuth } from 'src/common/decorators/optional-auth.decorator'; import { Throttle } from '@nestjs/throttler'; @@ -59,7 +59,7 @@ export class UsersController { parsedLimit, cursor, ); - const itemsDto = plainToInstance(FollowingUserDto, items); + const itemsDto = plainToInstance(CompactUserDto, items); return { items: itemsDto, pagination }; } @@ -79,7 +79,7 @@ export class UsersController { parsedLimit, cursor, ); - const itemsDto = plainToInstance(FollowingUserDto, items); + const itemsDto = plainToInstance(CompactUserDto, items); return { items: itemsDto, pagination }; } @@ -99,10 +99,15 @@ export class UsersController { parsedLimit, cursor, ); - const itemsDto = plainToInstance(FollowingUserDto, items); + const itemsDto = plainToInstance(CompactUserDto, items); return { items: itemsDto, pagination }; } + @Get(':username/relationship') + @UseGuards(JwtAuthGuard) + async getUserRelationship(@Param('username') username: string, @User() user: RequestUser) { + return this.usersService.getUserRelationship(BigInt(user.id), username); + } @Post(':username/notify') @HttpCode(200) @UseGuards(JwtAuthGuard) diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 04af587f..1a020317 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -8,13 +8,7 @@ import { USERS_ERROR_MESSAGES, } from 'src/users/constants'; -import { - BioEntitiesDto, - MutualUserDto, - UpdateProfileDto, - UserProfileResponseDto, - UserRelationshipDto, -} from './dtos'; +import { BioEntitiesDto, MutualUserDto, UpdateProfileDto, UserProfileResponseDto } from './dtos'; import * as bcrypt from 'bcrypt'; import { PlainMention } from 'src/tweets/interfaces'; import { createValidationError } from 'src/common/utils'; @@ -23,6 +17,7 @@ import { PeopleSearchFilter } from 'src/search/dtos'; import { RankedUser } from './interfaces/ranked-user.interface'; import { CompactAuthorDto } from 'src/tweets/dtos'; import { plainToClass } from 'class-transformer'; +import { UserRelationshipDto } from './dtos/relationship-dto'; import { RefreshTokensService } from 'src/refresh-tokens/refresh-tokens.service'; import { UserSearchCursor } from 'src/common/types/cursors'; @@ -315,6 +310,8 @@ export class UsersRepository { mutualsCount: mutualsCount, mutualUsers: mutualUsers, email: isMyProfile ? user.email : undefined, + phone: user.phone || undefined, + languageCode: user.languageCode || undefined, }; } @@ -771,15 +768,38 @@ export class UsersRepository { return !!mute; } - async getUserBlocks(userId: bigint) { - return await this.prisma.block.findMany({ + async getUserBlockRelations(userId: bigint, userIds?: bigint[]) { + const hasUserIds = Array.isArray(userIds) && userIds.length > 0; + + return this.prisma.block.findMany({ where: { - userId, + OR: [ + { + userId, + ...(hasUserIds && { blockedId: { in: userIds } }), + }, + { + ...(hasUserIds && { userId: { in: userIds } }), + blockedId: userId, + }, + ], }, + select: { userId: true, blockedId: true }, }); } + async getUserMuteRelations(userId: bigint, userIds: bigint[]) { + return await this.prisma.mute.findMany({ + where: { + OR: [ + { userId, mutedId: { in: userIds } }, // user-> them + ], + }, + select: { userId: true, mutedId: true }, + }); + } + async areUsersBlocked(firstUserId: bigint, secondUserId: bigint): Promise { const block = await this.prisma.block.findFirst({ where: { @@ -1582,6 +1602,15 @@ export class UsersRepository { ) as ranking_score`; } + /** + * Get a map of user IDs to their relationship status with the current user. + * + * @param currentUserId - ID of the current user + * @param userIds - Array of user IDs to get relationships for + * + * @returns A map where the key is the user ID and the value is the UserRelationshipDto + */ + async getUsersRelationshipsMap( currentUserId: bigint, userIds: bigint[], @@ -1627,9 +1656,7 @@ export class UsersRepository { WHERE u.id IN (${Prisma.join(userIds)}); `; - // 3. Map results for (const row of results) { - // Boolean() conversion handles cases where DB driver returns 1/0 instead of true/false relationshipsMap.set(row.user_id, { blocking: Boolean(row.is_blocking), blockedBy: Boolean(row.is_blocked_by), diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 1b3dc9a3..4eda218f 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -13,7 +13,7 @@ import { PrismaService } from 'src/prisma/prisma.service'; import { NewUser } from './interfaces'; import { comparePassword, hashPassword } from 'src/auth/utils'; import { VALIDATION_ERROR_CODES } from 'src/common/constants'; -import { ChangePasswordBasicDto, UpdateProfileDto, UserRelationshipDto } from './dtos'; +import { ChangePasswordBasicDto, UpdateProfileDto } from './dtos'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { decodeCompositeCursor, paginateComposite, createValidationError } from 'src/common/utils'; @@ -30,6 +30,7 @@ import { PlainMention } from 'src/tweets/interfaces'; import { PeopleSearchFilter } from 'src/search/dtos'; import { UserSearchCursor } from 'src/common/types/cursors'; import { ContentParsingService } from 'src/content-parsing/content-parsing.service'; +import { UserRelationshipDto } from './dtos/relationship-dto'; import { RedisService } from 'src/redis/redis.service'; import { REDIS_TIMELINE_KEYS } from 'src/common/constants/redis-timeline-keys.constant'; import { BackfillFollowJob } from 'src/tweets/timeline/interfaces'; @@ -685,14 +686,6 @@ export class UsersService { } } - const blockedUsers = await this.usersRepository.getUserBlocks(authUserId); - - const blockedIdsSet = new Set(); - - for (const blocked of blockedUsers) { - blockedIdsSet.add(blocked.blockedId); - } - const followers = await this.usersRepository.getUserFollowers( requestedUser.id, limit + 1, @@ -705,28 +698,21 @@ export class UsersService { })); const followerIds = followers.map((f) => f.followerUser.id); - const authUserFollowRelations = await this.usersRepository.getUserFollowRelations( + const relationMap = await this.usersRepository.getUsersRelationshipsMap( authUserId, followerIds, ); - const followsYouSet = new Set(); - const followingSet = new Set(); - - for (const relation of authUserFollowRelations) { - if (relation.followedId === authUserId) { - followsYouSet.add(relation.followerId); - } - if (relation.followerId === authUserId) { - followingSet.add(relation.followedId); - } - } const items = followers.map((f) => ({ ...f.followerUser.profile, username: f.followerUser.username, - isFollowing: followingSet.has(f.followerUser.id), - followsYou: followsYouSet.has(f.followerUser.id), - isBlocked: blockedIdsSet.has(f.followerUser.id), + relationship: (relationMap.get(f.followerUser.id) as UserRelationshipDto) || { + following: false, + follower: false, + blocking: false, + blockedBy: false, + muted: false, + }, })); return { items, pagination }; @@ -761,14 +747,6 @@ export class UsersService { } } - const blockedUsers = await this.usersRepository.getUserBlocks(authUserId); - - const blockedIdsSet = new Set(); - - for (const blocked of blockedUsers) { - blockedIdsSet.add(blocked.blockedId); - } - const authFollowedIds = await this.usersRepository.getUserIdsFollowedBy(authUserId); const mutualFollowers = await this.usersRepository.getUserMutualFollowers( @@ -784,28 +762,19 @@ export class UsersService { })); const mutualIds = mutualFollowers.map((f) => f.followerUser.id); - const authUserFollowRelations = await this.usersRepository.getUserFollowRelations( - authUserId, - mutualIds, - ); - const followsYouSet = new Set(); - const followingSet = new Set(); - for (const relation of authUserFollowRelations) { - if (relation.followedId === authUserId) { - followsYouSet.add(relation.followerId); - } - if (relation.followerId === authUserId) { - followingSet.add(relation.followedId); - } - } + const relationMap = await this.usersRepository.getUsersRelationshipsMap(authUserId, mutualIds); const items = mutualFollowers.map((f) => ({ ...f.followerUser.profile, username: f.followerUser.username, - isFollowing: followingSet.has(f.followerUser.id), - followsYou: followsYouSet.has(f.followerUser.id), - isBlocked: blockedIdsSet.has(f.followerUser.id), + relationship: (relationMap.get(f.followerUser.id) as UserRelationshipDto) || { + following: false, + follower: false, + blocking: false, + blockedBy: false, + muted: false, + }, })); return { items, pagination }; } @@ -839,14 +808,6 @@ export class UsersService { } } - const blockedUsers = await this.usersRepository.getUserBlocks(authUserId); - - const blockedIdsSet = new Set(); - - for (const blocked of blockedUsers) { - blockedIdsSet.add(blocked.blockedId); - } - const followings = await this.usersRepository.getUserFollowings( requestedUser.id, limit + 1, @@ -859,28 +820,22 @@ export class UsersService { })); const followingIds = followings.map((f) => f.followedUser.id); - const authUserFollowRelations = await this.usersRepository.getUserFollowRelations( + + const relationMap = await this.usersRepository.getUsersRelationshipsMap( authUserId, followingIds, ); - const followsYouSet = new Set(); - const followingSet = new Set(); - - for (const relation of authUserFollowRelations) { - if (relation.followedId === authUserId) { - followsYouSet.add(relation.followerId); - } - if (relation.followerId === authUserId) { - followingSet.add(relation.followedId); - } - } const items = followings.map((f) => ({ ...f.followedUser.profile, username: f.followedUser.username, - isFollowing: followingSet.has(f.followedUser.id), - followsYou: followsYouSet.has(f.followedUser.id), - isBlocked: blockedIdsSet.has(f.followedUser.id), + relationship: (relationMap.get(f.followedUser.id) as UserRelationshipDto) || { + following: false, + follower: false, + blocking: false, + blockedBy: false, + muted: false, + }, })); return { items, pagination }; @@ -1004,11 +959,23 @@ export class UsersService { return { message: 'Banner deleted successfully' }; } async getUserMutes(userId: bigint, limit: number, prevCursor: MutesCursor | undefined) { - return this.usersRepository.getUserMutedUsers(userId, limit, prevCursor); + const mutedUsers = await this.usersRepository.getUserMutedUsers(userId, limit, prevCursor); + + const mutedIds = mutedUsers.map((m) => m.mutedId); + + const relationMap = await this.usersRepository.getUsersRelationshipsMap(userId, mutedIds); + + return { mutedUsers, relationMap }; } async getUserBlocks(userId: bigint, limit: number, prevCursor: BlocksCursor | undefined) { - return this.usersRepository.getUserBlockedUsers(userId, limit, prevCursor); + const blockedUsers = await this.usersRepository.getUserBlockedUsers(userId, limit, prevCursor); + + const blockedIds = blockedUsers.map((b) => b.blockedId); + + const relationMap = await this.usersRepository.getUsersRelationshipsMap(userId, blockedIds); + + return { blockedUsers, relationMap }; } /** @@ -1062,6 +1029,21 @@ export class UsersService { return await this.usersRepository.getUserFollowRelations(userId, userIds); } + async getUserRelationship(userId: bigint, targetUsername: string) { + const requestedUser = await this.usersRepository.findByUsername(targetUsername); + + if (!requestedUser) { + throw new HttpException( + { + message: USERS_ERROR_MESSAGES.USER_NOT_FOUND, + code: USERS_ERROR_CODES.USER_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ); + } + const map = await this.usersRepository.getUsersRelationshipsMap(userId, [requestedUser.id]); + return (map.get(requestedUser.id) as UserRelationshipDto) || null; + } async searchUsers( currentUserId: bigint, query: string, @@ -1080,10 +1062,7 @@ export class UsersService { ); } - async getUsersRelationshipsMap( - currentUserId: bigint, - userIds: bigint[], - ): Promise> { + async getUsersRelationshipsMap(currentUserId: bigint, userIds: bigint[]) { return this.usersRepository.getUsersRelationshipsMap(currentUserId, userIds); } diff --git a/test/conversations/conversations.service.spec.ts b/test/conversations/conversations.service.spec.ts old mode 100644 new mode 100755 index 2427fb15..ffd51c8b --- a/test/conversations/conversations.service.spec.ts +++ b/test/conversations/conversations.service.spec.ts @@ -25,9 +25,9 @@ describe('ConversationsService', () => { const mockUsersRepository = { getUserByUsername: jest.fn(), - getUserBlocks: jest.fn(), - getUserBlockedBy: jest.fn(), getBlockingBlockedState: jest.fn(), + getUserBlockRelations: jest.fn(), + isBlocked: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -49,7 +49,7 @@ describe('ConversationsService', () => { usersRepository = module.get(UsersRepository); }); - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); @@ -99,9 +99,7 @@ describe('ConversationsService', () => { ]; conversationsRepository.getUserConversations.mockResolvedValue(mockConversations); - usersRepository.getUserBlocks.mockResolvedValue([]); - usersRepository.getUserBlockedBy.mockResolvedValue([]); - + usersRepository.getUserBlockRelations.mockResolvedValueOnce([]); const result = await service.getUserConversations(userId, limit, ''); expect(result.items).toHaveLength(1); @@ -266,8 +264,12 @@ describe('ConversationsService', () => { ]; conversationsRepository.getUserConversations.mockResolvedValue(mockConversations); - usersRepository.getUserBlocks.mockResolvedValue([{ userId, blockedId: BigInt(2) }]); - usersRepository.getUserBlockedBy.mockResolvedValue([]); + usersRepository.getUserBlockRelations.mockResolvedValueOnce([ + { + userId, + blockedId: BigInt(2), + }, + ]); const result = await service.getUserConversations(userId, limit, ''); @@ -308,14 +310,12 @@ describe('ConversationsService', () => { }, ], messages: [], - lastMessage: null, + lastMessage: { content: 'Hello', user: { username: 'testuser' }, createdAt: new Date() }, }; usersRepository.getUserByUsername.mockResolvedValue(otherUser); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - conversationsRepository.findConversation.mockResolvedValue(mockConversation as any); - usersRepository.getUserBlocks.mockResolvedValue([]); - usersRepository.getUserBlockedBy.mockResolvedValue([]); + conversationsRepository.findConversation.mockResolvedValue(mockConversation); + usersRepository.isBlocked.mockResolvedValue(false); const result = await service.createOrFindConversation(userId, username); @@ -380,8 +380,6 @@ describe('ConversationsService', () => { lastMessageId: null, createdAt: new Date(), }); - usersRepository.getUserBlocks.mockResolvedValue([]); - usersRepository.getUserBlockedBy.mockResolvedValue([]); const result = await service.createOrFindConversation(userId, username); @@ -392,8 +390,7 @@ describe('ConversationsService', () => { it('should throw error if user is blocking target', async () => { usersRepository.getUserByUsername.mockResolvedValue(otherUser); conversationsRepository.findConversation.mockResolvedValue(null); - usersRepository.getUserBlocks.mockResolvedValue([{ userId, blockedId: otherUser.id }]); - usersRepository.getUserBlockedBy.mockResolvedValue([]); + usersRepository.isBlocked.mockResolvedValue(true); await expect(service.createOrFindConversation(userId, username)).rejects.toThrow( HttpException, @@ -415,10 +412,7 @@ describe('ConversationsService', () => { it('should throw error if user is blocked by target', async () => { usersRepository.getUserByUsername.mockResolvedValue(otherUser); conversationsRepository.findConversation.mockResolvedValue(null); - usersRepository.getUserBlocks.mockResolvedValue([]); - usersRepository.getUserBlockedBy.mockResolvedValue([ - { userId: otherUser.id, blockedId: userId, createdAt: new Date() }, - ]); + usersRepository.isBlocked.mockResolvedValue(true); await expect(service.createOrFindConversation(userId, username)).rejects.toThrow( HttpException, @@ -441,8 +435,7 @@ describe('ConversationsService', () => { lastMessageId: null, createdAt: new Date(), }); - usersRepository.getUserBlocks.mockResolvedValue([]); - usersRepository.getUserBlockedBy.mockResolvedValue([]); + usersRepository.isBlocked.mockResolvedValue(false); await expect(service.createOrFindConversation(userId, username)).rejects.toThrow( HttpException, diff --git a/test/tweets/tweets.service.spec.ts b/test/tweets/tweets.service.spec.ts index 8da163e8..dd2c7305 100644 --- a/test/tweets/tweets.service.spec.ts +++ b/test/tweets/tweets.service.spec.ts @@ -45,6 +45,8 @@ describe('TweetsService', () => { getFeedSkeletonSQL: jest.fn(), hydrateTweetsInList: jest.fn(), mapToDetailedTweetDto: jest.fn(), + mapToTweetDto: jest.fn(), + mapToAuthorDto: jest.fn(), getDetailedTweetById: jest.fn(), getTweetLikers: jest.fn(), getTweetRetweeters: jest.fn(), @@ -246,6 +248,7 @@ describe('TweetsService', () => { replyToTweetId: null, quoteToTweetId: null, quotedTweet: undefined, + repostedBy: undefined, replyToTweet: undefined, rootTweetId: null, }); diff --git a/test/users/me/settings/settings.service.spec.ts b/test/users/me/settings/settings.service.spec.ts index 381e9ec6..990d2f8b 100644 --- a/test/users/me/settings/settings.service.spec.ts +++ b/test/users/me/settings/settings.service.spec.ts @@ -860,6 +860,54 @@ describe('SettingsService', () => { const userId = BigInt(1); const limit = 2; + const mockMutedUsers = [ + { + userId: BigInt(1), + mutedId: BigInt(2), + createdAt: new Date(), + mutedUser: { + id: BigInt(2), + username: 'muted1', + profile: { + displayName: 'muted One', + bio: 'Bio 1', + bioEntities: null, + avatarUrl: 'https://example.com/avatar1.jpg', + }, + }, + }, + { + userId: BigInt(1), + mutedId: BigInt(3), + createdAt: new Date(), + mutedUser: { + id: BigInt(3), + username: 'muted2', + profile: { + displayName: 'muted Two', + bio: 'Bio 2', + bioEntities: null, + avatarUrl: 'https://example.com/avatar2.jpg', + }, + }, + }, + { + userId: BigInt(1), + mutedId: BigInt(4), + createdAt: new Date(), + mutedUser: { + id: BigInt(4), + username: 'muted3', + profile: { + displayName: 'muted Three', + bio: 'Bio 3', + bioEntities: null, + avatarUrl: 'https://example.com/avatar3.jpg', + }, + }, + }, + ]; + // Helper to encode a valid cursor const encodeValidCursor = (userId: string, mutedId: string): string => { @@ -876,55 +924,11 @@ describe('SettingsService', () => { it('should return muted users without cursor (first page)', async () => { // Arrange: 3 muted users returned (limit+1 to detect hasNextPage) - const mockMutedUsers = [ - { - userId: BigInt(1), - mutedId: BigInt(2), - createdAt: new Date(), - mutedUser: { - id: BigInt(2), - username: 'muted1', - profile: { - displayName: 'muted One', - bio: 'Bio 1', - bioEntities: null, - avatarUrl: 'https://example.com/avatar1.jpg', - }, - }, - }, - { - userId: BigInt(1), - mutedId: BigInt(3), - createdAt: new Date(), - mutedUser: { - id: BigInt(3), - username: 'muted2', - profile: { - displayName: 'muted Two', - bio: 'Bio 2', - bioEntities: null, - avatarUrl: 'https://example.com/avatar2.jpg', - }, - }, - }, - { - userId: BigInt(1), - mutedId: BigInt(4), - createdAt: new Date(), - mutedUser: { - id: BigInt(4), - username: 'muted3', - profile: { - displayName: 'muted Three', - bio: 'Bio 3', - bioEntities: null, - avatarUrl: 'https://example.com/avatar3.jpg', - }, - }, - }, - ]; - mockUsersService.getUserMutes.mockResolvedValue(mockMutedUsers); + mockUsersService.getUserMutes.mockResolvedValue({ + mutedUsers: mockMutedUsers, + relationMap: new Map(), + }); // Act const result = await service.getUserMutedUsers(userId, limit); @@ -957,39 +961,11 @@ describe('SettingsService', () => { it('should return muted users with valid cursor (subsequent page)', async () => { // Arrange const validCursor = encodeValidCursor('1', '2'); // userId=1, mutedId=2 - const mockmutedUsers = [ - { - userId: BigInt(1), - mutedId: BigInt(5), - createdAt: new Date(), - mutedUser: { - id: BigInt(5), - username: 'muted5', - profile: { - displayName: 'muted Five', - bio: 'Bio 5', - bioEntities: null, - avatarUrl: 'https://example.com/avatar5.jpg', - }, - }, - }, - { - userId: BigInt(1), - mutedId: BigInt(6), - createdAt: new Date(), - mutedUser: { - id: BigInt(6), - username: 'muted6', - profile: { - displayName: 'muted Six', - bio: 'Bio 6', - bioEntities: null, - avatarUrl: 'https://example.com/avatar6.jpg', - }, - }, - }, - ]; - mockUsersService.getUserMutes.mockResolvedValue(mockmutedUsers); + + mockUsersService.getUserMutes.mockResolvedValue({ + mutedUsers: mockMutedUsers, + relationMap: new Map(), + }); // Act const result = await service.getUserMutedUsers(userId, limit, validCursor); @@ -1028,7 +1004,10 @@ describe('SettingsService', () => { it('should use default limit (20) when no limit provided', async () => { // Arrange - mockUsersService.getUserMutes.mockResolvedValue([]); + mockUsersService.getUserMutes.mockResolvedValue({ + mutedUsers: mockMutedUsers, + relationMap: new Map(), + }); // Act await service.getUserMutedUsers(userId); @@ -1062,7 +1041,38 @@ describe('SettingsService', () => { const cursorObj = { userId, blockedId }; return Buffer.from(JSON.stringify(cursorObj)).toString('base64'); }; - + const mockBlockedUsers = [ + { + userId: BigInt(1), + blockedId: BigInt(5), + createdAt: new Date(), + blockedUser: { + id: BigInt(5), + username: 'blocked5', + profile: { + displayName: 'Blocked Five', + bio: 'Bio 5', + bioEntities: null, + avatarUrl: 'https://example.com/avatar5.jpg', + }, + }, + }, + { + userId: BigInt(1), + blockedId: BigInt(6), + createdAt: new Date(), + blockedUser: { + id: BigInt(6), + username: 'blocked6', + profile: { + displayName: 'Blocked Six', + bio: 'Bio 6', + bioEntities: null, + avatarUrl: 'https://example.com/avatar6.jpg', + }, + }, + }, + ]; beforeEach(() => { // Add getUserBlocks to mock if not already present if (!mockUsersService.getUserBlocks) { @@ -1072,55 +1082,11 @@ describe('SettingsService', () => { it('should return blocked users without cursor (first page)', async () => { // Arrange: 3 blocked users returned (limit+1 to detect hasNextPage) - const mockBlockedUsers = [ - { - userId: BigInt(1), - blockedId: BigInt(2), - createdAt: new Date(), - blockedUser: { - id: BigInt(2), - username: 'blocked1', - profile: { - displayName: 'Blocked One', - bio: 'Bio 1', - bioEntities: null, - avatarUrl: 'https://example.com/avatar1.jpg', - }, - }, - }, - { - userId: BigInt(1), - blockedId: BigInt(3), - createdAt: new Date(), - blockedUser: { - id: BigInt(3), - username: 'blocked2', - profile: { - displayName: 'Blocked Two', - bio: 'Bio 2', - bioEntities: null, - avatarUrl: 'https://example.com/avatar2.jpg', - }, - }, - }, - { - userId: BigInt(1), - blockedId: BigInt(4), - createdAt: new Date(), - blockedUser: { - id: BigInt(4), - username: 'blocked3', - profile: { - displayName: 'Blocked Three', - bio: 'Bio 3', - bioEntities: null, - avatarUrl: 'https://example.com/avatar3.jpg', - }, - }, - }, - ]; - mockUsersService.getUserBlocks.mockResolvedValue(mockBlockedUsers); + mockUsersService.getUserBlocks.mockResolvedValue({ + blockedUsers: mockBlockedUsers, + relationMap: new Map(), + }); // Act const result = await service.getUserBlockedUsers(userId, limit); @@ -1135,58 +1101,29 @@ describe('SettingsService', () => { // Only first 2 items returned (limit=2), third is used for pagination expect(result.items).toHaveLength(2); expect(result.items[0]).toMatchObject({ - displayName: 'Blocked One', - bio: 'Bio 1', - username: 'blocked1', + displayName: 'Blocked Five', + bio: 'Bio 5', + username: 'blocked5', }); expect(result.items[1]).toMatchObject({ - displayName: 'Blocked Two', - bio: 'Bio 2', - username: 'blocked2', + displayName: 'Blocked Six', + bio: 'Bio 6', + username: 'blocked6', }); // Pagination should indicate next page - expect(result.pagination.hasNextPage).toBe(true); - expect(result.pagination.nextCursor).toBeTruthy(); + expect(result.pagination.hasNextPage).toBe(false); + expect(result.pagination.nextCursor).toBeFalsy(); }); it('should return blocked users with valid cursor (subsequent page)', async () => { // Arrange const validCursor = encodeValidCursor('1', '2'); // userId=1, blockedId=2 - const mockBlockedUsers = [ - { - userId: BigInt(1), - blockedId: BigInt(5), - createdAt: new Date(), - blockedUser: { - id: BigInt(5), - username: 'blocked5', - profile: { - displayName: 'Blocked Five', - bio: 'Bio 5', - bioEntities: null, - avatarUrl: 'https://example.com/avatar5.jpg', - }, - }, - }, - { - userId: BigInt(1), - blockedId: BigInt(6), - createdAt: new Date(), - blockedUser: { - id: BigInt(6), - username: 'blocked6', - profile: { - displayName: 'Blocked Six', - bio: 'Bio 6', - bioEntities: null, - avatarUrl: 'https://example.com/avatar6.jpg', - }, - }, - }, - ]; - mockUsersService.getUserBlocks.mockResolvedValue(mockBlockedUsers); + mockUsersService.getUserBlocks.mockResolvedValue({ + blockedUsers: mockBlockedUsers, + relationMap: new Map(), + }); // Act const result = await service.getUserBlockedUsers(userId, limit, validCursor); @@ -1227,7 +1164,10 @@ describe('SettingsService', () => { it('should use default limit (20) when no limit provided', async () => { // Arrange - mockUsersService.getUserBlocks.mockResolvedValue([]); + mockUsersService.getUserBlocks.mockResolvedValue({ + blockedUsers: mockBlockedUsers, + relationMap: new Map(), + }); // Act await service.getUserBlockedUsers(userId); diff --git a/test/users/users.service.spec.ts b/test/users/users.service.spec.ts old mode 100644 new mode 100755 index f00fa5af..b8f78aed --- a/test/users/users.service.spec.ts +++ b/test/users/users.service.spec.ts @@ -14,6 +14,7 @@ import { MediaService } from 'src/media/media.service'; import { MediaFolder } from 'src/media/enums'; import { ContentParsingService } from 'src/content-parsing/content-parsing.service'; import { NewUser } from 'src/users/interfaces'; +import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; import { RedisService } from 'src/redis/redis.service'; import { DomainEventsService } from 'src/events/domain-events.service'; @@ -94,9 +95,8 @@ describe('UsersService', () => { getUserFollowers: jest.fn(), getUserFollowings: jest.fn(), getUserMutualFollowers: jest.fn(), - getUserFollowRelations: jest.fn(), - getUserBlocks: jest.fn(), getUserIdsFollowedBy: jest.fn(), + getUsersRelationshipsMap: jest.fn(), }; const mockEmailQueue = { @@ -2216,45 +2216,68 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([]); mockRepository.getUserFollowers.mockResolvedValue(mockFollowers); - mockRepository.getUserFollowRelations.mockResolvedValue([ - { followerId: BigInt(2), followedId: authUserId }, // follower1 follows auth user - { followerId: authUserId, followedId: BigInt(3) }, // auth user follows follower2 - ]); + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserFollowers(mockUsername, authUserId, limit); // Assert expect(mockRepository.findByUsername).toHaveBeenCalledWith(mockUsername); - expect(mockRepository.getUserBlocks).toHaveBeenCalledWith(authUserId); expect(mockRepository.getUserFollowers).toHaveBeenCalledWith( requestedUserId, limit + 1, undefined, // no cursor decoded ); - // Note: paginateComposite removes the extra item, so only first 2 follower IDs are passed - expect(mockRepository.getUserFollowRelations).toHaveBeenCalledWith(authUserId, [ + + expect(mockRepository.getUsersRelationshipsMap).toHaveBeenCalledWith(authUserId, [ BigInt(2), BigInt(3), ]); - // Only first 2 items returned (limit=2), third is used for pagination expect(result.items).toHaveLength(2); expect(result.items[0]).toMatchObject({ displayName: 'Follower One', username: 'follower1', - isFollowing: false, // auth user doesn't follow follower1 - followsYou: true, // follower1 follows auth user - isBlocked: false, + relationship: { + following: false, + follower: true, + blockedBy: false, + blocking: false, + muted: false, + }, }); expect(result.items[1]).toMatchObject({ displayName: 'Follower Two', username: 'follower2', - isFollowing: true, // auth user follows follower2 - followsYou: false, // follower2 doesn't follow auth user - isBlocked: false, + relationship: { + following: true, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, }); // Pagination should indicate next page @@ -2286,9 +2309,29 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([]); + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); mockRepository.getUserFollowers.mockResolvedValue(mockFollowers); - mockRepository.getUserFollowRelations.mockResolvedValue([]); // Act const result = await service.getUserFollowers(mockUsername, authUserId, limit, validCursor); @@ -2325,7 +2368,7 @@ describe('UsersService', () => { }); }); - it('should filter out blocked users in the response', async () => { + it('should flag blocked and muted users in the response', async () => { // Arrange const mockFollowers = [ { @@ -2348,18 +2391,36 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([ - { blockerId: authUserId, blockedId: BigInt(2) }, // auth user blocked follower1 - ]); mockRepository.getUserFollowers.mockResolvedValue(mockFollowers); - mockRepository.getUserFollowRelations.mockResolvedValue([]); + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserFollowers(mockUsername, authUserId, limit); // Assert - expect(result.items[0].isBlocked).toBe(true); // follower1 is blocked - expect(result.items[1].isBlocked).toBe(false); // follower2 is not blocked + expect(result.items[0].relationship.blocking).toBe(false); // follower1 is blocked + expect(result.items[1].relationship.blocking).toBe(false); // follower2 is not blocked }); it('should correctly set isFollowing flag based on user follows Relation ', async () => { @@ -2385,22 +2446,38 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([]); mockRepository.getUserFollowers.mockResolvedValue(mockFollowers); - mockRepository.getUserFollowRelations.mockResolvedValue([ - { followerId: BigInt(3), followedId: authUserId }, // follower2 follows auth user - { followerId: authUserId, followedId: BigInt(2) }, // auth user follows follower1 - { followerId: authUserId, followedId: BigInt(3) }, // auth user follows follower2 - ]); + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserFollowers(mockUsername, authUserId, limit); // Assert - expect(result.items[0].isFollowing).toBe(true); // follower1 not followed back - expect(result.items[0].followsYou).toBe(false); // follower1 doesn't follows auth user - expect(result.items[1].isFollowing).toBe(true); // follower2 followed back - expect(result.items[1].followsYou).toBe(true); // follower2 follow auth user + expect(result.items[0].relationship.following).toBe(false); // follower1 not followed back + expect(result.items[0].relationship.follower).toBe(true); // follower1 doesn't follows auth user + expect(result.items[1].relationship.following).toBe(true); // follower2 followed back + expect(result.items[1].relationship.follower).toBe(false); // follower2 follow auth user }); it('should throw NOT_FOUND if requested user does not exist', async () => { @@ -2425,9 +2502,29 @@ describe('UsersService', () => { it('should return empty items when user has no followers', async () => { // Arrange - mockRepository.getUserBlocks.mockResolvedValue([]); mockRepository.getUserFollowers.mockResolvedValue([]); - mockRepository.getUserFollowRelations.mockResolvedValue([]); + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserFollowers(mockUsername, authUserId, limit); @@ -2491,26 +2588,42 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([]); mockRepository.getUserFollowings.mockResolvedValue(mockFollowings); - mockRepository.getUserFollowRelations.mockResolvedValue([ - { followerId: BigInt(2), followedId: authUserId }, // follower1 follows auth user - { followerId: authUserId, followedId: BigInt(3) }, // auth user follows follower2 - ]); + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: false, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserFollowings(mockUsername, authUserId, limit); // Assert expect(mockRepository.findByUsername).toHaveBeenCalledWith(mockUsername); - expect(mockRepository.getUserBlocks).toHaveBeenCalledWith(authUserId); expect(mockRepository.getUserFollowings).toHaveBeenCalledWith( requestedUserId, limit + 1, undefined, // no cursor decoded ); // Note: paginateComposite removes the extra item, so only first 2 follower IDs are passed - expect(mockRepository.getUserFollowRelations).toHaveBeenCalledWith(authUserId, [ + expect(mockRepository.getUsersRelationshipsMap).toHaveBeenCalledWith(authUserId, [ BigInt(2), BigInt(3), ]); @@ -2520,16 +2633,24 @@ describe('UsersService', () => { expect(result.items[0]).toMatchObject({ displayName: 'Followed One', username: 'followed1', - isFollowing: false, - followsYou: true, - isBlocked: false, + relationship: { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, }); expect(result.items[1]).toMatchObject({ displayName: 'Followed Two', username: 'followed2', - isFollowing: true, - followsYou: false, - isBlocked: false, + relationship: { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, }); // Pagination should indicate next page @@ -2561,9 +2682,30 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([]); mockRepository.getUserFollowings.mockResolvedValue(mockFollowings); - mockRepository.getUserFollowRelations.mockResolvedValue([]); + + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserFollowings(mockUsername, authUserId, limit, validCursor); @@ -2600,7 +2742,7 @@ describe('UsersService', () => { }); }); - it('should filter out blocked users in the response', async () => { + it('should flag blocked and muted users in the response', async () => { // Arrange const mockFollowings = [ { @@ -2623,18 +2765,38 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([ - { blockerId: authUserId, blockedId: BigInt(2) }, // auth user blocked followed1 - ]); mockRepository.getUserFollowings.mockResolvedValue(mockFollowings); - mockRepository.getUserFollowRelations.mockResolvedValue([]); + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserFollowings(mockUsername, authUserId, limit); // Assert - expect(result.items[0].isBlocked).toBe(true); // followed1 is blocked - expect(result.items[1].isBlocked).toBe(false); // followed2 is not blocked + expect(result.items[0].relationship.blocking).toBe(false); // followed1 is blocked + expect(result.items[1].relationship.blocking).toBe(false); // followed2 is not blocked + expect(result.items[0].relationship.muted).toBe(false); // followed1 is blocked + expect(result.items[1].relationship.muted).toBe(false); // followed2 is not blocked }); it('should correctly set isFollowing flag based on follow backs', async () => { @@ -2660,21 +2822,37 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([]); mockRepository.getUserFollowings.mockResolvedValue(mockFollowings); - mockRepository.getUserFollowRelations.mockResolvedValue([ - { followerId: BigInt(3), followedId: authUserId }, - { followerId: authUserId, followedId: BigInt(2) }, - { followerId: authUserId, followedId: BigInt(3) }, - ]); + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserFollowings(mockUsername, authUserId, limit); // Assert - expect(result.items[0].isFollowing).toBe(true); - expect(result.items[0].followsYou).toBe(false); - expect(result.items[1].isFollowing).toBe(true); - expect(result.items[1].followsYou).toBe(true); + expect(result.items[0].relationship.following).toBe(false); + expect(result.items[0].relationship.follower).toBe(true); + expect(result.items[1].relationship.following).toBe(true); + expect(result.items[1].relationship.follower).toBe(false); }); it('should throw NOT_FOUND if requested user does not exist', async () => { @@ -2699,9 +2877,30 @@ describe('UsersService', () => { it('should return empty items when user has no followings', async () => { // Arrange - mockRepository.getUserBlocks.mockResolvedValue([]); mockRepository.getUserFollowings.mockResolvedValue([]); - mockRepository.getUserFollowRelations.mockResolvedValue([]); + + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserFollowings(mockUsername, authUserId, limit); @@ -2802,20 +3001,37 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([]); mockRepository.getUserIdsFollowedBy.mockResolvedValue(mockAuthFollowings); mockRepository.getUserMutualFollowers.mockResolvedValue(mockMutualFollowers); - mockRepository.getUserFollowRelations.mockResolvedValue([ - { followerId: BigInt(2), followedId: authUserId }, - { followerId: authUserId, followedId: BigInt(2) }, - ]); + + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserMutualFollowers(mockUsername, authUserId, limit); // Assert expect(mockRepository.findByUsername).toHaveBeenCalledWith(mockUsername); - expect(mockRepository.getUserBlocks).toHaveBeenCalledWith(authUserId); expect(mockRepository.getUserIdsFollowedBy).toHaveBeenCalledWith(authUserId); expect(mockRepository.getUserMutualFollowers).toHaveBeenCalledWith( requestedUserId, @@ -2824,7 +3040,7 @@ describe('UsersService', () => { undefined, // no cursor decoded ); // Note: paginateComposite removes the extra item, so only first 2 follower IDs are passed - expect(mockRepository.getUserFollowRelations).toHaveBeenCalledWith(authUserId, [ + expect(mockRepository.getUsersRelationshipsMap).toHaveBeenCalledWith(authUserId, [ BigInt(2), BigInt(3), ]); @@ -2834,16 +3050,24 @@ describe('UsersService', () => { expect(result.items[0]).toMatchObject({ displayName: 'Followed One', username: 'followed1', - isFollowing: true, - followsYou: true, - isBlocked: false, + relationship: { + following: false, + follower: true, + blockedBy: false, + blocking: false, + muted: false, + }, }); expect(result.items[1]).toMatchObject({ displayName: 'Followed Two', username: 'followed2', - isFollowing: false, - followsYou: false, - isBlocked: false, + relationship: { + following: true, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, }); // Pagination should indicate next page @@ -2905,11 +3129,30 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([]); + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); mockRepository.getUserIdsFollowedBy.mockResolvedValue(mockAuthFollowings); mockRepository.getUserMutualFollowers.mockResolvedValue(mockMutualFollowers); - mockRepository.getUserFollowRelations.mockResolvedValue([]); - // Act const result = await service.getUserMutualFollowers( mockUsername, @@ -2951,7 +3194,7 @@ describe('UsersService', () => { }); }); - it('should filter out blocked users in the response', async () => { + it('should flag blocked and muted users as blocked in the response', async () => { // Arrange const mockMutualFollowers = [ { @@ -2974,18 +3217,37 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([ - { blockerId: authUserId, blockedId: BigInt(2) }, // auth user blocked followed1 - ]); mockRepository.getUserMutualFollowers.mockResolvedValue(mockMutualFollowers); - mockRepository.getUserFollowRelations.mockResolvedValue([]); - + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserMutualFollowers(mockUsername, authUserId, limit); // Assert - expect(result.items[0].isBlocked).toBe(true); // followed1 is blocked - expect(result.items[1].isBlocked).toBe(false); // followed2 is not blocked + expect(result.items[0].relationship.blocking).toBe(false); // followed1 is blocked + expect(result.items[1].relationship.blocking).toBe(false); // followed2 is not blocked + expect(result.items[0].relationship.muted).toBe(false); // followed1 is muted + expect(result.items[1].relationship.muted).toBe(false); // followed2 is not blocked }); it('should correctly set isFollowing flag based on follow backs', async () => { @@ -3011,21 +3273,36 @@ describe('UsersService', () => { }, ]; - mockRepository.getUserBlocks.mockResolvedValue([]); mockRepository.getUserMutualFollowers.mockResolvedValue(mockMutualFollowers); - mockRepository.getUserFollowRelations.mockResolvedValue([ - { followerId: authUserId, followedId: BigInt(2) }, - { followerId: BigInt(2), followedId: authUserId }, - { followerId: BigInt(3), followedId: authUserId }, - ]); - + mockRepository.getUsersRelationshipsMap.mockResolvedValue( + new Map([ + [ + BigInt(2), + { following: false, follower: true, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: false }, + ], + [ + BigInt(4), + { + following: false, + follower: false, + blockedBy: false, + blocking: false, + muted: false, + }, + ], + ]), + ); // Act const result = await service.getUserMutualFollowers(mockUsername, authUserId, limit); // Assert - expect(result.items[0].isFollowing).toBe(true); - expect(result.items[0].followsYou).toBe(true); - expect(result.items[1].isFollowing).toBe(false); - expect(result.items[1].followsYou).toBe(true); + expect(result.items[0].relationship.following).toBe(false); + expect(result.items[0].relationship.follower).toBe(true); + expect(result.items[1].relationship.following).toBe(true); + expect(result.items[1].relationship.follower).toBe(false); }); it('should throw NOT_FOUND if requested user does not exist', async () => { @@ -3050,10 +3327,8 @@ describe('UsersService', () => { it('should return empty items when user has no mutualFollowers', async () => { // Arrange - mockRepository.getUserBlocks.mockResolvedValue([]); mockRepository.getUserMutualFollowers.mockResolvedValue([]); - mockRepository.getUserFollowRelations.mockResolvedValue([]); - + mockRepository.getUsersRelationshipsMap.mockResolvedValue([]); // Act const result = await service.getUserMutualFollowers(mockUsername, authUserId, limit); // Assert From dc9e282a3b8564769bf792cba1cb4ad5994d87aa Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Mon, 15 Dec 2025 02:05:12 +0200 Subject: [PATCH 20/43] feat: add author relations to tweets and retweeter id in repostedBy, with endpoint to get the user by id - [CU-869bev8y1] (#193) Co-authored-by: Loay Ahmed Co-authored-by: mostafa <14712022100624@stud.cu.edu.eg> --- api-spec/complete-spec/main.tsp | 12 ++ api-spec/implemented-spec/main.tsp | 12 ++ .../users/get-by-id(for retweeter).bru | 19 +++ src/explore/explore.module.ts | 3 +- src/explore/explore.repository.ts | 118 ++---------------- src/tweets/dtos/compact-author.dto.ts | 3 +- src/tweets/dtos/tweet.dto.ts | 9 +- .../timeline/timeline.events.service.ts | 74 +++++++++++ src/tweets/timeline/timeline.service.ts | 25 ++-- src/tweets/tweets.repository.ts | 66 +++++++++- src/tweets/tweets.service.ts | 11 +- src/users/users.controller.ts | 7 ++ src/users/users.repository.ts | 30 ++++- src/users/users.service.ts | 14 +++ 14 files changed, 265 insertions(+), 138 deletions(-) create mode 100644 bruno/collections/users/get-by-id(for retweeter).bru create mode 100644 src/tweets/timeline/timeline.events.service.ts mode change 100755 => 100644 src/tweets/tweets.repository.ts diff --git a/api-spec/complete-spec/main.tsp b/api-spec/complete-spec/main.tsp index 54c971de..09338262 100644 --- a/api-spec/complete-spec/main.tsp +++ b/api-spec/complete-spec/main.tsp @@ -405,6 +405,7 @@ model UserMetadataWithLastSeen { @doc("Author metadata for tweets with relationship status") model TweetAuthor { ...UserMetadata; + relationship: UserRelationship; } @@ -1400,6 +1401,17 @@ namespace Users { | NotFoundResponse | InternalServerErrorResponse; + @summary("Get user metadata by id (used for retweeter caching in client)") + @get + @route("/id/{userId}") + op getUserMetadataById(@path userId: string): + | DataResponse<{ + username: string; + displayName: string; + }> + | NotFoundResponse + | InternalServerErrorResponse; + @summary("User's profile details") @get @route("/{username}/profile") diff --git a/api-spec/implemented-spec/main.tsp b/api-spec/implemented-spec/main.tsp index eba483ff..6416c4f5 100644 --- a/api-spec/implemented-spec/main.tsp +++ b/api-spec/implemented-spec/main.tsp @@ -628,6 +628,7 @@ model UserMetadataWithLastSeen { @doc("Author metadata for tweets with relationship status") model TweetAuthor { ...UserMetadata; + relationship: UserRelationship; } @@ -1612,6 +1613,17 @@ namespace Users { | UnauthorizedResponse | InternalServerErrorResponse; + @summary("Get user metadata by id (used for retweeter caching in client)") + @get + @route("/id/{userId}") + op getUserMetadataById(@path userId: string): + | DataResponse<{ + username: string; + displayName: string; + }> + | NotFoundResponse + | InternalServerErrorResponse; + @get @route("/{username}/mutual") @summary("List mutual followers with a user given their username") diff --git a/bruno/collections/users/get-by-id(for retweeter).bru b/bruno/collections/users/get-by-id(for retweeter).bru new file mode 100644 index 00000000..2a8f40da --- /dev/null +++ b/bruno/collections/users/get-by-id(for retweeter).bru @@ -0,0 +1,19 @@ +meta { + name: get-by-id(for retweeter) + type: http + seq: 7 +} + +get { + url: http://localhost:3000/users/id/:id + body: none + auth: inherit +} + +params:path { + id: +} + +settings { + encodeUrl: true +} diff --git a/src/explore/explore.module.ts b/src/explore/explore.module.ts index c1b90939..c3febea5 100644 --- a/src/explore/explore.module.ts +++ b/src/explore/explore.module.ts @@ -3,9 +3,10 @@ import { ExploreController } from './explore.controller'; import { ExploreService } from './explore.service'; import { ExploreRepository } from './explore.repository'; import { PrismaModule } from 'src/prisma/prisma.module'; +import { TweetsModule } from 'src/tweets/tweets.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, TweetsModule], controllers: [ExploreController], providers: [ExploreService, ExploreRepository], exports: [ExploreService], diff --git a/src/explore/explore.repository.ts b/src/explore/explore.repository.ts index 3d70464b..266237ff 100644 --- a/src/explore/explore.repository.ts +++ b/src/explore/explore.repository.ts @@ -1,80 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { Categories, Prisma } from '@prisma/client'; +import { Categories } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; import { TweetDto } from 'src/tweets/dtos'; - -const tweetInclude = (currentUserId: bigint) => - ({ - user: { - select: { - username: true, - id: true, - profile: { - select: { - displayName: true, - avatarUrl: true, - }, - }, - }, - }, - _count: { - select: { - likes: { - where: { userId: currentUserId }, - }, - retweets: { - where: { userId: currentUserId }, - }, - }, - }, - tweetMentions: { - select: { - startPosition: true, - user: { - select: { - username: true, - }, - }, - }, - }, - tweetHashtags: { - select: { - startPosition: true, - hashtag: { - select: { - keyword: true, - }, - }, - }, - }, - tweetMedia: { - select: { - order: true, - media: { - select: { - url: true, - type: true, - altText: true, - width: true, - height: true, - }, - }, - }, - orderBy: { order: 'asc' as const }, - }, - }) satisfies Prisma.TweetInclude; - -type BaseTweetWithIncludes = Prisma.TweetGetPayload<{ - include: ReturnType; -}>; - -type TweetWithIncludes = BaseTweetWithIncludes & { - quotedTweet?: (BaseTweetWithIncludes & { quotedTweet?: null }) | null; -}; +import { tweetInclude, TweetsRepository, TweetWithIncludes } from 'src/tweets/tweets.repository'; @Injectable() export class ExploreRepository { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly tweetsRepository: TweetsRepository, + ) {} async getUserInterests(userId: bigint): Promise { const user = await this.prisma.user.findUnique({ @@ -150,7 +85,7 @@ export class ExploreRepository { if (tweet) { const categoryKey = category.toLowerCase(); const categoryTweets = categoryMap.get(categoryKey) ?? []; - categoryTweets.push(this.mapToTweetDto(tweet as TweetWithIncludes)); + categoryTweets.push(this.tweetsRepository.mapToTweetDto(tweet as TweetWithIncludes)); categoryMap.set(categoryKey, categoryTweets); } } @@ -158,45 +93,6 @@ export class ExploreRepository { return categoryMap; } - private mapToTweetDto(tweet: TweetWithIncludes): TweetDto { - return { - id: tweet.id.toString(), - author: { - username: tweet.user.username, - displayName: tweet.user.profile?.displayName ?? '', - avatarUrl: tweet.user.profile?.avatarUrl, - }, - rootTweetId: null, - content: tweet.content ?? '', - createdAt: tweet.createdAt, - replyCount: tweet.replyCount, - retweetCount: tweet.retweetCount, - likeCount: tweet.likeCount, - isLiked: tweet._count.likes > 0, - isRetweeted: tweet._count.retweets > 0, - entities: { - mentions: tweet.tweetMentions.map((mention) => ({ - username: mention.user.username, - startPosition: mention.startPosition, - })), - hashtags: tweet.tweetHashtags.map((hashtag) => ({ - hashtag: hashtag.hashtag.keyword, - startPosition: hashtag.startPosition, - })), - }, - media: tweet.tweetMedia?.map((media) => ({ - url: media.media.url, - type: media.media.type, - altText: media.media.altText, - width: media.media.width ?? 0, - height: media.media.height ?? 0, - })), - replyToTweetId: tweet.replyToTweetId?.toString() ?? null, - quoteToTweetId: tweet.quotedTweetId?.toString() ?? null, - quotedTweet: tweet.quotedTweet ? this.mapToTweetDto(tweet.quotedTweet) : undefined, - }; - } - async getTrendingKeywords() { const keywords = await this.prisma.trendingKeyword.findMany({ orderBy: { overallScore: 'desc' }, diff --git a/src/tweets/dtos/compact-author.dto.ts b/src/tweets/dtos/compact-author.dto.ts index 06581b37..de68a538 100644 --- a/src/tweets/dtos/compact-author.dto.ts +++ b/src/tweets/dtos/compact-author.dto.ts @@ -1,8 +1,9 @@ import { AuthorDto } from './author.dto'; +type CompactAuthorDtoCached = Omit; export type CompactAuthorDto = Omit< AuthorDto, 'relationship' | 'isBlocked' | 'isFollowing' | 'isMuted' >; -export type CompactAuthorWithId = CompactAuthorDto & { id: string }; +export type CompactAuthorWithId = CompactAuthorDtoCached & { id: string }; diff --git a/src/tweets/dtos/tweet.dto.ts b/src/tweets/dtos/tweet.dto.ts index 1c3d02cd..aaf9a5bb 100644 --- a/src/tweets/dtos/tweet.dto.ts +++ b/src/tweets/dtos/tweet.dto.ts @@ -1,10 +1,11 @@ import { TweetEntitiesDto } from './tweet-entitites.dto'; import { MediaResponseDto } from 'src/media/dtos/media-response.dto'; import { DeletedTweet } from '../types'; -import { CompactAuthorDto } from './compact-author.dto'; +import { AuthorDto } from './author.dto'; + export class TweetDto { id: string; - author: CompactAuthorDto; + author: AuthorDto; content: string | null; createdAt: Date; @@ -27,4 +28,6 @@ export class TweetDto { repostedBy?: Retweeter; } -export type Retweeter = Omit; +type Retweeter = Omit & { + id: string; +}; diff --git a/src/tweets/timeline/timeline.events.service.ts b/src/tweets/timeline/timeline.events.service.ts new file mode 100644 index 00000000..aeb1b2b0 --- /dev/null +++ b/src/tweets/timeline/timeline.events.service.ts @@ -0,0 +1,74 @@ +// import { Injectable, Logger } from '@nestjs/common'; +// import { Cron } from '@nestjs/schedule'; +// import { REDIS_TIMELINE_KEYS } from 'src/common/constants/redis-timeline-keys.constant'; +// import { RedisService } from 'src/redis/redis.service'; +// import { SseEventsService } from 'src/sse/sse-events.service'; +// import { UsersRepository } from 'src/users/users.repository'; +// import { NEW_TWEETS_INDICATOR_MAX_AUTHORS } from './constants'; + +// @Injectable() +// export class TimelineEventsService { +// private readonly logger = new Logger(TimelineEventsService.name); +// private readonly redisClient; + +// constructor( +// private readonly redisService: RedisService, +// private readonly usersRepository: UsersRepository, +// private readonly sseEventsService: SseEventsService, +// ) { +// this.redisClient = redisService.getClient(); +// } + +// @Cron('0 */1 * * * *') +// async handleNewTweetsCheck() { +// this.logger.debug('Checking for online users with following timeline SSE connections'); + +// const onlineUserIds = await this.redisClient.smembers( +// REDIS_TIMELINE_KEYS.getSSEOnlineFollowingTimelineKey(), +// ); + +// if (onlineUserIds.length === 0) { +// this.logger.debug('No users online for timeline events. Skipping check.'); +// return; +// } + +// this.logger.debug(`Checking new tweet indicators for ${onlineUserIds.length} online users.`); + +// for (const userId of onlineUserIds) { +// try { +// const indicatorKey = REDIS_TIMELINE_KEYS.getNewTweetsIndicatorKey(BigInt(userId)); + +// // prevents the race condition of getting a new tweet event while the cron job is running +// const transaction = this.redisClient.multi(); +// transaction.zrevrange(indicatorKey, 0, NEW_TWEETS_INDICATOR_MAX_AUTHORS - 1); +// transaction.del(indicatorKey); +// const execResult = await transaction.exec(); +// let newActorIds: string[] = []; +// // transaction result is [[err, Set], [err, number of deleted keys]] +// if (execResult && Array.isArray(execResult) && execResult[0] && !execResult[0][0]) { +// newActorIds = Array.isArray(execResult[0][1]) ? (execResult[0][1] as string[]) : []; +// } +// console.log(newActorIds); +// console.log(execResult); + +// if (newActorIds.length > 0) { +// const uniqueActorIds = [...new Set(newActorIds)].slice(0, 3); +// const authors = await this.usersRepository.findAvatarUrlsByUserIds( +// uniqueActorIds.map((id) => BigInt(id)), +// ); + +// const authorAvatarsOrdered = uniqueActorIds.map((id) => authors.get(id)!); + +// await this.sseEventsService.publishTimelineFollowingTweets( +// BigInt(userId), +// authorAvatarsOrdered, +// ); + +// await this.redisClient.del(indicatorKey); +// } +// } catch (error) { +// this.logger.error(`Failed to process new tweets indicator for user ${userId}`, error); +// } +// } +// } +// } diff --git a/src/tweets/timeline/timeline.service.ts b/src/tweets/timeline/timeline.service.ts index 37ba2245..71423a91 100644 --- a/src/tweets/timeline/timeline.service.ts +++ b/src/tweets/timeline/timeline.service.ts @@ -9,7 +9,7 @@ import { PAGINATION_ERROR_CODES, PAGINATION_ERROR_MESSAGES, } from 'src/common/constants'; -import { TweetDto, CompactAuthorWithId } from '../dtos'; +import { TweetDto, CompactAuthorWithId, AuthorDto } from '../dtos'; import { AUTHOR_COMPACT_DATA_CACHE_TTL, COUNT_CACHE_TTL, @@ -101,7 +101,9 @@ export class TimelineService { }; } - // 1 - check the empty placeholder to fail fast (no following tweets) + // reviewer, don't delete these comments please they keep me sane, I will delete them myself (or not) + // this should be exactly like for you, just the extra step to get the ids from multiple sorted sets instead of one + // 1 - check the empty placeholder to fail fast (no following tweets, or no interests at all for for you) // 2 - get the actual ids (tweets and authors) from redis sorted set (timeline, paginated) (pagination) // 3 - hydrate all static data from redis (tweets and authors), get back the missing ones too // 4 - backfill the missing ones from db to redis @@ -218,10 +220,13 @@ export class TimelineService { authors.set(authorId, author); } + const fullAuthorsMap = await this.tweetsRepository.getAuthorRelationships(userId, authors); + const batchTweets = this.assembleTimelineTweets( uniqueFilteredTimelineObjects, tweets, authors, + fullAuthorsMap, { likeCounts, retweetCounts, @@ -760,6 +765,7 @@ export class TimelineService { items: string[], tweets: Map, authors: Map, + fullAuthors: Map, dynamicData: DynamicDataFromCache, ): TweetDto[] { const timelineTweets = new Array(); @@ -767,11 +773,11 @@ export class TimelineService { for (const item of items) { const [authorIdStr, tweetIdStr, actionType] = item.split(':'); const tweet = tweets.get(tweetIdStr); - const author = authors.get(authorIdStr); + const fullAuthor = fullAuthors.get(authorIdStr); const retweeterId = actionType === 'R' ? item.split(':')[3] : null; const retweeter = retweeterId ? authors.get(retweeterId) : undefined; - if (!tweet || !author) { + if (!tweet || !fullAuthor) { continue; // never happens } @@ -786,12 +792,10 @@ export class TimelineService { /* eslint-disable @typescript-eslint/no-unused-vars */ const { authorId, ...tweetWithoutAuthorId } = tweet; // remove authorId from tweet - const { id, ...authorWithoutId } = author; // remove id from author dto - /* eslint-enable @typescript-eslint/no-unused-vars */ const tweetDto: TweetDto = { ...tweetWithoutAuthorId, - author: authorWithoutId, + author: fullAuthor, likeCount, retweetCount, replyCount, @@ -799,6 +803,7 @@ export class TimelineService { isRetweeted, repostedBy: retweeter ? { + id: retweeter.id, username: retweeter.username, displayName: retweeter.displayName, } @@ -813,9 +818,8 @@ export class TimelineService { if (tweet.quoteToTweetId) { const quotedTweet = tweets.get(tweet.quoteToTweetId); if (quotedTweet) { - const quotedAuthor = authors.get(quotedTweet.authorId); + const quotedAuthor = fullAuthors.get(quotedTweet.authorId); if (quotedAuthor) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { authorId, ...quotedTweetWithoutAuthorId } = quotedTweet; tweet.quotedTweet = { ...quotedTweetWithoutAuthorId, @@ -1179,12 +1183,13 @@ export class TimelineService { authors.set(authorId, author); } } - + const fullAuthorsMap = await this.tweetsRepository.getAuthorRelationships(userId, authors); // Assemble tweets const assembledBatch = this.assembleTimelineTweets( orderedTimelineItems, tweets, authors, + fullAuthorsMap, dynamicData, ); diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts old mode 100755 new mode 100644 index 67614b1a..00148433 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -18,6 +18,7 @@ import { CompactUserDto } from 'src/users/dtos/compact-user.dto'; import { TweetsBackfill } from './timeline/interfaces'; import { MAX_TWEET_DEPTH, TWEETS_ERROR_CODES, TWEETS_ERROR_MESSAGES } from './constants'; import { DeletedTweet, TweetOrDeleted } from './types'; +import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; export const authorSelect = (currentUserId: bigint | null) => ({ @@ -100,7 +101,7 @@ type BaseTweetWithIncludes = Prisma.TweetGetPayload<{ include: ReturnType; }>; -type TweetWithIncludes = BaseTweetWithIncludes & { +export type TweetWithIncludes = BaseTweetWithIncludes & { quotedTweet?: (BaseTweetWithIncludes & { quotedTweet?: null }) | null; }; @@ -203,7 +204,7 @@ export class TweetsRepository { return { username: user.username, displayName: user.profile?.displayName ?? '', - avatarUrl: user.profile?.avatarUrl, + avatarUrl: user.profile?.avatarUrl || DEFAULT_PROFILE_PICTURE, relationship: { following: user.followers ? user.followers.length > 0 : false, follower: user.following ? user.following.length > 0 : false, @@ -216,7 +217,10 @@ export class TweetsRepository { mapToTweetDto( tweet: TweetWithIncludes, - context: { repostedBy?: { username: string; displayName: string } } = {}, + context: { + isRepost?: boolean; + repostedBy?: { username: string; displayName: string; id: string }; + } = {}, ): TweetDto { let quotedTweet: TweetDto | DeletedTweet | undefined = undefined; if (tweet.quotedTweet) { @@ -968,6 +972,7 @@ export class TweetsRepository { } async hydrateTweetsInList(authUserId: bigint, tweetIds: bigint[]) { + console.log(authUserId); return await this.prisma.tweet.findMany({ where: { id: { in: tweetIds }, @@ -1533,6 +1538,61 @@ export class TweetsRepository { })); } + async getAuthorRelationships( + userId: bigint, + authorMap: Map, + ): Promise> { + const authorIds = Array.from(authorMap.keys()).map((id) => BigInt(id)); + + const relationships = await this.prisma.$queryRaw< + Array<{ + author_id: bigint; + is_following: boolean; + is_follower: boolean; + is_blocked: boolean; + is_blocking: boolean; + is_muted: boolean; + }> + >` + SELECT + a.id AS author_id, + EXISTS ( + SELECT 1 FROM follows f WHERE f.follower_id = ${userId} AND f.followed_id = a.id + ) AS is_following, + EXISTS ( + SELECT 1 FROM follows f WHERE f.follower_id = a.id AND f.followed_id = ${userId} + ) AS is_follower, + EXISTS ( + SELECT 1 FROM blocks b WHERE b.user_id = ${userId} AND b.blocked_id = a.id + ) AS is_blocking, + EXISTS ( + SELECT 1 FROM blocks b WHERE b.user_id = a.id AND b.blocked_id = ${userId} + ) AS is_blocked, + EXISTS ( + SELECT 1 FROM mutes m WHERE m.user_id = ${userId} AND m.muted_id = a.id + ) AS is_muted + FROM users a + WHERE a.id = ANY(${authorIds}::bigint[]) + `; // because I think prisma will do it with joins + + const authors: Map = new Map(); + relationships.forEach((rel) => { + const author = authorMap.get(rel.author_id.toString()); + if (author) { + authors.set(rel.author_id.toString(), { + ...author, + relationship: { + following: rel.is_following, + follower: rel.is_follower, + blockedBy: rel.is_blocked, + blocking: rel.is_blocking, + muted: rel.is_muted, + }, + }); + } + }); + return authors; + } /** * Get user's interests from their profile */ diff --git a/src/tweets/tweets.service.ts b/src/tweets/tweets.service.ts index 1b2b196c..6398246c 100644 --- a/src/tweets/tweets.service.ts +++ b/src/tweets/tweets.service.ts @@ -24,7 +24,7 @@ import { import { GetTweetResponseDto } from './dtos/get-tweet-response.dto'; import { TweetRelationsCursor, UserInteractionsCursor } from 'src/common/types/cursors'; import { MediaResponseDto } from 'src/media/dtos/media-response.dto'; -import { CompactAuthorDto, TweetDto } from './dtos'; +import { AuthorDto, TweetDto } from './dtos'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { RetweetFanoutJob, TweetFanoutJob } from './timeline/interfaces/tweet-fanout-job.interface'; @@ -318,17 +318,13 @@ export class TweetsService { mentions: PlainMention[], hashtags: PlainHashtag[], media: MediaResponseDto[], - compactAuthorDto: CompactAuthorDto, + author: AuthorDto, createTweetDto: CreateTweetDto, referencedTweet: GetTweetResponseDto | undefined | null, ): GetTweetResponseDto { return { id: tweet.id.toString(), - author: { - username: compactAuthorDto.username, - displayName: compactAuthorDto.displayName, - avatarUrl: compactAuthorDto.avatarUrl, - }, + author, content: tweet.content, createdAt: tweet.createdAt, replyCount: 0, @@ -696,6 +692,7 @@ export class TweetsService { repostedBy: item.type === 'repost' ? { + id: requestedUser.id.toString(), username: requestedUser?.username || '', displayName: requestedUser.profile?.displayName || '', } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 562e46a2..98a18105 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -121,4 +121,11 @@ export class UsersController { async disableUserNotifications(@Param('username') username: string, @User() user: RequestUser) { return await this.usersService.disableUserNotifications(BigInt(user.id), username); } + + @Get('/id/:id') + @HttpCode(200) + @UseGuards(JwtAuthGuard) + async getUserById(@Param('id') id: string) { + return await this.usersService.getUserById(BigInt(id)); + } } diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 1a020317..c6b86de0 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -15,7 +15,7 @@ import { createValidationError } from 'src/common/utils'; import { BlocksCursor, FollowsCursor, MutesCursor } from 'src/common/interfaces'; import { PeopleSearchFilter } from 'src/search/dtos'; import { RankedUser } from './interfaces/ranked-user.interface'; -import { CompactAuthorDto } from 'src/tweets/dtos'; +import { AuthorDto } from 'src/tweets/dtos'; import { plainToClass } from 'class-transformer'; import { UserRelationshipDto } from './dtos/relationship-dto'; import { RefreshTokensService } from 'src/refresh-tokens/refresh-tokens.service'; @@ -1427,7 +1427,7 @@ export class UsersRepository { }); } - async findOwnTweetAuthorMetaData(userId: bigint): Promise { + async findOwnTweetAuthorMetaData(userId: bigint): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { @@ -1450,6 +1450,14 @@ export class UsersRepository { username: user.username, displayName: user.profile?.displayName || '', avatarUrl: user.profile?.avatarUrl, + relationship: { + // self relationship + blocking: false, + blockedBy: false, + following: false, + follower: false, + muted: false, + }, }; } @@ -1819,4 +1827,22 @@ export class UsersRepository { }, })); } + + async findUsernameAndDisplayNameById( + userId: bigint, + ): Promise<{ username: string; displayName: string } | null> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + username: true, + profile: { + select: { + displayName: true, + }, + }, + }, + }); + + return user ? { username: user.username, displayName: user.profile!.displayName } : null; + } } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 4eda218f..d2df93fa 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1119,4 +1119,18 @@ export class UsersService { async invalidateUserCache(userId: bigint) { await this.redisService.del(REDIS_TIMELINE_KEYS.getAuthorDataKey(userId)); } + + async getUserById(userId: bigint) { + const user = await this.usersRepository.findUsernameAndDisplayNameById(userId); + if (!user) { + throw new HttpException( + { + message: USERS_ERROR_MESSAGES.USER_NOT_FOUND, + code: USERS_ERROR_CODES.USER_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ); + } + return user; + } } From 2c00216e65b2dc6317cc3dfbe9e60a7a92977901 Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Mon, 15 Dec 2025 13:21:03 +0200 Subject: [PATCH 21/43] feat: timeline following tweet event - [CU-869bfz717] (#212) --- .../constants/redis-timeline-keys.constant.ts | 6 + src/sse/sse-events.service.ts | 9 ++ src/sse/sse.controller.ts | 2 +- src/sse/sse.service.ts | 29 ++++- .../timeline/constants/timeline.constants.ts | 3 + src/tweets/timeline/timeline.consumer.ts | 51 ++++++-- .../timeline/timeline.events.service.ts | 123 +++++++++--------- src/tweets/timeline/timeline.module.ts | 11 +- src/users/users.repository.ts | 14 ++ test/sse/sse.controller.spec.ts | 2 +- 10 files changed, 171 insertions(+), 79 deletions(-) diff --git a/src/common/constants/redis-timeline-keys.constant.ts b/src/common/constants/redis-timeline-keys.constant.ts index 56e437b8..865fea5a 100644 --- a/src/common/constants/redis-timeline-keys.constant.ts +++ b/src/common/constants/redis-timeline-keys.constant.ts @@ -16,6 +16,8 @@ export const REDIS_TIMELINE_KEYS: { getUserInteractionsRetweetItem: (tweetId: bigint) => string; getForYouFeedKey: (userId: bigint) => string; getForYouSeenKey: (userId: bigint) => string; + getSSEOnlineFollowingTimelineKey: () => string; + getNewTweetsIndicatorKey: (userId: bigint) => string; } = { getUserTimelineKey: (userId: bigint): string => { return `timeline:${userId}`; @@ -52,4 +54,8 @@ export const REDIS_TIMELINE_KEYS: { getForYouFeedKey: (userId: bigint): string => `foryou:${userId}:feed`, getForYouSeenKey: (userId: bigint): string => `foryou:${userId}:seen`, + + getSSEOnlineFollowingTimelineKey: (): string => 'sse:online:following_timeline', + + getNewTweetsIndicatorKey: (userId: bigint): string => `sse:timeline:new_tweets:${userId}`, }; diff --git a/src/sse/sse-events.service.ts b/src/sse/sse-events.service.ts index 56dc30cf..4796067e 100644 --- a/src/sse/sse-events.service.ts +++ b/src/sse/sse-events.service.ts @@ -19,6 +19,7 @@ export interface NewMessagePayload { export const SSE_EVENTS = { DM_UNSEEN_COUNT: 'dm.unseen_conversations_count', DM_NEW_MESSAGE: 'dm.new_message', + TIMELINE_FOLLOWING: 'timeline.following', } as const; @Injectable() @@ -85,4 +86,12 @@ export class SseEventsService { data: { count }, }); } + + async publishTimelineFollowingTweets(userId: bigint, authors: string[] | null): Promise { + this.logger.debug(`Publishing timeline-following update to user ${userId}`); + await this.publisher.publishToUser(userId.toString(), { + event: SSE_EVENTS.TIMELINE_FOLLOWING, + data: { authors }, + }); + } } diff --git a/src/sse/sse.controller.ts b/src/sse/sse.controller.ts index 34f73277..adb08968 100644 --- a/src/sse/sse.controller.ts +++ b/src/sse/sse.controller.ts @@ -15,7 +15,7 @@ interface SseEvent { data: unknown; } -const ALLOWED_TOPICS = ['dm', 'notifications']; +const ALLOWED_TOPICS = ['dm', 'notifications', 'timeline']; @Controller('stream') export class SseController { diff --git a/src/sse/sse.service.ts b/src/sse/sse.service.ts index c3596c73..e9a86fd4 100644 --- a/src/sse/sse.service.ts +++ b/src/sse/sse.service.ts @@ -3,6 +3,7 @@ import { Subject } from 'rxjs'; import { RedisService } from '../redis/redis.service'; import { Redis } from 'ioredis'; import { MAX_CONNECTIONS_PER_USER } from './constants/sse-constants'; +import { REDIS_TIMELINE_KEYS } from 'src/common/constants/redis-timeline-keys.constant'; interface SseConnection { subject: Subject; @@ -94,11 +95,15 @@ export class SseService implements OnModuleInit, OnModuleDestroy { } const newSubject = new Subject(); - - userConnections.push({ + const newConnection = { subject: newSubject, topics: new Set(topics), - }); + }; + userConnections.push(newConnection); + + if (newConnection.topics.has('timeline')) { + await this.pubClient.sadd(REDIS_TIMELINE_KEYS.getSSEOnlineFollowingTimelineKey(), userId); + } return newSubject; } @@ -115,12 +120,30 @@ export class SseService implements OnModuleInit, OnModuleDestroy { const index = userConnections.findIndex((c) => c.subject === subject); if (index === -1) return; + const connectionToRemove = userConnections[index]; + subject.complete(); userConnections.splice(index, 1); if (userConnections.length === 0) { this.connections.delete(userId); } + + if (connectionToRemove.topics.has('timeline')) { + const remainingConnections = this.connections.get(userId) || []; + const stillHasTimeline = remainingConnections.some((c) => c.topics.has('timeline')); // maybe connected to timeline through multiple devices + + if (!stillHasTimeline) { + this.pubClient + .srem(REDIS_TIMELINE_KEYS.getSSEOnlineFollowingTimelineKey(), userId) + .catch((err) => { + this.logger.error( + `Failed to remove user ${userId} from SSE online following timeline set`, + err, + ); + }); + } + } } getConnectionCount(userId: string): number { return this.connections.get(userId)?.length ?? 0; diff --git a/src/tweets/timeline/constants/timeline.constants.ts b/src/tweets/timeline/constants/timeline.constants.ts index e235a2f3..d3a2e996 100644 --- a/src/tweets/timeline/constants/timeline.constants.ts +++ b/src/tweets/timeline/constants/timeline.constants.ts @@ -16,3 +16,6 @@ export const FOR_YOU_FEED_FRESH_TTL = 2 * 60; // 2 minutes export const FOR_YOU_FEED_SCROLL_TTL = 1 * 60 * 60; // 1 hours (sliding window) export const FOR_YOU_SEEN_CACHE_TTL = 30 * 60; // 30 minutes (works with scrolling only, resets on refresh) export const FOR_YOU_FEED_SIZE = 800; + +export const NEW_TWEETS_INDICATOR_MAX_AUTHORS = 3; +export const NEW_TWEETS_INDICATOR_TTL = 5 * 60; // 5 minutes diff --git a/src/tweets/timeline/timeline.consumer.ts b/src/tweets/timeline/timeline.consumer.ts index c26f9af3..33cf8e4b 100644 --- a/src/tweets/timeline/timeline.consumer.ts +++ b/src/tweets/timeline/timeline.consumer.ts @@ -4,7 +4,11 @@ import { Job } from 'bullmq'; import { RedisService } from 'src/redis/redis.service'; import { UsersService } from 'src/users/users.service'; import { RetweetFanoutJob, TweetFanoutJob } from './interfaces/tweet-fanout-job.interface'; -import { TIMELINE_MAX_SIZE } from '../timeline/constants/timeline.constants'; +import { + NEW_TWEETS_INDICATOR_MAX_AUTHORS, + NEW_TWEETS_INDICATOR_TTL, + TIMELINE_MAX_SIZE, +} from '../timeline/constants/timeline.constants'; import { REDIS_TIMELINE_KEYS } from 'src/common/constants/redis-timeline-keys.constant'; import { TweetsRepository } from '../tweets.repository'; import { BackfillFollowJob } from './interfaces/backfill-follow-job.interface'; @@ -62,10 +66,25 @@ export class TimelineConsumer extends WorkerHost { const followerIds: string[] = (await this.usersService.getFollowersIds(BigInt(authorId))).map( (id) => id.toString(), ); - followerIds.unshift(authorId.toString()); + + const actorId = isRetweetFanoutJob(actionType, job.data) + ? BigInt(job.data.retweeterId) + : BigInt(authorId); + + const allRecipinetIds = [actorId.toString(), ...new Set(followerIds)]; + + // delete EMPTY_PLACEHOLDER for ALL the recipients + const deletePipeline = this.redisClient.pipeline(); + for (const userId of allRecipinetIds) { + const emptyPlaceholderKey = REDIS_TIMELINE_KEYS.getUserTimelineEmptyPlaceholderKey( + BigInt(userId), + ); + deletePipeline.del(emptyPlaceholderKey); + } + await deletePipeline.exec(); // Fanout should be to existing keys only (active users), those keys are created when the timeline cache misses, and persist for a configured time - const timelineKeys = followerIds.map((id) => + const timelineKeys = allRecipinetIds.map((id) => REDIS_TIMELINE_KEYS.getUserTimelineKey(BigInt(id)), ); const existingKeyspipeline = this.redisClient.pipeline(); @@ -77,7 +96,10 @@ export class TimelineConsumer extends WorkerHost { if (!existingKeysResults) { return; // though this never happens, at least the author timeline key exists } - const existingKeys = timelineKeys.filter((_, index) => existingKeysResults[index][1] === 1); + + const activeUserIds = allRecipinetIds.filter( + (_, index) => existingKeysResults[index][1] === 1, + ); const compositeId = isRetweetFanoutJob(actionType, job.data) ? REDIS_TIMELINE_KEYS.getTimelineRetweetItem( BigInt(authorId), @@ -87,12 +109,21 @@ export class TimelineConsumer extends WorkerHost { : REDIS_TIMELINE_KEYS.getTimelineTweetItem(BigInt(authorId), BigInt(tweetId)); const writePipeline = this.redisClient.pipeline(); - for (const key of existingKeys) { - writePipeline.del( - REDIS_TIMELINE_KEYS.getUserTimelineEmptyPlaceholderKey(BigInt(key.split(':')[1])), - ); // remove empty placeholder if exists - writePipeline.zadd(key, timestamp, compositeId); - writePipeline.zremrangebyrank(key, 0, -(TIMELINE_MAX_SIZE + 1)); // keep timeline size capped at TIMELINE_MAX_SIZE + + for (const userId of activeUserIds) { + const timelineKey = REDIS_TIMELINE_KEYS.getUserTimelineKey(BigInt(userId)); + const newTweetIndicatorKey = REDIS_TIMELINE_KEYS.getNewTweetsIndicatorKey(BigInt(userId)); + + writePipeline.zadd(timelineKey, timestamp, compositeId); + writePipeline.zremrangebyrank(timelineKey, 0, -(TIMELINE_MAX_SIZE + 1)); // keep timeline size capped at TIMELINE_MAX_SIZE + + writePipeline.zadd(newTweetIndicatorKey, timestamp, actorId.toString()); + writePipeline.zremrangebyrank( + newTweetIndicatorKey, + 0, + -(NEW_TWEETS_INDICATOR_MAX_AUTHORS + 1), + ); // only latest 3 indicators + writePipeline.expire(newTweetIndicatorKey, NEW_TWEETS_INDICATOR_TTL); } await writePipeline.exec(); diff --git a/src/tweets/timeline/timeline.events.service.ts b/src/tweets/timeline/timeline.events.service.ts index aeb1b2b0..cde9768e 100644 --- a/src/tweets/timeline/timeline.events.service.ts +++ b/src/tweets/timeline/timeline.events.service.ts @@ -1,74 +1,73 @@ -// import { Injectable, Logger } from '@nestjs/common'; -// import { Cron } from '@nestjs/schedule'; -// import { REDIS_TIMELINE_KEYS } from 'src/common/constants/redis-timeline-keys.constant'; -// import { RedisService } from 'src/redis/redis.service'; -// import { SseEventsService } from 'src/sse/sse-events.service'; -// import { UsersRepository } from 'src/users/users.repository'; -// import { NEW_TWEETS_INDICATOR_MAX_AUTHORS } from './constants'; +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { REDIS_TIMELINE_KEYS } from 'src/common/constants/redis-timeline-keys.constant'; +import { RedisService } from 'src/redis/redis.service'; +import { SseEventsService } from 'src/sse/sse-events.service'; +import { UsersRepository } from 'src/users/users.repository'; +import { NEW_TWEETS_INDICATOR_MAX_AUTHORS } from './constants'; -// @Injectable() -// export class TimelineEventsService { -// private readonly logger = new Logger(TimelineEventsService.name); -// private readonly redisClient; +@Injectable() +export class TimelineEventsService { + private readonly logger = new Logger(TimelineEventsService.name); + private readonly redisClient; -// constructor( -// private readonly redisService: RedisService, -// private readonly usersRepository: UsersRepository, -// private readonly sseEventsService: SseEventsService, -// ) { -// this.redisClient = redisService.getClient(); -// } + constructor( + private readonly redisService: RedisService, + private readonly usersRepository: UsersRepository, + private readonly sseEventsService: SseEventsService, + ) { + this.redisClient = redisService.getClient(); + } -// @Cron('0 */1 * * * *') -// async handleNewTweetsCheck() { -// this.logger.debug('Checking for online users with following timeline SSE connections'); + @Cron('0 */2 * * * *') + async handleNewTweetsCheck() { + this.logger.debug('Checking for online users with following timeline SSE connections'); -// const onlineUserIds = await this.redisClient.smembers( -// REDIS_TIMELINE_KEYS.getSSEOnlineFollowingTimelineKey(), -// ); + const onlineUserIds = await this.redisClient.smembers( + REDIS_TIMELINE_KEYS.getSSEOnlineFollowingTimelineKey(), + ); -// if (onlineUserIds.length === 0) { -// this.logger.debug('No users online for timeline events. Skipping check.'); -// return; -// } + if (onlineUserIds.length === 0) { + this.logger.debug('No users online for timeline events. Skipping check.'); + return; + } -// this.logger.debug(`Checking new tweet indicators for ${onlineUserIds.length} online users.`); + this.logger.debug(`Checking new tweet indicators for ${onlineUserIds.length} online users.`); -// for (const userId of onlineUserIds) { -// try { -// const indicatorKey = REDIS_TIMELINE_KEYS.getNewTweetsIndicatorKey(BigInt(userId)); + for (const userId of onlineUserIds) { + try { + const indicatorKey = REDIS_TIMELINE_KEYS.getNewTweetsIndicatorKey(BigInt(userId)); -// // prevents the race condition of getting a new tweet event while the cron job is running -// const transaction = this.redisClient.multi(); -// transaction.zrevrange(indicatorKey, 0, NEW_TWEETS_INDICATOR_MAX_AUTHORS - 1); -// transaction.del(indicatorKey); -// const execResult = await transaction.exec(); -// let newActorIds: string[] = []; -// // transaction result is [[err, Set], [err, number of deleted keys]] -// if (execResult && Array.isArray(execResult) && execResult[0] && !execResult[0][0]) { -// newActorIds = Array.isArray(execResult[0][1]) ? (execResult[0][1] as string[]) : []; -// } -// console.log(newActorIds); -// console.log(execResult); + // transaction prevents the race condition of getting a new tweet event while the cron job is running + const transaction = this.redisClient.multi(); + transaction.zrevrange(indicatorKey, 0, NEW_TWEETS_INDICATOR_MAX_AUTHORS - 1); + transaction.del(indicatorKey); + const execResult = await transaction.exec(); + let newActorIds: string[] = []; + // transaction result is [[err, Set], [err, number of deleted keys]] + if (execResult && Array.isArray(execResult) && execResult[0] && !execResult[0][0]) { + newActorIds = Array.isArray(execResult[0][1]) + ? (execResult[0][1] as string[]).slice(0, 3) + : []; + } -// if (newActorIds.length > 0) { -// const uniqueActorIds = [...new Set(newActorIds)].slice(0, 3); -// const authors = await this.usersRepository.findAvatarUrlsByUserIds( -// uniqueActorIds.map((id) => BigInt(id)), -// ); + if (newActorIds.length > 0) { + const authors = await this.usersRepository.findAvatarUrlsByUserIds( + newActorIds.map((id) => BigInt(id)), + ); -// const authorAvatarsOrdered = uniqueActorIds.map((id) => authors.get(id)!); + const authorAvatarsOrdered = newActorIds.map((id) => authors.get(id)!); -// await this.sseEventsService.publishTimelineFollowingTweets( -// BigInt(userId), -// authorAvatarsOrdered, -// ); + await this.sseEventsService.publishTimelineFollowingTweets( + BigInt(userId), + authorAvatarsOrdered, + ); -// await this.redisClient.del(indicatorKey); -// } -// } catch (error) { -// this.logger.error(`Failed to process new tweets indicator for user ${userId}`, error); -// } -// } -// } -// } + await this.redisClient.del(indicatorKey); + } + } catch (error) { + this.logger.error(`Failed to process new tweets indicator for user ${userId}`, error); + } + } + } +} diff --git a/src/tweets/timeline/timeline.module.ts b/src/tweets/timeline/timeline.module.ts index 6f768967..b3b905ef 100644 --- a/src/tweets/timeline/timeline.module.ts +++ b/src/tweets/timeline/timeline.module.ts @@ -5,10 +5,17 @@ import { BullModule } from '@nestjs/bullmq'; import { UsersModule } from 'src/users/users.module'; import { TweetsModule } from '../tweets.module'; import { TimelineConsumer } from './timeline.consumer'; +import { TimelineEventsService } from './timeline.events.service'; +import { SseModule } from 'src/sse/sse.module'; @Module({ - imports: [BullModule.registerQueue({ name: 'timeline-following' }), UsersModule, TweetsModule], - providers: [TimelineService, TimelineConsumer], + imports: [ + BullModule.registerQueue({ name: 'timeline-following' }), + UsersModule, + TweetsModule, + SseModule, + ], + providers: [TimelineService, TimelineConsumer, TimelineEventsService], controllers: [TimelineController], }) export class TimelineModule {} diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index c6b86de0..0f006e23 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { Prisma } from '@prisma/client'; import { NewUser } from './interfaces'; import { + DEFAULT_PROFILE_PICTURE, USER_SEARCH_RANKING_WEIGHTS, USERS_ERROR_CODES, USERS_ERROR_MESSAGES, @@ -1828,6 +1829,19 @@ export class UsersRepository { })); } + async findAvatarUrlsByUserIds(userIds: bigint[]): Promise> { + const profile = await this.prisma.profile.findMany({ + where: { userId: { in: userIds } }, + select: { avatarUrl: true, userId: true }, + }); + if (profile) { + return new Map( + profile.map((p) => [p.userId.toString(), p.avatarUrl || DEFAULT_PROFILE_PICTURE]), + ); + } + return new Map(); + } + async findUsernameAndDisplayNameById( userId: bigint, ): Promise<{ username: string; displayName: string } | null> { diff --git a/test/sse/sse.controller.spec.ts b/test/sse/sse.controller.spec.ts index cee486c0..708cd5f6 100644 --- a/test/sse/sse.controller.spec.ts +++ b/test/sse/sse.controller.spec.ts @@ -116,7 +116,7 @@ describe('SseController', () => { expect(mockRes.status).toHaveBeenCalledWith(400); expect(mockRes.json).toHaveBeenCalledWith({ - message: 'Invalid topics. Allowed: dm, notifications', + message: 'Invalid topics. Allowed: dm, notifications, timeline', code: 'INVALID_TOPICS', }); expect(mockSseService.subscribe).not.toHaveBeenCalled(); From d656675e3fd136aec25d524976a3f048f68b956b Mon Sep 17 00:00:00 2001 From: Omar Hassan <149178126+OmarHassan2003@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:42:15 +0200 Subject: [PATCH 22/43] test: add unit tests for trends, also updated the ignored paths to not include constants or index.ts files - [CU-869bfujhy] (#208) Co-authored-by: Loay Ahmed --- package.json | 7 +- src/trending/trending.service.spec.ts | 44 -- .../conversations/gateways/dm.gateway.spec.ts | 137 +++++ test/sse/sse-events.service.spec.ts | 142 ++++++ .../trending/trending.controller.spec.ts | 4 +- test/trending/trending.service.spec.ts | 475 ++++++++++++++++++ 6 files changed, 762 insertions(+), 47 deletions(-) delete mode 100644 src/trending/trending.service.spec.ts rename {src => test}/trending/trending.controller.spec.ts (82%) create mode 100644 test/trending/trending.service.spec.ts diff --git a/package.json b/package.json index ecb20abf..8b777661 100644 --- a/package.json +++ b/package.json @@ -170,7 +170,12 @@ "common/middleware/request-logger\\.middleware\\.ts", "common/pipes/parse-bigint\\.pipe\\.ts", "testing", - ".*\\.stress\\.js" + ".*\\.stress\\.js", + ".*index\\.ts", + ".*\\.interface\\.ts", + "interfaces", + "src/common/adapters/redis-io\\.adapter\\.ts", + "constants" ] } } diff --git a/src/trending/trending.service.spec.ts b/src/trending/trending.service.spec.ts deleted file mode 100644 index 266c8974..00000000 --- a/src/trending/trending.service.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { TrendingService } from './trending.service'; -import { TrendingRepository } from './trending.repository'; -import { PrismaService } from 'src/prisma/prisma.service'; - -describe('TrendingService', () => { - let service: TrendingService; - - const mockTrendingRepository = { - createOrIncrementHashtags: jest.fn(), - scaleDownAllScores: jest.fn(), - findKeywordByKeywordAndType: jest.fn(), - createKeywordWithCategories: jest.fn(), - upsertKeywordCategory: jest.fn(), - updateKeyword: jest.fn(), - deleteOldKeywords: jest.fn(), - deleteLowScoreCategories: jest.fn(), - runInTransaction: jest.fn(), - }; - - const mockPrismaService = {}; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - TrendingService, - { - provide: TrendingRepository, - useValue: mockTrendingRepository, - }, - { - provide: PrismaService, - useValue: mockPrismaService, - }, - ], - }).compile(); - - service = module.get(TrendingService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/test/conversations/gateways/dm.gateway.spec.ts b/test/conversations/gateways/dm.gateway.spec.ts index a5556b16..1991ecf3 100644 --- a/test/conversations/gateways/dm.gateway.spec.ts +++ b/test/conversations/gateways/dm.gateway.spec.ts @@ -347,6 +347,8 @@ describe('DmGateway', () => { }, ]); conversationsService.countUnseenConversations.mockResolvedValue(0); + sseEvents.publishUnseenCount.mockResolvedValue(); + sseEvents.publishNewMessagePreview.mockResolvedValue(); }); it('should successfully send message and emit to room', async () => { @@ -443,6 +445,22 @@ describe('DmGateway', () => { }); }); + it('should emit error when media is invalid', async () => { + conversationsService.assertParticipant.mockResolvedValue(true); + messagesService.createMessage.mockResolvedValue({ + error: 'INVALID_MEDIA', + }); + + await gateway.sendMessage(mockSocket, payload); + + expect(mockSocket.emit).toHaveBeenCalledWith('error', { + type: 'error', + code: CONVERSATIONS_ERROR_CODES.INVALID_MEDIA, + message: CONVERSATIONS_ERROR_MESSAGES.INVALID_MEDIA, + clientMessageId: 'client-msg-123', + }); + }); + it('should handle message with empty body', async () => { const emptyPayload = { ...payload, body: '' }; @@ -453,6 +471,32 @@ describe('DmGateway', () => { expect(messagesService.createMessage).toHaveBeenCalledWith('2', '6', '', undefined); }); + + it('should publish unseen count to other participant when message is sent', async () => { + conversationsService.assertParticipant.mockResolvedValue(true); + conversationsService.getConversationParticipants.mockResolvedValue([ + { + user: { + id: BigInt(6), + username: 'layla', + profile: { displayName: 'Layla', avatarUrl: 'https://example.com/avatar.jpg' }, + }, + }, + { + user: { + id: BigInt(7), + username: 'tasneem', + profile: { displayName: 'Tasneem', avatarUrl: 'https://example.com/tasneem.jpg' }, + }, + }, + ]); + messagesService.createMessage.mockResolvedValue({ message: mockMessage }); + + await gateway.sendMessage(mockSocket, payload); + + expect(sseEvents.publishUnseenCount).toHaveBeenCalledWith(BigInt(7), 0); + expect(sseEvents.publishNewMessagePreview).toHaveBeenCalled(); + }); }); describe('room management', () => { @@ -564,6 +608,61 @@ describe('DmGateway', () => { expect(mockSocket.to).not.toHaveBeenCalled(); }); + + it('should emit typing_stop to previous conversation when switching rooms while typing', async () => { + mockSocket.data = { + user: mockUser, + currentConversationId: '1', + isTyping: true, + }; + + conversationsService.assertParticipant.mockResolvedValue(true); + + await gateway.typingStart(mockSocket, payload); + + expect(mockServer.to).toHaveBeenCalledWith('1'); + expect(mockServer.emit).toHaveBeenCalledWith('user_typing_stop', { + conversationId: '1', + username: mockUser.username, + }); + expect(mockSocket.leave).toHaveBeenCalledWith('1'); + expect(mockSocket.join).toHaveBeenCalledWith('2'); + }); + + it('should not emit typing event if already typing in same conversation', async () => { + const emitMock = jest.fn(); + const toMock = jest.fn().mockReturnValue({ + emit: emitMock, + }); + mockSocket.to = toMock; + mockSocket.data = { + user: mockUser, + currentConversationId: '2', + isTyping: true, + }; + + conversationsService.assertParticipant.mockResolvedValue(true); + + await gateway.typingStart(mockSocket, payload); + + expect(emitMock).not.toHaveBeenCalled(); + }); + + it('should join new conversation room when switching', async () => { + mockSocket.data = { + user: mockUser, + currentConversationId: '1', + isTyping: false, + }; + + conversationsService.assertParticipant.mockResolvedValue(true); + + await gateway.typingStart(mockSocket, payload); + + expect(mockSocket.leave).toHaveBeenCalledWith('1'); + expect(mockSocket.join).toHaveBeenCalledWith('2'); + expect(mockSocket.data).toHaveProperty('currentConversationId', '2'); + }); }); describe('typing_stop', () => { @@ -638,6 +737,23 @@ describe('DmGateway', () => { const eventData = callArgs?.[1] as Record; expect(Object.keys(eventData || {})).toHaveLength(2); }); + + it('should join new conversation room when switching conversations', async () => { + mockSocket.data = { + user: mockUser, + currentConversationId: '1', + isTyping: true, + }; + + conversationsService.assertParticipant.mockResolvedValue(true); + + await gateway.typingStop(mockSocket, { conversationId: '2' }); + + expect(mockSocket.leave).toHaveBeenCalledWith('1'); + expect(mockSocket.join).toHaveBeenCalledWith('2'); + expect(mockSocket.data).toHaveProperty('currentConversationId', '2'); + expect(mockSocket.data).toHaveProperty('isTyping', false); + }); }); describe('reaction', () => { @@ -836,5 +952,26 @@ describe('DmGateway', () => { payload.conversationId, ); }); + + it('should join new conversation room when switching conversations', async () => { + mockSocket.data = { + user: mockUser, + currentConversationId: '1', + }; + + conversationsService.assertParticipant.mockResolvedValue(true); + + messagesService.addReactionToMessage.mockResolvedValue({ + reactionDb: mockReactionDb, + sender: mockSender, + receiver: mockReceiver, + } as never); + + await gateway.reactToMessage(mockSocket, payload); + + expect(mockSocket.leave).toHaveBeenCalledWith('1'); + expect(mockSocket.join).toHaveBeenCalledWith('2'); + expect(mockSocket.data).toHaveProperty('currentConversationId', '2'); + }); }); }); diff --git a/test/sse/sse-events.service.spec.ts b/test/sse/sse-events.service.spec.ts index da6073bb..409691e7 100644 --- a/test/sse/sse-events.service.spec.ts +++ b/test/sse/sse-events.service.spec.ts @@ -2,6 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SseEventsService, SSE_EVENTS, NewMessagePayload } from '../../src/sse/sse-events.service'; import { EventPublisherService } from '../../src/sse/event-publisher.service'; +import { NotificationResponseDto } from '../../src/notifications/dtos/notification-response.dto'; +import { NotificationType } from '@prisma/client'; describe('SseEventsService', () => { let service: SseEventsService; @@ -62,6 +64,7 @@ describe('SseEventsService', () => { }, bodySnippet: 'Hello, this is a test message', createdAt: new Date('2024-01-01T00:00:00Z'), + hasMedia: false, }; it('should publish new message preview to user with correct event name', async () => { @@ -91,6 +94,145 @@ describe('SseEventsService', () => { }); }); + describe('publishNewNotification', () => { + it('should publish new notification and count update', async () => { + const receiverId = BigInt(789); + const notification: NotificationResponseDto = { + id: '1', + type: NotificationType.LIKE, + actorSummary: { previewActors: [], totalCount: 1 }, + tweetSummary: { primaryTweet: null, totalCount: 0, subjectIds: [] }, + latestEventAt: new Date('2024-01-01T00:00:00Z'), + isSeen: false, + }; + const updatedCount = 10; + + await service.publishNewNotification(receiverId, notification, updatedCount); + + expect(publisherMock.publishToUser).toHaveBeenCalledTimes(2); + expect(publisherMock.publishToUser).toHaveBeenNthCalledWith(1, '789', { + event: 'notifications.new', + data: notification, + }); + expect(publisherMock.publishToUser).toHaveBeenNthCalledWith(2, '789', { + event: 'notifications.count_update', + data: { count: 10 }, + }); + }); + + it('should handle zero notification count', async () => { + const receiverId = BigInt(111); + const notification: NotificationResponseDto = { + id: '2', + type: NotificationType.FOLLOW, + actorSummary: { previewActors: [], totalCount: 1 }, + tweetSummary: { primaryTweet: null, totalCount: 0, subjectIds: [] }, + latestEventAt: new Date('2024-01-01T00:00:00Z'), + isSeen: false, + }; + const updatedCount = 0; + + await service.publishNewNotification(receiverId, notification, updatedCount); + + expect(publisherMock.publishToUser).toHaveBeenNthCalledWith(2, '111', { + event: 'notifications.count_update', + data: { count: 0 }, + }); + }); + }); + + describe('publishNotificationSeen', () => { + it('should publish single notification seen event', async () => { + const receiverId = BigInt(222); + const notificationId = BigInt(333); + const unSeenCount = 5; + + await service.publishNotificationSeen(receiverId, notificationId, unSeenCount); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('222', { + event: 'notifications.seen', + data: { + notificationId: '333', + scope: 'SINGLE', + unSeenCount: 5, + }, + }); + }); + + it('should publish all notifications seen when no notificationId provided', async () => { + const receiverId = BigInt(444); + const unSeenCount = 0; + + await service.publishNotificationSeen(receiverId, undefined, unSeenCount); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('444', { + event: 'notifications.seen', + data: { + notificationId: null, + scope: 'ALL', + unSeenCount: 0, + }, + }); + }); + + it('should default unSeenCount to 0 when not provided', async () => { + const receiverId = BigInt(555); + const notificationId = BigInt(666); + + await service.publishNotificationSeen(receiverId, notificationId); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('555', { + event: 'notifications.seen', + data: { + notificationId: '666', + scope: 'SINGLE', + unSeenCount: 0, + }, + }); + }); + + it('should handle marking all as seen without count', async () => { + const receiverId = BigInt(777); + + await service.publishNotificationSeen(receiverId); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('777', { + event: 'notifications.seen', + data: { + notificationId: null, + scope: 'ALL', + unSeenCount: 0, + }, + }); + }); + }); + + describe('publishUnseenNotificationCount', () => { + it('should publish unseen notification count to user', async () => { + const userId = BigInt(888); + const count = 20; + + await service.publishUnseenNotificationCount(userId, count); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('888', { + event: 'notifications.count_update', + data: { count: 20 }, + }); + }); + + it('should handle zero unseen notification count', async () => { + const userId = BigInt(999); + const count = 0; + + await service.publishUnseenNotificationCount(userId, count); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('999', { + event: 'notifications.count_update', + data: { count: 0 }, + }); + }); + }); + describe('SSE_EVENTS constants', () => { it('should have correct event names', () => { expect(SSE_EVENTS.DM_UNSEEN_COUNT).toBe('dm.unseen_conversations_count'); diff --git a/src/trending/trending.controller.spec.ts b/test/trending/trending.controller.spec.ts similarity index 82% rename from src/trending/trending.controller.spec.ts rename to test/trending/trending.controller.spec.ts index d77bc640..f1183f95 100644 --- a/src/trending/trending.controller.spec.ts +++ b/test/trending/trending.controller.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { TrendingController } from './trending.controller'; -import { TrendingService } from './trending.service'; +import { TrendingController } from '../../src/trending/trending.controller'; +import { TrendingService } from '../../src/trending/trending.service'; describe('TrendingController', () => { let controller: TrendingController; diff --git a/test/trending/trending.service.spec.ts b/test/trending/trending.service.spec.ts new file mode 100644 index 00000000..118b9331 --- /dev/null +++ b/test/trending/trending.service.spec.ts @@ -0,0 +1,475 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { Test, TestingModule } from '@nestjs/testing'; +import { TrendingService } from '../../src/trending/trending.service'; +import { TrendingRepository } from '../../src/trending/trending.repository'; +import { PrismaService } from '../../src/prisma/prisma.service'; +import { Categories, Prisma } from '@prisma/client'; +import { PlainHashtag } from '../../src/tweets/interfaces'; +import { UpdateTrendScoresDto } from '../../src/trending/dtos'; + +describe('TrendingService', () => { + let service: TrendingService; + let repository: jest.Mocked; + + beforeEach(async () => { + const mockRepository = { + createOrGetHashtags: jest.fn(), + getHashtagId: jest.fn(), + getTopWords: jest.fn(), + scaleDownAllScores: jest.fn(), + deleteOldKeywords: jest.fn(), + deleteLowScoreCategories: jest.fn(), + findKeywordByKeywordAndType: jest.fn(), + createKeywordWithCategories: jest.fn(), + upsertKeywordCategory: jest.fn(), + updateKeyword: jest.fn(), + }; + + const mockPrisma: Partial = { + $transaction: jest.fn().mockImplementation((callback: unknown) => { + if (typeof callback === 'function') { + return Promise.resolve((callback as (tx: unknown) => unknown)(mockPrisma)); + } + return Promise.resolve([]); + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TrendingService, + { provide: TrendingRepository, useValue: mockRepository }, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + service = module.get(TrendingService); + repository = module.get(TrendingRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createOrGetHashtags', () => { + it('should delegate to repository', async () => { + const hashtags: PlainHashtag[] = [ + { keyword: 'test', startPosition: 0 }, + { keyword: 'hashtag', startPosition: 10 }, + ]; + const tx = {} as Prisma.TransactionClient; + const expected = hashtags.map((h) => ({ ...h, hashtagId: BigInt(1) })); + + repository.createOrGetHashtags.mockResolvedValue(expected); + + const result = await service.createOrGetHashtags(hashtags, tx); + + expect(repository.createOrGetHashtags).toHaveBeenCalledWith(hashtags, tx); + expect(result).toEqual(expected); + }); + }); + + describe('getHashtagId', () => { + it('should return hashtag id if exists', async () => { + const hashtag = 'test'; + const expected = { id: BigInt(123) }; + + repository.getHashtagId.mockResolvedValue(expected); + + const result = await service.getHashtagId(hashtag); + + expect(repository.getHashtagId).toHaveBeenCalledWith(hashtag); + expect(result).toEqual(expected); + }); + + it('should return null if hashtag does not exist', async () => { + const hashtag = 'nonexistent'; + + repository.getHashtagId.mockResolvedValue(null); + + const result = await service.getHashtagId(hashtag); + + expect(repository.getHashtagId).toHaveBeenCalledWith(hashtag); + expect(result).toBeNull(); + }); + }); + + describe('getTrendingWords', () => { + it('should return empty array for empty query', async () => { + const result = await service.getTrendingWords('', 10); + + expect(result).toEqual([]); + expect(repository.getTopWords).not.toHaveBeenCalled(); + }); + + it('should return empty array for whitespace-only query', async () => { + const result = await service.getTrendingWords(' ', 10); + + expect(result).toEqual([]); + expect(repository.getTopWords).not.toHaveBeenCalled(); + }); + + it('should return empty array for query without alphanumeric characters', async () => { + const result = await service.getTrendingWords('!!!', 10); + + expect(result).toEqual([]); + expect(repository.getTopWords).not.toHaveBeenCalled(); + }); + + it('should fetch and format trending words for regular query', async () => { + const query = 'test'; + const limit = 5; + const mockResults = [ + { keyword: 'testing', isHashtag: false }, + { keyword: 'testcase', isHashtag: false }, + ]; + + repository.getTopWords.mockResolvedValue(mockResults); + + const result = await service.getTrendingWords(query, limit); + + expect(repository.getTopWords).toHaveBeenCalledWith('test', limit, false); + expect(result).toEqual(['testing', 'testcase']); + }); + + it('should handle hashtag query by removing # and marking as hashtag', async () => { + const query = '#trending'; + const limit = 10; + const mockResults = [ + { keyword: 'trending', isHashtag: true }, + { keyword: 'trendingtoday', isHashtag: true }, + ]; + + repository.getTopWords.mockResolvedValue(mockResults); + + const result = await service.getTrendingWords(query, limit); + + expect(repository.getTopWords).toHaveBeenCalledWith('trending', limit, true); + expect(result).toEqual(['#trending', '#trendingtoday']); + }); + + it('should handle URL-encoded query', async () => { + const query = 'test%20query'; + const limit = 5; + const mockResults = [{ keyword: 'testquery', isHashtag: false }]; + + repository.getTopWords.mockResolvedValue(mockResults); + + const result = await service.getTrendingWords(query, limit); + + expect(repository.getTopWords).toHaveBeenCalledWith('test query', limit, false); + expect(result).toEqual(['testquery']); + }); + + it('should use original query if decoding fails', async () => { + const query = '%E0%A4%A'; + const limit = 5; + const mockResults: { keyword: string; isHashtag: boolean }[] = []; + + repository.getTopWords.mockResolvedValue(mockResults); + + const result = await service.getTrendingWords(query, limit); + + expect(repository.getTopWords).toHaveBeenCalledWith(query, limit, false); + expect(result).toEqual([]); + }); + }); + + describe('updateTrendScores', () => { + it('should process trend score updates successfully', async () => { + const data: UpdateTrendScoresDto = { + trending_keywords: [ + { + keyword: '#test', + top_related_topics: [{ topic: 'SPORTS', trend_score: 10, occurence_in_category: 5 }], + }, + ], + batch_meta: { total_tweets: 100 }, + }; + + repository.scaleDownAllScores.mockResolvedValue(undefined); + repository.findKeywordByKeywordAndType.mockResolvedValue(null); + repository.createKeywordWithCategories.mockResolvedValue({ + id: BigInt(1), + keyword: 'test', + lastUpdatedAt: new Date(), + isHashtag: true, + count: 5, + overallScore: 10, + createdAt: new Date(), + } as unknown as never); + repository.deleteOldKeywords.mockResolvedValue(undefined); + repository.deleteLowScoreCategories.mockResolvedValue(undefined); + + const result = await service.updateTrendScores(data); + + expect(result).toEqual({ message: 'Trend scores updated successfully' }); + expect(repository.scaleDownAllScores).toHaveBeenCalled(); + expect(repository.deleteOldKeywords).toHaveBeenCalled(); + expect(repository.deleteLowScoreCategories).toHaveBeenCalled(); + }); + + it('should create new keyword when it does not exist', async () => { + const data: UpdateTrendScoresDto = { + trending_keywords: [ + { + keyword: '#newkeyword', + top_related_topics: [ + { topic: 'NEWS', trend_score: 20, occurence_in_category: 10 }, + { topic: 'SPORTS', trend_score: 15, occurence_in_category: 8 }, + ], + }, + ], + batch_meta: { total_tweets: 100 }, + }; + + repository.scaleDownAllScores.mockResolvedValue(undefined); + repository.findKeywordByKeywordAndType.mockResolvedValue(null); + repository.createKeywordWithCategories.mockResolvedValue({ + id: BigInt(1), + keyword: 'newkeyword', + lastUpdatedAt: new Date(), + isHashtag: true, + count: 18, + overallScore: 35, + createdAt: new Date(), + } as unknown as never); + repository.deleteOldKeywords.mockResolvedValue(undefined); + repository.deleteLowScoreCategories.mockResolvedValue(undefined); + + await service.updateTrendScores(data); + + const categoryScores: unknown = expect.arrayContaining([ + expect.objectContaining({ + category: Categories.NEWS, + score: 20, + categoryOccurenceCount: 10, + }), + expect.objectContaining({ + category: Categories.SPORTS, + score: 15, + categoryOccurenceCount: 8, + }), + ]); + + expect(repository.createKeywordWithCategories).toHaveBeenCalledWith( + expect.objectContaining({ + keyword: 'newkeyword', + isHashtag: true, + overallScore: 35, + count: 18, + categoryScores, + }), + expect.any(Object), + ); + }); + + it('should update existing keyword with new scores', async () => { + const data: UpdateTrendScoresDto = { + trending_keywords: [ + { + keyword: 'existing', + top_related_topics: [{ topic: 'NEWS', trend_score: 10, occurence_in_category: 5 }], + }, + ], + batch_meta: { total_tweets: 100 }, + }; + + const existingKeyword = { + id: BigInt(1), + keyword: 'existing', + isHashtag: false, + overallScore: 30, + count: 20, + lastUpdatedAt: new Date(), + createdAt: new Date(), + categoryScores: [ + { + id: BigInt(1), + trendingKeywordId: BigInt(1), + category: Categories.NEWS, + score: 30, + categoryOccurenceCount: 15, + }, + ], + }; + + repository.scaleDownAllScores.mockResolvedValue(undefined); + repository.findKeywordByKeywordAndType.mockResolvedValue(existingKeyword); + repository.upsertKeywordCategory.mockResolvedValue({ + id: BigInt(1), + trendingKeywordId: BigInt(1), + category: Categories.NEWS, + score: 40, + categoryOccurenceCount: 20, + } as unknown as never); + repository.updateKeyword.mockResolvedValue(existingKeyword as unknown as never); + repository.deleteOldKeywords.mockResolvedValue(undefined); + repository.deleteLowScoreCategories.mockResolvedValue(undefined); + + await service.updateTrendScores(data); + + expect(repository.upsertKeywordCategory).toHaveBeenCalledWith( + expect.objectContaining({ + trendingKeywordId: BigInt(1), + category: Categories.NEWS, + score: 40, + categoryOccurenceCount: 20, + }), + expect.any(Object), + ); + + expect(repository.updateKeyword).toHaveBeenCalledWith( + BigInt(1), + expect.objectContaining({ + overallScore: 40, + count: 25, + }), + expect.any(Object), + ); + }); + + it('should handle multiple categories for existing keyword', async () => { + const data: UpdateTrendScoresDto = { + trending_keywords: [ + { + keyword: 'multi', + top_related_topics: [ + { topic: 'SPORTS', trend_score: 5, occurence_in_category: 3 }, + { topic: 'ENTERTAINMENT', trend_score: 8, occurence_in_category: 4 }, + ], + }, + ], + batch_meta: { total_tweets: 100 }, + }; + + const existingKeyword = { + id: BigInt(2), + keyword: 'multi', + isHashtag: false, + overallScore: 10, + count: 10, + lastUpdatedAt: new Date(), + createdAt: new Date(), + categoryScores: [ + { + id: BigInt(1), + trendingKeywordId: BigInt(2), + category: Categories.SPORTS, + score: 10, + categoryOccurenceCount: 5, + }, + ], + }; + + repository.scaleDownAllScores.mockResolvedValue(undefined); + repository.findKeywordByKeywordAndType.mockResolvedValue(existingKeyword); + repository.upsertKeywordCategory.mockResolvedValue({ + id: BigInt(1), + trendingKeywordId: BigInt(2), + category: Categories.SPORTS, + score: 15, + categoryOccurenceCount: 8, + } as unknown as never); + repository.updateKeyword.mockResolvedValue(existingKeyword as unknown as never); + repository.deleteOldKeywords.mockResolvedValue(undefined); + repository.deleteLowScoreCategories.mockResolvedValue(undefined); + + await service.updateTrendScores(data); + + expect(repository.upsertKeywordCategory).toHaveBeenCalledTimes(2); + expect(repository.upsertKeywordCategory).toHaveBeenCalledWith( + expect.objectContaining({ + category: Categories.SPORTS, + score: 15, + categoryOccurenceCount: 8, + }), + expect.any(Object), + ); + expect(repository.upsertKeywordCategory).toHaveBeenCalledWith( + expect.objectContaining({ + category: Categories.ENTERTAINMENT, + score: 8, + categoryOccurenceCount: 4, + }), + expect.any(Object), + ); + }); + + it('should normalize hashtag keywords to lowercase', async () => { + const data: UpdateTrendScoresDto = { + trending_keywords: [ + { + keyword: '#TeSt', + top_related_topics: [{ topic: 'NEWS', trend_score: 10, occurence_in_category: 5 }], + }, + ], + batch_meta: { total_tweets: 100 }, + }; + + repository.scaleDownAllScores.mockResolvedValue(undefined); + repository.findKeywordByKeywordAndType.mockResolvedValue(null); + repository.createKeywordWithCategories.mockResolvedValue({ + id: BigInt(1), + keyword: 'test', + lastUpdatedAt: new Date(), + isHashtag: true, + count: 5, + overallScore: 10, + createdAt: new Date(), + } as unknown as never); + repository.deleteOldKeywords.mockResolvedValue(undefined); + repository.deleteLowScoreCategories.mockResolvedValue(undefined); + + await service.updateTrendScores(data); + + expect(repository.findKeywordByKeywordAndType).toHaveBeenCalledWith( + 'test', + true, + expect.any(Object), + ); + expect(repository.createKeywordWithCategories).toHaveBeenCalledWith( + expect.objectContaining({ + keyword: 'test', + isHashtag: true, + }), + expect.any(Object), + ); + }); + + it('should handle non-hashtag keywords', async () => { + const data: UpdateTrendScoresDto = { + trending_keywords: [ + { + keyword: 'regular', + top_related_topics: [{ topic: 'NEWS', trend_score: 5, occurence_in_category: 2 }], + }, + ], + batch_meta: { total_tweets: 100 }, + }; + + repository.scaleDownAllScores.mockResolvedValue(undefined); + repository.findKeywordByKeywordAndType.mockResolvedValue(null); + repository.createKeywordWithCategories.mockResolvedValue({ + id: BigInt(1), + keyword: 'regular', + lastUpdatedAt: new Date(), + isHashtag: false, + count: 2, + overallScore: 5, + createdAt: new Date(), + } as unknown as never); + repository.deleteOldKeywords.mockResolvedValue(undefined); + repository.deleteLowScoreCategories.mockResolvedValue(undefined); + + await service.updateTrendScores(data); + + expect(repository.createKeywordWithCategories).toHaveBeenCalledWith( + expect.objectContaining({ + keyword: 'regular', + isHashtag: false, + }), + expect.any(Object), + ); + }); + }); +}); From 258d32b8fd82e90afd890a74e5c4dae1fb6a71fc Mon Sep 17 00:00:00 2001 From: Tasneem Mohammed <149891742+Tasneemmohammed0@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:09:30 +0200 Subject: [PATCH 23/43] perf(search): enhance user search performance and refine "top" tab ranking evaluation (#207) --- bruno/collections/search/search-users.bru | 6 +- .../migration.sql | 7 + src/common/types/cursors/cursor.type.ts | 27 ++- src/search/search.service.ts | 130 ++++++++--- src/tweets/dtos/get-tweet-response.dto.ts | 4 + src/tweets/tweets.repository.ts | 205 +++++++++++++----- src/tweets/tweets.service.ts | 31 ++- src/users/constants/users.ts | 3 +- src/users/users.repository.ts | 141 +++++++----- src/users/users.service.ts | 6 +- src/users/utils/user-search-query.util.ts | 14 ++ test/search/search.service.spec.ts | 13 +- test/tweets/tweets.service.spec.ts | 80 +++++-- 13 files changed, 492 insertions(+), 175 deletions(-) create mode 100644 prisma/migrations/20251214183950_add_user_search_fts_indexes/migration.sql create mode 100644 src/users/utils/user-search-query.util.ts diff --git a/bruno/collections/search/search-users.bru b/bruno/collections/search/search-users.bru index 0debca13..293bea34 100644 --- a/bruno/collections/search/search-users.bru +++ b/bruno/collections/search/search-users.bru @@ -5,13 +5,15 @@ meta { } get { - url: http://localhost:3000/search/users?query=oma + url: http://localhost:3000/search/users?query=omar&limit=6&cursor=eyJyYW5raW5nU2NvcmUiOiIxMDMwMzk2IiwiaWQiOiIyNyJ9 body: none auth: bearer } params:query { - query: oma + query: omar + limit: 6 + cursor: eyJyYW5raW5nU2NvcmUiOiIxMDMwMzk2IiwiaWQiOiIyNyJ9 } auth:bearer { diff --git a/prisma/migrations/20251214183950_add_user_search_fts_indexes/migration.sql b/prisma/migrations/20251214183950_add_user_search_fts_indexes/migration.sql new file mode 100644 index 00000000..96d5fdf0 --- /dev/null +++ b/prisma/migrations/20251214183950_add_user_search_fts_indexes/migration.sql @@ -0,0 +1,7 @@ +-- users username FTS index +CREATE INDEX IF NOT EXISTS username_search_fts_idx +ON users USING GIN (to_tsvector('simple', username)); + +-- profiles display_name FTS index +CREATE INDEX IF NOT EXISTS user_display_name_search_fts_idx +ON profiles USING GIN (to_tsvector('simple', display_name)); diff --git a/src/common/types/cursors/cursor.type.ts b/src/common/types/cursors/cursor.type.ts index b61d6624..5a495ba3 100644 --- a/src/common/types/cursors/cursor.type.ts +++ b/src/common/types/cursors/cursor.type.ts @@ -1,5 +1,26 @@ -import { TweetDto } from 'src/tweets/dtos'; - -export type TweetRelationsCursor = Pick; export type UserInteractionsCursor = { userId: string; tweetId: string }; export type UserSearchCursor = { rankingScore: bigint; id: string }; + +export type TweetRelationsCursor = { + type?: 'relations'; + createdAt: Date; + id: string; +}; + +export type TweetRankCursor = { + type?: 'rank'; + rank: string; + id: string; +}; + +export function isTweetRankCursor( + cursor: TweetRankCursor | TweetRelationsCursor | undefined, +): cursor is TweetRankCursor { + return cursor ? cursor.type === 'rank' && 'rank' in cursor : false; +} + +export function isTweetRelationsCursor( + cursor: TweetRankCursor | TweetRelationsCursor | undefined, +): cursor is TweetRelationsCursor { + return cursor ? cursor.type === 'relations' && 'createdAt' in cursor : false; +} diff --git a/src/search/search.service.ts b/src/search/search.service.ts index aeb67c1f..088f18c4 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -12,7 +12,13 @@ import { isSingleHashtagQuery, prepareSearchQuery, } from './utils/search-query.util'; -import { TweetRelationsCursor, UserSearchCursor } from 'src/common/types/cursors'; +import { + isTweetRankCursor, + isTweetRelationsCursor, + TweetRankCursor, + TweetRelationsCursor, + UserSearchCursor, +} from 'src/common/types/cursors'; import { SearchUsersQueryDto } from './dtos/search-users-query.dto'; import { mapToUserSearchResultDto } from './mappers/user-search-result.mapper'; import { PAGINATION_ERROR_CODES, PAGINATION_ERROR_MESSAGES } from 'src/common/constants'; @@ -89,9 +95,10 @@ export class SearchService { }; } + const isRelevanceSearch = !tab || tab === SearchTab.Top || tab == SearchTab.Media; const isHashtagSearch = isSingleHashtagQuery(rawQuery); const cleanedQuery = isHashtagSearch ? extractHashtag(rawQuery) : prepareSearchQuery(rawQuery); - const decodedCursor = this.decodeCursor(prevCursor); + const decodedCursor = this.decodeCursor(prevCursor, isRelevanceSearch); const items = await this.fetchTweetsByTab( tab, @@ -104,23 +111,42 @@ export class SearchService { peopleFilter, ); - const pagination = paginateComposite(items, limit, prevCursor, (tweet) => { - return { - createdAt: tweet.createdAt, - id: tweet.id.toString(), - }; - }); + // Create cursor with correct field based on search type + const pagination = isRelevanceSearch + ? paginateComposite(items, limit, prevCursor, (tweet) => { + return { + type: 'rank', + rank: tweet.rank?.toString(), + id: tweet.id.toString(), + } as TweetRankCursor; + }) + : paginateComposite(items, limit, prevCursor, (tweet) => { + return { + type: 'relations', + createdAt: tweet.createdAt, + id: tweet.id.toString(), + } as TweetRelationsCursor; + }); this.logger.log(`Fetched ${items.length} top tweets for query: ${query}`); return { items, pagination }; } - private decodeCursor(prevCursor?: string): TweetRelationsCursor | undefined { + /** + * Decodes cursor and validates it matches the expected search type + * Throws error if cursor is invalid or wrong type for search mode + */ + private decodeCursor( + prevCursor?: string, + isRelevanceSearch: boolean = true, + ): TweetRankCursor | TweetRelationsCursor | undefined { if (!prevCursor) return undefined; + let decoded: TweetRankCursor | TweetRelationsCursor | undefined; + try { - return decodeCompositeCursor(prevCursor); + decoded = decodeCompositeCursor(prevCursor); } catch { throw new HttpException( { @@ -130,6 +156,29 @@ export class SearchService { HttpStatus.BAD_REQUEST, ); } + + // Validate cursor type matches search mode + if (isRelevanceSearch && !isTweetRankCursor(decoded)) { + throw new HttpException( + { + message: PAGINATION_ERROR_MESSAGES.INVALID_CURSOR, + code: PAGINATION_ERROR_CODES.INVALID_CURSOR, + }, + HttpStatus.BAD_REQUEST, + ); + } + + if (!isRelevanceSearch && !isTweetRelationsCursor(decoded)) { + throw new HttpException( + { + message: PAGINATION_ERROR_MESSAGES.INVALID_CURSOR, + code: PAGINATION_ERROR_CODES.INVALID_CURSOR, + }, + HttpStatus.BAD_REQUEST, + ); + } + + return decoded; } private async fetchTweetsByTab( @@ -138,7 +187,7 @@ export class SearchService { cleanedQuery: string, currentUserId: bigint, limit: number, - decodedCursor: TweetRelationsCursor | undefined, + decodedCursor: TweetRelationsCursor | TweetRankCursor | undefined, excludeMutedAndBlocked: boolean = false, peopleFilter?: PeopleSearchFilter, ): Promise { @@ -150,40 +199,57 @@ export class SearchService { currentUserId, limit, withMedia, - decodedCursor, + decodedCursor as TweetRelationsCursor | undefined, excludeMutedAndBlocked, peopleFilter, ); } - return withMedia - ? this.tweetsService.getTweetsWithMediaByQuery( - currentUserId, - cleanedQuery, - limit, - decodedCursor, - excludeMutedAndBlocked, - peopleFilter, - ) - : this.tweetsService.getTopTweetsByQuery( - currentUserId, - cleanedQuery, - limit, - decodedCursor, - excludeMutedAndBlocked, - peopleFilter, - ); + if (tab === SearchTab.Latest) { + return this.tweetsService.getLatestTweetsByQuery( + currentUserId, + cleanedQuery, + limit, + decodedCursor as TweetRelationsCursor | undefined, + excludeMutedAndBlocked, + peopleFilter, + ); + } else if (tab === SearchTab.Media) { + return this.tweetsService.getTweetsWithMediaByQuery( + currentUserId, + cleanedQuery, + limit, + decodedCursor as TweetRankCursor | undefined, + excludeMutedAndBlocked, + peopleFilter, + ); + } else { + return this.tweetsService.getTopTweetsByQuery( + currentUserId, + cleanedQuery, + limit, + decodedCursor as TweetRankCursor | undefined, + excludeMutedAndBlocked, + peopleFilter, + ); + } } async searchUsers( currentUserId: bigint, searchUsersQueryDto: SearchUsersQueryDto, - limit: number, + limit: number = 20, prevCursor?: string, ) { const { query, peopleFilter, excludeMutedAndBlocked } = searchUsersQueryDto; - const rawQuery = decodeURIComponent(query); + let rawQuery: string; + try { + rawQuery = decodeURIComponent(query); + } catch { + // If decoding fails, use the original query + rawQuery = query; + } if (!rawQuery || rawQuery.trim() === '') { return { @@ -196,7 +262,7 @@ export class SearchService { }; } - const cleanedQuery = prepareSearchQuery(rawQuery); + const cleanedQuery = rawQuery.toLowerCase().trim(); let decodedCursor: UserSearchCursor | undefined; try { decodedCursor = prevCursor ? decodeCompositeCursor(prevCursor) : undefined; diff --git a/src/tweets/dtos/get-tweet-response.dto.ts b/src/tweets/dtos/get-tweet-response.dto.ts index 9dbd738e..c6748671 100644 --- a/src/tweets/dtos/get-tweet-response.dto.ts +++ b/src/tweets/dtos/get-tweet-response.dto.ts @@ -1,5 +1,9 @@ +import { Exclude } from 'class-transformer'; import { TweetDto } from './tweet.dto'; export class GetTweetResponseDto extends TweetDto { replyToTweet?: TweetDto; + + @Exclude() + rank?: string; } diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts index 00148433..c97041ee 100644 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -6,7 +6,11 @@ import { FeedCursor } from 'src/common/interfaces/cursor.interfaces'; import { FeedSkeleton } from './interfaces'; import { CreateTweetData } from './interfaces/create-tweet-data.interface'; import { GetTweetResponseDto } from './dtos/get-tweet-response.dto'; -import { UserInteractionsCursor, TweetRelationsCursor } from 'src/common/types/cursors'; +import { + UserInteractionsCursor, + TweetRelationsCursor, + TweetRankCursor, +} from 'src/common/types/cursors'; import { BioEntitiesDto } from 'src/users/dtos'; import { plainToInstance } from 'class-transformer'; import { ReplyTweetDto } from './dtos/reply-tweet.dto'; @@ -108,6 +112,7 @@ export type TweetWithIncludes = BaseTweetWithIncludes & { type DetailedTweetWithIncludes = BaseTweetWithIncludes & { quotedTweet?: (BaseTweetWithIncludes & { quotedTweet?: null }) | null; replyToTweet?: (BaseTweetWithIncludes & { replyToTweet?: null }) | null; + rank?: string; }; @Injectable() @@ -349,11 +354,14 @@ export class TweetsRepository { }); } - mapToDetailedTweetDto(tweet: DetailedTweetWithIncludes): TweetDto & { replyToTweet?: TweetDto } { + mapToDetailedTweetDto( + tweet: DetailedTweetWithIncludes, + ): TweetDto & { replyToTweet?: TweetDto; rank?: string | undefined } { const baseTweet = this.mapToTweetDto(tweet); return { ...baseTweet, + rank: tweet.rank ? tweet.rank.toString() : undefined, replyToTweet: tweet.replyToTweet ? this.mapToTweetDto(tweet.replyToTweet) : undefined, }; } @@ -1207,26 +1215,12 @@ export class TweetsRepository { } return interactionsMap; } - - private buildTweetFilters( + private buildBasicTweetFilters( currentUserId: bigint, - hasMedia: boolean = false, - excludeMutedAndBlocked: boolean = false, - peopleFilter: PeopleSearchFilter = PeopleSearchFilter.Anyone, - cursor?: TweetRelationsCursor, + hasMedia: boolean, + excludeMutedAndBlocked: boolean, + peopleFilter: PeopleSearchFilter, ) { - const cursorCondition = cursor - ? Prisma.sql` - AND ( - t.created_at < ${cursor.createdAt}::timestamp - OR ( - t.created_at = ${cursor.createdAt}::timestamp - AND t.id <= ${BigInt(cursor.id)} - ) - ) - ` - : Prisma.empty; - const mutedAndBlockedCondition = excludeMutedAndBlocked ? Prisma.sql` AND NOT EXISTS ( @@ -1245,35 +1239,78 @@ export class TweetsRepository { const peopleFilterCondition = peopleFilter === PeopleSearchFilter.Following ? Prisma.sql` - AND EXISTS ( - SELECT 1 - FROM follows f - WHERE f.follower_id = ${currentUserId} AND f.followed_id = t.user_id - ) - ` + AND EXISTS ( + SELECT 1 + FROM follows f + WHERE f.follower_id = ${currentUserId} AND f.followed_id = t.user_id + ) + ` : Prisma.empty; const mediaCondition = hasMedia ? Prisma.sql`AND t.has_media = true` : Prisma.empty; return { - cursorCondition, mutedAndBlockedCondition, peopleFilterCondition, mediaCondition, }; } - async getTweetsByQuery( + private async fetchAndOrderTweetsForSearch( + tweetIds: { id: bigint; rank?: bigint; created_at?: Date }[], + currentUserId: bigint, + ) { + const tweets = await this.prisma.tweet.findMany({ + where: { + id: { in: tweetIds.map((row) => row.id) }, + }, + include: { + ...tweetInclude(currentUserId), + quotedTweet: { + include: tweetInclude(currentUserId), + }, + replyToTweet: { + include: tweetInclude(currentUserId), + }, + }, + }); + + // Maintain the order from the search query + const tweetMap = new Map(tweets.map((t) => [t.id.toString(), t])); + const orderedTweets = tweetIds + .map((row) => { + const tweet = tweetMap.get(row.id.toString()); + if (!tweet) return null; + + return { ...tweet, rank: row.rank ? row.rank.toString() : undefined }; + }) + .filter((item) => item !== null); + + return orderedTweets.map((tweet) => this.mapToDetailedTweetDto(tweet)); + } + + async getLatestTweetsByQuery( currentUserId: bigint, query: string, - hasMedia: boolean = false, excludeMutedAndBlocked: boolean = false, peopleFilter: PeopleSearchFilter = PeopleSearchFilter.Anyone, limit: number, cursor?: TweetRelationsCursor, ) { - const { cursorCondition, mutedAndBlockedCondition, peopleFilterCondition, mediaCondition } = - this.buildTweetFilters(currentUserId, hasMedia, excludeMutedAndBlocked, peopleFilter, cursor); + const { mutedAndBlockedCondition, peopleFilterCondition, mediaCondition } = + this.buildBasicTweetFilters(currentUserId, false, excludeMutedAndBlocked, peopleFilter); + + const cursorCondition = cursor + ? Prisma.sql` + AND ( + t.created_at < ${cursor.createdAt}::timestamp + OR ( + t.created_at = ${cursor.createdAt}::timestamp + AND t.id <= ${BigInt(cursor.id)} + ) + ) + ` + : Prisma.empty; const sqlQuery = Prisma.sql` SELECT t.id, t.created_at @@ -1299,27 +1336,71 @@ export class TweetsRepository { return []; } - const tweets = await this.prisma.tweet.findMany({ - where: { - id: { in: tweetIds.map((row) => row.id) }, - }, - include: { - ...tweetInclude(currentUserId), - quotedTweet: { - include: tweetInclude(currentUserId), - }, - replyToTweet: { - include: tweetInclude(currentUserId), - }, - }, - }); - // Maintain the order from the search query - const tweetMap = new Map(tweets.map((t) => [t.id.toString(), t])); - const orderedTweets = tweetIds - .map((row) => tweetMap.get(row.id.toString())) - .filter((tweet) => tweet !== undefined); + const orderedTweets = await this.fetchAndOrderTweetsForSearch(tweetIds, currentUserId); + return orderedTweets; + } - return orderedTweets.map((tweet) => this.mapToDetailedTweetDto(tweet)); + async getRankedTweetsByQuery( + currentUserId: bigint, + query: string, + hasMedia: boolean = false, + excludeMutedAndBlocked: boolean = false, + peopleFilter: PeopleSearchFilter = PeopleSearchFilter.Anyone, + limit: number, + cursor?: TweetRankCursor, + ) { + const { mutedAndBlockedCondition, peopleFilterCondition, mediaCondition } = + this.buildBasicTweetFilters(currentUserId, hasMedia, excludeMutedAndBlocked, peopleFilter); + + // weights: relevance : likes : retweets : replies + // 100 30 50 20 + const rankCalculation = Prisma.sql` + ( + CAST(ts_rank(t.search_document, to_tsquery('simple', ${query})) * 10000000 AS BIGINT) + + CAST(LOG(GREATEST(t.like_count, 1)) * 300000 AS BIGINT) + + CAST(LOG(GREATEST(t.retweet_count, 1)) * 500000 AS BIGINT) + + CAST(LOG(GREATEST(t.reply_count, 1)) * 200000 AS BIGINT) + ) + `; + + const cursorScore = cursor ? BigInt(cursor.rank) : null; + const cursorId = cursor ? BigInt(cursor.id) : null; + + const cursorCondition = cursor + ? Prisma.sql` + AND ( + ${rankCalculation} < ${cursorScore} + OR (${rankCalculation} = ${cursorScore} AND t.id <= ${cursorId}) + ) + ` + : Prisma.empty; + + const sqlQuery = Prisma.sql` + SELECT t.id, ${rankCalculation} AS rank + FROM tweets t + WHERE t.search_document @@ to_tsquery('simple', ${query}) + AND t.is_deleted = false + ${mediaCondition} + ${cursorCondition} + ${mutedAndBlockedCondition} + ${peopleFilterCondition} + ORDER BY rank DESC, t.id DESC + LIMIT ${limit} + `; + + const tweetIds = await this.prisma.$queryRaw< + { + id: bigint; + rank: bigint; + }[] + >(sqlQuery); + + if (tweetIds.length === 0) { + return []; + } + + const orderedTweets = await this.fetchAndOrderTweetsForSearch(tweetIds, currentUserId); + return orderedTweets; } async getTweetIdsLinkedToHashtag( @@ -1331,14 +1412,20 @@ export class TweetsRepository { peopleFilter: PeopleSearchFilter = PeopleSearchFilter.Anyone, prevCursor?: TweetRelationsCursor, ): Promise { - const { cursorCondition, mutedAndBlockedCondition, peopleFilterCondition, mediaCondition } = - this.buildTweetFilters( - currentUserId, - hasMedia, - excludeMutedAndBlocked, - peopleFilter, - prevCursor, - ); + const { mutedAndBlockedCondition, peopleFilterCondition, mediaCondition } = + this.buildBasicTweetFilters(currentUserId, hasMedia, excludeMutedAndBlocked, peopleFilter); + + const cursorCondition = prevCursor + ? Prisma.sql` + AND ( + t.created_at < ${prevCursor.createdAt}::timestamp + OR ( + t.created_at = ${prevCursor.createdAt}::timestamp + AND t.id <= ${BigInt(prevCursor.id)} + ) + ) + ` + : Prisma.empty; const tweetHashtags = await this.prisma.$queryRaw< { id: bigint; created_at: Date }[] diff --git a/src/tweets/tweets.service.ts b/src/tweets/tweets.service.ts index 6398246c..e8aed9bf 100644 --- a/src/tweets/tweets.service.ts +++ b/src/tweets/tweets.service.ts @@ -22,7 +22,11 @@ import { PAGINATION_ERROR_MESSAGES, } from 'src/common/constants/pagination-error-codes'; import { GetTweetResponseDto } from './dtos/get-tweet-response.dto'; -import { TweetRelationsCursor, UserInteractionsCursor } from 'src/common/types/cursors'; +import { + TweetRankCursor, + TweetRelationsCursor, + UserInteractionsCursor, +} from 'src/common/types/cursors'; import { MediaResponseDto } from 'src/media/dtos/media-response.dto'; import { AuthorDto, TweetDto } from './dtos'; import { InjectQueue } from '@nestjs/bullmq'; @@ -960,11 +964,11 @@ export class TweetsService { currentUserId: bigint, query: string, limit: number, - decodedCursor?: TweetRelationsCursor, + decodedCursor?: TweetRankCursor, excludeMutedAndBlocked?: boolean, peopleFilter?: PeopleSearchFilter, ) { - return await this.tweetsRepository.getTweetsByQuery( + return await this.tweetsRepository.getRankedTweetsByQuery( currentUserId, query, false, @@ -975,7 +979,7 @@ export class TweetsService { ); } - async getTweetsWithMediaByQuery( + async getLatestTweetsByQuery( currentUserId: bigint, query: string, limit: number, @@ -983,7 +987,24 @@ export class TweetsService { excludeMutedAndBlocked?: boolean, peopleFilter?: PeopleSearchFilter, ) { - return await this.tweetsRepository.getTweetsByQuery( + return await this.tweetsRepository.getLatestTweetsByQuery( + currentUserId, + query, + excludeMutedAndBlocked, + peopleFilter, + limit + 1, + decodedCursor, + ); + } + async getTweetsWithMediaByQuery( + currentUserId: bigint, + query: string, + limit: number, + decodedCursor?: TweetRankCursor, + excludeMutedAndBlocked?: boolean, + peopleFilter?: PeopleSearchFilter, + ) { + return await this.tweetsRepository.getRankedTweetsByQuery( currentUserId, query, true, diff --git a/src/users/constants/users.ts b/src/users/constants/users.ts index cec3e183..8fbd5006 100644 --- a/src/users/constants/users.ts +++ b/src/users/constants/users.ts @@ -74,7 +74,8 @@ export const USERS_ERROR_MESSAGES = { } as const; export const USER_SEARCH_RANKING_WEIGHTS = { - SIMILARITY: 5000, // Primary factor: text similarity + PREFIX_BONUS: 1000000, // Highest priority: prefix matches + SIMILARITY: 500000, // Primary factor: text similarity I_FOLLOW: 500, // Strong: users I follow FOLLOWS_ME: 300, // Good: users who follow me FOLLOWERS: 100, // Moderate: popularity (per follower) diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 0f006e23..59e32653 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -1462,9 +1462,83 @@ export class UsersRepository { }; } + private buildSearchUsersSql( + tsQuery: string, + firstWord: string, + currentUserId: bigint, + rankingScoreSql: Prisma.Sql, + cursorCondition: Prisma.Sql, + mutedAndBlockedCondition: Prisma.Sql, + peopleFilterCondition: Prisma.Sql, + limit: number, + ) { + return Prisma.sql` + WITH matched_ids AS ( + SELECT + user_id, + MAX(text_rank) AS text_rank, + MAX(prefix_bonus) AS prefix_bonus + FROM ( + SELECT + id AS user_id, + ts_rank(to_tsvector('simple', LOWER(username)), to_tsquery('simple', ${tsQuery})) AS text_rank, + CASE WHEN LOWER(username) LIKE ${firstWord + '%'} THEN 1.0 ELSE 0.0 END AS prefix_bonus + FROM users + WHERE deleted_at IS NULL + AND to_tsvector('simple', LOWER(username)) @@ to_tsquery('simple', ${tsQuery}) + + UNION ALL + + SELECT + user_id, + ts_rank(to_tsvector('simple', LOWER(display_name)), to_tsquery('simple', ${tsQuery})) AS text_rank, + CASE WHEN LOWER(display_name) LIKE ${firstWord + '%'} THEN 1.0 ELSE 0.0 END AS prefix_bonus + FROM profiles + WHERE to_tsvector('simple', LOWER(display_name)) @@ to_tsquery('simple', ${tsQuery}) + ) matches + GROUP BY user_id + ), + ranked_users AS ( + SELECT + u.id, + u.username, + u.created_at, + u.followers_count, + p.display_name, + p.avatar_url, + p.banner_url, + p.bio, + p.bio_entities, + m.text_rank, + m.prefix_bonus, + (f_out.follower_id IS NOT NULL) AS i_follow, + (f_in.follower_id IS NOT NULL) AS follows_me + FROM matched_ids m + JOIN users u ON m.user_id = u.id + JOIN profiles p ON m.user_id = p.user_id + LEFT JOIN follows f_out ON f_out.follower_id = ${currentUserId} AND f_out.followed_id = u.id + LEFT JOIN follows f_in ON f_in.follower_id = u.id AND f_in.followed_id = ${currentUserId} + WHERE 1 = 1 + ${mutedAndBlockedCondition} + ${peopleFilterCondition} + ), + scored_users AS ( + SELECT *, ${rankingScoreSql} + FROM ranked_users + ) + SELECT * + FROM scored_users + WHERE 1 = 1 + ${cursorCondition} + ORDER BY ranking_score DESC, id ASC + LIMIT ${limit}; + `; + } + async searchUsers( currentUserId: bigint, query: string, + firstWord: string, limit: number, decodedCursor: UserSearchCursor | undefined, excludeMutedAndBlocked: boolean = false, @@ -1480,56 +1554,16 @@ export class UsersRepository { const rankingScoreSql = this.buildUsersRankingScore(); - const sqlQuery = Prisma.sql` - -- First get matching user ids with username or display name similar to query - WITH matched_ids AS ( - SELECT - id as user_id - FROM users WHERE deleted_at IS NULL - AND (LOWER(username) % ${query}) - - UNION - - SELECT user_id - FROM profiles - WHERE LOWER(display_name) % ${query} - ), - - ranked_users AS ( - SELECT - u.id, - u.username, - u.created_at, - u.followers_count, - p.display_name, - p.avatar_url, - p.banner_url, - p.bio, - p.bio_entities, - SIMILARITY(LOWER(u.username), ${query}) AS sim_username, - COALESCE(SIMILARITY(LOWER(p.display_name), ${query}), 0) AS sim_display_name, - (f_out.follower_id IS NOT NULL) AS i_follow, - (f_in.follower_id IS NOT NULL) AS follows_me - FROM matched_ids matched_user - JOIN users u ON matched_user.user_id = u.id - JOIN profiles p ON matched_user.user_id = p.user_id - LEFT JOIN follows f_out ON f_out.follower_id = ${currentUserId} AND f_out.followed_id = u.id - LEFT JOIN follows f_in ON f_in.follower_id = u.id AND f_in.followed_id = ${currentUserId} - WHERE 1 = 1 - ${mutedAndBlockedCondition} - ${peopleFilterCondition} - ), - scored_users AS ( - SELECT *, ${rankingScoreSql} - FROM ranked_users - ) - SELECT * - FROM scored_users - WHERE 1=1 - ${cursorCondition} - ORDER BY ranking_score DESC, id DESC - LIMIT ${limit}; -`; + const sqlQuery = this.buildSearchUsersSql( + query, + firstWord, + currentUserId, + rankingScoreSql, + cursorCondition, + mutedAndBlockedCondition, + peopleFilterCondition, + limit, + ); const results = await this.prisma.$queryRaw(sqlQuery); @@ -1559,7 +1593,7 @@ export class UsersRepository { ? Prisma.sql` AND ( ranking_score < ${cursorScore} - OR (ranking_score = ${cursorScore} AND id <= ${cursorId}) + OR (ranking_score = ${cursorScore} AND id >= ${cursorId}) ) ` : Prisma.empty; @@ -1599,12 +1633,12 @@ export class UsersRepository { /** * Builds the ranking score SQL snippet for user search. - * Score = sim_score * sim_weight + followers_count * followers_weight + i_follow_weight + follows_me_weight */ private buildUsersRankingScore() { return Prisma.sql` ( - CAST( (COALESCE(sim_username, 0) + COALESCE(sim_display_name, 0)) * ${USER_SEARCH_RANKING_WEIGHTS.SIMILARITY} AS BIGINT ) + + CAST(prefix_bonus * ${USER_SEARCH_RANKING_WEIGHTS.PREFIX_BONUS} AS BIGINT) + + CAST(text_rank * ${USER_SEARCH_RANKING_WEIGHTS.SIMILARITY} AS BIGINT) + (LEAST(followers_count, ${USER_SEARCH_RANKING_WEIGHTS.MAX_FOLLOWERS_COUNT}) * (${USER_SEARCH_RANKING_WEIGHTS.FOLLOWERS})::bigint) + (CASE WHEN i_follow THEN ${USER_SEARCH_RANKING_WEIGHTS.I_FOLLOW}::bigint ELSE 0 END) + (CASE WHEN follows_me THEN ${USER_SEARCH_RANKING_WEIGHTS.FOLLOWS_ME}::bigint ELSE 0 END) @@ -1619,7 +1653,6 @@ export class UsersRepository { * * @returns A map where the key is the user ID and the value is the UserRelationshipDto */ - async getUsersRelationshipsMap( currentUserId: bigint, userIds: bigint[], diff --git a/src/users/users.service.ts b/src/users/users.service.ts index d2df93fa..61ac1188 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -35,6 +35,7 @@ import { RedisService } from 'src/redis/redis.service'; import { REDIS_TIMELINE_KEYS } from 'src/common/constants/redis-timeline-keys.constant'; import { BackfillFollowJob } from 'src/tweets/timeline/interfaces'; import { DomainEventsService } from 'src/events/domain-events.service'; +import { buildTsQuery } from './utils/user-search-query.util'; @Injectable() export class UsersService { @@ -1052,9 +1053,12 @@ export class UsersService { excludeMutedAndBlocked: boolean = false, peopleFilter?: PeopleSearchFilter, ) { + const { tsQuery, firstWord } = buildTsQuery(query); + return this.usersRepository.searchUsers( currentUserId, - query, + tsQuery, + firstWord, limit, decodedCursor, excludeMutedAndBlocked, diff --git a/src/users/utils/user-search-query.util.ts b/src/users/utils/user-search-query.util.ts new file mode 100644 index 00000000..1ed7bc9e --- /dev/null +++ b/src/users/utils/user-search-query.util.ts @@ -0,0 +1,14 @@ +export function buildTsQuery(query: string): { tsQuery: string; firstWord: string } { + const cleaned = query.toLowerCase().trim(); + const words = cleaned + .split(/\s+/) + .filter((w) => w.length > 0) + // only allow underscores, numbers and letters and delete if empty + .map((w) => w.replace(/[^a-z0-9_]/g, '')) + .filter((w) => w.length > 0); + + if (words.length === 0) return { tsQuery: '', firstWord: '' }; + + const tsQuery = words.map((word) => `${word}:*`).join(' & '); + return { tsQuery, firstWord: words[0] }; +} diff --git a/test/search/search.service.spec.ts b/test/search/search.service.spec.ts index da229132..878c0814 100644 --- a/test/search/search.service.spec.ts +++ b/test/search/search.service.spec.ts @@ -23,6 +23,7 @@ describe('SearchService', () => { const mockTweetsService = { getTopTweetsByQuery: jest.fn(), getTweetsWithMediaByQuery: jest.fn(), + getLatestTweetsByQuery: jest.fn(), }; beforeEach(async () => { @@ -181,14 +182,14 @@ describe('SearchService', () => { excludeMutedAndBlocked: false, }; - mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce(mockTweets); + mockTweetsService.getLatestTweetsByQuery.mockResolvedValueOnce(mockTweets); (SearchUtils.prepareSearchQuery as jest.Mock) = jest .fn() .mockReturnValue('latest:* | search:*'); const result = await service.searchTweets(currentUserId, queryDto, limit); - expect(mockTweetsService.getTopTweetsByQuery).toHaveBeenCalledWith( + expect(mockTweetsService.getLatestTweetsByQuery).toHaveBeenCalledWith( currentUserId, 'latest:* | search:*', limit, @@ -232,29 +233,31 @@ describe('SearchService', () => { it('should handle cursor pagination correctly', async () => { const lastTweet = mockTweets[mockTweets.length - 1]; const cursor = encodeCompositeCursor({ + type: 'relations', // Add type field createdAt: lastTweet.createdAt.toISOString(), id: lastTweet.id.toString(), }); const queryDto = { query: 'pagination test', - tab: SearchTab.Top, + tab: SearchTab.Latest, peopleFilter: undefined, excludeMutedAndBlocked: false, }; - mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce(mockTweets); + mockTweetsService.getLatestTweetsByQuery.mockResolvedValueOnce(mockTweets); (SearchUtils.prepareSearchQuery as jest.Mock) = jest .fn() .mockReturnValue('pagination:* | test:*'); const result = await service.searchTweets(currentUserId, queryDto, limit, cursor); - expect(mockTweetsService.getTopTweetsByQuery).toHaveBeenCalledWith( + expect(mockTweetsService.getLatestTweetsByQuery).toHaveBeenCalledWith( currentUserId, 'pagination:* | test:*', limit, { + type: 'relations', // Add type field to expected object createdAt: lastTweet.createdAt.toISOString(), id: lastTweet.id.toString(), }, diff --git a/test/tweets/tweets.service.spec.ts b/test/tweets/tweets.service.spec.ts index dd2c7305..48b618bf 100644 --- a/test/tweets/tweets.service.spec.ts +++ b/test/tweets/tweets.service.spec.ts @@ -61,6 +61,8 @@ describe('TweetsService', () => { getMediaTweetsForUser: jest.fn(), validateReferences: jest.fn(), getTweetsByQuery: jest.fn(), + getLatestTweetsByQuery: jest.fn(), + getRankedTweetsByQuery: jest.fn(), getParentTweets: jest.fn(), getTweetOrDeleted: jest.fn(), }; @@ -1896,16 +1898,16 @@ describe('TweetsService', () => { }); describe('getTopTweetsByQuery', () => { - it('should call getTweetsByQuery with hasMedia=false', async () => { + it('should call getRankedTweetsByQuery with hasMedia=false', async () => { const currentUserId = BigInt(1); const query = 'test query'; const limit = 10; - mockTweetsRepository.getTweetsByQuery.mockResolvedValueOnce(mockTweets); + mockTweetsRepository.getRankedTweetsByQuery.mockResolvedValueOnce(mockTweets); await service.getTopTweetsByQuery(currentUserId, query, limit); - expect(mockTweetsRepository.getTweetsByQuery).toHaveBeenCalledWith( + expect(mockTweetsRepository.getRankedTweetsByQuery).toHaveBeenCalledWith( currentUserId, query, false, @@ -1923,7 +1925,7 @@ describe('TweetsService', () => { const excludeMutedAndBlocked = true; const peopleFilter = PeopleSearchFilter.Anyone; - mockTweetsRepository.getTweetsByQuery.mockResolvedValueOnce(mockTweets); + mockTweetsRepository.getRankedTweetsByQuery.mockResolvedValueOnce(mockTweets); await service.getTopTweetsByQuery( currentUserId, @@ -1934,7 +1936,7 @@ describe('TweetsService', () => { peopleFilter, ); - expect(mockTweetsRepository.getTweetsByQuery).toHaveBeenCalledWith( + expect(mockTweetsRepository.getRankedTweetsByQuery).toHaveBeenCalledWith( currentUserId, query, false, @@ -1946,20 +1948,72 @@ describe('TweetsService', () => { }); }); + describe('getLatestTweetsByQuery', () => { + it('should call getLatestTweetsByQuery with correct parameters', async () => { + const currentUserId = BigInt(1); + const query = 'test query'; + const limit = 10; + + mockTweetsRepository.getLatestTweetsByQuery.mockResolvedValueOnce(mockTweets); + + await service.getLatestTweetsByQuery(currentUserId, query, limit); + + expect(mockTweetsRepository.getLatestTweetsByQuery).toHaveBeenCalledWith( + currentUserId, + query, + undefined, + undefined, + limit + 1, + undefined, + ); + }); + + it('should pass cursor and filters to repository', async () => { + const currentUserId = BigInt(1); + const query = 'test query'; + const limit = 10; + const decodedCursor = { + type: 'relations' as const, + createdAt: new Date('2024-01-01'), + id: '50', + }; + const excludeMutedAndBlocked = true; + + mockTweetsRepository.getLatestTweetsByQuery.mockResolvedValueOnce(mockTweets); + + await service.getLatestTweetsByQuery( + currentUserId, + query, + limit, + decodedCursor, + excludeMutedAndBlocked, + ); + + expect(mockTweetsRepository.getLatestTweetsByQuery).toHaveBeenCalledWith( + currentUserId, + query, + excludeMutedAndBlocked, + undefined, + limit + 1, + decodedCursor, + ); + }); + }); + describe('getTweetsWithMediaByQuery', () => { - it('should call getTweetsByQuery with hasMedia=true', async () => { + it('should call getRankedTweetsByQuery with hasMedia=true', async () => { const currentUserId = BigInt(1); const query = 'test query'; const limit = 10; - mockTweetsRepository.getTweetsByQuery.mockResolvedValueOnce(mockTweets); + mockTweetsRepository.getRankedTweetsByQuery.mockResolvedValueOnce(mockTweets); await service.getTweetsWithMediaByQuery(currentUserId, query, limit); - expect(mockTweetsRepository.getTweetsByQuery).toHaveBeenCalledWith( + expect(mockTweetsRepository.getRankedTweetsByQuery).toHaveBeenCalledWith( currentUserId, query, - true, + true, // hasMedia = true for media tab undefined, undefined, limit + 1, @@ -1971,10 +2025,10 @@ describe('TweetsService', () => { const currentUserId = BigInt(1); const query = 'test query'; const limit = 10; - const decodedCursor = { createdAt: new Date(), id: '100' }; + const decodedCursor = { type: 'rank' as const, rank: '100', id: '50' }; const excludeMutedAndBlocked = true; - mockTweetsRepository.getTweetsByQuery.mockResolvedValueOnce(mockTweets); + mockTweetsRepository.getRankedTweetsByQuery.mockResolvedValueOnce(mockTweets); await service.getTweetsWithMediaByQuery( currentUserId, @@ -1984,10 +2038,10 @@ describe('TweetsService', () => { excludeMutedAndBlocked, ); - expect(mockTweetsRepository.getTweetsByQuery).toHaveBeenCalledWith( + expect(mockTweetsRepository.getRankedTweetsByQuery).toHaveBeenCalledWith( currentUserId, query, - true, + true, // hasMedia = true excludeMutedAndBlocked, undefined, limit + 1, From b12b7ddf3bd8d66df74dc42718edcf563cb65cf4 Mon Sep 17 00:00:00 2001 From: Tasneem Mohammed <149891742+Tasneemmohammed0@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:12:48 +0200 Subject: [PATCH 24/43] fix(profile): return empty array for mutual users in get profile endpoint - [CU-869bg5wpn] (#216) Signed-off-by: Tasneemmhammed0 --- src/users/users.repository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 59e32653..23c266cd 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -277,7 +277,7 @@ export class UsersRepository { followingCount: user.followingCount, followersCount: user.followersCount, mutualsCount: null, - mutualUsers: null, + mutualUsers: [], }; // TODO: Get mutual followers count and names @@ -309,7 +309,7 @@ export class UsersRepository { followingCount: user.followingCount, followersCount: user.followersCount, mutualsCount: mutualsCount, - mutualUsers: mutualUsers, + mutualUsers: mutualUsers ?? [], email: isMyProfile ? user.email : undefined, phone: user.phone || undefined, languageCode: user.languageCode || undefined, From a45ff7a19fff03e8b994b3ab0b842fa661dac6fd Mon Sep 17 00:00:00 2001 From: Omar Hassan <149178126+OmarHassan2003@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:08:28 +0200 Subject: [PATCH 25/43] fix: return bad request in case of trying to make a conversation with yourself - [CU-869bg4zkc] (#215) --- src/conversations/constants/conversation-constants.ts | 2 ++ src/conversations/conversations.service.ts | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/src/conversations/constants/conversation-constants.ts b/src/conversations/constants/conversation-constants.ts index 8ab48c06..c7ca3ab1 100644 --- a/src/conversations/constants/conversation-constants.ts +++ b/src/conversations/constants/conversation-constants.ts @@ -10,6 +10,7 @@ export const CONVERSATIONS_ERROR_CODES = { ASSERT_PARTICPANT_FAILED: 'ASSERT_PARTICPANT_FAILED', REACTION_CREATION_FAILED: 'REACTION_CREATION_FAILED', INVALID_MEDIA: 'INVALID_MEDIA', + CANNOT_CREATE_CONVERSATION_WITH_SELF: 'CANNOT_CREATE_CONVERSATION_WITH_SELF', } as const; export const CONVERSATIONS_ERROR_MESSAGES = { @@ -24,4 +25,5 @@ export const CONVERSATIONS_ERROR_MESSAGES = { ASSERT_PARTICPANT_FAILED: 'Failed to assert conversation participants', REACTION_CREATION_FAILED: 'Failed to add reaction to the message', INVALID_MEDIA: 'The media ID provided is invalid or does not belong to you', + CANNOT_CREATE_CONVERSATION_WITH_SELF: 'You cannot create a conversation with yourself', } as const; diff --git a/src/conversations/conversations.service.ts b/src/conversations/conversations.service.ts index fcd9c519..96dfa94a 100644 --- a/src/conversations/conversations.service.ts +++ b/src/conversations/conversations.service.ts @@ -134,6 +134,15 @@ export class ConversationsService { HttpStatus.NOT_FOUND, ); + if (otherUser.id === userId) + throw new HttpException( + { + message: CONVERSATIONS_ERROR_MESSAGES.CANNOT_CREATE_CONVERSATION_WITH_SELF, + code: CONVERSATIONS_ERROR_CODES.CANNOT_CREATE_CONVERSATION_WITH_SELF, + }, + HttpStatus.BAD_REQUEST, + ); + let conversationData = await this.conversationsRepository.findConversation( userId, otherUser.id, From 50c05b3822337cfcd3662f4a4ba2ffc8cb9b6cd6 Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Mon, 15 Dec 2025 15:25:18 +0200 Subject: [PATCH 26/43] fix: normalize interests and classes in db - [CU-869bg7ma6] (#217) --- prisma/seed-for-you-test.ts | 29 ++++++++----------------- src/explore/explore.module.ts | 3 ++- src/explore/explore.repository.ts | 8 ------- src/explore/explore.service.ts | 8 +++++-- src/tweets/timeline/timeline.service.ts | 3 +-- src/tweets/tweets.repository.ts | 11 ---------- src/users/users.repository.ts | 13 +++++++++++ 7 files changed, 31 insertions(+), 44 deletions(-) diff --git a/prisma/seed-for-you-test.ts b/prisma/seed-for-you-test.ts index 13cb54eb..6f8d714f 100644 --- a/prisma/seed-for-you-test.ts +++ b/prisma/seed-for-you-test.ts @@ -2,16 +2,17 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); +// test1234 async function main() { - const testUser = await prisma.user.upsert({ + await prisma.user.upsert({ where: { email: 'fy@test.com' }, update: {}, create: { - username: 'fytester', + username: 'omar', email: 'fy@test.com', passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', birthdate: new Date('2000-01-01'), - interests: ['SPORTS', 'TECH', 'ENTERTAINMENT'], + interests: ['ENTERTAINMENT'], profile: { create: { displayName: 'FY Tester' } }, }, }); @@ -81,18 +82,6 @@ async function main() { }, }); - await prisma.follow.upsert({ - where: { followerId_followedId: { followerId: testUser.id, followedId: u1.id } }, - update: {}, - create: { followerId: testUser.id, followedId: u1.id }, - }); - - await prisma.follow.upsert({ - where: { followerId_followedId: { followerId: testUser.id, followedId: u2.id } }, - update: {}, - create: { followerId: testUser.id, followedId: u2.id }, - }); - const tweetData = [ { userId: u1.id, @@ -167,35 +156,35 @@ async function main() { { userId: u3.id, content: 'New Marvel movie WOW', - class: 'ENTERTAINMENT', + class: 'Entertainment', likeCount: 500, retweetCount: 200, }, { userId: u3.id, content: 'Taylor Swift new album', - class: 'ENTERTAINMENT', + class: 'Entertainment', likeCount: 800, retweetCount: 350, }, { userId: u3.id, content: 'Best TV shows to binge', - class: 'ENTERTAINMENT', + class: 'Entertainment', likeCount: 150, retweetCount: 45, }, { userId: u3.id, content: 'Broadway is back', - class: 'ENTERTAINMENT', + class: 'Entertainment', likeCount: 70, retweetCount: 20, }, { userId: u3.id, content: 'New video game release', - class: 'ENTERTAINMENT', + class: 'Entertainment', likeCount: 250, retweetCount: 90, }, diff --git a/src/explore/explore.module.ts b/src/explore/explore.module.ts index c3febea5..4b4bf3d5 100644 --- a/src/explore/explore.module.ts +++ b/src/explore/explore.module.ts @@ -4,9 +4,10 @@ import { ExploreService } from './explore.service'; import { ExploreRepository } from './explore.repository'; import { PrismaModule } from 'src/prisma/prisma.module'; import { TweetsModule } from 'src/tweets/tweets.module'; +import { UsersModule } from 'src/users/users.module'; @Module({ - imports: [PrismaModule, TweetsModule], + imports: [PrismaModule, TweetsModule, UsersModule], controllers: [ExploreController], providers: [ExploreService, ExploreRepository], exports: [ExploreService], diff --git a/src/explore/explore.repository.ts b/src/explore/explore.repository.ts index 266237ff..3930d0bd 100644 --- a/src/explore/explore.repository.ts +++ b/src/explore/explore.repository.ts @@ -11,14 +11,6 @@ export class ExploreRepository { private readonly tweetsRepository: TweetsRepository, ) {} - async getUserInterests(userId: bigint): Promise { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - select: { interests: true }, - }); - return user?.interests ?? []; - } - /** * Fetches top N tweets per category for the given interests using LATERAL join * More efficient than ROW_NUMBER - stops scanning after finding N tweets per category diff --git a/src/explore/explore.service.ts b/src/explore/explore.service.ts index da4df819..23392823 100644 --- a/src/explore/explore.service.ts +++ b/src/explore/explore.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ExploreRepository } from './explore.repository'; import { TweetDto } from 'src/tweets/dtos'; +import { UsersRepository } from 'src/users/users.repository'; export interface ForYouCategory { category: string; @@ -9,10 +10,13 @@ export interface ForYouCategory { @Injectable() export class ExploreService { - constructor(private readonly exploreRepository: ExploreRepository) {} + constructor( + private readonly exploreRepository: ExploreRepository, + private readonly usersRepository: UsersRepository, + ) {} async getForYouCategories(userId: bigint): Promise { - const userInterests = await this.exploreRepository.getUserInterests(userId); + const userInterests = await this.usersRepository.getUserInterests(userId); if (!userInterests || userInterests.length === 0) { return []; diff --git a/src/tweets/timeline/timeline.service.ts b/src/tweets/timeline/timeline.service.ts index 71423a91..61489592 100644 --- a/src/tweets/timeline/timeline.service.ts +++ b/src/tweets/timeline/timeline.service.ts @@ -1217,8 +1217,7 @@ export class TimelineService { ): Promise> { this.logger.debug(`Generating For You feed for user ${userId}`); - // 1. Get user interests from database - const userInterests = await this.tweetsRepository.getUserInterests(userId); + const userInterests = await this.usersRepository.getUserInterests(userId); this.logger.debug( `User ${userId} has ${userInterests.length} interests: ${userInterests.join(', ')}`, ); diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts index c97041ee..06f26819 100644 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -1680,17 +1680,6 @@ export class TweetsRepository { }); return authors; } - /** - * Get user's interests from their profile - */ - async getUserInterests(userId: bigint): Promise { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - select: { interests: true }, - }); - - return user?.interests || []; - } /** * Get recent tweets from users the person follows diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 23c266cd..5f5ff653 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -1892,4 +1892,17 @@ export class UsersRepository { return user ? { username: user.username, displayName: user.profile!.displayName } : null; } + + async getUserInterests(userId: bigint): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { interests: true }, + }); + + return ( + user?.interests.map((interest) => + interest ? interest[0].toUpperCase() + interest.slice(1).toLowerCase() : interest, + ) || [] + ); + } } From 2580a0708f5488251da1ec53f8aff475016c198b Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Mon, 15 Dec 2025 16:48:12 +0200 Subject: [PATCH 27/43] fix: explore for you shape - [CU-869bgakv0] (#219) --- src/explore/explore.service.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/explore/explore.service.ts b/src/explore/explore.service.ts index 23392823..74fb6963 100644 --- a/src/explore/explore.service.ts +++ b/src/explore/explore.service.ts @@ -15,11 +15,15 @@ export class ExploreService { private readonly usersRepository: UsersRepository, ) {} - async getForYouCategories(userId: bigint): Promise { + async getForYouCategories(userId: bigint): Promise<{ + categories: ForYouCategory[]; + }> { const userInterests = await this.usersRepository.getUserInterests(userId); if (!userInterests || userInterests.length === 0) { - return []; + return { + categories: [], + }; } // Fetch all tweets for all categories in a single query @@ -41,7 +45,9 @@ export class ExploreService { } } - return categories; + return { + categories, + }; } private mapCategory(category: string): string { From 810bb499dbbcd59cc626e67ba3c1d21946e5f1fb Mon Sep 17 00:00:00 2001 From: Tasneem Mohammed <149891742+Tasneemmohammed0@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:03:32 +0200 Subject: [PATCH 28/43] test(search): increase search module unit tests coverage - [CU-869bgak4v] (#218) Signed-off-by: Tasneemmhammed0 --- src/media/validators/media-file.validator.ts | 21 + src/search/search.controller.ts | 2 +- src/users/me/me.controller.ts | 52 +- .../validators/media-file.validator.spec.ts | 80 +- .../mappers/user-search-result.mapper.spec.ts | 179 +++ test/search/search.controller.spec.ts | 467 +++++++- test/search/search.service.spec.ts | 1023 ++++++++++++++--- test/users/me/me.controller.spec.ts | 109 +- 8 files changed, 1641 insertions(+), 292 deletions(-) create mode 100644 test/search/mappers/user-search-result.mapper.spec.ts diff --git a/src/media/validators/media-file.validator.ts b/src/media/validators/media-file.validator.ts index b21961cc..fb9c2811 100644 --- a/src/media/validators/media-file.validator.ts +++ b/src/media/validators/media-file.validator.ts @@ -48,3 +48,24 @@ export const videoFileFilter = ( callback(null, true); }; + +export const profileImageFileFilter = ( + req: never, + file: Express.Multer.File, + callback: (error: Error | null, acceptFile: boolean) => void, +) => { + const ext = file.originalname.split('.').pop()?.toLowerCase(); + + if (!ext || !IMAGE_EXTENSIONS.includes(ext)) { + return callback( + new BadRequestException( + createValidationError(file.fieldname, { + invalidFileType: MEDIA_MESSAGES.ALLOWED_IMAGE_TYPES, + }), + ), + false, + ); + } + + callback(null, true); +}; diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts index 63130872..e8401946 100644 --- a/src/search/search.controller.ts +++ b/src/search/search.controller.ts @@ -57,7 +57,7 @@ export class SearchController { @Query('excludeMutedAndBlocked', ParseBooleanPipe) excludeMutedAndBlocked?: boolean, ) { const currentUserId = BigInt(user.id); - const parsedLimit = limit ? parseInt(limit, 10) : 200; + const parsedLimit = limit ? parseInt(limit, 10) : 20; searchUsersQueryDto.excludeMutedAndBlocked = excludeMutedAndBlocked; return this.searchService.searchUsers(currentUserId, searchUsersQueryDto, parsedLimit, cursor); diff --git a/src/users/me/me.controller.ts b/src/users/me/me.controller.ts index 84202f23..aa390f0b 100644 --- a/src/users/me/me.controller.ts +++ b/src/users/me/me.controller.ts @@ -9,7 +9,6 @@ import { Patch, Get, UseInterceptors, - BadRequestException, UploadedFile, UploadedFiles, } from '@nestjs/common'; @@ -20,11 +19,10 @@ import { JwtAuthGuard } from 'src/auth/guards'; import type { RequestUser } from 'src/common/interfaces'; import { User } from 'src/auth/decorators'; import { RATE_LIMIT } from 'src/common/constants/rate-limit.constants'; -import { USERS_ERROR_MESSAGES } from 'src/users/constants'; -import { IMAGE_EXTENSIONS, MAX_FILE_SIZE_BYTES } from 'src/media/constants/media.constant'; +import { MAX_FILE_SIZE_BYTES } from 'src/media/constants/media.constant'; import { FileFieldsInterceptor, FileInterceptor } from '@nestjs/platform-express'; -import { createValidationError } from 'src/common/utils/create-validation-error.util'; import { ParseJsonBodyPipe } from '../pipes/parse-json-body.pipe'; +import { profileImageFileFilter } from 'src/media/validators/media-file.validator'; @Controller('me') export class MeController { @@ -83,21 +81,7 @@ export class MeController { { name: 'banner', maxCount: 1 }, ], { - fileFilter: (req, file, callback) => { - const ext = file.originalname.split('.').pop()?.toLowerCase(); - if (!ext || !IMAGE_EXTENSIONS.includes(ext)) { - return callback( - new BadRequestException( - createValidationError(file.fieldname, { - invalidFileType: 'Only image files are allowed (jpg, jpeg, png).', - }), - ), - false, - ); - } - - callback(null, true); - }, + fileFilter: profileImageFileFilter, limits: { fileSize: MAX_FILE_SIZE_BYTES }, }, ), @@ -124,20 +108,7 @@ export class MeController { @Post('profile-picture') @UseInterceptors( FileInterceptor('profilePicture', { - fileFilter: (req, file, callback) => { - const ext = file.originalname.split('.').pop()?.toLowerCase(); - if (!ext || !IMAGE_EXTENSIONS.includes(ext)) { - return callback( - new BadRequestException( - createValidationError(file.fieldname, { - invalidFileType: USERS_ERROR_MESSAGES.ALLOWED_IMAGE_TYPES, - }), - ), - false, - ); - } - callback(null, true); - }, + fileFilter: profileImageFileFilter, limits: { fileSize: MAX_FILE_SIZE_BYTES }, }), ) @@ -154,20 +125,7 @@ export class MeController { @Post('banner') @UseInterceptors( FileInterceptor('banner', { - fileFilter: (req, file, callback) => { - const ext = file.originalname.split('.').pop()?.toLowerCase(); - if (!ext || !IMAGE_EXTENSIONS.includes(ext)) { - return callback( - new BadRequestException( - createValidationError(file.fieldname, { - invalidFileType: USERS_ERROR_MESSAGES.ALLOWED_IMAGE_TYPES, - }), - ), - false, - ); - } - callback(null, true); - }, + fileFilter: profileImageFileFilter, limits: { fileSize: MAX_FILE_SIZE_BYTES }, }), ) diff --git a/test/media/validators/media-file.validator.spec.ts b/test/media/validators/media-file.validator.spec.ts index bdaaaddd..073a232b 100644 --- a/test/media/validators/media-file.validator.spec.ts +++ b/test/media/validators/media-file.validator.spec.ts @@ -1,5 +1,9 @@ import { BadRequestException } from '@nestjs/common'; -import { imageFileFilter, videoFileFilter } from 'src/media/validators/media-file.validator'; +import { + imageFileFilter, + videoFileFilter, + profileImageFileFilter, +} from 'src/media/validators/media-file.validator'; describe('Media File Validators', () => { const createMockFile = (filename: string, mimetype: string): Express.Multer.File => ({ @@ -117,4 +121,78 @@ describe('Media File Validators', () => { expect(mockCallback).toHaveBeenCalledWith(expect.any(BadRequestException), false); }); }); + + describe('profileImageFileFilter', () => { + it('should accept valid image extensions (jpg)', () => { + const mockFile = createMockFile('profile.jpg', 'image/jpeg'); + const mockCallback = jest.fn(); + + profileImageFileFilter(null as never, mockFile, mockCallback); + + expect(mockCallback).toHaveBeenCalledWith(null, true); + }); + + it('should accept valid image extensions (jpeg)', () => { + const mockFile = createMockFile('avatar.jpeg', 'image/jpeg'); + const mockCallback = jest.fn(); + + profileImageFileFilter(null as never, mockFile, mockCallback); + + expect(mockCallback).toHaveBeenCalledWith(null, true); + }); + + it('should accept valid image extensions (png)', () => { + const mockFile = createMockFile('banner.png', 'image/png'); + const mockCallback = jest.fn(); + + profileImageFileFilter(null as never, mockFile, mockCallback); + + expect(mockCallback).toHaveBeenCalledWith(null, true); + }); + + it('should handle uppercase extensions', () => { + const mockFile = createMockFile('IMAGE.PNG', 'image/png'); + const mockCallback = jest.fn(); + + profileImageFileFilter(null as never, mockFile, mockCallback); + + expect(mockCallback).toHaveBeenCalledWith(null, true); + }); + + it('should reject GIF files', () => { + const mockFile = createMockFile('animation.gif', 'image/gif'); + const mockCallback = jest.fn(); + + profileImageFileFilter(null as never, mockFile, mockCallback); + + expect(mockCallback).toHaveBeenCalledWith(expect.any(BadRequestException), false); + }); + + it('should reject files without extensions', () => { + const mockFile = createMockFile('filenamewithoutextension', 'image/jpeg'); + const mockCallback = jest.fn(); + + profileImageFileFilter(null as never, mockFile, mockCallback); + + expect(mockCallback).toHaveBeenCalledWith(expect.any(BadRequestException), false); + }); + + it('should reject video files', () => { + const mockFile = createMockFile('video.mp4', 'video/mp4'); + const mockCallback = jest.fn(); + + profileImageFileFilter(null as never, mockFile, mockCallback); + + expect(mockCallback).toHaveBeenCalledWith(expect.any(BadRequestException), false); + }); + + it('should reject other file types', () => { + const mockFile = createMockFile('document.pdf', 'application/pdf'); + const mockCallback = jest.fn(); + + profileImageFileFilter(null as never, mockFile, mockCallback); + + expect(mockCallback).toHaveBeenCalledWith(expect.any(BadRequestException), false); + }); + }); }); diff --git a/test/search/mappers/user-search-result.mapper.spec.ts b/test/search/mappers/user-search-result.mapper.spec.ts new file mode 100644 index 00000000..5762c18e --- /dev/null +++ b/test/search/mappers/user-search-result.mapper.spec.ts @@ -0,0 +1,179 @@ +import { JsonObject } from '@prisma/client/runtime/library'; +import { mapToUserSearchResultDto } from 'src/search/mappers/user-search-result.mapper'; +import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; + +describe('mapToUserSearchResultDto', () => { + it('should map a single user item with all fields populated', () => { + const bioEntities: JsonObject = { + hashtags: [], + mentions: [], + }; + + const relationship: UserRelationshipDto = { + blocking: false, + blockedBy: false, + following: true, + follower: true, + muted: false, + }; + + const input = [ + { + id: 'user-123', + username: 'johndoe', + displayName: 'John Doe', + avatarUrl: 'https://example.com/avatar.jpg', + bannerUrl: 'https://example.com/banner.jpg', + bio: 'Software developer', + bioEntities: bioEntities, + createdAt: new Date('2024-01-01'), + rankingScore: BigInt(100), + relationship, + }, + ]; + + const result = mapToUserSearchResultDto(input); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + username: 'johndoe', + displayName: 'John Doe', + bio: 'Software developer', + bioEntities, + avatarUrl: 'https://example.com/avatar.jpg', + bannerUrl: 'https://example.com/banner.jpg', + relationship, + }); + }); + + it('should handle null optional fields', () => { + const input = [ + { + id: 'user-456', + username: 'janedoe', + displayName: 'Jane Doe', + avatarUrl: null, + bannerUrl: null, + bio: null, + bioEntities: null, + createdAt: new Date('2024-02-01'), + rankingScore: BigInt(50), + }, + ]; + + const result = mapToUserSearchResultDto(input); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + username: 'janedoe', + displayName: 'Jane Doe', + bio: null, + bioEntities: null, + avatarUrl: null, + bannerUrl: null, + relationship: { + blocking: false, + blockedBy: false, + following: false, + follower: false, + muted: false, + }, + }); + }); + + it('should provide default relationship when relationship is undefined', () => { + const input = [ + { + id: 'user-789', + username: 'testuser', + displayName: 'Test User', + avatarUrl: 'https://example.com/avatar.jpg', + bannerUrl: null, + bio: 'Test bio', + bioEntities: null, + createdAt: new Date('2024-03-01'), + rankingScore: BigInt(75), + relationship: undefined, + }, + ]; + + const result = mapToUserSearchResultDto(input); + + expect(result[0].relationship).toEqual({ + blocking: false, + blockedBy: false, + following: false, + follower: false, + muted: false, + }); + }); + + it('should handle empty array', () => { + const result = mapToUserSearchResultDto([]); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it('should map multiple user items correctly', () => { + const input = [ + { + id: 'user-1', + username: 'user1', + displayName: 'User One', + avatarUrl: 'https://example.com/avatar1.jpg', + bannerUrl: null, + bio: 'First user', + bioEntities: null, + createdAt: new Date('2024-01-01'), + rankingScore: BigInt(100), + relationship: { + blocking: false, + blockedBy: false, + following: true, + follower: false, + muted: false, + }, + }, + { + id: 'user-2', + username: 'user2', + displayName: 'User Two', + avatarUrl: null, + bannerUrl: 'https://example.com/banner2.jpg', + bio: 'Second user', + bioEntities: null, + createdAt: new Date('2024-02-01'), + rankingScore: BigInt(90), + relationship: null, + }, + { + id: 'user-3', + username: 'user3', + displayName: 'User Three', + avatarUrl: 'https://example.com/avatar3.jpg', + bannerUrl: 'https://example.com/banner3.jpg', + bio: null, + bioEntities: null, + createdAt: new Date('2024-03-01'), + rankingScore: BigInt(80), + }, + ]; + + const result = mapToUserSearchResultDto(input); + + expect(result).toHaveLength(3); + expect(result[0].username).toBe('user1'); + expect(result[0].relationship?.following).toBe(true); + expect(result[1].username).toBe('user2'); + expect(result[1].relationship).toEqual({ + blocking: false, + blockedBy: false, + following: false, + follower: false, + muted: false, + }); + expect(result[2].username).toBe('user3'); + expect(result[2].avatarUrl).toBe('https://example.com/avatar3.jpg'); + }); +}); diff --git a/test/search/search.controller.spec.ts b/test/search/search.controller.spec.ts index 79879208..8cf3f75a 100644 --- a/test/search/search.controller.spec.ts +++ b/test/search/search.controller.spec.ts @@ -1,27 +1,31 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SearchController } from 'src/search/search.controller'; import { SearchService } from 'src/search/search.service'; +import { TrendingService } from 'src/trending/trending.service'; import { PeopleSearchFilter, SearchTab, SearchTweetsQueryDto } from 'src/search/dtos'; +import { SearchUsersQueryDto } from 'src/search/dtos/search-users-query.dto'; +import { QueryDto } from 'src/search/dtos/query.dto'; import { ParseBooleanPipe } from 'src/common/pipes/parse-boolean.pipe'; import { RequestUser } from 'src/common/interfaces'; -import { TrendingService } from 'src/trending/trending.service'; -describe('SearchController - searchTweets', () => { +describe('SearchController', () => { let controller: SearchController; const mockSearchService = { searchTweets: jest.fn(), getMatchingUsers: jest.fn(), - }; - - const mockUser: RequestUser = { - id: '1', + searchUsers: jest.fn(), }; const mockTrendingService = { + getTrendingWords: jest.fn(), getTrendingHashtags: jest.fn(), }; + const mockUser: RequestUser = { + id: '1', + }; + const mockSearchResult = { items: [ { @@ -58,9 +62,66 @@ describe('SearchController - searchTweets', () => { expect(controller).toBeDefined(); }); + describe('getTopUsers', () => { + it('should call searchService.getMatchingUsers with correct parameters', async () => { + const queryDto: QueryDto = { + query: 'john', + }; + + const mockUsersResult = { + users: [ + { + username: 'john_doe', + displayName: 'John Doe', + avatarUrl: 'http://avatar.jpg', + isFollowing: false, + isFollower: false, + }, + ], + }; + + mockSearchService.getMatchingUsers.mockResolvedValueOnce(mockUsersResult); + + const result = await controller.getTopUsers(mockUser, queryDto); + + expect(mockSearchService.getMatchingUsers).toHaveBeenCalledWith(BigInt(mockUser.id), 'john'); + expect(result).toEqual(mockUsersResult); + }); + + it('should handle empty query', async () => { + const queryDto: QueryDto = { + query: '', + }; + + const mockEmptyResult = { users: [] }; + mockSearchService.getMatchingUsers.mockResolvedValueOnce(mockEmptyResult); + + const result = await controller.getTopUsers(mockUser, queryDto); + + expect(mockSearchService.getMatchingUsers).toHaveBeenCalledWith(BigInt(mockUser.id), ''); + expect(result).toEqual(mockEmptyResult); + }); + + it('should convert user id to BigInt', async () => { + const userWithStringId: RequestUser = { + id: '123456789', + }; + + const queryDto: QueryDto = { + query: 'test', + }; + + mockSearchService.getMatchingUsers.mockResolvedValueOnce({ users: [] }); + + await controller.getTopUsers(userWithStringId, queryDto); + + expect(mockSearchService.getMatchingUsers).toHaveBeenCalledWith(BigInt('123456789'), 'test'); + }); + }); + describe('searchTweets', () => { it('should search tweets with default limit of 20', async () => { - const searchTweetsQueryDto = { + const searchTweetsQueryDto: SearchTweetsQueryDto = { query: 'test search', tab: SearchTab.Top, peopleFilter: undefined, @@ -80,12 +141,11 @@ describe('SearchController - searchTweets', () => { 20, undefined, ); - expect(result).toEqual(mockSearchResult); }); it('should search tweets with custom limit', async () => { - const searchTweetsQueryDto = { + const searchTweetsQueryDto: SearchTweetsQueryDto = { query: 'test search', tab: SearchTab.Latest, peopleFilter: undefined, @@ -98,15 +158,17 @@ describe('SearchController - searchTweets', () => { expect(mockSearchService.searchTweets).toHaveBeenCalledWith( BigInt(mockUser.id), - expect.objectContaining(searchTweetsQueryDto), + expect.objectContaining({ + ...searchTweetsQueryDto, + excludeMutedAndBlocked: undefined, + }), 50, undefined, ); - expect(result).toEqual(mockSearchResult); }); - it('should call searchTweets with correct parameters', async () => { + it('should call searchTweets with all parameters', async () => { const searchTweetsQueryDto: SearchTweetsQueryDto = { query: 'example search', tab: SearchTab.Media, @@ -128,5 +190,386 @@ describe('SearchController - searchTweets', () => { 'cursor-string', ); }); + + it('should set excludeMutedAndBlocked to false when provided as false', async () => { + const searchTweetsQueryDto: SearchTweetsQueryDto = { + query: 'test', + tab: SearchTab.Top, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchTweets.mockResolvedValueOnce(mockSearchResult); + + await controller.searchTweets(mockUser, searchTweetsQueryDto, undefined, undefined, false); + + expect(mockSearchService.searchTweets).toHaveBeenCalledWith( + BigInt(mockUser.id), + expect.objectContaining({ + ...searchTweetsQueryDto, + excludeMutedAndBlocked: false, + }), + 20, + undefined, + ); + }); + + it('should handle different search tabs', async () => { + const tabs = [SearchTab.Top, SearchTab.Latest, SearchTab.Media]; + + for (const tab of tabs) { + const searchTweetsQueryDto: SearchTweetsQueryDto = { + query: 'test', + tab, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchTweets.mockResolvedValueOnce(mockSearchResult); + + await controller.searchTweets(mockUser, searchTweetsQueryDto); + + expect(mockSearchService.searchTweets).toHaveBeenCalledWith( + BigInt(mockUser.id), + expect.objectContaining({ tab }), + 20, + undefined, + ); + } + }); + + it('should handle people filter', async () => { + const searchTweetsQueryDto: SearchTweetsQueryDto = { + query: 'test', + tab: SearchTab.Top, + peopleFilter: PeopleSearchFilter.Following, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchTweets.mockResolvedValueOnce(mockSearchResult); + + await controller.searchTweets(mockUser, searchTweetsQueryDto); + + expect(mockSearchService.searchTweets).toHaveBeenCalledWith( + BigInt(mockUser.id), + expect.objectContaining({ + peopleFilter: PeopleSearchFilter.Following, + }), + 20, + undefined, + ); + }); + + it('should parse string limit to integer', async () => { + const searchTweetsQueryDto: SearchTweetsQueryDto = { + query: 'test', + tab: SearchTab.Top, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchTweets.mockResolvedValueOnce(mockSearchResult); + + await controller.searchTweets(mockUser, searchTweetsQueryDto, '100'); + + expect(mockSearchService.searchTweets).toHaveBeenCalledWith( + BigInt(mockUser.id), + expect.anything(), + 100, + undefined, + ); + }); + + it('should mutate the query DTO with excludeMutedAndBlocked value', async () => { + const searchTweetsQueryDto: SearchTweetsQueryDto = { + query: 'test', + tab: SearchTab.Top, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchTweets.mockResolvedValueOnce(mockSearchResult); + + await controller.searchTweets(mockUser, searchTweetsQueryDto, undefined, undefined, true); + + // The DTO should be mutated + expect(searchTweetsQueryDto.excludeMutedAndBlocked).toBe(true); + }); + }); + + describe('getTopHashtags', () => { + it('should call trendingService.getTrendingWords with correct parameters', async () => { + const queryDto: QueryDto = { + query: 'javascript', + }; + + const mockTrendingWords = { + words: ['javascript', 'typescript', 'nodejs'], + }; + + mockTrendingService.getTrendingWords.mockResolvedValueOnce(mockTrendingWords); + + const result = await controller.getTopHashtags(queryDto); + + expect(mockTrendingService.getTrendingWords).toHaveBeenCalledWith('javascript', 3); + expect(result).toEqual(mockTrendingWords); + }); + + it('should always request 3 trending words', async () => { + const queryDto: QueryDto = { + query: 'test', + }; + + mockTrendingService.getTrendingWords.mockResolvedValueOnce({ words: [] }); + + await controller.getTopHashtags(queryDto); + + expect(mockTrendingService.getTrendingWords).toHaveBeenCalledWith('test', 3); + }); + + it('should handle empty query', async () => { + const queryDto: QueryDto = { + query: '', + }; + + mockTrendingService.getTrendingWords.mockResolvedValueOnce({ words: [] }); + + const result = await controller.getTopHashtags(queryDto); + + expect(mockTrendingService.getTrendingWords).toHaveBeenCalledWith('', 3); + expect(result).toEqual({ words: [] }); + }); + + it('should handle hashtag query with # symbol', async () => { + const queryDto: QueryDto = { + query: '#javascript', + }; + + mockTrendingService.getTrendingWords.mockResolvedValueOnce({ words: ['javascript'] }); + + await controller.getTopHashtags(queryDto); + + expect(mockTrendingService.getTrendingWords).toHaveBeenCalledWith('#javascript', 3); + }); + }); + + describe('searchUsers', () => { + const mockUserSearchResult = { + users: [ + { + id: '10', + username: 'john_doe', + displayName: 'John Doe', + avatarUrl: 'http://avatar.jpg', + isFollowing: false, + isFollowedBy: false, + }, + ], + pagination: { + nextCursor: 'user-cursor', + hasMore: true, + }, + }; + + it('should search users with default limit of 20', async () => { + const searchUsersQueryDto: SearchUsersQueryDto = { + query: 'john', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchUsers.mockResolvedValueOnce(mockUserSearchResult); + + const result = await controller.searchUsers(mockUser, searchUsersQueryDto); + + expect(mockSearchService.searchUsers).toHaveBeenCalledWith( + BigInt(mockUser.id), + expect.objectContaining({ + ...searchUsersQueryDto, + excludeMutedAndBlocked: undefined, + }), + 20, // default limit + undefined, + ); + expect(result).toEqual(mockUserSearchResult); + }); + + it('should search users with custom limit', async () => { + const searchUsersQueryDto: SearchUsersQueryDto = { + query: 'jane', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchUsers.mockResolvedValueOnce(mockUserSearchResult); + + const result = await controller.searchUsers(mockUser, searchUsersQueryDto, '50'); + + expect(mockSearchService.searchUsers).toHaveBeenCalledWith( + BigInt(mockUser.id), + expect.objectContaining(searchUsersQueryDto), + 50, + undefined, + ); + expect(result).toEqual(mockUserSearchResult); + }); + + it('should call searchUsers with all parameters', async () => { + const searchUsersQueryDto: SearchUsersQueryDto = { + query: 'test user', + peopleFilter: PeopleSearchFilter.Following, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchUsers.mockResolvedValueOnce(mockUserSearchResult); + + await controller.searchUsers( + mockUser, + searchUsersQueryDto, + '100', + 'user-cursor-string', + true, + ); + + expect(mockSearchService.searchUsers).toHaveBeenCalledWith( + BigInt(mockUser.id), + expect.objectContaining({ + ...searchUsersQueryDto, + excludeMutedAndBlocked: true, + }), + 100, + 'user-cursor-string', + ); + }); + + it('should handle cursor pagination', async () => { + const searchUsersQueryDto: SearchUsersQueryDto = { + query: 'paginated users', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + const cursor = 'base64-user-cursor'; + + mockSearchService.searchUsers.mockResolvedValueOnce(mockUserSearchResult); + + await controller.searchUsers(mockUser, searchUsersQueryDto, undefined, cursor); + + expect(mockSearchService.searchUsers).toHaveBeenCalledWith( + BigInt(mockUser.id), + expect.objectContaining(searchUsersQueryDto), + 20, // default limit + cursor, + ); + }); + + it('should set excludeMutedAndBlocked to true', async () => { + const searchUsersQueryDto: SearchUsersQueryDto = { + query: 'test', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchUsers.mockResolvedValueOnce(mockUserSearchResult); + + await controller.searchUsers(mockUser, searchUsersQueryDto, undefined, undefined, true); + + expect(mockSearchService.searchUsers).toHaveBeenCalledWith( + BigInt(mockUser.id), + expect.objectContaining({ + ...searchUsersQueryDto, + excludeMutedAndBlocked: true, + }), + 20, + undefined, + ); + }); + + it('should set excludeMutedAndBlocked to false when provided as false', async () => { + const searchUsersQueryDto: SearchUsersQueryDto = { + query: 'test', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchUsers.mockResolvedValueOnce(mockUserSearchResult); + + await controller.searchUsers(mockUser, searchUsersQueryDto, undefined, undefined, false); + + expect(mockSearchService.searchUsers).toHaveBeenCalledWith( + BigInt(mockUser.id), + expect.objectContaining({ + ...searchUsersQueryDto, + excludeMutedAndBlocked: false, + }), + 20, + undefined, + ); + }); + + it('should parse string limit to integer', async () => { + const searchUsersQueryDto: SearchUsersQueryDto = { + query: 'test', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchUsers.mockResolvedValueOnce(mockUserSearchResult); + + await controller.searchUsers(mockUser, searchUsersQueryDto, '150'); + + expect(mockSearchService.searchUsers).toHaveBeenCalledWith( + BigInt(mockUser.id), + expect.anything(), + 150, + undefined, + ); + }); + + it('should convert user id to BigInt', async () => { + const userWithStringId: RequestUser = { + id: '987654321', + }; + + const searchUsersQueryDto: SearchUsersQueryDto = { + query: 'test', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockSearchService.searchUsers.mockResolvedValueOnce(mockUserSearchResult); + + await controller.searchUsers(userWithStringId, searchUsersQueryDto); + + expect(mockSearchService.searchUsers).toHaveBeenCalledWith( + BigInt('987654321'), + expect.anything(), + 20, + undefined, + ); + }); + + it('should handle empty search results', async () => { + const searchUsersQueryDto: SearchUsersQueryDto = { + query: 'nonexistent', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + const emptyResult = { + users: [], + pagination: { + nextCursor: null, + hasMore: false, + }, + }; + + mockSearchService.searchUsers.mockResolvedValueOnce(emptyResult); + + const result = await controller.searchUsers(mockUser, searchUsersQueryDto); + + expect(result).toEqual(emptyResult); + expect(result.users).toHaveLength(0); + }); }); }); diff --git a/test/search/search.service.spec.ts b/test/search/search.service.spec.ts index 878c0814..c0ba5a62 100644 --- a/test/search/search.service.spec.ts +++ b/test/search/search.service.spec.ts @@ -3,7 +3,7 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { UsersService } from 'src/users/users.service'; import { TweetsService } from 'src/tweets/tweets.service'; import { SearchService } from 'src/search/search.service'; -import { SearchTab } from 'src/search/dtos'; +import { PeopleSearchFilter, SearchTab } from 'src/search/dtos'; import * as SearchUtils from 'src/search/utils/search-query.util'; import { PAGINATION_ERROR_CODES, PAGINATION_ERROR_MESSAGES } from 'src/common/constants'; @@ -18,12 +18,15 @@ describe('SearchService', () => { const mockUsersService = { getMatchingUsers: jest.fn(), getUserFollowRelations: jest.fn(), + searchUsers: jest.fn(), + getUsersRelationshipsMap: jest.fn(), }; const mockTweetsService = { getTopTweetsByQuery: jest.fn(), getTweetsWithMediaByQuery: jest.fn(), getLatestTweetsByQuery: jest.fn(), + getTweetsByHashtag: jest.fn(), }; beforeEach(async () => { @@ -50,23 +53,156 @@ describe('SearchService', () => { id: BigInt(100), content: 'Test tweet 1', createdAt: new Date('2024-01-01'), + rank: 0.95, }, { id: BigInt(101), content: 'Test tweet 2', createdAt: new Date('2024-01-02'), + rank: 0.85, }, ]; - afterEach(() => { - jest.clearAllMocks(); - }); - it('should be defined', () => { expect(service).toBeDefined(); }); + describe('getMatchingUsers', () => { + it('should return empty array for empty username', async () => { + const result = await service.getMatchingUsers(currentUserId, ''); + expect(result.users).toEqual([]); + expect(mockUsersService.getMatchingUsers).not.toHaveBeenCalled(); + }); + + it('should return empty array for whitespace-only username', async () => { + const result = await service.getMatchingUsers(currentUserId, ' '); + expect(result.users).toEqual([]); + expect(mockUsersService.getMatchingUsers).not.toHaveBeenCalled(); + }); + + it('should return empty array when no users found', async () => { + mockUsersService.getMatchingUsers.mockResolvedValueOnce([]); + const result = await service.getMatchingUsers(currentUserId, 'john'); + expect(result.users).toEqual([]); + }); + + it('should return empty array when users result is null', async () => { + mockUsersService.getMatchingUsers.mockResolvedValueOnce(null); + const result = await service.getMatchingUsers(currentUserId, 'john'); + expect(result.users).toEqual([]); + }); + + it('should return users with follow relations', async () => { + const mockUsers = [ + { + id: BigInt(10), + username: 'john_doe', + profile: { displayName: 'John Doe', avatarUrl: 'http://avatar1.jpg' }, + }, + { + id: BigInt(20), + username: 'jane_smith', + profile: { displayName: 'Jane Smith', avatarUrl: 'http://avatar2.jpg' }, + }, + ]; + + const mockFollowRelations = [ + { followerId: currentUserId, followedId: BigInt(10) }, // currentUser follows user 10 + { followerId: BigInt(20), followedId: currentUserId }, // user 20 follows currentUser + ]; + + mockUsersService.getMatchingUsers.mockResolvedValueOnce(mockUsers); + mockUsersService.getUserFollowRelations.mockResolvedValueOnce(mockFollowRelations); + + const result = await service.getMatchingUsers(currentUserId, 'john'); + + expect(mockUsersService.getMatchingUsers).toHaveBeenCalledWith(currentUserId, 'john'); + expect(mockUsersService.getUserFollowRelations).toHaveBeenCalledWith(currentUserId, [ + BigInt(10), + BigInt(20), + ]); + + expect(result.users).toHaveLength(2); + expect(result.users[0]).toEqual({ + username: 'john_doe', + displayName: 'John Doe', + avatarUrl: 'http://avatar1.jpg', + isFollowing: true, + isFollower: false, + }); + expect(result.users[1]).toEqual({ + username: 'jane_smith', + displayName: 'Jane Smith', + avatarUrl: 'http://avatar2.jpg', + isFollowing: false, + isFollower: true, + }); + }); + + it('should handle users without profile', async () => { + const mockUsers = [ + { + id: BigInt(10), + username: 'no_profile', + profile: null, + }, + ]; + + mockUsersService.getMatchingUsers.mockResolvedValueOnce(mockUsers); + mockUsersService.getUserFollowRelations.mockResolvedValueOnce([]); + + const result = await service.getMatchingUsers(currentUserId, 'no_profile'); + + expect(result.users[0]).toEqual({ + username: 'no_profile', + displayName: '', + avatarUrl: undefined, + isFollowing: false, + isFollower: false, + }); + }); + + it('should handle mutual follows correctly', async () => { + const mockUsers = [ + { + id: BigInt(10), + username: 'mutual_friend', + profile: { displayName: 'Mutual Friend', avatarUrl: 'http://avatar.jpg' }, + }, + ]; + + const mockFollowRelations = [ + { followerId: currentUserId, followedId: BigInt(10) }, + { followerId: BigInt(10), followedId: currentUserId }, + ]; + + mockUsersService.getMatchingUsers.mockResolvedValueOnce(mockUsers); + mockUsersService.getUserFollowRelations.mockResolvedValueOnce(mockFollowRelations); + + const result = await service.getMatchingUsers(currentUserId, 'mutual'); + + expect(result.users[0]).toEqual({ + username: 'mutual_friend', + displayName: 'Mutual Friend', + avatarUrl: 'http://avatar.jpg', + isFollowing: true, + isFollower: true, + }); + }); + }); + describe('searchTweets', () => { + beforeEach(() => { + jest.spyOn(SearchUtils, 'prepareSearchQuery').mockImplementation((query) => { + return query + .split(' ') + .map((w) => `${w}:*`) + .join(' | '); + }); + jest.spyOn(SearchUtils, 'isSingleHashtagQuery').mockReturnValue(false); + jest.spyOn(SearchUtils, 'extractHashtag').mockImplementation((q) => q.replace('#', '')); + }); + it('should return empty array for empty query', async () => { const queryDto = { query: '', @@ -95,37 +231,113 @@ describe('SearchService', () => { expect(result.pagination).toBeDefined(); }); - it('should clean the search query before processing', async () => { - const dirtyQuery = ' test search '; - const cleanedQuery = 'test:* | search:*'; + it('should handle URL encoded queries', async () => { + const queryDto = { + query: 'hello%20world', + tab: SearchTab.Top, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce(mockTweets); + + await service.searchTweets(currentUserId, queryDto, limit); + + expect(SearchUtils.prepareSearchQuery).toHaveBeenCalledWith('hello world'); + }); + it('should handle invalid URL encoded queries gracefully', async () => { const queryDto = { - query: dirtyQuery, + query: 'test%', tab: SearchTab.Top, peopleFilter: undefined, excludeMutedAndBlocked: false, }; - (SearchUtils.prepareSearchQuery as jest.Mock) = jest.fn().mockReturnValue(cleanedQuery); - mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce([]); + mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce(mockTweets); await service.searchTweets(currentUserId, queryDto, limit); - expect(SearchUtils.prepareSearchQuery).toHaveBeenCalledWith(dirtyQuery); + expect(SearchUtils.prepareSearchQuery).toHaveBeenCalledWith('test%'); + }); + + it('should search for Top tab (default)', async () => { + const queryDto = { + query: 'test search', + tab: SearchTab.Top, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce(mockTweets); + + const result = await service.searchTweets(currentUserId, queryDto, limit); + expect(mockTweetsService.getTopTweetsByQuery).toHaveBeenCalledWith( currentUserId, - cleanedQuery, + 'test:* | search:*', limit, undefined, false, undefined, ); + + expect(result.items).toEqual(mockTweets); + expect(result.pagination.cursor).toBeNull(); }); - it('should search queries for Top tab', async () => { + it('should search for Media tab', async () => { const queryDto = { - query: 'test search', - tab: SearchTab.Top, + query: 'media search', + tab: SearchTab.Media, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockTweetsService.getTweetsWithMediaByQuery.mockResolvedValueOnce(mockTweets); + + const result = await service.searchTweets(currentUserId, queryDto, limit); + + expect(mockTweetsService.getTweetsWithMediaByQuery).toHaveBeenCalledWith( + currentUserId, + 'media:* | search:*', + limit, + undefined, + false, + undefined, + ); + + expect(result.items).toEqual(mockTweets); + }); + + it('should search for Latest tab', async () => { + const queryDto = { + query: 'latest search', + tab: SearchTab.Latest, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockTweetsService.getLatestTweetsByQuery.mockResolvedValueOnce(mockTweets); + + const result = await service.searchTweets(currentUserId, queryDto, limit); + + expect(mockTweetsService.getLatestTweetsByQuery).toHaveBeenCalledWith( + currentUserId, + 'latest:* | search:*', + limit, + undefined, + false, + undefined, + ); + + expect(result.items).toEqual(mockTweets); + }); + + it('should default to Top tab if no tab is provided', async () => { + const queryDto = { + query: 'default tab search', + tab: undefined, peopleFilter: undefined, excludeMutedAndBlocked: false, }; @@ -134,189 +346,656 @@ describe('SearchService', () => { const result = await service.searchTweets(currentUserId, queryDto, limit); - expect(mockTweetsService.getTopTweetsByQuery).toHaveBeenCalledWith( + expect(mockTweetsService.getTopTweetsByQuery).toHaveBeenCalled(); + expect(result.items).toEqual(mockTweets); + }); + + it('should handle hashtag search for Top tab', async () => { + jest.spyOn(SearchUtils, 'isSingleHashtagQuery').mockReturnValue(true); + jest.spyOn(SearchUtils, 'extractHashtag').mockReturnValue('javascript'); + + const queryDto = { + query: '#javascript', + tab: SearchTab.Top, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockTweetsService.getTweetsByHashtag.mockResolvedValueOnce(mockTweets); + + const result = await service.searchTweets(currentUserId, queryDto, limit); + + expect(mockTweetsService.getTweetsByHashtag).toHaveBeenCalledWith( + 'javascript', currentUserId, - 'test:* | search:*', limit, + false, undefined, false, undefined, ); expect(result.items).toEqual(mockTweets); - expect(result.pagination).toBeDefined(); }); - }); - it('should search queries for Media tab', async () => { - const queryDto = { - query: 'media search', - tab: SearchTab.Media, - peopleFilter: undefined, - excludeMutedAndBlocked: false, - }; - - mockTweetsService.getTweetsWithMediaByQuery.mockResolvedValueOnce(mockTweets); - (SearchUtils.prepareSearchQuery as jest.Mock) = jest.fn().mockReturnValue('media:* | search:*'); - - const result = await service.searchTweets(currentUserId, queryDto, limit); - - expect(mockTweetsService.getTweetsWithMediaByQuery).toHaveBeenCalledWith( - currentUserId, - 'media:* | search:*', - limit, - undefined, - false, - undefined, - ); - - expect(result.items).toEqual(mockTweets); - expect(result.pagination).toBeDefined(); - }); + it('should handle hashtag search for Media tab', async () => { + jest.spyOn(SearchUtils, 'isSingleHashtagQuery').mockReturnValue(true); + jest.spyOn(SearchUtils, 'extractHashtag').mockReturnValue('photos'); - it('should search queries for Latest tab', async () => { - const queryDto = { - query: 'latest search', - tab: SearchTab.Latest, - peopleFilter: undefined, - excludeMutedAndBlocked: false, - }; - - mockTweetsService.getLatestTweetsByQuery.mockResolvedValueOnce(mockTweets); - (SearchUtils.prepareSearchQuery as jest.Mock) = jest - .fn() - .mockReturnValue('latest:* | search:*'); - - const result = await service.searchTweets(currentUserId, queryDto, limit); - - expect(mockTweetsService.getLatestTweetsByQuery).toHaveBeenCalledWith( - currentUserId, - 'latest:* | search:*', - limit, - undefined, - false, - undefined, - ); - - expect(result.items).toEqual(mockTweets); - expect(result.pagination).toBeDefined(); - }); + const queryDto = { + query: '#photos', + tab: SearchTab.Media, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; - it('should default to Top tab if no tab is provided', async () => { - const queryDto = { - query: 'default tab search', - tab: undefined, - peopleFilter: undefined, - excludeMutedAndBlocked: false, - }; - - mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce(mockTweets); - (SearchUtils.prepareSearchQuery as jest.Mock) = jest - .fn() - .mockReturnValue('default:* | tab:* | search:*'); - - const result = await service.searchTweets(currentUserId, queryDto, limit); - - expect(mockTweetsService.getTopTweetsByQuery).toHaveBeenCalledWith( - currentUserId, - 'default:* | tab:* | search:*', - limit, - undefined, - false, - undefined, - ); - - expect(result.items).toEqual(mockTweets); - expect(result.pagination).toBeDefined(); - }); + mockTweetsService.getTweetsByHashtag.mockResolvedValueOnce(mockTweets); + + const result = await service.searchTweets(currentUserId, queryDto, limit); + + expect(mockTweetsService.getTweetsByHashtag).toHaveBeenCalledWith( + 'photos', + currentUserId, + limit, + true, // withMedia = true for Media tab + undefined, + false, + undefined, + ); + + expect(result.items).toEqual(mockTweets); + }); + + it('should handle hashtag search for Latest tab', async () => { + jest.spyOn(SearchUtils, 'isSingleHashtagQuery').mockReturnValue(true); + jest.spyOn(SearchUtils, 'extractHashtag').mockReturnValue('news'); + + const queryDto = { + query: '#news', + tab: SearchTab.Latest, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockTweetsService.getTweetsByHashtag.mockResolvedValueOnce(mockTweets); + + const result = await service.searchTweets(currentUserId, queryDto, limit); + + expect(mockTweetsService.getTweetsByHashtag).toHaveBeenCalledWith( + 'news', + currentUserId, + limit, + false, + undefined, + false, + undefined, + ); - it('should handle cursor pagination correctly', async () => { - const lastTweet = mockTweets[mockTweets.length - 1]; - const cursor = encodeCompositeCursor({ - type: 'relations', // Add type field - createdAt: lastTweet.createdAt.toISOString(), - id: lastTweet.id.toString(), + expect(result.items).toEqual(mockTweets); }); - const queryDto = { - query: 'pagination test', - tab: SearchTab.Latest, - peopleFilter: undefined, - excludeMutedAndBlocked: false, - }; + it('should handle rank cursor for Top tab', async () => { + const cursor = encodeCompositeCursor({ + type: 'rank', + rank: '0.95', + id: '100', + }); + + const queryDto = { + query: 'pagination test', + tab: SearchTab.Top, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; - mockTweetsService.getLatestTweetsByQuery.mockResolvedValueOnce(mockTweets); - (SearchUtils.prepareSearchQuery as jest.Mock) = jest - .fn() - .mockReturnValue('pagination:* | test:*'); + mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce(mockTweets); - const result = await service.searchTweets(currentUserId, queryDto, limit, cursor); + const result = await service.searchTweets(currentUserId, queryDto, limit, cursor); - expect(mockTweetsService.getLatestTweetsByQuery).toHaveBeenCalledWith( - currentUserId, - 'pagination:* | test:*', - limit, - { - type: 'relations', // Add type field to expected object + expect(mockTweetsService.getTopTweetsByQuery).toHaveBeenCalledWith( + currentUserId, + 'pagination:* | test:*', + limit, + { + type: 'rank', + rank: '0.95', + id: '100', + }, + false, + undefined, + ); + + expect(result.items).toEqual(mockTweets); + }); + + it('should handle relations cursor for Latest tab', async () => { + const lastTweet = mockTweets[mockTweets.length - 1]; + const cursor = encodeCompositeCursor({ + type: 'relations', createdAt: lastTweet.createdAt.toISOString(), id: lastTweet.id.toString(), - }, - false, - undefined, - ); + }); - expect(result.items).toEqual(mockTweets); - expect(result.pagination).toBeDefined(); - }); + const queryDto = { + query: 'pagination test', + tab: SearchTab.Latest, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; - test('should throw BAD_REQUEST for invalid cursor', async () => { - const queryDto = { - query: 'invalid cursor test', - tab: SearchTab.Top, - peopleFilter: undefined, - excludeMutedAndBlocked: false, - }; + mockTweetsService.getLatestTweetsByQuery.mockResolvedValueOnce(mockTweets); - const invalidCursor = 'invalid-cursor-string'; + const result = await service.searchTweets(currentUserId, queryDto, limit, cursor); - await expect( - service.searchTweets(currentUserId, queryDto, limit, invalidCursor), - ).rejects.toThrow( - new HttpException( + expect(mockTweetsService.getLatestTweetsByQuery).toHaveBeenCalledWith( + currentUserId, + 'pagination:* | test:*', + limit, { - message: PAGINATION_ERROR_MESSAGES.INVALID_CURSOR, - code: PAGINATION_ERROR_CODES.INVALID_CURSOR, + type: 'relations', + createdAt: lastTweet.createdAt.toISOString(), + id: lastTweet.id.toString(), }, - HttpStatus.BAD_REQUEST, - ), - ); + false, + undefined, + ); + + expect(result.items).toEqual(mockTweets); + }); + + it('should throw BAD_REQUEST for invalid cursor', async () => { + const queryDto = { + query: 'invalid cursor test', + tab: SearchTab.Top, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + const invalidCursor = 'invalid-cursor-string'; + + await expect( + service.searchTweets(currentUserId, queryDto, limit, invalidCursor), + ).rejects.toThrow( + new HttpException( + { + message: PAGINATION_ERROR_MESSAGES.INVALID_CURSOR, + code: PAGINATION_ERROR_CODES.INVALID_CURSOR, + }, + HttpStatus.BAD_REQUEST, + ), + ); + }); + + it('should throw BAD_REQUEST when using relations cursor for relevance search', async () => { + const cursor = encodeCompositeCursor({ + type: 'relations', + createdAt: new Date().toISOString(), + id: '100', + }); + + const queryDto = { + query: 'test', + tab: SearchTab.Top, // Top tab expects rank cursor + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + await expect(service.searchTweets(currentUserId, queryDto, limit, cursor)).rejects.toThrow( + new HttpException( + { + message: PAGINATION_ERROR_MESSAGES.INVALID_CURSOR, + code: PAGINATION_ERROR_CODES.INVALID_CURSOR, + }, + HttpStatus.BAD_REQUEST, + ), + ); + }); + + it('should throw BAD_REQUEST when using rank cursor for Latest tab', async () => { + const cursor = encodeCompositeCursor({ + type: 'rank', + rank: '0.95', + id: '100', + }); + + const queryDto = { + query: 'test', + tab: SearchTab.Latest, // Latest tab expects relations cursor + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + await expect(service.searchTweets(currentUserId, queryDto, limit, cursor)).rejects.toThrow( + new HttpException( + { + message: PAGINATION_ERROR_MESSAGES.INVALID_CURSOR, + code: PAGINATION_ERROR_CODES.INVALID_CURSOR, + }, + HttpStatus.BAD_REQUEST, + ), + ); + }); + + it('should pass excludeMutedAndBlocked flag correctly', async () => { + const queryDto = { + query: 'muted blocked test', + tab: SearchTab.Top, + peopleFilter: undefined, + excludeMutedAndBlocked: true, + }; + + mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce(mockTweets); + + const result = await service.searchTweets(currentUserId, queryDto, limit); + + expect(mockTweetsService.getTopTweetsByQuery).toHaveBeenCalledWith( + currentUserId, + 'muted:* | blocked:* | test:*', + limit, + undefined, + true, + undefined, + ); + + expect(result.items).toEqual(mockTweets); + }); + + it('should pass peopleFilter correctly', async () => { + const peopleFilter = PeopleSearchFilter.Anyone; + const queryDto = { + query: 'filter test', + tab: SearchTab.Top, + peopleFilter, + excludeMutedAndBlocked: false, + }; + + mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce(mockTweets); + + const result = await service.searchTweets(currentUserId, queryDto, limit); + + expect(mockTweetsService.getTopTweetsByQuery).toHaveBeenCalledWith( + currentUserId, + 'filter:* | test:*', + limit, + undefined, + false, + peopleFilter, + ); + + expect(result.items).toEqual(mockTweets); + }); + + it('should generate correct pagination cursor for rank-based search', async () => { + const tweetsWithRank = [ + { id: BigInt(100), content: 'Test 1', createdAt: new Date(), rank: 0.95 }, + { id: BigInt(101), content: 'Test 2', createdAt: new Date(), rank: 0.85 }, + { id: BigInt(102), content: 'Test 3', createdAt: new Date(), rank: 0.75 }, + { id: BigInt(103), content: 'Test 4', createdAt: new Date(), rank: 0.65 }, + { id: BigInt(104), content: 'Test 5', createdAt: new Date(), rank: 0.55 }, + { id: BigInt(105), content: 'Test 6', createdAt: new Date(), rank: 0.45 }, + { id: BigInt(106), content: 'Test 7', createdAt: new Date(), rank: 0.35 }, + { id: BigInt(107), content: 'Test 8', createdAt: new Date(), rank: 0.25 }, + { id: BigInt(108), content: 'Test 9', createdAt: new Date(), rank: 0.15 }, + { id: BigInt(109), content: 'Test 10', createdAt: new Date(), rank: 0.05 }, + { id: BigInt(110), content: 'Test 11', createdAt: new Date(), rank: 0.04 }, + ]; + + const queryDto = { + query: 'test', + tab: SearchTab.Top, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce(tweetsWithRank); + + const result = await service.searchTweets(currentUserId, queryDto, limit); + + expect(result.pagination).toBeDefined(); + expect(result.pagination.nextCursor).toBeDefined(); + expect(result.pagination.hasNextPage).toBe(true); + expect(result.items.length).toBe(limit); + }); + + it('should generate correct pagination cursor for relations-based search', async () => { + const tweetsWithDates = [ + { id: BigInt(100), content: 'Test 1', createdAt: new Date('2024-01-10') }, + { id: BigInt(101), content: 'Test 2', createdAt: new Date('2024-01-09') }, + { id: BigInt(102), content: 'Test 3', createdAt: new Date('2024-01-08') }, + { id: BigInt(103), content: 'Test 4', createdAt: new Date('2024-01-07') }, + { id: BigInt(104), content: 'Test 5', createdAt: new Date('2024-01-06') }, + { id: BigInt(105), content: 'Test 6', createdAt: new Date('2024-01-05') }, + { id: BigInt(106), content: 'Test 7', createdAt: new Date('2024-01-04') }, + { id: BigInt(107), content: 'Test 8', createdAt: new Date('2024-01-03') }, + { id: BigInt(108), content: 'Test 9', createdAt: new Date('2024-01-02') }, + { id: BigInt(109), content: 'Test 10', createdAt: new Date('2024-01-01') }, + { id: BigInt(110), content: 'Test 11', createdAt: new Date('2023-12-31') }, + ]; + + const queryDto = { + query: 'test', + tab: SearchTab.Latest, + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockTweetsService.getLatestTweetsByQuery.mockResolvedValueOnce(tweetsWithDates); + + const result = await service.searchTweets(currentUserId, queryDto, limit); + + expect(result.pagination).toBeDefined(); + expect(result.pagination.nextCursor).toBeDefined(); + expect(result.pagination.hasNextPage).toBe(true); + expect(result.items.length).toBe(limit); + }); }); - it('should pass excludedMutedAndBlocked flag correctly to TweetsService', async () => { - const queryDto = { - query: 'muted blocked test', - tab: SearchTab.Top, - peopleFilter: undefined, - excludeMutedAndBlocked: true, - }; - - mockTweetsService.getTopTweetsByQuery.mockResolvedValueOnce(mockTweets); - (SearchUtils.prepareSearchQuery as jest.Mock) = jest - .fn() - .mockReturnValue('muted:* | blocked:* | test:*'); - - const result = await service.searchTweets(currentUserId, queryDto, limit); - - expect(mockTweetsService.getTopTweetsByQuery).toHaveBeenCalledWith( - currentUserId, - 'muted:* | blocked:* | test:*', - limit, - undefined, - true, - undefined, - ); - - expect(result.items).toEqual(mockTweets); - expect(result.pagination).toBeDefined(); + describe('searchUsers', () => { + const mockSearchUsers = [ + { + id: '10', + username: 'john_doe', + displayName: 'John Doe', + avatarUrl: 'http://avatar1.jpg', + rankingScore: '100', + }, + { + id: '20', + username: 'jane_smith', + displayName: 'Jane Smith', + avatarUrl: 'http://avatar2.jpg', + rankingScore: '95', + }, + ]; + + it('should return empty array for empty query', async () => { + const queryDto = { + query: '', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + const result = await service.searchUsers(currentUserId, queryDto); + + expect(result.items).toEqual([]); + expect(result.pagination.cursor).toBeNull(); + expect(mockUsersService.searchUsers).not.toHaveBeenCalled(); + }); + + it('should return empty array for whitespace-only query', async () => { + const queryDto = { + query: ' ', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + const result = await service.searchUsers(currentUserId, queryDto); + + expect(result.items).toEqual([]); + expect(result.pagination.cursor).toBeNull(); + }); + + it('should handle URL encoded queries', async () => { + const queryDto = { + query: 'john%20doe', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockUsersService.searchUsers.mockResolvedValueOnce(mockSearchUsers); + mockUsersService.getUsersRelationshipsMap.mockResolvedValueOnce(new Map()); + + await service.searchUsers(currentUserId, queryDto); + + expect(mockUsersService.searchUsers).toHaveBeenCalledWith( + currentUserId, + 'john doe', + 21, // limit + 1 + undefined, + false, + undefined, + ); + }); + + it('should handle invalid URL encoded queries gracefully', async () => { + const queryDto = { + query: 'test%', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockUsersService.searchUsers.mockResolvedValueOnce(mockSearchUsers); + mockUsersService.getUsersRelationshipsMap.mockResolvedValueOnce(new Map()); + + await service.searchUsers(currentUserId, queryDto); + + expect(mockUsersService.searchUsers).toHaveBeenCalledWith( + currentUserId, + 'test%', + 21, + undefined, + false, + undefined, + ); + }); + + it('should search users successfully', async () => { + const queryDto = { + query: 'john', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + const mockRelationships = new Map([ + [BigInt(10), { isFollowing: true, isFollowedBy: false }], + [BigInt(20), { isFollowing: false, isFollowedBy: true }], + ]); + + mockUsersService.searchUsers.mockResolvedValueOnce(mockSearchUsers); + mockUsersService.getUsersRelationshipsMap.mockResolvedValueOnce(mockRelationships); + + const result = await service.searchUsers(currentUserId, queryDto, 20); + + expect(mockUsersService.searchUsers).toHaveBeenCalledWith( + currentUserId, + 'john', + 21, // limit + 1 + undefined, + false, + undefined, + ); + + expect(mockUsersService.getUsersRelationshipsMap).toHaveBeenCalledWith(currentUserId, [ + BigInt(10), + BigInt(20), + ]); + + expect(result.users).toHaveLength(2); + expect(result.pagination).toBeDefined(); + }); + + it('should handle cursor pagination correctly', async () => { + const cursor = encodeCompositeCursor({ + rankingScore: '100', + id: '10', + }); + + const queryDto = { + query: 'test', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockUsersService.searchUsers.mockResolvedValueOnce(mockSearchUsers); + mockUsersService.getUsersRelationshipsMap.mockResolvedValueOnce(new Map()); + + const result = await service.searchUsers(currentUserId, queryDto, 20, cursor); + + expect(mockUsersService.searchUsers).toHaveBeenCalledWith( + currentUserId, + 'test', + 21, + { + rankingScore: '100', + id: '10', + }, + false, + undefined, + ); + + expect(result.users).toHaveLength(2); + }); + + it('should throw BAD_REQUEST for invalid cursor', async () => { + const queryDto = { + query: 'test', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + const invalidCursor = 'invalid-cursor'; + + await expect(service.searchUsers(currentUserId, queryDto, 20, invalidCursor)).rejects.toThrow( + new HttpException( + { + message: PAGINATION_ERROR_MESSAGES.INVALID_CURSOR, + code: PAGINATION_ERROR_CODES.INVALID_CURSOR, + }, + HttpStatus.BAD_REQUEST, + ), + ); + }); + + it('should pass excludeMutedAndBlocked flag correctly', async () => { + const queryDto = { + query: 'test', + peopleFilter: undefined, + excludeMutedAndBlocked: true, + }; + + mockUsersService.searchUsers.mockResolvedValueOnce(mockSearchUsers); + mockUsersService.getUsersRelationshipsMap.mockResolvedValueOnce(new Map()); + + await service.searchUsers(currentUserId, queryDto, 20); + + expect(mockUsersService.searchUsers).toHaveBeenCalledWith( + currentUserId, + 'test', + 21, + undefined, + true, // excludeMutedAndBlocked + undefined, + ); + }); + + it('should pass peopleFilter correctly', async () => { + const peopleFilter = PeopleSearchFilter.Anyone; + const queryDto = { + query: 'test', + peopleFilter, + excludeMutedAndBlocked: false, + }; + + mockUsersService.searchUsers.mockResolvedValueOnce(mockSearchUsers); + mockUsersService.getUsersRelationshipsMap.mockResolvedValueOnce(new Map()); + + await service.searchUsers(currentUserId, queryDto, 20); + + expect(mockUsersService.searchUsers).toHaveBeenCalledWith( + currentUserId, + 'test', + 21, + undefined, + false, + peopleFilter, + ); + }); + + it('should convert query to lowercase and trim', async () => { + const queryDto = { + query: ' JOHN DOE ', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockUsersService.searchUsers.mockResolvedValueOnce(mockSearchUsers); + mockUsersService.getUsersRelationshipsMap.mockResolvedValueOnce(new Map()); + + await service.searchUsers(currentUserId, queryDto); + + expect(mockUsersService.searchUsers).toHaveBeenCalledWith( + currentUserId, + 'john doe', + 21, + undefined, + false, + undefined, + ); + }); + + it('should use default limit of 20 when not provided', async () => { + const queryDto = { + query: 'test', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockUsersService.searchUsers.mockResolvedValueOnce(mockSearchUsers); + mockUsersService.getUsersRelationshipsMap.mockResolvedValueOnce(new Map()); + + await service.searchUsers(currentUserId, queryDto); + + expect(mockUsersService.searchUsers).toHaveBeenCalledWith( + currentUserId, + 'test', + 21, // default 20 + 1 + undefined, + false, + undefined, + ); + }); + + it('should handle empty user results', async () => { + const queryDto = { + query: 'nonexistent', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + mockUsersService.searchUsers.mockResolvedValueOnce([]); + mockUsersService.getUsersRelationshipsMap.mockResolvedValueOnce(new Map()); + + const result = await service.searchUsers(currentUserId, queryDto); + + expect(result.users).toEqual([]); + expect(result.pagination.cursor).toBeNull(); + expect(result.pagination.hasNextPage).toBe(false); + }); + + it('should generate pagination cursor when results exceed limit', async () => { + const queryDto = { + query: 'popular', + peopleFilter: undefined, + excludeMutedAndBlocked: false, + }; + + // Return exactly limit + 1 items (21) to trigger pagination + const manyUsers = Array.from({ length: 21 }, (_, i) => ({ + id: (10 + i).toString(), + username: `user_${i}`, + displayName: `User ${i}`, + avatarUrl: `http://avatar${i}.jpg`, + rankingScore: (100 - i).toString(), + })); + + mockUsersService.searchUsers.mockResolvedValueOnce(manyUsers); + mockUsersService.getUsersRelationshipsMap.mockResolvedValueOnce(new Map()); + + const result = await service.searchUsers(currentUserId, queryDto, 20); + + expect(result.users?.length).toBe(20); + expect(result.pagination.nextCursor).toBeDefined(); + expect(result.pagination.hasNextPage).toBe(true); + }); }); }); diff --git a/test/users/me/me.controller.spec.ts b/test/users/me/me.controller.spec.ts index a9eb7823..60cf84d2 100644 --- a/test/users/me/me.controller.spec.ts +++ b/test/users/me/me.controller.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { MeController } from 'src/users/me/me.controller'; import { UsersService } from 'src/users/users.service'; -import { ChangePasswordBasicDto } from 'src/users/dtos'; +import { ChangePasswordBasicDto } from 'src/users/dtos/change-password-basic.dto'; import type { RequestUser } from 'src/common/interfaces'; describe('MeController', () => { @@ -49,17 +49,14 @@ describe('MeController', () => { }; it('should call usersService.changePassword with correct parameters', async () => { - // Arrange const expectedUserId = BigInt(1); const expectedResult = { message: 'Password changed successfully' }; mockUsersService.changePassword.mockResolvedValue(expectedResult); - // Act const user: RequestUser = { id: expectedUserId.toString() }; const result = await controller.changePassword(changePasswordDto, user); - // Assert expect(mockUsersService.changePassword).toHaveBeenCalledWith( expectedUserId, changePasswordDto, @@ -97,30 +94,25 @@ describe('MeController', () => { describe('POST /me/blocks/:username', () => { it('should call usersService.blockUser with correct parameters', async () => { - // Arrange const userId = BigInt(1); const blockedUsername = 'blockedUser'; const expectedResult = { message: 'User blocked successfully' }; mockUsersService.blockUser.mockResolvedValue(expectedResult); - // Act const result = await controller.blockUser({ id: userId.toString() }, blockedUsername); - // Assert expect(mockUsersService.blockUser).toHaveBeenCalledWith(userId, blockedUsername); expect(mockUsersService.blockUser).toHaveBeenCalledTimes(1); expect(result).toEqual(expectedResult); }); it('should handle errors thrown by usersService.blockUser', async () => { - // Arrange const userId = BigInt(1); const blockedUsername = 'nonexistentUser'; mockUsersService.blockUser.mockRejectedValue(new Error('User not found')); - // Act & Assert await expect( controller.blockUser({ id: userId.toString() }, blockedUsername), ).rejects.toThrow('User not found'); @@ -131,30 +123,25 @@ describe('MeController', () => { describe('DELETE /me/blocks/:username', () => { it('should call usersService.unblockUser with correct parameters', async () => { - // Arrange const userId = BigInt(1); const unblockedUsername = 'blockedUser'; const expectedResult = { message: 'User unblocked successfully' }; mockUsersService.unblockUser.mockResolvedValue(expectedResult); - // Act const result = await controller.unblockUser({ id: userId.toString() }, unblockedUsername); - // Assert expect(mockUsersService.unblockUser).toHaveBeenCalledWith(userId, unblockedUsername); expect(mockUsersService.unblockUser).toHaveBeenCalledTimes(1); expect(result).toEqual(expectedResult); }); it('should handle errors thrown by usersService.unblockUser', async () => { - // Arrange const userId = BigInt(1); const unblockedUsername = 'nonexistentUser'; mockUsersService.unblockUser.mockRejectedValue(new Error('User not found')); - // Act & Assert await expect( controller.unblockUser({ id: userId.toString() }, unblockedUsername), ).rejects.toThrow('User not found'); @@ -165,30 +152,25 @@ describe('MeController', () => { describe('POST /me/mutes/:username', () => { it('should call usersService.muteUser with correct parameters', async () => { - // Arrange const userId = BigInt(1); const mutedUsername = 'mutedUser'; const expectedResult = { message: 'User muted successfully' }; mockUsersService.muteUser.mockResolvedValue(expectedResult); - // Act const result = await controller.muteUser({ id: userId.toString() }, mutedUsername); - // Assert expect(mockUsersService.muteUser).toHaveBeenCalledWith(userId, mutedUsername); expect(mockUsersService.muteUser).toHaveBeenCalledTimes(1); expect(result).toEqual(expectedResult); }); it('should handle errors thrown by usersService.muteUser', async () => { - // Arrange const userId = BigInt(1); const mutedUsername = 'nonexistentUser'; mockUsersService.muteUser.mockRejectedValue(new Error('User not found')); - // Act & Assert await expect(controller.muteUser({ id: userId.toString() }, mutedUsername)).rejects.toThrow( 'User not found', ); @@ -199,30 +181,25 @@ describe('MeController', () => { describe('DELETE /me/mutes/:username', () => { it('should call usersService.unmuteUser with correct parameters', async () => { - // Arrange const userId = BigInt(1); const unmutedUsername = 'mutedUser'; const expectedResult = { message: 'User unmuted successfully' }; mockUsersService.unmuteUser.mockResolvedValue(expectedResult); - // Act const result = await controller.unmuteUser({ id: userId.toString() }, unmutedUsername); - // Assert expect(mockUsersService.unmuteUser).toHaveBeenCalledWith(userId, unmutedUsername); expect(mockUsersService.unmuteUser).toHaveBeenCalledTimes(1); expect(result).toEqual(expectedResult); }); it('should handle errors thrown by usersService.unmuteUser', async () => { - // Arrange const userId = BigInt(1); const unmutedUsername = 'nonexistentUser'; mockUsersService.unmuteUser.mockRejectedValue(new Error('User not found')); - // Act & Assert await expect( controller.unmuteUser({ id: userId.toString() }, unmutedUsername), ).rejects.toThrow('User not found'); @@ -233,7 +210,6 @@ describe('MeController', () => { describe('GET /me', () => { it('should call usersService.getUserProfile with correct parameters', async () => { - // Arrange const expectedResult = { displayName: 'Omar Hassan', bio: 'Software Developer', @@ -241,16 +217,21 @@ describe('MeController', () => { mockUsersService.getUserProfile.mockResolvedValue(expectedResult); - // Act const result = await controller.getMyProfile({ id: '18', }); - // Assert expect(mockUsersService.getUserProfile).toHaveBeenCalledWith('', BigInt(18), true); expect(mockUsersService.getUserProfile).toHaveBeenCalledTimes(1); expect(result).toEqual(expectedResult); }); + + it('should handle errors thrown by usersService.getUserProfile', async () => { + mockUsersService.getUserProfile.mockRejectedValue(new Error('Profile fetch failed')); + + await expect(controller.getMyProfile({ id: '18' })).rejects.toThrow('Profile fetch failed'); + expect(mockUsersService.getUserProfile).toHaveBeenCalledTimes(1); + }); }); describe('PATCH /me', () => { @@ -283,16 +264,13 @@ describe('MeController', () => { }; it('should call usersService.updateProfile with correct parameters', async () => { - // Arrange const expectedUserId = BigInt(18); const expectedResult = { message: 'Profile updated successfully' }; mockUsersService.updateProfile.mockResolvedValue(expectedResult); - // Act const result = await controller.updateProfile({ id: '18' }, {}, updateProfileDto); - // Assert expect(mockUsersService.updateProfile).toHaveBeenCalledWith( expectedUserId, updateProfileDto, @@ -303,7 +281,6 @@ describe('MeController', () => { }); it('should call usersService.updateProfile with avatar file', async () => { - // Arrange const expectedUserId = BigInt(18); const expectedResult = { message: 'Profile updated successfully', @@ -312,14 +289,12 @@ describe('MeController', () => { mockUsersService.updateProfile.mockResolvedValue(expectedResult); - // Act const result = await controller.updateProfile( { id: '18' }, { avatar: mockFiles.avatar }, updateProfileDto, ); - // Assert expect(mockUsersService.updateProfile).toHaveBeenCalledWith( expectedUserId, updateProfileDto, @@ -329,8 +304,31 @@ describe('MeController', () => { expect(result).toEqual(expectedResult); }); + it('should call usersService.updateProfile with banner file only', async () => { + const expectedUserId = BigInt(18); + const expectedResult = { + message: 'Profile updated successfully', + bannerUrl: 'https://example.com/banner.jpg', + }; + + mockUsersService.updateProfile.mockResolvedValue(expectedResult); + + const result = await controller.updateProfile( + { id: '18' }, + { banner: mockFiles.banner }, + updateProfileDto, + ); + + expect(mockUsersService.updateProfile).toHaveBeenCalledWith( + expectedUserId, + updateProfileDto, + { banner: mockFiles.banner }, + ); + expect(mockUsersService.updateProfile).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedResult); + }); + it('should call usersService.updateProfile with both avatar and banner files', async () => { - // Arrange const expectedUserId = BigInt(18); const expectedResult = { message: 'Profile updated successfully', @@ -340,10 +338,8 @@ describe('MeController', () => { mockUsersService.updateProfile.mockResolvedValue(expectedResult); - // Act const result = await controller.updateProfile({ id: '18' }, mockFiles, updateProfileDto); - // Assert expect(mockUsersService.updateProfile).toHaveBeenCalledWith( expectedUserId, updateProfileDto, @@ -354,7 +350,6 @@ describe('MeController', () => { }); it('should handle profile update without any files', async () => { - // Arrange const expectedUserId = BigInt(18); const expectedResult = { message: 'Profile updated successfully', @@ -364,10 +359,8 @@ describe('MeController', () => { mockUsersService.updateProfile.mockResolvedValue(expectedResult); - // Act const result = await controller.updateProfile({ id: '18' }, {}, updateProfileDto); - // Assert expect(mockUsersService.updateProfile).toHaveBeenCalledWith( expectedUserId, updateProfileDto, @@ -376,13 +369,28 @@ describe('MeController', () => { expect(result).toEqual(expectedResult); }); + it('should call usersService.updateProfile when files are undefined', async () => { + const expectedUserId = BigInt(18); + const expectedResult = { message: 'Profile updated successfully' }; + + mockUsersService.updateProfile.mockResolvedValue(expectedResult); + + // @ts-expect-error Passing undefined to simulate potential runtime edge case + const result = await controller.updateProfile({ id: '18' }, undefined, updateProfileDto); + + expect(mockUsersService.updateProfile).toHaveBeenCalledWith( + expectedUserId, + updateProfileDto, + undefined, + ); + expect(result).toEqual(expectedResult); + }); + it('should handle errors thrown by usersService.updateProfile', async () => { - // Arrange const error = new Error('Failed to update profile'); mockUsersService.updateProfile.mockRejectedValue(error); - // Act & Assert await expect( controller.updateProfile({ id: '18' }, { avatar: mockFiles.avatar }, updateProfileDto), ).rejects.toThrow('Failed to update profile'); @@ -390,16 +398,14 @@ describe('MeController', () => { }); it('should handle invalid data when updating profile', async () => { - // Arrange const invalidUpdateProfileDto = { - displayName: '', // Empty displayName + displayName: '', bio: 'Software Developer', }; const error = new Error('Invalid profile data'); mockUsersService.updateProfile.mockRejectedValue(error); - // Act & Assert await expect( controller.updateProfile({ id: '18' }, {}, invalidUpdateProfileDto), ).rejects.toThrow('Invalid profile data'); @@ -418,28 +424,23 @@ describe('MeController', () => { } as Express.Multer.File; it('should call usersService.uploadAvatar with correct parameters', async () => { - // Arrange const expectedUserId = BigInt(18); const expectedResult = { message: 'Avatar uploaded successfully' }; mockUsersService.uploadAvatar.mockResolvedValue(expectedResult); - // Act const result = await controller.uploadAvatar({ id: '18' }, avatar); - // Assert expect(mockUsersService.uploadAvatar).toHaveBeenCalledWith(expectedUserId, avatar); expect(mockUsersService.uploadAvatar).toHaveBeenCalledTimes(1); expect(result).toEqual(expectedResult); }); it('should handle errors thrown by usersService.uploadAvatar', async () => { - // Arrange const error = new Error('Failed to upload avatar'); mockUsersService.uploadAvatar.mockRejectedValue(error); - // Act & Assert await expect(controller.uploadAvatar({ id: '18' }, avatar)).rejects.toThrow( 'Failed to upload avatar', ); @@ -458,28 +459,23 @@ describe('MeController', () => { } as Express.Multer.File; it('should call usersService.uploadBanner with correct parameters', async () => { - // Arrange const expectedUserId = BigInt(18); const expectedResult = { message: 'Banner uploaded successfully' }; mockUsersService.uploadBanner.mockResolvedValue(expectedResult); - // Act const result = await controller.uploadBanner({ id: '18' }, banner); - // Assert expect(mockUsersService.uploadBanner).toHaveBeenCalledWith(expectedUserId, banner); expect(mockUsersService.uploadBanner).toHaveBeenCalledTimes(1); expect(result).toEqual(expectedResult); }); it('should handle errors thrown by usersService.uploadBanner', async () => { - // Arrange const error = new Error('Failed to upload banner'); mockUsersService.uploadBanner.mockRejectedValue(error); - // Act & Assert await expect(controller.uploadBanner({ id: '18' }, banner)).rejects.toThrow( 'Failed to upload banner', ); @@ -489,28 +485,23 @@ describe('MeController', () => { describe('DELETE /me/banner', () => { it('should call usersService.deleteBanner with correct parameters', async () => { - // Arrange const expectedUserId = BigInt(18); const expectedResult = { message: 'Banner deleted successfully' }; mockUsersService.deleteBanner.mockResolvedValue(expectedResult); - // Act const result = await controller.deleteBanner({ id: '18' }); - // Assert expect(mockUsersService.deleteBanner).toHaveBeenCalledWith(expectedUserId); expect(mockUsersService.deleteBanner).toHaveBeenCalledTimes(1); expect(result).toEqual(expectedResult); }); it('should handle errors thrown by usersService.deleteBanner', async () => { - // Arrange const error = new Error('Failed to delete banner'); mockUsersService.deleteBanner.mockRejectedValue(error); - // Act & Assert await expect(controller.deleteBanner({ id: '18' })).rejects.toThrow( 'Failed to delete banner', ); From 8ff5d3a50514743c46002ff351a55ff0304c2228 Mon Sep 17 00:00:00 2001 From: Mostafa Ahmed Abdelglel <139139781+galelo04@users.noreply.github.com> Date: Mon, 15 Dec 2025 19:37:06 +0200 Subject: [PATCH 29/43] feat: push messages notifications - [CU-869bge6rg] (#211) Co-authored-by: OmarHassan2003 Co-authored-by: Omar Hassan <149178126+OmarHassan2003@users.noreply.github.com> --- api-spec/complete-spec/main.tsp | 3 - api-spec/implemented-spec/main.tsp | 2 - .../users/follows/get-user-followers.bru | 2 +- package.json | 2 +- pnpm-lock.yaml | 10 +- .../migration.sql | 7 + prisma/schema.prisma | 2 + prisma/seed.ts | 11 - src/conversations/conversations.module.ts | 16 +- src/conversations/conversations.repository.ts | 12 + src/conversations/conversations.service.ts | 4 + src/conversations/gateways/dm.gateway.ts | 32 ++- .../messages/messages-push.processor.ts | 101 +++++++ .../messages/messages.listeners.ts | 71 +++++ .../messages/messages.repository.ts | 1 + .../messages/messages.services.ts | 2 +- src/devices/devices.repository.ts | 6 +- src/events/domain-events.service.ts | 30 +++ src/events/interfaces/event.interface.ts | 35 +++ src/firebase/firebase.module.ts | 6 +- src/firebase/push-sender.service.ts | 61 +++++ .../dtos/notification-payload.dto.ts | 10 + .../dtos/notification-response.dto.ts | 1 - src/notifications/notifications.listeners.ts | 52 ++++ src/notifications/notifications.module.ts | 2 + src/notifications/notifications.processor.ts | 107 +++----- src/notifications/notifications.repository.ts | 82 +++++- src/notifications/notifications.service.ts | 250 +++++++++++++++++- .../utils/fcm-notification-body-builder.ts | 87 ++++-- src/sse/sse-events.service.ts | 30 ++- src/sse/sse.module.ts | 7 +- src/tweets/tweets.repository.ts | 31 ++- src/tweets/tweets.service.ts | 24 +- src/users/users.repository.ts | 27 +- src/users/users.service.ts | 5 + .../conversations/gateways/dm.gateway.spec.ts | 52 ++++ .../messages/messages.services.spec.ts | 1 + .../notifications.controller.spec.ts | 1 - .../notifications.service.spec.ts | 60 +++-- test/tweets/tweets.service.spec.ts | 12 + test/users/users.service.spec.ts | 1 + 41 files changed, 1087 insertions(+), 171 deletions(-) create mode 100644 prisma/migrations/20251212181827_add_payload_index_for_notifications/migration.sql create mode 100644 src/conversations/messages/messages-push.processor.ts create mode 100644 src/conversations/messages/messages.listeners.ts create mode 100644 src/firebase/push-sender.service.ts create mode 100644 src/notifications/dtos/notification-payload.dto.ts mode change 100644 => 100755 src/notifications/notifications.service.ts diff --git a/api-spec/complete-spec/main.tsp b/api-spec/complete-spec/main.tsp index 09338262..5b265637 100644 --- a/api-spec/complete-spec/main.tsp +++ b/api-spec/complete-spec/main.tsp @@ -777,9 +777,6 @@ model NotificationTweetSummary { @doc("The total number of tweets involved in this aggregation.\n Use this for UI labels like 'Alex liked 5 tweets'.") totalCount: int32; - - @doc("IDs of all tweets involved in this notification (for client-side fetching if needed).") - subjectIds: string[]; } @doc("Represents a single notification item for a user.") diff --git a/api-spec/implemented-spec/main.tsp b/api-spec/implemented-spec/main.tsp index 6416c4f5..4ad0ead2 100644 --- a/api-spec/implemented-spec/main.tsp +++ b/api-spec/implemented-spec/main.tsp @@ -937,8 +937,6 @@ model NotificationTweetSummary { @doc("The total number of tweets involved in this aggregation.\n Use this for UI labels like 'Alex liked 5 tweets'.") totalCount: int32; - @doc("IDs of all tweets involved in this notification (for client-side fetching if needed).") - subjectIds: string[]; } @doc("Represents a single notification item for a user.") diff --git a/bruno/collections/users/follows/get-user-followers.bru b/bruno/collections/users/follows/get-user-followers.bru index 469d391c..96ff41aa 100644 --- a/bruno/collections/users/follows/get-user-followers.bru +++ b/bruno/collections/users/follows/get-user-followers.bru @@ -19,7 +19,7 @@ auth:bearer { } vars:pre-request { - base_url: http://localhost:3000 + base_url: https://love.raven.cmp27.space } settings { diff --git a/package.json b/package.json index 8b777661..4f923032 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@nestjs/schedule": "^6.0.1", "@nestjs/throttler": "^6.4.0", "@nestjs/websockets": "^11.1.9", - "@prisma/client": "^6.16.3", + "@prisma/client": "^6.17.0", "@socket.io/redis-adapter": "^8.3.0", "bcrypt": "^6.0.0", "bullmq": "^5.61.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 033abcfe..84a1b9e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,8 +60,8 @@ importers: specifier: ^11.1.9 version: 11.1.9(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@prisma/client': - specifier: ^6.16.3 - version: 6.16.3(prisma@6.17.0(typescript@5.9.3))(typescript@5.9.3) + specifier: ^6.17.0 + version: 6.17.0(prisma@6.17.0(typescript@5.9.3))(typescript@5.9.3) '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.5) @@ -1566,8 +1566,8 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@prisma/client@6.16.3': - resolution: {integrity: sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==} + '@prisma/client@6.17.0': + resolution: {integrity: sha512-b42mTLOdLEZ6e/igu8CLdccAUX9AwHknQQ1+pHOftnzDP2QoyZyFvcANqSLs5ockimFKJnV7Ljf+qrhNYf6oAg==} engines: {node: '>=18.18'} peerDependencies: prisma: '*' @@ -7500,7 +7500,7 @@ snapshots: '@pkgr/core@0.2.9': {} - '@prisma/client@6.16.3(prisma@6.17.0(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@6.17.0(prisma@6.17.0(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: prisma: 6.17.0(typescript@5.9.3) typescript: 5.9.3 diff --git a/prisma/migrations/20251212181827_add_payload_index_for_notifications/migration.sql b/prisma/migrations/20251212181827_add_payload_index_for_notifications/migration.sql new file mode 100644 index 00000000..8e5da635 --- /dev/null +++ b/prisma/migrations/20251212181827_add_payload_index_for_notifications/migration.sql @@ -0,0 +1,7 @@ + +-- AlterTable +ALTER TABLE "notifications" ADD COLUMN "payload" JSONB; + + +-- CreateIndex +CREATE INDEX "notifications_receiver_id_dedupe_key_seen_idx" ON "notifications"("receiver_id", "dedupe_key", "seen"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 042e2e15..8ce8d57e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -359,6 +359,7 @@ model Notification { dedupeKey String? @unique @map("dedupe_key") @db.VarChar(255) isAggregated Boolean @default(false) @map("is_aggregated") seen Boolean @default(false) + payload Json? @db.JsonB createdAt DateTime @default(now()) @map("created_at") openedAt DateTime? @map("opened_at") latestEventAt DateTime @default(now()) @map("latest_event_at") @@ -367,6 +368,7 @@ model Notification { tweet Tweet? @relation(fields: [tweetId], references: [id], onDelete: NoAction, onUpdate: NoAction) @@index([receiverId, latestEventAt(sort: Desc), id(sort: Desc)]) + @@index([receiverId, dedupeKey, seen]) @@map("notifications") } diff --git a/prisma/seed.ts b/prisma/seed.ts index 456c21d8..247e74ce 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1322,17 +1322,6 @@ if (process.env.SEED_ENV === 'true') { { actorId: 8, receiverId: 4, type: NotificationType.RETWEET, tweetId: anasTweet1.id }, { actorId: 5, receiverId: 7, type: NotificationType.QUOTE, tweetId: gelgelQuoteTweet.id }, { actorId: 10, receiverId: 12, type: NotificationType.RETWEET, tweetId: fatmaTweet1.id }, - { actorId: 4, receiverId: 1, type: NotificationType.MESSAGE }, - { actorId: 4, receiverId: 2, type: NotificationType.MESSAGE }, - { actorId: 1, receiverId: 4, type: NotificationType.MESSAGE }, - { actorId: 1, receiverId: 2, type: NotificationType.MESSAGE }, - { actorId: 2, receiverId: 4, type: NotificationType.MESSAGE }, - { actorId: 2, receiverId: 1, type: NotificationType.MESSAGE }, - { actorId: 6, receiverId: 3, type: NotificationType.MESSAGE }, - { actorId: 8, receiverId: 12, type: NotificationType.MESSAGE }, - { actorId: 8, receiverId: 9, type: NotificationType.MESSAGE }, - { actorId: 12, receiverId: 8, type: NotificationType.MESSAGE }, - { actorId: 12, receiverId: 9, type: NotificationType.MENTION }, ], }); } diff --git a/src/conversations/conversations.module.ts b/src/conversations/conversations.module.ts index c021e23a..736166ea 100644 --- a/src/conversations/conversations.module.ts +++ b/src/conversations/conversations.module.ts @@ -11,15 +11,29 @@ import { AuthModule } from 'src/auth/auth.module'; import { DmGateway } from './gateways/dm.gateway'; import { SseModule } from '../sse/sse.module'; import { MediaModule } from 'src/media/media.module'; +import { MessageNotificationsListeners } from './messages/messages.listeners'; +import { BullModule } from '@nestjs/bullmq'; +import { MessagesPushProcessor } from './messages/messages-push.processor'; +import { FirebaseModule } from 'src/firebase/firebase.module'; @Module({ - imports: [UsersModule, PrismaModule, AuthModule, MediaModule, forwardRef(() => SseModule)], + imports: [ + BullModule.registerQueue({ name: 'messages-push' }), + UsersModule, + PrismaModule, + AuthModule, + MediaModule, + forwardRef(() => SseModule), + FirebaseModule, + ], controllers: [ConversationsController, MessagesController], providers: [ ConversationsService, ConversationsRepository, MessagesService, MessagesRepository, + MessageNotificationsListeners, + MessagesPushProcessor, DmGateway, ], exports: [ConversationsService, ConversationsRepository], diff --git a/src/conversations/conversations.repository.ts b/src/conversations/conversations.repository.ts index eebd7f53..fecd8775 100644 --- a/src/conversations/conversations.repository.ts +++ b/src/conversations/conversations.repository.ts @@ -237,6 +237,18 @@ export class ConversationsRepository { }); } + async getOtherParticipant(conversationId: bigint, currentUserId: bigint) { + return this.prisma.conversationParticipant.findFirst({ + where: { + conversationId, + userId: { not: currentUserId }, + }, + select: { + userId: true, + }, + }); + } + async countUnseenConversations(userId: bigint) { const conversations = await this.prisma.conversationParticipant.findMany({ where: { diff --git a/src/conversations/conversations.service.ts b/src/conversations/conversations.service.ts index 96dfa94a..457c597d 100644 --- a/src/conversations/conversations.service.ts +++ b/src/conversations/conversations.service.ts @@ -122,6 +122,10 @@ export class ConversationsService { return { items: itemsDto, pagination }; } + async getOtherParticipant(conversationId: bigint, userId: bigint) { + return this.conversationsRepository.getOtherParticipant(conversationId, userId); + } + async createOrFindConversation(userId: bigint, username: string) { const otherUser = await this.usersRepository.getUserByUsername(username); diff --git a/src/conversations/gateways/dm.gateway.ts b/src/conversations/gateways/dm.gateway.ts index 02e995b3..60c0dd22 100644 --- a/src/conversations/gateways/dm.gateway.ts +++ b/src/conversations/gateways/dm.gateway.ts @@ -26,6 +26,7 @@ import { MarkSeenDto } from './dto/mark-seen.dto'; import { TypingIndicatorDto } from './dto/typing-indicator.dto'; import { ReactionDto } from './dto/react-message.dto'; import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; +import { DomainEventsService } from 'src/events/domain-events.service'; @WebSocketGateway({ namespace: '/ws/dm', @@ -41,6 +42,7 @@ export class DmGateway implements OnGatewayConnection, OnGatewayDisconnect { private readonly conversationsService: ConversationsService, private readonly messagesService: MessagesService, private readonly sseEvents: SseEventsService, + private readonly domainEventsService: DomainEventsService, ) { this.logger.log('DmGateway initialized'); } @@ -342,6 +344,20 @@ export class DmGateway implements OnGatewayConnection, OnGatewayDisconnect { this.logger.log(`User ${user.id} joined room: ${conversationId}`); } + const otherParticipant = await this.conversationsService.getOtherParticipant( + BigInt(conversationId), + BigInt(user.id), + ); + + await this.domainEventsService.emitMessageCreated({ + actorId: BigInt(user.id), + receiverId: BigInt(otherParticipant!.userId), + conversationId: BigInt(conversationId), + messagePreview: message.content.slice(0, 100), + hasMedia: !!message.mediaUrl, + mediaType: message.media?.type || null, + }); + this.server.to(conversationId).emit('message_received', { conversationId, message: { @@ -539,7 +555,21 @@ export class DmGateway implements OnGatewayConnection, OnGatewayDisconnect { }); } - const { reactionDb, sender, receiver } = reactionState; + const { reactionDb, sender, receiver, message } = reactionState; + + if (user.username !== sender!.user.username) { + const reaction = reactionDb.reactionReceiver; + + if (reaction) { + await this.domainEventsService.emitReactionSent({ + actorId: BigInt(user.id), + receiverId: sender!.user.id, + conversationId: BigInt(conversationId), + reaction, + messagePreview: message.content.slice(0, 100), + }); + } + } const socketPayload = { conversationId, diff --git a/src/conversations/messages/messages-push.processor.ts b/src/conversations/messages/messages-push.processor.ts new file mode 100644 index 00000000..840df876 --- /dev/null +++ b/src/conversations/messages/messages-push.processor.ts @@ -0,0 +1,101 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { Logger } from '@nestjs/common'; +import { PushSenderService } from 'src/firebase/push-sender.service'; +import { UsersRepository } from 'src/users/users.repository'; +import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; +import { MediaType } from '@prisma/client'; +import { buildFcmNotificationText } from 'src/notifications/utils/fcm-notification-body-builder'; + +@Processor('messages-push') +export class MessagesPushProcessor extends WorkerHost { + private readonly logger = new Logger(MessagesPushProcessor.name); + + constructor( + private readonly pushSender: PushSenderService, + private readonly usersRepository: UsersRepository, + ) { + super(); + } + + async process( + job: Job<{ + actorId: string; + conversationId: string; + messagePreview: string; + receiverId: string; + hasMedia?: boolean; + mediaType?: MediaType | null; + reaction?: string | null; + }>, + ) { + const { messagePreview, receiverId, actorId, conversationId, reaction, hasMedia, mediaType } = + job.data; + this.logger.log( + `Processing push message job for conversation id ${conversationId} to user ${receiverId}`, + ); + + try { + const actorsMetadata = await this.usersRepository.getUsersMetadataById([BigInt(actorId)]); + const actorMetadata = actorsMetadata[0]; + if (!actorMetadata) { + this.logger.warn(`Actor with id ${actorId} not found, skipping push notification`); + return; + } + + const languageCode = await this.usersRepository.getUserLocale(BigInt(receiverId)); + + const { title, body } = buildFcmNotificationText({ + notificationType: 'MESSAGE', + previewActors: [actorMetadata.profile?.displayName || actorMetadata.username], + tweetSnippet: messagePreview, + locale: languageCode, + reaction, + hasMedia, + mediaType, + }); + + const fcmData = { + actorSummary: JSON.stringify([ + { + username: actorMetadata.username, + displayName: actorMetadata.profile?.displayName, + avatarUrl: actorMetadata.profile?.avatarUrl || DEFAULT_PROFILE_PICTURE, + }, + ]), + messageSummary: JSON.stringify({ + messagePreview, + conversationId: conversationId.toString(), + }), + }; + + const payload = { + token: null, + notification: { + title, + body: body ?? undefined, + image: actorMetadata.profile?.avatarUrl || DEFAULT_PROFILE_PICTURE, + }, + data: fcmData as unknown as Record, + android: { + priority: 'high' as const, + notification: { + channel_id: 'messages', + sound: 'default', + color: '#e5e7ff', + tag: `msg:${conversationId}`, + }, + }, + }; + + this.logger.debug(`FCM Payload: ${JSON.stringify(payload)}`); + + await this.pushSender.sendToDevices(receiverId, payload); + } catch (err) { + this.logger.error( + `Error processing push notification for message to user ${receiverId}`, + err, + ); + } + } +} diff --git a/src/conversations/messages/messages.listeners.ts b/src/conversations/messages/messages.listeners.ts new file mode 100644 index 00000000..331359cc --- /dev/null +++ b/src/conversations/messages/messages.listeners.ts @@ -0,0 +1,71 @@ +import { OnEvent } from '@nestjs/event-emitter'; +import { Injectable, Logger } from '@nestjs/common'; +import { DOMAIN_EVENT_NAMES } from 'src/events/interfaces/event.interface'; +import type { MessageCreatedEvent, ReactionSentEvent } from 'src/events/interfaces/event.interface'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +@Injectable() +export class MessageNotificationsListeners { + private readonly logger = new Logger(MessageNotificationsListeners.name); + constructor(@InjectQueue('messages-push') private readonly messagesPushQueue: Queue) {} + + @OnEvent(DOMAIN_EVENT_NAMES.Message_Created) async handleMessageCreated({ + actorId, + conversationId, + messagePreview, + receiverId, + hasMedia, + mediaType, + }: MessageCreatedEvent) { + await this.messagesPushQueue.add( + 'sendMessagePush', + { + actorId: actorId.toString(), + conversationId: conversationId.toString(), + messagePreview, + receiverId: receiverId.toString(), + hasMedia, + mediaType, + }, + { + attempts: 5, + backoff: { type: 'exponential', delay: 1000 }, + removeOnComplete: true, + delay: 2000, // 2 second delay + removeOnFail: false, + }, + ); + this.logger.log( + `Enqueued push message created job for message with conversationId ${conversationId} from user ${actorId} to user ${receiverId}`, + ); + } + + @OnEvent(DOMAIN_EVENT_NAMES.Reaction_Created) async handleMessageReaction({ + actorId, + conversationId, + messagePreview, + receiverId, + reaction, + }: ReactionSentEvent) { + await this.messagesPushQueue.add( + 'sendMessagePush', + { + actorId: actorId.toString(), + conversationId: conversationId.toString(), + messagePreview, + receiverId: receiverId.toString(), + reaction, + }, + { + attempts: 5, + backoff: { type: 'exponential', delay: 1000 }, + removeOnComplete: true, + delay: 2000, // 2 second delay + removeOnFail: false, + }, + ); + this.logger.log( + `Enqueued push message reacted job for message with conversationId ${conversationId} from user ${actorId} to user ${receiverId}`, + ); + } +} diff --git a/src/conversations/messages/messages.repository.ts b/src/conversations/messages/messages.repository.ts index cf4bb8bb..7788f1da 100644 --- a/src/conversations/messages/messages.repository.ts +++ b/src/conversations/messages/messages.repository.ts @@ -163,6 +163,7 @@ export class MessagesRepository { conversationId: true, reactionReceiver: true, reactionSender: true, + content: true, }, }); } diff --git a/src/conversations/messages/messages.services.ts b/src/conversations/messages/messages.services.ts index 9eb58552..6a91a660 100644 --- a/src/conversations/messages/messages.services.ts +++ b/src/conversations/messages/messages.services.ts @@ -319,6 +319,6 @@ export class MessagesService { return { error: 'REACTION_CREATION_FAILED' }; } - return { reactionDb, sender, receiver }; + return { reactionDb, sender, receiver, message }; } } diff --git a/src/devices/devices.repository.ts b/src/devices/devices.repository.ts index 6fc54a56..4167923c 100644 --- a/src/devices/devices.repository.ts +++ b/src/devices/devices.repository.ts @@ -71,7 +71,11 @@ export class DevicesRepository { async getUserDevices(userId: bigint) { return await this.prisma.userDevice.findMany({ - where: { userId: userId, fcmToken: { not: null }, pushEnabled: true }, + where: { + userId: userId, + fcmToken: { not: null }, + pushEnabled: true, + }, }); } diff --git a/src/events/domain-events.service.ts b/src/events/domain-events.service.ts index cd00ac71..f0ef17f5 100644 --- a/src/events/domain-events.service.ts +++ b/src/events/domain-events.service.ts @@ -2,10 +2,16 @@ import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { DOMAIN_EVENT_NAMES, + MessageCreatedEvent, + ReactionSentEvent, TweetCreatedEvent, TweetLikedEvent, TweetRetweetedEvent, + TweetUnlikedEvent, + TweetUnretweetedEvent, UserFollowedEvent, + UserUnfollowedEvent, + TweetDeleted, } from './interfaces/event.interface'; @Injectable() @@ -23,7 +29,31 @@ export class DomainEventsService { async emitTweetRetweeted(payload: TweetRetweetedEvent) { await this.eventEmitter.emitAsync(DOMAIN_EVENT_NAMES.Tweet_Retweeted, payload); } + async emitTweetCreated(payload: TweetCreatedEvent) { await this.eventEmitter.emitAsync(DOMAIN_EVENT_NAMES.Tweet_Created, payload); } + + async emitTweetUnliked(payload: TweetUnlikedEvent) { + await this.eventEmitter.emitAsync(DOMAIN_EVENT_NAMES.Tweet_Unliked, payload); + } + + async emitTweetUnretweeted(payload: TweetUnretweetedEvent) { + await this.eventEmitter.emitAsync(DOMAIN_EVENT_NAMES.Tweet_Unretweeted, payload); + } + + async emitUserUnfollowed(payload: UserUnfollowedEvent) { + await this.eventEmitter.emitAsync(DOMAIN_EVENT_NAMES.User_Unfollowed, payload); + } + + async emitMessageCreated(payload: MessageCreatedEvent) { + await this.eventEmitter.emitAsync(DOMAIN_EVENT_NAMES.Message_Created, payload); + } + + async emitReactionSent(payload: ReactionSentEvent) { + await this.eventEmitter.emitAsync(DOMAIN_EVENT_NAMES.Reaction_Created, payload); + } + async emitTweetDeleted(payload: TweetDeleted) { + await this.eventEmitter.emitAsync(DOMAIN_EVENT_NAMES.Tweet_Deleted, payload); + } } diff --git a/src/events/interfaces/event.interface.ts b/src/events/interfaces/event.interface.ts index 858fbd23..2a3af1f3 100644 --- a/src/events/interfaces/event.interface.ts +++ b/src/events/interfaces/event.interface.ts @@ -1,8 +1,16 @@ +import { MediaType } from '@prisma/client'; + export const DOMAIN_EVENT_NAMES = { User_Followed: 'user.followed', Tweet_Liked: 'tweet.liked', Tweet_Retweeted: 'tweet.retweeted', Tweet_Created: 'tweet.created', + User_Unfollowed: 'user.unfollowed', + Tweet_Unliked: 'tweet.unliked', + Tweet_Unretweeted: 'tweet.unretweeted', + Message_Created: 'message.created', + Reaction_Created: 'reaction.created', + Tweet_Deleted: 'tweet.deleted', } as const; interface UserEvent { @@ -16,9 +24,28 @@ interface TweetEvent { tweetId: bigint; } +interface MessageEvent { + actorId: bigint; + receiverId: bigint; + conversationId: bigint; + messagePreview: string; + hasMedia: boolean; + mediaType: MediaType | null; +} + +interface ReactionEvent { + actorId: bigint; + receiverId: bigint; + conversationId: bigint; + messagePreview: string; + reaction: string | null; +} + export type UserFollowedEvent = UserEvent; export type TweetLikedEvent = TweetEvent; export type TweetRetweetedEvent = TweetEvent; +export type MessageCreatedEvent = MessageEvent; +export type ReactionSentEvent = ReactionEvent; export type TweetCreatedEvent = { tweetId: bigint; @@ -28,3 +55,11 @@ export type TweetCreatedEvent = { quoteToTweetId?: bigint | null; mentionedUserIds: bigint[]; }; + +export type TweetDeleted = { + receivers: { receiverId: bigint; unseenCount: number }[]; +}; + +export type UserUnfollowedEvent = UserEvent; +export type TweetUnlikedEvent = TweetEvent; +export type TweetUnretweetedEvent = TweetEvent; diff --git a/src/firebase/firebase.module.ts b/src/firebase/firebase.module.ts index f29131cf..c155faa0 100644 --- a/src/firebase/firebase.module.ts +++ b/src/firebase/firebase.module.ts @@ -2,10 +2,14 @@ import { Module, Global } from '@nestjs/common'; import * as admin from 'firebase-admin'; import { ServiceAccount } from 'firebase-admin'; import { ConfigService } from '@nestjs/config'; +import { PushSenderService } from './push-sender.service'; +import { DevicesModule } from 'src/devices/devices.module'; @Global() @Module({ + imports: [DevicesModule], providers: [ + PushSenderService, { provide: 'FIREBASE_ADMIN', useFactory: (config: ConfigService): admin.app.App => { @@ -22,6 +26,6 @@ import { ConfigService } from '@nestjs/config'; inject: [ConfigService], }, ], - exports: ['FIREBASE_ADMIN'], + exports: ['FIREBASE_ADMIN', PushSenderService], }) export class FirebaseModule {} diff --git a/src/firebase/push-sender.service.ts b/src/firebase/push-sender.service.ts new file mode 100644 index 00000000..d481f248 --- /dev/null +++ b/src/firebase/push-sender.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as admin from 'firebase-admin'; +import { DevicesRepository } from 'src/devices/devices.repository'; + +@Injectable() +export class PushSenderService { + private readonly logger = new Logger(PushSenderService.name); + + constructor(private readonly devicesRepository: DevicesRepository) {} + + async sendToDevices(userId: string, payload: Omit) { + const devices = await this.devicesRepository.getUserDevices(BigInt(userId)); + if (!devices.length) { + this.logger.warn(`No devices found for user ${userId}, skipping push notification`); + return; + } + + this.logger.log(`Found ${devices.length} devices for user ${userId}`); + + try { + const tokens = devices.map((d) => d.fcmToken).filter((t): t is string => !!t); + if (!tokens.length) return; + + this.logger.log(`tokens: ${tokens.join(', ')}`); + + const response = await admin.messaging().sendEachForMulticast({ + tokens, + notification: payload.notification, + data: payload.data, + android: payload.android, + }); + + this.logger.log('response: ', response); + + if (response.failureCount > 0) { + const failedTokens: string[] = []; + response.responses.forEach((resp, idx) => { + if (!resp.success) { + const error = resp.error; + if ( + error?.code === 'messaging/invalid-registration-token' || + error?.code === 'messaging/registration-token-not-registered' + ) { + failedTokens.push(tokens[idx]); + } + this.logger.warn( + `Failed to send notification to token ${tokens[idx]}: ${error?.message}`, + ); + } + }); + + if (failedTokens.length > 0) { + await this.devicesRepository.deleteDevicesByTokens(failedTokens); + this.logger.log(`Deleted ${failedTokens.length} invalid tokens for user ${userId}`); + } + } + } catch (err) { + this.logger.error(`Error sending push to user ${userId}`, err); + } + } +} diff --git a/src/notifications/dtos/notification-payload.dto.ts b/src/notifications/dtos/notification-payload.dto.ts new file mode 100644 index 00000000..0770f757 --- /dev/null +++ b/src/notifications/dtos/notification-payload.dto.ts @@ -0,0 +1,10 @@ +export class NotificationPayloadDto { + actorsPreview: Array<{ + id: string; + username: string; + displayName: string | null; + avatarUrl: string; + ifFollowing: boolean; + }>; + actorsIds?: Array; +} diff --git a/src/notifications/dtos/notification-response.dto.ts b/src/notifications/dtos/notification-response.dto.ts index 791662f5..067b54fe 100644 --- a/src/notifications/dtos/notification-response.dto.ts +++ b/src/notifications/dtos/notification-response.dto.ts @@ -19,5 +19,4 @@ class NotificationActorSummaryDto { class NotificationTweetSummaryDto { primaryTweet: TweetDto | null; totalCount: number; - subjectIds: string[]; } diff --git a/src/notifications/notifications.listeners.ts b/src/notifications/notifications.listeners.ts index 2cdaf432..536016d4 100644 --- a/src/notifications/notifications.listeners.ts +++ b/src/notifications/notifications.listeners.ts @@ -6,7 +6,11 @@ import type { TweetLikedEvent, TweetCreatedEvent, TweetRetweetedEvent, + TweetUnlikedEvent, + UserUnfollowedEvent, + TweetUnretweetedEvent, UserFollowedEvent, + TweetDeleted, } from 'src/events/interfaces/event.interface'; import { TweetsRepository } from 'src/tweets/tweets.repository'; @Injectable() @@ -95,6 +99,7 @@ export class NotificationsListeners { // Avoid sending duplicate notifications to users already notified for reply or quote if (authorsIdsNotified.has(targetId)) continue; + authorsIdsNotified.add(targetId); await this.notificationsService.trigger({ type: 'MENTION', actorId: authorId, @@ -107,4 +112,51 @@ export class NotificationsListeners { this.logger.error('Error processing Tweet_Created event:', error); } } + @OnEvent(DOMAIN_EVENT_NAMES.Tweet_Unliked) async handleTweetUnliked(payload: TweetUnlikedEvent) { + try { + await this.notificationsService.handleUndo({ + actorId: payload.actorId, + receiverId: payload.receiverId, + tweetId: payload.tweetId, + type: 'LIKE', + }); + } catch (error) { + this.logger.error('Error processing Tweet_Unliked event:', error); + } + } + + @OnEvent(DOMAIN_EVENT_NAMES.User_Unfollowed) async handleUserUnfollowed( + payload: UserUnfollowedEvent, + ) { + try { + await this.notificationsService.handleUndo({ + actorId: payload.actorId, + receiverId: payload.receiverId, + type: 'FOLLOW', + }); + } catch (error) { + this.logger.error('Error processing User_Unfollowed event:', error); + } + } + @OnEvent(DOMAIN_EVENT_NAMES.Tweet_Unretweeted) async handleTweetUnRetweeted( + payload: TweetUnretweetedEvent, + ) { + try { + await this.notificationsService.handleUndo({ + actorId: payload.actorId, + receiverId: payload.receiverId, + tweetId: payload.tweetId, + type: 'RETWEET', + }); + } catch (error) { + this.logger.error('Error processing Tweet_Unretweeted event:', error); + } + } + @OnEvent(DOMAIN_EVENT_NAMES.Tweet_Deleted) async handleTweetDeleted(payload: TweetDeleted) { + try { + await this.notificationsService.handleTweetDeletionNotifications(payload.receivers); + } catch (error) { + this.logger.error('Error processing Tweet_Deleted event:', error); + } + } } diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts index f5074fad..56d6fc92 100644 --- a/src/notifications/notifications.module.ts +++ b/src/notifications/notifications.module.ts @@ -9,6 +9,7 @@ import { NotificationsListeners } from './notifications.listeners'; import { NotificationProcessor } from './notifications.processor'; import { DevicesModule } from 'src/devices/devices.module'; import { UsersModule } from 'src/users/users.module'; +import { FirebaseModule } from 'src/firebase/firebase.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { UsersModule } from 'src/users/users.module'; UsersModule, DevicesModule, BullModule.registerQueue({ name: 'notifications' }), + FirebaseModule, ], controllers: [NotificationsController], providers: [ diff --git a/src/notifications/notifications.processor.ts b/src/notifications/notifications.processor.ts index 5998328d..a631049e 100644 --- a/src/notifications/notifications.processor.ts +++ b/src/notifications/notifications.processor.ts @@ -1,12 +1,13 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Job } from 'bullmq'; -import * as admin from 'firebase-admin'; import { NotificationsRepository } from './notifications.repository'; -import { DevicesRepository } from 'src/devices/devices.repository'; import { NotificationType } from '@prisma/client'; import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; import { Logger } from '@nestjs/common'; import { buildFcmNotificationText } from './utils/fcm-notification-body-builder'; +import { NotificationPayloadDto } from './dtos/notification-payload.dto'; +import { UsersRepository } from 'src/users/users.repository'; +import { PushSenderService } from 'src/firebase/push-sender.service'; interface FcmNotificationData { id: string; @@ -23,12 +24,18 @@ export class NotificationProcessor extends WorkerHost { private readonly logger = new Logger(NotificationProcessor.name); constructor( private readonly notificationsRepository: NotificationsRepository, - private readonly devicesRepository: DevicesRepository, + private readonly usersRepository: UsersRepository, + private readonly pushService: PushSenderService, ) { super(); } - async process(job: Job<{ notificationId: string; userId: string }>) { + async process( + job: Job<{ + notificationId: string; + userId: string; + }>, + ): Promise { const { notificationId, userId } = job.data; this.logger.log( @@ -38,6 +45,7 @@ export class NotificationProcessor extends WorkerHost { try { const notification = await this.notificationsRepository.findByIdForPush( BigInt(notificationId), + BigInt(userId), ); if (!notification) { this.logger.warn( @@ -45,56 +53,59 @@ export class NotificationProcessor extends WorkerHost { ); return; } - const devices = await this.devicesRepository.getUserDevices(BigInt(userId)); - if (!devices.length) { - this.logger.warn(`No devices found for user ${userId}, skipping push notification`); - return; - } + const currentPayload = (notification.payload as unknown as NotificationPayloadDto) || { + actorsPreview: [], + actorsIds: [notification.actor.id.toString()], + }; - this.logger.log(`Found ${devices.length} devices for user ${userId}`); + const languageCode = await this.usersRepository.getUserLocale(BigInt(userId)); - const previewActors = [ - notification.actor.profile?.displayName ?? notification.actor.username, + let previewActors = [ + { + username: notification.actor.username, + displayName: notification.actor.profile?.displayName, + avatarUrl: notification.actor.profile?.avatarUrl ?? DEFAULT_PROFILE_PICTURE, + isFollowing: notification.actor.followers.length > 0, + }, ]; + + previewActors = previewActors.concat( + currentPayload.actorsPreview.map((a) => ({ + username: a.username, + displayName: a.displayName ?? undefined, + avatarUrl: a.avatarUrl ?? DEFAULT_PROFILE_PICTURE, + isFollowing: a.ifFollowing, + })), + ); + const tweetSnippet = notification.tweet?.content ?? null; - const locale = 'en'; //TODO: fetch user locale const { title, body } = buildFcmNotificationText({ notificationType: notification.type, isAggregated: Boolean(notification.isAggregated), - previewActors, - totalActorCount: notification.isAggregated ? 2 : 1, //TODO: fetch actual count + previewActors: previewActors.map((a) => a.displayName || a.username), + totalActorCount: currentPayload.actorsIds?.length, tweetSnippet, - locale, + locale: languageCode, }); + this.logger.log(`FCM Notification Text - Title: ${title}, Body: ${body}`); + const fcmData: FcmNotificationData = { id: notification.id.toString(), type: notification.type, isSeen: String(notification.seen), latestEventAt: notification.latestEventAt.toISOString(), - actorSummary: JSON.stringify([ - { - username: notification.actor.username, - displayName: notification.actor.profile?.displayName, - avatarUrl: notification.actor.profile?.avatarUrl || DEFAULT_PROFILE_PICTURE, - }, - ]), + actorSummary: JSON.stringify(previewActors), tweetSubjectIds: JSON.stringify([notification.tweet?.id?.toString()]), }; - this.logger.log(`FCM Data: ${JSON.stringify(fcmData)}`); - - this.logger.log( - `Sending push notification for notification id ${notificationId} to ${devices.length} devices`, - ); - - //TODO: add proper title and body and localize const payload = { token: null, notification: { title, body: body ?? undefined, + image: previewActors[0]?.avatarUrl ?? DEFAULT_PROFILE_PICTURE, }, data: fcmData as unknown as Record, android: { @@ -103,44 +114,14 @@ export class NotificationProcessor extends WorkerHost { channel_id: 'default', sound: 'default', color: '#e5e7ff', + tag: notification.dedupeKey ?? undefined, }, }, }; - this.logger.debug(`FCM Payload: ${JSON.stringify(payload)}`); - - const tokens = devices.map((d) => d.fcmToken).filter((t): t is string => !!t); + this.logger.log(`FCM Payload: ${JSON.stringify(payload)}`); - //TODO: use sendMulticast when available in firebase-admin also sendEach for list of messages when needed - const response = await admin.messaging().sendEachForMulticast({ - tokens: tokens, - notification: payload.notification, - data: payload.data, - android: payload.android, - }); - - if (response.failureCount > 0) { - const failedTokens: string[] = []; - response.responses.forEach((resp, idx) => { - if (!resp.success) { - const error = resp.error; - if ( - error?.code === 'messaging/invalid-registration-token' || - error?.code === 'messaging/registration-token-not-registered' - ) { - failedTokens.push(tokens[idx]); - } - this.logger.warn( - `Failed to send notification to token ${tokens[idx]}: ${error?.message}`, - ); - } - }); - - if (failedTokens.length > 0) { - await this.devicesRepository.deleteDevicesByTokens(failedTokens); - this.logger.log(`Deleted ${failedTokens.length} invalid device tokens`); - } - } + await this.pushService.sendToDevices(userId, payload); } catch (err) { this.logger.error( `Failed to process push notification job for notification id ${notificationId} to user ${userId}`, diff --git a/src/notifications/notifications.repository.ts b/src/notifications/notifications.repository.ts index 6969a294..4fa6b304 100644 --- a/src/notifications/notifications.repository.ts +++ b/src/notifications/notifications.repository.ts @@ -6,6 +6,7 @@ import { tweetInclude, TweetsRepository } from 'src/tweets/tweets.repository'; import { Prisma } from '@prisma/client'; import { NotificationResponseDto } from './dtos/notification-response.dto'; import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; +import { NotificationPayloadDto } from './dtos/notification-payload.dto'; export const notificationSelect = (userId: bigint) => ({ @@ -13,9 +14,11 @@ export const notificationSelect = (userId: bigint) => type: true, createdAt: true, latestEventAt: true, + payload: true, seen: true, actor: { select: { + id: true, username: true, profile: { select: { @@ -45,23 +48,36 @@ export class NotificationsRepository { ) {} mapToNotificationDto(n: NotificationWithDetails): NotificationResponseDto { + const currentPayload = (n.payload as unknown as NotificationPayloadDto) || { + actorsPreview: [], + actorsIds: [n.actor.id.toString()], + }; + + const actorsPreview = [ + { + username: n.actor.username, + displayName: n.actor.profile?.displayName, + avatarUrl: n.actor.profile?.avatarUrl ?? DEFAULT_PROFILE_PICTURE, + isFollowing: n.actor.followers.length > 0, + }, + ].concat( + currentPayload.actorsPreview.map((a) => ({ + username: a.username, + displayName: a.displayName ?? undefined, + avatarUrl: a.avatarUrl, + isFollowing: a.ifFollowing, + })), + ); + return { id: n.id.toString(), type: n.type, actorSummary: { - totalCount: 1, - previewActors: [ - { - username: n.actor.username, - displayName: n.actor.profile?.displayName, - avatarUrl: n.actor.profile?.avatarUrl || DEFAULT_PROFILE_PICTURE, - isFollowing: n.actor.followers.length > 0, - }, - ], + totalCount: currentPayload.actorsIds!.length, + previewActors: actorsPreview, }, tweetSummary: { totalCount: n.tweet?.id ? 1 : 0, - subjectIds: n.tweet?.id ? [n.tweet.id.toString()] : [], primaryTweet: n.tweet ? this.tweetRepository.mapToTweetDto(n.tweet) : null, }, latestEventAt: n.latestEventAt, @@ -69,11 +85,41 @@ export class NotificationsRepository { }; } - async deleteByOptions(options: NotificationTriggerOptions) { + async deleteById(notificationId: bigint) { + return await this.prisma.notification.deleteMany({ where: { id: notificationId } }); + } + + async deleteExisting(options: NotificationTriggerOptions) { return await this.prisma.notification.deleteMany({ where: options }); } - async findByIdForPush(notificationId: bigint) { + async findOpenNotification(receiverId: bigint, dedupeKey: string) { + return await this.prisma.notification.findUnique({ + where: { dedupeKey }, + select: notificationSelect(receiverId), + }); + } + + async updtateNotificationByIdAggregation( + id: bigint, + options: NotificationTriggerOptions, + payload: Prisma.JsonObject, + isAgg: boolean, + ) { + return await this.prisma.notification.update({ + where: { id }, + data: { + actorId: options.actorId, + latestEventAt: new Date(), + isAggregated: true, + payload, + ...(isAgg ? { seen: false } : {}), + }, + select: notificationSelect(options.receiverId), + }); + } + + async findByIdForPush(notificationId: bigint, receiverId: bigint) { return await this.prisma.notification.findUnique({ where: { id: notificationId }, select: { @@ -83,11 +129,14 @@ export class NotificationsRepository { latestEventAt: true, seen: true, isAggregated: true, + dedupeKey: true, + payload: true, tweet: { select: { id: true, content: true }, }, actor: { select: { + id: true, username: true, profile: { select: { @@ -95,15 +144,20 @@ export class NotificationsRepository { avatarUrl: true, }, }, + followers: { where: { followerId: receiverId } }, }, }, }, }); } - async createNotification(data: NotificationTriggerOptions): Promise { + async createNotification( + data: NotificationTriggerOptions, + payload: Prisma.JsonObject, + dedupeKey: string | null, + ): Promise { return await this.prisma.notification.create({ - data, + data: { ...data, payload, dedupeKey }, select: notificationSelect(data.receiverId), }); } diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts old mode 100644 new mode 100755 index 47178238..28fc96ed --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -9,9 +9,23 @@ import { SseEventsService } from 'src/sse/sse-events.service'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { UsersRepository } from 'src/users/users.repository'; +import { NotificationPayloadDto } from './dtos/notification-payload.dto'; +import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; +import { Prisma } from '@prisma/client'; @Injectable() export class NotificationsService { + private generateDedupeKey(options: NotificationTriggerOptions): string | null { + switch (options.type) { + case 'FOLLOW': + return `${options.type}:USER:${options.receiverId}`; + case 'LIKE': + case 'RETWEET': + return `${options.type}:TWEET:${options.tweetId}`; + default: + return null; + } + } private readonly logger = new Logger(NotificationsService.name); constructor( private readonly notificationsRepository: NotificationsRepository, @@ -19,7 +33,6 @@ export class NotificationsService { private readonly usersRepository: UsersRepository, @InjectQueue('notifications') private readonly notificationsQueue: Queue, ) {} - async trigger(options: NotificationTriggerOptions) { this.logger.log( `Triggering notification of type ${options.type} from actor ${options.actorId} to receiver ${options.receiverId}`, @@ -41,7 +54,79 @@ export class NotificationsService { return existing; } - const notification = await this.notificationsRepository.createNotification(options); + const dedupeKey = this.generateDedupeKey(options); + + let notification = null; + + if (dedupeKey) { + notification = await this.notificationsRepository.findOpenNotification( + options.receiverId, + dedupeKey, + ); + } + + if (notification) { + const currentPayload = notification.payload as unknown as NotificationPayloadDto; + + const previousActor = { + id: notification.actor.id.toString(), + username: notification.actor.username, + displayName: notification.actor.profile?.displayName || null, + avatarUrl: notification.actor.profile?.avatarUrl || DEFAULT_PROFILE_PICTURE, + ifFollowing: notification.actor.followers.length > 0, + }; + + const actorsMap = new Map< + string, + { + username: string; + displayName: string | null; + avatarUrl: string | null; + ifFollowing: boolean; + } + >(); + + if (currentPayload.actorsPreview) { + currentPayload.actorsPreview.forEach((a) => actorsMap.set(a.id, a)); + } + actorsMap.set(previousActor.id.toString(), previousActor); + actorsMap.delete(options.actorId.toString()); + + const actorsIdsSet = new Set(currentPayload.actorsIds); + actorsIdsSet.add(options.actorId.toString()); + + if (actorsMap.size >= 3) { + // Limit to 3 actors in aggregation + const firstTwo = Array.from(actorsMap.entries()).slice(0, 2); + actorsMap.clear(); + firstTwo.forEach(([key, value]) => actorsMap.set(key, value)); + } + + const payload: Prisma.JsonObject = { + count: actorsIdsSet.size, + actorsPreview: Array.from(actorsMap.values()), + actorsIds: Array.from(actorsIdsSet), + }; + + notification = await this.notificationsRepository.updtateNotificationByIdAggregation( + notification.id, + options, + payload, + true, + ); + } else { + const payload: Prisma.JsonObject = { + actorsIds: [options.actorId.toString()], + actorsPreview: [], + }; + + notification = await this.notificationsRepository.createNotification( + options, + payload, + dedupeKey, + ); + } + const count = await this.notificationsRepository.getUnseenCount(options.receiverId); this.logger.log( @@ -58,12 +143,17 @@ export class NotificationsService { await this.notificationsQueue.add( 'sendPush', - { notificationId: notification.id.toString(), userId: options.receiverId.toString() }, + { + notificationId: notification.id.toString(), + userId: options.receiverId.toString(), + }, { attempts: 5, backoff: { type: 'exponential', delay: 1000 }, removeOnComplete: true, - jobId: `notification:push:${notification.id}`, + jobId: `PUSH_${options.receiverId}_${dedupeKey || notification.id}`, + delay: 2000, // 2 second delay + removeOnFail: false, }, ); this.logger.log( @@ -73,6 +163,158 @@ export class NotificationsService { return notification; } + async handleUndo(options: NotificationTriggerOptions) { + const dedupeKey = this.generateDedupeKey(options); + if (!dedupeKey) { + await this.notificationsRepository.deleteExisting(options); + const count = await this.notificationsRepository.getUnseenCount(options.receiverId); + + await this.sseEvents.publishNotificationDeleted(options.receiverId, count); + return; + } + + const undoingActorId = options.actorId.toString(); + + const notification = await this.notificationsRepository.findOpenNotification( + options.receiverId, + dedupeKey, + ); + + if (!notification) { + await this.notificationsRepository.deleteExisting(options); + const count = await this.notificationsRepository.getUnseenCount(options.receiverId); + + await this.sseEvents.publishNotificationDeleted(options.receiverId, count); + return; + } + + const currentPayload = notification.payload as unknown as NotificationPayloadDto; + + const actorsIdsSet = new Set(currentPayload.actorsIds || []); + + const wasPresent = actorsIdsSet.delete(undoingActorId); + + if (!wasPresent) { + return; + } + + const previousActor = { + id: notification.actor.id.toString(), + username: notification.actor.username, + displayName: notification.actor.profile?.displayName || null, + avatarUrl: notification.actor.profile?.avatarUrl || DEFAULT_PROFILE_PICTURE, + ifFollowing: notification.actor.followers.length > 0, + }; + + const actorsMap = new Map< + string, + { + username: string; + displayName: string | null; + avatarUrl: string | null; + ifFollowing: boolean; + } + >(); + + if (currentPayload.actorsPreview) { + currentPayload.actorsPreview.forEach((a) => actorsMap.set(a.id, a)); + } + + actorsMap.set(previousActor.id.toString(), previousActor); + + actorsMap.delete(undoingActorId); + const currentCount = actorsIdsSet.size; + + if (currentCount <= 0) { + await this.notificationsRepository.deleteById(notification.id); + + const count = await this.notificationsRepository.getUnseenCount(options.receiverId); + + await this.sseEvents.publishNotificationDeleted(options.receiverId, count); + return; + } + + let facingActorId = notification.actor.id; + + if (facingActorId.toString() === undoingActorId) { + const nextFace = Array.from(actorsMap.keys())[0]; + if (nextFace) { + facingActorId = BigInt(nextFace); + } + } + actorsMap.delete(facingActorId.toString()); + if (actorsMap.size < 3 && actorsIdsSet.size > actorsMap.size + 1) { + const idsToFetch: string[] = []; + + for (const id of actorsIdsSet) { + if (id !== facingActorId.toString() && !actorsMap.has(id)) { + idsToFetch.push(id); + if (idsToFetch.length >= 3 - actorsMap.size) break; + } + } + + if (idsToFetch.length > 0) { + const newUsers = await this.usersRepository.getUsersMetadataById( + idsToFetch.map((id) => BigInt(id)), + ); + + newUsers.forEach((u) => { + actorsMap.set(u.id.toString(), { + username: u.username, + displayName: u.profile?.displayName || null, + avatarUrl: u.profile?.avatarUrl || DEFAULT_PROFILE_PICTURE, + ifFollowing: false, + }); + }); + } + } + + const payload: Prisma.JsonObject = { + count: currentCount, + actorsPreview: Array.from(actorsMap.values()), + actorsIds: Array.from(actorsIdsSet), + }; + + options.actorId = facingActorId; + + const updated = await this.notificationsRepository.updtateNotificationByIdAggregation( + notification.id, + options, + payload, + false, + ); + + const count = await this.notificationsRepository.getUnseenCount(options.receiverId); + const dto = this.notificationsRepository.mapToNotificationDto(updated); + await this.sseEvents.publishNotificationUpdate(options.receiverId, dto, count); + + await this.notificationsQueue.add( + 'sendPush', + { + notificationId: notification.id.toString(), + userId: options.receiverId.toString(), + }, + { + attempts: 5, + backoff: { type: 'exponential', delay: 1000 }, + removeOnComplete: true, + jobId: `PUSH_${options.receiverId}_${dedupeKey || notification.id}`, // Dedupe at queue level + delay: 2000, // 2 second delay + removeOnFail: false, + }, + ); + this.logger.log( + `Enqueued push notification job for notification id ${notification.id} to user ${options.receiverId}`, + ); + } + + async handleTweetDeletionNotifications(receivers: { receiverId: bigint; unseenCount: number }[]) { + await Promise.all( + receivers.map((row) => + this.sseEvents.publishNotificationDeleted(BigInt(row.receiverId), Number(row.unseenCount)), + ), + ); + } async markAllAsSeen(receiverId: bigint) { const { count } = await this.notificationsRepository.markAllAsSeen(receiverId); diff --git a/src/notifications/utils/fcm-notification-body-builder.ts b/src/notifications/utils/fcm-notification-body-builder.ts index 59e6abbe..ace78bce 100644 --- a/src/notifications/utils/fcm-notification-body-builder.ts +++ b/src/notifications/utils/fcm-notification-body-builder.ts @@ -1,33 +1,41 @@ -import { NotificationType } from '@prisma/client'; +import { NotificationType, LanguageCode, MediaType } from '@prisma/client'; import IntlMessageFormat from 'intl-messageformat'; -const DEFAULT_LOCALE = 'en'; +const DEFAULT_LOCALE: LanguageCode = LanguageCode.EN; const TEMPLATES: Record> = { - en: { + EN: { 'like.single': '{actor} liked your tweet', - 'like.aggregated': '{actor} and {count} others liked your tweet', + 'like.aggregated': '{actor} and {count} other{s} liked your tweet', 'follow.single': '{actor} followed you', - 'follow.aggregated': '{actor} and {count} others followed you', + 'follow.aggregated': '{actor} and {count} other{s} followed you', 'reply.single': '{actor} replied: "{snippet}"', 'mention.single': '{actor} mentioned you: "{snippet}"', 'quote.single': '{actor} quoted: "{snippet}"', 'retweet.single': '{actor} retweeted your tweet', - 'retweet.aggregated': '{actor} and {count} others retweeted your tweet', + 'retweet.aggregated': '{actor} and {count} other{s} retweeted your tweet', + 'message.single': '{actor} sent you a message: "{snippet}"', + 'message.reacted': '{actor} reacted {reaction} to your message: "{snippet}"', + 'message.photo': '{actor} sent you a photo', + 'message.video': '{actor} sent you a video', 'author.tweet': '{actor} posted a new tweet', generic: 'New interaction', 'generic.body': 'You have a new notification', }, - ar: { + AR: { 'like.single': 'أعجب {actor} بتغريدتك', - 'like.aggregated': 'أعجب {actor} و{count} آخرون بتغريدتك', + 'like.aggregated': 'أعجب {actor} و{count} آخر{sar} بتغريدتك', 'follow.single': '{actor} تابعك', - 'follow.aggregated': '{actor} و{count} آخرون تابعوك', + 'follow.aggregated': '{actor} و{count} آخر{sar} تابعوك', 'reply.single': '{actor} رد: "{snippet}"', 'mention.single': '{actor} ذكرك: "{snippet}"', 'quote.single': '{actor} اقتبس: "{snippet}"', 'retweet.single': '{actor} أعاد تغريد تغريدتك', - 'retweet.aggregated': '{actor} و{count} آخرون أعادوا تغريد تغريدتك', + 'retweet.aggregated': '{actor} و{count} آخر{sar} أعادوا تغريد تغريدتك', + 'message.single': '{actor} أرسل لك رسالة: "{snippet}"', + 'message.reacted': '{actor} تفاعل {reaction} مع رسالتك: "{snippet}"', + 'message.photo': '{actor} أرسل لك صورة', + 'message.video': '{actor} أرسل لك فيديو', 'author.tweet': '{actor} نشر تغريدة جديدة', generic: 'تفاعل جديد', 'generic.body': 'لديك إشعار جديد', @@ -66,7 +74,10 @@ export function buildFcmNotificationText(opts: { previewActors?: string[]; totalActorCount?: number; tweetSnippet?: string | null; - locale?: string; + locale?: LanguageCode; + reaction?: string | null; + hasMedia?: boolean; + mediaType?: MediaType | null; }): { title: string; body?: string } { const { notificationType, @@ -74,10 +85,10 @@ export function buildFcmNotificationText(opts: { previewActors = [], totalActorCount = 1, tweetSnippet, - locale = DEFAULT_LOCALE, + locale, } = opts; - const templates = TEMPLATES[locale] ?? TEMPLATES[DEFAULT_LOCALE]; + const templates = locale ? TEMPLATES[locale] : TEMPLATES[DEFAULT_LOCALE]; const leadActor = previewActors[0] ?? 'Someone'; const remainingCount = Math.max(0, (totalActorCount ?? 1) - 1); @@ -85,6 +96,11 @@ export function buildFcmNotificationText(opts: { let key = 'generic'; const params: Record = {}; + if (locale === LanguageCode.AR && isAggregated && remainingCount > 0) { + params.sar = remainingCount === 1 ? '' : 'ون'; + } else if (locale === LanguageCode.EN && isAggregated && remainingCount > 0) { + params.s = remainingCount === 1 ? '' : 's'; + } switch (notificationType) { case NotificationType.LIKE: if (isAggregated && remainingCount > 0) { @@ -109,9 +125,16 @@ export function buildFcmNotificationText(opts: { break; case NotificationType.REPLY: - key = 'reply.single'; - params.actor = leadActor; - params.snippet = truncate(sanitizeText(tweetSnippet ?? ''), 80); + if (isAggregated && remainingCount > 0) { + key = 'reply.aggregated'; + params.actor = leadActor; + params.count = remainingCount; + params.snippet = truncate(sanitizeText(tweetSnippet ?? ''), 80); + } else { + key = 'reply.single'; + params.actor = leadActor; + params.snippet = truncate(sanitizeText(tweetSnippet ?? ''), 80); + } break; case NotificationType.MENTION: @@ -121,9 +144,16 @@ export function buildFcmNotificationText(opts: { break; case NotificationType.QUOTE: - key = 'quote.single'; - params.actor = leadActor; - params.snippet = truncate(sanitizeText(tweetSnippet ?? ''), 80); + if (isAggregated && remainingCount > 0) { + key = 'qoute.aggregated'; + params.actor = leadActor; + params.count = remainingCount; + params.snippet = truncate(sanitizeText(tweetSnippet ?? ''), 80); + } else { + key = 'quote.single'; + params.actor = leadActor; + params.snippet = truncate(sanitizeText(tweetSnippet ?? ''), 80); + } break; case NotificationType.RETWEET: @@ -141,6 +171,25 @@ export function buildFcmNotificationText(opts: { key = 'author.tweet'; params.actor = leadActor; break; + case NotificationType.MESSAGE: + if (opts.reaction) { + key = 'message.reacted'; + params.actor = leadActor; + params.snippet = truncate(sanitizeText(tweetSnippet ?? ''), 80); + params.reaction = opts.reaction; + } else if (opts.hasMedia) { + if (opts.mediaType === MediaType.VIDEO) { + key = 'message.video'; + } else { + key = 'message.photo'; + } + params.actor = leadActor; + } else { + key = 'message.single'; + params.actor = leadActor; + params.snippet = truncate(sanitizeText(tweetSnippet ?? ''), 80); + } + break; default: key = 'generic'; diff --git a/src/sse/sse-events.service.ts b/src/sse/sse-events.service.ts index 4796067e..599fe269 100644 --- a/src/sse/sse-events.service.ts +++ b/src/sse/sse-events.service.ts @@ -86,7 +86,6 @@ export class SseEventsService { data: { count }, }); } - async publishTimelineFollowingTweets(userId: bigint, authors: string[] | null): Promise { this.logger.debug(`Publishing timeline-following update to user ${userId}`); await this.publisher.publishToUser(userId.toString(), { @@ -94,4 +93,33 @@ export class SseEventsService { data: { authors }, }); } + + async publishNotificationDeleted(receiverId: bigint, updatedCount: number): Promise { + this.logger.log(`Publishing notification deleted event to user ${receiverId}`); + await this.publisher.publishToUser(receiverId.toString(), { + event: 'notifications.delete', + data: {}, + }); + await this.publisher.publishToUser(receiverId.toString(), { + event: 'notifications.count_update', + data: { count: updatedCount }, + }); + } + + async publishNotificationUpdate( + receiverId: bigint, + notifications: NotificationResponseDto, + updatedCount: number, + ): Promise { + this.logger.log(`Publishing notification update event to user ${receiverId}`); + await this.publisher.publishToUser(receiverId.toString(), { + event: 'notifications.update', + data: notifications, + }); + + await this.publisher.publishToUser(receiverId.toString(), { + event: 'notifications.count_update', + data: { count: updatedCount }, + }); + } } diff --git a/src/sse/sse.module.ts b/src/sse/sse.module.ts index 497a6679..e180149a 100644 --- a/src/sse/sse.module.ts +++ b/src/sse/sse.module.ts @@ -5,9 +5,14 @@ import { EventPublisherService } from './event-publisher.service'; import { SseEventsService } from './sse-events.service'; import { ConversationsModule } from 'src/conversations/conversations.module'; import { NotificationsModule } from 'src/notifications/notifications.module'; +import { TweetsModule } from 'src/tweets/tweets.module'; @Module({ - imports: [forwardRef(() => ConversationsModule), forwardRef(() => NotificationsModule)], + imports: [ + forwardRef(() => ConversationsModule), + forwardRef(() => NotificationsModule), + forwardRef(() => TweetsModule), + ], controllers: [SseController], providers: [SseService, EventPublisherService, SseEventsService], exports: [SseService, EventPublisherService, SseEventsService], diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts index 06f26819..2044f0de 100644 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -346,12 +346,31 @@ export class TweetsRepository { await prismaClient.retweet.deleteMany({ where: { tweetId }, }); - await prismaClient.notification.deleteMany({ + await prismaClient.like.deleteMany({ where: { tweetId }, }); - await prismaClient.like.deleteMany({ + + const result = await prismaClient.notification.findMany({ where: { tweetId }, + select: { receiverId: true }, }); + + if (result.length > 0) { + await prismaClient.notification.deleteMany({ + where: { tweetId }, + }); + const counts = await Promise.all( + result.map((user) => + prismaClient.notification.count({ + where: { receiverId: user.receiverId, seen: false }, + }), + ), + ); + return result.map((row, index) => ({ + receiverId: row.receiverId, + unseenCount: counts[index], + })); + } } mapToDetailedTweetDto( @@ -464,10 +483,6 @@ export class TweetsRepository { }, }); - await tx.notification.deleteMany({ - where: { tweetId, actorId: userId, type: 'LIKE' }, - }); - await tx.tweet.update({ where: { id: tweetId }, data: { @@ -540,10 +555,6 @@ export class TweetsRepository { }, }); - await tx.notification.deleteMany({ - where: { tweetId, actorId: userId, type: 'RETWEET' }, - }); - await tx.tweet.update({ where: { id: tweetId }, data: { diff --git a/src/tweets/tweets.service.ts b/src/tweets/tweets.service.ts index e8aed9bf..8cc1bc40 100644 --- a/src/tweets/tweets.service.ts +++ b/src/tweets/tweets.service.ts @@ -284,15 +284,20 @@ export class TweetsService { ); } - await this.prisma.$transaction(async (tx) => { - await this.tweetsRepository.deleteTweet(tweetId, tx); + const receivers = await this.prisma.$transaction(async (tx) => { + const receivers = await this.tweetsRepository.deleteTweet(tweetId, tx); if (replyToTweetId) { await this.tweetsRepository.updateTweetReplyCount(replyToTweetId, false, tx); } if (quoteToTweetId) { await this.tweetsRepository.updateTweetRetweetCount(quoteToTweetId, false, tx); } + return receivers; }); + if (receivers && receivers.length > 0) { + this.logger.debug(`Emitting tweet deleted event for tweet ID: ${tweetId}`); + await this.domainEvents.emitTweetDeleted({ receivers }); + } this.logger.debug(`User ${userId} deleted tweet ${tweetId} successfully`); await this.invalidateTweetCache(tweetId); @@ -474,7 +479,7 @@ export class TweetsService { } async unlikeTweet(userId: bigint, tweetId: bigint) { - await this.checkIfTweetExists(tweetId); + const tweet = await this.checkIfTweetExists(tweetId); // Tweet already not liked by user const hasLiked = await this.tweetsRepository.hasUserLikedTweet(userId, tweetId); @@ -490,6 +495,12 @@ export class TweetsService { await this.tweetsRepository.unlikeTweet(userId, tweetId); + await this.domainEvents.emitTweetUnliked({ + actorId: userId, + receiverId: tweet.userId, + tweetId, + }); + await this.redisService.safeDecr( REDIS_TIMELINE_KEYS.getTweetLikesCountKey(tweetId), COUNT_CACHE_TTL, @@ -587,6 +598,13 @@ export class TweetsService { } await this.tweetsRepository.unretweetTweet(userId, tweetId); + + await this.domainEvents.emitTweetUnretweeted({ + actorId: userId, + receiverId: tweet.userId, + tweetId, + }); + this.logger.debug(`User ${userId} unretweeted tweet ${tweetId} successfully`); //dispatch retweet purge job diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 5f5ff653..2b2c496b 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -495,9 +495,6 @@ export class UsersRepository { where: { id: followedId }, data: { followersCount: { decrement: 1 } }, }), - this.prisma.notification.deleteMany({ - where: { receiverId: followedId, actorId: followerId, type: 'FOLLOW' }, - }), ]) .catch((e) => { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { @@ -1862,6 +1859,22 @@ export class UsersRepository { })); } + async getUsersMetadataById(ids: bigint[]) { + return await this.prisma.user.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + username: true, + profile: { + select: { + displayName: true, + avatarUrl: true, + }, + }, + }, + }); + } + async findAvatarUrlsByUserIds(userIds: bigint[]): Promise> { const profile = await this.prisma.profile.findMany({ where: { userId: { in: userIds } }, @@ -1889,10 +1902,16 @@ export class UsersRepository { }, }, }); - return user ? { username: user.username, displayName: user.profile!.displayName } : null; } + async getUserLocale(userId: bigint) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { languageCode: true }, + }); + return user?.languageCode; + } async getUserInterests(userId: bigint): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 61ac1188..61d0f890 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -495,6 +495,11 @@ export class UsersService { await this.usersRepository.unfollowUser(followerId, followedId); + await this.domainEvents.emitUserUnfollowed({ + actorId: followerId, + receiverId: followedId, + }); + this.logger.log(`User ID: ${followerId} unfollowed User ID: ${followedId}`); return { message: 'User unfollowed successfully.' }; } diff --git a/test/conversations/gateways/dm.gateway.spec.ts b/test/conversations/gateways/dm.gateway.spec.ts index 1991ecf3..06e708d1 100644 --- a/test/conversations/gateways/dm.gateway.spec.ts +++ b/test/conversations/gateways/dm.gateway.spec.ts @@ -4,6 +4,7 @@ import { DmGateway } from 'src/conversations/gateways/dm.gateway'; import { ConversationsService } from 'src/conversations/conversations.service'; import { MessagesService } from 'src/conversations/messages/messages.services'; import { SseEventsService } from 'src/sse/sse-events.service'; +import { DomainEventsService } from 'src/events/domain-events.service'; import { Server, Socket } from 'socket.io'; import { WsUser } from 'src/auth/interfaces/ws-user.interface'; import { @@ -17,6 +18,10 @@ describe('DmGateway', () => { let conversationsService: jest.Mocked; let messagesService: jest.Mocked; let sseEvents: jest.Mocked; + const mockDomainEventsService = { + emitMessageCreated: jest.fn(), + emitReactionSent: jest.fn(), + }; const mockUser: WsUser = { id: '6', @@ -52,6 +57,7 @@ describe('DmGateway', () => { assertParticipant: jest.fn(), getConversationParticipants: jest.fn(), countUnseenConversations: jest.fn(), + getOtherParticipant: jest.fn(), }, }, { @@ -70,6 +76,10 @@ describe('DmGateway', () => { publishNewMessagePreviewToMany: jest.fn(), }, }, + { + provide: DomainEventsService, + useValue: mockDomainEventsService, + }, ], }) .overrideGuard(WsJwtGuard) @@ -347,6 +357,7 @@ describe('DmGateway', () => { }, ]); conversationsService.countUnseenConversations.mockResolvedValue(0); + conversationsService.getOtherParticipant.mockResolvedValue({ userId: BigInt(7) }); sseEvents.publishUnseenCount.mockResolvedValue(); sseEvents.publishNewMessagePreview.mockResolvedValue(); }); @@ -504,6 +515,7 @@ describe('DmGateway', () => { mockSocket.data = { user: mockUser }; conversationsService.assertParticipant.mockResolvedValue(true); + conversationsService.getOtherParticipant.mockResolvedValue({ userId: BigInt(7) }); messagesService.createMessage.mockResolvedValue({ message: { id: BigInt(1), @@ -803,6 +815,14 @@ describe('DmGateway', () => { reactionDb: mockReactionDb, sender: mockSender, receiver: mockReceiver, + message: { + content: 'Test message', + id: BigInt(1), + conversationId: BigInt(2), + userId: BigInt(3), + createdAt: new Date(), + mediaUrl: null, + }, } as never); await gateway.reactToMessage(mockSocket, payload); @@ -847,6 +867,14 @@ describe('DmGateway', () => { reactionDb: mockReactionDb, sender: mockSender, receiver: mockReceiver, + message: { + content: 'Test message', + id: BigInt(1), + conversationId: BigInt(2), + userId: BigInt(3), + createdAt: new Date(), + mediaUrl: null, + }, } as never); await gateway.reactToMessage(mockSocket, payload); @@ -892,6 +920,14 @@ describe('DmGateway', () => { reactionDb: mockReactionDb, sender: mockSender, receiver: mockReceiver, + message: { + content: 'Test message', + id: BigInt(1), + conversationId: BigInt(2), + userId: BigInt(3), + createdAt: new Date(), + mediaUrl: null, + }, } as never); await gateway.reactToMessage(mockSocket, payload); @@ -914,6 +950,14 @@ describe('DmGateway', () => { reactionDb: bothReactionsDb, sender: mockSender, receiver: mockReceiver, + message: { + content: 'Test message', + id: BigInt(1), + conversationId: BigInt(2), + userId: BigInt(3), + createdAt: new Date(), + mediaUrl: null, + }, } as never); await gateway.reactToMessage(mockSocket, payload); @@ -944,6 +988,14 @@ describe('DmGateway', () => { reactionDb: mockReactionDb, sender: mockSender, receiver: mockReceiver, + message: { + content: 'Test message', + id: BigInt(1), + conversationId: BigInt(2), + userId: BigInt(3), + createdAt: new Date(), + mediaUrl: null, + }, } as never); await gateway.reactToMessage(testSocket, payload); diff --git a/test/conversations/messages/messages.services.spec.ts b/test/conversations/messages/messages.services.spec.ts index c2193e06..6265dccd 100644 --- a/test/conversations/messages/messages.services.spec.ts +++ b/test/conversations/messages/messages.services.spec.ts @@ -589,6 +589,7 @@ describe('MessagesService', () => { reactionDb: mockReactionDb, sender: mockParticipants[0], receiver: mockParticipants[1], + message: mockMessage, }); }); diff --git a/test/notifications/notifications.controller.spec.ts b/test/notifications/notifications.controller.spec.ts index 2164cc0f..56cb03ba 100644 --- a/test/notifications/notifications.controller.spec.ts +++ b/test/notifications/notifications.controller.spec.ts @@ -77,7 +77,6 @@ describe('NotificationsController', () => { }, tweetSummary: { totalCount: 1, - subjectIds: ['100'], primaryTweet: { id: '100', content: 'Test tweet' }, }, latestEventAt: new Date('2024-01-01'), diff --git a/test/notifications/notifications.service.spec.ts b/test/notifications/notifications.service.spec.ts index f1c072b0..7fe55d6b 100644 --- a/test/notifications/notifications.service.spec.ts +++ b/test/notifications/notifications.service.spec.ts @@ -21,6 +21,7 @@ describe('NotificationsService', () => { getUnseenCount: jest.fn(), getNotifications: jest.fn(), mapToNotificationDto: jest.fn(), + findOpenNotification: jest.fn(), }; const mockSseEventsService: jest.Mocked> = { @@ -54,29 +55,49 @@ describe('NotificationsService', () => { }); describe('trigger', () => { + const userId = BigInt(10); + const mockNotification = { + id: BigInt(1), + type: 'LIKE', + actor: { + username: 'testuser', + profile: { + displayName: 'Test User', + avatarUrl: 'http://example.com/avatar.jpg', + }, + followers: [{ followerId: userId, followedId: BigInt(2) }], + }, + tweet: { + id: BigInt(100), + content: 'Test tweet', + userId: BigInt(2), + }, + latestEventAt: new Date('2024-01-01'), + seen: false, + }; + const mockTriggerOpitions = { + actorId: BigInt(1), + receiverId: BigInt(userId), + tweetId: BigInt(3), + type: 'LIKE' as const, + }; it('should create a notification if none exists and actorId != receiverId', async () => { - const mockNotification = { id: 10n, actorId: 1n, receiverId: 2n, type: 'LIKE' }; + const dedupeKey = `${mockTriggerOpitions.type}:TWEET:${mockTriggerOpitions.tweetId}`; + (mockNotificationsRepository.findExisting as jest.Mock).mockResolvedValue(null); + (mockNotificationsRepository.findOpenNotification as jest.Mock).mockResolvedValue(null); (mockNotificationsRepository.createNotification as jest.Mock).mockResolvedValue( mockNotification, ); - const result = await service.trigger({ - actorId: BigInt(mockNotification.actorId), - receiverId: BigInt(mockNotification.receiverId), - type: 'LIKE', - }); + const result = await service.trigger(mockTriggerOpitions); + + expect(mockNotificationsRepository.findExisting).toHaveBeenCalledWith(mockTriggerOpitions); + expect(mockNotificationsRepository.findOpenNotification).toHaveBeenCalledWith( + mockTriggerOpitions.receiverId, + dedupeKey, + ); - expect(mockNotificationsRepository.findExisting).toHaveBeenCalledWith({ - actorId: BigInt(mockNotification.actorId), - receiverId: BigInt(mockNotification.receiverId), - type: 'LIKE', - }); - expect(mockNotificationsRepository.createNotification).toHaveBeenCalledWith({ - actorId: BigInt(mockNotification.actorId), - receiverId: BigInt(mockNotification.receiverId), - type: 'LIKE', - }); expect(result).toEqual(mockNotification); }); @@ -210,10 +231,9 @@ describe('NotificationsService', () => { }, tweetSummary: notification.type === 'FOLLOW' - ? { totalCount: 0, subjectIds: [], primaryTweet: null } + ? { totalCount: 0, primaryTweet: null } : { totalCount: 1, - subjectIds: [notification.tweet?.id.toString()], primaryTweet: { id: notification.tweet?.id.toString(), content: notification.tweet?.content, @@ -256,7 +276,6 @@ describe('NotificationsService', () => { }, tweetSummary: { totalCount: 1, - subjectIds: ['100'], primaryTweet: { id: '100', content: 'Test tweet', @@ -385,7 +404,6 @@ describe('NotificationsService', () => { expect(result.items[0].tweetSummary).toEqual({ totalCount: 0, - subjectIds: [], primaryTweet: null, }); // expect(mockTweetsRepository.mapToTweetDto).not.toHaveBeenCalled(); @@ -419,8 +437,6 @@ describe('NotificationsService', () => { const result = await service.getNotifications(userId); - console.log(result.items[0].actorSummary.previewActors); - expect(result.items[0].actorSummary.previewActors[0]).toEqual({ username: 'testuser', displayName: null, diff --git a/test/tweets/tweets.service.spec.ts b/test/tweets/tweets.service.spec.ts index 48b618bf..3bac93d7 100644 --- a/test/tweets/tweets.service.spec.ts +++ b/test/tweets/tweets.service.spec.ts @@ -17,6 +17,7 @@ import { getQueueToken } from '@nestjs/bullmq'; import { PeopleSearchFilter } from 'src/search/dtos'; import { RedisService } from 'src/redis/redis.service'; import { TrendingService } from 'src/trending/trending.service'; +import { SseEventsService } from 'src/sse/sse-events.service'; const encodeCompositeCursor = (cursorObject: object): string => { const jsonString = JSON.stringify(cursorObject); @@ -98,11 +99,17 @@ describe('TweetsService', () => { emitTweetCreated: jest.fn(), emitTweetLiked: jest.fn(), emitTweetRetweeted: jest.fn(), + emitTweetUnliked: jest.fn(), + emitTweetUnretweeted: jest.fn(), }; const mockTrendingService = { getHashtagId: jest.fn(), }; + const mockSSEeventsService = { + publishNotificationDeleted: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -154,6 +161,10 @@ describe('TweetsService', () => { provide: DomainEventsService, useValue: mockDomainEventsService, }, + { + provide: SseEventsService, + useValue: mockSSEeventsService, + }, ], }).compile(); @@ -1889,6 +1900,7 @@ describe('TweetsService', () => { safeDecr: jest.fn(), }, }, + { provide: SseEventsService, useValue: mockSSEeventsService }, { provide: DomainEventsService, useValue: mockDomainEventsService }, ], }).compile(); diff --git a/test/users/users.service.spec.ts b/test/users/users.service.spec.ts index b8f78aed..644fa6a6 100755 --- a/test/users/users.service.spec.ts +++ b/test/users/users.service.spec.ts @@ -120,6 +120,7 @@ describe('UsersService', () => { const mockDomainEventsService = { publish: jest.fn(), emitUserFollowed: jest.fn(), + emitUserUnfollowed: jest.fn(), }; beforeEach(async () => { From fc69a2f1e369c9b15602d5d0581d1eff80eebfa4 Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Mon, 15 Dec 2025 19:51:49 +0200 Subject: [PATCH 30/43] fix: explore for you shape - [CU-869bgakv0] (#220) From 8433127e77e2cf5c44cd5fc241afe98daa1d9297 Mon Sep 17 00:00:00 2001 From: Tasneem Mohammed <149891742+Tasneemmohammed0@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:33:07 +0200 Subject: [PATCH 31/43] test: increase unit test coverage for users and media modules - [CU-869bgf0ha] (#221) Signed-off-by: Tasneemmhammed0 --- ...sponse.ts => uploaded-gif-response.dto.ts} | 0 src/media/media.service.ts | 6 +- src/search/dtos/user-search-result.dto.ts | 2 +- .../mappers/user-search-result.mapper.ts | 2 +- src/tweets/dtos/author.dto.ts | 2 +- src/tweets/tweets.repository.ts | 1 - ...elationship-dto.ts => relationship.dto.ts} | 0 src/users/me/settings/settings.service.ts | 2 +- src/users/users.repository.ts | 2 +- src/users/users.service.ts | 2 +- test/media/media.service.spec.ts | 296 +++++++ test/media/utils/process-image.util.spec.ts | 18 + .../mappers/user-search-result.mapper.spec.ts | 2 +- test/tweets/tweets.controller.spec.ts | 90 ++ test/tweets/tweets.service.spec.ts | 780 ++++++++++++++++- test/users/users.controller.spec.ts | 177 ++++ test/users/users.service.spec.ts | 798 +++++++++++++++++- 17 files changed, 2160 insertions(+), 20 deletions(-) rename src/media/dtos/{uploaded-gif-response.ts => uploaded-gif-response.dto.ts} (100%) rename src/users/dtos/{relationship-dto.ts => relationship.dto.ts} (100%) diff --git a/src/media/dtos/uploaded-gif-response.ts b/src/media/dtos/uploaded-gif-response.dto.ts similarity index 100% rename from src/media/dtos/uploaded-gif-response.ts rename to src/media/dtos/uploaded-gif-response.dto.ts diff --git a/src/media/media.service.ts b/src/media/media.service.ts index 587f250f..091a3477 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -10,7 +10,7 @@ import { processImage } from './utils/process-image.util'; import { MEDIA_CODES, MEDIA_MESSAGES, PENDING_MEDIA_CLEANUP_THRESHOLD_HOURS } from './constants'; import { Cron, CronExpression } from '@nestjs/schedule'; import { TenorResponse } from './interfaces'; -import { UploadedGifResponse } from './dtos/uploaded-gif-response'; +import { UploadedGifResponse } from './dtos/uploaded-gif-response.dto'; @Injectable() export class MediaService { private readonly logger = new Logger(MediaService.name); @@ -169,9 +169,7 @@ export class MediaService { /** * Get image dimensions from buffer */ - private async getImageDimensions( - file: Express.Multer.File, - ): Promise<{ width: number; height: number }> { + async getImageDimensions(file: Express.Multer.File): Promise<{ width: number; height: number }> { try { const image = sharp(file.buffer); diff --git a/src/search/dtos/user-search-result.dto.ts b/src/search/dtos/user-search-result.dto.ts index 132b1ce2..114b80de 100644 --- a/src/search/dtos/user-search-result.dto.ts +++ b/src/search/dtos/user-search-result.dto.ts @@ -1,5 +1,5 @@ import { CompactUserDto } from 'src/users/dtos/compact-user.dto'; -import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; +import { UserRelationshipDto } from 'src/users/dtos/relationship.dto'; export type UserSearchResultItem = CompactUserDto & { bannerUrl: string | null; diff --git a/src/search/mappers/user-search-result.mapper.ts b/src/search/mappers/user-search-result.mapper.ts index 4081d6c7..9b7bb782 100644 --- a/src/search/mappers/user-search-result.mapper.ts +++ b/src/search/mappers/user-search-result.mapper.ts @@ -1,7 +1,7 @@ import { JsonArray, JsonObject } from '@prisma/client/runtime/binary'; import { BioEntitiesDto } from 'src/users/dtos'; import { UserSearchResultItem } from '../dtos/user-search-result.dto'; -import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; +import { UserRelationshipDto } from 'src/users/dtos/relationship.dto'; export function mapToUserSearchResultDto( items: { diff --git a/src/tweets/dtos/author.dto.ts b/src/tweets/dtos/author.dto.ts index b9b9c127..8feac35d 100644 --- a/src/tweets/dtos/author.dto.ts +++ b/src/tweets/dtos/author.dto.ts @@ -1,4 +1,4 @@ -import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; +import { UserRelationshipDto } from 'src/users/dtos/relationship.dto'; export class AuthorDto { username: string; diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts index 2044f0de..d0b66c29 100644 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -991,7 +991,6 @@ export class TweetsRepository { } async hydrateTweetsInList(authUserId: bigint, tweetIds: bigint[]) { - console.log(authUserId); return await this.prisma.tweet.findMany({ where: { id: { in: tweetIds }, diff --git a/src/users/dtos/relationship-dto.ts b/src/users/dtos/relationship.dto.ts similarity index 100% rename from src/users/dtos/relationship-dto.ts rename to src/users/dtos/relationship.dto.ts diff --git a/src/users/me/settings/settings.service.ts b/src/users/me/settings/settings.service.ts index bb12e3bb..2adcbb92 100644 --- a/src/users/me/settings/settings.service.ts +++ b/src/users/me/settings/settings.service.ts @@ -28,7 +28,7 @@ import { generateAndStoreOtp } from 'src/auth/utils'; import { createValidationError, decodeCompositeCursor, paginateComposite } from 'src/common/utils'; import { BlocksCursor, MutesCursor } from 'src/common/interfaces'; import { PAGINATION_ERROR_CODES, PAGINATION_ERROR_MESSAGES } from 'src/common/constants'; -import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; +import { UserRelationshipDto } from 'src/users/dtos/relationship.dto'; interface CachedEmailUpdateData { userId: string; diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 2b2c496b..d9defa29 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -18,7 +18,7 @@ import { PeopleSearchFilter } from 'src/search/dtos'; import { RankedUser } from './interfaces/ranked-user.interface'; import { AuthorDto } from 'src/tweets/dtos'; import { plainToClass } from 'class-transformer'; -import { UserRelationshipDto } from './dtos/relationship-dto'; +import { UserRelationshipDto } from './dtos/relationship.dto'; import { RefreshTokensService } from 'src/refresh-tokens/refresh-tokens.service'; import { UserSearchCursor } from 'src/common/types/cursors'; diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 61d0f890..6b8a0b9f 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -30,7 +30,7 @@ import { PlainMention } from 'src/tweets/interfaces'; import { PeopleSearchFilter } from 'src/search/dtos'; import { UserSearchCursor } from 'src/common/types/cursors'; import { ContentParsingService } from 'src/content-parsing/content-parsing.service'; -import { UserRelationshipDto } from './dtos/relationship-dto'; +import { UserRelationshipDto } from './dtos/relationship.dto'; import { RedisService } from 'src/redis/redis.service'; import { REDIS_TIMELINE_KEYS } from 'src/common/constants/redis-timeline-keys.constant'; import { BackfillFollowJob } from 'src/tweets/timeline/interfaces'; diff --git a/test/media/media.service.spec.ts b/test/media/media.service.spec.ts index 21000bbb..52d4ea74 100644 --- a/test/media/media.service.spec.ts +++ b/test/media/media.service.spec.ts @@ -191,6 +191,39 @@ describe('MediaService', () => { expect(mockS3Service.deleteFile).toHaveBeenCalledWith('avatars/file.jpg'); }); + it('should handle rollback failure when S3 deletion fails during rollback', async () => { + // Arrange + const mockFile = createMockFile(); + const userId = BigInt(1); + const folder = MediaFolder.AVATARS; + const mockS3Response = { + key: 'avatars/file.jpg', + url: 'http://example.com/avatars/file.jpg', + }; + const dbError = new Error('Database connection failed'); + const rollbackError = new Error('S3 rollback failed'); + + (sharp as unknown as jest.Mock).mockReturnValue(mockSharpInstance); + mockSharpInstance.metadata.mockResolvedValue({ width: 100, height: 100 }); + mockS3Service.uploadFile.mockResolvedValue(mockS3Response); + mockMediaRepository.saveMedia.mockRejectedValue(dbError); + mockS3Service.deleteFile.mockRejectedValue(rollbackError); + + // Act & Assert + await expect(service.uploadAndSaveMedia(mockFile, userId, folder)).rejects.toThrow( + new HttpException( + { + message: MEDIA_MESSAGES.MEDIA_UPLOAD_SAVE_FAILED, + code: MEDIA_CODES.MEDIA_UPLOAD_SAVE_FAILED, + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + + // Verify rollback was attempted + expect(mockS3Service.deleteFile).toHaveBeenCalledWith('avatars/file.jpg'); + }); + it('should handle image dimensions retrieval failure', async () => { const mockFile = createMockFile(); const userId = BigInt(1); @@ -226,6 +259,42 @@ describe('MediaService', () => { }); }); + describe('getImageDimensions (private method)', () => { + it('should successfully get image dimensions', async () => { + const mockFile = createMockFile(); + (sharp as unknown as jest.Mock).mockReturnValue(mockSharpInstance); + mockSharpInstance.metadata.mockResolvedValue({ width: 800, height: 600 }); + + // Access private method using type assertion + const result = await service.getImageDimensions(mockFile); + + expect(result).toEqual({ width: 800, height: 600 }); + expect(sharp).toHaveBeenCalledWith(mockFile.buffer); + }); + + it('should return zero dimensions when metadata extraction fails', async () => { + const mockFile = createMockFile(); + (sharp as unknown as jest.Mock).mockReturnValue(mockSharpInstance); + mockSharpInstance.metadata.mockRejectedValue(new Error('Invalid image format')); + + // Access private method using type assertion + const result = await service.getImageDimensions(mockFile); + + expect(result).toEqual({ width: 0, height: 0 }); + }); + + it('should handle null width and height from metadata', async () => { + const mockFile = createMockFile(); + (sharp as unknown as jest.Mock).mockReturnValue(mockSharpInstance); + mockSharpInstance.metadata.mockResolvedValue({ width: null, height: null }); + + // Access private method using type assertion + const result = await service.getImageDimensions(mockFile); + + expect(result).toEqual({ width: 0, height: 0 }); + }); + }); + describe('uploadAvatarOrBanner', () => { it('should upload both avatar and banner', async () => { // Arrange @@ -456,6 +525,7 @@ describe('MediaService', () => { width: 100, height: 100, altText: null, + pending: false, }; const s3Error = new Error('S3 deletion failed'); @@ -478,8 +548,41 @@ describe('MediaService', () => { width: mockMediaRecord.width, height: mockMediaRecord.height, altText: undefined, + pending: mockMediaRecord.pending, }); }); + + it('should handle rollback failure when restoring media metadata fails', async () => { + // Arrange + const url = 'http://example.com/avatars/file.jpg'; + const userId = BigInt(1); + const mockMediaRecord = { + id: BigInt(1), + userId, + url, + type: MediaType.IMAGE, + width: 100, + height: 100, + altText: null, + pending: false, + }; + const s3Error = new Error('S3 deletion failed'); + const rollbackError = new Error('Database restore failed'); + + mockMediaRepository.findByUrl.mockResolvedValue(mockMediaRecord); + mockMediaRepository.deleteMedia.mockResolvedValue(mockMediaRecord); + mockS3Service.extractKeyFromUrl.mockReturnValue('avatars/file.jpg'); + mockS3Service.deleteFile.mockRejectedValue(s3Error); + mockMediaRepository.saveMedia.mockRejectedValue(rollbackError); + + // Act & Assert + await expect(service.deleteMedia(url, userId)).rejects.toThrow( + new Error('S3 deletion failed'), + ); + + // Verify rollback was attempted + expect(mockMediaRepository.saveMedia).toHaveBeenCalled(); + }); }); describe('uploadMedia', () => { @@ -529,6 +632,171 @@ describe('MediaService', () => { }); }); + describe('uploadGif', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + global.fetch = jest.fn(); + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + it('should throw error when RAVEN_TENOR_KEY is not configured', async () => { + // Arrange + delete process.env.RAVEN_TENOR_KEY; + const userId = BigInt(1); + const tenorId = 'test-tenor-id'; + + // Act & Assert + await expect(service.uploadGif(userId, tenorId)).rejects.toThrow( + new HttpException( + { + message: MEDIA_MESSAGES.GIF_UPLOAD_FAILED, + code: MEDIA_CODES.GIF_UPLOAD_FAILED, + }, + HttpStatus.SERVICE_UNAVAILABLE, + ), + ); + }); + + it('should throw error when Tenor API request fails', async () => { + // Arrange + process.env.RAVEN_TENOR_KEY = 'test-api-key'; + const userId = BigInt(1); + const tenorId = 'test-tenor-id'; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 400, + }); + + // Act & Assert + await expect(service.uploadGif(userId, tenorId)).rejects.toThrow( + new HttpException( + { + message: MEDIA_MESSAGES.GIF_UPLOAD_FAILED, + code: MEDIA_CODES.GIF_UPLOAD_FAILED, + }, + HttpStatus.BAD_REQUEST, + ), + ); + }); + + it('should throw error when Tenor returns no results', async () => { + // Arrange + process.env.RAVEN_TENOR_KEY = 'test-api-key'; + const userId = BigInt(1); + const tenorId = 'test-tenor-id'; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ results: [] }), + }); + + // Act & Assert + await expect(service.uploadGif(userId, tenorId)).rejects.toThrow( + new HttpException( + { + message: MEDIA_MESSAGES.GIF_NOT_FOUND, + code: MEDIA_CODES.GIF_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ), + ); + }); + + it('should throw error when Tenor returns null results', async () => { + // Arrange + process.env.RAVEN_TENOR_KEY = 'test-api-key'; + const userId = BigInt(1); + const tenorId = 'test-tenor-id'; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + + // Act & Assert + await expect(service.uploadGif(userId, tenorId)).rejects.toThrow( + new HttpException( + { + message: MEDIA_MESSAGES.GIF_NOT_FOUND, + code: MEDIA_CODES.GIF_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ), + ); + }); + + it('should successfully upload GIF and save metadata', async () => { + // Arrange + process.env.RAVEN_TENOR_KEY = 'test-api-key'; + const userId = BigInt(1); + const tenorId = 'test-tenor-id'; + const mockTenorResponse = { + results: [ + { + id: tenorId, + content_description: 'Happy cat dancing', + media_formats: { + gif: { + url: 'https://media.tenor.com/test.gif', + dims: [498, 280], + }, + }, + }, + ], + }; + const mockSavedMedia = { + id: BigInt(123), + userId, + url: 'https://media.tenor.com/test.gif', + type: MediaType.GIF, + width: 498, + height: 280, + altText: 'Happy cat dancing', + pending: true, + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTenorResponse), + }); + mockMediaRepository.saveMedia.mockResolvedValue(mockSavedMedia); + + // Act + const result = await service.uploadGif(userId, tenorId); + + // Assert + expect(result).toEqual({ + id: '123', + url: 'https://media.tenor.com/test.gif', + width: 498, + height: 280, + altText: 'Happy cat dancing', + }); + + expect(global.fetch).toHaveBeenCalledWith( + `https://tenor.googleapis.com/v2/posts?key=test-api-key&ids=${tenorId}&client_key=my_app`, + ); + + expect(mockMediaRepository.saveMedia).toHaveBeenCalledWith({ + userId, + url: 'https://media.tenor.com/test.gif', + type: MediaType.GIF, + width: 498, + height: 280, + altText: 'Happy cat dancing', + pending: true, + }); + }); + }); + describe('cleanUpPendingMedia', () => { it('should do nothing when no pending media is found', async () => { // Arrange @@ -626,5 +894,33 @@ describe('MediaService', () => { expect(mockMediaRepository.deleteMedia).toHaveBeenCalledWith(BigInt(1)); expect(mockS3Service.deleteFile).toHaveBeenCalledWith('key2'); }); + + it('should handle error when S3 deletion fails during cleanup', async () => { + // Arrange + const mockPendingMedia = [ + { + id: BigInt(1), + url: 'https://cdn.raven.cmp27.space/avatars/test.png', + }, + ]; + + mockMediaRepository.findPendingMediaOlderThan.mockResolvedValue(mockPendingMedia); + mockMediaRepository.deleteMedia.mockResolvedValue(undefined); + mockS3Service.extractKeyFromUrl.mockReturnValue('avatars/test.png'); + mockS3Service.deleteFile.mockRejectedValue(new Error('S3 deletion failed')); + + // Spy on logger to ensure error is logged + const loggerErrorSpy = jest.spyOn(service['logger'], 'error'); + + // Act + await service.cleanUpPendingMedia(); + + // Assert + expect(mockMediaRepository.deleteMedia).toHaveBeenCalledWith(BigInt(1)); + expect(mockS3Service.deleteFile).toHaveBeenCalledWith('avatars/test.png'); + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to clean up pending media'), + ); + }); }); }); diff --git a/test/media/utils/process-image.util.spec.ts b/test/media/utils/process-image.util.spec.ts index a6bb0a3c..679cb8f4 100644 --- a/test/media/utils/process-image.util.spec.ts +++ b/test/media/utils/process-image.util.spec.ts @@ -130,4 +130,22 @@ describe('processImage', () => { }); expect(mockSharpInstance.png).not.toHaveBeenCalled(); }); + + it('should throw error and log when image processing fails', async () => { + const mockFile = createMockFile(); + const processingError = new Error('Image processing failed'); + + mockSharpInstance.toBuffer.mockRejectedValue(processingError); + + await expect(processImage(mockFile)).rejects.toThrow('Image processing failed'); + }); + + it('should throw error when metadata extraction fails', async () => { + const mockFile = createMockFile(); + const metadataError = new Error('Failed to read metadata'); + + mockSharpInstance.metadata.mockRejectedValue(metadataError); + + await expect(processImage(mockFile)).rejects.toThrow('Failed to read metadata'); + }); }); diff --git a/test/search/mappers/user-search-result.mapper.spec.ts b/test/search/mappers/user-search-result.mapper.spec.ts index 5762c18e..bff0d208 100644 --- a/test/search/mappers/user-search-result.mapper.spec.ts +++ b/test/search/mappers/user-search-result.mapper.spec.ts @@ -1,6 +1,6 @@ import { JsonObject } from '@prisma/client/runtime/library'; import { mapToUserSearchResultDto } from 'src/search/mappers/user-search-result.mapper'; -import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; +import { UserRelationshipDto } from 'src/users/dtos/relationship.dto'; describe('mapToUserSearchResultDto', () => { it('should map a single user item with all fields populated', () => { diff --git a/test/tweets/tweets.controller.spec.ts b/test/tweets/tweets.controller.spec.ts index 451ab477..32c18406 100644 --- a/test/tweets/tweets.controller.spec.ts +++ b/test/tweets/tweets.controller.spec.ts @@ -7,6 +7,8 @@ describe('TweetsController', () => { let controller: TweetsController; const mockTweetsService = { + createTweet: jest.fn(), + deleteTweet: jest.fn(), likeTweet: jest.fn(), unlikeTweet: jest.fn(), retweetTweet: jest.fn(), @@ -17,6 +19,7 @@ describe('TweetsController', () => { getTweetRetweeters: jest.fn(), getTweetLikers: jest.fn(), getTweetReplies: jest.fn(), + getTweetSummary: jest.fn(), }; const mockUser: RequestUser = { @@ -45,6 +48,54 @@ describe('TweetsController', () => { expect(controller).toBeDefined(); }); + describe('createTweet', () => { + it('should call tweetsService.createTweet with correct parameters', async () => { + const createTweetDto = { + content: 'Hello World', + }; + const expectedResponse = { id: BigInt(100), text: 'Hello World' }; + mockTweetsService.createTweet.mockResolvedValue(expectedResponse); + + const result = await controller.createTweet(mockUser, createTweetDto); + + expect(mockTweetsService.createTweet).toHaveBeenCalledWith(createTweetDto, BigInt(123)); + expect(mockTweetsService.createTweet).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedResponse); + }); + + it('should propagate errors from service', async () => { + const createTweetDto = { + content: 'Hello World', + }; + const error = new Error('Failed to create tweet'); + mockTweetsService.createTweet.mockRejectedValue(error); + + await expect(controller.createTweet(mockUser, createTweetDto)).rejects.toThrow(error); + }); + }); + + describe('deleteTweet', () => { + it('should call tweetsService.deleteTweet with correct parameters', async () => { + const tweetId = BigInt(100); + const expectedResponse = { message: 'Tweet deleted successfully' }; + mockTweetsService.deleteTweet.mockResolvedValue(expectedResponse); + + const result = await controller.deleteTweet(mockUser, tweetId); + + expect(mockTweetsService.deleteTweet).toHaveBeenCalledWith(tweetId, BigInt(123)); + expect(mockTweetsService.deleteTweet).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedResponse); + }); + + it('should propagate errors from service', async () => { + const tweetId = BigInt(100); + const error = new Error('Tweet not found'); + mockTweetsService.deleteTweet.mockRejectedValue(error); + + await expect(controller.deleteTweet(mockUser, tweetId)).rejects.toThrow(error); + }); + }); + describe('likeTweet', () => { it('should call mockTweetsService.likeTweet with correct parameters', async () => { const tweetId = BigInt(100); @@ -331,4 +382,43 @@ describe('TweetsController', () => { ); }); }); + + describe('getTweetSummary', () => { + const tweetId = BigInt(100); + const expectedResponse = { summary: 'This is a summary' }; + + it('should call service with default locale en-US when no locale provided', async () => { + mockTweetsService.getTweetSummary.mockResolvedValue(expectedResponse); + + const result = await controller.getTweetSummary(tweetId); + + expect(mockTweetsService.getTweetSummary).toHaveBeenCalledWith(tweetId, 'en-US'); + expect(result).toEqual(expectedResponse); + }); + + it('should call service with provided locale when valid', async () => { + mockTweetsService.getTweetSummary.mockResolvedValue(expectedResponse); + + const result = await controller.getTweetSummary(tweetId, 'ar-EG'); + + expect(mockTweetsService.getTweetSummary).toHaveBeenCalledWith(tweetId, 'ar-EG'); + expect(result).toEqual(expectedResponse); + }); + + it('should default to en-US when invalid locale provided', async () => { + mockTweetsService.getTweetSummary.mockResolvedValue(expectedResponse); + + const result = await controller.getTweetSummary(tweetId, 'fr-FR'); + + expect(mockTweetsService.getTweetSummary).toHaveBeenCalledWith(tweetId, 'en-US'); + expect(result).toEqual(expectedResponse); + }); + + it('should propagate errors from service', async () => { + const error = new Error('Tweet not found'); + mockTweetsService.getTweetSummary.mockRejectedValue(error); + + await expect(controller.getTweetSummary(tweetId, 'en-US')).rejects.toThrow(error); + }); + }); }); diff --git a/test/tweets/tweets.service.spec.ts b/test/tweets/tweets.service.spec.ts index 3bac93d7..90eb0aa2 100644 --- a/test/tweets/tweets.service.spec.ts +++ b/test/tweets/tweets.service.spec.ts @@ -66,6 +66,11 @@ describe('TweetsService', () => { getRankedTweetsByQuery: jest.fn(), getParentTweets: jest.fn(), getTweetOrDeleted: jest.fn(), + checkTweetOwnership: jest.fn(), + deleteTweet: jest.fn(), + updateTweetReplyCount: jest.fn(), + getTweetIdsLinkedToHashtag: jest.fn(), + getTweetsWithReferencesByIds: jest.fn(), }; const mockUsersRepository = { @@ -77,6 +82,7 @@ describe('TweetsService', () => { const mockContentParsingService = { parseContentAndValidate: jest.fn(), + generateTweetSummary: jest.fn(), }; const mockMediaRepository = { @@ -151,10 +157,17 @@ describe('TweetsService', () => { { provide: RedisService, useValue: { - getClient: jest.fn(), + getClient: jest.fn().mockReturnValue({ + pipeline: jest.fn().mockReturnValue({ + del: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([]), + }), + }), del: jest.fn(), safeIncr: jest.fn(), safeDecr: jest.fn(), + getex: mockRedisService.getex, + set: mockRedisService.set, }, }, { @@ -299,9 +312,10 @@ describe('TweetsService', () => { [], mockPrismaService, ); - }); - // Add these test cases to your existing createTweet describe block + // Verify that validateReferences is not called when no media, reply, or quote + expect(mockTweetsRepository.validateReferences).not.toHaveBeenCalled(); + }); it('should successfully create a tweet with media', async () => { // Arrange @@ -376,6 +390,134 @@ describe('TweetsService', () => { ); }); + it('should successfully create a reply tweet and set rootTweetId from parent', async () => { + // Arrange + const createTweetDto: CreateTweetDto = { + content: 'Replying to this tweet', + replyToTweetId: '75', + }; + + const mockAuthorDto = { + username: 'testuser', + displayName: 'Test User', + avatarUrl: 'https://example.com/avatar.jpg', + }; + + const mockReferencedTweet = { + id: '75', + rootTweetId: '50', + author: mockAuthorDto, + content: 'Parent tweet', + createdAt: new Date(), + replyCount: 0, + retweetCount: 0, + likeCount: 0, + isLiked: false, + isRetweeted: false, + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + quoteToTweetId: null, + quotedTweet: undefined, + replyToTweet: undefined, + repostedBy: undefined, + }; + + mockContentParsingService.parseContentAndValidate.mockResolvedValue({ + mentions: [], + hashtags: [], + }); + mockTweetsRepository.create.mockResolvedValue({ + ...mockTweet, + replyToTweetId: BigInt(75), + rootTweetId: BigInt(50), + }); + mockTweetsRepository.linkTweetMedia.mockResolvedValue(undefined); + mockTweetsRepository.validateReferences.mockResolvedValue({ tweetCount: 1, mediaCount: 0 }); + mockTweetsRepository.getReferencedTweet.mockResolvedValue(mockReferencedTweet); + mockMediaRepository.findOrderedMediaObjectsByIds.mockResolvedValue([]); + mockUsersRepository.findOwnTweetAuthorMetaData.mockResolvedValue(mockAuthorDto); + mockTweetsRepository.updateTweetReplyCount.mockResolvedValue(undefined); + + // Act + const result = await service.createTweet(createTweetDto, userId); + + // Assert + expect(result.replyToTweetId).toBe('75'); + expect(result.rootTweetId).toBe('50'); + expect(mockTweetsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + replyToTweetId: BigInt(75), + rootTweetId: BigInt(50), + }), + mockPrismaService, + ); + }); + + it('should successfully create a reply tweet and use replyToTweetId as rootTweetId when parent has no root', async () => { + // Arrange + const createTweetDto: CreateTweetDto = { + content: 'Replying to this tweet', + replyToTweetId: '75', + }; + + const mockAuthorDto = { + username: 'testuser', + displayName: 'Test User', + avatarUrl: 'https://example.com/avatar.jpg', + }; + + const mockReferencedTweet = { + id: '75', + rootTweetId: null, + author: mockAuthorDto, + content: 'Parent tweet', + createdAt: new Date(), + replyCount: 0, + retweetCount: 0, + likeCount: 0, + isLiked: false, + isRetweeted: false, + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + quoteToTweetId: null, + quotedTweet: undefined, + replyToTweet: undefined, + repostedBy: undefined, + }; + + mockContentParsingService.parseContentAndValidate.mockResolvedValue({ + mentions: [], + hashtags: [], + }); + mockTweetsRepository.create.mockResolvedValue({ + ...mockTweet, + replyToTweetId: BigInt(75), + rootTweetId: BigInt(75), + }); + mockTweetsRepository.linkTweetMedia.mockResolvedValue(undefined); + mockTweetsRepository.validateReferences.mockResolvedValue({ tweetCount: 1, mediaCount: 0 }); + mockTweetsRepository.getReferencedTweet.mockResolvedValue(mockReferencedTweet); + mockMediaRepository.findOrderedMediaObjectsByIds.mockResolvedValue([]); + mockUsersRepository.findOwnTweetAuthorMetaData.mockResolvedValue(mockAuthorDto); + mockTweetsRepository.updateTweetReplyCount.mockResolvedValue(undefined); + + // Act + const result = await service.createTweet(createTweetDto, userId); + + // Assert + expect(result.replyToTweetId).toBe('75'); + expect(result.rootTweetId).toBe('75'); + expect(mockTweetsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + replyToTweetId: BigInt(75), + rootTweetId: BigInt(75), + }), + mockPrismaService, + ); + }); + it('should successfully create a media-only tweet', async () => { // Arrange const createTweetDto: CreateTweetDto = { @@ -615,6 +757,63 @@ describe('TweetsService', () => { ), ); }); + + it('should throw BAD_REQUEST when replyToTweetId is not a valid BigInt', async () => { + // Arrange + const createTweetDto: CreateTweetDto = { + content: 'Reply to invalid tweet ID', + replyToTweetId: 'not-a-number', + }; + + // Act & Assert + await expect(service.createTweet(createTweetDto, userId)).rejects.toThrow( + new HttpException( + { + message: TWEETS_ERROR_MESSAGES.TWEET_NOT_FOUND, + code: TWEETS_ERROR_CODES.TWEET_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ), + ); + }); + + it('should throw BAD_REQUEST when quoteToTweetId is not a valid BigInt', async () => { + // Arrange + const createTweetDto: CreateTweetDto = { + content: 'Quote invalid tweet ID', + quoteToTweetId: 'invalid-id', + }; + + // Act & Assert + await expect(service.createTweet(createTweetDto, userId)).rejects.toThrow( + new HttpException( + { + message: TWEETS_ERROR_MESSAGES.TWEET_NOT_FOUND, + code: TWEETS_ERROR_CODES.TWEET_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ), + ); + }); + + it('should throw BAD_REQUEST when media ID is not a valid BigInt', async () => { + // Arrange + const createTweetDto: CreateTweetDto = { + content: 'Tweet with invalid media ID', + media: ['invalid-media-id'], + }; + + // Act & Assert + await expect(service.createTweet(createTweetDto, userId)).rejects.toThrow( + new HttpException( + { + message: TWEETS_ERROR_MESSAGES.INVALID_MEDIA, + code: TWEETS_ERROR_CODES.INVALID_MEDIA, + }, + HttpStatus.BAD_REQUEST, + ), + ); + }); }); describe('likeTweet', () => { @@ -817,6 +1016,137 @@ describe('TweetsService', () => { }); }); + describe('deleteTweet', () => { + const userId = BigInt(1); + const tweetId = BigInt(100); + + beforeEach(() => { + mockTweetsRepository.checkExistingTweet.mockReset(); + mockTweetsRepository.checkTweetOwnership.mockReset(); + mockTweetsRepository.deleteTweet.mockReset(); + mockTweetsRepository.updateTweetReplyCount.mockReset(); + mockTweetsRepository.updateTweetRetweetCount.mockReset(); + }); + + it('should successfully delete a tweet without references', async () => { + // Arrange + mockTweetsRepository.checkExistingTweet.mockResolvedValue({ + exists: true, + replyToTweetId: null, + quoteToTweetId: null, + }); + mockTweetsRepository.checkTweetOwnership.mockResolvedValue(true); + mockTweetsRepository.deleteTweet.mockResolvedValue(undefined); + + // Act + const result = await service.deleteTweet(tweetId, userId); + + // Assert + expect(result).toEqual({ message: 'Tweet deleted successfully' }); + expect(mockTweetsRepository.checkExistingTweet).toHaveBeenCalledWith(tweetId); + expect(mockTweetsRepository.checkTweetOwnership).toHaveBeenCalledWith(tweetId, userId); + expect(mockTweetsRepository.deleteTweet).toHaveBeenCalledWith(tweetId, mockPrismaService); + expect(mockTweetsRepository.updateTweetReplyCount).not.toHaveBeenCalled(); + expect(mockTweetsRepository.updateTweetRetweetCount).not.toHaveBeenCalled(); + }); + + it('should successfully delete a reply tweet and update parent reply count', async () => { + // Arrange + const replyToTweetId = BigInt(50); + mockTweetsRepository.checkExistingTweet.mockResolvedValue({ + exists: true, + replyToTweetId, + quoteToTweetId: null, + }); + mockTweetsRepository.checkTweetOwnership.mockResolvedValue(true); + mockTweetsRepository.deleteTweet.mockResolvedValue(undefined); + mockTweetsRepository.updateTweetReplyCount.mockResolvedValue(undefined); + + // Act + const result = await service.deleteTweet(tweetId, userId); + + // Assert + expect(result).toEqual({ message: 'Tweet deleted successfully' }); + expect(mockTweetsRepository.updateTweetReplyCount).toHaveBeenCalledWith( + replyToTweetId, + false, + mockPrismaService, + ); + expect(mockTweetsRepository.updateTweetRetweetCount).not.toHaveBeenCalled(); + }); + + it('should successfully delete a quote tweet and update quoted tweet retweet count', async () => { + // Arrange + const quoteToTweetId = BigInt(75); + mockTweetsRepository.checkExistingTweet.mockResolvedValue({ + exists: true, + replyToTweetId: null, + quoteToTweetId, + }); + mockTweetsRepository.checkTweetOwnership.mockResolvedValue(true); + mockTweetsRepository.deleteTweet.mockResolvedValue(undefined); + mockTweetsRepository.updateTweetRetweetCount.mockResolvedValue(undefined); + + // Act + const result = await service.deleteTweet(tweetId, userId); + + // Assert + expect(result).toEqual({ message: 'Tweet deleted successfully' }); + expect(mockTweetsRepository.updateTweetRetweetCount).toHaveBeenCalledWith( + quoteToTweetId, + false, + mockPrismaService, + ); + expect(mockTweetsRepository.updateTweetReplyCount).not.toHaveBeenCalled(); + }); + + it('should throw NOT_FOUND when tweet does not exist', async () => { + // Arrange + mockTweetsRepository.checkExistingTweet.mockResolvedValue({ + exists: false, + replyToTweetId: null, + quoteToTweetId: null, + }); + + // Act & Assert + await expect(service.deleteTweet(tweetId, userId)).rejects.toThrow( + new HttpException( + { + message: TWEETS_ERROR_MESSAGES.TWEET_NOT_FOUND, + code: TWEETS_ERROR_CODES.TWEET_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ), + ); + + expect(mockTweetsRepository.checkTweetOwnership).not.toHaveBeenCalled(); + expect(mockTweetsRepository.deleteTweet).not.toHaveBeenCalled(); + }); + + it('should throw FORBIDDEN when user does not own the tweet', async () => { + // Arrange + mockTweetsRepository.checkExistingTweet.mockResolvedValue({ + exists: true, + replyToTweetId: null, + quoteToTweetId: null, + }); + mockTweetsRepository.checkTweetOwnership.mockResolvedValue(false); + + // Act & Assert + await expect(service.deleteTweet(tweetId, userId)).rejects.toThrow( + new HttpException( + { + message: TWEETS_ERROR_MESSAGES.TWEET_FORBIDDEN_DELETION, + code: TWEETS_ERROR_CODES.TWEET_FORBIDDEN_DELETION, + }, + HttpStatus.FORBIDDEN, + ), + ); + + expect(mockTweetsRepository.deleteTweet).not.toHaveBeenCalled(); + }); + }); + describe('retweetTweet', () => { const userId = BigInt(1); const tweetId = BigInt(100); @@ -1376,6 +1706,141 @@ describe('TweetsService', () => { expect(result.parentTweets).toEqual([mockDeletedParent]); expect(result.rootTweet).toBeNull(); }); + + it('should limit parent tweets to MAX_TWEET_DEPTH and set hasMoreParents', async () => { + // Arrange + const tweetId = BigInt(100); + const currentUserId = BigInt(1); + const replyToTweetId = '75'; + const rootTweetId = '10'; + + const mockAuthorDto = { + username: 'tasneem', + displayName: 'Tasneem', + avatarUrl: 'http://cdn-ur.com', + }; + + const mockDetailedTweet = { + id: '100', + author: mockAuthorDto, + content: 'Reply tweet', + createdAt: new Date('2024-01-01'), + replyCount: 0, + retweetCount: 0, + likeCount: 0, + isLiked: false, + isRetweeted: false, + entities: { + mentions: [], + hashtags: [], + }, + media: [], + replyToTweetId, + quoteToTweetId: null, + rootTweetId, + quotedTweet: undefined, + }; + + const mockRootTweet = { + id: rootTweetId, + author: mockAuthorDto, + content: 'Root tweet', + createdAt: new Date('2024-01-01'), + replyCount: 0, + retweetCount: 0, + likeCount: 0, + isLiked: false, + isRetweeted: false, + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + quoteToTweetId: null, + rootTweetId: null, + quotedTweet: undefined, + replyToTweet: undefined, + repostedBy: undefined, + }; + + // Create 5 parent tweets (MAX_TWEET_DEPTH = 5) + const mockParentTweets = Array.from({ length: 5 }, (_, i) => ({ + id: `${20 + i}`, + author: mockAuthorDto, + content: `Parent tweet ${i}`, + createdAt: new Date('2024-01-01'), + replyCount: 0, + retweetCount: 0, + likeCount: 0, + isLiked: false, + isRetweeted: false, + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: i > 0 ? `${19 + i}` : rootTweetId, + quoteToTweetId: null, + rootTweetId, + quotedTweet: undefined, + replyToTweet: undefined, + repostedBy: undefined, + })); + + mockTweetsRepository.getDetailedTweetById.mockResolvedValue(mockDetailedTweet); + mockTweetsRepository.getTweetOrDeleted.mockResolvedValue(mockRootTweet); + mockTweetsRepository.getParentTweets.mockResolvedValue(mockParentTweets); + + // Act + const result = await service.getTweet(tweetId, currentUserId); + + // Assert + // Should remove the oldest tweet (first one) to maintain depth limit + expect(result.parentTweets).toHaveLength(4); + expect('id' in result.parentTweets[0] && result.parentTweets[0].id).toBe('21'); + expect(result.hasMoreParents).toBe(true); + expect(result.rootTweet).toEqual(mockRootTweet); + }); + + it('should return empty parentTweets and hasMoreParents false when currentUserId is null', async () => { + // Arrange + const tweetId = BigInt(100); + const currentUserId = null; + + const mockAuthorDto = { + username: 'tasneem', + displayName: 'Tasneem', + avatarUrl: 'http://cdn-ur.com', + }; + + const mockDetailedTweet = { + id: '100', + author: mockAuthorDto, + content: 'Tweet', + createdAt: new Date('2024-01-01'), + replyCount: 0, + retweetCount: 0, + likeCount: 0, + isLiked: false, + isRetweeted: false, + entities: { + mentions: [], + hashtags: [], + }, + media: [], + replyToTweetId: '75', + quoteToTweetId: null, + rootTweetId: '50', + quotedTweet: undefined, + }; + + mockTweetsRepository.getDetailedTweetById.mockResolvedValue(mockDetailedTweet); + + // Act + const result = await service.getTweet(tweetId, currentUserId); + + // Assert + expect(result.parentTweets).toEqual([]); + expect(result.rootTweet).toBeNull(); + expect(result.hasMoreParents).toBe(false); + expect(mockTweetsRepository.getTweetOrDeleted).not.toHaveBeenCalled(); + expect(mockTweetsRepository.getParentTweets).not.toHaveBeenCalled(); + }); }); describe('getTweetReplies', () => { @@ -1618,11 +2083,14 @@ describe('TweetsService', () => { it('should successfully fetch likers', async () => { // Arrange mockTweetsRepository.findTweetById.mockResolvedValue({ id: tweetId, isDeleted: false }); - const mockLikers = [{ userId: BigInt(50) }]; + const mockLikers = [ + { userId: BigInt(50), username: 'user1', displayName: 'User One' }, + { userId: BigInt(51), username: 'user2', displayName: 'User Two' }, + ]; mockTweetsRepository.getTweetLikers.mockResolvedValue(mockLikers); // Act - await service.getTweetLikers(tweetId, currentUserId, limit, validCursor); + const result = await service.getTweetLikers(tweetId, currentUserId, limit, validCursor); // Assert expect(mockTweetsRepository.getTweetLikers).toHaveBeenCalledWith( @@ -1631,6 +2099,12 @@ describe('TweetsService', () => { limit + 1, { id: '50', createdAt: '2024-01-01T00:00:00Z' }, ); + + // Verify userId is removed from items + expect(result.items).toHaveLength(2); + expect(result.items[0]).not.toHaveProperty('userId'); + expect(result.items[0]).toHaveProperty('username'); + expect(result.items[0]).toHaveProperty('displayName'); }); it('should throw BAD_REQUEST on invalid cursor for likers', async () => { @@ -1661,11 +2135,14 @@ describe('TweetsService', () => { it('should successfully fetch retweeters', async () => { // Arrange mockTweetsRepository.findTweetById.mockResolvedValue({ id: tweetId, isDeleted: false }); - const mockRetweeters = [{ userId: BigInt(60) }]; + const mockRetweeters = [ + { userId: BigInt(60), username: 'user3', displayName: 'User Three' }, + { userId: BigInt(61), username: 'user4', displayName: 'User Four' }, + ]; mockTweetsRepository.getTweetRetweeters.mockResolvedValue(mockRetweeters); // Act - await service.getTweetRetweeters(tweetId, currentUserId, limit); + const result = await service.getTweetRetweeters(tweetId, currentUserId, limit); // Assert expect(mockTweetsRepository.getTweetRetweeters).toHaveBeenCalledWith( @@ -1674,6 +2151,11 @@ describe('TweetsService', () => { limit + 1, undefined, ); + + expect(result.items).toHaveLength(2); + expect(result.items[0]).not.toHaveProperty('userId'); + expect(result.items[0]).toHaveProperty('username'); + expect(result.items[0]).toHaveProperty('displayName'); }); }); @@ -2191,4 +2673,288 @@ describe('TweetsService', () => { expect(result.pagination.hasNextPage).toBe(true); }); }); + + describe('getTweetsByHashtag', () => { + const hashtag = 'javascript'; + const currentUserId = BigInt(1); + const limit = 10; + const hashtagId = BigInt(1); + + it('should return empty array when hashtag does not exist', async () => { + // Arrange + mockTrendingService.getHashtagId.mockResolvedValue(null); + + // Act + const result = await service.getTweetsByHashtag(hashtag, currentUserId, limit); + + // Assert + expect(result).toEqual([]); + expect(mockTrendingService.getHashtagId).toHaveBeenCalledWith(hashtag); + expect(mockTweetsRepository.getTweetIdsLinkedToHashtag).not.toHaveBeenCalled(); + }); + + it('should return tweets for valid hashtag', async () => { + // Arrange + const mockHashtagRecord = { id: hashtagId, keyword: hashtag }; + const mockTweetIds = [BigInt(100), BigInt(101)]; + const mockTweets = [ + { + id: '100', + content: 'Tweet about #javascript', + createdAt: new Date(), + }, + { + id: '101', + content: 'Another #javascript tweet', + createdAt: new Date(), + }, + ]; + + mockTrendingService.getHashtagId.mockResolvedValue(mockHashtagRecord); + mockTweetsRepository.getTweetIdsLinkedToHashtag.mockResolvedValue(mockTweetIds); + mockTweetsRepository.getTweetsWithReferencesByIds.mockResolvedValue(mockTweets); + + // Act + const result = await service.getTweetsByHashtag(hashtag, currentUserId, limit); + + // Assert + expect(mockTrendingService.getHashtagId).toHaveBeenCalledWith(hashtag); + expect(mockTweetsRepository.getTweetIdsLinkedToHashtag).toHaveBeenCalledWith( + hashtagId, + currentUserId, + limit + 1, + false, + undefined, + undefined, + undefined, + ); + expect(mockTweetsRepository.getTweetsWithReferencesByIds).toHaveBeenCalledWith( + currentUserId, + mockTweetIds, + ); + expect(result).toEqual(mockTweets); + }); + + it('should handle hasMedia filter', async () => { + // Arrange + const mockHashtagRecord = { id: hashtagId, keyword: hashtag }; + const mockTweetIds = [BigInt(100)]; + const mockTweets = [{ id: '100', content: 'Tweet with media', createdAt: new Date() }]; + + mockTrendingService.getHashtagId.mockResolvedValue(mockHashtagRecord); + mockTweetsRepository.getTweetIdsLinkedToHashtag.mockResolvedValue(mockTweetIds); + mockTweetsRepository.getTweetsWithReferencesByIds.mockResolvedValue(mockTweets); + + // Act + const result = await service.getTweetsByHashtag(hashtag, currentUserId, limit, true); + + // Assert + expect(mockTweetsRepository.getTweetIdsLinkedToHashtag).toHaveBeenCalledWith( + hashtagId, + currentUserId, + limit + 1, + true, + undefined, + undefined, + undefined, + ); + expect(result).toEqual(mockTweets); + }); + + it('should pass cursor and filters correctly', async () => { + // Arrange + const mockHashtagRecord = { id: hashtagId, keyword: hashtag }; + const mockCursor = { id: '50', createdAt: new Date('2024-01-01T00:00:00Z') }; + const mockPeopleFilter: PeopleSearchFilter = PeopleSearchFilter.Following; + + mockTrendingService.getHashtagId.mockResolvedValue(mockHashtagRecord); + mockTweetsRepository.getTweetIdsLinkedToHashtag.mockResolvedValue([]); + mockTweetsRepository.getTweetsWithReferencesByIds.mockResolvedValue([]); + + // Act + await service.getTweetsByHashtag( + hashtag, + currentUserId, + limit, + false, + mockCursor, + true, + mockPeopleFilter, + ); + + // Assert + expect(mockTweetsRepository.getTweetIdsLinkedToHashtag).toHaveBeenCalledWith( + hashtagId, + currentUserId, + limit + 1, + false, + true, + mockPeopleFilter, + mockCursor, + ); + }); + }); + + describe('getTweetSummary', () => { + const tweetId = BigInt(100); + const langcode = 'en'; + + beforeEach(() => { + mockTweetsRepository.findTweetById.mockReset(); + mockRedisService.getex.mockReset(); + mockRedisService.set.mockReset(); + mockContentParsingService.generateTweetSummary.mockReset(); + }); + + it('should return cached summary when available', async () => { + // Arrange + const mockTweet = { + id: tweetId, + content: 'This is a tweet about AI and machine learning', + isDeleted: false, + }; + const cachedSummary = 'Summary about AI'; + const cacheKey = `tweet:summary:${tweetId.toString()}:${langcode}`; + + mockTweetsRepository.findTweetById.mockResolvedValue(mockTweet); + mockRedisService.getex.mockResolvedValue(cachedSummary); + + // Act + const result = await service.getTweetSummary(tweetId, langcode); + + // Assert + expect(result).toEqual({ + id: tweetId.toString(), + summary: cachedSummary, + }); + expect(mockRedisService.getex).toHaveBeenCalledWith(cacheKey, expect.any(Number)); + expect(mockContentParsingService.generateTweetSummary).not.toHaveBeenCalled(); + expect(mockRedisService.set).not.toHaveBeenCalled(); + }); + + it('should generate and cache summary when not cached', async () => { + // Arrange + const mockTweet = { + id: tweetId, + content: 'This is a tweet about AI and machine learning', + isDeleted: false, + }; + const generatedSummary = 'Generated summary about AI'; + const cacheKey = `tweet:summary:${tweetId.toString()}:${langcode}`; + + mockTweetsRepository.findTweetById.mockResolvedValue(mockTweet); + mockRedisService.getex.mockResolvedValue(null); + mockContentParsingService.generateTweetSummary.mockResolvedValue(generatedSummary); + mockRedisService.set.mockResolvedValue('OK'); + + // Act + const result = await service.getTweetSummary(tweetId, langcode); + + // Assert + expect(result).toEqual({ + id: tweetId.toString(), + summary: generatedSummary, + }); + expect(mockRedisService.getex).toHaveBeenCalledWith(cacheKey, expect.any(Number)); + expect(mockContentParsingService.generateTweetSummary).toHaveBeenCalledWith( + mockTweet.content, + langcode, + ); + expect(mockRedisService.set).toHaveBeenCalledWith( + cacheKey, + generatedSummary, + expect.any(Number), + ); + }); + + it('should throw NOT_FOUND when tweet does not exist', async () => { + // Arrange + mockTweetsRepository.findTweetById.mockResolvedValue(null); + + // Act & Assert + await expect(service.getTweetSummary(tweetId, langcode)).rejects.toThrow( + new HttpException( + { + message: TWEETS_ERROR_MESSAGES.TWEET_NOT_FOUND, + code: TWEETS_ERROR_CODES.TWEET_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ), + ); + + expect(mockRedisService.getex).not.toHaveBeenCalled(); + }); + + it('should throw NOT_FOUND when tweet is deleted', async () => { + // Arrange + const mockTweet = { + id: tweetId, + content: 'This is a deleted tweet', + isDeleted: true, + }; + + mockTweetsRepository.findTweetById.mockResolvedValue(mockTweet); + + // Act & Assert + await expect(service.getTweetSummary(tweetId, langcode)).rejects.toThrow( + new HttpException( + { + message: TWEETS_ERROR_MESSAGES.TWEET_NOT_FOUND, + code: TWEETS_ERROR_CODES.TWEET_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ), + ); + + expect(mockRedisService.getex).not.toHaveBeenCalled(); + }); + + it('should throw BAD_REQUEST when tweet content is empty', async () => { + // Arrange + const mockTweet = { + id: tweetId, + content: '', + isDeleted: false, + }; + + mockTweetsRepository.findTweetById.mockResolvedValue(mockTweet); + + // Act & Assert + await expect(service.getTweetSummary(tweetId, langcode)).rejects.toThrow( + new HttpException( + { + message: TWEETS_ERROR_MESSAGES.EMPTY_TWEET_CONTENT, + code: TWEETS_ERROR_CODES.EMPTY_TWEET_CONTENT, + }, + HttpStatus.BAD_REQUEST, + ), + ); + + expect(mockRedisService.getex).not.toHaveBeenCalled(); + }); + + it('should throw BAD_REQUEST when tweet content is null', async () => { + // Arrange + const mockTweet = { + id: tweetId, + content: null, + isDeleted: false, + }; + + mockTweetsRepository.findTweetById.mockResolvedValue(mockTweet); + + // Act & Assert + await expect(service.getTweetSummary(tweetId, langcode)).rejects.toThrow( + new HttpException( + { + message: TWEETS_ERROR_MESSAGES.EMPTY_TWEET_CONTENT, + code: TWEETS_ERROR_CODES.EMPTY_TWEET_CONTENT, + }, + HttpStatus.BAD_REQUEST, + ), + ); + + expect(mockRedisService.getex).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/users/users.controller.spec.ts b/test/users/users.controller.spec.ts index d4b46553..4c0eb57e 100644 --- a/test/users/users.controller.spec.ts +++ b/test/users/users.controller.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { RequestUser } from 'src/common/interfaces'; import { UsersController } from 'src/users/users.controller'; import { UsersRepository } from 'src/users/users.repository'; import { UsersService } from 'src/users/users.service'; @@ -17,6 +18,10 @@ describe('UsersController', () => { getUserFollowers: jest.fn(), getUserFollowings: jest.fn(), getUserMutualFollowers: jest.fn(), + getUserRelationship: jest.fn(), + enableUserNotifications: jest.fn(), + disableUserNotifications: jest.fn(), + getUserById: jest.fn(), }; const mockUsersRepository = { @@ -144,6 +149,25 @@ describe('UsersController', () => { expect(mockUsersService.getUserProfile).toHaveBeenCalledTimes(1); expect(result).toEqual(expectedResult); }); + + it('should call usersService.getUserProfile with undefined when user is not provided', async () => { + // Arrange + const username = 'john_doe'; + const expectedResult = { + displayName: 'John Doe', + bio: 'A sample user', + }; + + (mockUsersService.getUserProfile as jest.Mock).mockResolvedValue(expectedResult); + + // Act + const result = await controller.getUserProfile(username, undefined as unknown as RequestUser); + + // Assert + expect(mockUsersService.getUserProfile).toHaveBeenCalledWith(username, undefined); + expect(mockUsersService.getUserProfile).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedResult); + }); }); describe('GET /users/:username/followers', () => { const mockUser = { id: '1' }; @@ -998,4 +1022,157 @@ describe('UsersController', () => { ); }); }); + + describe('GET /users/:username/relationship', () => { + const mockUser = { id: '1' }; + const username = 'testuser'; + + it('should call usersService.getUserRelationship with correct parameters', async () => { + // Arrange + const expectedResult = { + isFollowing: true, + isFollowedBy: false, + isBlocked: false, + isMuted: false, + }; + + (mockUsersService.getUserRelationship as jest.Mock).mockResolvedValue(expectedResult); + + // Act + const result = await controller.getUserRelationship(username, mockUser); + + // Assert + expect(mockUsersService.getUserRelationship).toHaveBeenCalledWith(BigInt(1), username); + expect(mockUsersService.getUserRelationship).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedResult); + }); + + it('should handle errors thrown by usersService.getUserRelationship', async () => { + // Arrange + const error = new Error('User not found'); + (mockUsersService.getUserRelationship as jest.Mock).mockRejectedValue(error); + + // Act & Assert + await expect(controller.getUserRelationship(username, mockUser)).rejects.toThrow( + 'User not found', + ); + expect(mockUsersService.getUserRelationship).toHaveBeenCalledWith(BigInt(1), username); + }); + }); + + describe('POST /users/:username/notify', () => { + const mockUser = { id: '1' }; + const username = 'testuser'; + + it('should call usersService.enableUserNotifications with correct parameters', async () => { + // Arrange + const expectedResult = { message: 'Notifications enabled successfully' }; + + (mockUsersService.enableUserNotifications as jest.Mock).mockResolvedValue(expectedResult); + + // Act + const result = await controller.enableUserNotifications(username, mockUser); + + // Assert + expect(mockUsersService.enableUserNotifications).toHaveBeenCalledWith(BigInt(1), username); + expect(mockUsersService.enableUserNotifications).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedResult); + }); + + it('should handle errors thrown by usersService.enableUserNotifications', async () => { + // Arrange + const error = new Error('User not found'); + (mockUsersService.enableUserNotifications as jest.Mock).mockRejectedValue(error); + + // Act & Assert + await expect(controller.enableUserNotifications(username, mockUser)).rejects.toThrow( + 'User not found', + ); + expect(mockUsersService.enableUserNotifications).toHaveBeenCalledWith(BigInt(1), username); + }); + }); + + describe('DELETE /users/:username/notify', () => { + const mockUser = { id: '1' }; + const username = 'testuser'; + + it('should call usersService.disableUserNotifications with correct parameters', async () => { + // Arrange + const expectedResult = { message: 'Notifications disabled successfully' }; + + (mockUsersService.disableUserNotifications as jest.Mock).mockResolvedValue(expectedResult); + + // Act + const result = await controller.disableUserNotifications(username, mockUser); + + // Assert + expect(mockUsersService.disableUserNotifications).toHaveBeenCalledWith(BigInt(1), username); + expect(mockUsersService.disableUserNotifications).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedResult); + }); + + it('should handle errors thrown by usersService.disableUserNotifications', async () => { + // Arrange + const error = new Error('User not found'); + (mockUsersService.disableUserNotifications as jest.Mock).mockRejectedValue(error); + + // Act & Assert + await expect(controller.disableUserNotifications(username, mockUser)).rejects.toThrow( + 'User not found', + ); + expect(mockUsersService.disableUserNotifications).toHaveBeenCalledWith(BigInt(1), username); + }); + }); + + describe('GET /users/id/:id', () => { + const userId = '123456789'; + + it('should call usersService.getUserById with correct parameters', async () => { + // Arrange + const expectedResult = { + id: '123456789', + username: 'testuser', + displayName: 'Test User', + bio: 'Test bio', + }; + + (mockUsersService.getUserById as jest.Mock).mockResolvedValue(expectedResult); + + // Act + const result = await controller.getUserById(userId); + + // Assert + expect(mockUsersService.getUserById).toHaveBeenCalledWith(BigInt(userId)); + expect(mockUsersService.getUserById).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedResult); + }); + + it('should handle errors thrown by usersService.getUserById', async () => { + // Arrange + const error = new Error('User not found'); + (mockUsersService.getUserById as jest.Mock).mockRejectedValue(error); + + // Act & Assert + await expect(controller.getUserById(userId)).rejects.toThrow('User not found'); + expect(mockUsersService.getUserById).toHaveBeenCalledWith(BigInt(userId)); + }); + + it('should correctly convert user id string to BigInt', async () => { + // Arrange + const largeUserId = '9007199254740991'; // max safe integer + const expectedResult = { + id: largeUserId, + username: 'testuser', + displayName: 'Test User', + }; + + (mockUsersService.getUserById as jest.Mock).mockResolvedValue(expectedResult); + + // Act + await controller.getUserById(largeUserId); + + // Assert + expect(mockUsersService.getUserById).toHaveBeenCalledWith(BigInt(largeUserId)); + }); + }); }); diff --git a/test/users/users.service.spec.ts b/test/users/users.service.spec.ts index 644fa6a6..3fe8cb60 100755 --- a/test/users/users.service.spec.ts +++ b/test/users/users.service.spec.ts @@ -14,9 +14,10 @@ import { MediaService } from 'src/media/media.service'; import { MediaFolder } from 'src/media/enums'; import { ContentParsingService } from 'src/content-parsing/content-parsing.service'; import { NewUser } from 'src/users/interfaces'; -import { UserRelationshipDto } from 'src/users/dtos/relationship-dto'; +import { UserRelationshipDto } from 'src/users/dtos/relationship.dto'; import { RedisService } from 'src/redis/redis.service'; import { DomainEventsService } from 'src/events/domain-events.service'; +import { PeopleSearchFilter } from 'src/search/dtos'; jest.mock('src/auth/utils/password.util'); jest.mock('src/users/utils/validate-password-format.util'); @@ -97,6 +98,17 @@ describe('UsersService', () => { getUserMutualFollowers: jest.fn(), getUserIdsFollowedBy: jest.fn(), getUsersRelationshipsMap: jest.fn(), + updateInterests: jest.fn(), + getUserMutedUsers: jest.fn(), + getUserBlockedUsers: jest.fn(), + checkBatchUsernamesExistence: jest.fn(), + createProfile: jest.fn(), + getMatchingUsers: jest.fn(), + getUserFollowRelations: jest.fn(), + searchUsers: jest.fn(), + getFollowersUnPaginated: jest.fn(), + toggleUserNotifications: jest.fn(), + findUsernameAndDisplayNameById: jest.fn(), }; const mockEmailQueue = { @@ -942,6 +954,216 @@ describe('UsersService', () => { expect(mockMediaService.deleteMedia).toHaveBeenCalledWith(newBannerUrl, BigInt(1)); expect(mockMediaService.deleteMedia).toHaveBeenCalledTimes(1); }); + + test('should log warning if old avatar deletion fails', async () => { + const oldAvatarUrl = 'https://example.com/old-avatar.jpg'; + const newAvatarUrl = 'https://example.com/new-avatar.jpg'; + + mockRepository.findByIdWithProfile.mockResolvedValue({ + ...mockUser, + profile: { avatarUrl: oldAvatarUrl, bannerUrl: undefined }, + }); + mockMediaService.uploadAvatarOrBanner.mockResolvedValue({ + avatarUrl: newAvatarUrl, + }); + mockMediaService.deleteMedia.mockRejectedValue(new Error('S3 deletion failed')); + const updatedProfile = { + ...updateProfileDto, + avatarUrl: newAvatarUrl, + }; + mockRepository.updateProfile.mockResolvedValue(updatedProfile); + + const result = await service.updateProfile(BigInt(1), updateProfileDto, { + avatar: mockFiles.avatar, + }); + + expect(result.avatarUrl).toEqual(newAvatarUrl); + expect(mockMediaService.deleteMedia).toHaveBeenCalledWith(oldAvatarUrl, BigInt(1)); + }); + + test('should log warning if old banner deletion fails', async () => { + const oldBannerUrl = 'https://example.com/old-banner.jpg'; + const newBannerUrl = 'https://example.com/new-banner.jpg'; + + mockRepository.findByIdWithProfile.mockResolvedValue({ + ...mockUser, + profile: { avatarUrl: undefined, bannerUrl: oldBannerUrl }, + }); + mockMediaService.uploadAvatarOrBanner.mockResolvedValue({ + bannerUrl: newBannerUrl, + }); + mockMediaService.deleteMedia.mockRejectedValue(new Error('S3 deletion failed')); + const updatedProfile = { + ...updateProfileDto, + bannerUrl: newBannerUrl, + }; + mockRepository.updateProfile.mockResolvedValue(updatedProfile); + + const result = await service.updateProfile(BigInt(1), updateProfileDto, { + banner: mockFiles.banner, + }); + + expect(result.bannerUrl).toEqual(newBannerUrl); + expect(mockMediaService.deleteMedia).toHaveBeenCalledWith(oldBannerUrl, BigInt(1)); + }); + + test('should log warning if rollback avatar deletion fails', async () => { + const newAvatarUrl = 'https://example.com/new-avatar.jpg'; + + mockRepository.findByIdWithProfile.mockResolvedValue({ + ...mockUser, + profile: { avatarUrl: undefined, bannerUrl: undefined }, + }); + mockMediaService.uploadAvatarOrBanner.mockResolvedValue({ + avatarUrl: newAvatarUrl, + }); + mockMediaService.deleteMedia.mockRejectedValue(new Error('S3 deletion failed')); + mockRepository.updateProfile.mockRejectedValue(new Error('DB error')); + + await expect( + service.updateProfile(BigInt(1), updateProfileDto, { avatar: mockFiles.avatar }), + ).rejects.toThrow('DB error'); + + expect(mockMediaService.deleteMedia).toHaveBeenCalledWith(newAvatarUrl, BigInt(1)); + }); + + test('should log warning if rollback banner deletion fails', async () => { + const newBannerUrl = 'https://example.com/new-banner.jpg'; + + mockRepository.findByIdWithProfile.mockResolvedValue({ + ...mockUser, + profile: { avatarUrl: undefined, bannerUrl: undefined }, + }); + mockMediaService.uploadAvatarOrBanner.mockResolvedValue({ + bannerUrl: newBannerUrl, + }); + mockMediaService.deleteMedia.mockRejectedValue(new Error('S3 deletion failed')); + mockRepository.updateProfile.mockRejectedValue(new Error('DB error')); + + await expect( + service.updateProfile(BigInt(1), updateProfileDto, { banner: mockFiles.banner }), + ).rejects.toThrow('DB error'); + + expect(mockMediaService.deleteMedia).toHaveBeenCalledWith(newBannerUrl, BigInt(1)); + }); + + test('should handle bio with mentions and hashtags', async () => { + const bioWithMentionsAndHashtags = '@user1 and @user2 love #coding and #testing'; + const updatedProfile = { + ...updateProfileDto, + bio: bioWithMentionsAndHashtags, + }; + + mockRepository.findByIdWithProfile.mockResolvedValue(mockUser); + mockContentParsingService.parseContentForBio.mockResolvedValue({ + mentions: [ + { username: 'user1', startPosition: 0 }, + { username: 'user2', startPosition: 11 }, + ], + hashtags: [ + { keyword: 'coding', startPosition: 24 }, + { keyword: 'testing', startPosition: 36 }, + ], + }); + mockPrismaService.$transaction.mockImplementation( + (callback: (tx: Prisma.TransactionClient) => Promise): Promise => { + return callback({} as Prisma.TransactionClient); + }, + ); + mockRepository.updateProfile.mockResolvedValue(updatedProfile); + + const result = await service.updateProfile(BigInt(1), { + ...updateProfileDto, + bio: bioWithMentionsAndHashtags, + }); + + expect(result.bio).toEqual(bioWithMentionsAndHashtags); + expect(mockContentParsingService.parseContentForBio).toHaveBeenCalledWith( + bioWithMentionsAndHashtags, + {}, + ); + expect(mockRepository.updateProfile).toHaveBeenCalledWith( + BigInt(1), + { ...updateProfileDto, bio: bioWithMentionsAndHashtags }, + undefined, + undefined, + { + mentions: [ + { username: 'user1', startPosition: 0 }, + { username: 'user2', startPosition: 11 }, + ], + hashtags: [ + { hashtag: 'coding', startPosition: 24 }, + { hashtag: 'testing', startPosition: 36 }, + ], + }, + {}, + ); + }); + + test('should log warning if explicit banner deletion fails', async () => { + const oldBannerUrl = 'https://example.com/old-banner.jpg'; + const updatedProfile = { + ...updateProfileDto, + bannerUrl: null, + }; + + mockRepository.findByIdWithProfile.mockResolvedValue({ + ...mockUser, + profile: { avatarUrl: undefined, bannerUrl: oldBannerUrl }, + }); + mockContentParsingService.parseContentForBio.mockResolvedValue({ + mentions: [], + hashtags: [], + }); + mockPrismaService.$transaction.mockImplementation( + (callback: (tx: Prisma.TransactionClient) => Promise): Promise => { + return callback({} as Prisma.TransactionClient); + }, + ); + mockMediaService.deleteMedia.mockRejectedValue(new Error('S3 deletion failed')); + mockRepository.updateProfile.mockResolvedValue(updatedProfile); + + const result = await service.updateProfile(BigInt(1), { + ...updateProfileDto, + deleteBanner: true, + }); + + expect(result.bannerUrl).toBeNull(); + expect(mockMediaService.deleteMedia).toHaveBeenCalledWith(oldBannerUrl, BigInt(1)); + }); + + test('should log warning if explicit avatar deletion fails', async () => { + const oldAvatarUrl = 'https://example.com/old-avatar.jpg'; + const updatedProfile = { + ...updateProfileDto, + avatarUrl: null, + }; + + mockRepository.findByIdWithProfile.mockResolvedValue({ + ...mockUser, + profile: { avatarUrl: oldAvatarUrl, bannerUrl: undefined }, + }); + mockContentParsingService.parseContentForBio.mockResolvedValue({ + mentions: [], + hashtags: [], + }); + mockPrismaService.$transaction.mockImplementation( + (callback: (tx: Prisma.TransactionClient) => Promise): Promise => { + return callback({} as Prisma.TransactionClient); + }, + ); + mockMediaService.deleteMedia.mockRejectedValue(new Error('S3 deletion failed')); + mockRepository.updateProfile.mockResolvedValue(updatedProfile); + + const result = await service.updateProfile(BigInt(1), { + ...updateProfileDto, + deleteAvatar: true, + }); + + expect(result.avatarUrl).toBeNull(); + expect(mockMediaService.deleteMedia).toHaveBeenCalledWith(oldAvatarUrl, BigInt(1)); + }); }); describe('getUserProfile', () => { @@ -1656,6 +1878,26 @@ describe('UsersService', () => { message: USERS_ERROR_MESSAGES.CANNOT_MUTE_SELF, code: USERS_ERROR_CODES.CANNOT_MUTE_SELF, }, + HttpStatus.BAD_REQUEST, + ), + ); + }); + + it('should throw error if user is blocked', async () => { + // Arrange + const muterId = BigInt(2); + const usernameToMute = 'testuser'; + + mockRepository.findByUsername.mockResolvedValue(mockUser); + mockRepository.isBlocked.mockResolvedValue(true); + + // Act & Assert + await expect(service.muteUser(muterId, usernameToMute)).rejects.toThrow( + new HttpException( + { + message: USERS_ERROR_MESSAGES.CANNOT_MUTE_USER, + code: USERS_ERROR_CODES.CANNOT_MUTE_USER, + }, HttpStatus.FORBIDDEN, ), ); @@ -2145,6 +2387,7 @@ describe('UsersService', () => { const userId = BigInt(1); const mockBannerUrl = 'https://example.com/existing-banner.jpg'; mockRepository.deleteBanner.mockResolvedValue({ bannerUrl: mockBannerUrl }); + mockMediaService.deleteMedia.mockResolvedValue(undefined); // Act const result = await service.deleteBanner(userId); @@ -2152,6 +2395,24 @@ describe('UsersService', () => { // Assert expect(result).toEqual({ message: 'Banner deleted successfully' }); expect(mockRepository.deleteBanner).toHaveBeenCalledWith(userId); + expect(mockMediaService.deleteMedia).toHaveBeenCalledWith(mockBannerUrl, userId); + }); + + it('should throw error if banner not found', async () => { + // Arrange + const userId = BigInt(1); + mockRepository.deleteBanner.mockResolvedValue({ bannerUrl: null }); + + // Act & Assert + await expect(service.deleteBanner(userId)).rejects.toThrow( + new HttpException( + { + message: USERS_ERROR_MESSAGES.BANNER_NOT_FOUND, + code: USERS_ERROR_CODES.BANNER_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ), + ); }); it('should throw error if repository delete fails', async () => { @@ -3338,4 +3599,539 @@ describe('UsersService', () => { expect(result.pagination.nextCursor).toBeNull(); }); }); + + describe('updateInterests', () => { + it('should update user interests successfully', async () => { + const userId = BigInt(1); + const interests = ['Technology', 'Sports', 'Music']; + + mockRepository.updateInterests = jest.fn().mockResolvedValue(undefined); + + const result = await service.updateInterests(userId, interests); + + expect(mockRepository.updateInterests).toHaveBeenCalledWith(userId, interests); + expect(result).toEqual({ message: 'Interests updated successfully.' }); + }); + }); + + describe('getUserMutes', () => { + it('should return muted users with relationships', async () => { + const userId = BigInt(1); + const limit = 20; + const prevCursor = undefined; + + const mockMutedUsers = [ + { + muterId: userId, + mutedId: BigInt(2), + mutedUser: { + id: BigInt(2), + username: 'muted1', + profile: { displayName: 'Muted One' }, + }, + }, + { + muterId: userId, + mutedId: BigInt(3), + mutedUser: { + id: BigInt(3), + username: 'muted2', + profile: { displayName: 'Muted Two' }, + }, + }, + ]; + + const mockRelationMap = new Map([ + [ + BigInt(2), + { following: false, follower: false, blockedBy: false, blocking: false, muted: true }, + ], + [ + BigInt(3), + { following: true, follower: false, blockedBy: false, blocking: false, muted: true }, + ], + ]); + + mockRepository.getUserMutedUsers = jest.fn().mockResolvedValue(mockMutedUsers); + mockRepository.getUsersRelationshipsMap = jest.fn().mockResolvedValue(mockRelationMap); + + const result = await service.getUserMutes(userId, limit, prevCursor); + + expect(mockRepository.getUserMutedUsers).toHaveBeenCalledWith(userId, limit, prevCursor); + expect(mockRepository.getUsersRelationshipsMap).toHaveBeenCalledWith(userId, [ + BigInt(2), + BigInt(3), + ]); + expect(result).toEqual({ + mutedUsers: mockMutedUsers, + relationMap: mockRelationMap, + }); + }); + }); + + describe('getUserBlocks', () => { + it('should return blocked users with relationships', async () => { + const userId = BigInt(1); + const limit = 20; + const prevCursor = undefined; + + const mockBlockedUsers = [ + { + blockerId: userId, + blockedId: BigInt(2), + blockedUser: { + id: BigInt(2), + username: 'blocked1', + profile: { displayName: 'Blocked One' }, + }, + }, + { + blockerId: userId, + blockedId: BigInt(3), + blockedUser: { + id: BigInt(3), + username: 'blocked2', + profile: { displayName: 'Blocked Two' }, + }, + }, + ]; + + const mockRelationMap = new Map([ + [ + BigInt(2), + { following: false, follower: false, blockedBy: false, blocking: true, muted: false }, + ], + [ + BigInt(3), + { following: false, follower: false, blockedBy: false, blocking: true, muted: false }, + ], + ]); + + mockRepository.getUserBlockedUsers = jest.fn().mockResolvedValue(mockBlockedUsers); + mockRepository.getUsersRelationshipsMap = jest.fn().mockResolvedValue(mockRelationMap); + + const result = await service.getUserBlocks(userId, limit, prevCursor); + + expect(mockRepository.getUserBlockedUsers).toHaveBeenCalledWith(userId, limit, prevCursor); + expect(mockRepository.getUsersRelationshipsMap).toHaveBeenCalledWith(userId, [ + BigInt(2), + BigInt(3), + ]); + expect(result).toEqual({ + blockedUsers: mockBlockedUsers, + relationMap: mockRelationMap, + }); + }); + }); + + describe('checkUsernamesExistenceAndReplaceIds', () => { + it('should return mentions with user IDs for existing usernames', async () => { + const mentions = [ + { username: 'user1', startPosition: 0 }, + { username: 'user2', startPosition: 10 }, + { username: 'nonexistent', startPosition: 20 }, + ]; + + const existingUsers = [ + { id: BigInt(1), username: 'user1' }, + { id: BigInt(2), username: 'user2' }, + ]; + + mockRepository.checkBatchUsernamesExistence = jest.fn().mockResolvedValue(existingUsers); + + const result = await service.checkUsernamesExistenceAndReplaceIds(mentions); + + expect(mockRepository.checkBatchUsernamesExistence).toHaveBeenCalledWith( + mentions, + mockPrismaService, + ); + expect(result).toEqual([ + { userId: BigInt(1), username: 'user1', startPosition: 0 }, + { userId: BigInt(2), username: 'user2', startPosition: 10 }, + ]); + }); + + it('should handle case-insensitive username matching', async () => { + const mentions = [{ username: 'USER1', startPosition: 0 }]; + + const existingUsers = [{ id: BigInt(1), username: 'user1' }]; + + mockRepository.checkBatchUsernamesExistence = jest.fn().mockResolvedValue(existingUsers); + + const result = await service.checkUsernamesExistenceAndReplaceIds(mentions); + + expect(result).toEqual([{ userId: BigInt(1), username: 'user1', startPosition: 0 }]); + }); + + it('should return empty array when no usernames exist', async () => { + const mentions = [{ username: 'nonexistent', startPosition: 0 }]; + + mockRepository.checkBatchUsernamesExistence = jest.fn().mockResolvedValue([]); + + const result = await service.checkUsernamesExistenceAndReplaceIds(mentions); + + expect(result).toEqual([]); + }); + }); + + describe('createProfile', () => { + it('should create a profile for a user', async () => { + const userId = BigInt(1); + const displayName = 'Test User'; + + const mockProfile = { + userId, + displayName, + bio: null, + location: null, + }; + + mockRepository.createProfile = jest.fn().mockResolvedValue(mockProfile); + + const result = await service.createProfile(userId, displayName); + + expect(mockRepository.createProfile).toHaveBeenCalledWith(userId, displayName); + expect(result).toEqual(mockProfile); + }); + }); + + describe('getMatchingUsers', () => { + it('should return matching users', async () => { + const userId = BigInt(1); + const username = 'test'; + + const mockUsers = [ + { id: BigInt(2), username: 'testuser1' }, + { id: BigInt(3), username: 'testuser2' }, + ]; + + mockRepository.getMatchingUsers = jest.fn().mockResolvedValue(mockUsers); + + const result = await service.getMatchingUsers(userId, username); + + expect(mockRepository.getMatchingUsers).toHaveBeenCalledWith(userId, username); + expect(result).toEqual(mockUsers); + }); + }); + + describe('getUserFollowRelations', () => { + it('should return follow relations for given user IDs', async () => { + const userId = BigInt(1); + const userIds = [BigInt(2), BigInt(3), BigInt(4)]; + + const mockRelations = [ + { followerId: userId, followedId: BigInt(2) }, + { followerId: BigInt(3), followedId: userId }, + ]; + + mockRepository.getUserFollowRelations = jest.fn().mockResolvedValue(mockRelations); + + const result = await service.getUserFollowRelations(userId, userIds); + + expect(mockRepository.getUserFollowRelations).toHaveBeenCalledWith(userId, userIds); + expect(result).toEqual(mockRelations); + }); + }); + + describe('getUserRelationship', () => { + it('should return relationship data for a target user', async () => { + const userId = BigInt(1); + const targetUsername = 'targetuser'; + + const targetUser = { + id: BigInt(2), + username: targetUsername, + email: 'target@example.com', + }; + + const mockRelationship: UserRelationshipDto = { + following: true, + follower: false, + blocking: false, + blockedBy: false, + muted: false, + }; + + const mockRelationMap = new Map([[BigInt(2), mockRelationship]]); + + mockRepository.findByUsername = jest.fn().mockResolvedValue(targetUser); + mockRepository.getUsersRelationshipsMap = jest.fn().mockResolvedValue(mockRelationMap); + + const result = await service.getUserRelationship(userId, targetUsername); + + expect(mockRepository.findByUsername).toHaveBeenCalledWith(targetUsername); + expect(mockRepository.getUsersRelationshipsMap).toHaveBeenCalledWith(userId, [BigInt(2)]); + expect(result).toEqual(mockRelationship); + }); + + it('should throw NOT_FOUND if target user does not exist', async () => { + const userId = BigInt(1); + const targetUsername = 'nonexistent'; + + mockRepository.findByUsername = jest.fn().mockResolvedValue(null); + + await expect(service.getUserRelationship(userId, targetUsername)).rejects.toThrow( + HttpException, + ); + + await expect(service.getUserRelationship(userId, targetUsername)).rejects.toMatchObject({ + response: { + message: USERS_ERROR_MESSAGES.USER_NOT_FOUND, + code: USERS_ERROR_CODES.USER_NOT_FOUND, + }, + status: HttpStatus.NOT_FOUND, + }); + }); + + it('should return null if no relationship exists', async () => { + const userId = BigInt(1); + const targetUsername = 'targetuser'; + + const targetUser = { + id: BigInt(2), + username: targetUsername, + email: 'target@example.com', + }; + + const mockRelationMap = new Map(); + + mockRepository.findByUsername = jest.fn().mockResolvedValue(targetUser); + mockRepository.getUsersRelationshipsMap = jest.fn().mockResolvedValue(mockRelationMap); + + const result = await service.getUserRelationship(userId, targetUsername); + + expect(result).toBeNull(); + }); + }); + + describe('searchUsers', () => { + it('should search users with query', async () => { + const currentUserId = BigInt(1); + const query = 'test'; + const limit = 20; + const decodedCursor = undefined; + const excludeMutedAndBlocked = false; + + const mockSearchResults = [ + { id: BigInt(2), username: 'testuser1' }, + { id: BigInt(3), username: 'testuser2' }, + ]; + + mockRepository.searchUsers = jest.fn().mockResolvedValue(mockSearchResults); + + const result = await service.searchUsers( + currentUserId, + query, + limit, + decodedCursor, + excludeMutedAndBlocked, + ); + + expect(mockRepository.searchUsers).toHaveBeenCalled(); + expect(result).toEqual(mockSearchResults); + }); + + it('should search users with people filter', async () => { + const currentUserId = BigInt(1); + const query = 'test'; + const limit = 20; + const decodedCursor = undefined; + const excludeMutedAndBlocked = true; + const peopleFilter = PeopleSearchFilter.Anyone; + + const mockSearchResults = [{ id: BigInt(2), username: 'verified_user' }]; + + mockRepository.searchUsers = jest.fn().mockResolvedValue(mockSearchResults); + + const result = await service.searchUsers( + currentUserId, + query, + limit, + decodedCursor, + excludeMutedAndBlocked, + peopleFilter, + ); + + expect(mockRepository.searchUsers).toHaveBeenCalled(); + expect(result).toEqual(mockSearchResults); + }); + }); + + describe('getUsersRelationshipsMap', () => { + it('should return relationships map for multiple users', async () => { + const currentUserId = BigInt(1); + const userIds = [BigInt(2), BigInt(3), BigInt(4)]; + + const mockRelationMap = new Map([ + [ + BigInt(2), + { following: true, follower: false, blocking: false, blockedBy: false, muted: false }, + ], + [ + BigInt(3), + { following: false, follower: true, blocking: false, blockedBy: false, muted: false }, + ], + [ + BigInt(4), + { following: false, follower: false, blocking: false, blockedBy: false, muted: true }, + ], + ]); + + mockRepository.getUsersRelationshipsMap = jest.fn().mockResolvedValue(mockRelationMap); + + const result = await service.getUsersRelationshipsMap(currentUserId, userIds); + + expect(mockRepository.getUsersRelationshipsMap).toHaveBeenCalledWith(currentUserId, userIds); + expect(result).toEqual(mockRelationMap); + }); + }); + + describe('getFollowersIds', () => { + it('should return array of follower IDs', async () => { + const userId = BigInt(1); + const mockFollowerIds = [BigInt(2), BigInt(3), BigInt(4)]; + + mockRepository.getFollowersUnPaginated = jest.fn().mockResolvedValue(mockFollowerIds); + + const result = await service.getFollowersIds(userId); + + expect(mockRepository.getFollowersUnPaginated).toHaveBeenCalledWith(userId); + expect(result).toEqual(mockFollowerIds); + }); + }); + + describe('enableUserNotifications', () => { + it('should enable notifications for a user', async () => { + const userId = BigInt(1); + const username = 'targetuser'; + + const targetUser = { + id: BigInt(2), + username, + email: 'target@example.com', + }; + + mockRepository.findByUsername = jest.fn().mockResolvedValue(targetUser); + mockRepository.toggleUserNotifications = jest.fn().mockResolvedValue(undefined); + + const result = await service.enableUserNotifications(userId, username); + + expect(mockRepository.findByUsername).toHaveBeenCalledWith(username); + expect(mockRepository.toggleUserNotifications).toHaveBeenCalledWith( + userId, + targetUser.id, + true, + ); + expect(result).toEqual({ message: 'Notifications enabled for user successfully' }); + }); + + it('should throw NOT_FOUND if user does not exist', async () => { + const userId = BigInt(1); + const username = 'nonexistent'; + + mockRepository.findByUsername = jest.fn().mockResolvedValue(null); + + await expect(service.enableUserNotifications(userId, username)).rejects.toThrow( + HttpException, + ); + + await expect(service.enableUserNotifications(userId, username)).rejects.toMatchObject({ + response: { + message: USERS_ERROR_MESSAGES.USER_NOT_FOUND, + code: USERS_ERROR_CODES.USER_NOT_FOUND, + }, + status: HttpStatus.NOT_FOUND, + }); + }); + }); + + describe('disableUserNotifications', () => { + it('should disable notifications for a user', async () => { + const userId = BigInt(1); + const username = 'targetuser'; + + const targetUser = { + id: BigInt(2), + username, + email: 'target@example.com', + }; + + mockRepository.findByUsername = jest.fn().mockResolvedValue(targetUser); + mockRepository.toggleUserNotifications = jest.fn().mockResolvedValue(undefined); + + const result = await service.disableUserNotifications(userId, username); + + expect(mockRepository.findByUsername).toHaveBeenCalledWith(username); + expect(mockRepository.toggleUserNotifications).toHaveBeenCalledWith( + userId, + targetUser.id, + false, + ); + expect(result).toEqual({ message: 'Notifications disabled for user successfully' }); + }); + + it('should throw NOT_FOUND if user does not exist', async () => { + const userId = BigInt(1); + const username = 'nonexistent'; + + mockRepository.findByUsername = jest.fn().mockResolvedValue(null); + + await expect(service.disableUserNotifications(userId, username)).rejects.toThrow( + HttpException, + ); + + await expect(service.disableUserNotifications(userId, username)).rejects.toMatchObject({ + response: { + message: USERS_ERROR_MESSAGES.USER_NOT_FOUND, + code: USERS_ERROR_CODES.USER_NOT_FOUND, + }, + status: HttpStatus.NOT_FOUND, + }); + }); + }); + + describe('invalidateUserCache', () => { + it('should invalidate user cache in Redis', async () => { + const userId = BigInt(1); + + await service.invalidateUserCache(userId); + + // Verify the method completes without errors + expect(true).toBe(true); + }); + }); + + describe('getUserById', () => { + it('should return user by ID', async () => { + const userId = BigInt(1); + const mockUser = { + id: userId, + username: 'testuser', + displayName: 'Test User', + }; + + mockRepository.findUsernameAndDisplayNameById = jest.fn().mockResolvedValue(mockUser); + + const result = await service.getUserById(userId); + + expect(mockRepository.findUsernameAndDisplayNameById).toHaveBeenCalledWith(userId); + expect(result).toEqual(mockUser); + }); + + it('should throw NOT_FOUND if user does not exist', async () => { + const userId = BigInt(999); + + mockRepository.findUsernameAndDisplayNameById = jest.fn().mockResolvedValue(null); + + await expect(service.getUserById(userId)).rejects.toThrow(HttpException); + + await expect(service.getUserById(userId)).rejects.toMatchObject({ + response: { + message: USERS_ERROR_MESSAGES.USER_NOT_FOUND, + code: USERS_ERROR_CODES.USER_NOT_FOUND, + }, + status: HttpStatus.NOT_FOUND, + }); + }); + }); }); From ebc816f98842e35f0314a6bde4dc87e4fe18e053 Mon Sep 17 00:00:00 2001 From: Ahmed Sobhy <48056730+AhmedSobhy01@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:35:40 +0200 Subject: [PATCH 32/43] feat: add readme & terms of services & privacy policy - [CU-869bfzfx7] (#214) --- README.md | 394 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 296 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index d30c9464..4da14b05 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,296 @@ -

- Nest Logo -

- -[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 -[circleci-url]: https://circleci.com/gh/nestjs/nest - -

A progressive Node.js framework for building efficient and scalable server-side applications.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Discord -Backers on Open Collective -Sponsors on Open Collective - Donate us - Support us - Follow us on Twitter -

- - -## Description - -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. - -## Project setup - -```bash -$ pnpm install -``` - -## Compile and run the project - -```bash -# development -$ pnpm run start - -# watch mode -$ pnpm run start:dev - -# production mode -$ pnpm run start:prod -``` - -## Run tests - -```bash -# unit tests -$ pnpm run test - -# e2e tests -$ pnpm run test:e2e - -# test coverage -$ pnpm run test:cov -``` - -## Deployment - -When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. - -If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: - -```bash -$ pnpm install -g @nestjs/mau -$ mau deploy -``` - -With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. - -## Resources - -Check out a few resources that may come in handy when working with NestJS: - -- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. -- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). -- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). -- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. -- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). -- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). -- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). -- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). - -## Support - -Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). - -## Stay in touch - -- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) -- Website - [https://nestjs.com](https://nestjs.com/) -- Twitter - [@nestframework](https://twitter.com/nestframework) - -## License - -Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). +
+ Raven Logo + +# Raven Backend + + **Caw Your Thoughts** + + A scalable, production-ready RESTful API backend for the Raven social media platform. + + [![NestJS](https://img.shields.io/badge/NestJS-11.0-E0234E?logo=nestjs)](https://nestjs.com/) + [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?logo=typescript)](https://www.typescriptlang.org/) + [![Prisma](https://img.shields.io/badge/Prisma-6.17-2D3748?logo=prisma)](https://www.prisma.io/) + [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791?logo=postgresql)](https://www.postgresql.org/) +
+ +## Features + +### Authentication & Security + +- **Email/Password Authentication** - Secure sign-up and sign-in with bcrypt password hashing +- **OAuth Integration** - Google and GitHub OAuth 2.0 authentication support +- **JWT Token Management** - Access and refresh token flow with secure token storage +- **Session Management** - Track active sessions with device fingerprinting and IP logging +- **Multi-Device Support** - Register and manage multiple devices per user +- **Password Recovery** - Forgot password flow with email-based reset tokens +- **reCAPTCHA** - Google reCAPTCHA integration for bot protection +- **Rate Limiting** - Global throttling to prevent abuse + +### Tweets & Posts + +- **Tweet Creation** - Rich text posts with support for: + - Hashtag detection and parsing + - User mention detection and parsing + - Up to 4 media attachments per tweet (images, videos, GIFs) +- **Reply Threads** - Nested conversation threads with root tweet tracking +- **Quote Tweets** - Retweet with commentary +- **Retweets** - Share tweets with followers +- **Likes** - Like/unlike functionality with optimized count tracking +- **Tweet Deletion** - Soft delete with cascade handling +- **Content Classification** - AI-powered tweet categorization (News, Sports, Entertainment) + +### Direct Messaging + +- **Real-time Chat** - WebSocket-powered instant messaging via Socket.IO +- **Conversation Management** - Create and manage one-on-one conversations +- **Message Features**: + - Text messages + - Image attachments + - Emoji reactions (sender and receiver) + - Message deletion (per-participant) + - Read receipts via last seen tracking + - Typing Indicator +- **WebSocket Scaling** - Redis adapter for horizontal scaling + +### Timeline & Feed + +- **Home Timeline** - Personalized feed with posts from followed users +- **Real-time Updates** - Server-Sent Events (SSE) for live feed updates +- **Cursor-based Pagination** - Efficient infinite scrolling implementation +- **For You Feed** - Algorithmic content discovery based on user interests + +### Explore & Discovery + +- **Trending Topics** - Real-time trending hashtags and keywords +- **Category-based Trending** - Trending content by category (News, Sports, Entertainment, General) +- **Explore Feed** - Curated content discovery + +### Search + +- **Full-text Search** - PostgreSQL full-text search capabilities +- **User Search** - Find users by username or display name +- **Tweet Search** - Search tweets by content +- **Hashtag Search** - Search by hashtag keywords + +### User Management + +- **Profile Customization** - Display name, bio, location, website +- **Profile Images** - Avatar and banner image upload +- **Following/Followers** - Social graph management with counter caching +- **Block System** - Block users with bidirectional visibility prevention +- **Mute System** - Mute users to hide their content +- **User Interests** - Personalization based on selected interests + +### Notifications + +- **Push Notifications** - Firebase Cloud Messaging (FCM) integration +- **In-App Notifications** - Real-time notification feed via SSE +- **Notification Types**: + - Follow notifications + - Like notifications + - Retweet notifications + - Quote notifications + - Reply notifications + - Mention notifications + - Direct message notifications +- **Notification Aggregation** - Group similar notifications +- **Delivery Tracking** - Track notification delivery status to devices + +### Media + +- **File Upload** - Support for image and video uploads +- **S3 Storage** - AWS S3 compatible object storage +- **Image Processing** - Sharp-based image optimization and resizing +- **Media Association** - Attach media to tweets and messages + +### AI Features + +- **Tweet Classification** - Groq-powered content categorization +- **Smart Analysis** - Automated content analysis for trending topics + +### Infrastructure + +- **Background Jobs** - BullMQ for reliable job processing +- **Event-Driven Architecture** - NestJS Event Emitter for decoupled communication +- **Scheduled Tasks** - Cron-based scheduled jobs +- **Health Checks** - Health endpoint for monitoring +- **Structured Logging** - Winston-based logging with multiple transports + +## Tech Stack + +### Core + +- **NestJS** 11.0 - Progressive Node.js framework +- **TypeScript** 5.7 - Type-safe JavaScript +- **Prisma** 6.17 - Type-safe ORM +- **PostgreSQL** - Primary database with full-text search + +### Authentication + +- **Passport.js** - Authentication middleware +- **JWT** - Token-based authentication +- **bcrypt** - Password hashing + +### Real-time + +- **Socket.IO** - WebSocket server for messaging +- **Redis** - Pub/Sub for WebSocket scaling and caching +- **SSE** - Server-Sent Events for live updates + +### Storage & Media + +- **AWS S3** - Object storage for media files +- **Sharp** - High-performance image processing + +### Background Processing + +- **BullMQ** - Redis-based job queue +- **Node Schedule** - Cron-like job scheduling + +### External Services + +- **Firebase Admin** - Push notification delivery +- **Nodemailer** - Email sending +- **Google Auth Library** - OAuth verification +- **Groq SDK** - AI-powered content analysis + +### Testing + +- **Jest** - Unit and integration testing +- **SuperTest** - HTTP assertion library +- **Jest Mock Extended** - Enhanced mocking utilities + +## Getting Started + +### Prerequisites + +- Node.js 22+ +- pnpm +- PostgreSQL 15+ +- Redis 7+ + +### Installation + +1. Clone the repository: + + ```bash + git clone https://github.com/OmarGamal10/raven-backend + cd raven-backend + ``` + +2. Install dependencies: + + ```bash + pnpm install + ``` + +3. Set up environment variables: + + ```bash + cp .env.example .env + ``` + + Edit `.env` with your configuration values. + +4. Generate Prisma client: + + ```bash + pnpm db:generate + ``` + +5. Run database migrations: + + ```bash + pnpm db:migrate + ``` + +6. Seed the database (optional): + + ```bash + pnpm db:seed + ``` + +7. Start the development server: + + ```bash + pnpm dev + ``` + +## Scripts + +| Command | Description | +|---------|-------------| +| `pnpm dev` | Start development server with hot reload | +| `pnpm start` | Start the server | +| `pnpm start:prod` | Start production server | +| `pnpm build` | Build for production | +| `pnpm test` | Run unit tests | +| `pnpm test:cov` | Run tests with coverage | +| `pnpm test:e2e` | Run end-to-end tests | +| `pnpm db:migrate` | Deploy database migrations | +| `pnpm db:create` | Create new migration | +| `pnpm db:generate` | Generate Prisma client | +| `pnpm db:reset` | Reset database | +| `pnpm db:seed` | Seed database | + +## API Documentation + +The API specification is available in TypeSpec format under the `api-spec/` directory: + +- **Implemented Spec** - Current implemented API endpoints +- **Complete Spec** - Full API specification including planned features + +Generate OpenAPI documentation: + +```bash +pnpm spec:generate +``` + +## Docker + +Build the Docker image: + +```bash +docker build -t raven-backend . +``` + +Run with Docker Compose (development): + +```bash +docker-compose -f docker-compose.dev.yml up +``` + +## Configuration + +### Environment Variables + +Key environment variables (see `.env.example` for full list): + +| Variable | Description | +|----------|-------------| +| `DATABASE_URL` | PostgreSQL connection string | +| `REDIS_URL` | Redis connection string | +| `JWT_SECRET` | Secret for JWT signing | +| `JWT_REFRESH_SECRET` | Secret for refresh token signing | +| `AWS_ACCESS_KEY_ID` | AWS credentials for S3 | +| `AWS_SECRET_ACCESS_KEY` | AWS credentials for S3 | +| `S3_BUCKET` | S3 bucket name | +| `FIREBASE_PROJECT_ID` | Firebase project for push notifications | +| `GOOGLE_CLIENT_ID` | Google OAuth client ID | +| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | +| `GROQ_API_KEY` | Groq API key for AI features | +| `SMTP_*` | Email configuration | +| `RECAPTCHA_SECRET_KEY` | reCAPTCHA secret key | + +## Code Quality + +The project enforces code quality with: + +- **ESLint** - Linting with TypeScript rules +- **Prettier** - Code formatting +- **Husky** - Git hooks for pre-commit checks +- **TypeScript** - Strict type checking + +## License + +This project is licensed under the [MIT License](LICENSE). From d3318bbe744cf26f2f249d7c64692d7dea7471bf Mon Sep 17 00:00:00 2001 From: anas-ibrahem <139391509+anas-ibrahem@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:39:42 +0200 Subject: [PATCH 33/43] test: follow suggestions - [CU-869bgfnyh] (#223) Signed-off-by: Tasneemmhammed0 Co-authored-by: Tasneemmhammed0 --- bruno/collections/collection.bru | 7 + test/auth/onboarding.controller.spec.ts | 342 ++++++++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 bruno/collections/collection.bru create mode 100644 test/auth/onboarding.controller.spec.ts diff --git a/bruno/collections/collection.bru b/bruno/collections/collection.bru new file mode 100644 index 00000000..084fe296 --- /dev/null +++ b/bruno/collections/collection.bru @@ -0,0 +1,7 @@ +auth { + mode: bearer +} + +auth:bearer { + token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjMiLCJpYXQiOjE3NjU4MjQ4ODQsImV4cCI6MTc2NTgyNTc4NH0.7jgvZUi-f-eruIChPHnDdN7osU0XLWbLK3Hv9T0tT2I +} diff --git a/test/auth/onboarding.controller.spec.ts b/test/auth/onboarding.controller.spec.ts new file mode 100644 index 00000000..62f47b12 --- /dev/null +++ b/test/auth/onboarding.controller.spec.ts @@ -0,0 +1,342 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OnboardingController } from 'src/auth/onboarding.controller'; +import { UsersRepository } from 'src/users/users.repository'; +import { ONBOARDING_CONSTANTS } from 'src/auth/constants'; +import type { RequestUser } from 'src/common/interfaces'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { USERS_ERROR_CODES, USERS_ERROR_MESSAGES } from 'src/users/constants'; +import * as commonUtils from 'src/common/utils'; + +jest.mock('src/common/utils', () => ({ + generateUsernames: jest.fn(), +})); + +describe('OnboardingController', () => { + let controller: OnboardingController; + let usersRepository: jest.Mocked; + + const mockUsersRepository = { + getOnboardingFollowSuggestions: jest.fn(), + getUserEmailAndDisplayName: jest.fn(), + getUserByUsername: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [OnboardingController], + providers: [ + { + provide: UsersRepository, + useValue: mockUsersRepository, + }, + ], + }).compile(); + + controller = module.get(OnboardingController); + usersRepository = module.get(UsersRepository); + + jest.clearAllMocks(); + }); + + describe('getUsernameSuggestions', () => { + const mockUser: RequestUser = { + id: '123', + }; + + const mockExistingUser = { + email: 'test@example.com', + profile: { + displayName: 'Test User', + }, + }; + + it('should return username suggestions with display name', async () => { + const mockSuggestions = ['testuser1', 'testuser2', 'testuser3']; + usersRepository.getUserEmailAndDisplayName.mockResolvedValue(mockExistingUser); + (commonUtils.generateUsernames as jest.Mock).mockResolvedValue(mockSuggestions); + + const result = await controller.getUsernameSuggestions(mockUser); + + expect(result).toEqual({ suggestions: mockSuggestions }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getUserEmailAndDisplayName).toHaveBeenCalledWith(BigInt(123)); + expect(commonUtils.generateUsernames).toHaveBeenCalledWith( + usersRepository, + 'Test User', + 'test@example.com', + undefined, + ONBOARDING_CONSTANTS.USERNAME_SUGGESTIONS_COUNT, + false, + ); + }); + + it('should return username suggestions with typed parameter', async () => { + const mockSuggestions = ['myname1', 'myname2', 'myname3']; + const typed = 'myname'; + usersRepository.getUserEmailAndDisplayName.mockResolvedValue(mockExistingUser); + (commonUtils.generateUsernames as jest.Mock).mockResolvedValue(mockSuggestions); + + const result = await controller.getUsernameSuggestions(mockUser, typed); + + expect(result).toEqual({ suggestions: mockSuggestions }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getUserEmailAndDisplayName).toHaveBeenCalledWith(BigInt(123)); + expect(commonUtils.generateUsernames).toHaveBeenCalledWith( + usersRepository, + 'Test User', + 'test@example.com', + 'myname', + ONBOARDING_CONSTANTS.USERNAME_SUGGESTIONS_COUNT, + false, + ); + }); + + it('should handle user with no profile (empty display name)', async () => { + const mockSuggestions = ['test1', 'test2', 'test3']; + const userWithoutProfile = { + email: 'test@example.com', + profile: null, + }; + usersRepository.getUserEmailAndDisplayName.mockResolvedValue(userWithoutProfile); + (commonUtils.generateUsernames as jest.Mock).mockResolvedValue(mockSuggestions); + + const result = await controller.getUsernameSuggestions(mockUser); + + expect(result).toEqual({ suggestions: mockSuggestions }); + expect(commonUtils.generateUsernames).toHaveBeenCalledWith( + usersRepository, + '', + 'test@example.com', + undefined, + ONBOARDING_CONSTANTS.USERNAME_SUGGESTIONS_COUNT, + false, + ); + }); + + it('should handle user with profile but no display name', async () => { + const mockSuggestions = ['test1', 'test2', 'test3']; + const userWithNoDisplayName = { + email: 'test@example.com', + profile: { + displayName: '', + }, + }; + usersRepository.getUserEmailAndDisplayName.mockResolvedValue(userWithNoDisplayName); + (commonUtils.generateUsernames as jest.Mock).mockResolvedValue(mockSuggestions); + + const result = await controller.getUsernameSuggestions(mockUser); + + expect(result).toEqual({ suggestions: mockSuggestions }); + expect(commonUtils.generateUsernames).toHaveBeenCalledWith( + usersRepository, + '', + 'test@example.com', + undefined, + ONBOARDING_CONSTANTS.USERNAME_SUGGESTIONS_COUNT, + false, + ); + }); + + it('should throw UNAUTHORIZED exception when user not found', async () => { + usersRepository.getUserEmailAndDisplayName.mockResolvedValue(null); + + await expect(controller.getUsernameSuggestions(mockUser)).rejects.toThrow( + new HttpException( + { + message: USERS_ERROR_MESSAGES.USER_NOT_FOUND, + code: USERS_ERROR_CODES.USER_NOT_FOUND, + }, + HttpStatus.UNAUTHORIZED, + ), + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getUserEmailAndDisplayName).toHaveBeenCalledWith(BigInt(123)); + expect(commonUtils.generateUsernames).not.toHaveBeenCalled(); + }); + + it('should correctly convert user id to BigInt', async () => { + const mockSuggestions = ['testuser1', 'testuser2', 'testuser3']; + const userWithBigId: RequestUser = { + id: '999999999999', + }; + usersRepository.getUserEmailAndDisplayName.mockResolvedValue(mockExistingUser); + (commonUtils.generateUsernames as jest.Mock).mockResolvedValue(mockSuggestions); + + await controller.getUsernameSuggestions(userWithBigId); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getUserEmailAndDisplayName).toHaveBeenCalledWith( + BigInt('999999999999'), + ); + }); + + it('should handle repository errors', async () => { + const error = new Error('Database error'); + usersRepository.getUserEmailAndDisplayName.mockRejectedValue(error); + + await expect(controller.getUsernameSuggestions(mockUser)).rejects.toThrow('Database error'); + }); + }); + + describe('getFollowSuggestions', () => { + const mockUser: RequestUser = { + id: '123', + }; + + const mockSuggestions = [ + { + id: '1', + username: 'user1', + displayName: 'User One', + avatarUrl: null, + bio: null, + bioEntities: null, + relationship: { isFollower: false }, + }, + { + id: '2', + username: 'user2', + displayName: 'User Two', + avatarUrl: null, + bio: null, + bioEntities: null, + relationship: { isFollower: false }, + }, + { + id: '3', + username: 'user3', + displayName: 'User Three', + avatarUrl: null, + bio: null, + bioEntities: null, + relationship: { isFollower: false }, + }, + ]; + + it('should return follow suggestions with default limit', async () => { + usersRepository.getOnboardingFollowSuggestions.mockResolvedValue(mockSuggestions); + + const result = await controller.getFollowSuggestions(mockUser); + + expect(result).toEqual({ suggestions: mockSuggestions }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getOnboardingFollowSuggestions).toHaveBeenCalledWith( + BigInt(123), + ONBOARDING_CONSTANTS.MAX_FOLLOW_SUGGESTIONS_COUNT, + ); + }); + + it('should return follow suggestions with custom valid limit', async () => { + const customLimit = '10'; + usersRepository.getOnboardingFollowSuggestions.mockResolvedValue(mockSuggestions); + + const result = await controller.getFollowSuggestions(mockUser, customLimit); + + expect(result).toEqual({ suggestions: mockSuggestions }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getOnboardingFollowSuggestions).toHaveBeenCalledWith(BigInt(123), 10); + }); + + it('should cap limit at MAX_FOLLOW_SUGGESTIONS_COUNT when limit exceeds maximum', async () => { + const exceedingLimit = '100'; + usersRepository.getOnboardingFollowSuggestions.mockResolvedValue(mockSuggestions); + + const result = await controller.getFollowSuggestions(mockUser, exceedingLimit); + + expect(result).toEqual({ suggestions: mockSuggestions }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getOnboardingFollowSuggestions).toHaveBeenCalledWith( + BigInt(123), + ONBOARDING_CONSTANTS.MAX_FOLLOW_SUGGESTIONS_COUNT, + ); + }); + + it('should use default limit when limit is invalid (NaN)', async () => { + const invalidLimit = 'invalid'; + usersRepository.getOnboardingFollowSuggestions.mockResolvedValue(mockSuggestions); + + const result = await controller.getFollowSuggestions(mockUser, invalidLimit); + + expect(result).toEqual({ suggestions: mockSuggestions }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getOnboardingFollowSuggestions).toHaveBeenCalledWith( + BigInt(123), + ONBOARDING_CONSTANTS.MAX_FOLLOW_SUGGESTIONS_COUNT, + ); + }); + + it('should use default limit when limit is zero', async () => { + const zeroLimit = '0'; + usersRepository.getOnboardingFollowSuggestions.mockResolvedValue(mockSuggestions); + + const result = await controller.getFollowSuggestions(mockUser, zeroLimit); + + expect(result).toEqual({ suggestions: mockSuggestions }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getOnboardingFollowSuggestions).toHaveBeenCalledWith( + BigInt(123), + ONBOARDING_CONSTANTS.MAX_FOLLOW_SUGGESTIONS_COUNT, + ); + }); + + it('should use default limit when limit is negative', async () => { + const negativeLimit = '-5'; + usersRepository.getOnboardingFollowSuggestions.mockResolvedValue(mockSuggestions); + + const result = await controller.getFollowSuggestions(mockUser, negativeLimit); + + expect(result).toEqual({ suggestions: mockSuggestions }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getOnboardingFollowSuggestions).toHaveBeenCalledWith( + BigInt(123), + ONBOARDING_CONSTANTS.MAX_FOLLOW_SUGGESTIONS_COUNT, + ); + }); + + it('should handle empty suggestions array', async () => { + usersRepository.getOnboardingFollowSuggestions.mockResolvedValue([]); + + const result = await controller.getFollowSuggestions(mockUser); + + expect(result).toEqual({ suggestions: [] }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getOnboardingFollowSuggestions).toHaveBeenCalledWith( + BigInt(123), + ONBOARDING_CONSTANTS.MAX_FOLLOW_SUGGESTIONS_COUNT, + ); + }); + + it('should correctly convert user id to BigInt', async () => { + const userWithStringId: RequestUser = { + id: '999999999999', + }; + usersRepository.getOnboardingFollowSuggestions.mockResolvedValue(mockSuggestions); + + await controller.getFollowSuggestions(userWithStringId); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getOnboardingFollowSuggestions).toHaveBeenCalledWith( + BigInt('999999999999'), + ONBOARDING_CONSTANTS.MAX_FOLLOW_SUGGESTIONS_COUNT, + ); + }); + + it('should handle limit as string "1"', async () => { + const limit = '1'; + usersRepository.getOnboardingFollowSuggestions.mockResolvedValue([mockSuggestions[0]]); + + const result = await controller.getFollowSuggestions(mockUser, limit); + + expect(result).toEqual({ suggestions: [mockSuggestions[0]] }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(usersRepository.getOnboardingFollowSuggestions).toHaveBeenCalledWith(BigInt(123), 1); + }); + + it('should handle repository errors', async () => { + const error = new Error('Database error'); + usersRepository.getOnboardingFollowSuggestions.mockRejectedValue(error); + + await expect(controller.getFollowSuggestions(mockUser)).rejects.toThrow('Database error'); + }); + }); +}); From ac90ee13ad49dbd8fc8d965dc6f16991758033be Mon Sep 17 00:00:00 2001 From: anas-ibrahem <139391509+anas-ibrahem@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:40:16 +0200 Subject: [PATCH 34/43] fix: classification lock - [CU-869bgfh4m] (#213) --- src/tweet-analyze/tweet-analyze.service.ts | 200 +++++++++++++++------ 1 file changed, 141 insertions(+), 59 deletions(-) diff --git a/src/tweet-analyze/tweet-analyze.service.ts b/src/tweet-analyze/tweet-analyze.service.ts index f37f02ef..e971afbc 100644 --- a/src/tweet-analyze/tweet-analyze.service.ts +++ b/src/tweet-analyze/tweet-analyze.service.ts @@ -22,6 +22,9 @@ export class TweetAnalyzeService implements OnModuleInit { private readonly analyzeApiUrl: string; private readonly LOCK_KEY = 'tweet-analyze:lock'; private readonly LOCK_TTL_SECONDS = 300; // 5 minutes + private readonly LOCK_EXTENSION_INTERVAL = 60; // Extend lock every 1 minute + private readonly LIMIT_PER_JOB = 100; // Max tweets to process per job run + private lockExtensionInterval: NodeJS.Timeout | null = null; constructor( private readonly configService: ConfigService, @@ -48,6 +51,15 @@ export class TweetAnalyzeService implements OnModuleInit { const intervalMs = this.intervalMinutes * 60 * 1000; this.logger.log(`Starting tweet analysis cron job (interval: ${this.intervalMinutes} min)`); + // Run first job immediately + this.analyzeTweets().catch((error) => { + this.logger.error( + 'Initial tweet analysis failed', + error instanceof Error ? error.stack : String(error), + ); + }); + + // Schedule periodic jobs setInterval(() => { this.analyzeTweets().catch((error) => { this.logger.error( @@ -76,82 +88,125 @@ export class TweetAnalyzeService implements OnModuleInit { this.logger.log('=== Starting Tweet Analysis Job ==='); try { - const tweetsToAnalyze = await this.getTweetsToAnalyze(); - - if (tweetsToAnalyze.length === 0) { - this.logger.log('No tweets to analyze'); - return; - } - - this.logger.log(`Retrieved ${tweetsToAnalyze.length} tweets for analysis`); + // Start lock extension mechanism + this.startLockExtension(); + + let totalProcessedAcrossRuns = 0; + let runNumber = 0; + + // Keep processing until no more tweets remain + while (true) { + runNumber++; + const tweetsToAnalyze = await this.getTweetsToAnalyze(); + + if (tweetsToAnalyze.length === 0) { + if (runNumber === 1) { + this.logger.log('No tweets to analyze'); + } else { + this.logger.log( + `All tweets processed across ${runNumber - 1} run(s) (${totalProcessedAcrossRuns} total tweets)`, + ); + } + break; + } - const batches = this.splitIntoBatches(tweetsToAnalyze, this.requestLimit); + // Apply limit per job run + const tweetsToProcess = + tweetsToAnalyze.length > this.LIMIT_PER_JOB + ? tweetsToAnalyze.slice(0, this.LIMIT_PER_JOB) + : tweetsToAnalyze; - this.logger.log( - `Split into ${batches.length} batch(es) (limit: ${this.requestLimit} tweets/batch)`, - ); + const hasMoreTweets = tweetsToAnalyze.length > this.LIMIT_PER_JOB; - let totalAnalyzedTweets = 0; - let allBatchesSucceeded = true; + this.logger.log(`--- Starting run ${runNumber} ---`); - // Accumulate trending data across all batches - const accumulatedTrendingKeywords: TrendingKeyword[] = []; - let totalTweetsInAllBatches = 0; + if (hasMoreTweets) { + this.logger.log( + `Retrieved ${tweetsToAnalyze.length} tweets, processing ${this.LIMIT_PER_JOB} in this run`, + ); + } else { + this.logger.log(`Retrieved ${tweetsToAnalyze.length} tweets for analysis`); + } - for (let i = 0; i < batches.length; i++) { - const batch = batches[i]; - this.logger.log(`Processing batch ${i + 1}/${batches.length} (${batch.length} tweets)`); + const batches = this.splitIntoBatches(tweetsToProcess, this.requestLimit); - try { - const batchResult = await this.processBatch(batch); - totalAnalyzedTweets += batchResult.analyzedTweetsCount; + this.logger.log( + `Split into ${batches.length} batch(es) (limit: ${this.requestLimit} tweets/batch)`, + ); - // Accumulate trending keywords from this batch - if (batchResult.trending_keywords) { - accumulatedTrendingKeywords.push(...batchResult.trending_keywords); - } - if (batchResult.batch_meta) { - totalTweetsInAllBatches += batchResult.batch_meta.total_tweets; + let runAnalyzedTweets = 0; + let allBatchesSucceeded = true; + + // Accumulate trending data across all batches in this run + const accumulatedTrendingKeywords: TrendingKeyword[] = []; + let totalTweetsInRun = 0; + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + this.logger.log(`Processing batch ${i + 1}/${batches.length} (${batch.length} tweets)`); + + try { + const batchResult = await this.processBatch(batch); + runAnalyzedTweets += batchResult.analyzedTweetsCount; + + // Accumulate trending keywords from this batch + if (batchResult.trending_keywords) { + accumulatedTrendingKeywords.push(...batchResult.trending_keywords); + } + if (batchResult.batch_meta) { + totalTweetsInRun += batchResult.batch_meta.total_tweets; + } + + this.logger.log( + `Batch ${i + 1}/${batches.length} completed ` + + `(analyzed: ${batchResult.analyzedTweetsCount}, keywords: ${batchResult.trending_keywords?.length || 0})`, + ); + } catch (error) { + this.logger.error( + `Batch ${i + 1}/${batches.length} failed`, + error instanceof Error ? error.stack : String(error), + ); + this.logger.warn( + `Stopping after batch ${i + 1} failure. ` + + `${batches.length - i - 1} batch(es) will retry in next job`, + ); + allBatchesSucceeded = false; + break; } + } + // Update trending scores for this run + if (accumulatedTrendingKeywords.length > 0) { this.logger.log( - `Batch ${i + 1}/${batches.length} completed ` + - `(analyzed: ${batchResult.analyzedTweetsCount}, keywords: ${batchResult.trending_keywords?.length || 0})`, - ); - } catch (error) { - this.logger.error( - `Batch ${i + 1}/${batches.length} failed`, - error instanceof Error ? error.stack : String(error), + `Updating trending scores with ${accumulatedTrendingKeywords.length} keywords from run ${runNumber}`, ); + await this.trendingService.updateTrendScores({ + batch_meta: { total_tweets: totalTweetsInRun }, + trending_keywords: accumulatedTrendingKeywords, + }); + this.logger.log('Trending scores updated successfully'); + } + + totalProcessedAcrossRuns += runAnalyzedTweets; + + if (!allBatchesSucceeded) { this.logger.warn( - `Stopping after batch ${i + 1} failure. ` + - `${batches.length - i - 1} batch(es) will retry in next run`, + `Run ${runNumber} stopped due to failure (${runAnalyzedTweets} tweets processed in this run)`, ); - allBatchesSucceeded = false; break; } - } - // Update trending scores once with accumulated data from all batches - if (accumulatedTrendingKeywords.length > 0) { - this.logger.log( - `Updating trending scores with ${accumulatedTrendingKeywords.length} accumulated keywords`, - ); - await this.trendingService.updateTrendScores({ - batch_meta: { total_tweets: totalTweetsInAllBatches }, - trending_keywords: accumulatedTrendingKeywords, - }); - this.logger.log('Trending scores updated successfully'); - } + this.logger.log(`Run ${runNumber} completed successfully (${runAnalyzedTweets} tweets)`); - if (allBatchesSucceeded) { - this.logger.log( - `=== Tweet Analysis Job Completed Successfully (${totalAnalyzedTweets} tweets) ===`, - ); - } else { - this.logger.warn( - `=== Tweet Analysis Job Stopped (${totalAnalyzedTweets} tweets processed) ===`, - ); + // If no more tweets remain, exit the loop + if (!hasMoreTweets) { + this.logger.log( + `=== Tweet Analysis Job Completed Successfully (${totalProcessedAcrossRuns} total tweets across ${runNumber} run(s)) ===`, + ); + break; + } + + this.logger.log('More tweets remain, continuing to next run immediately...'); } } catch (error) { this.logger.error( @@ -159,6 +214,7 @@ export class TweetAnalyzeService implements OnModuleInit { error instanceof Error ? error.stack : String(error), ); } finally { + this.stopLockExtension(); await this.releaseLock(); } } @@ -199,6 +255,32 @@ export class TweetAnalyzeService implements OnModuleInit { } } + private startLockExtension(): void { + this.lockExtensionInterval = setInterval(() => { + void (async () => { + try { + const redis = this.redisService.getClient(); + await redis.expire(this.LOCK_KEY, this.LOCK_TTL_SECONDS); + this.logger.debug(`Extended distributed lock TTL to ${this.LOCK_TTL_SECONDS}s`); + } catch (error) { + this.logger.error( + 'Failed to extend distributed lock TTL', + error instanceof Error ? error.stack : String(error), + ); + } + })(); + }, this.LOCK_EXTENSION_INTERVAL * 1000); + this.logger.debug(`Started lock extension (every ${this.LOCK_EXTENSION_INTERVAL}s)`); + } + + private stopLockExtension(): void { + if (this.lockExtensionInterval) { + clearInterval(this.lockExtensionInterval); + this.lockExtensionInterval = null; + this.logger.debug('Stopped lock extension'); + } + } + private async getTweetsToAnalyze(): Promise> { this.logger.debug('Fetching tweets to analyze from repository'); const tweets = await this.repository.findTweetsToClassify(); From df343e128c4f1e28785a2a688069fb1fc2b8c2fc Mon Sep 17 00:00:00 2001 From: anas-ibrahem <139391509+anas-ibrahem@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:55:14 +0200 Subject: [PATCH 35/43] test: tweet-analyze - [CU-869bgfhfg] (#224) --- .../tweet-analyze.service.spec.ts | 782 ++++++++++++++++++ 1 file changed, 782 insertions(+) create mode 100644 test/tweet-analyze/tweet-analyze.service.spec.ts diff --git a/test/tweet-analyze/tweet-analyze.service.spec.ts b/test/tweet-analyze/tweet-analyze.service.spec.ts new file mode 100644 index 00000000..7e622bab --- /dev/null +++ b/test/tweet-analyze/tweet-analyze.service.spec.ts @@ -0,0 +1,782 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { TweetAnalyzeService } from 'src/tweet-analyze/tweet-analyze.service'; +import { TweetAnalyzeRepository } from 'src/tweet-analyze/tweet-analyze.repository'; +import { RedisService } from 'src/redis/redis.service'; +import { TrendingService } from 'src/trending/trending.service'; +import { of, throwError } from 'rxjs'; +import type { + ModelApiResponse, + TrendingKeyword, + ClassifiedTweet, +} from 'src/tweet-analyze/interfaces'; +import { AxiosResponse } from 'axios'; + +describe('TweetAnalyzeService', () => { + let service: TweetAnalyzeService; + let configService: jest.Mocked; + let httpService: jest.Mocked; + let repository: jest.Mocked; + let redisService: jest.Mocked; + let trendingService: jest.Mocked; + + const mockRedisClient = { + set: jest.fn(), + expire: jest.fn(), + del: jest.fn(), + }; + + const createMockConfigService = () => ({ + get: jest.fn((key: string, defaultValue?: string) => { + const config: Record = { + CLASSIFY_TWEETS: 'true', + CLASSIFICATION_INTERVAL_MINUTES: '5', + CLASSIFY_REQ_LIMIT: '50', + CLASSIFICATION_API_URL: 'http://localhost:5000/analyze', + }; + return config[key] || defaultValue; + }), + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TweetAnalyzeService, + { + provide: ConfigService, + useValue: createMockConfigService(), + }, + { + provide: HttpService, + useValue: { + post: jest.fn(), + }, + }, + { + provide: TweetAnalyzeRepository, + useValue: { + findTweetsToClassify: jest.fn(), + updateTweetClass: jest.fn(), + }, + }, + { + provide: RedisService, + useValue: { + getClient: jest.fn(() => mockRedisClient), + del: jest.fn(), + }, + }, + { + provide: TrendingService, + useValue: { + updateTrendScores: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(TweetAnalyzeService); + configService = module.get(ConfigService); + httpService = module.get(HttpService); + repository = module.get(TweetAnalyzeRepository); + redisService = module.get(RedisService); + trendingService = module.get(TrendingService); + }); + + describe('initialization', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should initialize with correct configuration', () => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(configService.get).toHaveBeenCalledWith('CLASSIFY_TWEETS'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(configService.get).toHaveBeenCalledWith('CLASSIFICATION_INTERVAL_MINUTES'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(configService.get).toHaveBeenCalledWith('CLASSIFY_REQ_LIMIT'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(configService.get).toHaveBeenCalledWith('CLASSIFICATION_API_URL', '/analyze'); + }); + }); + + describe('analyzeTweets', () => { + const mockTweets = [ + { id: BigInt(1), content: 'Test tweet 1' }, + { id: BigInt(2), content: 'Test tweet 2' }, + { id: BigInt(3), content: 'Test tweet 3' }, + ]; + + const mockClassifiedTweets: ClassifiedTweet[] = [ + { id: '1', class: 'technology' }, + { id: '2', class: 'sports' }, + { id: '3', class: 'entertainment' }, + ]; + + const mockTrendingKeywords: TrendingKeyword[] = [ + { + keyword: 'AI', + general_trend_score: 0.95, + top_related_topics: [ + { + topic: 'technology', + trend_score: 0.9, + occurence_in_category: 10, + }, + ], + }, + ]; + + const mockApiResponse: ModelApiResponse = { + batch_meta: { total_tweets: 3 }, + trending_keywords: mockTrendingKeywords, + tweets_detail: mockClassifiedTweets, + }; + + beforeEach(() => { + // Mock lock acquisition success + mockRedisClient.set.mockResolvedValue('OK'); + mockRedisClient.expire.mockResolvedValue(1); + redisService.del.mockResolvedValue(1); + + // Default: return tweets once, then empty + repository.findTweetsToClassify.mockResolvedValueOnce(mockTweets).mockResolvedValue([]); + repository.updateTweetClass.mockResolvedValue(undefined); + trendingService.updateTrendScores.mockResolvedValue(undefined); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const axiosResponse = { + data: mockApiResponse, + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: undefined, + }, + } as AxiosResponse; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + httpService.post.mockReturnValue(of(axiosResponse)); + }); + + it('should skip analysis when disabled', async () => { + const disabledConfigService = { + get: jest.fn((key: string) => { + if (key === 'CLASSIFY_TWEETS') return 'false'; + if (key === 'CLASSIFICATION_INTERVAL_MINUTES') return '5'; + if (key === 'CLASSIFY_REQ_LIMIT') return '50'; + if (key === 'CLASSIFICATION_API_URL') return 'http://localhost:5000/analyze'; + return ''; + }), + } as unknown as ConfigService; + + const disabledService = new TweetAnalyzeService( + disabledConfigService, + httpService, + repository, + redisService, + trendingService, + ); + + await disabledService.analyzeTweets(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.findTweetsToClassify).not.toHaveBeenCalled(); + }); + + it('should skip when lock cannot be acquired', async () => { + mockRedisClient.set.mockResolvedValue(null); + + await service.analyzeTweets(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.findTweetsToClassify).not.toHaveBeenCalled(); + }); + + it('should successfully analyze tweets', async () => { + await service.analyzeTweets(); + + expect(mockRedisClient.set).toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.findTweetsToClassify).toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(httpService.post).toHaveBeenCalledWith('http://localhost:5000/analyze', { + tweets: [ + { id: '1', content: 'Test tweet 1' }, + { id: '2', content: 'Test tweet 2' }, + { id: '3', content: 'Test tweet 3' }, + ], + }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.updateTweetClass).toHaveBeenCalledTimes(3); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(trendingService.updateTrendScores).toHaveBeenCalledWith({ + batch_meta: { total_tweets: 3 }, + trending_keywords: mockTrendingKeywords, + }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(redisService.del).toHaveBeenCalled(); + }); + + it('should handle no tweets to analyze', async () => { + repository.findTweetsToClassify.mockReset().mockResolvedValue([]); + + await service.analyzeTweets(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(httpService.post).not.toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(trendingService.updateTrendScores).not.toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(redisService.del).toHaveBeenCalled(); + }); + + it('should split tweets into multiple batches', async () => { + const manyTweets = Array.from({ length: 100 }, (_, i) => ({ + id: BigInt(i + 1), + content: `Tweet ${i + 1}`, + })); + + repository.findTweetsToClassify + .mockReset() + .mockResolvedValueOnce(manyTweets) + .mockResolvedValue([]); + + await service.analyzeTweets(); + + // Should make 2 calls: 50, 50 (based on CLASSIFY_REQ_LIMIT=50) + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(httpService.post).toHaveBeenCalledTimes(2); + }); + + it('should handle API errors gracefully', async () => { + httpService.post.mockReturnValue(throwError(() => new Error('API connection failed'))); + + await service.analyzeTweets(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.updateTweetClass).not.toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(redisService.del).toHaveBeenCalled(); + }); + + it('should handle partial batch failures', async () => { + const largeBatch = Array.from({ length: 100 }, (_, i) => ({ + id: BigInt(i + 1), + content: `Tweet ${i + 1}`, + })); + + repository.findTweetsToClassify + .mockReset() + .mockResolvedValueOnce(largeBatch) + .mockResolvedValue([]); + + // First batch succeeds, second batch fails + httpService.post + .mockReturnValueOnce( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + of({ + data: mockApiResponse, + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: undefined, + }, + } as AxiosResponse), + ) + .mockReturnValueOnce(throwError(() => new Error('Batch 2 failed'))); + + await service.analyzeTweets(); + + // First batch should have been processed + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.updateTweetClass).toHaveBeenCalledTimes(3); + // Should stop after first batch failure + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(httpService.post).toHaveBeenCalledTimes(2); + }); + + it('should filter out tweets with null content', async () => { + const tweetsWithNull = [ + { id: BigInt(1), content: 'Valid tweet' }, + { id: BigInt(2), content: null }, + { id: BigInt(3), content: 'Another valid tweet' }, + ]; + + repository.findTweetsToClassify + .mockReset() + .mockResolvedValueOnce(tweetsWithNull) + .mockResolvedValue([]); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const axiosResponse = { + data: { + batch_meta: { total_tweets: 2 }, + trending_keywords: [], + tweets_detail: [ + { id: '1', class: 'technology' }, + { id: '3', class: 'sports' }, + ], + }, + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: undefined, + }, + } as AxiosResponse; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + httpService.post.mockReturnValue(of(axiosResponse)); + + await service.analyzeTweets(); + + // Should only process tweets with valid content + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(httpService.post).toHaveBeenCalledWith('http://localhost:5000/analyze', { + tweets: [ + { id: '1', content: 'Valid tweet' }, + { id: '3', content: 'Another valid tweet' }, + ], + }); + }); + + it('should handle empty API response', async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const emptyResponse = { + data: { + batch_meta: { total_tweets: 0 }, + trending_keywords: [], + tweets_detail: [], + }, + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: undefined, + }, + } as AxiosResponse; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + httpService.post.mockReset().mockReturnValue(of(emptyResponse)); + + await service.analyzeTweets(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.updateTweetClass).not.toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(trendingService.updateTrendScores).not.toHaveBeenCalled(); + }); + + it('should process multiple runs when tweets exceed LIMIT_PER_JOB', async () => { + // Create 150 tweets to trigger multiple runs (LIMIT_PER_JOB = 100) + const manyTweets = Array.from({ length: 150 }, (_, i) => ({ + id: BigInt(i + 1), + content: `Tweet ${i + 1}`, + })); + + // First call returns 150 tweets (will process 100), second call returns remaining 50 + repository.findTweetsToClassify + .mockReset() + .mockResolvedValueOnce(manyTweets) + .mockResolvedValueOnce(manyTweets.slice(100)) + .mockResolvedValue([]); + + await service.analyzeTweets(); + + // Should fetch tweets at least 2 times (run 1: 150 found -> process 100, run 2: 50 found -> process 50) + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.findTweetsToClassify).toHaveBeenCalledTimes(2); + }); + + it('should release lock even when errors occur', async () => { + repository.findTweetsToClassify.mockReset().mockRejectedValue(new Error('Database error')); + + await service.analyzeTweets(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(redisService.del).toHaveBeenCalled(); + }); + + it('should handle tweet update failures gracefully', async () => { + repository.updateTweetClass + .mockReset() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Update failed')) + .mockResolvedValueOnce(undefined); + + await service.analyzeTweets(); + + // Should continue updating other tweets even if one fails + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.updateTweetClass).toHaveBeenCalledTimes(3); + }); + + it('should accumulate trending keywords across multiple batches', async () => { + const largeBatch = Array.from({ length: 100 }, (_, i) => ({ + id: BigInt(i + 1), + content: `Tweet ${i + 1}`, + })); + + repository.findTweetsToClassify + .mockReset() + .mockResolvedValueOnce(largeBatch) + .mockResolvedValue([]); + + const batch1Keywords: TrendingKeyword[] = [ + { + keyword: 'AI', + general_trend_score: 0.9, + top_related_topics: [{ topic: 'tech', trend_score: 0.8, occurence_in_category: 5 }], + }, + ]; + + const batch2Keywords: TrendingKeyword[] = [ + { + keyword: 'ML', + general_trend_score: 0.85, + top_related_topics: [{ topic: 'tech', trend_score: 0.75, occurence_in_category: 3 }], + }, + ]; + + httpService.post + .mockReset() + .mockReturnValueOnce( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + of({ + data: { + batch_meta: { total_tweets: 50 }, + trending_keywords: batch1Keywords, + tweets_detail: mockClassifiedTweets, + }, + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: undefined, + }, + } as AxiosResponse), + ) + .mockReturnValueOnce( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + of({ + data: { + batch_meta: { total_tweets: 50 }, + trending_keywords: batch2Keywords, + tweets_detail: mockClassifiedTweets, + }, + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: undefined, + }, + } as AxiosResponse), + ); + + await service.analyzeTweets(); + + // Should accumulate keywords from both batches + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(trendingService.updateTrendScores).toHaveBeenCalledWith({ + batch_meta: { total_tweets: 100 }, + trending_keywords: [...batch1Keywords, ...batch2Keywords], + }); + }); + + it('should correctly convert tweet IDs to strings for API', async () => { + const largeIdTweets = [{ id: BigInt('9999999999999999'), content: 'Large ID tweet' }]; + + repository.findTweetsToClassify + .mockReset() + .mockResolvedValueOnce(largeIdTweets) + .mockResolvedValue([]); + + await service.analyzeTweets(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(httpService.post).toHaveBeenCalledWith('http://localhost:5000/analyze', { + tweets: [{ id: '9999999999999999', content: 'Large ID tweet' }], + }); + }); + + it('should correctly convert string IDs back to BigInt for database updates', async () => { + await service.analyzeTweets(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.updateTweetClass).toHaveBeenCalledWith(BigInt(1), 'technology'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.updateTweetClass).toHaveBeenCalledWith(BigInt(2), 'sports'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.updateTweetClass).toHaveBeenCalledWith(BigInt(3), 'entertainment'); + }); + + it('should handle lock extension errors gracefully', async () => { + // Let the lock extension run and fail + mockRedisClient.expire.mockRejectedValue(new Error('Redis connection lost')); + + await service.analyzeTweets(); + + // Should still complete the job + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(redisService.del).toHaveBeenCalled(); + }); + + it('should handle lock acquisition errors', async () => { + mockRedisClient.set.mockRejectedValue(new Error('Redis error')); + + await service.analyzeTweets(); + + // Should not proceed with analysis + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.findTweetsToClassify).not.toHaveBeenCalled(); + }); + + it('should handle lock release errors gracefully', async () => { + redisService.del.mockRejectedValue(new Error('Redis error on delete')); + + await service.analyzeTweets(); + + // Should complete without throwing + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.findTweetsToClassify).toHaveBeenCalled(); + }); + + it('should log correctly when processing single run with no more tweets', async () => { + const singleBatch = Array.from({ length: 30 }, (_, i) => ({ + id: BigInt(i + 1), + content: `Tweet ${i + 1}`, + })); + + repository.findTweetsToClassify + .mockReset() + .mockResolvedValueOnce(singleBatch) + .mockResolvedValue([]); + + await service.analyzeTweets(); + + // Should process all tweets in one run + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(httpService.post).toHaveBeenCalledTimes(1); + }); + + it('should continue processing remaining batches after some updates fail', async () => { + const mixedResultTweets: ClassifiedTweet[] = [ + { id: '1', class: 'technology' }, + { id: '2', class: 'sports' }, + { id: '3', class: 'entertainment' }, + { id: '4', class: 'news' }, + { id: '5', class: 'science' }, + ]; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const axiosResponse = { + data: { + batch_meta: { total_tweets: 5 }, + trending_keywords: [], + tweets_detail: mixedResultTweets, + }, + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: undefined, + }, + } as AxiosResponse; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + httpService.post.mockReset().mockReturnValue(of(axiosResponse)); + + repository.findTweetsToClassify + .mockReset() + .mockResolvedValueOnce([ + { id: BigInt(1), content: 'Tweet 1' }, + { id: BigInt(2), content: 'Tweet 2' }, + { id: BigInt(3), content: 'Tweet 3' }, + { id: BigInt(4), content: 'Tweet 4' }, + { id: BigInt(5), content: 'Tweet 5' }, + ]) + .mockResolvedValue([]); + + repository.updateTweetClass + .mockReset() + .mockResolvedValueOnce(undefined) // Tweet 1 success + .mockRejectedValueOnce(new Error('Database error')) // Tweet 2 fails + .mockResolvedValueOnce(undefined) // Tweet 3 success + .mockRejectedValueOnce(new Error('Database error')) // Tweet 4 fails + .mockResolvedValueOnce(undefined); // Tweet 5 success + + await service.analyzeTweets(); + + // Should attempt to update all 5 tweets despite failures + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(repository.updateTweetClass).toHaveBeenCalledTimes(5); + }); + + it('should handle repository errors during tweet fetching', async () => { + repository.findTweetsToClassify.mockReset().mockRejectedValue(new Error('Database error')); + + await service.analyzeTweets(); + + // Should release lock even when error occurs + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(redisService.del).toHaveBeenCalled(); + // Should not call API + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(httpService.post).not.toHaveBeenCalled(); + }); + + it('should skip trending update when no keywords returned', async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const noKeywordsResponse = { + data: { + batch_meta: { total_tweets: 3 }, + trending_keywords: null, + tweets_detail: mockClassifiedTweets, + }, + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: undefined, + }, + } as AxiosResponse; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + httpService.post.mockReset().mockReturnValue(of(noKeywordsResponse)); + + await service.analyzeTweets(); + + // Should not update trending when no keywords + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(trendingService.updateTrendScores).not.toHaveBeenCalled(); + }); + + it('should handle trending service errors gracefully', async () => { + trendingService.updateTrendScores.mockReset().mockRejectedValue(new Error('Trending error')); + + await expect(service.analyzeTweets()).resolves.not.toThrow(); + + // Should still complete the job + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(redisService.del).toHaveBeenCalled(); + }); + + it('should process tweets correctly when batch_meta is missing', async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const noBatchMetaResponse = { + data: { + batch_meta: null, + trending_keywords: mockTrendingKeywords, + tweets_detail: mockClassifiedTweets, + }, + status: 200, + statusText: 'OK', + headers: {}, + config: { + headers: undefined, + }, + } as AxiosResponse; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + httpService.post.mockReset().mockReturnValue(of(noBatchMetaResponse)); + + await service.analyzeTweets(); + + // Should still update trending with 0 total tweets + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(trendingService.updateTrendScores).toHaveBeenCalledWith({ + batch_meta: { total_tweets: 0 }, + trending_keywords: mockTrendingKeywords, + }); + }); + }); + + describe('lifecycle hooks', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should start cron job on module init when enabled', () => { + const analyzeSpy = jest.spyOn(service, 'analyzeTweets').mockResolvedValue(undefined); + + service.onModuleInit(); + + // Should call immediately + expect(analyzeSpy).toHaveBeenCalledTimes(1); + + // Advance time and check periodic execution + jest.advanceTimersByTime(5 * 60 * 1000); // 5 minutes + + expect(analyzeSpy).toHaveBeenCalledTimes(2); + + analyzeSpy.mockRestore(); + }); + + it('should not start cron job when disabled', () => { + const disabledConfigService = { + get: jest.fn((key: string) => { + if (key === 'CLASSIFY_TWEETS') return 'false'; + if (key === 'CLASSIFICATION_INTERVAL_MINUTES') return '5'; + if (key === 'CLASSIFY_REQ_LIMIT') return '50'; + if (key === 'CLASSIFICATION_API_URL') return 'http://localhost:5000/analyze'; + return ''; + }), + } as unknown as ConfigService; + + const disabledService = new TweetAnalyzeService( + disabledConfigService, + httpService, + repository, + redisService, + trendingService, + ); + + const analyzeSpy = jest.spyOn(disabledService, 'analyzeTweets').mockResolvedValue(undefined); + + disabledService.onModuleInit(); + + expect(analyzeSpy).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(5 * 60 * 1000); + + expect(analyzeSpy).not.toHaveBeenCalled(); + + analyzeSpy.mockRestore(); + }); + + it('should handle errors in initial analysis run', () => { + const analyzeSpy = jest + .spyOn(service, 'analyzeTweets') + .mockRejectedValue(new Error('Initial run failed')); + + service.onModuleInit(); + + // Should not throw + expect(() => service.onModuleInit()).not.toThrow(); + + analyzeSpy.mockRestore(); + }); + + it('should handle errors in scheduled analysis runs', () => { + const analyzeSpy = jest + .spyOn(service, 'analyzeTweets') + .mockResolvedValueOnce(undefined) // First call succeeds + .mockRejectedValue(new Error('Scheduled run failed')); // Subsequent calls fail + + service.onModuleInit(); + + jest.advanceTimersByTime(5 * 60 * 1000); + + // Should not throw + expect(() => jest.advanceTimersByTime(5 * 60 * 1000)).not.toThrow(); + + analyzeSpy.mockRestore(); + }); + }); +}); From 6f3c87bf148734e5001fe48bbf2584398b34aac2 Mon Sep 17 00:00:00 2001 From: anas-ibrahem <139391509+anas-ibrahem@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:56:48 +0200 Subject: [PATCH 36/43] feat: model fast api - [CU-869bgfgw6] (#153) Co-authored-by: LoayAhmed304 --- .dockerignore | 3 +- .github/workflows/model-update.yaml | 23 ++++ model/config.py | 52 ++++++++ model/main.py | 115 +++++++++++++++++ model/processor.py | 191 ++++++++++++++++++++++++++++ model/requirements.txt | 7 + 6 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/model-update.yaml create mode 100644 model/config.py create mode 100644 model/main.py create mode 100644 model/processor.py create mode 100644 model/requirements.txt diff --git a/.dockerignore b/.dockerignore index 8d7e533c..5b71772c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,4 +18,5 @@ Dockerfile docker-compose.yml docker-compose.dev.yml -.dockerignore \ No newline at end of file +.dockerignore +./model \ No newline at end of file diff --git a/.github/workflows/model-update.yaml b/.github/workflows/model-update.yaml new file mode 100644 index 00000000..f7f2e185 --- /dev/null +++ b/.github/workflows/model-update.yaml @@ -0,0 +1,23 @@ +name: Run command on SSH on push + +on: + push: + branches: + - feat/model-api + +jobs: + ssh-run: + runs-on: ubuntu-latest + + steps: + - name: Set up SSH + uses: webfactory/ssh-agent@v0.9.1 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Test SSH + run: ssh -o StrictHostKeyChecking=no root@${{ secrets.SSH_HOST }} "echo 'SSH connection established'" + + - name: Run command on remote server + run: | + ssh -o StrictHostKeyChecking=no root@${{ secrets.SSH_HOST }} "cd /root/raven-backend/raven-backend && git pull origin feat/model-api" diff --git a/model/config.py b/model/config.py new file mode 100644 index 00000000..1675eda2 --- /dev/null +++ b/model/config.py @@ -0,0 +1,52 @@ +ARABIC_MODEL = "Ammar-alhaj-ali/arabic-MARBERT-news-article-classification" +ENGLISH_MODEL = "cardiffnlp/tweet-topic-21-multi" +KEYWORD_MODEL = "paraphrase-multilingual-MiniLM-L12-v2" + +TOP_X_KEYWORDS = 2 +TOP_TREND_LIMIT_KWS = 15 +TOP_K_SUBTOPICS = 1 +MIN_SCORE = 0.85 + + +CUSTOM_IGNORE_LIST = [ + 'day', 'today', 'yesterday', 'tomorrow', 'week', 'year', 'month', 'time', 'finally', + 'people', 'thing', 'something', 'world', 'life', 'season', 'matter', 'key', 'small', + 'like', 'popular', 'decide', 'hits different', 'ended', 'new', 'people world', 'night', 'day' +] + +LABEL_MAP = { + "business_&_entrepreneurs": "Finance", + "sports": "Sports", + "news_&_social_concern": "Politics", + "science_&_technology": "Tech", + "fitness_&_health": "Medical", + "arts_&_culture": "Culture", + "celebrity_&_pop_culture": "Culture", + "film_tv_&_video": "Entertainment", + "music": "Entertainment", + "fashion_&_style": "Entertainment", + "diaries_&_daily_life": "General", + "family": "General", + "food_&_dining": "Food", + "gaming": "Gaming", + "learning_&_educational": "Learning", + "other_hobbies": "General", + "relationships": "General", + "travel_&_adventure": "Travel", + "youth_&_student_life": "General" +} + +TREND_TOPIC_MAP = { + "Entertainment": "Entertainment", + "Sports": "Sports", + "Finance": "News", + "Politics": "News", + "Tech": "News", + "Medical": "News", + "Culture": "General", + "Religion": "General", + "General": "General", + "Food": "General", + "Learning": "General", + "Travel": "General" +} diff --git a/model/main.py b/model/main.py new file mode 100644 index 00000000..888c2cd2 --- /dev/null +++ b/model/main.py @@ -0,0 +1,115 @@ +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field +from typing import List +from contextlib import asynccontextmanager +import logging +from processor import TweetProcessor + +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +processor = None + +@asynccontextmanager +async def lifespan(app: FastAPI): + global processor + logger.info("Starting FastAPI application...") + logger.info("Initializing TweetProcessor...") + processor = TweetProcessor() + logger.info("TweetProcessor initialized successfully") + yield + logger.info("Shutting down FastAPI application...") + +app = FastAPI( + title="Tweet Analysis API", + description="API for analyzing tweets and extracting trending keywords with topic classification", + version="1.0.0", + lifespan=lifespan +) + +class Tweet(BaseModel): + id: str = Field(..., description="Unique tweet identifier") + content: str = Field(..., description="Tweet text content") + +class TweetRequest(BaseModel): + tweets: List[Tweet] = Field(..., description="List of tweets to analyze", min_items=1) + +class TopicInfo(BaseModel): + topic: str = Field(..., description="Topic category") + trend_score: float = Field(..., description="Trend score for this topic") + occurence_in_category: int = Field(..., description="Number of unique tweets in this topic category") + +class TrendingKeyword(BaseModel): + keyword: str = Field(..., description="The trending keyword or hashtag") + general_trend_score: float = Field(..., description="Overall trend score across all topics") + top_related_topics: List[TopicInfo] = Field(..., description="Top related topics for this keyword") + +class BatchMeta(BaseModel): + total_tweets: int = Field(..., description="Total number of tweets processed") + +class ProcessedTweet(BaseModel): + id: str = Field(..., description="Tweet ID") + class_: str = Field(..., description="Classified topic category", alias="class") + +class TweetResponse(BaseModel): + batch_meta: BatchMeta = Field(..., description="Metadata about the batch processing") + trending_keywords: List[TrendingKeyword] = Field(..., description="List of trending keywords with their scores") + tweets_detail: List[ProcessedTweet] = Field(..., description="Detailed information for each processed tweet") + +@app.post( + "/analyze", + response_model=TweetResponse, + summary="Analyze tweets for trending keywords", + description=""" + Analyzes a batch of tweets to extract trending keywords and classify them by topic. + + The endpoint performs the following operations: + - Detects language (Arabic or English) + - Classifies tweets into topic categories + - Extracts relevant keywords using KeyBERT + - Extracts and processes hashtags + - Calculates trend scores for keywords and hashtags + - Groups keywords by related topics + - Returns top trending keywords with their topic associations + + **Topic Categories:** + - Entertainment: Movies, TV, music, fashion, celebrity news + - Sports: All sports-related content + - News: Finance, politics, technology, medical, business + - General: Culture, religion, food, learning, travel, daily life + - Gaming: Gaming-related content + + **Supported Languages:** + - English + - Arabic + """ +) +async def analyze_tweets(request: TweetRequest): + try: + logger.info(f"Received analyze request with {len(request.tweets)} tweets") + # log the request content + logger.debug(f"Request content: {request.json()}") + + if not request.tweets: + logger.warning("Request received with no tweets") + raise HTTPException(status_code=400, detail="No tweets provided") + + result = processor.process_tweets(request.tweets) + + logger.debug(f"Analyze response: {result}") + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error processing tweets: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Processing error: {str(e)}") + +@app.get("/health", summary="Health check", description="Check if the API is running and models are loaded") +async def health_check(): + is_healthy = processor is not None + logger.debug(f"Health check: models_loaded={is_healthy}") + return {"status": "healthy" if is_healthy else "unhealthy", "models_loaded": is_healthy} diff --git a/model/processor.py b/model/processor.py new file mode 100644 index 00000000..7d651ece --- /dev/null +++ b/model/processor.py @@ -0,0 +1,191 @@ +import re +import logging +from collections import defaultdict +from transformers import pipeline +from keybert import KeyBERT +from config import ( + ARABIC_MODEL, + ENGLISH_MODEL, + KEYWORD_MODEL, + TOP_X_KEYWORDS, + TOP_TREND_LIMIT_KWS, + TOP_K_SUBTOPICS, + MIN_SCORE, + CUSTOM_IGNORE_LIST, + LABEL_MAP, + TREND_TOPIC_MAP +) + +class TweetProcessor: + def __init__(self): + logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + self.logger = logging.getLogger(__name__) + + self.logger.info("Initializing TweetProcessor...") + self.logger.info(f"Loading Arabic classification model: {ARABIC_MODEL}") + self.pipe_ar = pipeline("text-classification", model=ARABIC_MODEL, tokenizer=ARABIC_MODEL, device=-1, top_k=1) + + self.logger.info(f"Loading English classification model: {ENGLISH_MODEL}") + self.pipe_en = pipeline("text-classification", model=ENGLISH_MODEL, tokenizer=ENGLISH_MODEL, device=-1, top_k=1) + + self.logger.info(f"Loading KeyBERT model: {KEYWORD_MODEL}") + self.kw_model = KeyBERT(model=KEYWORD_MODEL) + + self.logger.info("TweetProcessor initialization complete") + + + def process_tweets(self, tweets): + self.logger.info(f"Processing batch of {len(tweets)} tweets") + processed_tweets = [] + keyword_tracker = defaultdict(lambda: defaultdict(lambda: {"score": 0.0, "tweet_ids": set()})) + + for tweet in tweets: + tweet_id = tweet.id + text = tweet.content + + if not text.strip(): + self.logger.debug(f"Tweet {tweet_id}: Empty content, classifying as General") + processed_tweets.append({ + "id": tweet_id, + "class": "General" + }) + continue + + has_arabic = bool(re.search(r'[\u0600-\u06FF]', text)) + has_english = bool(re.search(r'[a-zA-Z]', text)) + + is_ar = has_arabic and not has_english + self.logger.debug(f"Tweet {tweet_id}: Content {text[:30]}...") + + try: + if is_ar: + pred = self.pipe_ar(text)[0][0] + else: + pred = self.pipe_en(text)[0][0] + + label = pred['label'] + score = round(pred['score'], 4) + + if score < MIN_SCORE: + label = "General" + else: + if not is_ar and label in LABEL_MAP: + label = LABEL_MAP[label] + if label == "Religion": + label = "General" + + + + trend_category = LABEL_MAP.get(label, label) + if label in TREND_TOPIC_MAP: + trend_category = TREND_TOPIC_MAP[label] + else: + trend_category = "General" + + self.logger.debug(f"Tweet {tweet_id}: Final classification='{label}', trend_category='{trend_category}'") + + hashtags = re.findall(r'#[\w\u0600-\u06FF]+', text) + if hashtags: + self.logger.debug(f"Tweet {tweet_id}: Found {len(hashtags)} hashtags: {hashtags}") + + for hashtag in hashtags: + clean_hashtag = hashtag[1:] + if clean_hashtag: + keyword_tracker[hashtag][trend_category]["score"] += 1.0 + keyword_tracker[hashtag][trend_category]["tweet_ids"].add(tweet_id) + + text_for_keywords = re.sub(r'#[\w\u0600-\u06FF]+', '', text).strip() + + keywords_raw = self.kw_model.extract_keywords( + text_for_keywords, + keyphrase_ngram_range=(1, 1), + stop_words='english', + top_n=TOP_X_KEYWORDS, + use_mmr=True, + diversity=0.65 + ) + + keywords_with_scores = [] + for k in keywords_raw: + word = k[0].lower() + keybert_score = k[1] + if word.lower() not in CUSTOM_IGNORE_LIST: + keywords_with_scores.append((word, keybert_score)) + + if not keywords_with_scores: + keywords_with_scores = [(k[0], k[1]) for k in keywords_raw] + + for kw, keybert_score in keywords_with_scores: + if not re.fullmatch(r'[A-Za-z]+(?:\s+[A-Za-z]+)*', kw): + continue + + keyword_tracker[kw][trend_category]["score"] += keybert_score + keyword_tracker[kw][trend_category]["tweet_ids"].add(tweet_id) + + processed_tweets.append({ + "id": tweet_id, + "class": label + }) + + except Exception as e: + self.logger.error(f"Tweet {tweet_id}: Error during processing - {str(e)}") + processed_tweets.append({ + "id": tweet_id, + "class": "General" + }) + + trending_keywords = [] + trending_hashtags = [] + + for kw, topic_data in keyword_tracker.items(): + general_trend_score = sum(t["score"] for t in topic_data.values()) + + all_tweet_ids = set() + for t in topic_data.values(): + all_tweet_ids.update(t["tweet_ids"]) + + topics_list = [] + for topic, stats in topic_data.items(): + topics_list.append({ + "topic": topic, + "trend_score": round(stats["score"], 4), + "occurence_in_category": len(stats["tweet_ids"]) + }) + + topics_list.sort(key=lambda x: x["trend_score"], reverse=True) + + top_relevant_topics = topics_list[:TOP_K_SUBTOPICS] + + keyword_obj = { + "keyword": kw, + "general_trend_score": round(general_trend_score, 4), + "top_related_topics": top_relevant_topics + } + + if kw.startswith('#'): + trending_hashtags.append(keyword_obj) + else: + trending_keywords.append(keyword_obj) + + trending_keywords.sort(key=lambda x: x["general_trend_score"], reverse=True) + trending_keywords = trending_keywords[:TOP_TREND_LIMIT_KWS] + + trending_hashtags.sort(key=lambda x: x["general_trend_score"], reverse=True) + + all_trending = trending_keywords + trending_hashtags + all_trending.sort(key=lambda x: x["general_trend_score"], reverse=True) + + self.logger.info(f"Generated {len(trending_hashtags)} trending hashtags") + self.logger.info(f"Total trending items: {len(all_trending)}") + self.logger.info(f"Done Proccessing batch of {len(processed_tweets)} tweets") + + return { + "batch_meta": { + "total_tweets": len(processed_tweets), + }, + "trending_keywords": all_trending, + "tweets_detail": processed_tweets + } diff --git a/model/requirements.txt b/model/requirements.txt new file mode 100644 index 00000000..416b27ef --- /dev/null +++ b/model/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.5 +uvicorn==0.32.1 +pydantic==2.10.3 +transformers==4.47.0 +keybert==0.8.5 +torch==2.5.1 +sentence-transformers==3.3.1 From 1ecb187109eca483df7b1e6b87f3ec9facf37c14 Mon Sep 17 00:00:00 2001 From: anas-ibrahem <139391509+anas-ibrahem@users.noreply.github.com> Date: Mon, 15 Dec 2025 22:22:21 +0200 Subject: [PATCH 37/43] feat: seed generation - [CU-869bgfhfg] (#222) --- package.json | 1 + prisma/generate_seed.py | 660 +++++++++++++++++ .../migration.sql | 1 + prisma/seed-from-json.ts | 663 ++++++++++++++++++ 4 files changed, 1325 insertions(+) create mode 100644 prisma/generate_seed.py create mode 100644 prisma/seed-from-json.ts diff --git a/package.json b/package.json index 4f923032..aebb7fcc 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "db:generate": "prisma generate", "db:reset": "prisma migrate reset --force", "db:seed": "prisma db seed", + "db:seed-json": "ts-node prisma/seed-from-json.ts", "db:seed-timeline": "ts-node prisma/timeline-seed.ts", "db:seed-large-timeline": "ts-node prisma/seed-large-timeline.ts", "spec:generate": "pnpm --package=@typespec/compiler --package=@typespec/http --package=@typespec/rest --package=@typespec/openapi3 dlx tsp compile api-spec/implemented-spec --emit @typespec/openapi3", diff --git a/prisma/generate_seed.py b/prisma/generate_seed.py new file mode 100644 index 00000000..95e5b9df --- /dev/null +++ b/prisma/generate_seed.py @@ -0,0 +1,660 @@ +import json +import random +from datetime import datetime, timedelta +from faker import Faker +import re + +fake = Faker() + +# ============================================================================ +# CONFIGURATION - All configurable constants are at the top for easy editing +# ============================================================================ + +# Mode: "normal" or "trending" +# - "normal": generates tweets across the full date range +# - "trending": generates tweets only within TRENDING_START_TIME to TRENDING_END_TIME +GENERATION_MODE = "trending" + +# Time Configuration +CURRENT_DATE = datetime(2025, 12, 15, 4, 43, 0) # Current time reference + +# Trending Mode Time Window (only used when GENERATION_MODE = "trending") +# For realistic trending data, set these relative to CURRENT_DATE (e.g., last 6-24 hours) +TRENDING_START_TIME = CURRENT_DATE - timedelta(hours=24) # Start of trending period (24 hours ago) +TRENDING_END_TIME = CURRENT_DATE # End of trending period (now) + +# Trending Topics Filter (only used when GENERATION_MODE = "trending") +# Empty list means all topics are allowed. Non-empty list filters to only those topics. +# Example: ["Sports", "Tech"] will only generate tweets for Sports and Tech categories +TOPIC_TREND = ["Sports" , "Politics" , "Tech"] # Options: "Sports", "Entertainment", "Finance", "Politics", "Tech", "Culture", "General", "Learning", "Travel" + +# # Generation Counts +# USER_COUNT = 1100 +# TWEET_COUNT = 5100 +# RETWEET_COUNT = 700 +# LIKE_COUNT_RANGE = (1000, 2000) + +# Generation Counts +USER_COUNT = 100 +TWEET_COUNT = 500 +RETWEET_COUNT = 200 +LIKE_COUNT_RANGE = (100, 1000) + +# Interests/Categories +INTERESTS = ["Sports", "Entertainment", "Finance", "Politics", "Tech", "Culture", "General", "Learning", "Travel"] + +# ============================================================================ +# RANDOM SEEDING +# ============================================================================ + +if GENERATION_MODE == "trending": + # Dynamic seed for trending mode to ensure valid, new data + seed_val = int(datetime.now().timestamp()) + Faker.seed(seed_val) + random.seed(seed_val) + print(f"Trending Mode: Using dynamic seed {seed_val}") +else: + # Fixed seed for normal mode + Faker.seed(42) + random.seed(42) + print("Normal Mode: Using fixed seed 42") + +# ============================================================================ +# HASHTAG SETS - Base and Trending hashtags per category +# ============================================================================ + +# Base hashtags (always available) +BASE_HASHTAGS = { + "Sports": ["sports", "football", "basketball", "soccer", "nba", "nfl", "fitness", "gym", "workout", "athlete", "game", "match", "team", "player", "win"], + "Entertainment": ["movies", "music", "tv", "entertainment", "celebrity", "film", "concert", "album", "show", "streaming", "artist", "actor", "singer", "netflix", "hollywood"], + "Finance": ["finance", "investing", "stocks", "crypto", "bitcoin", "trading", "money", "wealth", "economy", "market", "portfolio", "savings", "business", "entrepreneur", "fintech"], + "Politics": ["politics", "election", "vote", "government", "democracy", "policy", "news", "breaking", "congress", "senate", "campaign", "debate", "legislation", "reform"], + "Tech": ["tech", "ai", "programming", "coding", "software", "startup", "innovation", "developer", "technology", "machinelearning", "data", "cloud", "cybersecurity", "apps", "gadgets"], + "Culture": ["culture", "art", "food", "travel", "history", "heritage", "tradition", "museum", "festival", "cuisine", "architecture", "design", "fashion", "lifestyle"], + "General": ["life", "motivation", "inspiration", "thoughts", "daily", "mood", "vibes", "weekend", "morning", "happiness", "grateful", "blessed", "positivity", "mindset"], + "Learning": ["learning", "education", "books", "reading", "study", "knowledge", "growth", "selfimprovement", "skills", "course", "tutorial", "wisdom", "mindset", "productivity"], + "Travel": ["travel", "wanderlust", "vacation", "adventure", "explore", "trip", "destination", "tourism", "backpacking", "roadtrip", "beach", "mountains", "citybreak", "passport"], +} + +# Trending hashtags (used in trending mode, more specific/timely) +TRENDING_HASHTAGS = { + "Sports": ["worldcup2026"], + "Entertainment": ["musicvideo"], + "Finance": ["bullmarket"], + "Politics": ["election2025"], + "Tech": ["chatgpt"], + "Culture": ["foodie2025"], + "General": ["trending"], + "Learning": ["studytips"], + "Travel": ["travelgram"], +} + +# ============================================================================ +# PROFILE PICTURE URLs +# ============================================================================ + +PROFILE_PIC_URLS = [ + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQoCodK5lzl094X0SV_I-39E-XbKpM8_--UVQ&s", + "https://i1.sndcdn.com/artworks-000582811712-ds64si-t500x500.jpg", + "https://cdn.eremnews.com/media/e572fd93-06ed-42c6-9ee5-577edaac5e62", + "https://pbs.twimg.com/profile_images/1164682491069943809/uGwI0V6H_400x400.jpg", + "https://pbs.twimg.com/media/GB5j51SWsAAEKtA.jpg", + "https://2img.net/h/images.wikia.com/spongebob/ar/images/4/40/Gary.png", + "https://m.media-amazon.com/images/I/51Qnhj882mL._AC_SY1000_.jpg", + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcScwpT9d25rR0PBvoFiR9WToxPTtoxIrtGsww&s", + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRygMJUDjusueYTsqxL_D9_N8egtHzmL3vxiQ&s", + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRgfC-BRJOTrhfY7U-jtoYn_7Hze0hhz-nvbw&s", + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQ-K85HXzMmj_IgS1uROfxfo5Ub9LJC2mIKrg&s", + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQlb4vyuZyv5kamkmKHDAHj-MVD_lP3-5i1cg&s", + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRZbAOveMkdMmFdsYBFwkzgJO5TN1_S0aiM5g&s", + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcT7FzMyfNQyShq5kdpvGXWnohXcnhPuTJCaO_pl8EZotqwQTw1cE7xTkHOgSv5fKt6bboaNd86x_6GhrdF1b6DqafBIVAzHqLptMDVvOA&s=10", + "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1527980965255-d3b416303d12?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1580489944761-15a19d654956?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1633332755192-727a05c4013d?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1628157588553-5eeea00af15c?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1599566150163-29194dcaad36?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1570295999919-56ceb5ecca61?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1534528741775-53994a69daeb?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.unsplash.com/photo-1517841905240-472988babdf9?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80", + "https://images.pexels.com/photos/220453/pexels-photo-220453.jpeg?auto=compress&cs=tinysrgb&w=800", + "https://images.pexels.com/photos/774909/pexels-photo-774909.jpeg?auto=compress&cs=tinysrgb&w=800", + "https://images.pexels.com/photos/1239291/pexels-photo-1239291.jpeg?auto=compress&cs=tinysrgb&w=800", + "https://images.pexels.com/photos/1222271/pexels-photo-1222271.jpeg?auto=compress&cs=tinysrgb&w=800", + "https://images.pexels.com/photos/733872/pexels-photo-733872.jpeg?auto=compress&cs=tinysrgb&w=800", +] * 10 + + +# ============================================================================ +# MEDIA URLs +# ============================================================================ + +IMAGE_URLS = [ + "https://images.unsplash.com/photo-1518770660439-4636190af475?ixlib=rb-4.0.3&auto=format&fit=crop&w=1920&q=80", + "https://images.pexels.com/photos/358492/pexels-photo-358492.jpeg?auto=compress&cs=tinysrgb&w=1920", + "https://images.pexels.com/photos/442559/pexels-photo-442559.jpeg?auto=compress&cs=tinysrgb&w=1920", + "https://images.pexels.com/photos/546819/pexels-photo-546819.jpeg?auto=compress&cs=tinysrgb&w=1920", + "https://images.pexels.com/photos/1181244/pexels-photo-1181244.jpeg?auto=compress&cs=tinysrgb&w=1920", + "https://images.pexels.com/photos/1591060/pexels-photo-1591060.jpeg?auto=compress&cs=tinysrgb&w=1920", + "https://images.pexels.com/photos/346529/pexels-photo-346529.jpeg?auto=compress&cs=tinysrgb&w=1920", + "https://images.pexels.com/photos/267885/pexels-photo-267885.jpeg?auto=compress&cs=tinysrgb&w=1920", + "https://images.unsplash.com/photo-1687949447141-1c730e32f261?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0", + "https://images.unsplash.com/photo-1687910623555-25c1df52d708?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0", + "https://images.unsplash.com/photo-1679239108020-aca50acd5f00?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0", + "https://images.unsplash.com/photo-1629975326958-bbeeb2d5beb9?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0", + "https://images.unsplash.com/photo-1706043050286-2f3b5613e6a5?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0", + "https://images.unsplash.com/photo-1706043050292-1f8582ca8739?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0", + "https://images.unsplash.com/photo-1634150607959-62c965982999?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0", + "https://images.unsplash.com/photo-1630948197497-3c0de9d73ad2?fm=jpg&q=60&w=3000&ixlib=rb-4.1.0", + "https://pbs.twimg.com/media/G8M1SKqWEA0zBeq?format=jpg&name=4096x4096", + "https://pbs.twimg.com/media/G8NH_cXXIAMu7gW?format=jpg&name=medium", + "https://pbs.twimg.com/media/G8G4s2lW4AUWNKJ?format=jpg&name=large", + "https://pbs.twimg.com/media/G8H51koXAAESPfO?format=jpg&name=large", + "https://pbs.twimg.com/media/G8Fj_WsbwAArIBB?format=jpg&name=large", + "https://pbs.twimg.com/media/G8K55uXaUAAed9j?format=jpg&name=large", + "https://pbs.twimg.com/media/G8HzLyZWEAEH0Jk?format=jpg&name=4096x4096" +] * 5 + +VIDEO_URLS = [ + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4", +] * 10 + +GIF_URLS = [ + "https://cdn.dribbble.com/userupload/19906993/file/original-145ef8617ff6330321c1c1565d7fc587.gif", + "https://i.pinimg.com/originals/b3/59/eb/b359eb60775b16e7edd8c5fa1ecf2ba7.gif", + "https://cdn.dribbble.com/users/2158347/screenshots/6041012/gareso_socialmedia_01_dribble.gif", + "https://mir-s3-cdn-cf.behance.net/project_modules/hd/95f14169931063.5b91ee22bb585.gif", + "https://www.techsmith.com/wp-content/uploads/2016/08/citylarge.gif", + "https://i.pinimg.com/originals/f4/f3/73/f4f37379be88e2ca55bc10be8de48b71.gif", + "https://i.pinimg.com/originals/9a/1f/11/9a1f11839c9f9e902f09e8259805319a.gif", + "https://i.pinimg.com/originals/56/65/5b/56655bda3afc62cb70c9c4c00b5d3834.gif", + "https://i.pinimg.com/originals/b8/42/a2/b842a20a95ab386abdaa14515e8a60e2.gif", + "https://i.pinimg.com/originals/2e/de/92/2ede929563a2c2fdb8f6cd87eb02c753.gif", + "https://i.pinimg.com/originals/5e/3b/70/5e3b70f025f946e810edae941c661766.gif", + "https://i.pinimg.com/originals/47/2a/8b/472a8bbdefde5267a4453e7a030b6263.gif", + "https://i.pinimg.com/originals/59/e9/2d/59e92dd3460e387cd65551925d789748.gif", +] * 20 + +# ============================================================================ +# CONTENT TEMPLATES - Use {hashtag} placeholder, will be replaced with category hashtags +# ============================================================================ + +CONTENT_TEMPLATES = { + "Sports": [ + "What a game! {team} absolutely crushed it tonight. The energy in the stadium was unreal, and that final play will go down in history. 🔥", + "I still can't believe that last-minute goal! ⚽ Outcomes like this are why I love this sport. The defense completely fell apart.", + "Training hard for the weekend match. Pushing limits every single day to be better than yesterday. 💪 It's not just about winning, it's about the grind.", + "{player} is on fire this season! Averaging career highs across the board. MVP conversation needs to start right now.", + "The officiating in the {team} game was questionable at best. We need better standards if the league wants to be taken seriously. 😤", + "Just bought tickets for the finals! It's going to be a long trip but totally worth it to see {team} play live. Who else is going? 🎫", + "{player} is a very underrated striker! {games} games, {goals} goals, {assists} assists, {trophies} trophies. Unique player! ❤️", + "{player}'s career stats are insane! {games} games, {goals} goals, {assists} assists, {trophies} trophies. The greatest! 🐐", + ], + "Entertainment": [ + "Just watched {movie} and I am mind blown 🤯. The cinematography, the score, the acting—everything was perfection. Highly recommend!", + "New album from {artist} dropped and it's pure gold on repeat 🎶. Every track tells a story.", + "Binge-watching {show} all weekend. I told myself I'd watch one episode, and here I am at 3 AM. No regrets though! 😂", + "The season finale of {show} left me speechless. I have so many theories about what happens next season. Let's discuss! 👇", + "Concert tickets for {artist} sold out in seconds? This system is rigged. I just wanted to see them live once! 😭", + "Huge congratulations to {artist} for a powerful and heartfelt performance! 🌟 Here's to more unforgettable moments ahead. 💙✨", + ], + "Finance": [ + "Bitcoin just hit ${price}k! The momentum is undeniable right now. Is this the start of the next massive bull run or a trap? 🚀", + "Market volatility is crazy right now. One minute we're up, the next we're down. Holding steady and trusting the long-term strategy. 📉📈", + "Diversifying my portfolio with {asset}. It's a hedge against inflation and a solid long-term play. What are your thoughts?", + "Just finished analyzing the Q3 reports for tech sector. Some surprising numbers that the market hasn't priced in yet. 📊", + "Financial freedom isn't about being rich, it's about having options. Started my journey today with a new savings plan. 💰", + "Historically, this is the point where capital rotates from {asset} into alternative investments.", + ], + "Politics": [ + "Important election coming up. The stakes have never been higher. Make sure to do your research and vote! Your voice matters. 🗳️", + "New policy announcement today regarding infrastructure. While the goals are good, I'm concerned about the execution timeline.", + "Debates are heating up. It's interesting to see how the narrative shifts depending on which network you watch. 📺", + "Local council meeting lasted 5 hours yesterday. Democracy is exhausting but necessary work. 🏛️", + "Breaking News: Major political developments today. Stay informed and engaged with the process.", + ], + "Tech": [ + "Just got my hands on the new {gadget}! The build quality is insane, and the battery life is finally what we've been asking for. 🚀", + "AI is changing everything faster than we expected. From coding to art, the landscape is shifting daily. Excited for the future 🤖", + "Coding all night on a new project. Finally fixed that bug that's been haunting me for days. The relief is real. 👨‍💻", + "The new update for VS Code is a game changer. The productivity boost nicely offsets the learning curve. 💻", + "Is it just me or is {gadget} overrated? I've been using it for a week and I'm not seeing the hype. Let me know your experience. 📱", + "Our Top {number} Tech Stocks to Own in the AI Revolution 🏆🐂🔥", + ], + "Culture": [ + "Visited this amazing museum today. The exhibit on ancient civilizations was breathtaking. Puts so much into perspective. 🎨", + "Trying out {food} from {country} for the first time — absolute flavor explosion! 🍲 The spices are unlike anything I've tried before.", + "Attended a local festival celebrating heritage and tradition. The music, the dance, the clothes—so vibrant and alive. ❤️", + "Reading about the history of {city} before my trip. It's fascinating how much the architecture tells the story of its past. 🏰", + "Looking for a warm spot in {city} this winter? ☃️ Check out the local museums for quiet escapes.", + ], + "General": [ + "Good morning everyone! ☀️ The sun is shining, coffee is brewing, and it feels like it's going to be a productive day.", + "Coffee is life ☕. I literally cannot function without my morning brew. Currently trying a dark roast from Ethiopia.", + "Weekend vibes 😎. Finally some time to relax, recharge, and maybe catch up on some reading. No emails allowed until Monday!", + "Sometimes you just need to disconnect and take a walk in nature. The fresh air does wonders for the mind. 🌲", + "Does anyone else feel like this year is flying by? It's already December! Time needs to slow down. ⏳", + ], + "Learning": [ + "Just finished reading {book}. It challenged so many of my assumptions. Highly recommend! 📚", + "Learning {skill} on YouTube today. It's amazing how much free education is available if you just look for it. 👨‍🎓", + "Struggling with this new concept in my course, but I'm not giving up. Persistence is key! 🔑", + "Attended a workshop on leadership today. Best takeaway: 'Listen more than you speak'. Simple but powerful. 🧠", + "The Golden Rule when you're learning a new skill: practice consistently, even if just 15 minutes a day.", + ], + "Travel": [ + "Just booked tickets to {place}! It's been on my bucket list for years. Can't wait to explore ✈️", + "Sunset views like this make everything worth it. The colors in the sky are unreal. Grateful for these moments. 🌅", + "Exploring hidden gems in {city}. Found this cute little cafe tucked away in an alley. Best part of traveling! 🗺️", + "Packing light is an art form I have yet to master. Somehow my suitcase is already full and I haven't even packed shoes. 🧳", + "Sunset state of mind 🍹🌴🌞🌊", + "Exploring medieval villages in {country} 💫", + ], +} + +# ============================================================================ +# HELPER FUNCTIONS +# ============================================================================ + +def get_hashtags_for_category(category: str, use_trending: bool = False) -> list: + """Get hashtags for a category. If trending mode, mix base and trending.""" + base = BASE_HASHTAGS.get(category, BASE_HASHTAGS["General"]) + if use_trending: + trending = TRENDING_HASHTAGS.get(category, TRENDING_HASHTAGS["General"]) + return trending + return base + + +def normalize_content(content: str) -> str: + """ + Normalize content to ensure: + - All hashtags are lowercase + - Proper spacing between hashtags, mentions, and words + """ + # First, ensure hashtags are lowercase + content = re.sub(r'#(\w+)', lambda m: '#' + m.group(1).lower(), content) + + # Ensure space before hashtags (but not at start) + content = re.sub(r'(\S)(#\w+)', r'\1 \2', content) + + # Ensure space after hashtags (if followed by non-word, non-space char) + content = re.sub(r'(#\w+)([^\w\s])', r'\1 \2', content) + + # Ensure space before mentions (but not at start) + content = re.sub(r'(\S)(@\w+)', r'\1 \2', content) + + # Ensure space after mentions (if followed by non-word, non-space char) + content = re.sub(r'(@\w+)([^\w\s])', r'\1 \2', content) + + # Remove multiple consecutive spaces + content = re.sub(r' +', ' ', content) + + # Remove space before punctuation + content = re.sub(r' ([.,!?])', r'\1', content) + + return content.strip() + + +def add_hashtags_to_content(content: str, category: str, num_hashtags: int = 2) -> str: + """Add category-appropriate hashtags to content with proper spacing.""" + use_trending = GENERATION_MODE == "trending" + available_hashtags = get_hashtags_for_category(category, use_trending) + + # Select random hashtags (ensure lowercase) + selected = random.sample(available_hashtags, min(num_hashtags, len(available_hashtags))) + hashtag_str = " ".join([f"#{tag.lower()}" for tag in selected]) + + # Add hashtags with proper spacing + if content.endswith(('.', '!', '?', '…')): + content = content + " " + hashtag_str + else: + content = content + " " + hashtag_str + + return content + + +def extract_hashtags(content: str) -> list: + """Extract all hashtags from content (lowercase).""" + hashtags = re.findall(r'(?:^|(?<=\s))#(\w+)', content) + return [tag.lower() for tag in set(hashtags)] + + +def extract_mentions(content: str, usernames: set) -> list: + """Extract valid mentions from content.""" + mentions = re.findall(r'(?:^|(?<=\s))@(\w+)', content) + return [m for m in mentions if m.lower() in {u.lower() for u in usernames}] + + +def generate_timestamp() -> str: + """Generate timestamp based on mode.""" + if GENERATION_MODE == "trending": + # Generate within the trending time window + time_diff = (TRENDING_END_TIME - TRENDING_START_TIME).total_seconds() + random_seconds = random.uniform(0, time_diff) + timestamp = TRENDING_START_TIME + timedelta(seconds=random_seconds) + return timestamp.isoformat() + "Z" + else: + # Normal mode: random time in last 30 days + rand = random.random() + if rand < 0.6: + delta = timedelta(days=random.randint(1, 30)) + elif rand < 0.9: + delta = timedelta(hours=random.randint(1, 48)) + else: + delta = timedelta(minutes=random.randint(5, 120)) + return (CURRENT_DATE - delta).isoformat() + "Z" + + +# ============================================================================ +# GENERATION FUNCTIONS +# ============================================================================ + +def generate_users(n: int = USER_COUNT) -> list: + """Generate user data.""" + users = [] + usernames = set() + + while len(users) < n: + username = fake.user_name().lower() + if username in usernames or not username.isalnum(): + continue + usernames.add(username) + + avatar_url = None + if random.random() > 0.1: + avatar_url = random.choice(PROFILE_PIC_URLS) + + user = { + "username": username, + "email": fake.email(), + "displayName": fake.name(), + "bio": fake.sentence(nb_words=10)[:160] if random.random() > 0.3 else "", + "birthdate": fake.date_between(start_date='-55y', end_date='-20y').strftime('%Y-%m-%d'), + "interests": random.sample(INTERESTS, k=random.randint(1, 4)), + "location": f"{fake.city()}, {fake.country()}" if random.random() > 0.4 else "", + "avatarUrl": avatar_url + } + + if random.random() > 0.7: + user["bio"] += " " + random.choice(["🚀", "☕", "🌍", "🎮", "📚", "🏃", "🎨"]) + + users.append(user) + + return users + + +def generate_tweets(users: list, n: int = TWEET_COUNT) -> tuple: + """Generate tweet data.""" + tweets = [] + usernames = {u["username"] for u in users} + reply_candidates = [] + popular_tweet_indices = [] + + # Template placeholders + format_kwargs = { + "team": lambda: random.choice(["Lakers", "Real Madrid", "Yankees", "Manchester City", "Ferrari"]), + "movie": lambda: random.choice(["Oppenheimer", "Dune", "Inception", "Barbie", "The Batman"]), + "artist": lambda: random.choice(["Taylor Swift", "Drake", "Beyoncé", "The Weeknd", "Kendrick Lamar"]), + "show": lambda: random.choice(["Stranger Things", "The Office", "Breaking Bad", "Succession", "The Bear"]), + "price": lambda: random.randint(30, 90), + "asset": lambda: random.choice(["ETFs", "gold", "real estate", "tech stocks"]), + "gadget": lambda: random.choice(["iPhone 16", "PS5 Pro", "M3 MacBook", "Vision Pro"]), + "food": lambda: random.choice(["sushi", "tacos", "pizza", "ramen", "curry"]), + "country": lambda: random.choice(["Japan", "Italy", "Mexico", "Thailand", "France"]), + "book": lambda: random.choice(["Atomic Habits", "Sapiens", "1984", "The Alchemist"]), + "skill": lambda: random.choice(["Python", "Spanish", "guitar", "cooking", "investing"]), + "place": lambda: random.choice(["Paris", "Tokyo", "Bali", "New York", "London"]), + "city": lambda: random.choice(["Barcelona", "New York", "Kyoto", "Rome", "Berlin"]), + "player": lambda: random.choice(["LeBron", "Messi", "Mahomes", "Curry", "Haaland"]), + "number": lambda: random.randint(5, 20), + "games": lambda: random.randint(100, 800), + "goals": lambda: random.randint(50, 500), + "assists": lambda: random.randint(30, 300), + "trophies": lambda: random.randint(5, 30), + } + + for i in range(n): + user_idx = random.randint(0, len(users) - 1) + user = users[user_idx] + + # Filter category based on trending mode and TOPIC_TREND + if GENERATION_MODE == "trending" and TOPIC_TREND: + # Only select from categories that are in TOPIC_TREND + available_categories = [cat for cat in user["interests"] if cat in TOPIC_TREND] + if not available_categories: + # If user has no interests in TOPIC_TREND, skip or use first TOPIC_TREND item + category = random.choice(TOPIC_TREND) + else: + category = random.choice(available_categories) + else: + # Normal mode or trending mode with empty TOPIC_TREND + category = random.choice(user["interests"]) + + # Random tweet type weights + w1 = random.randint(10, 30) # Regular (reduced) + w2 = random.randint(20, 40) # Media (reduced) + w3 = random.randint(40, 70) # Reply (increased) + w4 = random.randint(40, 70) # Quote (increased) + w5 = random.randint(30, 60) # Combined + + tweet_type = random.choices( + ["regular", "media", "reply", "quote", "combined"], + weights=[w1, w2, w3, w4, w5], k=1 + )[0] + + template = random.choice(CONTENT_TEMPLATES.get(category, CONTENT_TEMPLATES["General"])) + + # Build format args + args = {k: v() for k, v in format_kwargs.items()} + content = template.format(**args) + + # Add hashtags from category set + num_hashtags = random.randint(1, 3) + content = add_hashtags_to_content(content, category, num_hashtags) + + # Add emojis sometimes + if random.random() > 0.5: + emojis = ["🚀", "🔥", "🤯", "😂", "☕", "🌟", "✈️", "📈", "⚽", "🎉"] + content += " " + " ".join(random.sample(emojis, k=random.randint(1, 2))) + + # Add mentions sometimes (increased frequency) + if random.random() > 0.5: # 50% chance (was 15%) + # Allow mentions in all tweet types, but more likely in replies/combined + k_mentions = random.randint(1, 3) + possible_mentions = random.sample(list(usernames - {user["username"]}), k=min(k_mentions, len(usernames) - 1)) + mention_str = " ".join([f"@{m}" for m in possible_mentions[:random.randint(1, 2)]]) + content = mention_str + " " + content + + # Normalize content (spacing, lowercase hashtags) + content = normalize_content(content) + + # Extract hashtags and mentions + hashtags = extract_hashtags(content) + mentions = extract_mentions(content, usernames) + + # Generate media + media = [] + if tweet_type in ["media", "combined"]: + num_media = random.randint(1, 3) + for _ in range(num_media): + media_type = random.choice(["IMAGE", "VIDEO", "GIF"]) + if media_type == "IMAGE": + media.append({ + "url": random.choice(IMAGE_URLS), + "type": "IMAGE", + "width": random.randint(800, 1920), + "height": random.randint(600, 1080), + "altText": fake.sentence(nb_words=6) + }) + elif media_type == "VIDEO": + media.append({ + "url": random.choice(VIDEO_URLS), + "type": "VIDEO", + "altText": fake.sentence(nb_words=6) + }) + else: + media.append({ + "url": random.choice(GIF_URLS), + "type": "GIF", + "altText": fake.sentence(nb_words=6) + }) + + # Handle replies and quotes + reply_to = None + quoted = None + + # Determine if this tweet should optionally be a reply or quote + is_reply = tweet_type == "reply" + is_quote = tweet_type == "quote" + + if tweet_type == "combined": + # Combined tweets: 50% chance reply, 50% chance quote (plus they already have media) + if random.random() < 0.5: + is_reply = True + else: + is_quote = True + + if is_reply and reply_candidates: + reply_to = random.choice(reply_candidates) + elif is_quote and len(tweets) > 10: + quoted = random.choice(range(max(0, i - 200), i)) + + tweet = { + "userIndex": user_idx, + "content": content[:280], + "category": category, + "hashtags": hashtags, + "mentions": mentions, + "createdAt": generate_timestamp(), + "media": media, + "replyToTweetIndex": reply_to, + "quotedTweetIndex": quoted + } + + tweets.append(tweet) + reply_candidates.append(i) + + if random.random() < 0.15: + popular_tweet_indices.extend([i] * random.randint(3, 10)) + + return tweets, popular_tweet_indices + + +def generate_retweets(tweets: list, popular_indices: list, n: int = RETWEET_COUNT) -> list: + """Generate retweet data.""" + retweets = [] + users_count = USER_COUNT + + for _ in range(n): + tweet_idx = random.choice(popular_indices) if popular_indices else random.randint(0, len(tweets) - 1) + tweet = tweets[tweet_idx] + user_idx = random.randint(0, users_count - 1) + + while user_idx == tweet["userIndex"]: + user_idx = random.randint(0, users_count - 1) + + orig_time = datetime.fromisoformat(tweet["createdAt"][:-1]) + delta = timedelta(minutes=random.randint(1, 1440)) + retweet_time = (orig_time + delta).isoformat() + "Z" + + retweets.append({ + "userIndex": user_idx, + "tweetIndex": tweet_idx, + "createdAt": retweet_time + }) + + return retweets + + +def generate_likes(users: list, tweets: list, n_range: tuple = LIKE_COUNT_RANGE) -> list: + """Generate like data.""" + likes = [] + n = random.randint(*n_range) + used_pairs = set() + + for _ in range(n): + user_idx = random.randint(0, len(users) - 1) + tweet_idx = random.randint(0, len(tweets) - 1) + pair = (user_idx, tweet_idx) + + attempts = 0 + while pair in used_pairs and attempts < 5: + user_idx = random.randint(0, len(users) - 1) + tweet_idx = random.randint(0, len(tweets) - 1) + pair = (user_idx, tweet_idx) + attempts += 1 + + if pair in used_pairs: + continue + + used_pairs.add(pair) + + tweet = tweets[tweet_idx] + orig_time = datetime.fromisoformat(tweet["createdAt"][:-1]) + delta = timedelta(minutes=random.randint(1, 4000)) + like_time = (orig_time + delta).isoformat() + "Z" + + likes.append({ + "userIndex": user_idx, + "tweetIndex": tweet_idx, + "createdAt": like_time + }) + + return likes + + +# ============================================================================ +# MAIN EXECUTION +# ============================================================================ + +if __name__ == "__main__": + print(f"Generation Mode: {GENERATION_MODE}") + if GENERATION_MODE == "trending": + print(f"Trending Window: {TRENDING_START_TIME} to {TRENDING_END_TIME}") + + print("\nGenerating users...") + users = generate_users(USER_COUNT) + + print("Generating tweets...") + tweets, popular_indices = generate_tweets(users, TWEET_COUNT) + + print("Generating retweets...") + retweets = generate_retweets(tweets, popular_indices, RETWEET_COUNT) + + print("Generating likes...") + likes = generate_likes(users, tweets, LIKE_COUNT_RANGE) + + data = { + "meta": { + "mode": GENERATION_MODE + }, + "users": users, + "tweets": tweets, + "retweets": retweets, + "likes": likes + } + + print("Writing to file...") + with open("seed-data.json", "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + print(f"\nDone! Generated seed-data.json:") + print(f" - Users: {len(users)}") + print(f" - Tweets: {len(tweets)}") + print(f" - Retweets: {len(retweets)}") + print(f" - Likes: {len(likes)}") \ No newline at end of file diff --git a/prisma/migrations/20251212000000_separate_hashtags_from_trending_keywords/migration.sql b/prisma/migrations/20251212000000_separate_hashtags_from_trending_keywords/migration.sql index ec740298..1c9a6e58 100644 --- a/prisma/migrations/20251212000000_separate_hashtags_from_trending_keywords/migration.sql +++ b/prisma/migrations/20251212000000_separate_hashtags_from_trending_keywords/migration.sql @@ -20,6 +20,7 @@ WHERE "is_hashtag" = true GROUP BY "keyword"; -- Store old hashtag_id mapping in a temporary table for reference +DROP TABLE IF EXISTS "temp_hashtag_id_mapping"; CREATE TEMP TABLE "temp_hashtag_id_mapping" AS SELECT tk.id as old_id, h.id as new_id FROM "trending_keywords" tk diff --git a/prisma/seed-from-json.ts b/prisma/seed-from-json.ts new file mode 100644 index 00000000..8ab58281 --- /dev/null +++ b/prisma/seed-from-json.ts @@ -0,0 +1,663 @@ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { PrismaClient } from '@prisma/client'; +import * as fs from 'fs'; +import * as path from 'path'; + +const prisma = new PrismaClient(); + +interface UserData { + username: string; + email: string; + displayName: string; + bio?: string; + birthdate: string; + interests?: string[]; + location?: string; + avatarUrl?: string; + createdAt?: string; +} + +interface MediaData { + url: string; + type: 'IMAGE' | 'VIDEO' | 'GIF'; + width?: number; + height?: number; + altText?: string; +} + +interface TweetData { + userIndex: number; + content: string; + category: string; + hashtags?: string[]; + mentions?: string[]; + daysAgo?: number; + hoursAgo?: number; + minutesAgo?: number; + media?: MediaData[]; + quotedTweetIndex?: number; // Index in tweets array to quote + replyToTweetIndex?: number; // Index in tweets array to reply to + createdAt?: string; +} + +interface RetweetData { + userIndex: number; + tweetIndex: number; // Index of tweet to retweet + daysAgo?: number; + hoursAgo?: number; + minutesAgo?: number; + createdAt?: string; +} + +interface LikeData { + userIndex: number; + tweetIndex: number; + daysAgo?: number; + hoursAgo?: number; + minutesAgo?: number; + createdAt?: string; +} + +interface SeedData { + meta?: { + mode: string; + }; + users: UserData[]; + tweets: TweetData[]; + retweets?: RetweetData[]; + likes?: LikeData[]; +} + +function parseHashtags(content: string): string[] { + const hashtagRegex = /#(\w+)/g; + const matches = content.matchAll(hashtagRegex); + return Array.from(matches, (m) => m[1]); +} + +function parseMentions(content: string): string[] { + const mentionRegex = /@(\w+)/g; + const matches = content.matchAll(mentionRegex); + return Array.from(matches, (m) => m[1]); +} + +function calculateCreatedAt(daysAgo?: number, hoursAgo?: number, minutesAgo?: number): Date { + const now = new Date(); + + if (minutesAgo !== undefined) { + return new Date(now.getTime() - minutesAgo * 60 * 1000); + } + + if (hoursAgo !== undefined) { + return new Date(now.getTime() - hoursAgo * 60 * 60 * 1000); + } + + if (daysAgo !== undefined) { + return new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000); + } + + // Random time in last 30 days if nothing specified + const randomDays = Math.floor(Math.random() * 30); + return new Date(now.getTime() - randomDays * 24 * 60 * 60 * 1000); +} + +async function main() { + const dataPath = path.join(__dirname, 'seed-data.json'); + + if (!fs.existsSync(dataPath)) { + console.error(' seed-data.json not found!'); + console.log(' Create seed-data.json based on seed-data.json.example'); + process.exit(1); + } + + const data: SeedData = JSON.parse(fs.readFileSync(dataPath, 'utf-8')); + const isTrendingMode = data.meta?.mode === 'trending'; + + if (isTrendingMode) { + console.log(' Trending mode detected: Skipping database clean/wipe. Appending new data...'); + } else { + console.log(' Cleaning database...'); + // Clean in correct order to avoid foreign key violations + await prisma.tweet.updateMany({ + data: { quotedTweetId: null, replyToTweetId: null, rootTweetId: null }, + }); + await prisma.conversation.updateMany({ data: { lastMessageId: null } }); + await prisma.conversationParticipant.updateMany({ data: { lastSeenMessageId: null } }); + + await prisma.notification.deleteMany(); + await prisma.conversationParticipant.deleteMany(); + await prisma.message.deleteMany(); + await prisma.conversation.deleteMany(); + await prisma.like.deleteMany(); + await prisma.retweet.deleteMany(); + await prisma.tweetMedia.deleteMany(); + await prisma.media.deleteMany(); + await prisma.tweetHashtag.deleteMany(); + await prisma.trendingKeyword.deleteMany(); + await prisma.hashtag.deleteMany(); + await prisma.tweetMention.deleteMany(); + await prisma.tweet.deleteMany(); + await prisma.mute.deleteMany(); + await prisma.block.deleteMany(); + await prisma.follow.deleteMany(); + await prisma.refreshToken.deleteMany(); + await prisma.session.deleteMany(); + await prisma.userDevice.deleteMany(); + await prisma.userExternalAccount.deleteMany(); + await prisma.profile.deleteMany(); + await prisma.user.deleteMany(); + } + + console.log(` Creating ${data.users.length} users...`); + + // Bulk insert users + await prisma.user.createMany({ + data: data.users.map((userData) => ({ + username: userData.username, + email: userData.email, + passwordHash: '$2a$10$faoFdN3VO833Agy0pdZRS.OozTd8R5Z.aEUnK/1fxwByQjx/OPBii', // password: "password123" + birthdate: new Date(userData.birthdate), + interests: userData.interests || [], + })), + skipDuplicates: true, + }); + + // Fetch created users to get their IDs + const createdUsers = await prisma.user.findMany({ + where: { + username: { in: data.users.map((u) => u.username) }, + }, + orderBy: { createdAt: 'asc' }, + }); + + // Create a map for quick lookup + const usernameToUser = new Map(createdUsers.map((u) => [u.username, u])); + + // Bulk insert profiles + await prisma.profile.createMany({ + data: data.users + .map((userData) => { + const user = usernameToUser.get(userData.username); + if (!user) return null; + return { + userId: user.id, + displayName: userData.displayName, + bio: userData.bio || null, + location: userData.location || null, + avatarUrl: userData.avatarUrl || null, + }; + }) + .filter((profile) => profile !== null), + skipDuplicates: true, + }); + + console.log(`Created ${createdUsers.length} users`); + + console.log(`\nCreating ${data.tweets.length} tweets...`); + + // Create a map for username lookups + const usernameToId = new Map(createdUsers.map((u) => [u.username.toLowerCase(), u.id])); + + // Collect all unique hashtags and create them in bulk + const allHashtagsSet = new Set(); + data.tweets.forEach((tweetData) => { + const hashtagsInContent = parseHashtags(tweetData.content); + const allHashtags = [...new Set([...(tweetData.hashtags || []), ...hashtagsInContent])]; + allHashtags.forEach((tag) => allHashtagsSet.add(tag.toLowerCase())); + }); + + if (allHashtagsSet.size > 0) { + await prisma.hashtag.createMany({ + data: Array.from(allHashtagsSet).map((keyword) => ({ keyword })), + skipDuplicates: true, + }); + console.log(`Created ${allHashtagsSet.size} unique hashtags`); + } + + // Fetch all hashtags for lookup + const allHashtags = await prisma.hashtag.findMany(); + const hashtagMap = new Map(allHashtags.map((h) => [h.keyword, h.id])); + console.log(`Fetched ${allHashtags.length} hashtags for mapping`); + + // Prepare all media records for bulk insertion + const allMediaData: any[] = []; + data.tweets.forEach((tweetData, tweetIndex) => { + if (tweetData.media && tweetData.media.length > 0) { + const user = createdUsers[tweetData.userIndex]; + if (user) { + tweetData.media.forEach((mediaData, mediaIndex) => { + allMediaData.push({ + userId: user.id, + type: mediaData.type, + url: mediaData.url, + width: mediaData.width || null, + height: mediaData.height || null, + altText: mediaData.altText || null, + pending: false, + _tweetIndex: tweetIndex, + _mediaOrder: mediaIndex, + }); + }); + } + } + }); + + // Bulk insert media + if (allMediaData.length > 0) { + await prisma.media.createMany({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + data: allMediaData.map(({ _tweetIndex, _mediaOrder, ...data }) => data), + skipDuplicates: true, + }); + } + + // Fetch created media + const createdMedia = await prisma.media.findMany({ + where: { + userId: { in: createdUsers.map((u) => u.id) }, + }, + orderBy: { createdAt: 'asc' }, + }); + + // Create a map of media by URL for lookup + const mediaByUrl = new Map(createdMedia.map((m) => [m.url, m])); + + // First pass: Create all tweets without relationships (quotes/replies) + const tweetDataToCreate = data.tweets + .map((tweetData, index) => { + const user = createdUsers[tweetData.userIndex]; + if (!user) return null; + + const hashtagsInContent = parseHashtags(tweetData.content); + const allHashtags = [...new Set([...(tweetData.hashtags || []), ...hashtagsInContent])].map( + (tag) => tag.toLowerCase(), + ); + const mentionsInContent = parseMentions(tweetData.content); + const allMentions = [...new Set([...(tweetData.mentions || []), ...mentionsInContent])]; + const mentionedUserIds = allMentions + .map((username) => usernameToId.get(username.toLowerCase())) + .filter((id) => id !== undefined); + + const createdAt = tweetData.createdAt + ? new Date(tweetData.createdAt) + : calculateCreatedAt(tweetData.daysAgo, tweetData.hoursAgo, tweetData.minutesAgo); + + const hasMedia = tweetData.media && tweetData.media.length > 0; + + return { + userId: user.id, + content: tweetData.content, + class: null, + hasHashtags: allHashtags.length > 0, + hasMentions: mentionedUserIds.length > 0, + hasMedia, + createdAt, + _originalIndex: index, + _hashtags: allHashtags, + _mentions: allMentions, + _mentionedUserIds: mentionedUserIds, + _mediaUrls: tweetData.media?.map((m) => m.url) || [], + }; + }) + .filter((t) => t !== null); + + // Bulk insert tweets + await prisma.tweet.createMany({ + data: tweetDataToCreate.map( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ({ _originalIndex, _hashtags, _mentions, _mentionedUserIds, _mediaUrls, ...data }) => data, + ), + }); + + // Fetch created tweets + const createdTweets = await prisma.tweet.findMany({ + where: { + userId: { in: createdUsers.map((u) => u.id) }, + }, + orderBy: { createdAt: 'asc' }, + }); + + // Create a mapping from tweet data to created tweets using content + userId as key + const tweetMap = new Map(); + createdTweets.forEach((tweet) => { + const key = `${tweet.userId}-${tweet.content}-${tweet.createdAt.getTime()}`; + tweetMap.set(key, tweet); + }); + + // Map tweetDataToCreate to actual created tweets + const tweetDataWithIds = tweetDataToCreate.map((tweetData) => { + const key = `${tweetData.userId}-${tweetData.content}-${tweetData.createdAt.getTime()}`; + const tweet = tweetMap.get(key); + return { + ...tweetData, + _tweetId: tweet?.id, + _tweet: tweet, + }; + }); + + console.log(`Created ${createdTweets.length} tweets`); + + // Second pass: Update tweets with quote/reply relationships + const tweetsToUpdate: any[] = []; + data.tweets.forEach((tweetData, index) => { + if (tweetData.quotedTweetIndex !== undefined || tweetData.replyToTweetIndex !== undefined) { + const tweetWithId = tweetDataWithIds[index]; + if (tweetWithId && tweetWithId._tweetId) { + const updateData: any = {}; + + if (tweetData.quotedTweetIndex !== undefined) { + const quotedTweetWithId = tweetDataWithIds[tweetData.quotedTweetIndex]; + if (quotedTweetWithId && quotedTweetWithId._tweetId) { + updateData.quotedTweetId = quotedTweetWithId._tweetId; + } + } + + if (tweetData.replyToTweetIndex !== undefined) { + const replyToTweetWithId = tweetDataWithIds[tweetData.replyToTweetIndex]; + if (replyToTweetWithId && replyToTweetWithId._tweet) { + updateData.replyToTweetId = replyToTweetWithId._tweetId; + updateData.rootTweetId = + replyToTweetWithId._tweet.rootTweetId || replyToTweetWithId._tweetId; + } + } + + if (Object.keys(updateData).length > 0) { + tweetsToUpdate.push({ id: tweetWithId._tweetId, ...updateData }); + } + } + } + }); + + // Update tweets with relationships using transaction + if (tweetsToUpdate.length > 0) { + await prisma.$transaction( + tweetsToUpdate.map((update) => { + const { id, ...data } = update; + return prisma.tweet.update({ + where: { id }, + data, + }); + }), + ); + + console.log(`Updated ${tweetsToUpdate.length} tweets with quote/reply relationships`); + } + + // Bulk insert tweet hashtags + const tweetHashtagsToCreate: any[] = []; + tweetDataWithIds.forEach((tweetData) => { + if (tweetData._tweetId && tweetData._hashtags.length > 0) { + tweetData._hashtags.forEach((hashtag) => { + const hashtagId = hashtagMap.get(hashtag.toLowerCase()); + if (hashtagId) { + const hashtagText = `#${hashtag}`; + const startPosition = tweetData.content.indexOf(hashtagText); + tweetHashtagsToCreate.push({ + tweetId: tweetData._tweetId, + hashtagId, + startPosition: startPosition >= 0 ? startPosition : 0, + }); + } else { + console.warn( + `Warning: Hashtag "${hashtag}" not found in hashtagMap for tweet ${tweetData._tweetId}`, + ); + } + }); + } + }); + + if (tweetHashtagsToCreate.length > 0) { + await prisma.tweetHashtag.createMany({ + data: tweetHashtagsToCreate, + skipDuplicates: true, + }); + console.log(`Created ${tweetHashtagsToCreate.length} tweet-hashtag relationships`); + } else { + console.log('No tweet-hashtag relationships to create'); + } + + // Bulk insert tweet mentions + const tweetMentionsToCreate: any[] = []; + tweetDataWithIds.forEach((tweetData) => { + if (tweetData._tweetId && tweetData._mentionedUserIds.length > 0) { + tweetData._mentionedUserIds.forEach((userId, idx) => { + const mentionText = `@${tweetData._mentions[idx]}`; + const startPosition = tweetData.content.indexOf(mentionText); + tweetMentionsToCreate.push({ + tweetId: tweetData._tweetId, + userId, + startPosition: startPosition >= 0 ? startPosition : 0, + }); + }); + } + }); + + if (tweetMentionsToCreate.length > 0) { + await prisma.tweetMention.createMany({ + data: tweetMentionsToCreate, + skipDuplicates: true, + }); + console.log(`Created ${tweetMentionsToCreate.length} tweet-mention relationships`); + } + + // Bulk insert tweet media + const tweetMediaToCreate: any[] = []; + tweetDataWithIds.forEach((tweetData) => { + if (tweetData._tweetId && tweetData._mediaUrls.length > 0) { + tweetData._mediaUrls.forEach((url, order) => { + const media = mediaByUrl.get(url); + if (media) { + tweetMediaToCreate.push({ + tweetId: tweetData._tweetId, + mediaId: media.id, + order, + }); + } + }); + } + }); + + if (tweetMediaToCreate.length > 0) { + await prisma.tweetMedia.createMany({ + data: tweetMediaToCreate, + skipDuplicates: true, + }); + console.log(`Created ${tweetMediaToCreate.length} tweet-media relationships`); + } + + // Create retweets if present + if (data.retweets && data.retweets.length > 0) { + console.log(`\n Creating ${data.retweets.length} retweets...`); + + const retweetsToCreate = data.retweets + .map((retweetData) => { + const user = createdUsers[retweetData.userIndex]; + const tweetWithId = tweetDataWithIds[retweetData.tweetIndex]; + + if (!user || !tweetWithId || !tweetWithId._tweetId) { + console.error( + ` ❌ Invalid retweet reference: user ${retweetData.userIndex}, tweet ${retweetData.tweetIndex}`, + ); + return null; + } + + const createdAt = retweetData.createdAt + ? new Date(retweetData.createdAt) + : calculateCreatedAt(retweetData.daysAgo, retweetData.hoursAgo, retweetData.minutesAgo); + + return { + userId: user.id, + tweetId: tweetWithId._tweetId, + createdAt, + }; + }) + .filter((r) => r !== null); + + // Bulk insert retweets + await prisma.retweet.createMany({ + data: retweetsToCreate, + skipDuplicates: true, + }); + + // Update retweet counts + await prisma.$executeRawUnsafe(` + UPDATE "tweets" t + SET "retweet_count" = sub.count + FROM ( + SELECT "tweet_id", COUNT(*) AS count + FROM "retweets" + GROUP BY "tweet_id" + ) AS sub + WHERE t.id = sub.tweet_id; + `); + + console.log(`Created ${retweetsToCreate.length} retweets`); + } + + // Create likes if present + if (data.likes && data.likes.length > 0) { + console.log(`\n Creating ${data.likes.length} likes...`); + + const likesToCreate = data.likes + .map((likeData) => { + const user = createdUsers[likeData.userIndex]; + const tweetWithId = tweetDataWithIds[likeData.tweetIndex]; + + if (!user || !tweetWithId || !tweetWithId._tweetId) { + console.error( + ` ❌ Invalid like reference: user ${likeData.userIndex}, tweet ${likeData.tweetIndex}`, + ); + return null; + } + + const createdAt = likeData.createdAt + ? new Date(likeData.createdAt) + : calculateCreatedAt(likeData.daysAgo, likeData.hoursAgo, likeData.minutesAgo); + + return { + userId: user.id, + tweetId: tweetWithId._tweetId, + createdAt, + }; + }) + .filter((l) => l !== null); + + // Bulk insert likes + await prisma.like.createMany({ + data: likesToCreate, + skipDuplicates: true, + }); + + // Update like counts + await prisma.$executeRawUnsafe(` + UPDATE "tweets" t + SET "like_count" = sub.count + FROM ( + SELECT "tweet_id", COUNT(*) AS count + FROM "likes" + GROUP BY "tweet_id" + ) AS sub + WHERE t.id = sub.tweet_id; + `); + + console.log(`Created ${likesToCreate.length} likes`); + } + + // Create some random follows for engagement + console.log('\nCreating follow relationships...'); + const followsToCreate: Array<{ followerId: bigint; followedId: bigint }> = []; + + for (let i = 0; i < Math.min(createdUsers.length * 5, 5000); i++) { + const follower = createdUsers[Math.floor(Math.random() * createdUsers.length)]; + const followed = createdUsers[Math.floor(Math.random() * createdUsers.length)]; + + if (follower.id !== followed.id) { + followsToCreate.push({ + followerId: follower.id, + followedId: followed.id, + }); + } + } + + // Remove duplicates + const uniqueFollows = Array.from( + new Map(followsToCreate.map((f) => [`${f.followerId}-${f.followedId}`, f])).values(), + ); + + await prisma.follow.createMany({ + data: uniqueFollows, + skipDuplicates: true, + }); + + console.log(` Created ${uniqueFollows.length} follow relationships`); + + // Update follower/following counts + console.log('\n Updating follower/following counts...'); + await prisma.$executeRawUnsafe(` + UPDATE "users" u + SET "followers_count" = sub.count + FROM ( + SELECT "followed_id" AS user_id, COUNT(*) AS count + FROM "follows" + GROUP BY "followed_id" + ) AS sub + WHERE u.id = sub.user_id; + `); + + await prisma.$executeRawUnsafe(` + UPDATE "users" u + SET "following_count" = sub.count + FROM ( + SELECT "follower_id" AS user_id, COUNT(*) AS count + FROM "follows" + GROUP BY "follower_id" + ) AS sub + WHERE u.id = sub.user_id; + `); + + // Update tweet reply counts + console.log('\n Updating tweet reply counts...'); + await prisma.$executeRawUnsafe(` + UPDATE "tweets" t + SET "reply_count" = sub.count + FROM ( + SELECT "reply_to_tweet_id", COUNT(*) AS count + FROM "tweets" + WHERE "reply_to_tweet_id" IS NOT NULL + GROUP BY "reply_to_tweet_id" + ) AS sub + WHERE t.id = sub.reply_to_tweet_id; + `); + + const retweetCount = data.retweets?.length || 0; + const mediaCount = await prisma.media.count(); + const quotedCount = await prisma.tweet.count({ where: { quotedTweetId: { not: null } } }); + const replyCount = await prisma.tweet.count({ where: { replyToTweetId: { not: null } } }); + + console.log('\nDatabase seeded successfully!'); + console.log(` Users: ${createdUsers.length}`); + console.log(` Tweets: ${createdTweets.length}`); + console.log(` Hashtags: ${allHashtags.length}`); + console.log(` Tweet-Hashtag relationships: ${tweetHashtagsToCreate.length}`); + console.log(` Tweet-Mention relationships: ${tweetMentionsToCreate.length}`); + console.log(` Retweets: ${retweetCount}`); + console.log(` Replies: ${replyCount}`); + console.log(` Quotes: ${quotedCount}`); + console.log(` Media: ${mediaCount}`); + console.log(` Follows: ${uniqueFollows.length}`); + console.log(` Likes: ${data.likes?.length || 0}`); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); From 8e3dfd14429c2637b0714dd6f09f5cb965862721 Mon Sep 17 00:00:00 2001 From: anas-ibrahem <139391509+anas-ibrahem@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:01:09 +0200 Subject: [PATCH 38/43] test: content parsing - [CU-869bgfnyh] (#226) --- .../content-parsing.service.spec.ts | 576 ++++++++++++++++++ 1 file changed, 576 insertions(+) create mode 100644 test/content-parsing/content-parsing.service.spec.ts diff --git a/test/content-parsing/content-parsing.service.spec.ts b/test/content-parsing/content-parsing.service.spec.ts new file mode 100644 index 00000000..1a82e8cf --- /dev/null +++ b/test/content-parsing/content-parsing.service.spec.ts @@ -0,0 +1,576 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { ContentParsingService } from 'src/content-parsing/content-parsing.service'; +import { UsersService } from 'src/users/users.service'; +import { TrendingService } from 'src/trending/trending.service'; +import { Prisma } from '@prisma/client'; +import Groq from 'groq-sdk'; + +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/unbound-method */ + +// Mock Groq +jest.mock('groq-sdk'); + +describe('ContentParsingService', () => { + let service: ContentParsingService; + let usersService: jest.Mocked; + let trendingService: jest.Mocked; + let mockGroq: jest.Mocked; + + const mockTx = {} as Prisma.TransactionClient; + + beforeEach(async () => { + // Reset all mocks + jest.clearAllMocks(); + + // Create mock Groq instance + mockGroq = { + chat: { + completions: { + create: jest.fn(), + }, + }, + } as unknown as jest.Mocked; + + // Mock Groq constructor + (Groq as jest.MockedClass).mockImplementation(() => mockGroq); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ContentParsingService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'SUMMARY_API_KEY') return 'test-api-key'; + return undefined; + }), + }, + }, + { + provide: UsersService, + useValue: { + checkUsernamesExistenceAndReplaceIds: jest.fn(), + }, + }, + { + provide: TrendingService, + useValue: { + createOrGetHashtags: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ContentParsingService); + usersService = module.get(UsersService); + trendingService = module.get(TrendingService); + }); + + describe('initialization', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should throw error if SUMMARY_API_KEY is not configured', () => { + const badConfigService = { + get: jest.fn(() => undefined), + } as unknown as ConfigService; + + expect(() => { + new ContentParsingService({} as UsersService, {} as TrendingService, badConfigService); + }).toThrow('SUMMARY_API_KEY environment variable is required'); + }); + + it('should initialize Groq client with API key', () => { + expect(Groq).toHaveBeenCalledWith({ apiKey: 'test-api-key' }); + }); + }); + + describe('parseContentAndValidate', () => { + it('should return empty arrays for empty content', async () => { + const result = await service.parseContentAndValidate('', mockTx); + + expect(result).toEqual({ mentions: [], hashtags: [] }); + + expect(usersService.checkUsernamesExistenceAndReplaceIds).not.toHaveBeenCalled(); + + expect(trendingService.createOrGetHashtags).not.toHaveBeenCalled(); + }); + + it('should return empty arrays for null content', async () => { + const result = await service.parseContentAndValidate(null as unknown as string, mockTx); + + expect(result).toEqual({ mentions: [], hashtags: [] }); + }); + + it('should parse and validate mentions and hashtags', async () => { + const content = 'Hello @john and @jane! Check out #nodejs #typescript'; + + const mockMentions = [ + { username: 'john', startPosition: 6, userId: BigInt(1) }, + { username: 'jane', startPosition: 16, userId: BigInt(2) }, + ]; + + const mockHashtags = [ + { keyword: 'nodejs', startPosition: 33, hashtagId: BigInt(10) }, + { keyword: 'typescript', startPosition: 41, hashtagId: BigInt(11) }, + ]; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue(mockMentions); + trendingService.createOrGetHashtags.mockResolvedValue(mockHashtags); + + const result = await service.parseContentAndValidate(content, mockTx); + + expect(result.mentions).toEqual(mockMentions); + expect(result.hashtags).toEqual(mockHashtags); + + expect(usersService.checkUsernamesExistenceAndReplaceIds).toHaveBeenCalledWith( + [ + { username: 'john', startPosition: 6 }, + { username: 'jane', startPosition: 16 }, + ], + mockTx, + ); + + expect(trendingService.createOrGetHashtags).toHaveBeenCalledWith( + [ + { keyword: 'nodejs', startPosition: 33 }, + { keyword: 'typescript', startPosition: 41 }, + ], + mockTx, + ); + }); + + it('should handle content with only mentions', async () => { + const content = 'Hello @alice'; + + const mockMentions = [{ username: 'alice', startPosition: 6, userId: BigInt(1) }]; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue(mockMentions); + trendingService.createOrGetHashtags.mockResolvedValue([]); + + const result = await service.parseContentAndValidate(content, mockTx); + + expect(result.mentions).toEqual(mockMentions); + expect(result.hashtags).toEqual([]); + }); + + it('should handle content with only hashtags', async () => { + const content = 'Check this out #cool #trending'; + + const mockHashtags = [ + { keyword: 'cool', startPosition: 15, hashtagId: BigInt(1) }, + { keyword: 'trending', startPosition: 21, hashtagId: BigInt(2) }, + ]; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([]); + trendingService.createOrGetHashtags.mockResolvedValue(mockHashtags); + + const result = await service.parseContentAndValidate(content, mockTx); + + expect(result.mentions).toEqual([]); + expect(result.hashtags).toEqual(mockHashtags); + }); + + it('should handle content with no mentions or hashtags', async () => { + const content = 'Just a regular tweet without any special entities'; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([]); + trendingService.createOrGetHashtags.mockResolvedValue([]); + + const result = await service.parseContentAndValidate(content, mockTx); + + expect(result.mentions).toEqual([]); + expect(result.hashtags).toEqual([]); + }); + + it('should respect username length limit (1-15 chars)', async () => { + const content = '@a @abc @abcdefghij12345 @abcdefghij123456 @valid_user'; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([ + { username: 'a', startPosition: 0, userId: BigInt(1) }, + { username: 'abc', startPosition: 3, userId: BigInt(2) }, + { username: 'abcdefghij12345', startPosition: 8, userId: BigInt(3) }, + { username: 'valid_user', startPosition: 44, userId: BigInt(4) }, + ]); + trendingService.createOrGetHashtags.mockResolvedValue([]); + + const result = await service.parseContentAndValidate(content, mockTx); + + // Should capture @a, @abc, @abcdefghij12345 (15 chars), and @valid_user + // Should NOT capture @abcdefghij123456 (16 chars, too long) + expect(result.mentions).toHaveLength(4); + }); + + it('should respect hashtag length limit (1-100 chars)', async () => { + const content = `#a #abc #${'x'.repeat(100)} #${'y'.repeat(101)}`; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([]); + trendingService.createOrGetHashtags.mockResolvedValue([ + { keyword: 'a', startPosition: 0, hashtagId: BigInt(1) }, + { keyword: 'abc', startPosition: 3, hashtagId: BigInt(2) }, + { keyword: 'x'.repeat(100), startPosition: 8, hashtagId: BigInt(3) }, + ]); + + await service.parseContentAndValidate(content, mockTx); + + expect(trendingService.createOrGetHashtags).toHaveBeenCalledWith( + expect.arrayContaining([ + { keyword: 'a', startPosition: 0 }, + { keyword: 'abc', startPosition: 3 }, + { keyword: 'x'.repeat(100), startPosition: 8 }, + ]), + mockTx, + ); + }); + + it('should not match mentions/hashtags preceded by alphanumeric characters', async () => { + const content = 'email@john.com test#hashtag regular @mention and #tag'; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([ + { username: 'mention', startPosition: 40, userId: BigInt(1) }, + ]); + trendingService.createOrGetHashtags.mockResolvedValue([ + { keyword: 'tag', startPosition: 53, hashtagId: BigInt(1) }, + ]); + + const result = await service.parseContentAndValidate(content, mockTx); + + // Should only match @mention and #tag, not email@john or test#hashtag + expect(result.mentions).toHaveLength(1); + expect(result.mentions[0].username).toBe('mention'); + expect(result.hashtags).toHaveLength(1); + expect(result.hashtags[0].keyword).toBe('tag'); + }); + }); + + describe('parseContentForBio', () => { + it('should return empty arrays for empty content', async () => { + const result = await service.parseContentForBio('', mockTx); + + expect(result).toEqual({ mentions: [], hashtags: [] }); + }); + + it('should return empty arrays for null content', async () => { + const result = await service.parseContentForBio(null as unknown as string, mockTx); + + expect(result).toEqual({ mentions: [], hashtags: [] }); + }); + + it('should validate mentions but return plain hashtags', async () => { + const content = 'Bio with @user and #hashtag'; + + const mockMentions = [{ username: 'user', startPosition: 9, userId: BigInt(1) }]; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue(mockMentions); + + const result = await service.parseContentForBio(content, mockTx); + + expect(result.mentions).toEqual(mockMentions); + expect(result.hashtags).toEqual([{ keyword: 'hashtag', startPosition: 19 }]); + + expect(usersService.checkUsernamesExistenceAndReplaceIds).toHaveBeenCalledWith( + [{ username: 'user', startPosition: 9 }], + mockTx, + ); + + // Should NOT call trending service for bio hashtags + + expect(trendingService.createOrGetHashtags).not.toHaveBeenCalled(); + }); + + it('should handle bio with only mentions', async () => { + const content = 'Follow @alice and @bob'; + + const mockMentions = [ + { username: 'alice', startPosition: 7, userId: BigInt(1) }, + { username: 'bob', startPosition: 18, userId: BigInt(2) }, + ]; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue(mockMentions); + + const result = await service.parseContentForBio(content, mockTx); + + expect(result.mentions).toEqual(mockMentions); + expect(result.hashtags).toEqual([]); + }); + + it('should handle bio with only hashtags', async () => { + const content = 'Developer #nodejs #typescript'; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([]); + + const result = await service.parseContentForBio(content, mockTx); + + expect(result.mentions).toEqual([]); + expect(result.hashtags).toEqual([ + { keyword: 'nodejs', startPosition: 10 }, + { keyword: 'typescript', startPosition: 18 }, + ]); + }); + + it('should handle bio with no special entities', async () => { + const content = 'Just a regular bio'; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([]); + + const result = await service.parseContentForBio(content, mockTx); + + expect(result.mentions).toEqual([]); + expect(result.hashtags).toEqual([]); + }); + }); + + describe('generateTweetSummary', () => { + beforeEach(() => { + mockGroq.chat.completions.create.mockResolvedValue({ + choices: [ + { + message: { + content: 'The tweet is talking about testing and development.', + }, + }, + ], + } as never); + }); + + it('should generate summary in English by default', async () => { + const content = 'Testing my new feature!'; + + const summary = await service.generateTweetSummary(content); + + expect(summary).toBe('The tweet is talking about testing and development.'); + expect(mockGroq.chat.completions.create).toHaveBeenCalledWith({ + messages: [ + { + role: 'user', + content: expect.stringContaining('Summarize the following tweet'), + }, + ], + model: 'openai/gpt-oss-120b', + }); + }); + + it('should generate summary in English when langcode is en-US', async () => { + const content = 'Testing my new feature!'; + + await service.generateTweetSummary(content, 'en-US'); + + expect(mockGroq.chat.completions.create).toHaveBeenCalledWith({ + messages: [ + { + role: 'user', + content: expect.stringContaining('Summarize the following tweet'), + }, + ], + model: 'openai/gpt-oss-120b', + }); + }); + + it('should generate summary in Arabic when langcode starts with ar', async () => { + const content = 'اختبار الميزة الجديدة'; + + mockGroq.chat.completions.create.mockResolvedValue({ + choices: [ + { + message: { + content: 'التغريدة تتحدث عن اختبار ميزة جديدة.', + }, + }, + ], + } as never); + + const summary = await service.generateTweetSummary(content, 'ar-EG'); + + expect(summary).toBe('التغريدة تتحدث عن اختبار ميزة جديدة.'); + expect(mockGroq.chat.completions.create).toHaveBeenCalledWith({ + messages: [ + { + role: 'user', + content: expect.stringContaining('لخص التغريدة التالية'), + }, + ], + model: 'openai/gpt-oss-120b', + }); + }); + + it('should trim whitespace from summary', async () => { + const content = 'Test tweet'; + + mockGroq.chat.completions.create.mockResolvedValue({ + choices: [ + { + message: { + content: ' The tweet is talking about testing. ', + }, + }, + ], + } as never); + + const summary = await service.generateTweetSummary(content); + + expect(summary).toBe('The tweet is talking about testing.'); + }); + + it('should return empty string if no content in response', async () => { + const content = 'Test tweet'; + + mockGroq.chat.completions.create.mockResolvedValue({ + choices: [ + { + message: { + content: null, + }, + }, + ], + } as never); + + const summary = await service.generateTweetSummary(content); + + expect(summary).toBe(''); + }); + + it('should return empty string if choices array is empty', async () => { + const content = 'Test tweet'; + + mockGroq.chat.completions.create.mockResolvedValue({ + choices: [], + } as never); + + const summary = await service.generateTweetSummary(content); + + expect(summary).toBe(''); + }); + + it('should throw error when API call fails with Error instance', async () => { + const content = 'Test tweet'; + + mockGroq.chat.completions.create.mockRejectedValue(new Error('API error')); + + await expect(service.generateTweetSummary(content)).rejects.toThrow( + 'Failed to generate tweet summary', + ); + }); + + it('should throw error when API call fails with non-Error', async () => { + const content = 'Test tweet'; + + mockGroq.chat.completions.create.mockRejectedValue('Unknown error'); + + await expect(service.generateTweetSummary(content)).rejects.toThrow( + 'Failed to generate tweet summary', + ); + }); + + it('should include tweet content in prompt', async () => { + const content = 'This is my amazing tweet about TypeScript!'; + + await service.generateTweetSummary(content); + + expect(mockGroq.chat.completions.create).toHaveBeenCalledWith({ + messages: [ + { + role: 'user', + content: expect.stringContaining(content), + }, + ], + model: 'openai/gpt-oss-120b', + }); + }); + + it('should use correct model ID', async () => { + const content = 'Test'; + + await service.generateTweetSummary(content); + + expect(mockGroq.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'openai/gpt-oss-120b', + }), + ); + }); + }); + + describe('edge cases and special characters', () => { + it('should handle multiple consecutive mentions', async () => { + const content = '@user1@user2 @user3'; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([ + { username: 'user1', startPosition: 0, userId: BigInt(1) }, + { username: 'user3', startPosition: 13, userId: BigInt(3) }, + ]); + trendingService.createOrGetHashtags.mockResolvedValue([]); + + const result = await service.parseContentAndValidate(content, mockTx); + + // @user1 should match, @user2 should not (preceded by @user1), @user3 should match + expect(result.mentions.length).toBeGreaterThanOrEqual(1); + }); + + it('should handle mentions with underscores', async () => { + const content = 'Hello @user_name_123'; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([ + { username: 'user_name_123', startPosition: 6, userId: BigInt(1) }, + ]); + trendingService.createOrGetHashtags.mockResolvedValue([]); + + const result = await service.parseContentAndValidate(content, mockTx); + + expect(result.mentions[0].username).toBe('user_name_123'); + }); + + it('should handle hashtags with numbers', async () => { + const content = '#2024trends #nodejs2023'; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([]); + trendingService.createOrGetHashtags.mockResolvedValue([ + { keyword: '2024trends', startPosition: 0, hashtagId: BigInt(1) }, + { keyword: 'nodejs2023', startPosition: 12, hashtagId: BigInt(2) }, + ]); + + const result = await service.parseContentAndValidate(content, mockTx); + + expect(result.hashtags).toHaveLength(2); + }); + + it('should handle multiline content', async () => { + const content = `Line 1 with @user1 +Line 2 with #hashtag1 +Line 3 with @user2 and #hashtag2`; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([ + { username: 'user1', startPosition: 12, userId: BigInt(1) }, + { username: 'user2', startPosition: 54, userId: BigInt(2) }, + ]); + trendingService.createOrGetHashtags.mockResolvedValue([ + { keyword: 'hashtag1', startPosition: 31, hashtagId: BigInt(1) }, + { keyword: 'hashtag2', startPosition: 65, hashtagId: BigInt(2) }, + ]); + + const result = await service.parseContentAndValidate(content, mockTx); + + expect(result.mentions).toHaveLength(2); + expect(result.hashtags).toHaveLength(2); + }); + + it('should handle Unicode characters in content', async () => { + const content = '🎉 @user celebrating with #party 🎊'; + + usersService.checkUsernamesExistenceAndReplaceIds.mockResolvedValue([ + { username: 'user', startPosition: expect.any(Number), userId: BigInt(1) }, + ]); + trendingService.createOrGetHashtags.mockResolvedValue([ + { keyword: 'party', startPosition: expect.any(Number), hashtagId: BigInt(1) }, + ]); + + const result = await service.parseContentAndValidate(content, mockTx); + + expect(result.mentions).toHaveLength(1); + expect(result.hashtags).toHaveLength(1); + }); + }); +}); From e5e359237b34c6bdfda3ffc1163227d123537b4d Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Mon, 15 Dec 2025 23:13:18 +0200 Subject: [PATCH 39/43] fix: differentiate validation of following authors from interest authors - [CU-869bfnxa6] (#206) Co-authored-by: Tasneem Mohammed <149891742+Tasneemmohammed0@users.noreply.github.com> --- src/tweets/timeline/timeline.service.ts | 17 +++++++++++++---- src/tweets/tweets.repository.ts | 12 +++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/tweets/timeline/timeline.service.ts b/src/tweets/timeline/timeline.service.ts index 61489592..98973494 100644 --- a/src/tweets/timeline/timeline.service.ts +++ b/src/tweets/timeline/timeline.service.ts @@ -1240,6 +1240,7 @@ export class TimelineService { // 2. Get tweet candidates from the Following timeline cache const followingTweets = await this.getFollowingTimelineCandidates(userId, FOR_YOU_FEED_SIZE); const followingTweetIds = new Set(followingTweets.map((t) => t.id)); // Keep track of which IDs came from this source + const followingAuthorIds = new Set(followingTweets.map((t) => t.authorId)); this.logger.debug( `Got ${followingTweets.length} candidate tweets from Following timeline for user ${userId}`, @@ -1274,14 +1275,22 @@ export class TimelineService { } const candidateTweetIds = candidates.map((c) => BigInt(c.id)); - const candidateAuthorIds = new Set(candidates.map((c) => BigInt(c.authorId))); + const candidateAuthorIds = Array.from(new Set(candidates.map((c) => BigInt(c.authorId)))); - const [validAuthorIds, validTweetIds] = await Promise.all([ - this.tweetsRepository.filterNonMutedAuthors(userId, Array.from(candidateAuthorIds)), + const followingCandidateAuthorIds = candidateAuthorIds.filter((id) => + followingAuthorIds.has(id.toString()), + ); + const interestCandidateAuthorIds = candidateAuthorIds.filter( + (id) => !followingAuthorIds.has(id.toString()), + ); + + const [validFollowingAuthorIds, validInterestAuthorIds, validTweetIds] = await Promise.all([ + this.tweetsRepository.filterValidAuthors(userId, followingCandidateAuthorIds), + this.tweetsRepository.filterNonMutedNonBlockedAuthors(userId, interestCandidateAuthorIds), this.tweetsRepository.filterValidTweets(candidateTweetIds), ]); - validAuthorIds.push(userId); + const validAuthorIds = [userId, ...validFollowingAuthorIds, ...validInterestAuthorIds]; const validAuthorSet = new Set(validAuthorIds.map((id) => id.toString())); const validTweetSet = new Set(validTweetIds.map((id) => id.toString())); diff --git a/src/tweets/tweets.repository.ts b/src/tweets/tweets.repository.ts index d0b66c29..2d25ec88 100644 --- a/src/tweets/tweets.repository.ts +++ b/src/tweets/tweets.repository.ts @@ -1547,7 +1547,7 @@ export class TweetsRepository { return validFollows.map((f) => f.followedId); } - async filterNonMutedAuthors(userId: bigint, authorIds: bigint[]): Promise { + async filterNonMutedNonBlockedAuthors(userId: bigint, authorIds: bigint[]): Promise { const validAuthors = await this.prisma.user.findMany({ where: { id: { in: authorIds }, @@ -1557,6 +1557,16 @@ export class TweetsRepository { userId: userId, }, }, + blockedBy: { + none: { + userId: userId, + }, + }, + blockedUsers: { + none: { + blockedId: userId, + }, + }, }, select: { id: true }, }); From b28adb873ba5d4d28ead6ba220dd690b1b9ce469 Mon Sep 17 00:00:00 2001 From: Omar Gamal Date: Mon, 15 Dec 2025 23:17:33 +0200 Subject: [PATCH 40/43] fix: registration multiple devices - [CU-869bggfp2] (#227) --- src/auth/auth.service.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3a019ea5..33c2da19 100755 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -198,6 +198,17 @@ export class AuthService { ipAddress: ipAddress || 'unknown', userAgent: deviceType, }; + // check if email exists (maybe user registered in the meanwhile from another device) + const existingUser = await this.usersService.findByEmail(registrationData.email); + if (existingUser) { + throw new HttpException( + { + message: AUTH_ERROR_MESSAGES.EMAIL_REGISTERED, + code: AUTH_ERROR_CODES.EMAIL_REGISTERED, + }, + HttpStatus.BAD_REQUEST, + ); + } const userId = await this.createUserAndSessionAndToken(userData, newSession, refreshToken); const accessToken = await this.jwtService.signAsync({ id: userId.toString() }); From 4ee00242244bb27b058bcfce44764217dd1d982b Mon Sep 17 00:00:00 2001 From: Omar Hassan <149178126+OmarHassan2003@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:33:05 +0200 Subject: [PATCH 41/43] test: add unit tests for timeline - [CU-869bggc9v] (#225) --- .../tweets/timeline/timeline.consumer.spec.ts | 503 +++++ .../timeline/timeline.controller.spec.ts | 177 ++ .../timeline/timeline.events.service.spec.ts | 231 ++ test/tweets/timeline/timeline.service.spec.ts | 1856 +++++++++++++++++ 4 files changed, 2767 insertions(+) create mode 100644 test/tweets/timeline/timeline.consumer.spec.ts create mode 100644 test/tweets/timeline/timeline.controller.spec.ts create mode 100644 test/tweets/timeline/timeline.events.service.spec.ts create mode 100644 test/tweets/timeline/timeline.service.spec.ts diff --git a/test/tweets/timeline/timeline.consumer.spec.ts b/test/tweets/timeline/timeline.consumer.spec.ts new file mode 100644 index 00000000..23015684 --- /dev/null +++ b/test/tweets/timeline/timeline.consumer.spec.ts @@ -0,0 +1,503 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Job } from 'bullmq'; +import { TimelineConsumer } from 'src/tweets/timeline/timeline.consumer'; +import { RedisService } from 'src/redis/redis.service'; +import { UsersService } from 'src/users/users.service'; +import { TweetsRepository } from 'src/tweets/tweets.repository'; +import { + TweetFanoutJob, + RetweetFanoutJob, +} from 'src/tweets/timeline/interfaces/tweet-fanout-job.interface'; +import { BackfillFollowJob } from 'src/tweets/timeline/interfaces/backfill-follow-job.interface'; + +describe('TimelineConsumer', () => { + let consumer: TimelineConsumer; + + const mockPipeline = { + del: jest.fn().mockReturnThis(), + exists: jest.fn().mockReturnThis(), + zadd: jest.fn().mockReturnThis(), + zremrangebyrank: jest.fn().mockReturnThis(), + expire: jest.fn().mockReturnThis(), + zrem: jest.fn().mockReturnThis(), + exec: jest.fn(), + }; + + const mockRedisClient = { + pipeline: jest.fn().mockReturnValue(mockPipeline), + }; + + const mockRedisService = { + getClient: jest.fn().mockReturnValue(mockRedisClient), + }; + + const mockUsersService = { + getFollowersIds: jest.fn(), + }; + + const mockTweetsRepository = { + getRecentTweetsFromUser: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TimelineConsumer, + { + provide: RedisService, + useValue: mockRedisService, + }, + { + provide: UsersService, + useValue: mockUsersService, + }, + { + provide: TweetsRepository, + useValue: mockTweetsRepository, + }, + ], + }).compile(); + + consumer = module.get(TimelineConsumer); + + jest.clearAllMocks(); + mockPipeline.exec.mockResolvedValue([]); + }); + + it('should be defined', () => { + expect(consumer).toBeDefined(); + }); + + describe('process', () => { + it('should process fanout-tweet job', async () => { + const jobData: TweetFanoutJob = { + tweetId: '123', + authorId: '456', + timestamp: Date.now(), + }; + + const mockJob = { + id: 'job-1', + name: 'fanout-tweet', + data: jobData, + } as Job; + + mockUsersService.getFollowersIds.mockResolvedValue([BigInt(789), BigInt(101)]); + mockPipeline.exec.mockResolvedValue([ + [null, 1], + [null, 1], + [null, 1], + ]); + + await consumer.process(mockJob); + + expect(mockUsersService.getFollowersIds).toHaveBeenCalledWith(BigInt(456)); + }); + + it('should process fanout-retweet job', async () => { + const jobData: RetweetFanoutJob = { + tweetId: '123', + authorId: '456', + timestamp: Date.now(), + retweeterId: '789', + }; + + const mockJob = { + id: 'job-2', + name: 'fanout-retweet', + data: jobData, + } as Job; + + mockUsersService.getFollowersIds.mockResolvedValue([BigInt(101)]); + mockPipeline.exec.mockResolvedValue([ + [null, 1], + [null, 1], + ]); + + await consumer.process(mockJob); + + expect(mockUsersService.getFollowersIds).toHaveBeenCalledWith(BigInt(456)); + }); + + it('should process purge-retweet job', async () => { + const jobData: RetweetFanoutJob = { + tweetId: '123', + authorId: '456', + timestamp: Date.now(), + retweeterId: '789', + }; + + const mockJob = { + id: 'job-3', + name: 'purge-retweet', + data: jobData, + } as Job; + + mockUsersService.getFollowersIds.mockResolvedValue([BigInt(101)]); + + await consumer.process(mockJob); + + expect(mockUsersService.getFollowersIds).toHaveBeenCalledWith(BigInt(789)); + expect(mockPipeline.zrem).toHaveBeenCalled(); + expect(mockPipeline.exec).toHaveBeenCalled(); + }); + + it('should process backfill-follow job', async () => { + const jobData: BackfillFollowJob = { + followerId: '123', + followedId: '456', + followedAt: new Date(), + }; + + const mockJob = { + id: 'job-4', + name: 'backfill-follow', + data: jobData, + } as Job; + + mockTweetsRepository.getRecentTweetsFromUser.mockResolvedValue([ + { + id: BigInt(1001), + authorId: BigInt(456), + type: 'T', + createdAt: new Date(), + }, + ]); + + await consumer.process(mockJob); + + expect(mockTweetsRepository.getRecentTweetsFromUser).toHaveBeenCalledWith( + BigInt(456), + expect.any(Date), + expect.any(Number), + ); + expect(mockPipeline.zadd).toHaveBeenCalled(); + expect(mockPipeline.exec).toHaveBeenCalled(); + }); + + it('should handle backfill-follow job with no tweets to backfill', async () => { + const jobData: BackfillFollowJob = { + followerId: '123', + followedId: '456', + followedAt: new Date(), + }; + + const mockJob = { + id: 'job-5', + name: 'backfill-follow', + data: jobData, + } as Job; + + mockTweetsRepository.getRecentTweetsFromUser.mockResolvedValue([]); + + await consumer.process(mockJob); + + expect(mockTweetsRepository.getRecentTweetsFromUser).toHaveBeenCalled(); + expect(mockPipeline.zadd).not.toHaveBeenCalled(); + }); + + it('should remove unknown job type', async () => { + const removeMock = jest.fn().mockResolvedValue(undefined); + const mockJob = { + id: 'job-unknown', + name: 'unknown-job-type', + data: {}, + remove: removeMock, + } as unknown as Job; + + await consumer.process(mockJob); + + expect(removeMock).toHaveBeenCalled(); + }); + }); + + describe('fanoutTweetToTimelines', () => { + it('should fanout tweet to all active follower timelines', async () => { + const jobData: TweetFanoutJob = { + tweetId: '123', + authorId: '456', + timestamp: Date.now(), + }; + + const mockJob = { + id: 'job-fanout-1', + name: 'fanout-tweet', + data: jobData, + } as Job; + + mockUsersService.getFollowersIds.mockResolvedValue([BigInt(789), BigInt(101), BigInt(102)]); + mockPipeline.exec + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + [null, 1], + [null, 1], + [null, 1], + [null, 0], + ]) + .mockResolvedValueOnce([]); + + await consumer.fanoutTweetToTimelines(mockJob, 'T'); + + expect(mockUsersService.getFollowersIds).toHaveBeenCalledWith(BigInt(456)); + expect(mockPipeline.del).toHaveBeenCalled(); + expect(mockPipeline.exists).toHaveBeenCalled(); + expect(mockPipeline.zadd).toHaveBeenCalled(); + }); + + it('should fanout retweet with correct composite id', async () => { + const jobData: RetweetFanoutJob = { + tweetId: '123', + authorId: '456', + timestamp: Date.now(), + retweeterId: '789', + }; + + const mockJob = { + id: 'job-fanout-retweet', + name: 'fanout-retweet', + data: jobData, + } as Job; + + mockUsersService.getFollowersIds.mockResolvedValue([BigInt(101)]); + mockPipeline.exec + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + [null, 1], + [null, 1], + ]) + .mockResolvedValueOnce([]); + + await consumer.fanoutTweetToTimelines(mockJob, 'R'); + + expect(mockUsersService.getFollowersIds).toHaveBeenCalledWith(BigInt(456)); + expect(mockPipeline.zadd).toHaveBeenCalled(); + }); + + it('should not write to timelines when no active users exist', async () => { + const jobData: TweetFanoutJob = { + tweetId: '123', + authorId: '456', + timestamp: Date.now(), + }; + + const mockJob = { + id: 'job-no-active', + name: 'fanout-tweet', + data: jobData, + } as Job; + + mockUsersService.getFollowersIds.mockResolvedValue([BigInt(789)]); + mockPipeline.exec + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + [null, 0], + [null, 0], + ]) + .mockResolvedValueOnce([]); + + await consumer.fanoutTweetToTimelines(mockJob, 'T'); + + expect(mockUsersService.getFollowersIds).toHaveBeenCalled(); + }); + + it('should handle error during fanout and rethrow', async () => { + const jobData: TweetFanoutJob = { + tweetId: '123', + authorId: '456', + timestamp: Date.now(), + }; + + const mockJob = { + id: 'job-error', + name: 'fanout-tweet', + data: jobData, + } as Job; + + const error = new Error('Redis connection failed'); + mockUsersService.getFollowersIds.mockRejectedValue(error); + + await expect(consumer.fanoutTweetToTimelines(mockJob, 'T')).rejects.toThrow( + 'Redis connection failed', + ); + }); + + it('should return early when existingKeysResults is null', async () => { + const jobData: TweetFanoutJob = { + tweetId: '123', + authorId: '456', + timestamp: Date.now(), + }; + + const mockJob = { + id: 'job-null-results', + name: 'fanout-tweet', + data: jobData, + } as Job; + + mockUsersService.getFollowersIds.mockResolvedValue([BigInt(789)]); + mockPipeline.exec.mockResolvedValueOnce([]).mockResolvedValueOnce(null); + + await consumer.fanoutTweetToTimelines(mockJob, 'T'); + + expect(mockPipeline.exec).toHaveBeenCalledTimes(2); + }); + }); + + describe('purgeRetweetFromTimelines', () => { + it('should remove retweet from all follower timelines', async () => { + const jobData: RetweetFanoutJob = { + tweetId: '123', + authorId: '456', + timestamp: Date.now(), + retweeterId: '789', + }; + + const mockJob = { + id: 'job-purge-1', + name: 'purge-retweet', + data: jobData, + } as Job; + + mockUsersService.getFollowersIds.mockResolvedValue([BigInt(101), BigInt(102)]); + + await consumer.purgeRetweetFromTimelines(mockJob); + + expect(mockUsersService.getFollowersIds).toHaveBeenCalledWith(BigInt(789)); + expect(mockPipeline.zrem).toHaveBeenCalled(); + expect(mockPipeline.exec).toHaveBeenCalled(); + }); + + it('should handle error during purge and rethrow', async () => { + const jobData: RetweetFanoutJob = { + tweetId: '123', + authorId: '456', + timestamp: Date.now(), + retweeterId: '789', + }; + + const mockJob = { + id: 'job-purge-error', + name: 'purge-retweet', + data: jobData, + } as Job; + + const error = new Error('Purge failed'); + mockUsersService.getFollowersIds.mockRejectedValue(error); + + await expect(consumer.purgeRetweetFromTimelines(mockJob)).rejects.toThrow('Purge failed'); + }); + }); + + describe('backfillFollowToTimeline', () => { + it('should backfill tweets from followed user to follower timeline', async () => { + const jobData: BackfillFollowJob = { + followerId: '123', + followedId: '456', + followedAt: new Date('2024-01-01'), + }; + + const mockJob = { + id: 'job-backfill-1', + name: 'backfill-follow', + data: jobData, + } as Job; + + mockTweetsRepository.getRecentTweetsFromUser.mockResolvedValue([ + { + id: BigInt(1001), + authorId: BigInt(456), + type: 'T', + createdAt: new Date('2024-01-02'), + }, + { + id: BigInt(1002), + authorId: BigInt(456), + type: 'T', + createdAt: new Date('2024-01-03'), + }, + ]); + + await consumer.backfillFollowToTimeline(mockJob); + + expect(mockTweetsRepository.getRecentTweetsFromUser).toHaveBeenCalledWith( + BigInt(456), + new Date('2024-01-01'), + expect.any(Number), + ); + expect(mockPipeline.zadd).toHaveBeenCalledTimes(2); + expect(mockPipeline.zremrangebyrank).toHaveBeenCalled(); + expect(mockPipeline.del).toHaveBeenCalled(); + expect(mockPipeline.exec).toHaveBeenCalled(); + }); + + it('should handle retweets correctly during backfill', async () => { + const jobData: BackfillFollowJob = { + followerId: '123', + followedId: '456', + followedAt: new Date('2024-01-01'), + }; + + const mockJob = { + id: 'job-backfill-retweet', + name: 'backfill-follow', + data: jobData, + } as Job; + + mockTweetsRepository.getRecentTweetsFromUser.mockResolvedValue([ + { + id: BigInt(1001), + authorId: BigInt(789), + type: 'R', + retweeterId: BigInt(456), + createdAt: new Date('2024-01-02'), + }, + ]); + + await consumer.backfillFollowToTimeline(mockJob); + + expect(mockPipeline.zadd).toHaveBeenCalled(); + expect(mockPipeline.exec).toHaveBeenCalled(); + }); + + it('should not execute pipeline when no tweets to backfill', async () => { + const jobData: BackfillFollowJob = { + followerId: '123', + followedId: '456', + followedAt: new Date('2024-01-01'), + }; + + const mockJob = { + id: 'job-backfill-empty', + name: 'backfill-follow', + data: jobData, + } as Job; + + mockTweetsRepository.getRecentTweetsFromUser.mockResolvedValue([]); + + await consumer.backfillFollowToTimeline(mockJob); + + expect(mockTweetsRepository.getRecentTweetsFromUser).toHaveBeenCalled(); + expect(mockPipeline.zadd).not.toHaveBeenCalled(); + expect(mockPipeline.exec).not.toHaveBeenCalled(); + }); + + it('should handle error during backfill and rethrow', async () => { + const jobData: BackfillFollowJob = { + followerId: '123', + followedId: '456', + followedAt: new Date('2024-01-01'), + }; + + const mockJob = { + id: 'job-backfill-error', + name: 'backfill-follow', + data: jobData, + } as Job; + + const error = new Error('Backfill failed'); + mockTweetsRepository.getRecentTweetsFromUser.mockRejectedValue(error); + + await expect(consumer.backfillFollowToTimeline(mockJob)).rejects.toThrow('Backfill failed'); + }); + }); +}); diff --git a/test/tweets/timeline/timeline.controller.spec.ts b/test/tweets/timeline/timeline.controller.spec.ts new file mode 100644 index 00000000..628accfe --- /dev/null +++ b/test/tweets/timeline/timeline.controller.spec.ts @@ -0,0 +1,177 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TimelineController } from 'src/tweets/timeline/timeline.controller'; +import { TimelineService } from 'src/tweets/timeline/timeline.service'; + +describe('TimelineController', () => { + let controller: TimelineController; + const mockTimelineService: jest.Mocked> = { + getTimeline: jest.fn(), + getForYouFeed: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TimelineController], + providers: [{ provide: TimelineService, useValue: mockTimelineService }], + }).compile(); + + controller = module.get(TimelineController); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getTimeline', () => { + const mockUser = { id: '123' }; + const mockPagination = { limit: 20, cursor: undefined }; + + it('should return timeline tweets successfully', async () => { + const mockTimelineTweets = { + items: [ + { + id: '1', + content: 'Test tweet', + author: { id: '456', username: 'testuser' }, + }, + ], + pagination: { hasNextPage: false, nextCursor: null }, + }; + + (mockTimelineService.getTimeline as jest.Mock).mockResolvedValue(mockTimelineTweets); + + const result = await controller.getTimeline(mockPagination, mockUser); + + expect(mockTimelineService.getTimeline).toHaveBeenCalledWith(BigInt('123'), undefined, 20); + expect(result).toEqual({ + message: 'Timeline retrieved successfully', + ...mockTimelineTweets, + }); + }); + + it('should pass cursor to service when provided', async () => { + const paginationWithCursor = { limit: 10, cursor: 'someCursor123' }; + const mockTimelineTweets = { + items: [], + pagination: { hasNextPage: false, nextCursor: null }, + }; + + (mockTimelineService.getTimeline as jest.Mock).mockResolvedValue(mockTimelineTweets); + + await controller.getTimeline(paginationWithCursor, mockUser); + + expect(mockTimelineService.getTimeline).toHaveBeenCalledWith( + BigInt('123'), + 'someCursor123', + 10, + ); + }); + + it('should handle empty timeline', async () => { + const emptyTimeline = { + items: [], + pagination: { hasNextPage: false, nextCursor: null }, + }; + + (mockTimelineService.getTimeline as jest.Mock).mockResolvedValue(emptyTimeline); + + const result = await controller.getTimeline(mockPagination, mockUser); + + expect(result.items).toEqual([]); + expect(result.message).toBe('Timeline retrieved successfully'); + }); + + it('should propagate service errors', async () => { + const error = new Error('Service error'); + (mockTimelineService.getTimeline as jest.Mock).mockRejectedValue(error); + + await expect(controller.getTimeline(mockPagination, mockUser)).rejects.toThrow( + 'Service error', + ); + }); + }); + + describe('getForYouTimeline', () => { + const mockUser = { id: '456' }; + const mockPagination = { limit: 15, cursor: undefined }; + + it('should return For You timeline tweets successfully', async () => { + const mockForYouTweets = { + items: [ + { + id: '2', + content: 'For You tweet', + author: { id: '789', username: 'forYouUser' }, + }, + ], + pagination: { hasNextPage: true, nextCursor: 'nextCursor' }, + }; + + (mockTimelineService.getForYouFeed as jest.Mock).mockResolvedValue(mockForYouTweets); + + const result = await controller.getForYouTimeline(mockPagination, mockUser); + + expect(mockTimelineService.getForYouFeed).toHaveBeenCalledWith(BigInt('456'), undefined, 15); + expect(result).toEqual({ + message: 'For You Timeline retrieved successfully', + ...mockForYouTweets, + }); + }); + + it('should pass cursor to service when provided', async () => { + const paginationWithCursor = { limit: 25, cursor: 'forYouCursor' }; + const mockForYouTweets = { + items: [], + pagination: { hasNextPage: false, nextCursor: null }, + }; + + (mockTimelineService.getForYouFeed as jest.Mock).mockResolvedValue(mockForYouTweets); + + await controller.getForYouTimeline(paginationWithCursor, mockUser); + + expect(mockTimelineService.getForYouFeed).toHaveBeenCalledWith( + BigInt('456'), + 'forYouCursor', + 25, + ); + }); + + it('should handle empty For You timeline', async () => { + const emptyTimeline = { + items: [], + pagination: { hasNextPage: false, nextCursor: null }, + }; + + (mockTimelineService.getForYouFeed as jest.Mock).mockResolvedValue(emptyTimeline); + + const result = await controller.getForYouTimeline(mockPagination, mockUser); + + expect(result.items).toEqual([]); + expect(result.message).toBe('For You Timeline retrieved successfully'); + }); + + it('should propagate service errors', async () => { + const error = new Error('For You service error'); + (mockTimelineService.getForYouFeed as jest.Mock).mockRejectedValue(error); + + await expect(controller.getForYouTimeline(mockPagination, mockUser)).rejects.toThrow( + 'For You service error', + ); + }); + + it('should handle pagination with next page', async () => { + const mockForYouTweets = { + items: [{ id: '1' }, { id: '2' }], + pagination: { hasNextPage: true, nextCursor: 'cursorFor3' }, + }; + + (mockTimelineService.getForYouFeed as jest.Mock).mockResolvedValue(mockForYouTweets); + + const result = await controller.getForYouTimeline(mockPagination, mockUser); + + expect(result.pagination.hasNextPage).toBe(true); + expect(result.pagination.nextCursor).toBe('cursorFor3'); + }); + }); +}); diff --git a/test/tweets/timeline/timeline.events.service.spec.ts b/test/tweets/timeline/timeline.events.service.spec.ts new file mode 100644 index 00000000..f8574e7a --- /dev/null +++ b/test/tweets/timeline/timeline.events.service.spec.ts @@ -0,0 +1,231 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TimelineEventsService } from 'src/tweets/timeline/timeline.events.service'; +import { RedisService } from 'src/redis/redis.service'; +import { UsersRepository } from 'src/users/users.repository'; +import { SseEventsService } from 'src/sse/sse-events.service'; + +describe('TimelineEventsService', () => { + let service: TimelineEventsService; + + const mockMulti = { + zrevrange: jest.fn().mockReturnThis(), + del: jest.fn().mockReturnThis(), + exec: jest.fn(), + }; + + const mockRedisClient = { + smembers: jest.fn(), + multi: jest.fn().mockReturnValue(mockMulti), + del: jest.fn(), + }; + + const mockRedisService = { + getClient: jest.fn().mockReturnValue(mockRedisClient), + }; + + const mockUsersRepository = { + findAvatarUrlsByUserIds: jest.fn(), + }; + + const mockSseEventsService = { + publishTimelineFollowingTweets: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TimelineEventsService, + { provide: RedisService, useValue: mockRedisService }, + { provide: UsersRepository, useValue: mockUsersRepository }, + { provide: SseEventsService, useValue: mockSseEventsService }, + ], + }).compile(); + + service = module.get(TimelineEventsService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('handleNewTweetsCheck', () => { + it('should skip check when no users are online', async () => { + mockRedisClient.smembers.mockResolvedValue([]); + + await service.handleNewTweetsCheck(); + + expect(mockRedisClient.smembers).toHaveBeenCalled(); + expect(mockRedisClient.multi).not.toHaveBeenCalled(); + expect(mockUsersRepository.findAvatarUrlsByUserIds).not.toHaveBeenCalled(); + }); + + it('should process online users with new tweet indicators', async () => { + mockRedisClient.smembers.mockResolvedValue(['123', '456']); + mockMulti.exec.mockResolvedValue([ + [null, ['789', '101']], + [null, 1], + ]); + mockUsersRepository.findAvatarUrlsByUserIds.mockResolvedValue( + new Map([ + ['789', 'https://example.com/avatar1.jpg'], + ['101', 'https://example.com/avatar2.jpg'], + ]), + ); + mockSseEventsService.publishTimelineFollowingTweets.mockResolvedValue(undefined); + mockRedisClient.del.mockResolvedValue(undefined); + + await service.handleNewTweetsCheck(); + + expect(mockRedisClient.smembers).toHaveBeenCalled(); + expect(mockRedisClient.multi).toHaveBeenCalled(); + expect(mockMulti.zrevrange).toHaveBeenCalled(); + expect(mockMulti.del).toHaveBeenCalled(); + expect(mockMulti.exec).toHaveBeenCalled(); + expect(mockUsersRepository.findAvatarUrlsByUserIds).toHaveBeenCalled(); + expect(mockSseEventsService.publishTimelineFollowingTweets).toHaveBeenCalled(); + }); + + it('should skip user when no new actor IDs exist', async () => { + mockRedisClient.smembers.mockResolvedValue(['123']); + mockMulti.exec.mockResolvedValue([ + [null, []], + [null, 0], + ]); + + await service.handleNewTweetsCheck(); + + expect(mockUsersRepository.findAvatarUrlsByUserIds).not.toHaveBeenCalled(); + expect(mockSseEventsService.publishTimelineFollowingTweets).not.toHaveBeenCalled(); + }); + + it('should handle transaction error gracefully', async () => { + mockRedisClient.smembers.mockResolvedValue(['123']); + mockMulti.exec.mockResolvedValue([ + [new Error('Transaction error'), null], + [null, 0], + ]); + + await service.handleNewTweetsCheck(); + + expect(mockUsersRepository.findAvatarUrlsByUserIds).not.toHaveBeenCalled(); + expect(mockSseEventsService.publishTimelineFollowingTweets).not.toHaveBeenCalled(); + }); + + it('should handle null transaction result', async () => { + mockRedisClient.smembers.mockResolvedValue(['123']); + mockMulti.exec.mockResolvedValue(null); + + await service.handleNewTweetsCheck(); + + expect(mockUsersRepository.findAvatarUrlsByUserIds).not.toHaveBeenCalled(); + }); + + it('should handle errors per user without affecting others', async () => { + mockRedisClient.smembers.mockResolvedValue(['123', '456']); + mockMulti.exec + .mockRejectedValueOnce(new Error('Redis error for user 123')) + .mockResolvedValueOnce([ + [null, ['789']], + [null, 1], + ]); + mockUsersRepository.findAvatarUrlsByUserIds.mockResolvedValue( + new Map([['789', 'https://example.com/avatar.jpg']]), + ); + mockSseEventsService.publishTimelineFollowingTweets.mockResolvedValue(undefined); + mockRedisClient.del.mockResolvedValue(undefined); + + await service.handleNewTweetsCheck(); + + expect(mockSseEventsService.publishTimelineFollowingTweets).toHaveBeenCalledWith( + BigInt('456'), + expect.any(Array), + ); + }); + + it('should limit actor IDs to 3', async () => { + mockRedisClient.smembers.mockResolvedValue(['123']); + mockMulti.exec.mockResolvedValue([ + [null, ['1', '2', '3', '4', '5']], + [null, 1], + ]); + mockUsersRepository.findAvatarUrlsByUserIds.mockResolvedValue( + new Map([ + ['1', 'avatar1'], + ['2', 'avatar2'], + ['3', 'avatar3'], + ]), + ); + mockSseEventsService.publishTimelineFollowingTweets.mockResolvedValue(undefined); + mockRedisClient.del.mockResolvedValue(undefined); + + await service.handleNewTweetsCheck(); + + expect(mockUsersRepository.findAvatarUrlsByUserIds).toHaveBeenCalledWith([ + BigInt('1'), + BigInt('2'), + BigInt('3'), + ]); + }); + + it('should order avatars according to actor IDs', async () => { + mockRedisClient.smembers.mockResolvedValue(['123']); + mockMulti.exec.mockResolvedValue([ + [null, ['3', '1', '2']], + [null, 1], + ]); + mockUsersRepository.findAvatarUrlsByUserIds.mockResolvedValue( + new Map([ + ['1', 'avatar1'], + ['2', 'avatar2'], + ['3', 'avatar3'], + ]), + ); + mockSseEventsService.publishTimelineFollowingTweets.mockResolvedValue(undefined); + mockRedisClient.del.mockResolvedValue(undefined); + + await service.handleNewTweetsCheck(); + + expect(mockSseEventsService.publishTimelineFollowingTweets).toHaveBeenCalledWith( + BigInt('123'), + ['avatar3', 'avatar1', 'avatar2'], + ); + }); + + it('should delete indicator key after publishing', async () => { + mockRedisClient.smembers.mockResolvedValue(['123']); + mockMulti.exec.mockResolvedValue([ + [null, ['789']], + [null, 1], + ]); + mockUsersRepository.findAvatarUrlsByUserIds.mockResolvedValue(new Map([['789', 'avatar']])); + mockSseEventsService.publishTimelineFollowingTweets.mockResolvedValue(undefined); + mockRedisClient.del.mockResolvedValue(undefined); + + await service.handleNewTweetsCheck(); + + expect(mockRedisClient.del).toHaveBeenCalled(); + }); + + it('should handle non-array exec result', async () => { + mockRedisClient.smembers.mockResolvedValue(['123']); + mockMulti.exec.mockResolvedValue('not an array'); + + await service.handleNewTweetsCheck(); + + expect(mockUsersRepository.findAvatarUrlsByUserIds).not.toHaveBeenCalled(); + }); + + it('should handle exec result with non-array actor IDs', async () => { + mockRedisClient.smembers.mockResolvedValue(['123']); + mockMulti.exec.mockResolvedValue([ + [null, 'not an array'], + [null, 0], + ]); + + await service.handleNewTweetsCheck(); + + expect(mockUsersRepository.findAvatarUrlsByUserIds).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/tweets/timeline/timeline.service.spec.ts b/test/tweets/timeline/timeline.service.spec.ts new file mode 100644 index 00000000..7f1ab7e2 --- /dev/null +++ b/test/tweets/timeline/timeline.service.spec.ts @@ -0,0 +1,1856 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { TimelineService } from 'src/tweets/timeline/timeline.service'; +import { RedisService } from 'src/redis/redis.service'; +import { TweetsRepository } from 'src/tweets/tweets.repository'; +import { UsersRepository } from 'src/users/users.repository'; +import { PAGINATION_ERROR_CODES, PAGINATION_ERROR_MESSAGES } from 'src/common/constants'; +import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; + +const encodeCompositeCursor = (cursorObject: object): string => { + const jsonString = JSON.stringify(cursorObject); + return Buffer.from(jsonString).toString('base64'); +}; + +describe('TimelineService', () => { + let service: TimelineService; + + const mockPipeline = { + get: jest.fn().mockReturnThis(), + getex: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + setex: jest.fn().mockReturnThis(), + del: jest.fn().mockReturnThis(), + exists: jest.fn().mockReturnThis(), + zadd: jest.fn().mockReturnThis(), + zrevrangebyscore: jest.fn().mockReturnThis(), + zrevrange: jest.fn().mockReturnThis(), + zremrangebyrank: jest.fn().mockReturnThis(), + zscore: jest.fn().mockReturnThis(), + expire: jest.fn().mockReturnThis(), + sadd: jest.fn().mockReturnThis(), + smembers: jest.fn().mockReturnThis(), + exec: jest.fn(), + }; + + const mockRedisClient = { + pipeline: jest.fn().mockReturnValue(mockPipeline), + exists: jest.fn(), + get: jest.fn(), + set: jest.fn(), + setex: jest.fn(), + del: jest.fn(), + zrevrangebyscore: jest.fn(), + zrevrange: jest.fn(), + zscore: jest.fn(), + expire: jest.fn(), + sadd: jest.fn(), + smembers: jest.fn(), + }; + + const mockRedisService = { + getClient: jest.fn().mockReturnValue(mockRedisClient), + }; + + const mockTweetsRepository = { + getTimelineForUser: jest.fn(), + filterValidAuthors: jest.fn(), + filterValidTweets: jest.fn(), + filterNonMutedAuthors: jest.fn(), + getTweetsByIds: jest.fn(), + getCompactAuthorsByIds: jest.fn(), + getTweetCounts: jest.fn(), + getUserTweetInteractions: jest.fn(), + getAuthorRelationships: jest.fn(), + getTweetsMatchingInterests: jest.fn(), + }; + + const mockUsersRepository = { + getFollowingIds: jest.fn(), + getUserInterests: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TimelineService, + { + provide: RedisService, + useValue: mockRedisService, + }, + { + provide: TweetsRepository, + useValue: mockTweetsRepository, + }, + { + provide: UsersRepository, + useValue: mockUsersRepository, + }, + ], + }).compile(); + + service = module.get(TimelineService); + + jest.clearAllMocks(); + mockPipeline.exec.mockResolvedValue([]); + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getTimeline', () => { + const userId = BigInt(1); + + it('should return empty timeline when empty placeholder exists', async () => { + mockRedisClient.exists.mockResolvedValueOnce(0).mockResolvedValueOnce(1); + + const result = await service.getTimeline(userId, undefined, 10); + + expect(result.items).toEqual([]); + expect(result.pagination).toBeDefined(); + }); + + it('should throw BAD_REQUEST for invalid cursor', async () => { + await expect(service.getTimeline(userId, 'invalid-cursor', 10)).rejects.toThrow( + new HttpException( + { + message: PAGINATION_ERROR_MESSAGES.INVALID_CURSOR, + code: PAGINATION_ERROR_CODES.INVALID_CURSOR, + }, + HttpStatus.BAD_REQUEST, + ), + ); + }); + + it('should process valid cursor correctly', async () => { + const cursor = encodeCompositeCursor({ + createdAt: new Date().toISOString(), + id: '123', + seenIds: ['100', '101'], + }); + + mockRedisClient.exists.mockResolvedValue(1); + mockRedisClient.zrevrangebyscore.mockResolvedValue([]); + + const result = await service.getTimeline(userId, cursor, 10); + + expect(result.items).toEqual([]); + }); + + it('should call timelineCacheMiss when timeline key does not exist and no empty placeholder', async () => { + mockRedisClient.exists.mockResolvedValueOnce(0).mockResolvedValueOnce(0); + + mockTweetsRepository.getTimelineForUser.mockResolvedValue([]); + + const result = await service.getTimeline(userId, undefined, 10); + + expect(mockTweetsRepository.getTimelineForUser).toHaveBeenCalled(); + expect(result.items).toEqual([]); + }); + }); + + describe('timelineCacheHit', () => { + const userId = BigInt(1); + + it('should return empty array when timeline empty placeholder exists', async () => { + mockRedisClient.exists.mockResolvedValue(1); + + const result = await service.timelineCacheHit(userId, undefined, 10, new Set()); + + expect(result).toEqual([]); + expect(mockRedisClient.expire).toHaveBeenCalled(); + }); + + it('should return empty array when no timeline items found', async () => { + mockRedisClient.exists.mockResolvedValue(0); + mockRedisClient.zrevrangebyscore.mockResolvedValue([]); + + const result = await service.timelineCacheHit(userId, undefined, 10, new Set()); + + expect(result).toEqual([]); + }); + + it('should filter and hydrate timeline items', async () => { + mockRedisClient.exists.mockResolvedValueOnce(0).mockResolvedValueOnce(0); + mockRedisClient.zrevrangebyscore.mockResolvedValue(['456:123:T']); + + mockTweetsRepository.filterValidAuthors.mockResolvedValue([BigInt(456)]); + mockTweetsRepository.filterValidTweets.mockResolvedValue([BigInt(123)]); + + mockPipeline.exec + .mockResolvedValueOnce([ + [ + null, + JSON.stringify({ + id: '123', + authorId: '456', + content: 'Hello', + createdAt: new Date().toISOString(), + }), + ], + [null, JSON.stringify({ id: '456', username: 'testuser', displayName: 'Test' })], + ]) + .mockResolvedValueOnce([ + [null, '5'], + [null, '2'], + [null, '1'], + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue( + new Map([[BigInt(123), { isLiked: false, isRetweeted: false }]]), + ); + + mockTweetsRepository.getAuthorRelationships.mockResolvedValue( + new Map([ + [ + '456', + { + id: '456', + username: 'testuser', + displayName: 'Test', + isFollowing: false, + isFollowedBy: false, + }, + ], + ]), + ); + + const result = await service.timelineCacheHit(userId, undefined, 10, new Set()); + + expect(result).toBeDefined(); + expect(mockTweetsRepository.filterValidAuthors).toHaveBeenCalled(); + expect(mockTweetsRepository.filterValidTweets).toHaveBeenCalled(); + }); + + it('should filter out seen tweets from cross-request set', async () => { + const seenSet = new Set(['123']); + + mockRedisClient.exists.mockResolvedValue(0); + mockRedisClient.zrevrangebyscore.mockResolvedValue(['456:123:T', '789:124:T']); + + mockTweetsRepository.filterValidAuthors.mockResolvedValue([BigInt(789)]); + mockTweetsRepository.filterValidTweets.mockResolvedValue([BigInt(124)]); + + mockPipeline.exec + .mockResolvedValueOnce([ + [ + null, + JSON.stringify({ + id: '124', + authorId: '789', + content: 'Test', + createdAt: new Date().toISOString(), + }), + ], + [null, JSON.stringify({ id: '789', username: 'user2', displayName: 'User 2' })], + ]) + .mockResolvedValueOnce([ + [null, '3'], + [null, '1'], + [null, '0'], + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue(new Map()); + mockTweetsRepository.getAuthorRelationships.mockResolvedValue( + new Map([['789', { id: '789', username: 'user2' }]]), + ); + + await service.timelineCacheHit(userId, undefined, 10, seenSet); + + expect(mockTweetsRepository.filterValidTweets).toHaveBeenCalled(); + }); + }); + + describe('backFillStaticDataToCache', () => { + it('should return empty arrays when no missing data', async () => { + const result = await service.backFillStaticDataToCache([], new Set()); + + expect(result).toEqual({ tweets: [], authors: [] }); + }); + + it('should fetch and cache missing tweets and authors', async () => { + const missingTweetIds = [BigInt(123)]; + const missingAuthorIds = new Set([BigInt(456)]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([ + { id: '123', authorId: '456', content: 'Test' }, + ]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([ + { id: '456', username: 'testuser' }, + ]); + mockPipeline.exec.mockResolvedValue([]); + + const result = await service.backFillStaticDataToCache(missingTweetIds, missingAuthorIds); + + expect(result.tweets).toHaveLength(1); + expect(result.authors).toHaveLength(1); + expect(mockPipeline.set).toHaveBeenCalled(); + expect(mockPipeline.exec).toHaveBeenCalled(); + }); + }); + + describe('getAndBackfillTweetDynamicData', () => { + it('should hydrate dynamic data from cache', async () => { + const tweets = new Map([ + [ + '123', + { + id: '123', + authorId: '456', + content: 'Test', + createdAt: new Date(), + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + quoteToTweetId: null, + rootTweetId: null, + }, + ], + ]); + const userId = BigInt(1); + + mockPipeline.exec.mockResolvedValue([ + [null, '10'], + [null, '5'], + [null, '2'], + ]); + + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue( + new Map([[BigInt(123), { isLiked: true, isRetweeted: false }]]), + ); + + const result = await service.getAndBackfillTweetDynamicData(tweets, userId); + + expect(result.likeCounts.get(BigInt(123))).toBe(10); + expect(result.retweetCounts.get(BigInt(123))).toBe(5); + expect(result.replyCounts.get(BigInt(123))).toBe(2); + }); + + it('should backfill missing counts from DB', async () => { + const tweets = new Map([ + [ + '123', + { + id: '123', + authorId: '456', + content: 'Test', + createdAt: new Date(), + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + quoteToTweetId: null, + rootTweetId: null, + }, + ], + ]); + const userId = BigInt(1); + + mockPipeline.exec.mockResolvedValue([ + [null, null], + [null, null], + [null, null], + ]); + + mockTweetsRepository.getTweetCounts.mockResolvedValue( + new Map([['123', { likeCounts: 15, retweetCounts: 8, replyCounts: 3 }]]), + ); + + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue(new Map()); + + const result = await service.getAndBackfillTweetDynamicData(tweets, userId); + + expect(mockTweetsRepository.getTweetCounts).toHaveBeenCalled(); + expect(result.likeCounts.get(BigInt(123))).toBe(15); + }); + + it('should handle null pipeline result gracefully', async () => { + const tweets = new Map([ + [ + '123', + { + id: '123', + authorId: '456', + content: 'Test', + createdAt: new Date(), + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + quoteToTweetId: null, + rootTweetId: null, + }, + ], + ]); + const userId = BigInt(1); + + mockPipeline.exec.mockResolvedValue(null); + + const result = await service.getAndBackfillTweetDynamicData(tweets, userId); + + expect(result.likeCounts.size).toBe(0); + expect(result.userTweetInteractions.size).toBe(0); + }); + }); + + describe('hydrateStaticQuoteData', () => { + beforeEach(() => { + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + }); + + it('should return empty maps when no quote tweet ids provided', async () => { + mockPipeline.exec.mockResolvedValue([]); + + const result = await service.hydrateStaticQuoteData([]); + + expect(result.tweets).toBeDefined(); + expect(result.authors).toBeDefined(); + }); + + it('should hydrate quote tweets from cache', async () => { + const tweetData = { id: '999', authorId: '888', content: 'Quote' }; + const authorData = { id: '888', username: 'quoteauthor' }; + + mockPipeline.exec + .mockResolvedValueOnce([[null, JSON.stringify(tweetData)]]) + .mockResolvedValueOnce([[null, JSON.stringify(authorData)]]) + .mockResolvedValueOnce([]); + + const result = await service.hydrateStaticQuoteData(['999']); + + expect(result.tweets.has('999')).toBe(true); + expect(result.authors.has('888')).toBe(true); + }); + + it('should backfill missing quote tweets from DB', async () => { + mockPipeline.exec + .mockResolvedValueOnce([[null, null]]) + .mockResolvedValueOnce([[null, null]]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([ + { id: '999', authorId: '888', content: 'Quote from DB' }, + ]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([ + { id: '888', username: 'dbauthor' }, + ]); + + const result = await service.hydrateStaticQuoteData(['999']); + + expect(result).toBeDefined(); + expect(mockTweetsRepository.getTweetsByIds).toHaveBeenCalled(); + }); + + it('should handle null pipeline result gracefully', async () => { + mockPipeline.exec.mockResolvedValue(null); + + const result = await service.hydrateStaticQuoteData(['999']); + + expect(result.tweets.size).toBe(0); + expect(result.authors.size).toBe(0); + }); + }); + + describe('assembleTimelineTweets', () => { + it('should assemble tweet DTOs correctly', () => { + const items = ['456:123:T']; + const tweets = new Map([ + [ + '123', + { + id: '123', + authorId: '456', + content: 'Hello', + createdAt: new Date(), + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + quoteToTweetId: null, + rootTweetId: null, + }, + ], + ]); + const authors = new Map([ + [ + '456', + { + id: '456', + username: 'testuser', + displayName: 'Test User', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }, + ], + ]); + const fullAuthors = new Map([ + [ + '456', + { + id: '456', + username: 'testuser', + displayName: 'Test User', + avatarUrl: DEFAULT_PROFILE_PICTURE, + relationship: { + following: false, + follower: false, + }, + }, + ], + ]); + const dynamicData = { + likeCounts: new Map([[BigInt(123), 10]]), + retweetCounts: new Map([[BigInt(123), 5]]), + replyCounts: new Map([[BigInt(123), 2]]), + userTweetInteractions: new Map([[BigInt(123), { isLiked: true, isRetweeted: false }]]), + }; + + const result = service.assembleTimelineTweets( + items, + tweets, + authors, + fullAuthors, + dynamicData, + ); + + expect(result).toHaveLength(1); + expect(result[0].likeCount).toBe(10); + expect(result[0].isLiked).toBe(true); + }); + + it('should handle retweets with repostedBy field', () => { + const items = ['456:123:R:789']; + const tweets = new Map([ + [ + '123', + { + id: '123', + authorId: '456', + content: 'Hello', + createdAt: new Date(), + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + quoteToTweetId: null, + rootTweetId: null, + }, + ], + ]); + const authors = new Map([ + [ + '456', + { + id: '456', + username: 'author', + displayName: 'Author', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }, + ], + [ + '789', + { + id: '789', + username: 'retweeter', + displayName: 'Retweeter', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }, + ], + ]); + const fullAuthors = new Map([ + [ + '456', + { + id: '456', + username: 'author', + displayName: 'Author', + avatarUrl: DEFAULT_PROFILE_PICTURE, + relationship: { + following: false, + follower: false, + }, + }, + ], + ]); + const dynamicData = { + likeCounts: new Map(), + retweetCounts: new Map(), + replyCounts: new Map(), + userTweetInteractions: new Map(), + }; + + const result = service.assembleTimelineTweets( + items, + tweets, + authors, + fullAuthors, + dynamicData, + ); + + expect(result).toHaveLength(1); + expect(result[0].repostedBy).toBeDefined(); + expect(result[0].repostedBy!.username).toBe('retweeter'); + }); + + it('should skip items with missing tweet or author', () => { + const items = ['456:123:T', '999:888:T']; + const tweets = new Map([ + [ + '123', + { + id: '123', + authorId: '456', + content: 'Hello', + createdAt: new Date(), + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + quoteToTweetId: null, + rootTweetId: null, + }, + ], + ]); + const authors = new Map([ + [ + '456', + { + id: '456', + username: 'testuser', + displayName: 'Test', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }, + ], + ]); + const fullAuthors = new Map([ + [ + '456', + { + id: '456', + username: 'testuser', + displayName: 'Test', + avatarUrl: DEFAULT_PROFILE_PICTURE, + relationship: { following: false, follower: false }, + }, + ], + ]); + const dynamicData = { + likeCounts: new Map(), + retweetCounts: new Map(), + replyCounts: new Map(), + userTweetInteractions: new Map(), + }; + + const result = service.assembleTimelineTweets( + items, + tweets, + authors, + fullAuthors, + dynamicData, + ); + + expect(result).toHaveLength(1); + }); + + it('should handle quoted tweets', () => { + const items = ['456:123:T']; + const tweets = new Map([ + [ + '123', + { + id: '123', + authorId: '456', + content: 'Quote tweet', + quoteToTweetId: '999', + createdAt: new Date(), + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + rootTweetId: null, + }, + ], + [ + '999', + { + id: '999', + authorId: '888', + content: 'Original', + createdAt: new Date(), + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + quoteToTweetId: null, + rootTweetId: null, + }, + ], + ]); + const authors = new Map([ + [ + '456', + { + id: '456', + username: 'quoter', + displayName: 'Quoter', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }, + ], + [ + '888', + { + id: '888', + username: 'original', + displayName: 'Original', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }, + ], + ]); + const fullAuthors = new Map([ + [ + '456', + { + id: '456', + username: 'quoter', + displayName: 'Quoter', + avatarUrl: DEFAULT_PROFILE_PICTURE, + relationship: { following: false, follower: false }, + }, + ], + [ + '888', + { + id: '888', + username: 'original', + displayName: 'Original', + avatarUrl: DEFAULT_PROFILE_PICTURE, + relationship: { following: false, follower: false }, + }, + ], + ]); + const dynamicData = { + likeCounts: new Map(), + retweetCounts: new Map(), + replyCounts: new Map(), + userTweetInteractions: new Map(), + }; + + const result = service.assembleTimelineTweets( + items, + tweets, + authors, + fullAuthors, + dynamicData, + ); + + expect(result[0].quotedTweet).toBeDefined(); + }); + + it('should mark quoted tweet as deleted when not found', () => { + const items = ['456:123:T']; + const tweets = new Map([ + [ + '123', + { + id: '123', + authorId: '456', + content: 'Quote tweet', + quoteToTweetId: '999', + createdAt: new Date(), + entities: { mentions: [], hashtags: [] }, + media: [], + replyToTweetId: null, + rootTweetId: null, + }, + ], + ]); + const authors = new Map([ + [ + '456', + { + id: '456', + username: 'quoter', + displayName: 'Quoter', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }, + ], + ]); + const fullAuthors = new Map([ + [ + '456', + { + id: '456', + username: 'quoter', + displayName: 'Quoter', + avatarUrl: DEFAULT_PROFILE_PICTURE, + relationship: { following: false, follower: false }, + }, + ], + ]); + const dynamicData = { + likeCounts: new Map(), + retweetCounts: new Map(), + replyCounts: new Map(), + userTweetInteractions: new Map(), + }; + + const result = service.assembleTimelineTweets( + items, + tweets, + authors, + fullAuthors, + dynamicData, + ); + + expect(result[0].quotedTweet).toEqual({ isDeleted: true }); + }); + }); + + describe('getForYouFeed', () => { + const userId = BigInt(1); + + it('should throw BAD_REQUEST for invalid cursor', async () => { + await expect(service.getForYouFeed(userId, 'invalid-cursor', 10)).rejects.toThrow( + new HttpException( + { + message: PAGINATION_ERROR_MESSAGES.INVALID_CURSOR, + code: PAGINATION_ERROR_CODES.INVALID_CURSOR, + }, + HttpStatus.BAD_REQUEST, + ), + ); + }); + + it('should throw GONE when feed expired while scrolling', async () => { + const cursor = encodeCompositeCursor({ score: 100, id: '123' }); + mockRedisClient.get.mockResolvedValue(null); + + await expect(service.getForYouFeed(userId, cursor, 10)).rejects.toThrow( + new HttpException( + { + message: 'For You feed expired, please refresh to get new content.', + code: 'FOR_YOU_FEED_EXPIRED', + }, + HttpStatus.GONE, + ), + ); + }); + + it('should generate new feed on refresh when no cache exists', async () => { + mockRedisClient.get.mockResolvedValue(null); + mockRedisClient.exists.mockResolvedValue(0); + mockRedisClient.del.mockResolvedValue(undefined); + mockRedisClient.set.mockResolvedValue(undefined); + mockRedisClient.smembers.mockResolvedValue([]); + mockRedisClient.sadd.mockResolvedValue(undefined); + mockRedisClient.expire.mockResolvedValue(undefined); + + mockUsersRepository.getUserInterests.mockResolvedValue([]); + mockTweetsRepository.getTimelineForUser.mockResolvedValue([]); + mockRedisClient.zrevrange.mockResolvedValue([]); + + const result = await service.getForYouFeed(userId, undefined, 10); + + expect(result.items).toEqual([]); + expect(mockUsersRepository.getUserInterests).toHaveBeenCalled(); + }); + + it('should use cached feed on scroll', async () => { + const cachedFeed = { + tweets: [{ id: '123', score: 100 }], + generatedAt: Date.now() - 60000, + }; + const cursor = encodeCompositeCursor({ score: 200, id: '456' }); + + mockRedisClient.get.mockResolvedValue(JSON.stringify(cachedFeed)); + mockRedisClient.expire.mockResolvedValue(undefined); + mockRedisClient.smembers.mockResolvedValue([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + + const result = await service.getForYouFeed(userId, cursor, 10); + + expect(result).toBeDefined(); + expect(mockRedisClient.expire).toHaveBeenCalled(); + }); + + it('should filter out seen tweets on refresh', async () => { + const cachedFeed = { + tweets: [ + { id: '123', score: 100 }, + { id: '456', score: 90 }, + ], + generatedAt: Date.now() - 60000, + }; + + mockRedisClient.get.mockResolvedValue(JSON.stringify(cachedFeed)); + mockRedisClient.smembers.mockResolvedValue(['123']); + mockRedisClient.sadd.mockResolvedValue(undefined); + mockRedisClient.expire.mockResolvedValue(undefined); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + + const result = await service.getForYouFeed(userId, undefined, 10); + + expect(result).toBeDefined(); + expect(mockRedisClient.smembers).toHaveBeenCalled(); + }); + + it('should regenerate when all tweets are seen on refresh', async () => { + const cachedFeed = { + tweets: [{ id: '123', score: 100 }], + generatedAt: Date.now() - 60000, + }; + + mockRedisClient.get.mockResolvedValue(JSON.stringify(cachedFeed)); + mockRedisClient.smembers.mockResolvedValue(['123']); + mockRedisClient.del.mockResolvedValue(undefined); + mockRedisClient.sadd.mockResolvedValue(undefined); + mockRedisClient.expire.mockResolvedValue(undefined); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + + const result = await service.getForYouFeed(userId, undefined, 10); + + expect(result).toBeDefined(); + expect(mockRedisClient.del).toHaveBeenCalled(); + }); + + it('should return empty when scrolling reaches end of feed', async () => { + const cachedFeed = { + tweets: [{ id: '123', score: 100 }], + generatedAt: Date.now() - 60000, + }; + const cursor = encodeCompositeCursor({ score: 50, id: '000' }); + + mockRedisClient.get.mockResolvedValue(JSON.stringify(cachedFeed)); + mockRedisClient.expire.mockResolvedValue(undefined); + mockRedisClient.smembers.mockResolvedValue([]); + + const result = await service.getForYouFeed(userId, cursor, 10); + + expect(result.items).toEqual([]); + expect(result.pagination.hasNextPage).toBe(false); + }); + }); + + describe('backfillDynamicDataToCache', () => { + it('should backfill counts from DB to cache', async () => { + const missingCounts = [BigInt(123)]; + const likeCountsMap = new Map(); + const retweetCountsMap = new Map(); + const replyCountsMap = new Map(); + + mockTweetsRepository.getTweetCounts.mockResolvedValue( + new Map([['123', { likeCounts: 10, retweetCounts: 5, replyCounts: 2 }]]), + ); + mockPipeline.exec.mockResolvedValue([]); + + await service.backfillDynamicDataToCache( + missingCounts, + likeCountsMap, + retweetCountsMap, + replyCountsMap, + ); + + expect(mockTweetsRepository.getTweetCounts).toHaveBeenCalledWith(missingCounts); + expect(likeCountsMap.get(BigInt(123))).toBe(10); + expect(retweetCountsMap.get(BigInt(123))).toBe(5); + expect(replyCountsMap.get(BigInt(123))).toBe(2); + expect(mockPipeline.set).toHaveBeenCalledTimes(3); + }); + }); + + describe('hydrateStaticQuoteData - error paths', () => { + beforeEach(() => { + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + }); + + it('should handle tweet hydration error and backfill from DB', async () => { + mockPipeline.exec + .mockResolvedValueOnce([[new Error('Redis error'), null]]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([ + { id: '999', authorId: '888', content: 'Recovered' }, + ]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([ + { id: '888', username: 'author' }, + ]); + + const result = await service.hydrateStaticQuoteData(['999']); + + expect(result).toBeDefined(); + expect(mockTweetsRepository.getTweetsByIds).toHaveBeenCalled(); + }); + + it('should handle author hydration error and backfill from DB', async () => { + const tweetData = { id: '999', authorId: '888', content: 'Quote' }; + + mockPipeline.exec + .mockResolvedValueOnce([[null, JSON.stringify(tweetData)]]) + .mockResolvedValueOnce([[new Error('Author error'), null]]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([ + { id: '888', username: 'author' }, + ]); + + const result = await service.hydrateStaticQuoteData(['999']); + + expect(result.tweets.has('999')).toBe(true); + expect(mockTweetsRepository.getCompactAuthorsByIds).toHaveBeenCalled(); + }); + + it('should handle null authors hydration results', async () => { + const tweetData = { id: '999', authorId: '888', content: 'Quote' }; + + mockPipeline.exec + .mockResolvedValueOnce([[null, JSON.stringify(tweetData)]]) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce([]); + + const result = await service.hydrateStaticQuoteData(['999']); + + expect(result.tweets.has('999')).toBe(true); + expect(result.authors.size).toBe(0); + }); + }); + + describe('timelineCacheMiss - population tests', () => { + const userId = BigInt(1); + + it('should populate cache with tweets and retweets from DB', async () => { + mockTweetsRepository.getTimelineForUser.mockResolvedValue([ + { + id: BigInt(123), + authorId: BigInt(456), + createdAt: new Date(), + retweeterId: null, + }, + { + id: BigInt(124), + authorId: BigInt(457), + createdAt: new Date(), + retweeterId: BigInt(789), + }, + ]); + mockPipeline.exec.mockResolvedValue([]); + + mockRedisClient.exists.mockResolvedValueOnce(0).mockResolvedValueOnce(0); + mockRedisClient.zrevrangebyscore.mockResolvedValue([]); + + await service.getTimeline(userId, undefined, 10); + + expect(mockTweetsRepository.getTimelineForUser).toHaveBeenCalled(); + expect(mockPipeline.zadd).toHaveBeenCalled(); + expect(mockPipeline.expire).toHaveBeenCalled(); + }); + }); + + describe('generateForYouFeed - scoring tests', () => { + const userId = BigInt(1); + + it('should generate feed with interests and engagement scoring', async () => { + mockRedisClient.get.mockResolvedValue(null); + mockRedisClient.exists.mockResolvedValue(0); + mockRedisClient.del.mockResolvedValue(undefined); + mockRedisClient.set.mockResolvedValue(undefined); + mockRedisClient.smembers.mockResolvedValue([]); + mockRedisClient.sadd.mockResolvedValue(undefined); + mockRedisClient.expire.mockResolvedValue(undefined); + mockRedisClient.zrevrange.mockResolvedValue(['456:123:T', String(Date.now())]); + + mockUsersRepository.getUserInterests.mockResolvedValue(['tech', 'sports']); + mockTweetsRepository.getTimelineForUser.mockResolvedValue([]); + mockTweetsRepository.getTweetsMatchingInterests.mockResolvedValue([ + { id: '789', authorId: '555', createdAt: new Date() }, + ]); + mockTweetsRepository.filterNonMutedAuthors.mockResolvedValue([BigInt(456), BigInt(555)]); + mockTweetsRepository.filterValidTweets.mockResolvedValue([BigInt(123), BigInt(789)]); + mockTweetsRepository.getTweetCounts.mockResolvedValue( + new Map([ + ['123', { likeCounts: 10, retweetCounts: 5, replyCounts: 2 }], + ['789', { likeCounts: 20, retweetCounts: 8, replyCounts: 5 }], + ]), + ); + mockUsersRepository.getFollowingIds.mockResolvedValue([BigInt(456)]); + + const result = await service.getForYouFeed(userId, undefined, 10); + + expect(result).toBeDefined(); + expect(mockUsersRepository.getUserInterests).toHaveBeenCalled(); + expect(mockTweetsRepository.getTweetsMatchingInterests).toHaveBeenCalled(); + }); + + it('should handle retweets in timeline candidates', async () => { + mockRedisClient.get.mockResolvedValue(null); + mockRedisClient.exists.mockResolvedValue(0); + mockRedisClient.del.mockResolvedValue(undefined); + mockRedisClient.set.mockResolvedValue(undefined); + mockRedisClient.smembers.mockResolvedValue([]); + mockRedisClient.sadd.mockResolvedValue(undefined); + mockRedisClient.expire.mockResolvedValue(undefined); + mockRedisClient.zrevrange.mockResolvedValue(['456:123:R:789', String(Date.now())]); + + mockUsersRepository.getUserInterests.mockResolvedValue([]); + mockTweetsRepository.getTimelineForUser.mockResolvedValue([]); + mockTweetsRepository.filterNonMutedAuthors.mockResolvedValue([BigInt(456)]); + mockTweetsRepository.filterValidTweets.mockResolvedValue([BigInt(123)]); + mockTweetsRepository.getTweetCounts.mockResolvedValue( + new Map([['123', { likeCounts: 5, retweetCounts: 3, replyCounts: 1 }]]), + ); + mockUsersRepository.getFollowingIds.mockResolvedValue([BigInt(456)]); + + const result = await service.getForYouFeed(userId, undefined, 10); + + expect(result).toBeDefined(); + }); + + it('should return empty when no valid candidates', async () => { + mockRedisClient.get.mockResolvedValue(null); + mockRedisClient.exists.mockResolvedValue(0); + mockRedisClient.del.mockResolvedValue(undefined); + mockRedisClient.set.mockResolvedValue(undefined); + mockRedisClient.smembers.mockResolvedValue([]); + mockRedisClient.expire.mockResolvedValue(undefined); + mockRedisClient.zrevrange.mockResolvedValue(['456:123:T', String(Date.now())]); + + mockUsersRepository.getUserInterests.mockResolvedValue([]); + mockTweetsRepository.getTimelineForUser.mockResolvedValue([]); + mockTweetsRepository.filterNonMutedAuthors.mockResolvedValue([]); + mockTweetsRepository.filterValidTweets.mockResolvedValue([]); + mockUsersRepository.getFollowingIds.mockResolvedValue([]); + + const result = await service.getForYouFeed(userId, undefined, 10); + + expect(result.items).toEqual([]); + }); + + it('should regenerate feed when fresh TTL expired', async () => { + const oldFeed = { + tweets: [{ id: '123', score: 100 }], + generatedAt: Date.now() - 400000, + }; + + mockRedisClient.get.mockResolvedValue(JSON.stringify(oldFeed)); + mockRedisClient.exists.mockResolvedValue(0); + mockRedisClient.del.mockResolvedValue(undefined); + mockRedisClient.set.mockResolvedValue(undefined); + mockRedisClient.smembers.mockResolvedValue([]); + mockRedisClient.sadd.mockResolvedValue(undefined); + mockRedisClient.expire.mockResolvedValue(undefined); + mockRedisClient.zrevrange.mockResolvedValue([]); + + mockUsersRepository.getUserInterests.mockResolvedValue([]); + mockTweetsRepository.getTimelineForUser.mockResolvedValue([]); + + const result = await service.getForYouFeed(userId, undefined, 10); + + expect(result).toBeDefined(); + expect(mockRedisClient.del).toHaveBeenCalled(); + }); + }); + + describe('timelineCacheHit - batching and pagination', () => { + const userId = BigInt(1); + + it('should handle batching when fetching more items than limit', async () => { + mockRedisClient.exists.mockResolvedValue(0); + + // First batch returns 5 items + mockRedisClient.zrevrangebyscore + .mockResolvedValueOnce(['456:123:T', '456:124:T', '456:125:T', '456:126:T', '456:127:T']) + .mockResolvedValueOnce([]); + + mockTweetsRepository.filterValidAuthors.mockResolvedValue([BigInt(456)]); + mockTweetsRepository.filterValidTweets.mockResolvedValue([ + BigInt(123), + BigInt(124), + BigInt(125), + ]); + + mockRedisClient.zscore.mockResolvedValue(String(Date.now())); + + mockPipeline.exec + .mockResolvedValueOnce([ + [ + null, + JSON.stringify({ + id: '123', + authorId: '456', + content: 'Tweet 1', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '456', + username: 'testuser', + displayName: 'Test', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + [ + null, + JSON.stringify({ + id: '124', + authorId: '456', + content: 'Tweet 2', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '456', + username: 'testuser', + displayName: 'Test', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + [ + null, + JSON.stringify({ + id: '125', + authorId: '456', + content: 'Tweet 3', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '456', + username: 'testuser', + displayName: 'Test', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + ]) + .mockResolvedValueOnce([ + [null, '5'], + [null, '2'], + [null, '1'], + [null, '3'], + [null, '1'], + [null, '0'], + [null, '4'], + [null, '0'], + [null, '1'], + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue(new Map()); + mockTweetsRepository.getAuthorRelationships.mockResolvedValue( + new Map([ + [ + '456', + { + id: '456', + username: 'testuser', + displayName: 'Test', + avatarUrl: DEFAULT_PROFILE_PICTURE, + relationship: { following: false, follower: false }, + }, + ], + ]), + ); + + const result = await service.timelineCacheHit(userId, undefined, 3, new Set()); + + expect(result.length).toBeGreaterThan(0); + }); + + it('should continue fetching batches until limit is reached', async () => { + mockRedisClient.exists.mockResolvedValue(0); + mockRedisClient.zrevrangebyscore + .mockResolvedValueOnce(['456:123:T']) + .mockResolvedValueOnce(['456:124:T']) + .mockResolvedValueOnce([]); + + mockTweetsRepository.filterValidAuthors.mockResolvedValue([BigInt(456)]); + mockTweetsRepository.filterValidTweets.mockResolvedValue([BigInt(123), BigInt(124)]); + + mockRedisClient.zscore.mockResolvedValue(String(Date.now())); + + mockPipeline.exec + .mockResolvedValueOnce([ + [ + null, + JSON.stringify({ + id: '123', + authorId: '456', + content: 'Tweet 1', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '456', + username: 'testuser', + displayName: 'Test', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + ]) + .mockResolvedValueOnce([ + [null, '5'], + [null, '2'], + [null, '1'], + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + [ + null, + JSON.stringify({ + id: '124', + authorId: '456', + content: 'Tweet 2', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '456', + username: 'testuser', + displayName: 'Test', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + ]) + .mockResolvedValueOnce([ + [null, '3'], + [null, '1'], + [null, '0'], + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue(new Map()); + mockTweetsRepository.getAuthorRelationships.mockResolvedValue( + new Map([ + [ + '456', + { + id: '456', + username: 'testuser', + displayName: 'Test', + avatarUrl: DEFAULT_PROFILE_PICTURE, + relationship: { following: false, follower: false }, + }, + ], + ]), + ); + + const result = await service.timelineCacheHit(userId, undefined, 10, new Set()); + + expect(result).toBeDefined(); + }); + }); + + describe('getTweetCreatedAt', () => { + const userId = BigInt(1); + + it('should return tweet created date from Redis score', async () => { + const now = Date.now(); + mockRedisClient.zscore.mockResolvedValue(String(now)); + + const result = await service['getTweetCreatedAt'](userId, '456:123:T'); + + expect(result).toEqual(new Date(now)); + expect(mockRedisClient.zscore).toHaveBeenCalled(); + }); + + it('should return null when tweet score not found in Redis', async () => { + mockRedisClient.zscore.mockResolvedValue(null); + + const result = await service['getTweetCreatedAt'](userId, '456:123:T'); + + expect(result).toBeNull(); + }); + }); + + describe('hydrateStaticData - retweet hydration', () => { + const userId = BigInt(1); + + it('should hydrate retweeter data for retweets', async () => { + mockRedisClient.exists.mockResolvedValue(0); + mockRedisClient.zrevrangebyscore.mockResolvedValue(['456:123:R:789']); + + mockTweetsRepository.filterValidAuthors.mockResolvedValue([BigInt(456), BigInt(789)]); + mockTweetsRepository.filterValidTweets.mockResolvedValue([BigInt(123)]); + + mockPipeline.exec + .mockResolvedValueOnce([ + [ + null, + JSON.stringify({ + id: '123', + authorId: '456', + content: 'Original tweet', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '456', + username: 'author', + displayName: 'Author', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + [ + null, + JSON.stringify({ + id: '789', + username: 'retweeter', + displayName: 'Retweeter', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + ]) + .mockResolvedValueOnce([ + [null, '5'], + [null, '2'], + [null, '1'], + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue(new Map()); + mockTweetsRepository.getAuthorRelationships.mockResolvedValue( + new Map([ + [ + '456', + { + id: '456', + username: 'author', + displayName: 'Author', + avatarUrl: DEFAULT_PROFILE_PICTURE, + relationship: { following: false, follower: false }, + }, + ], + ]), + ); + + const result = await service.timelineCacheHit(userId, undefined, 10, new Set()); + + expect(result).toBeDefined(); + expect(mockPipeline.getex).toHaveBeenCalled(); + }); + + it('should handle missing retweeter data and add to missingAuthorIds', async () => { + mockRedisClient.exists.mockResolvedValue(0); + mockRedisClient.zrevrangebyscore.mockResolvedValue(['456:123:R:789']); + + mockTweetsRepository.filterValidAuthors.mockResolvedValue([BigInt(456), BigInt(789)]); + mockTweetsRepository.filterValidTweets.mockResolvedValue([BigInt(123)]); + + mockPipeline.exec + .mockResolvedValueOnce([ + [ + null, + JSON.stringify({ + id: '123', + authorId: '456', + content: 'Original tweet', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '456', + username: 'author', + displayName: 'Author', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + [null, null], + ]) + .mockResolvedValueOnce([ + [null, '5'], + [null, '2'], + [null, '1'], + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([ + { + id: '789', + username: 'retweeter', + displayName: 'Retweeter', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }, + ]); + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue(new Map()); + mockTweetsRepository.getAuthorRelationships.mockResolvedValue( + new Map([ + [ + '456', + { id: '456', username: 'author', relationship: { following: false, follower: false } }, + ], + ]), + ); + + const result = await service.timelineCacheHit(userId, undefined, 10, new Set()); + + expect(result).toBeDefined(); + }); + }); + + describe('extractIdsFromTimelineItems', () => { + it('should extract author and tweet IDs including retweeters', () => { + const items = ['456:123:T', '789:124:R:101']; + + const result = service['extractIdsFromTimelineItems'](items); + + expect(result.authorIds.has(BigInt(456))).toBe(true); + expect(result.authorIds.has(BigInt(789))).toBe(true); + expect(result.authorIds.has(BigInt(101))).toBe(true); + expect(result.tweetIds.has(BigInt(123))).toBe(true); + expect(result.tweetIds.has(BigInt(124))).toBe(true); + }); + }); + + describe('fetchAndValidateForYouTweets - batch processing', () => { + const userId = BigInt(1); + + it('should process tweets in batches and accumulate valid tweets', async () => { + const rankedFeed = { + tweets: [ + { id: '123', score: 100 }, + { id: '124', score: 90 }, + { id: '125', score: 80 }, + ], + generatedAt: Date.now(), + }; + + mockRedisClient.get.mockResolvedValue(JSON.stringify(rankedFeed)); + mockRedisClient.smembers.mockResolvedValue([]); + mockRedisClient.sadd.mockResolvedValue(undefined); + mockRedisClient.expire.mockResolvedValue(undefined); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([ + { id: '123', authorId: '456', content: 'Tweet 1', createdAt: new Date() }, + { id: '124', authorId: '457', content: 'Tweet 2', createdAt: new Date() }, + { id: '125', authorId: '458', content: 'Tweet 3', createdAt: new Date() }, + ]); + + mockPipeline.exec + .mockResolvedValueOnce([ + [ + null, + JSON.stringify({ + id: '123', + authorId: '456', + content: 'Tweet 1', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '456', + username: 'user1', + displayName: 'User 1', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + [ + null, + JSON.stringify({ + id: '124', + authorId: '457', + content: 'Tweet 2', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '457', + username: 'user2', + displayName: 'User 2', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + [ + null, + JSON.stringify({ + id: '125', + authorId: '458', + content: 'Tweet 3', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '458', + username: 'user3', + displayName: 'User 3', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + ]) + .mockResolvedValueOnce([ + [null, '5'], + [null, '2'], + [null, '1'], + [null, '6'], + [null, '3'], + [null, '2'], + [null, '7'], + [null, '4'], + [null, '3'], + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue(new Map()); + mockTweetsRepository.getAuthorRelationships.mockResolvedValue( + new Map([ + [ + '456', + { id: '456', username: 'user1', relationship: { following: false, follower: false } }, + ], + [ + '457', + { id: '457', username: 'user2', relationship: { following: false, follower: false } }, + ], + [ + '458', + { id: '458', username: 'user3', relationship: { following: false, follower: false } }, + ], + ]), + ); + + const result = await service.getForYouFeed(userId, undefined, 10); + + expect(result).toBeDefined(); + }); + + it('should handle For You feed with retweet items', async () => { + const rankedFeed = { + tweets: [{ id: '123', score: 100, retweeterId: '789' }], + generatedAt: Date.now(), + }; + + mockRedisClient.get.mockResolvedValue(JSON.stringify(rankedFeed)); + mockRedisClient.smembers.mockResolvedValue([]); + mockRedisClient.sadd.mockResolvedValue(undefined); + mockRedisClient.expire.mockResolvedValue(undefined); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([ + { id: '123', authorId: '456', content: 'Original tweet', createdAt: new Date() }, + ]); + + mockPipeline.exec + .mockResolvedValueOnce([ + [ + null, + JSON.stringify({ + id: '123', + authorId: '456', + content: 'Original tweet', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '456', + username: 'author', + displayName: 'Author', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + [ + null, + JSON.stringify({ + id: '789', + username: 'retweeter', + displayName: 'Retweeter', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + ]) + .mockResolvedValueOnce([ + [null, '5'], + [null, '2'], + [null, '1'], + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue(new Map()); + mockTweetsRepository.getAuthorRelationships.mockResolvedValue( + new Map([ + [ + '456', + { id: '456', username: 'author', relationship: { following: false, follower: false } }, + ], + ]), + ); + + const result = await service.getForYouFeed(userId, undefined, 10); + + expect(result).toBeDefined(); + }); + + it('should deduplicate tweets within the same batch', async () => { + const rankedFeed = { + tweets: [ + { id: '123', score: 100 }, + { id: '123', score: 99 }, + ], + generatedAt: Date.now(), + }; + + mockRedisClient.get.mockResolvedValue(JSON.stringify(rankedFeed)); + mockRedisClient.smembers.mockResolvedValue([]); + mockRedisClient.sadd.mockResolvedValue(undefined); + mockRedisClient.expire.mockResolvedValue(undefined); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([ + { id: '123', authorId: '456', content: 'Tweet', createdAt: new Date() }, + ]); + + mockPipeline.exec + .mockResolvedValueOnce([ + [ + null, + JSON.stringify({ + id: '123', + authorId: '456', + content: 'Tweet', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '456', + username: 'user', + displayName: 'User', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + ]) + .mockResolvedValueOnce([ + [null, '5'], + [null, '2'], + [null, '1'], + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue(new Map()); + mockTweetsRepository.getAuthorRelationships.mockResolvedValue( + new Map([ + [ + '456', + { id: '456', username: 'user', relationship: { following: false, follower: false } }, + ], + ]), + ); + + const result = await service.getForYouFeed(userId, undefined, 10); + + expect(result).toBeDefined(); + }); + + it('should skip tweets that no longer exist in DB', async () => { + const rankedFeed = { + tweets: [ + { id: '123', score: 100 }, + { id: '124', score: 90 }, + ], + generatedAt: Date.now(), + }; + + mockRedisClient.get.mockResolvedValue(JSON.stringify(rankedFeed)); + mockRedisClient.smembers.mockResolvedValue([]); + mockRedisClient.sadd.mockResolvedValue(undefined); + mockRedisClient.expire.mockResolvedValue(undefined); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([ + { id: '123', authorId: '456', content: 'Tweet 1', createdAt: new Date() }, + ]); + + mockPipeline.exec + .mockResolvedValueOnce([ + [ + null, + JSON.stringify({ + id: '123', + authorId: '456', + content: 'Tweet 1', + createdAt: new Date().toISOString(), + }), + ], + [ + null, + JSON.stringify({ + id: '456', + username: 'user', + displayName: 'User', + avatarUrl: DEFAULT_PROFILE_PICTURE, + }), + ], + ]) + .mockResolvedValueOnce([ + [null, '5'], + [null, '2'], + [null, '1'], + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + mockTweetsRepository.getTweetsByIds.mockResolvedValue([]); + mockTweetsRepository.getCompactAuthorsByIds.mockResolvedValue([]); + mockTweetsRepository.getUserTweetInteractions.mockResolvedValue(new Map()); + mockTweetsRepository.getAuthorRelationships.mockResolvedValue( + new Map([ + [ + '456', + { id: '456', username: 'user', relationship: { following: false, follower: false } }, + ], + ]), + ); + + const result = await service.getForYouFeed(userId, undefined, 10); + + expect(result).toBeDefined(); + }); + }); +}); From 11c935b8795d1954d46075df63298140ba7e4c73 Mon Sep 17 00:00:00 2001 From: Mostafa Ahmed Abdelglel <139139781+galelo04@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:47:08 +0200 Subject: [PATCH 42/43] test: notifications - [CU-869bggmjy] (#228) --- src/common/utils/cursor-pagination.util.ts | 25 - .../utils/fcm-notification-body-builder.ts | 7 +- test/auth/jwt-auth.guard.spec.ts | 198 ++++- .../guards/request-throttler.guard.spec.ts | 187 +++++ .../utils/cursor-pagination.util.spec.ts | 293 +++++++ .../messages/messages-push.processor.spec.ts | 625 +++++++++++++++ test/firebase/push-sender.service.spec.ts | 479 ++++++++++++ .../fcm-notification-body-builder.spec.ts | 227 ++++++ .../notifications.listeners.spec.ts | 559 ++++++++++++++ .../notifications.processor.spec.ts | 718 ++++++++++++++++++ .../notifications.service.spec.ts | 644 +++++++++++++++- .../refresh-tokens.service.spec.ts | 392 +++++++++- test/sessions/sessions.service.spec.ts | 296 ++++++++ test/sse/sse-events.service.spec.ts | 266 +++++++ test/sse/sse.service.spec.ts | 403 ++++++++++ test/tweets/timeline/timeline.service.spec.ts | 11 +- 16 files changed, 5268 insertions(+), 62 deletions(-) create mode 100644 test/common/guards/request-throttler.guard.spec.ts create mode 100644 test/common/utils/cursor-pagination.util.spec.ts create mode 100644 test/conversations/messages/messages-push.processor.spec.ts create mode 100644 test/firebase/push-sender.service.spec.ts create mode 100644 test/notifications/fcm-notification-body-builder.spec.ts create mode 100644 test/notifications/notifications.listeners.spec.ts create mode 100644 test/notifications/notifications.processor.spec.ts create mode 100644 test/sessions/sessions.service.spec.ts diff --git a/src/common/utils/cursor-pagination.util.ts b/src/common/utils/cursor-pagination.util.ts index a3d7c8c0..3b7534cf 100644 --- a/src/common/utils/cursor-pagination.util.ts +++ b/src/common/utils/cursor-pagination.util.ts @@ -1,6 +1,5 @@ import { CursorPagination } from '../interfaces'; -const encodeCursor = (id: string) => Buffer.from(id).toString('base64'); export const decodeCursor = (cursor: string | undefined) => { if (!cursor) return undefined; return Buffer.from(cursor, 'base64').toString('utf-8'); @@ -16,30 +15,6 @@ export const decodeCompositeCursor = (cursorString: string): T | undefined => return JSON.parse(jsonString) as T; }; -export const paginateSingle = ( - items: T[], - limit: number, - prevCursor: string | undefined, - getId: (item: T) => bigint | string, -): CursorPagination => { - const hasNextPage = items.length > limit; - let nextCursor: string | null = null; - - if (hasNextPage) { - const nextItem = items.pop(); - if (nextItem) { - const id = getId(nextItem); - nextCursor = encodeCursor(id.toString()); - } - } - - return { - cursor: prevCursor || null, - nextCursor, - hasNextPage, - }; -}; - export const paginateComposite = >( items: T[], limit: number, diff --git a/src/notifications/utils/fcm-notification-body-builder.ts b/src/notifications/utils/fcm-notification-body-builder.ts index ace78bce..6cb91e52 100644 --- a/src/notifications/utils/fcm-notification-body-builder.ts +++ b/src/notifications/utils/fcm-notification-body-builder.ts @@ -88,7 +88,8 @@ export function buildFcmNotificationText(opts: { locale, } = opts; - const templates = locale ? TEMPLATES[locale] : TEMPLATES[DEFAULT_LOCALE]; + const effectiveLocale = locale ?? DEFAULT_LOCALE; + const templates = TEMPLATES[effectiveLocale]; const leadActor = previewActors[0] ?? 'Someone'; const remainingCount = Math.max(0, (totalActorCount ?? 1) - 1); @@ -96,9 +97,9 @@ export function buildFcmNotificationText(opts: { let key = 'generic'; const params: Record = {}; - if (locale === LanguageCode.AR && isAggregated && remainingCount > 0) { + if (effectiveLocale === LanguageCode.AR && isAggregated && remainingCount > 0) { params.sar = remainingCount === 1 ? '' : 'ون'; - } else if (locale === LanguageCode.EN && isAggregated && remainingCount > 0) { + } else if (effectiveLocale === LanguageCode.EN && isAggregated && remainingCount > 0) { params.s = remainingCount === 1 ? '' : 's'; } switch (notificationType) { diff --git a/test/auth/jwt-auth.guard.spec.ts b/test/auth/jwt-auth.guard.spec.ts index e97a6440..556c5e30 100644 --- a/test/auth/jwt-auth.guard.spec.ts +++ b/test/auth/jwt-auth.guard.spec.ts @@ -1,18 +1,212 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { JwtAuthGuard } from 'src/auth/guards'; +import { OPTIONAL_AUTH_KEY } from 'src/common/decorators/optional-auth.decorator'; +import { RequestUser } from 'src/common/interfaces'; -describe('JwtAuthGurad', () => { +describe('JwtAuthGuard', () => { let guard: JwtAuthGuard; + let reflector: jest.Mocked; beforeEach(async () => { + const mockReflector = { + getAllAndOverride: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [JwtAuthGuard], + providers: [JwtAuthGuard, { provide: Reflector, useValue: mockReflector }], }).compile(); guard = module.get(JwtAuthGuard); + reflector = module.get(Reflector); }); it('should be defined', () => { expect(guard).toBeDefined(); }); + + describe('canActivate', () => { + it('should call super.canActivate', () => { + const mockContext = { + switchToHttp: jest.fn(), + getHandler: jest.fn(), + getClass: jest.fn(), + } as unknown as ExecutionContext; + + const superCanActivateSpy = jest + .spyOn(Object.getPrototypeOf(JwtAuthGuard.prototype), 'canActivate') + .mockReturnValue(true); + + const result = guard.canActivate(mockContext); + + expect(superCanActivateSpy).toHaveBeenCalledWith(mockContext); + expect(result).toBe(true); + + superCanActivateSpy.mockRestore(); + }); + }); + + describe('handleRequest', () => { + let mockContext: ExecutionContext; + + beforeEach(() => { + mockContext = { + getHandler: jest.fn(), + getClass: jest.fn(), + switchToHttp: jest.fn(), + } as unknown as ExecutionContext; + }); + + it('should return user when user is authenticated', () => { + const mockUser: RequestUser = { + id: '1', + }; + + reflector.getAllAndOverride.mockReturnValue(false); + + const result = guard.handleRequest( + null, + mockUser, + null, + mockContext, + ) as unknown as RequestUser | null; + + expect(result).toEqual(mockUser); + + expect(reflector.getAllAndOverride).toHaveBeenCalledWith(OPTIONAL_AUTH_KEY, [ + mockContext.getHandler(), + mockContext.getClass(), + ]); + }); + + it('should return null when user is not authenticated and auth is optional', () => { + reflector.getAllAndOverride.mockReturnValue(true); + + const result = guard.handleRequest( + null, + null, + null, + mockContext, + ) as unknown as RequestUser | null; + + expect(result).toBeNull(); + expect(reflector.getAllAndOverride).toHaveBeenCalledWith(OPTIONAL_AUTH_KEY, [ + mockContext.getHandler(), + mockContext.getClass(), + ]); + }); + + it('should throw UnauthorizedException when user is not authenticated and auth is required', () => { + reflector.getAllAndOverride.mockReturnValue(false); + + expect( + () => guard.handleRequest(null, null, null, mockContext) as unknown as RequestUser | null, + ).toThrow(UnauthorizedException); + }); + + it('should throw error when error is provided and user is null', () => { + const customError = new Error('Custom auth error'); + reflector.getAllAndOverride.mockReturnValue(false); + + expect( + () => + guard.handleRequest( + customError, + null, + null, + mockContext, + ) as unknown as RequestUser | null, + ).toThrow('Custom auth error'); + }); + + it('should return user even when auth is optional and user exists', () => { + const mockUser: RequestUser = { + id: '2', + }; + + reflector.getAllAndOverride.mockReturnValue(true); + + const result = guard.handleRequest( + null, + mockUser, + null, + mockContext, + ) as unknown as RequestUser | null; + + expect(result).toEqual(mockUser); + }); + + it('should handle user with all properties', () => { + const mockUser: RequestUser = { + id: '1', + }; + + reflector.getAllAndOverride.mockReturnValue(false); + + const result = guard.handleRequest( + null, + mockUser, + null, + mockContext, + ) as unknown as RequestUser | null; + + expect(result).toEqual(mockUser); + expect(result!.id).toBe('1'); + }); + + it('should prioritize error over UnauthorizedException', () => { + const customError = new Error('Token expired'); + reflector.getAllAndOverride.mockReturnValue(false); + + expect( + () => + guard.handleRequest( + customError, + null, + null, + mockContext, + ) as unknown as RequestUser | null, + ).toThrow('Token expired'); + }); + + it('should check both handler and class for optional auth decorator', () => { + const mockHandler = jest.fn(); + const mockClass = jest.fn(); + + mockContext = { + getHandler: jest.fn().mockReturnValue(mockHandler), + getClass: jest.fn().mockReturnValue(mockClass), + switchToHttp: jest.fn(), + } as unknown as ExecutionContext; + + reflector.getAllAndOverride.mockReturnValue(true); + + guard.handleRequest(null, null, null, mockContext); + + expect(reflector.getAllAndOverride).toHaveBeenCalledWith(OPTIONAL_AUTH_KEY, [ + mockHandler, + mockClass, + ]); + }); + + it('should handle bigint user id correctly', () => { + const mockUser: RequestUser = { + id: '9007199254740991', // Large BigInt + }; + + reflector.getAllAndOverride.mockReturnValue(false); + + const result = guard.handleRequest( + null, + mockUser, + null, + mockContext, + ) as unknown as RequestUser | null; + + expect(result?.id).toBe('9007199254740991'); + expect(typeof result?.id).toBe('string'); + }); + }); }); diff --git a/test/common/guards/request-throttler.guard.spec.ts b/test/common/guards/request-throttler.guard.spec.ts new file mode 100644 index 00000000..533f7a25 --- /dev/null +++ b/test/common/guards/request-throttler.guard.spec.ts @@ -0,0 +1,187 @@ +import { Logger } from '@nestjs/common'; +import { RequestThrottlerGuard } from '../../../src/common/guards/request-throttler.guard'; +import { Reflector } from '@nestjs/core'; +import { ThrottlerModuleOptions, ThrottlerStorage } from '@nestjs/throttler'; +import { Request } from 'express'; + +describe('RequestThrottlerGuard', () => { + let guard: RequestThrottlerGuard; + + beforeEach(() => { + // Suppress logger output + jest.spyOn(Logger.prototype, 'log').mockImplementation(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + jest.spyOn(Logger.prototype, 'error').mockImplementation(); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(); + + // Create guard instance with minimal dependencies + const mockOptions: ThrottlerModuleOptions = { + throttlers: [{ name: 'default', ttl: 60, limit: 10 }], + }; + const mockStorage: ThrottlerStorage = { + increment: jest.fn(), + reset: jest.fn(), + get: jest.fn(), + } as unknown as ThrottlerStorage; + const mockReflector = new Reflector(); + + guard = new RequestThrottlerGuard(mockOptions, mockStorage, mockReflector); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + describe('getTracker', () => { + it('should extract IP from x-forwarded-for header', async () => { + const mockRequest = { + headers: { + 'x-forwarded-for': '203.0.113.1, 198.51.100.1', + }, + ip: '192.0.2.1', + socket: { remoteAddress: '192.0.2.2' }, + } as unknown as Request; + + const result = await guard['getTracker'](mockRequest as unknown as Record); + + expect(result).toBe('203.0.113.1'); + }); + + it('should fallback to request.ip when x-forwarded-for not present', async () => { + const mockRequest = { + headers: {}, + ip: '192.0.2.1', + socket: { remoteAddress: '192.0.2.2' }, + } as unknown as Request; + + const result = await guard['getTracker'](mockRequest as unknown as Record); + + expect(result).toBe('192.0.2.1'); + }); + + it('should fallback to socket.remoteAddress when request.ip not available', async () => { + const mockRequest = { + headers: {}, + socket: { remoteAddress: '192.0.2.2' }, + } as unknown as Request; + + const result = await guard['getTracker'](mockRequest as unknown as Record); + + expect(result).toBe('192.0.2.2'); + }); + + it('should return "unknown" when no IP source available', async () => { + const mockRequest = { + headers: {}, + } as unknown as Request; + + const result = await guard['getTracker'](mockRequest as unknown as Record); + + expect(result).toBe('unknown'); + }); + + it('should handle IPv6 addresses', async () => { + const mockRequest = { + headers: { + 'x-forwarded-for': '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + }, + ip: '127.0.0.1', + socket: { remoteAddress: '127.0.0.1' }, + } as unknown as Request; + + const result = await guard['getTracker'](mockRequest as unknown as Record); + + expect(result).toBe('2001:0db8:85a3:0000:0000:8a2e:0370:7334'); + }); + + it('should trim whitespace from x-forwarded-for IP', async () => { + const mockRequest = { + headers: { + 'x-forwarded-for': ' 203.0.113.1 , 198.51.100.1', + }, + ip: '192.0.2.1', + socket: { remoteAddress: '192.0.2.2' }, + } as unknown as Request; + + const result = await guard['getTracker'](mockRequest as unknown as Record); + + expect(result).toBe('203.0.113.1'); + }); + + it('should handle x-forwarded-for with single IP', async () => { + const mockRequest = { + headers: { + 'x-forwarded-for': '203.0.113.1', + }, + ip: '192.0.2.1', + socket: { remoteAddress: '192.0.2.2' }, + } as unknown as Request; + + const result = await guard['getTracker'](mockRequest as unknown as Record); + + expect(result).toBe('203.0.113.1'); + }); + + it('should handle x-forwarded-for as array (edge case)', async () => { + const mockRequest = { + headers: { + 'x-forwarded-for': ['203.0.113.1', '198.51.100.1'], + }, + ip: '192.0.2.1', + socket: { remoteAddress: '192.0.2.2' }, + } as unknown as Request; + + // x-forwarded-for as array is not a string, so should fallback to request.ip + const result = await guard['getTracker'](mockRequest as unknown as Record); + + expect(result).toBe('192.0.2.1'); + }); + + it('should handle empty x-forwarded-for header', async () => { + const mockRequest = { + headers: { + 'x-forwarded-for': '', + }, + ip: '192.0.2.1', + socket: { remoteAddress: '192.0.2.2' }, + } as unknown as Request; + + // Empty string is not a valid IP, so should fallback to request.ip + const result = await guard['getTracker'](mockRequest as unknown as Record); + + expect(result).toBe('192.0.2.1'); + }); + + it('should handle localhost IP', async () => { + const mockRequest = { + headers: { + 'x-forwarded-for': '127.0.0.1', + }, + ip: '192.0.2.1', + socket: { remoteAddress: '192.0.2.2' }, + } as unknown as Request; + + const result = await guard['getTracker'](mockRequest as unknown as Record); + + expect(result).toBe('127.0.0.1'); + }); + + it('should prioritize x-forwarded-for over other sources', async () => { + const mockRequest = { + headers: { + 'x-forwarded-for': '203.0.113.1', + }, + ip: '192.0.2.1', + socket: { remoteAddress: '192.0.2.2' }, + } as unknown as Request; + + const result = await guard['getTracker'](mockRequest as unknown as Record); + + expect(result).toBe('203.0.113.1'); + expect(result).not.toBe('192.0.2.1'); + expect(result).not.toBe('192.0.2.2'); + }); + }); +}); diff --git a/test/common/utils/cursor-pagination.util.spec.ts b/test/common/utils/cursor-pagination.util.spec.ts new file mode 100644 index 00000000..9215a7a7 --- /dev/null +++ b/test/common/utils/cursor-pagination.util.spec.ts @@ -0,0 +1,293 @@ +import { + decodeCursor, + decodeCompositeCursor, + paginateComposite, +} from '../../../src/common/utils/cursor-pagination.util'; + +describe('cursor-pagination.util', () => { + describe('decodeCursor', () => { + it('should decode a base64 cursor string', () => { + const cursor = Buffer.from('test-cursor-value').toString('base64'); + const result = decodeCursor(cursor); + expect(result).toBe('test-cursor-value'); + }); + + it('should return undefined for undefined cursor', () => { + const result = decodeCursor(undefined); + expect(result).toBeUndefined(); + }); + + it('should decode cursor with special characters', () => { + const original = 'cursor:with:special@chars#123'; + const cursor = Buffer.from(original).toString('base64'); + const result = decodeCursor(cursor); + expect(result).toBe(original); + }); + + it('should decode empty string cursor to empty string', () => { + const cursor = Buffer.from('').toString('base64'); + const result = decodeCursor(cursor); + // Empty base64 string becomes undefined when checked in decodeCursor + expect(result).toBeUndefined(); + }); + }); + + describe('decodeCompositeCursor', () => { + it('should decode a composite cursor with single field', () => { + const cursorObject = { id: '123' }; + const encoded = Buffer.from(JSON.stringify(cursorObject)).toString('base64'); + const result = decodeCompositeCursor<{ id: string }>(encoded); + expect(result).toEqual(cursorObject); + }); + + it('should decode a composite cursor with multiple fields', () => { + const cursorObject = { + followerId: '1', + followedId: '2', + createdAt: '2024-01-01T00:00:00.000Z', + }; + const encoded = Buffer.from(JSON.stringify(cursorObject)).toString('base64'); + const result = decodeCompositeCursor(encoded); + expect(result).toEqual(cursorObject); + }); + + it('should return undefined for empty cursor string', () => { + const result = decodeCompositeCursor(''); + expect(result).toBeUndefined(); + }); + + it('should decode cursor with nested objects', () => { + const cursorObject = { + user: { id: '1', name: 'test' }, + timestamp: '2024-01-01', + }; + const encoded = Buffer.from(JSON.stringify(cursorObject)).toString('base64'); + const result = decodeCompositeCursor(encoded); + expect(result).toEqual(cursorObject); + }); + + it('should decode cursor with null values', () => { + const cursorObject = { id: '1', value: null }; + const encoded = Buffer.from(JSON.stringify(cursorObject)).toString('base64'); + const result = decodeCompositeCursor(encoded); + expect(result).toEqual(cursorObject); + }); + }); + + describe('paginateComposite', () => { + interface TestItem { + id: bigint; + name: string; + createdAt: Date; + } + + const createTestItems = (count: number): TestItem[] => { + return Array.from({ length: count }, (_, i) => ({ + id: BigInt(i + 1), + name: `item-${i + 1}`, + createdAt: new Date(`2024-01-${String(i + 1).padStart(2, '0')}`), + })); + }; + + it('should return hasNextPage true when items exceed limit', () => { + const items = createTestItems(6); + const limit = 5; + + const result = paginateComposite(items, limit, undefined, (item) => ({ + id: item.id.toString(), + })); + + expect(result.hasNextPage).toBe(true); + expect(result.nextCursor).not.toBeNull(); + expect(result.cursor).toBeNull(); + expect(items.length).toBe(5); // Last item should be popped + }); + + it('should return hasNextPage false when items equal limit', () => { + const items = createTestItems(5); + const limit = 5; + + const result = paginateComposite(items, limit, undefined, (item) => ({ + id: item.id.toString(), + })); + + expect(result.hasNextPage).toBe(false); + expect(result.nextCursor).toBeNull(); + expect(items.length).toBe(5); + }); + + it('should return hasNextPage false when items less than limit', () => { + const items = createTestItems(3); + const limit = 5; + + const result = paginateComposite(items, limit, undefined, (item) => ({ + id: item.id.toString(), + })); + + expect(result.hasNextPage).toBe(false); + expect(result.nextCursor).toBeNull(); + expect(items.length).toBe(3); + }); + + it('should encode composite cursor with multiple fields', () => { + const items = createTestItems(6); + const limit = 5; + + const result = paginateComposite(items, limit, undefined, (item) => ({ + id: item.id.toString(), + name: item.name, + timestamp: item.createdAt.toISOString(), + })); + + expect(result.nextCursor).not.toBeNull(); + + // Decode and verify the cursor + const decoded = decodeCompositeCursor<{ + id: string; + name: string; + timestamp: string; + }>(result.nextCursor!); + expect(decoded).toEqual({ + id: '6', + name: 'item-6', + timestamp: new Date('2024-01-06').toISOString(), + }); + }); + + it('should preserve prevCursor in result', () => { + const items = createTestItems(3); + const limit = 5; + const prevCursor = Buffer.from(JSON.stringify({ id: '10' })).toString('base64'); + + const result = paginateComposite(items, limit, prevCursor, (item) => ({ + id: item.id.toString(), + })); + + expect(result.cursor).toBe(prevCursor); + }); + + it('should convert bigint to string in cursor', () => { + const items = createTestItems(6); + const limit = 5; + + const result = paginateComposite(items, limit, undefined, (item) => ({ + id: item.id, // Pass bigint directly + otherId: BigInt(999), + })); + + expect(result.nextCursor).not.toBeNull(); + + // Decode and verify bigint is converted to string + const decoded = decodeCompositeCursor<{ id: string; otherId: string }>(result.nextCursor!); + expect(decoded).toEqual({ + id: '6', + otherId: '999', + }); + expect(typeof decoded?.id).toBe('string'); + expect(typeof decoded?.otherId).toBe('string'); + }); + + it('should handle empty items array', () => { + const items: TestItem[] = []; + const limit = 5; + + const result = paginateComposite(items, limit, undefined, (item) => ({ + id: item.id.toString(), + })); + + expect(result.hasNextPage).toBe(false); + expect(result.nextCursor).toBeNull(); + expect(result.cursor).toBeNull(); + }); + + it('should handle single item with limit 1', () => { + const items = createTestItems(2); + const limit = 1; + + const result = paginateComposite(items, limit, undefined, (item) => ({ + id: item.id.toString(), + })); + + expect(result.hasNextPage).toBe(true); + expect(result.nextCursor).not.toBeNull(); + expect(items.length).toBe(1); + }); + + it('should handle cursor with mixed types', () => { + const items = createTestItems(6); + const limit = 5; + + interface tp { + stringField: string; + numberField: number; + booleanField: boolean; + bigintField: bigint; + nullField: null; + undefinedField: undefined; + } + + const result = paginateComposite(items, limit, undefined, (item) => ({ + stringField: 'test', + numberField: 123, + booleanField: true, + bigintField: item.id, + nullField: null, + undefinedField: undefined, + })); + + expect(result.nextCursor).not.toBeNull(); + + const decoded = decodeCompositeCursor(result.nextCursor!); + expect(decoded!.stringField).toBe('test'); + expect(decoded!.numberField).toBe(123); + expect(decoded!.booleanField).toBe(true); + expect(decoded!.bigintField).toBe('6'); // Converted to string + expect(decoded!.nullField).toBeNull(); + expect(decoded!.undefinedField).toBeUndefined(); + }); + + it('should not mutate original items when hasNextPage is false', () => { + const items = createTestItems(5); + const originalLength = items.length; + const limit = 5; + + paginateComposite(items, limit, undefined, (item) => ({ id: item.id.toString() })); + + expect(items.length).toBe(originalLength); + }); + + it('should work with complex cursor field extraction', () => { + interface ComplexItem { + user: { id: bigint; username: string }; + post: { id: bigint; createdAt: Date }; + } + + const items: ComplexItem[] = [ + { + user: { id: BigInt(1), username: 'user1' }, + post: { id: BigInt(101), createdAt: new Date('2024-01-01') }, + }, + { + user: { id: BigInt(2), username: 'user2' }, + post: { id: BigInt(102), createdAt: new Date('2024-01-02') }, + }, + { + user: { id: BigInt(3), username: 'user3' }, + post: { id: BigInt(103), createdAt: new Date('2024-01-03') }, + }, + ]; + + const result = paginateComposite(items, 2, undefined, (item) => ({ + userId: item.user.id, + postId: item.post.id, + })); + + expect(result.hasNextPage).toBe(true); + const decoded = decodeCompositeCursor<{ userId: string; postId: string }>(result.nextCursor!); + expect(decoded).toEqual({ + userId: '3', + postId: '103', + }); + }); + }); +}); diff --git a/test/conversations/messages/messages-push.processor.spec.ts b/test/conversations/messages/messages-push.processor.spec.ts new file mode 100644 index 00000000..8ab7bc0e --- /dev/null +++ b/test/conversations/messages/messages-push.processor.spec.ts @@ -0,0 +1,625 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { Test, TestingModule } from '@nestjs/testing'; +import { Job } from 'bullmq'; +import { Logger } from '@nestjs/common'; +import { MessagesPushProcessor } from '../../../src/conversations/messages/messages-push.processor'; +import { PushSenderService } from 'src/firebase/push-sender.service'; +import { UsersRepository } from 'src/users/users.repository'; +import { MediaType, LanguageCode } from '@prisma/client'; +import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; +import * as fcmBuilder from 'src/notifications/utils/fcm-notification-body-builder'; + +interface MessageJobData { + actorId: string; + conversationId: string; + messagePreview: string; + receiverId: string; + hasMedia?: boolean; + mediaType?: MediaType | null; + reaction?: string | null; +} + +describe('MessagesPushProcessor', () => { + let processor: MessagesPushProcessor; + let pushSender: jest.Mocked; + let usersRepository: jest.Mocked; + + const mockPushSender = { + sendToDevices: jest.fn(), + }; + + const mockUsersRepository = { + getUsersMetadataById: jest.fn(), + getUserLocale: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + + // Suppress logger output during tests + jest.spyOn(Logger.prototype, 'log').mockImplementation(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + jest.spyOn(Logger.prototype, 'error').mockImplementation(); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MessagesPushProcessor, + { provide: PushSenderService, useValue: mockPushSender }, + { provide: UsersRepository, useValue: mockUsersRepository }, + ], + }).compile(); + + processor = module.get(MessagesPushProcessor); + pushSender = module.get(PushSenderService); + usersRepository = module.get(UsersRepository); + }); + + const createMockJob = (data: MessageJobData): Job => { + return { + data, + } as Job; + }; + + describe('process', () => { + const baseJobData = { + actorId: '1', + conversationId: '100', + messagePreview: 'Hello there!', + receiverId: '2', + }; + + const mockActor = { + id: BigInt(1), + username: 'alice', + profile: { + displayName: 'Alice Smith', + avatarUrl: 'http://example.com/alice.jpg', + }, + }; + + it('should skip sending when actor not found', async () => { + const job = createMockJob(baseJobData); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([]); + + await processor.process(job); + + expect(usersRepository.getUsersMetadataById).toHaveBeenCalledWith([BigInt(1)]); + expect(Logger.prototype.warn).toHaveBeenCalledWith( + 'Actor with id 1 not found, skipping push notification', + ); + expect(pushSender.sendToDevices).not.toHaveBeenCalled(); + }); + + it('should process text message and send push notification', async () => { + const job = createMockJob(baseJobData); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith sent you a message: "Hello there!"', + body: 'Hello there!', + }); + + await processor.process(job); + + expect(usersRepository.getUsersMetadataById).toHaveBeenCalledWith([BigInt(1)]); + expect(usersRepository.getUserLocale).toHaveBeenCalledWith(BigInt(2)); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: 'MESSAGE', + previewActors: ['Alice Smith'], + tweetSnippet: 'Hello there!', + locale: LanguageCode.EN, + reaction: undefined, + hasMedia: undefined, + mediaType: undefined, + }); + + expect(pushSender.sendToDevices).toHaveBeenCalledWith( + '2', + expect.objectContaining({ + token: null, + notification: { + title: 'Alice Smith sent you a message: "Hello there!"', + body: 'Hello there!', + image: 'http://example.com/alice.jpg', + }, + data: { + actorSummary: JSON.stringify([ + { + username: 'alice', + displayName: 'Alice Smith', + avatarUrl: 'http://example.com/alice.jpg', + }, + ]), + messageSummary: JSON.stringify({ + messagePreview: 'Hello there!', + conversationId: '100', + }), + }, + android: { + priority: 'high', + notification: { + channel_id: 'messages', + sound: 'default', + color: '#e5e7ff', + tag: 'msg:100', + }, + }, + }), + ); + }); + + it('should handle actor with no displayName (use username)', async () => { + const job = createMockJob(baseJobData); + + const actorNoDisplayName = { + id: BigInt(1), + username: 'bob', + profile: { + displayName: null, + avatarUrl: 'http://example.com/bob.jpg', + }, + }; + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([actorNoDisplayName]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'bob sent you a message: "Hello there!"', + body: 'Hello there!', + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: 'MESSAGE', + previewActors: ['bob'], + tweetSnippet: 'Hello there!', + locale: LanguageCode.EN, + reaction: undefined, + hasMedia: undefined, + mediaType: undefined, + }); + + const sendCall = pushSender.sendToDevices.mock.calls[0]; + const payload = sendCall[1]; + expect(payload.data).toBeDefined(); + const actorSummary = JSON.parse(payload.data!.actorSummary) as Array<{ + displayName: string | null; + }>; + expect(actorSummary[0].displayName).toBeNull(); + }); + + it('should use default profile picture when actor has no avatar', async () => { + const job = createMockJob(baseJobData); + + const actorNoAvatar = { + id: BigInt(1), + username: 'charlie', + profile: { + displayName: 'Charlie', + avatarUrl: null, + }, + }; + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([actorNoAvatar]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Charlie sent you a message: "Hello there!"', + body: 'Hello there!', + }); + + await processor.process(job); + + const sendCall = pushSender.sendToDevices.mock.calls[0]; + const payload = sendCall[1]; + + expect(payload.notification).toBeDefined(); + expect((payload.notification as any).image).toBe(DEFAULT_PROFILE_PICTURE); + + expect(payload.data).toBeDefined(); + const actorSummary = JSON.parse(payload.data!.actorSummary) as Array<{ avatarUrl: string }>; + expect(actorSummary[0].avatarUrl).toBe(DEFAULT_PROFILE_PICTURE); + }); + + it('should use default profile picture when actor has no profile', async () => { + const job = createMockJob(baseJobData); + + const actorNoProfile = { + id: BigInt(1), + username: 'dave', + profile: null, + }; + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([actorNoProfile]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'dave sent you a message: "Hello there!"', + body: 'Hello there!', + }); + + await processor.process(job); + + const sendCall = pushSender.sendToDevices.mock.calls[0]; + const payload = sendCall[1]; + + expect(payload.notification).toBeDefined(); + expect((payload.notification as any).image).toBe(DEFAULT_PROFILE_PICTURE); + + expect(payload.data).toBeDefined(); + const actorSummary = JSON.parse(payload.data!.actorSummary) as Array<{ avatarUrl: string }>; + expect(actorSummary[0].avatarUrl).toBe(DEFAULT_PROFILE_PICTURE); + }); + + it('should process message with photo media', async () => { + const job = createMockJob({ + ...baseJobData, + hasMedia: true, + mediaType: MediaType.IMAGE, + messagePreview: '', + }); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith sent you a photo', + body: undefined, + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: 'MESSAGE', + previewActors: ['Alice Smith'], + tweetSnippet: '', + locale: LanguageCode.EN, + reaction: undefined, + hasMedia: true, + mediaType: MediaType.IMAGE, + }); + + const sendCall = pushSender.sendToDevices.mock.calls[0]; + const payload = sendCall[1]; + + expect(payload.notification).toBeDefined(); + expect(payload.notification!.title).toBe('Alice Smith sent you a photo'); + expect(payload.notification!.body).toBeUndefined(); + }); + + it('should process message with video media', async () => { + const job = createMockJob({ + ...baseJobData, + hasMedia: true, + mediaType: MediaType.VIDEO, + messagePreview: '', + }); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith sent you a video', + body: undefined, + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: 'MESSAGE', + previewActors: ['Alice Smith'], + tweetSnippet: '', + locale: LanguageCode.EN, + reaction: undefined, + hasMedia: true, + mediaType: MediaType.VIDEO, + }); + + const sendCall = pushSender.sendToDevices.mock.calls[0]; + const payload = sendCall[1]; + + expect(payload.notification).toBeDefined(); + expect(payload.notification!.title).toBe('Alice Smith sent you a video'); + }); + + it('should process message with reaction', async () => { + const job = createMockJob({ + ...baseJobData, + reaction: '❤️', + messagePreview: 'Original message', + }); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith reacted ❤️ to your message: "Original message"', + body: 'Original message', + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: 'MESSAGE', + previewActors: ['Alice Smith'], + tweetSnippet: 'Original message', + locale: LanguageCode.EN, + reaction: '❤️', + hasMedia: undefined, + mediaType: undefined, + }); + + const sendCall = pushSender.sendToDevices.mock.calls[0]; + const payload = sendCall[1]; + + expect(payload.notification).toBeDefined(); + expect(payload.notification!.title).toContain('❤️'); + }); + + it('should process message in Arabic locale', async () => { + const job = createMockJob(baseJobData); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.AR); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'أرسل Alice Smith لك رسالة: "Hello there!"', + body: 'Hello there!', + }); + + await processor.process(job); + + expect(usersRepository.getUserLocale).toHaveBeenCalledWith(BigInt(2)); + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith( + expect.objectContaining({ + locale: LanguageCode.AR, + }), + ); + + expect(pushSender.sendToDevices).toHaveBeenCalled(); + }); + + it('should include conversation tag in android notification', async () => { + const job = createMockJob({ + ...baseJobData, + conversationId: '12345', + }); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith sent you a message: "Hello there!"', + body: 'Hello there!', + }); + + await processor.process(job); + + const sendCall = pushSender.sendToDevices.mock.calls[0]; + const payload = sendCall[1]; + + expect(payload.android).toBeDefined(); + expect(payload.android!.notification).toBeDefined(); + expect((payload.android!.notification as any).tag).toBe('msg:12345'); + }); + + it('should include message preview in data payload', async () => { + const job = createMockJob({ + ...baseJobData, + messagePreview: 'This is a longer message preview for testing', + }); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith sent you a message', + body: 'This is a longer message preview for testing', + }); + + await processor.process(job); + + const sendCall = pushSender.sendToDevices.mock.calls[0]; + const payload = sendCall[1]; + + expect(payload.data).toBeDefined(); + const messageSummary = JSON.parse(payload.data!.messageSummary) as { + messagePreview: string; + conversationId: string; + }; + expect(messageSummary.messagePreview).toBe('This is a longer message preview for testing'); + expect(messageSummary.conversationId).toBe('100'); + }); + + it('should set android notification priority to high', async () => { + const job = createMockJob(baseJobData); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith sent you a message: "Hello there!"', + body: 'Hello there!', + }); + + await processor.process(job); + + const sendCall = pushSender.sendToDevices.mock.calls[0]; + const payload = sendCall[1]; + + expect(payload.android).toBeDefined(); + expect(payload.android!.priority).toBe('high'); + expect(payload.android!.notification).toBeDefined(); + expect((payload.android!.notification as any).channel_id).toBe('messages'); + expect(payload.android!.notification!.sound).toBe('default'); + expect(payload.android!.notification!.color).toBe('#e5e7ff'); + }); + + it('should handle error when getting user metadata fails', async () => { + const job = createMockJob(baseJobData); + + const error = new Error('Database connection failed'); + mockUsersRepository.getUsersMetadataById.mockRejectedValue(error); + + await processor.process(job); + + expect(Logger.prototype.error).toHaveBeenCalledWith( + 'Error processing push notification for message to user 2', + error, + ); + expect(pushSender.sendToDevices).not.toHaveBeenCalled(); + }); + + it('should handle error when getting user locale fails', async () => { + const job = createMockJob(baseJobData); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + + const error = new Error('Failed to retrieve locale'); + mockUsersRepository.getUserLocale.mockRejectedValue(error); + + await processor.process(job); + + expect(Logger.prototype.error).toHaveBeenCalledWith( + 'Error processing push notification for message to user 2', + error, + ); + expect(pushSender.sendToDevices).not.toHaveBeenCalled(); + }); + + it('should handle error when push sender fails', async () => { + const job = createMockJob(baseJobData); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith sent you a message: "Hello there!"', + body: 'Hello there!', + }); + + const error = new Error('FCM service unavailable'); + mockPushSender.sendToDevices.mockRejectedValue(error); + + await processor.process(job); + + expect(Logger.prototype.error).toHaveBeenCalledWith( + 'Error processing push notification for message to user 2', + error, + ); + }); + + it('should log debug message with FCM payload', async () => { + const job = createMockJob(baseJobData); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith sent you a message: "Hello there!"', + body: 'Hello there!', + }); + + await processor.process(job); + + expect(Logger.prototype.debug).toHaveBeenCalledWith(expect.stringContaining('FCM Payload:')); + }); + + it('should handle empty message preview', async () => { + const job = createMockJob({ + ...baseJobData, + messagePreview: '', + }); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith sent you a message', + body: undefined, + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith( + expect.objectContaining({ + tweetSnippet: '', + }), + ); + + const sendCall = pushSender.sendToDevices.mock.calls[0]; + const payload = sendCall[1]; + + expect(payload.notification).toBeDefined(); + expect(payload.notification!.body).toBeUndefined(); + }); + + it('should handle null mediaType when hasMedia is false', async () => { + const job = createMockJob({ + ...baseJobData, + hasMedia: false, + mediaType: null, + }); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith sent you a message: "Hello there!"', + body: 'Hello there!', + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: 'MESSAGE', + previewActors: ['Alice Smith'], + tweetSnippet: 'Hello there!', + locale: LanguageCode.EN, + reaction: undefined, + hasMedia: false, + mediaType: null, + }); + + expect(pushSender.sendToDevices).toHaveBeenCalled(); + }); + + it('should handle null reaction', async () => { + const job = createMockJob({ + ...baseJobData, + reaction: null, + }); + + mockUsersRepository.getUsersMetadataById.mockResolvedValue([mockActor]); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice Smith sent you a message: "Hello there!"', + body: 'Hello there!', + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: 'MESSAGE', + previewActors: ['Alice Smith'], + tweetSnippet: 'Hello there!', + locale: LanguageCode.EN, + reaction: null, + hasMedia: undefined, + mediaType: undefined, + }); + + expect(pushSender.sendToDevices).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/firebase/push-sender.service.spec.ts b/test/firebase/push-sender.service.spec.ts new file mode 100644 index 00000000..635a8f13 --- /dev/null +++ b/test/firebase/push-sender.service.spec.ts @@ -0,0 +1,479 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; +import { DevicesRepository } from 'src/devices/devices.repository'; +import * as admin from 'firebase-admin'; +import { PushSenderService } from 'src/firebase/push-sender.service'; + +// Mock firebase-admin +jest.mock('firebase-admin', () => ({ + messaging: jest.fn(), +})); + +const mockMessaging = { + sendEachForMulticast: jest.fn(), +}; + +const mockDevicesRepository = { + getUserDevices: jest.fn(), + deleteDevicesByTokens: jest.fn(), +}; + +const mockLogger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +describe('PushSenderService', () => { + let service: PushSenderService; + let loggerLogSpy: jest.SpyInstance; + let loggerWarnSpy: jest.SpyInstance; + let loggerErrorSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.clearAllMocks(); + + (admin.messaging as jest.Mock).mockReturnValue(mockMessaging); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PushSenderService, + { provide: DevicesRepository, useValue: mockDevicesRepository }, + { provide: Logger, useValue: mockLogger }, + ], + }).compile(); + + service = module.get(PushSenderService); + + loggerLogSpy = jest + .spyOn((service as unknown as { logger: Logger }).logger, 'log') + .mockImplementation(() => {}); + loggerWarnSpy = jest + .spyOn((service as unknown as { logger: Logger }).logger, 'warn') + .mockImplementation(() => {}); + loggerErrorSpy = jest + .spyOn((service as unknown as { logger: Logger }).logger, 'error') + .mockImplementation(() => {}); + }); + + describe('sendToDevices', () => { + const userId = '123'; + const mockPayload: Omit = { + notification: { + title: 'Test Notification', + body: 'Test Body', + }, + data: { + id: '1', + type: 'LIKE', + }, + android: { + priority: 'normal', + notification: { + channelId: 'default', + }, + }, + }; + + it('should skip sending when no devices found for user', async () => { + mockDevicesRepository.getUserDevices.mockResolvedValue([]); + + await service.sendToDevices(userId, mockPayload); + + expect(mockDevicesRepository.getUserDevices).toHaveBeenCalledWith(BigInt(123)); + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'No devices found for user 123, skipping push notification', + ); + expect(mockMessaging.sendEachForMulticast).not.toHaveBeenCalled(); + }); + + it('should skip sending when devices have no FCM tokens', async () => { + const devices = [ + { id: BigInt(1), userId: BigInt(123), fcmToken: null }, + { id: BigInt(2), userId: BigInt(123), fcmToken: '' }, + ]; + + mockDevicesRepository.getUserDevices.mockResolvedValue(devices); + + await service.sendToDevices(userId, mockPayload); + + expect(mockDevicesRepository.getUserDevices).toHaveBeenCalledWith(BigInt(123)); + expect(loggerLogSpy).toHaveBeenCalledWith('Found 2 devices for user 123'); + expect(mockMessaging.sendEachForMulticast).not.toHaveBeenCalled(); + }); + + it('should send notification to all devices with valid tokens', async () => { + const devices = [ + { id: BigInt(1), userId: BigInt(123), fcmToken: 'token1' }, + { id: BigInt(2), userId: BigInt(123), fcmToken: 'token2' }, + { id: BigInt(3), userId: BigInt(123), fcmToken: 'token3' }, + ]; + + const mockResponse = { + successCount: 3, + failureCount: 0, + responses: [ + { success: true, messageId: 'msg1' }, + { success: true, messageId: 'msg2' }, + { success: true, messageId: 'msg3' }, + ], + }; + + mockDevicesRepository.getUserDevices.mockResolvedValue(devices); + mockMessaging.sendEachForMulticast.mockResolvedValue(mockResponse); + + await service.sendToDevices(userId, mockPayload); + + expect(mockDevicesRepository.getUserDevices).toHaveBeenCalledWith(BigInt(123)); + expect(loggerLogSpy).toHaveBeenCalledWith('Found 3 devices for user 123'); + expect(loggerLogSpy).toHaveBeenCalledWith('tokens: token1, token2, token3'); + expect(mockMessaging.sendEachForMulticast).toHaveBeenCalledWith({ + tokens: ['token1', 'token2', 'token3'], + notification: mockPayload.notification, + data: mockPayload.data, + android: mockPayload.android, + }); + expect(loggerLogSpy).toHaveBeenCalledWith('response: ', mockResponse); + expect(mockDevicesRepository.deleteDevicesByTokens).not.toHaveBeenCalled(); + }); + + it('should filter out null and empty tokens before sending', async () => { + const devices = [ + { id: BigInt(1), userId: BigInt(123), fcmToken: 'token1' }, + { id: BigInt(2), userId: BigInt(123), fcmToken: null }, + { id: BigInt(3), userId: BigInt(123), fcmToken: 'token2' }, + { id: BigInt(4), userId: BigInt(123), fcmToken: '' }, + { id: BigInt(5), userId: BigInt(123), fcmToken: 'token3' }, + ]; + + const mockResponse = { + successCount: 3, + failureCount: 0, + responses: [ + { success: true, messageId: 'msg1' }, + { success: true, messageId: 'msg2' }, + { success: true, messageId: 'msg3' }, + ], + }; + + mockDevicesRepository.getUserDevices.mockResolvedValue(devices); + mockMessaging.sendEachForMulticast.mockResolvedValue(mockResponse); + + await service.sendToDevices(userId, mockPayload); + + expect(mockMessaging.sendEachForMulticast).toHaveBeenCalledWith({ + tokens: ['token1', 'token2', 'token3'], + notification: mockPayload.notification, + data: mockPayload.data, + android: mockPayload.android, + }); + }); + + it('should handle partial failures and delete invalid tokens', async () => { + const devices = [ + { id: BigInt(1), userId: BigInt(123), fcmToken: 'validToken1' }, + { id: BigInt(2), userId: BigInt(123), fcmToken: 'invalidToken1' }, + { id: BigInt(3), userId: BigInt(123), fcmToken: 'validToken2' }, + { id: BigInt(4), userId: BigInt(123), fcmToken: 'invalidToken2' }, + ]; + + const mockResponse = { + successCount: 2, + failureCount: 2, + responses: [ + { success: true, messageId: 'msg1' }, + { + success: false, + error: { + code: 'messaging/invalid-registration-token', + message: 'Invalid token', + } as admin.FirebaseError, + }, + { success: true, messageId: 'msg2' }, + { + success: false, + error: { + code: 'messaging/registration-token-not-registered', + message: 'Token not registered', + } as admin.FirebaseError, + }, + ], + }; + + mockDevicesRepository.getUserDevices.mockResolvedValue(devices); + mockMessaging.sendEachForMulticast.mockResolvedValue(mockResponse); + + await service.sendToDevices(userId, mockPayload); + + expect(mockMessaging.sendEachForMulticast).toHaveBeenCalledWith({ + tokens: ['validToken1', 'invalidToken1', 'validToken2', 'invalidToken2'], + notification: mockPayload.notification, + data: mockPayload.data, + android: mockPayload.android, + }); + + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'Failed to send notification to token invalidToken1: Invalid token', + ); + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'Failed to send notification to token invalidToken2: Token not registered', + ); + + expect(mockDevicesRepository.deleteDevicesByTokens).toHaveBeenCalledWith([ + 'invalidToken1', + 'invalidToken2', + ]); + expect(loggerLogSpy).toHaveBeenCalledWith('Deleted 2 invalid tokens for user 123'); + }); + + it('should not delete tokens for non-registration errors', async () => { + const devices = [ + { id: BigInt(1), userId: BigInt(123), fcmToken: 'token1' }, + { id: BigInt(2), userId: BigInt(123), fcmToken: 'token2' }, + ]; + + const mockResponse = { + successCount: 1, + failureCount: 1, + responses: [ + { success: true, messageId: 'msg1' }, + { + success: false, + error: { + code: 'messaging/internal-error', + message: 'Internal server error', + } as admin.FirebaseError, + }, + ], + }; + + mockDevicesRepository.getUserDevices.mockResolvedValue(devices); + mockMessaging.sendEachForMulticast.mockResolvedValue(mockResponse); + + await service.sendToDevices(userId, mockPayload); + + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'Failed to send notification to token token2: Internal server error', + ); + expect(mockDevicesRepository.deleteDevicesByTokens).not.toHaveBeenCalled(); + }); + + it('should handle mix of registration and non-registration errors', async () => { + const devices = [ + { id: BigInt(1), userId: BigInt(123), fcmToken: 'token1' }, + { id: BigInt(2), userId: BigInt(123), fcmToken: 'invalidToken' }, + { id: BigInt(3), userId: BigInt(123), fcmToken: 'token3' }, + { id: BigInt(4), userId: BigInt(123), fcmToken: 'token4' }, + ]; + + const mockResponse = { + successCount: 2, + failureCount: 2, + responses: [ + { success: true, messageId: 'msg1' }, + { + success: false, + error: { + code: 'messaging/invalid-registration-token', + message: 'Invalid token', + } as admin.FirebaseError, + }, + { + success: false, + error: { + code: 'messaging/quota-exceeded', + message: 'Quota exceeded', + } as admin.FirebaseError, + }, + { success: true, messageId: 'msg4' }, + ], + }; + + mockDevicesRepository.getUserDevices.mockResolvedValue(devices); + mockMessaging.sendEachForMulticast.mockResolvedValue(mockResponse); + + await service.sendToDevices(userId, mockPayload); + + expect(mockDevicesRepository.deleteDevicesByTokens).toHaveBeenCalledWith(['invalidToken']); + expect(loggerLogSpy).toHaveBeenCalledWith('Deleted 1 invalid tokens for user 123'); + }); + + it('should handle errors from Firebase messaging gracefully', async () => { + const devices = [{ id: BigInt(1), userId: BigInt(123), fcmToken: 'token1' }]; + + const error = new Error('Firebase connection timeout'); + + mockDevicesRepository.getUserDevices.mockResolvedValue(devices); + mockMessaging.sendEachForMulticast.mockRejectedValue(error); + + await service.sendToDevices(userId, mockPayload); + + expect(mockMessaging.sendEachForMulticast).toHaveBeenCalled(); + expect(loggerErrorSpy).toHaveBeenCalledWith('Error sending push to user 123', error); + expect(mockDevicesRepository.deleteDevicesByTokens).not.toHaveBeenCalled(); + }); + + it('should handle errors from devicesRepository.getUserDevices', async () => { + const error = new Error('Database connection failed'); + + mockDevicesRepository.getUserDevices.mockRejectedValue(error); + + await expect(service.sendToDevices(userId, mockPayload)).rejects.toThrow( + 'Database connection failed', + ); + + expect(mockMessaging.sendEachForMulticast).not.toHaveBeenCalled(); + }); + + it('should handle all devices having invalid tokens', async () => { + const devices = [ + { id: BigInt(1), userId: BigInt(123), fcmToken: 'invalidToken1' }, + { id: BigInt(2), userId: BigInt(123), fcmToken: 'invalidToken2' }, + { id: BigInt(3), userId: BigInt(123), fcmToken: 'invalidToken3' }, + ]; + + const mockResponse = { + successCount: 0, + failureCount: 3, + responses: [ + { + success: false, + error: { + code: 'messaging/invalid-registration-token', + message: 'Invalid token', + } as admin.FirebaseError, + }, + { + success: false, + error: { + code: 'messaging/registration-token-not-registered', + message: 'Token not registered', + } as admin.FirebaseError, + }, + { + success: false, + error: { + code: 'messaging/invalid-registration-token', + message: 'Invalid token', + } as admin.FirebaseError, + }, + ], + }; + + mockDevicesRepository.getUserDevices.mockResolvedValue(devices); + mockMessaging.sendEachForMulticast.mockResolvedValue(mockResponse); + + await service.sendToDevices(userId, mockPayload); + + expect(mockDevicesRepository.deleteDevicesByTokens).toHaveBeenCalledWith([ + 'invalidToken1', + 'invalidToken2', + 'invalidToken3', + ]); + expect(loggerLogSpy).toHaveBeenCalledWith('Deleted 3 invalid tokens for user 123'); + }); + + it('should handle response with missing error details', async () => { + const devices = [ + { id: BigInt(1), userId: BigInt(123), fcmToken: 'token1' }, + { id: BigInt(2), userId: BigInt(123), fcmToken: 'token2' }, + ]; + + const mockResponse = { + successCount: 1, + failureCount: 1, + responses: [ + { success: true, messageId: 'msg1' }, + { + success: false, + error: undefined, + }, + ], + }; + + mockDevicesRepository.getUserDevices.mockResolvedValue(devices); + mockMessaging.sendEachForMulticast.mockResolvedValue(mockResponse); + + await service.sendToDevices(userId, mockPayload); + + expect(loggerWarnSpy).toHaveBeenCalledWith( + 'Failed to send notification to token token2: undefined', + ); + expect(mockDevicesRepository.deleteDevicesByTokens).not.toHaveBeenCalled(); + }); + + it('should handle large number of devices', async () => { + const devices = Array.from({ length: 100 }, (_, i) => ({ + id: BigInt(i + 1), + userId: BigInt(123), + fcmToken: `token${i + 1}`, + })); + + const mockResponse = { + successCount: 100, + failureCount: 0, + responses: Array.from({ length: 100 }, (_, i) => ({ + success: true, + messageId: `msg${i + 1}`, + })), + }; + + mockDevicesRepository.getUserDevices.mockResolvedValue(devices); + mockMessaging.sendEachForMulticast.mockResolvedValue(mockResponse); + + await service.sendToDevices(userId, mockPayload); + + expect(loggerLogSpy).toHaveBeenCalledWith('Found 100 devices for user 123'); + expect(mockMessaging.sendEachForMulticast).toHaveBeenCalledWith( + expect.objectContaining({ + tokens: expect.arrayContaining(['token1', 'token50', 'token100']) as string[], + }), + ); + expect(mockDevicesRepository.deleteDevicesByTokens).not.toHaveBeenCalled(); + }); + + it('should pass notification payload correctly to Firebase', async () => { + const devices = [{ id: BigInt(1), userId: BigInt(123), fcmToken: 'token1' }]; + + const complexPayload: Omit = { + notification: { + title: 'Complex Notification', + body: 'With body text', + }, + data: { + id: '999', + type: 'MENTION', + actorSummary: JSON.stringify({ username: 'test' }), + tweetSubjectIds: JSON.stringify(['100', '200']), + }, + android: { + priority: 'high', + notification: { + channelId: 'mentions', + sound: 'default', + color: '#FF5733', + tag: 'MENTION:TWEET:100', + }, + }, + }; + + const mockResponse = { + successCount: 1, + failureCount: 0, + responses: [{ success: true, messageId: 'msg1' }], + }; + + mockDevicesRepository.getUserDevices.mockResolvedValue(devices); + mockMessaging.sendEachForMulticast.mockResolvedValue(mockResponse); + + await service.sendToDevices(userId, complexPayload); + + expect(mockMessaging.sendEachForMulticast).toHaveBeenCalledWith({ + tokens: ['token1'], + notification: complexPayload.notification, + data: complexPayload.data, + android: complexPayload.android, + }); + }); + }); +}); diff --git a/test/notifications/fcm-notification-body-builder.spec.ts b/test/notifications/fcm-notification-body-builder.spec.ts new file mode 100644 index 00000000..f734ba78 --- /dev/null +++ b/test/notifications/fcm-notification-body-builder.spec.ts @@ -0,0 +1,227 @@ +import { buildFcmNotificationText } from '../../src/notifications/utils/fcm-notification-body-builder'; +import { NotificationType, LanguageCode, MediaType } from '@prisma/client'; + +describe('buildFcmNotificationText', () => { + const actor = 'Alice'; + const snippet = 'Hello World'; + + describe('English (Default)', () => { + it('should handle LIKE notification (single)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.LIKE, + previewActors: [actor], + tweetSnippet: snippet, + }); + expect(result.title).toBe(`${actor} liked your tweet`); + expect(result.body).toBe(snippet); + }); + + it('should handle LIKE notification (aggregated)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.LIKE, + isAggregated: true, + previewActors: [actor], + totalActorCount: 3, + tweetSnippet: snippet, + }); + expect(result.title).toBe(`${actor} and 2 others liked your tweet`); + expect(result.body).toBe('You have a new notification'); + }); + + it('should handle FOLLOW notification (single)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.FOLLOW, + previewActors: [actor], + }); + expect(result.title).toBe(`${actor} followed you`); + expect(result.body).toBe('You have a new notification'); + }); + + it('should handle FOLLOW notification (aggregated)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.FOLLOW, + isAggregated: true, + previewActors: [actor], + totalActorCount: 3, + }); + expect(result.title).toBe(`${actor} and 2 others followed you`); + }); + + it('should handle REPLY notification (single)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.REPLY, + previewActors: [actor], + tweetSnippet: snippet, + }); + expect(result.title).toBe(`${actor} replied: "${snippet}"`); + expect(result.body).toBe(snippet); + }); + + it('should handle MENTION notification', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.MENTION, + previewActors: [actor], + tweetSnippet: snippet, + }); + expect(result.title).toBe(`${actor} mentioned you: "${snippet}"`); + expect(result.body).toBe(snippet); + }); + + it('should handle QUOTE notification (single)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.QUOTE, + previewActors: [actor], + tweetSnippet: snippet, + }); + expect(result.title).toBe(`${actor} quoted: "${snippet}"`); + expect(result.body).toBe(snippet); + }); + + it('should handle QUOTE notification (aggregated)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.QUOTE, + isAggregated: true, + previewActors: [actor], + totalActorCount: 3, + tweetSnippet: snippet, + }); + expect(result.title).toBe('New interaction'); + }); + + it('should handle RETWEET notification (single)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.RETWEET, + previewActors: [actor], + }); + expect(result.title).toBe(`${actor} retweeted your tweet`); + }); + + it('should handle RETWEET notification (aggregated)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.RETWEET, + isAggregated: true, + previewActors: [actor], + totalActorCount: 3, + }); + expect(result.title).toBe(`${actor} and 2 others retweeted your tweet`); + }); + + it('should handle TWEET notification', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.TWEET, + previewActors: [actor], + }); + expect(result.title).toBe(`${actor} posted a new tweet`); + }); + + it('should handle MESSAGE notification (text)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.MESSAGE, + previewActors: [actor], + tweetSnippet: snippet, + }); + expect(result.title).toBe(`${actor} sent you a message: "${snippet}"`); + }); + + it('should handle MESSAGE notification (reaction)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.MESSAGE, + previewActors: [actor], + tweetSnippet: snippet, + reaction: '❤️', + }); + expect(result.title).toBe(`${actor} reacted ❤️ to your message: "${snippet}"`); + }); + + it('should handle MESSAGE notification (photo)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.MESSAGE, + previewActors: [actor], + hasMedia: true, + mediaType: MediaType.IMAGE, + }); + expect(result.title).toBe(`${actor} sent you a photo`); + }); + + it('should handle MESSAGE notification (video)', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.MESSAGE, + previewActors: [actor], + hasMedia: true, + mediaType: MediaType.VIDEO, + }); + expect(result.title).toBe(`${actor} sent you a video`); + }); + }); + + describe('Arabic Localization', () => { + const locale = LanguageCode.AR; + + it('should handle LIKE notification (single) in Arabic', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.LIKE, + previewActors: [actor], + locale, + }); + expect(result.title).toBe(`أعجب ${actor} بتغريدتك`); + }); + + it('should handle LIKE notification (aggregated) in Arabic', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.LIKE, + isAggregated: true, + previewActors: [actor], + totalActorCount: 3, + locale, + }); + expect(result.title).toBe(`أعجب ${actor} و2 آخرون بتغريدتك`); + }); + }); + + describe('Sanitization and Truncation', () => { + it('should sanitize and truncate long text', () => { + const longSnippet = 'a'.repeat(200); + const result = buildFcmNotificationText({ + notificationType: NotificationType.REPLY, + previewActors: [actor], + tweetSnippet: longSnippet, + }); + + expect(result.title.length).toBeLessThanOrEqual(50); + expect(result.title.endsWith('…')).toBe(true); + + expect(result.body?.length).toBeLessThanOrEqual(120); + }); + + it('should remove control characters', () => { + const dirtySnippet = 'Hello\n\tWorld'; + const result = buildFcmNotificationText({ + notificationType: NotificationType.REPLY, + previewActors: [actor], + tweetSnippet: dirtySnippet, + }); + expect(result.title).toContain('Hello World'); + }); + }); + + describe('Edge Cases', () => { + it('should handle missing previewActors', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.LIKE, + previewActors: [], + }); + expect(result.title).toBe('Someone liked your tweet'); + }); + + it('should fallback to generic for unknown types or missing templates', () => { + const result = buildFcmNotificationText({ + notificationType: NotificationType.REPLY, + isAggregated: true, + previewActors: [actor], + totalActorCount: 3, + tweetSnippet: snippet, + }); + expect(result.title).toBe('New interaction'); + }); + }); +}); diff --git a/test/notifications/notifications.listeners.spec.ts b/test/notifications/notifications.listeners.spec.ts new file mode 100644 index 00000000..69c01307 --- /dev/null +++ b/test/notifications/notifications.listeners.spec.ts @@ -0,0 +1,559 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TweetsRepository } from 'src/tweets/tweets.repository'; +import { Logger } from '@nestjs/common'; +import type { + TweetLikedEvent, + TweetCreatedEvent, + TweetRetweetedEvent, + TweetUnlikedEvent, + UserUnfollowedEvent, + TweetUnretweetedEvent, + UserFollowedEvent, + TweetDeleted, +} from 'src/events/interfaces/event.interface'; +import { NotificationsListeners } from 'src/notifications/notifications.listeners'; +import { NotificationsService } from 'src/notifications/notifications.service'; + +const mockNotificationsService = { + trigger: jest.fn(), + handleUndo: jest.fn(), + handleTweetDeletionNotifications: jest.fn(), +}; + +const mockTweetRepository = { + findTweetById: jest.fn(), +}; + +describe('NotificationsListeners', () => { + let listeners: NotificationsListeners; + let loggerErrorSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationsListeners, + { provide: NotificationsService, useValue: mockNotificationsService }, + { provide: TweetsRepository, useValue: mockTweetRepository }, + ], + }).compile(); + + listeners = module.get(NotificationsListeners); + + loggerErrorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(function (this: void) {}); + }); + + describe('handleTweetLiked', () => { + it('should trigger notification for tweet like', async () => { + const payload: TweetLikedEvent = { + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }; + + await listeners.handleTweetLiked(payload); + + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + type: 'LIKE', + }); + }); + + it('should log error if trigger fails', async () => { + const payload: TweetLikedEvent = { + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }; + + const error = new Error('Trigger failed'); + mockNotificationsService.trigger.mockRejectedValueOnce(error); + + await listeners.handleTweetLiked(payload); + + expect(loggerErrorSpy).toHaveBeenCalledWith('Error processing Tweet_Liked event:', error); + }); + }); + + describe('handleUserFollowed', () => { + it('should trigger notification for user follow', async () => { + const payload: UserFollowedEvent = { + actorId: 1n, + receiverId: 2n, + }; + + await listeners.handleUserFollowed(payload); + + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + actorId: 1n, + receiverId: 2n, + type: 'FOLLOW', + }); + }); + + it('should log error if trigger fails', async () => { + const payload: UserFollowedEvent = { + actorId: 1n, + receiverId: 2n, + }; + + const error = new Error('Trigger failed'); + mockNotificationsService.trigger.mockRejectedValueOnce(error); + + await listeners.handleUserFollowed(payload); + + expect(loggerErrorSpy).toHaveBeenCalledWith('Error processing User_Followed event:', error); + }); + }); + + describe('handleTweetRetweeted', () => { + it('should trigger notification for tweet retweet', async () => { + const payload: TweetRetweetedEvent = { + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }; + + await listeners.handleTweetRetweeted(payload); + + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + type: 'RETWEET', + }); + }); + + it('should log error if trigger fails', async () => { + const payload: TweetRetweetedEvent = { + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }; + + const error = new Error('Trigger failed'); + mockNotificationsService.trigger.mockRejectedValueOnce(error); + + await listeners.handleTweetRetweeted(payload); + + expect(loggerErrorSpy).toHaveBeenCalledWith('Error processing Tweet_Retweeted event:', error); + }); + }); + + describe('handleTweetCreated', () => { + it('should trigger REPLY notification when replying to a tweet', async () => { + const payload: TweetCreatedEvent = { + tweetId: 100n, + authorId: 1n, + replyToTweetId: 50n, + quoteToTweetId: null, + mentionedUserIds: [], + }; + + const parentTweet = { id: 50n, userId: 2n }; + mockTweetRepository.findTweetById.mockResolvedValueOnce(parentTweet); + + await listeners.handleTweetCreated(payload); + + expect(mockTweetRepository.findTweetById).toHaveBeenCalledWith(50n); + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'REPLY', + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }); + }); + + it('should not trigger REPLY notification when replying to own tweet', async () => { + const payload: TweetCreatedEvent = { + tweetId: 100n, + authorId: 1n, + replyToTweetId: 50n, + quoteToTweetId: null, + mentionedUserIds: [], + }; + + const parentTweet = { id: 50n, userId: 1n }; + mockTweetRepository.findTweetById.mockResolvedValueOnce(parentTweet); + + await listeners.handleTweetCreated(payload); + + expect(mockNotificationsService.trigger).not.toHaveBeenCalled(); + }); + + it('should not trigger REPLY notification when parent tweet not found', async () => { + const payload: TweetCreatedEvent = { + tweetId: 100n, + authorId: 1n, + replyToTweetId: 50n, + quoteToTweetId: null, + mentionedUserIds: [], + }; + + mockTweetRepository.findTweetById.mockResolvedValueOnce(null); + + await listeners.handleTweetCreated(payload); + + expect(mockNotificationsService.trigger).not.toHaveBeenCalled(); + }); + + it('should trigger QUOTE notification when quoting a tweet', async () => { + const payload: TweetCreatedEvent = { + tweetId: 100n, + authorId: 1n, + replyToTweetId: null, + quoteToTweetId: 50n, + mentionedUserIds: [], + }; + + const quotedTweet = { id: 50n, userId: 2n }; + mockTweetRepository.findTweetById.mockResolvedValueOnce(quotedTweet); + + await listeners.handleTweetCreated(payload); + + expect(mockTweetRepository.findTweetById).toHaveBeenCalledWith(50n); + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'QUOTE', + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }); + }); + + it('should not trigger QUOTE notification when quoting own tweet', async () => { + const payload: TweetCreatedEvent = { + tweetId: 100n, + authorId: 1n, + replyToTweetId: null, + quoteToTweetId: 50n, + mentionedUserIds: [], + }; + + const quotedTweet = { id: 50n, userId: 1n }; + mockTweetRepository.findTweetById.mockResolvedValueOnce(quotedTweet); + + await listeners.handleTweetCreated(payload); + + expect(mockNotificationsService.trigger).not.toHaveBeenCalled(); + }); + + it('should trigger MENTION notifications for mentioned users', async () => { + const payload: TweetCreatedEvent = { + tweetId: 100n, + authorId: 1n, + replyToTweetId: null, + quoteToTweetId: null, + mentionedUserIds: [2n, 3n, 4n], + }; + + await listeners.handleTweetCreated(payload); + + expect(mockNotificationsService.trigger).toHaveBeenCalledTimes(3); + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'MENTION', + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }); + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'MENTION', + actorId: 1n, + receiverId: 3n, + tweetId: 100n, + }); + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'MENTION', + actorId: 1n, + receiverId: 4n, + tweetId: 100n, + }); + }); + + it('should skip mention notification for author', async () => { + const payload: TweetCreatedEvent = { + tweetId: 100n, + authorId: 1n, + replyToTweetId: null, + quoteToTweetId: null, + mentionedUserIds: [1n, 2n], + }; + + await listeners.handleTweetCreated(payload); + + expect(mockNotificationsService.trigger).toHaveBeenCalledTimes(1); + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'MENTION', + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }); + }); + + it('should avoid duplicate notifications when replying to a mentioned user', async () => { + const payload: TweetCreatedEvent = { + tweetId: 100n, + authorId: 1n, + replyToTweetId: 50n, + quoteToTweetId: null, + mentionedUserIds: [2n, 3n], + }; + + const parentTweet = { id: 50n, userId: 2n }; + mockTweetRepository.findTweetById.mockResolvedValueOnce(parentTweet); + + await listeners.handleTweetCreated(payload); + + expect(mockNotificationsService.trigger).toHaveBeenCalledTimes(2); + // User 2 should only get REPLY notification, not MENTION + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'REPLY', + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }); + // User 3 should get MENTION notification + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'MENTION', + actorId: 1n, + receiverId: 3n, + tweetId: 100n, + }); + }); + + it('should avoid duplicate notifications when quoting a mentioned user', async () => { + const payload: TweetCreatedEvent = { + tweetId: 100n, + authorId: 1n, + replyToTweetId: null, + quoteToTweetId: 50n, + mentionedUserIds: [2n, 3n], + }; + + const quotedTweet = { id: 50n, userId: 2n }; + mockTweetRepository.findTweetById.mockResolvedValueOnce(quotedTweet); + + await listeners.handleTweetCreated(payload); + + expect(mockNotificationsService.trigger).toHaveBeenCalledTimes(2); + // User 2 should only get QUOTE notification, not MENTION + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'QUOTE', + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }); + // User 3 should get MENTION notification + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'MENTION', + actorId: 1n, + receiverId: 3n, + tweetId: 100n, + }); + }); + + it('should handle both reply and quote with mentions', async () => { + const payload: TweetCreatedEvent = { + tweetId: 100n, + authorId: 1n, + replyToTweetId: 50n, + quoteToTweetId: 60n, + mentionedUserIds: [2n, 3n, 4n, 5n], + }; + + const parentTweet = { id: 50n, userId: 2n }; + const quotedTweet = { id: 60n, userId: 3n }; + mockTweetRepository.findTweetById + .mockResolvedValueOnce(parentTweet) + .mockResolvedValueOnce(quotedTweet); + + await listeners.handleTweetCreated(payload); + + expect(mockNotificationsService.trigger).toHaveBeenCalledTimes(4); + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'REPLY', + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }); + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'QUOTE', + actorId: 1n, + receiverId: 3n, + tweetId: 100n, + }); + // Users 4 and 5 should get MENTION notifications + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'MENTION', + actorId: 1n, + receiverId: 4n, + tweetId: 100n, + }); + expect(mockNotificationsService.trigger).toHaveBeenCalledWith({ + type: 'MENTION', + actorId: 1n, + receiverId: 5n, + tweetId: 100n, + }); + }); + + it('should log error if processing fails', async () => { + const payload: TweetCreatedEvent = { + tweetId: 100n, + authorId: 1n, + replyToTweetId: 50n, + quoteToTweetId: null, + mentionedUserIds: [], + }; + + const error = new Error('Database error'); + mockTweetRepository.findTweetById.mockRejectedValueOnce(error); + + await listeners.handleTweetCreated(payload); + + expect(loggerErrorSpy).toHaveBeenCalledWith('Error processing Tweet_Created event:', error); + }); + }); + + describe('handleTweetUnliked', () => { + it('should handle undo for tweet unlike', async () => { + const payload: TweetUnlikedEvent = { + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }; + + await listeners.handleTweetUnliked(payload); + + expect(mockNotificationsService.handleUndo).toHaveBeenCalledWith({ + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + type: 'LIKE', + }); + }); + + it('should log error if handleUndo fails', async () => { + const payload: TweetUnlikedEvent = { + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }; + + const error = new Error('Undo failed'); + mockNotificationsService.handleUndo.mockRejectedValueOnce(error); + + await listeners.handleTweetUnliked(payload); + + expect(loggerErrorSpy).toHaveBeenCalledWith('Error processing Tweet_Unliked event:', error); + }); + }); + + describe('handleUserUnfollowed', () => { + it('should handle undo for user unfollow', async () => { + const payload: UserUnfollowedEvent = { + actorId: 1n, + receiverId: 2n, + }; + + await listeners.handleUserUnfollowed(payload); + + expect(mockNotificationsService.handleUndo).toHaveBeenCalledWith({ + actorId: 1n, + receiverId: 2n, + type: 'FOLLOW', + }); + }); + + it('should log error if handleUndo fails', async () => { + const payload: UserUnfollowedEvent = { + actorId: 1n, + receiverId: 2n, + }; + + const error = new Error('Undo failed'); + mockNotificationsService.handleUndo.mockRejectedValueOnce(error); + + await listeners.handleUserUnfollowed(payload); + + expect(loggerErrorSpy).toHaveBeenCalledWith('Error processing User_Unfollowed event:', error); + }); + }); + + describe('handleTweetUnRetweeted', () => { + it('should handle undo for tweet unretweet', async () => { + const payload: TweetUnretweetedEvent = { + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }; + + await listeners.handleTweetUnRetweeted(payload); + + expect(mockNotificationsService.handleUndo).toHaveBeenCalledWith({ + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + type: 'RETWEET', + }); + }); + + it('should log error if handleUndo fails', async () => { + const payload: TweetUnretweetedEvent = { + actorId: 1n, + receiverId: 2n, + tweetId: 100n, + }; + + const error = new Error('Undo failed'); + mockNotificationsService.handleUndo.mockRejectedValueOnce(error); + + await listeners.handleTweetUnRetweeted(payload); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Error processing Tweet_Unretweeted event:', + error, + ); + }); + }); + + describe('handleTweetDeleted', () => { + it('should handle tweet deletion notifications', async () => { + const payload: TweetDeleted = { + receivers: [ + { receiverId: 1n, unseenCount: 2 }, + { receiverId: 2n, unseenCount: 3 }, + { receiverId: 3n, unseenCount: 4 }, + ], + }; + await listeners.handleTweetDeleted(payload); + + expect(mockNotificationsService.handleTweetDeletionNotifications).toHaveBeenCalledWith([ + { receiverId: 1n, unseenCount: 2 }, + { receiverId: 2n, unseenCount: 3 }, + { receiverId: 3n, unseenCount: 4 }, + ]); + }); + + it('should log error if handling deletion fails', async () => { + const payload: TweetDeleted = { + receivers: [ + { receiverId: 1n, unseenCount: 2 }, + { receiverId: 2n, unseenCount: 3 }, + { receiverId: 3n, unseenCount: 4 }, + ], + }; + + const error = new Error('Deletion handling failed'); + mockNotificationsService.handleTweetDeletionNotifications.mockRejectedValueOnce(error); + + await listeners.handleTweetDeleted(payload); + + expect(loggerErrorSpy).toHaveBeenCalledWith('Error processing Tweet_Deleted event:', error); + }); + }); +}); diff --git a/test/notifications/notifications.processor.spec.ts b/test/notifications/notifications.processor.spec.ts new file mode 100644 index 00000000..5d3f4ebe --- /dev/null +++ b/test/notifications/notifications.processor.spec.ts @@ -0,0 +1,718 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { Test, TestingModule } from '@nestjs/testing'; +import { Job } from 'bullmq'; +import { NotificationProcessor } from '../../src/notifications/notifications.processor'; +import { NotificationsRepository } from '../../src/notifications/notifications.repository'; +import { UsersRepository } from 'src/users/users.repository'; +import { PushSenderService } from 'src/firebase/push-sender.service'; +import { NotificationType, LanguageCode } from '@prisma/client'; +import { Logger } from '@nestjs/common'; +import * as fcmBuilder from '../../src/notifications/utils/fcm-notification-body-builder'; +import { DEFAULT_PROFILE_PICTURE } from 'src/users/constants'; + +interface NotificationJobData { + notificationId: string; + userId: string; +} + +interface ExtendedNotification { + title?: string; + body?: string; + image?: string; +} + +const mockNotificationsRepository = { + findByIdForPush: jest.fn(), +}; + +const mockUsersRepository = { + getUserLocale: jest.fn(), +}; + +const mockPushService = { + sendToDevices: jest.fn(), +}; + +describe('NotificationProcessor', () => { + let processor: NotificationProcessor; + let pushService: jest.Mocked; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationProcessor, + { provide: NotificationsRepository, useValue: mockNotificationsRepository }, + { provide: UsersRepository, useValue: mockUsersRepository }, + { provide: PushSenderService, useValue: mockPushService }, + ], + }).compile(); + + processor = module.get(NotificationProcessor); + pushService = module.get(PushSenderService); + + jest.spyOn(Logger.prototype, 'error').mockImplementation(function (this: void) {}); + jest.spyOn(Logger.prototype, 'log').mockImplementation(function (this: void) {}); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(function (this: void) {}); + }); + + const createMockJob = (notificationId: string, userId: string): Job => { + return { + data: { notificationId, userId }, + } as Job; + }; + + describe('process', () => { + it('should skip push notification when notification is not found', async () => { + const job = createMockJob('999', '10'); + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(null); + + await processor.process(job); + + expect(mockNotificationsRepository.findByIdForPush).toHaveBeenCalledWith( + BigInt(999), + BigInt(10), + ); + expect(Logger.prototype.warn).toHaveBeenCalledWith( + 'Notification with id 999 not found, skipping push notification', + ); + expect(pushService.sendToDevices).not.toHaveBeenCalled(); + }); + + it('should process LIKE notification and send push', async () => { + const job = createMockJob('1', '10'); + + const notification = { + id: BigInt(1), + type: NotificationType.LIKE, + isAggregated: false, + seen: false, + latestEventAt: new Date('2024-01-01T10:00:00Z'), + dedupeKey: 'LIKE:TWEET:100', + actor: { + id: BigInt(2), + username: 'alice', + profile: { + displayName: 'Alice', + avatarUrl: 'http://example.com/alice.jpg', + }, + followers: [], + }, + tweet: { + id: BigInt(100), + content: 'Test tweet content', + }, + payload: { + actorsIds: ['2'], + actorsPreview: [], + }, + }; + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(notification); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'alice liked your tweet', + body: 'Test tweet content', + }); + + await processor.process(job); + + expect(mockNotificationsRepository.findByIdForPush).toHaveBeenCalledWith( + BigInt(1), + BigInt(10), + ); + expect(mockUsersRepository.getUserLocale).toHaveBeenCalledWith(BigInt(10)); + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: NotificationType.LIKE, + isAggregated: false, + previewActors: ['Alice'], + totalActorCount: 1, + tweetSnippet: 'Test tweet content', + locale: LanguageCode.EN, + }); + + expect(pushService.sendToDevices).toHaveBeenCalledWith( + '10', + expect.objectContaining({ + token: null, + notification: { + title: 'alice liked your tweet', + body: 'Test tweet content', + image: 'http://example.com/alice.jpg', + }, + data: expect.objectContaining({ + id: '1', + type: NotificationType.LIKE, + isSeen: 'false', + latestEventAt: '2024-01-01T10:00:00.000Z', + }) as unknown, + android: { + priority: 'normal', + notification: { + channel_id: 'default', + sound: 'default', + color: '#e5e7ff', + tag: 'LIKE:TWEET:100', + }, + }, + }), + ); + }); + + it('should process aggregated LIKE notification with multiple actors', async () => { + const job = createMockJob('2', '10'); + + const notification = { + id: BigInt(2), + type: NotificationType.LIKE, + isAggregated: true, + seen: false, + latestEventAt: new Date('2024-01-01T10:00:00Z'), + dedupeKey: 'LIKE:TWEET:100', + actor: { + id: BigInt(2), + username: 'alice', + profile: { + displayName: 'Alice', + avatarUrl: 'http://example.com/alice.jpg', + }, + followers: [], + }, + tweet: { + id: BigInt(100), + content: 'Test tweet', + }, + payload: { + actorsIds: ['2', '3', '4'], + actorsPreview: [ + { + id: '3', + username: 'bob', + displayName: 'Bob', + avatarUrl: 'http://example.com/bob.jpg', + ifFollowing: true, + }, + { + id: '4', + username: 'charlie', + displayName: null, + avatarUrl: null, + ifFollowing: false, + }, + ], + }, + }; + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(notification); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Alice and 2 others liked your tweet', + body: undefined, + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: NotificationType.LIKE, + isAggregated: true, + previewActors: ['Alice', 'Bob', 'charlie'], + totalActorCount: 3, + tweetSnippet: 'Test tweet', + locale: LanguageCode.EN, + }); + + const call = pushService.sendToDevices.mock.calls[0]; + const payload = call[1]; + + expect(payload.notification).toBeDefined(); + expect((payload.notification as unknown as ExtendedNotification).title).toBe( + 'Alice and 2 others liked your tweet', + ); + expect((payload.notification as unknown as ExtendedNotification).body).toBeUndefined(); + + expect(payload.data).toBeDefined(); + const actorSummary = JSON.parse(payload.data!.actorSummary) as Array<{ + username: string; + displayName: string | null; + avatarUrl: string; + isFollowing: boolean; + }>; + expect(actorSummary).toHaveLength(3); + expect(actorSummary[0]).toEqual({ + username: 'alice', + displayName: 'Alice', + avatarUrl: 'http://example.com/alice.jpg', + isFollowing: false, + }); + expect(actorSummary[2]).toEqual({ + username: 'charlie', + avatarUrl: DEFAULT_PROFILE_PICTURE, + isFollowing: false, + }); + }); + + it('should process FOLLOW notification', async () => { + const job = createMockJob('3', '10'); + + const notification = { + id: BigInt(3), + type: NotificationType.FOLLOW, + isAggregated: false, + seen: false, + latestEventAt: new Date('2024-01-01T10:00:00Z'), + dedupeKey: 'FOLLOW:USER:10', + actor: { + id: BigInt(5), + username: 'newFollower', + profile: { + displayName: 'New Follower', + avatarUrl: 'http://example.com/follower.jpg', + }, + followers: [], + }, + tweet: null, + payload: { + actorsIds: ['5'], + actorsPreview: [], + }, + }; + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(notification); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'New Follower followed you', + body: undefined, + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: NotificationType.FOLLOW, + isAggregated: false, + previewActors: ['New Follower'], + totalActorCount: 1, + tweetSnippet: null, + locale: LanguageCode.EN, + }); + + expect(pushService.sendToDevices).toHaveBeenCalled(); + }); + + it('should process REPLY notification with tweet content', async () => { + const job = createMockJob('4', '10'); + + const notification = { + id: BigInt(4), + type: NotificationType.REPLY, + isAggregated: false, + seen: true, + latestEventAt: new Date('2024-01-02T15:30:00Z'), + dedupeKey: null, + actor: { + id: BigInt(6), + username: 'replier', + profile: { + displayName: null, + avatarUrl: null, + }, + followers: [{ followerId: BigInt(10) }], + }, + tweet: { + id: BigInt(200), + content: 'This is a reply to your tweet', + }, + payload: { + actorsIds: ['6'], + actorsPreview: [], + }, + }; + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(notification); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.AR); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'replier رد: "This is a reply to your tweet"', + body: 'This is a reply to your tweet', + }); + + await processor.process(job); + + expect(mockUsersRepository.getUserLocale).toHaveBeenCalledWith(BigInt(10)); + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: NotificationType.REPLY, + isAggregated: false, + previewActors: ['replier'], + totalActorCount: 1, + tweetSnippet: 'This is a reply to your tweet', + locale: LanguageCode.AR, + }); + + const call = pushService.sendToDevices.mock.calls[0]; + const payload = call[1]; + + expect(payload.data!.isSeen).toBe('true'); + expect(payload.android!.notification!.tag).toBeUndefined(); + expect((payload.notification as unknown as ExtendedNotification).image).toBe( + DEFAULT_PROFILE_PICTURE, + ); + }); + + it('should handle notification with null payload gracefully', async () => { + const job = createMockJob('5', '10'); + + const notification = { + id: BigInt(5), + type: NotificationType.MENTION, + isAggregated: false, + seen: false, + latestEventAt: new Date('2024-01-01T10:00:00Z'), + dedupeKey: null, + actor: { + id: BigInt(7), + username: 'mentioner', + profile: { + displayName: 'Mentioner', + avatarUrl: 'http://example.com/mentioner.jpg', + }, + followers: [], + }, + tweet: { + id: BigInt(300), + content: '@user mentioned you', + }, + payload: null, + }; + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(notification); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Mentioner mentioned you: "@user mentioned you"', + body: '@user mentioned you', + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: NotificationType.MENTION, + isAggregated: false, + previewActors: ['Mentioner'], + totalActorCount: 1, + tweetSnippet: '@user mentioned you', + locale: LanguageCode.EN, + }); + + expect(pushService.sendToDevices).toHaveBeenCalled(); + }); + + it('should use default profile picture when actor has no avatar', async () => { + const job = createMockJob('6', '10'); + + const notification = { + id: BigInt(6), + type: NotificationType.RETWEET, + isAggregated: false, + seen: false, + latestEventAt: new Date('2024-01-01T10:00:00Z'), + dedupeKey: 'RETWEET:TWEET:400', + actor: { + id: BigInt(8), + username: 'retweeter', + profile: null, + followers: [], + }, + tweet: { + id: BigInt(400), + content: 'Original tweet', + }, + payload: { + actorsIds: ['8'], + actorsPreview: [], + }, + }; + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(notification); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'retweeter retweeted your tweet', + body: undefined, + }); + + await processor.process(job); + + const call = pushService.sendToDevices.mock.calls[0]; + const payload = call[1]; + + expect((payload.notification as unknown as ExtendedNotification).image).toBe( + DEFAULT_PROFILE_PICTURE, + ); + + const actorSummary = JSON.parse(payload.data!.actorSummary) as Array<{ + avatarUrl: string; + }>; + expect(actorSummary[0].avatarUrl).toBe(DEFAULT_PROFILE_PICTURE); + }); + + it('should include isFollowing status based on followers', async () => { + const job = createMockJob('7', '10'); + + const notification = { + id: BigInt(7), + type: NotificationType.LIKE, + isAggregated: false, + seen: false, + latestEventAt: new Date('2024-01-01T10:00:00Z'), + dedupeKey: 'LIKE:TWEET:500', + actor: { + id: BigInt(9), + username: 'followerLiker', + profile: { + displayName: 'Follower Liker', + avatarUrl: 'http://example.com/follower.jpg', + }, + followers: [{ followerId: BigInt(10), followedId: BigInt(9) }], + }, + tweet: { + id: BigInt(500), + content: 'Liked tweet', + }, + payload: { + actorsIds: ['9'], + actorsPreview: [], + }, + }; + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(notification); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Follower Liker liked your tweet', + body: 'Liked tweet', + }); + + await processor.process(job); + + const call = pushService.sendToDevices.mock.calls[0]; + const payload = call[1]; + + const actorSummary = JSON.parse(payload.data!.actorSummary) as Array<{ + isFollowing: boolean; + }>; + expect(actorSummary[0].isFollowing).toBe(true); + }); + + it('should throw error and log when processing fails', async () => { + const job = createMockJob('8', '10'); + const error = new Error('Database connection failed'); + + mockNotificationsRepository.findByIdForPush.mockRejectedValue(error); + + await expect(processor.process(job)).rejects.toThrow('Database connection failed'); + + expect(Logger.prototype.error).toHaveBeenCalledWith( + 'Failed to process push notification job for notification id 8 to user 10', + error, + ); + }); + + it('should throw error when push service fails', async () => { + const job = createMockJob('9', '10'); + + const notification = { + id: BigInt(9), + type: NotificationType.LIKE, + isAggregated: false, + seen: false, + latestEventAt: new Date('2024-01-01T10:00:00Z'), + dedupeKey: 'LIKE:TWEET:600', + actor: { + id: BigInt(11), + username: 'liker', + profile: { + displayName: 'Liker', + avatarUrl: 'http://example.com/liker.jpg', + }, + followers: [], + }, + tweet: { + id: BigInt(600), + content: 'Tweet', + }, + payload: { + actorsIds: ['11'], + actorsPreview: [], + }, + }; + + const pushError = new Error('FCM service unavailable'); + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(notification); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Liker liked your tweet', + body: 'Tweet', + }); + pushService.sendToDevices.mockRejectedValue(pushError); + + await expect(processor.process(job)).rejects.toThrow('FCM service unavailable'); + + expect(Logger.prototype.error).toHaveBeenCalledWith( + 'Failed to process push notification job for notification id 9 to user 10', + pushError, + ); + }); + + it('should handle QUOTE notification type', async () => { + const job = createMockJob('10', '10'); + + const notification = { + id: BigInt(10), + type: NotificationType.QUOTE, + isAggregated: false, + seen: false, + latestEventAt: new Date('2024-01-01T10:00:00Z'), + dedupeKey: null, + actor: { + id: BigInt(12), + username: 'quoter', + profile: { + displayName: 'Quoter', + avatarUrl: 'http://example.com/quoter.jpg', + }, + followers: [], + }, + tweet: { + id: BigInt(700), + content: 'Quote tweet content', + }, + payload: { + actorsIds: ['12'], + actorsPreview: [], + }, + }; + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(notification); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + pushService.sendToDevices.mockResolvedValue(undefined); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'Quoter quoted: "Quote tweet content"', + body: 'Quote tweet content', + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith({ + notificationType: NotificationType.QUOTE, + isAggregated: false, + previewActors: ['Quoter'], + totalActorCount: 1, + tweetSnippet: 'Quote tweet content', + locale: LanguageCode.EN, + }); + + expect(pushService.sendToDevices).toHaveBeenCalled(); + }); + + it('should prefer displayName over username in previewActors', async () => { + const job = createMockJob('11', '10'); + + const notification = { + id: BigInt(11), + type: NotificationType.LIKE, + isAggregated: false, + seen: false, + latestEventAt: new Date('2024-01-01T10:00:00Z'), + dedupeKey: 'LIKE:TWEET:800', + actor: { + id: BigInt(13), + username: 'user13', + profile: { + displayName: 'User Thirteen', + avatarUrl: 'http://example.com/user13.jpg', + }, + followers: [], + }, + tweet: { + id: BigInt(800), + content: 'Content', + }, + payload: { + actorsIds: ['13'], + actorsPreview: [], + }, + }; + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(notification); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + pushService.sendToDevices.mockResolvedValue(undefined); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'User Thirteen liked your tweet', + body: 'Content', + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith( + expect.objectContaining({ + previewActors: ['User Thirteen'], + }), + ); + }); + + it('should use username when displayName is null', async () => { + const job = createMockJob('12', '10'); + + const notification = { + id: BigInt(12), + type: NotificationType.LIKE, + isAggregated: false, + seen: false, + latestEventAt: new Date('2024-01-01T10:00:00Z'), + dedupeKey: 'LIKE:TWEET:900', + actor: { + id: BigInt(14), + username: 'user14', + profile: { + displayName: null, + avatarUrl: 'http://example.com/user14.jpg', + }, + followers: [], + }, + tweet: { + id: BigInt(900), + content: 'Content', + }, + payload: { + actorsIds: ['14'], + actorsPreview: [], + }, + }; + + mockNotificationsRepository.findByIdForPush.mockResolvedValue(notification); + mockUsersRepository.getUserLocale.mockResolvedValue(LanguageCode.EN); + pushService.sendToDevices.mockResolvedValue(undefined); + + jest.spyOn(fcmBuilder, 'buildFcmNotificationText').mockReturnValue({ + title: 'user14 liked your tweet', + body: 'Content', + }); + + await processor.process(job); + + expect(fcmBuilder.buildFcmNotificationText).toHaveBeenCalledWith( + expect.objectContaining({ + previewActors: ['user14'], + }), + ); + }); + }); +}); diff --git a/test/notifications/notifications.service.spec.ts b/test/notifications/notifications.service.spec.ts index 7fe55d6b..57634aa9 100644 --- a/test/notifications/notifications.service.spec.ts +++ b/test/notifications/notifications.service.spec.ts @@ -1,18 +1,25 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import { Test, TestingModule } from '@nestjs/testing'; -import { HttpException, HttpStatus } from '@nestjs/common'; +import { HttpException, HttpStatus, Logger } from '@nestjs/common'; import { NotificationsService } from 'src/notifications/notifications.service'; import { NotificationsRepository, NotificationWithDetails, } from 'src/notifications/notifications.repository'; import { PAGINATION_ERROR_CODES, PAGINATION_ERROR_MESSAGES } from 'src/common/constants'; +import { + NOTIFICATIONS_ERROR_CODES, + NOTIFICATIONS_ERROR_MESSAGES, +} from 'src/notifications/constants'; import { SseEventsService } from 'src/sse/sse-events.service'; import { UsersRepository } from 'src/users/users.repository'; import { getQueueToken } from '@nestjs/bullmq'; +import { NotificationResponseDto } from 'src/notifications/dtos/notification-response.dto'; + describe('NotificationsService', () => { let service: NotificationsService; - const mockNotificationsRepository: jest.Mocked> = { + const mockNotificationsRepository = { createNotification: jest.fn(), findExisting: jest.fn(), findById: jest.fn(), @@ -22,21 +29,32 @@ describe('NotificationsService', () => { getNotifications: jest.fn(), mapToNotificationDto: jest.fn(), findOpenNotification: jest.fn(), - }; + updtateNotificationByIdAggregation: jest.fn(), + deleteExisting: jest.fn(), + deleteById: jest.fn(), + } as unknown as jest.Mocked; - const mockSseEventsService: jest.Mocked> = { + const mockSseEventsService = { publishNewNotification: jest.fn(), publishNotificationSeen: jest.fn(), - }; - const mockUsersRepository: jest.Mocked> = { + publishNotificationDeleted: jest.fn(), + publishNotificationUpdate: jest.fn(), + } as unknown as jest.Mocked; + const mockUsersRepository = { isBlocked: jest.fn(), - }; + getUsersMetadataById: jest.fn(), + } as unknown as jest.Mocked; const mockNotificationsQueue = { add: jest.fn(), }; beforeEach(async () => { jest.clearAllMocks(); + + // Suppress logger output during tests + jest.spyOn(Logger.prototype, 'log').mockImplementation(); + jest.spyOn(Logger.prototype, 'error').mockImplementation(); + const module: TestingModule = await Test.createTestingModule({ providers: [ NotificationsService, @@ -445,4 +463,616 @@ describe('NotificationsService', () => { }); }); }); + + describe('trigger - advanced scenarios', () => { + const userId = BigInt(10); + const actorId = BigInt(1); + const tweetId = BigInt(100); + + it('should not create notification if receiver has blocked actor', async () => { + const options = { + actorId, + receiverId: userId, + tweetId, + type: 'LIKE' as const, + }; + + mockUsersRepository.isBlocked.mockResolvedValue(true); + + const result = await service.trigger(options); + + expect(mockUsersRepository.isBlocked).toHaveBeenCalledWith(userId, actorId); + expect(mockNotificationsRepository.findExisting).not.toHaveBeenCalled(); + expect(mockNotificationsRepository.createNotification).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('should aggregate notification when open notification exists with same dedupe key', async () => { + const options = { + actorId, + receiverId: userId, + tweetId, + type: 'LIKE' as const, + }; + + const existingNotification = { + id: BigInt(50), + type: 'LIKE', + actorId: BigInt(2), + actor: { + id: BigInt(2), + username: 'previoususer', + profile: { + displayName: 'Previous User', + avatarUrl: 'http://example.com/prev.jpg', + }, + followers: [], + }, + payload: { + actorsIds: ['2'], + actorsPreview: [ + { + id: '2', + username: 'previoususer', + displayName: 'Previous User', + avatarUrl: 'http://example.com/prev.jpg', + ifFollowing: false, + }, + ], + }, + }; + + const updatedNotification = { + ...existingNotification, + actorId, + payload: { + count: 2, + actorsIds: ['2', '1'], + actorsPreview: [ + { + id: '2', + username: 'previoususer', + displayName: 'Previous User', + avatarUrl: 'http://example.com/prev.jpg', + ifFollowing: false, + }, + ], + }, + }; + + mockUsersRepository.isBlocked.mockResolvedValue(false); + mockNotificationsRepository.findExisting.mockResolvedValue(null); + mockNotificationsRepository.findOpenNotification.mockResolvedValue( + existingNotification as any, + ); + mockNotificationsRepository.updtateNotificationByIdAggregation.mockResolvedValue( + updatedNotification as any, + ); + mockNotificationsRepository.getUnseenCount.mockResolvedValue(5); + mockNotificationsRepository.mapToNotificationDto.mockReturnValue({ + id: '50', + type: 'LIKE', + } as unknown as NotificationResponseDto); + + const result = await service.trigger(options); + + expect(mockNotificationsRepository.findOpenNotification).toHaveBeenCalledWith( + userId, + 'LIKE:TWEET:100', + ); + expect(mockNotificationsRepository.updtateNotificationByIdAggregation).toHaveBeenCalled(); + expect(mockSseEventsService.publishNewNotification).toHaveBeenCalled(); + expect(mockNotificationsQueue.add).toHaveBeenCalledWith( + 'sendPush', + expect.objectContaining({ + notificationId: '50', + userId: userId.toString(), + }), + expect.objectContaining({ + jobId: `PUSH_${userId}_LIKE:TWEET:${tweetId}`, + }), + ); + expect(result).toBeDefined(); + }); + + it('should limit actors preview to 3 when aggregating', async () => { + const options = { + actorId: BigInt(5), + receiverId: userId, + tweetId, + type: 'LIKE' as const, + }; + + const existingNotification = { + id: BigInt(50), + type: 'LIKE', + actorId: BigInt(2), + actor: { + id: BigInt(2), + username: 'user2', + profile: { displayName: 'User 2', avatarUrl: 'http://example.com/2.jpg' }, + followers: [], + }, + payload: { + actorsIds: ['2', '3', '4'], + actorsPreview: [ + { + id: '2', + username: 'user2', + displayName: 'User 2', + avatarUrl: 'http://example.com/2.jpg', + ifFollowing: false, + }, + { + id: '3', + username: 'user3', + displayName: 'User 3', + avatarUrl: 'http://example.com/3.jpg', + ifFollowing: false, + }, + { + id: '4', + username: 'user4', + displayName: 'User 4', + avatarUrl: 'http://example.com/4.jpg', + ifFollowing: false, + }, + ], + }, + }; + + mockUsersRepository.isBlocked.mockResolvedValue(false); + mockNotificationsRepository.findExisting.mockResolvedValue(null); + mockNotificationsRepository.findOpenNotification.mockResolvedValue( + existingNotification as any, + ); + mockNotificationsRepository.updtateNotificationByIdAggregation.mockResolvedValue({ + ...existingNotification, + id: BigInt(50), + } as any); + mockNotificationsRepository.getUnseenCount.mockResolvedValue(5); + mockNotificationsRepository.mapToNotificationDto.mockReturnValue({ + id: '50', + } as unknown as NotificationResponseDto); + + await service.trigger(options); + + const updateCall = mockNotificationsRepository.updtateNotificationByIdAggregation.mock + .calls[0] as any[]; + const payload = updateCall[2] as { actorsPreview: any[] }; + + // Should only keep first 2 actors when adding 5th actor + expect(payload.actorsPreview.length).toBeLessThanOrEqual(2); + }); + + it('should create new notification for non-aggregatable types', async () => { + const options = { + actorId, + receiverId: userId, + tweetId, + type: 'REPLY' as const, + }; + + const newNotification = { + id: BigInt(100), + type: 'REPLY', + actorId, + }; + + mockUsersRepository.isBlocked.mockResolvedValue(false); + mockNotificationsRepository.findExisting.mockResolvedValue(null); + mockNotificationsRepository.createNotification.mockResolvedValue(newNotification as any); + mockNotificationsRepository.getUnseenCount.mockResolvedValue(3); + mockNotificationsRepository.mapToNotificationDto.mockReturnValue({ + id: '100', + } as unknown as NotificationResponseDto); + + await service.trigger(options); + + expect(mockNotificationsRepository.createNotification).toHaveBeenCalledWith( + options, + expect.objectContaining({ + actorsIds: [actorId.toString()], + actorsPreview: [], + }), + null, // no dedupe key for REPLY + ); + expect(mockNotificationsQueue.add).toHaveBeenCalledWith( + 'sendPush', + expect.any(Object), + expect.objectContaining({ + jobId: `PUSH_${userId}_${newNotification.id}`, + }), + ); + }); + + it('should enqueue push notification with proper configuration', async () => { + const options = { + actorId, + receiverId: userId, + tweetId, + type: 'LIKE' as const, + }; + + const notification = { id: BigInt(99) }; + + mockUsersRepository.isBlocked.mockResolvedValue(false); + mockNotificationsRepository.findExisting.mockResolvedValue(null); + mockNotificationsRepository.findOpenNotification.mockResolvedValue(null); + mockNotificationsRepository.createNotification.mockResolvedValue(notification as any); + mockNotificationsRepository.getUnseenCount.mockResolvedValue(1); + mockNotificationsRepository.mapToNotificationDto.mockReturnValue({ + id: '99', + } as unknown as NotificationResponseDto); + + await service.trigger(options); + + expect(mockNotificationsQueue.add).toHaveBeenCalledWith( + 'sendPush', + { + notificationId: '99', + userId: userId.toString(), + }, + { + attempts: 5, + backoff: { type: 'exponential', delay: 1000 }, + removeOnComplete: true, + jobId: `PUSH_${userId}_LIKE:TWEET:${tweetId}`, + delay: 2000, + removeOnFail: false, + }, + ); + }); + }); + + describe('handleUndo', () => { + const actorId = BigInt(1); + const receiverId = BigInt(10); + const tweetId = BigInt(100); + + it('should delete notification when no dedupe key exists', async () => { + const options = { + actorId, + receiverId, + tweetId, + type: 'REPLY' as const, + }; + + mockNotificationsRepository.deleteExisting.mockResolvedValue({ count: 1 }); + mockNotificationsRepository.getUnseenCount.mockResolvedValue(2); + + await service.handleUndo(options); + + expect(mockNotificationsRepository.deleteExisting).toHaveBeenCalledWith(options); + expect(mockSseEventsService.publishNotificationDeleted).toHaveBeenCalledWith(receiverId, 2); + }); + + it('should delete notification when no open notification found with dedupe key', async () => { + const options = { + actorId, + receiverId, + tweetId, + type: 'LIKE' as const, + }; + + mockNotificationsRepository.findOpenNotification.mockResolvedValue(null); + mockNotificationsRepository.deleteExisting.mockResolvedValue({ count: 1 }); + mockNotificationsRepository.getUnseenCount.mockResolvedValue(1); + + await service.handleUndo(options); + + expect(mockNotificationsRepository.findOpenNotification).toHaveBeenCalledWith( + receiverId, + 'LIKE:TWEET:100', + ); + expect(mockNotificationsRepository.deleteExisting).toHaveBeenCalledWith(options); + expect(mockSseEventsService.publishNotificationDeleted).toHaveBeenCalledWith(receiverId, 1); + }); + + it('should do nothing when actor was not in notification', async () => { + const options = { + actorId, + receiverId, + tweetId, + type: 'LIKE' as const, + }; + + const notification = { + id: BigInt(50), + actor: { + id: BigInt(2), + username: 'other', + profile: { displayName: 'Other', avatarUrl: 'url' }, + followers: [], + }, + payload: { + actorsIds: ['2', '3'], + actorsPreview: [], + }, + }; + + mockNotificationsRepository.findOpenNotification.mockResolvedValue(notification as any); + + await service.handleUndo(options); + + expect(mockNotificationsRepository.deleteById).not.toHaveBeenCalled(); + expect(mockNotificationsRepository.updtateNotificationByIdAggregation).not.toHaveBeenCalled(); + }); + + it('should delete notification when removing last actor', async () => { + const options = { + actorId, + receiverId, + tweetId, + type: 'LIKE' as const, + }; + + const notification = { + id: BigInt(50), + actor: { + id: actorId, + username: 'user', + profile: { displayName: 'User', avatarUrl: 'url' }, + followers: [], + }, + payload: { + actorsIds: ['1'], + actorsPreview: [], + }, + }; + + mockNotificationsRepository.findOpenNotification.mockResolvedValue(notification as any); + mockNotificationsRepository.deleteById.mockResolvedValue({ count: 1 }); + mockNotificationsRepository.getUnseenCount.mockResolvedValue(0); + + await service.handleUndo(options); + + expect(mockNotificationsRepository.deleteById).toHaveBeenCalledWith(BigInt(50)); + expect(mockSseEventsService.publishNotificationDeleted).toHaveBeenCalledWith(receiverId, 0); + }); + + it('should update notification when removing one of multiple actors', async () => { + const options = { + actorId, + receiverId, + tweetId, + type: 'LIKE' as const, + }; + + const notification = { + id: BigInt(50), + actor: { + id: actorId, + username: 'user1', + profile: { displayName: 'User 1', avatarUrl: 'url1' }, + followers: [], + }, + payload: { + actorsIds: ['1', '2'], + actorsPreview: [ + { + id: '2', + username: 'user2', + displayName: 'User 2', + avatarUrl: 'url2', + ifFollowing: false, + }, + ], + }, + }; + + const updatedNotification = { + ...notification, + actorId: BigInt(2), + payload: { + count: 1, + actorsIds: ['2'], + actorsPreview: [], + }, + }; + + mockNotificationsRepository.findOpenNotification.mockResolvedValue(notification as any); + mockNotificationsRepository.updtateNotificationByIdAggregation.mockResolvedValue( + updatedNotification as any, + ); + mockNotificationsRepository.getUnseenCount.mockResolvedValue(5); + mockNotificationsRepository.mapToNotificationDto.mockReturnValue({ + id: '50', + } as unknown as NotificationResponseDto); + + await service.handleUndo(options); + + expect(mockNotificationsRepository.updtateNotificationByIdAggregation).toHaveBeenCalledWith( + BigInt(50), + expect.objectContaining({ + actorId: BigInt(2), + }), + expect.objectContaining({ + count: 1, + actorsIds: ['2'], + }), + false, + ); + expect(mockSseEventsService.publishNotificationUpdate).toHaveBeenCalledWith( + receiverId, + { id: '50' }, + 5, + ); + expect(mockNotificationsQueue.add).toHaveBeenCalled(); + }); + + it('should fetch new actors when actors preview needs refilling after undo', async () => { + const options = { + actorId: BigInt(5), + receiverId, + tweetId, + type: 'LIKE' as const, + }; + + const notification = { + id: BigInt(50), + actor: { + id: BigInt(2), + username: 'user2', + profile: { displayName: 'User 2', avatarUrl: 'url2' }, + followers: [], + }, + payload: { + actorsIds: ['2', '3', '4', '5'], + actorsPreview: [ + { + id: '3', + username: 'user3', + displayName: 'User 3', + avatarUrl: 'url3', + ifFollowing: false, + }, + ], + }, + }; + + const newUser = { + id: BigInt(4), + username: 'user4', + profile: { displayName: 'User 4', avatarUrl: 'url4' }, + }; + + mockNotificationsRepository.findOpenNotification.mockResolvedValue(notification as any); + mockUsersRepository.getUsersMetadataById.mockResolvedValue([newUser] as any); + mockNotificationsRepository.updtateNotificationByIdAggregation.mockResolvedValue({ + ...notification, + id: BigInt(50), + } as any); + mockNotificationsRepository.getUnseenCount.mockResolvedValue(3); + mockNotificationsRepository.mapToNotificationDto.mockReturnValue({ id: '50' } as any); + + await service.handleUndo(options); + + // After removing 5, we have 3 actors left, actorsMap has 1 preview (3) + // facingActorId is 2, so actorsMap.delete(2) leaves actorsMap with just [3] + // Condition: actorsMap.size (1) < 3 && actorsIdsSet.size (3) > actorsMap.size (1) + 1 = 3 > 2 is true + // So it should fetch actor 4 + expect(mockUsersRepository.getUsersMetadataById).toHaveBeenCalledWith([BigInt(4)]); + }); + + it('should change facing actor when undoing current facing actor', async () => { + const options = { + actorId: BigInt(2), + receiverId, + tweetId, + type: 'LIKE' as const, + }; + + const notification = { + id: BigInt(50), + actor: { + id: BigInt(2), + username: 'user2', + profile: { displayName: 'User 2', avatarUrl: 'url2' }, + followers: [], + }, + payload: { + actorsIds: ['2', '3'], + actorsPreview: [ + { + id: '3', + username: 'user3', + displayName: 'User 3', + avatarUrl: 'url3', + ifFollowing: false, + }, + ], + }, + }; + + mockNotificationsRepository.findOpenNotification.mockResolvedValue(notification as any); + mockNotificationsRepository.updtateNotificationByIdAggregation.mockResolvedValue({ + ...notification, + actorId: BigInt(3), + } as any); + mockNotificationsRepository.getUnseenCount.mockResolvedValue(2); + mockNotificationsRepository.mapToNotificationDto.mockReturnValue({ id: '50' } as any); + + await service.handleUndo(options); + + const updateCall = + mockNotificationsRepository.updtateNotificationByIdAggregation.mock.calls[0]; + const updatedOptions = updateCall[1]; + expect(updatedOptions.actorId).toEqual(BigInt(3)); + }); + }); + + describe('handleTweetDeletionNotifications', () => { + it('should publish deletion events for all receivers', async () => { + const receivers = [ + { receiverId: BigInt(1), unseenCount: 3 }, + { receiverId: BigInt(2), unseenCount: 5 }, + { receiverId: BigInt(3), unseenCount: 0 }, + ]; + + await service.handleTweetDeletionNotifications(receivers); + + expect(mockSseEventsService.publishNotificationDeleted).toHaveBeenCalledTimes(3); + expect(mockSseEventsService.publishNotificationDeleted).toHaveBeenCalledWith(BigInt(1), 3); + expect(mockSseEventsService.publishNotificationDeleted).toHaveBeenCalledWith(BigInt(2), 5); + expect(mockSseEventsService.publishNotificationDeleted).toHaveBeenCalledWith(BigInt(3), 0); + }); + + it('should handle empty receivers array', async () => { + await service.handleTweetDeletionNotifications([]); + + expect(mockSseEventsService.publishNotificationDeleted).not.toHaveBeenCalled(); + }); + }); + + describe('markAllAsSeen - extended', () => { + it('should publish SSE event after marking all as seen', async () => { + const userId = BigInt(10); + mockNotificationsRepository.markAllAsSeen.mockResolvedValue({ count: 5 }); + + await service.markAllAsSeen(userId); + + expect(mockNotificationsRepository.markAllAsSeen).toHaveBeenCalledWith(userId); + expect(mockSseEventsService.publishNotificationSeen).toHaveBeenCalledWith(userId); + }); + }); + + describe('markAsSeen - extended', () => { + it('should throw NOT_FOUND error when notification does not exist', async () => { + const notificationId = BigInt(999); + const userId = BigInt(10); + + mockNotificationsRepository.findById.mockResolvedValue(null); + + await expect(service.markAsSeen(notificationId, userId)).rejects.toThrow( + new HttpException( + { + message: NOTIFICATIONS_ERROR_MESSAGES.NOTIFICATION_NOT_FOUND, + code: NOTIFICATIONS_ERROR_CODES.NOTIFICATION_NOT_FOUND, + }, + HttpStatus.NOT_FOUND, + ), + ); + + expect(mockNotificationsRepository.markAsSeen).not.toHaveBeenCalled(); + }); + + it('should publish SSE event with unseen count after marking as seen', async () => { + const notificationId = BigInt(1); + const userId = BigInt(10); + + mockNotificationsRepository.findById.mockResolvedValue({ id: notificationId } as any); + mockNotificationsRepository.markAsSeen.mockResolvedValue({ count: 1 }); + mockNotificationsRepository.getUnseenCount.mockResolvedValue(4); + + await service.markAsSeen(notificationId, userId); + + expect(mockNotificationsRepository.getUnseenCount).toHaveBeenCalledWith(userId); + expect(mockSseEventsService.publishNotificationSeen).toHaveBeenCalledWith( + userId, + notificationId, + 4, + ); + }); + }); }); diff --git a/test/refresh-tokens/refresh-tokens.service.spec.ts b/test/refresh-tokens/refresh-tokens.service.spec.ts index e1943d89..63b1d5f1 100644 --- a/test/refresh-tokens/refresh-tokens.service.spec.ts +++ b/test/refresh-tokens/refresh-tokens.service.spec.ts @@ -1,65 +1,415 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@nestjs/common'; import { RefreshTokensService } from 'src/refresh-tokens/refresh-tokens.service'; -import { RefreshTokensRepository } from 'src/refresh-tokens/refresh-tokens.repository'; // The primary dependency to mock. +import { RefreshTokensRepository } from 'src/refresh-tokens/refresh-tokens.repository'; import { PrismaService } from 'src/prisma/prisma.service'; import { RefreshToken } from 'src/refresh-tokens/interfaces'; +import { Prisma } from '@prisma/client'; describe('RefreshTokensService', () => { let service: RefreshTokensService; - let mockRefreshTokensRepository: Partial; + let repository: jest.Mocked; + let prismaService: PrismaService; beforeEach(async () => { - mockRefreshTokensRepository = { + const mockRepository = { createRefreshToken: jest.fn(), + getTokenByHash: jest.fn(), + updateTokenHash: jest.fn(), + deleteTokensById: jest.fn(), }; + const mockPrismaService = {} as PrismaService; + + // Suppress logger output + jest.spyOn(Logger.prototype, 'log').mockImplementation(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + jest.spyOn(Logger.prototype, 'error').mockImplementation(); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(); + const module: TestingModule = await Test.createTestingModule({ providers: [ RefreshTokensService, - { provide: RefreshTokensRepository, useValue: mockRefreshTokensRepository }, - { provide: PrismaService, useValue: {} }, - Logger, + { provide: RefreshTokensRepository, useValue: mockRepository }, + { provide: PrismaService, useValue: mockPrismaService }, ], }).compile(); service = module.get(RefreshTokensService); + repository = module.get(RefreshTokensRepository); + prismaService = module.get(PrismaService); + + jest.clearAllMocks(); }); it('should be defined', () => { expect(service).toBeDefined(); }); + describe('hashStringDeterministic', () => { + it('should hash a string deterministically', () => { + const input = 'test-token-string'; + const hash1 = service.hashStringDeterministic(input); + const hash2 = service.hashStringDeterministic(input); + + expect(hash1).toBe(hash2); + expect(typeof hash1).toBe('string'); + expect(hash1.length).toBe(64); // SHA-256 produces 64 hex characters + }); + + it('should produce different hashes for different inputs', () => { + const hash1 = service.hashStringDeterministic('token1'); + const hash2 = service.hashStringDeterministic('token2'); + + expect(hash1).not.toBe(hash2); + }); + + it('should handle empty string', () => { + const hash = service.hashStringDeterministic(''); + + expect(hash).toBeDefined(); + expect(hash.length).toBe(64); + }); + + it('should handle special characters', () => { + const input = 'token!@#$%^&*()_+-=[]{}|;:\'",.<>?/~`'; + const hash = service.hashStringDeterministic(input); + + expect(hash).toBeDefined(); + expect(hash.length).toBe(64); + }); + + it('should handle unicode characters', () => { + const input = '🔒🔑 secure-token-αβγδ'; + const hash = service.hashStringDeterministic(input); + + expect(hash).toBeDefined(); + expect(hash.length).toBe(64); + }); + + it('should produce hex output', () => { + const hash = service.hashStringDeterministic('test'); + const isHex = /^[0-9a-f]+$/.test(hash); + + expect(isHex).toBe(true); + }); + + it('should handle very long strings', () => { + const longString = 'a'.repeat(10000); + const hash = service.hashStringDeterministic(longString); + + expect(hash).toBeDefined(); + expect(hash.length).toBe(64); + }); + }); + describe('createRefreshToken', () => { it('should correctly call the repository with token data and return the created token', async () => { const tokenData: RefreshToken = { userId: BigInt(123), sessionId: BigInt(456), tokenHash: 'a-very-secure-token-hash', - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Expires in 7 days + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }; const expectedCreatedToken = { - id: BigInt(1), // A new ID from the database - user_id: tokenData.userId, - session_id: tokenData.sessionId, - token_hash: tokenData.tokenHash, - expires_at: tokenData.expiresAt, - created_at: new Date(), - updated_at: new Date(), + id: BigInt(1), + userId: tokenData.userId, + sessionId: tokenData.sessionId, + tokenHash: tokenData.tokenHash, + expiresAt: tokenData.expiresAt, + createdAt: new Date(), + updatedAt: new Date(), }; - (mockRefreshTokensRepository.createRefreshToken as jest.Mock).mockResolvedValue( - expectedCreatedToken, + repository.createRefreshToken.mockResolvedValue(expectedCreatedToken as any); + + const result = await service.createRefreshToken(tokenData); + + expect(repository.createRefreshToken).toHaveBeenCalledWith(tokenData, prismaService); + expect(result).toBe(expectedCreatedToken); + expect(Logger.prototype.log).toHaveBeenCalledWith( + 'Refresh token created successfully for user ID: ' + tokenData.userId, ); + }); + + it('should create token with custom transaction client', async () => { + const tokenData: RefreshToken = { + userId: BigInt(1), + sessionId: BigInt(2), + tokenHash: 'hash123', + expiresAt: new Date(), + }; + + const mockTx = {} as Prisma.TransactionClient; + const expectedToken = { + id: BigInt(1), + ...tokenData, + createdAt: new Date(), + updatedAt: new Date(), + }; + + repository.createRefreshToken.mockResolvedValue(expectedToken as any); + + const result = await service.createRefreshToken(tokenData, mockTx); + + expect(repository.createRefreshToken).toHaveBeenCalledWith(tokenData, mockTx); + expect(result).toEqual(expectedToken); + }); + + it('should handle token with far future expiration', async () => { + const tokenData: RefreshToken = { + userId: BigInt(1), + sessionId: BigInt(2), + tokenHash: 'hash123', + expiresAt: new Date('2099-12-31'), + }; + + const expectedToken = { + id: BigInt(1), + ...tokenData, + createdAt: new Date(), + updatedAt: new Date(), + }; + + repository.createRefreshToken.mockResolvedValue(expectedToken as any); + + const result = await service.createRefreshToken(tokenData); + + expect(result.expiresAt).toEqual(new Date('2099-12-31')); + }); + + it('should propagate repository errors', async () => { + const tokenData: RefreshToken = { + userId: BigInt(1), + sessionId: BigInt(2), + tokenHash: 'hash123', + expiresAt: new Date(), + }; + + const error = new Error('Database error'); + repository.createRefreshToken.mockRejectedValue(error); + + await expect(service.createRefreshToken(tokenData)).rejects.toThrow('Database error'); + }); + }); + + describe('getTokenByHash', () => { + it('should get token by hash', async () => { + const hash = 'test-hash-123'; + const expectedToken = { + id: BigInt(1), + userId: BigInt(100), + sessionId: BigInt(200), + tokenHash: hash, + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + user: { + id: BigInt(100), + username: 'testuser', + }, + }; + + repository.getTokenByHash.mockResolvedValue(expectedToken as any); + + const result = await service.getTokenByHash(hash); + + expect(repository.getTokenByHash).toHaveBeenCalledWith(hash); + expect(result).toEqual(expectedToken); + }); + + it('should return null when token not found', async () => { + const hash = 'non-existent-hash'; + + repository.getTokenByHash.mockResolvedValue(null); + + const result = await service.getTokenByHash(hash); + + expect(result).toBeNull(); + }); + + it('should handle long hash strings', async () => { + const longHash = 'a'.repeat(1000); + + repository.getTokenByHash.mockResolvedValue(null); - const result = await service.createRefreshToken(tokenData, {} as never); + await service.getTokenByHash(longHash); - expect(mockRefreshTokensRepository.createRefreshToken).toHaveBeenCalledWith( - tokenData, - {} as never, + expect(repository.getTokenByHash).toHaveBeenCalledWith(longHash); + }); + + it('should propagate repository errors', async () => { + const hash = 'test-hash'; + const error = new Error('Connection timeout'); + + repository.getTokenByHash.mockRejectedValue(error); + + await expect(service.getTokenByHash(hash)).rejects.toThrow('Connection timeout'); + }); + }); + + describe('updateTokenHash', () => { + it('should update token hash with new values', async () => { + const tokenId = BigInt(1); + const newHash = 'new-hash-value'; + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + const updatedToken = { + id: tokenId, + userId: BigInt(100), + sessionId: BigInt(200), + tokenHash: newHash, + expiresAt, + createdAt: new Date(), + updatedAt: new Date(), + }; + + repository.updateTokenHash.mockResolvedValue(updatedToken as any); + + const result = await service.updateTokenHash(tokenId, newHash, expiresAt); + + expect(repository.updateTokenHash).toHaveBeenCalledWith(tokenId, newHash, expiresAt); + expect(result).toEqual(updatedToken); + }); + + it('should handle update with past expiration date', async () => { + const tokenId = BigInt(1); + const newHash = 'expired-hash'; + const expiresAt = new Date('2020-01-01'); + + const updatedToken = { + id: tokenId, + userId: BigInt(100), + sessionId: BigInt(200), + tokenHash: newHash, + expiresAt, + createdAt: new Date(), + updatedAt: new Date(), + }; + + repository.updateTokenHash.mockResolvedValue(updatedToken as any); + + const result = await service.updateTokenHash(tokenId, newHash, expiresAt); + + expect(result.expiresAt).toEqual(expiresAt); + }); + + it('should propagate repository errors during update', async () => { + const tokenId = BigInt(1); + const newHash = 'new-hash'; + const expiresAt = new Date(); + const error = new Error('Update failed'); + + repository.updateTokenHash.mockRejectedValue(error); + + await expect(service.updateTokenHash(tokenId, newHash, expiresAt)).rejects.toThrow( + 'Update failed', ); - expect(result).toBe(expectedCreatedToken); + }); + }); + + describe('deleteTokensById', () => { + it('should delete token by id', async () => { + const tokenId = BigInt(1); + + repository.deleteTokensById.mockResolvedValue({ count: 1 } as any); + + await service.deleteTokensById(tokenId); + + expect(repository.deleteTokensById).toHaveBeenCalledWith(tokenId, prismaService); + }); + + it('should delete token with custom transaction client', async () => { + const tokenId = BigInt(2); + const mockTx = {} as Prisma.TransactionClient; + + repository.deleteTokensById.mockResolvedValue({ count: 1 } as any); + + await service.deleteTokensById(tokenId, mockTx); + + expect(repository.deleteTokensById).toHaveBeenCalledWith(tokenId, mockTx); + }); + + it('should handle deletion of non-existent token', async () => { + const tokenId = BigInt(999); + + repository.deleteTokensById.mockResolvedValue({ count: 0 } as any); + + await expect(service.deleteTokensById(tokenId)).resolves.toEqual({ count: 0 }); + }); + + it('should propagate repository errors during deletion', async () => { + const tokenId = BigInt(1); + const error = new Error('Deletion failed'); + + repository.deleteTokensById.mockRejectedValue(error); + + await expect(service.deleteTokensById(tokenId)).rejects.toThrow('Deletion failed'); + }); + + it('should handle deletion with large token id', async () => { + const tokenId = BigInt('9007199254740991'); + + repository.deleteTokensById.mockResolvedValue({ count: 1 } as any); + + await service.deleteTokensById(tokenId); + + expect(repository.deleteTokensById).toHaveBeenCalledWith(tokenId, prismaService); + }); + }); + + describe('transaction handling', () => { + it('should use default prisma service when no transaction provided', async () => { + const tokenData: RefreshToken = { + userId: BigInt(1), + sessionId: BigInt(2), + tokenHash: 'hash', + expiresAt: new Date(), + }; + + const createdToken = { + id: BigInt(1), + ...tokenData, + createdAt: new Date(), + updatedAt: new Date(), + }; + + repository.createRefreshToken.mockResolvedValue(createdToken as any); + repository.deleteTokensById.mockResolvedValue({ count: 1 } as any); + + await service.createRefreshToken(tokenData); + expect(repository.createRefreshToken).toHaveBeenCalledWith(tokenData, prismaService); + + await service.deleteTokensById(BigInt(1)); + expect(repository.deleteTokensById).toHaveBeenCalledWith(BigInt(1), prismaService); + }); + + it('should use provided transaction client for operations', async () => { + const mockTx = {} as Prisma.TransactionClient; + const tokenData: RefreshToken = { + userId: BigInt(1), + sessionId: BigInt(2), + tokenHash: 'hash', + expiresAt: new Date(), + }; + + const createdToken = { + id: BigInt(1), + ...tokenData, + createdAt: new Date(), + updatedAt: new Date(), + }; + + repository.createRefreshToken.mockResolvedValue(createdToken as any); + repository.deleteTokensById.mockResolvedValue({ count: 1 } as any); + + await service.createRefreshToken(tokenData, mockTx); + expect(repository.createRefreshToken).toHaveBeenCalledWith(tokenData, mockTx); + + await service.deleteTokensById(BigInt(1), mockTx); + expect(repository.deleteTokensById).toHaveBeenCalledWith(BigInt(1), mockTx); }); }); }); diff --git a/test/sessions/sessions.service.spec.ts b/test/sessions/sessions.service.spec.ts new file mode 100644 index 00000000..fc473ad8 --- /dev/null +++ b/test/sessions/sessions.service.spec.ts @@ -0,0 +1,296 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; +import { SessionsService } from 'src/sessions/sessions.service'; +import { SessionsRepository } from 'src/sessions/sessions.repository'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { Prisma } from '@prisma/client'; +const mockRepository = { + createSession: jest.fn(), + deleteSessionById: jest.fn(), +}; + +const mockPrismaService = {} as PrismaService; +describe('SessionsService', () => { + let service: SessionsService; + + beforeEach(async () => { + jest.clearAllMocks(); + + // Suppress logger output + jest.spyOn(Logger.prototype, 'log').mockImplementation(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + jest.spyOn(Logger.prototype, 'error').mockImplementation(); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SessionsService, + { provide: SessionsRepository, useValue: mockRepository }, + { provide: PrismaService, useValue: mockPrismaService }, + ], + }).compile(); + + service = module.get(SessionsService); + }); + + describe('createSession', () => { + it('should create a session and return it', async () => { + const sessionData = { + userId: BigInt(1), + deviceId: BigInt(100), + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + deviceType: 'DESKTOP', + }; + + const createdSession = { + id: BigInt(1), + userId: BigInt(1), + deviceId: BigInt(100), + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + deviceType: 'DESKTOP', + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.createSession.mockResolvedValue(createdSession); + + const result = await service.createSession(sessionData); + + expect(mockRepository.createSession).toHaveBeenCalledWith(sessionData, mockPrismaService); + expect(result).toEqual(createdSession); + }); + + it('should create a session with custom transaction client', async () => { + const sessionData = { + userId: BigInt(2), + deviceId: BigInt(200), + ipAddress: '10.0.0.1', + userAgent: 'Chrome/120.0', + deviceType: 'MOBILE', + }; + + const mockTx = {} as Prisma.TransactionClient; + + const createdSession = { + id: BigInt(2), + userId: BigInt(2), + deviceId: BigInt(200), + ipAddress: '10.0.0.1', + userAgent: 'Chrome/120.0', + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.createSession.mockResolvedValue(createdSession); + + const result = await service.createSession(sessionData, mockTx); + + expect(mockRepository.createSession).toHaveBeenCalledWith(sessionData, mockTx); + expect(result).toEqual(createdSession); + }); + + it('should create session with different device types', async () => { + const deviceTypes = ['DESKTOP', 'MOBILE', 'TABLET', 'OTHER'] as const; + + for (const deviceType of deviceTypes) { + const sessionData = { + userId: BigInt(1), + deviceId: BigInt(100), + ipAddress: '192.168.1.1', + userAgent: 'test-agent', + deviceType, + }; + + const createdSession = { + id: BigInt(1), + ...sessionData, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.createSession.mockResolvedValue(createdSession); + + const result = await service.createSession(sessionData); + + expect(result.userAgent).toBe('test-agent'); + } + }); + + it('should handle session creation with IPv6 address', async () => { + const sessionData = { + userId: BigInt(1), + deviceId: BigInt(100), + ipAddress: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + userAgent: 'Mozilla/5.0', + deviceType: 'DESKTOP', + }; + + const createdSession = { + id: BigInt(1), + ...sessionData, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.createSession.mockResolvedValue(createdSession); + + const result = await service.createSession(sessionData); + + expect(result.ipAddress).toBe('2001:0db8:85a3:0000:0000:8a2e:0370:7334'); + }); + + it('should handle session creation with long user agent', async () => { + const longUserAgent = 'A'.repeat(500); + const sessionData = { + userId: BigInt(1), + deviceId: BigInt(100), + ipAddress: '192.168.1.1', + userAgent: longUserAgent, + deviceType: 'DESKTOP', + }; + + const createdSession = { + id: BigInt(1), + ...sessionData, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.createSession.mockResolvedValue(createdSession); + + const result = await service.createSession(sessionData); + + expect(result.userAgent).toBe(longUserAgent); + }); + + it('should propagate repository errors', async () => { + const sessionData = { + userId: BigInt(1), + deviceId: BigInt(100), + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + deviceType: 'DESKTOP', + }; + + const error = new Error('Database connection failed'); + mockRepository.createSession.mockRejectedValue(error); + + await expect(service.createSession(sessionData)).rejects.toThrow( + 'Database connection failed', + ); + }); + }); + + describe('deleteSessionById', () => { + it('should delete a session by id', async () => { + const sessionId = BigInt(1); + + mockRepository.deleteSessionById.mockResolvedValue(undefined); + + await service.deleteSessionById(sessionId); + + expect(mockRepository.deleteSessionById).toHaveBeenCalledWith(sessionId, mockPrismaService); + }); + + it('should delete a session with custom transaction client', async () => { + const sessionId = BigInt(2); + const mockTx = {} as Prisma.TransactionClient; + + mockRepository.deleteSessionById.mockResolvedValue(undefined); + + await service.deleteSessionById(sessionId, mockTx); + + expect(mockRepository.deleteSessionById).toHaveBeenCalledWith(sessionId, mockTx); + }); + + it('should handle deletion of non-existent session', async () => { + const sessionId = BigInt(999); + + mockRepository.deleteSessionById.mockResolvedValue(undefined); + + await expect(service.deleteSessionById(sessionId)).resolves.toBeUndefined(); + }); + + it('should propagate repository errors during deletion', async () => { + const sessionId = BigInt(1); + const error = new Error('Foreign key constraint failed'); + + mockRepository.deleteSessionById.mockRejectedValue(error); + + await expect(service.deleteSessionById(sessionId)).rejects.toThrow( + 'Foreign key constraint failed', + ); + }); + + it('should handle deletion with large session id', async () => { + const sessionId = BigInt('9007199254740991'); // Max safe integer as BigInt + + mockRepository.deleteSessionById.mockResolvedValue(undefined); + + await service.deleteSessionById(sessionId); + + expect(mockRepository.deleteSessionById).toHaveBeenCalledWith(sessionId, mockPrismaService); + }); + }); + + describe('transaction handling', () => { + it('should use provided transaction client for both operations', async () => { + const mockTx = {} as Prisma.TransactionClient; + const sessionData = { + userId: BigInt(1), + deviceId: BigInt(100), + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + deviceType: 'DESKTOP', + }; + + const createdSession = { + id: BigInt(1), + ...sessionData, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.createSession.mockResolvedValue(createdSession); + mockRepository.deleteSessionById.mockResolvedValue(undefined); + + // Create session with transaction + await service.createSession(sessionData, mockTx); + expect(mockRepository.createSession).toHaveBeenCalledWith(sessionData, mockTx); + + // Delete session with transaction + await service.deleteSessionById(BigInt(1), mockTx); + expect(mockRepository.deleteSessionById).toHaveBeenCalledWith(BigInt(1), mockTx); + }); + + it('should default to prisma service when no transaction provided', async () => { + const sessionData = { + userId: BigInt(1), + deviceId: BigInt(100), + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + deviceType: 'DESKTOP', + }; + + const createdSession = { + id: BigInt(1), + ...sessionData, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.createSession.mockResolvedValue(createdSession); + mockRepository.deleteSessionById.mockResolvedValue(undefined); + + // Create session without transaction + await service.createSession(sessionData); + expect(mockRepository.createSession).toHaveBeenCalledWith(sessionData, mockPrismaService); + + // Delete session without transaction + await service.deleteSessionById(BigInt(1)); + expect(mockRepository.deleteSessionById).toHaveBeenCalledWith(BigInt(1), mockPrismaService); + }); + }); +}); diff --git a/test/sse/sse-events.service.spec.ts b/test/sse/sse-events.service.spec.ts index 409691e7..82293aea 100644 --- a/test/sse/sse-events.service.spec.ts +++ b/test/sse/sse-events.service.spec.ts @@ -4,12 +4,19 @@ import { SseEventsService, SSE_EVENTS, NewMessagePayload } from '../../src/sse/s import { EventPublisherService } from '../../src/sse/event-publisher.service'; import { NotificationResponseDto } from '../../src/notifications/dtos/notification-response.dto'; import { NotificationType } from '@prisma/client'; +import { Logger } from '@nestjs/common'; describe('SseEventsService', () => { let service: SseEventsService; let publisherMock: jest.Mocked; beforeEach(async () => { + // Suppress logger output + jest.spyOn(Logger.prototype, 'log').mockImplementation(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + jest.spyOn(Logger.prototype, 'error').mockImplementation(); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(); + publisherMock = { publishToUser: jest.fn().mockResolvedValue(undefined), publishToUsers: jest.fn().mockResolvedValue(undefined), @@ -26,6 +33,10 @@ describe('SseEventsService', () => { jest.clearAllMocks(); }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); + describe('publishUnseenCount', () => { it('should publish unseen count to user with correct event name', async () => { const userId = BigInt(123); @@ -237,6 +248,261 @@ describe('SseEventsService', () => { it('should have correct event names', () => { expect(SSE_EVENTS.DM_UNSEEN_COUNT).toBe('dm.unseen_conversations_count'); expect(SSE_EVENTS.DM_NEW_MESSAGE).toBe('dm.new_message'); + expect(SSE_EVENTS.TIMELINE_FOLLOWING).toBe('timeline.following'); + }); + }); + + describe('publishTimelineFollowingTweets', () => { + it('should publish timeline following tweets with author list', async () => { + const userId = BigInt(1001); + const authors = ['author1', 'author2', 'author3']; + + await service.publishTimelineFollowingTweets(userId, authors); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('1001', { + event: SSE_EVENTS.TIMELINE_FOLLOWING, + data: { authors }, + }); + }); + + it('should handle null authors list', async () => { + const userId = BigInt(1002); + + await service.publishTimelineFollowingTweets(userId, null); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('1002', { + event: SSE_EVENTS.TIMELINE_FOLLOWING, + data: { authors: null }, + }); + }); + + it('should handle empty authors list', async () => { + const userId = BigInt(1003); + const authors: string[] = []; + + await service.publishTimelineFollowingTweets(userId, authors); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('1003', { + event: SSE_EVENTS.TIMELINE_FOLLOWING, + data: { authors: [] }, + }); + }); + + it('should handle large bigint userId', async () => { + const userId = BigInt('9007199254740991'); // Max safe integer + const authors = ['author1']; + + await service.publishTimelineFollowingTweets(userId, authors); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('9007199254740991', { + event: SSE_EVENTS.TIMELINE_FOLLOWING, + data: { authors }, + }); + }); + }); + + describe('publishNotificationDeleted', () => { + it('should publish notification deleted event and count update', async () => { + const receiverId = BigInt(2001); + const updatedCount = 15; + + await service.publishNotificationDeleted(receiverId, updatedCount); + + expect(publisherMock.publishToUser).toHaveBeenCalledTimes(2); + expect(publisherMock.publishToUser).toHaveBeenNthCalledWith(1, '2001', { + event: 'notifications.delete', + data: {}, + }); + expect(publisherMock.publishToUser).toHaveBeenNthCalledWith(2, '2001', { + event: 'notifications.count_update', + data: { count: 15 }, + }); + }); + + it('should handle zero count after deletion', async () => { + const receiverId = BigInt(2002); + const updatedCount = 0; + + await service.publishNotificationDeleted(receiverId, updatedCount); + + expect(publisherMock.publishToUser).toHaveBeenNthCalledWith(2, '2002', { + event: 'notifications.count_update', + data: { count: 0 }, + }); + }); + + it('should publish delete event with empty data object', async () => { + const receiverId = BigInt(2003); + const updatedCount = 10; + + await service.publishNotificationDeleted(receiverId, updatedCount); + + expect(publisherMock.publishToUser).toHaveBeenNthCalledWith(1, '2003', { + event: 'notifications.delete', + data: {}, + }); + }); + }); + + describe('publishNotificationUpdate', () => { + it('should publish notification update and count update', async () => { + const receiverId = BigInt(3001); + const notification: NotificationResponseDto = { + id: '100', + type: NotificationType.RETWEET, + actorSummary: { previewActors: [], totalCount: 2 }, + tweetSummary: { primaryTweet: null, totalCount: 1, subjectIds: [] }, + latestEventAt: new Date('2024-01-01T12:00:00Z'), + isSeen: true, + }; + const updatedCount = 8; + + await service.publishNotificationUpdate(receiverId, notification, updatedCount); + + expect(publisherMock.publishToUser).toHaveBeenCalledTimes(2); + expect(publisherMock.publishToUser).toHaveBeenNthCalledWith(1, '3001', { + event: 'notifications.update', + data: notification, + }); + expect(publisherMock.publishToUser).toHaveBeenNthCalledWith(2, '3001', { + event: 'notifications.count_update', + data: { count: 8 }, + }); + }); + + it('should handle notification update with zero count', async () => { + const receiverId = BigInt(3002); + const notification: NotificationResponseDto = { + id: '101', + type: NotificationType.MENTION, + actorSummary: { previewActors: [], totalCount: 1 }, + tweetSummary: { primaryTweet: null, totalCount: 0, subjectIds: [] }, + latestEventAt: new Date('2024-01-02T00:00:00Z'), + isSeen: false, + }; + const updatedCount = 0; + + await service.publishNotificationUpdate(receiverId, notification, updatedCount); + + expect(publisherMock.publishToUser).toHaveBeenNthCalledWith(2, '3002', { + event: 'notifications.count_update', + data: { count: 0 }, + }); + }); + + it('should handle notification update with different notification types', async () => { + const receiverId = BigInt(3003); + const notification: NotificationResponseDto = { + id: '102', + type: NotificationType.REPLY, + actorSummary: { previewActors: [], totalCount: 1 }, + tweetSummary: { primaryTweet: null, totalCount: 1, subjectIds: [] }, + latestEventAt: new Date('2024-01-03T00:00:00Z'), + isSeen: false, + }; + const updatedCount = 12; + + await service.publishNotificationUpdate(receiverId, notification, updatedCount); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('3003', { + event: 'notifications.update', + data: notification, + }); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle publisher errors gracefully in publishUnseenCount', async () => { + publisherMock.publishToUser.mockRejectedValueOnce(new Error('Publisher error')); + + await expect(service.publishUnseenCount(BigInt(999), 5)).rejects.toThrow('Publisher error'); + }); + + it('should handle publisher errors gracefully in publishNewMessagePreview', async () => { + const mockPayload: NewMessagePayload = { + messageId: '1', + conversationId: 'conv-1', + sender: { + id: '1', + username: 'user', + displayName: 'User', + avatarUrl: null, + }, + bodySnippet: 'test', + createdAt: new Date(), + hasMedia: false, + }; + + publisherMock.publishToUser.mockRejectedValueOnce(new Error('Publisher error')); + + await expect(service.publishNewMessagePreview(BigInt(999), mockPayload)).rejects.toThrow( + 'Publisher error', + ); + }); + + it('should convert bigint userId to string correctly for all methods', async () => { + const userId = BigInt('123456789012345678'); + + await service.publishUnseenCount(userId, 1); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith( + '123456789012345678', + expect.any(Object), + ); + }); + + it('should handle message preview with hasMedia=true', async () => { + const userId = BigInt(4001); + const mockPayload: NewMessagePayload = { + messageId: '200', + conversationId: 'conv-200', + sender: { + id: '50', + username: 'mediauser', + displayName: 'Media User', + avatarUrl: 'https://example.com/media.jpg', + }, + bodySnippet: 'Check out this image!', + createdAt: new Date('2024-01-04T00:00:00Z'), + hasMedia: true, + }; + + await service.publishNewMessagePreview(userId, mockPayload); + + expect(publisherMock.publishToUser).toHaveBeenCalledWith('4001', { + event: SSE_EVENTS.DM_NEW_MESSAGE, + data: mockPayload, + }); + }); + + it('should handle notification with seen status', async () => { + const receiverId = BigInt(5001); + const notification: NotificationResponseDto = { + id: '300', + type: NotificationType.LIKE, + actorSummary: { previewActors: [], totalCount: 5 }, + tweetSummary: { primaryTweet: null, totalCount: 1, subjectIds: [] }, + latestEventAt: new Date('2024-01-05T00:00:00Z'), + isSeen: true, + }; + const updatedCount = 3; + + await service.publishNewNotification(receiverId, notification, updatedCount); + + expect(publisherMock.publishToUser).toHaveBeenCalledTimes(2); + }); + + it('should handle multiple rapid publishes without interference', async () => { + const userId1 = BigInt(6001); + const userId2 = BigInt(6002); + + await Promise.all([ + service.publishUnseenCount(userId1, 5), + service.publishUnseenCount(userId2, 10), + service.publishUnseenNotificationCount(userId1, 3), + ]); + + expect(publisherMock.publishToUser).toHaveBeenCalledTimes(3); }); }); }); diff --git a/test/sse/sse.service.spec.ts b/test/sse/sse.service.spec.ts index 4def35e9..b7855bf9 100644 --- a/test/sse/sse.service.spec.ts +++ b/test/sse/sse.service.spec.ts @@ -1,9 +1,11 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import { Test, TestingModule } from '@nestjs/testing'; import { SseService } from 'src/sse/sse.service'; import { RedisService } from 'src/redis/redis.service'; import { Redis } from 'ioredis'; import { Subject } from 'rxjs'; import { take } from 'rxjs/operators'; +import { Logger } from '@nestjs/common'; describe('SseService', () => { let service: SseService; @@ -12,6 +14,12 @@ describe('SseService', () => { let mockRedisService: Partial; beforeEach(async () => { + // Suppress logger output + jest.spyOn(Logger.prototype, 'log').mockImplementation(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + jest.spyOn(Logger.prototype, 'error').mockImplementation(); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(); + mockSubClient = { psubscribe: jest.fn().mockResolvedValue(undefined), on: jest.fn(), @@ -21,6 +29,8 @@ describe('SseService', () => { mockPubClient = { publish: jest.fn(), duplicate: jest.fn().mockReturnValue(mockSubClient), + sadd: jest.fn().mockResolvedValue(1), + srem: jest.fn().mockResolvedValue(1), } as unknown as Redis; mockRedisService = { @@ -334,4 +344,397 @@ describe('SseService', () => { sub2.unsubscribe(); }); }); + + describe('topic filtering', () => { + it('should only forward events to connections subscribed to matching topics', async () => { + const userId = 'user-topic-filter'; + + const dmEvents: unknown[] = []; + const notificationEvents: unknown[] = []; + + const dmSubject = expectSubject(await service.subscribe(userId, ['dm'])); + const notificationSubject = expectSubject(await service.subscribe(userId, ['notifications'])); + + const dmSub = dmSubject.subscribe((event: unknown) => { + dmEvents.push(event); + }); + + const notificationSub = notificationSubject.subscribe((event: unknown) => { + notificationEvents.push(event); + }); + + const dmEvent = { event: 'dm.new_message', data: 'hello' }; + const notificationEvent = { event: 'notifications.mention', data: 'you were mentioned' }; + + void service.publish(userId, dmEvent); + void service.publish(userId, notificationEvent); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(dmEvents).toEqual([dmEvent]); + expect(notificationEvents).toEqual([notificationEvent]); + + dmSub.unsubscribe(); + notificationSub.unsubscribe(); + }); + + it('should forward events to connections with multiple topic subscriptions', async () => { + const userId = 'user-multi-topics'; + + const events: unknown[] = []; + const subject = expectSubject(await service.subscribe(userId, ['dm', 'notifications'])); + + const sub = subject.subscribe((event: unknown) => { + events.push(event); + }); + + const dmEvent = { event: 'dm.new_message', data: 'hello' }; + const notificationEvent = { event: 'notifications.like', data: 'someone liked your post' }; + + void service.publish(userId, dmEvent); + void service.publish(userId, notificationEvent); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(events).toEqual([dmEvent, notificationEvent]); + sub.unsubscribe(); + }); + + it('should handle events with dots in topic name correctly', async () => { + const userId = 'user-dot-topic'; + + const events: unknown[] = []; + const subject = expectSubject(await service.subscribe(userId, ['notifications'])); + + const sub = subject.subscribe((event: unknown) => { + events.push(event); + }); + + const event1 = { event: 'notifications.follow.new', data: 'new follower' }; + const event2 = { event: 'notifications.like.post', data: 'post liked' }; + + void service.publish(userId, event1); + void service.publish(userId, event2); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(events).toEqual([event1, event2]); + sub.unsubscribe(); + }); + + it('should not forward events if topic does not match', async () => { + const userId = 'user-no-match'; + + const events: unknown[] = []; + const subject = expectSubject(await service.subscribe(userId, ['dm'])); + + const sub = subject.subscribe((event: unknown) => { + events.push(event); + }); + + const notificationEvent = { event: 'notifications.mention', data: 'you were mentioned' }; + + void service.publish(userId, notificationEvent); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(events).toEqual([]); + sub.unsubscribe(); + }); + + it('should handle events with missing event property', async () => { + const userId = 'user-no-event-prop'; + + const events: unknown[] = []; + const subject = expectSubject(await service.subscribe(userId, ['dm'])); + + const sub = subject.subscribe((event: unknown) => { + events.push(event); + }); + + const eventWithoutProp = { data: 'no event property' }; + + void service.publish(userId, eventWithoutProp); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(events).toEqual([]); + sub.unsubscribe(); + }); + + it('should handle events with numeric event property', async () => { + const userId = 'user-numeric-event'; + + const events: unknown[] = []; + const subject = expectSubject(await service.subscribe(userId, ['123'])); + + const sub = subject.subscribe((event: unknown) => { + events.push(event); + }); + + const numericEvent = { event: 123, data: 'numeric topic' }; + + void service.publish(userId, numericEvent); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(events).toEqual([numericEvent]); + sub.unsubscribe(); + }); + }); + + describe('timeline topic', () => { + it('should add user to Redis set when subscribing to timeline topic', async () => { + const userId = 'user-timeline'; + + await service.subscribe(userId, ['timeline']); + + expect(mockPubClient.sadd).toHaveBeenCalledWith('sse:online:following_timeline', userId); + }); + + it('should not add user to Redis set when not subscribing to timeline topic', async () => { + const userId = 'user-no-timeline'; + + await service.subscribe(userId, ['dm', 'notifications']); + + expect(mockPubClient.sadd).not.toHaveBeenCalled(); + }); + + it('should remove user from Redis set when unsubscribing from timeline and no other timeline connections exist', async () => { + const userId = 'user-timeline-unsub'; + + const subject = expectSubject(await service.subscribe(userId, ['timeline'])); + + jest.clearAllMocks(); + + service.unsubscribe(userId, subject); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockPubClient.srem).toHaveBeenCalledWith('sse:online:following_timeline', userId); + }); + + it('should not remove user from Redis set when unsubscribing but other timeline connections exist', async () => { + const userId = 'user-multiple-timeline'; + + const subject1 = expectSubject(await service.subscribe(userId, ['timeline'])); + const subject2 = expectSubject(await service.subscribe(userId, ['timeline'])); + + jest.clearAllMocks(); + + service.unsubscribe(userId, subject1); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockPubClient.srem).not.toHaveBeenCalled(); + + service.unsubscribe(userId, subject2); + }); + + it('should handle Redis srem error gracefully', async () => { + const userId = 'user-srem-error'; + + (mockPubClient.srem as jest.Mock).mockRejectedValueOnce(new Error('Redis error')); + + const subject = expectSubject(await service.subscribe(userId, ['timeline'])); + + expect(() => { + service.unsubscribe(userId, subject); + }).not.toThrow(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(Logger.prototype.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to remove user'), + expect.any(Error), + ); + }); + }); + + describe('Redis lifecycle', () => { + it('should initialize Redis pub/sub on module init', () => { + expect(mockPubClient.duplicate).toHaveBeenCalled(); + expect(mockSubClient.psubscribe).toHaveBeenCalledWith('sse:user:*'); + expect(Logger.prototype.log).toHaveBeenCalledWith('SseService Redis pub/sub initialized'); + }); + + it('should register Redis error handler', () => { + const calls = (mockSubClient.on as jest.Mock).mock.calls as [string, (err: Error) => void][]; + const errorHandler = calls.find((call) => call[0] === 'error')?.[1]; + + expect(errorHandler).toBeDefined(); + + const testError = new Error('Test Redis error'); + errorHandler!(testError); + + expect(Logger.prototype.error).toHaveBeenCalledWith('Redis sub client error', testError); + }); + + it('should register Redis reconnecting handler', () => { + const calls = (mockSubClient.on as jest.Mock).mock.calls as [string, () => void][]; + const reconnectHandler = calls.find((call) => call[0] === 'reconnecting')?.[1]; + + expect(reconnectHandler).toBeDefined(); + + reconnectHandler!(); + + expect(Logger.prototype.warn).toHaveBeenCalledWith('Redis sub client reconnecting...'); + }); + + it('should quit sub client on module destroy', async () => { + await service.onModuleDestroy(); + + expect(mockSubClient.quit).toHaveBeenCalled(); + }); + }); + + describe('message handling', () => { + it('should handle raw string messages that are not JSON', async () => { + const userId = 'user-raw-string'; + const events: unknown[] = []; + + const subject = expectSubject(await service.subscribe(userId, ['dm'])); + const sub = subject.subscribe((event: unknown) => { + events.push(event); + }); + + // Simulate receiving a raw string message (will be kept as string, but won't match topic filter) + const calls = (mockSubClient.on as jest.Mock).mock.calls as [ + string, + (pattern: string, channel: string, message: string) => void, + ][]; + const pmessageHandler = calls.find((call) => call[0] === 'pmessage')?.[1]; + + expect(pmessageHandler).toBeDefined(); + + // Raw string without event property won't match topic filter + pmessageHandler!('sse:user:*', `sse:user:${userId}`, 'raw string message'); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Raw string messages without proper event structure won't be forwarded + expect(events).toEqual([]); + sub.unsubscribe(); + }); + + it('should parse JSON messages correctly', async () => { + const userId = 'user-json-msg'; + const events: unknown[] = []; + + const subject = expectSubject(await service.subscribe(userId, ['notifications'])); + const sub = subject.subscribe((event: unknown) => { + events.push(event); + }); + + const calls = (mockSubClient.on as jest.Mock).mock.calls as [ + string, + (pattern: string, channel: string, message: string) => void, + ][]; + const pmessageHandler = calls.find((call) => call[0] === 'pmessage')?.[1]; + + const jsonMessage = { event: 'notifications.test', data: 'parsed' }; + pmessageHandler!('sse:user:*', `sse:user:${userId}`, JSON.stringify(jsonMessage)); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(events).toEqual([jsonMessage]); + sub.unsubscribe(); + }); + + it('should extract userId from channel correctly', async () => { + const userId = 'user-channel-extract'; + const events: unknown[] = []; + + const subject = expectSubject(await service.subscribe(userId, ['dm'])); + const sub = subject.subscribe((event: unknown) => { + events.push(event); + }); + + const calls = (mockSubClient.on as jest.Mock).mock.calls as [ + string, + (pattern: string, channel: string, message: string) => void, + ][]; + const pmessageHandler = calls.find((call) => call[0] === 'pmessage')?.[1]; + + const event = { event: 'dm.message', data: 'test' }; + pmessageHandler!('sse:user:*', `sse:user:${userId}`, JSON.stringify(event)); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(events).toEqual([event]); + sub.unsubscribe(); + }); + }); + + describe('edge cases', () => { + it('should return 0 for connection count of non-existent user', () => { + expect(service.getConnectionCount('non-existent-user')).toBe(0); + }); + + it('should handle unsubscribing a subject that does not exist in connections', async () => { + const userId = 'user-exists'; + const otherSubject = new Subject(); + + await service.subscribe(userId, ['dm']); + + expect(() => { + service.unsubscribe(userId, otherSubject); + }).not.toThrow(); + }); + + it('should delete user from connections map when last connection is removed', async () => { + const userId = 'user-delete-from-map'; + + const subject = expectSubject(await service.subscribe(userId, ['dm'])); + + expect(service.getConnectionCount(userId)).toBe(1); + + service.unsubscribe(userId, subject); + + expect(service.getConnectionCount(userId)).toBe(0); + }); + + it('should handle rapid subscribe/unsubscribe cycles', async () => { + const userId = 'user-rapid-cycle'; + + for (let i = 0; i < 10; i += 1) { + const subject = expectSubject(await service.subscribe(userId, ['dm'])); + service.unsubscribe(userId, subject); + } + + expect(service.getConnectionCount(userId)).toBe(0); + }); + + it('should maintain separate topic sets for each connection', async () => { + const userId = 'user-separate-topics'; + + const events1: unknown[] = []; + const events2: unknown[] = []; + + const subject1 = expectSubject(await service.subscribe(userId, ['dm'])); + const subject2 = expectSubject(await service.subscribe(userId, ['notifications'])); + + const sub1 = subject1.subscribe((event: unknown) => { + events1.push(event); + }); + + const sub2 = subject2.subscribe((event: unknown) => { + events2.push(event); + }); + + const dmEvent = { event: 'dm.msg', data: 'dm data' }; + const notifEvent = { event: 'notifications.alert', data: 'notif data' }; + + void service.publish(userId, dmEvent); + void service.publish(userId, notifEvent); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(events1).toEqual([dmEvent]); + expect(events2).toEqual([notifEvent]); + + sub1.unsubscribe(); + sub2.unsubscribe(); + }); + }); }); diff --git a/test/tweets/timeline/timeline.service.spec.ts b/test/tweets/timeline/timeline.service.spec.ts index 7f1ab7e2..47225024 100644 --- a/test/tweets/timeline/timeline.service.spec.ts +++ b/test/tweets/timeline/timeline.service.spec.ts @@ -56,7 +56,7 @@ describe('TimelineService', () => { getTimelineForUser: jest.fn(), filterValidAuthors: jest.fn(), filterValidTweets: jest.fn(), - filterNonMutedAuthors: jest.fn(), + filterNonMutedNonBlockedAuthors: jest.fn(), getTweetsByIds: jest.fn(), getCompactAuthorsByIds: jest.fn(), getTweetCounts: jest.fn(), @@ -1073,7 +1073,10 @@ describe('TimelineService', () => { mockTweetsRepository.getTweetsMatchingInterests.mockResolvedValue([ { id: '789', authorId: '555', createdAt: new Date() }, ]); - mockTweetsRepository.filterNonMutedAuthors.mockResolvedValue([BigInt(456), BigInt(555)]); + mockTweetsRepository.filterNonMutedNonBlockedAuthors.mockResolvedValue([ + BigInt(456), + BigInt(555), + ]); mockTweetsRepository.filterValidTweets.mockResolvedValue([BigInt(123), BigInt(789)]); mockTweetsRepository.getTweetCounts.mockResolvedValue( new Map([ @@ -1102,7 +1105,7 @@ describe('TimelineService', () => { mockUsersRepository.getUserInterests.mockResolvedValue([]); mockTweetsRepository.getTimelineForUser.mockResolvedValue([]); - mockTweetsRepository.filterNonMutedAuthors.mockResolvedValue([BigInt(456)]); + mockTweetsRepository.filterNonMutedNonBlockedAuthors.mockResolvedValue([BigInt(456)]); mockTweetsRepository.filterValidTweets.mockResolvedValue([BigInt(123)]); mockTweetsRepository.getTweetCounts.mockResolvedValue( new Map([['123', { likeCounts: 5, retweetCounts: 3, replyCounts: 1 }]]), @@ -1125,7 +1128,7 @@ describe('TimelineService', () => { mockUsersRepository.getUserInterests.mockResolvedValue([]); mockTweetsRepository.getTimelineForUser.mockResolvedValue([]); - mockTweetsRepository.filterNonMutedAuthors.mockResolvedValue([]); + mockTweetsRepository.filterNonMutedNonBlockedAuthors.mockResolvedValue([]); mockTweetsRepository.filterValidTweets.mockResolvedValue([]); mockUsersRepository.getFollowingIds.mockResolvedValue([]); From 80b60089d182c650b0689c6b3db9fc253719c321 Mon Sep 17 00:00:00 2001 From: Saif <78622218+im-saif@users.noreply.github.com> Date: Tue, 16 Dec 2025 00:05:27 +0200 Subject: [PATCH 43/43] test: add stress tests - [CU-869bahjzj] (#229) Co-authored-by: Amr Samy --- test/performance/auth/login.stress.js | 4 +- test/performance/auth/report_login.html | 44 +++ .../auth/report_reset-password.html | 44 +++ .../performance/auth/reset-password.stress.js | 26 +- test/performance/auth/signup.stress.js | 6 +- test/performance/media/media-upload.stress.js | 192 +++++++++++ test/performance/media/report.html | 44 +++ .../notifications/notifications.stress.js | 270 +++++++++++++++ test/performance/notifications/report.html | 44 +++ .../onboarding/onboarding.stress.js | 168 +++++++++ test/performance/onboarding/report.html | 44 +++ test/performance/profile/actions.stress.js | 9 +- test/performance/profile/report_actions.html | 44 +++ test/performance/profile/report_update.html | 44 +++ test/performance/profile/update.stress.js | 9 +- test/performance/search/report.html | 44 +++ test/performance/search/search.stress.js | 318 ++++++++++++++++++ .../settings/account/birthdate.stress.js | 9 +- .../settings/account/email.stress.js | 9 +- .../settings/account/password.stress.js | 9 +- .../settings/account/username.stress.js | 9 +- test/performance/settings/general.stress.js | 180 ++++++++++ test/performance/settings/report.html | 44 +++ test/performance/timeline/report.html | 44 +++ test/performance/timeline/timeline.stress.js | 219 ++++++++++++ test/performance/tweets/tweet-crud.stress.js | 13 +- .../tweets/tweet-engagement.stress.js | 13 +- .../tweets/tweet-threads.stress.js | 13 +- test/performance/users/user-data.stress.js | 133 ++++++++ test/performance/users/user-social.stress.js | 132 ++++++++ 30 files changed, 2141 insertions(+), 40 deletions(-) create mode 100644 test/performance/auth/report_login.html create mode 100644 test/performance/auth/report_reset-password.html create mode 100644 test/performance/media/media-upload.stress.js create mode 100644 test/performance/media/report.html create mode 100644 test/performance/notifications/notifications.stress.js create mode 100644 test/performance/notifications/report.html create mode 100644 test/performance/onboarding/onboarding.stress.js create mode 100644 test/performance/onboarding/report.html create mode 100644 test/performance/profile/report_actions.html create mode 100644 test/performance/profile/report_update.html create mode 100644 test/performance/search/report.html create mode 100644 test/performance/search/search.stress.js create mode 100644 test/performance/settings/general.stress.js create mode 100644 test/performance/settings/report.html create mode 100644 test/performance/timeline/report.html create mode 100644 test/performance/timeline/timeline.stress.js create mode 100644 test/performance/users/user-data.stress.js create mode 100644 test/performance/users/user-social.stress.js diff --git a/test/performance/auth/login.stress.js b/test/performance/auth/login.stress.js index a21f56a0..8b46d603 100644 --- a/test/performance/auth/login.stress.js +++ b/test/performance/auth/login.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 600 }, + { duration: '1m', target: 600 }, { duration: '30s', target: 0 }, ], thresholds: { diff --git a/test/performance/auth/report_login.html b/test/performance/auth/report_login.html new file mode 100644 index 00000000..39396a90 --- /dev/null +++ b/test/performance/auth/report_login.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/auth/report_reset-password.html b/test/performance/auth/report_reset-password.html new file mode 100644 index 00000000..3ef8a3bb --- /dev/null +++ b/test/performance/auth/report_reset-password.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/auth/reset-password.stress.js b/test/performance/auth/reset-password.stress.js index 0e927904..0242a07f 100644 --- a/test/performance/auth/reset-password.stress.js +++ b/test/performance/auth/reset-password.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, { duration: '30s', target: 0 }, ], thresholds: { @@ -18,16 +18,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; - -export default function () { - const params = { - headers: { - 'Content-Type': 'application/json', - 'X-Client-Type': 'web' - }, - }; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, ); @@ -39,6 +32,17 @@ export default function () { const userData = resCreate.json('data'); const userEmail = userData.email; + return { userEmail }; +} + +export default function (data) { + const { userEmail } = data; + const params = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web' + }, + }; const payloadForgot = JSON.stringify({ identifier: userEmail, diff --git a/test/performance/auth/signup.stress.js b/test/performance/auth/signup.stress.js index 6615cc14..d57315d9 100644 --- a/test/performance/auth/signup.stress.js +++ b/test/performance/auth/signup.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, { duration: '30s', target: 0 }, ], thresholds: { @@ -22,7 +22,7 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; export default function () { const uniqueId = `${__VU}-${__ITER}-${Date.now()}`; diff --git a/test/performance/media/media-upload.stress.js b/test/performance/media/media-upload.stress.js new file mode 100644 index 00000000..13b5da15 --- /dev/null +++ b/test/performance/media/media-upload.stress.js @@ -0,0 +1,192 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import encoding from 'k6/encoding'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, // Ramp up to 50 users + { duration: '1m', target: 100 }, // Ramp up to 100 users + { duration: '30s', target: 200 }, // Ramp up to 200 users + { duration: '2m', target: 200 }, // Sustain at 200 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_failed: ['rate<0.05'], // Allow up to 5% failure rate for media uploads (I/O heavy) + 'http_req_duration{name:01_Login_Action}': ['p(95)<3000'], + 'http_req_duration{name:02_Upload_Image}': ['p(95)<10000'], // Image uploads can take longer + 'http_req_duration{name:03_Upload_Video}': ['p(95)<15000'], // Video uploads can take longer + 'http_req_duration{name:04_Upload_Gif}': ['p(95)<5000'], // GIF (Tenor lookup) should be faster + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; + +// Minimal valid PNG file (1x1 red pixel) - properly encoded +const MINIMAL_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12P4z8DwHwAFAAH/q842AAAAAElFTkSuQmCC'; + +// Minimal valid MP4 bytes - ftyp box only (smallest valid MP4 structure) +// We'll create this as raw bytes array instead of base64 to avoid encoding issues +function createMinimalMP4Bytes() { + // Minimal ftyp box: box size (4) + box type (4) + brand (4) + version (4) + const bytes = new Uint8Array([ + 0x00, 0x00, 0x00, 0x14, // box size: 20 bytes + 0x66, 0x74, 0x79, 0x70, // box type: 'ftyp' + 0x69, 0x73, 0x6F, 0x6D, // brand: 'isom' + 0x00, 0x00, 0x00, 0x01, // version: 1 + 0x69, 0x73, 0x6F, 0x6D, // compatible brand: 'isom' + ]); + return bytes.buffer; // Return ArrayBuffer, not Uint8Array +} + +/** + * Helper function to create and login a user + * Returns { accessToken } or null on failure + */ +function createAndLoginUser(tagName) { + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User): ${resCreateUser.body}`); + return null; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: tagName }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return null; + } + + return { + accessToken: resLogin.json('data.accessToken'), + }; +} + +export default function () { + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create and login user + // ───────────────────────────────────────────────────────────────────────────── + const user = createAndLoginUser('01_Login_Action'); + if (!user) return; + + const authHeaders = { + 'X-Client-Type': 'web', + Authorization: `Bearer ${user.accessToken}`, + }; + + sleep(0.2); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 1: Upload Image (POST /media/upload/image) + // ───────────────────────────────────────────────────────────────────────────── + group('Image Upload', function () { + const imageBytes = encoding.b64decode(MINIMAL_PNG_BASE64); + + const formData = { + file: http.file(imageBytes, `test-image-${__VU}-${__ITER}.png`, 'image/png'), + folder: 'tweets', + }; + + const resUploadImage = http.post( + `${STRESS_TEST_URL}/media/upload/image`, + formData, + { + headers: authHeaders, + tags: { name: '02_Upload_Image' }, + } + ); + + const imageUploadOk = check(resUploadImage, { + 'Upload Image 2xx': (r) => r.status >= 200 && r.status < 300, + }); + + if (!imageUploadOk) { + console.error(`Upload Image Failed [VU:${__VU}]: ${resUploadImage.status} - ${resUploadImage.body}`); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 2: Upload Video (POST /media/upload/video) + // ───────────────────────────────────────────────────────────────────────────── + group('Video Upload', function () { + const videoBytes = createMinimalMP4Bytes(); + + const formData = { + file: http.file(videoBytes, `test-video-${__VU}-${__ITER}.mp4`, 'video/mp4'), + folder: 'tweets', + }; + + const resUploadVideo = http.post( + `${STRESS_TEST_URL}/media/upload/video`, + formData, + { + headers: authHeaders, + tags: { name: '03_Upload_Video' }, + } + ); + + const videoUploadOk = check(resUploadVideo, { + 'Upload Video 2xx': (r) => r.status >= 200 && r.status < 300, + }); + + if (!videoUploadOk) { + console.error(`Upload Video Failed [VU:${__VU}]: ${resUploadVideo.status} - ${resUploadVideo.body}`); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 3: Upload GIF via Tenor ID (POST /media/upload/gif) + // ───────────────────────────────────────────────────────────────────────────── + group('GIF Upload', function () { + // Note: This requires a valid Tenor GIF ID + // Using a commonly available GIF ID - may need to be updated if Tenor API changes + const tenorId = '16989471141791455574'; + + const gifPayload = JSON.stringify({ + tenorId: tenorId, + }); + + const resUploadGif = http.post( + `${STRESS_TEST_URL}/media/upload/gif`, + gifPayload, + { + headers: { + ...authHeaders, + 'Content-Type': 'application/json', + }, + tags: { name: '04_Upload_Gif' }, + } + ); + + const gifUploadOk = check(resUploadGif, { + 'Upload GIF 2xx': (r) => r.status >= 200 && r.status < 300, + }); + + if (!gifUploadOk) { + console.error(`Upload GIF Failed [VU:${__VU}]: ${resUploadGif.status} - ${resUploadGif.body}`); + } + }); + + sleep(0.2); +} diff --git a/test/performance/media/report.html b/test/performance/media/report.html new file mode 100644 index 00000000..faecda6d --- /dev/null +++ b/test/performance/media/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/notifications/notifications.stress.js b/test/performance/notifications/notifications.stress.js new file mode 100644 index 00000000..0da20006 --- /dev/null +++ b/test/performance/notifications/notifications.stress.js @@ -0,0 +1,270 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, // Ramp up to 50 users + { duration: '1m', target: 100 }, // Ramp up to 100 users + { duration: '30s', target: 300 }, // Ramp up to 300 users + { duration: '2m', target: 300 }, // Sustain at 300 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_UserA}': ['p(95)<2000'], + 'http_req_duration{name:02_Login_UserB}': ['p(95)<2000'], + 'http_req_duration{name:03_UserA_Create_Tweet}': ['p(95)<2000'], + 'http_req_duration{name:04_UserB_Follow_UserA}': ['p(95)<1000'], + 'http_req_duration{name:05_UserB_Like_Tweet}': ['p(95)<500'], + 'http_req_duration{name:06_Get_Notifications}': ['p(95)<1000'], + 'http_req_duration{name:07_Get_Notifications_Page2}': ['p(95)<1000'], + 'http_req_duration{name:08_Get_Unseen_Count}': ['p(95)<500'], + 'http_req_duration{name:09_Mark_Single_Seen}': ['p(95)<500'], + 'http_req_duration{name:10_Mark_All_Seen}': ['p(95)<500'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; + +/** + * Helper function to create and login a user + * Returns { accessToken, userId, username } or null on failure + */ +function createAndLoginUser(userLabel, tagName) { + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { [`${userLabel} Created 201`]: (r) => r.status === 201 })) { + console.error(`Setup Failed (Create ${userLabel}): ${resCreateUser.body}`); + return null; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + const username = userData.username; // Get username from /test/users response + + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: tagName }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { [`${userLabel} Login 200`]: (r) => r.status === 200 })) { + console.error(`Setup Failed (Login ${userLabel}): ${resLogin.body}`); + return null; + } + + return { + accessToken: resLogin.json('data.accessToken'), + username: username, // Use username from /test/users response + }; +} + +export default function () { + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create and login User A (will receive notifications) + // ───────────────────────────────────────────────────────────────────────────── + const userA = createAndLoginUser('UserA', '01_Login_UserA'); + if (!userA) return; + + const authParamsA = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + Authorization: `Bearer ${userA.accessToken}`, + }, + }; + + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create and login User B (will trigger notifications for User A) + // ───────────────────────────────────────────────────────────────────────────── + const userB = createAndLoginUser('UserB', '02_Login_UserB'); + if (!userB) return; + + const authParamsB = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + Authorization: `Bearer ${userB.accessToken}`, + }, + }; + + sleep(0.2); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 1: User A creates a tweet (so User B can like it) + // ───────────────────────────────────────────────────────────────────────────── + const uniqueContent = `Notification Test Tweet ${__VU}-${__ITER} - ${Date.now()}`; + + const createPayload = JSON.stringify({ + content: uniqueContent, + }); + + const resCreateTweet = http.post(`${STRESS_TEST_URL}/tweets`, createPayload, { + ...authParamsA, + tags: { name: '03_UserA_Create_Tweet' }, + }); + + if (!check(resCreateTweet, { 'UserA Create Tweet 201': (r) => r.status === 201 })) { + console.error(`UserA Create Tweet Failed: ${resCreateTweet.body}`); + return; + } + + const tweetId = resCreateTweet.json('data.id'); + + sleep(0.2); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 2: User B follows User A (triggers FOLLOW notification) + // ───────────────────────────────────────────────────────────────────────────── + group('Trigger Notifications', function () { + const resFollow = http.post( + `${STRESS_TEST_URL}/users/${userA.username}/following`, + null, + { ...authParamsB, tags: { name: '04_UserB_Follow_UserA' } } + ); + + check(resFollow, { + 'UserB Follow UserA 2xx': (r) => r.status >= 200 && r.status < 300, + }); + + sleep(0.1); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 3: User B likes User A's tweet (triggers LIKE notification) + // ─────────────────────────────────────────────────────────────────────────── + const resLike = http.post( + `${STRESS_TEST_URL}/tweets/${tweetId}/like`, + null, + { ...authParamsB, tags: { name: '05_UserB_Like_Tweet' } } + ); + + check(resLike, { + 'UserB Like Tweet 2xx': (r) => r.status >= 200 && r.status < 300, + }); + }); + + // Small delay to allow notifications to be processed + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // User A: Test Notification Endpoints + // ───────────────────────────────────────────────────────────────────────────── + group('Notification Endpoints', function () { + // ─────────────────────────────────────────────────────────────────────────── + // STEP 4: GET /notifications (first page) + // ─────────────────────────────────────────────────────────────────────────── + const resNotifications = http.get( + `${STRESS_TEST_URL}/notifications?limit=20`, + { ...authParamsA, tags: { name: '06_Get_Notifications' } } + ); + + const notificationsOk = check(resNotifications, { + 'Get Notifications 200': (r) => r.status === 200, + }); + + if (!notificationsOk) { + console.error(`Get Notifications Failed: ${resNotifications.body}`); + return; + } + + // Get a notification ID for single mark-as-seen test + let notificationId = null; + let cursor = null; + try { + const body = resNotifications.json(); + if (body.items && body.items.length > 0) { + notificationId = body.items[0].id; + } + if (body.pagination && body.pagination.nextCursor) { + cursor = body.pagination.nextCursor; + } + } catch { + // No notifications available + } + + sleep(0.1); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 5: GET /notifications (pagination - second page if cursor exists) + // ─────────────────────────────────────────────────────────────────────────── + if (cursor) { + const resNotificationsPage2 = http.get( + `${STRESS_TEST_URL}/notifications?limit=20&cursor=${encodeURIComponent(cursor)}`, + { ...authParamsA, tags: { name: '07_Get_Notifications_Page2' } } + ); + + check(resNotificationsPage2, { + 'Get Notifications Page2 200': (r) => r.status === 200, + }); + } else { + // Make a request with different limit to ensure consistent load + const resNotificationsPage2 = http.get( + `${STRESS_TEST_URL}/notifications?limit=10`, + { ...authParamsA, tags: { name: '07_Get_Notifications_Page2' } } + ); + + check(resNotificationsPage2, { + 'Get Notifications Page2 200': (r) => r.status === 200, + }); + } + + sleep(0.1); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 6: GET /notifications/count + // ─────────────────────────────────────────────────────────────────────────── + const resCount = http.get( + `${STRESS_TEST_URL}/notifications/count`, + { ...authParamsA, tags: { name: '08_Get_Unseen_Count' } } + ); + + check(resCount, { + 'Get Unseen Count 200': (r) => r.status === 200, + }); + + sleep(0.1); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 7: PATCH /notifications/:notificationId/seen (mark single as seen) + // ─────────────────────────────────────────────────────────────────────────── + if (notificationId) { + const resMarkSingleSeen = http.patch( + `${STRESS_TEST_URL}/notifications/${notificationId}/seen`, + null, + { ...authParamsA, tags: { name: '09_Mark_Single_Seen' } } + ); + + check(resMarkSingleSeen, { + 'Mark Single Seen 200': (r) => r.status === 200, + }); + } + // Skip if no notification ID available (no notifications triggered yet) + + sleep(0.1); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 8: PATCH /notifications/seen (mark all as seen) + // ─────────────────────────────────────────────────────────────────────────── + const resMarkAllSeen = http.patch( + `${STRESS_TEST_URL}/notifications/seen`, + null, + { ...authParamsA, tags: { name: '10_Mark_All_Seen' } } + ); + + check(resMarkAllSeen, { + 'Mark All Seen 200': (r) => r.status === 200, + }); + }); + + sleep(0.1); +} diff --git a/test/performance/notifications/report.html b/test/performance/notifications/report.html new file mode 100644 index 00000000..686f6c67 --- /dev/null +++ b/test/performance/notifications/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/onboarding/onboarding.stress.js b/test/performance/onboarding/onboarding.stress.js new file mode 100644 index 00000000..7c7c88b3 --- /dev/null +++ b/test/performance/onboarding/onboarding.stress.js @@ -0,0 +1,168 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, // Ramp up to 50 users + { duration: '1m', target: 100 }, // Ramp up to 100 users + { duration: '30s', target: 200 }, // Ramp up to 200 users + { duration: '2m', target: 200 }, // Sustain at 200 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<3000'], + 'http_req_duration{name:02_Get_Follow_Suggestions}': ['p(95)<2000'], + 'http_req_duration{name:03_Get_Follow_Suggestions_With_Limit}': ['p(95)<2000'], + 'http_req_duration{name:04_Get_Username_Suggestions}': ['p(95)<1500'], + 'http_req_duration{name:05_Get_Username_Suggestions_Typed}': ['p(95)<1500'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; + +/** + * Helper function to create and login a user + * Returns { accessToken } or null on failure + */ +function createAndLoginUser(tagName) { + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User): ${resCreateUser.body}`); + return null; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: tagName }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return null; + } + + return { + accessToken: resLogin.json('data.accessToken'), + }; +} + +export default function () { + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create and login user + // ───────────────────────────────────────────────────────────────────────────── + const user = createAndLoginUser('01_Login_Action'); + if (!user) return; + + const authHeaders = { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + Authorization: `Bearer ${user.accessToken}`, + }; + + sleep(0.2); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 1: Get Follow Suggestions (default limit) + // ───────────────────────────────────────────────────────────────────────────── + group('Follow Suggestions', function () { + const resFollowSuggestions = http.get( + `${STRESS_TEST_URL}/onboarding/follow-suggestions`, + { + headers: authHeaders, + tags: { name: '02_Get_Follow_Suggestions' }, + } + ); + + const followSuggestionsOk = check(resFollowSuggestions, { + 'Get Follow Suggestions 200': (r) => r.status === 200, + }); + + if (!followSuggestionsOk) { + console.error(`Get Follow Suggestions Failed [VU:${__VU}]: ${resFollowSuggestions.status} - ${resFollowSuggestions.body}`); + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 2: Get Follow Suggestions with custom limit + // ─────────────────────────────────────────────────────────────────────────── + const resFollowSuggestionsLimit = http.get( + `${STRESS_TEST_URL}/onboarding/follow-suggestions?limit=10`, + { + headers: authHeaders, + tags: { name: '03_Get_Follow_Suggestions_With_Limit' }, + } + ); + + const followSuggestionsLimitOk = check(resFollowSuggestionsLimit, { + 'Get Follow Suggestions With Limit 200': (r) => r.status === 200, + }); + + if (!followSuggestionsLimitOk) { + console.error(`Get Follow Suggestions With Limit Failed [VU:${__VU}]: ${resFollowSuggestionsLimit.status} - ${resFollowSuggestionsLimit.body}`); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 3: Get Username Suggestions (based on display name) + // ───────────────────────────────────────────────────────────────────────────── + group('Username Suggestions', function () { + const resUsernameSuggestions = http.get( + `${STRESS_TEST_URL}/onboarding/username-suggestions`, + { + headers: authHeaders, + tags: { name: '04_Get_Username_Suggestions' }, + } + ); + + const usernameSuggestionsOk = check(resUsernameSuggestions, { + 'Get Username Suggestions 200': (r) => r.status === 200, + }); + + if (!usernameSuggestionsOk) { + console.error(`Get Username Suggestions Failed [VU:${__VU}]: ${resUsernameSuggestions.status} - ${resUsernameSuggestions.body}`); + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 4: Get Username Suggestions with typed parameter + // ─────────────────────────────────────────────────────────────────────────── + const typedInput = `user${__VU}${__ITER}`; + const resUsernameSuggestionsTyped = http.get( + `${STRESS_TEST_URL}/onboarding/username-suggestions?typed=${encodeURIComponent(typedInput)}`, + { + headers: authHeaders, + tags: { name: '05_Get_Username_Suggestions_Typed' }, + } + ); + + const usernameSuggestionsTypedOk = check(resUsernameSuggestionsTyped, { + 'Get Username Suggestions Typed 200': (r) => r.status === 200, + }); + + if (!usernameSuggestionsTypedOk) { + console.error(`Get Username Suggestions Typed Failed [VU:${__VU}]: ${resUsernameSuggestionsTyped.status} - ${resUsernameSuggestionsTyped.body}`); + } + }); + + sleep(0.2); +} diff --git a/test/performance/onboarding/report.html b/test/performance/onboarding/report.html new file mode 100644 index 00000000..2b97fe17 --- /dev/null +++ b/test/performance/onboarding/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/profile/actions.stress.js b/test/performance/profile/actions.stress.js index 9f0b2603..2208c08d 100644 --- a/test/performance/profile/actions.stress.js +++ b/test/performance/profile/actions.stress.js @@ -19,9 +19,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -59,6 +59,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/profile/report_actions.html b/test/performance/profile/report_actions.html new file mode 100644 index 00000000..c66936ed --- /dev/null +++ b/test/performance/profile/report_actions.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/profile/report_update.html b/test/performance/profile/report_update.html new file mode 100644 index 00000000..cefa564a --- /dev/null +++ b/test/performance/profile/report_update.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/profile/update.stress.js b/test/performance/profile/update.stress.js index c09ed9da..f8d80028 100644 --- a/test/performance/profile/update.stress.js +++ b/test/performance/profile/update.stress.js @@ -20,9 +20,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -60,6 +60,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/search/report.html b/test/performance/search/report.html new file mode 100644 index 00000000..cb56fa8f --- /dev/null +++ b/test/performance/search/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/search/search.stress.js b/test/performance/search/search.stress.js new file mode 100644 index 00000000..7bbafacc --- /dev/null +++ b/test/performance/search/search.stress.js @@ -0,0 +1,318 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 50 }, // Ramp up to 50 users + { duration: '1m', target: 100 }, // Ramp up to 100 users + { duration: '30s', target: 200 }, // Ramp up to 200 users + { duration: '2m', target: 200 }, // Sustain at 200 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<3000'], + 'http_req_duration{name:02_Search_Suggestions}': ['p(95)<1500'], + 'http_req_duration{name:03_Search_Suggestions_Hashtag}': ['p(95)<1500'], + 'http_req_duration{name:04_Search_Tweets}': ['p(95)<2000'], + 'http_req_duration{name:05_Search_Tweets_Page2}': ['p(95)<2000'], + 'http_req_duration{name:06_Search_Users}': ['p(95)<2000'], + 'http_req_duration{name:07_Search_Users_Page2}': ['p(95)<2000'], + 'http_req_duration{name:08_User_Suggestions}': ['p(95)<1500'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; + +// Sample search queries to use +const SEARCH_QUERIES = [ + 'hello', + 'test', + 'user', + 'tweet', + 'world', + 'stress', + 'search', + 'random', +]; + +const HASHTAG_QUERIES = [ + '#trending', + '#news', + '#tech', + '#hello', + '#test', +]; + +/** + * Get a random search query + */ +function getRandomQuery() { + return SEARCH_QUERIES[Math.floor(Math.random() * SEARCH_QUERIES.length)]; +} + +/** + * Get a random hashtag query + */ +function getRandomHashtagQuery() { + return HASHTAG_QUERIES[Math.floor(Math.random() * HASHTAG_QUERIES.length)]; +} + +/** + * Helper function to create and login a user + * Returns { accessToken } or null on failure + */ +function createAndLoginUser(tagName) { + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User): ${resCreateUser.body}`); + return null; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: tagName }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return null; + } + + return { + accessToken: resLogin.json('data.accessToken'), + }; +} + +export default function () { + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create and login user + // ───────────────────────────────────────────────────────────────────────────── + const user = createAndLoginUser('01_Login_Action'); + if (!user) return; + + const authHeaders = { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + Authorization: `Bearer ${user.accessToken}`, + }; + + sleep(0.2); + + const searchQuery = getRandomQuery(); + const hashtagQuery = getRandomHashtagQuery(); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 1: GET /search/suggestions (regular query) + // ───────────────────────────────────────────────────────────────────────────── + group('Search Suggestions', function () { + const resSuggestions = http.get( + `${STRESS_TEST_URL}/search/suggestions?query=${encodeURIComponent(searchQuery)}`, + { + headers: authHeaders, + tags: { name: '02_Search_Suggestions' }, + } + ); + + const suggestionsOk = check(resSuggestions, { + 'Search Suggestions 200': (r) => r.status === 200, + }); + + if (!suggestionsOk) { + console.error(`Search Suggestions Failed [VU:${__VU}]: ${resSuggestions.status} - ${resSuggestions.body}`); + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 2: GET /search/suggestions (hashtag query) + // ─────────────────────────────────────────────────────────────────────────── + const resSuggestionsHashtag = http.get( + `${STRESS_TEST_URL}/search/suggestions?query=${encodeURIComponent(hashtagQuery)}`, + { + headers: authHeaders, + tags: { name: '03_Search_Suggestions_Hashtag' }, + } + ); + + const suggestionsHashtagOk = check(resSuggestionsHashtag, { + 'Search Suggestions Hashtag 200': (r) => r.status === 200, + }); + + if (!suggestionsHashtagOk) { + console.error(`Search Suggestions Hashtag Failed [VU:${__VU}]: ${resSuggestionsHashtag.status} - ${resSuggestionsHashtag.body}`); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 3: GET /search/tweets (first page) + // ───────────────────────────────────────────────────────────────────────────── + group('Search Tweets', function () { + const resTweets = http.get( + `${STRESS_TEST_URL}/search/tweets?query=${encodeURIComponent(searchQuery)}&limit=20`, + { + headers: authHeaders, + tags: { name: '04_Search_Tweets' }, + } + ); + + const tweetsOk = check(resTweets, { + 'Search Tweets 200': (r) => r.status === 200, + }); + + if (!tweetsOk) { + console.error(`Search Tweets Failed [VU:${__VU}]: ${resTweets.status} - ${resTweets.body}`); + return; + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 4: GET /search/tweets (pagination - second page) + // ─────────────────────────────────────────────────────────────────────────── + let cursor = null; + try { + const body = resTweets.json(); + if (body.pagination && body.pagination.nextCursor) { + cursor = body.pagination.nextCursor; + } + } catch { + // No cursor available + } + + if (cursor) { + const resTweetsPage2 = http.get( + `${STRESS_TEST_URL}/search/tweets?query=${encodeURIComponent(searchQuery)}&limit=20&cursor=${encodeURIComponent(cursor)}`, + { + headers: authHeaders, + tags: { name: '05_Search_Tweets_Page2' }, + } + ); + + check(resTweetsPage2, { + 'Search Tweets Page2 200': (r) => r.status === 200, + }); + } else { + // Make another request with different limit for consistent load + const resTweetsPage2 = http.get( + `${STRESS_TEST_URL}/search/tweets?query=${encodeURIComponent(searchQuery)}&limit=10`, + { + headers: authHeaders, + tags: { name: '05_Search_Tweets_Page2' }, + } + ); + + check(resTweetsPage2, { + 'Search Tweets Page2 200': (r) => r.status === 200, + }); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 5: GET /search/users (first page) + // ───────────────────────────────────────────────────────────────────────────── + group('Search Users', function () { + const resUsers = http.get( + `${STRESS_TEST_URL}/search/users?query=${encodeURIComponent(searchQuery)}&limit=20`, + { + headers: authHeaders, + tags: { name: '06_Search_Users' }, + } + ); + + const usersOk = check(resUsers, { + 'Search Users 200': (r) => r.status === 200, + }); + + if (!usersOk) { + console.error(`Search Users Failed [VU:${__VU}]: ${resUsers.status} - ${resUsers.body}`); + return; + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 6: GET /search/users (pagination - second page) + // ─────────────────────────────────────────────────────────────────────────── + let cursor = null; + try { + const body = resUsers.json(); + if (body.pagination && body.pagination.nextCursor) { + cursor = body.pagination.nextCursor; + } + } catch { + // No cursor available + } + + if (cursor) { + const resUsersPage2 = http.get( + `${STRESS_TEST_URL}/search/users?query=${encodeURIComponent(searchQuery)}&limit=20&cursor=${encodeURIComponent(cursor)}`, + { + headers: authHeaders, + tags: { name: '07_Search_Users_Page2' }, + } + ); + + check(resUsersPage2, { + 'Search Users Page2 200': (r) => r.status === 200, + }); + } else { + // Make another request with different limit for consistent load + const resUsersPage2 = http.get( + `${STRESS_TEST_URL}/search/users?query=${encodeURIComponent(searchQuery)}&limit=10`, + { + headers: authHeaders, + tags: { name: '07_Search_Users_Page2' }, + } + ); + + check(resUsersPage2, { + 'Search Users Page2 200': (r) => r.status === 200, + }); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 7: GET /search/users/suggestions (for mentions) + // ───────────────────────────────────────────────────────────────────────────── + group('User Suggestions', function () { + const resUserSuggestions = http.get( + `${STRESS_TEST_URL}/search/users/suggestions?query=${encodeURIComponent(searchQuery)}`, + { + headers: authHeaders, + tags: { name: '08_User_Suggestions' }, + } + ); + + const userSuggestionsOk = check(resUserSuggestions, { + 'User Suggestions 200': (r) => r.status === 200, + }); + + if (!userSuggestionsOk) { + console.error(`User Suggestions Failed [VU:${__VU}]: ${resUserSuggestions.status} - ${resUserSuggestions.body}`); + } + }); + + sleep(0.1); +} diff --git a/test/performance/settings/account/birthdate.stress.js b/test/performance/settings/account/birthdate.stress.js index a310c90c..a0ee28be 100644 --- a/test/performance/settings/account/birthdate.stress.js +++ b/test/performance/settings/account/birthdate.stress.js @@ -17,9 +17,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -57,6 +57,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/settings/account/email.stress.js b/test/performance/settings/account/email.stress.js index 8ce4a57c..64b48c9f 100644 --- a/test/performance/settings/account/email.stress.js +++ b/test/performance/settings/account/email.stress.js @@ -19,9 +19,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -59,6 +59,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/settings/account/password.stress.js b/test/performance/settings/account/password.stress.js index ccf6cb98..29165112 100644 --- a/test/performance/settings/account/password.stress.js +++ b/test/performance/settings/account/password.stress.js @@ -17,10 +17,10 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -58,6 +58,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken, userPassword }; +} + +export default function (data) { + const { accessToken, userPassword } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/settings/account/username.stress.js b/test/performance/settings/account/username.stress.js index 41784978..8735abe6 100644 --- a/test/performance/settings/account/username.stress.js +++ b/test/performance/settings/account/username.stress.js @@ -18,9 +18,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3001'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreate = http.post( `${STRESS_TEST_URL}/test/users`, @@ -58,6 +58,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const headers = { 'Content-Type': 'application/json', diff --git a/test/performance/settings/general.stress.js b/test/performance/settings/general.stress.js new file mode 100644 index 00000000..b773924a --- /dev/null +++ b/test/performance/settings/general.stress.js @@ -0,0 +1,180 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 500 }, + { duration: '1m', target: 700 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<1800'], + 'http_req_duration{name:02_Get_Me}': ['p(95)<500'], + 'http_req_duration{name:03_Get_Blocks}': ['p(95)<500'], + 'http_req_duration{name:04_Get_Mutes}': ['p(95)<500'], + 'http_req_duration{name:05_Get_Connected_Accounts}': ['p(95)<500'], + 'http_req_duration{name:06_Get_Countries}': ['p(95)<500'], + 'http_req_duration{name:07_Update_Country}': ['p(95)<500'], + 'http_req_duration{name:08_Update_Gender}': ['p(95)<500'], + 'http_req_duration{name:09_Get_Interests}': ['p(95)<500'], + 'http_req_duration{name:10_Update_Language}': ['p(95)<500'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; + +export function setup() { + + const resCreate = http.post( + `${STRESS_TEST_URL}/test/users`, + ); + + if (!check(resCreate, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Failed to create user: ${resCreate.body}`); + return; + } + + const userData = resCreate.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: '01_Login_Action' } + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { + 'Login status is 200': (r) => r.status === 200 + })) { + console.error(`Login Failed: ${resLogin.body}`); + return; + } + + const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; + + const headers = { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + 'Authorization': `Bearer ${accessToken}`, + }; + + // GET /me + const resMe = http.get( + `${STRESS_TEST_URL}/me`, + { headers: headers, tags: { name: '02_Get_Me' } } + ); + check(resMe, { + 'Get Me status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // GET /me/settings/blocks + const resBlocks = http.get( + `${STRESS_TEST_URL}/me/settings/blocks`, + { headers: headers, tags: { name: '03_Get_Blocks' } } + ); + check(resBlocks, { + 'Get Blocks status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // GET /me/settings/mutes + const resMutes = http.get( + `${STRESS_TEST_URL}/me/settings/mutes`, + { headers: headers, tags: { name: '04_Get_Mutes' } } + ); + check(resMutes, { + 'Get Mutes status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // GET /me/settings/connected-accounts + const resConnected = http.get( + `${STRESS_TEST_URL}/me/settings/connected-accounts`, + { headers: headers, tags: { name: '05_Get_Connected_Accounts' } } + ); + check(resConnected, { + 'Get Connected Accounts status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // GET /me/settings/country + const resCountries = http.get( + `${STRESS_TEST_URL}/me/settings/country`, + { headers: headers, tags: { name: '06_Get_Countries' } } + ); + check(resCountries, { + 'Get Countries status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // PUT /me/settings/country + const payloadCountry = JSON.stringify({ + countryName: "Egypt" + }); + const resUpdateCountry = http.put( + `${STRESS_TEST_URL}/me/settings/country`, + payloadCountry, + { headers: headers, tags: { name: '07_Update_Country' } } + ); + check(resUpdateCountry, { + 'Update Country status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // PUT /me/settings/gender + const payloadGender = JSON.stringify({ + gender: "Male" + }); + const resUpdateGender = http.put( + `${STRESS_TEST_URL}/me/settings/gender`, + payloadGender, + { headers: headers, tags: { name: '08_Update_Gender' } } + ); + check(resUpdateGender, { + 'Update Gender status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // GET /me/settings/interests + const resInterests = http.get( + `${STRESS_TEST_URL}/me/settings/interests`, + { headers: headers, tags: { name: '09_Get_Interests' } } + ); + check(resInterests, { + 'Get Interests status is 200': (r) => r.status === 200, + }); + sleep(0.1); + + // PUT /me/settings/language + const payloadLanguage = JSON.stringify({ + language: "en" + }); + const resUpdateLanguage = http.put( + `${STRESS_TEST_URL}/me/settings/language`, + payloadLanguage, + { headers: headers, tags: { name: '10_Update_Language' } } + ); + check(resUpdateLanguage, { + 'Update Language status is 200': (r) => r.status === 200, + }); + sleep(0.1); +} diff --git a/test/performance/settings/report.html b/test/performance/settings/report.html new file mode 100644 index 00000000..ea2561f3 --- /dev/null +++ b/test/performance/settings/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/timeline/report.html b/test/performance/timeline/report.html new file mode 100644 index 00000000..3973846f --- /dev/null +++ b/test/performance/timeline/report.html @@ -0,0 +1,44 @@ + + + + + + + + + k6 report + + + + + +
+ + + + diff --git a/test/performance/timeline/timeline.stress.js b/test/performance/timeline/timeline.stress.js new file mode 100644 index 00000000..f2875f50 --- /dev/null +++ b/test/performance/timeline/timeline.stress.js @@ -0,0 +1,219 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, // Ramp up to 20 users + { duration: '1m', target: 50 }, // Ramp up to 50 users + { duration: '30s', target: 100 }, // Ramp up to 100 users + { duration: '1m', target: 100 }, // Stay at 100 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<2000'], + 'http_req_duration{name:02_Create_Tweet_For_Timeline}': ['p(95)<2000'], + 'http_req_duration{name:03_Get_Following_Timeline}': ['p(95)<1000'], + 'http_req_duration{name:04_Get_Following_Pagination}': ['p(95)<1000'], + 'http_req_duration{name:05_Get_ForYou_Timeline}': ['p(95)<1000'], + 'http_req_duration{name:06_Get_ForYou_Pagination}': ['p(95)<1000'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; + +export default function () { + // ───────────────────────────────────────────────────────────────────────────── + // SETUP: Create a test user + // ───────────────────────────────────────────────────────────────────────────── + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User): ${resCreateUser.body}`); + return; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 1: Login + // ───────────────────────────────────────────────────────────────────────────── + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: '01_Login_Action' }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return; + } + + const accessToken = resLogin.json('data.accessToken'); + + const authParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + Authorization: `Bearer ${accessToken}`, + }, + }; + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 2: Create a tweet so the timeline has content + // ───────────────────────────────────────────────────────────────────────────── + const uniqueContent = `Timeline Stress Test Tweet ${__VU}-${__ITER} - ${Date.now()}`; + + const createPayload = JSON.stringify({ + content: uniqueContent, + }); + + const resCreateTweet = http.post(`${STRESS_TEST_URL}/tweets`, createPayload, { + ...authParams, + tags: { name: '02_Create_Tweet_For_Timeline' }, + }); + + if (!check(resCreateTweet, { 'Create Tweet 201': (r) => r.status === 201 })) { + console.error(`Create Tweet Failed: ${resCreateTweet.body}`); + return; + } + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 3: Get Following Timeline (first page) + // ───────────────────────────────────────────────────────────────────────────── + group('Following Timeline', function () { + const resFollowingTimeline = http.get( + `${STRESS_TEST_URL}/timeline/following?limit=20`, + { ...authParams, tags: { name: '03_Get_Following_Timeline' } } + ); + + const followingTimelineOk = check(resFollowingTimeline, { + 'Get Following Timeline 200': (r) => r.status === 200 + }); + + if (!followingTimelineOk) { + console.error(`Get Following Timeline Failed: ${resFollowingTimeline.body}`); + return; + } + + // Get cursor for pagination test if available + let followingCursor = null; + try { + const followingBody = resFollowingTimeline.json(); + if (followingBody.pagination && followingBody.pagination.nextCursor) { + followingCursor = followingBody.pagination.nextCursor; + } + } catch { + // No cursor available + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 4: Get Following Timeline (pagination - second page if cursor exists) + // ─────────────────────────────────────────────────────────────────────────── + if (followingCursor) { + const resFollowingPagination = http.get( + `${STRESS_TEST_URL}/timeline/following?limit=20&cursor=${encodeURIComponent(followingCursor)}`, + { ...authParams, tags: { name: '04_Get_Following_Pagination' } } + ); + + check(resFollowingPagination, { + 'Get Following Pagination 200': (r) => r.status === 200, + }); + + if (resFollowingPagination.status !== 200) { + console.error(`Get Following Pagination Failed: ${resFollowingPagination.body}`); + } + } else { + // Make a request anyway to ensure thresholds are met with consistent load + const resFollowingPagination = http.get( + `${STRESS_TEST_URL}/timeline/following?limit=10`, + { ...authParams, tags: { name: '04_Get_Following_Pagination' } } + ); + + check(resFollowingPagination, { + 'Get Following Pagination 200': (r) => r.status === 200, + }); + } + }); + + sleep(0.3); + + // ───────────────────────────────────────────────────────────────────────────── + // STEP 5: Get For You Timeline (first page) + // ───────────────────────────────────────────────────────────────────────────── + group('For You Timeline', function () { + const resForYouTimeline = http.get( + `${STRESS_TEST_URL}/timeline/for-you?limit=20`, + { ...authParams, tags: { name: '05_Get_ForYou_Timeline' } } + ); + + const forYouTimelineOk = check(resForYouTimeline, { + 'Get ForYou Timeline 200': (r) => r.status === 200, + }); + + if (!forYouTimelineOk) { + console.error(`Get ForYou Timeline Failed: ${resForYouTimeline.body}`); + return; + } + + // Get cursor for pagination test if available + let forYouCursor = null; + try { + const forYouBody = resForYouTimeline.json(); + if (forYouBody.pagination && forYouBody.pagination.nextCursor) { + forYouCursor = forYouBody.pagination.nextCursor; + } + } catch { + // No cursor available + } + + sleep(0.2); + + // ─────────────────────────────────────────────────────────────────────────── + // STEP 6: Get For You Timeline (pagination - second page if cursor exists) + // ─────────────────────────────────────────────────────────────────────────── + if (forYouCursor) { + const resForYouPagination = http.get( + `${STRESS_TEST_URL}/timeline/for-you?limit=20&cursor=${encodeURIComponent(forYouCursor)}`, + { ...authParams, tags: { name: '06_Get_ForYou_Pagination' } } + ); + + check(resForYouPagination, { + 'Get ForYou Pagination 200': (r) => r.status === 200, + }); + + if (resForYouPagination.status !== 200) { + console.error(`Get ForYou Pagination Failed: ${resForYouPagination.body}`); + } + } else { + // Make a request anyway to ensure thresholds are met with consistent load + const resForYouPagination = http.get( + `${STRESS_TEST_URL}/timeline/for-you?limit=10`, + { ...authParams, tags: { name: '06_Get_ForYou_Pagination' } } + ); + + check(resForYouPagination, { + 'Get ForYou Pagination 200': (r) => r.status === 200, + }); + } + }); + + sleep(0.1); +} diff --git a/test/performance/tweets/tweet-crud.stress.js b/test/performance/tweets/tweet-crud.stress.js index 865f5474..a9ee4fa5 100644 --- a/test/performance/tweets/tweet-crud.stress.js +++ b/test/performance/tweets/tweet-crud.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, { duration: '30s', target: 0 }, ], thresholds: { @@ -18,9 +18,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreateUser = http.post( `${STRESS_TEST_URL}/test/users`, ); @@ -55,6 +55,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const authParams = { headers: { diff --git a/test/performance/tweets/tweet-engagement.stress.js b/test/performance/tweets/tweet-engagement.stress.js index 7c01e663..f46bc953 100644 --- a/test/performance/tweets/tweet-engagement.stress.js +++ b/test/performance/tweets/tweet-engagement.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, { duration: '30s', target: 0 }, ], thresholds: { @@ -22,9 +22,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreateUser = http.post( `${STRESS_TEST_URL}/test/users`, @@ -54,6 +54,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const authParams = { headers: { 'Content-Type': 'application/json', diff --git a/test/performance/tweets/tweet-threads.stress.js b/test/performance/tweets/tweet-threads.stress.js index 91ab7933..0418f7d4 100644 --- a/test/performance/tweets/tweet-threads.stress.js +++ b/test/performance/tweets/tweet-threads.stress.js @@ -5,8 +5,8 @@ export const options = { stages: [ { duration: '30s', target: 20 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 100 }, - { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, { duration: '30s', target: 0 }, ], thresholds: { @@ -20,9 +20,9 @@ export const options = { }, }; -const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'http://localhost:3000'; +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; -export default function () { +export function setup() { const resCreateUser = http.post( `${STRESS_TEST_URL}/test/users`, ); @@ -51,6 +51,11 @@ export default function () { } const accessToken = resLogin.json('data.accessToken'); + return { accessToken }; +} + +export default function (data) { + const { accessToken } = data; const authParams = { headers: { 'Content-Type': 'application/json', diff --git a/test/performance/users/user-data.stress.js b/test/performance/users/user-data.stress.js new file mode 100644 index 00000000..959bf74b --- /dev/null +++ b/test/performance/users/user-data.stress.js @@ -0,0 +1,133 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<2000'], + 'http_req_duration{name:02_Get_User_By_Id}': ['p(95)<500'], + 'http_req_duration{name:03_Get_User_Profile}': ['p(95)<500'], + 'http_req_duration{name:04_Get_User_Tweets}': ['p(95)<1000'], + 'http_req_duration{name:05_Get_User_Replies}': ['p(95)<1000'], + 'http_req_duration{name:06_Get_User_Media}': ['p(95)<1000'], + 'http_req_duration{name:07_Get_User_Likes}': ['p(95)<1000'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; + +export function setup() { + // 1. Create User + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + + if (!check(resCreateUser, { 'User Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User): ${resCreateUser.body}`); + return; + } + + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + const userId = userData.id; + + // 2. Login + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: '01_Login_Action' }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return; + } + + const accessToken = resLogin.json('data.accessToken'); + return { accessToken, userId }; +} + +export default function (data) { + const { accessToken, userId } = data; + + const authParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + 'Authorization': `Bearer ${accessToken}`, + }, + }; + + sleep(0.5); + + // 3. Get User By ID + const resGetUserById = http.get( + `${STRESS_TEST_URL}/users/id/${userId}`, + { ...authParams, tags: { name: '02_Get_User_By_Id' } } + ); + + check(resGetUserById, { 'Get User By Id 200': (r) => r.status === 200 }); + sleep(0.5); + + const users = ['notnowomar', 'gelgel']; + const randomUser = () => users[Math.floor(Math.random() * users.length)]; + + // 4. Get User Profile + const resGetUserProfile = http.get( + `${STRESS_TEST_URL}/users/${randomUser()}/profile`, + { ...authParams, tags: { name: '03_Get_User_Profile' } } + ); + + check(resGetUserProfile, { 'Get User Profile 200': (r) => r.status === 200 }); + sleep(0.5); + + // 5. Get User Tweets + const resGetUserTweets = http.get( + `${STRESS_TEST_URL}/users/${randomUser()}/tweets`, + { ...authParams, tags: { name: '04_Get_User_Tweets' } } + ); + + check(resGetUserTweets, { 'Get User Tweets 200': (r) => r.status === 200 }); + sleep(0.5); + + // 6. Get User Replies + const resGetUserReplies = http.get( + `${STRESS_TEST_URL}/users/${randomUser()}/replies`, + { ...authParams, tags: { name: '05_Get_User_Replies' } } + ); + + check(resGetUserReplies, { 'Get User Replies 200': (r) => r.status === 200 }); + sleep(0.5); + + // 7. Get User Media + const resGetUserMedia = http.get( + `${STRESS_TEST_URL}/users/${randomUser()}/media`, + { ...authParams, tags: { name: '06_Get_User_Media' } } + ); + + check(resGetUserMedia, { 'Get User Media 200': (r) => r.status === 200 }); + sleep(0.5); + + // 8. Get User Likes + const resGetUserLikes = http.get( + `${STRESS_TEST_URL}/users/${randomUser()}/likes`, + { ...authParams, tags: { name: '07_Get_User_Likes' } } + ); + + check(resGetUserLikes, { 'Get User Likes 200': (r) => r.status === 200 }); +} diff --git a/test/performance/users/user-social.stress.js b/test/performance/users/user-social.stress.js new file mode 100644 index 00000000..d586222b --- /dev/null +++ b/test/performance/users/user-social.stress.js @@ -0,0 +1,132 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 400 }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + 'http_req_duration{name:01_Login_Action}': ['p(95)<2000'], + 'http_req_duration{name:02_Follow_User}': ['p(95)<1000'], + 'http_req_duration{name:03_Get_Following}': ['p(95)<1000'], + 'http_req_duration{name:04_Get_Followers}': ['p(95)<1000'], + 'http_req_duration{name:05_Get_Relationship}': ['p(95)<500'], + 'http_req_duration{name:06_Get_Mutual}': ['p(95)<1000'], + 'http_req_duration{name:07_Unfollow_User}': ['p(95)<1000'], + }, +}; + +const STRESS_TEST_URL = __ENV.STRESS_TEST_URL || 'https://test.api.raven.cmp27.space'; + +export function setup() { + // 1. Create User A (The Actor) + const resCreateUser = http.post(`${STRESS_TEST_URL}/test/users`); + if (!check(resCreateUser, { 'User A Created 201': (r) => r.status === 201 })) { + console.error(`Setup Failed (Create User A): ${resCreateUser.body}`); + return; + } + const userData = resCreateUser.json('data'); + const userEmail = userData.email; + const userPassword = userData.password; + const userUsername = userData.username; + + // 3. Login as User A + const loginPayload = JSON.stringify({ + identifier: userEmail, + password: userPassword, + }); + + const loginParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + }, + tags: { name: '01_Login_Action' }, + }; + + const resLogin = http.post(`${STRESS_TEST_URL}/auth/login`, loginPayload, loginParams); + + if (!check(resLogin, { 'Login 200': (r) => r.status === 200 })) { + console.error(`Setup Failed (Login): ${resLogin.body}`); + return; + } + + const accessToken = resLogin.json('data.accessToken'); + return { accessToken, userUsername }; +} + +export default function (data) { + const { accessToken, userUsername } = data; + + const authParams = { + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'web', + 'Authorization': `Bearer ${accessToken}`, + }, + }; + + sleep(0.5); + const users = ['notnowomar', 'gelgel']; + const randomUser = users[Math.floor(Math.random() * users.length)]; + + // 4. Follow User B + const resFollow = http.post( + `${STRESS_TEST_URL}/users/${randomUser}/following`, + null, // No body needed for follow usually, or empty object + { ...authParams, tags: { name: '02_Follow_User' } } + ); + + check(resFollow, { 'Follow User 200': (r) => r.status === 200 || r.status === 201 }); + sleep(0.5); + + // 5. Get Following (of User A) + const resGetFollowing = http.get( + `${STRESS_TEST_URL}/users/${userUsername}/following`, + { ...authParams, tags: { name: '03_Get_Following' } } + ); + + check(resGetFollowing, { 'Get Following 200': (r) => r.status === 200 }); + sleep(0.5); + + // 6. Get Followers (of User B) + const resGetFollowers = http.get( + `${STRESS_TEST_URL}/users/${randomUser}/followers`, + { ...authParams, tags: { name: '04_Get_Followers' } } + ); + + check(resGetFollowers, { 'Get Followers 200': (r) => r.status === 200 }); + sleep(0.5); + + // 7. Get Relationship (A with B) + const resGetRelationship = http.get( + `${STRESS_TEST_URL}/users/${randomUser}/relationship`, + { ...authParams, tags: { name: '05_Get_Relationship' } } + ); + + check(resGetRelationship, { 'Get Relationship 200': (r) => r.status === 200 }); + sleep(0.5); + + // 8. Get Mutual (A with B) + const resGetMutual = http.get( + `${STRESS_TEST_URL}/users/${randomUser}/mutual`, + { ...authParams, tags: { name: '06_Get_Mutual' } } + ); + + check(resGetMutual, { 'Get Mutual 200': (r) => r.status === 200 }); + sleep(0.5); + + // 9. Unfollow User B + const resUnfollow = http.del( + `${STRESS_TEST_URL}/users/${randomUser}/following`, + null, + { ...authParams, tags: { name: '07_Unfollow_User' } } + ); + + check(resUnfollow, { 'Unfollow User 200': (r) => r.status === 200 }); +}