diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 47c4f68f..8ede5e8d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -47,7 +47,7 @@ jobs: username: ${{ secrets.EC2_USER }} key: ${{ secrets.EC2_SSH_KEY }} script: | - cd ~/app + cd ~/app-new docker pull ${{ secrets.DOCKERHUB_USERNAME }}/harmonica-backend:${{ env.TAG }} docker compose -p ${{ env.PROJECT }} \ -f ${{ env.COMPOSE_FILE }} \ diff --git a/package.json b/package.json index 66aec104..d4c4b6d1 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "seed": "ts-node -P tsconfig.json src/seed-main.ts", "schema:drop": "typeorm-ts-node-commonjs schema:drop -d src/ormconfig.ts", "meili:clear": "ts-node src/search/clear-meili.ts", + "meili:reindex": "ts-node src/search/reindex-meili.ts", "seed:fresh": "npm run schema:drop && npm run migration:run && npm run meili:clear && npm run seed", "test": "jest", "test:watch": "jest --watch", @@ -192,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.controller.spec.ts b/src/admin/admin.controller.spec.ts index 714d356e..d1c296a7 100644 --- a/src/admin/admin.controller.spec.ts +++ b/src/admin/admin.controller.spec.ts @@ -1,20 +1,207 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; +import { SuspendUserDto } from './dto/suspend-user.dto'; describe('AdminController', () => { let controller: AdminController; + let adminService: jest.Mocked; beforeEach(async () => { + const mockAdminService = { + getUsers: jest.fn(), + suspendUser: jest.fn(), + reactivateUser: jest.fn(), + getTopTracks: jest.fn(), + getPlatformStats: jest.fn(), + getEngagementAnalytics: jest.fn(), + getAllTracksWithReportCount: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ controllers: [AdminController], - providers: [AdminService], + providers: [{ provide: AdminService, useValue: mockAdminService }], }).compile(); controller = module.get(AdminController); + adminService = module.get(AdminService); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('getUsers', () => { + it('should return successfully paginated users with correct hasMore calculation', async () => { + const limit = 20; + const offset = 0; + const search = 'test'; + const mockUsers = [{ id: '1', username: 'test1' }]; + const total = 50; + + adminService.getUsers.mockResolvedValue({ users: mockUsers as any, total }); + + const result = await controller.getUsers(limit, offset, search); + + expect(adminService.getUsers).toHaveBeenCalledWith( + limit, + offset, + search, + undefined, + undefined + ); + expect(result).toEqual({ + status: 'success', + data: { + users: mockUsers, + pagination: { + limit: 20, + offset: 0, + total: 50, + hasMore: true, + }, + }, + }); + }); + + it('should set hasMore to false when offset + limit >= total', async () => { + const limit = 20; + const offset = 40; + const total = 50; + + adminService.getUsers.mockResolvedValue({ users: [] as any, total }); + + const result = await controller.getUsers(limit, offset, undefined); + + expect(result.data.pagination.hasMore).toBe(false); + }); + }); + + describe('suspendUser', () => { + it('should call suspendUser on the service and return a success message', async () => { + const userId = 'user-123'; + const suspendDto: SuspendUserDto = { reason: 'Violation of terms' }; + + adminService.suspendUser.mockResolvedValue(undefined); + + const result = await controller.suspendUser(userId, suspendDto); + + expect(adminService.suspendUser).toHaveBeenCalledWith(userId, suspendDto.reason); + expect(result).toEqual({ + status: 'success', + message: 'User suspended successfully', + }); + }); + }); + + describe('reactivateUser', () => { + it('should call reactivateUser on the service and return a success message', async () => { + const userId = 'user-123'; + + adminService.reactivateUser.mockResolvedValue(undefined); + + const result = await controller.reactivateUser(userId); + + expect(adminService.reactivateUser).toHaveBeenCalledWith(userId); + expect(result).toEqual({ + status: 'success', + message: 'User reactivated successfully', + }); + }); + }); + + describe('getTopTracks', () => { + it('should return a success response containing the top tracks', async () => { + const mockTracks = [{ trackId: '1', title: 'Banger 1', playCount: 1000 }]; + + adminService.getTopTracks.mockResolvedValue(mockTracks as any); + + const result = await controller.getTopTracks(); + + expect(adminService.getTopTracks).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + status: 'success', + data: { + tracks: mockTracks, + }, + }); + }); + }); + + describe('getPlatformStats', () => { + it('should return a success response containing platform statistics', async () => { + const mockStats = { + activeUsers: 150, + totalPlays: 5000, + totalUploads: 300, + openReports: 5, + }; + + adminService.getPlatformStats.mockResolvedValue(mockStats); + + const result = await controller.getPlatformStats(); + + expect(adminService.getPlatformStats).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + status: 'success', + data: mockStats, + }); + }); + }); + + describe('getEngagementAnalytics', () => { + it('should return a success response containing the 30-day engagement timeline', async () => { + const mockTimeline = [ + { + date: '2026-04-20', + activeUsers: 85, + uploads: 5, + plays: 120, + likes: 45, + reposts: 10, + }, + ]; + + adminService.getEngagementAnalytics.mockResolvedValue(mockTimeline as any); + + const result = await controller.getEngagementAnalytics(); + + expect(adminService.getEngagementAnalytics).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + status: 'success', + data: { + timeline: mockTimeline, + }, + }); + }); + }); + + describe('getAllTracksWithReportCount', () => { + it('should return paginated tracks with their report counts', async () => { + const mockResult = { + status: 'success', + data: [{ trackId: 'track-123', title: 'Midnight Drive', reportsCount: 3 }], + meta: { total: 1, page: 1, limit: 20, totalPages: 1 }, + }; + + adminService.getAllTracksWithReportCount.mockResolvedValue(mockResult as any); + + const result = await controller.getAllTracksWithReportCount(1, 20, undefined); + + expect(adminService.getAllTracksWithReportCount).toHaveBeenCalledWith(1, 20, undefined); + expect(result).toEqual(mockResult); + }); + + it('should forward optional status filter to service', async () => { + adminService.getAllTracksWithReportCount.mockResolvedValue({ + status: 'success', + data: [], + meta: { total: 0, page: 1, limit: 20, totalPages: 0 }, + } as any); + + await controller.getAllTracksWithReportCount(1, 20, 'finished' as any); + + expect(adminService.getAllTracksWithReportCount).toHaveBeenCalledWith(1, 20, 'finished'); + }); + }); }); diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index 70a9d117..1d51b7b5 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -1,7 +1,160 @@ -import { Controller } from '@nestjs/common'; +import { + Controller, + Get, + Query, + ParseIntPipe, + DefaultValuePipe, + Patch, + Param, + ParseUUIDPipe, + Body, + Delete, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { AdminService } from './admin.service'; +import { SuspendUserDto } from './dto/suspend-user.dto'; +import { Roles } from '../authentication/decorators/roles.decorator'; +import { + ApiGetAllUsers, + ApiSuspendUser, + ApiReactivateUser, + ApiGetTopTracks, + ApiGetPlatformStats, + ApiGetEngagementAnalytics, + ApiGetAllTracksWithReportCount, + ApiHideTrack, + ApiDeleteTrack, +} from './admin.swagger'; +import { TrackStatus } from '../track/enums/track-status.enum'; +import { Role, UserStatus } from './report-enums'; +@Roles('admin') +@ApiTags('Admin') @Controller('admin') export class AdminController { constructor(private readonly adminService: AdminService) {} + + @ApiGetAllUsers() + @Get('users') + async getUsers( + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number, + @Query('search') search?: string, + @Query('role') role?: Role, + @Query('status') status?: UserStatus + ) { + const safeLimit = Math.min(limit, 50); + + const result = await this.adminService.getUsers(safeLimit, offset, search, role, status); + + return { + status: 'success', + data: { + users: result.users, + pagination: { + limit: safeLimit, + offset, + total: result.total, + hasMore: offset + safeLimit < result.total, + }, + }, + }; + } + + @Patch('users/:user_id/suspend') + @ApiSuspendUser() + async suspendUser( + @Param('user_id', ParseUUIDPipe) userId: string, + @Body() suspendUserDto: SuspendUserDto + ) { + await this.adminService.suspendUser(userId, suspendUserDto.reason); + + return { + status: 'success', + message: 'User suspended successfully', + }; + } + + @Patch('users/:user_id/reactivate') + @ApiReactivateUser() + async reactivateUser(@Param('user_id', ParseUUIDPipe) userId: string) { + await this.adminService.reactivateUser(userId); + + return { + status: 'success', + message: 'User reactivated successfully', + }; + } + + @ApiGetTopTracks() + @Get('tracks/top') + async getTopTracks() { + const tracks = await this.adminService.getTopTracks(); + + return { + status: 'success', + data: { + tracks, + }, + }; + } + + @ApiGetPlatformStats() + @Get('stats') + async getPlatformStats() { + const stats = await this.adminService.getPlatformStats(); + + return { + status: 'success', + data: stats, + }; + } + + @ApiGetEngagementAnalytics() + @Get('engagement') + async getEngagementAnalytics() { + const analytics = await this.adminService.getEngagementAnalytics(); + + return { + status: 'success', + data: { + timeline: analytics, + }, + }; + } + + @ApiGetAllTracksWithReportCount() + @Get('tracks') + getAllTracksWithReportCount( + @Query('page') page: number = 1, + @Query('limit') limit: number = 20, + @Query('status') status?: TrackStatus + ) { + return this.adminService.getAllTracksWithReportCount(page, limit, status); + } + + @ApiHideTrack() + @Patch('tracks/:track_id/hide') + async hideTrack( + @Param('track_id', ParseUUIDPipe) trackId: string, + @Body('hidden') hidden: boolean + ) { + await this.adminService.toggleTrackVisibility(trackId, hidden); + + return { + status: 'success', + message: `Track ${hidden ? 'hidden' : 'shown'} successfully`, + }; + } + + @ApiDeleteTrack() + @Delete('tracks/:track_id') + async deleteTrack(@Param('track_id', ParseUUIDPipe) trackId: string) { + await this.adminService.adminDeleteTrack(trackId); + + return { + status: 'success', + message: 'Track deleted successfully', + }; + } } diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index 2926ee84..5628fdf7 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -1,9 +1,25 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { AdminService } from './admin.service'; import { AdminController } from './admin.controller'; +import { ReportController } from './report.controller'; +import { Report } from './entities/report.entity'; +import { TrackModule } from '../track/track.module'; +import { UserModule } from '../user/user.module'; +import { User } from '../user/entities/user.entity'; +import { Track } from '../track/entities/track.entity'; +import { AdminRepository } from './admin.repository'; +import { TrackLikes } from '../track/entities/track-likes.entity'; +import { TrackRepost } from '../track/entities/track-reposts.entity'; +import { RecentlyPlayed } from '../track/entities/recently-played.entity'; @Module({ - controllers: [AdminController], - providers: [AdminService], + imports: [ + TypeOrmModule.forFeature([Report, User, Track, TrackLikes, TrackRepost, RecentlyPlayed]), + TrackModule, + UserModule, + ], + controllers: [AdminController, ReportController], + providers: [AdminService, AdminRepository], }) export class AdminModule {} diff --git a/src/admin/admin.repository.ts b/src/admin/admin.repository.ts new file mode 100644 index 00000000..2a585e9b --- /dev/null +++ b/src/admin/admin.repository.ts @@ -0,0 +1,198 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Brackets } from 'typeorm'; +import { User } from '../user/entities/user.entity'; +import { Track } from '../track/entities/track.entity'; +import { Report } from './entities/report.entity'; +import { ReportStatus, Role, UserStatus } from './report-enums'; +import { RecentlyPlayed } from '../track/entities/recently-played.entity'; +import { TrackLikes } from '../track/entities/track-likes.entity'; +import { TrackRepost } from '../track/entities/track-reposts.entity'; + +@Injectable() +export class AdminRepository { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Track) + private readonly trackRepository: Repository, + @InjectRepository(Report) + private readonly reportRepository: Repository, + @InjectRepository(RecentlyPlayed) + private readonly recentlyPlayedRepository: Repository, + @InjectRepository(TrackLikes) + private readonly trackLikesRepository: Repository, + @InjectRepository(TrackRepost) + private readonly trackRepostRepository: Repository + ) {} + + async findAllUsers( + limit: number, + offset: number, + search?: string, + role?: Role, + status?: UserStatus + ): Promise<[User[], number]> { + const query = this.userRepository + .createQueryBuilder('user') + .leftJoinAndSelect('user.emails', 'emailRecord'); + + if (search) { + query.andWhere( + new Brackets((qb) => { + qb.where('user.username ILIKE :search', { search: `%${search}%` }).orWhere( + 'emailRecord.email ILIKE :search', + { search: `%${search}%` } + ); + }) + ); + } + + if (role) { + query.andWhere('user.role = :role', { role }); + } + + if (status) { + if (status === UserStatus.SUSPENDED) { + query.andWhere('user.isSuspended = :isSuspended', { isSuspended: true }); + } else if (status === UserStatus.ACTIVE) { + query.andWhere('user.isSuspended = :isSuspended', { isSuspended: false }); + } + } + query.skip(offset).take(limit).orderBy('user.createdAt', 'DESC'); + + return query.getManyAndCount(); + } + + async updateUserSuspensionStatus( + userId: string, + isSuspended: boolean, + reason: string | null + ): Promise { + await this.userRepository.update(userId, { + isSuspended, + suspensionReason: reason, + }); + } + + async getTopTracks(): Promise { + return this.trackRepository.find({ + order: { + playCount: 'DESC', + }, + take: 5, + relations: ['user'], + select: { + trackId: true, + title: true, + playCount: true, + coverImage: true, + durationSeconds: true, + user: { + userId: true, + username: true, + }, + }, + }); + } + + async getPlatformStats() { + const [activeUsers, totalUploads, openReports, totalPlaysResult] = await Promise.all([ + this.userRepository.count({ where: { isSuspended: false } }), + + this.trackRepository.count(), + + this.reportRepository.count({ where: { status: ReportStatus.PENDING } }), + + this.trackRepository + .createQueryBuilder('track') + .select('SUM(track.playCount)', 'sum') + .getRawOne(), + ]); + + return { + activeUsers, + totalPlays: parseInt(totalPlaysResult?.sum || '0', 10), + totalUploads, + openReports, + }; + } + + async getEngagementAnalytics30Days() { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const [uploadsDb, playsDb, likesDb, repostsDb, activeUsersDb] = await Promise.all([ + this.trackRepository + .createQueryBuilder('track') + .select('DATE(track.createdAt)', 'date') + .addSelect('COUNT(*)', 'count') + .where('track.createdAt >= :date', { date: thirtyDaysAgo }) + .groupBy('DATE(track.createdAt)') + .getRawMany(), + + this.recentlyPlayedRepository + .createQueryBuilder('rp') + .select('DATE(rp.playedAt)', 'date') + .addSelect('COUNT(*)', 'count') + .where('rp.playedAt >= :date', { date: thirtyDaysAgo }) + .groupBy('DATE(rp.playedAt)') + .getRawMany(), + + this.trackLikesRepository + .createQueryBuilder('like') + .select('DATE(like.createdAt)', 'date') + .addSelect('COUNT(*)', 'count') + .where('like.createdAt >= :date', { date: thirtyDaysAgo }) + .groupBy('DATE(like.createdAt)') + .getRawMany(), + + this.trackRepostRepository + .createQueryBuilder('repost') + .select('DATE(repost.createdAt)', 'date') + .addSelect('COUNT(*)', 'count') + .where('repost.createdAt >= :date', { date: thirtyDaysAgo }) + .groupBy('DATE(repost.createdAt)') + .getRawMany(), + + this.recentlyPlayedRepository + .createQueryBuilder('rp') + .select('DATE(rp.playedAt)', 'date') + .addSelect('COUNT(DISTINCT rp.userId)', 'count') + .where('rp.playedAt >= :date', { date: thirtyDaysAgo }) + .groupBy('DATE(rp.playedAt)') + .getRawMany(), + ]); + + const last30Days = []; + const dataMap = new Map(); + + // eslint-disable-next-line no-plusplus + for (let i = 29; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const dateStr = d.toISOString().split('T')[0]; + + const dayData = { date: dateStr, activeUsers: 0, uploads: 0, plays: 0, likes: 0, reposts: 0 }; + last30Days.push(dayData); + dataMap.set(dateStr, dayData); + } + + const mergeData = (dbResults: any[], key: string) => { + dbResults.forEach((row) => { + const dateStr = new Date(row.date).toISOString().split('T')[0]; + if (dataMap.has(dateStr)) { + dataMap.get(dateStr)[key] = parseInt(row.count, 10); + } + }); + }; + + mergeData(uploadsDb, 'uploads'); + mergeData(playsDb, 'plays'); + mergeData(likesDb, 'likes'); + mergeData(repostsDb, 'reposts'); + mergeData(activeUsersDb, 'activeUsers'); + + return last30Days; + } +} diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts index 5e5e153d..f94c4cae 100644 --- a/src/admin/admin.service.spec.ts +++ b/src/admin/admin.service.spec.ts @@ -1,18 +1,536 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { AdminService } from './admin.service'; +import { AdminRepository } from './admin.repository'; +import { UserService } from '../user/user.service'; +import { TrackService } from '../track/track.service'; +import { TrackRepository } from '../track/track.repository'; +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; + let adminRepository: jest.Mocked; + let userService: jest.Mocked; + + const mockUser = { id: 'user-123', username: 'testuser' }; + + const mockReportRepository = { + findOne: jest.fn(), + findAndCount: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const mockUserService = { + findById: jest.fn(), + }; + + 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 = { + findById: jest.fn(), + }; beforeEach(async () => { + const mockAdminRepository = { + findAllUsers: jest.fn(), + updateUserSuspensionStatus: jest.fn(), + getTopTracks: jest.fn(), + getPlatformStats: jest.fn(), + getEngagementAnalytics30Days: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [AdminService], + providers: [ + AdminService, + { provide: AdminRepository, useValue: mockAdminRepository }, + { provide: UserService, useValue: mockUserService }, + { provide: getRepositoryToken(Report), useValue: mockReportRepository }, + { provide: TrackService, useValue: mockTrackService }, + { provide: TrackRepository, useValue: mockTrackRepository }, + { provide: UserRepository, useValue: mockUserRepository }, + ], }).compile(); service = module.get(AdminService); + adminRepository = module.get(AdminRepository); + userService = module.get(UserService); }); + afterEach(() => jest.clearAllMocks()); + it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getUsers', () => { + it('should return a list of users and the total count', async () => { + const limit = 20; + const offset = 0; + const search = 'test'; + const mockUsers = [mockUser]; + const total = 1; + + adminRepository.findAllUsers.mockResolvedValue([mockUsers as any, total]); + + const result = await service.getUsers(limit, offset, search); + + expect(adminRepository.findAllUsers).toHaveBeenCalledWith( + limit, + offset, + search, + undefined, + undefined + ); + expect(result).toEqual({ users: mockUsers, total }); + }); + }); + + describe('suspendUser', () => { + it('should suspend a user successfully', async () => { + const userId = 'user-123'; + const reason = 'Spamming'; + + userService.findById.mockResolvedValue(mockUser as any); + adminRepository.updateUserSuspensionStatus.mockResolvedValue(undefined); + + await service.suspendUser(userId, reason); + + expect(userService.findById).toHaveBeenCalledWith(userId); + expect(adminRepository.updateUserSuspensionStatus).toHaveBeenCalledWith(userId, true, reason); + }); + + it('should throw NotFoundException if user to suspend is not found', async () => { + const userId = 'non-existent-user'; + const reason = 'Spamming'; + + userService.findById.mockResolvedValue(null); + + await expect(service.suspendUser(userId, reason)).rejects.toThrow(NotFoundException); + expect(userService.findById).toHaveBeenCalledWith(userId); + expect(adminRepository.updateUserSuspensionStatus).not.toHaveBeenCalled(); + }); + }); + + describe('reactivateUser', () => { + it('should reactivate a user successfully and clear the reason', async () => { + const userId = 'user-123'; + + userService.findById.mockResolvedValue(mockUser as any); + adminRepository.updateUserSuspensionStatus.mockResolvedValue(undefined); + + await service.reactivateUser(userId); + + expect(userService.findById).toHaveBeenCalledWith(userId); + expect(adminRepository.updateUserSuspensionStatus).toHaveBeenCalledWith(userId, false, null); + }); + + it('should throw NotFoundException if user to reactivate is not found', async () => { + const userId = 'non-existent-user'; + + userService.findById.mockResolvedValue(null); + + await expect(service.reactivateUser(userId)).rejects.toThrow(NotFoundException); + expect(userService.findById).toHaveBeenCalledWith(userId); + expect(adminRepository.updateUserSuspensionStatus).not.toHaveBeenCalled(); + }); + }); + + describe('getTopTracks', () => { + it('should return the top 5 tracks from the repository', async () => { + const mockTracks = [ + { trackId: '1', title: 'Banger 1', playCount: 1000 }, + { trackId: '2', title: 'Banger 2', playCount: 800 }, + ]; + + adminRepository.getTopTracks.mockResolvedValue(mockTracks as any); + + const result = await service.getTopTracks(); + + expect(adminRepository.getTopTracks).toHaveBeenCalledTimes(1); + expect(result).toEqual(mockTracks); + }); + }); + + describe('getPlatformStats', () => { + it('should return overall platform statistics', async () => { + const mockStats = { + activeUsers: 150, + totalPlays: 5000, + totalUploads: 300, + openReports: 5, + }; + + adminRepository.getPlatformStats.mockResolvedValue(mockStats); + + const result = await service.getPlatformStats(); + + expect(adminRepository.getPlatformStats).toHaveBeenCalledTimes(1); + expect(result).toEqual(mockStats); + }); + }); + + describe('getEngagementAnalytics', () => { + it('should return 30-day engagement analytics from the repository', async () => { + const mockTimeline = [ + { + date: '2026-04-20', + activeUsers: 85, + uploads: 5, + plays: 120, + likes: 45, + reposts: 10, + }, + ]; + + adminRepository.getEngagementAnalytics30Days.mockResolvedValue(mockTimeline as any); + + const result = await service.getEngagementAnalytics(); + + expect(adminRepository.getEngagementAnalytics30Days).toHaveBeenCalledTimes(1); + expect(result).toEqual(mockTimeline); + }); + }); // 👈 Here are the fully restored closing brackets! + + describe('addReport', () => { + it('should submit a report successfully', async () => { + const userId = 'user-123'; + const dto: CreateReportDto = { + type: ReportType.TRACK, + targetId: 'track-123', + reason: ReportReason.COPYRIGHT, + description: 'Stolen', + }; + mockReportRepository.findOne.mockResolvedValue(null); + mockTrackService.getTrackById.mockResolvedValue({ userId: 'other-user' }); + mockReportRepository.create.mockReturnValue({ ...dto, reporterId: userId }); + mockReportRepository.save.mockResolvedValue(undefined); + + const result = await service.addReport(userId, dto); + + expect(result.status).toBe('success'); + expect(result.message).toBe('Report submitted successfully'); + }); + + it('should throw BadRequestException if report already exists', async () => { + mockReportRepository.findOne.mockResolvedValue({ reportId: 'existing' }); + + await expect( + service.addReport('user-123', { + type: ReportType.TRACK, + targetId: 'track-123', + reason: ReportReason.COPYRIGHT, + } as CreateReportDto) + ).rejects.toThrow(BadRequestException); + }); + + it('should throw ForbiddenException if user reports themselves', async () => { + mockReportRepository.findOne.mockResolvedValue(null); + + await expect( + service.addReport('user-123', { + type: ReportType.USER, + targetId: 'user-123', + reason: ReportReason.SPAM, + } as CreateReportDto) + ).rejects.toThrow(ForbiddenException); + }); + + it('should throw ForbiddenException if user reports their own track', async () => { + mockReportRepository.findOne.mockResolvedValue(null); + mockTrackService.getTrackById.mockResolvedValue({ userId: 'user-123' }); + + await expect( + service.addReport('user-123', { + type: ReportType.TRACK, + targetId: 'track-123', + reason: ReportReason.COPYRIGHT, + } as CreateReportDto) + ).rejects.toThrow(ForbiddenException); + }); + + it('should throw ForbiddenException if user reports their own comment', async () => { + mockReportRepository.findOne.mockResolvedValue(null); + mockTrackRepository.findCommentById.mockResolvedValue({ + commentId: 'c-1', + userId: 'user-123', + content: 'hi', + }); + + await expect( + service.addReport('user-123', { + type: ReportType.COMMENT, + targetId: 'comment-123', + reason: ReportReason.SPAM, + } as CreateReportDto) + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getAllReports', () => { + it('should return paginated enriched reports', async () => { + const mockReport = { + reportId: 'r-1', + reporterId: 'user-123', + targetId: 'track-123', + type: ReportType.TRACK, + reason: ReportReason.COPYRIGHT, + status: ReportStatus.PENDING, + }; + mockReportRepository.findAndCount.mockResolvedValue([[mockReport], 1]); + mockUserRepository.findById.mockResolvedValue({ + userId: 'user-123', + username: 'dj_nour', + displayName: 'DJ Nour', + avatarUrl: null, + coverPhoto: null, + }); + mockTrackService.getTrackById.mockResolvedValue({ + trackId: 'track-123', + title: 'Midnight Drive', + coverImage: null, + }); + + const result = await service.getAllReports(1, 20); + + expect(result.status).toBe('success'); + expect(result.data).toHaveLength(1); + expect(result.meta.total).toBe(1); + }); + }); + + describe('deleteReport', () => { + it('should delete a report successfully', async () => { + mockReportRepository.findOne.mockResolvedValue({ reportId: 'r-1' }); + mockReportRepository.delete.mockResolvedValue(undefined); + + const result = await service.deleteReport('r-1'); + + expect(result.status).toBe('success'); + expect(mockReportRepository.delete).toHaveBeenCalledWith('r-1'); + }); + + it('should throw BadRequestException if report not found', async () => { + mockReportRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteReport('non-existent')).rejects.toThrow(BadRequestException); + }); + }); + + describe('updateReportStatus', () => { + it('should update report status successfully', async () => { + mockReportRepository.findOne.mockResolvedValue({ reportId: 'r-1' }); + mockReportRepository.update.mockResolvedValue(undefined); + + const result = await service.updateReportStatus(ReportStatus.RESOLVED, 'r-1'); + + expect(result.status).toBe('success'); + expect(mockReportRepository.update).toHaveBeenCalledWith('r-1', { + status: ReportStatus.RESOLVED, + reviewedAt: expect.any(Date), + }); + }); + + it('should throw BadRequestException if report not found', async () => { + mockReportRepository.findOne.mockResolvedValue(null); + + await expect( + service.updateReportStatus(ReportStatus.RESOLVED, 'non-existent') + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('getReport', () => { + it('should return a single enriched report', async () => { + const mockReport = { + reportId: 'r-1', + reporterId: 'user-123', + targetId: 'track-123', + type: ReportType.TRACK, + }; + mockReportRepository.findOne.mockResolvedValue(mockReport); + mockUserRepository.findById.mockResolvedValue({ + userId: 'user-123', + username: 'dj_nour', + displayName: 'DJ Nour', + avatarUrl: null, + coverPhoto: null, + }); + mockTrackService.getTrackById.mockResolvedValue({ + trackId: 'track-123', + title: 'Midnight Drive', + coverImage: null, + }); + + const result = await service.getReport('r-1'); + + expect(result.status).toBe('success'); + expect(result.data).toHaveProperty('reporter'); + expect(result.data).toHaveProperty('target'); + }); + + it('should throw BadRequestException if report not found', async () => { + mockReportRepository.findOne.mockResolvedValue(null); + + await expect(service.getReport('non-existent')).rejects.toThrow(BadRequestException); + }); + }); + + describe('getAllTracksWithReportCount', () => { + it('should return paginated tracks with report counts', async () => { + const mockTracks = [{ trackId: 'track-123', title: 'Midnight Drive', reportsCount: 3 }]; + mockTrackRepository.findAllTracksWithReportCount.mockResolvedValue({ + tracks: mockTracks, + total: 1, + }); + + const result = await service.getAllTracksWithReportCount(1, 20); + + expect(result.status).toBe('success'); + expect(result.data).toHaveLength(1); + expect(result.data[0]).toHaveProperty('reportsCount', 3); + 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/admin/admin.service.ts b/src/admin/admin.service.ts index 796f9fd1..b2da0423 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -1,4 +1,330 @@ -import { Injectable } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Report } from './entities/report.entity'; +import { CreateReportDto } from './dto/createReport.dto'; +import { ReportReason, ReportStatus, ReportType, Role, UserStatus } from './report-enums'; +import { TrackService } from '../track/track.service'; +import { TrackRepository } from '../track/track.repository'; +import { UserRepository } from '../user/user.repository'; + +import { AdminRepository } from './admin.repository'; +import { UserService } from '../user/user.service'; +import { TrackStatus } from '../track/enums/track-status.enum'; +import { deleteDocument, mapTrack, updateDocument } from '../search/indexing'; +import { TrackVisibility } from '../track/enums/track-visibility.enum'; @Injectable() -export class AdminService {} +export class AdminService { + constructor( + @InjectRepository(Report) + private readonly reportRepository: Repository, + private readonly trackService: TrackService, + private readonly trackRepository: TrackRepository, + private readonly userRepository: UserRepository, + private readonly adminRepository: AdminRepository, + private readonly userService: UserService + ) {} + + async addReport(userId: string, createReportDto: CreateReportDto) { + const existingReport = await this.reportRepository.findOne({ + where: { + reporterId: userId, + targetId: createReportDto.targetId, + }, + }); + if (existingReport) { + throw new BadRequestException('you have already submitted this report'); + } + if (createReportDto.type === ReportType.USER && userId === createReportDto.targetId) { + throw new ForbiddenException('you cannot report yourself'); + } + if (createReportDto.type === ReportType.TRACK) { + const track = await this.trackService.getTrackById(createReportDto.targetId); + if (track.userId === userId) { + throw new ForbiddenException('you cannot report your own track'); + } + } + if (createReportDto.type === ReportType.COMMENT) { + const comment = await this.trackRepository.findCommentById(createReportDto.targetId); + if (comment!.userId === userId) { + throw new ForbiddenException('you cannot report your own comment'); + } + } + const report = this.reportRepository.create({ + reporterId: userId, + type: createReportDto.type, + targetId: createReportDto.targetId, + reason: createReportDto.reason, + description: createReportDto.description, + }); + + await this.reportRepository.save(report); + + return { + status: 'success', + message: 'Report submitted successfully', + data: report, + }; + } + + async getAllReports( + page: number = 1, + limit: number = 20, + status?: ReportStatus, + type?: ReportType, + reason?: ReportReason + ) { + const where: Record = {}; + + if (status) where.status = status; + if (type) where.type = type; + if (reason) where.reason = reason; + + const [reports, total] = await this.reportRepository.findAndCount({ + where, + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + }); + + // ... rest of the function stays exactly the same + const enrichedReports = await Promise.all( + reports.map(async (report) => { + const reporterData = await this.userRepository.findById(report.reporterId); + + const reporter = { + userId: reporterData?.userId, + username: reporterData?.username, + displayName: reporterData?.displayName, + avatarUrl: reporterData?.avatarUrl, + coverPhoto: reporterData?.coverPhoto, + }; + + let target = null; + + if (report.type === ReportType.USER) { + const user = await this.userRepository.findById(report.targetId); + target = { + username: user?.username, + displayName: user?.displayName, + coverPhoto: user?.coverPhoto, + avatarUrl: user?.avatarUrl, + isPublic: user?.isPublic, + }; + } else if (report.type === ReportType.TRACK) { + try { + const track = await this.trackService.getTrackById(report.targetId); + target = { trackId: track.trackId, title: track.title, coverImage: track.coverImage }; + } catch { + target = { trackId: report.targetId, title: null, coverImage: null, deleted: true }; + } + } else if (report.type === ReportType.COMMENT) { + const comment = await this.trackRepository.findCommentById(report.targetId); + target = { + commentId: comment?.commentId, + userId: comment?.userId, + content: comment?.content, + }; + } + + return { + ...report, + reporter, + target, + }; + }) + ); + + return { + status: 'success', + data: enrichedReports, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async deleteReport(reportId: string) { + const report = await this.reportRepository.findOne({ where: { reportId } }); + if (!report) { + throw new BadRequestException('report not found'); + } + await this.reportRepository.delete(reportId); + return { + status: 'success', + message: 'report deleted successfully', + }; + } + + async updateReportStatus(status: ReportStatus, reportId: string) { + const report = await this.reportRepository.findOne({ where: { reportId } }); + if (!report) { + throw new BadRequestException('Report not found'); + } + + await this.reportRepository.update(reportId, { + status, + reviewedAt: new Date(), + }); + + return { + status: 'success', + message: 'Report status updated successfully', + }; + } + + async getReport(reportId: string) { + const report = await this.reportRepository.findOne({ where: { reportId } }); + + if (!report) { + throw new BadRequestException('Report not found'); + } + + const reporterData = await this.userRepository.findById(report.reporterId); + + const reporter = { + userId: reporterData?.userId, + username: reporterData?.username, + displayName: reporterData?.displayName, + avatarUrl: reporterData?.avatarUrl, + coverPhoto: reporterData?.coverPhoto, + }; + + let target = null; + + if (report.type === ReportType.USER) { + const user = await this.userRepository.findById(report.targetId); + target = { + username: user?.username, + displayName: user?.displayName, + coverPhoto: user?.coverPhoto, + avatarUrl: user?.avatarUrl, + isPublic: user?.isPublic, + }; + } else if (report.type === ReportType.TRACK) { + const track = await this.trackService.getTrackById(report.targetId); + target = { trackId: track.trackId, title: track.title, coverImage: track.coverImage }; + } else if (report.type === ReportType.COMMENT) { + const comment = await this.trackRepository.findCommentById(report.targetId); + target = { + commentId: comment?.commentId, + userId: comment?.userId, + content: comment?.content, + }; + } + + return { + status: 'success', + data: { + ...report, + reporter, + target, + }, + }; + } + + async getUsers(limit: number, offset: number, search?: string, role?: Role, status?: UserStatus) { + const [users, total] = await this.adminRepository.findAllUsers( + limit, + offset, + search, + role, + status + ); + return { users, total }; + } + + async suspendUser(userId: string, reason: string): Promise { + const user = await this.userService.findById(userId); + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found`); + } + await this.adminRepository.updateUserSuspensionStatus(userId, true, reason); + } + + async reactivateUser(userId: string): Promise { + const user = await this.userService.findById(userId); + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found`); + } + await this.adminRepository.updateUserSuspensionStatus(userId, false, null); + } + + async getTopTracks() { + return this.adminRepository.getTopTracks(); + } + + async getPlatformStats() { + return this.adminRepository.getPlatformStats(); + } + + async getEngagementAnalytics() { + return this.adminRepository.getEngagementAnalytics30Days(); + } + + async getAllTracksWithReportCount(page: number = 1, limit: number = 20, status?: TrackStatus) { + const { tracks, total } = await this.trackRepository.findAllTracksWithReportCount( + page, + limit, + status + ); + + return { + status: 'success', + data: tracks, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async toggleTrackVisibility(trackId: string, hidden: boolean) { + const track = await this.trackRepository.findByIdWithRelations(trackId); + + if (!track) { + throw new NotFoundException('Track not found'); + } + + await this.trackRepository.updateHiddenStatus(trackId, hidden); + + track.hidden = hidden; + + if (hidden) { + await deleteDocument(`track_${trackId}`); + } else if ( + track.visibility === TrackVisibility.PUBLIC && + track.trackStatus === TrackStatus.FINISHED + ) { + await updateDocument(mapTrack(track)); + } else { + await deleteDocument(`track_${trackId}`); + } + + return { + status: 'success', + message: `Track ${hidden ? 'hidden' : 'shown'} successfully`, + }; + } + + async adminDeleteTrack(trackId: string) { + const track = await this.trackRepository.findById(trackId); + + if (!track) { + throw new NotFoundException('Track not found'); + } + + return this.trackService.deleteTrack(trackId, track.userId); + } +} diff --git a/src/admin/admin.swagger.ts b/src/admin/admin.swagger.ts new file mode 100644 index 00000000..ea7c3295 --- /dev/null +++ b/src/admin/admin.swagger.ts @@ -0,0 +1,796 @@ +import { applyDecorators } from '@nestjs/common'; +import { + ApiOperation, + ApiBody, + ApiResponse, + ApiBearerAuth, + ApiQuery, + ApiParam, +} from '@nestjs/swagger'; +import { CreateReportDto } from './dto/createReport.dto'; +import { ReportReason, ReportStatus, ReportType, Role, UserStatus } from './report-enums'; +import { SuspendUserDto } from './dto/suspend-user.dto'; +import { TrackStatus } from '../track/enums/track-status.enum'; + +export function ApiAddReport() { + return applyDecorators( + ApiBearerAuth('access_token'), + ApiOperation({ + summary: 'Submit a report', + description: + 'Allows an authenticated user to report a user, track, or comment. ' + + 'A user cannot report the same target twice, cannot report themselves, ' + + 'and cannot report their own tracks or comments.', + }), + ApiBody({ + type: CreateReportDto, + description: + 'Report payload. `type` accepts: `user | track | comment`. ' + + '`reason` accepts: `copyright | inappropriate | spam | harassment`. ' + + '`targetId` must be a valid UUID of the reported entity. ' + + '`description` is optional free text for additional context.', + examples: { + reportTrack: { + summary: 'Report a track', + value: { + type: 'track', + targetId: 'b6fc3946-ee96-4721-9118-5ca776a874f8', + reason: 'copyright', + description: 'This track uses my original composition without permission.', + }, + }, + reportUser: { + summary: 'Report a user', + value: { + type: 'user', + targetId: 'c7d8e9f0-1234-5678-abcd-ef0987654321', + reason: 'harassment', + description: 'This user has been sending threatening messages.', + }, + }, + reportComment: { + summary: 'Report a comment', + value: { + type: 'comment', + targetId: 'd4e5f6a7-b8c9-0123-abcd-ef4567890123', + reason: 'spam', + description: null, + }, + }, + }, + }), + ApiResponse({ + status: 201, + description: 'Report submitted successfully.', + schema: { + example: { + status: 'success', + message: 'Report submitted successfully', + data: { + reportId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + reporterId: 'b6fc3946-ee96-4721-9118-5ca776a874f8', + type: 'track', + targetId: 'c7d8e9f0-1234-5678-abcd-ef0987654321', + reason: 'copyright', + description: 'This track uses my original composition without permission.', + status: 'pending', + reviewedAt: null, + createdAt: '2025-04-29T12:00:00.000Z', + updatedAt: '2025-04-29T12:00:00.000Z', + }, + }, + }, + }), + ApiResponse({ + status: 400, + description: 'Duplicate report — user has already reported this target.', + schema: { + example: { statusCode: 400, message: 'You have already submitted this report' }, + }, + }), + ApiResponse({ + status: 403, + description: 'User attempted to report themselves, their own track, or their own comment.', + schema: { + example: { statusCode: 403, message: 'You cannot report your own track' }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid access token.' }) + ); +} +export function ApiGetAllReports() { + return applyDecorators( + ApiBearerAuth('access_token'), + ApiOperation({ + summary: 'Get all reports (Admin only)', + description: + 'Returns a paginated list of all reports enriched with reporter and target details. ' + + 'Target details vary by report type: user reports return user profile info, ' + + 'track reports return track title and cover image, ' + + 'comment reports return comment content and author.', + }), + ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Page number (default: 1)', + example: 1, + }), + ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Number of reports per page (default: 20)', + example: 20, + }), + ApiQuery({ + name: 'status', + required: false, + enum: ReportStatus, + description: 'Filter by report status.', + example: ReportStatus.PENDING, + }), + ApiQuery({ + name: 'type', + required: false, + enum: ReportType, + description: 'Filter by report type.', + example: ReportType.TRACK, + }), + ApiQuery({ + name: 'reason', + required: false, + enum: ReportReason, + description: 'Filter by report reason.', + example: ReportReason.COPYRIGHT, + }), + ApiResponse({ + status: 200, + description: 'Paginated list of enriched reports.', + schema: { + example: { + status: 'success', + data: [ + { + reportId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + type: 'track', + reason: 'copyright', + description: 'This track uses my original composition without permission.', + status: 'pending', + reviewedBy: null, + reviewedAt: null, + createdAt: '2025-04-29T12:00:00.000Z', + updatedAt: '2025-04-29T12:00:00.000Z', + reporter: { + userId: 'b6fc3946-ee96-4721-9118-5ca776a874f8', + username: 'dj_nour', + displayName: 'DJ Nour', + avatarUrl: 'https://cdn.harmonica.com/avatars/dj_nour.jpg', + coverPhoto: 'https://cdn.harmonica.com/covers/dj_nour.jpg', + }, + target: { + trackId: 'c7d8e9f0-1234-5678-abcd-ef0987654321', + title: 'Midnight Drive', + coverImage: 'https://cdn.harmonica.com/covers/midnight.jpg', + }, + }, + { + reportId: 'b2c3d4e5-f6a7-8901-bcde-f12345678901', + type: 'user', + reason: 'harassment', + description: 'This user has been sending threatening messages.', + status: 'reviewed', + reviewedBy: 'admin-uuid', + reviewedAt: '2025-04-29T13:00:00.000Z', + createdAt: '2025-04-29T11:00:00.000Z', + updatedAt: '2025-04-29T13:00:00.000Z', + reporter: { + userId: 'b6fc3946-ee96-4721-9118-5ca776a874f8', + username: 'dj_nour', + displayName: 'DJ Nour', + avatarUrl: 'https://cdn.harmonica.com/avatars/dj_nour.jpg', + coverPhoto: null, + }, + target: { + username: 'bad_user', + displayName: 'Bad User', + avatarUrl: null, + coverPhoto: null, + isPublic: true, + }, + }, + { + reportId: 'c3d4e5f6-a7b8-9012-cdef-123456789012', + type: 'comment', + reason: 'spam', + description: null, + status: 'pending', + reviewedBy: null, + reviewedAt: null, + createdAt: '2025-04-29T10:00:00.000Z', + updatedAt: '2025-04-29T10:00:00.000Z', + reporter: { + userId: 'b6fc3946-ee96-4721-9118-5ca776a874f8', + username: 'dj_nour', + displayName: 'DJ Nour', + avatarUrl: 'https://cdn.harmonica.com/avatars/dj_nour.jpg', + coverPhoto: null, + }, + target: { + commentId: 'd4e5f6a7-b8c9-0123-abcd-ef4567890123', + userId: 'e5f6a7b8-c9d0-1234-bcde-f67890123456', + content: 'Buy followers now at spam-site.com!', + }, + }, + ], + meta: { + total: 100, + page: 1, + limit: 20, + totalPages: 5, + }, + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid access token.' }), + ApiResponse({ status: 403, description: 'Forbidden — admin access only.' }) + ); +} +export function ApiDeleteReport() { + return applyDecorators( + ApiBearerAuth('access_token'), + ApiOperation({ + summary: 'Delete a report (Admin only)', + description: + 'Permanently deletes a report by its ID. Throws 400 if the report does not exist.', + }), + ApiParam({ + name: 'reportId', + description: 'UUID of the report to delete.', + format: 'uuid', + example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }), + ApiResponse({ + status: 200, + description: 'Report deleted successfully.', + schema: { + example: { + status: 'success', + message: 'Report deleted successfully', + }, + }, + }), + ApiResponse({ + status: 400, + description: 'Report not found.', + schema: { + example: { + statusCode: 400, + message: 'Report not found', + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid access token. ' }), + ApiResponse({ status: 403, description: 'Forbidden — admin access only.' }) + ); +} + +export function ApiUpdateReportStatus() { + return applyDecorators( + ApiBearerAuth('access_token'), + ApiOperation({ + summary: 'Update report status (Admin only)', + description: + 'Updates the status of a report and sets reviewedAt to the current date. ' + + 'Throws 400 if the report does not exist.', + }), + ApiParam({ + name: 'reportId', + description: 'UUID of the report to update.', + format: 'uuid', + example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }), + ApiQuery({ + name: 'status', + required: true, + enum: ReportStatus, + description: + 'New status to set. Accepted values: `pending | reviewed | resolved | rejected`.', + example: ReportStatus.RESOLVED, + }), + ApiResponse({ + status: 200, + description: 'Report status updated successfully.', + schema: { + example: { + status: 'success', + message: 'Report status updated successfully', + }, + }, + }), + ApiResponse({ + status: 400, + description: 'Report not found.', + schema: { + example: { + statusCode: 400, + message: 'Report not found', + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid access token.' }), + ApiResponse({ status: 403, description: 'Forbidden — admin access only.' }) + ); +} + +export function ApiGetReport() { + return applyDecorators( + ApiBearerAuth('access_token'), + ApiOperation({ + summary: 'Get a report by ID (Admin only)', + description: + 'Returns a single report enriched with reporter and target details. ' + + 'Target details vary by report type: user reports return user profile info, ' + + 'track reports return track title and cover image, ' + + 'comment reports return comment content and author.', + }), + ApiParam({ + name: 'reportId', + description: 'UUID of the report to retrieve.', + format: 'uuid', + example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }), + ApiResponse({ + status: 200, + description: 'Report retrieved successfully.', + schema: { + example: { + status: 'success', + data: { + reportId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + type: 'track', + reason: 'copyright', + description: 'This track uses my original composition without permission.', + status: 'pending', + reviewedBy: null, + reviewedAt: null, + createdAt: '2025-04-29T12:00:00.000Z', + updatedAt: '2025-04-29T12:00:00.000Z', + reporter: { + userId: 'b6fc3946-ee96-4721-9118-5ca776a874f8', + username: 'dj_nour', + displayName: 'DJ Nour', + avatarUrl: 'https://cdn.harmonica.com/avatars/dj_nour.jpg', + coverPhoto: 'https://cdn.harmonica.com/covers/dj_nour.jpg', + }, + target: { + trackId: 'c7d8e9f0-1234-5678-abcd-ef0987654321', + title: 'Midnight Drive', + coverImage: 'https://cdn.harmonica.com/covers/midnight.jpg', + }, + }, + }, + }, + }), + ApiResponse({ + status: 400, + description: 'Report not found.', + schema: { + example: { + statusCode: 400, + message: 'Report not found', + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid access token.' }), + ApiResponse({ status: 403, description: 'Forbidden — admin access only.' }) + ); +} + +export function ApiGetAllUsers() { + return applyDecorators( + ApiOperation({ + summary: 'Retrieve paginated users', + description: + 'Fetches a list of all users on the platform. Includes pagination (limit and offset) and an optional search query that matches against both usernames and email addresses.', + }), + ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'The maximum number of users to return. Capped at 50.', + example: 20, + }), + ApiQuery({ + name: 'offset', + required: false, + type: Number, + description: 'The number of users to skip before starting to collect the result set.', + example: 0, + }), + ApiQuery({ + name: 'search', + required: false, + type: String, + description: + 'Optional search term to filter users by username or email address (case-insensitive).', + example: 'schuyler.kozey-okuneva', + }), + ApiQuery({ + name: 'role', + required: false, + enum: Role, + description: 'Optional filter to return only users with a specific role.', + example: Role.LISTENER, + }), + ApiQuery({ + name: 'status', + required: false, + enum: UserStatus, + description: 'Optional filter to return only users with a specific status.', + example: UserStatus.ACTIVE, + }), + ApiResponse({ + status: 200, + description: 'Successfully retrieved the list of users.', + schema: { + example: { + status: 'success', + data: { + users: [ + { + id: '123e4567-e89b-12d3-a456-426614174000', + username: 'schuyler.kozey-okuneva', + isSuspended: false, + createdAt: '2026-04-20T10:30:00Z', + emails: [{ email: 'schuyler@example.com' }], + }, + ], + pagination: { + limit: 20, + offset: 0, + total: 145, + hasMore: true, + }, + }, + }, + }, + }), + ApiResponse({ + status: 401, + description: 'Unauthorized. The user making the request is not authenticated.', + }), + ApiResponse({ + status: 403, + description: 'Forbidden. The user does not have the required admin role.', + }) + ); +} + +export function ApiSuspendUser() { + return applyDecorators( + ApiOperation({ + summary: 'Suspend a user account', + description: + 'Suspends a specific user account by their ID. Requires a detailed reason for the suspension, which will be saved in the database.', + }), + ApiParam({ + name: 'user_id', + type: 'string', + format: 'uuid', + description: 'The UUID of the user you want to suspend.', + example: '123e4567-e89b-12d3-a456-426614174000', + }), + ApiBody({ + type: SuspendUserDto, + description: 'The reason for suspending the user.', + examples: { + spam: { + summary: 'Spam Behavior', + value: { reason: 'Uploading copyrighted tracks and spamming comments.' }, + }, + harassment: { + summary: 'Harassment', + value: { reason: 'Harassing other artists in direct messages.' }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'User was successfully suspended.', + schema: { + example: { + status: 'success', + message: 'User suspended successfully', + }, + }, + }), + ApiResponse({ + status: 400, + description: + 'Bad Request. Usually happens if the reason is missing or exceeds 500 characters.', + }), + ApiResponse({ + status: 404, + description: 'Not Found. No user exists with the provided UUID.', + }) + ); +} + +export function ApiReactivateUser() { + return applyDecorators( + ApiOperation({ + summary: 'Reactivate a suspended user', + description: + 'Lifts the suspension on a specific user account and clears their suspension reason from the database, restoring their access.', + }), + ApiParam({ + name: 'user_id', + type: 'string', + format: 'uuid', + description: 'The UUID of the user you want to reactivate.', + example: '123e4567-e89b-12d3-a456-426614174000', + }), + ApiResponse({ + status: 200, + description: 'User was successfully reactivated.', + schema: { + example: { + status: 'success', + message: 'User reactivated successfully', + }, + }, + }), + ApiResponse({ + status: 404, + description: 'Not Found. No user exists with the provided UUID.', + }) + ); +} + +export function ApiGetTopTracks() { + return applyDecorators( + ApiOperation({ + summary: 'Retrieve top 5 tracks', + description: + 'Fetches the top 5 tracks on the platform based on their all-time total play count.', + }), + ApiResponse({ + status: 200, + description: 'Successfully retrieved the top tracks.', + schema: { + example: { + status: 'success', + data: { + tracks: [ + { + trackId: '123e4567-e89b-12d3-a456-426614174000', + title: 'Summer Vibes', + playCount: 154302, + coverImage: 'https://example.com/image.jpg', + durationSeconds: 210, + user: { + userId: '987e6543-e21b-12d3-a456-426614174000', + username: 'dj_awesome', + }, + }, + ], + }, + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized.' }), + ApiResponse({ status: 403, description: 'Forbidden. Admin role required.' }) + ); +} + +export function ApiGetAllTracksWithReportCount() { + return applyDecorators( + ApiBearerAuth('access_token'), + ApiOperation({ + summary: 'Get all tracks with report count (Admin only)', + description: + 'Returns a paginated list of all tracks, each including the number of reports associated with it.', + }), + ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Page number (default: 1)', + example: 1, + }), + ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Number of tracks per page (default: 20)', + example: 20, + }), + ApiQuery({ + name: 'status', + required: false, + enum: TrackStatus, + description: 'Filter tracks by status (optional)', + }), + ApiResponse({ + status: 200, + description: 'Paginated list of tracks with report counts.', + schema: { + example: { + status: 'success', + data: [ + { + trackId: 'c7d8e9f0-1234-5678-abcd-ef0987654321', + title: 'Midnight Drive', + trackStatus: 'published', + coverImage: 'https://cdn.harmonica.com/covers/midnight.jpg', + audioUrl: 'https://cdn.harmonica.com/audio/midnight.mp3', + userId: 'b6fc3946-ee96-4721-9118-5ca776a874f8', + playCount: 12000, + likesCount: 870, + repostsCount: 130, + commentsCount: 45, + createdAt: '2025-04-29T12:00:00.000Z', + updatedAt: '2025-04-29T12:00:00.000Z', + reportsCount: 3, + }, + ], + meta: { + total: 100, + page: 1, + limit: 20, + totalPages: 5, + }, + }, + }, + }), + ApiResponse({ + status: 401, + description: 'Unauthorized. The user making the request is not authenticated.', + }), + ApiResponse({ + status: 403, + description: 'Forbidden. The user does not have the required admin role.', + }) + ); +} + +export function ApiGetPlatformStats() { + return applyDecorators( + ApiOperation({ + summary: 'Retrieve platform statistics', + description: + 'Fetches high-level metrics for the admin dashboard, including active users, total track plays, total uploads, and pending reports.', + }), + ApiResponse({ + status: 200, + description: 'Successfully retrieved the statistics.', + schema: { + example: { + status: 'success', + data: { + activeUsers: 1452, + totalPlays: 854930, + totalUploads: 340, + openReports: 12, + }, + }, + }, + }), + ApiResponse({ + status: 401, + description: 'Unauthorized.', + }), + ApiResponse({ + status: 403, + description: 'Forbidden. Admin role required.', + }) + ); +} + +export function ApiGetEngagementAnalytics() { + return applyDecorators( + ApiOperation({ + summary: 'Retrieve 30-day engagement analytics', + description: + 'Fetches daily activity metrics (active users, uploads, plays, likes, reposts) over the last 30 days.', + }), + ApiResponse({ + status: 200, + description: 'Successfully retrieved the engagement timeline.', + schema: { + example: { + status: 'success', + data: { + timeline: [ + { + date: '2026-04-20', + activeUsers: 85, + uploads: 5, + plays: 120, + likes: 45, + reposts: 10, + }, + ], + }, + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized.' }), + ApiResponse({ status: 403, description: 'Forbidden. Admin role required.' }), + ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid access token.' }), + ApiResponse({ status: 403, description: 'Forbidden — admin access only.' }) + ); +} + +export function ApiHideTrack() { + return applyDecorators( + ApiOperation({ + summary: 'Toggle track visibility (Hide/Show)', + description: + 'Allows an admin to hide a track from public view or show a previously hidden track. This also syncs with the search index.', + }), + ApiParam({ + name: 'track_id', + description: 'The UUID of the track to hide or show', + type: 'string', + format: 'uuid', + }), + ApiBody({ + schema: { + type: 'object', + properties: { + hidden: { + type: 'boolean', + description: 'Set to true to hide the track, false to make it visible', + example: true, + }, + }, + required: ['hidden'], + }, + }), + ApiResponse({ + status: 200, + description: 'Track visibility updated successfully.', + schema: { + example: { + status: 'success', + message: 'Track hidden successfully', + }, + }, + }), + ApiResponse({ status: 400, description: 'Bad Request — invalid track ID.' }), + ApiResponse({ status: 401, description: 'Unauthorized.' }), + ApiResponse({ status: 403, description: 'Forbidden — admin access only.' }), + ApiResponse({ status: 404, description: 'Track not found.' }) + ); +} + +export function ApiDeleteTrack() { + return applyDecorators( + ApiOperation({ + summary: 'Administrative delete of a track', + description: + 'Permanently deletes a track, removes associated files from storage (S3), and wipes it from the search index.', + }), + ApiParam({ + name: 'track_id', + description: 'The UUID of the track to permanently delete', + type: 'string', + format: 'uuid', + }), + ApiResponse({ + status: 200, + description: 'Track deleted successfully.', + schema: { + example: { + status: 'success', + message: 'Track deleted successfully', + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized.' }), + ApiResponse({ status: 403, description: 'Forbidden — admin access only.' }), + ApiResponse({ status: 404, description: 'Track not found.' }) + ); +} diff --git a/src/admin/dto/createReport.dto.ts b/src/admin/dto/createReport.dto.ts new file mode 100644 index 00000000..872a91d0 --- /dev/null +++ b/src/admin/dto/createReport.dto.ts @@ -0,0 +1,40 @@ +import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ReportReason, ReportType } from '../report-enums'; + +export class CreateReportDto { + @ApiProperty({ + description: 'The type of the reported target.', + enum: ReportType, + example: ReportType.TRACK, + }) + @IsEnum(ReportType) + @IsNotEmpty() + type!: ReportType; + + @ApiProperty({ + description: 'UUID of the reported user / track / comment.', + format: 'uuid', + example: 'b6fc3946-ee96-4721-9118-5ca776a874f8', + }) + @IsUUID() + @IsNotEmpty() + targetId!: string; + + @ApiProperty({ + description: 'The reason for the report.', + enum: ReportReason, + example: ReportReason.COPYRIGHT, + }) + @IsEnum(ReportReason) + @IsNotEmpty() + reason!: ReportReason; + + @ApiPropertyOptional({ + description: 'Optional additional context provided by the reporter.', + example: 'This track uses my original composition without permission.', + }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/src/admin/dto/suspend-user.dto.ts b/src/admin/dto/suspend-user.dto.ts new file mode 100644 index 00000000..7a07ecfe --- /dev/null +++ b/src/admin/dto/suspend-user.dto.ts @@ -0,0 +1,8 @@ +import { IsString, IsNotEmpty, MaxLength } from 'class-validator'; + +export class SuspendUserDto { + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: string; +} diff --git a/src/admin/entities/report.entity.ts b/src/admin/entities/report.entity.ts index e9c89d48..391784ce 100644 --- a/src/admin/entities/report.entity.ts +++ b/src/admin/entities/report.entity.ts @@ -25,9 +25,6 @@ export class Report extends BaseEntity { @Column({ type: 'enum', enum: ReportStatus, default: ReportStatus.PENDING }) status!: ReportStatus; - @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) - reviewedBy!: string; // admin userId who acted on it - @Column({ name: 'reviewed_at', type: 'timestamp', nullable: true }) reviewedAt!: Date; } diff --git a/src/admin/report-enums.ts b/src/admin/report-enums.ts index d40de037..3fc15939 100644 --- a/src/admin/report-enums.ts +++ b/src/admin/report-enums.ts @@ -17,3 +17,13 @@ export enum ReportStatus { RESOLVED = 'resolved', REJECTED = 'rejected', } + +export enum Role { + LISTENER = 'listener', + ARTIST = 'artist', + ADMIN = 'admin', +} +export enum UserStatus { + ACTIVE = 'active', + SUSPENDED = 'suspended', +} diff --git a/src/admin/report.controller.spec.ts b/src/admin/report.controller.spec.ts new file mode 100644 index 00000000..7702c4da --- /dev/null +++ b/src/admin/report.controller.spec.ts @@ -0,0 +1,115 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReportController } from './report.controller'; +import { AdminService } from './admin.service'; +import { CreateReportDto } from './dto/createReport.dto'; +import { ReportStatus, ReportType, ReportReason } from './report-enums'; + +describe('ReportController', () => { + let controller: ReportController; + let adminService: jest.Mocked; + + const mockAdminService = { + addReport: jest.fn(), + getAllReports: jest.fn(), + deleteReport: jest.fn(), + updateReportStatus: jest.fn(), + getReport: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ReportController], + providers: [{ provide: AdminService, useValue: mockAdminService }], + }).compile(); + + controller = module.get(ReportController); + adminService = module.get(AdminService); + }); + + afterEach(() => jest.clearAllMocks()); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('addReport', () => { + it('should call adminService.addReport with userId and dto', async () => { + const userId = 'user-123'; + const dto: CreateReportDto = { + type: ReportType.TRACK, + targetId: 'track-123', + reason: ReportReason.COPYRIGHT, + description: 'Stolen track', + }; + const mockResult = { status: 'success', message: 'Report submitted successfully', data: {} }; + mockAdminService.addReport.mockResolvedValue(mockResult); + + const result = await controller.addReport(userId, dto); + + expect(adminService.addReport).toHaveBeenCalledWith(userId, dto); + expect(result).toEqual(mockResult); + }); + }); + + describe('getAllReports', () => { + it('should call adminService.getAllReports with page and limit', async () => { + const mockResult = { + status: 'success', + data: [], + meta: { total: 0, page: 1, limit: 20, totalPages: 0 }, + }; + mockAdminService.getAllReports.mockResolvedValue(mockResult); + + const result = await controller.getAllReports('user-123', 1, 20); + + expect(adminService.getAllReports).toHaveBeenCalledWith( + 1, + 20, + undefined, + undefined, + undefined + ); + expect(result).toEqual(mockResult); + }); + }); + + describe('deleteReport', () => { + it('should call adminService.deleteReport with reportId', async () => { + const reportId = 'report-123'; + const mockResult = { status: 'success', message: 'report deleted successfully' }; + mockAdminService.deleteReport.mockResolvedValue(mockResult); + + const result = await controller.deleteReport(reportId); + + expect(adminService.deleteReport).toHaveBeenCalledWith(reportId); + expect(result).toEqual(mockResult); + }); + }); + + describe('updateReportStatus', () => { + it('should call adminService.updateReportStatus with status and reportId', async () => { + const reportId = 'report-123'; + const status = ReportStatus.RESOLVED; + const mockResult = { status: 'success', message: 'Report status updated successfully' }; + mockAdminService.updateReportStatus.mockResolvedValue(mockResult); + + const result = await controller.updateReportStatus(status, reportId); + + expect(adminService.updateReportStatus).toHaveBeenCalledWith(status, reportId); + expect(result).toEqual(mockResult); + }); + }); + + describe('getReport', () => { + it('should call adminService.getReport with reportId', async () => { + const reportId = 'report-123'; + const mockResult = { status: 'success', data: { reportId, type: ReportType.TRACK } }; + mockAdminService.getReport.mockResolvedValue(mockResult); + + const result = await controller.getReport(reportId); + + expect(adminService.getReport).toHaveBeenCalledWith(reportId); + expect(result).toEqual(mockResult); + }); + }); +}); diff --git a/src/admin/report.controller.ts b/src/admin/report.controller.ts new file mode 100644 index 00000000..e3da8775 --- /dev/null +++ b/src/admin/report.controller.ts @@ -0,0 +1,62 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AdminService } from './admin.service'; +import { CurrentUser } from '../authentication/decorators/current-user.decorator'; +import { CreateReportDto } from './dto/createReport.dto'; +import { + ApiAddReport, + ApiDeleteReport, + ApiGetAllReports, + ApiGetReport, + ApiUpdateReportStatus, +} from './admin.swagger'; +import { Roles } from '../authentication/decorators/roles.decorator'; +import { ReportReason, ReportStatus, ReportType } from './report-enums'; + +@ApiTags('Reports') +@Controller('report') +export class ReportController { + constructor(private readonly adminService: AdminService) {} + + @ApiAddReport() + @Post('add-report') + addReport(@CurrentUser('sub') userId: string, @Body() createReportDto: CreateReportDto) { + return this.adminService.addReport(userId, createReportDto); + } + + @ApiGetAllReports() + @Roles('admin') + @Get('/all') + getAllReports( + @CurrentUser('sub') userId: string, + @Query('page') page: number = 1, + @Query('limit') limit: number = 20, + @Query('status') status?: ReportStatus, + @Query('type') type?: ReportType, + @Query('reason') reason?: ReportReason + ) { + console.log(userId); + return this.adminService.getAllReports(page, limit, status, type, reason); + } + + @Roles('admin') + @ApiDeleteReport() + @Delete(':reportId') + deleteReport(@Param('reportId') reportId: string) { + return this.adminService.deleteReport(reportId); + } + + @Roles('admin') + @ApiUpdateReportStatus() + @Patch('update-status/:reportId') + updateReportStatus(@Query('status') status: ReportStatus, @Param('reportId') reportId: string) { + return this.adminService.updateReportStatus(status, reportId); + } + + @Roles('admin') + @ApiGetReport() + @Get(':reportId') + getReport(@Param('reportId') reportId: string) { + return this.adminService.getReport(reportId); + } +} diff --git a/src/audio/audio.processor.ts b/src/audio/audio.processor.ts index 50a8103e..2567d96f 100644 --- a/src/audio/audio.processor.ts +++ b/src/audio/audio.processor.ts @@ -10,6 +10,7 @@ import { FfmpegService } from './ffmpeg.service'; import { StorageService } from '../common/storage_service'; import { Track } from '../track/entities/track.entity'; import { TrackStatus } from '../track/enums/track-status.enum'; +import { SEED_PROTECTED_AUDIO_URLS } from '../database/seeds/seed-audio-urls.constant'; export interface AudioJobResult { audioUrl: string; @@ -58,7 +59,8 @@ export class AudioProcessor extends WorkerHost { const { trackId, filePath, originalName, previewStartTime } = job.data; this.logger.log(`Processing audio for track ${trackId}`); - // Snapshot old S3 URLs before overwriting them + // Snapshot old S3 URLs before overwriting them. + // Exclude seeded URLs so they are never accidentally deleted. const existing = await this.trackRepository.findOne({ where: { trackId } }); const oldUrls = existing ? [ @@ -66,7 +68,7 @@ export class AudioProcessor extends WorkerHost { existing.audioUrlHq, existing.previewAudioUrl, existing.waveformUrl, - ].filter(Boolean) + ].filter((url) => url && !SEED_PROTECTED_AUDIO_URLS.has(url)) : []; try { diff --git a/src/authentication/authentication.service.ts b/src/authentication/authentication.service.ts index 410b7c9e..0778fb39 100644 --- a/src/authentication/authentication.service.ts +++ b/src/authentication/authentication.service.ts @@ -296,16 +296,25 @@ 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, }); - const newRefreshToken = this.jwtService.sign(payload, { - secret: this.configService.get('JWT_REFRESH_SECRET'), - expiresIn: REFRESH_TOKEN_EXPIRY, - }); + const newRefreshToken = this.jwtService.sign( + { ...payload, jti: crypto.randomUUID() }, + { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: REFRESH_TOKEN_EXPIRY, + } + ); const expiresAt = new Date(Date.now() + REFRESH_COOKIE_MAX_AGE_MS); const tokenHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex'); @@ -734,7 +743,7 @@ export class AuthenticationService { // Brand new user const pendingToken = await this.createPendingOAuthSession(profile); - const displayName = `${profile.firstName} ${profile.lastName}`; + const displayName = `${profile.firstName} ${profile.lastName ? profile.lastName : ''}`; const pendingParams = `pendingToken=${pendingToken}&displayName=${encodeURIComponent(displayName)}`; return { url: isMobile diff --git a/src/authentication/guards/oauth.constants.ts b/src/authentication/guards/oauth.constants.ts index 076f28f2..d9a7c5d0 100644 --- a/src/authentication/guards/oauth.constants.ts +++ b/src/authentication/guards/oauth.constants.ts @@ -1,5 +1,5 @@ /** Deep-link schemes that are allowed as OAuth redirect_uri values. */ -export const ALLOWED_MOBILE_SCHEMES = ['harmonica://']; +export const ALLOWED_MOBILE_SCHEMES = ['harmonica://', 'com.harmonica.app://']; /** * Validates an OAuth redirect_uri coming from a client. 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/audio.util.ts b/src/common/utilities/audio.util.ts new file mode 100644 index 00000000..2910ccf6 --- /dev/null +++ b/src/common/utilities/audio.util.ts @@ -0,0 +1,7 @@ +export function resolveAudioUrl( + track: { audioUrl: string | null; audioUrlHq?: string | null }, + plan?: string +): string | null { + if ((plan === 'pro' || plan === 'go+') && track.audioUrlHq) return track.audioUrlHq; + return track.audioUrl ?? null; +} diff --git a/src/common/utilities/captcha.util.spec.ts b/src/common/utilities/captcha.util.spec.ts index cc2e9a27..2d787803 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 } 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,76 @@ 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(); + }); +}); + +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/common/utilities/captcha.util.ts b/src/common/utilities/captcha.util.ts index d84a88b0..e76f4ad4 100644 --- a/src/common/utilities/captcha.util.ts +++ b/src/common/utilities/captcha.util.ts @@ -5,21 +5,24 @@ import { getAppCheck } from 'firebase-admin/app-check'; export function getFirebaseApp() { if (getApps().length) return getApps()[0]; - const projectId = process.env.FIREBASE_PROJECT_ID; - const clientEmail = process.env.FIREBASE_CLIENT_EMAIL; - const privateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'); + const b64 = process.env.FIREBASE_SERVICE_ACCOUNT_B64; - if (!projectId || !clientEmail || !privateKey) { - throw new Error( - 'Missing Firebase env vars: FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY' - ); + if (!b64) { + throw new Error('Missing FIREBASE_SERVICE_ACCOUNT_B64'); } + const serviceAccount = JSON.parse(Buffer.from(b64, 'base64').toString('utf8')); + + console.log('🔥 Firebase service account check:', { + hasProjectId: !!serviceAccount.project_id, + hasClientEmail: !!serviceAccount.client_email, + hasPrivateKey: !!serviceAccount.private_key, + }); + return initializeApp({ - credential: cert({ projectId, clientEmail, privateKey }), + credential: cert(serviceAccount), }); } - export const verifyAppCheckToken = async (token: string): Promise => { if (token === process.env.FIREBASE_DEBUG_TOKEN) { return true; diff --git a/src/config/database.config.spec.ts b/src/config/database.config.spec.ts deleted file mode 100644 index a6c798e5..00000000 --- a/src/config/database.config.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { ConfigService } from '@nestjs/config'; -import { getDatabaseConfig } from './database.config'; - -const mockConfigService = { - get: jest.fn((key: string) => { - const values: Record = { - DB_HOST: 'localhost', - DB_PORT: 5432, - DB_USERNAME: 'postgres', - DB_PASSWORD: 'secret', - DB_DATABASE: 'harmonica', - NODE_ENV: 'development', - }; - return values[key]; - }), -} as unknown as ConfigService; - -describe('getDatabaseConfig', () => { - it('should return correct connection fields from ConfigService', () => { - const config = getDatabaseConfig(mockConfigService) as Record; - - expect(config.type).toBe('postgres'); - expect(config.host).toBe('localhost'); - expect(config.port).toBe(5432); - expect(config.username).toBe('postgres'); - expect(config.password).toBe('secret'); - expect(config.database).toBe('harmonica'); - }); - - it('should set synchronize to false', () => { - const config = getDatabaseConfig(mockConfigService) as Record; - - expect(config.synchronize).toBe(false); - }); - - it('should set migrationsRun to false', () => { - const config = getDatabaseConfig(mockConfigService) as Record; - - expect(config.migrationsRun).toBe(false); - }); - - it('should enable logging in development', () => { - const config = getDatabaseConfig(mockConfigService) as Record; - - expect(config.logging).toBe(true); - }); - - it('should disable logging in production', () => { - const prodConfigService = { - get: jest.fn((key: string) => { - if (key === 'NODE_ENV') return 'production'; - return 'value'; - }), - } as unknown as ConfigService; - - const config = getDatabaseConfig(prodConfigService) as Record; - - expect(config.logging).toBe(false); - }); - - it('should include entities and migrations path patterns', () => { - const config = getDatabaseConfig(mockConfigService) as Record; - - expect(Array.isArray(config.entities)).toBe(true); - expect(Array.isArray(config.migrations)).toBe(true); - expect((config.entities as string[])[0]).toContain('.entity'); - expect((config.migrations as string[])[0]).toContain('migrations'); - }); -}); diff --git a/src/database/factories/track.factory.ts b/src/database/factories/track.factory.ts index f6f41080..6e548e8f 100644 --- a/src/database/factories/track.factory.ts +++ b/src/database/factories/track.factory.ts @@ -2,6 +2,9 @@ import { setSeederFactory } from 'typeorm-extension'; import { Track } from '../../track/entities/track.entity'; import { TrackStatus } from '../../track/enums/track-status.enum'; import { TrackVisibility } from '../../track/enums/track-visibility.enum'; +import { SEED_PROTECTED_AUDIO_URLS } from '../seeds/seed-audio-urls.constant'; + +const [audioUrl, audioUrlHq, previewAudioUrl, waveformUrl] = [...SEED_PROTECTED_AUDIO_URLS]; export default setSeederFactory(Track, async () => { const { faker } = await import('@faker-js/faker'); @@ -11,27 +14,22 @@ export default setSeederFactory(Track, async () => { track.title = faker.music.songName(); track.description = faker.lorem.paragraph(); - // Media URLs - track.audioUrl = - 'https://harmonica-s3-storage-287109772507-us-east-1-an.s3.amazonaws.com/tracks/594929cf-87b3-4461-a80f-c2572fed8107/u_vld2chgq8f-magma-prod-2025-sample-142bpm-459102_standard.mp3'; - track.previewAudioUrl = - 'https://harmonica-s3-storage-287109772507-us-east-1-an.s3.amazonaws.com/tracks/594929cf-87b3-4461-a80f-c2572fed8107/u_vld2chgq8f-magma-prod-2025-sample-142bpm-459102_preview.mp3'; - track.waveformUrl = - 'https://harmonica-s3-storage-287109772507-us-east-1-an.s3.amazonaws.com/tracks/594929cf-87b3-4461-a80f-c2572fed8107/waveform.json'; - track.coverImage = - 'https://harmonica-s3-storage-287109772507-us-east-1-an.s3.amazonaws.com/transparent-cassete.png'; - track.audioUrlHq = - 'https://harmonica-s3-storage-287109772507-us-east-1-an.s3.amazonaws.com/tracks/594929cf-87b3-4461-a80f-c2572fed8107/u_vld2chgq8f-magma-prod-2025-sample-142bpm-459102_hq.mp3'; + // Media URLs — sourced from seed-audio-urls.constant.ts (protected from processor deletion) + track.audioUrl = audioUrl; + track.previewAudioUrl = previewAudioUrl; + track.waveformUrl = waveformUrl; + track.audioUrlHq = audioUrlHq; + track.coverImage = faker.image.url({ width: 500, height: 500 }); // Metadata - track.durationSeconds = faker.number.int({ min: 60, max: 600 }); - track.playCount = faker.number.int({ min: 0, max: 100000 }); + track.durationSeconds = 248; + track.playCount = 0; track.likesCount = 0; track.repostsCount = 0; track.commentsCount = 0; // Status & Visibility - track.trackStatus = faker.helpers.arrayElement(Object.values(TrackStatus)) as TrackStatus; + track.trackStatus = TrackStatus.FINISHED; track.visibility = faker.helpers.weightedArrayElement([ { weight: 3, value: TrackVisibility.PUBLIC }, { weight: 1, value: TrackVisibility.PRIVATE }, diff --git a/src/database/factories/user.factory.ts b/src/database/factories/user.factory.ts index 775d0afc..ea901474 100644 --- a/src/database/factories/user.factory.ts +++ b/src/database/factories/user.factory.ts @@ -15,8 +15,9 @@ export default setSeederFactory(User, async () => { user.lastName = lastName; user.displayName = `${firstName} ${lastName}`; user.birthdate = faker.date.birthdate({ min: 13, max: 65, mode: 'age' }); - user.gender = faker.helpers.arrayElement(['male', 'female', 'other', 'preferNotToSay']); + user.gender = faker.helpers.arrayElement(['male', 'female']); user.bio = faker.lorem.sentence(); + user.avatarUrl = faker.image.avatar(); user.country = faker.location.country(); user.city = faker.location.city(); user.plan = faker.helpers.weightedArrayElement([ diff --git a/src/database/migrations/1777414513444-ChangeOfflineListeningDefaultValue.ts b/src/database/migrations/1777414513444-ChangeOfflineListeningDefaultValue.ts new file mode 100644 index 00000000..c48accc3 --- /dev/null +++ b/src/database/migrations/1777414513444-ChangeOfflineListeningDefaultValue.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ChangeOfflineListeningDefaultValue1777414513444 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "tracks" + ALTER COLUMN "offline_listening" SET DEFAULT true + `); + + await queryRunner.query(` + UPDATE "tracks" + SET "offline_listening" = true + WHERE "offline_listening" = false + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "tracks" + ALTER COLUMN "offline_listening" SET DEFAULT false + `); + + await queryRunner.query(` + UPDATE "tracks" + SET "offline_listening" = false + WHERE "offline_listening" = true + `); + } +} diff --git a/src/database/migrations/1777415551196-DropUniqueDownloads1.ts b/src/database/migrations/1777415551196-DropUniqueDownloads1.ts new file mode 100644 index 00000000..4ac5a7aa --- /dev/null +++ b/src/database/migrations/1777415551196-DropUniqueDownloads1.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropUniqueDownloads11777415551196 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "downloaded_tracks" + DROP CONSTRAINT "downloaded_tracks_user_id_track_id_source_key" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "downloaded_tracks" + ADD CONSTRAINT "downloaded_tracks_user_id_track_id_source_key" + UNIQUE (user_id, track_id, source) + `); + } +} diff --git a/src/database/migrations/1777540641921-CreateSpotlightTracksTable.ts b/src/database/migrations/1777540641921-CreateSpotlightTracksTable.ts new file mode 100644 index 00000000..df198d72 --- /dev/null +++ b/src/database/migrations/1777540641921-CreateSpotlightTracksTable.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSpotlightTracksTable1777540641921 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "spotlight_tracks" ( + "spotlight_track_id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "track_id" uuid NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + + CONSTRAINT "PK_spotlight_tracks" + PRIMARY KEY ("spotlight_track_id"), + + CONSTRAINT "UQ_spotlight_tracks_user_track" + UNIQUE ("user_id", "track_id"), + + CONSTRAINT "FK_spotlight_tracks_user" + FOREIGN KEY ("user_id") + REFERENCES "users" ("user_id") + ON DELETE CASCADE, + + CONSTRAINT "FK_spotlight_tracks_track" + FOREIGN KEY ("track_id") + REFERENCES "tracks" ("track_id") + ON DELETE CASCADE + ) + `); + + await queryRunner.query(` + CREATE INDEX "IDX_spotlight_tracks_user_id" + ON "spotlight_tracks" ("user_id") + `); + + await queryRunner.query(` + CREATE INDEX "IDX_spotlight_tracks_track_id" + ON "spotlight_tracks" ("track_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_spotlight_tracks_track_id"`); + await queryRunner.query(`DROP INDEX "IDX_spotlight_tracks_user_id"`); + await queryRunner.query(`DROP TABLE "spotlight_tracks"`); + } +} diff --git a/src/database/migrations/1777560300995-AddScheduledAtAndStatusEnum.ts b/src/database/migrations/1777560300995-AddScheduledAtAndStatusEnum.ts new file mode 100644 index 00000000..c0690a82 --- /dev/null +++ b/src/database/migrations/1777560300995-AddScheduledAtAndStatusEnum.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddScheduledAtAndStatusEnum1777560300995 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add new enum value to track_status type + await queryRunner.query(`ALTER TYPE "track_status_enum" ADD VALUE IF NOT EXISTS 'scheduled'`); + + // Add scheduled_at column + await queryRunner.query(` + ALTER TABLE "tracks" + ADD COLUMN IF NOT EXISTS "scheduled_at" TIMESTAMPTZ NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove scheduled_at column + await queryRunner.query(`ALTER TABLE "tracks" DROP COLUMN IF EXISTS "scheduled_at"`); + + // Note: PostgreSQL does not support removing enum values without recreating the type. + // To fully revert the enum, recreate it without 'scheduled' and update the column. + await queryRunner.query(` + ALTER TABLE "tracks" + ALTER COLUMN "track_status" TYPE VARCHAR(50) + `); + await queryRunner.query(`DROP TYPE IF EXISTS "track_status_enum"`); + await queryRunner.query(` + CREATE TYPE "track_status_enum" AS ENUM ('processing', 'finished', 'failed') + `); + await queryRunner.query(` + ALTER TABLE "tracks" + ALTER COLUMN "track_status" TYPE "track_status_enum" + USING "track_status"::"track_status_enum" + `); + } +} diff --git a/src/database/seeds/playlist.seeder.ts b/src/database/seeds/playlist.seeder.ts index f4138f59..212ea6a2 100644 --- a/src/database/seeds/playlist.seeder.ts +++ b/src/database/seeds/playlist.seeder.ts @@ -199,7 +199,9 @@ export class PlaylistSeeder implements Seeder { }); } - // --- Seed Likes + playlist_like activities --- + // --- Seed Likes + playlist_like activities (public playlists only) --- + if (!playlist.isPublic) continue; + const numLikes = Math.floor(Math.random() * 5); const likers = [...users].sort(() => 0.5 - Math.random()).slice(0, numLikes); for (const liker of likers) { diff --git a/src/database/seeds/seed-audio-urls.constant.ts b/src/database/seeds/seed-audio-urls.constant.ts new file mode 100644 index 00000000..dbb50581 --- /dev/null +++ b/src/database/seeds/seed-audio-urls.constant.ts @@ -0,0 +1,11 @@ +/** + * Shared S3 URLs used by the track seeder factory. + * The audio processor checks against this list before deleting old S3 files + * to prevent accidentally wiping seeded assets during re-processing. + */ +export const SEED_PROTECTED_AUDIO_URLS = new Set([ + 'https://harmonica-s3-storage-287109772507-us-east-1-an.s3.amazonaws.com/tracks/4bb04f6f-b570-48b9-a97b-2790795e0d72/WhatsApp_Audio_2026-05-02_at_51805_PM_standard.mp3', + 'https://harmonica-s3-storage-287109772507-us-east-1-an.s3.amazonaws.com/tracks/4bb04f6f-b570-48b9-a97b-2790795e0d72/WhatsApp_Audio_2026-05-02_at_51805_PM_hq.mp3', + 'https://harmonica-s3-storage-287109772507-us-east-1-an.s3.amazonaws.com/tracks/4bb04f6f-b570-48b9-a97b-2790795e0d72/WhatsApp_Audio_2026-05-02_at_51805_PM_preview.mp3', + 'https://harmonica-s3-storage-287109772507-us-east-1-an.s3.amazonaws.com/tracks/4bb04f6f-b570-48b9-a97b-2790795e0d72/waveform.json', +]); diff --git a/src/database/seeds/track-plays.seeder.ts b/src/database/seeds/track-plays.seeder.ts index 306baf56..01099702 100644 --- a/src/database/seeds/track-plays.seeder.ts +++ b/src/database/seeds/track-plays.seeder.ts @@ -1,6 +1,8 @@ import { DataSource } from 'typeorm'; import { Seeder, SeederFactoryManager } from 'typeorm-extension'; +import * as bcrypt from 'bcrypt'; import { User } from '../../user/entities/user.entity'; +import { UserEmail } from '../../user/entities/user-email.entity'; import { Track } from '../../track/entities/track.entity'; import { Playlist } from '../../playlist/entities/playlist.entity'; import { TrackLikes } from '../../track/entities/track-likes.entity'; @@ -11,6 +13,27 @@ import { RecentlyPlayedItemType, } from '../../track/entities/recently-played.entity'; import { TrackFirstFan } from '../../track/entities/track-first-fan.entity'; +import { Settings } from '../../settings/entities/settings.entity'; +import { TrackVisibility } from '../../track/enums/track-visibility.enum'; +import { mapUser, addDocuments } from '../../search/indexing'; + +const AVATAR_URL = + 'https://harmonica-s3-storage-287109772507-us-east-1-an.s3.amazonaws.com/profiles/bb4207da-259a-4338-a24e-3df2911abbd2/avatar_1777219932024.webp'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +// 8 dedicated fan users who meet all top/first fan qualification criteria: +// they follow both artists, like all featured tracks, and have an avatar +const FAN_DEMOS = [ + { username: 'superfan1', email: 'superfan1@test.com', firstName: 'Alex', lastName: 'Rivera' }, + { username: 'superfan2', email: 'superfan2@test.com', firstName: 'Jordan', lastName: 'Chen' }, + { username: 'superfan3', email: 'superfan3@test.com', firstName: 'Morgan', lastName: 'Kim' }, + { username: 'superfan4', email: 'superfan4@test.com', firstName: 'Taylor', lastName: 'Patel' }, + { username: 'superfan5', email: 'superfan5@test.com', firstName: 'Casey', lastName: 'Okafor' }, + { username: 'superfan6', email: 'superfan6@test.com', firstName: 'Riley', lastName: 'Santos' }, + { username: 'superfan7', email: 'superfan7@test.com', firstName: 'Drew', lastName: 'Nguyen' }, + { username: 'superfan8', email: 'superfan8@test.com', firstName: 'Jamie', lastName: 'Muller' }, +] as const; export class TrackPlaysSeeder implements Seeder { public async run(dataSource: DataSource, _factoryManager: SeederFactoryManager): Promise { @@ -19,9 +42,11 @@ export class TrackPlaysSeeder implements Seeder { const firstFanRepository = dataSource.getRepository(TrackFirstFan); const trackRepository = dataSource.getRepository(Track); const userRepository = dataSource.getRepository(User); + const userEmailRepository = dataSource.getRepository(UserEmail); const playlistRepository = dataSource.getRepository(Playlist); const likesRepository = dataSource.getRepository(TrackLikes); const followsRepository = dataSource.getRepository(UserFollow); + const settingsRepository = dataSource.getRepository(Settings); const existingPlays = await trackPlayRepository.count(); if (existingPlays > 0) { @@ -29,61 +54,185 @@ export class TrackPlaysSeeder implements Seeder { return; } - // Use only known test listeners to keep seed data small and predictable - const listeners = await userRepository.find({ - where: [{ username: 'listener1' }, { username: 'listener2' }, { username: 'listener3' }], - }); + // ── 1. Create fan demo users ────────────────────────────────────────────── + console.log(' Creating fan demo users...'); + const passwordHash = await bcrypt.hash('Password123', 10); + const fanUsers: User[] = []; + + for (const fd of FAN_DEMOS) { + let fan = await userRepository.findOne({ where: { username: fd.username } }); + if (!fan) { + fan = await userRepository.save( + userRepository.create({ + username: fd.username, + passwordHash, + firstName: fd.firstName, + lastName: fd.lastName, + displayName: `${fd.firstName} ${fd.lastName}`, + role: 'listener', + plan: 'free', + isPublic: true, + avatarUrl: AVATAR_URL, + bio: 'Dedicated music fan.', + country: 'England', + city: 'london', + }) + ); + await addDocuments([mapUser(fan)]); + await userEmailRepository.save( + userEmailRepository.create({ + userId: fan.userId, + email: fd.email, + isPrimary: true, + isVerified: true, + verifiedAt: new Date(), + }) + ); + // Explicit settings to guarantee show_when_top_or_first_fan = true + await settingsRepository.save(settingsRepository.create({ userId: fan.userId })); + } + fanUsers.push(fan); + } + console.log(` Created ${fanUsers.length} fan demo users (superfan1-8 / Password123)`); - const artists = await userRepository.find({ - where: [{ username: 'artist1' }, { username: 'artist2' }], - }); + // ── 2. Resolve artists and their first 3 public tracks each ─────────────── + const artist1 = await userRepository.findOne({ where: { username: 'artist1' } }); + const artist2 = await userRepository.findOne({ where: { username: 'artist2' } }); - if (listeners.length === 0 || artists.length === 0) { - console.warn('Known test users not found. Run UserSeeder first.'); + if (!artist1 || !artist2) { + console.warn(' artist1 or artist2 not found. Run UserSeeder first.'); return; } - // Use only 3 tracks per artist (first ones found) - const tracks: Track[] = []; - for (const artist of artists) { - const artistTracks = await trackRepository.find({ - where: { userId: artist.userId }, + const [a1Tracks, a2Tracks] = await Promise.all([ + trackRepository.find({ + where: { userId: artist1.userId, visibility: TrackVisibility.PUBLIC, hidden: false }, take: 3, - }); - tracks.push(...artistTracks); - } + }), + trackRepository.find({ + where: { userId: artist2.userId, visibility: TrackVisibility.PUBLIC, hidden: false }, + take: 3, + }), + ]); + + // featuredTracks spans both artists so fans' play histories cross artists, + // enabling related-track discovery (fans of A's tracks also played B's tracks) + const featuredTracks = [...a1Tracks, ...a2Tracks]; - if (tracks.length === 0) { - console.warn('No tracks found. Run TrackSeeder first.'); + if (featuredTracks.length === 0) { + console.warn(' No public tracks found. Run TrackSeeder first.'); return; } - const playlists = await playlistRepository.find({ take: 3 }); + // ── 3. Fans follow both artists ─────────────────────────────────────────── + for (const fan of fanUsers) { + for (const artist of [artist1, artist2]) { + const exists = await followsRepository.findOne({ + where: { follower: fan.userId, followed: artist.userId }, + }); + if (!exists) { + await followsRepository.save( + followsRepository.create({ follower: fan.userId, followed: artist.userId }) + ); + } + } + } - console.log( - `Seeding track plays for ${listeners.length} listeners across ${tracks.length} tracks...` - ); + // ── 4. Fans like all featured tracks ────────────────────────────────────── + for (const fan of fanUsers) { + for (const track of featuredTracks) { + const exists = await likesRepository.findOne({ + where: { userId: fan.userId, trackId: track.trackId }, + }); + if (!exists) { + await likesRepository.save( + likesRepository.create({ userId: fan.userId, trackId: track.trackId }) + ); + } + } + } + // ── 5. Build play records ───────────────────────────────────────────────── + // Each fan gets: + // • 10-20 plays within the first-7-day window → qualifies as a first fan + // • 10-30 plays after the window → boosts top-fan ranking + // All fans play ALL featured tracks from both artists + // → top fans of track A also played track B → related-track data const allPlays: Partial[] = []; - // Track most recent play per (user → artist) and (user → playlist) for recently_played const artistLatestPlay = new Map>(); + + for (const track of featuredTracks) { + const releaseDate = track.releaseDate + ? new Date(track.releaseDate) + : new Date(track.createdAt); + const windowEnd = new Date(releaseDate.getTime() + 7 * DAY_MS); + + for (const fan of fanUsers) { + // 10 plays spread evenly across the first 6 days + for (let i = 0; i < 10; i++) { + allPlays.push({ + trackId: track.trackId, + userId: fan.userId, + playedAt: new Date(releaseDate.getTime() + (i / 10) * 6 * DAY_MS), + playlistId: null, + }); + } + // add random plays within the window to create variation + var random_num = Math.floor(Math.random() * 10); + for (let i = 0; i < random_num; i++) { + const playedAt = new Date(releaseDate.getTime() + Math.random() * 7 * DAY_MS); + allPlays.push({ trackId: track.trackId, userId: fan.userId, playedAt, playlistId: null }); + } + // 10 plays after the window, one every 2 days + for (let i = 0; i < 10; i++) { + const playedAt = new Date(windowEnd.getTime() + (i + 1) * 2 * DAY_MS); + allPlays.push({ trackId: track.trackId, userId: fan.userId, playedAt, playlistId: null }); + + // Track latest play per (fan → artist) for recently_played + const artistId = track.userId; + if (!artistLatestPlay.has(fan.userId)) artistLatestPlay.set(fan.userId, new Map()); + const aMap = artistLatestPlay.get(fan.userId)!; + if (!aMap.has(artistId) || playedAt > aMap.get(artistId)!) { + aMap.set(artistId, playedAt); + } + } + // add random plays after the window to create variation + random_num = Math.floor(Math.random() * 20); + for (let i = 0; i < random_num; i++) { + const playedAt = new Date(windowEnd.getTime() + Math.random() * 10 * DAY_MS); + allPlays.push({ trackId: track.trackId, userId: fan.userId, playedAt, playlistId: null }); + } + } + } + + // ── 6. Legacy listener plays (listener1–3) kept for other seed expectations ─ + const listeners = await userRepository.find({ + where: [{ username: 'listener1' }, { username: 'listener2' }, { username: 'listener3' }], + }); + + const legacyTracks: Track[] = []; + for (const artist of [artist1, artist2]) { + const artistTracks = await trackRepository.find({ + where: { userId: artist.userId }, + take: 3, + }); + legacyTracks.push(...artistTracks); + } + const playlistLatestPlay = new Map>(); + const playlists = await playlistRepository.find({ take: 3 }); - for (const track of tracks) { + for (const track of legacyTracks) { const releaseDate = track.releaseDate ? new Date(track.releaseDate) : new Date(track.createdAt); + const windowEnd = new Date(releaseDate.getTime() + 7 * DAY_MS); for (const listener of listeners) { - // 3 plays per listener per track: 1 guaranteed within the 7-day window, 2 after - const windowEnd = new Date(releaseDate.getTime() + 7 * 24 * 60 * 60 * 1000); - const playTimes: Date[] = [ - // Early play — within first 7 days - new Date(releaseDate.getTime() + Math.random() * 6 * 24 * 60 * 60 * 1000), - // Two later plays - new Date(windowEnd.getTime() + Math.random() * 10 * 24 * 60 * 60 * 1000), - new Date(windowEnd.getTime() + Math.random() * 20 * 24 * 60 * 60 * 1000), + new Date(releaseDate.getTime() + Math.random() * 6 * DAY_MS), + new Date(windowEnd.getTime() + Math.random() * 10 * DAY_MS), + new Date(windowEnd.getTime() + Math.random() * 20 * DAY_MS), ]; for (const playedAt of playTimes) { @@ -99,15 +248,16 @@ export class TrackPlaysSeeder implements Seeder { playlistId: playlist?.playlistId ?? null, }); - // Track latest (user → artist) play + // Track latest (listener → artist) for recently_played + const artistId = track.userId; if (!artistLatestPlay.has(listener.userId)) artistLatestPlay.set(listener.userId, new Map()); const aMap = artistLatestPlay.get(listener.userId)!; - if (!aMap.has(track.userId) || playedAt > aMap.get(track.userId)!) { - aMap.set(track.userId, playedAt); + if (!aMap.has(artistId) || playedAt > aMap.get(artistId)!) { + aMap.set(artistId, playedAt); } - // Track latest (user → playlist) play + // Track latest (listener → playlist) for recently_played if (playlist) { if (!playlistLatestPlay.has(listener.userId)) playlistLatestPlay.set(listener.userId, new Map()); @@ -120,10 +270,11 @@ export class TrackPlaysSeeder implements Seeder { } } + // ── 7. Persist all plays ────────────────────────────────────────────────── await trackPlayRepository.save(allPlays as TrackPlay[]); console.log(` - ${allPlays.length} play records created`); - // Upsert recently_played — artists + // ── 8. Upsert recently_played ───────────────────────────────────────────── const artistEntries: Partial[] = []; for (const [userId, aMap] of artistLatestPlay.entries()) { for (const [artistId, playedAt] of aMap.entries()) { @@ -138,7 +289,6 @@ export class TrackPlaysSeeder implements Seeder { await recentlyPlayedRepository.upsert(artistEntries, ['userId', 'itemId', 'itemType']); console.log(` - ${artistEntries.length} recently played artist entries upserted`); - // Upsert recently_played — playlists const playlistEntries: Partial[] = []; for (const [userId, pMap] of playlistLatestPlay.entries()) { for (const [playlistId, playedAt] of pMap.entries()) { @@ -155,10 +305,8 @@ export class TrackPlaysSeeder implements Seeder { console.log(` - ${playlistEntries.length} recently played playlist entries upserted`); } - // Compute track_first_fans - // Qualification: played within 7 days of release + follows artist + liked track + has avatarUrl - console.log(' Computing first fans...'); - + // ── 9. Snapshot first fans for featured tracks ──────────────────────────── + console.log(' Computing first fan snapshots...'); const allLikes = await likesRepository.find(); const allFollows = await followsRepository.find(); const likeSet = new Set(allLikes.map((l) => `${l.userId}:${l.trackId}`)); @@ -166,20 +314,21 @@ export class TrackPlaysSeeder implements Seeder { const firstFanEntries: Partial[] = []; - for (const track of tracks) { + for (const track of featuredTracks) { const releaseDate = track.releaseDate ? new Date(track.releaseDate) : new Date(track.createdAt); - const windowEnd = new Date(releaseDate.getTime() + 7 * 24 * 60 * 60 * 1000); + const windowEnd = new Date(releaseDate.getTime() + 7 * DAY_MS); - // Users who played this track within the first 7 days const earlyPlays = allPlays.filter( (p) => p.trackId === track.trackId && p.playedAt! <= windowEnd ); const earlyPlayerIds = [...new Set(earlyPlays.map((p) => p.userId!))]; - const qualified = earlyPlayerIds.filter((userId) => { - const user = listeners.find((u) => u.userId === userId); + // Qualification: follows artist + liked track + has avatarUrl + // (show_when_top_or_first_fan defaults true for all fan users we created) + const qualifiedIds = earlyPlayerIds.filter((userId) => { + const user = [...fanUsers, ...listeners].find((u) => u.userId === userId); return ( user?.avatarUrl && followSet.has(`${userId}:${track.userId}`) && @@ -187,18 +336,17 @@ export class TrackPlaysSeeder implements Seeder { ); }); - if (qualified.length === 0) continue; + if (qualifiedIds.length === 0) continue; - // All-time play count per qualified user for this track + // All-time play count for ranking const playCountMap = new Map(); for (const play of allPlays.filter((p) => p.trackId === track.trackId)) { - if (qualified.includes(play.userId!)) { + if (qualifiedIds.includes(play.userId!)) { playCountMap.set(play.userId!, (playCountMap.get(play.userId!) ?? 0) + 1); } } const top5 = [...playCountMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); - for (const [userId, playCount] of top5) { firstFanEntries.push({ trackId: track.trackId, userId, playCount }); } @@ -208,9 +356,14 @@ export class TrackPlaysSeeder implements Seeder { await firstFanRepository.save(firstFanEntries); console.log(` - ${firstFanEntries.length} first fan records saved`); } else { - console.log(' - No first fans qualified (listeners may lack avatarUrl, follows, or likes)'); + console.log(' - No first fans qualified (check follows, likes, avatarUrl conditions)'); } console.log('Track plays seeding complete!'); + console.log(` - ${fanUsers.length} fan users (superfan1-8 / Password123)`); + console.log( + ` - Each fan follows both artists, liked all ${featuredTracks.length} featured tracks` + ); + console.log(` - ${allPlays.length} total plays`); } } diff --git a/src/database/seeds/track.seeder.ts b/src/database/seeds/track.seeder.ts index 37d5926d..71e4f45e 100644 --- a/src/database/seeds/track.seeder.ts +++ b/src/database/seeds/track.seeder.ts @@ -104,8 +104,10 @@ export class TrackSeeder implements Seeder { totalActivitiesCreated++; } - // 6. Generate Engagement for each track + // 6. Generate Engagement for each track (public, non-hidden only) for (const track of tracks) { + if (track.visibility !== TrackVisibility.PUBLIC || track.hidden) continue; + // --- A. Seed Likes --- // 20% to 60% of users like each track const likers = [...allUsers] diff --git a/src/database/seeds/user.seeder.ts b/src/database/seeds/user.seeder.ts index 528a8550..43ecb52a 100644 --- a/src/database/seeds/user.seeder.ts +++ b/src/database/seeds/user.seeder.ts @@ -9,6 +9,11 @@ import { Genre } from '../../genre/entities/genre.entity'; import { Settings } from '../../settings/entities/settings.entity'; import * as bcrypt from 'bcrypt'; import { mapUser, addDocuments } from '../../search/indexing'; +import { + Subscription, + SubscriptionPlan, + SubscriptionStatus, +} from '../../subscription/entities/subscription.entity'; export class UserSeeder implements Seeder { private async createSettingsForUser( @@ -28,6 +33,8 @@ export class UserSeeder implements Seeder { const favoriteGenreRepository = dataSource.getRepository(FavoriteGenre); const genreRepository = dataSource.getRepository(Genre); const settingsRepository = dataSource.getRepository(Settings); + const subscriptionRepository = dataSource.getRepository(Subscription); + const { faker } = await import('@faker-js/faker'); // Check if users already exist const existingUsers = await userRepository.count(); @@ -56,12 +63,32 @@ export class UserSeeder implements Seeder { lastName: 'User', displayName: 'Administrator', role: 'admin', - plan: 'go+', + plan: 'pro', isPublic: true, + country: 'England', + city: 'London', }); var adminUser = await userRepository.save(admin); await addDocuments([mapUser(adminUser)]); await this.createSettingsForUser(admin.userId, settingsRepository); + // Admin has pro plan — seed a subscription + { + const now = new Date(); + const periodEnd = new Date(now); + periodEnd.setFullYear(periodEnd.getFullYear() + 1); + await subscriptionRepository.save( + subscriptionRepository.create({ + userId: adminUser.userId, + stripeCustomerId: `cus_seed_admin_${faker.string.nanoid(8)}`, + stripeSubscriptionId: `sub_seed_admin_${faker.string.nanoid(8)}`, + plan: SubscriptionPlan.PRO_YEARLY, + status: SubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: false, + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + }) + ); + } const adminEmail = emailRepository.create({ userId: admin.userId, @@ -82,6 +109,8 @@ export class UserSeeder implements Seeder { lastName: 'Doe', role: 'artist', plan: 'pro', + country: 'England', + city: 'London', }, { username: 'artist2', @@ -90,6 +119,8 @@ export class UserSeeder implements Seeder { lastName: 'Smith', role: 'artist', plan: 'pro', + country: 'England', + city: 'London', }, { username: 'listener1', @@ -98,6 +129,8 @@ export class UserSeeder implements Seeder { lastName: 'Johnson', role: 'listener', plan: 'free', + country: 'England', + city: 'London', }, { username: 'listener2', @@ -107,6 +140,8 @@ export class UserSeeder implements Seeder { role: 'listener', plan: 'go+', isSuspended: true, + country: 'England', + city: 'London', }, { username: 'listener3', @@ -114,7 +149,9 @@ export class UserSeeder implements Seeder { firstName: 'Ann', lastName: 'Michael', role: 'listener', - plan: 'free', + plan: 'go+', + country: 'England', + city: 'London', }, ]; @@ -156,6 +193,28 @@ export class UserSeeder implements Seeder { genreId: genre.genreId, }); } + + // if the user is pro or go+ add subscription + if (testUserData.plan === 'pro' || testUserData.plan === 'go+') { + const now = new Date(); + const periodEnd = new Date(now); + periodEnd.setFullYear(periodEnd.getFullYear() + 1); + await subscriptionRepository.save( + subscriptionRepository.create({ + userId: savedUser.userId, + stripeCustomerId: `cus_seed_${faker.string.nanoid(14)}`, + stripeSubscriptionId: `sub_seed_${faker.string.nanoid(14)}`, + plan: + testUserData.plan === 'pro' + ? SubscriptionPlan.PRO_YEARLY + : SubscriptionPlan.GO_PLUS_YEARLY, + status: SubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: false, + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + }) + ); + } } // Generate 50 random users using factory @@ -233,6 +292,26 @@ export class UserSeeder implements Seeder { genreId: genre.genreId, }); } + + // if the user is pro or go+ add subscription + if (user.plan === 'pro' || user.plan === 'go+') { + const now = new Date(); + const periodEnd = new Date(now); + periodEnd.setFullYear(periodEnd.getFullYear() + 1); + await subscriptionRepository.save( + subscriptionRepository.create({ + userId: user.userId, + stripeCustomerId: `cus_seed_${faker.string.nanoid(14)}`, + stripeSubscriptionId: `sub_seed_${faker.string.nanoid(14)}`, + plan: + user.plan === 'pro' ? SubscriptionPlan.PRO_YEARLY : SubscriptionPlan.GO_PLUS_YEARLY, + status: SubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: false, + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + }) + ); + } } console.log('Users seeded successfully!'); diff --git a/src/discovery/discovery.controller.spec.ts b/src/discovery/discovery.controller.spec.ts index ad628c7d..bc3e2ea2 100644 --- a/src/discovery/discovery.controller.spec.ts +++ b/src/discovery/discovery.controller.spec.ts @@ -79,7 +79,14 @@ describe('DiscoveryController', () => { const result = await controller.getFeed(mockUserId, mockIp, true, 1, 20); - expect(discoveryService.getFeed).toHaveBeenCalledWith(mockUserId, mockIp, true, 1, 20); + expect(discoveryService.getFeed).toHaveBeenCalledWith( + mockUserId, + mockIp, + true, + 1, + 20, + undefined + ); expect(discoveryService.getFeed).toHaveBeenCalledTimes(1); expect(result).toEqual(mockFeed); }); @@ -91,7 +98,14 @@ describe('DiscoveryController', () => { await controller.getFeed(mockUserId, mockIp, false, undefined, undefined); - expect(discoveryService.getFeed).toHaveBeenCalledWith(mockUserId, mockIp, false, 1, 20); + expect(discoveryService.getFeed).toHaveBeenCalledWith( + mockUserId, + mockIp, + false, + 1, + 20, + undefined + ); }); it('should handle includeReposts parameter correctly', async () => { @@ -100,7 +114,14 @@ describe('DiscoveryController', () => { await controller.getFeed(mockUserId, mockIp, false, 1, 20); - expect(discoveryService.getFeed).toHaveBeenCalledWith(mockUserId, mockIp, false, 1, 20); + expect(discoveryService.getFeed).toHaveBeenCalledWith( + mockUserId, + mockIp, + false, + 1, + 20, + undefined + ); }); it('should handle custom pagination parameters', async () => { @@ -121,7 +142,14 @@ describe('DiscoveryController', () => { const result = await controller.getFeed(mockUserId, mockIp, true, 3, 50); - expect(discoveryService.getFeed).toHaveBeenCalledWith(mockUserId, mockIp, true, 3, 50); + expect(discoveryService.getFeed).toHaveBeenCalledWith( + mockUserId, + mockIp, + true, + 3, + 50, + undefined + ); expect(result).toEqual(mockFeed); }); @@ -191,7 +219,8 @@ describe('DiscoveryController', () => { mockUsername, mockIp, 1, - 20 + 20, + undefined ); expect(discoveryService.getUserRecentActivities).toHaveBeenCalledTimes(1); expect(result).toEqual(mockActivities); @@ -215,7 +244,8 @@ describe('DiscoveryController', () => { mockUsername, mockIp, 1, - 20 + 20, + undefined ); }); @@ -237,7 +267,8 @@ describe('DiscoveryController', () => { mockUsername, mockIp, 2, - 10 + 10, + undefined ); expect(result).toEqual(mockActivities); }); @@ -253,7 +284,8 @@ describe('DiscoveryController', () => { 'different-user', mockIp, 1, - 20 + 20, + undefined ); }); @@ -376,7 +408,8 @@ describe('DiscoveryController', () => { mockArtistUsername, mockTrackName, mockUserId, - mockIp + mockIp, + undefined ); expect(discoveryService.getTrackStation).toHaveBeenCalledTimes(1); expect(result).toEqual(mockStation); @@ -400,7 +433,8 @@ describe('DiscoveryController', () => { 'different-artist', mockTrackName, mockUserId, - mockIp + mockIp, + undefined ); }); @@ -422,7 +456,8 @@ describe('DiscoveryController', () => { mockArtistUsername, 'different-track', mockUserId, - mockIp + mockIp, + undefined ); }); @@ -445,7 +480,8 @@ describe('DiscoveryController', () => { mockArtistUsername, specialTrackName, mockUserId, - mockIp + mockIp, + undefined ); }); @@ -632,7 +668,8 @@ describe('DiscoveryController', () => { expect(discoveryService.getArtistStation).toHaveBeenCalledWith( mockUsername, mockUserId, - mockIp + mockIp, + undefined ); expect(discoveryService.getArtistStation).toHaveBeenCalledTimes(1); expect(result).toEqual(mockStation); @@ -656,7 +693,8 @@ describe('DiscoveryController', () => { expect(discoveryService.getArtistStation).toHaveBeenCalledWith( 'different-artist', mockUserId, - mockIp + mockIp, + undefined ); }); @@ -799,7 +837,8 @@ describe('DiscoveryController', () => { expect(discoveryService.getArtistStation).toHaveBeenCalledWith( mockUsername, mockUserId, - differentIp + differentIp, + undefined ); }); @@ -833,7 +872,8 @@ describe('DiscoveryController', () => { expect(discoveryService.getUserPopularTracks).toHaveBeenCalledWith( mockUsername, mockUserId, - mockIp + mockIp, + undefined ); expect(discoveryService.getUserPopularTracks).toHaveBeenCalledTimes(1); expect(result).toEqual(mockResult); @@ -896,7 +936,11 @@ describe('DiscoveryController', () => { const result = await controller.getMoreOfWhatYouLike(mockUserId, mockIp); - expect(discoveryService.getMoreOfWhatYouLike).toHaveBeenCalledWith(mockUserId, mockIp); + expect(discoveryService.getMoreOfWhatYouLike).toHaveBeenCalledWith( + mockUserId, + mockIp, + undefined + ); expect(discoveryService.getMoreOfWhatYouLike).toHaveBeenCalledTimes(1); expect(result).toEqual(mockResult); }); @@ -966,7 +1010,8 @@ describe('DiscoveryController', () => { 'd', mockIp, 1, - 20 + 20, + undefined ); expect(result).toEqual(mockResult); }); @@ -998,7 +1043,8 @@ describe('DiscoveryController', () => { undefined, mockIp, 1, - 20 + 20, + undefined ); }); @@ -1029,7 +1075,8 @@ describe('DiscoveryController', () => { undefined, mockIp, 1, - 20 + 20, + undefined ); }); @@ -1131,7 +1178,11 @@ describe('DiscoveryController', () => { const result = await controller.getRecommendedStations(mockUserId, mockIp); - expect(discoveryService.getRecommendedStations).toHaveBeenCalledWith(mockUserId, mockIp); + expect(discoveryService.getRecommendedStations).toHaveBeenCalledWith( + mockUserId, + mockIp, + undefined + ); expect(discoveryService.getRecommendedStations).toHaveBeenCalledTimes(1); expect(result).toEqual(mockResult); }); @@ -1226,7 +1277,8 @@ describe('DiscoveryController', () => { mockIp, 'recent', 1, - 20 + 20, + undefined ); expect(discoveryService.getTracksByTag).toHaveBeenCalledTimes(1); }); @@ -1282,7 +1334,8 @@ describe('DiscoveryController', () => { mockIp, 'playlists', 1, - 20 + 20, + undefined ); expect(result).toBe(mockResult); }); @@ -1366,15 +1419,16 @@ describe('DiscoveryController', () => { playlistTracks: [], }; - it('should call getUserLikedby with currentUserId first then targetUserId', async () => { + it('should call getUserLikedby with targetUserId first then currentUserId', async () => { mockDiscoveryService.getUserLikedby.mockResolvedValue(mockPlaylistResponse); - const result = await controller.getLikedByUsersForUser(targetUserId, mockUserId, mockIp); + const result = await controller.getLikedByUsersForUser(mockUserId, targetUserId, mockIp); expect(mockDiscoveryService.getUserLikedby).toHaveBeenCalledWith( mockUserId, targetUserId, - mockIp + mockIp, + undefined ); expect(result).toEqual(mockPlaylistResponse); }); @@ -1423,7 +1477,8 @@ describe('DiscoveryController', () => { expect(mockDiscoveryService.getMoreAlbumsOfWhatYouLike).toHaveBeenCalledWith( mockUserId, - mockIp + mockIp, + undefined ); expect(result).toEqual(expected); }); @@ -1450,7 +1505,11 @@ describe('DiscoveryController', () => { const result = await controller.getDiscoverFeed(mockUserId, mockIp); - expect(mockDiscoveryService.getDiscoverFeed).toHaveBeenCalledWith(mockUserId, mockIp); + expect(mockDiscoveryService.getDiscoverFeed).toHaveBeenCalledWith( + mockUserId, + mockIp, + undefined + ); expect(result).toEqual(expected); }); diff --git a/src/discovery/discovery.controller.ts b/src/discovery/discovery.controller.ts index 86f992a7..a6ec0d18 100644 --- a/src/discovery/discovery.controller.ts +++ b/src/discovery/discovery.controller.ts @@ -37,9 +37,10 @@ export class DiscoveryController { @Ip() ip: string, @Body('includeReposts') includeReposts: boolean, @Query('page') page: number = 1, - @Query('limit') limit: number = 20 + @Query('limit') limit: number = 20, + @CurrentUser('plan') plan?: string ) { - return this.discoveryService.getFeed(userId, ip, includeReposts, page, limit); + return this.discoveryService.getFeed(userId, ip, includeReposts, page, limit, plan); } @ApiGetUserRecentActivities() @@ -49,9 +50,10 @@ export class DiscoveryController { @Ip() ip: string, @Param('username') username: string, @Query('page') page: number = 1, - @Query('limit') limit: number = 20 + @Query('limit') limit: number = 20, + @CurrentUser('plan') plan?: string ) { - return this.discoveryService.getUserRecentActivities(userId, username, ip, page, limit); + return this.discoveryService.getUserRecentActivities(userId, username, ip, page, limit, plan); } @ApiGetTrackStation() @@ -60,9 +62,10 @@ export class DiscoveryController { @CurrentUser('sub') userId: string, @Param('artist_username') artistUsername: string, @Param('track_name') trackName: string, - @Ip() ip: string + @Ip() ip: string, + @CurrentUser('plan') plan?: string ) { - return this.discoveryService.getTrackStation(artistUsername, trackName, userId, ip); + return this.discoveryService.getTrackStation(artistUsername, trackName, userId, ip, plan); } @ApiGetArtistStation() @@ -70,9 +73,10 @@ export class DiscoveryController { async getArtistStation( @CurrentUser('sub') userId: string, @Param('username') username: string, - @Ip() ip: string + @Ip() ip: string, + @CurrentUser('plan') plan?: string ) { - return this.discoveryService.getArtistStation(username, userId, ip); + return this.discoveryService.getArtistStation(username, userId, ip, plan); } @ApiGetUserPopularTracks() @@ -80,15 +84,20 @@ export class DiscoveryController { async getUserPopularTracks( @CurrentUser('sub') userId: string, @Param('username') username: string, - @Ip() ip: string + @Ip() ip: string, + @CurrentUser('plan') plan?: string ) { - return this.discoveryService.getUserPopularTracks(username, userId, ip); + return this.discoveryService.getUserPopularTracks(username, userId, ip, plan); } @ApiGetMoreOfWhatYouLike() @Get('/more-of-what-you-like') - async getMoreOfWhatYouLike(@CurrentUser('sub') userId: string, @Ip() ip: string) { - return this.discoveryService.getMoreOfWhatYouLike(userId, ip); + async getMoreOfWhatYouLike( + @CurrentUser('sub') userId: string, + @Ip() ip: string, + @CurrentUser('plan') plan?: string + ) { + return this.discoveryService.getMoreOfWhatYouLike(userId, ip, plan); } @ApiSearch() @@ -103,7 +112,8 @@ export class DiscoveryController { @Query('duration') duration?: string, @Query('created') createdAt?: string, @Query('page') page: number = 1, - @Query('limit') limit: number = 20 + @Query('limit') limit: number = 20, + @CurrentUser('plan') plan?: string ) { return this.discoveryService.getSearchResults( userId, @@ -115,7 +125,8 @@ export class DiscoveryController { createdAt, ip, page, - limit + limit, + plan ); } @@ -127,8 +138,12 @@ export class DiscoveryController { @ApiGetRecommendedStations() @Get('/recommended-stations') - async getRecommendedStations(@CurrentUser('sub') userId: string, @Ip() ip: string) { - return this.discoveryService.getRecommendedStations(userId, ip); + async getRecommendedStations( + @CurrentUser('sub') userId: string, + @Ip() ip: string, + @CurrentUser('plan') plan?: string + ) { + return this.discoveryService.getRecommendedStations(userId, ip, plan); } @ApiGetTrendingMusicByGenre() @@ -155,9 +170,10 @@ export class DiscoveryController { async getLikedByUsersForUser( @Param('userId') userId: string, @CurrentUser('sub') currentUserId: string, - @Ip() ip: string + @Ip() ip: string, + @CurrentUser('plan') plan?: string ) { - return this.discoveryService.getUserLikedby(currentUserId, userId, ip); + return this.discoveryService.getUserLikedby(userId, currentUserId, ip, plan); } @ApiGetTracksByTag() @@ -168,21 +184,30 @@ export class DiscoveryController { @Ip() ip: string, @Query('type') type: string = 'recent', @Query('page') page: number = 1, - @Query('limit') limit: number = 20 + @Query('limit') limit: number = 20, + @CurrentUser('plan') plan?: string ) { - return this.discoveryService.getTracksByTag(userId, tagName, ip, type, page, limit); + return this.discoveryService.getTracksByTag(userId, tagName, ip, type, page, limit, plan); } @ApiGetMoreAlbumsOfWhatYouLike() @Get('/albums/more-albums-of-what-you-like') - async getMoreAlbumsOfWhatYouLike(@CurrentUser('sub') userId: string, @Ip() ip: string) { - return this.discoveryService.getMoreAlbumsOfWhatYouLike(userId, ip); + async getMoreAlbumsOfWhatYouLike( + @CurrentUser('sub') userId: string, + @Ip() ip: string, + @CurrentUser('plan') plan?: string + ) { + return this.discoveryService.getMoreAlbumsOfWhatYouLike(userId, ip, plan); } @ApiGetDiscoverFeed() @Get('feed/discover') - async getDiscoverFeed(@CurrentUser('sub') userId: string, @Ip() ip: string) { - return this.discoveryService.getDiscoverFeed(userId, ip); + async getDiscoverFeed( + @CurrentUser('sub') userId: string, + @Ip() ip: string, + @CurrentUser('plan') plan?: string + ) { + return this.discoveryService.getDiscoverFeed(userId, ip, plan); } @ApiGetArtistsToWatchOutFor() 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/discovery.service.ts b/src/discovery/discovery.service.ts index c626afbc..d6c5f7bb 100644 --- a/src/discovery/discovery.service.ts +++ b/src/discovery/discovery.service.ts @@ -17,6 +17,7 @@ import { Track } from '../track/entities/track.entity'; import { Playlist } from '../playlist/entities/playlist.entity'; import { TrackVisibility } from '../track/enums/track-visibility.enum'; import { getLocationFromIp } from '../common/utilities/geolocation.util'; +import { resolveAudioUrl } from '../common/utilities/audio.util'; import { UserService } from '../user/user.service'; import { search, autocomplete } from '../search/search'; import { EntityType } from '../search/types'; @@ -61,7 +62,8 @@ export class DiscoveryService { */ private formatTrack( track: Track & { isLiked?: boolean; isReposted?: boolean }, - country: string | null + country: string | null, + plan?: string ): Record | null { if (track.hidden || track.visibility !== TrackVisibility.PUBLIC) return null; const isBlocked = country && track.blockedRegions?.includes(country); @@ -85,7 +87,7 @@ export class DiscoveryService { genre: track.genre ? { genreId: (track.genre as Genre).genreId, name: (track.genre as Genre).name } : null, - audioUrl: isBlocked ? null : track.audioUrl, + audioUrl: isBlocked ? null : resolveAudioUrl(track, plan), waveformUrl: track.waveformUrl, playCount: track.playCount, likesCount: track.likesCount, @@ -105,7 +107,8 @@ export class DiscoveryService { */ private formatPlaylistTrack( pt: { position: number; track: Track & { isLiked?: boolean; isReposted?: boolean } }, - country: string | null + country: string | null, + plan?: string ): Record | null { const { track } = pt; if (!track) return null; @@ -113,14 +116,13 @@ export class DiscoveryService { const isBlocked = country && track.blockedRegions?.includes(country); const artist = track.user as User; - console.log('liked?', track.isLiked); return { position: pt.position, trackId: track.trackId, title: track.title, durationSeconds: track.durationSeconds, coverImage: track.coverImage, - audioUrl: isBlocked ? null : track.audioUrl, + audioUrl: isBlocked ? null : resolveAudioUrl(track, plan), waveformUrl: track.waveformUrl, playCount: track.playCount, likesCount: track.likesCount, @@ -148,7 +150,8 @@ export class DiscoveryService { */ private formatPlaylist( playlist: Playlist & { isLiked?: boolean; isReposted?: boolean }, - country: string | null + country: string | null, + plan?: string ): Record { const owner = playlist.user as User; return { @@ -175,7 +178,7 @@ export class DiscoveryService { } : null, playlistTracks: (playlist.playlistTracks ?? []) - .map((pt: any) => this.formatPlaylistTrack(pt, country)) + .map((pt: any) => this.formatPlaylistTrack(pt, country, plan)) .filter((pt): pt is NonNullable => pt !== null), }; } @@ -186,7 +189,8 @@ export class DiscoveryService { private async assembleActivities( activities: any[], userId: string, - country: string | null + country: string | null, + plan?: string ): Promise { const trackIds = activities .filter( @@ -232,7 +236,7 @@ export class DiscoveryService { a.activityType === ActivityType.TRACK_POSTED || a.activityType === ActivityType.TRACK_REPOST ) { - return { ...a, target: this.formatTrack(rawTarget, country) }; + return { ...a, target: this.formatTrack(rawTarget, country, plan) }; } if ( @@ -255,7 +259,7 @@ export class DiscoveryService { : pt.track, })), }; - return { ...a, target: this.formatPlaylist(playlistWithStatus, country) }; + return { ...a, target: this.formatPlaylist(playlistWithStatus, country, plan) }; } return { ...a, target: rawTarget }; @@ -267,7 +271,8 @@ export class DiscoveryService { ip: string, includeReposts: boolean = true, page: number = 1, - limit: number = 20 + limit: number = 20, + plan?: string ) { const { country } = getLocationFromIp(ip); @@ -283,10 +288,15 @@ export class DiscoveryService { includeReposts ); - return this.assembleActivities(activities, userId, country); + return this.assembleActivities(activities, userId, country, plan); } - private async stationResponse(currentUserId: string, station: Playlist, ip?: string) { + private async stationResponse( + currentUserId: string, + station: Playlist, + ip?: string, + plan?: string + ) { // Batch-query like/repost status for the station and its tracks const stationTrackIds = station.playlistTracks .map((pt) => pt.track?.trackId) @@ -352,7 +362,8 @@ export class DiscoveryService { } : pt.track, }, - country + country, + plan ) ) .filter((pt): pt is NonNullable => pt !== null), @@ -365,7 +376,8 @@ export class DiscoveryService { artistUsername: string, trackName: string, currentUserId: string, - ip?: string + ip?: string, + plan?: string ) { const track = await this.trackRepository.findTrackByTitleAndArtist(trackName, artistUsername); if (!track) { @@ -378,7 +390,7 @@ export class DiscoveryService { const station = await this.playlistRepository.getTrackStation(track.trackId); if (station && station?.createdAt.getTime() > new Date().getTime() - 15 * 24 * 60 * 60 * 1000) { // If station exists and is less than 15 days old, return it - return this.stationResponse(currentUserId, station, ip); + return this.stationResponse(currentUserId, station, ip, plan); } const relatedTracks = await this.trackService.getRelatedTracksByTrackId(track.trackId); @@ -445,7 +457,7 @@ export class DiscoveryService { newStation.playlistId ); - return this.stationResponse(currentUserId, stationWithTracks!, ip); + return this.stationResponse(currentUserId, stationWithTracks!, ip, plan); } private updateScore(map: Map, track: Track, points: number) { @@ -460,7 +472,7 @@ export class DiscoveryService { } } - async getArtistStation(username: string, currentUserId: string, ip?: string) { + async getArtistStation(username: string, currentUserId: string, ip?: string, plan?: string) { const user = await this.userService.findByUsername(username); if (!user) { throw new NotFoundException('User not found'); @@ -478,7 +490,7 @@ export class DiscoveryService { const station = await this.playlistRepository.getArtistStation(user.userId); if (station && station?.createdAt.getTime() > new Date().getTime() - 15 * 24 * 60 * 60 * 1000) { // If station exists and is less than 15 days old, return it - return this.stationResponse(currentUserId, station, ip); + return this.stationResponse(currentUserId, station, ip, plan); } // get the user's most popular tracks @@ -577,7 +589,7 @@ export class DiscoveryService { newStation.playlistId ); - return this.stationResponse(currentUserId, stationWithTracks!, ip); + return this.stationResponse(currentUserId, stationWithTracks!, ip, plan); } async getUserRecentActivities( @@ -585,7 +597,8 @@ export class DiscoveryService { username: string, ip: string, page: number = 1, - limit: number = 20 + limit: number = 20, + plan?: string ) { const { country } = getLocationFromIp(ip); @@ -607,10 +620,10 @@ export class DiscoveryService { true ); - return this.assembleActivities(activities, userId, country); + return this.assembleActivities(activities, userId, country, plan); } - async getUserPopularTracks(username: string, currentUserId: string, ip: string) { + async getUserPopularTracks(username: string, currentUserId: string, ip: string, plan?: string) { const targetUser = await this.userService.findByUsername(username); if (!targetUser) { return { status: 'error', message: 'User not found' }; @@ -653,14 +666,15 @@ export class DiscoveryService { isLiked: likedIds.has(track.trackId), isReposted: repostedIds.has(track.trackId), }, - country + country, + plan ) ) .filter((t): t is NonNullable => t !== null), }; } - async getMoreOfWhatYouLike(userId: string, ip: string) { + async getMoreOfWhatYouLike(userId: string, ip: string, plan?: string) { // get genres of tracks the user has interacted with const interactedTrackTags = await this.trackService.getUserInteractedTrackTags(userId); // determine top 5 most interacted genres @@ -688,7 +702,7 @@ export class DiscoveryService { return { status: 'success', data: tracks - .map((track) => this.formatTrack(track as any, country)) + .map((track) => this.formatTrack(track as any, country, plan)) .filter((t): t is NonNullable => t !== null), }; } @@ -703,7 +717,8 @@ export class DiscoveryService { createdAt: string | undefined, ip: string, page: number = 1, - limit: number = 20 + limit: number = 20, + plan?: string ) { let durationRange; if (duration && type === 'track') { @@ -828,7 +843,7 @@ export class DiscoveryService { const id = hit.id.replace('track_', ''); const track = tracks.find((t) => t.trackId === id); if (!track) return null; - const formatted = this.formatTrack(track, country); + const formatted = this.formatTrack(track, country, plan); if (!formatted) return null; return { type: 'track', ...formatted }; } @@ -854,7 +869,7 @@ export class DiscoveryService { }; return { type: 'playlist', - ...this.formatPlaylist(playlistWithStatus, country), + ...this.formatPlaylist(playlistWithStatus, country, plan), }; } const id = hit.id.replace('album_', ''); @@ -877,7 +892,7 @@ export class DiscoveryService { }; return { type: 'album', - ...this.formatPlaylist(albumWithStatus, country), + ...this.formatPlaylist(albumWithStatus, country, plan), }; }) ); @@ -896,7 +911,7 @@ export class DiscoveryService { }; } - async getRecommendedStations(userId: string, ip: string) { + async getRecommendedStations(userId: string, ip: string, plan?: string) { const cached = await this.redis.get(`recommended_stations:${userId}`); if (cached) { const data = JSON.parse(cached) as Track[]; @@ -916,7 +931,7 @@ export class DiscoveryService { ...new Set([...lastListenedArtistUsernames, ...topFollowedArtistUsernames]), ].map(async (username) => { try { - return (await this.getArtistStation(username, userId, ip)).data; + return (await this.getArtistStation(username, userId, ip, plan)).data; } catch (e) { return null; } @@ -1062,7 +1077,7 @@ export class DiscoveryService { }; } - async getUserLikedby(userId: string, myUserId: string, ip: string | null) { + async getUserLikedby(userId: string, myUserId: string, ip: string | null, plan?: string) { const { country } = ip ? getLocationFromIp(ip) : { country: null }; const user = await this.userService.findById(userId); if (!user) throw new NotFoundException('User not found'); @@ -1077,7 +1092,7 @@ export class DiscoveryService { // format liked tracks as playlist tracks const likedTrackItems = trackLikes .map((like, index) => - this.formatPlaylistTrack({ position: index + 1, track: like.track }, country) + this.formatPlaylistTrack({ position: index + 1, track: like.track }, country, plan) ) .filter(Boolean); @@ -1085,7 +1100,7 @@ export class DiscoveryService { const playlistTrackItems = playlistLikes.flatMap((like) => (like.playlist.playlistTracks ?? []) .slice(0, 5) - .map((pt: any) => this.formatPlaylistTrack(pt, country)) + .map((pt: any) => this.formatPlaylistTrack(pt, country, plan)) .filter(Boolean) ); @@ -1127,7 +1142,8 @@ export class DiscoveryService { ip: string, type: string, page: number, - limit: number + limit: number, + plan?: string ) { const tag = await this.genreRepository.findByName(tagName); if (!tag) { @@ -1160,7 +1176,8 @@ export class DiscoveryService { isLiked: likedIds.has(track.trackId), isReposted: repostedIds.has(track.trackId), }, - country + country, + plan ) ); total = trackTotal; @@ -1186,7 +1203,8 @@ export class DiscoveryService { isLiked: likedIds.has(playlist.playlistId), isReposted: repostedIds.has(playlist.playlistId), }, - country + country, + plan ) ); total = playlistTotal; @@ -1198,7 +1216,7 @@ export class DiscoveryService { }; } - async getMoreAlbumsOfWhatYouLike(userId: string, ip: string) { + async getMoreAlbumsOfWhatYouLike(userId: string, ip: string, plan?: string) { // Get genres from albums the user has interacted with const interactedAlbumTags = await this.playlistRepository.getUserInteractedAlbumTags(userId); @@ -1226,12 +1244,12 @@ export class DiscoveryService { return { status: 'success', data: albums - .map((album) => this.formatPlaylist(album as any, country)) + .map((album) => this.formatPlaylist(album as any, country, plan)) .filter((a): a is NonNullable => a !== null), }; } - async getDiscoverFeed(userId: string, ip: string) { + async getDiscoverFeed(userId: string, ip: string, plan?: string) { const { country } = getLocationFromIp(ip); // check cache const cached = await this.redis.get(`discover_feed_pool:${userId}`); @@ -1340,7 +1358,7 @@ export class DiscoveryService { const formattedTracks = selected .map(({ track, reason }) => { - const formatted = this.formatTrack(track, country); + const formatted = this.formatTrack(track, country, plan); if (!formatted) return null; return { ...formatted, 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/download/download.controller.spec.ts b/src/download/download.controller.spec.ts index f3e66608..0ab5e1b3 100644 --- a/src/download/download.controller.spec.ts +++ b/src/download/download.controller.spec.ts @@ -4,27 +4,130 @@ import { DownloadService } from './download.service'; describe('DownloadController', () => { let controller: DownloadController; + let service: jest.Mocked; + + const userId = 'user-uuid-1'; + const trackId = 'track-uuid-1'; + const playlistId = 'playlist-uuid-1'; + const ip = '1.2.3.4'; + + const mockDownloadService = { + downloadTrack: jest.fn(), + deleteDownloadedTrack: jest.fn(), + downloadPlaylist: jest.fn(), + deleteDownloadedPlaylist: jest.fn(), + getDownloadedList: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [DownloadController], - providers: [ - { - provide: DownloadService, - useValue: { - // Mock only the methods your controller calls - isTrackDownloadable: jest.fn(), - saveDownloadedTrack: jest.fn(), - downloadTrackFile: jest.fn(), - }, - }, - ], + providers: [{ provide: DownloadService, useValue: mockDownloadService }], }).compile(); controller = module.get(DownloadController); + service = module.get(DownloadService); }); + afterEach(() => jest.clearAllMocks()); + it('should be defined', () => { expect(controller).toBeDefined(); }); + + // ─── downloadTrack ────────────────────────────────────────────────────────── + + describe('downloadTrack', () => { + it('should call service.downloadTrack with correct args and return the result', async () => { + const expected = { + status: 'success', + message: 'Track downloaded successfully', + downloadId: 'dl-1', + }; + service.downloadTrack.mockResolvedValue(expected); + + const result = await controller.downloadTrack(trackId, userId, ip); + + expect(service.downloadTrack).toHaveBeenCalledWith(trackId, userId, ip); + expect(result).toEqual(expected); + }); + }); + + // ─── deleteDownloadedTrack ────────────────────────────────────────────────── + + describe('deleteDownloadedTrack', () => { + it('should call service.deleteDownloadedTrack with correct args and return the result', async () => { + const expected = { status: 'success', message: 'Downloaded track deleted successfully' }; + service.deleteDownloadedTrack.mockResolvedValue(expected); + + const result = await controller.deleteDownloadedTrack(trackId, userId); + + expect(service.deleteDownloadedTrack).toHaveBeenCalledWith(trackId, userId); + expect(result).toEqual(expected); + }); + }); + + // ─── downloadPlaylist ─────────────────────────────────────────────────────── + + describe('downloadPlaylist', () => { + it('should call service.downloadPlaylist with correct args and return the result', async () => { + const expected = { + status: 'success', + message: 'Playlist downloaded successfully', + downloadedTracks: 5, + downloadedTrackIds: [], + downloadIds: [], + skippedTracks: 0, + skippedTrackIds: [], + }; + service.downloadPlaylist.mockResolvedValue(expected); + + const result = await controller.downloadPlaylist(playlistId, userId, ip); + + expect(service.downloadPlaylist).toHaveBeenCalledWith(playlistId, userId, ip); + expect(result).toEqual(expected); + }); + }); + + // ─── deleteDownloadedPlaylist ─────────────────────────────────────────────── + + describe('deleteDownloadedPlaylist', () => { + it('should call service.deleteDownloadedPlaylist with correct args and return the result', async () => { + const expected = { + status: 'success', + message: 'Downloaded playlist and associated tracks deleted successfully', + }; + service.deleteDownloadedPlaylist.mockResolvedValue(expected); + + const result = await controller.deleteDownloadedPlaylist(playlistId, userId); + + expect(service.deleteDownloadedPlaylist).toHaveBeenCalledWith(playlistId, userId); + expect(result).toEqual(expected); + }); + }); + + // ─── getDownloadedList ────────────────────────────────────────────────────── + + describe('getDownloadedList', () => { + it('should call service.getDownloadedList with default pagination and return the result', async () => { + const expected = { + status: 'success', + data: { tracks: { items: [], total: 0 }, playlists: { items: [], total: 0 } }, + }; + service.getDownloadedList.mockResolvedValue(expected); + + const result = await controller.getDownloadedList(userId); + + expect(service.getDownloadedList).toHaveBeenCalledWith(userId, 1, 20); + expect(result).toEqual(expected); + }); + + it('should forward custom page and limit to the service', async () => { + service.getDownloadedList.mockResolvedValue({ status: 'success', data: {} } as any); + + await controller.getDownloadedList(userId, 2, 10); + + expect(service.getDownloadedList).toHaveBeenCalledWith(userId, 2, 10); + }); + }); }); diff --git a/src/download/download.controller.ts b/src/download/download.controller.ts index f23acf87..313424cf 100644 --- a/src/download/download.controller.ts +++ b/src/download/download.controller.ts @@ -1,20 +1,33 @@ -import { Controller, Delete, Param, Post } from '@nestjs/common'; +import { Controller, Delete, Get, Ip, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { DownloadService } from './download.service'; import { Plans } from '../authentication/decorators/plans.decorator'; import { CurrentUser } from '../authentication/decorators/current-user.decorator'; +import { + ApiDownloadTrack, + ApiDeleteDownloadedTrack, + ApiDownloadPlaylist, + ApiDeleteDownloadedPlaylist, + ApiGetDownloadedList, +} from './download.swagger'; @ApiTags('Download') @Controller('download') export class DownloadController { constructor(private readonly downloadService: DownloadService) {} + @ApiDownloadTrack() @Plans('pro', 'go+') @Post('track/:track_id') - async downloadTrack(@Param('track_id') trackId: string, @CurrentUser('sub') userId: string) { - return this.downloadService.downloadTrack(trackId, userId); + async downloadTrack( + @Param('track_id') trackId: string, + @CurrentUser('sub') userId: string, + @Ip() ip: string + ) { + return this.downloadService.downloadTrack(trackId, userId, ip); } + @ApiDeleteDownloadedTrack() @Plans('pro', 'go+') @Delete('track/:track_id') async deleteDownloadedTrack( @@ -23,4 +36,36 @@ export class DownloadController { ) { return this.downloadService.deleteDownloadedTrack(trackId, userId); } + + @ApiDownloadPlaylist() + @Plans('pro', 'go+') + @Post('playlist/:playlist_id') + async downloadPlaylist( + @Param('playlist_id') playlistId: string, + @CurrentUser('sub') userId: string, + @Ip() ip: string + ) { + return this.downloadService.downloadPlaylist(playlistId, userId, ip); + } + + @ApiDeleteDownloadedPlaylist() + @Plans('pro', 'go+') + @Delete('playlist/:playlist_id') + async deleteDownloadedPlaylist( + @Param('playlist_id') playlistId: string, + @CurrentUser('sub') userId: string + ) { + return this.downloadService.deleteDownloadedPlaylist(playlistId, userId); + } + + @ApiGetDownloadedList() + @Plans('pro', 'go+') + @Get('/list') + async getDownloadedList( + @CurrentUser('sub') userId: string, + @Query('page') page: number = 1, + @Query('limit') limit: number = 20 + ) { + return this.downloadService.getDownloadedList(userId, page, limit); + } } diff --git a/src/download/download.module.ts b/src/download/download.module.ts index 71c67feb..b317fd6a 100644 --- a/src/download/download.module.ts +++ b/src/download/download.module.ts @@ -6,9 +6,14 @@ import { DownloadedTrack } from './entities/downloaded-tracks.entity'; import { DownloadedPlaylist } from './entities/downloaded-playlists.entity'; import { TrackModule } from '../track/track.module'; import { DownloadRepository } from './download.repository'; +import { PlaylistModule } from '../playlist/playlist.module'; @Module({ - imports: [TypeOrmModule.forFeature([DownloadedTrack, DownloadedPlaylist]), TrackModule], + imports: [ + TypeOrmModule.forFeature([DownloadedTrack, DownloadedPlaylist]), + TrackModule, + PlaylistModule, + ], controllers: [DownloadController], providers: [DownloadService, DownloadRepository], exports: [DownloadService], diff --git a/src/download/download.repository.ts b/src/download/download.repository.ts index 8b631b18..c8a1a94c 100644 --- a/src/download/download.repository.ts +++ b/src/download/download.repository.ts @@ -55,15 +55,31 @@ export class DownloadRepository { } async deleteDownloadedPlaylist(playlistId: string, userId: string) { - await this.downloadedPlaylistRepository.delete({ playlistId, userId }); + const result = await this.downloadedPlaylistRepository.delete({ playlistId, userId }); + if (result.affected && result.affected > 0) { + // Also delete all tracks associated with this playlist download + await this.downloadedTrackRepository.delete({ sourcePlaylistId: playlistId, userId }); + return true; + } + return false; } - async getDownloadedTracksByUser(userId: string): Promise { - return this.downloadedTrackRepository.find({ where: { userId } }); - } - - async getDownloadedPlaylistsByUser(userId: string): Promise { - return this.downloadedPlaylistRepository.find({ where: { userId } }); + async getDownloadedTracksByPlaylistIds( + userId: string, + playlistIds: string[] + ): Promise { + if (!playlistIds.length) return []; + return this.downloadedTrackRepository + .createQueryBuilder('dt') + .leftJoinAndSelect('dt.track', 'track') + .leftJoinAndSelect('track.user', 'artist') + .leftJoinAndSelect('track.genre', 'genre') + .where('dt.userId = :userId', { userId }) + .andWhere('dt.source = :source', { source: DownloadSource.PLAYLIST }) + .andWhere('dt.sourcePlaylistId IN (:...playlistIds)', { playlistIds }) + .andWhere('dt.status = :status', { status: DownloadStatus.COMPLETED }) + .orderBy('dt.downloadedAt', 'ASC') + .getMany(); } async getDownloadedTrack( diff --git a/src/download/download.service.spec.ts b/src/download/download.service.spec.ts index a0ba6e17..349a3e04 100644 --- a/src/download/download.service.spec.ts +++ b/src/download/download.service.spec.ts @@ -1,42 +1,461 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { DownloadService } from './download.service'; import { TrackRepository } from '../track/track.repository'; import { DownloadRepository } from './download.repository'; +import { PlaylistRepository } from '../playlist/playlist.repository'; +import { DownloadSource } from './entities/downloaded-tracks.entity'; +import { DownloadStatus } from './enums/download-status.enum'; +import { TrackVisibility } from '../track/enums/track-visibility.enum'; +import * as geolocation from '../common/utilities/geolocation.util'; + +jest.mock('../common/utilities/geolocation.util'); + +const mockTrack = (overrides = {}) => ({ + trackId: 'track-uuid-1', + title: 'Test Track', + offlineListening: true, + visibility: TrackVisibility.PUBLIC, + blockedRegions: [], + coverImage: null, + audioUrl: 'https://cdn.example.com/audio.mp3', + waveformUrl: null, + durationSeconds: 180, + playCount: 0, + likesCount: 0, + repostsCount: 0, + commentsCount: 0, + mainArtists: [], + createdAt: new Date(), + user: null, + genre: null, + ...overrides, +}); + +const mockPlaylistTrack = (trackOverrides = {}) => ({ + trackId: 'track-uuid-1', + track: mockTrack(trackOverrides), +}); + +const mockPlaylist = (overrides = {}) => ({ + playlistId: 'playlist-uuid-1', + title: 'Test Playlist', + isPublic: true, + playlistTracks: [mockPlaylistTrack()], + tracksCount: 1, + totalDurationSeconds: 180, + likesCount: 0, + repostsCount: 0, + createdAt: new Date(), + user: null, + ...overrides, +}); + +const mockDownloadedTrack = (overrides = {}) => ({ + downloadId: 'download-uuid-1', + userId: 'user-uuid-1', + trackId: 'track-uuid-1', + sourcePlaylistId: null, + source: DownloadSource.TRACK, + status: DownloadStatus.PENDING, + downloadedAt: new Date(), + track: mockTrack(), + ...overrides, +}); describe('DownloadService', () => { let service: DownloadService; - let trackRepository: TrackRepository; - let downloadRepository: DownloadRepository; + let trackRepository: jest.Mocked; + let downloadRepository: jest.Mocked; + let playlistRepository: jest.Mocked; + + const userId = 'user-uuid-1'; + const trackId = 'track-uuid-1'; + const playlistId = 'playlist-uuid-1'; + const ip = '1.2.3.4'; beforeEach(async () => { + jest.useFakeTimers(); + (geolocation.getLocationFromIp as jest.Mock).mockReturnValue({ country: 'US' }); + const module: TestingModule = await Test.createTestingModule({ providers: [ DownloadService, { provide: TrackRepository, useValue: { - findOne: jest.fn(), - // add other methods you call from DownloadService + findById: jest.fn(), + getDownloadedTracksByUser: jest.fn(), + getUserLikedTrackIds: jest.fn(), + getUserRepostedTrackIds: jest.fn(), }, }, { provide: DownloadRepository, useValue: { + getDownloadedTrack: jest.fn(), saveDownloadedTrack: jest.fn(), - // add other methods you call + updateDownloadedTrackStatus: jest.fn(), + deleteDownloadedTrack: jest.fn(), + getDownloadedPlaylist: jest.fn(), + saveDownloadedPlaylist: jest.fn(), + updateDownloadedPlaylistStatus: jest.fn(), + deleteDownloadedPlaylist: jest.fn(), + getDownloadedTracksByPlaylistIds: jest.fn(), + }, + }, + { + provide: PlaylistRepository, + useValue: { + getPlaylistDetails: jest.fn(), + getDownloadedPlaylistsByUser: jest.fn(), + getUserLikedPlaylistIds: jest.fn(), + getUserRepostedPlaylistIds: jest.fn(), }, }, ], }).compile(); service = module.get(DownloadService); - trackRepository = module.get(TrackRepository); - downloadRepository = module.get(DownloadRepository); + trackRepository = module.get(TrackRepository); + downloadRepository = module.get(DownloadRepository); + playlistRepository = module.get(PlaylistRepository); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + // ─── downloadTrack ────────────────────────────────────────────────────────── + + describe('downloadTrack', () => { + it('should throw BadRequestException if track is not found', async () => { + trackRepository.findById.mockResolvedValue(null); + + await expect(service.downloadTrack(trackId, userId, ip)).rejects.toThrow( + new BadRequestException('Track not found') + ); + }); + + it('should throw ForbiddenException if track has offline listening disabled', async () => { + trackRepository.findById.mockResolvedValue(mockTrack({ offlineListening: false }) as any); + + await expect(service.downloadTrack(trackId, userId, ip)).rejects.toThrow( + new ForbiddenException('Track is not available for download') + ); + }); + + it('should throw ForbiddenException if track is private', async () => { + trackRepository.findById.mockResolvedValue( + mockTrack({ visibility: TrackVisibility.PRIVATE }) as any + ); + + await expect(service.downloadTrack(trackId, userId, ip)).rejects.toThrow( + new ForbiddenException('Track is private and cannot be downloaded') + ); + }); + + it('should throw ForbiddenException if track is blocked in user region', async () => { + (geolocation.getLocationFromIp as jest.Mock).mockReturnValue({ country: 'EG' }); + trackRepository.findById.mockResolvedValue(mockTrack({ blockedRegions: ['EG'] }) as any); + + await expect(service.downloadTrack(trackId, userId, ip)).rejects.toThrow( + new ForbiddenException('Track is not available in your region') + ); + }); + + it('should throw BadRequestException if track is already downloaded', async () => { + trackRepository.findById.mockResolvedValue(mockTrack() as any); + downloadRepository.getDownloadedTrack.mockResolvedValue(mockDownloadedTrack() as any); + + await expect(service.downloadTrack(trackId, userId, ip)).rejects.toThrow( + new BadRequestException('Track has already been downloaded by this user') + ); + }); + + it('should save and complete the download successfully', async () => { + trackRepository.findById.mockResolvedValue(mockTrack() as any); + downloadRepository.getDownloadedTrack.mockResolvedValue(null); + downloadRepository.saveDownloadedTrack.mockResolvedValue(mockDownloadedTrack() as any); + downloadRepository.updateDownloadedTrackStatus.mockResolvedValue(undefined); + + const promise = service.downloadTrack(trackId, userId, ip); + await jest.runAllTimersAsync(); + const result = await promise; + + expect(downloadRepository.saveDownloadedTrack).toHaveBeenCalledWith(userId, trackId); + expect(downloadRepository.updateDownloadedTrackStatus).toHaveBeenCalledWith( + 'download-uuid-1', + DownloadStatus.COMPLETED + ); + expect(result).toEqual({ + status: 'success', + message: 'Track downloaded successfully', + downloadId: 'download-uuid-1', + }); + }); + + it('should not block download when region check passes (no blocked region)', async () => { + (geolocation.getLocationFromIp as jest.Mock).mockReturnValue({ country: null }); + trackRepository.findById.mockResolvedValue(mockTrack({ blockedRegions: ['EG'] }) as any); + downloadRepository.getDownloadedTrack.mockResolvedValue(null); + downloadRepository.saveDownloadedTrack.mockResolvedValue(mockDownloadedTrack() as any); + downloadRepository.updateDownloadedTrackStatus.mockResolvedValue(undefined); + + const promise = service.downloadTrack(trackId, userId, ip); + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result.status).toBe('success'); + }); + }); + + // ─── deleteDownloadedTrack ────────────────────────────────────────────────── + + describe('deleteDownloadedTrack', () => { + it('should throw BadRequestException if track was not downloaded', async () => { + downloadRepository.getDownloadedTrack.mockResolvedValue(null); + + await expect(service.deleteDownloadedTrack(trackId, userId)).rejects.toThrow( + new BadRequestException('Track has not been downloaded by this user') + ); + }); + + it('should delete the downloaded track successfully', async () => { + downloadRepository.getDownloadedTrack.mockResolvedValue(mockDownloadedTrack() as any); + downloadRepository.deleteDownloadedTrack.mockResolvedValue(undefined); + + const result = await service.deleteDownloadedTrack(trackId, userId); + + expect(downloadRepository.deleteDownloadedTrack).toHaveBeenCalledWith(trackId, userId); + expect(result).toEqual({ + status: 'success', + message: 'Downloaded track deleted successfully', + }); + }); }); - it('should be defined', () => { - expect(service).toBeDefined(); - expect(trackRepository).toBeDefined(); - expect(downloadRepository).toBeDefined(); + // ─── downloadPlaylist ─────────────────────────────────────────────────────── + + describe('downloadPlaylist', () => { + it('should throw BadRequestException if playlist is not found', async () => { + playlistRepository.getPlaylistDetails.mockResolvedValue(null); + + await expect(service.downloadPlaylist(playlistId, userId, ip)).rejects.toThrow( + new BadRequestException('Playlist not found') + ); + }); + + it('should throw BadRequestException if playlist has no tracks', async () => { + playlistRepository.getPlaylistDetails.mockResolvedValue( + mockPlaylist({ playlistTracks: [] }) as any + ); + + await expect(service.downloadPlaylist(playlistId, userId, ip)).rejects.toThrow( + new BadRequestException('Playlist has no tracks to download') + ); + }); + + it('should throw ForbiddenException if playlist is private', async () => { + playlistRepository.getPlaylistDetails.mockResolvedValue( + mockPlaylist({ isPublic: false }) as any + ); + + await expect(service.downloadPlaylist(playlistId, userId, ip)).rejects.toThrow( + new ForbiddenException('Playlist is private and cannot be downloaded') + ); + }); + + it('should throw BadRequestException if playlist is already downloaded', async () => { + playlistRepository.getPlaylistDetails.mockResolvedValue(mockPlaylist() as any); + downloadRepository.getDownloadedPlaylist.mockResolvedValue({ playlistId, userId } as any); + + await expect(service.downloadPlaylist(playlistId, userId, ip)).rejects.toThrow( + new BadRequestException('Playlist has already been downloaded by this user') + ); + }); + + it('should skip tracks that have offline listening disabled', async () => { + const playlist = mockPlaylist({ + playlistTracks: [ + mockPlaylistTrack({ offlineListening: false }), + mockPlaylistTrack({ trackId: 'track-uuid-2', offlineListening: true }), + ], + }); + playlistRepository.getPlaylistDetails.mockResolvedValue(playlist as any); + downloadRepository.getDownloadedPlaylist.mockResolvedValue(null); + downloadRepository.saveDownloadedPlaylist.mockResolvedValue(undefined as any); + downloadRepository.saveDownloadedTrack.mockResolvedValue( + mockDownloadedTrack({ trackId: 'track-uuid-2' }) as any + ); + downloadRepository.updateDownloadedTrackStatus.mockResolvedValue(undefined); + downloadRepository.updateDownloadedPlaylistStatus.mockResolvedValue(undefined); + + const promise = service.downloadPlaylist(playlistId, userId, ip); + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result.skippedTracks).toBe(1); + expect(result.downloadedTracks).toBe(1); + }); + + it('should skip tracks that are private', async () => { + const playlist = mockPlaylist({ + playlistTracks: [mockPlaylistTrack({ visibility: TrackVisibility.PRIVATE })], + }); + playlistRepository.getPlaylistDetails.mockResolvedValue(playlist as any); + downloadRepository.getDownloadedPlaylist.mockResolvedValue(null); + downloadRepository.saveDownloadedPlaylist.mockResolvedValue(undefined as any); + downloadRepository.updateDownloadedPlaylistStatus.mockResolvedValue(undefined); + + const promise = service.downloadPlaylist(playlistId, userId, ip); + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result.skippedTracks).toBe(1); + expect(result.downloadedTracks).toBe(0); + }); + + it('should skip region-blocked tracks', async () => { + (geolocation.getLocationFromIp as jest.Mock).mockReturnValue({ country: 'EG' }); + const playlist = mockPlaylist({ + playlistTracks: [mockPlaylistTrack({ blockedRegions: ['EG'] })], + }); + playlistRepository.getPlaylistDetails.mockResolvedValue(playlist as any); + downloadRepository.getDownloadedPlaylist.mockResolvedValue(null); + downloadRepository.saveDownloadedPlaylist.mockResolvedValue(undefined as any); + downloadRepository.updateDownloadedPlaylistStatus.mockResolvedValue(undefined); + + const promise = service.downloadPlaylist(playlistId, userId, ip); + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result.skippedTracks).toBe(1); + expect(result.downloadedTracks).toBe(0); + }); + + it('should download all eligible tracks and return correct summary', async () => { + const downloaded = mockDownloadedTrack({ + source: DownloadSource.PLAYLIST, + sourcePlaylistId: playlistId, + }); + playlistRepository.getPlaylistDetails.mockResolvedValue(mockPlaylist() as any); + downloadRepository.getDownloadedPlaylist.mockResolvedValue(null); + downloadRepository.saveDownloadedPlaylist.mockResolvedValue(undefined as any); + downloadRepository.saveDownloadedTrack.mockResolvedValue(downloaded as any); + downloadRepository.updateDownloadedTrackStatus.mockResolvedValue(undefined); + downloadRepository.updateDownloadedPlaylistStatus.mockResolvedValue(undefined); + + const promise = service.downloadPlaylist(playlistId, userId, ip); + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result.status).toBe('success'); + expect(result.downloadedTracks).toBe(1); + expect(result.skippedTracks).toBe(0); + expect(downloadRepository.updateDownloadedPlaylistStatus).toHaveBeenCalledWith( + playlistId, + userId, + DownloadStatus.COMPLETED + ); + }); + }); + + // ─── deleteDownloadedPlaylist ─────────────────────────────────────────────── + + describe('deleteDownloadedPlaylist', () => { + it('should throw BadRequestException if playlist was not downloaded', async () => { + downloadRepository.deleteDownloadedPlaylist.mockResolvedValue(false); + + await expect(service.deleteDownloadedPlaylist(playlistId, userId)).rejects.toThrow( + new BadRequestException('Playlist has not been downloaded by this user') + ); + }); + + it('should delete the downloaded playlist and its tracks successfully', async () => { + downloadRepository.deleteDownloadedPlaylist.mockResolvedValue(true); + + const result = await service.deleteDownloadedPlaylist(playlistId, userId); + + expect(downloadRepository.deleteDownloadedPlaylist).toHaveBeenCalledWith(playlistId, userId); + expect(result).toEqual({ + status: 'success', + message: 'Downloaded playlist and associated tracks deleted successfully', + }); + }); + }); + + // ─── getDownloadedList ────────────────────────────────────────────────────── + + describe('getDownloadedList', () => { + const track = mockTrack() as any; + const playlist = mockPlaylist() as any; + const playlistDownloadedTrack = mockDownloadedTrack({ + source: DownloadSource.PLAYLIST, + sourcePlaylistId: playlistId, + track, + }) as any; + + beforeEach(() => { + trackRepository.getDownloadedTracksByUser.mockResolvedValue([[track], 1]); + playlistRepository.getDownloadedPlaylistsByUser.mockResolvedValue([[playlist], 1]); + downloadRepository.getDownloadedTracksByPlaylistIds.mockResolvedValue([ + playlistDownloadedTrack, + ]); + trackRepository.getUserLikedTrackIds.mockResolvedValue(new Set(['track-uuid-1'])); + trackRepository.getUserRepostedTrackIds.mockResolvedValue(new Set()); + playlistRepository.getUserLikedPlaylistIds.mockResolvedValue(new Set()); + playlistRepository.getUserRepostedPlaylistIds.mockResolvedValue(new Set()); + }); + + it('should return paginated tracks and playlists', async () => { + const result = await service.getDownloadedList(userId, 1, 20); + + expect(result.status).toBe('success'); + expect(result.data.tracks.total).toBe(1); + expect(result.data.playlists.total).toBe(1); + expect(result.data.tracks.items).toHaveLength(1); + expect(result.data.playlists.items).toHaveLength(1); + }); + + it('should mark isLiked correctly for tracks', async () => { + const result = await service.getDownloadedList(userId, 1, 20); + + expect(result.data.tracks.items[0].isLiked).toBe(true); + expect(result.data.tracks.items[0].isReposted).toBe(false); + }); + + it('should include downloaded tracks inside each playlist', async () => { + const result = await service.getDownloadedList(userId, 1, 20); + + expect(result.data.playlists.items[0].playlistTracks).toHaveLength(1); + expect(result.data.playlists.items[0].playlistTracks[0].trackId).toBe('track-uuid-1'); + }); + + it('should batch liked/reposted lookups using all unique track IDs', async () => { + await service.getDownloadedList(userId, 1, 20); + + const calledWith = trackRepository.getUserLikedTrackIds.mock.calls[0][1]; + // both individual track and playlist track are the same UUID → deduped to 1 + expect(calledWith).toHaveLength(1); + }); + + it('should return empty arrays when user has no downloads', async () => { + trackRepository.getDownloadedTracksByUser.mockResolvedValue([[], 0]); + playlistRepository.getDownloadedPlaylistsByUser.mockResolvedValue([[], 0]); + downloadRepository.getDownloadedTracksByPlaylistIds.mockResolvedValue([]); + trackRepository.getUserLikedTrackIds.mockResolvedValue(new Set()); + trackRepository.getUserRepostedTrackIds.mockResolvedValue(new Set()); + playlistRepository.getUserLikedPlaylistIds.mockResolvedValue(new Set()); + playlistRepository.getUserRepostedPlaylistIds.mockResolvedValue(new Set()); + + const result = await service.getDownloadedList(userId, 1, 20); + + expect(result.data.tracks.items).toHaveLength(0); + expect(result.data.playlists.items).toHaveLength(0); + expect(result.data.tracks.total).toBe(0); + expect(result.data.playlists.total).toBe(0); + }); }); }); diff --git a/src/download/download.service.ts b/src/download/download.service.ts index f72fcfc7..a8296fc1 100644 --- a/src/download/download.service.ts +++ b/src/download/download.service.ts @@ -1,29 +1,50 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { TrackRepository } from '../track/track.repository'; import { DownloadRepository } from './download.repository'; -import { DownloadSource } from './entities/downloaded-tracks.entity'; +import { DownloadedTrack, DownloadSource } from './entities/downloaded-tracks.entity'; import { DownloadStatus } from './enums/download-status.enum'; +import { PlaylistRepository } from '../playlist/playlist.repository'; +import { TrackVisibility } from '../track/enums/track-visibility.enum'; +import { getLocationFromIp } from '../common/utilities/geolocation.util'; +import { Track } from '../track/entities/track.entity'; +import { Playlist } from '../playlist/entities/playlist.entity'; +import { User } from '../user/entities/user.entity'; +import { Genre } from '../genre/entities/genre.entity'; + +// mock sleep function to simulate download time +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); @Injectable() export class DownloadService { constructor( private readonly trackRepository: TrackRepository, - private readonly downloadRepository: DownloadRepository + private readonly downloadRepository: DownloadRepository, + private readonly playlistRepository: PlaylistRepository ) {} - async downloadTrack(trackId: string, userId: string) { - // Check if the track is downloadable - const isDownloadable = await this.trackRepository.isTrackDownloadable(trackId); - if (!isDownloadable) { + async downloadTrack(trackId: string, userId: string, ip: string) { + const { country } = getLocationFromIp(ip); + const track = await this.trackRepository.findById(trackId); + + if (!track) { + throw new BadRequestException('Track not found'); + } + + if (!track.offlineListening) { throw new ForbiddenException('Track is not available for download'); } - // check if the track is private - const isPrivate = await this.trackRepository.isPrivate(trackId); - if (isPrivate) { + if (track.visibility === TrackVisibility.PRIVATE) { throw new ForbiddenException('Track is private and cannot be downloaded'); } + if (country && track.blockedRegions.includes(country)) { + throw new ForbiddenException('Track is not available in your region'); + } + const isDownloaded = await this.downloadRepository.getDownloadedTrack( userId, trackId, @@ -34,10 +55,7 @@ export class DownloadService { } const downloadTrack = await this.downloadRepository.saveDownloadedTrack(userId, trackId); - const sleep = (ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); + await sleep(2000); // mock download time await this.downloadRepository.updateDownloadedTrackStatus( downloadTrack.downloadId, @@ -66,4 +84,229 @@ export class DownloadService { message: 'Downloaded track deleted successfully', }; } + + async downloadPlaylist(playlistId: string, userId: string, ip: string) { + const playlist = await this.playlistRepository.getPlaylistDetails(playlistId); + if (!playlist) { + throw new BadRequestException('Playlist not found'); + } + + const tracks = playlist.playlistTracks; + if (tracks.length === 0) { + throw new BadRequestException('Playlist has no tracks to download'); + } + + if (!playlist.isPublic) { + throw new ForbiddenException('Playlist is private and cannot be downloaded'); + } + + const alreadyDownloaded = await this.downloadRepository.getDownloadedPlaylist( + userId, + playlistId + ); + if (alreadyDownloaded) { + throw new BadRequestException('Playlist has already been downloaded by this user'); + } + + const { country } = getLocationFromIp(ip); + // mark playlist as pending first + await this.downloadRepository.saveDownloadedPlaylist(userId, playlistId); + const downloads: DownloadedTrack[] = []; + const skipped: string[] = []; + // Insert all tracks as 'pending' first + await Promise.all( + tracks.map(async (track) => { + if ( + !track.track.offlineListening || + track.track.visibility === TrackVisibility.PRIVATE || + (country && track.track.blockedRegions.includes(country)) + ) { + skipped.push(track.track.trackId); + return; + } + const download = await this.downloadRepository.saveDownloadedTrack( + userId, + track.trackId, + playlistId, + DownloadSource.PLAYLIST + ); + downloads.push(download); + }) + ); + + // "Download" all simultaneously + await Promise.all( + downloads.map(async (download) => { + await sleep(2000); + await this.downloadRepository.updateDownloadedTrackStatus( + download.downloadId, + DownloadStatus.COMPLETED + ); + }) + ); + + await this.downloadRepository.updateDownloadedPlaylistStatus( + playlistId, + userId, + DownloadStatus.COMPLETED + ); + + return { + status: 'success', + message: 'Playlist downloaded successfully', + downloadedTracks: downloads.length, + downloadedTrackIds: downloads.map((d) => d.trackId), + downloadIds: downloads.map((d) => d.downloadId), + skippedTracks: skipped.length, + skippedTrackIds: skipped, + }; + } + + async deleteDownloadedPlaylist(playlistId: string, userId: string) { + const result = await this.downloadRepository.deleteDownloadedPlaylist(playlistId, userId); + if (!result) { + throw new BadRequestException('Playlist has not been downloaded by this user'); + } + return { + status: 'success', + message: 'Downloaded playlist and associated tracks deleted successfully', + }; + } + + async getDownloadedList(userId: string, page: number = 1, limit: number = 20) { + const [[tracks, totalTracks], [playlists, totalPlaylists]] = await Promise.all([ + this.trackRepository.getDownloadedTracksByUser(userId, page, limit), + this.playlistRepository.getDownloadedPlaylistsByUser(userId, page, limit), + ]); + + const playlistIds = playlists.map((p) => p.playlistId); + + // Fetch the downloaded tracks per playlist + const playlistDownloadedTracks = await this.downloadRepository.getDownloadedTracksByPlaylistIds( + userId, + playlistIds + ); + + // Collect all unique track IDs to batch liked/reposted lookups + const individualTrackIds = tracks.map((t) => t.trackId); + const playlistTrackIds = playlistDownloadedTracks.map((dt) => dt.trackId); + const allTrackIds = [...new Set([...individualTrackIds, ...playlistTrackIds])]; + + const [likedTrackIds, repostedTrackIds, likedPlaylistIds, repostedPlaylistIds] = + await Promise.all([ + this.trackRepository.getUserLikedTrackIds(userId, allTrackIds), + this.trackRepository.getUserRepostedTrackIds(userId, allTrackIds), + this.playlistRepository.getUserLikedPlaylistIds(userId, playlistIds), + this.playlistRepository.getUserRepostedPlaylistIds(userId, playlistIds), + ]); + + // Group playlist downloaded tracks by sourcePlaylistId + const tracksByPlaylist = playlistDownloadedTracks.reduce>( + (acc, dt) => { + const key = dt.sourcePlaylistId!; + if (!acc[key]) acc[key] = []; + acc[key].push(dt); + return acc; + }, + {} + ); + + return { + status: 'success', + data: { + tracks: { + items: tracks.map((t) => this.formatTrack(t, likedTrackIds, repostedTrackIds)), + total: totalTracks, + }, + playlists: { + items: playlists.map((p) => + this.formatPlaylist( + p, + tracksByPlaylist[p.playlistId] ?? [], + likedPlaylistIds, + repostedPlaylistIds, + likedTrackIds, + repostedTrackIds + ) + ), + total: totalPlaylists, + }, + }, + }; + } + + private formatTrack( + track: Track, + likedIds: Set, + repostedIds: Set + ): Record { + const user = track.user as User; + const genre = track.genre as Genre; + return { + trackId: track.trackId, + title: track.title, + coverImage: track.coverImage, + audioUrl: track.audioUrlHq, + waveformUrl: track.waveformUrl, + durationSeconds: track.durationSeconds, + playCount: track.playCount, + likesCount: track.likesCount, + repostsCount: track.repostsCount, + commentsCount: track.commentsCount, + mainArtists: track.mainArtists, + createdAt: track.createdAt, + genre: genre ? { genreId: genre.genreId, name: genre.name } : null, + user: user + ? { + userId: user.userId, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + followersCount: user.followersCount, + city: user.city, + country: user.country, + } + : null, + isLiked: likedIds.has(track.trackId), + isReposted: repostedIds.has(track.trackId), + }; + } + + private formatPlaylist( + playlist: Playlist, + downloadedTracks: DownloadedTrack[], + likedPlaylistIds: Set, + repostedPlaylistIds: Set, + likedTrackIds: Set, + repostedTrackIds: Set + ): Record { + const owner = playlist.user as User; + return { + playlistId: playlist.playlistId, + title: playlist.title, + description: playlist.description, + coverImage: playlist.coverImage, + tracksCount: playlist.tracksCount, + durationSeconds: playlist.totalDurationSeconds, + likesCount: playlist.likesCount, + repostsCount: playlist.repostsCount, + createdAt: playlist.createdAt, + isLiked: likedPlaylistIds.has(playlist.playlistId), + isReposted: repostedPlaylistIds.has(playlist.playlistId), + user: owner + ? { + userId: owner.userId, + username: owner.username, + displayName: owner.displayName, + avatarUrl: owner.avatarUrl, + city: owner.city, + country: owner.country, + followersCount: owner.followersCount, + } + : null, + playlistTracks: downloadedTracks.map((dt) => + this.formatTrack(dt.track, likedTrackIds, repostedTrackIds) + ), + }; + } } diff --git a/src/download/download.swagger.ts b/src/download/download.swagger.ts new file mode 100644 index 00000000..af586d02 --- /dev/null +++ b/src/download/download.swagger.ts @@ -0,0 +1,266 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; + +const TRACK_EXAMPLE = { + trackId: '550e8400-e29b-41d4-a716-446655440010', + title: 'Midnight Vibes', + coverImage: 'https://cdn.example.com/covers/midnight.jpg', + audioUrl: 'https://cdn.example.com/audio/midnight.mp3', + waveformUrl: 'https://cdn.example.com/waveforms/midnight.png', + durationSeconds: 212, + playCount: 4200, + likesCount: 320, + repostsCount: 45, + commentsCount: 18, + mainArtists: ['DJ Nova'], + createdAt: '2024-06-01T12:00:00Z', + genre: { genreId: '550e8400-e29b-41d4-a716-446655440099', name: 'Electronic' }, + user: { + userId: '550e8400-e29b-41d4-a716-446655440001', + username: 'dj_nova', + displayName: 'DJ Nova', + avatarUrl: 'https://cdn.example.com/avatars/dj_nova.jpg', + followersCount: 12000, + city: 'Cairo', + country: 'EG', + }, + isLiked: false, + isReposted: false, +}; + +// ─── Download Track ─────────────────────────────────────────────────────────── + +export function ApiDownloadTrack() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Download a track for offline listening', + description: + "Marks a track as downloaded for the authenticated user. The track must have offline listening enabled, be public, and not be blocked in the user's region. Requires a Pro or Go+ subscription. Simulates a download delay before returning.", + }), + ApiParam({ name: 'track_id', description: 'UUID of the track to download', type: 'string' }), + ApiResponse({ + status: 201, + description: 'Track downloaded successfully', + schema: { + example: { + status: 'success', + message: 'Track downloaded successfully', + downloadId: '550e8400-e29b-41d4-a716-446655440020', + }, + }, + }), + ApiResponse({ + status: 400, + description: 'Track not found or already downloaded', + schema: { + example: { statusCode: 400, message: 'Track not found' }, + }, + }), + ApiResponse({ + status: 403, + description: + 'Track not available for download (offline listening disabled, private, or region-blocked)', + schema: { + example: { statusCode: 403, message: 'Track is not available for download' }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }), + ApiResponse({ + status: 403, + description: 'Subscription plan does not include offline listening', + }) + ); +} + +// ─── Delete Downloaded Track ────────────────────────────────────────────────── + +export function ApiDeleteDownloadedTrack() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Remove a downloaded track', + description: + 'Removes the offline copy of a track for the authenticated user. Only removes tracks that were individually downloaded (source = track). Does not affect tracks downloaded as part of a playlist.', + }), + ApiParam({ + name: 'track_id', + description: 'UUID of the downloaded track to remove', + type: 'string', + }), + ApiResponse({ + status: 200, + description: 'Downloaded track removed successfully', + schema: { + example: { status: 'success', message: 'Downloaded track deleted successfully' }, + }, + }), + ApiResponse({ + status: 400, + description: 'Track was not individually downloaded by this user', + schema: { + example: { statusCode: 400, message: 'Track has not been downloaded by this user' }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }) + ); +} + +// ─── Download Playlist ──────────────────────────────────────────────────────── + +export function ApiDownloadPlaylist() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Download a playlist for offline listening', + description: + 'Downloads all eligible tracks in a playlist for offline listening. The playlist must be public. Tracks that are private, have offline listening disabled, or are region-blocked are skipped. Requires a Pro or Go+ subscription.', + }), + ApiParam({ + name: 'playlist_id', + description: 'UUID of the playlist to download', + type: 'string', + }), + ApiResponse({ + status: 201, + description: 'Playlist downloaded successfully (some tracks may have been skipped)', + schema: { + example: { + status: 'success', + message: 'Playlist downloaded successfully', + downloadedTracks: 18, + downloadedTrackIds: [ + '550e8400-e29b-41d4-a716-446655440010', + '550e8400-e29b-41d4-a716-446655440011', + ], + downloadIds: [ + '550e8400-e29b-41d4-a716-446655440020', + '550e8400-e29b-41d4-a716-446655440021', + ], + skippedTracks: 2, + skippedTrackIds: ['550e8400-e29b-41d4-a716-446655440012'], + }, + }, + }), + ApiResponse({ + status: 400, + description: 'Playlist not found, empty, or already downloaded', + schema: { + example: { statusCode: 400, message: 'Playlist not found' }, + }, + }), + ApiResponse({ + status: 403, + description: 'Playlist is private', + schema: { + example: { statusCode: 403, message: 'Playlist is private and cannot be downloaded' }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }) + ); +} + +// ─── Delete Downloaded Playlist ─────────────────────────────────────────────── + +export function ApiDeleteDownloadedPlaylist() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Remove a downloaded playlist', + description: + 'Removes the offline copy of a playlist and all its associated downloaded tracks for the authenticated user.', + }), + ApiParam({ + name: 'playlist_id', + description: 'UUID of the downloaded playlist to remove', + type: 'string', + }), + ApiResponse({ + status: 200, + description: 'Downloaded playlist and its tracks removed successfully', + schema: { + example: { + status: 'success', + message: 'Downloaded playlist and associated tracks deleted successfully', + }, + }, + }), + ApiResponse({ + status: 400, + description: 'Playlist was not downloaded by this user', + schema: { + example: { statusCode: 400, message: 'Playlist has not been downloaded by this user' }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }) + ); +} + +// ─── Get Downloaded List ────────────────────────────────────────────────────── + +export function ApiGetDownloadedList() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Get all downloaded tracks and playlists', + description: + "Returns the authenticated user's offline library: individually downloaded tracks and downloaded playlists (with their downloaded tracks). Tracks include isLiked and isReposted flags. Supports pagination.", + }), + ApiQuery({ + name: 'page', + required: false, + description: 'Page number (default: 1)', + type: 'number', + }), + ApiQuery({ + name: 'limit', + required: false, + description: 'Items per page (default: 20)', + type: 'number', + }), + ApiResponse({ + status: 200, + description: 'Offline library returned successfully', + schema: { + example: { + status: 'success', + data: { + tracks: { + items: [TRACK_EXAMPLE], + total: 5, + }, + playlists: { + items: [ + { + playlistId: '550e8400-e29b-41d4-a716-446655440030', + title: 'Late Night Sessions', + description: 'Chill beats for the night', + coverImage: 'https://cdn.example.com/playlists/lns.jpg', + tracksCount: 12, + durationSeconds: 2880, + likesCount: 150, + repostsCount: 20, + createdAt: '2024-05-15T10:00:00Z', + isLiked: true, + isReposted: false, + user: { + userId: '550e8400-e29b-41d4-a716-446655440001', + username: 'dj_nova', + displayName: 'DJ Nova', + avatarUrl: 'https://cdn.example.com/avatars/dj_nova.jpg', + city: 'Cairo', + country: 'EG', + followersCount: 12000, + }, + playlistTracks: [TRACK_EXAMPLE], + }, + ], + total: 1, + }, + }, + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }) + ); +} 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/mail/mail.service.ts b/src/mail/mail.service.ts index 288ffe29..6ebbd69a 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -95,6 +95,25 @@ export class MailService { }); } + async sendFirstMessageEmail( + recipientEmail: string, + recipientUsername: string, + senderUsername: string + ) { + const messagesUrl = `${this.configService.get('FRONTEND_URL')}/messages`; + + await this.mailerService.sendMail({ + to: recipientEmail, + subject: `${senderUsername} sent you a message`, + template: 'first-message', + context: { + recipientUsername, + senderUsername, + messagesUrl, + }, + }); + } + /** * Send confirmation after primary email change */ diff --git a/src/mail/templates/first-message.hbs b/src/mail/templates/first-message.hbs new file mode 100644 index 00000000..5be1a5ba --- /dev/null +++ b/src/mail/templates/first-message.hbs @@ -0,0 +1,69 @@ + + + + + + New Message + + + +
+ + +

Someone started a conversation with you!

+ +

Hi {{recipientUsername}},

+ +

{{senderUsername}} has opened a conversation with you on Harmonica. Head over to your messages to see what they said and send a reply.

+ + + +

Or copy and paste this link into your browser:

+

{{messagesUrl}}

+ +

If you don't want to receive these emails, you can update your notification preferences in your account settings.

+ + +
+ + \ No newline at end of file diff --git a/src/messaging/messaging.module.ts b/src/messaging/messaging.module.ts index 32065cfc..ebfddd5f 100644 --- a/src/messaging/messaging.module.ts +++ b/src/messaging/messaging.module.ts @@ -9,9 +9,20 @@ import { MessagingGateway } from './messaging.gateway'; import { MessagingController } from './messaging.controller'; import { WebsocketsModule } from '../websockets/websockets.module'; import { UserBlock } from '../followers/entities/user-blocks.entity'; +import { User } from '../user/entities/user.entity'; +import { UserEmail } from '../user/entities/user-email.entity'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { MailModule } from '../mail/mail.module'; +import { UserModule } from '../user/user.module'; @Module({ - imports: [TypeOrmModule.forFeature([Chat, Message, ChatStatus, UserBlock]), WebsocketsModule], + imports: [ + TypeOrmModule.forFeature([Chat, Message, ChatStatus, UserBlock, User, UserEmail]), + WebsocketsModule, + NotificationsModule, + MailModule, + UserModule, + ], providers: [MessagingRepository, MessagingService, MessagingGateway], controllers: [MessagingController], exports: [MessagingService], diff --git a/src/messaging/messaging.repository.ts b/src/messaging/messaging.repository.ts index 534299a2..487f824a 100644 --- a/src/messaging/messaging.repository.ts +++ b/src/messaging/messaging.repository.ts @@ -45,6 +45,16 @@ export class MessagingRepository { .getOne(); } + async isFirstMessage(chatId: string): Promise { + const count = await this.messageRepo.count({ where: { chatId } }); + return count === 0; + } + + async getChatUnreadCountForUser(chatId: string, userId: string): Promise { + const status = await this.chatStatusRepo.findOne({ where: { chatId, userId } }); + return this.getUnreadCount(chatId, status?.lastReadMessageId ?? null); + } + async createChat(participantOneId: string, participantTwoId: string): Promise { const chat = this.chatRepo.create({ participantOneId, participantTwoId }); return this.chatRepo.save(chat); diff --git a/src/messaging/messaging.service.spec.ts b/src/messaging/messaging.service.spec.ts index 70526b34..e8771d1f 100644 --- a/src/messaging/messaging.service.spec.ts +++ b/src/messaging/messaging.service.spec.ts @@ -6,9 +6,6 @@ import { WebsocketsService } from '../websockets/websockets.service'; import { MessageType } from './enums/message-type.enum'; import { ChatFilter } from './enums/chat-filter.enum'; import { - mockMessagingRepository, - mockWebsocketsService, - mockChat, mockMessage, mockChatStatus, mockUserOne, @@ -17,7 +14,16 @@ import { makeSendMessageDto, makeMarkReadDto, makeGetMessagesDto, + mockNotificationsService, + mockMessagingRepository, + mockWebsocketsService, + mockUserRepository, + mockChat, } from './test/messaging.mock'; +import { NotificationsService } from '../notifications/notifications.service'; +import { MailService } from '../mail/mail.service'; +import { mockMailService } from '../authentication/test/auth.mock'; +import { UserRepository } from '../user/user.repository'; describe('MessagingService', () => { let service: MessagingService; @@ -28,6 +34,9 @@ describe('MessagingService', () => { MessagingService, { provide: MessagingRepository, useValue: mockMessagingRepository }, { provide: WebsocketsService, useValue: mockWebsocketsService }, + { provide: NotificationsService, useValue: mockNotificationsService }, + { provide: MailService, useValue: mockMailService }, + { provide: UserRepository, useValue: mockUserRepository }, ], }).compile(); diff --git a/src/messaging/messaging.service.ts b/src/messaging/messaging.service.ts index 572c2169..cd13f7df 100644 --- a/src/messaging/messaging.service.ts +++ b/src/messaging/messaging.service.ts @@ -7,6 +7,8 @@ import { import { plainToInstance } from 'class-transformer'; import { MessagingRepository } from './messaging.repository'; import { WebsocketsService } from '../websockets/websockets.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { MailService } from '../mail/mail.service'; import { CreateChatDto } from './dto/api/create-chat.dto'; import { SendMessageDto, MessageType } from './dto/ws/send-message.dto'; import { MarkReadDto } from './dto/ws/mark-read.dto'; @@ -15,12 +17,16 @@ import { ChatResDto } from './dto/chat-res.dto'; import { MessageResDto } from './dto/message-res.dto'; import { buildPaginationResponse } from '../common/utilities/pagination.util'; import { ChatFilter } from './enums/chat-filter.enum'; +import { UserRepository } from '../user/user.repository'; @Injectable() export class MessagingService { constructor( private readonly messagingRepository: MessagingRepository, - private readonly websocketsService: WebsocketsService + private readonly websocketsService: WebsocketsService, + private readonly notificationsService: NotificationsService, + private readonly mailService: MailService, + private readonly userRepository: UserRepository ) {} async createChat(currentUserId: string, dto: CreateChatDto) { @@ -117,18 +123,43 @@ export class MessagingService { await this.assertNotBlocked(dto.chatId, currentUserId); this.assertMessagePayload(dto); + const [isFirst, sender] = await Promise.all([ + this.messagingRepository.isFirstMessage(dto.chatId), + this.userRepository.findById(currentUserId), + ]); + const message = await this.messagingRepository.createMessage(currentUserId, dto); const otherId = await this.getOtherParticipantId(dto.chatId, currentUserId); - await Promise.all([ + const [unreadCount] = await Promise.all([ + this.messagingRepository.getChatUnreadCountForUser(dto.chatId, otherId), this.messagingRepository.setChatArchived(dto.chatId, currentUserId, false), this.messagingRepository.setChatArchived(dto.chatId, otherId, false), ]); const formatted = plainToInstance(MessageResDto, message); - // Emit to both participants via the chat room this.websocketsService.emitToRoom(`chat:${dto.chatId}`, 'message:new', formatted); + this.websocketsService.emitToUser(otherId, 'chat:update', { + chatId: dto.chatId, + unreadCount, + lastMessage: formatted, + }); + + if (sender) { + this.notificationsService.notifyNewMessage(otherId, sender, dto.chatId); + + if (isFirst) { + Promise.all([ + this.userRepository.getPrimaryEmail(otherId), + this.userRepository.findById(otherId), + ]).then(([email, recipient]) => { + if (email && recipient) { + this.mailService.sendFirstMessageEmail(email, recipient.username, sender.username); + } + }); + } + } return { status: 'success', diff --git a/src/messaging/test/messaging.mock.ts b/src/messaging/test/messaging.mock.ts index 1bbcd76e..ea425039 100644 --- a/src/messaging/test/messaging.mock.ts +++ b/src/messaging/test/messaging.mock.ts @@ -65,6 +65,8 @@ export const mockMessagingRepository = { getUnreadCount: jest.fn(), getTotalUnreadCount: jest.fn(), hasBlockRelationship: jest.fn(), + isFirstMessage: jest.fn().mockResolvedValue(false), + getChatUnreadCountForUser: jest.fn().mockResolvedValue(0), }; export const mockWebsocketsService = { @@ -103,6 +105,11 @@ export function makeMockSocket(userId: string, socketId = 'socket-abc'): any { }; } +export const mockUserRepository = { + findById: jest.fn().mockResolvedValue(mockUserOne), + getPrimaryEmail: jest.fn().mockResolvedValue('user@example.com'), +}; + export const makeCreateChatDto = (participantTwoId = mockUserTwo.userId) => ({ participantTwoId, }); @@ -137,3 +144,7 @@ export const makeGetMessagesDto = (overrides = {}) => ({ limit: 20, ...overrides, }); + +export const mockNotificationsService = { + notifyNewMessage: jest.fn(), +}; 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/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index e3a17bda..d21f94b1 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -253,6 +253,16 @@ export class NotificationsService { ); } + async notifyNewMessage(recipientId: string, actor: User, chatId: string) { + await this.sendPushNotification( + recipientId, + 'New Message', + `${actor.username} sent you a message.`, + 'message', + { chatId } + ); + } + async notifyNewPost(artist: any, trackId: string) { const followers = await this.notificationsRepository.getFollowers(artist.userId); @@ -302,24 +312,31 @@ export class NotificationsService { data?: any ) { try { - // 1. Permission Check const settings = await this.settingsService.getNotificationSettings(userId); const deviceSettings = settings?.data?.device as Record; + // 1. Opt-in by default: Only abort if the user explicitly turned it off if ( - !deviceSettings || - deviceSettings[settingKey] === false || - deviceSettings[settingKey] === 'off' - ) + deviceSettings && + (deviceSettings[settingKey] === false || deviceSettings[settingKey] === 'off') + ) { + console.log(`[Push] Aborted for user ${userId}: Setting '${settingKey}' is off.`); return; + } + // 2. Fetch and sanitize tokens const rawTokens = await this.notificationsRepository.getUserDeviceTokens(userId); const tokens: string[] = (rawTokens || []) .map((t: any) => (typeof t === 'string' ? t : t?.token)?.trim()) .filter((t): t is string => !!t && t.length > 0); - if (tokens.length === 0) return; + // 3. Log a warning if no tokens exist, instead of failing silently + if (tokens.length === 0) { + console.warn(`[Push Warning] No device tokens in DB for user: ${userId}. Aborting.`); + return; + } + // 4. Format the custom data payload for Flutter const fcmData: Record = { click_action: 'FLUTTER_NOTIFICATION_CLICK' }; if (data) { Object.entries(data).forEach(([k, v]) => { @@ -327,13 +344,41 @@ export class NotificationsService { }); } - await getMessaging(getFirebaseApp()).sendEachForMulticast({ + // 5. Send to Firebase and capture the receipt response + const response = await getMessaging(getFirebaseApp()).sendEachForMulticast({ notification: { title, body }, data: fcmData, tokens, + android: { + priority: 'high', + }, + apns: { + payload: { + aps: { + 'content-available': 1, + }, + }, + }, }); + + // 6. Print the EXACT raw JSON receipt from Google's servers + console.log(`[Raw Firebase ACK]:\n${JSON.stringify(response, null, 2)}`); + + // 7. If any tokens failed, print exactly why (helps with quick debugging) + if (response.failureCount > 0) { + console.log( + `[Push Summary] Successes: ${response.successCount}, Failures: ${response.failureCount}` + ); + response.responses.forEach((resp, idx) => { + if (!resp.success) { + console.error( + `[Push Error] Token ${idx} failed: ${resp.error?.code} - ${resp.error?.message}` + ); + } + }); + } } catch (error: any) { - console.error(`Push error: ${error?.message}`); + console.error(`[Push Error] Failed for user ${userId} (Catastrophic): ${error?.message}`); } } diff --git a/src/notifications/notifications.swagger.ts b/src/notifications/notifications.swagger.ts index 7c080f24..3b33252d 100644 --- a/src/notifications/notifications.swagger.ts +++ b/src/notifications/notifications.swagger.ts @@ -51,7 +51,8 @@ export function ApiGetNotifications() { name: 'type', required: false, type: String, - description: 'Filter by notification type (e.g., follow, like, repost, comment)', + description: + 'Filter by notification type (new_follower, new_like, new_repost, new_comment, new_post)', }), ApiResponse({ status: HttpStatus.OK, diff --git a/src/playlist/playlist.controller.spec.ts b/src/playlist/playlist.controller.spec.ts index 5cc1031d..74b62c77 100644 --- a/src/playlist/playlist.controller.spec.ts +++ b/src/playlist/playlist.controller.spec.ts @@ -873,7 +873,12 @@ describe('PlaylistController', () => { const result = await controller.getPublicPlaylist(mockPlaylistId, mockUserId, '1.2.3.4'); - expect(service.getPublicPlaylist).toHaveBeenCalledWith(mockPlaylistId, mockUserId, '1.2.3.4'); + expect(service.getPublicPlaylist).toHaveBeenCalledWith( + mockPlaylistId, + mockUserId, + '1.2.3.4', + undefined + ); expect(result).toBe(mockResponse); }); diff --git a/src/playlist/playlist.controller.ts b/src/playlist/playlist.controller.ts index 52b9cd1d..4ecc6680 100644 --- a/src/playlist/playlist.controller.ts +++ b/src/playlist/playlist.controller.ts @@ -261,9 +261,10 @@ export class PlaylistController { async getPublicPlaylist( @Param('playlistId', ParseUUIDPipe) playlistId: string, @CurrentUser('sub') userId: string, - @Ip() ip: string + @Ip() ip: string, + @CurrentUser('plan') plan?: string ) { - return this.playlistService.getPublicPlaylist(playlistId, userId, ip); + return this.playlistService.getPublicPlaylist(playlistId, userId, ip, plan); } @ApiGetSecretPlaylist() diff --git a/src/playlist/playlist.repository.ts b/src/playlist/playlist.repository.ts index bd06982d..ba3b2979 100644 --- a/src/playlist/playlist.repository.ts +++ b/src/playlist/playlist.repository.ts @@ -1020,4 +1020,21 @@ export class PlaylistRepository { })) || [], })); } + + async getDownloadedPlaylistsByUser( + userId: string, + page: number = 1, + limit: number = 10 + ): Promise<[Playlist[], number]> { + return this.playlistRepository + .createQueryBuilder('playlist') + .innerJoin('playlist.downloads', 'download') + .leftJoinAndSelect('playlist.user', 'owner') + .addSelect('download.downloadedAt') + .where('download.userId = :userId', { userId }) + .orderBy('download.downloadedAt', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + } } diff --git a/src/playlist/playlist.service.ts b/src/playlist/playlist.service.ts index 4f333917..f418eace 100644 --- a/src/playlist/playlist.service.ts +++ b/src/playlist/playlist.service.ts @@ -16,6 +16,7 @@ import { StorageService } from '../common/storage_service'; import { ActivityService } from '../activity/activity.service'; import { ActivityType } from '../activity/entities/activity.entity'; import { getLocationFromIp } from '../common/utilities/geolocation.util'; +import { resolveAudioUrl } from '../common/utilities/audio.util'; import { addDocuments, deleteDocument, mapAlbum, mapPlaylist } from '../search/indexing'; import { generateVerificationToken } from '../common/utilities/tokens.util'; import { NotificationsService } from '../notifications/notifications.service'; @@ -798,7 +799,7 @@ export class PlaylistService { }; } - async getPublicPlaylist(playlistId: string, userId: string, ip?: string) { + async getPublicPlaylist(playlistId: string, userId: string, ip?: string, plan?: string) { const playlist = await this.playlistRepository.getPublicPlaylist(playlistId); if (!playlist) { @@ -812,7 +813,12 @@ export class PlaylistService { } const country = ip ? getLocationFromIp(ip).country : null; - + let returnSecret = false; + if (!playlist.isPublic) { + if (playlist.userId === userId) { + returnSecret = true; + } + } return { status: 'success', data: { @@ -829,6 +835,7 @@ export class PlaylistService { updatedAt: playlist.updatedAt, genreName: playlist.genre?.name, genreId: playlist.genre?.genreId, + secretToken: returnSecret ? playlist.secretToken : undefined, tags: playlist.tags.map((tag) => ({ tagId: tag.genreId, name: tag.name, @@ -847,8 +854,8 @@ export class PlaylistService { title: pt.track.title, durationSeconds: pt.track.durationSeconds, coverImage: pt.track.coverImage, - audioUrl: isBlocked ? null : (pt.track.audioUrl ?? null), - waveformUrl: isBlocked ? null : (pt.track.waveformUrl ?? null), + audioUrl: isBlocked ? null : resolveAudioUrl(pt.track, plan), + waveformUrl: pt.track.waveformUrl ?? null, playCount: pt.track.playCount, likesCount: pt.track.likesCount, repostsCount: pt.track.repostsCount, @@ -909,7 +916,7 @@ export class PlaylistService { durationSeconds: pt.track.durationSeconds, coverImage: pt.track.coverImage, audioUrl: isBlocked ? null : (pt.track.audioUrl ?? null), - waveformUrl: isBlocked ? null : (pt.track.waveformUrl ?? null), + waveformUrl: pt.track.waveformUrl ?? null, playCount: pt.track.playCount, likesCount: pt.track.likesCount, repostsCount: pt.track.repostsCount, diff --git a/src/playlist/playlist.swagger.ts b/src/playlist/playlist.swagger.ts index a76b92ee..9bf5afb0 100644 --- a/src/playlist/playlist.swagger.ts +++ b/src/playlist/playlist.swagger.ts @@ -907,7 +907,7 @@ export function ApiGetPublicPlaylist() { summary: 'Get playlist details', description: 'Retrieves a public playlist by ID, including its owner, tracks (ordered), and tags. ' + - "For tracks blocked in the requester's region, `audioUrl` and `waveformUrl` are returned as `null`.", + "For tracks blocked in the requester's region, `audioUrl` is returned as `null`", }), ApiParam({ name: 'playlistId', @@ -918,7 +918,7 @@ export function ApiGetPublicPlaylist() { ApiResponse({ status: 200, description: - "Playlist retrieved successfully. `audioUrl` and `waveformUrl` per track are `null` when blocked in the requester's region.", + "Playlist retrieved successfully. Only `audioUrl` per track is `null` when blocked in the requester's region", schema: { example: { status: 'success', @@ -973,7 +973,7 @@ export function ApiGetPublicPlaylist() { durationSeconds: 195, coverImage: 'https://cdn.harmonica.com/covers/locked.jpg', audioUrl: null, - waveformUrl: null, + waveformUrl: 'https://cdn.harmonica.com/waveforms/locked.json', playCount: 500, likesCount: 20, repostsCount: 3, @@ -1009,7 +1009,7 @@ export function ApiGetSecretPlaylist() { summary: 'Get a private playlist via secret token', description: 'Allows users with the secret link to view a private playlist. Used for SoundCloud-style sharing. ' + - "For tracks blocked in the requester's region, `audioUrl` and `waveformUrl` are returned as `null`.", + "For tracks blocked in the requester's region, `audioUrl` is returned as `null`.", }), ApiParam({ name: 'secretToken', @@ -1020,7 +1020,7 @@ export function ApiGetSecretPlaylist() { ApiResponse({ status: 200, description: - "Secret playlist retrieved successfully. `audioUrl` and `waveformUrl` per track are `null` when blocked in the requester's region.", + "Secret playlist retrieved successfully. `audioUrl` per track is `null` when blocked in the requester's region.", schema: { example: { status: 'success', @@ -1072,7 +1072,7 @@ export function ApiGetSecretPlaylist() { durationSeconds: 210, coverImage: 'https://cdn.harmonica.com/covers/locked.jpg', audioUrl: null, - waveformUrl: null, + waveformUrl: 'https://cdn.harmonica.com/waveforms/locked.json', playCount: 0, likesCount: 0, repostsCount: 0, diff --git a/src/search/reindex-meili.ts b/src/search/reindex-meili.ts new file mode 100644 index 00000000..488ffd5a --- /dev/null +++ b/src/search/reindex-meili.ts @@ -0,0 +1,110 @@ +/* eslint-disable no-console */ +import path from 'path'; +import 'dotenv/config'; +import { DataSource } from 'typeorm'; +import { User } from '../user/entities/user.entity'; +import { Track } from '../track/entities/track.entity'; +import { Playlist, PlaylistType, ALBUM_TYPES } from '../playlist/entities/playlist.entity'; +import { TrackVisibility } from '../track/enums/track-visibility.enum'; +import { getIndex } from './client'; +import { mapUser, mapTrack, mapPlaylist, mapAlbum } from './indexing'; +import { SearchDocument } from './types'; + +const dataSource = new DataSource({ + type: 'postgres', + url: process.env.DATABASE_URL, + entities: [path.join(__dirname, '/../**/*.entity.js')], + synchronize: false, + logging: false, + ssl: true, + extra: { + ssl: { + rejectUnauthorized: false, + }, + }, +}); + +const BATCH_SIZE = 100; + +function toBatches(items: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(items.length / size) }, (_, i) => + items.slice(i * size, (i + 1) * size) + ); +} + +async function indexBatched(docs: SearchDocument[]): Promise { + const index = getIndex(); + await Promise.all(toBatches(docs, BATCH_SIZE).map((batch) => index.addDocuments(batch))); +} + +async function reindex() { + console.log('--- Meilisearch Full Reindex ---\n'); + + // -- Connect to Postgres + await dataSource.initialize(); + console.log('Connected to Postgres.'); + + const index = getIndex(); + + // -- Wipe the catalog index + console.log('Clearing catalog index...'); + await index.deleteAllDocuments(); + console.log('Catalog cleared.\n'); + + let totalIndexed = 0; + + // -- Users + console.log('Indexing users...'); + const users = await dataSource.getRepository(User).find({ where: { isPublic: true } }); + const userDocs: SearchDocument[] = users.map(mapUser); + await indexBatched(userDocs); + console.log(` Indexed ${userDocs.length} users.`); + totalIndexed += userDocs.length; + + // -- Tracks + console.log('Indexing tracks...'); + const tracks = await dataSource.getRepository(Track).find({ + where: { visibility: TrackVisibility.PUBLIC, hidden: false }, + relations: ['user', 'tags', 'genre'], + }); + const trackDocs: SearchDocument[] = tracks.map(mapTrack); + await indexBatched(trackDocs); + console.log(` Indexed ${trackDocs.length} tracks.`); + totalIndexed += trackDocs.length; + + // -- Playlists + console.log('Indexing playlists...'); + const playlists = await dataSource.getRepository(Playlist).find({ + where: { isPublic: true, type: PlaylistType.PLAYLIST }, + relations: ['user', 'tags', 'genre'], + }); + const playlistDocs: SearchDocument[] = playlists.map(mapPlaylist); + await indexBatched(playlistDocs); + console.log(` Indexed ${playlistDocs.length} playlists.`); + totalIndexed += playlistDocs.length; + + // -- Albums (all album sub-types fetched in parallel) + console.log('Indexing albums...'); + const albumRepo = dataSource.getRepository(Playlist); + const albumBatches = await Promise.all( + ALBUM_TYPES.map((albumType) => + albumRepo.find({ + where: { isPublic: true, type: albumType }, + relations: ['user', 'tags', 'genre'], + }) + ) + ); + const albumDocs: SearchDocument[] = albumBatches.flat().map(mapAlbum); + await indexBatched(albumDocs); + console.log(` Indexed ${albumDocs.length} albums.`); + totalIndexed += albumDocs.length; + + // -- Done + console.log(`\nReindex complete. Total documents indexed: ${totalIndexed}`); + await dataSource.destroy(); +} + +reindex().catch((err) => { + console.error('Reindex failed:', err); + process.exit(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/dto/createCheckOutSessionDto.ts b/src/subscription/dto/createCheckOutSessionDto.dto.ts similarity index 100% rename from src/subscription/dto/createCheckOutSessionDto.ts rename to src/subscription/dto/createCheckOutSessionDto.dto.ts diff --git a/src/subscription/entities/subscription.entity.ts b/src/subscription/entities/subscription.entity.ts index 6e15b2d3..af6f71c5 100644 --- a/src/subscription/entities/subscription.entity.ts +++ b/src/subscription/entities/subscription.entity.ts @@ -60,6 +60,9 @@ export class Subscription extends BaseEntity { cancelAtPeriodEnd!: boolean; // Relation + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + @OneToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user!: User; 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/subscription.controller.spec.ts b/src/subscription/subscription.controller.spec.ts index 5774d9a2..4bce541c 100644 --- a/src/subscription/subscription.controller.spec.ts +++ b/src/subscription/subscription.controller.spec.ts @@ -1,9 +1,20 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; import { SubscriptionController } from './subscription.controller'; import { SubscriptionService } from './subscription.service'; +import { SubscriptionPlan, SubscriptionStatus } from './entities/subscription.entity'; +import { PlanType, BillingCycle } from './dto/createCheckOutSessionDto.dto'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const MOCK_USER_ID = 'user-uuid-1'; +const MOCK_PAYMENT_METHOD_ID = 'pm_test_card'; + +// ─── Suite ──────────────────────────────────────────────────────────────────── describe('SubscriptionController', () => { let controller: SubscriptionController; + let subscriptionService: SubscriptionService; const mockSubscriptionService = { createCheckout: jest.fn(), @@ -13,6 +24,8 @@ describe('SubscriptionController', () => { }; beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ controllers: [SubscriptionController], providers: [ @@ -24,9 +37,192 @@ describe('SubscriptionController', () => { }).compile(); controller = module.get(SubscriptionController); + subscriptionService = module.get(SubscriptionService); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + // ─── createCheckout ──────────────────────────────────────────────────────── + + describe('createCheckout', () => { + const dto = { + plan: PlanType.PRO, + billingCycle: BillingCycle.MONTHLY, + paymentMethodId: MOCK_PAYMENT_METHOD_ID, + }; + + it('should call subscriptionService.createCheckout with correct arguments', async () => { + const mockResult = { status: 'success', message: 'Subscription created successfully' }; + mockSubscriptionService.createCheckout.mockResolvedValue(mockResult); + + await controller.createCheckout(MOCK_USER_ID, dto as any); + + expect(subscriptionService.createCheckout).toHaveBeenCalledTimes(1); + expect(subscriptionService.createCheckout).toHaveBeenCalledWith( + MOCK_USER_ID, + dto.plan, + dto.billingCycle, + dto.paymentMethodId + ); + }); + + it('should return the service result as-is on success', async () => { + const mockResult = { status: 'success', message: 'Subscription created successfully' }; + mockSubscriptionService.createCheckout.mockResolvedValue(mockResult); + + const result = await controller.createCheckout(MOCK_USER_ID, dto as any); + + expect(result).toEqual(mockResult); + }); + + it('should return pending status when payment is being processed', async () => { + const mockResult = { status: 'pending', message: 'Payment is being processed' }; + mockSubscriptionService.createCheckout.mockResolvedValue(mockResult); + + const result = await controller.createCheckout(MOCK_USER_ID, dto as any); + + expect(result).toEqual(mockResult); + }); + + it('should propagate BadRequestException from service', async () => { + mockSubscriptionService.createCheckout.mockRejectedValue( + new BadRequestException('User already has an active subscription') + ); + + await expect(controller.createCheckout(MOCK_USER_ID, dto as any)).rejects.toThrow( + new BadRequestException('User already has an active subscription') + ); + }); + }); + + // ─── getMySubscription ───────────────────────────────────────────────────── + + describe('getMySubscription', () => { + it('should call subscriptionService.getMySubscription with the user ID', async () => { + const mockResult = { + plan: SubscriptionPlan.FREE, + status: SubscriptionStatus.ACTIVE, + billingCycle: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + stripeSubscriptionId: null, + }; + mockSubscriptionService.getMySubscription.mockResolvedValue(mockResult); + + await controller.getMySubscription(MOCK_USER_ID); + + expect(subscriptionService.getMySubscription).toHaveBeenCalledTimes(1); + expect(subscriptionService.getMySubscription).toHaveBeenCalledWith(MOCK_USER_ID); + }); + + it('should return default FREE plan response when user has no subscription', async () => { + const mockResult = { + plan: SubscriptionPlan.FREE, + status: SubscriptionStatus.ACTIVE, + billingCycle: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + stripeSubscriptionId: null, + }; + mockSubscriptionService.getMySubscription.mockResolvedValue(mockResult); + + const result = await controller.getMySubscription(MOCK_USER_ID); + + expect(result).toEqual(mockResult); + }); + + it('should return subscription data for a paid plan', async () => { + const mockResult = { + plan: SubscriptionPlan.PRO_MONTHLY, + status: SubscriptionStatus.ACTIVE, + billingCycle: 'monthly', + currentPeriodEnd: new Date('2026-05-30T00:00:00Z'), + cancelAtPeriodEnd: false, + stripeSubscriptionId: 'sub_test5678', + }; + mockSubscriptionService.getMySubscription.mockResolvedValue(mockResult); + + const result = await controller.getMySubscription(MOCK_USER_ID); + + expect(result).toEqual(mockResult); + }); + }); + + // ─── cancelSubscription ──────────────────────────────────────────────────── + + describe('cancelSubscription', () => { + it('should call subscriptionService.cancelSubscription with the user ID', async () => { + const mockResult = { message: 'Your subscription will remain active until Sat May 30 2026' }; + mockSubscriptionService.cancelSubscription.mockResolvedValue(mockResult); + + await controller.cancelSubscription(MOCK_USER_ID); + + expect(subscriptionService.cancelSubscription).toHaveBeenCalledTimes(1); + expect(subscriptionService.cancelSubscription).toHaveBeenCalledWith(MOCK_USER_ID); + }); + + it('should return the message from the service', async () => { + const mockResult = { message: 'Your subscription will remain active until Sat May 30 2026' }; + mockSubscriptionService.cancelSubscription.mockResolvedValue(mockResult); + + const result = await controller.cancelSubscription(MOCK_USER_ID); + + expect(result).toEqual(mockResult); + }); + + it('should propagate BadRequestException when no active subscription', async () => { + mockSubscriptionService.cancelSubscription.mockRejectedValue( + new BadRequestException('No active subscription to cancel') + ); + + await expect(controller.cancelSubscription(MOCK_USER_ID)).rejects.toThrow( + new BadRequestException('No active subscription to cancel') + ); + }); + + it('should propagate BadRequestException when already scheduled for cancellation', async () => { + mockSubscriptionService.cancelSubscription.mockRejectedValue( + new BadRequestException('Subscription is already scheduled for cancellation') + ); + + await expect(controller.cancelSubscription(MOCK_USER_ID)).rejects.toThrow( + new BadRequestException('Subscription is already scheduled for cancellation') + ); + }); + }); + + // ─── resumeSubscription ──────────────────────────────────────────────────── + + describe('resumeSubscription', () => { + it('should call subscriptionService.resumeSubscription with the user ID', async () => { + const mockResult = { message: 'Subscription resumed successfully' }; + mockSubscriptionService.resumeSubscription.mockResolvedValue(mockResult); + + await controller.resumeSubscription(MOCK_USER_ID); + + expect(subscriptionService.resumeSubscription).toHaveBeenCalledTimes(1); + expect(subscriptionService.resumeSubscription).toHaveBeenCalledWith(MOCK_USER_ID); + }); + + it('should return the success message from the service', async () => { + const mockResult = { message: 'Subscription resumed successfully' }; + mockSubscriptionService.resumeSubscription.mockResolvedValue(mockResult); + + const result = await controller.resumeSubscription(MOCK_USER_ID); + + expect(result).toEqual(mockResult); + }); + + it('should propagate BadRequestException when subscription is not scheduled for cancellation', async () => { + mockSubscriptionService.resumeSubscription.mockRejectedValue( + new BadRequestException('Subscription is not scheduled for cancellation') + ); + + await expect(controller.resumeSubscription(MOCK_USER_ID)).rejects.toThrow( + new BadRequestException('Subscription is not scheduled for cancellation') + ); + }); + }); }); diff --git a/src/subscription/subscription.controller.ts b/src/subscription/subscription.controller.ts index 6756abf6..b3db73a5 100644 --- a/src/subscription/subscription.controller.ts +++ b/src/subscription/subscription.controller.ts @@ -1,13 +1,22 @@ import { Controller, Post, Get, Patch, Body } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { SubscriptionService } from './subscription.service'; -import { CreateCheckoutSessionDto } from './dto/createCheckOutSessionDto'; +import { CreateCheckoutSessionDto } from './dto/createCheckOutSessionDto.dto'; import { CurrentUser } from '../authentication/decorators/current-user.decorator'; +import { + ApiCreateCheckout, + ApiGetMySubscription, + ApiCancelSubscription, + ApiResumeSubscription, +} from './subscription.swagger'; +@ApiTags('Subscription') @Controller('subscription') export class SubscriptionController { constructor(private readonly subscriptionService: SubscriptionService) {} // POST /subscriptions/checkout + @ApiCreateCheckout() @Post('checkout') async createCheckout(@CurrentUser('sub') userId: string, @Body() dto: CreateCheckoutSessionDto) { console.log(dto); @@ -20,18 +29,21 @@ export class SubscriptionController { } // GET /subscriptions/me + @ApiGetMySubscription() @Get('me') async getMySubscription(@CurrentUser('sub') userId: string) { return this.subscriptionService.getMySubscription(userId); } // PATCH /subscriptions/cancel + @ApiCancelSubscription() @Patch('cancel') async cancelSubscription(@CurrentUser('sub') userId: string) { return this.subscriptionService.cancelSubscription(userId); } // PATCH /subscriptions/resume + @ApiResumeSubscription() @Patch('resume') async resumeSubscription(@CurrentUser('sub') userId: string) { return this.subscriptionService.resumeSubscription(userId); diff --git a/src/subscription/subscription.repository.ts b/src/subscription/subscription.repository.ts index 48aa988a..dc9bff84 100644 --- a/src/subscription/subscription.repository.ts +++ b/src/subscription/subscription.repository.ts @@ -36,4 +36,11 @@ export class SubscriptionRepository { where: { stripeCustomerId }, }); } + + async findOneByCustomerId(stripeCustomerId: string): Promise { + return this.subscriptionRepository.findOne({ + where: { stripeCustomerId }, + relations: ['user'], // make sure user relation is loaded + }); + } } diff --git a/src/subscription/subscription.service.spec.ts b/src/subscription/subscription.service.spec.ts index 8b2d2e09..c18b9fa3 100644 --- a/src/subscription/subscription.service.spec.ts +++ b/src/subscription/subscription.service.spec.ts @@ -1,9 +1,45 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import Stripe from 'stripe'; import { SubscriptionService } from './subscription.service'; import { SubscriptionRepository } from './subscription.repository'; import { StripeService } from './stripe.service'; import { UserRepository } from '../user/user.repository'; +import { SubscriptionPlan, SubscriptionStatus } from './entities/subscription.entity'; +import { PlanType, BillingCycle } from './dto/createCheckOutSessionDto.dto'; + +// ─── UUIDs ──────────────────────────────────────────────────────────────────── + +const MOCK_USER_ID = '550e8400-e29b-41d4-a716-446655440001'; +const MOCK_STRIPE_CUSTOMER_ID = 'cus_test1234'; +const MOCK_STRIPE_SUBSCRIPTION_ID = 'sub_test5678'; +const MOCK_PRICE_ID_PRO_MONTHLY = 'price_pro_monthly'; +const MOCK_PAYMENT_METHOD_ID = 'pm_test_card'; + +// ─── Mock factories ─────────────────────────────────────────────────────────── + +const mockSubscription = (overrides?: object) => ({ + subscriptionId: 'sub-uuid-1', + stripeCustomerId: MOCK_STRIPE_CUSTOMER_ID, + stripeSubscriptionId: MOCK_STRIPE_SUBSCRIPTION_ID, + plan: SubscriptionPlan.FREE, + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: null, + currentPeriodEnd: new Date('2026-05-30T00:00:00Z'), + cancelAtPeriodEnd: false, + user: { userId: MOCK_USER_ID }, + ...overrides, +}); + +const mockUser = (overrides?: object) => ({ + userId: MOCK_USER_ID, + username: 'dj_nour', + emails: [{ email: 'dj@harmonica.com', isPrimary: true }], + ...overrides, +}); + +// ─── Suite ──────────────────────────────────────────────────────────────────── describe('SubscriptionService', () => { let service: SubscriptionService; @@ -13,6 +49,7 @@ describe('SubscriptionService', () => { createSubscription: jest.fn(), updateSubscription: jest.fn(), findByStripeCustomerId: jest.fn(), + findOneByCustomerId: jest.fn(), }; const mockStripeService = { @@ -27,13 +64,25 @@ describe('SubscriptionService', () => { const mockUserRepository = { findUserWithEmails: jest.fn(), + update: jest.fn(), }; const mockConfigService = { - get: jest.fn(), + get: jest.fn((key: string) => { + const map: Record = { + STRIPE_ARTIST_PRO_PRICE_MONTHLY: MOCK_PRICE_ID_PRO_MONTHLY, + STRIPE_ARTIST_PRO_PRICE_YEARLY: 'price_pro_yearly', + STRIPE_GO_PLUS_PRICE_MONTHLY: 'price_go_plus_monthly', + STRIPE_GO_PLUS_PRICE_YEARLY: 'price_go_plus_yearly', + }; + return map[key]; + }), }; beforeEach(async () => { + jest.clearAllMocks(); + jest.useFakeTimers(); + const module: TestingModule = await Test.createTestingModule({ providers: [ SubscriptionService, @@ -47,7 +96,610 @@ describe('SubscriptionService', () => { service = module.get(SubscriptionService); }); + afterEach(() => { + jest.useRealTimers(); + }); + it('should be defined', () => { expect(service).toBeDefined(); }); + + // ─── createCheckout ──────────────────────────────────────────────────────── + + describe('createCheckout', () => { + const makeStripeSubscription = (paymentStatus: string) => ({ + id: MOCK_STRIPE_SUBSCRIPTION_ID, + latest_invoice: { + payment_intent: { status: paymentStatus }, + }, + }); + + it('should throw BadRequestException when user is not found and no subscription exists', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(null); + mockUserRepository.findUserWithEmails.mockResolvedValue(null); + + await expect( + service.createCheckout( + MOCK_USER_ID, + PlanType.PRO, + BillingCycle.MONTHLY, + MOCK_PAYMENT_METHOD_ID + ) + ).rejects.toThrow(new BadRequestException('user not found')); + }); + + it('should throw BadRequestException when user has no primary email', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(null); + mockUserRepository.findUserWithEmails.mockResolvedValue( + mockUser({ emails: [{ email: 'secondary@test.com', isPrimary: false }] }) + ); + + await expect( + service.createCheckout( + MOCK_USER_ID, + PlanType.PRO, + BillingCycle.MONTHLY, + MOCK_PAYMENT_METHOD_ID + ) + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when user already has an active paid subscription', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ plan: SubscriptionPlan.PRO_MONTHLY, status: SubscriptionStatus.ACTIVE }) + ); + + await expect( + service.createCheckout( + MOCK_USER_ID, + PlanType.PRO, + BillingCycle.MONTHLY, + MOCK_PAYMENT_METHOD_ID + ) + ).rejects.toThrow(new BadRequestException('User already has an active subscription')); + }); + + it('should throw BadRequestException when card is declined', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription()); + mockStripeService.attachPaymentMethod.mockResolvedValue(undefined); + mockStripeService.createStripeSubscription.mockResolvedValue( + makeStripeSubscription('requires_payment_method') + ); + + await expect( + service.createCheckout( + MOCK_USER_ID, + PlanType.PRO, + BillingCycle.MONTHLY, + MOCK_PAYMENT_METHOD_ID + ) + ).rejects.toThrow(new BadRequestException('Your card was declined')); + }); + + it('should return success when payment intent status is succeeded', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription()); + mockStripeService.attachPaymentMethod.mockResolvedValue(undefined); + mockStripeService.createStripeSubscription.mockResolvedValue( + makeStripeSubscription('succeeded') + ); + mockUserRepository.update.mockResolvedValue(undefined); + + const result = await service.createCheckout( + MOCK_USER_ID, + PlanType.PRO, + BillingCycle.MONTHLY, + MOCK_PAYMENT_METHOD_ID + ); + + expect(result).toEqual({ status: 'success', message: 'Subscription created successfully' }); + expect(mockUserRepository.update).toHaveBeenCalledWith(MOCK_USER_ID, { plan: PlanType.PRO }); + }); + + it('should return success when webhook updates DB to active while polling', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription()); + mockStripeService.attachPaymentMethod.mockResolvedValue(undefined); + mockStripeService.createStripeSubscription.mockResolvedValue( + makeStripeSubscription('processing') + ); + mockUserRepository.update.mockResolvedValue(undefined); + mockSubscriptionRepository.findOneByCustomerId.mockResolvedValue( + mockSubscription({ status: SubscriptionStatus.ACTIVE, plan: SubscriptionPlan.PRO_MONTHLY }) + ); + + const resultPromise = service.createCheckout( + MOCK_USER_ID, + PlanType.PRO, + BillingCycle.MONTHLY, + MOCK_PAYMENT_METHOD_ID + ); + + await jest.runAllTimersAsync(); + + const result = await resultPromise; + expect(result).toEqual({ status: 'success', message: 'Subscription created successfully' }); + }); + + it('should throw BadRequestException when webhook signals payment failure', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription()); + mockStripeService.attachPaymentMethod.mockResolvedValue(undefined); + mockStripeService.createStripeSubscription.mockResolvedValue( + makeStripeSubscription('processing') + ); + mockSubscriptionRepository.findOneByCustomerId.mockResolvedValue( + mockSubscription({ status: SubscriptionStatus.PAST_DUE }) + ); + + // ✅ capture the error via .catch() before timers run + let caughtError: unknown; + const resultPromise = service + .createCheckout(MOCK_USER_ID, PlanType.PRO, BillingCycle.MONTHLY, MOCK_PAYMENT_METHOD_ID) + .catch((err: unknown) => { + caughtError = err; + }); + + await jest.runAllTimersAsync(); + await resultPromise; + + expect(caughtError).toBeInstanceOf(BadRequestException); + expect((caughtError as BadRequestException).message).toBe('Payment failed'); + }); + + it('should throw BadRequestException when payment times out', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription()); + mockStripeService.attachPaymentMethod.mockResolvedValue(undefined); + mockStripeService.createStripeSubscription.mockResolvedValue( + makeStripeSubscription('processing') + ); + // ✅ never return a terminal status — poll runs until timeout fires + mockSubscriptionRepository.findOneByCustomerId.mockResolvedValue(null); + + let caughtError: unknown; + const resultPromise = service + .createCheckout(MOCK_USER_ID, PlanType.PRO, BillingCycle.MONTHLY, MOCK_PAYMENT_METHOD_ID) + .catch((err: unknown) => { + caughtError = err; + }); + + await jest.runAllTimersAsync(); + await resultPromise; + + expect(caughtError).toBeInstanceOf(BadRequestException); + expect((caughtError as BadRequestException).message).toBe( + 'Payment timed out, please check your subscription status' + ); + }); + + it('should attach payment method to stripe customer before creating subscription', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription()); + mockStripeService.attachPaymentMethod.mockResolvedValue(undefined); + mockStripeService.createStripeSubscription.mockResolvedValue( + makeStripeSubscription('succeeded') + ); + mockUserRepository.update.mockResolvedValue(undefined); + + await service.createCheckout( + MOCK_USER_ID, + PlanType.PRO, + BillingCycle.MONTHLY, + MOCK_PAYMENT_METHOD_ID + ); + + expect(mockStripeService.attachPaymentMethod).toHaveBeenCalledWith( + MOCK_PAYMENT_METHOD_ID, + MOCK_STRIPE_CUSTOMER_ID + ); + expect(mockStripeService.createStripeSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + MOCK_PRICE_ID_PRO_MONTHLY + ); + }); + + it('should create a stripe customer when no subscription exists yet', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(null); + mockUserRepository.findUserWithEmails.mockResolvedValue(mockUser()); + mockStripeService.createCustomer.mockResolvedValue({ id: MOCK_STRIPE_CUSTOMER_ID }); + mockSubscriptionRepository.createSubscription.mockResolvedValue(mockSubscription()); + mockStripeService.attachPaymentMethod.mockResolvedValue(undefined); + mockStripeService.createStripeSubscription.mockResolvedValue( + makeStripeSubscription('succeeded') + ); + mockUserRepository.update.mockResolvedValue(undefined); + + await service.createCheckout( + MOCK_USER_ID, + PlanType.PRO, + BillingCycle.MONTHLY, + MOCK_PAYMENT_METHOD_ID + ); + + expect(mockStripeService.createCustomer).toHaveBeenCalledWith( + 'dj@harmonica.com', + 'dj_nour', + MOCK_USER_ID + ); + }); + + it('should use yearly price ID when billingCycle is yearly', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription()); + mockStripeService.attachPaymentMethod.mockResolvedValue(undefined); + mockStripeService.createStripeSubscription.mockResolvedValue( + makeStripeSubscription('succeeded') + ); + mockUserRepository.update.mockResolvedValue(undefined); + + await service.createCheckout( + MOCK_USER_ID, + PlanType.PRO, + BillingCycle.YEARLY, + MOCK_PAYMENT_METHOD_ID + ); + + expect(mockStripeService.createStripeSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + 'price_pro_yearly' + ); + }); + }); + + // ─── getMySubscription ───────────────────────────────────────────────────── + + describe('getMySubscription', () => { + it('should return default FREE plan when no subscription record exists', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(null); + + const result = await service.getMySubscription(MOCK_USER_ID); + + expect(result).toEqual({ + plan: SubscriptionPlan.FREE, + status: SubscriptionStatus.ACTIVE, + billingCycle: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + stripeSubscriptionId: null, + }); + }); + + it('should return subscription data when a record exists', async () => { + const sub = mockSubscription({ + plan: SubscriptionPlan.PRO_MONTHLY, + status: SubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: false, + stripeSubscriptionId: MOCK_STRIPE_SUBSCRIPTION_ID, + }); + mockSubscriptionRepository.findOne.mockResolvedValue(sub); + + const result = await service.getMySubscription(MOCK_USER_ID); + + expect(result).toEqual({ + plan: SubscriptionPlan.PRO_MONTHLY, + status: SubscriptionStatus.ACTIVE, + billingCycle: 'monthly', + currentPeriodEnd: sub.currentPeriodEnd, + cancelAtPeriodEnd: false, + stripeSubscriptionId: MOCK_STRIPE_SUBSCRIPTION_ID, + }); + }); + + it('should resolve billingCycle as "yearly" for yearly plans', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ plan: SubscriptionPlan.PRO_YEARLY }) + ); + + const result = await service.getMySubscription(MOCK_USER_ID); + + expect(result.billingCycle).toBe('yearly'); + }); + + it('should resolve billingCycle as null for the FREE plan', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ plan: SubscriptionPlan.FREE }) + ); + + const result = await service.getMySubscription(MOCK_USER_ID); + + expect(result.billingCycle).toBeNull(); + }); + + it('should return cancelAtPeriodEnd as true when set', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ + plan: SubscriptionPlan.GO_PLUS_MONTHLY, + status: SubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: true, + }) + ); + + const result = await service.getMySubscription(MOCK_USER_ID); + + expect(result.cancelAtPeriodEnd).toBe(true); + }); + + it('should return stripeSubscriptionId as null when field is not set', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ stripeSubscriptionId: null }) + ); + + const result = await service.getMySubscription(MOCK_USER_ID); + + expect(result.stripeSubscriptionId).toBeNull(); + }); + }); + + // ─── cancelSubscription ──────────────────────────────────────────────────── + + describe('cancelSubscription', () => { + it('should throw BadRequestException when user is not on a paid plan', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ plan: SubscriptionPlan.FREE, status: SubscriptionStatus.ACTIVE }) + ); + + await expect(service.cancelSubscription(MOCK_USER_ID)).rejects.toThrow( + new BadRequestException('No active subscription to cancel') + ); + }); + + it('should throw BadRequestException when subscription is not active', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ + plan: SubscriptionPlan.PRO_MONTHLY, + status: SubscriptionStatus.PAST_DUE, + }) + ); + + await expect(service.cancelSubscription(MOCK_USER_ID)).rejects.toThrow( + new BadRequestException('No active subscription to cancel') + ); + }); + + it('should throw BadRequestException when already scheduled for cancellation', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ + plan: SubscriptionPlan.PRO_MONTHLY, + status: SubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: true, + }) + ); + + await expect(service.cancelSubscription(MOCK_USER_ID)).rejects.toThrow( + new BadRequestException('Subscription is already scheduled for cancellation') + ); + }); + + it('should call stripe cancelSubscription and update the repository', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ plan: SubscriptionPlan.PRO_MONTHLY, status: SubscriptionStatus.ACTIVE }) + ); + mockStripeService.cancelSubscription.mockResolvedValue(undefined); + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + + await service.cancelSubscription(MOCK_USER_ID); + + expect(mockStripeService.cancelSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_SUBSCRIPTION_ID + ); + expect(mockSubscriptionRepository.updateSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + { cancelAtPeriodEnd: true } + ); + }); + + it('should return a message containing the period end date', async () => { + const periodEnd = new Date('2026-05-30T00:00:00Z'); + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ + plan: SubscriptionPlan.PRO_MONTHLY, + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: periodEnd, + }) + ); + mockStripeService.cancelSubscription.mockResolvedValue(undefined); + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + + const result = await service.cancelSubscription(MOCK_USER_ID); + + expect(result.message).toContain(periodEnd.toDateString()); + }); + }); + + // ─── resumeSubscription ──────────────────────────────────────────────────── + + describe('resumeSubscription', () => { + it('should throw BadRequestException when subscription is not scheduled for cancellation', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ cancelAtPeriodEnd: false }) + ); + + await expect(service.resumeSubscription(MOCK_USER_ID)).rejects.toThrow( + new BadRequestException('Subscription is not scheduled for cancellation') + ); + }); + + it('should call stripe resumeSubscription and update the repository', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ cancelAtPeriodEnd: true }) + ); + mockStripeService.resumeSubscription.mockResolvedValue(undefined); + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + + await service.resumeSubscription(MOCK_USER_ID); + + expect(mockStripeService.resumeSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_SUBSCRIPTION_ID + ); + expect(mockSubscriptionRepository.updateSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + { cancelAtPeriodEnd: false } + ); + }); + + it('should return success message', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue( + mockSubscription({ cancelAtPeriodEnd: true }) + ); + mockStripeService.resumeSubscription.mockResolvedValue(undefined); + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + + const result = await service.resumeSubscription(MOCK_USER_ID); + + expect(result).toEqual({ message: 'Subscription resumed successfully' }); + }); + }); + + // ─── handleWebhookEvent ──────────────────────────────────────────────────── + + describe('handleWebhookEvent', () => { + const makeInvoiceSucceededEvent = (priceId: string | null, subscriptionId = 'sub_123') => + ({ + type: 'invoice.payment_succeeded' as const, + data: { + object: { + customer: MOCK_STRIPE_CUSTOMER_ID, + subscription: subscriptionId, + lines: { + data: [ + { + price: priceId ? { id: priceId } : null, + period: { start: 1700000000, end: 1702600000 }, + }, + ], + }, + }, + }, + }) as unknown as Stripe.Event; + + const makeSubUpdatedEvent = (cancelAtPeriodEnd: boolean) => + ({ + type: 'customer.subscription.updated' as const, + data: { + object: { + customer: MOCK_STRIPE_CUSTOMER_ID, + cancel_at_period_end: cancelAtPeriodEnd, + current_period_start: 1700000000, + current_period_end: 1702600000, + }, + }, + }) as unknown as Stripe.Event; + + const makeSubDeletedEvent = () => + ({ + type: 'customer.subscription.deleted' as const, + data: { object: { customer: MOCK_STRIPE_CUSTOMER_ID } }, + }) as unknown as Stripe.Event; + + const makeInvoiceFailedEvent = () => + ({ + type: 'invoice.payment_failed' as const, + data: { object: { customer: MOCK_STRIPE_CUSTOMER_ID } }, + }) as unknown as Stripe.Event; + + describe('invoice.payment_succeeded', () => { + it('should update subscription plan to PRO_MONTHLY when priceId matches, and update user plan', async () => { + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + mockSubscriptionRepository.findOneByCustomerId.mockResolvedValue( + mockSubscription({ user: { userId: MOCK_USER_ID } }) + ); + mockUserRepository.update.mockResolvedValue(undefined); + + await service.handleWebhookEvent(makeInvoiceSucceededEvent(MOCK_PRICE_ID_PRO_MONTHLY)); + + expect(mockSubscriptionRepository.updateSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + expect.objectContaining({ + plan: SubscriptionPlan.PRO_MONTHLY, + status: SubscriptionStatus.ACTIVE, + }) + ); + expect(mockUserRepository.update).toHaveBeenCalledWith(MOCK_USER_ID, { + plan: SubscriptionPlan.PRO_MONTHLY, + }); + }); + + it('should skip update when priceId is missing (no lines data)', async () => { + await service.handleWebhookEvent(makeInvoiceSucceededEvent(null)); + + expect(mockSubscriptionRepository.updateSubscription).not.toHaveBeenCalled(); + }); + + it('should skip update when priceId does not match any plan (unknown price ID)', async () => { + await service.handleWebhookEvent(makeInvoiceSucceededEvent('price_unknown_xyz')); + + expect(mockSubscriptionRepository.updateSubscription).not.toHaveBeenCalled(); + }); + }); + + describe('customer.subscription.updated', () => { + it('should call updateSubscription with correct cancelAtPeriodEnd and period dates', async () => { + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + + await service.handleWebhookEvent(makeSubUpdatedEvent(true)); + + expect(mockSubscriptionRepository.updateSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + expect.objectContaining({ + cancelAtPeriodEnd: true, + currentPeriodStart: expect.any(Date), + currentPeriodEnd: expect.any(Date), + }) + ); + }); + }); + + describe('customer.subscription.deleted', () => { + it('should reset plan to FREE and status to ACTIVE and update user plan', async () => { + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + mockSubscriptionRepository.findOneByCustomerId.mockResolvedValue( + mockSubscription({ user: { userId: MOCK_USER_ID } }) + ); + mockUserRepository.update.mockResolvedValue(undefined); + + await service.handleWebhookEvent(makeSubDeletedEvent()); + + expect(mockSubscriptionRepository.updateSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + expect.objectContaining({ + plan: SubscriptionPlan.FREE, + status: SubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: false, + }) + ); + expect(mockUserRepository.update).toHaveBeenCalledWith(MOCK_USER_ID, { + plan: SubscriptionPlan.FREE, + }); + }); + }); + + describe('invoice.payment_failed', () => { + it('should set status to PAST_DUE and reset user plan to FREE', async () => { + mockSubscriptionRepository.updateSubscription.mockResolvedValue(undefined); + mockSubscriptionRepository.findOneByCustomerId.mockResolvedValue( + mockSubscription({ user: { userId: MOCK_USER_ID } }) + ); + mockUserRepository.update.mockResolvedValue(undefined); + + await service.handleWebhookEvent(makeInvoiceFailedEvent()); + + expect(mockSubscriptionRepository.updateSubscription).toHaveBeenCalledWith( + MOCK_STRIPE_CUSTOMER_ID, + expect.objectContaining({ status: SubscriptionStatus.PAST_DUE }) + ); + expect(mockUserRepository.update).toHaveBeenCalledWith(MOCK_USER_ID, { + plan: SubscriptionPlan.FREE, + }); + }); + }); + + describe('unknown event type', () => { + it('should do nothing for unknown event type', async () => { + const unknownEvent = { + type: 'some.unknown.event', + data: { object: {} }, + } as unknown as Stripe.Event; + + await service.handleWebhookEvent(unknownEvent); + + expect(mockSubscriptionRepository.updateSubscription).not.toHaveBeenCalled(); + expect(mockUserRepository.update).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/subscription/subscription.service.ts b/src/subscription/subscription.service.ts index f6ec9016..d8711ffd 100644 --- a/src/subscription/subscription.service.ts +++ b/src/subscription/subscription.service.ts @@ -6,7 +6,7 @@ import { SubscriptionRepository } from './subscription.repository'; import { StripeService } from './stripe.service'; import { Subscription, SubscriptionStatus, SubscriptionPlan } from './entities/subscription.entity'; -import { PlanType } from './dto/createCheckOutSessionDto'; +import { PlanType } from './dto/createCheckOutSessionDto.dto'; @Injectable() export class SubscriptionService { @@ -75,43 +75,78 @@ export class SubscriptionService { ): Promise<{ status: string; message: string }> { const subscription = await this.getOrCreateSubscription(userId); - // Already on a paid plan if (this.isPaidPlan(subscription.plan) && subscription.status === SubscriptionStatus.ACTIVE) { throw new BadRequestException('User already has an active subscription'); } - // Get the right price ID const priceId = this.getPriceId(plan, billingCycle); - // Attach payment method to Stripe customer await this.stripeService.attachPaymentMethod(paymentMethodId, subscription.stripeCustomerId); - // Create subscription in Stripe const stripeSubscription = await this.stripeService.createStripeSubscription( subscription.stripeCustomerId, priceId ); - // Check payment status const invoice = stripeSubscription.latest_invoice as Stripe.Invoice; const paymentIntent = invoice?.payment_intent as Stripe.PaymentIntent; + // Declined immediately — no need to wait + if (paymentIntent?.status === 'requires_payment_method') { + throw new BadRequestException('Your card was declined'); + } + + // Already succeeded synchronously (rare but possible) if (paymentIntent?.status === 'succeeded') { await this.userRepository.update(userId, { plan }); - return { - status: 'success', - message: 'Subscription created successfully', - }; + return { status: 'success', message: 'Subscription created successfully' }; } - if (paymentIntent?.status === 'requires_payment_method') { - throw new BadRequestException('Your card was declined'); + // ⏳ Payment is processing — wait for webhook to update the DB + const result = await this.pollSubscriptionStatus(subscription.stripeCustomerId); + + if (result === 'active') { + await this.userRepository.update(userId, { plan }); + return { status: 'success', message: 'Subscription created successfully' }; } - return { - status: 'pending', - message: 'Payment is being processed', - }; + if (result === 'failed') { + throw new BadRequestException('Payment failed'); + } + + throw new BadRequestException('Payment timed out, please check your subscription status'); + } + + // ─── Poll DB until webhook updates it ───────────────────────────────────── + + private pollSubscriptionStatus( + stripeCustomerId: string, + intervalMs = 1000, + timeoutMs = 30000 + ): Promise<'active' | 'failed' | 'timeout'> { + return new Promise((resolve) => { + let interval: ReturnType; + + const timeout = setTimeout(() => { + clearInterval(interval); + resolve('timeout'); + }, timeoutMs); + + interval = setInterval(async () => { + const subscription = + await this.subscriptionRepository.findOneByCustomerId(stripeCustomerId); + + if (subscription?.status === SubscriptionStatus.ACTIVE) { + clearInterval(interval); + clearTimeout(timeout); + resolve('active'); + } else if (subscription?.status === SubscriptionStatus.PAST_DUE) { + clearInterval(interval); + clearTimeout(timeout); + resolve('failed'); + } + }, intervalMs); + }); } // ─── Get My Subscription ─────────────────────────────────────────────────── @@ -221,6 +256,11 @@ export class SubscriptionService { currentPeriodEnd, cancelAtPeriodEnd: false, }); + + const sub = await this.subscriptionRepository.findOneByCustomerId(stripeCustomerId); + if (sub?.user?.userId) { + await this.userRepository.update(sub.user.userId, { plan }); + } break; } @@ -248,6 +288,11 @@ export class SubscriptionService { currentPeriodStart: undefined, stripeSubscriptionId: undefined, }); + + const sub = await this.subscriptionRepository.findOneByCustomerId(stripeCustomerId); + if (sub?.user?.userId) { + await this.userRepository.update(sub.user.userId, { plan: SubscriptionPlan.FREE }); + } break; } @@ -258,6 +303,11 @@ export class SubscriptionService { await this.subscriptionRepository.updateSubscription(stripeCustomerId, { status: SubscriptionStatus.PAST_DUE, }); + + const sub = await this.subscriptionRepository.findOneByCustomerId(stripeCustomerId); + if (sub?.user?.userId) { + await this.userRepository.update(sub.user.userId, { plan: SubscriptionPlan.FREE }); + } break; } default: diff --git a/src/subscription/subscription.swagger.ts b/src/subscription/subscription.swagger.ts new file mode 100644 index 00000000..b23bf1b2 --- /dev/null +++ b/src/subscription/subscription.swagger.ts @@ -0,0 +1,194 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; + +// ─── Create Checkout ────────────────────────────────────────────────────────── + +export function ApiCreateCheckout() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Create a subscription checkout', + description: + 'Attaches a Stripe payment method to the user and creates a new paid subscription. ' + + 'The user must not already have an active paid subscription. ' + + 'Returns `status: "success"` when payment is immediately confirmed, ' + + 'or `status: "pending"` while Stripe is still processing.', + }), + ApiBody({ + required: true, + schema: { + type: 'object', + required: ['plan', 'billingCycle', 'paymentMethodId'], + properties: { + plan: { + type: 'string', + enum: ['pro', 'go+'], + description: 'The plan to subscribe to', + example: 'pro', + }, + billingCycle: { + type: 'string', + enum: ['monthly', 'yearly'], + description: 'Billing interval', + example: 'monthly', + }, + paymentMethodId: { + type: 'string', + description: 'Stripe payment method ID (e.g. pm_xxx)', + example: 'pm_1OqHBx2eZvKYlo2C0123abcd', + }, + }, + }, + }), + ApiResponse({ + status: 201, + description: 'Subscription created — payment confirmed immediately.', + schema: { + example: { + status: 'success', + message: 'Subscription created successfully', + }, + }, + }), + ApiResponse({ + status: 201, + description: 'Subscription created — payment still being processed by Stripe.', + schema: { + example: { + status: 'pending', + message: 'Payment is being processed', + }, + }, + }), + ApiResponse({ + status: 400, + description: + 'Bad request — user already has an active subscription, card was declined, or user account not found.', + schema: { + example: { + statusCode: 400, + message: 'User already has an active subscription', + error: 'Bad Request', + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }) + ); +} + +// ─── Get My Subscription ────────────────────────────────────────────────────── + +export function ApiGetMySubscription() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Get current user subscription', + description: + "Returns the authenticated user's current subscription state. " + + 'If no subscription record exists in the database, a default FREE / active ' + + 'response is returned (no 404).', + }), + ApiResponse({ + status: 200, + description: 'Active paid subscription.', + schema: { + example: { + plan: 'pro_monthly', + status: 'active', + billingCycle: 'monthly', + currentPeriodEnd: '2026-05-30T00:00:00.000Z', + cancelAtPeriodEnd: false, + stripeSubscriptionId: 'sub_1OqHBx2eZvKYlo2C0123abcd', + }, + }, + }), + ApiResponse({ + status: 200, + description: 'No subscription record — user is on the free plan.', + schema: { + example: { + plan: 'free', + status: 'active', + billingCycle: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + stripeSubscriptionId: null, + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }) + ); +} + +// ─── Cancel Subscription ────────────────────────────────────────────────────── + +export function ApiCancelSubscription() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Cancel subscription at period end', + description: + 'Marks the active subscription to cancel at the end of the current billing period. ' + + 'The subscription remains accessible until `currentPeriodEnd`. ' + + 'Throws if the user has no active paid subscription or if cancellation is already scheduled.', + }), + ApiResponse({ + status: 200, + description: 'Cancellation scheduled successfully.', + schema: { + example: { + message: 'Your subscription will remain active until Thu May 30 2026', + }, + }, + }), + ApiResponse({ + status: 400, + description: + 'Bad request — no active paid subscription to cancel, or already scheduled for cancellation.', + schema: { + example: { + statusCode: 400, + message: 'No active subscription to cancel', + error: 'Bad Request', + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }) + ); +} + +// ─── Resume Subscription ────────────────────────────────────────────────────── + +export function ApiResumeSubscription() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Resume a cancelled subscription', + description: + 'Reverses a pending cancellation on the current subscription so it renews normally ' + + 'at the end of the billing period. ' + + 'Throws if the subscription is not currently scheduled for cancellation.', + }), + ApiResponse({ + status: 200, + description: 'Subscription resumed successfully.', + schema: { + example: { + message: 'Subscription resumed successfully', + }, + }, + }), + ApiResponse({ + status: 400, + description: 'Subscription is not scheduled for cancellation.', + schema: { + example: { + statusCode: 400, + message: 'Subscription is not scheduled for cancellation', + error: 'Bad Request', + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }) + ); +} 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/dto/schedule-release.dto.ts b/src/track/dto/schedule-release.dto.ts new file mode 100644 index 00000000..cff02868 --- /dev/null +++ b/src/track/dto/schedule-release.dto.ts @@ -0,0 +1,14 @@ +import { IsDateString, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ScheduleReleaseDto { + @ApiProperty({ + description: 'ISO 8601 datetime in Egypt timezone (+03:00)', + example: '2026-06-01T12:00:00+03:00', + }) + @IsDateString() + @Matches(/\+03:00$/, { + message: 'scheduledAt must use Egypt timezone offset (+03:00)', + }) + scheduledAt!: string; +} diff --git a/src/track/entities/spotlght-track.entity.ts b/src/track/entities/spotlght-track.entity.ts new file mode 100644 index 00000000..c652589f --- /dev/null +++ b/src/track/entities/spotlght-track.entity.ts @@ -0,0 +1,24 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Relation } from 'typeorm'; +import { BaseEntity } from '../../common/entities/base.entity'; +import { User } from '../../user/entities/user.entity'; +import { Track } from './track.entity'; + +@Entity('spotlight_tracks') +export class SpotlightTrack extends BaseEntity { + @PrimaryGeneratedColumn('uuid', { name: 'spotlight_track_id' }) + spotlightTrackId!: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @Column({ name: 'track_id', type: 'uuid' }) + trackId!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: Relation; + + @ManyToOne(() => Track, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'track_id' }) + track!: Relation; +} diff --git a/src/track/entities/track.entity.ts b/src/track/entities/track.entity.ts index a5750b93..b1f89def 100644 --- a/src/track/entities/track.entity.ts +++ b/src/track/entities/track.entity.ts @@ -19,6 +19,7 @@ import { TrackVisibility } from '../enums/track-visibility.enum'; import { Genre } from '../../genre/entities/genre.entity'; import { TrackPlay } from './track-play.entity'; import { DownloadedTrack } from '../../download/entities/downloaded-tracks.entity'; +import { SpotlightTrack } from './spotlght-track.entity'; @Entity('tracks') export class Track extends BaseEntity { @@ -127,7 +128,7 @@ export class Track extends BaseEntity { @Column({ name: 'enable_direct_downloads', type: 'boolean', default: false }) enableDirectDownloads!: boolean; - @Column({ name: 'offline_listening', type: 'boolean', default: false }) + @Column({ name: 'offline_listening', type: 'boolean', default: true }) offlineListening!: boolean; // Licensing (Creative Commons style) @@ -155,6 +156,9 @@ export class Track extends BaseEntity { @Column({ name: 'show_insights', type: 'boolean', default: false }) showInsights!: boolean; + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt!: Date | null; + @ManyToOne(() => User, (user) => user.tracks, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user!: Relation; @@ -185,4 +189,7 @@ export class Track extends BaseEntity { @OneToMany(() => DownloadedTrack, (dt) => dt.track) downloads!: DownloadedTrack[]; + + @OneToMany(() => SpotlightTrack, (spotlight) => spotlight.user) + spotlightTracks!: SpotlightTrack[]; } diff --git a/src/track/enums/track-status.enum.ts b/src/track/enums/track-status.enum.ts index c1293c00..c0bb3b10 100644 --- a/src/track/enums/track-status.enum.ts +++ b/src/track/enums/track-status.enum.ts @@ -2,4 +2,5 @@ export enum TrackStatus { PROCESSING = 'processing', FINISHED = 'finished', FAILED = 'failed', + SCHEDULED = 'scheduled', } 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/listeners/release-job.processor.ts b/src/track/listeners/release-job.processor.ts new file mode 100644 index 00000000..7c9c2668 --- /dev/null +++ b/src/track/listeners/release-job.processor.ts @@ -0,0 +1,20 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { TrackRepository } from '../track.repository'; + +@Processor('releaseQueue') +export class ReleaseJobProcessor extends WorkerHost { + private readonly logger = new Logger(ReleaseJobProcessor.name); + + constructor(private readonly trackRepository: TrackRepository) { + super(); + } + + async process(job: Job<{ trackId: string }>): Promise { + const { trackId } = job.data; + this.logger.log(`Releasing scheduled track ${trackId}`); + await this.trackRepository.releaseTrack(trackId); + this.logger.log(`Track ${trackId} is now live`); + } +} diff --git a/src/track/services/fans.service.spec.ts b/src/track/services/fans.service.spec.ts index 8ef71e75..6f3c91ab 100644 --- a/src/track/services/fans.service.spec.ts +++ b/src/track/services/fans.service.spec.ts @@ -259,10 +259,6 @@ describe('FansService', () => { expect(fanRepo.hasSnapshot).toHaveBeenCalledWith(MOCK_TRACK_ID); expect(fanRepo.findStoredFirstFans).toHaveBeenCalledWith(MOCK_TRACK_ID); expect(fanRepo.computeFirstFans).not.toHaveBeenCalled(); - // 25h TTL for locked snapshot - expect(redis.set).toHaveBeenCalledWith(`first_fans:${MOCK_TRACK_ID}`, expect.any(String), { - EX: 90000, - }); expect(result).toHaveLength(1); }); @@ -328,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/services/fans.service.ts b/src/track/services/fans.service.ts index c28a38da..6ca08df6 100644 --- a/src/track/services/fans.service.ts +++ b/src/track/services/fans.service.ts @@ -7,8 +7,8 @@ import { REDIS_CLIENT } from '../../redis/redis.module'; import { Track } from '../entities/track.entity'; import { Settings } from '../../settings/entities/settings.entity'; -const TOP_FANS_TTL_SECS = 25 * 60 * 60; -const FIRST_FANS_TTL_SECS = 25 * 60 * 60; +const TOP_FANS_TTL_SECS = 1 * 60 * 60; // 25 hours but for testing and demonstration of the project we will set it to 1 hour +const FIRST_FANS_TTL_SECS = 1 * 60 * 60; // 25 hours but for testing and demonstration of the project we will set it to 1 hour const FIRST_FANS_LIVE_TTL_SECS = 60 * 60; // 1h cache while window is still open export interface FanResult { diff --git a/src/track/tests/track.mock.ts b/src/track/tests/track.mock.ts index c33b0a8c..84b12d92 100644 --- a/src/track/tests/track.mock.ts +++ b/src/track/tests/track.mock.ts @@ -286,9 +286,26 @@ export const mockTrackRepository = () => ({ getTrackPlaylists: jest.fn(), deleteTrack: jest.fn(), findTrackByTitleAndArtist: jest.fn(), + findByTitleAndUser: jest.fn(), getTrackTopFansIds: jest.fn(), findRelatedTracks: jest.fn(), findAllTimeStats: jest.fn(), + getTopListeners: jest.fn(), + getTopRegions: jest.fn(), + getTopPlaylistsAndAlbums: jest.fn(), + getSpotlightTracks: jest.fn(), + countSpotlight: jest.fn(), + findSpotlight: jest.fn(), + addToSpotlight: jest.fn(), + updateSpotlightTracks: jest.fn(), + 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 = () => ({ @@ -322,6 +339,11 @@ export const mockAudioQueue = () => ({ remove: jest.fn(), }); +export const mockReleaseQueue = () => ({ + add: jest.fn(), + getJob: jest.fn(), +}); + export const mockFansService = () => ({ getTopFans: jest.fn(), getFirstFans: jest.fn(), diff --git a/src/track/track.controller.spec.ts b/src/track/track.controller.spec.ts index 78cf431b..04b2f94c 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, @@ -54,6 +58,14 @@ const mockTrackService = () => ({ deleteTrack: jest.fn(), getRelatedTracks: jest.fn(), getAllTimeStats: jest.fn(), + getTopListeners: jest.fn(), + getTopRegions: jest.fn(), + getTopPlaylistsAndAlbums: jest.fn(), + updateTrackCommentSettings: jest.fn(), + getUserSpotlightTacks: jest.fn(), + addToSpotlight: jest.fn(), + updateSpotlightTracks: jest.fn(), + scheduleRelease: jest.fn(), }); jest.mock('meilisearch', () => ({ @@ -293,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({}); @@ -487,19 +499,23 @@ 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 as unknown as Express.Multer.File + ) ).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); @@ -512,13 +528,14 @@ describe('TrackController', () => { it('should delegate to service with all params', async () => { service.getUserUploadedTracks.mockResolvedValue({ status: 'success', data: [] }); - await controller.getUserUploadedTracks(MOCK_OTHER_USER_ID, MOCK_USER_ID, 1, 20); + await controller.getUserUploadedTracks(MOCK_OTHER_USER_ID, MOCK_USER_ID, 1, 20, undefined); expect(service.getUserUploadedTracks).toHaveBeenCalledWith( MOCK_OTHER_USER_ID, MOCK_USER_ID, 1, - 20 + 20, + undefined ); }); @@ -526,7 +543,7 @@ describe('TrackController', () => { service.getUserUploadedTracks.mockRejectedValue(new ForbiddenException()); await expect( - controller.getUserUploadedTracks(MOCK_OTHER_USER_ID, MOCK_USER_ID, 1, 20) + controller.getUserUploadedTracks(MOCK_OTHER_USER_ID, MOCK_USER_ID, 1, 20, undefined) ).rejects.toThrow(ForbiddenException); }); }); @@ -624,13 +641,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 ); }); @@ -643,7 +660,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], }); @@ -659,7 +676,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], }); @@ -674,7 +691,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, @@ -691,7 +713,7 @@ describe('TrackController', () => { const result = await controller.updateTrackMetadata( MOCK_TRACK_ID, MOCK_USER_ID, - {} as any, + {} as UpdateTrackDto, undefined ); @@ -702,7 +724,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); }); @@ -710,7 +732,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); }); }); @@ -741,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 as any) + controller.reuploadTrackAudio( + MOCK_TRACK_ID, + MOCK_USER_ID, + undefined as unknown as Express.Multer.File + ) ).rejects.toThrow(BadRequestException); }); @@ -769,6 +795,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', @@ -781,8 +809,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 () => { @@ -792,7 +820,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 () => { @@ -976,9 +1004,16 @@ describe('TrackController', () => { it('should delegate to service with correct args', async () => { service.getRelatedTracks.mockResolvedValue({ status: 'success', data: [] }); - await controller.getRelatedTracks(ARTIST, TITLE, 1, 10, '1.2.3.4'); + await controller.getRelatedTracks(ARTIST, TITLE, 1, 10, '1.2.3.4', undefined); - expect(service.getRelatedTracks).toHaveBeenCalledWith(TITLE, ARTIST, 1, 10, '1.2.3.4'); + expect(service.getRelatedTracks).toHaveBeenCalledWith( + TITLE, + ARTIST, + 1, + 10, + '1.2.3.4', + undefined + ); }); it('should return service response as-is', async () => { @@ -989,7 +1024,7 @@ describe('TrackController', () => { }; service.getRelatedTracks.mockResolvedValue(mockResponse); - const result = await controller.getRelatedTracks(ARTIST, TITLE, 1, 10, '1.2.3.4'); + const result = await controller.getRelatedTracks(ARTIST, TITLE, 1, 10, '1.2.3.4', undefined); expect(result).toBe(mockResponse); }); @@ -997,17 +1032,17 @@ describe('TrackController', () => { it('should propagate NotFoundException when track does not exist', async () => { service.getRelatedTracks.mockRejectedValue(new NotFoundException()); - await expect(controller.getRelatedTracks(ARTIST, TITLE, 1, 10, '1.2.3.4')).rejects.toThrow( - NotFoundException - ); + await expect( + controller.getRelatedTracks(ARTIST, TITLE, 1, 10, '1.2.3.4', undefined) + ).rejects.toThrow(NotFoundException); }); it('should propagate ForbiddenException when track is private', async () => { service.getRelatedTracks.mockRejectedValue(new ForbiddenException()); - await expect(controller.getRelatedTracks(ARTIST, TITLE, 1, 10, '1.2.3.4')).rejects.toThrow( - ForbiddenException - ); + await expect( + controller.getRelatedTracks(ARTIST, TITLE, 1, 10, '1.2.3.4', undefined) + ).rejects.toThrow(ForbiddenException); }); }); @@ -1040,4 +1075,443 @@ describe('TrackController', () => { expect(result).toBe(mockResponse); }); }); + + // ─── getTopListeners ───────────────────────────────────────────────────────── + + describe('getTopListeners', () => { + it('should delegate to service with the current userId', async () => { + service.getTopListeners.mockResolvedValue({ status: 'success', data: [] }); + + await controller.getTopListeners(MOCK_USER_ID); + + expect(service.getTopListeners).toHaveBeenCalledWith(MOCK_USER_ID); + }); + + it('should return service response as-is', async () => { + const mockResponse = { + status: 'success', + data: [ + { + userId: MOCK_MY_USER_ID, + username: 'superfan1', + displayName: 'Super Fan', + avatarUrl: 'https://example.com/avatar.jpg', + followersCount: 200, + playCount: 42, + }, + ], + }; + service.getTopListeners.mockResolvedValue(mockResponse); + + const result = await controller.getTopListeners(MOCK_USER_ID); + + expect(result).toBe(mockResponse); + }); + }); + + // ─── getTopRegions ──────────────────────────────────────────────────────────── + + describe('getTopRegions', () => { + it('should delegate to service with the current userId', async () => { + service.getTopRegions.mockResolvedValue({ status: 'success', data: [] }); + + await controller.getTopRegions(MOCK_USER_ID); + + expect(service.getTopRegions).toHaveBeenCalledWith(MOCK_USER_ID); + }); + + it('should return service response as-is', async () => { + const mockResponse = { + status: 'success', + data: [ + { country: 'EG', playCount: 120 }, + { country: 'US', playCount: 85 }, + ], + }; + service.getTopRegions.mockResolvedValue(mockResponse); + + const result = await controller.getTopRegions(MOCK_USER_ID); + + expect(result).toBe(mockResponse); + }); + }); + + // ─── getTopPlaylistsAndAlbums ───────────────────────────────────────────────── + + describe('getTopPlaylistsAndAlbums', () => { + it('should delegate to service with the current userId', async () => { + service.getTopPlaylistsAndAlbums.mockResolvedValue({ status: 'success', data: [] }); + + await controller.getTopPlaylistsAndAlbums(MOCK_USER_ID); + + expect(service.getTopPlaylistsAndAlbums).toHaveBeenCalledWith(MOCK_USER_ID); + }); + + it('should return service response as-is', async () => { + const mockResponse = { + status: 'success', + data: [ + { + playlistId: '770e8400-e29b-41d4-a716-446655440020', + title: 'Late Night Vibes', + coverImage: 'https://s3.amazonaws.com/covers/playlist.jpg', + trackCount: 14, + likesCount: 200, + repostsCount: 30, + ownerId: MOCK_MY_USER_ID, + type: 'playlist', + playCount: 560, + }, + ], + }; + service.getTopPlaylistsAndAlbums.mockResolvedValue(mockResponse); + + const result = await controller.getTopPlaylistsAndAlbums(MOCK_USER_ID); + + expect(result).toBe(mockResponse); + }); + }); + + // ─── getsSpotlightTracks ────────────────────────────────────────────────────── + + describe('getsSpotlightTracks', () => { + it('should delegate to service with userId and ip', async () => { + service.getUserSpotlightTacks.mockResolvedValue([]); + await controller.getsSpotlightTracks(MOCK_USER_ID, '1.2.3.4'); + expect(service.getUserSpotlightTacks).toHaveBeenCalledWith(MOCK_USER_ID, '1.2.3.4'); + }); + + it('should return service response as-is', async () => { + const mockTracks = [{ trackId: MOCK_TRACK_ID, title: 'My Song' }]; + service.getUserSpotlightTacks.mockResolvedValue(mockTracks); + expect(await controller.getsSpotlightTracks(MOCK_USER_ID, '1.2.3.4')).toBe(mockTracks); + }); + + it('should propagate BadRequestException when user not found', async () => { + service.getUserSpotlightTacks.mockRejectedValue(new BadRequestException('user is not found')); + await expect(controller.getsSpotlightTracks(MOCK_USER_ID, '1.2.3.4')).rejects.toThrow( + BadRequestException + ); + }); + }); + + // ─── addToSpotlight ─────────────────────────────────────────────────────────── + + describe('addToSpotlight', () => { + it('should delegate to service with userId and trackId', async () => { + service.addToSpotlight.mockResolvedValue({ status: 'success' }); + await controller.addToSpotlight(MOCK_USER_ID, MOCK_TRACK_ID); + expect(service.addToSpotlight).toHaveBeenCalledWith(MOCK_USER_ID, MOCK_TRACK_ID); + }); + + it('should return service response as-is', async () => { + const mockResponse = { + status: 'success', + messsage: 'track is successfully added to spotlight', + }; + service.addToSpotlight.mockResolvedValue(mockResponse); + expect(await controller.addToSpotlight(MOCK_USER_ID, MOCK_TRACK_ID)).toBe(mockResponse); + }); + + it('should propagate BadRequestException when track not found', async () => { + service.addToSpotlight.mockRejectedValue(new BadRequestException('track not found')); + await expect(controller.addToSpotlight(MOCK_USER_ID, MOCK_TRACK_ID)).rejects.toThrow( + BadRequestException + ); + }); + + it('should propagate ForbiddenException when spotlight limit reached', async () => { + service.addToSpotlight.mockRejectedValue(new ForbiddenException()); + await expect(controller.addToSpotlight(MOCK_USER_ID, MOCK_TRACK_ID)).rejects.toThrow( + ForbiddenException + ); + }); + }); + + // ─── updateSpotlightTracks ──────────────────────────────────────────────────── + + describe('updateSpotlightTracks', () => { + const body = { trackIds: [MOCK_TRACK_ID] }; + + it('should delegate to service with userId and trackIds', async () => { + service.updateSpotlightTracks.mockResolvedValue(undefined); + await controller.updateSpotlightTracks(MOCK_USER_ID, body); + expect(service.updateSpotlightTracks).toHaveBeenCalledWith(MOCK_USER_ID, body.trackIds); + }); + + it('should propagate BadRequestException when too many trackIds', async () => { + service.updateSpotlightTracks.mockRejectedValue( + new BadRequestException('Maximum 5 tracks allowed in spotlight') + ); + await expect(controller.updateSpotlightTracks(MOCK_USER_ID, body)).rejects.toThrow( + BadRequestException + ); + }); + }); + + // ─── 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.controller.ts b/src/track/track.controller.ts index 2bec1b6c..8e7af206 100644 --- a/src/track/track.controller.ts +++ b/src/track/track.controller.ts @@ -54,6 +54,13 @@ import { ApiGetRelatedTracks, ApiGetAllTimeStats, ApiUpdateCommentsSettings, + ApiGetTopListeners, + ApiGetTopRegions, + ApiGetTopPlaylistsAndAlbums, + ApiUpdateSpotlight, + ApiGetSpotlightTracks, + ApiAddToSpotlight, + ApiScheduleTrackRelease, } from './track.swagger'; import { CurrentUser } from '../authentication/decorators/current-user.decorator'; import { CheckBlock } from '../followers/decorators/no-block.decorator'; @@ -66,13 +73,18 @@ import { Public } from '../authentication/decorators/public.decorator'; import { OptionalCurrentUser } from '../authentication/decorators/optional-current-user.decorator'; import { JwtPayload } from '../authentication/strategies/jwt.strategy'; import { BlockedRegionsDto } from './dto/blocked-regions.dto'; +import { ScheduleReleaseDto } from './dto/schedule-release.dto'; import { Plans } from '../authentication/decorators/plans.decorator'; import { PlaylistTypeFilter } from '../playlist/entities/playlist.entity'; const ALLOWED_AUDIO_MIME_TYPES = [ 'audio/mpeg', 'audio/wav', + 'audio/x-wav', + 'audio/wave', + 'audio/vnd.wave', 'audio/flac', + 'audio/x-flac', 'audio/aiff', 'audio/x-aiff', ]; @@ -392,9 +404,10 @@ export class TrackController { @Param('userId', ParseUUIDPipe) userId: string, @CurrentUser('sub') currentUserId: string, @Query('page') page: number, - @Query('limit') limit: number + @Query('limit') limit: number, + @CurrentUser('plan') plan?: string ) { - return this.trackService.getUserUploadedTracks(userId, currentUserId, page, limit); + return this.trackService.getUserUploadedTracks(userId, currentUserId, page, limit, plan); } @ApiGetTrackPlaylists() @@ -470,9 +483,10 @@ export class TrackController { @Param('title') title: string, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, - @Ip() ip: string + @Ip() ip: string, + @CurrentUser('plan') plan?: string ) { - return this.trackService.getRelatedTracks(title, artistUsername, page, limit, ip); + return this.trackService.getRelatedTracks(title, artistUsername, page, limit, ip, plan); } @ApiUpdateCommentsSettings() @@ -490,4 +504,60 @@ export class TrackController { showComments ); } + + @ApiGetTopListeners() + @Plans('pro') + @Get('insights/top-listeners') + getTopListeners(@CurrentUser('sub') userId: string) { + return this.trackService.getTopListeners(userId); + } + + @ApiGetTopRegions() + @Plans('pro') + @Get('insights/top-regions') + getTopRegions(@CurrentUser('sub') userId: string) { + return this.trackService.getTopRegions(userId); + } + + @ApiGetTopPlaylistsAndAlbums() + @Plans('pro') + @Get('insights/top-playlists-albums') + getTopPlaylistsAndAlbums(@CurrentUser('sub') userId: string) { + return this.trackService.getTopPlaylistsAndAlbums(userId); + } + + @ApiScheduleTrackRelease() + @Plans('pro') + @Post(':trackId/schedule') + scheduleRelease( + @CurrentUser('sub') userId: string, + @Param('trackId', ParseUUIDPipe) trackId: string, + @Body() dto: ScheduleReleaseDto + ) { + return this.trackService.scheduleRelease(userId, trackId, new Date(dto.scheduledAt)); + } + + @ApiGetSpotlightTracks() + @CheckBlock() + @Get('/spotlight/:userId') + getsSpotlightTracks(@Param('userId') userId: string, @Ip() ip: string) { + return this.trackService.getUserSpotlightTacks(userId, ip); + } + + @ApiAddToSpotlight() + @Plans('pro') + @Post('/spotlight/:trackId') + addToSpotlight( + @CurrentUser('sub') userId: string, + @Param('trackId') trackId: string // was 'trackid', must be 'trackId' + ) { + return this.trackService.addToSpotlight(userId, trackId); + } + + @ApiUpdateSpotlight() + @Plans('pro') + @Patch('/spotlight') + updateSpotlightTracks(@CurrentUser('sub') userId: string, @Body() body: { trackIds: string[] }) { + return this.trackService.updateSpotlightTracks(userId, body.trackIds); + } } diff --git a/src/track/track.module.ts b/src/track/track.module.ts index 0e7e7e88..90c3ad75 100644 --- a/src/track/track.module.ts +++ b/src/track/track.module.ts @@ -26,7 +26,10 @@ import { FansService } from './services/fans.service'; import { FansJobProcessor } from './listeners/fans-job.processor'; import { ActivityModule } from '../activity/activity.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { SpotlightTrack } from './entities/spotlght-track.entity'; +import { DownloadedTrack } from '../download/entities/downloaded-tracks.entity'; import { FfmpegService } from '../audio/ffmpeg.service'; +import { ReleaseJobProcessor } from './listeners/release-job.processor'; @Module({ imports: [ @@ -40,6 +43,8 @@ import { FfmpegService } from '../audio/ffmpeg.service'; RecentlyPlayed, TrackFirstFan, Settings, + DownloadedTrack, + SpotlightTrack, ]), PlaylistModule, NotificationsModule, @@ -61,6 +66,7 @@ import { FfmpegService } from '../audio/ffmpeg.service'; }), BullModule.registerQueue({ name: 'audioQueue' }), BullModule.registerQueue({ name: 'fansQueue' }), + BullModule.registerQueue({ name: 'releaseQueue' }), UserModule, FollowersModule, ActivityModule, @@ -76,6 +82,7 @@ import { FfmpegService } from '../audio/ffmpeg.service'; FanRepository, FansService, FansJobProcessor, + ReleaseJobProcessor, FfmpegService, ], exports: [TrackService, TrackRepository], diff --git a/src/track/track.repository.ts b/src/track/track.repository.ts index 578915db..95f30b53 100644 --- a/src/track/track.repository.ts +++ b/src/track/track.repository.ts @@ -14,6 +14,10 @@ import { TrackVisibility } from './enums/track-visibility.enum'; import { TrackPlay } from './entities/track-play.entity'; import { RecentlyPlayed, RecentlyPlayedItemType } from './entities/recently-played.entity'; import { mapTrack, addDocuments, updateDocument, deleteDocument } from '../search/indexing'; +import { DownloadedTrack } from '../download/entities/downloaded-tracks.entity'; +import { ReportType } from '../admin/report-enums'; +import { PlaylistType } from '../playlist/entities/playlist.entity'; +import { SpotlightTrack } from './entities/spotlght-track.entity'; const RECENTLY_PLAYED_LIMIT = 6; @@ -39,7 +43,12 @@ export class TrackRepository { private readonly trackPlayRepository: Repository, @InjectRepository(RecentlyPlayed) - private readonly recentlyPlayedRepository: Repository + private readonly recentlyPlayedRepository: Repository, + + @InjectRepository(DownloadedTrack) + private readonly downloadedTrackRepository: Repository, + @InjectRepository(SpotlightTrack) + private readonly spotlightTracksRepository: Repository ) {} async findById(trackId: string): Promise { @@ -48,6 +57,21 @@ export class TrackRepository { }); } + async findByTitleAndUser( + title: string, + userId: string, + excludeTrackId?: string + ): Promise { + const qb = this.trackRepository + .createQueryBuilder('track') + .where('LOWER(track.title) = LOWER(:title)', { title }) + .andWhere('track.userId = :userId', { userId }); + if (excludeTrackId) { + qb.andWhere('track.trackId != :excludeTrackId', { excludeTrackId }); + } + return qb.getOne(); + } + async findByIdWithRelations(trackId: string): Promise { return this.trackRepository .createQueryBuilder('track') @@ -462,7 +486,7 @@ export class TrackRepository { async getUserUploadedSeconds(userId: string): Promise { const sum = await this.trackRepository.sum('durationSeconds', { userId, - trackStatus: TrackStatus.FINISHED, + trackStatus: In([TrackStatus.FINISHED, TrackStatus.SCHEDULED]), }); return sum ?? 0; } @@ -478,10 +502,15 @@ export class TrackRepository { const query = this.trackRepository .createQueryBuilder('track') - .where('track.userId = :userId', { userId }) - .andWhere('track.trackStatus = :status', { status: TrackStatus.FINISHED }); + .where('track.userId = :userId', { userId }); - if (!isOwner) { + if (isOwner) { + // Owner sees finished + their own scheduled tracks + query.andWhere('track.trackStatus IN (:...statuses)', { + statuses: [TrackStatus.FINISHED, TrackStatus.SCHEDULED], + }); + } else { + query.andWhere('track.trackStatus = :status', { status: TrackStatus.FINISHED }); query.andWhere('track.visibility = :visibility', { visibility: TrackVisibility.PUBLIC }); } @@ -541,6 +570,7 @@ export class TrackRepository { .leftJoinAndSelect('track.user', 'user') .leftJoinAndSelect('track.genre', 'genre') .where('track.trackId IN (:...relatedTrackIds)', { relatedTrackIds }) + .andWhere('track.trackStatus = :status', { status: TrackStatus.FINISHED }) .getMany(); } @@ -551,7 +581,15 @@ export class TrackRepository { totalLikes: number; totalComments: number; }> { - const totalDownloads = 0; // TODO: add download count to track entity in module 12 + const { totalDownloads } = await this.downloadedTrackRepository + .createQueryBuilder('download') + .innerJoin('download.track', 'track') + .where('track.userId = :userId', { userId }) + .select('COUNT(DISTINCT(download.userId, download.trackId))', 'totalDownloads') + .getRawOne(); + + console.log('Total Downloads Raw Result:', totalDownloads); + const { totalPlays, totalReposts, totalLikes, totalComments } = await this.trackRepository .createQueryBuilder('track') .select('SUM(track.playCount)', 'totalPlays') @@ -587,6 +625,7 @@ export class TrackRepository { 'track.coverImage', 'track.audioUrl', 'track.waveformUrl', + 'track.audioUrlHq', 'track.durationSeconds', 'track.userId', 'track.createdAt', @@ -647,7 +686,7 @@ export class TrackRepository { .leftJoinAndSelect('track.user', 'trackUser') .where('user.username = :username', { username }) .andWhere('track.visibility = :visibility', { visibility: TrackVisibility.PUBLIC }) - .andWhere('track.hidden = false') + .andWhere('track.trackStatus = :status', { status: TrackStatus.FINISHED }) .getMany(); } @@ -658,7 +697,7 @@ export class TrackRepository { .leftJoinAndSelect('track.user', 'trackUser') .where('user.username = :username', { username }) .andWhere('track.visibility = :visibility', { visibility: TrackVisibility.PUBLIC }) - .andWhere('track.hidden = false') + .andWhere('track.trackStatus = :status', { status: TrackStatus.FINISHED }) .orderBy('track.playCount', 'DESC') .getOne(); } @@ -766,6 +805,7 @@ export class TrackRepository { }) ) .andWhere('track.visibility = :visibility', { visibility: TrackVisibility.PUBLIC }) + .andWhere('track.trackStatus = :status', { status: TrackStatus.FINISHED }) .andWhere('track.hidden = false') .setParameter('tagIds', tagIds) .select([ @@ -773,6 +813,7 @@ export class TrackRepository { 'track.title', 'track.coverImage', 'track.audioUrl', + 'track.audioUrlHq', 'track.waveformUrl', 'track.durationSeconds', 'track.userId', @@ -846,6 +887,7 @@ export class TrackRepository { .leftJoinAndSelect('track.user', 'user') .leftJoinAndSelect('track.genre', 'genre') .where('track.visibility = :visibility', { visibility: TrackVisibility.PUBLIC }) + .andWhere('track.trackStatus = :status', { status: TrackStatus.FINISHED }) .andWhere('track.hidden = false') .andWhere('track.releaseDate <= :now', { now: new Date() }) .orderBy('track.createdAt', 'DESC') @@ -890,7 +932,6 @@ export class TrackRepository { } async findTrendingTracks(): Promise { - // top 5 played tracks in the last month const oneMonthAgo = new Date(); oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); @@ -898,6 +939,7 @@ export class TrackRepository { .createQueryBuilder('track') .leftJoinAndSelect('track.user', 'user') .where('track.createdAt > :oneMonthAgo', { oneMonthAgo }) + .andWhere('track.trackStatus = :status', { status: TrackStatus.FINISHED }) .orderBy('track.playCount', 'DESC') .take(5) .getMany(); @@ -905,27 +947,245 @@ export class TrackRepository { return tracks; } - async isTrackDownloadable(trackId: string): Promise { - const track = await this.trackRepository.findOne({ - where: { trackId }, - select: ['offlineListening'], + async updateTrackCommentSettings(trackId: string, allowComments: boolean, showComments: boolean) { + await this.trackRepository.update(trackId, { + allowComments, + showComments, }); + } - return track?.offlineListening ?? false; + async getDownloadedTracksByUser( + userId: string, + page: number = 1, + limit: number = 10 + ): Promise<[Track[], number]> { + return this.trackRepository + .createQueryBuilder('track') + .innerJoin('track.downloads', 'download') + .leftJoinAndSelect('track.user', 'artist') + .leftJoinAndSelect('track.genre', 'genre') + .addSelect('download.downloadedAt') + .where('download.userId = :userId', { userId }) + .orderBy('download.downloadedAt', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); } - async isPrivate(trackId: string): Promise { - const track = await this.trackRepository.findOne({ - where: { trackId }, - select: ['visibility'], + async findAllTracksWithReportCount(page: number, limit: number, status?: TrackStatus) { + const qb = this.trackRepository + .createQueryBuilder('track') + .addSelect( + (subQb) => + subQb + .select('COUNT(r.report_id)', 'reportsCount') + .from('reports', 'r') + .where('r.target_id = track.track_id') + .andWhere('r.type = :type', { type: ReportType.TRACK }), + 'reportsCount' + ) + .skip((page - 1) * limit) + .take(limit) + .orderBy('track.createdAt', 'DESC'); + + if (status) { + qb.andWhere('track.trackStatus = :status', { status }); + } + + const { entities, raw } = await qb.getRawAndEntities(); + + const total = await this.trackRepository.count({ + where: status ? { trackStatus: status } : {}, }); - return track?.visibility === TrackVisibility.PRIVATE; + + const tracks = entities.map((track, index) => ({ + ...track, + reportsCount: parseInt(raw[index]?.reportsCount ?? '0', 10), + })); + + return { tracks, total }; } - async updateTrackCommentSettings(trackId: string, allowComments: boolean, showComments: boolean) { + async getTopListeners(userId: string) { + const oneMonthAgo = new Date(); + oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); + + const rawResults = await this.trackPlayRepository + .createQueryBuilder('play') + .innerJoin('play.track', 'track') + .innerJoin('track.user', 'artist') + .innerJoin('play.user', 'user') + .select([ + 'user.userId AS "userId"', + 'user.username AS "username"', + 'user.displayName AS "displayName"', + 'user.avatarUrl AS "avatarUrl"', + 'user.followersCount AS "followersCount"', + 'COUNT(play.trackPlayId) AS "playCount"', + ]) + .where('play.playedAt > :oneMonthAgo', { oneMonthAgo }) + .andWhere('artist.userId = :userId', { userId }) + .groupBy('user.userId') + .addGroupBy('user.username') + .addGroupBy('user.displayName') + .addGroupBy('user.avatarUrl') + .addGroupBy('user.followersCount') + .orderBy('"playCount"', 'DESC') + .limit(10) + .getRawMany(); + + return rawResults.map((result) => ({ + userId: result.userId, + username: result.username, + displayName: result.displayName, + avatarUrl: result.avatarUrl, + followersCount: Number(result.followersCount), + playCount: Number(result.playCount), + })); + } + + async getTopRegions(userId: string) { + const oneMonthAgo = new Date(); + oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); + + const rawResults = await this.trackPlayRepository + .createQueryBuilder('play') + .innerJoin('play.track', 'track') + .innerJoin('track.user', 'artist') + .innerJoin('play.user', 'user') + .select(['user.country AS "country"', 'COUNT(play.trackPlayId) AS "playCount"']) + .where('play.playedAt > :oneMonthAgo', { oneMonthAgo }) + .andWhere('artist.userId = :userId', { userId }) + .andWhere('user.country IS NOT NULL') + .groupBy('user.country') + .orderBy('"playCount"', 'DESC') + .limit(10) + .getRawMany(); + + return rawResults.map((result) => ({ + country: result.country, + playCount: Number(result.playCount), + })); + } + + async getTopPlaylistsAndAlbums(userId: string) { + const oneMonthAgo = new Date(); + oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); + + const rawResults = await this.trackPlayRepository + .createQueryBuilder('play') + .innerJoin('play.track', 'track') + .innerJoin('track.user', 'artist') + .innerJoin('play.playlist', 'playlist') + .select([ + 'playlist.playlistId AS "playlistId"', + 'playlist.title AS "title"', + 'playlist.coverImage AS "coverImage"', + 'playlist.tracksCount AS "trackCount"', + 'playlist.likesCount AS "likesCount"', + 'playlist.repostsCount AS "repostsCount"', + 'playlist.userId AS "ownerId"', + 'playlist.isPublic AS "isPublic"', + 'playlist.type AS "type"', + 'COUNT(play.trackPlayId) AS "playCount"', + ]) + .where('play.playedAt > :oneMonthAgo', { oneMonthAgo }) + .andWhere('artist.userId = :userId', { userId }) + .andWhere('playlist.isPublic = true') + .andWhere('playlist.type != :playlistType ', { playlistType: PlaylistType.STATION }) + .groupBy('playlist.playlistId') + .addGroupBy('playlist.title') + .addGroupBy('playlist.coverImage') + .addGroupBy('playlist.tracksCount') + .addGroupBy('playlist.likesCount') + .addGroupBy('playlist.repostsCount') + .addGroupBy('playlist.userId') + .addGroupBy('playlist.isPublic') + .addGroupBy('playlist.type') + .orderBy('"playCount"', 'DESC') + .limit(10) + .getRawMany(); + + return rawResults.map((result) => ({ + playlistId: result.playlistId, + title: result.title, + coverImage: result.coverImage, + trackCount: Number(result.trackCount), + likesCount: Number(result.likesCount), + repostsCount: Number(result.repostsCount), + ownerId: result.ownerId, + type: result.type, + playCount: Number(result.playCount), + })); + } + + async setScheduledAt(trackId: string, scheduledAt: Date): Promise { + await this.trackRepository.update(trackId, { scheduledAt }); + } + + async scheduleTrackRelease( + trackId: string, + scheduledAt: Date, + removeFromSearch = true + ): Promise { await this.trackRepository.update(trackId, { - allowComments, - showComments, + scheduledAt, + trackStatus: TrackStatus.SCHEDULED, + }); + if (removeFromSearch) { + await deleteDocument(`track_${trackId}`); + } + } + + async releaseTrack(trackId: string): Promise { + await this.trackRepository.update(trackId, { + trackStatus: TrackStatus.FINISHED, + scheduledAt: null, }); + const track = await this.findByIdWithRelations(trackId); + if (track && track.visibility === TrackVisibility.PUBLIC) { + await addDocuments([mapTrack(track)]); + } + return track; + } + + async getSpotlightTracks(userId: string) { + const spotlights = await this.spotlightTracksRepository.find({ + where: { userId }, + relations: ['track', 'track.genre', 'track.tags'], + }); + + return spotlights.map((s) => s.track); + } + + async countSpotlight(userId: string) { + return this.spotlightTracksRepository.count({ where: { userId } }); + } + + async findSpotlight(userId: string, trackId: string) { + return this.spotlightTracksRepository.findOne({ + where: { userId, trackId }, + }); + } + + async addToSpotlight(userId: string, trackId: string) { + const spotlight = this.spotlightTracksRepository.create({ userId, trackId }); + return this.spotlightTracksRepository.save(spotlight); + } + + async updateSpotlightTracks(userId: string, trackIds: string[]): Promise { + await this.spotlightTracksRepository.delete({ userId }); + + if (trackIds.length === 0) return; + + const spotlights = trackIds.map((trackId) => + this.spotlightTracksRepository.create({ userId, trackId }) + ); + + await this.spotlightTracksRepository.save(spotlights); + } + + async updateHiddenStatus(trackId: string, hidden: boolean): Promise { + await this.trackRepository.update(trackId, { hidden }); } } diff --git a/src/track/track.service.spec.ts b/src/track/track.service.spec.ts index 53d9dbfd..d909a057 100644 --- a/src/track/track.service.spec.ts +++ b/src/track/track.service.spec.ts @@ -57,6 +57,7 @@ import { mockRedisClient, mockActivitiesService, mockFfmpegService, + mockReleaseQueue, } from './tests/track.mock'; import { REDIS_CLIENT } from '../redis/redis.module'; import { ActivityService } from '../activity/activity.service'; @@ -83,6 +84,7 @@ describe('TrackService', () => { let playlistService: ReturnType; let redisClient: ReturnType; let ffmpegService: ReturnType; + let releaseQueue: ReturnType; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -94,6 +96,7 @@ describe('TrackService', () => { { provide: TrackSseService, useValue: {} }, { provide: StorageService, useFactory: mockStorageService }, { provide: getQueueToken('audioQueue'), useFactory: mockAudioQueue }, + { provide: getQueueToken('releaseQueue'), useFactory: mockReleaseQueue }, { provide: FansService, useFactory: mockFansService }, { provide: PlaylistService, useFactory: mockPlaylistService }, { provide: REDIS_CLIENT, useFactory: mockRedisClient }, @@ -117,6 +120,7 @@ describe('TrackService', () => { playlistService = module.get(PlaylistService); redisClient = module.get(REDIS_CLIENT); ffmpegService = module.get(FfmpegService); + releaseQueue = module.get(getQueueToken('releaseQueue')); }); afterEach(() => jest.clearAllMocks()); @@ -1411,9 +1415,26 @@ describe('TrackService', () => { ); }); - it('should return message and processing trackStatus', async () => { + it('should throw ConflictException when title already exists for this user', async () => { + ffmpegService.getDuration.mockResolvedValue(120); userRepo.findById.mockResolvedValue(mockPublicUser()); - ffmpegService.getDuration.mockResolvedValue(213); + trackRepo.getUserUploadedSeconds.mockResolvedValue(0); + (trackRepo as any).findByTitleAndUser.mockResolvedValue( + mockOwnTrack({ title: 'Midnight Drive' }) + ); + + await expect(service.uploadTrack(MOCK_USER_ID, dto as any, mockAudioFile)).rejects.toThrow( + ConflictException + ); + + expect((trackRepo as any).createTrack).not.toHaveBeenCalled(); + }); + + it('should allow upload when title does not exist for this user', async () => { + userRepo.findById.mockResolvedValue(mockPublicUser()); + ffmpegService.getDuration.mockResolvedValue(120); + trackRepo.getUserUploadedSeconds.mockResolvedValue(0); + (trackRepo as any).findByTitleAndUser.mockResolvedValue(null); const savedTrack = { trackId: MOCK_TRACK_ID, title: 'Midnight Drive', @@ -1425,8 +1446,7 @@ describe('TrackService', () => { const result = await service.uploadTrack(MOCK_USER_ID, dto as any, mockAudioFile); - expect(result.message).toBe('Track upload started. Processing in background.'); - expect(result.data.trackStatus).toBe('processing'); + expect(result.status).toBe('success'); }); }); @@ -1521,6 +1541,32 @@ describe('TrackService', () => { expect.any(Object) ); }); + + it('should throw ConflictException when new title already exists for this user', async () => { + trackRepo.findById.mockResolvedValue(mockOwnTrack({ title: 'Old Title' })); + (trackRepo as any).findByTitleAndUser.mockResolvedValue( + mockOwnTrack({ title: 'Taken Title' }) + ); + + await expect( + service.updateTrackMetadata(MOCK_TRACK_ID, MOCK_USER_ID, { title: 'Taken Title' } as any) + ).rejects.toThrow(ConflictException); + + expect(trackRepo.updateTrack).not.toHaveBeenCalled(); + }); + + it('should skip title uniqueness check when title is unchanged', async () => { + const ownTrack = mockOwnTrack({ title: 'Same Title', trackStatus: 'finished' }); + trackRepo.findById.mockResolvedValue(ownTrack); + trackRepo.updateTrack.mockResolvedValue({ ...ownTrack, previewStartTime: null }); + + const result = await service.updateTrackMetadata(MOCK_TRACK_ID, MOCK_USER_ID, { + title: 'Same Title', + } as any); + + expect((trackRepo as any).findByTitleAndUser).not.toHaveBeenCalled(); + expect(result.status).toBe('success'); + }); }); // ─── reuploadTrackAudio ─────────────────────────────────────────────────────── @@ -1743,21 +1789,20 @@ describe('TrackService', () => { expect(trackRepo.deleteTrack).toHaveBeenCalledWith(MOCK_TRACK_ID); expect(result).toEqual({ status: 'success', message: 'Track deleted successfully' }); }); - - it('should throw NotFoundException when track does not exist', async () => { - trackRepo.findById.mockResolvedValue(null); + it('should throw ForbiddenException when user does not own the track', async () => { + trackRepo.findById.mockResolvedValue(mockPublicTrack({ userId: MOCK_OTHER_USER_ID })); + userRepo.findById.mockResolvedValue({ userId: MOCK_USER_ID, role: 'user' }); // 👈 add this await expect(service.deleteTrack(MOCK_TRACK_ID, MOCK_USER_ID)).rejects.toThrow( - NotFoundException + ForbiddenException ); expect(trackRepo.deleteTrack).not.toHaveBeenCalled(); }); - - it('should throw ForbiddenException when user does not own the track', async () => { - trackRepo.findById.mockResolvedValue(mockPublicTrack({ userId: MOCK_OTHER_USER_ID })); + it('should throw NotFoundException when track does not exist', async () => { + trackRepo.findById.mockResolvedValue(null); await expect(service.deleteTrack(MOCK_TRACK_ID, MOCK_USER_ID)).rejects.toThrow( - ForbiddenException + NotFoundException ); expect(trackRepo.deleteTrack).not.toHaveBeenCalled(); }); @@ -1973,4 +2018,505 @@ describe('TrackService', () => { expect(result.data).toEqual(emptyStats); }); }); + + // ─── getTopListeners ───────────────────────────────────────────────────────── + + describe('getTopListeners', () => { + const mockListeners = [ + { + userId: MOCK_MY_USER_ID, + username: 'superfan1', + displayName: 'Super Fan', + avatarUrl: 'https://example.com/avatar.jpg', + followersCount: 200, + playCount: 42, + }, + { + userId: MOCK_OTHER_USER_ID, + username: 'superfan2', + displayName: 'Super Fan 2', + avatarUrl: 'https://example.com/avatar2.jpg', + followersCount: 150, + playCount: 30, + }, + ]; + + it('should return top listeners wrapped in a success envelope', async () => { + trackRepo.getTopListeners.mockResolvedValue(mockListeners); + + const result = await service.getTopListeners(MOCK_USER_ID); + + expect(trackRepo.getTopListeners).toHaveBeenCalledWith(MOCK_USER_ID); + expect(result).toEqual({ status: 'success', data: mockListeners }); + }); + + it('should return empty data array when no plays exist in the last month', async () => { + trackRepo.getTopListeners.mockResolvedValue([]); + + const result = await service.getTopListeners(MOCK_USER_ID); + + expect(result).toEqual({ status: 'success', data: [] }); + }); + + it('should return listeners ordered by playCount descending (as returned by repo)', async () => { + trackRepo.getTopListeners.mockResolvedValue(mockListeners); + + const result = await service.getTopListeners(MOCK_USER_ID); + + expect(result.data[0].playCount).toBeGreaterThanOrEqual(result.data[1].playCount); + }); + }); + + // ─── getTopRegions ──────────────────────────────────────────────────────────── + + describe('getTopRegions', () => { + const mockRegions = [ + { country: 'EG', playCount: 120 }, + { country: 'US', playCount: 85 }, + { country: 'GB', playCount: 40 }, + ]; + + it('should return top regions wrapped in a success envelope', async () => { + trackRepo.getTopRegions.mockResolvedValue(mockRegions); + + const result = await service.getTopRegions(MOCK_USER_ID); + + expect(trackRepo.getTopRegions).toHaveBeenCalledWith(MOCK_USER_ID); + expect(result).toEqual({ status: 'success', data: mockRegions }); + }); + + it('should return empty data array when no plays with country data exist', async () => { + trackRepo.getTopRegions.mockResolvedValue([]); + + const result = await service.getTopRegions(MOCK_USER_ID); + + expect(result).toEqual({ status: 'success', data: [] }); + }); + + it('should return regions ordered by playCount descending (as returned by repo)', async () => { + trackRepo.getTopRegions.mockResolvedValue(mockRegions); + + const result = await service.getTopRegions(MOCK_USER_ID); + + expect(result.data[0].playCount).toBeGreaterThanOrEqual(result.data[1].playCount); + }); + }); + + // ─── getTopPlaylistsAndAlbums ───────────────────────────────────────────────── + + describe('getTopPlaylistsAndAlbums', () => { + const mockPlaylists = [ + { + playlistId: '770e8400-e29b-41d4-a716-446655440020', + title: 'Late Night Vibes', + coverImage: 'https://s3.amazonaws.com/covers/playlist.jpg', + trackCount: 14, + likesCount: 200, + repostsCount: 30, + ownerId: MOCK_MY_USER_ID, + type: 'playlist', + playCount: 560, + }, + { + playlistId: '770e8400-e29b-41d4-a716-446655440021', + title: 'Debut Album', + coverImage: 'https://s3.amazonaws.com/covers/album.jpg', + trackCount: 10, + likesCount: 150, + repostsCount: 20, + ownerId: MOCK_MY_USER_ID, + type: 'album', + playCount: 310, + }, + ]; + + it('should return top playlists and albums wrapped in a success envelope', async () => { + trackRepo.getTopPlaylistsAndAlbums.mockResolvedValue(mockPlaylists); + + const result = await service.getTopPlaylistsAndAlbums(MOCK_USER_ID); + + expect(trackRepo.getTopPlaylistsAndAlbums).toHaveBeenCalledWith(MOCK_USER_ID); + expect(result).toEqual({ status: 'success', data: mockPlaylists }); + }); + + it('should return empty data array when no playlist plays exist in the last month', async () => { + trackRepo.getTopPlaylistsAndAlbums.mockResolvedValue([]); + + const result = await service.getTopPlaylistsAndAlbums(MOCK_USER_ID); + + expect(result).toEqual({ status: 'success', data: [] }); + }); + + it('should return playlists ordered by playCount descending (as returned by repo)', async () => { + trackRepo.getTopPlaylistsAndAlbums.mockResolvedValue(mockPlaylists); + + const result = await service.getTopPlaylistsAndAlbums(MOCK_USER_ID); + + expect(result.data[0].playCount).toBeGreaterThanOrEqual(result.data[1].playCount); + }); + + it('should include both playlist and album types in the response', async () => { + trackRepo.getTopPlaylistsAndAlbums.mockResolvedValue(mockPlaylists); + + const result = await service.getTopPlaylistsAndAlbums(MOCK_USER_ID); + const types = result.data.map((item: { type: string }) => item.type); + + expect(types).toContain('playlist'); + expect(types).toContain('album'); + }); + }); + + // ─── getUserSpotlightTacks ──────────────────────────────────────────────────── + + describe('getUserSpotlightTacks', () => { + const MOCK_IP = '1.2.3.4'; + + beforeEach(() => { + jest.spyOn(geolocationUtil, 'getLocationFromIp').mockReturnValue({ country: 'US' } as any); + }); + + it('should throw BadRequestException if user not found', async () => { + userRepo.findById.mockResolvedValue(null); + + await expect(service.getUserSpotlightTacks(MOCK_USER_ID, MOCK_IP)).rejects.toThrow( + BadRequestException + ); + }); + + it('should return tracks as-is when none are blocked in the user region', async () => { + userRepo.findById.mockResolvedValue(mockPublicUser()); + const track = { + ...mockPublicTrack(), + blockedRegions: ['DE'], + audioUrl: 'url', + audioUrlHq: 'hq', + }; + trackRepo.getSpotlightTracks.mockResolvedValue([track]); + + const result = await service.getUserSpotlightTacks(MOCK_USER_ID, MOCK_IP); + + expect(result[0]).toHaveProperty('audioUrl'); + expect(result[0]).toHaveProperty('audioUrlHq'); + }); + + it('should strip audioUrl and audioUrlHq for tracks blocked in the user region', async () => { + userRepo.findById.mockResolvedValue(mockPublicUser()); + const track = { + ...mockPublicTrack(), + blockedRegions: ['US'], + audioUrl: 'url', + audioUrlHq: 'hq', + }; + trackRepo.getSpotlightTracks.mockResolvedValue([track]); + + const result = await service.getUserSpotlightTacks(MOCK_USER_ID, MOCK_IP); + + expect(result[0].audioUrl).toBeNull(); + expect(result[0].audioUrlHq).toBeNull(); + }); + + it('should return empty array when spotlight is empty', async () => { + userRepo.findById.mockResolvedValue(mockPublicUser()); + trackRepo.getSpotlightTracks.mockResolvedValue([]); + + const result = await service.getUserSpotlightTacks(MOCK_USER_ID, MOCK_IP); + + expect(result).toEqual([]); + }); + }); + + // ─── addToSpotlight ─────────────────────────────────────────────────────────── + + describe('addToSpotlight', () => { + it('should throw BadRequestException if track not found', async () => { + trackRepo.findById.mockResolvedValue(null); + + await expect(service.addToSpotlight(MOCK_USER_ID, MOCK_TRACK_ID)).rejects.toThrow( + BadRequestException + ); + }); + + it('should throw BadRequestException if track already in spotlight', async () => { + trackRepo.findById.mockResolvedValue(mockPublicTrack()); + trackRepo.findSpotlight.mockResolvedValue({ id: 'existing' }); + + await expect(service.addToSpotlight(MOCK_USER_ID, MOCK_TRACK_ID)).rejects.toThrow( + BadRequestException + ); + }); + + it('should throw ForbiddenException if spotlight count exceeds 5', async () => { + trackRepo.findById.mockResolvedValue(mockPublicTrack()); + trackRepo.findSpotlight.mockResolvedValue(null); + trackRepo.countSpotlight.mockResolvedValue(6); + + await expect(service.addToSpotlight(MOCK_USER_ID, MOCK_TRACK_ID)).rejects.toThrow( + ForbiddenException + ); + }); + + it('should add track to spotlight and return success message', async () => { + trackRepo.findById.mockResolvedValue(mockPublicTrack()); + trackRepo.findSpotlight.mockResolvedValue(null); + trackRepo.countSpotlight.mockResolvedValue(3); + trackRepo.addToSpotlight.mockResolvedValue(undefined); + + const result = await service.addToSpotlight(MOCK_USER_ID, MOCK_TRACK_ID); + + expect(trackRepo.addToSpotlight).toHaveBeenCalledWith(MOCK_USER_ID, MOCK_TRACK_ID); + expect(result).toMatchObject({ status: 'success' }); + }); + }); + + // ─── updateSpotlightTracks ──────────────────────────────────────────────────── + + describe('updateSpotlightTracks', () => { + it('should throw BadRequestException if more than 5 trackIds provided', async () => { + const trackIds = ['1', '2', '3', '4', '5', '6']; + + await expect(service.updateSpotlightTracks(MOCK_USER_ID, trackIds)).rejects.toThrow( + BadRequestException + ); + }); + + it('should call repository updateSpotlightTracks with correct args', async () => { + const trackIds = [MOCK_TRACK_ID]; + trackRepo.updateSpotlightTracks.mockResolvedValue(undefined); + + await service.updateSpotlightTracks(MOCK_USER_ID, trackIds); + + expect(trackRepo.updateSpotlightTracks).toHaveBeenCalledWith(MOCK_USER_ID, trackIds); + }); + }); + + // ─── scheduleRelease ────────────────────────────────────────────────────────── + + describe('scheduleRelease', () => { + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hrs from now + + it('should throw NotFoundException if track not found', async () => { + trackRepo.findById.mockResolvedValue(null); + + await expect( + service.scheduleRelease(MOCK_USER_ID, MOCK_TRACK_ID, futureDate) + ).rejects.toThrow(NotFoundException); + }); + + it('should throw ForbiddenException if user does not own the track', async () => { + trackRepo.findById.mockResolvedValue({ ...mockPublicTrack(), userId: MOCK_OTHER_USER_ID }); + + await expect( + service.scheduleRelease(MOCK_USER_ID, MOCK_TRACK_ID, futureDate) + ).rejects.toThrow(ForbiddenException); + }); + + it('should throw BadRequestException if scheduledAt is in the past', async () => { + trackRepo.findById.mockResolvedValue({ + ...mockPublicTrack(), + userId: MOCK_USER_ID, + trackStatus: TrackStatus.FINISHED, + }); + const pastDate = new Date(Date.now() - 1000); + + await expect(service.scheduleRelease(MOCK_USER_ID, MOCK_TRACK_ID, pastDate)).rejects.toThrow( + BadRequestException + ); + }); + + it('should schedule a FINISHED track and enqueue a delayed release job', async () => { + trackRepo.findById.mockResolvedValue({ + ...mockPublicTrack(), + userId: MOCK_USER_ID, + trackStatus: TrackStatus.FINISHED, + }); + trackRepo.scheduleTrackRelease.mockResolvedValue(undefined); + releaseQueue.getJob.mockResolvedValue(null); + releaseQueue.add.mockResolvedValue({}); + + const result = await service.scheduleRelease(MOCK_USER_ID, MOCK_TRACK_ID, futureDate); + + expect(trackRepo.scheduleTrackRelease).toHaveBeenCalledWith(MOCK_TRACK_ID, futureDate, true); + expect(releaseQueue.add).toHaveBeenCalledWith( + 'release-track', + { trackId: MOCK_TRACK_ID }, + expect.objectContaining({ jobId: `release-${MOCK_TRACK_ID}` }) + ); + expect(result.data.scheduledAt).toBe(futureDate); + }); + + it('should reschedule a SCHEDULED track, remove old job and enqueue new one', async () => { + const mockJob = { remove: jest.fn() }; + trackRepo.findById.mockResolvedValue({ + ...mockPublicTrack(), + userId: MOCK_USER_ID, + trackStatus: TrackStatus.SCHEDULED, + }); + trackRepo.scheduleTrackRelease.mockResolvedValue(undefined); + releaseQueue.getJob.mockResolvedValue(mockJob); + releaseQueue.add.mockResolvedValue({}); + + const result = await service.scheduleRelease(MOCK_USER_ID, MOCK_TRACK_ID, futureDate); + + expect(trackRepo.scheduleTrackRelease).toHaveBeenCalledWith(MOCK_TRACK_ID, futureDate, false); + expect(mockJob.remove).toHaveBeenCalled(); + expect(releaseQueue.add).toHaveBeenCalled(); + expect(result.message).toBe('Release schedule updated.'); + }); + + it('should throw BadRequestException if track is still PROCESSING', async () => { + trackRepo.findById.mockResolvedValue({ ...mockProcessingTrack(), userId: MOCK_USER_ID }); + + await expect( + service.scheduleRelease(MOCK_USER_ID, MOCK_TRACK_ID, futureDate) + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for FAILED track', async () => { + trackRepo.findById.mockResolvedValue({ + ...mockPublicTrack(), + userId: MOCK_USER_ID, + trackStatus: TrackStatus.FAILED, + }); + + await expect( + service.scheduleRelease(MOCK_USER_ID, MOCK_TRACK_ID, futureDate) + ).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([]); + }); + }); }); diff --git a/src/track/track.service.ts b/src/track/track.service.ts index 7dca77d0..1ad82f97 100644 --- a/src/track/track.service.ts +++ b/src/track/track.service.ts @@ -42,19 +42,21 @@ import { BlockedRegionsDto } from './dto/blocked-regions.dto'; import { GenresResDto } from './dto/get-genres-res.dto'; import { TrackTagDto } from './dto/track-tag.dto'; import { getLocationFromIp } from '../common/utilities/geolocation.util'; +import { resolveAudioUrl } from '../common/utilities/audio.util'; import { PlaylistService } from '../playlist/playlist.service'; import { RecentlyPlayedItemType } from './entities/recently-played.entity'; import { FansService } from './services/fans.service'; import { ActivityService } from '../activity/activity.service'; import { ActivityType } from '../activity/entities/activity.entity'; +import { SEED_PROTECTED_AUDIO_URLS } from '../database/seeds/seed-audio-urls.constant'; import { REDIS_CLIENT } from '../redis/redis.module'; import { Genre } from '../genre/entities/genre.entity'; import { DEFAULT_GENRE_NAMES } from '../genre/genre.constants'; import { NotificationsService } from '../notifications/notifications.service'; import { FfmpegService } from '../audio/ffmpeg.service'; -const RELATED_TRACKS_TTL_SECS = 3 * 24 * 60 * 60; // 3 days -const ALL_TIME_STATS_TTL_SECS = 24 * 60 * 60; // 1 day +const RELATED_TRACKS_TTL_SECS = 1 * 60 * 60; // 3 days but for testing we will set it to 1 hour +const ALL_TIME_STATS_TTL_SECS = 1 * 60 * 60; // 1 day but for testing and demonstration we will set it to 1 hour @Injectable() export class TrackService { @@ -69,6 +71,8 @@ export class TrackService { private readonly notificationsService: NotificationsService, @InjectQueue('audioQueue') private readonly audioQueue: Queue, + @InjectQueue('releaseQueue') + private readonly releaseQueue: Queue, @Inject(REDIS_CLIENT) private readonly redis: ReturnType, @@ -76,9 +80,7 @@ export class TrackService { ) {} private resolveAudioUrl(track: Track, user?: JwtPayload): string | null { - const isHqEligible = user?.plan === 'pro' || user?.plan === 'go+'; - if (isHqEligible && track.audioUrlHq) return track.audioUrlHq; - return track.audioUrl ?? null; + return resolveAudioUrl(track, user?.plan); } async uploadTrack( @@ -112,7 +114,16 @@ export class TrackService { throw new ForbiddenException('Insufficient quota'); } - // Step 3 — create track record in DB with PROCESSING status + // Step 3 — enforce unique title per artist + if (dto.title) { + const existing = await this.trackRepository.findByTitleAndUser(dto.title, userId); + if (existing) { + fs.unlinkSync(tempFilePath); + throw new ConflictException('You already have a track with this title'); + } + } + + // Step 4 — create track record in DB with PROCESSING status const savedTrack = await this.trackRepository.createTrack(userId, dto, coverImageUrl); // Step 4 — queue the background job; jobId === trackId for SSE keying @@ -173,6 +184,12 @@ export class TrackService { if (!track) throw new NotFoundException('Track not found'); if (track.userId !== userId) throw new ForbiddenException('You do not own this track'); + // Enforce unique title per artist when title is being changed + if (dto.title && dto.title !== track.title) { + const existing = await this.trackRepository.findByTitleAndUser(dto.title, userId, trackId); + if (existing) throw new ConflictException('You already have a track with this title'); + } + // Upload new cover and delete old one atomically let coverImageUrl: string | undefined; if (coverFile) { @@ -785,7 +802,8 @@ export class TrackService { userId: string, currentUserId: string, page: number = 1, - limit: number = 20 + limit: number = 20, + currentUserPlan?: string ) { const user = await this.userRepository.findById(userId); if (!user) throw new NotFoundException('User does not exist'); @@ -802,8 +820,10 @@ export class TrackService { cappedLimit ); - const data = plainToInstance(UserTrackResponseDto, tracks, { - excludeExtraneousValues: true, + const data = tracks.map((t) => { + const dto = plainToInstance(UserTrackResponseDto, t, { excludeExtraneousValues: true }); + dto.audioUrl = resolveAudioUrl(t, currentUserPlan); + return dto; }); return { status: 'success', ...buildPaginationResponse(data, total, page, cappedLimit) }; @@ -909,9 +929,15 @@ export class TrackService { async deleteTrack(trackId: string, userId: string) { const track = await this.trackRepository.findById(trackId); if (!track) throw new NotFoundException('Track not found'); - if (track.userId !== userId) throw new ForbiddenException('You do not own this track'); + const user = await this.userRepository.findById(userId); + if (user) { + if (track.userId !== userId && user.role !== 'admin') + throw new ForbiddenException('You do not own this track'); + } const oldUrls = track - ? [track.audioUrl, track.audioUrlHq, track.previewAudioUrl, track.waveformUrl].filter(Boolean) + ? [track.audioUrl, track.audioUrlHq, track.previewAudioUrl, track.waveformUrl].filter( + (url) => url && !SEED_PROTECTED_AUDIO_URLS.has(url) + ) : []; await Promise.allSettled(oldUrls.map((url) => this.storageService.deleteFile(url))); @@ -949,7 +975,8 @@ export class TrackService { artistUsername: string, page: number = 1, limit: number = 10, - ip?: string + ip?: string, + plan?: string ): Promise<{ status: string; data: UserTrackResponseDto[]; @@ -969,9 +996,11 @@ export class TrackService { const data = relatedTracks.map((t) => { const dto = plainToInstance(UserTrackResponseDto, t, { excludeExtraneousValues: true }); - if (country && t.blockedRegions?.includes(country)) { + const isBlocked = country && t.blockedRegions?.includes(country); + if (isBlocked) { dto.audioUrl = null; - dto.waveformUrl = null; + } else { + dto.audioUrl = resolveAudioUrl(t, plan); } dto.genreName = t.genre?.name ?? null; dto.artistId = t.user.userId; @@ -1043,4 +1072,120 @@ export class TrackService { message: 'track comment settings updated', }; } + + async getTopListeners(userId: string) { + const data = await this.trackRepository.getTopListeners(userId); + return { + status: 'success', + data, + }; + } + + async getTopRegions(userId: string) { + const data = await this.trackRepository.getTopRegions(userId); + return { + status: 'success', + data, + }; + } + + async getTopPlaylistsAndAlbums(userId: string) { + const data = await this.trackRepository.getTopPlaylistsAndAlbums(userId); + return { + status: 'success', + data, + }; + } + + async scheduleRelease(userId: string, trackId: string, scheduledAt: Date) { + const track = await this.trackRepository.findById(trackId); + if (!track) throw new NotFoundException('Track not found'); + if (track.userId !== userId) throw new ForbiddenException('You do not own this track'); + if (scheduledAt <= new Date()) { + throw new BadRequestException('Scheduled date must be in the future'); + } + + if (track.trackStatus === TrackStatus.FINISHED || track.trackStatus === TrackStatus.SCHEDULED) { + // FINISHED → first-time schedule; SCHEDULED → update existing schedule + await this.trackRepository.scheduleTrackRelease( + trackId, + scheduledAt, + track.trackStatus === TrackStatus.FINISHED + ); + const existing = await this.releaseQueue.getJob(`release-${trackId}`); + if (existing) await existing.remove(); + await this.releaseQueue.add( + 'release-track', + { trackId }, + { + jobId: `release-${trackId}`, + delay: scheduledAt.getTime() - Date.now(), + removeOnComplete: true, + removeOnFail: { count: 3 }, + } + ); + return { + status: 'success', + message: + track.trackStatus === TrackStatus.FINISHED + ? 'Track scheduled for release.' + : 'Release schedule updated.', + data: { trackId, scheduledAt }, + }; + } + + // PROCESSING, FAILED — not valid + throw new BadRequestException( + track.trackStatus === TrackStatus.PROCESSING + ? 'Track is still processing. Wait for processing to complete before scheduling.' + : `Cannot schedule a track with status: ${track.trackStatus}` + ); + } + + async getUserSpotlightTacks(userId: string, ip: string) { + const { country } = getLocationFromIp(ip); + const user = await this.userRepository.findById(userId); + if (!user) { + throw new BadRequestException('user is not found'); + } + + const tracks = await this.trackRepository.getSpotlightTracks(userId); + + return tracks.map((track) => { + const isBlocked = track.blockedRegions.includes(country!); + if (isBlocked) { + return { ...track, audioUrl: null, audioUrlHq: null }; + } + return track; + }); + } + + async addToSpotlight(userId: string, trackId: string) { + const track = await this.trackRepository.findById(trackId); + if (!track) { + throw new BadRequestException('track not found'); + } + const existing = await this.trackRepository.findSpotlight(userId, trackId); + if (existing) { + throw new BadRequestException('you have already added this to your spotlight'); + } + const countSpotlighTracks = await this.trackRepository.countSpotlight(userId); + if (countSpotlighTracks > 5) { + throw new ForbiddenException( + 'you have reached the maximum limit of tracks to be added to your spotlight' + ); + } + await this.trackRepository.addToSpotlight(userId, trackId); + return { + status: 'success', + messsage: 'track is successfully added to spotlight', + }; + } + + async updateSpotlightTracks(userId: string, trackIds: string[]) { + if (trackIds.length > 5) { + throw new BadRequestException('Maximum 5 tracks allowed in spotlight'); + } + return this.trackRepository.updateSpotlightTracks(userId, trackIds); + } } diff --git a/src/track/track.swagger.ts b/src/track/track.swagger.ts index 9d2011b1..00706276 100644 --- a/src/track/track.swagger.ts +++ b/src/track/track.swagger.ts @@ -7,6 +7,7 @@ import { ApiParam, ApiQuery, ApiResponse, + ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { AddCommentDto } from './dto/add-comment.dto'; @@ -1704,7 +1705,7 @@ export function ApiGetRelatedTracks() { 'Returns a paginated list of tracks that are related to the given track, ' + 'based on the listening history of its top fans. ' + 'The track must be public. Results are cached for 3 days. ' + - "For tracks blocked in the requester's region, `audioUrl` and `waveformUrl` are returned as `null`.", + "For tracks blocked in the requester's region, `audioUrl` is returned as `null`", }), ApiParam({ name: 'artistUsername', @@ -1735,7 +1736,7 @@ export function ApiGetRelatedTracks() { ApiResponse({ status: 200, description: - "Paginated list of related tracks. `audioUrl` and `waveformUrl` are `null` for tracks blocked in the requester's region.", + "Paginated list of related tracks. `audioUrl` per track is `null` when blocked in the requester's region.", schema: { example: { status: 'success', @@ -1767,7 +1768,7 @@ export function ApiGetRelatedTracks() { description: 'Not available in all regions.', coverImage: 'https://s3.amazonaws.com/covers/locked.jpg', audioUrl: null, - waveformUrl: null, + waveformUrl: 'https://s3.amazonaws.com/waveforms/locked.json', durationSeconds: 210, playCount: 800, likesCount: 90, @@ -1878,3 +1879,257 @@ export function ApiUpdateCommentsSettings() { ApiResponse({ status: 401, description: 'Unauthorized' }) ); } + +// ─── Get Top Listeners ──────────────────────────────────────────────────────── + +export function ApiGetTopListeners() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Get top listeners for the authenticated artist', + description: + "Returns the top 10 listeners who played the authenticated artist's tracks the most " + + 'in the last 30 days, ordered by play count descending. ' + + 'Requires a Pro plan.', + }), + ApiResponse({ + status: 200, + description: 'Top listeners returned successfully', + schema: { + example: { + status: 'success', + data: [ + { + userId: '550e8400-e29b-41d4-a716-446655440001', + username: 'superfan1', + displayName: 'Super Fan', + avatarUrl: 'https://s3.amazonaws.com/profiles/avatar.webp', + followersCount: 200, + playCount: 42, + }, + ], + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }), + ApiResponse({ status: 403, description: 'Forbidden — Pro plan required' }) + ); +} + +// ─── Get Top Regions ──────────────────────────────────────────────────────── + +export function ApiGetTopRegions() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Get top regions for the authenticated artist', + description: + "Returns the top 10 regions where the authenticated artist's tracks were played the most " + + 'in the last 30 days, ordered by play count descending. ' + + 'Requires a Pro plan.', + }), + ApiResponse({ + status: 200, + description: 'Top regions returned successfully', + schema: { + example: { + status: 'success', + data: [ + { + country: 'Egypt', + playCount: 42, + }, + { + country: 'England', + playCount: 23, + }, + ], + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }), + ApiResponse({ status: 403, description: 'Forbidden — Pro plan required' }) + ); +} + +// ─── Get Top Playlists ──────────────────────────────────────────────────────── + +export function ApiGetTopPlaylistsAndAlbums() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Get top playlists and albums streams for the authenticated artist', + description: + "Returns the top 10 playlists and albums that the authenticated artist's tracks get played from the most " + + 'in the last 30 days, ordered by play count descending. ' + + 'Requires a Pro plan.', + }), + ApiResponse({ + status: 200, + description: 'Top playlists and albums returned successfully', + schema: { + example: { + status: 'success', + data: [ + { + playlistId: '550e8400-e29b-41d4-a716-446655440001', + title: 'Top Hits', + coverImage: 'https://s3.amazonaws.com/playlists/cover.webp', + trackCount: 10, + likesCount: 100, + repostsCount: 50, + ownerId: '550e8400-e29b-41d4-a716-446655440001', + type: 'playlist', + playCount: 1000, + }, + ], + }, + }, + }), + ApiResponse({ status: 401, description: 'Unauthorized' }), + ApiResponse({ status: 403, description: 'Forbidden — Pro plan required' }) + ); +} + +export function ApiGetSpotlightTracks() { + return applyDecorators( + ApiOperation({ + summary: 'Get spotlight tracks for a user', + description: + "Returns the spotlight tracks of the given user. Audio URLs are stripped for tracks blocked in the requesting user's region.", + }), + ApiParam({ name: 'userId', description: 'UUID of the user', type: 'string' }), + ApiResponse({ + status: 200, + description: 'Spotlight tracks returned successfully', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + trackId: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + audioUrl: { + type: 'string', + nullable: true, + description: "Null if track is blocked in the requester's region", + }, + audioUrlHq: { + type: 'string', + nullable: true, + description: "Null if track is blocked in the requester's region", + }, + }, + }, + }, + }), + ApiResponse({ status: 400, description: 'User not found' }) + ); +} + +export function ApiAddToSpotlight() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Add a track to spotlight', + description: + "Adds the specified track to the authenticated user's spotlight. Requires Pro plan. Maximum 5 tracks allowed.", + }), + ApiParam({ name: 'trackId', description: 'UUID of the track to add', type: 'string' }), + ApiResponse({ + status: 201, + description: 'Track added to spotlight successfully', + schema: { + type: 'object', + properties: { + status: { type: 'string', example: 'success' }, + messsage: { type: 'string', example: 'track is successfully added to spotlight' }, + }, + }, + }), + ApiResponse({ status: 400, description: 'Track not found or already in spotlight' }), + ApiResponse({ + status: 403, + description: 'Spotlight limit (5 tracks) reached or Pro plan required', + }), + ApiUnauthorizedResponse({ description: 'Unauthorized' }) + ); +} + +export function ApiUpdateSpotlight() { + return applyDecorators( + ApiOperation({ summary: 'Update spotlight tracks' }), + ApiBody({ + schema: { + type: 'object', + properties: { + trackIds: { + type: 'array', + items: { type: 'string', format: 'uuid' }, + maxItems: 5, + example: [ + '550e8400-e29b-41d4-a716-446655440000', + '550e8400-e29b-41d4-a716-446655440001', + ], + }, + }, + required: ['trackIds'], + }, + }), + ApiResponse({ status: 200, description: 'Spotlight tracks updated successfully' }), + ApiResponse({ status: 400, description: 'Maximum 5 tracks allowed in spotlight' }), + ApiResponse({ status: 403, description: 'Pro plan required' }), + ApiUnauthorizedResponse({ description: 'Unauthorized' }) + ); +} + +export function ApiScheduleTrackRelease() { + return applyDecorators( + ApiCookieAuth('access_token'), + ApiOperation({ + summary: 'Schedule a track for future release', + description: + 'Sets or updates the future release date for a track. Pro plan required. ' + + 'The track must be FINISHED (processing complete) or already SCHEDULED. ' + + 'On first call (FINISHED): track moves to SCHEDULED and is removed from search. ' + + 'On subsequent calls (SCHEDULED): the release time and queued job are replaced. ' + + 'At the scheduled time the track is automatically published and indexed in search.', + }), + ApiParam({ name: 'trackId', description: 'UUID of the track to schedule', type: 'string' }), + ApiBody({ + schema: { + type: 'object', + required: ['scheduledAt'], + properties: { + scheduledAt: { + type: 'string', + format: 'date-time', + example: '2026-06-01T09:00:00.000Z', + description: + 'ISO 8601 datetime in the future. Must include timezone offset (e.g. +03:00 or Z). All times are stored and processed in UTC.', + }, + }, + }, + }), + ApiResponse({ + status: 200, + description: 'Release scheduled successfully', + schema: { + example: { + status: 'success', + message: 'Track scheduled for release.', + data: { + trackId: '550e8400-e29b-41d4-a716-446655440000', + scheduledAt: '2026-06-01T09:00:00.000Z', + }, + }, + }, + }), + ApiResponse({ + status: 400, + description: 'scheduledAt not in the future, or track status is PROCESSING/FAILED', + }), + ApiResponse({ status: 403, description: 'Pro plan required or not the track owner' }), + ApiUnauthorizedResponse({ description: 'Unauthorized' }) + ); +} diff --git a/src/user/dto/update-gender.dto.ts b/src/user/dto/update-gender.dto.ts index 8febc814..e8b4a835 100644 --- a/src/user/dto/update-gender.dto.ts +++ b/src/user/dto/update-gender.dto.ts @@ -4,11 +4,11 @@ import { IsIn } from 'class-validator'; export class UpdateGenderReqDto { @ApiProperty({ description: 'Gender value', - enum: ['male', 'female', 'preferNotToSay'], + enum: ['male', 'female'], example: 'male', }) - @IsIn(['male', 'female', 'preferNotToSay'], { - message: "Gender must be one of: 'male','female','preferNotToSay'", + @IsIn(['male', 'female'], { + message: "Gender must be one of: 'male','female'", }) gender!: string; } diff --git a/src/user/dto/update-profile-req.dto.ts b/src/user/dto/update-profile-req.dto.ts index 3258ee8f..9221ecce 100644 --- a/src/user/dto/update-profile-req.dto.ts +++ b/src/user/dto/update-profile-req.dto.ts @@ -74,12 +74,12 @@ export class UpdateProfileReqDto { city?: string; @ApiPropertyOptional({ - description: "Gender — accepted values: 'male', 'female', 'preferNotToSay'", + description: "Gender — accepted values: 'male', 'female'", example: 'male', - enum: ['male', 'female', 'preferNotToSay'], + enum: ['male', 'female'], }) @IsOptional() - @IsIn(['male', 'female', 'preferNotToSay']) + @IsIn(['male', 'female']) gender?: string; @ApiPropertyOptional({ diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 5f4fc930..4332e991 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -15,6 +15,7 @@ import { Settings } from '../../settings/entities/settings.entity'; import { Subscription } from '../../subscription/entities/subscription.entity'; import { DownloadedTrack } from '../../download/entities/downloaded-tracks.entity'; import { DownloadedPlaylist } from '../../download/entities/downloaded-playlists.entity'; +import { SpotlightTrack } from '../../track/entities/spotlght-track.entity'; @Entity('users') export class User extends BaseEntity { @@ -70,7 +71,7 @@ export class User extends BaseEntity { isSuspended!: boolean; @Column({ type: 'varchar', length: 500, nullable: true, name: 'suspension_reason' }) - suspensionReason!: string; + suspensionReason!: string | null; @Column({ type: 'varchar', length: 500, nullable: true, name: 'support_link' }) supportLink!: string; @@ -138,4 +139,7 @@ export class User extends BaseEntity { @OneToMany(() => DownloadedPlaylist, (dp) => dp.user) downloadedPlaylists!: DownloadedPlaylist[]; + + @OneToMany(() => SpotlightTrack, (spotlight) => spotlight.user) + spotlightTracks!: SpotlightTrack[]; } diff --git a/src/user/profile.swagger.ts b/src/user/profile.swagger.ts index e54feaef..10ce03cc 100644 --- a/src/user/profile.swagger.ts +++ b/src/user/profile.swagger.ts @@ -132,7 +132,7 @@ export function ApiUpdateProfile() { bio: { type: 'string', maxLength: 1000, example: 'Backend dev by day.' }, country: { type: 'string', maxLength: 100, example: 'EG' }, city: { type: 'string', maxLength: 100, example: 'Alexandria' }, - gender: { type: 'string', enum: ['male', 'female', 'preferNotToSay'] }, + gender: { type: 'string', enum: ['male', 'female'] }, favoriteGenres: { type: 'array', items: { type: 'string' }, @@ -227,7 +227,7 @@ export function ApiUpdateBirthdate() { return applyDecorators( ApiCookieAuth('access_token'), ApiOperation({ - summary: 'Update my birthdate', + summary: 'Update my birthdate - deprecated', description: "Updates the authenticated user's birthdate. Must be in YYYY-MM-DD format, not in the future, and the user must be at least 13 years old.", }), @@ -289,9 +289,8 @@ export function ApiUpdateGender() { return applyDecorators( ApiCookieAuth('access_token'), ApiOperation({ - summary: 'Update my gender', - description: - "Updates the authenticated user's gender. Accepted values: 'male', 'female', 'preferNotToSay'.", + summary: 'Update my gender - deprecated', + description: "Updates the authenticated user's gender. Accepted values: 'male', 'female'.", }), ApiResponse({ status: 200, @@ -314,9 +313,7 @@ export function ApiUpdateGender() { example: { status: 'error', message: 'Validation failed', - errors: [ - { field: 'gender', message: "Gender must be one of: 'male','female','preferNotToSay'" }, - ], + errors: [{ field: 'gender', message: "Gender must be one of: 'male','female'" }], }, }, }), @@ -329,7 +326,7 @@ export function ApiUpdatePrivacy() { return applyDecorators( ApiCookieAuth('access_token'), ApiOperation({ - summary: 'Change account privacy', + summary: 'Change account privacy - deprecated', description: "Toggles the authenticated user's account between public and private. Private accounts hide their content from non-followers.", }), @@ -368,7 +365,7 @@ export function ApiUpdateAvatar() { ApiCookieAuth('access_token'), ApiConsumes('multipart/form-data'), ApiOperation({ - summary: 'Update profile picture', + summary: 'Update profile picture - deprecated', description: "Updates the authenticated user's avatar/profile picture. The image is optimized, converted to WebP, and saved to S3.", }), @@ -422,7 +419,7 @@ export function ApiUpdateCover() { ApiCookieAuth('access_token'), ApiConsumes('multipart/form-data'), ApiOperation({ - summary: 'Update cover photo', + summary: 'Update cover photo - deprecated', description: "Updates the authenticated user's cover photo and saves it to S3.", }), ApiBody({ diff --git a/src/user/trending-music-user.constants.ts b/src/user/trending-music-user.constants.ts index b8e734d2..2fcab1f5 100644 --- a/src/user/trending-music-user.constants.ts +++ b/src/user/trending-music-user.constants.ts @@ -4,7 +4,9 @@ export const TRENDING_MUSIC_USER = { username: 'trending_music', firstName: 'Trending', lastName: 'Music', - role: 'artist', - plan: 'pro', + role: 'listener', + plan: 'free', isPublic: true, + avatarUrl: + 'https://harmonica-s3-storage-287109772507-us-east-1-an.s3.amazonaws.com/avatars-j5jQ5uSzeQh8QBSt-YgA6LQ-t500x500.jpg', }; diff --git a/src/user/user.repository.ts b/src/user/user.repository.ts index 926dc0ff..1e0bd0e8 100644 --- a/src/user/user.repository.ts +++ b/src/user/user.repository.ts @@ -155,7 +155,9 @@ export class UserRepository { // Step 3 - create user settings record with defaults await this.settingsService.createDefaultSettings(savedUser.userId); - + if (savedUser.isPublic) { + await addDocuments([mapUser(savedUser)]); + } return savedUser; }