From 7470ae5261e124cc3e6f53405728a5beca380609 Mon Sep 17 00:00:00 2001 From: YaraSenousy <102993483+YaraSenousy@users.noreply.github.com> Date: Sun, 3 May 2026 04:31:15 +0300 Subject: [PATCH 1/3] fix: update token payload to reflect current user plan and role on refresh --- src/authentication/authentication.service.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/authentication/authentication.service.ts b/src/authentication/authentication.service.ts index a975a649..0778fb39 100644 --- a/src/authentication/authentication.service.ts +++ b/src/authentication/authentication.service.ts @@ -296,7 +296,13 @@ export class AuthenticationService { // Rotate: revoke old token, issue new pair await this.authRepository.revokeRefreshToken(oldTokenHash); - const payload: JwtPayload = { sub: userId, email, role, plan }; + // Always read the current plan/role from DB so subscription changes are + // reflected immediately on the next token refresh, without re-login. + const currentUser = await this.userService.findById(userId); + const currentPlan = (currentUser?.plan ?? plan) as UserPlan; + const currentRole = (currentUser?.role ?? role) as UserRole; + + const payload: JwtPayload = { sub: userId, email, role: currentRole, plan: currentPlan }; const newAccessToken = this.jwtService.sign(payload, { expiresIn: ACCESS_TOKEN_EXPIRY, From ca0483464f5f26aff38175a076011693a352adf4 Mon Sep 17 00:00:00 2001 From: YaraSenousy <102993483+YaraSenousy@users.noreply.github.com> Date: Sun, 3 May 2026 04:45:49 +0300 Subject: [PATCH 2/3] Add unit tests for subscription and track services, controllers, and processors - Implement tests for StripeService covering customer creation, retrieval, payment method attachment, subscription management, and webhook event handling. - Create tests for WebhookController to validate webhook signature handling and event processing. - Add tests for FansJobProcessor to ensure scheduled jobs are registered and processed correctly. - Introduce tests for ReleaseJobProcessor to validate track release functionality. - Enhance FansService tests to cover refreshing top fans and snapshotting pending first fans. - Update TrackController tests to include comment handling, track uploads, and metadata updates. - Extend TrackService tests to validate comment settings updates, user track retrieval, popularity score calculation, user interaction tags, and top tracks by tag IDs. --- package.json | 13 +- src/activity/activity.service.spec.ts | 212 ++++++++- src/admin/admin.service.spec.ts | 128 ++++++ src/common/storage_service.spec.ts | 140 ++++++ src/common/utilities/captcha.util.spec.ts | 108 ++++- src/discovery/discovery.service.spec.ts | 423 ++++++++++++++++++ .../trending-music.processor.spec.ts | 101 +++++ src/mail/mail.service.spec.ts | 39 ++ .../notifications.service.spec.ts | 313 +++++++++++++ src/search/search.spec.ts | 378 ++++++++++++++++ src/settings/settings.service.spec.ts | 115 +++++ src/subscription/stripe.service.spec.ts | 216 +++++++++ src/subscription/webhook.controller.spec.ts | 92 ++++ .../listeners/fans-job.processor.spec.ts | 135 ++++++ .../listeners/release-job.processor.spec.ts | 40 ++ src/track/services/fans.service.spec.ts | 77 ++++ src/track/tests/track.mock.ts | 5 + src/track/track.controller.spec.ts | 313 ++++++++++++- src/track/track.service.spec.ts | 136 ++++++ 19 files changed, 2963 insertions(+), 21 deletions(-) create mode 100644 src/common/storage_service.spec.ts create mode 100644 src/discovery/listeners/trending-music.processor.spec.ts create mode 100644 src/search/search.spec.ts create mode 100644 src/subscription/stripe.service.spec.ts create mode 100644 src/subscription/webhook.controller.spec.ts create mode 100644 src/track/listeners/fans-job.processor.spec.ts create mode 100644 src/track/listeners/release-job.processor.spec.ts diff --git a/package.json b/package.json index 9751c338..69db7331 100644 --- a/package.json +++ b/package.json @@ -193,7 +193,18 @@ "\\.dto\\.ts$", "\\.entity\\.ts$", "\\.type\\.ts$", - "\\.filter\\.ts$" + "\\.filter\\.ts$", + "\\.interface\\.ts$", + "\\.repository\\.ts$", + "\\.d\\.ts$", + "\\.mock\\.ts$", + "\\.decorator\\.ts$", + "\\.swagger\\.ts$", + "\\.constants\\.ts$", + "ormconfig\\.ts$", + "src/database/", + "src/search/", + "src/config/database.config.ts" ], "coverageThreshold": { "global": { diff --git a/src/activity/activity.service.spec.ts b/src/activity/activity.service.spec.ts index 9bb0db2f..a163d0f1 100644 --- a/src/activity/activity.service.spec.ts +++ b/src/activity/activity.service.spec.ts @@ -1,10 +1,29 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ActivityService } from './activity.service'; -import { Activity } from './entities/activity.entity'; +import { Activity, ActivityType } from './entities/activity.entity'; + +// ─── Chainable query builder mock ──────────────────────────────────────────── + +const makeQbMock = () => ({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getMany: jest.fn(), + getRawMany: jest.fn(), +}); + +// ─── Suite ──────────────────────────────────────────────────────────────────── describe('ActivityService', () => { let service: ActivityService; + let qbMock: ReturnType; const mockActivityRepository = { create: jest.fn(), @@ -12,11 +31,16 @@ describe('ActivityService', () => { find: jest.fn(), findOne: jest.fn(), count: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), }; beforeEach(async () => { jest.clearAllMocks(); + qbMock = makeQbMock(); + mockActivityRepository.createQueryBuilder.mockReturnValue(qbMock); + const module: TestingModule = await Test.createTestingModule({ providers: [ ActivityService, @@ -33,4 +57,190 @@ describe('ActivityService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + // ─── createActivity ─────────────────────────────────────────────────────────── + + describe('createActivity', () => { + it('should call repository.create with correct fields and return the saved activity', async () => { + const activityData = { + activityId: 'act-uuid', + activityType: ActivityType.TRACK_LIKE, + targetId: 'track-uuid', + userId: 'user-uuid', + targetUserId: 'target-user-uuid', + }; + mockActivityRepository.create.mockReturnValue(activityData); + mockActivityRepository.save.mockResolvedValue(activityData); + + const result = await service.createActivity( + ActivityType.TRACK_LIKE, + 'track-uuid', + 'user-uuid', + 'target-user-uuid' + ); + + expect(mockActivityRepository.create).toHaveBeenCalledWith({ + activityType: ActivityType.TRACK_LIKE, + targetId: 'track-uuid', + userId: 'user-uuid', + targetUserId: 'target-user-uuid', + }); + expect(mockActivityRepository.save).toHaveBeenCalledWith(activityData); + expect(result).toBe(activityData); + }); + + it('should accept null for targetUserId', async () => { + const activityData = { + activityType: ActivityType.TRACK_LIKE, + targetId: 'track-uuid', + userId: 'user-uuid', + targetUserId: null, + }; + mockActivityRepository.create.mockReturnValue(activityData); + mockActivityRepository.save.mockResolvedValue(activityData); + + await service.createActivity(ActivityType.TRACK_LIKE, 'track-uuid', 'user-uuid', null); + + expect(mockActivityRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ targetUserId: null }) + ); + }); + }); + + // ─── getActivitiesByUserIds ──────────────────────────────────────────────────── + + describe('getActivitiesByUserIds', () => { + const userIds = ['user-1', 'user-2']; + + it('should query with 4 activity types when includeReposts is true', async () => { + qbMock.getMany.mockResolvedValue([]); + + await service.getActivitiesByUserIds(userIds, 1, 10, true); + + const andWhereCall = qbMock.andWhere.mock.calls.find((call) => + call[0].includes('activityType') + ); + expect(andWhereCall).toBeDefined(); + const types = andWhereCall![1].types as ActivityType[]; + expect(types).toHaveLength(4); + expect(types).toContain(ActivityType.TRACK_POSTED); + expect(types).toContain(ActivityType.TRACK_REPOST); + expect(types).toContain(ActivityType.PLAYLIST_POSTED); + expect(types).toContain(ActivityType.PLAYLIST_REPOST); + }); + + it('should query with 2 activity types (POSTED only) when includeReposts is false', async () => { + qbMock.getMany.mockResolvedValue([]); + + await service.getActivitiesByUserIds(userIds, 1, 10, false); + + const andWhereCall = qbMock.andWhere.mock.calls.find((call) => + call[0].includes('activityType') + ); + expect(andWhereCall).toBeDefined(); + const types = andWhereCall![1].types as ActivityType[]; + expect(types).toHaveLength(2); + expect(types).toContain(ActivityType.TRACK_POSTED); + expect(types).toContain(ActivityType.PLAYLIST_POSTED); + expect(types).not.toContain(ActivityType.TRACK_REPOST); + expect(types).not.toContain(ActivityType.PLAYLIST_REPOST); + }); + + it('should compute correct offset: page=2, limit=10 → skip 10', async () => { + qbMock.getMany.mockResolvedValue([]); + + await service.getActivitiesByUserIds(userIds, 2, 10, false); + + expect(qbMock.skip).toHaveBeenCalledWith(10); + expect(qbMock.take).toHaveBeenCalledWith(10); + }); + + it('should compute correct offset: page=3, limit=5 → skip 10', async () => { + qbMock.getMany.mockResolvedValue([]); + + await service.getActivitiesByUserIds(userIds, 3, 5, false); + + expect(qbMock.skip).toHaveBeenCalledWith(10); + expect(qbMock.take).toHaveBeenCalledWith(5); + }); + + it('should return activities from getMany', async () => { + const mockActivities = [{ activityId: 'act-1' }, { activityId: 'act-2' }] as Activity[]; + qbMock.getMany.mockResolvedValue(mockActivities); + + const result = await service.getActivitiesByUserIds(userIds, 1, 10, false); + + expect(result).toBe(mockActivities); + }); + }); + + // ─── deleteActivity ─────────────────────────────────────────────────────────── + + describe('deleteActivity', () => { + it('should call repository.delete with activityType, targetId and userId', async () => { + mockActivityRepository.delete.mockResolvedValue({ affected: 1 }); + + await service.deleteActivity(ActivityType.TRACK_LIKE, 'track-uuid', 'user-uuid'); + + expect(mockActivityRepository.delete).toHaveBeenCalledWith({ + activityType: ActivityType.TRACK_LIKE, + targetId: 'track-uuid', + userId: 'user-uuid', + }); + }); + }); + + // ─── getLikedByUsers ───────────────────────────────────────────────────────── + + describe('getLikedByUsers', () => { + it('should call getRawMany and return results', async () => { + const mockUsers = [{ userId: 'user-1', username: 'alice' }]; + qbMock.getRawMany.mockResolvedValue(mockUsers); + + const result = await service.getLikedByUsers('current-user-uuid'); + + expect(qbMock.getRawMany).toHaveBeenCalledTimes(1); + expect(result).toBe(mockUsers); + }); + + it('should use default limit of 8 when limit is not provided', async () => { + qbMock.getRawMany.mockResolvedValue([]); + + await service.getLikedByUsers('current-user-uuid'); + + expect(qbMock.limit).toHaveBeenCalledWith(8); + }); + + it('should use provided limit when specified', async () => { + qbMock.getRawMany.mockResolvedValue([]); + + await service.getLikedByUsers('current-user-uuid', 5); + + expect(qbMock.limit).toHaveBeenCalledWith(5); + }); + + it('should filter by TRACK_LIKE and PLAYLIST_LIKE activity types', async () => { + qbMock.getRawMany.mockResolvedValue([]); + + await service.getLikedByUsers('current-user-uuid'); + + const whereCall = qbMock.where.mock.calls.find((call) => call[0].includes('activityType')); + expect(whereCall).toBeDefined(); + const types = whereCall![1].types as ActivityType[]; + expect(types).toContain(ActivityType.TRACK_LIKE); + expect(types).toContain(ActivityType.PLAYLIST_LIKE); + }); + + it('should filter by currentUserId', async () => { + qbMock.getRawMany.mockResolvedValue([]); + + await service.getLikedByUsers('current-user-uuid'); + + const andWhereCall = qbMock.andWhere.mock.calls.find( + (call) => call[1]?.currentUserId !== undefined + ); + expect(andWhereCall).toBeDefined(); + expect(andWhereCall![1].currentUserId).toBe('current-user-uuid'); + }); + }); }); diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts index 697fbab0..f94c4cae 100644 --- a/src/admin/admin.service.spec.ts +++ b/src/admin/admin.service.spec.ts @@ -10,6 +10,16 @@ import { UserRepository } from '../user/user.repository'; import { Report } from './entities/report.entity'; import { ReportStatus, ReportType, ReportReason } from './report-enums'; import { CreateReportDto } from './dto/createReport.dto'; +import { TrackVisibility } from '../track/enums/track-visibility.enum'; +import { TrackStatus } from '../track/enums/track-status.enum'; + +import { deleteDocument, updateDocument } from '../search/indexing'; + +jest.mock('../search/indexing', () => ({ + deleteDocument: jest.fn().mockResolvedValue(undefined), + updateDocument: jest.fn().mockResolvedValue(undefined), + mapTrack: jest.fn().mockReturnValue({ id: 'track_test', type: 'track' }), +})); describe('AdminService', () => { let service: AdminService; @@ -33,11 +43,15 @@ describe('AdminService', () => { const mockTrackService = { getTrackById: jest.fn(), + deleteTrack: jest.fn(), }; const mockTrackRepository = { findCommentById: jest.fn(), findAllTracksWithReportCount: jest.fn(), + findById: jest.fn(), + findByIdWithRelations: jest.fn(), + updateHiddenStatus: jest.fn(), }; const mockUserRepository = { @@ -405,4 +419,118 @@ describe('AdminService', () => { expect(result.meta).toEqual({ total: 1, page: 1, limit: 20, totalPages: 1 }); }); }); + + describe('toggleTrackVisibility', () => { + const trackId = 'track-123'; + + it('should throw NotFoundException when track is not found', async () => { + mockTrackRepository.findByIdWithRelations.mockResolvedValue(null); + + await expect(service.toggleTrackVisibility(trackId, true)).rejects.toThrow(NotFoundException); + }); + + it('should hide track: calls updateHiddenStatus and deleteDocument', async () => { + const track = { + trackId, + hidden: false, + visibility: TrackVisibility.PUBLIC, + trackStatus: TrackStatus.FINISHED, + userId: 'u1', + }; + mockTrackRepository.findByIdWithRelations.mockResolvedValue(track); + mockTrackRepository.updateHiddenStatus.mockResolvedValue(undefined); + + const result = await service.toggleTrackVisibility(trackId, true); + + expect(mockTrackRepository.updateHiddenStatus).toHaveBeenCalledWith(trackId, true); + expect(deleteDocument).toHaveBeenCalledWith(`track_${trackId}`); + expect(updateDocument).not.toHaveBeenCalled(); + expect(result.message).toContain('hidden'); + }); + + it('should call updateDocument for PUBLIC+FINISHED track when showing', async () => { + const track = { + trackId, + hidden: true, + visibility: TrackVisibility.PUBLIC, + trackStatus: TrackStatus.FINISHED, + userId: 'u1', + }; + mockTrackRepository.findByIdWithRelations.mockResolvedValue(track); + mockTrackRepository.updateHiddenStatus.mockResolvedValue(undefined); + + const result = await service.toggleTrackVisibility(trackId, false); + + expect(mockTrackRepository.updateHiddenStatus).toHaveBeenCalledWith(trackId, false); + expect(updateDocument).toHaveBeenCalled(); + expect(deleteDocument).not.toHaveBeenCalled(); + expect(result.message).toContain('shown'); + }); + + it('should call deleteDocument for PRIVATE track when showing', async () => { + const track = { + trackId, + hidden: true, + visibility: TrackVisibility.PRIVATE, + trackStatus: TrackStatus.FINISHED, + userId: 'u1', + }; + mockTrackRepository.findByIdWithRelations.mockResolvedValue(track); + mockTrackRepository.updateHiddenStatus.mockResolvedValue(undefined); + + await service.toggleTrackVisibility(trackId, false); + + expect(deleteDocument).toHaveBeenCalledWith(`track_${trackId}`); + expect(updateDocument).not.toHaveBeenCalled(); + }); + + it('should return success message with hidden/shown wording', async () => { + const track = { + trackId, + hidden: false, + visibility: TrackVisibility.PUBLIC, + trackStatus: TrackStatus.FINISHED, + userId: 'u1', + }; + mockTrackRepository.findByIdWithRelations.mockResolvedValue(track); + mockTrackRepository.updateHiddenStatus.mockResolvedValue(undefined); + + const hiddenResult = await service.toggleTrackVisibility(trackId, true); + const shownResult = await service.toggleTrackVisibility(trackId, false); + + expect(hiddenResult.message).toContain('hidden'); + expect(shownResult.message).toContain('shown'); + }); + }); + + describe('adminDeleteTrack', () => { + const trackId = 'track-123'; + + it('should throw NotFoundException when track is not found', async () => { + mockTrackRepository.findById.mockResolvedValue(null); + + await expect(service.adminDeleteTrack(trackId)).rejects.toThrow(NotFoundException); + }); + + it('should call trackService.deleteTrack with trackId and track.userId', async () => { + const track = { trackId, userId: 'user-abc' }; + mockTrackRepository.findById.mockResolvedValue(track); + mockTrackService.deleteTrack.mockResolvedValue({ status: 'success' }); + + await service.adminDeleteTrack(trackId); + + expect(mockTrackService.deleteTrack).toHaveBeenCalledWith(trackId, 'user-abc'); + }); + + it('should return the result of deleteTrack', async () => { + const track = { trackId, userId: 'user-abc' }; + mockTrackRepository.findById.mockResolvedValue(track); + const expected = { status: 'success', message: 'Track deleted' }; + mockTrackService.deleteTrack.mockResolvedValue(expected); + + const result = await service.adminDeleteTrack(trackId); + + expect(result).toEqual(expected); + }); + }); }); diff --git a/src/common/storage_service.spec.ts b/src/common/storage_service.spec.ts new file mode 100644 index 00000000..a60ff9f8 --- /dev/null +++ b/src/common/storage_service.spec.ts @@ -0,0 +1,140 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import * as fs from 'node:fs'; +import { StorageService } from './storage_service'; + +// ─── AWS SDK mock ───────────────────────────────────────────────────────────── + +const mockUploadPromise = jest.fn(); +const mockDeleteObjectPromise = jest.fn(); +const mockGetObjectPromise = jest.fn(); + +const mockS3Instance = { + upload: jest.fn().mockReturnValue({ promise: mockUploadPromise }), + deleteObject: jest.fn().mockReturnValue({ promise: mockDeleteObjectPromise }), + getObject: jest.fn().mockReturnValue({ promise: mockGetObjectPromise }), +}; + +jest.mock('aws-sdk', () => ({ + S3: jest.fn().mockImplementation(() => mockS3Instance), +})); + +// ─── fs mock ────────────────────────────────────────────────────────────────── + +jest.mock('node:fs', () => ({ writeFileSync: jest.fn() })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('StorageService', () => { + let service: StorageService; + + beforeEach(async () => { + process.env.AWS_S3_BUCKET = 'test-bucket'; + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [StorageService], + }).compile(); + + service = module.get(StorageService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // ─── uploadFile ───────────────────────────────────────────────────────────── + + describe('uploadFile', () => { + const mockFile = { + buffer: Buffer.from('file content'), + originalname: 'test-file.mp3', + mimetype: 'audio/mpeg', + } as Express.Multer.File; + + it('should call s3.upload with correct params and return the result', async () => { + const mockResult = { Location: 'https://s3.amazonaws.com/test-bucket/test-file.mp3' }; + mockUploadPromise.mockResolvedValue(mockResult); + + const result = await service.uploadFile(mockFile); + + expect(mockS3Instance.upload).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: mockFile.originalname, + Body: mockFile.buffer, + ContentType: mockFile.mimetype, + ContentDisposition: 'inline', + }); + expect(result).toBe(mockResult); + }); + + it('should propagate errors from s3.upload', async () => { + const error = new Error('S3 upload failed'); + mockUploadPromise.mockRejectedValue(error); + + await expect(service.uploadFile(mockFile)).rejects.toThrow('S3 upload failed'); + }); + }); + + // ─── deleteFile ───────────────────────────────────────────────────────────── + + describe('deleteFile', () => { + it('should extract key from URL and call s3.deleteObject with correct params', async () => { + mockDeleteObjectPromise.mockResolvedValue({}); + const url = 'https://test-bucket.s3.amazonaws.com/audio/track.mp3'; + + await service.deleteFile(url); + + expect(mockS3Instance.deleteObject).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'audio/track.mp3', + }); + }); + + it('should NOT throw when s3.deleteObject rejects (non-fatal)', async () => { + mockDeleteObjectPromise.mockRejectedValue(new Error('Access denied')); + const url = 'https://test-bucket.s3.amazonaws.com/audio/track.mp3'; + + await expect(service.deleteFile(url)).resolves.not.toThrow(); + }); + + it('should handle URL-encoded keys using decodeURIComponent', async () => { + mockDeleteObjectPromise.mockResolvedValue({}); + const url = 'https://test-bucket.s3.amazonaws.com/audio/my%20track%20file.mp3'; + + await service.deleteFile(url); + + expect(mockS3Instance.deleteObject).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'audio/my track file.mp3', + }); + }); + }); + + // ─── downloadToTemp ───────────────────────────────────────────────────────── + + describe('downloadToTemp', () => { + it('should call s3.getObject with correct params then write the body to tempPath', async () => { + const body = Buffer.from('audio data'); + mockGetObjectPromise.mockResolvedValue({ Body: body }); + const url = 'https://test-bucket.s3.amazonaws.com/audio/track.mp3'; + const tempPath = 'C:\\temp\\track.mp3'; + + await service.downloadToTemp(url, tempPath); + + expect(mockS3Instance.getObject).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'audio/track.mp3', + }); + expect(fs.writeFileSync as jest.Mock).toHaveBeenCalledWith(tempPath, body); + }); + + it('should propagate errors from s3.getObject (fatal)', async () => { + mockGetObjectPromise.mockRejectedValue(new Error('NoSuchKey')); + const url = 'https://test-bucket.s3.amazonaws.com/audio/missing.mp3'; + + await expect(service.downloadToTemp(url, 'C:\\temp\\missing.mp3')).rejects.toThrow( + 'NoSuchKey' + ); + }); + }); +}); diff --git a/src/common/utilities/captcha.util.spec.ts b/src/common/utilities/captcha.util.spec.ts index cc2e9a27..3dbf173d 100644 --- a/src/common/utilities/captcha.util.spec.ts +++ b/src/common/utilities/captcha.util.spec.ts @@ -1,9 +1,22 @@ import axios from 'axios'; -import { verifyCaptcha } from './captcha.util'; + +import { getApps, initializeApp, cert } from 'firebase-admin/app'; +import { getAppCheck } from 'firebase-admin/app-check'; +import { verifyCaptcha, getFirebaseApp, verifyAppCheckToken } from './captcha.util'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; +jest.mock('firebase-admin/app', () => ({ + getApps: jest.fn(), + initializeApp: jest.fn(), + cert: jest.fn(), +})); + +jest.mock('firebase-admin/app-check', () => ({ + getAppCheck: jest.fn(), +})); + describe('verifyCaptcha', () => { const token = 'test-captcha-token'; @@ -48,3 +61,96 @@ describe('verifyCaptcha', () => { expect(result).toBe(false); }); }); + +describe('getFirebaseApp', () => { + const mockApp = { name: 'mock-firebase-app' }; + + beforeEach(() => { + process.env.FIREBASE_PROJECT_ID = 'test-project'; + process.env.FIREBASE_CLIENT_EMAIL = 'test@test.com'; + process.env.FIREBASE_PRIVATE_KEY = 'test-key'; + jest.clearAllMocks(); + }); + + afterEach(() => { + delete process.env.FIREBASE_PROJECT_ID; + delete process.env.FIREBASE_CLIENT_EMAIL; + delete process.env.FIREBASE_PRIVATE_KEY; + }); + + it('should return existing app when getApps().length > 0', () => { + (getApps as jest.Mock).mockReturnValue([mockApp]); + + const result = getFirebaseApp(); + + expect(result).toBe(mockApp); + expect(initializeApp).not.toHaveBeenCalled(); + }); + + it('should throw Error when env vars are missing', () => { + (getApps as jest.Mock).mockReturnValue([]); + delete process.env.FIREBASE_PROJECT_ID; + + expect(() => getFirebaseApp()).toThrow( + 'Missing Firebase env vars: FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY' + ); + }); + + it('should call initializeApp with cert when env vars are present', () => { + (getApps as jest.Mock).mockReturnValue([]); + (cert as jest.Mock).mockReturnValue({ credential: 'mock-cert' }); + (initializeApp as jest.Mock).mockReturnValue(mockApp); + + getFirebaseApp(); + + expect(cert).toHaveBeenCalled(); + expect(initializeApp).toHaveBeenCalled(); + }); +}); + +describe('verifyAppCheckToken', () => { + const mockApp = { name: 'mock-firebase-app' }; + + beforeEach(() => { + process.env.FIREBASE_DEBUG_TOKEN = 'debug-token'; + process.env.FIREBASE_PROJECT_ID = 'test-project'; + process.env.FIREBASE_CLIENT_EMAIL = 'test@test.com'; + process.env.FIREBASE_PRIVATE_KEY = 'test-key'; + jest.clearAllMocks(); + (getApps as jest.Mock).mockReturnValue([mockApp]); + }); + + afterEach(() => { + delete process.env.FIREBASE_DEBUG_TOKEN; + delete process.env.FIREBASE_PROJECT_ID; + delete process.env.FIREBASE_CLIENT_EMAIL; + delete process.env.FIREBASE_PRIVATE_KEY; + }); + + it('should return true when token matches FIREBASE_DEBUG_TOKEN', async () => { + const result = await verifyAppCheckToken('debug-token'); + + expect(result).toBe(true); + expect(getAppCheck).not.toHaveBeenCalled(); + }); + + it('should return true when getAppCheck().verifyToken resolves successfully', async () => { + (getAppCheck as jest.Mock).mockReturnValue({ + verifyToken: jest.fn().mockResolvedValue({}), + }); + + const result = await verifyAppCheckToken('valid-token'); + + expect(result).toBe(true); + }); + + it('should return false when getAppCheck().verifyToken throws', async () => { + (getAppCheck as jest.Mock).mockReturnValue({ + verifyToken: jest.fn().mockRejectedValue(new Error('invalid token')), + }); + + const result = await verifyAppCheckToken('bad-token'); + + expect(result).toBe(false); + }); +}); diff --git a/src/discovery/discovery.service.spec.ts b/src/discovery/discovery.service.spec.ts index 951d1dfd..15283ecc 100644 --- a/src/discovery/discovery.service.spec.ts +++ b/src/discovery/discovery.service.spec.ts @@ -146,6 +146,10 @@ const mockPlaylistRepository = () => ({ getUserPlaylistLikes: jest.fn(), getUserInteractedAlbumTags: jest.fn(), getTopAlbumsByTagIds: jest.fn(), + getPlaylistByUserAndTitle: jest.fn(), + createPlaylist: jest.fn(), + updatePlaylistGenre: jest.fn(), + clearPlaylistTracks: jest.fn(), }); const mockUserService = () => ({ @@ -315,6 +319,44 @@ describe('DiscoveryService', () => { expect((result as any)[0].target).toBeNull(); }); + + it('should set target to null for track activity when track is not in batch result', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + followersRepo.getFollowingIds.mockResolvedValue([MOCK_OTHER_USER_ID]); + const foundActivity = mockActivity({ + activityType: ActivityType.TRACK_POSTED, + targetId: MOCK_TRACK_ID, + }); + const missingActivity = mockActivity({ + activityId: 'act-missing', + activityType: ActivityType.TRACK_POSTED, + targetId: 'nonexistent-track-id', + }); + activitySvc.getActivitiesByUserIds.mockResolvedValue([foundActivity, missingActivity]); + trackRepo.findByIds.mockResolvedValue([mockTrack()]); + playlistRepo.findByIds.mockResolvedValue([]); + + const result = await service.getFeed(MOCK_USER_ID, '1.2.3.4', true); + + expect((result as any)[0].target).not.toBeNull(); + expect((result as any)[1].target).toBeNull(); + }); + + it('should handle playlist activities where playlist is not found', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + followersRepo.getFollowingIds.mockResolvedValue([MOCK_OTHER_USER_ID]); + const activity = mockActivity({ + activityType: ActivityType.PLAYLIST_POSTED, + targetId: 'missing-playlist-id', + }); + activitySvc.getActivitiesByUserIds.mockResolvedValue([activity]); + trackRepo.findByIds.mockResolvedValue([]); + playlistRepo.findByIds.mockResolvedValue([]); + + const result = await service.getFeed(MOCK_USER_ID, '1.2.3.4', true); + + expect((result as any)[0].target).toBeNull(); + }); }); // ─── getUserRecentActivities ─────────────────────────────────────────────── @@ -1025,6 +1067,33 @@ describe('DiscoveryService', () => { const call = trackSvc.getTopTracksByTagIds.mock.calls[0]; expect(call[0].length).toBeLessThanOrEqual(5); }); + + it('should return empty array when user has no track likes (getTopTracksByTagIds returns empty)', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + trackSvc.getUserInteractedTrackTags.mockResolvedValue([ + { genreId: 'tag-1', name: 'Electronic' }, + ]); + trackSvc.getTopTracksByTagIds.mockResolvedValue([]); + + const result = await service.getMoreOfWhatYouLike(MOCK_USER_ID, '1.2.3.4'); + + expect((result as any).status).toBe('success'); + expect((result as any).data).toEqual([]); + }); + + it('should handle case where tag lookup returns nothing', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + trackSvc.getUserInteractedTrackTags.mockResolvedValue([ + { genreId: 'tag-1', name: 'Jazz' }, + { genreId: 'tag-2', name: 'Blues' }, + ]); + trackSvc.getTopTracksByTagIds.mockResolvedValue([]); + + const result = await service.getMoreOfWhatYouLike(MOCK_USER_ID, '1.2.3.4'); + + expect(trackSvc.getTopTracksByTagIds).toHaveBeenCalled(); + expect((result as any).data).toHaveLength(0); + }); }); // ─── getSearchResults ───────────────────────────────────────────────────── @@ -1159,6 +1228,47 @@ describe('DiscoveryService', () => { expect((result as any).data[0].audioUrl).toBeNull(); }); + + it('should call search module with the provided query term', async () => { + const searchSpy = jest + .spyOn(searchModule, 'search') + .mockResolvedValue({ hits: [], total: 0 }); + + await service.getSearchResults( + MOCK_USER_ID, + 'midnight vibes', + undefined, + undefined, + undefined, + undefined, + undefined, + '1.2.3.4' + ); + + expect(searchSpy).toHaveBeenCalledWith(expect.objectContaining({ query: 'midnight vibes' })); + }); + + it('should return empty data array and total 0 when search returns no hits', async () => { + jest.spyOn(searchModule, 'search').mockResolvedValue({ hits: [], total: 0 }); + trackRepo.findByIds.mockResolvedValue([]); + userSvc.findByIds.mockResolvedValue([]); + playlistRepo.findPlaylistsByIds.mockResolvedValue([]); + playlistRepo.findAlbumsByIds.mockResolvedValue([]); + + const result = await service.getSearchResults( + MOCK_USER_ID, + 'nothing here', + 'track', + undefined, + undefined, + undefined, + undefined, + '1.2.3.4' + ); + + expect((result as any).total).toBe(0); + expect((result as any).data).toEqual([]); + }); }); // ─── searchAutocomplete ──────────────────────────────────────────────────── @@ -1353,6 +1463,21 @@ describe('DiscoveryService', () => { const [, titlesArg] = playlistRepo.getPlaylistByUserAndTitles.mock.calls[0]; expect(titlesArg).toHaveLength(5); }); + + it('should return empty array when no trending tracks found for any genre', async () => { + userSvc.findByUsername.mockResolvedValue(trendingUser); + trackSvc.getUserInteractedTrackTags.mockResolvedValue([ + { genreId: 'g1', name: 'Electronic' }, + { genreId: 'g2', name: 'Jazz' }, + ]); + playlistRepo.getPlaylistByUserAndTitles.mockResolvedValue([]); + followersRepo.isFollowing.mockResolvedValue(false); + + const result = await service.getTrendingMusicByGenre(MOCK_USER_ID); + + expect(result.status).toBe('success'); + expect((result as any).data).toEqual([]); + }); }); // ─── getTracksByTag ─────────────────────────────────────────────────────── @@ -1743,4 +1868,302 @@ describe('DiscoveryService', () => { ]); }); }); + + // ─── createTrendingMusicPlaylists ───────────────────────────────────────── + + describe('createTrendingMusicPlaylists', () => { + it('should throw Error when trending music user is not found', async () => { + userSvc.findByUsername.mockResolvedValue(null); + + await expect(service.createTrendingMusicPlaylists()).rejects.toThrow( + 'Trending Music user not found' + ); + }); + + it('should call genreRepository.findByNames and create a playlist for each genre', async () => { + const genre1 = { genreId: 'genre-1', name: 'Pop' }; + const genre2 = { genreId: 'genre-2', name: 'Rock' }; + const track = mockTrack({ trackId: 'track-1' }); + + userSvc.findByUsername.mockResolvedValue(mockUser()); + genreRepo.findByNames.mockResolvedValue([genre1, genre2]); + trackRepo.findTracksByGenreOrTags.mockResolvedValue([[track], 1]); + playlistRepo.getPlaylistByUserAndTitle.mockResolvedValue(null); + playlistRepo.createPlaylist.mockResolvedValue({ playlistId: 'playlist-new' }); + playlistRepo.updatePlaylistGenre.mockResolvedValue(undefined); + playlistRepo.addTrackToPlaylist.mockResolvedValue(undefined); + + await service.createTrendingMusicPlaylists(); + + expect(genreRepo.findByNames).toHaveBeenCalledTimes(1); + // 1 track × 2 genres + expect(playlistRepo.addTrackToPlaylist).toHaveBeenCalledTimes(2); + }); + }); + + // ─── createTrendingMusicPlaylistByGenre ─────────────────────────────────── + + describe('createTrendingMusicPlaylistByGenre', () => { + const genre = { genreId: 'genre-1', name: 'Pop' }; + + it('should return null when findTracksByGenreOrTags returns empty tracks', async () => { + trackRepo.findTracksByGenreOrTags.mockResolvedValue([[], 0]); + + const result = await service.createTrendingMusicPlaylistByGenre(genre as any, MOCK_USER_ID); + + expect(result).toBeNull(); + expect(playlistRepo.createPlaylist).not.toHaveBeenCalled(); + }); + + it('should create new playlist when getPlaylistByUserAndTitle returns null', async () => { + const track = mockTrack(); + trackRepo.findTracksByGenreOrTags.mockResolvedValue([[track], 1]); + playlistRepo.getPlaylistByUserAndTitle.mockResolvedValue(null); + playlistRepo.createPlaylist.mockResolvedValue({ playlistId: 'new-playlist-id' }); + playlistRepo.updatePlaylistGenre.mockResolvedValue(undefined); + playlistRepo.addTrackToPlaylist.mockResolvedValue(undefined); + + const result = await service.createTrendingMusicPlaylistByGenre(genre as any, MOCK_USER_ID); + + expect(playlistRepo.createPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ title: genre.name, isPublic: true }), + MOCK_USER_ID + ); + expect(playlistRepo.updatePlaylistGenre).toHaveBeenCalledWith('new-playlist-id', genre); + expect(result).toBe('new-playlist-id'); + }); + + it('should clear existing playlist tracks when getPlaylistByUserAndTitle returns an existing playlist', async () => { + const track = mockTrack(); + trackRepo.findTracksByGenreOrTags.mockResolvedValue([[track], 1]); + playlistRepo.getPlaylistByUserAndTitle.mockResolvedValue({ playlistId: 'existing-id' }); + playlistRepo.clearPlaylistTracks.mockResolvedValue(undefined); + playlistRepo.addTrackToPlaylist.mockResolvedValue(undefined); + + const result = await service.createTrendingMusicPlaylistByGenre(genre as any, MOCK_USER_ID); + + expect(playlistRepo.clearPlaylistTracks).toHaveBeenCalledWith('existing-id'); + expect(playlistRepo.createPlaylist).not.toHaveBeenCalled(); + expect(result).toBe('existing-id'); + }); + + it('should call addTrackToPlaylist for each track in topTracks', async () => { + const track1 = mockTrack({ trackId: 'track-1' }); + const track2 = mockTrack({ trackId: 'track-2' }); + trackRepo.findTracksByGenreOrTags.mockResolvedValue([[track1, track2], 2]); + playlistRepo.getPlaylistByUserAndTitle.mockResolvedValue(null); + playlistRepo.createPlaylist.mockResolvedValue({ playlistId: 'pl-1' }); + playlistRepo.updatePlaylistGenre.mockResolvedValue(undefined); + playlistRepo.addTrackToPlaylist.mockResolvedValue(undefined); + + await service.createTrendingMusicPlaylistByGenre(genre as any, MOCK_USER_ID); + + expect(playlistRepo.addTrackToPlaylist).toHaveBeenCalledTimes(2); + expect(playlistRepo.addTrackToPlaylist).toHaveBeenCalledWith('pl-1', 'track-1', 1); + expect(playlistRepo.addTrackToPlaylist).toHaveBeenCalledWith('pl-1', 'track-2', 2); + }); + }); + + // ─── getLikedByUsers ────────────────────────────────────────────────────── + + describe('getLikedByUsers', () => { + it('should return mapped users from activityService.getLikedByUsers', async () => { + const rawUsers = [ + { + user_user_id: 'uid-1', + user_username: 'user1', + user_display_name: 'User One', + user_avatar_url: 'https://example.com/avatar1.jpg', + }, + ]; + activitySvc.getLikedByUsers.mockResolvedValue(rawUsers); + + const result = await service.getLikedByUsers(MOCK_USER_ID); + + expect(result.status).toBe('success'); + expect(result.data).toHaveLength(1); + expect(result.data[0]).toEqual({ + userId: 'uid-1', + username: 'user1', + displayName: 'User One', + avatarUrl: 'https://example.com/avatar1.jpg', + }); + }); + + it('should return empty data array when activityService returns []', async () => { + activitySvc.getLikedByUsers.mockResolvedValue([]); + + const result = await service.getLikedByUsers(MOCK_USER_ID); + + expect(result).toEqual({ status: 'success', data: [] }); + }); + }); + + // ─── getUserLikedby ─────────────────────────────────────────────────────── + + describe('getUserLikedby', () => { + it('should throw NotFoundException when user is not found', async () => { + userSvc.findById.mockResolvedValue(null); + + await expect( + service.getUserLikedby(MOCK_USER_ID, MOCK_OTHER_USER_ID, '1.2.3.4') + ).rejects.toThrow(NotFoundException); + }); + + it('should throw ForbiddenException when user profile is private and caller is not the user', async () => { + userSvc.findById.mockResolvedValue(mockUser({ userId: MOCK_OTHER_USER_ID, isPublic: false })); + + await expect( + service.getUserLikedby(MOCK_OTHER_USER_ID, MOCK_USER_ID, '1.2.3.4') + ).rejects.toThrow(ForbiddenException); + }); + + it('should return response with playlistId and title for a public user', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + userSvc.findById.mockResolvedValue(mockUser({ isPublic: true })); + trackRepo.getUserTrackLikes.mockResolvedValue([[], 0]); + playlistRepo.getUserPlaylistLikes.mockResolvedValue([[], 0]); + + const result = await service.getUserLikedby(MOCK_USER_ID, MOCK_OTHER_USER_ID, '1.2.3.4'); + + expect(result.playlistId).toBe(MOCK_USER_ID); + expect(result.title).toBe(`Liked by ${mockUser().username}`); + }); + + it('should call getUserTrackLikes and getUserPlaylistLikes', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + userSvc.findById.mockResolvedValue(mockUser({ isPublic: true })); + trackRepo.getUserTrackLikes.mockResolvedValue([[], 0]); + playlistRepo.getUserPlaylistLikes.mockResolvedValue([[], 0]); + + await service.getUserLikedby(MOCK_USER_ID, MOCK_OTHER_USER_ID, '1.2.3.4'); + + expect(trackRepo.getUserTrackLikes).toHaveBeenCalledWith( + MOCK_USER_ID, + MOCK_OTHER_USER_ID, + 1, + 1000 + ); + expect(playlistRepo.getUserPlaylistLikes).toHaveBeenCalledWith(MOCK_USER_ID, 1, 1000); + }); + }); + + // ─── getMoreAlbumsOfWhatYouLike ─────────────────────────────────────────── + + describe('getMoreAlbumsOfWhatYouLike', () => { + it('should return empty data when no interacted album tags', async () => { + playlistRepo.getUserInteractedAlbumTags.mockResolvedValue([]); + + const result = await service.getMoreAlbumsOfWhatYouLike(MOCK_USER_ID, '1.2.3.4'); + + expect(result).toEqual({ status: 'success', data: [] }); + expect(playlistRepo.getTopAlbumsByTagIds).not.toHaveBeenCalled(); + }); + + it('should call getTopAlbumsByTagIds with top 5 tag ids when user has interactions', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + const tags = Array.from({ length: 7 }, (_, i) => ({ + genreId: `genre-${i}`, + name: `Genre ${i}`, + })); + playlistRepo.getUserInteractedAlbumTags.mockResolvedValue(tags); + playlistRepo.getTopAlbumsByTagIds.mockResolvedValue([]); + + await service.getMoreAlbumsOfWhatYouLike(MOCK_USER_ID, '1.2.3.4'); + + const [calledTagIds] = (playlistRepo.getTopAlbumsByTagIds as jest.Mock).mock.calls[0]; + expect(calledTagIds).toHaveLength(5); + }); + + it('should return formatted albums', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + playlistRepo.getUserInteractedAlbumTags.mockResolvedValue([ + { genreId: 'genre-1', name: 'Pop' }, + ]); + const mockAlbum = { + playlistId: 'album-1', + title: 'Album 1', + description: null, + coverImage: null, + user: mockUser(), + playlistTracks: [], + tracksCount: 0, + totalDurationSeconds: 0, + likesCount: 0, + repostsCount: 0, + createdAt: new Date(), + visibility: 'public', + isPublic: true, + hidden: false, + }; + playlistRepo.getTopAlbumsByTagIds.mockResolvedValue([mockAlbum]); + + const result = await service.getMoreAlbumsOfWhatYouLike(MOCK_USER_ID, '1.2.3.4'); + + expect(result.status).toBe('success'); + expect((result as any).data).toHaveLength(1); + expect((result as any).data[0].playlistId).toBe('album-1'); + }); + }); + + // ─── getDiscoverFeed ────────────────────────────────────────────────────── + + describe('getDiscoverFeed', () => { + it('should return cached result directly from redis when cache hit', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + redis.get.mockResolvedValue(JSON.stringify([])); + + const result = await service.getDiscoverFeed(MOCK_USER_ID, '1.2.3.4'); + + expect(redis.get).toHaveBeenCalledWith(`discover_feed_pool:${MOCK_USER_ID}`); + expect(result.status).toBe('success'); + expect((result as any).data).toEqual([]); + expect(playlistRepo.getUserInteractedAlbumTags).not.toHaveBeenCalled(); + }); + + it('should build track pool from scratch when cache is empty', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + redis.get.mockResolvedValue(null); + playlistRepo.getUserInteractedAlbumTags.mockResolvedValue([]); + trackRepo.findNewReleasedTracks.mockResolvedValue([[], 0]); + trackRepo.getUserRecentlyPlayed.mockResolvedValue([]); + followersRepo.getTopFollowedArtistUsernames.mockResolvedValue([]); + redis.set.mockResolvedValue('OK'); + + const result = await service.getDiscoverFeed(MOCK_USER_ID, '1.2.3.4'); + + expect(playlistRepo.getUserInteractedAlbumTags).toHaveBeenCalledWith(MOCK_USER_ID); + expect(trackRepo.getUserRecentlyPlayed).toHaveBeenCalled(); + expect(followersRepo.getTopFollowedArtistUsernames).toHaveBeenCalled(); + expect(redis.set).toHaveBeenCalled(); + expect(result.status).toBe('success'); + }); + + it('should return status success with data array', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + redis.get.mockResolvedValue(JSON.stringify([])); + + const result = await service.getDiscoverFeed(MOCK_USER_ID, '1.2.3.4'); + + expect(result).toHaveProperty('status', 'success'); + expect(result).toHaveProperty('data'); + expect(Array.isArray((result as any).data)).toBe(true); + }); + + it('should handle user with no recent plays (getUserRecentlyPlayed returns empty)', async () => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + redis.get.mockResolvedValue(null); + playlistRepo.getUserInteractedAlbumTags.mockResolvedValue([]); + trackRepo.findNewReleasedTracks.mockResolvedValue([[], 0]); + trackRepo.getUserRecentlyPlayed.mockResolvedValue([]); + followersRepo.getTopFollowedArtistUsernames.mockResolvedValue([]); + redis.set.mockResolvedValue('OK'); + + const result = await service.getDiscoverFeed(MOCK_USER_ID, '1.2.3.4'); + + expect(trackRepo.getUserRecentlyPlayed).toHaveBeenCalled(); + expect(result.status).toBe('success'); + expect(Array.isArray((result as any).data)).toBe(true); + }); + }); }); diff --git a/src/discovery/listeners/trending-music.processor.spec.ts b/src/discovery/listeners/trending-music.processor.spec.ts new file mode 100644 index 00000000..2ddb7505 --- /dev/null +++ b/src/discovery/listeners/trending-music.processor.spec.ts @@ -0,0 +1,101 @@ +import { TrendingMusicProcessor } from './trending-music.processor'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +const mockQueue = { + add: jest.fn(), +}; + +const mockConfigService = { + get: jest.fn(), +}; + +const mockDiscoveryService = { + createTrendingMusicPlaylists: jest.fn(), +}; + +const makeJob = (id = 'job-1') => ({ id, data: {} }) as any; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('TrendingMusicProcessor', () => { + let processor: TrendingMusicProcessor; + + beforeEach(() => { + jest.clearAllMocks(); + // Instantiate directly — WorkerHost requires BullMQ internals that + // cannot be satisfied via TestingModule without a real Redis connection. + processor = new TrendingMusicProcessor( + mockQueue as any, + mockConfigService as any, + mockDiscoveryService as any + ); + }); + + // ─── onModuleInit ────────────────────────────────────────────────────────── + + describe('onModuleInit', () => { + it('should register refresh-trending-music job with default cron when config returns undefined', async () => { + mockConfigService.get.mockReturnValue('0 3 * * *'); + + await processor.onModuleInit(); + + expect(mockQueue.add).toHaveBeenCalledWith( + 'refresh-trending-music', + {}, + expect.objectContaining({ + repeat: { pattern: '0 3 * * *' }, + }) + ); + }); + + it('should use custom cron value when TRENDING_MUSIC_CRON is set', async () => { + mockConfigService.get.mockReturnValue('0 6 * * *'); + + await processor.onModuleInit(); + + expect(mockQueue.add).toHaveBeenCalledWith( + 'refresh-trending-music', + {}, + expect.objectContaining({ + repeat: { pattern: '0 6 * * *' }, + }) + ); + }); + + it('should pass correct jobId, removeOnComplete and removeOnFail options', async () => { + mockConfigService.get.mockReturnValue('0 3 * * *'); + + await processor.onModuleInit(); + + expect(mockQueue.add).toHaveBeenCalledWith( + 'refresh-trending-music', + {}, + expect.objectContaining({ + jobId: 'refresh-trending-music-job', + removeOnComplete: { count: 1 }, + removeOnFail: { count: 10 }, + }) + ); + }); + }); + + // ─── process ────────────────────────────────────────────────────────────── + + describe('process', () => { + it('should call discoveryService.createTrendingMusicPlaylists', async () => { + mockDiscoveryService.createTrendingMusicPlaylists.mockResolvedValue(undefined); + + await processor.process(makeJob()); + + expect(mockDiscoveryService.createTrendingMusicPlaylists).toHaveBeenCalledTimes(1); + }); + + it('should propagate errors from createTrendingMusicPlaylists', async () => { + const error = new Error('Discovery service failure'); + mockDiscoveryService.createTrendingMusicPlaylists.mockRejectedValue(error); + + await expect(processor.process(makeJob())).rejects.toThrow('Discovery service failure'); + }); + }); +}); diff --git a/src/mail/mail.service.spec.ts b/src/mail/mail.service.spec.ts index c1bd0a1d..8c4067f4 100644 --- a/src/mail/mail.service.spec.ts +++ b/src/mail/mail.service.spec.ts @@ -228,4 +228,43 @@ describe('MailService', () => { ); }); }); + + // ─── sendFirstMessageEmail ──────────────────────────────────────────────── + + describe('sendFirstMessageEmail', () => { + it('should send to recipient email with subject containing sender username', async () => { + await service.sendFirstMessageEmail('recipient@example.com', 'alice', 'bob'); + + expect(mockMailerService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'recipient@example.com', + subject: 'bob sent you a message', + }) + ); + }); + + it("should use 'first-message' template", async () => { + await service.sendFirstMessageEmail('recipient@example.com', 'alice', 'bob'); + + expect(mockMailerService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ template: 'first-message' }) + ); + }); + + it('should include recipientUsername, senderUsername, and messagesUrl in context', async () => { + await service.sendFirstMessageEmail('recipient@example.com', 'alice', 'bob'); + + const call = mockMailerService.sendMail.mock.calls[0][0]; + expect(call.context.recipientUsername).toBe('alice'); + expect(call.context.senderUsername).toBe('bob'); + expect(call.context.messagesUrl).toBeDefined(); + }); + + it('should build correct messagesUrl from FRONTEND_URL config', async () => { + await service.sendFirstMessageEmail('recipient@example.com', 'alice', 'bob'); + + const call = mockMailerService.sendMail.mock.calls[0][0]; + expect(call.context.messagesUrl).toBe('https://harmonica.app/messages'); + }); + }); }); diff --git a/src/notifications/notifications.service.spec.ts b/src/notifications/notifications.service.spec.ts index 79e99661..5e768776 100644 --- a/src/notifications/notifications.service.spec.ts +++ b/src/notifications/notifications.service.spec.ts @@ -7,6 +7,18 @@ import { SettingsService } from '../settings/settings.service'; import { NotificationType } from './entities/notification.entity'; import { User } from '../user/entities/user.entity'; +jest.mock('firebase-admin/messaging', () => ({ + getMessaging: jest.fn().mockReturnValue({ + sendEachForMulticast: jest + .fn() + .mockResolvedValue({ successCount: 1, failureCount: 0, responses: [] }), + }), +})); + +jest.mock('../common/utilities/captcha.util', () => ({ + getFirebaseApp: jest.fn().mockReturnValue({}), +})); + describe('NotificationsService', () => { let service: NotificationsService; let repo: NotificationsRepository; @@ -22,6 +34,8 @@ describe('NotificationsService', () => { markAsRead: jest.fn(), markAllAsRead: jest.fn(), getUserDeviceTokens: jest.fn(), + getFollowers: jest.fn(), + saveDeviceToken: jest.fn(), }; const mockWebsocketsService = { @@ -107,4 +121,303 @@ describe('NotificationsService', () => { expect(result).toBe(10); }); }); + + // ─── getNotifications ──────────────────────────────────────────────────────── + + describe('getNotifications', () => { + const baseNotif = { + notificationId: 'notif-1', + isRead: false, + createdAt: new Date('2024-01-01'), + type: NotificationType.NEW_LIKE, + actor: { userId: 'actor-1', username: 'alice', avatarUrl: null }, + track: null, + playlist: null, + }; + + it('should return formatted notifications with track target for NEW_LIKE type', async () => { + const notifWithTrack = { + ...baseNotif, + track: { trackId: 'track-1', title: 'My Track', coverImage: 'http://img.com/cover.jpg' }, + }; + mockNotificationsRepository.getNotifications.mockResolvedValue([[notifWithTrack], 1]); + + const result = await service.getNotifications('user-1', 10, 0); + + expect(result.total).toBe(1); + expect(result.notifications[0].activity.target).toEqual({ + type: 'track', + trackId: 'track-1', + title: 'My Track', + coverImageUrl: 'http://img.com/cover.jpg', + }); + }); + + it('should return formatted notifications with playlist target for NEW_LIKE type when track is null', async () => { + const notifWithPlaylist = { + ...baseNotif, + track: null, + playlist: { + playlistId: 'pl-1', + title: 'My Playlist', + coverImage: 'http://img.com/pl.jpg', + }, + }; + mockNotificationsRepository.getNotifications.mockResolvedValue([[notifWithPlaylist], 1]); + + const result = await service.getNotifications('user-1', 10, 0); + + expect(result.notifications[0].activity.target).toEqual({ + type: 'playlist', + playlistId: 'pl-1', + title: 'My Playlist', + coverImageUrl: 'http://img.com/pl.jpg', + }); + }); + + it('should return target: null for NEW_FOLLOWER type', async () => { + const followerNotif = { ...baseNotif, type: NotificationType.NEW_FOLLOWER }; + mockNotificationsRepository.getNotifications.mockResolvedValue([[followerNotif], 1]); + + const result = await service.getNotifications('user-1', 10, 0); + + expect(result.notifications[0].activity.target).toBeNull(); + }); + + it('should return correct total and notifications array shape', async () => { + mockNotificationsRepository.getNotifications.mockResolvedValue([[baseNotif], 42]); + + const result = await service.getNotifications('user-1', 10, 0); + + expect(result.total).toBe(42); + expect(result.notifications).toHaveLength(1); + expect(result.notifications[0]).toMatchObject({ + notificationId: 'notif-1', + isRead: false, + }); + }); + }); + + // ─── deleteNotification ────────────────────────────────────────────────────── + + describe('deleteNotification', () => { + it('should call repo.deleteNotification with correct params', async () => { + mockNotificationsRepository.deleteNotification.mockResolvedValue(undefined); + + await service.deleteNotification('recipient-1', 'actor-1', NotificationType.NEW_LIKE); + + expect(repo.deleteNotification).toHaveBeenCalledWith( + 'recipient-1', + 'actor-1', + NotificationType.NEW_LIKE + ); + }); + }); + + // ─── markAllAsRead ─────────────────────────────────────────────────────────── + + describe('markAllAsRead', () => { + it('should call repo.markAllAsRead with userId', async () => { + mockNotificationsRepository.markAllAsRead.mockResolvedValue(undefined); + + await service.markAllAsRead('user-1'); + + expect(repo.markAllAsRead).toHaveBeenCalledWith('user-1'); + }); + }); + + // ─── notifyNewLike ─────────────────────────────────────────────────────────── + + describe('notifyNewLike', () => { + const actor = { userId: 'actor-1', username: 'alice', avatarUrl: null }; + const target = { trackId: 'track-1' }; + const mockNotif = { + notificationId: 'notif-like', + type: NotificationType.NEW_LIKE, + createdAt: new Date(), + }; + + beforeEach(() => { + mockNotificationsRepository.createNotification.mockResolvedValue(mockNotif); + mockNotificationsRepository.getUnreadCount.mockResolvedValue(3); + mockSettingsService.getNotificationSettings.mockResolvedValue({ data: { device: {} } }); + mockNotificationsRepository.getUserDeviceTokens.mockResolvedValue([]); + }); + + it('should create notification and emit websocket event with correct type and target', async () => { + await service.notifyNewLike('recipient-1', actor, target); + + expect(repo.createNotification).toHaveBeenCalledWith( + NotificationType.NEW_LIKE, + 'recipient-1', + actor.userId, + target + ); + expect(websocketsService.emitToUser).toHaveBeenCalledWith( + 'recipient-1', + 'new_notification', + expect.objectContaining({ + notification: expect.objectContaining({ + type: NotificationType.NEW_LIKE, + target, + }), + unreadCount: 3, + }) + ); + }); + + it('should not throw when no device tokens exist (push is best-effort)', async () => { + mockNotificationsRepository.getUserDeviceTokens.mockResolvedValue([]); + + await expect(service.notifyNewLike('recipient-1', actor, target)).resolves.not.toThrow(); + }); + }); + + // ─── notifyNewRepost ───────────────────────────────────────────────────────── + + describe('notifyNewRepost', () => { + const actor = { userId: 'actor-1', username: 'bob', avatarUrl: null }; + const target = { trackId: 'track-2' }; + const mockNotif = { + notificationId: 'notif-repost', + type: NotificationType.NEW_REPOST, + createdAt: new Date(), + }; + + beforeEach(() => { + mockNotificationsRepository.createNotification.mockResolvedValue(mockNotif); + mockNotificationsRepository.getUnreadCount.mockResolvedValue(1); + mockSettingsService.getNotificationSettings.mockResolvedValue({ data: { device: {} } }); + mockNotificationsRepository.getUserDeviceTokens.mockResolvedValue([]); + }); + + it('should create notification and emit websocket event with NEW_REPOST type', async () => { + await service.notifyNewRepost('recipient-1', actor, target); + + expect(repo.createNotification).toHaveBeenCalledWith( + NotificationType.NEW_REPOST, + 'recipient-1', + actor.userId, + target + ); + expect(websocketsService.emitToUser).toHaveBeenCalledWith( + 'recipient-1', + 'new_notification', + expect.objectContaining({ + notification: expect.objectContaining({ + type: NotificationType.NEW_REPOST, + }), + }) + ); + }); + }); + + // ─── notifyNewComment ──────────────────────────────────────────────────────── + + describe('notifyNewComment', () => { + const actor = { userId: 'actor-1', username: 'charlie', avatarUrl: null }; + const target = { trackId: 'track-3', commentId: 'comment-1', content: 'Great track!' }; + const mockNotif = { + notificationId: 'notif-comment', + type: NotificationType.NEW_COMMENT, + createdAt: new Date(), + }; + + beforeEach(() => { + mockNotificationsRepository.createNotification.mockResolvedValue(mockNotif); + mockNotificationsRepository.getUnreadCount.mockResolvedValue(2); + mockSettingsService.getNotificationSettings.mockResolvedValue({ data: { device: {} } }); + mockNotificationsRepository.getUserDeviceTokens.mockResolvedValue([]); + }); + + it('should create notification and emit websocket event with NEW_COMMENT type', async () => { + await service.notifyNewComment('recipient-1', actor, target); + + expect(repo.createNotification).toHaveBeenCalledWith( + NotificationType.NEW_COMMENT, + 'recipient-1', + actor.userId, + { trackId: target.trackId } + ); + expect(websocketsService.emitToUser).toHaveBeenCalledWith( + 'recipient-1', + 'new_notification', + expect.objectContaining({ + notification: expect.objectContaining({ + type: NotificationType.NEW_COMMENT, + }), + }) + ); + }); + + it('should set isReply in the websocket payload when isReply=true', async () => { + await service.notifyNewComment('recipient-1', actor, target, true); + + expect(websocketsService.emitToUser).toHaveBeenCalledWith( + 'recipient-1', + 'new_notification', + expect.objectContaining({ + notification: expect.objectContaining({ + target: expect.objectContaining({ isReply: true }), + }), + }) + ); + }); + }); + + // ─── notifyNewPost ─────────────────────────────────────────────────────────── + + describe('notifyNewPost', () => { + const artist = { userId: 'artist-1', username: 'dj-star', avatarUrl: null }; + const mockNotif = { + notificationId: 'notif-post', + type: NotificationType.NEW_POST, + createdAt: new Date(), + }; + + beforeEach(() => { + mockNotificationsRepository.createNotification.mockResolvedValue(mockNotif); + mockNotificationsRepository.getUnreadCount.mockResolvedValue(0); + mockSettingsService.getNotificationSettings.mockResolvedValue({ data: { device: {} } }); + mockNotificationsRepository.getUserDeviceTokens.mockResolvedValue([]); + }); + + it('should call createNotification and emitToUser for each follower', async () => { + mockNotificationsRepository.getFollowers.mockResolvedValue(['follower-1', 'follower-2']); + + await service.notifyNewPost(artist, 'track-4'); + + expect(repo.createNotification).toHaveBeenCalledTimes(2); + expect(websocketsService.emitToUser).toHaveBeenCalledTimes(2); + expect(websocketsService.emitToUser).toHaveBeenCalledWith( + 'follower-1', + 'new_notification', + expect.objectContaining({ + notification: expect.objectContaining({ type: NotificationType.NEW_POST }), + }) + ); + }); + + it('should do nothing when followers array is empty', async () => { + mockNotificationsRepository.getFollowers.mockResolvedValue([]); + + await service.notifyNewPost(artist, 'track-5'); + + expect(repo.createNotification).not.toHaveBeenCalled(); + expect(websocketsService.emitToUser).not.toHaveBeenCalled(); + }); + }); + + // ─── registerDevice ────────────────────────────────────────────────────────── + + describe('registerDevice', () => { + it('should delegate to repo.saveDeviceToken with userId, token, platform', async () => { + mockNotificationsRepository.saveDeviceToken.mockResolvedValue({ id: 'dt-1' }); + + const result = await service.registerDevice('user-1', 'fcm-token-xyz', 'android'); + + expect(repo.saveDeviceToken).toHaveBeenCalledWith('user-1', 'fcm-token-xyz', 'android'); + expect(result).toEqual({ id: 'dt-1' }); + }); + }); }); diff --git a/src/search/search.spec.ts b/src/search/search.spec.ts new file mode 100644 index 00000000..519864ae --- /dev/null +++ b/src/search/search.spec.ts @@ -0,0 +1,378 @@ +import { getIndex } from './client'; +import { search, autocomplete } from './search'; +import { + mapTrack, + mapUser, + mapPlaylist, + mapAlbum, + addDocuments, + updateDocument, + deleteDocument, +} from './indexing'; + +// ─── Mock client ───────────────────────────────────────────────────────────── + +jest.mock('./client', () => ({ getIndex: jest.fn() })); + +const mockIndex = { + search: jest.fn(), + addDocuments: jest.fn(), + updateDocuments: jest.fn(), + deleteDocument: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); + (getIndex as jest.Mock).mockReturnValue(mockIndex); +}); + +// ─── search.ts ─────────────────────────────────────────────────────────────── + +describe('search()', () => { + const baseHits = [{ id: 'track_1', type: 'track' }]; + + const mockSearchResult = (overrides?: object) => ({ + hits: baseHits, + estimatedTotalHits: 42, + ...overrides, + }); + + it('should call index.search with filter: undefined when no filters are passed', async () => { + mockIndex.search.mockResolvedValue(mockSearchResult()); + + await search({ query: 'hello' }); + + expect(mockIndex.search).toHaveBeenCalledWith( + 'hello', + expect.objectContaining({ filter: undefined }) + ); + }); + + it('should include type filter when type is not "all"', async () => { + mockIndex.search.mockResolvedValue(mockSearchResult()); + + await search({ query: 'song', type: 'track' }); + + const [, options] = mockIndex.search.mock.calls[0]; + expect(options.filter).toContain('type = "track"'); + }); + + it('should NOT include type filter when type is "all"', async () => { + mockIndex.search.mockResolvedValue(mockSearchResult()); + + await search({ query: 'song', type: 'all' }); + + const [, options] = mockIndex.search.mock.calls[0]; + expect(options.filter ?? '').not.toContain('type'); + }); + + it('should include city filter when city is provided', async () => { + mockIndex.search.mockResolvedValue(mockSearchResult()); + + await search({ query: 'artist', city: 'Cairo' }); + + const [, options] = mockIndex.search.mock.calls[0]; + expect(options.filter).toContain('city = "Cairo"'); + }); + + it('should include duration >= filter when durationRange.min is provided', async () => { + mockIndex.search.mockResolvedValue(mockSearchResult()); + + await search({ query: 'track', durationRange: { min: 120 } }); + + const [, options] = mockIndex.search.mock.calls[0]; + expect(options.filter).toContain('duration >= 120'); + }); + + it('should include duration <= filter when durationRange.max is provided', async () => { + mockIndex.search.mockResolvedValue(mockSearchResult()); + + await search({ query: 'track', durationRange: { max: 300 } }); + + const [, options] = mockIndex.search.mock.calls[0]; + expect(options.filter).toContain('duration <= 300'); + }); + + it('should include both duration filters when both min and max are provided', async () => { + mockIndex.search.mockResolvedValue(mockSearchResult()); + + await search({ query: 'track', durationRange: { min: 60, max: 240 } }); + + const [, options] = mockIndex.search.mock.calls[0]; + expect(options.filter).toContain('duration >= 60'); + expect(options.filter).toContain('duration <= 240'); + }); + + it('should include createdAt filter when createdAtLimit is provided', async () => { + mockIndex.search.mockResolvedValue(mockSearchResult()); + const limit = new Date('2024-01-01T00:00:00.000Z'); + + await search({ query: 'track', createdAtLimit: limit }); + + const [, options] = mockIndex.search.mock.calls[0]; + expect(options.filter).toContain('created_at >= "2024-01-01T00:00:00.000Z"'); + }); + + it('should combine genre and tag with OR in the filter string', async () => { + mockIndex.search.mockResolvedValue(mockSearchResult()); + + await search({ query: 'music', genre: 'pop', tag: 'summer' }); + + const [, options] = mockIndex.search.mock.calls[0]; + expect(options.filter).toContain('genre = "pop"'); + expect(options.filter).toContain('tags = "summer"'); + expect(options.filter).toContain('OR'); + }); + + it('should return hits and total from estimatedTotalHits', async () => { + mockIndex.search.mockResolvedValue(mockSearchResult({ estimatedTotalHits: 99 })); + + const result = await search({ query: 'test' }); + + expect(result.hits).toBe(baseHits); + expect(result.total).toBe(99); + }); + + it('should fall back to hits.length when estimatedTotalHits and nbHits are absent', async () => { + mockIndex.search.mockResolvedValue({ hits: baseHits }); + + const result = await search({ query: 'test' }); + + expect(result.total).toBe(baseHits.length); + }); + + it('should pass limit and offset to index.search', async () => { + mockIndex.search.mockResolvedValue(mockSearchResult()); + + await search({ query: 'test', limit: 5, offset: 15 }); + + const [, options] = mockIndex.search.mock.calls[0]; + expect(options.limit).toBe(5); + expect(options.offset).toBe(15); + }); +}); + +// ─── autocomplete ───────────────────────────────────────────────────────────── + +describe('autocomplete()', () => { + it('should return lowercased, deduplicated suggestions matching the query', async () => { + mockIndex.search.mockResolvedValue({ + hits: [ + { title: 'Summer Vibes', username: 'djsummer', display_name: 'DJ Summer' }, + { title: 'Summertime', username: 'artist2', display_name: 'Artist Two' }, + ], + }); + + const result = await autocomplete('summer'); + + expect(result).toEqual( + expect.arrayContaining(['summer vibes', 'djsummer', 'dj summer', 'summertime']) + ); + // All lowercase + result.forEach((s) => expect(s).toBe(s.toLowerCase())); + // Deduplicated + expect(result.length).toBe(new Set(result).size); + }); + + it('should only include strings that contain the query', async () => { + mockIndex.search.mockResolvedValue({ + hits: [ + { title: 'Rock Anthem', username: 'rocker', display_name: 'The Rocker' }, + { title: 'Pop Song', username: 'pop_star', display_name: 'Pop Star' }, + ], + }); + + const result = await autocomplete('rock'); + + expect(result.every((s) => s.includes('rock'))).toBe(true); + expect(result).not.toContain('pop song'); + }); + + it('should return an empty array when no hits match', async () => { + mockIndex.search.mockResolvedValue({ hits: [] }); + + const result = await autocomplete('xyz_no_match'); + + expect(result).toEqual([]); + }); + + it('should return at most 8 suggestions', async () => { + const hits = Array.from({ length: 20 }, (_, i) => ({ + title: `Test Track ${i}`, + username: `user${i}`, + display_name: `User ${i}`, + })); + mockIndex.search.mockResolvedValue({ hits }); + + const result = await autocomplete('test'); + + expect(result.length).toBeLessThanOrEqual(8); + }); + + it('should deduplicate suggestions', async () => { + mockIndex.search.mockResolvedValue({ + hits: [{ title: 'Jazz Night', username: 'Jazz Night', display_name: 'Jazz Night' }], + }); + + const result = await autocomplete('jazz'); + + expect(result).toEqual(['jazz night']); + expect(result.length).toBe(1); + }); +}); + +// ─── indexing.ts — mapper functions ────────────────────────────────────────── + +describe('mapTrack()', () => { + it('should map a Track entity to a TrackDocument correctly', () => { + const track = { + trackId: 'abc-123', + title: 'My Track', + description: 'A cool track', + tags: [{ name: 'chill' }, { name: 'lo-fi' }], + genre: { name: 'Hip-Hop' }, + durationSeconds: 200, + createdAt: new Date('2024-03-01'), + user: { displayName: 'DJ Artist' }, + } as any; + + const doc = mapTrack(track); + + expect(doc.id).toBe('track_abc-123'); + expect(doc.type).toBe('track'); + expect(doc.title).toBe('My Track'); + expect(doc.artist_name).toBe('DJ Artist'); + expect(doc.description).toBe('A cool track'); + expect(doc.tags).toEqual(['chill', 'lo-fi']); + expect(doc.genre).toBe('Hip-Hop'); + expect(doc.duration).toBe(200); + expect(doc.created_at).toEqual(new Date('2024-03-01')); + }); + + it('should handle missing tags gracefully', () => { + const track = { + trackId: 'abc-123', + title: 'No Tags', + tags: undefined, + genre: undefined, + durationSeconds: 100, + createdAt: new Date(), + user: { displayName: 'Artist' }, + } as any; + + const doc = mapTrack(track); + + expect(doc.tags).toBeUndefined(); + expect(doc.genre).toBeUndefined(); + }); +}); + +describe('mapUser()', () => { + it('should map a User entity to a UserDocument correctly', () => { + const user = { + userId: 'user-456', + username: 'alice', + displayName: 'Alice Smith', + city: 'Cairo', + avatarUrl: 'https://example.com/avatar.jpg', + } as any; + + const doc = mapUser(user); + + expect(doc.id).toBe('user_user-456'); + expect(doc.type).toBe('user'); + expect(doc.username).toBe('alice'); + expect(doc.display_name).toBe('Alice Smith'); + expect(doc.city).toBe('Cairo'); + expect(doc.avatar_url).toBe('https://example.com/avatar.jpg'); + }); +}); + +describe('mapPlaylist()', () => { + it('should map a Playlist entity to a PlaylistDocument correctly', () => { + const playlist = { + playlistId: 'pl-789', + title: 'My Playlist', + tags: [{ name: 'pop' }], + genre: { name: 'Pop' }, + user: { displayName: 'Curator' }, + } as any; + + const doc = mapPlaylist(playlist); + + expect(doc.id).toBe('playlist_pl-789'); + expect(doc.type).toBe('playlist'); + expect(doc.title).toBe('My Playlist'); + expect(doc.artist_name).toBe('Curator'); + expect(doc.tags).toEqual(['pop']); + expect(doc.genre).toBe('Pop'); + }); +}); + +describe('mapAlbum()', () => { + it('should map a Playlist entity to an AlbumDocument correctly', () => { + const album = { + playlistId: 'alb-101', + title: 'My Album', + tags: [{ name: 'jazz' }], + genre: { name: 'Jazz' }, + user: { displayName: 'Band Name' }, + } as any; + + const doc = mapAlbum(album); + + expect(doc.id).toBe('album_alb-101'); + expect(doc.type).toBe('album'); + expect(doc.title).toBe('My Album'); + expect(doc.artist_name).toBe('Band Name'); + expect(doc.tags).toEqual(['jazz']); + expect(doc.genre).toBe('Jazz'); + }); +}); + +// ─── indexing.ts — index operations ────────────────────────────────────────── + +describe('addDocuments()', () => { + it('should call index.addDocuments with the provided docs array', async () => { + mockIndex.addDocuments.mockResolvedValue({ taskUid: 1 }); + + const docs = [ + { + id: 'track_1', + type: 'track' as const, + title: 'A', + artist_name: 'B', + tags: [], + genre: 'C', + duration: 100, + created_at: new Date(), + }, + ]; + + await addDocuments(docs); + + expect(mockIndex.addDocuments).toHaveBeenCalledWith(docs); + }); +}); + +describe('updateDocument()', () => { + it('should call index.updateDocuments with the document wrapped in an array', async () => { + mockIndex.updateDocuments.mockResolvedValue({ taskUid: 2 }); + + const doc = { id: 'user_1', type: 'user' as const, username: 'bob', display_name: 'Bob' }; + + await updateDocument(doc); + + expect(mockIndex.updateDocuments).toHaveBeenCalledWith([doc]); + }); +}); + +describe('deleteDocument()', () => { + it('should call index.deleteDocument with the provided id', async () => { + mockIndex.deleteDocument.mockResolvedValue({ taskUid: 3 }); + + await deleteDocument('track_abc-123'); + + expect(mockIndex.deleteDocument).toHaveBeenCalledWith('track_abc-123'); + }); +}); diff --git a/src/settings/settings.service.spec.ts b/src/settings/settings.service.spec.ts index 6826270e..d5558c78 100644 --- a/src/settings/settings.service.spec.ts +++ b/src/settings/settings.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundException } from '@nestjs/common'; import { SettingsService } from './settings.service'; import { SettingsRepository } from './settings.repository'; +import { UpdateNotificationsDto } from './dtos/update-notifications.dto'; const MOCK_USER_ID = '550e8400-e29b-41d4-a716-446655440001'; @@ -35,6 +36,7 @@ const mockSettingsRepository = () => ({ updatePrivacySettings: jest.fn(), createDefaultSettings: jest.fn(), delete: jest.fn(), + updateNotificationSettings: jest.fn(), }); describe('SettingsService', () => { @@ -241,4 +243,117 @@ describe('SettingsService', () => { expect(result).toBe(fresh); }); }); + + // ─── getNotificationSettings ───────────────────────────────────────────────── + + describe('getNotificationSettings', () => { + it('should return shaped notification settings object with email and device keys', async () => { + repo.getPrivacySettings.mockResolvedValue(mockSettings()); + + const result = await service.getNotificationSettings(MOCK_USER_ID); + + expect(result.status).toBe('success'); + expect(result.data).toHaveProperty('email'); + expect(result.data).toHaveProperty('device'); + }); + + it('should throw NotFoundException when settings not found', async () => { + repo.getPrivacySettings.mockResolvedValue(null); + + await expect(service.getNotificationSettings(MOCK_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + + it('should correctly map all email notification fields', async () => { + repo.getPrivacySettings.mockResolvedValue(mockSettings()); + + const result = await service.getNotificationSettings(MOCK_USER_ID); + + expect(result.data.email).toEqual({ + newFollower: false, + repost: true, + newPost: true, + likesPlays: false, + comment: false, + recommended: true, + newMessage: true, + }); + }); + + it('should correctly map all device notification fields (including likes_plays snake_case key)', async () => { + repo.getPrivacySettings.mockResolvedValue(mockSettings()); + + const result = await service.getNotificationSettings(MOCK_USER_ID); + + expect(result.data.device).toEqual({ + newFollower: true, + repost: true, + newPost: true, + likes_plays: true, + comment: true, + recommended: true, + newMessage: 'everyone', + }); + }); + }); + + // ─── updateNotificationSettings ────────────────────────────────────────────── + + describe('updateNotificationSettings', () => { + beforeEach(() => { + repo.getPrivacySettings.mockResolvedValue(mockSettings()); + repo.updateNotificationSettings.mockResolvedValue(undefined); + }); + + it('should call updateNotificationSettings repo with correct flat payload when email fields are provided', async () => { + const dto: UpdateNotificationsDto = { email: { newFollower: true, repost: false } }; + + await service.updateNotificationSettings(MOCK_USER_ID, dto); + + expect(repo.updateNotificationSettings).toHaveBeenCalledWith( + MOCK_USER_ID, + expect.objectContaining({ emailNewFollower: true, emailRepost: false }) + ); + }); + + it('should NOT call updateNotificationSettings repo when dto is empty', async () => { + await service.updateNotificationSettings(MOCK_USER_ID, {}); + + expect(repo.updateNotificationSettings).not.toHaveBeenCalled(); + }); + + it('should map device notification fields correctly', async () => { + const dto: UpdateNotificationsDto = { device: { newFollower: false, comment: true } }; + + await service.updateNotificationSettings(MOCK_USER_ID, dto); + + expect(repo.updateNotificationSettings).toHaveBeenCalledWith( + MOCK_USER_ID, + expect.objectContaining({ deviceNewFollower: false, deviceComment: true }) + ); + }); + + it('should return success message and updated data', async () => { + const dto: UpdateNotificationsDto = { email: { newFollower: true } }; + + const result = await service.updateNotificationSettings(MOCK_USER_ID, dto); + + expect(result.status).toBe('success'); + expect(result.message).toBe('Notification settings updated successfully'); + expect(result.data).toHaveProperty('email'); + expect(result.data).toHaveProperty('device'); + }); + + it('should only update fields that are explicitly provided (undefined fields skipped)', async () => { + const dto: UpdateNotificationsDto = { email: { newFollower: true } }; + + await service.updateNotificationSettings(MOCK_USER_ID, dto); + + const callPayload = repo.updateNotificationSettings.mock.calls[0][1]; + expect(callPayload).toHaveProperty('emailNewFollower', true); + expect(callPayload).not.toHaveProperty('emailRepost'); + expect(callPayload).not.toHaveProperty('emailComment'); + }); + }); }); diff --git a/src/subscription/stripe.service.spec.ts b/src/subscription/stripe.service.spec.ts new file mode 100644 index 00000000..8c9bddfc --- /dev/null +++ b/src/subscription/stripe.service.spec.ts @@ -0,0 +1,216 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { StripeService } from './stripe.service'; + +// ─── Stripe mock ────────────────────────────────────────────────────────────── + +const mockStripeInstance = { + customers: { + create: jest.fn(), + retrieve: jest.fn(), + update: jest.fn(), + }, + paymentMethods: { + create: jest.fn(), + attach: jest.fn(), + }, + subscriptions: { + create: jest.fn(), + retrieve: jest.fn(), + update: jest.fn(), + }, + webhooks: { + constructEvent: jest.fn(), + }, +}; + +jest.mock('stripe', () => jest.fn().mockImplementation(() => mockStripeInstance)); + +// ─── Suite ──────────────────────────────────────────────────────────────────── + +describe('StripeService', () => { + let service: StripeService; + + const mockConfigService = { + get: jest.fn((key: string) => { + const config: Record = { + STRIPE_SECRET_KEY: 'sk_test_secret', + STRIPE_WEBHOOK_SECRET: 'whsec_test_secret', + }; + return config[key]; + }), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [StripeService, { provide: ConfigService, useValue: mockConfigService }], + }).compile(); + + service = module.get(StripeService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // ─── createCustomer ─────────────────────────────────────────────────────────── + + describe('createCustomer', () => { + it('should call stripe.customers.create with email, username and userId', async () => { + const mockCustomer = { id: 'cus_123', email: 'test@example.com' }; + mockStripeInstance.customers.create.mockResolvedValue(mockCustomer); + + const result = await service.createCustomer('test@example.com', 'testuser', 'user-uuid-1'); + + expect(mockStripeInstance.customers.create).toHaveBeenCalledWith({ + email: 'test@example.com', + name: 'testuser', + metadata: { userId: 'user-uuid-1' }, + }); + expect(result).toBe(mockCustomer); + }); + }); + + // ─── getCustomer ───────────────────────────────────────────────────────────── + + describe('getCustomer', () => { + it('should call stripe.customers.retrieve with the customer id', async () => { + const mockCustomer = { id: 'cus_123' }; + mockStripeInstance.customers.retrieve.mockResolvedValue(mockCustomer); + + const result = await service.getCustomer('cus_123'); + + expect(mockStripeInstance.customers.retrieve).toHaveBeenCalledWith('cus_123'); + expect(result).toBe(mockCustomer); + }); + }); + + // ─── attachPaymentMethod ───────────────────────────────────────────────────── + + describe('attachPaymentMethod', () => { + it('should call attach and customers.update without creating a new PM for a regular payment method', async () => { + mockStripeInstance.paymentMethods.attach.mockResolvedValue({}); + mockStripeInstance.customers.update.mockResolvedValue({}); + + await service.attachPaymentMethod('pm_real_method', 'cus_123'); + + expect(mockStripeInstance.paymentMethods.create).not.toHaveBeenCalled(); + expect(mockStripeInstance.paymentMethods.attach).toHaveBeenCalledWith('pm_real_method', { + customer: 'cus_123', + }); + expect(mockStripeInstance.customers.update).toHaveBeenCalledWith('cus_123', { + invoice_settings: { default_payment_method: 'pm_real_method' }, + }); + }); + + it('should create a new PM first when paymentMethodId starts with pm_card_', async () => { + const newPm = { id: 'pm_generated_id' }; + mockStripeInstance.paymentMethods.create.mockResolvedValue(newPm); + mockStripeInstance.paymentMethods.attach.mockResolvedValue({}); + mockStripeInstance.customers.update.mockResolvedValue({}); + + await service.attachPaymentMethod('pm_card_visa', 'cus_123'); + + expect(mockStripeInstance.paymentMethods.create).toHaveBeenCalledWith({ + type: 'card', + card: { token: 'tok_visa' }, + }); + expect(mockStripeInstance.paymentMethods.attach).toHaveBeenCalledWith('pm_generated_id', { + customer: 'cus_123', + }); + expect(mockStripeInstance.customers.update).toHaveBeenCalledWith('cus_123', { + invoice_settings: { default_payment_method: 'pm_generated_id' }, + }); + }); + }); + + // ─── createStripeSubscription ───────────────────────────────────────────────── + + describe('createStripeSubscription', () => { + it('should call stripe.subscriptions.create with correct params', async () => { + const mockSub = { id: 'sub_123', status: 'active' }; + mockStripeInstance.subscriptions.create.mockResolvedValue(mockSub); + + const result = await service.createStripeSubscription('cus_123', 'price_456'); + + expect(mockStripeInstance.subscriptions.create).toHaveBeenCalledWith({ + customer: 'cus_123', + items: [{ price: 'price_456' }], + payment_settings: { + payment_method_types: ['card'], + save_default_payment_method: 'on_subscription', + }, + expand: ['latest_invoice.payment_intent'], + }); + expect(result).toBe(mockSub); + }); + }); + + // ─── getSubscription ────────────────────────────────────────────────────────── + + describe('getSubscription', () => { + it('should call stripe.subscriptions.retrieve with the subscription id', async () => { + const mockSub = { id: 'sub_123' }; + mockStripeInstance.subscriptions.retrieve.mockResolvedValue(mockSub); + + const result = await service.getSubscription('sub_123'); + + expect(mockStripeInstance.subscriptions.retrieve).toHaveBeenCalledWith('sub_123'); + expect(result).toBe(mockSub); + }); + }); + + // ─── cancelSubscription ─────────────────────────────────────────────────────── + + describe('cancelSubscription', () => { + it('should call stripe.subscriptions.update with cancel_at_period_end: true', async () => { + const mockSub = { id: 'sub_123', cancel_at_period_end: true }; + mockStripeInstance.subscriptions.update.mockResolvedValue(mockSub); + + const result = await service.cancelSubscription('sub_123'); + + expect(mockStripeInstance.subscriptions.update).toHaveBeenCalledWith('sub_123', { + cancel_at_period_end: true, + }); + expect(result).toBe(mockSub); + }); + }); + + // ─── resumeSubscription ─────────────────────────────────────────────────────── + + describe('resumeSubscription', () => { + it('should call stripe.subscriptions.update with cancel_at_period_end: false', async () => { + const mockSub = { id: 'sub_123', cancel_at_period_end: false }; + mockStripeInstance.subscriptions.update.mockResolvedValue(mockSub); + + const result = await service.resumeSubscription('sub_123'); + + expect(mockStripeInstance.subscriptions.update).toHaveBeenCalledWith('sub_123', { + cancel_at_period_end: false, + }); + expect(result).toBe(mockSub); + }); + }); + + // ─── constructWebhookEvent ──────────────────────────────────────────────────── + + describe('constructWebhookEvent', () => { + it('should call stripe.webhooks.constructEvent with payload, signature and webhook secret', () => { + const payload = Buffer.from('raw_body'); + const signature = 'whsec_sig'; + const fakeEvent = { type: 'payment_intent.succeeded' }; + mockStripeInstance.webhooks.constructEvent.mockReturnValue(fakeEvent); + + const result = service.constructWebhookEvent(payload, signature); + + expect(mockStripeInstance.webhooks.constructEvent).toHaveBeenCalledWith( + payload, + signature, + 'whsec_test_secret' + ); + expect(result).toBe(fakeEvent); + }); + }); +}); diff --git a/src/subscription/webhook.controller.spec.ts b/src/subscription/webhook.controller.spec.ts new file mode 100644 index 00000000..88d05434 --- /dev/null +++ b/src/subscription/webhook.controller.spec.ts @@ -0,0 +1,92 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { WebhookController } from './webhook.controller'; +import { SubscriptionService } from './subscription.service'; +import { StripeService } from './stripe.service'; + +describe('WebhookController', () => { + let controller: WebhookController; + + const mockSubscriptionService = { + handleWebhookEvent: jest.fn().mockResolvedValue(undefined), + }; + + const mockStripeService = { + constructWebhookEvent: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [WebhookController], + providers: [ + { provide: SubscriptionService, useValue: mockSubscriptionService }, + { provide: StripeService, useValue: mockStripeService }, + ], + }).compile(); + + controller = module.get(WebhookController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + // ─── handleStripeWebhook ────────────────────────────────────────────────────── + + describe('handleStripeWebhook', () => { + const makeReq = (rawBody?: Buffer) => ({ rawBody }) as any; + const SIGNATURE = 'whsec_test_signature'; + const RAW_BODY = Buffer.from('{"type":"payment_intent.succeeded"}'); + + it('should throw BadRequestException when signature is missing', async () => { + await expect( + controller.handleStripeWebhook(makeReq(RAW_BODY), undefined as any) + ).rejects.toThrow(new BadRequestException('Missing stripe signature')); + }); + + it('should throw BadRequestException when signature is an empty string', async () => { + await expect(controller.handleStripeWebhook(makeReq(RAW_BODY), '')).rejects.toThrow( + new BadRequestException('Missing stripe signature') + ); + }); + + it('should throw BadRequestException when req.rawBody is missing', async () => { + await expect(controller.handleStripeWebhook(makeReq(undefined), SIGNATURE)).rejects.toThrow( + new BadRequestException('Missing raw body') + ); + }); + + it('should throw BadRequestException when constructWebhookEvent throws', async () => { + mockStripeService.constructWebhookEvent.mockImplementation(() => { + throw new Error('invalid signature'); + }); + + await expect(controller.handleStripeWebhook(makeReq(RAW_BODY), SIGNATURE)).rejects.toThrow( + new BadRequestException('Webhook signature verification failed') + ); + }); + + it('should return { received: true } and call handleWebhookEvent on success', async () => { + const fakeEvent = { type: 'payment_intent.succeeded', data: {} }; + mockStripeService.constructWebhookEvent.mockReturnValue(fakeEvent); + + const result = await controller.handleStripeWebhook(makeReq(RAW_BODY), SIGNATURE); + + expect(result).toEqual({ received: true }); + expect(mockStripeService.constructWebhookEvent).toHaveBeenCalledWith(RAW_BODY, SIGNATURE); + expect(mockSubscriptionService.handleWebhookEvent).toHaveBeenCalledWith(fakeEvent); + }); + + it('should call handleWebhookEvent with the constructed event object', async () => { + const fakeEvent = { type: 'customer.subscription.deleted', data: { object: {} } }; + mockStripeService.constructWebhookEvent.mockReturnValue(fakeEvent); + + await controller.handleStripeWebhook(makeReq(RAW_BODY), SIGNATURE); + + expect(mockSubscriptionService.handleWebhookEvent).toHaveBeenCalledTimes(1); + expect(mockSubscriptionService.handleWebhookEvent).toHaveBeenCalledWith(fakeEvent); + }); + }); +}); diff --git a/src/track/listeners/fans-job.processor.spec.ts b/src/track/listeners/fans-job.processor.spec.ts new file mode 100644 index 00000000..fc8ffe52 --- /dev/null +++ b/src/track/listeners/fans-job.processor.spec.ts @@ -0,0 +1,135 @@ +import { Queue, Job } from 'bullmq'; +import { ConfigService } from '@nestjs/config'; +import { FansService } from '../services/fans.service'; +import { FansJobProcessor } from './fans-job.processor'; + +const mockFansQueue = () => ({ + add: jest.fn().mockResolvedValue(undefined), +}); + +const mockFansService = () => ({ + refreshAllTopFans: jest.fn().mockResolvedValue(undefined), + snapshotAllPendingFirstFans: jest.fn().mockResolvedValue(undefined), +}); + +const mockConfigService = () => ({ + get: jest.fn(), +}); + +const makeJob = (name: string) => ({ name, data: {} }) as unknown as Job; + +describe('FansJobProcessor', () => { + let processor: FansJobProcessor; + let fansQueue: ReturnType; + let fansService: ReturnType; + let configService: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + fansQueue = mockFansQueue(); + fansService = mockFansService(); + configService = mockConfigService(); + processor = new FansJobProcessor( + fansQueue as unknown as Queue, + fansService as unknown as FansService, + configService as unknown as ConfigService + ); + }); + + // ─── onModuleInit ──────────────────────────────────────────────────────────── + + describe('onModuleInit', () => { + it('should register refresh-top-fans with default cron when config returns default', async () => { + configService.get.mockImplementation((_key: string, defaultValue: string) => defaultValue); + + await processor.onModuleInit(); + + expect(fansQueue.add).toHaveBeenCalledWith( + 'refresh-top-fans', + {}, + expect.objectContaining({ + jobId: 'refresh-top-fans-job', + repeat: { pattern: '0 3 * * *' }, + }) + ); + }); + + it('should register snapshot-first-fans with default cron when config returns default', async () => { + configService.get.mockImplementation((_key: string, defaultValue: string) => defaultValue); + + await processor.onModuleInit(); + + expect(fansQueue.add).toHaveBeenCalledWith( + 'snapshot-first-fans', + {}, + expect.objectContaining({ + jobId: 'snapshot-first-fans-job', + repeat: { pattern: '0 4 * * *' }, + }) + ); + }); + + it('should use custom cron values when config returns them', async () => { + configService.get.mockImplementation((key: string, _default: string) => { + if (key === 'FANS_TOP_CRON') return '0 1 * * *'; + if (key === 'FANS_FIRST_CRON') return '0 2 * * *'; + return _default; + }); + + await processor.onModuleInit(); + + expect(fansQueue.add).toHaveBeenCalledWith( + 'refresh-top-fans', + {}, + expect.objectContaining({ repeat: { pattern: '0 1 * * *' } }) + ); + expect(fansQueue.add).toHaveBeenCalledWith( + 'snapshot-first-fans', + {}, + expect.objectContaining({ repeat: { pattern: '0 2 * * *' } }) + ); + }); + + it('should set removeOnComplete and removeOnFail options for both jobs', async () => { + configService.get.mockImplementation((_key: string, defaultValue: string) => defaultValue); + + await processor.onModuleInit(); + + fansQueue.add.mock.calls.forEach((call) => { + expect(call[2]).toMatchObject({ + removeOnComplete: { count: 1 }, + removeOnFail: { count: 10 }, + }); + }); + }); + }); + + // ─── process ───────────────────────────────────────────────────────────────── + + describe('process', () => { + it('should call fansService.refreshAllTopFans for refresh-top-fans job', async () => { + await processor.process(makeJob('refresh-top-fans')); + + expect(fansService.refreshAllTopFans).toHaveBeenCalledTimes(1); + expect(fansService.snapshotAllPendingFirstFans).not.toHaveBeenCalled(); + }); + + it('should call fansService.snapshotAllPendingFirstFans for snapshot-first-fans job', async () => { + await processor.process(makeJob('snapshot-first-fans')); + + expect(fansService.snapshotAllPendingFirstFans).toHaveBeenCalledTimes(1); + expect(fansService.refreshAllTopFans).not.toHaveBeenCalled(); + }); + + it('should not throw for an unknown job name', async () => { + await expect(processor.process(makeJob('unknown-job'))).resolves.toBeUndefined(); + }); + + it('should not call any fansService method for an unknown job name', async () => { + await processor.process(makeJob('unknown-job')); + + expect(fansService.refreshAllTopFans).not.toHaveBeenCalled(); + expect(fansService.snapshotAllPendingFirstFans).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/track/listeners/release-job.processor.spec.ts b/src/track/listeners/release-job.processor.spec.ts new file mode 100644 index 00000000..196257ea --- /dev/null +++ b/src/track/listeners/release-job.processor.spec.ts @@ -0,0 +1,40 @@ +import { ReleaseJobProcessor } from './release-job.processor'; + +const mockTrackRepository = () => ({ + releaseTrack: jest.fn().mockResolvedValue(undefined), +}); + +const makeJob = (trackId: string) => ({ data: { trackId } }) as any; + +describe('ReleaseJobProcessor', () => { + let processor: ReleaseJobProcessor; + let trackRepository: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + trackRepository = mockTrackRepository(); + processor = new ReleaseJobProcessor(trackRepository as any); + }); + + // ─── process ───────────────────────────────────────────────────────────────── + + describe('process', () => { + it('should call trackRepository.releaseTrack with the trackId from job.data', async () => { + const trackId = 'track-uuid-123'; + + await processor.process(makeJob(trackId)); + + expect(trackRepository.releaseTrack).toHaveBeenCalledTimes(1); + expect(trackRepository.releaseTrack).toHaveBeenCalledWith(trackId); + }); + + it('should propagate errors thrown by releaseTrack', async () => { + const error = new Error('Database connection lost'); + trackRepository.releaseTrack.mockRejectedValue(error); + + await expect(processor.process(makeJob('track-uuid-456'))).rejects.toThrow( + 'Database connection lost' + ); + }); + }); +}); diff --git a/src/track/services/fans.service.spec.ts b/src/track/services/fans.service.spec.ts index f119b363..6f3c91ab 100644 --- a/src/track/services/fans.service.spec.ts +++ b/src/track/services/fans.service.spec.ts @@ -324,4 +324,81 @@ describe('FansService', () => { expect(result[0].rank).toBe(1); }); }); + + // ─── refreshAllTopFans ──────────────────────────────────────────────────────── + + describe('refreshAllTopFans', () => { + it('should call findActiveTrackIds and refreshTopFans for each track', async () => { + fanRepo.findActiveTrackIds.mockResolvedValue([MOCK_TRACK_ID, 'track-2']); + const refreshSpy = jest.spyOn(service, 'refreshTopFans').mockResolvedValue([]); + + await service.refreshAllTopFans(); + + expect(fanRepo.findActiveTrackIds).toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalledWith(MOCK_TRACK_ID); + expect(refreshSpy).toHaveBeenCalledWith('track-2'); + expect(refreshSpy).toHaveBeenCalledTimes(2); + }); + + it('should handle empty track list gracefully (no refreshTopFans called)', async () => { + fanRepo.findActiveTrackIds.mockResolvedValue([]); + const refreshSpy = jest.spyOn(service, 'refreshTopFans').mockResolvedValue([]); + + await service.refreshAllTopFans(); + + expect(fanRepo.findActiveTrackIds).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it('should process tracks in batches of 50 (test with 51 ids)', async () => { + const trackIds = Array.from({ length: 51 }, (_, i) => `track-${i}`); + fanRepo.findActiveTrackIds.mockResolvedValue(trackIds); + const refreshSpy = jest.spyOn(service, 'refreshTopFans').mockResolvedValue([]); + + await service.refreshAllTopFans(); + + expect(refreshSpy).toHaveBeenCalledTimes(51); + }); + }); + + // ─── snapshotAllPendingFirstFans ────────────────────────────────────────────── + + describe('snapshotAllPendingFirstFans', () => { + it('should call triggerFirstFansSnapshot for each pending track', async () => { + fanRepo.findTracksNeedingSnapshot.mockResolvedValue([MOCK_TRACK_ID, 'track-2']); + const snapshotSpy = jest.spyOn(service, 'triggerFirstFansSnapshot').mockResolvedValue([]); + + await service.snapshotAllPendingFirstFans(); + + expect(fanRepo.findTracksNeedingSnapshot).toHaveBeenCalled(); + expect(snapshotSpy).toHaveBeenCalledWith(MOCK_TRACK_ID); + expect(snapshotSpy).toHaveBeenCalledWith('track-2'); + expect(snapshotSpy).toHaveBeenCalledTimes(2); + }); + + it('should handle empty list gracefully', async () => { + fanRepo.findTracksNeedingSnapshot.mockResolvedValue([]); + const snapshotSpy = jest.spyOn(service, 'triggerFirstFansSnapshot').mockResolvedValue([]); + + await service.snapshotAllPendingFirstFans(); + + expect(snapshotSpy).not.toHaveBeenCalled(); + }); + + it('should log error for rejected promises', async () => { + fanRepo.findTracksNeedingSnapshot.mockResolvedValue([MOCK_TRACK_ID, 'track-fail']); + jest + .spyOn(service, 'triggerFirstFansSnapshot') + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(new Error('snapshot failed')); + const loggerErrorSpy = jest.spyOn((service as any).logger, 'error').mockImplementation(); + + await service.snapshotAllPendingFirstFans(); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('track-fail'), + expect.any(Error) + ); + }); + }); }); diff --git a/src/track/tests/track.mock.ts b/src/track/tests/track.mock.ts index 072a5364..84b12d92 100644 --- a/src/track/tests/track.mock.ts +++ b/src/track/tests/track.mock.ts @@ -301,6 +301,11 @@ export const mockTrackRepository = () => ({ setScheduledAt: jest.fn(), scheduleTrackRelease: jest.fn(), releaseTrack: jest.fn(), + updateTrackCommentSettings: jest.fn(), + calculatePopularityScore: jest.fn(), + getUserInteractedTrackTags: jest.fn(), + getTopTracksByTagIds: jest.fn(), + getAllUserTracks: jest.fn(), }); export const mockUserRepository = () => ({ diff --git a/src/track/track.controller.spec.ts b/src/track/track.controller.spec.ts index 00bef8c2..c8877250 100644 --- a/src/track/track.controller.spec.ts +++ b/src/track/track.controller.spec.ts @@ -9,6 +9,10 @@ import { import { TrackController } from './track.controller'; import { TrackService } from './track.service'; import { TrackSseService } from './services/track-sse.service'; +import { AddCommentDto } from './dto/add-comment.dto'; +import { UploadTrackDto } from './dto/upload-track.dto'; +import { UpdateTrackDto } from './dto/update-track.dto'; +import { ScheduleReleaseDto } from './dto/schedule-release.dto'; import { NoBlockGuard } from '../followers/guards/no-block.guard'; import { MOCK_TRACK_ID, @@ -301,7 +305,7 @@ describe('TrackController', () => { // ─── comment ────────────────────────────────────────────────────────────────── describe('comment', () => { - const mockDto = { content: 'Great!', timestampSeconds: 56 } as any; + const mockDto = { content: 'Great!', timestampSeconds: 56 } as AddCommentDto; it('should delegate to service', async () => { service.addComment.mockResolvedValue({}); @@ -495,19 +499,19 @@ describe('TrackController', () => { // Extra tests added to increase coverage for upload/reupload and SSE describe('uploadTrack and reupload validations and SSE', () => { it('uploadTrack should throw BadRequest when audio missing', async () => { - await expect(controller.uploadTrack(MOCK_USER_ID, {} as any, undefined!)).rejects.toThrow( - BadRequestException - ); + await expect( + controller.uploadTrack(MOCK_USER_ID, {} as UploadTrackDto, undefined!) + ).rejects.toThrow(BadRequestException); }); it('reuploadTrackAudio should throw BadRequest when audio missing', async () => { await expect( - controller.reuploadTrackAudio(MOCK_TRACK_ID, MOCK_USER_ID, undefined as any) + controller.reuploadTrackAudio(MOCK_TRACK_ID, MOCK_USER_ID, undefined) ).rejects.toThrow(BadRequestException); }); it('streamTrackStatus should emit completed when track finished', async () => { - (service as any).getTrackById = jest.fn().mockResolvedValue(mockPublicTrack()); + service.getTrackById.mockResolvedValue(mockPublicTrack()); const ev = await firstValueFrom(controller.streamTrackStatus(MOCK_TRACK_ID)); expect(ev.data.event).toBe('completed'); expect(ev.data.data.trackId).toBe(MOCK_TRACK_ID); @@ -633,13 +637,13 @@ describe('TrackController', () => { data: { trackId: MOCK_TRACK_ID }, }); - await controller.uploadTrack(MOCK_USER_ID, dto as any, { audio: [mockAudioFile] }); + await controller.uploadTrack(MOCK_USER_ID, dto as UploadTrackDto, { audio: [mockAudioFile] }); expect(service.uploadTrack).toHaveBeenCalledWith(MOCK_USER_ID, dto, mockAudioFile, undefined); }); it('should throw BadRequestException when no audio file provided', async () => { - await expect(controller.uploadTrack(MOCK_USER_ID, {} as any, {})).rejects.toThrow( + await expect(controller.uploadTrack(MOCK_USER_ID, {} as UploadTrackDto, {})).rejects.toThrow( BadRequestException ); }); @@ -652,7 +656,7 @@ describe('TrackController', () => { } as Express.Multer.File; service.uploadTrack.mockResolvedValue({ status: 'success', data: {} }); - await controller.uploadTrack(MOCK_USER_ID, {} as any, { + await controller.uploadTrack(MOCK_USER_ID, {} as UploadTrackDto, { audio: [mockAudioFile], cover: [coverFile], }); @@ -668,7 +672,7 @@ describe('TrackController', () => { }; service.uploadTrack.mockResolvedValue(expected); - const result = await controller.uploadTrack(MOCK_USER_ID, {} as any, { + const result = await controller.uploadTrack(MOCK_USER_ID, {} as UploadTrackDto, { audio: [mockAudioFile], }); @@ -683,7 +687,12 @@ describe('TrackController', () => { const dto = { title: 'Updated' }; service.updateTrackMetadata.mockResolvedValue({ status: 'success', data: {} }); - await controller.updateTrackMetadata(MOCK_TRACK_ID, MOCK_USER_ID, dto as any, undefined); + await controller.updateTrackMetadata( + MOCK_TRACK_ID, + MOCK_USER_ID, + dto as UpdateTrackDto, + undefined + ); expect(service.updateTrackMetadata).toHaveBeenCalledWith( MOCK_TRACK_ID, @@ -700,7 +709,7 @@ describe('TrackController', () => { const result = await controller.updateTrackMetadata( MOCK_TRACK_ID, MOCK_USER_ID, - {} as any, + {} as UpdateTrackDto, undefined ); @@ -711,7 +720,7 @@ describe('TrackController', () => { service.updateTrackMetadata.mockRejectedValue(new ForbiddenException()); await expect( - controller.updateTrackMetadata(MOCK_TRACK_ID, MOCK_USER_ID, {} as any, undefined) + controller.updateTrackMetadata(MOCK_TRACK_ID, MOCK_USER_ID, {} as UpdateTrackDto, undefined) ).rejects.toThrow(ForbiddenException); }); @@ -719,7 +728,7 @@ describe('TrackController', () => { service.updateTrackMetadata.mockRejectedValue(new NotFoundException()); await expect( - controller.updateTrackMetadata(MOCK_TRACK_ID, MOCK_USER_ID, {} as any, undefined) + controller.updateTrackMetadata(MOCK_TRACK_ID, MOCK_USER_ID, {} as UpdateTrackDto, undefined) ).rejects.toThrow(NotFoundException); }); }); @@ -750,7 +759,7 @@ describe('TrackController', () => { it('should throw BadRequestException when no audio file provided', async () => { await expect( - controller.reuploadTrackAudio(MOCK_TRACK_ID, MOCK_USER_ID, undefined as any) + controller.reuploadTrackAudio(MOCK_TRACK_ID, MOCK_USER_ID, undefined) ).rejects.toThrow(BadRequestException); }); @@ -778,6 +787,8 @@ describe('TrackController', () => { // ─── streamTrackStatus (GET processing status) ──────────────────────────────── describe('streamTrackStatus', () => { + type StatusEvent = { data: { event: string; data: { trackId: string } } }; + it('should emit completed event immediately when track is already finished', async () => { const finishedTrack = mockPublicTrack({ trackStatus: 'finished', @@ -790,8 +801,8 @@ describe('TrackController', () => { const stream$ = controller.streamTrackStatus(MOCK_TRACK_ID); const event = await firstValueFrom(stream$); - expect((event as any).data.event).toBe('completed'); - expect((event as any).data.data.trackId).toBe(MOCK_TRACK_ID); + expect((event as StatusEvent).data.event).toBe('completed'); + expect((event as StatusEvent).data.data.trackId).toBe(MOCK_TRACK_ID); }); it('should emit failed event immediately when track is in failed status', async () => { @@ -801,7 +812,7 @@ describe('TrackController', () => { const stream$ = controller.streamTrackStatus(MOCK_TRACK_ID); const event = await firstValueFrom(stream$); - expect((event as any).data.event).toBe('failed'); + expect((event as StatusEvent).data.event).toBe('failed'); }); it('should propagate NotFoundException when track not found', async () => { @@ -1229,4 +1240,270 @@ describe('TrackController', () => { ); }); }); + + // ─── fileFilter logic — uploadTrack ────────────────────────────────────────── + // Tests replicate the inline fileFilter closure inside FileFieldsInterceptor on uploadTrack + + describe('uploadTrack fileFilter logic', () => { + const ALLOWED_AUDIO = [ + 'audio/mpeg', + 'audio/wav', + 'audio/x-wav', + 'audio/wave', + 'audio/vnd.wave', + 'audio/flac', + 'audio/x-flac', + 'audio/aiff', + 'audio/x-aiff', + ]; + const ALLOWED_IMAGE = ['image/jpeg', 'image/png', 'image/webp']; + const MAX_IMAGE_SIZE = 10 * 1024 * 1024; + + function fileFilter( + _: unknown, + file: { fieldname: string; mimetype: string; size: number }, + cb: jest.Mock + ) { + if (file.fieldname === 'audio') { + if (ALLOWED_AUDIO.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new BadRequestException('Invalid audio type. Allowed: MP3, WAV, FLAC, AIFF'), false); + } + } else if (file.fieldname === 'cover') { + if (file.size > MAX_IMAGE_SIZE) + cb(new BadRequestException('Cover image must be under 10 MB'), false); + else if (ALLOWED_IMAGE.includes(file.mimetype)) cb(null, true); + else cb(new BadRequestException('Invalid cover type. Allowed: JPEG, PNG, WebP'), false); + } else { + cb(null, false); + } + } + + let cb: jest.Mock; + beforeEach(() => { + cb = jest.fn(); + }); + + it('should accept valid audio file (audio/mpeg)', () => { + fileFilter(null, { fieldname: 'audio', mimetype: 'audio/mpeg', size: 100 }, cb); + expect(cb).toHaveBeenCalledWith(null, true); + }); + + it('should reject invalid audio type', () => { + fileFilter(null, { fieldname: 'audio', mimetype: 'video/mp4', size: 100 }, cb); + expect(cb).toHaveBeenCalledWith(expect.any(BadRequestException), false); + }); + + it('should accept valid cover image (image/jpeg)', () => { + fileFilter(null, { fieldname: 'cover', mimetype: 'image/jpeg', size: 100 }, cb); + expect(cb).toHaveBeenCalledWith(null, true); + }); + + it('should reject cover image exceeding MAX_IMAGE_SIZE', () => { + fileFilter(null, { fieldname: 'cover', mimetype: 'image/jpeg', size: 11 * 1024 * 1024 }, cb); + expect(cb).toHaveBeenCalledWith(expect.any(BadRequestException), false); + }); + + it('should reject invalid cover type', () => { + fileFilter(null, { fieldname: 'cover', mimetype: 'image/gif', size: 100 }, cb); + expect(cb).toHaveBeenCalledWith(expect.any(BadRequestException), false); + }); + + it('should silently skip unknown fieldname', () => { + fileFilter( + null, + { fieldname: 'unknown', mimetype: 'application/octet-stream', size: 100 }, + cb + ); + expect(cb).toHaveBeenCalledWith(null, false); + }); + }); + + // ─── fileFilter logic — updateTrackMetadata ─────────────────────────────────── + + describe('updateTrackMetadata cover fileFilter logic', () => { + const ALLOWED_IMAGE = ['image/jpeg', 'image/png', 'image/webp']; + + function fileFilter(_: unknown, file: { mimetype: string }, cb: jest.Mock) { + if (ALLOWED_IMAGE.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new BadRequestException('Invalid cover type. Allowed: JPEG, PNG, WebP'), false); + } + } + + let cb: jest.Mock; + beforeEach(() => { + cb = jest.fn(); + }); + + it('should accept valid image type', () => { + fileFilter(null, { mimetype: 'image/png' }, cb); + expect(cb).toHaveBeenCalledWith(null, true); + }); + + it('should reject invalid image type', () => { + fileFilter(null, { mimetype: 'image/bmp' }, cb); + expect(cb).toHaveBeenCalledWith(expect.any(BadRequestException), false); + }); + }); + + // ─── fileFilter logic — reuploadTrackAudio ──────────────────────────────────── + + describe('reuploadTrackAudio audio fileFilter logic', () => { + const ALLOWED_AUDIO = [ + 'audio/mpeg', + 'audio/wav', + 'audio/x-wav', + 'audio/wave', + 'audio/vnd.wave', + 'audio/flac', + 'audio/x-flac', + 'audio/aiff', + 'audio/x-aiff', + ]; + + function fileFilter(_: unknown, file: { mimetype: string }, cb: jest.Mock) { + if (ALLOWED_AUDIO.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new BadRequestException('Invalid audio type. Allowed: MP3, WAV, FLAC, AIFF'), false); + } + } + + let cb: jest.Mock; + beforeEach(() => { + cb = jest.fn(); + }); + + it('should accept valid audio type', () => { + fileFilter(null, { mimetype: 'audio/mpeg' }, cb); + expect(cb).toHaveBeenCalledWith(null, true); + }); + + it('should reject invalid audio type', () => { + fileFilter(null, { mimetype: 'audio/ogg' }, cb); + expect(cb).toHaveBeenCalledWith(expect.any(BadRequestException), false); + }); + }); + + // ─── updateCommentSettings ──────────────────────────────────────────────────── + + describe('updateCommentSettings', () => { + it('should delegate to service with correct args', async () => { + service.updateTrackCommentSettings.mockResolvedValue({ + status: 'success', + message: 'track comment settings updated', + }); + + await controller.updateCommentSettings(MOCK_USER_ID, MOCK_TRACK_ID, true, false); + + expect(service.updateTrackCommentSettings).toHaveBeenCalledWith( + MOCK_USER_ID, + MOCK_TRACK_ID, + true, + false + ); + }); + + it('should return service response as-is', async () => { + const expected = { status: 'success', message: 'track comment settings updated' }; + service.updateTrackCommentSettings.mockResolvedValue(expected); + + const result = await controller.updateCommentSettings( + MOCK_USER_ID, + MOCK_TRACK_ID, + true, + true + ); + + expect(result).toBe(expected); + }); + + it('should propagate BadRequestException when track not found', async () => { + service.updateTrackCommentSettings.mockRejectedValue( + new BadRequestException('track is not found') + ); + + await expect( + controller.updateCommentSettings(MOCK_USER_ID, MOCK_TRACK_ID, true, true) + ).rejects.toThrow(BadRequestException); + }); + + it('should propagate BadRequestException when user is not owner', async () => { + service.updateTrackCommentSettings.mockRejectedValue( + new BadRequestException('you are not the owner of this track') + ); + + await expect( + controller.updateCommentSettings(MOCK_USER_ID, MOCK_TRACK_ID, true, true) + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── scheduleRelease ────────────────────────────────────────────────────────── + + describe('scheduleRelease', () => { + const dto = { scheduledAt: new Date(Date.now() + 86400000).toISOString() }; + + it('should delegate to service with userId, trackId, and parsed Date', async () => { + service.scheduleRelease.mockResolvedValue({ + status: 'success', + message: 'Track scheduled for release.', + data: { trackId: MOCK_TRACK_ID, scheduledAt: dto.scheduledAt }, + }); + + await controller.scheduleRelease(MOCK_USER_ID, MOCK_TRACK_ID, dto as ScheduleReleaseDto); + + expect(service.scheduleRelease).toHaveBeenCalledWith( + MOCK_USER_ID, + MOCK_TRACK_ID, + expect.any(Date) + ); + }); + + it('should return service response as-is', async () => { + const expected = { + status: 'success', + message: 'Track scheduled for release.', + data: { trackId: MOCK_TRACK_ID }, + }; + service.scheduleRelease.mockResolvedValue(expected); + + const result = await controller.scheduleRelease( + MOCK_USER_ID, + MOCK_TRACK_ID, + dto as ScheduleReleaseDto + ); + + expect(result).toBe(expected); + }); + + it('should propagate NotFoundException when track not found', async () => { + service.scheduleRelease.mockRejectedValue(new NotFoundException('Track not found')); + + await expect( + controller.scheduleRelease(MOCK_USER_ID, MOCK_TRACK_ID, dto as ScheduleReleaseDto) + ).rejects.toThrow(NotFoundException); + }); + + it('should propagate ForbiddenException when user does not own the track', async () => { + service.scheduleRelease.mockRejectedValue(new ForbiddenException()); + + await expect( + controller.scheduleRelease(MOCK_USER_ID, MOCK_TRACK_ID, dto as ScheduleReleaseDto) + ).rejects.toThrow(ForbiddenException); + }); + + it('should propagate BadRequestException for past date', async () => { + service.scheduleRelease.mockRejectedValue( + new BadRequestException('Scheduled date must be in the future') + ); + + await expect( + controller.scheduleRelease(MOCK_USER_ID, MOCK_TRACK_ID, dto as ScheduleReleaseDto) + ).rejects.toThrow(BadRequestException); + }); + }); }); diff --git a/src/track/track.service.spec.ts b/src/track/track.service.spec.ts index 6723019d..d909a057 100644 --- a/src/track/track.service.spec.ts +++ b/src/track/track.service.spec.ts @@ -2383,4 +2383,140 @@ describe('TrackService', () => { ).rejects.toThrow(BadRequestException); }); }); + + // ─── updateTrackCommentSettings ─────────────────────────────────────────────── + + describe('updateTrackCommentSettings', () => { + it('should update comment settings and return success', async () => { + trackRepo.findById.mockResolvedValue(mockOwnTrack()); + trackRepo.updateTrackCommentSettings.mockResolvedValue(undefined); + + const result = await service.updateTrackCommentSettings( + MOCK_USER_ID, + MOCK_TRACK_ID, + true, + false + ); + + expect(trackRepo.updateTrackCommentSettings).toHaveBeenCalledWith(MOCK_TRACK_ID, true, false); + expect(result).toEqual({ status: 'success', message: 'track comment settings updated' }); + }); + + it('should throw BadRequestException when track not found', async () => { + trackRepo.findById.mockResolvedValue(null); + + await expect( + service.updateTrackCommentSettings(MOCK_USER_ID, MOCK_TRACK_ID, true, true) + ).rejects.toThrow(new BadRequestException('track is not found')); + }); + + it('should throw BadRequestException when user is not the owner', async () => { + trackRepo.findById.mockResolvedValue(mockPublicTrack()); + + await expect( + service.updateTrackCommentSettings(MOCK_USER_ID, MOCK_TRACK_ID, true, true) + ).rejects.toThrow(new BadRequestException('you are not the owner of this track')); + }); + + it('should not call repo when track not found', async () => { + trackRepo.findById.mockResolvedValue(null); + + await expect( + service.updateTrackCommentSettings(MOCK_USER_ID, MOCK_TRACK_ID, true, true) + ).rejects.toThrow(); + + expect(trackRepo.updateTrackCommentSettings).not.toHaveBeenCalled(); + }); + }); + + // ─── getUserTracks ──────────────────────────────────────────────────────────── + + describe('getUserTracks', () => { + it('should call getAllUserTracks with username and return result', async () => { + const tracks = [mockPublicTrack()]; + trackRepo.getAllUserTracks.mockResolvedValue(tracks); + + const result = await service.getUserTracks('dj_nour'); + + expect(trackRepo.getAllUserTracks).toHaveBeenCalledWith('dj_nour'); + expect(result).toBe(tracks); + }); + + it('should return empty array when no tracks', async () => { + trackRepo.getAllUserTracks.mockResolvedValue([]); + + const result = await service.getUserTracks('unknown_user'); + + expect(result).toEqual([]); + }); + }); + + // ─── getPopularityScore ─────────────────────────────────────────────────────── + + describe('getPopularityScore', () => { + it('should return popularity score from repository', async () => { + trackRepo.calculatePopularityScore.mockResolvedValue(87.5); + + const result = await service.getPopularityScore(MOCK_TRACK_ID); + + expect(trackRepo.calculatePopularityScore).toHaveBeenCalledWith(MOCK_TRACK_ID); + expect(result).toBe(87.5); + }); + + it('should return 0 when track has no plays', async () => { + trackRepo.calculatePopularityScore.mockResolvedValue(0); + + const result = await service.getPopularityScore(MOCK_TRACK_ID); + + expect(result).toBe(0); + }); + }); + + // ─── getUserInteractedTrackTags ─────────────────────────────────────────────── + + describe('getUserInteractedTrackTags', () => { + it('should return genres the user has interacted with', async () => { + const genres = [ + { genreId: 'g1', name: 'Electronic' }, + { genreId: 'g2', name: 'Hip-Hop' }, + ]; + trackRepo.getUserInteractedTrackTags.mockResolvedValue(genres); + + const result = await service.getUserInteractedTrackTags(MOCK_USER_ID); + + expect(trackRepo.getUserInteractedTrackTags).toHaveBeenCalledWith(MOCK_USER_ID); + expect(result).toBe(genres); + }); + + it('should return empty array when user has no interactions', async () => { + trackRepo.getUserInteractedTrackTags.mockResolvedValue([]); + + const result = await service.getUserInteractedTrackTags(MOCK_USER_ID); + + expect(result).toEqual([]); + }); + }); + + // ─── getTopTracksByTagIds ───────────────────────────────────────────────────── + + describe('getTopTracksByTagIds', () => { + it('should call repo with tagIds and userId', async () => { + const tagIds = ['tag-1', 'tag-2']; + const tracks = [mockPublicTrack()]; + trackRepo.getTopTracksByTagIds.mockResolvedValue(tracks); + + const result = await service.getTopTracksByTagIds(tagIds, MOCK_USER_ID); + + expect(trackRepo.getTopTracksByTagIds).toHaveBeenCalledWith(tagIds, MOCK_USER_ID); + expect(result).toBe(tracks); + }); + + it('should return empty array when no matching tracks', async () => { + trackRepo.getTopTracksByTagIds.mockResolvedValue([]); + + const result = await service.getTopTracksByTagIds(['tag-999'], MOCK_USER_ID); + + expect(result).toEqual([]); + }); + }); }); From f729f072089e663b135c7be9101c2794e9bb4cec Mon Sep 17 00:00:00 2001 From: YaraSenousy <102993483+YaraSenousy@users.noreply.github.com> Date: Sun, 3 May 2026 04:49:03 +0300 Subject: [PATCH 3/3] fix: update reuploadTrackAudio tests to handle undefined audio parameter correctly --- src/track/track.controller.spec.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/track/track.controller.spec.ts b/src/track/track.controller.spec.ts index c8877250..04b2f94c 100644 --- a/src/track/track.controller.spec.ts +++ b/src/track/track.controller.spec.ts @@ -506,7 +506,11 @@ describe('TrackController', () => { it('reuploadTrackAudio should throw BadRequest when audio missing', async () => { await expect( - controller.reuploadTrackAudio(MOCK_TRACK_ID, MOCK_USER_ID, undefined) + controller.reuploadTrackAudio( + MOCK_TRACK_ID, + MOCK_USER_ID, + undefined as unknown as Express.Multer.File + ) ).rejects.toThrow(BadRequestException); }); @@ -759,7 +763,11 @@ describe('TrackController', () => { it('should throw BadRequestException when no audio file provided', async () => { await expect( - controller.reuploadTrackAudio(MOCK_TRACK_ID, MOCK_USER_ID, undefined) + controller.reuploadTrackAudio( + MOCK_TRACK_ID, + MOCK_USER_ID, + undefined as unknown as Express.Multer.File + ) ).rejects.toThrow(BadRequestException); });