From cf2e98b1d7f576b0aedca02c440ae50b88012ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9COmarNabil005=E2=80=9D?= Date: Mon, 15 Dec 2025 20:08:17 +0200 Subject: [PATCH 1/2] fixed unit tests --- package.json | 7 +- src/email/email.service.spec.ts | 6 +- src/gateway/socket.gateway.spec.ts | 17 +- src/messages/messages.service.spec.ts | 12 + .../events/notification.listener.spec.ts | 3 + src/post/hashtag.controller.spec.ts | 8 + src/post/post-timeline.service.spec.ts | 4 +- .../services/hashtag-trends.service.spec.ts | 288 +++++++++++------- 8 files changed, 229 insertions(+), 116 deletions(-) diff --git a/package.json b/package.json index 9baae7c..258fca5 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,12 @@ "\\.enum\\.ts$", "\\.config\\.ts$", "\\.d\\.ts$", - "constants\\.ts$" + "constants\\.ts$", + "firebase/", + "storage/", + "ai-integration/", + "redis/", + "config/validate-config.ts" ], "coverageDirectory": "../coverage", "testEnvironment": "node", diff --git a/src/email/email.service.spec.ts b/src/email/email.service.spec.ts index 091ab9b..34e5d2b 100644 --- a/src/email/email.service.spec.ts +++ b/src/email/email.service.spec.ts @@ -3,10 +3,10 @@ import { EmailService } from './email.service'; import { Services, RedisQueues } from 'src/utils/constants'; import mailerConfig from 'src/common/config/mailer.config'; import { getQueueToken } from '@nestjs/bullmq'; -import * as fs from 'fs'; +import * as fs from 'node:fs'; -// Mock fs.readFileSync -jest.mock('fs', () => ({ +// Mock fs.readFileSync - must use 'node:fs' to match the import in the service +jest.mock('node:fs', () => ({ readFileSync: jest.fn(), })); diff --git a/src/gateway/socket.gateway.spec.ts b/src/gateway/socket.gateway.spec.ts index 4c3237a..88f307c 100644 --- a/src/gateway/socket.gateway.spec.ts +++ b/src/gateway/socket.gateway.spec.ts @@ -34,6 +34,7 @@ describe('SocketGateway', () => { isUserInConversation: jest.fn(), markMessagesAsSeen: jest.fn(), getConversationUsers: jest.fn(), + getConversationUsersCached: jest.fn(), create: jest.fn(), update: jest.fn(), }; @@ -714,7 +715,7 @@ describe('SocketGateway', () => { const mockSocket = createMockSocket(1); mockSocket.to = jest.fn().mockReturnThis(); - mockMessagesService.getConversationUsers.mockResolvedValue({ + mockMessagesService.getConversationUsersCached.mockResolvedValue({ user1Id: 1, user2Id: 2, }); @@ -741,7 +742,7 @@ describe('SocketGateway', () => { const mockSocket = createMockSocket(3); mockSocket.to = jest.fn().mockReturnThis(); - mockMessagesService.getConversationUsers.mockResolvedValue({ + mockMessagesService.getConversationUsersCached.mockResolvedValue({ user1Id: 1, user2Id: 2, }); @@ -755,7 +756,7 @@ describe('SocketGateway', () => { const mockSocket = createMockSocket(1); mockSocket.to = jest.fn().mockReturnThis(); - mockMessagesService.getConversationUsers.mockResolvedValue({ + mockMessagesService.getConversationUsersCached.mockResolvedValue({ user1Id: 1, user2Id: 2, }); @@ -773,7 +774,7 @@ describe('SocketGateway', () => { const mockSocket = createMockSocket(2); mockSocket.to = jest.fn().mockReturnThis(); - mockMessagesService.getConversationUsers.mockResolvedValue({ + mockMessagesService.getConversationUsersCached.mockResolvedValue({ user1Id: 1, user2Id: 2, }); @@ -793,7 +794,7 @@ describe('SocketGateway', () => { const mockSocket = createMockSocket(1); mockSocket.to = jest.fn().mockReturnThis(); - mockMessagesService.getConversationUsers.mockResolvedValue({ + mockMessagesService.getConversationUsersCached.mockResolvedValue({ user1Id: 1, user2Id: 2, }); @@ -820,7 +821,7 @@ describe('SocketGateway', () => { const mockSocket = createMockSocket(3); mockSocket.to = jest.fn().mockReturnThis(); - mockMessagesService.getConversationUsers.mockResolvedValue({ + mockMessagesService.getConversationUsersCached.mockResolvedValue({ user1Id: 1, user2Id: 2, }); @@ -834,7 +835,7 @@ describe('SocketGateway', () => { const mockSocket = createMockSocket(1); mockSocket.to = jest.fn().mockReturnThis(); - mockMessagesService.getConversationUsers.mockResolvedValue({ + mockMessagesService.getConversationUsersCached.mockResolvedValue({ user1Id: 1, user2Id: 2, }); @@ -852,7 +853,7 @@ describe('SocketGateway', () => { const mockSocket = createMockSocket(2); mockSocket.to = jest.fn().mockReturnThis(); - mockMessagesService.getConversationUsers.mockResolvedValue({ + mockMessagesService.getConversationUsersCached.mockResolvedValue({ user1Id: 1, user2Id: 2, }); diff --git a/src/messages/messages.service.spec.ts b/src/messages/messages.service.spec.ts index 2c5b173..8bb436b 100644 --- a/src/messages/messages.service.spec.ts +++ b/src/messages/messages.service.spec.ts @@ -32,6 +32,14 @@ describe('MessagesService', () => { $transaction: jest.fn(), }; + const mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + getJSON: jest.fn(), + setJSON: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -43,6 +51,10 @@ describe('MessagesService', () => { provide: Services.PRISMA, useValue: mockPrismaService, }, + { + provide: Services.REDIS, + useValue: mockRedisService, + }, ], }).compile(); diff --git a/src/notifications/events/notification.listener.spec.ts b/src/notifications/events/notification.listener.spec.ts index d94dd6e..09f3e8d 100644 --- a/src/notifications/events/notification.listener.spec.ts +++ b/src/notifications/events/notification.listener.spec.ts @@ -35,6 +35,9 @@ describe('NotificationListener', () => { post: { findUnique: jest.fn(), }, + mute: { + findUnique: jest.fn(), + }, }, }, ], diff --git a/src/post/hashtag.controller.spec.ts b/src/post/hashtag.controller.spec.ts index cc04680..db81310 100644 --- a/src/post/hashtag.controller.spec.ts +++ b/src/post/hashtag.controller.spec.ts @@ -10,6 +10,10 @@ describe('HashtagController', () => { recalculateTrends: jest.fn(), }; + const mockPersonalizedTrendsService = { + getPersonalizedTrending: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [HashtagController], @@ -18,6 +22,10 @@ describe('HashtagController', () => { provide: Services.HASHTAG_TRENDS, useValue: mockHashtagTrendService, }, + { + provide: Services.PERSONALIZED_TRENDS, + useValue: mockPersonalizedTrendsService, + }, ], }).compile(); diff --git a/src/post/post-timeline.service.spec.ts b/src/post/post-timeline.service.spec.ts index cb9dafb..8a7eef3 100644 --- a/src/post/post-timeline.service.spec.ts +++ b/src/post/post-timeline.service.spec.ts @@ -1067,14 +1067,14 @@ describe('PostService - Timeline Endpoints', () => { ); }); - it('should only fetch posts from last 30 days', async () => { + it('should only fetch posts from last 14 days', async () => { mockPrismaService.$queryRawUnsafe.mockResolvedValue([mockPostWithInterestName]); mockMlService.getQualityScores.mockResolvedValue(new Map([[100, 0.85]])); await service.getExploreAllInterestsFeed(1); const query = mockPrismaService.$queryRawUnsafe.mock.calls[0][0]; - expect(query).toContain("NOW() - INTERVAL '30 days'"); + expect(query).toContain("NOW() - INTERVAL '14 days'"); }); it('should include user interaction flags', async () => { diff --git a/src/post/services/hashtag-trends.service.spec.ts b/src/post/services/hashtag-trends.service.spec.ts index 71fa0a5..acb9e54 100644 --- a/src/post/services/hashtag-trends.service.spec.ts +++ b/src/post/services/hashtag-trends.service.spec.ts @@ -1,27 +1,24 @@ import { Test, TestingModule } from '@nestjs/testing'; import { HashtagTrendService } from './hashtag-trends.service'; -import { Services, RedisQueues } from 'src/utils/constants'; -import { getQueueToken } from '@nestjs/bullmq'; +import { Services } from 'src/utils/constants'; import { TrendCategory } from '../enums/trend-category.enum'; describe('HashtagTrendService', () => { let service: HashtagTrendService; let prisma: any; let redisService: any; - let trendingQueue: any; - let usersService: any; + let redisTrendingService: any; + let personalizedTrendsService: any; beforeEach(async () => { const mockPrismaService = { - post: { - findMany: jest.fn(), - }, hashtag: { findMany: jest.fn(), }, hashtagTrend: { findMany: jest.fn(), - upsert: jest.fn(), + create: jest.fn(), + update: jest.fn(), }, }; @@ -31,12 +28,18 @@ describe('HashtagTrendService', () => { delPattern: jest.fn(), }; - const mockQueue = { - add: jest.fn().mockResolvedValue({ id: 'job-123' }), + const mockRedisTrendingService = { + trackPostHashtags: jest.fn(), + getTrending: jest.fn(), + getHashtagCounts: jest.fn(), + batchGetHashtagMetadata: jest.fn(), + batchGetHashtagCounts: jest.fn(), + setHashtagMetadata: jest.fn(), }; - const mockUsersService = { - getUserInterests: jest.fn().mockResolvedValue([]), + const mockPersonalizedTrendsService = { + getPersonalizedTrending: jest.fn(), + trackUserActivity: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -51,12 +54,12 @@ describe('HashtagTrendService', () => { useValue: mockRedisService, }, { - provide: getQueueToken(RedisQueues.hashTagQueue.name), - useValue: mockQueue, + provide: Services.REDIS_TRENDING, + useValue: mockRedisTrendingService, }, { - provide: Services.USERS, - useValue: mockUsersService, + provide: Services.PERSONALIZED_TRENDS, + useValue: mockPersonalizedTrendsService, }, ], }).compile(); @@ -64,88 +67,110 @@ describe('HashtagTrendService', () => { service = module.get(HashtagTrendService); prisma = module.get(Services.PRISMA); redisService = module.get(Services.REDIS); - trendingQueue = module.get(getQueueToken(RedisQueues.hashTagQueue.name)); - usersService = module.get(Services.USERS); + redisTrendingService = module.get(Services.REDIS_TRENDING); + personalizedTrendsService = module.get(Services.PERSONALIZED_TRENDS); }); afterEach(() => { jest.clearAllMocks(); }); - describe('queueTrendCalculation', () => { - it('should not queue when hashtagIds is empty', async () => { - await service.queueTrendCalculation([]); + describe('trackPostHashtags', () => { + it('should not track when hashtagIds is empty', async () => { + await service.trackPostHashtags(1, [], [TrendCategory.GENERAL]); - expect(trendingQueue.add).not.toHaveBeenCalled(); + expect(redisTrendingService.trackPostHashtags).not.toHaveBeenCalled(); }); - it('should queue trend calculation for hashtags', async () => { + it('should track hashtags for specified categories', async () => { const hashtagIds = [1, 2, 3]; + const categories = [TrendCategory.GENERAL, TrendCategory.NEWS]; + + await service.trackPostHashtags(1, hashtagIds, categories); - await service.queueTrendCalculation(hashtagIds); - - expect(trendingQueue.add).toHaveBeenCalledWith( - RedisQueues.hashTagQueue.processes.calculateTrends, - { hashtagIds }, - expect.objectContaining({ - delay: 5000, - removeOnComplete: true, - removeOnFail: false, - attempts: 3, - }), + expect(redisTrendingService.trackPostHashtags).toHaveBeenCalledTimes(2); + expect(redisTrendingService.trackPostHashtags).toHaveBeenCalledWith( + 1, + hashtagIds, + TrendCategory.GENERAL, + undefined, + ); + expect(redisTrendingService.trackPostHashtags).toHaveBeenCalledWith( + 1, + hashtagIds, + TrendCategory.NEWS, + undefined, ); }); - it('should throw error when queue fails', async () => { - trendingQueue.add.mockRejectedValue(new Error('Queue error')); + it('should filter out PERSONALIZED category', async () => { + const hashtagIds = [1, 2]; + const categories = [TrendCategory.GENERAL, TrendCategory.PERSONALIZED]; - await expect(service.queueTrendCalculation([1, 2])).rejects.toThrow('Queue error'); - }); - }); + await service.trackPostHashtags(1, hashtagIds, categories); - describe('calculateTrend', () => { - const hashtagId = 1; + expect(redisTrendingService.trackPostHashtags).toHaveBeenCalledTimes(1); + expect(redisTrendingService.trackPostHashtags).toHaveBeenCalledWith( + 1, + hashtagIds, + TrendCategory.GENERAL, + undefined, + ); + }); - it('should return 0 for personalized category without userId', async () => { - const result = await service.calculateTrend(hashtagId, TrendCategory.PERSONALIZED, null); + it('should throw error when tracking fails', async () => { + redisTrendingService.trackPostHashtags.mockRejectedValue(new Error('Redis error')); - expect(result).toBe(0); + await expect(service.trackPostHashtags(1, [1], [TrendCategory.GENERAL])).rejects.toThrow( + 'Redis error', + ); }); + }); - it('should calculate trend score correctly', async () => { - prisma.post.findMany - .mockResolvedValueOnce([{ id: 1 }, { id: 2 }]) // 1h posts - .mockResolvedValueOnce([{ id: 1 }, { id: 2 }, { id: 3 }]) // 24h posts - .mockResolvedValueOnce([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]); // 7d posts + describe('syncTrendToDB', () => { + const hashtagId = 1; - prisma.hashtagTrend.upsert.mockResolvedValue({}); + it('should sync trend to database', async () => { + redisTrendingService.getHashtagCounts.mockResolvedValue({ + count1h: 2, + count24h: 3, + count7d: 4, + }); + prisma.hashtagTrend.create.mockResolvedValue({}); - const result = await service.calculateTrend(hashtagId, TrendCategory.GENERAL, null); + const result = await service.syncTrendToDB(hashtagId, TrendCategory.GENERAL); // Score = 2 * 10 + 3 * 2 + 4 * 0.5 = 20 + 6 + 2 = 28 expect(result).toBe(28); - expect(prisma.hashtagTrend.upsert).toHaveBeenCalled(); + expect(prisma.hashtagTrend.create).toHaveBeenCalled(); }); - it('should filter by user interests when userId provided', async () => { - usersService.getUserInterests.mockResolvedValue([{ slug: 'tech' }]); - prisma.post.findMany - .mockResolvedValueOnce([{ id: 1, Interest: { slug: 'tech' } }]) - .mockResolvedValueOnce([{ id: 1, Interest: { slug: 'tech' } }]) - .mockResolvedValueOnce([{ id: 1, Interest: { slug: 'tech' } }, { id: 2, Interest: { slug: 'sports' } }]); + it('should update existing trend on duplicate', async () => { + redisTrendingService.getHashtagCounts.mockResolvedValue({ + count1h: 1, + count24h: 2, + count7d: 3, + }); + prisma.hashtagTrend.create.mockRejectedValue({ code: 'P2002' }); + prisma.hashtagTrend.update.mockResolvedValue({}); - prisma.hashtagTrend.upsert.mockResolvedValue({}); + const result = await service.syncTrendToDB(hashtagId, TrendCategory.GENERAL); - const result = await service.calculateTrend(hashtagId, TrendCategory.PERSONALIZED, 1); - - expect(usersService.getUserInterests).toHaveBeenCalledWith(1); - expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBe(1 * 10 + 2 * 2 + 3 * 0.5); // 10 + 4 + 1.5 = 15.5 + expect(prisma.hashtagTrend.update).toHaveBeenCalled(); }); - it('should throw error on calculation failure', async () => { - prisma.post.findMany.mockRejectedValue(new Error('Database error')); + it('should throw error on non-duplicate failure', async () => { + redisTrendingService.getHashtagCounts.mockResolvedValue({ + count1h: 1, + count24h: 2, + count7d: 3, + }); + prisma.hashtagTrend.create.mockRejectedValue(new Error('Database error')); - await expect(service.calculateTrend(hashtagId, TrendCategory.GENERAL, null)).rejects.toThrow('Database error'); + await expect(service.syncTrendToDB(hashtagId, TrendCategory.GENERAL)).rejects.toThrow( + 'Database error', + ); }); }); @@ -159,76 +184,135 @@ describe('HashtagTrendService', () => { const result = await service.getTrending(10, TrendCategory.GENERAL, userId); expect(result).toEqual(cachedData); - expect(prisma.hashtagTrend.findMany).not.toHaveBeenCalled(); + expect(redisTrendingService.getTrending).not.toHaveBeenCalled(); }); - it('should fetch from database when cache is empty', async () => { + it('should fetch from Redis when cache is empty', async () => { redisService.getJSON.mockResolvedValue(null); - - const mockTrends = [ - { - hashtag: { tag: 'trending' }, - post_count_7d: 50, - }, - ]; - prisma.hashtagTrend.findMany.mockResolvedValue(mockTrends); + redisTrendingService.getTrending.mockResolvedValue([ + { hashtagId: 1, score: 100 }, + ]); + redisTrendingService.batchGetHashtagMetadata.mockResolvedValue( + new Map([[1, { tag: 'trending', hashtagId: 1 }]]), + ); + redisTrendingService.batchGetHashtagCounts.mockResolvedValue( + new Map([[1, { count1h: 5, count24h: 20, count7d: 50 }]]), + ); const result = await service.getTrending(10, TrendCategory.GENERAL, userId); - expect(result).toEqual([{ tag: '#trending', totalPosts: 50 }]); + expect(result).toEqual([{ tag: '#trending', totalPosts: 50, score: 100 }]); expect(redisService.setJSON).toHaveBeenCalled(); }); - it('should trigger recalculation when no trends found', async () => { + it('should fallback to DB when Redis returns empty', async () => { redisService.getJSON.mockResolvedValue(null); + redisTrendingService.getTrending.mockResolvedValue([]); prisma.hashtagTrend.findMany.mockResolvedValue([]); - prisma.hashtag.findMany.mockResolvedValue([]); const result = await service.getTrending(10, TrendCategory.GENERAL, userId); expect(result).toEqual([]); - // Recalculation is triggered in background }); - it('should handle cached as empty array', async () => { - redisService.getJSON.mockResolvedValue([]); - prisma.hashtagTrend.findMany.mockResolvedValue([]); - prisma.hashtag.findMany.mockResolvedValue([]); + it('should use personalized service for PERSONALIZED category', async () => { + const personalizedTrends = [{ tag: '#personal', totalPosts: 5 }]; + personalizedTrendsService.getPersonalizedTrending.mockResolvedValue(personalizedTrends); + personalizedTrendsService.trackUserActivity.mockResolvedValue(undefined); - const result = await service.getTrending(10, TrendCategory.GENERAL, userId); + const result = await service.getTrending(10, TrendCategory.PERSONALIZED, userId); - expect(result).toEqual([]); + expect(result).toEqual(personalizedTrends); + expect(personalizedTrendsService.getPersonalizedTrending).toHaveBeenCalledWith(userId, 10); }); - }); - describe('recalculateTrends', () => { - it('should recalculate trends for active hashtags', async () => { - const activeHashtags = [{ id: 1 }, { id: 2 }]; - prisma.hashtag.findMany.mockResolvedValue(activeHashtags); + it('should fallback to GENERAL when PERSONALIZED requested without userId', async () => { + redisService.getJSON.mockResolvedValue([{ tag: '#general', totalPosts: 10 }]); + + const result = await service.getTrending(10, TrendCategory.PERSONALIZED, undefined); - const result = await service.recalculateTrends(TrendCategory.GENERAL); + expect(result).toEqual([{ tag: '#general', totalPosts: 10 }]); + expect(personalizedTrendsService.getPersonalizedTrending).not.toHaveBeenCalled(); + }); + }); + + describe('syncTrendingToDB', () => { + it('should sync trending hashtags from Redis to DB', async () => { + redisTrendingService.getTrending.mockResolvedValue([ + { hashtagId: 1, score: 100 }, + { hashtagId: 2, score: 50 }, + ]); + redisTrendingService.getHashtagCounts.mockResolvedValue({ + count1h: 1, + count24h: 2, + count7d: 3, + }); + prisma.hashtagTrend.create.mockResolvedValue({}); + + const result = await service.syncTrendingToDB(TrendCategory.GENERAL, 10); expect(result).toBe(2); - expect(trendingQueue.add).toHaveBeenCalled(); expect(redisService.delPattern).toHaveBeenCalled(); }); - it('should return 0 when no active hashtags', async () => { - prisma.hashtag.findMany.mockResolvedValue([]); + it('should return 0 when no trending hashtags in Redis', async () => { + redisTrendingService.getTrending.mockResolvedValue([]); - const result = await service.recalculateTrends(TrendCategory.GENERAL); + const result = await service.syncTrendingToDB(TrendCategory.GENERAL, 10); expect(result).toBe(0); - expect(trendingQueue.add).not.toHaveBeenCalled(); }); - it('should filter by user interests for personalized category', async () => { - usersService.getUserInterests.mockResolvedValue([{ slug: 'tech' }]); - prisma.hashtag.findMany.mockResolvedValue([{ id: 1 }]); + it('should return 0 for PERSONALIZED category', async () => { + const result = await service.syncTrendingToDB(TrendCategory.PERSONALIZED, 10); + + expect(result).toBe(0); + expect(redisTrendingService.getTrending).not.toHaveBeenCalled(); + }); + }); + + describe('handlePostCreated', () => { + it('should skip when no hashtag IDs provided', async () => { + await service.handlePostCreated({ + postId: 1, + userId: 1, + hashtagIds: [], + timestamp: Date.now(), + }); + + expect(redisTrendingService.trackPostHashtags).not.toHaveBeenCalled(); + }); + + it('should track hashtags for post', async () => { + const event = { + postId: 1, + userId: 1, + hashtagIds: [1, 2], + timestamp: Date.now(), + }; + + await service.handlePostCreated(event); + + expect(redisTrendingService.trackPostHashtags).toHaveBeenCalledWith( + 1, + [1, 2], + TrendCategory.GENERAL, + event.timestamp, + ); + }); + + it('should add category based on interest slug', async () => { + const event = { + postId: 1, + userId: 1, + hashtagIds: [1], + interestSlug: 'sports', + timestamp: Date.now(), + }; - await service.recalculateTrends(TrendCategory.PERSONALIZED, 1); + await service.handlePostCreated(event); - expect(usersService.getUserInterests).toHaveBeenCalledWith(1); + expect(redisTrendingService.trackPostHashtags).toHaveBeenCalledTimes(2); }); }); }); From 3908df90d1983ce2d0ca69123ae94444717eed0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9COmarNabil005=E2=80=9D?= Date: Mon, 15 Dec 2025 20:09:49 +0200 Subject: [PATCH 2/2] removed mistake --- src/post/services/like.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/post/services/like.service.ts b/src/post/services/like.service.ts index 95d9391..63e254d 100644 --- a/src/post/services/like.service.ts +++ b/src/post/services/like.service.ts @@ -124,7 +124,7 @@ export class LikeService { limit, page, }); - const orderMap = new Map(likes.map((m, index) => [m.post_id, index])); `` + const orderMap = new Map(likes.map((m, index) => [m.post_id, index])); likedPosts.sort((a, b) => orderMap.get(a.postId)! - orderMap.get(b.postId)!); return { data: likedPosts, metadata };