diff --git a/server/src/song/song-webhook/song-webhook.service.spec.ts b/server/src/song/song-webhook/song-webhook.service.spec.ts new file mode 100644 index 00000000..59381f9c --- /dev/null +++ b/server/src/song/song-webhook/song-webhook.service.spec.ts @@ -0,0 +1,260 @@ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Model } from 'mongoose'; + +import { SongWebhookService } from './song-webhook.service'; +import { Song as SongEntity, SongWithUser } from '../entity/song.entity'; +import { getUploadDiscordEmbed } from '../song.util'; + +jest.mock('../song.util', () => ({ + getUploadDiscordEmbed: jest.fn(), +})); + +const mockSongModel = { + find: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + populate: jest.fn().mockReturnThis(), + save: jest.fn(), +}; + +describe('SongWebhookService', () => { + let service: SongWebhookService; + let _songModel: Model; + let _configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot()], + providers: [ + SongWebhookService, + { + provide: getModelToken(SongEntity.name), + useValue: mockSongModel, + }, + { + provide: 'DISCORD_WEBHOOK_URL', + useValue: 'http://localhost/webhook', + }, + ], + }).compile(); + + service = module.get(SongWebhookService); + _songModel = module.get>(getModelToken(SongEntity.name)); + _configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('postSongWebhook', () => { + it('should post a new webhook message for a song', async () => { + const song: SongWithUser = { + publicId: '123', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as SongWithUser; + + (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); + + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ id: 'message-id' }), + }); + + const result = await service.postSongWebhook(song); + + expect(result).toBe('message-id'); + expect(fetch).toHaveBeenCalledWith('http://localhost/webhook?wait=true', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + }); + + it('should return null if there is an error', async () => { + const song: SongWithUser = { + publicId: '123', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as SongWithUser; + + (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); + + global.fetch = jest.fn().mockRejectedValue(new Error('Error')); + + const result = await service.postSongWebhook(song); + + expect(result).toBeNull(); + }); + }); + + describe('updateSongWebhook', () => { + it('should update the webhook message for a song', async () => { + const song: SongWithUser = { + publicId: '123', + webhookMessageId: 'message-id', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as SongWithUser; + + (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); + + global.fetch = jest.fn().mockResolvedValue({}); + + await service.updateSongWebhook(song); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/webhook/messages/message-id', + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }, + ); + }); + + it('should log an error if there is an error', async () => { + const song: SongWithUser = { + publicId: '123', + webhookMessageId: 'message-id', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as SongWithUser; + + (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); + + global.fetch = jest.fn().mockRejectedValue(new Error('Error')); + + const loggerSpy = jest.spyOn(service['logger'], 'error'); + + await service.updateSongWebhook(song); + + expect(loggerSpy).toHaveBeenCalledWith( + 'Error updating Discord webhook', + expect.any(Error), + ); + }); + }); + + describe('deleteSongWebhook', () => { + it('should delete the webhook message for a song', async () => { + const song: SongWithUser = { + publicId: '123', + webhookMessageId: 'message-id', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as SongWithUser; + + global.fetch = jest.fn().mockResolvedValue({}); + + await service.deleteSongWebhook(song); + + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/webhook/messages/message-id', + { + method: 'DELETE', + }, + ); + }); + + it('should log an error if there is an error', async () => { + const song: SongWithUser = { + publicId: '123', + webhookMessageId: 'message-id', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as SongWithUser; + + global.fetch = jest.fn().mockRejectedValue(new Error('Error')); + + const loggerSpy = jest.spyOn(service['logger'], 'error'); + + await service.deleteSongWebhook(song); + + expect(loggerSpy).toHaveBeenCalledWith( + 'Error deleting Discord webhook', + expect.any(Error), + ); + }); + }); + + describe('syncSongWebhook', () => { + it('should update the webhook message if the song is public', async () => { + const song: SongWithUser = { + publicId: '123', + webhookMessageId: 'message-id', + visibility: 'public', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as SongWithUser; + + const updateSpy = jest.spyOn(service, 'updateSongWebhook'); + + await service.syncSongWebhook(song); + + expect(updateSpy).toHaveBeenCalledWith(song); + }); + + it('should delete the webhook message if the song is not public', async () => { + const song: SongWithUser = { + publicId: '123', + webhookMessageId: 'message-id', + visibility: 'private', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as SongWithUser; + + const deleteSpy = jest.spyOn(service, 'deleteSongWebhook'); + + await service.syncSongWebhook(song); + + expect(deleteSpy).toHaveBeenCalledWith(song); + }); + + it('should post a new webhook message if the song is public and does not have a message', async () => { + const song: SongWithUser = { + publicId: '123', + visibility: 'public', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as SongWithUser; + + const postSpy = jest.spyOn(service, 'postSongWebhook'); + + await service.syncSongWebhook(song); + + expect(postSpy).toHaveBeenCalledWith(song); + }); + + it('should return null if the song is not public and does not have a message', async () => { + const song: SongWithUser = { + publicId: '123', + visibility: 'private', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as SongWithUser; + + const result = await service.syncSongWebhook(song); + + expect(result).toBeNull(); + }); + }); + + describe('syncAllSongsWebhook', () => { + it('should synchronize the webhook messages for all songs', async () => { + const songs: SongWithUser[] = [ + { + publicId: '123', + uploader: { username: 'testuser', profileImage: 'testimage' }, + save: jest.fn(), + } as unknown as SongWithUser, + ]; + + mockSongModel.find.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + populate: jest.fn().mockResolvedValue(songs), + }); + + const syncSpy = jest.spyOn(service, 'syncSongWebhook'); + + await (service as any).syncAllSongsWebhook(); + + expect(syncSpy).toHaveBeenCalledWith(songs[0]); + }); + }); +}); diff --git a/server/src/song/song-webhook/song-webhook.service.ts b/server/src/song/song-webhook/song-webhook.service.ts index 50b47d8f..ee249a43 100644 --- a/server/src/song/song-webhook/song-webhook.service.ts +++ b/server/src/song/song-webhook/song-webhook.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; @@ -12,6 +12,8 @@ export class SongWebhookService implements OnModuleInit { constructor( @InjectModel(SongEntity.name) private songModel: Model, + @Inject('DISCORD_WEBHOOK_URL') + private readonly discordWebhookUrl: string | undefined, ) {} async onModuleInit() { @@ -28,7 +30,7 @@ export class SongWebhookService implements OnModuleInit { * @throws {Error} If the Discord webhook URL is not found. * @throws {Error} If there is an error sending the webhook message. */ - const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + const webhookUrl = this.discordWebhookUrl; if (!webhookUrl) { this.logger.error('Discord webhook URL not found'); @@ -71,7 +73,7 @@ export class SongWebhookService implements OnModuleInit { throw new Error('Song does not have a webhook message'); } - const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + const webhookUrl = this.discordWebhookUrl; if (!webhookUrl) { this.logger.error('Discord webhook URL not found'); @@ -110,7 +112,7 @@ export class SongWebhookService implements OnModuleInit { throw new Error('Song does not have a webhook message'); } - const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + const webhookUrl = this.discordWebhookUrl; if (!webhookUrl) { this.logger.error('Discord webhook URL not found'); @@ -175,7 +177,7 @@ export class SongWebhookService implements OnModuleInit { .sort({ createdAt: 1 }) .populate('uploader', 'username profileImage -_id'); - for await (const songDocument of songQuery) { + for (const songDocument of await songQuery) { const webhookMessageId = await this.syncSongWebhook( songDocument as unknown as SongWithUser, ); diff --git a/server/src/song/song.module.ts b/server/src/song/song.module.ts index 5eb5b87c..a4f14b11 100644 --- a/server/src/song/song.module.ts +++ b/server/src/song/song.module.ts @@ -11,6 +11,7 @@ import { SongUploadService } from './song-upload/song-upload.service'; import { SongController } from './song.controller'; import { SongService } from './song.service'; import { SongWebhookService } from './song-webhook/song-webhook.service'; +import { ConfigService } from '@nestjs/config'; @Module({ imports: [ @@ -19,7 +20,17 @@ import { SongWebhookService } from './song-webhook/song-webhook.service'; UserModule, FileModule.forRootAsync(), ], - providers: [SongService, SongUploadService, SongWebhookService], + providers: [ + SongService, + SongUploadService, + SongWebhookService, + { + provide: 'DISCORD_WEBHOOK_URL', + useFactory: (configService: ConfigService) => + configService.getOrThrow('DISCORD_WEBHOOK_URL'), + inject: [ConfigService], + }, + ], controllers: [SongController, MySongsController], exports: [SongService], })