diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 39c6cbdb..e8cbbe84 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,7 @@ "recommendations": [ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", - "unifiedjs.vscode-mdx" + "unifiedjs.vscode-mdx", + "orta.vscode-jest" ] } diff --git a/NoteBlockWorld.code-workspace b/NoteBlockWorld.code-workspace new file mode 100644 index 00000000..1ec345e5 --- /dev/null +++ b/NoteBlockWorld.code-workspace @@ -0,0 +1,24 @@ +{ + "folders": [ + { + "path": ".", + "name": "Root" + }, + { + "path": "./server", + "name": "Backend" + }, + { + "path": "./shared", + "name": "Shared" + }, + { + "path": "./web", + "name": "Frontend" + } + ], + "settings": { + "mdx.server.enable": true, + "jest.disabledWorkspaceFolders": ["Root", "Frontend"] + } +} diff --git a/server/jest.config.js b/server/jest.config.js new file mode 100644 index 00000000..c16ccf97 --- /dev/null +++ b/server/jest.config.js @@ -0,0 +1,35 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '.', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.json', + ignoreCodes: ['TS151001'], + }, + ], + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: './coverage', + testEnvironment: 'node', + moduleNameMapper: { + '^@shared/(.*)$': '/../shared/$1', + '^@server/(.*)$': '/src/$1', + }, + testPathIgnorePatterns: [ + '/node_modules/', + '/dist/', + '/coverage/', + ], + coveragePathIgnorePatterns: [ + '/node_modules/', + '/coverage/', + '/dist/', + '/src/.*\\.module\\.ts$', + '/src/main.ts', + '.eslintrc.js', + 'jest.config.js', + ], +}; diff --git a/server/package.json b/server/package.json index 5e7145ee..2bfa9cff 100644 --- a/server/package.json +++ b/server/package.json @@ -76,22 +76,5 @@ "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" } } \ No newline at end of file diff --git a/server/src/GetRequestUser.spec.ts b/server/src/GetRequestUser.spec.ts new file mode 100644 index 00000000..06803c9c --- /dev/null +++ b/server/src/GetRequestUser.spec.ts @@ -0,0 +1,42 @@ +import { ExecutionContext, HttpException, HttpStatus } from '@nestjs/common'; + +import { GetRequestToken, validateUser } from './GetRequestUser'; +import { UserDocument } from './user/entity/user.entity'; + +describe('GetRequestToken', () => { + it('should be a defined decorator', () => { + const mockExecutionContext = { + switchToHttp: jest.fn().mockReturnThis(), + } as unknown as ExecutionContext; + + const result = GetRequestToken(null, mockExecutionContext); + + expect(typeof result).toBe('function'); + }); +}); + +describe('validateUser', () => { + it('should return the user if the user exists', () => { + const mockUser = { + _id: 'test-id', + username: 'testuser', + } as unknown as UserDocument; + + const result = validateUser(mockUser); + + expect(result).toEqual(mockUser); + }); + + it('should throw an error if the user does not exist', () => { + expect(() => validateUser(null)).toThrowError( + new HttpException( + { + error: { + user: 'User not found', + }, + }, + HttpStatus.UNAUTHORIZED, + ), + ); + }); +}); diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 51007c3c..7be33f1d 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -24,10 +24,7 @@ import { UserModule } from './user/user.module'; useFactory: ( configService: ConfigService, ): MongooseModuleFactoryOptions => { - const url = configService.get('MONGO_URL'); - if (!url) { - throw new Error('Missing DB config'); - } + const url = configService.getOrThrow('MONGO_URL'); Logger.debug(`Connecting to ${url}`); return { @@ -39,8 +36,8 @@ import { UserModule } from './user/user.module'; }), SongModule, UserModule, - AuthModule, - FileModule, + AuthModule.forRootAsync(), + FileModule.forRootAsync(), SongBrowserModule, ], controllers: [], diff --git a/server/src/auth/auth.controller.spec.ts b/server/src/auth/auth.controller.spec.ts new file mode 100644 index 00000000..52732362 --- /dev/null +++ b/server/src/auth/auth.controller.spec.ts @@ -0,0 +1,113 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Request, Response } from 'express'; + +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; + +const mockAuthService = { + githubLogin: jest.fn(), + googleLogin: jest.fn(), + discordLogin: jest.fn(), + verifyToken: jest.fn(), +}; + +describe('AuthController', () => { + let controller: AuthController; + let authService: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { + provide: AuthService, + useValue: mockAuthService, + }, + ], + }).compile(); + + controller = module.get(AuthController); + authService = module.get(AuthService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('githubRedirect', () => { + it('should call AuthService.githubLogin', async () => { + const req = {} as Request; + const res = {} as Response; + + await controller.githubRedirect(req, res); + + expect(authService.githubLogin).toHaveBeenCalledWith(req, res); + }); + + it('should handle exceptions', async () => { + const req = {} as Request; + const res = {} as Response; + const error = new Error('Test error'); + (authService.githubLogin as jest.Mock).mockRejectedValueOnce(error); + + await expect(controller.githubRedirect(req, res)).rejects.toThrow( + 'Test error', + ); + }); + }); + + describe('googleRedirect', () => { + it('should call AuthService.googleLogin', async () => { + const req = {} as Request; + const res = {} as Response; + + await controller.googleRedirect(req, res); + + expect(authService.googleLogin).toHaveBeenCalledWith(req, res); + }); + + it('should handle exceptions', async () => { + const req = {} as Request; + const res = {} as Response; + const error = new Error('Test error'); + (authService.googleLogin as jest.Mock).mockRejectedValueOnce(error); + + await expect(controller.googleRedirect(req, res)).rejects.toThrow( + 'Test error', + ); + }); + }); + + describe('discordRedirect', () => { + it('should call AuthService.discordLogin', async () => { + const req = {} as Request; + const res = {} as Response; + + await controller.discordRedirect(req, res); + + expect(authService.discordLogin).toHaveBeenCalledWith(req, res); + }); + + it('should handle exceptions', async () => { + const req = {} as Request; + const res = {} as Response; + const error = new Error('Test error'); + (authService.discordLogin as jest.Mock).mockRejectedValueOnce(error); + + await expect(controller.discordRedirect(req, res)).rejects.toThrow( + 'Test error', + ); + }); + }); + + describe('verify', () => { + it('should call AuthService.verifyToken', async () => { + const req = {} as Request; + const res = {} as Response; + + await controller.verify(req, res); + + expect(authService.verifyToken).toHaveBeenCalledWith(req, res); + }); + }); +}); diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts index 7ead9804..1d16702f 100644 --- a/server/src/auth/auth.module.ts +++ b/server/src/auth/auth.module.ts @@ -1,4 +1,4 @@ -import { Logger, Module } from '@nestjs/common'; +import { DynamicModule, Logger, Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; @@ -11,40 +11,87 @@ import { GithubStrategy } from './strategies/github.strategy'; import { GoogleStrategy } from './strategies/google.strategy'; import { JwtStrategy } from './strategies/JWT.strategy'; -@Module({ - imports: [ - UserModule, - ConfigModule, - JwtModule.registerAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: async (config: ConfigService) => { - const JWT_SECRET = config.get('JWT_SECRET'); - const JWT_EXPIRES_IN = config.get('JWT_EXPIRES_IN'); - if (!JWT_SECRET) { - Logger.error('JWT_SECRET is not set'); - throw new Error('JWT_SECRET is not set'); - } - if (!JWT_EXPIRES_IN) { - Logger.warn('JWT_EXPIRES_IN is not set, using default of 60s'); - } +@Module({}) +export class AuthModule { + static forRootAsync(): DynamicModule { + return { + module: AuthModule, + imports: [ + UserModule, + ConfigModule.forRoot(), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (config: ConfigService) => { + const JWT_SECRET = config.get('JWT_SECRET'); + const JWT_EXPIRES_IN = config.get('JWT_EXPIRES_IN'); - return { - secret: JWT_SECRET, - signOptions: { expiresIn: JWT_EXPIRES_IN || '60s' }, - }; - }, - }), - ], - controllers: [AuthController], - providers: [ - AuthService, - ConfigService, - GoogleStrategy, - GithubStrategy, - DiscordStrategy, - JwtStrategy, - ], - exports: [AuthService], -}) -export class AuthModule {} + if (!JWT_SECRET) { + Logger.error('JWT_SECRET is not set'); + throw new Error('JWT_SECRET is not set'); + } + + if (!JWT_EXPIRES_IN) { + Logger.warn('JWT_EXPIRES_IN is not set, using default of 60s'); + } + + return { + secret: JWT_SECRET, + signOptions: { expiresIn: JWT_EXPIRES_IN || '60s' }, + }; + }, + }), + ], + controllers: [AuthController], + providers: [ + AuthService, + ConfigService, + GoogleStrategy, + GithubStrategy, + DiscordStrategy, + JwtStrategy, + { + provide: 'FRONTEND_URL', + useValue: (configService: ConfigService) => + configService.getOrThrow('FRONTEND_URL'), + }, + { + provide: 'COOKIE_EXPIRES_IN', + useValue: (configService: ConfigService) => + configService.getOrThrow('COOKIE_EXPIRES_IN'), + }, + { + provide: 'JWT_SECRET', + useValue: (configService: ConfigService) => + configService.getOrThrow('JWT_SECRET'), + }, + { + provide: 'JWT_EXPIRES_IN', + useValue: (configService: ConfigService) => + configService.getOrThrow('JWT_EXPIRES_IN'), + }, + { + provide: 'JWT_REFRESH_SECRET', + useValue: (configService: ConfigService) => + configService.getOrThrow('JWT_REFRESH_SECRET'), + }, + { + provide: 'JWT_REFRESH_EXPIRES_IN', + useValue: (configService: ConfigService) => + configService.getOrThrow('JWT_REFRESH_EXPIRES_IN'), + }, + { + provide: 'WHITELISTED_USERS', + useValue: (configService: ConfigService) => + configService.getOrThrow('WHITELISTED_USERS'), + }, + { + provide: 'APP_DOMAIN', + useValue: (configService: ConfigService) => + configService.getOrThrow('APP_DOMAIN'), + }, + ], + exports: [AuthService], + }; + } +} diff --git a/server/src/auth/auth.service.spec.ts b/server/src/auth/auth.service.spec.ts new file mode 100644 index 00000000..7fc77ea6 --- /dev/null +++ b/server/src/auth/auth.service.spec.ts @@ -0,0 +1,581 @@ +import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import axios from 'axios'; +import type { Request, Response } from 'express'; + +import { UserDocument } from '@server/user/entity/user.entity'; +import { UserService } from '@server/user/user.service'; + +import { AuthService } from './auth.service'; +import { DiscordUser } from './types/discordProfile'; +import { GithubAccessToken } from './types/githubProfile'; +import { GoogleProfile } from './types/googleProfile'; +import { Profile } from './types/profile'; + +jest.mock('axios'); +const mockAxios = axios as jest.Mocked; + +const mockUserService = { + generateUsername: jest.fn(), + findByEmail: jest.fn(), + findByID: jest.fn(), + create: jest.fn(), +}; + +const mockJwtService = { + decode: jest.fn(), + signAsync: jest.fn(), + verify: jest.fn(), +}; + +describe('AuthService', () => { + let authService: AuthService; + let userService: UserService; + let jwtService: JwtService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: UserService, + useValue: mockUserService, + }, + { + provide: JwtService, + useValue: mockJwtService, + }, + { + provide: 'FRONTEND_URL', + useValue: 'http://frontend.test.com', + }, + { + provide: 'COOKIE_EXPIRES_IN', + useValue: '1d', + }, + { + provide: 'JWT_SECRET', + useValue: 'test-jwt-secret', + }, + { + provide: 'JWT_EXPIRES_IN', + useValue: '1d', + }, + { + provide: 'JWT_REFRESH_SECRET', + useValue: 'test-jwt-refresh-secret', + }, + { + provide: 'JWT_REFRESH_EXPIRES_IN', + useValue: '7d', + }, + { + provide: 'WHITELISTED_USERS', + useValue: 'tomast1337,bentroen,testuser', + }, + { + provide: 'APP_DOMAIN', + useValue: '.test.com', + }, + ], + }).compile(); + + authService = module.get(AuthService); + userService = module.get(UserService); + jwtService = module.get(JwtService); + }); + + it('should be defined', () => { + expect(authService).toBeDefined(); + }); + + describe('verifyToken', () => { + it('should throw an error if no authorization header is provided', async () => { + const req = { headers: {} } as Request; + + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as any; + + await authService.verifyToken(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + + expect(res.json).toHaveBeenCalledWith({ + message: 'No authorization header', + }); + }); + + it('should throw an error if no token is provided', async () => { + const req = { headers: { authorization: 'Bearer ' } } as Request; + + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as any; + + await authService.verifyToken(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: 'No token provided' }); + }); + + it('should throw an error if user is not found', async () => { + const req = { + headers: { authorization: 'Bearer test-token' }, + } as Request; + + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as any; + + mockJwtService.verify.mockReturnValueOnce({ id: 'test-id' }); + mockUserService.findByID.mockResolvedValueOnce(null); + + await authService.verifyToken(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: 'Unauthorized' }); + }); + + it('should return decoded token if user is found', async () => { + const req = { + headers: { authorization: 'Bearer test-token' }, + } as Request; + + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as any; + + const decodedToken = { id: 'test-id' }; + mockJwtService.verify.mockReturnValueOnce(decodedToken); + mockUserService.findByID.mockResolvedValueOnce({ id: 'test-id' }); + + const result = await authService.verifyToken(req, res); + expect(result).toEqual(decodedToken); + }); + }); + + describe('googleLogin', () => { + it('should generate token and redirect if user is whitelisted', async () => { + const req: Partial = { + user: { + emails: [{ value: 'test@example.com' }], + photos: [{ value: 'http://example.com/photo.jpg' }], + } as GoogleProfile, + }; + + const res: Partial = { + redirect: jest.fn(), + }; + + jest.spyOn(authService as any, 'verifyWhitelist').mockResolvedValue(true); + + jest + .spyOn(authService as any, 'verifyAndGetUser') + .mockResolvedValue({ id: 'user-id' }); + + jest + .spyOn(authService as any, 'GenTokenRedirect') + .mockImplementation((user, res: any) => { + res.redirect('/dashboard'); + }); + + await authService.googleLogin( + req as unknown as Request, + res as unknown as Response, + ); + + expect((authService as any).verifyAndGetUser).toHaveBeenCalledWith({ + username: 'test', + email: 'test@example.com', + profileImage: 'http://example.com/photo.jpg', + }); + + expect(res.redirect).toHaveBeenCalledWith('/dashboard'); + }); + + it('should redirect to login if user is not whitelisted', async () => { + const req = { + user: { + emails: [{ value: 'test@example.com' }], + photos: [{ value: 'http://example.com/photo.jpg' }], + } as GoogleProfile, + }; + + const res = { + redirect: jest.fn(), + }; + + jest + .spyOn(authService as any, 'verifyWhitelist') + .mockResolvedValue(false); + + await authService.googleLogin( + req as unknown as Request, + res as unknown as Response, + ); + + expect(res.redirect).toHaveBeenCalledWith( + (authService as any).FRONTEND_URL + '/login', + ); + }); + }); // TODO: implement tests for googleLogin + + describe('githubLogin', () => { + it('should generate token and redirect if user is whitelisted', async () => { + const req: Partial = { + user: { + accessToken: 'test-access-token', + profile: { + username: 'testuser', + photos: [{ value: 'http://example.com/photo.jpg' }], + }, + } as GithubAccessToken, + }; + + const res: Partial = { + redirect: jest.fn(), + }; + + jest.spyOn(authService as any, 'verifyWhitelist').mockResolvedValue(true); + + jest + .spyOn(authService as any, 'verifyAndGetUser') + .mockResolvedValue({ id: 'user-id' }); + + jest + .spyOn(authService as any, 'GenTokenRedirect') + .mockImplementation((user, res: any) => { + res.redirect('/dashboard'); + }); + + mockAxios.get.mockResolvedValue({ + data: [{ email: 'test@example.com', primary: true }], + } as any); + + await authService.githubLogin(req as Request, res as Response); + + expect((authService as any).verifyWhitelist).toHaveBeenCalledWith( + 'testuser', + ); + + expect((authService as any).verifyAndGetUser).toHaveBeenCalledWith({ + username: 'testuser', + email: 'test@example.com', + profileImage: 'http://example.com/photo.jpg', + }); + + expect(res.redirect).toHaveBeenCalledWith('/dashboard'); + }); + + it('should redirect to login if user is not whitelisted', async () => { + const req: Partial = { + user: { + accessToken: 'test-access-token', + profile: { + username: 'testuser', + photos: [{ value: 'http://example.com/photo.jpg' }], + }, + } as GithubAccessToken, + }; + + const res: Partial = { + redirect: jest.fn(), + }; + + jest + .spyOn(authService as any, 'verifyWhitelist') + .mockResolvedValue(false); + + mockAxios.get.mockResolvedValue({ + data: [{ email: 'test@example.com', primary: true }], + } as any); + + await authService.githubLogin(req as Request, res as Response); + + expect(res.redirect).toHaveBeenCalledWith( + (authService as any).FRONTEND_URL + '/login', + ); + }); + }); + + describe('discordLogin', () => { + it('should generate token and redirect if user is whitelisted', async () => { + const req: Partial = { + user: { + profile: { + id: 'discord-user-id', + username: 'testuser', + email: 'test@example.com', + avatar: 'avatar-hash', + }, + } as DiscordUser, + }; + + const res: Partial = { + redirect: jest.fn(), + }; + + jest.spyOn(authService as any, 'verifyWhitelist').mockResolvedValue(true); + + jest + .spyOn(authService as any, 'verifyAndGetUser') + .mockResolvedValue({ id: 'user-id' }); + + jest + .spyOn(authService as any, 'GenTokenRedirect') + .mockImplementation((user, res: any) => { + res.redirect('/dashboard'); + }); + + await authService.discordLogin(req as Request, res as Response); + + expect((authService as any).verifyWhitelist).toHaveBeenCalledWith( + 'testuser', + ); + + expect((authService as any).verifyAndGetUser).toHaveBeenCalledWith({ + username: 'testuser', + email: 'test@example.com', + profileImage: + 'https://cdn.discordapp.com/avatars/discord-user-id/avatar-hash.png', + }); + + expect(res.redirect).toHaveBeenCalledWith('/dashboard'); + }); + + it('should redirect to login if user is not whitelisted', async () => { + const req: Partial = { + user: { + profile: { + id: 'discord-user-id', + username: 'testuser', + email: 'test@example.com', + avatar: 'avatar-hash', + }, + } as DiscordUser, + }; + + const res: Partial = { + redirect: jest.fn(), + }; + + jest + .spyOn(authService as any, 'verifyWhitelist') + .mockResolvedValue(false); + + await authService.discordLogin(req as Request, res as Response); + + expect(res.redirect).toHaveBeenCalledWith( + (authService as any).FRONTEND_URL + '/login', + ); + }); + }); // TODO: implement tests for discordLogin + + describe('getUserFromToken', () => { + it('should return null if token is invalid', async () => { + mockJwtService.decode.mockReturnValueOnce(null); + + const result = await authService.getUserFromToken('invalid-token'); + expect(result).toBeNull(); + }); + + it('should return user if token is valid', async () => { + const decodedToken = { id: 'test-id' }; + mockJwtService.decode.mockReturnValueOnce(decodedToken); + mockUserService.findByID.mockResolvedValueOnce({ id: 'test-id' }); + + const result = await authService.getUserFromToken('valid-token'); + expect(result).toEqual({ id: 'test-id' }); + }); + }); + + describe('createJwtPayload', () => { + it('should create access and refresh tokens', async () => { + const payload = { id: 'user-id', username: 'testuser' }; + const accessToken = 'access-token'; + const refreshToken = 'refresh-token'; + + jest + .spyOn(jwtService, 'signAsync') + .mockImplementation((payload, options: any) => { + if (options.secret === 'test-jwt-secret') { + return Promise.resolve(accessToken); + } else if (options.secret === 'test-jwt-refresh-secret') { + return Promise.resolve(refreshToken); + } + + return Promise.reject(new Error('Invalid secret')); + }); + + const tokens = await (authService as any).createJwtPayload(payload); + + expect(tokens).toEqual({ + access_token: accessToken, + refresh_token: refreshToken, + }); + + expect(jwtService.signAsync).toHaveBeenCalledWith(payload, { + secret: 'test-jwt-secret', + expiresIn: '1d', + }); + + expect(jwtService.signAsync).toHaveBeenCalledWith(payload, { + secret: 'test-jwt-refresh-secret', + expiresIn: '7d', + }); + }); + }); + + describe('GenTokenRedirect', () => { + it('should set cookies and redirect to the frontend URL', async () => { + const user_registered = { + _id: 'user-id', + email: 'test@example.com', + username: 'testuser', + } as unknown as UserDocument; + + const res = { + cookie: jest.fn(), + redirect: jest.fn(), + } as unknown as Response; + + const tokens = { + access_token: 'access-token', + refresh_token: 'refresh-token', + }; + + jest + .spyOn(authService as any, 'createJwtPayload') + .mockResolvedValue(tokens); + + await (authService as any).GenTokenRedirect(user_registered, res); + + expect((authService as any).createJwtPayload).toHaveBeenCalledWith({ + id: 'user-id', + email: 'test@example.com', + username: 'testuser', + }); + + expect(res.cookie).toHaveBeenCalledWith('token', 'access-token', { + domain: '.test.com', + maxAge: 1, + }); + + expect(res.cookie).toHaveBeenCalledWith( + 'refresh_token', + 'refresh-token', + { + domain: '.test.com', + maxAge: 1, + }, + ); + + expect(res.redirect).toHaveBeenCalledWith('http://frontend.test.com/'); + }); + }); + + describe('verifyWhitelist', () => { + it('should approve login if whitelist is empty', async () => { + (authService as any).WHITELISTED_USERS = ''; + const result = await (authService as any).verifyWhitelist('anyuser'); + expect(result).toBe(true); + }); + + it('should approve login if username is in the whitelist', async () => { + (authService as any).WHITELISTED_USERS = 'user1,user2,user3'; + const result = await (authService as any).verifyWhitelist('user1'); + expect(result).toBe(true); + }); + + it('should reject login if username is not in the whitelist', async () => { + const result = await (authService as any).verifyWhitelist('user4'); + expect(result).toBe(false); + }); + + it('should approve login if username is in the whitelist (case insensitive)', async () => { + (authService as any).WHITELISTED_USERS = 'user1,user2,user3'; + const result = await (authService as any).verifyWhitelist('User1'); + expect(result).toBe(true); + }); + }); + + describe('verifyAndGetUser', () => { + it('should create a new user if the user is not registered', async () => { + const user: Profile = { + username: 'testuser', + email: 'test@example.com', + profileImage: 'http://example.com/photo.jpg', + }; + + mockUserService.findByEmail.mockResolvedValue(null); + mockUserService.create.mockResolvedValue({ id: 'new-user-id' }); + + const result = await (authService as any).verifyAndGetUser(user); + + expect(userService.findByEmail).toHaveBeenCalledWith('test@example.com'); + + expect(userService.create).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@example.com', + profileImage: 'http://example.com/photo.jpg', + }), + ); + + expect(result).toEqual({ id: 'new-user-id' }); + }); + + it('should return the registered user if the user is already registered', async () => { + const user: Profile = { + username: 'testuser', + email: 'test@example.com', + profileImage: 'http://example.com/photo.jpg', + }; + + const registeredUser = { + id: 'registered-user-id', + profileImage: 'http://example.com/photo.jpg', + }; + + mockUserService.findByEmail.mockResolvedValue(registeredUser); + + const result = await (authService as any).verifyAndGetUser(user); + + expect(userService.findByEmail).toHaveBeenCalledWith('test@example.com'); + expect(result).toEqual(registeredUser); + }); + + it('should update the profile image if it has changed', async () => { + const user: Profile = { + username: 'testuser', + email: 'test@example.com', + profileImage: 'http://example.com/new-photo.jpg', + }; + + const registeredUser = { + id: 'registered-user-id', + profileImage: 'http://example.com/old-photo.jpg', + save: jest.fn(), + }; + + mockUserService.findByEmail.mockResolvedValue(registeredUser); + + const result = await (authService as any).verifyAndGetUser(user); + + expect(userService.findByEmail).toHaveBeenCalledWith('test@example.com'); + + expect(registeredUser.profileImage).toEqual( + 'http://example.com/new-photo.jpg', + ); + + expect(registeredUser.save).toHaveBeenCalled(); + expect(result).toEqual(registeredUser); + }); + }); + + describe('createNewUser', () => {}); +}); diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 26b028d2..a57088eb 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -1,5 +1,4 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { CreateUser } from '@shared/validation/user/dto/CreateUser.dto'; import axios from 'axios'; @@ -17,47 +16,29 @@ import { TokenPayload, Tokens } from './types/token'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); - private readonly FRONTEND_URL: string; - private readonly APP_DOMAIN?: string; - private readonly COOKIE_EXPIRES_IN: string; - private readonly JWT_SECRET: string; - private readonly JWT_EXPIRES_IN: string; - private readonly JWT_REFRESH_SECRET: string; - private readonly JWT_REFRESH_EXPIRES_IN: string; - private readonly WHITELISTED_USERS: string; constructor( @Inject(UserService) private readonly userService: UserService, + @Inject(JwtService) private readonly jwtService: JwtService, - private readonly configService: ConfigService, - ) { - const config = { - FRONTEND_URL: configService.get('FRONTEND_URL'), - APP_DOMAIN: - configService.get('APP_DOMAIN').length > 0 - ? configService.get('APP_DOMAIN') - : undefined, - COOKIE_EXPIRES_IN: - configService.get('COOKIE_EXPIRES_IN') || String(60 * 60 * 24 * 7), // 7 days - JWT_SECRET: this.configService.get('JWT_SECRET'), - JWT_EXPIRES_IN: this.configService.get('JWT_EXPIRES_IN'), - JWT_REFRESH_SECRET: this.configService.get('JWT_REFRESH_SECRET'), - JWT_REFRESH_EXPIRES_IN: this.configService.get('JWT_REFRESH_EXPIRES_IN'), - WHITELISTED_USERS: this.configService.get('WHITELISTED_USERS'), - }; - - this.FRONTEND_URL = config.FRONTEND_URL; - this.APP_DOMAIN = config.APP_DOMAIN; - this.COOKIE_EXPIRES_IN = config.COOKIE_EXPIRES_IN; - this.JWT_SECRET = config.JWT_SECRET; - this.JWT_EXPIRES_IN = config.JWT_EXPIRES_IN; - this.JWT_REFRESH_SECRET = config.JWT_REFRESH_SECRET; - this.JWT_REFRESH_EXPIRES_IN = config.JWT_REFRESH_EXPIRES_IN; - - this.WHITELISTED_USERS = config.WHITELISTED_USERS - ? config.WHITELISTED_USERS.toLowerCase().split(',') - : []; - } + @Inject('FRONTEND_URL') + private readonly FRONTEND_URL: string, + + @Inject('COOKIE_EXPIRES_IN') + private readonly COOKIE_EXPIRES_IN: string, + @Inject('JWT_SECRET') + private readonly JWT_SECRET: string, + @Inject('JWT_EXPIRES_IN') + private readonly JWT_EXPIRES_IN: string, + @Inject('JWT_REFRESH_SECRET') + private readonly JWT_REFRESH_SECRET: string, + @Inject('JWT_REFRESH_EXPIRES_IN') + private readonly JWT_REFRESH_EXPIRES_IN: string, + @Inject('WHITELISTED_USERS') + private readonly WHITELISTED_USERS: string, + @Inject('APP_DOMAIN') + private readonly APP_DOMAIN?: string, + ) {} public async verifyToken(req: Request, res: Response) { const headers = req.headers; diff --git a/server/src/auth/strategies/JWT.strategy.spec.ts b/server/src/auth/strategies/JWT.strategy.spec.ts new file mode 100644 index 00000000..497df6c5 --- /dev/null +++ b/server/src/auth/strategies/JWT.strategy.spec.ts @@ -0,0 +1,92 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Request } from 'express'; + +import { JwtStrategy } from './JWT.strategy'; + +describe('JwtStrategy', () => { + let jwtStrategy: JwtStrategy; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtStrategy, + { + provide: ConfigService, + useValue: { + getOrThrow: jest.fn().mockReturnValue('test-secret'), + }, + }, + ], + }).compile(); + + jwtStrategy = module.get(JwtStrategy); + configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(jwtStrategy).toBeDefined(); + }); + + describe('constructor', () => { + it('should throw an error if JWT_SECRET is not set', () => { + jest.spyOn(configService, 'getOrThrow').mockReturnValue(null); + + expect(() => new JwtStrategy(configService)).toThrowError( + 'JwtStrategy requires a secret or key', + ); + }); + }); + + describe('validate', () => { + it('should return payload with refreshToken from header', () => { + const req = { + headers: { + authorization: 'Bearer test-refresh-token', + }, + cookies: {}, + } as unknown as Request; + + const payload = { userId: 'test-user-id' }; + + const result = jwtStrategy.validate(req, payload); + + expect(result).toEqual({ + ...payload, + refreshToken: 'test-refresh-token', + }); + }); + + it('should return payload with refreshToken from cookie', () => { + const req = { + headers: {}, + cookies: { + refresh_token: 'test-refresh-token', + }, + } as unknown as Request; + + const payload = { userId: 'test-user-id' }; + + const result = jwtStrategy.validate(req, payload); + + expect(result).toEqual({ + ...payload, + refreshToken: 'test-refresh-token', + }); + }); + + it('should throw an error if no refresh token is provided', () => { + const req = { + headers: {}, + cookies: {}, + } as unknown as Request; + + const payload = { userId: 'test-user-id' }; + + expect(() => jwtStrategy.validate(req, payload)).toThrowError( + 'No refresh token', + ); + }); + }); +}); diff --git a/server/src/auth/strategies/JWT.strategy.ts b/server/src/auth/strategies/JWT.strategy.ts index 2b634878..6311d4e4 100644 --- a/server/src/auth/strategies/JWT.strategy.ts +++ b/server/src/auth/strategies/JWT.strategy.ts @@ -8,12 +8,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; export class JwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { private static logger = new Logger(JwtStrategy.name); constructor(@Inject(ConfigService) config: ConfigService) { - const JWT_SECRET = config.get('JWT_SECRET'); - - if (!JWT_SECRET) { - Logger.error('JWT_SECRET is not set'); - throw new Error('JWT_SECRET is not set'); - } + const JWT_SECRET = config.getOrThrow('JWT_SECRET'); super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), diff --git a/server/src/auth/strategies/discord.strategy.spec.ts b/server/src/auth/strategies/discord.strategy.spec.ts new file mode 100644 index 00000000..a4251eb8 --- /dev/null +++ b/server/src/auth/strategies/discord.strategy.spec.ts @@ -0,0 +1,67 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { DiscordStrategy } from './discord.strategy'; + +describe('DiscordStrategy', () => { + let discordStrategy: DiscordStrategy; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DiscordStrategy, + { + provide: ConfigService, + useValue: { + getOrThrow: jest.fn((key: string) => { + switch (key) { + case 'DISCORD_CLIENT_ID': + return 'test-client-id'; + case 'DISCORD_CLIENT_SECRET': + return 'test-client-secret'; + case 'SERVER_URL': + return 'http://localhost:3000'; + default: + return null; + } + }), + }, + }, + ], + }).compile(); + + discordStrategy = module.get(DiscordStrategy); + configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(discordStrategy).toBeDefined(); + }); + + describe('constructor', () => { + it('should throw an error if Discord config is missing', () => { + jest.spyOn(configService, 'getOrThrow').mockReturnValueOnce(null); + + expect(() => new DiscordStrategy(configService)).toThrowError( + 'OAuth2Strategy requires a clientID option', + ); + }); + }); + + describe('validate', () => { + it('should return accessToken, refreshToken, and profile', async () => { + const accessToken = 'test-access-token'; + const refreshToken = 'test-refresh-token'; + const profile = { id: 'test-id', username: 'Test User' }; + + const result = await discordStrategy.validate( + accessToken, + refreshToken, + profile, + ); + + expect(result).toEqual({ accessToken, refreshToken, profile }); + }); + }); +}); diff --git a/server/src/auth/strategies/discord.strategy.ts b/server/src/auth/strategies/discord.strategy.ts index 535c1cf4..6164b4cf 100644 --- a/server/src/auth/strategies/discord.strategy.ts +++ b/server/src/auth/strategies/discord.strategy.ts @@ -10,17 +10,14 @@ export class DiscordStrategy extends PassportStrategy(strategy, 'discord') { @Inject(ConfigService) configService: ConfigService, ) { - const DISCORD_CLIENT_ID = configService.get('DISCORD_CLIENT_ID'); + const DISCORD_CLIENT_ID = + configService.getOrThrow('DISCORD_CLIENT_ID'); - const DISCORD_CLIENT_SECRET = configService.get( + const DISCORD_CLIENT_SECRET = configService.getOrThrow( 'DISCORD_CLIENT_SECRET', ); - const SERVER_URL = configService.get('SERVER_URL'); - - if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET || !SERVER_URL) { - throw new Error('Missing Discord config'); - } + const SERVER_URL = configService.getOrThrow('SERVER_URL'); super({ clientID: DISCORD_CLIENT_ID, diff --git a/server/src/auth/strategies/github.strategy.spec.ts b/server/src/auth/strategies/github.strategy.spec.ts new file mode 100644 index 00000000..c8793e00 --- /dev/null +++ b/server/src/auth/strategies/github.strategy.spec.ts @@ -0,0 +1,67 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { GithubStrategy } from './github.strategy'; + +describe('GithubStrategy', () => { + let githubStrategy: GithubStrategy; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GithubStrategy, + { + provide: ConfigService, + useValue: { + getOrThrow: jest.fn((key: string) => { + switch (key) { + case 'GITHUB_CLIENT_ID': + return 'test-client-id'; + case 'GITHUB_CLIENT_SECRET': + return 'test-client-secret'; + case 'SERVER_URL': + return 'http://localhost:3000'; + default: + return null; + } + }), + }, + }, + ], + }).compile(); + + githubStrategy = module.get(GithubStrategy); + configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(githubStrategy).toBeDefined(); + }); + + describe('constructor', () => { + it('should throw an error if GitHub config is missing', () => { + jest.spyOn(configService, 'getOrThrow').mockReturnValueOnce(null); + + expect(() => new GithubStrategy(configService)).toThrowError( + 'OAuth2Strategy requires a clientID option', + ); + }); + }); + + describe('validate', () => { + it('should return accessToken, refreshToken, and profile', async () => { + const accessToken = 'test-access-token'; + const refreshToken = 'test-refresh-token'; + const profile = { id: 'test-id', displayName: 'Test User' }; + + const result = await githubStrategy.validate( + accessToken, + refreshToken, + profile, + ); + + expect(result).toEqual({ accessToken, refreshToken, profile }); + }); + }); +}); diff --git a/server/src/auth/strategies/github.strategy.ts b/server/src/auth/strategies/github.strategy.ts index c5eeb45d..27293151 100644 --- a/server/src/auth/strategies/github.strategy.ts +++ b/server/src/auth/strategies/github.strategy.ts @@ -10,17 +10,14 @@ export class GithubStrategy extends PassportStrategy(strategy, 'github') { @Inject(ConfigService) configService: ConfigService, ) { - const GITHUB_CLIENT_ID = configService.get('GITHUB_CLIENT_ID'); + const GITHUB_CLIENT_ID = + configService.getOrThrow('GITHUB_CLIENT_ID'); - const GITHUB_CLIENT_SECRET = configService.get( + const GITHUB_CLIENT_SECRET = configService.getOrThrow( 'GITHUB_CLIENT_SECRET', ); - const SERVER_URL = configService.get('SERVER_URL'); - - if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET || !SERVER_URL) { - throw new Error('Missing GitHub config'); - } + const SERVER_URL = configService.getOrThrow('SERVER_URL'); super({ clientID: GITHUB_CLIENT_ID, diff --git a/server/src/auth/strategies/google.strategy.spec.ts b/server/src/auth/strategies/google.strategy.spec.ts new file mode 100644 index 00000000..c1f1233e --- /dev/null +++ b/server/src/auth/strategies/google.strategy.spec.ts @@ -0,0 +1,65 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { VerifyCallback } from 'passport-google-oauth20'; + +import { GoogleStrategy } from './google.strategy'; + +describe('GoogleStrategy', () => { + let googleStrategy: GoogleStrategy; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GoogleStrategy, + { + provide: ConfigService, + useValue: { + getOrThrow: jest.fn((key: string) => { + switch (key) { + case 'GOOGLE_CLIENT_ID': + return 'test-client-id'; + case 'GOOGLE_CLIENT_SECRET': + return 'test-client-secret'; + case 'SERVER_URL': + return 'http://localhost:3000'; + default: + return null; + } + }), + }, + }, + ], + }).compile(); + + googleStrategy = module.get(GoogleStrategy); + configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(googleStrategy).toBeDefined(); + }); + + describe('constructor', () => { + it('should throw an error if Google config is missing', () => { + jest.spyOn(configService, 'getOrThrow').mockReturnValueOnce(null); + + expect(() => new GoogleStrategy(configService)).toThrowError( + 'OAuth2Strategy requires a clientID option', + ); + }); + }); + + describe('validate', () => { + it('should call done with profile', () => { + const accessToken = 'test-access-token'; + const refreshToken = 'test-refresh-token'; + const profile = { id: 'test-id', displayName: 'Test User' }; + const done: VerifyCallback = jest.fn(); + + googleStrategy.validate(accessToken, refreshToken, profile, done); + + expect(done).toHaveBeenCalledWith(null, profile); + }); + }); +}); diff --git a/server/src/auth/strategies/google.strategy.ts b/server/src/auth/strategies/google.strategy.ts index fcd191d8..a19e1789 100644 --- a/server/src/auth/strategies/google.strategy.ts +++ b/server/src/auth/strategies/google.strategy.ts @@ -10,17 +10,14 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { @Inject(ConfigService) configService: ConfigService, ) { - const GOOGLE_CLIENT_ID = configService.get('GOOGLE_CLIENT_ID'); + const GOOGLE_CLIENT_ID = + configService.getOrThrow('GOOGLE_CLIENT_ID'); - const GOOGLE_CLIENT_SECRET = configService.get( + const GOOGLE_CLIENT_SECRET = configService.getOrThrow( 'GOOGLE_CLIENT_SECRET', ); - const SERVER_URL = configService.get('SERVER_URL'); - - if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET || !SERVER_URL) { - throw new Error('Missing Google config'); - } + const SERVER_URL = configService.getOrThrow('SERVER_URL'); const callbackURL = `${SERVER_URL}/api/v1/auth/google/callback`; GoogleStrategy.logger.debug(`Google Login callbackURL ${callbackURL}`); diff --git a/server/src/file/file.module.ts b/server/src/file/file.module.ts index de58d639..147f658c 100644 --- a/server/src/file/file.module.ts +++ b/server/src/file/file.module.ts @@ -1,9 +1,48 @@ -import { Module } from '@nestjs/common'; +import { DynamicModule, Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { FileService } from './file.service'; -@Module({ - providers: [FileService], - exports: [FileService], -}) -export class FileModule {} +@Module({}) +export class FileModule { + static forRootAsync(): DynamicModule { + return { + module: FileModule, + imports: [ConfigModule.forRoot()], + providers: [ + { + provide: 'S3_BUCKET_SONGS', + useValue: (configService: ConfigService) => + configService.getOrThrow('S3_BUCKET_SONGS'), + }, + { + provide: 'S3_BUCKET_THUMBS', + useValue: (configService: ConfigService) => + configService.getOrThrow('S3_BUCKET_THUMBS'), + }, + { + provide: 'S3_KEY', + useValue: (configService: ConfigService) => + configService.getOrThrow('S3_KEY'), + }, + { + provide: 'S3_SECRET', + useValue: (configService: ConfigService) => + configService.getOrThrow('S3_SECRET'), + }, + { + provide: 'S3_ENDPOINT', + useValue: (configService: ConfigService) => + configService.getOrThrow('S3_ENDPOINT'), + }, + { + provide: 'S3_REGION', + useValue: (configService: ConfigService) => + configService.getOrThrow('S3_REGION'), + }, + FileService, + ], + exports: [FileService], + }; + } +} diff --git a/server/src/file/file.service.spec.ts b/server/src/file/file.service.spec.ts new file mode 100644 index 00000000..569be7aa --- /dev/null +++ b/server/src/file/file.service.spec.ts @@ -0,0 +1,233 @@ +import { + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { FileService } from './file.service'; + +jest.mock('@aws-sdk/client-s3', () => { + const mS3Client = { + send: jest.fn(), + }; + + return { + S3Client: jest.fn(() => mS3Client), + GetObjectCommand: jest.fn(), + PutObjectCommand: jest.fn(), + ObjectCannedACL: { + private: 'private', + public_read: 'public-read', + }, + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn(), +})); + +describe('FileService', () => { + let fileService: FileService; + let s3Client: S3Client; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FileService, + { + provide: 'S3_BUCKET_THUMBS', + useValue: 'test-bucket-thumbs', + }, + { + provide: 'S3_BUCKET_SONGS', + useValue: 'test-bucket-songs', + }, + { + provide: 'S3_KEY', + useValue: 'test-key', + }, + { + provide: 'S3_SECRET', + useValue: 'test-secret', + }, + { + provide: 'S3_ENDPOINT', + useValue: 'test-endpoint', + }, + { + provide: 'S3_REGION', + useValue: 'test-region', + }, + ], + }).compile(); + + fileService = module.get(FileService); + + s3Client = new S3Client({}); + }); + + it('should be defined', () => { + expect(fileService).toBeDefined(); + }); + + it('should throw an error if S3 configuration is missing', () => { + expect(() => { + new FileService( + '', + 'test-bucket-thumbs', + 'test-key', + 'test-secret', + 'test-endpoint', + 'test-region', + ); + }).toThrow('Missing S3 bucket configuration'); + }); + + it('should upload a song', async () => { + const buffer = Buffer.from('test'); + const publicId = 'test-id'; + const mockResponse = { ETag: 'mock-etag' }; + (s3Client.send as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await fileService.uploadSong(buffer, publicId); + expect(result).toBe('songs/test-id.nbs'); + expect(s3Client.send).toHaveBeenCalledWith(expect.any(PutObjectCommand)); + }); + + it('should throw an error if song upload fails', async () => { + const buffer = Buffer.from('test'); + const publicId = 'test-id'; + + (s3Client.send as jest.Mock).mockRejectedValueOnce( + new Error('Upload failed'), + ); + + await expect(fileService.uploadSong(buffer, publicId)).rejects.toThrow( + 'Upload failed', + ); + }); + + it('should get a signed URL for a song download', async () => { + const key = 'test-key'; + const filename = 'test-file.nbs'; + const mockUrl = 'https://mock-signed-url'; + (getSignedUrl as jest.Mock).mockResolvedValueOnce(mockUrl); + + const result = await fileService.getSongDownloadUrl(key, filename); + expect(result).toBe(mockUrl); + + expect(getSignedUrl).toHaveBeenCalledWith( + s3Client, + expect.any(GetObjectCommand), + { expiresIn: 120 }, + ); + }); + + it('should throw an error if signed URL generation fails', async () => { + const key = 'test-key'; + const filename = 'test-file.nbs'; + + (getSignedUrl as jest.Mock).mockRejectedValueOnce( + new Error('Signed URL generation failed'), + ); + + await expect(fileService.getSongDownloadUrl(key, filename)).rejects.toThrow( + 'Signed URL generation failed', + ); + }); + + it('should upload a thumbnail', async () => { + const buffer = Buffer.from('test'); + const publicId = 'test-id'; + const mockResponse = { ETag: 'mock-etag' }; + (s3Client.send as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await fileService.uploadThumbnail(buffer, publicId); + + expect(result).toBe( + 'https://test-bucket-thumbs.s3.test-region.backblazeb2.com/thumbs/test-id.png', + ); + + expect(s3Client.send).toHaveBeenCalledWith(expect.any(PutObjectCommand)); + }); + + it('should delete a song', async () => { + const nbsFileUrl = 'test-file.nbs'; + const mockResponse = {}; + (s3Client.send as jest.Mock).mockResolvedValueOnce(mockResponse); + + await fileService.deleteSong(nbsFileUrl); + expect(s3Client.send).toHaveBeenCalledWith(expect.any(GetObjectCommand)); + }); + + it('should throw an error if song deletion fails', async () => { + const nbsFileUrl = 'test-file.nbs'; + + (s3Client.send as jest.Mock).mockRejectedValueOnce( + new Error('Deletion failed'), + ); + + await expect(fileService.deleteSong(nbsFileUrl)).rejects.toThrow( + 'Deletion failed', + ); + }); + + it('should get a song file', async () => { + const nbsFileUrl = 'test-file.nbs'; + + const mockResponse = { + Body: { + transformToByteArray: jest + .fn() + .mockResolvedValueOnce(new Uint8Array([1, 2, 3])), + }, + }; + + (s3Client.send as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await fileService.getSongFile(nbsFileUrl); + expect(result).toEqual(new Uint8Array([1, 2, 3]).buffer); + expect(s3Client.send).toHaveBeenCalledWith(expect.any(GetObjectCommand)); + }); + + it('should throw an error if song file retrieval fails', async () => { + const nbsFileUrl = 'test-file.nbs'; + + (s3Client.send as jest.Mock).mockRejectedValueOnce( + new Error('Retrieval failed'), + ); + + await expect(fileService.getSongFile(nbsFileUrl)).rejects.toThrow( + 'Retrieval failed', + ); + }); + + it('should throw an error if song file is empty', async () => { + const nbsFileUrl = 'test-file.nbs'; + + const mockResponse = { + Body: { + transformToByteArray: jest.fn().mockResolvedValueOnce(null), + }, + }; + + (s3Client.send as jest.Mock).mockResolvedValueOnce(mockResponse); + + await expect(fileService.getSongFile(nbsFileUrl)).rejects.toThrow( + 'Error getting file', + ); + }); + + it('should get upload a packed song', async () => { + const buffer = Buffer.from('test'); + const publicId = 'test-id'; + const mockResponse = { ETag: 'mock-etag' }; + (s3Client.send as jest.Mock).mockResolvedValueOnce(mockResponse); + + const result = await fileService.uploadPackedSong(buffer, publicId); + expect(result).toBe('packed/test-id.zip'); + expect(s3Client.send).toHaveBeenCalledWith(expect.any(PutObjectCommand)); + }); +}); diff --git a/server/src/file/file.service.ts b/server/src/file/file.service.ts index dc80a476..9b0061d9 100644 --- a/server/src/file/file.service.ts +++ b/server/src/file/file.service.ts @@ -7,22 +7,35 @@ import { S3Client, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Inject, Injectable, Logger } from '@nestjs/common'; @Injectable() export class FileService { - s3Client: S3Client; - region: string; - bucketSongs: string; - bucketThumbs: string; - - constructor(private readonly configService: ConfigService) { + private readonly logger = new Logger(FileService.name); + private s3Client: S3Client; + private region: string; + private bucketSongs: string; + private bucketThumbs: string; + + constructor( + @Inject('S3_BUCKET_SONGS') + private readonly S3_BUCKET_SONGS: string, + @Inject('S3_BUCKET_THUMBS') + private readonly S3_BUCKET_THUMBS: string, + + @Inject('S3_KEY') + private readonly S3_KEY: string, + @Inject('S3_SECRET') + private readonly S3_SECRET: string, + @Inject('S3_ENDPOINT') + private readonly S3_ENDPOINT: string, + @Inject('S3_REGION') + private readonly S3_REGION: string, + ) { + const bucketSongs = S3_BUCKET_SONGS; + const bucketThumbs = S3_BUCKET_THUMBS; this.s3Client = this.getS3Client(); - const bucketSongs = this.configService.get('S3_BUCKET_SONGS'); - const bucketThumbs = this.configService.get('S3_BUCKET_THUMBS'); - if (!(bucketSongs && bucketThumbs)) { throw new Error('Missing S3 bucket configuration'); } @@ -33,14 +46,10 @@ export class FileService { private getS3Client() { // Load environment variables - const key = this.configService.get('S3_KEY'); - const secret = this.configService.get('S3_SECRET'); - const endpoint = this.configService.get('S3_ENDPOINT'); - const region = this.configService.get('S3_REGION'); - - if (!(key && secret && endpoint && region)) { - throw new Error('Missing S3 configuration'); - } + const key = this.S3_KEY; + const secret = this.S3_SECRET; + const endpoint = this.S3_ENDPOINT; + const region = this.S3_REGION; this.region = region; @@ -153,7 +162,7 @@ export class FileService { try { await this.s3Client.send(command); } catch (error) { - console.error('Error deleting file: ', error); + this.logger.error('Error deleting file: ', error); throw error; } @@ -185,7 +194,7 @@ export class FileService { const s3Response = await this.s3Client.send(command); return s3Response; } catch (error) { - console.error('Error uploading file: ', error); + this.logger.error('Error uploading file: ', error); throw error; } } @@ -208,7 +217,7 @@ export class FileService { return byteArray.buffer; } catch (error) { - console.error('Error getting file: ', error); + this.logger.error('Error getting file: ', error); throw error; } } diff --git a/server/src/initializeSwagger.spec.ts b/server/src/initializeSwagger.spec.ts new file mode 100644 index 00000000..d3c30804 --- /dev/null +++ b/server/src/initializeSwagger.spec.ts @@ -0,0 +1,46 @@ +import { INestApplication } from '@nestjs/common'; +import { SwaggerModule } from '@nestjs/swagger'; + +import { initializeSwagger } from './initializeSwagger'; + +jest.mock('@nestjs/swagger', () => ({ + DocumentBuilder: jest.fn().mockImplementation(() => ({ + setTitle: jest.fn().mockReturnThis(), + setDescription: jest.fn().mockReturnThis(), + setVersion: jest.fn().mockReturnThis(), + addBearerAuth: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), + })), + SwaggerModule: { + createDocument: jest.fn().mockReturnValue({}), + setup: jest.fn(), + }, +})); + +describe('initializeSwagger', () => { + let app: INestApplication; + + beforeEach(() => { + app = {} as INestApplication; + }); + + it('should initialize Swagger with the correct configuration', () => { + initializeSwagger(app); + + expect(SwaggerModule.createDocument).toHaveBeenCalledWith( + app, + expect.any(Object), + ); + + expect(SwaggerModule.setup).toHaveBeenCalledWith( + 'api/doc', + app, + expect.any(Object), + { + swaggerOptions: { + persistAuthorization: true, + }, + }, + ); + }); +}); diff --git a/server/src/parseToken.spec.ts b/server/src/parseToken.spec.ts new file mode 100644 index 00000000..8099ef34 --- /dev/null +++ b/server/src/parseToken.spec.ts @@ -0,0 +1,83 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { AuthService } from './auth/auth.service'; +import { ParseTokenPipe } from './parseToken'; + +describe('ParseTokenPipe', () => { + let parseTokenPipe: ParseTokenPipe; + let authService: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ParseTokenPipe, + { + provide: AuthService, + useValue: { + getUserFromToken: jest.fn(), + }, + }, + ], + }).compile(); + + parseTokenPipe = module.get(ParseTokenPipe); + authService = module.get(AuthService); + }); + + it('should be defined', () => { + expect(parseTokenPipe).toBeDefined(); + }); + + describe('canActivate', () => { + it('should return true if no authorization header is present', async () => { + const mockExecutionContext = { + switchToHttp: jest.fn().mockReturnThis(), + getRequest: jest.fn().mockReturnValue({ headers: {} }), + } as unknown as ExecutionContext; + + const result = await parseTokenPipe.canActivate(mockExecutionContext); + + expect(result).toBe(true); + }); + + it('should return true if user is not found from token', async () => { + const mockExecutionContext = { + switchToHttp: jest.fn().mockReturnThis(), + getRequest: jest.fn().mockReturnValue({ + headers: { authorization: 'Bearer test-token' }, + }), + } as unknown as ExecutionContext; + + jest.spyOn(authService, 'getUserFromToken').mockResolvedValue(null); + + const result = await parseTokenPipe.canActivate(mockExecutionContext); + + expect(result).toBe(true); + expect(authService.getUserFromToken).toHaveBeenCalledWith('test-token'); + }); + + it('should set existingUser on request and return true if user is found from token', async () => { + const mockUser = { _id: 'test-id', username: 'testuser' } as any; + + const mockExecutionContext = { + switchToHttp: jest.fn().mockReturnThis(), + getRequest: jest.fn().mockReturnValue({ + headers: { authorization: 'Bearer test-token' }, + existingUser: null, + }), + } as unknown as ExecutionContext; + + jest.spyOn(authService, 'getUserFromToken').mockResolvedValue(mockUser); + + const result = await parseTokenPipe.canActivate(mockExecutionContext); + + expect(result).toBe(true); + expect(authService.getUserFromToken).toHaveBeenCalledWith('test-token'); + + expect( + mockExecutionContext.switchToHttp().getRequest().existingUser, + ).toEqual(mockUser); + }); + }); +}); diff --git a/server/src/song-browser/song-browser.controller.spec.ts b/server/src/song-browser/song-browser.controller.spec.ts new file mode 100644 index 00000000..d31f48d1 --- /dev/null +++ b/server/src/song-browser/song-browser.controller.spec.ts @@ -0,0 +1,102 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; +import { FeaturedSongsDto } from '@shared/validation/song/dto/FeaturedSongsDto.dtc'; +import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto'; + +import { SongBrowserController } from './song-browser.controller'; +import { SongBrowserService } from './song-browser.service'; + +const mockSongBrowserService = { + getFeaturedSongs: jest.fn(), + getRecentSongs: jest.fn(), + getCategories: jest.fn(), + getSongsByCategory: jest.fn(), +}; + +describe('SongBrowserController', () => { + let controller: SongBrowserController; + let songBrowserService: SongBrowserService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SongBrowserController], + providers: [ + { + provide: SongBrowserService, + useValue: mockSongBrowserService, + }, + ], + }).compile(); + + controller = module.get(SongBrowserController); + songBrowserService = module.get(SongBrowserService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getFeaturedSongs', () => { + it('should return a list of featured songs', async () => { + const featuredSongs: FeaturedSongsDto = {} as FeaturedSongsDto; + + mockSongBrowserService.getFeaturedSongs.mockResolvedValueOnce( + featuredSongs, + ); + + const result = await controller.getFeaturedSongs(); + + expect(result).toEqual(featuredSongs); + expect(songBrowserService.getFeaturedSongs).toHaveBeenCalled(); + }); + }); + + describe('getSongList', () => { + it('should return a list of recent songs', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = []; + + mockSongBrowserService.getRecentSongs.mockResolvedValueOnce(songList); + + const result = await controller.getSongList(query); + + expect(result).toEqual(songList); + expect(songBrowserService.getRecentSongs).toHaveBeenCalledWith(query); + }); + }); + + describe('getCategories', () => { + it('should return a list of song categories and song counts', async () => { + const categories: Record = { + category1: 10, + category2: 5, + }; + + mockSongBrowserService.getCategories.mockResolvedValueOnce(categories); + + const result = await controller.getCategories(); + + expect(result).toEqual(categories); + expect(songBrowserService.getCategories).toHaveBeenCalled(); + }); + }); + + describe('getSongsByCategory', () => { + it('should return a list of songs by category', async () => { + const id = 'test-category'; + const query: PageQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = []; + + mockSongBrowserService.getSongsByCategory.mockResolvedValueOnce(songList); + + const result = await controller.getSongsByCategory(id, query); + + expect(result).toEqual(songList); + + expect(songBrowserService.getSongsByCategory).toHaveBeenCalledWith( + id, + query, + ); + }); + }); +}); diff --git a/server/src/song-browser/song-browser.service.spec.ts b/server/src/song-browser/song-browser.service.spec.ts new file mode 100644 index 00000000..94462d30 --- /dev/null +++ b/server/src/song-browser/song-browser.service.spec.ts @@ -0,0 +1,138 @@ +import { HttpException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; +import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto'; + +import { SongService } from '@server/song/song.service'; + +import { SongBrowserService } from './song-browser.service'; +import { SongWithUser } from '../song/entity/song.entity'; + +const mockSongService = { + getSongsForTimespan: jest.fn(), + getSongsBeforeTimespan: jest.fn(), + getRecentSongs: jest.fn(), + getCategories: jest.fn(), + getSongsByCategory: jest.fn(), +}; + +describe('SongBrowserService', () => { + let service: SongBrowserService; + let songService: SongService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SongBrowserService, + { + provide: SongService, + useValue: mockSongService, + }, + ], + }).compile(); + + service = module.get(SongBrowserService); + songService = module.get(SongService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getFeaturedSongs', () => { + it('should return featured songs', async () => { + const songWithUser: SongWithUser = { + title: 'Test Song', + uploader: { username: 'testuser', profileImage: 'testimage' }, + stats: { + duration: 100, + noteCount: 100, + }, + } as any; + + jest + .spyOn(songService, 'getSongsForTimespan') + .mockResolvedValue([songWithUser]); + + jest + .spyOn(songService, 'getSongsBeforeTimespan') + .mockResolvedValue([songWithUser]); + + await service.getFeaturedSongs(); + + expect(songService.getSongsForTimespan).toHaveBeenCalled(); + expect(songService.getSongsBeforeTimespan).toHaveBeenCalled(); + }); + }); + + describe('getRecentSongs', () => { + it('should return recent songs', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + + const songPreviewDto: SongPreviewDto = { + title: 'Test Song', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as any; + + jest + .spyOn(songService, 'getRecentSongs') + .mockResolvedValue([songPreviewDto]); + + const result = await service.getRecentSongs(query); + + expect(result).toEqual([songPreviewDto]); + + expect(songService.getRecentSongs).toHaveBeenCalledWith( + query.page, + query.limit, + ); + }); + + it('should throw an error if query parameters are invalid', async () => { + const query: PageQueryDTO = { page: undefined, limit: undefined }; + + await expect(service.getRecentSongs(query)).rejects.toThrow( + HttpException, + ); + }); + }); + + describe('getCategories', () => { + it('should return categories', async () => { + const categories = { pop: 10, rock: 5 }; + + jest.spyOn(songService, 'getCategories').mockResolvedValue(categories); + + const result = await service.getCategories(); + + expect(result).toEqual(categories); + expect(songService.getCategories).toHaveBeenCalled(); + }); + }); + + describe('getSongsByCategory', () => { + it('should return songs by category', async () => { + const category = 'pop'; + const query: PageQueryDTO = { page: 1, limit: 10 }; + + const songPreviewDto: SongPreviewDto = { + title: 'Test Song', + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as any; + + jest + .spyOn(songService, 'getSongsByCategory') + .mockResolvedValue([songPreviewDto]); + + const result = await service.getSongsByCategory(category, query); + + expect(result).toEqual([songPreviewDto]); + + expect(songService.getSongsByCategory).toHaveBeenCalledWith( + category, + query.page, + query.limit, + ); + }); + }); +}); diff --git a/server/src/song/my-songs/my-songs.controller.spec.ts b/server/src/song/my-songs/my-songs.controller.spec.ts new file mode 100644 index 00000000..f25abd6d --- /dev/null +++ b/server/src/song/my-songs/my-songs.controller.spec.ts @@ -0,0 +1,86 @@ +import { HttpException } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; +import { SongPageDto } from '@shared/validation/song/dto/SongPageDto'; + +import { UserDocument } from '@server/user/entity/user.entity'; + +import { MySongsController } from './my-songs.controller'; +import { SongService } from '../song.service'; + +const mockSongService = { + getMySongsPage: jest.fn(), +}; + +describe('MySongsController', () => { + let controller: MySongsController; + let songService: SongService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [MySongsController], + providers: [ + { + provide: SongService, + useValue: mockSongService, + }, + ], + }) + .overrideGuard(AuthGuard('jwt-refresh')) + .useValue({ canActivate: jest.fn(() => true) }) + .compile(); + + controller = module.get(MySongsController); + songService = module.get(SongService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getMySongsPage', () => { + it('should return a list of songs uploaded by the current authenticated user', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const songPageDto: SongPageDto = { + content: [], + page: 0, + limit: 0, + total: 0, + }; + + mockSongService.getMySongsPage.mockResolvedValueOnce(songPageDto); + + const result = await controller.getMySongsPage(query, user); + + expect(result).toEqual(songPageDto); + expect(songService.getMySongsPage).toHaveBeenCalledWith({ query, user }); + }); + + it('should handle thrown an exception if userDocument is null', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const user = null; + const error = new Error('Test error'); + + mockSongService.getMySongsPage.mockRejectedValueOnce(error); + + await expect(controller.getMySongsPage(query, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should handle exceptions', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const error = new Error('Test error'); + + mockSongService.getMySongsPage.mockRejectedValueOnce(error); + + await expect(controller.getMySongsPage(query, user)).rejects.toThrow( + 'Test error', + ); + }); + }); +}); diff --git a/server/src/song/song-upload/song-upload.service.spec.ts b/server/src/song/song-upload/song-upload.service.spec.ts new file mode 100644 index 00000000..3c8a580d --- /dev/null +++ b/server/src/song/song-upload/song-upload.service.spec.ts @@ -0,0 +1,434 @@ +import { + Instrument, + Layer, + Note, + Song, + fromArrayBuffer, +} from '@encode42/nbs.js'; +import { HttpException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ThumbnailData } from '@shared/validation/song/dto/ThumbnailData.dto'; +import { UploadSongDto } from '@shared/validation/song/dto/UploadSongDto.dto'; +import { Types } from 'mongoose'; + +import { FileService } from '@server/file/file.service'; +import { UserDocument } from '@server/user/entity/user.entity'; +import { UserService } from '@server/user/user.service'; + +import { SongUploadService } from './song-upload.service'; +import { SongDocument, Song as SongEntity } from '../entity/song.entity'; + +// mock drawToImage function +jest.mock('@shared/features/thumbnail', () => ({ + drawToImage: jest.fn().mockResolvedValue(Buffer.from('test')), +})); + +const mockFileService = { + uploadSong: jest.fn(), + uploadPackedSong: jest.fn(), + uploadThumbnail: jest.fn(), + getSongFile: jest.fn(), +}; + +const mockUserService = { + findByID: jest.fn(), +}; + +describe('SongUploadService', () => { + let songUploadService: SongUploadService; + let fileService: FileService; + let _userService: UserService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SongUploadService, + { + provide: FileService, + useValue: mockFileService, + }, + { + provide: UserService, + useValue: mockUserService, + }, + ], + }).compile(); + + songUploadService = module.get(SongUploadService); + fileService = module.get(FileService); + _userService = module.get(UserService); + }); + + it('should be defined', () => { + expect(songUploadService).toBeDefined(); + }); + + describe('processUploadedSong', () => { + it('should process and upload a song', async () => { + const file = { buffer: Buffer.from('test') } as Express.Multer.File; + + const user: UserDocument = { + _id: new Types.ObjectId(), + username: 'testuser', + } as UserDocument; + + const body: UploadSongDto = { + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + allowDownload: true, + file: 'somebytes', + }; + + const songEntity = new SongEntity(); + songEntity.uploader = user._id; + + jest + .spyOn(songUploadService as any, 'checkIsFileValid') + .mockImplementation((_file: Express.Multer.File) => undefined); + + jest + .spyOn(songUploadService as any, 'prepareSongForUpload') + .mockReturnValue({ + nbsSong: new Song(), + songBuffer: Buffer.from('test'), + }); + + jest + .spyOn(songUploadService as any, 'preparePackedSongForUpload') + .mockResolvedValue(Buffer.from('test')); + + jest + .spyOn(songUploadService as any, 'generateSongDocument') + .mockResolvedValue(songEntity); + + jest + .spyOn(songUploadService, 'generateAndUploadThumbnail') + .mockResolvedValue('http://test.com/thumbnail.png'); + + jest + .spyOn(songUploadService as any, 'uploadSongFile') + .mockResolvedValue('http://test.com/file.nbs'); + + jest + .spyOn(songUploadService as any, 'uploadPackedSongFile') + .mockResolvedValue('http://test.com/packed-file.nbs'); + + const result = await songUploadService.processUploadedSong({ + file, + user, + body, + }); + + expect(result).toEqual(songEntity); + + expect((songUploadService as any).checkIsFileValid).toHaveBeenCalledWith( + file, + ); + + expect( + (songUploadService as any).prepareSongForUpload, + ).toHaveBeenCalledWith(file.buffer, body, user); + + expect( + (songUploadService as any).preparePackedSongForUpload, + ).toHaveBeenCalledWith(expect.any(Song), body.customInstruments); + + expect(songUploadService.generateAndUploadThumbnail).toHaveBeenCalledWith( + body.thumbnailData, + expect.any(Song), + expect.any(String), + ); + + expect((songUploadService as any).uploadSongFile).toHaveBeenCalledWith( + expect.any(Buffer), + expect.any(String), + ); + + expect( + (songUploadService as any).uploadPackedSongFile, + ).toHaveBeenCalledWith(expect.any(Buffer), expect.any(String)); + }); + }); + + describe('processSongPatch', () => { + it('should process and patch a song', async () => { + const user: UserDocument = { + _id: new Types.ObjectId(), + username: 'testuser', + } as UserDocument; + + const body: UploadSongDto = { + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + allowDownload: true, + file: 'somebytes', + }; + + const songDocument: SongDocument = { + ...body, + publicId: 'test-id', + uploader: user._id, + customInstruments: [], + thumbnailData: body.thumbnailData, + nbsFileUrl: 'http://test.com/file.nbs', + save: jest.fn().mockResolvedValue({}), + } as any; + + jest + .spyOn(fileService, 'getSongFile') + .mockResolvedValue(Buffer.from('test')); + + jest + .spyOn(songUploadService as any, 'prepareSongForUpload') + .mockReturnValue({ + nbsSong: new Song(), + songBuffer: Buffer.from('test'), + }); + + jest + .spyOn(songUploadService as any, 'preparePackedSongForUpload') + .mockResolvedValue(Buffer.from('test')); + + jest + .spyOn(songUploadService, 'generateAndUploadThumbnail') + .mockResolvedValue('http://test.com/thumbnail.png'); + + jest + .spyOn(songUploadService as any, 'uploadSongFile') + .mockResolvedValue('http://test.com/file.nbs'); + + jest + .spyOn(songUploadService as any, 'uploadPackedSongFile') + .mockResolvedValue('http://test.com/packed-file.nbs'); + + await songUploadService.processSongPatch(songDocument, body, user); + }); + }); + + describe('generateAndUploadThumbnail', () => { + it('should generate and upload a thumbnail', async () => { + const thumbnailData: ThumbnailData = { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }; + + const nbsSong = new Song(); + nbsSong.addLayer(new Layer(1)); + nbsSong.addLayer(new Layer(2)); + const publicId = 'test-id'; + + jest + .spyOn(fileService, 'uploadThumbnail') + .mockResolvedValue('http://test.com/thumbnail.png'); + + const result = await songUploadService.generateAndUploadThumbnail( + thumbnailData, + nbsSong, + publicId, + ); + + expect(result).toBe('http://test.com/thumbnail.png'); + + expect(fileService.uploadThumbnail).toHaveBeenCalledWith( + expect.any(Buffer), + publicId, + ); + }); + + it('should throw an error if the thumbnail is invalid', async () => { + const thumbnailData: ThumbnailData = { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }; + + const nbsSong = new Song(); + const publicId = 'test-id'; + + jest + .spyOn(fileService, 'uploadThumbnail') + // throw an error + .mockRejectedValue(new Error('test error')); + + try { + await songUploadService.generateAndUploadThumbnail( + thumbnailData, + nbsSong, + publicId, + ); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + } + }); + }); + + describe('uploadSongFile', () => { + it('should upload a song file', async () => { + const file = Buffer.from('test'); + const publicId = 'test-id'; + + jest + .spyOn(fileService, 'uploadSong') + .mockResolvedValue('http://test.com/file.nbs'); + + const result = await (songUploadService as any).uploadSongFile( + file, + publicId, + ); + + expect(result).toBe('http://test.com/file.nbs'); + expect(fileService.uploadSong).toHaveBeenCalledWith(file, publicId); + }); + + it('should throw an error if the file is invalid', async () => { + const file = Buffer.from('test'); + const publicId = 'test-id'; + + jest + .spyOn(fileService, 'uploadSong') + .mockRejectedValue(new Error('test error')); + + try { + await (songUploadService as any).uploadSongFile(file, publicId); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + } + }); + }); + + describe('uploadPackedSongFile', () => { + it('should upload a packed song file', async () => { + const file = Buffer.from('test'); + const publicId = 'test-id'; + + jest + .spyOn(fileService, 'uploadPackedSong') + .mockResolvedValue('http://test.com/packed-file.nbs'); + + const result = await (songUploadService as any).uploadPackedSongFile( + file, + publicId, + ); + + expect(result).toBe('http://test.com/packed-file.nbs'); + expect(fileService.uploadPackedSong).toHaveBeenCalledWith(file, publicId); + }); + + it('should throw an error if the file is invalid', async () => { + const file = Buffer.from('test'); + const publicId = 'test-id'; + + jest + .spyOn(fileService, 'uploadPackedSong') + .mockRejectedValue(new Error('test error')); + + try { + await (songUploadService as any).uploadPackedSongFile(file, publicId); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + } + }); + }); + + describe('getSongObject', () => { + it('should return a song object from an array buffer', () => { + const songTest = new Song(); + + songTest.meta = { + author: 'Nicolas Vycas', + description: 'super cool song', + importName: 'test', + name: 'Cool Test Song', + originalAuthor: 'Nicolas Vycas', + }; + + songTest.tempo = 20; + + // The following will add 3 layers for 3 instruments, each containing five notes + for (let layerCount = 0; layerCount < 3; layerCount++) { + const instrument = Instrument.builtIn[layerCount]; + + // Create a layer for the instrument + const layer = songTest.createLayer(); + layer.meta.name = instrument.meta.name; + + const notes = [ + new Note(instrument, { key: 40 }), + new Note(instrument, { key: 45 }), + new Note(instrument, { key: 50 }), + new Note(instrument, { key: 45 }), + new Note(instrument, { key: 57 }), + ]; + + // Place the notes + for (let i = 0; i < notes.length; i++) { + songTest.setNote(i * 4, layer, notes[i]); // "i * 4" is placeholder - this is the tick to place on + } + } + + const buffer = songTest.toArrayBuffer(); + + console.log(fromArrayBuffer(buffer).length); + + const song = songUploadService.getSongObject(buffer); //TODO: For some reason the song is always empty + + expect(song).toBeInstanceOf(Song); + }); + + it('should throw an error if the array buffer is invalid', () => { + const buffer = new ArrayBuffer(0); + + expect(() => songUploadService.getSongObject(buffer)).toThrow( + HttpException, + ); + }); + }); + + describe('checkIsFileValid', () => { + it('should throw an error if the file is not provided', () => { + expect(() => (songUploadService as any).checkIsFileValid(null)).toThrow( + HttpException, + ); + }); + + it('should not throw an error if the file is provided', () => { + const file = { buffer: Buffer.from('test') } as Express.Multer.File; + + expect(() => + (songUploadService as any).checkIsFileValid(file), + ).not.toThrow(); + }); + }); + + describe('getSoundsMapping', () => undefined); + describe('getValidSoundsSubset', () => undefined); + describe('validateUploader', () => undefined); + describe('generateSongDocument', () => undefined); + describe('prepareSongForUpload', () => undefined); + describe('preparePackedSongForUpload', () => undefined); +}); diff --git a/server/src/song/song-upload/song-upload.service.ts b/server/src/song/song-upload/song-upload.service.ts index 85e5f36a..99f8bbd9 100644 --- a/server/src/song/song-upload/song-upload.service.ts +++ b/server/src/song/song-upload/song-upload.service.ts @@ -6,7 +6,6 @@ import { Injectable, Logger, } from '@nestjs/common'; -import { InjectModel } from '@nestjs/mongoose'; import { injectSongFileMetadata } from '@shared/features/song/injectMetadata'; import { NoteQuadTree } from '@shared/features/song/notes'; import { obfuscateAndPackSong } from '@shared/features/song/pack'; @@ -15,7 +14,7 @@ import { drawToImage } from '@shared/features/thumbnail'; import { SongStats } from '@shared/validation/song/dto/SongStats'; import { ThumbnailData } from '@shared/validation/song/dto/ThumbnailData.dto'; import { UploadSongDto } from '@shared/validation/song/dto/UploadSongDto.dto'; -import { Model, Types } from 'mongoose'; +import { Types } from 'mongoose'; import { FileService } from '@server/file/file.service'; import { UserDocument } from '@server/user/entity/user.entity'; @@ -26,23 +25,21 @@ import { generateSongId, removeExtraSpaces } from '../song.util'; @Injectable() export class SongUploadService { - soundsMapping: Record; - soundsSubset: Set; + private soundsMapping: Record; + private soundsSubset: Set; // TODO: move all upload auxiliary methods to new UploadSongService - private logger = new Logger(SongUploadService.name); + private readonly logger = new Logger(SongUploadService.name); constructor( @Inject(FileService) private fileService: FileService, - @InjectModel(SongEntity.name) - private songModel: Model, @Inject(UserService) private userService: UserService, ) {} - private async getSoundsMapping() { + private async getSoundsMapping(): Promise> { // Object that maps sound paths to their respective hashes if (!this.soundsMapping) { @@ -105,7 +102,7 @@ export class SongUploadService { packedFileKey: string, songStats: SongStats, file: Express.Multer.File, - ) { + ): Promise { const song = new SongEntity(); song.uploader = await this.validateUploader(user); song.publicId = publicId; @@ -207,7 +204,7 @@ export class SongUploadService { songDocument: SongDocument, body: UploadSongDto, user: UserDocument, - ) { + ): Promise { // Compare arrays of custom instruments including order const customInstrumentsChanged = JSON.stringify(songDocument.customInstruments) !== @@ -281,7 +278,7 @@ export class SongUploadService { buffer: Buffer, body: UploadSongDto, user: UserDocument, - ) { + ): { nbsSong: Song; songBuffer: Buffer } { const loadedArrayBuffer = new ArrayBuffer(buffer.byteLength); const view = new Uint8Array(loadedArrayBuffer); @@ -311,7 +308,7 @@ export class SongUploadService { private async preparePackedSongForUpload( nbsSong: Song, soundsArray: string[], - ) { + ): Promise { const soundsMapping = await this.getSoundsMapping(); const validSoundsSubset = await this.getValidSoundsSubset(); @@ -329,7 +326,7 @@ export class SongUploadService { private validateCustomInstruments( soundsArray: string[], validSounds: Set, - ) { + ): void { const isInstrumentValid = (sound: string) => sound === '' || validSounds.has(sound); @@ -354,7 +351,7 @@ export class SongUploadService { thumbnailData: ThumbnailData, nbsSong: Song, publicId: string, - ) { + ): Promise { const { startTick, startLayer, zoomLevel, backgroundColor } = thumbnailData; const quadTree = new NoteQuadTree(nbsSong); @@ -390,7 +387,10 @@ export class SongUploadService { return thumbUrl; } - private async uploadSongFile(file: Buffer, publicId: string) { + private async uploadSongFile( + file: Buffer, + publicId: string, + ): Promise { let fileKey: string; try { @@ -411,7 +411,10 @@ export class SongUploadService { return fileKey; } - private async uploadPackedSongFile(file: Buffer, publicId: string) { + private async uploadPackedSongFile( + file: Buffer, + publicId: string, + ): Promise { let fileKey: string; try { @@ -432,7 +435,7 @@ export class SongUploadService { return fileKey; } - public getSongObject(loadedArrayBuffer: ArrayBuffer) { + public getSongObject(loadedArrayBuffer: ArrayBuffer): Song { const nbsSong = fromArrayBuffer(loadedArrayBuffer); // If the above operation fails, it will return an empty song @@ -450,7 +453,7 @@ export class SongUploadService { return nbsSong; } - private checkIsFileValid(file: Express.Multer.File) { + private checkIsFileValid(file: Express.Multer.File): void { if (!file) { throw new HttpException( { diff --git a/server/src/song/song.controller.spec.ts b/server/src/song/song.controller.spec.ts new file mode 100644 index 00000000..5ebd3fdf --- /dev/null +++ b/server/src/song/song.controller.spec.ts @@ -0,0 +1,344 @@ +import { HttpStatus, UnauthorizedException } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; +import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto'; +import { SongViewDto } from '@shared/validation/song/dto/SongView.dto'; +import { UploadSongDto } from '@shared/validation/song/dto/UploadSongDto.dto'; +import { UploadSongResponseDto } from '@shared/validation/song/dto/UploadSongResponseDto.dto'; +import { Response } from 'express'; + +import { FileService } from '@server/file/file.service'; +import { UserDocument } from '@server/user/entity/user.entity'; + +import { SongController } from './song.controller'; +import { SongService } from './song.service'; + +const mockSongService = { + getSongByPage: jest.fn(), + getSong: jest.fn(), + getSongEdit: jest.fn(), + patchSong: jest.fn(), + getSongDownloadUrl: jest.fn(), + deleteSong: jest.fn(), + uploadSong: jest.fn(), +}; + +const mockFileService = {}; + +describe('SongController', () => { + let songController: SongController; + let songService: SongService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SongController], + providers: [ + { + provide: SongService, + useValue: mockSongService, + }, + { + provide: FileService, + useValue: mockFileService, + }, + ], + }) + .overrideGuard(AuthGuard('jwt-refresh')) + .useValue({ canActivate: jest.fn(() => true) }) + .compile(); + + songController = module.get(SongController); + songService = module.get(SongService); + }); + + it('should be defined', () => { + expect(songController).toBeDefined(); + }); + + describe('getSongList', () => { + it('should return a list of songs', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = []; + + mockSongService.getSongByPage.mockResolvedValueOnce(songList); + + const result = await songController.getSongList(query); + + expect(result).toEqual(songList); + expect(songService.getSongByPage).toHaveBeenCalledWith(query); + }); + + it('should handle errors', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + + mockSongService.getSongByPage.mockRejectedValueOnce(new Error('Error')); + + await expect(songController.getSongList(query)).rejects.toThrow('Error'); + }); + }); + + describe('getSong', () => { + it('should return song info by ID', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const song: SongViewDto = {} as SongViewDto; + + mockSongService.getSong.mockResolvedValueOnce(song); + + const result = await songController.getSong(id, user); + + expect(result).toEqual(song); + expect(songService.getSong).toHaveBeenCalledWith(id, user); + }); + + it('should handle errors', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + mockSongService.getSong.mockRejectedValueOnce(new Error('Error')); + + await expect(songController.getSong(id, user)).rejects.toThrow('Error'); + }); + }); + + describe('getEditSong', () => { + it('should return song info for editing by ID', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const song: UploadSongDto = {} as UploadSongDto; + + mockSongService.getSongEdit.mockResolvedValueOnce(song); + + const result = await songController.getEditSong(id, user); + + expect(result).toEqual(song); + expect(songService.getSongEdit).toHaveBeenCalledWith(id, user); + }); + + it('should handle errors', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + mockSongService.getSongEdit.mockRejectedValueOnce(new Error('Error')); + + await expect(songController.getEditSong(id, user)).rejects.toThrow( + 'Error', + ); + }); + }); + + describe('patchSong', () => { + it('should edit song info by ID', async () => { + const id = 'test-id'; + const req = { body: {} } as any; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const response: UploadSongResponseDto = {} as UploadSongResponseDto; + + mockSongService.patchSong.mockResolvedValueOnce(response); + + const result = await songController.patchSong(id, req, user); + + expect(result).toEqual(response); + expect(songService.patchSong).toHaveBeenCalledWith(id, req.body, user); + }); + + it('should handle errors', async () => { + const id = 'test-id'; + const req = { body: {} } as any; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + mockSongService.patchSong.mockRejectedValueOnce(new Error('Error')); + + await expect(songController.patchSong(id, req, user)).rejects.toThrow( + 'Error', + ); + }); + }); + + describe('getSongFile', () => { + it('should get song .nbs file', async () => { + const id = 'test-id'; + const src = 'test-src'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const res = { + set: jest.fn(), + redirect: jest.fn(), + } as unknown as Response; + + const url = 'test-url'; + + mockSongService.getSongDownloadUrl.mockResolvedValueOnce(url); + + await songController.getSongFile(id, src, user, res); + + expect(res.set).toHaveBeenCalledWith({ + 'Content-Disposition': 'attachment; filename="song.nbs"', + 'Access-Control-Expose-Headers': 'Content-Disposition', + }); + + expect(res.redirect).toHaveBeenCalledWith(HttpStatus.FOUND, url); + + expect(songService.getSongDownloadUrl).toHaveBeenCalledWith( + id, + user, + src, + false, + ); + }); + + it('should handle errors', async () => { + const id = 'test-id'; + const src = 'test-src'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const res = { + set: jest.fn(), + redirect: jest.fn(), + } as unknown as Response; + + mockSongService.getSongDownloadUrl.mockRejectedValueOnce( + new Error('Error'), + ); + + await expect( + songController.getSongFile(id, src, user, res), + ).rejects.toThrow('Error'); + }); + }); + + describe('getSongOpenUrl', () => { + it('should get song .nbs file open URL', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const src = 'downloadButton'; + const url = 'test-url'; + + mockSongService.getSongDownloadUrl.mockResolvedValueOnce(url); + + const result = await songController.getSongOpenUrl(id, user, src); + + expect(result).toEqual(url); + + expect(songService.getSongDownloadUrl).toHaveBeenCalledWith( + id, + user, + 'open', + true, + ); + }); + + it('should throw UnauthorizedException if src is invalid', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const src = 'invalid-src'; + + await expect( + songController.getSongOpenUrl(id, user, src), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should handle errors', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const src = 'downloadButton'; + + mockSongService.getSongDownloadUrl.mockRejectedValueOnce( + new Error('Error'), + ); + + await expect( + songController.getSongOpenUrl(id, user, src), + ).rejects.toThrow('Error'); + }); + }); + + describe('deleteSong', () => { + it('should delete a song', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + mockSongService.deleteSong.mockResolvedValueOnce(undefined); + + await songController.deleteSong(id, user); + + expect(songService.deleteSong).toHaveBeenCalledWith(id, user); + }); + + it('should handle errors', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + mockSongService.deleteSong.mockRejectedValueOnce(new Error('Error')); + + await expect(songController.deleteSong(id, user)).rejects.toThrow( + 'Error', + ); + }); + }); + + describe('createSong', () => { + it('should upload a song', async () => { + const file = { buffer: Buffer.from('test') } as Express.Multer.File; + + const body: UploadSongDto = { + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'cc_by_sa', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + file: undefined, + allowDownload: false, + }; + + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const response: UploadSongResponseDto = {} as UploadSongResponseDto; + + mockSongService.uploadSong.mockResolvedValueOnce(response); + + const result = await songController.createSong(file, body, user); + + expect(result).toEqual(response); + expect(songService.uploadSong).toHaveBeenCalledWith({ body, file, user }); + }); + + it('should handle errors', async () => { + const file = { buffer: Buffer.from('test') } as Express.Multer.File; + + const body: UploadSongDto = { + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'cc_by_sa', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + file: undefined, + allowDownload: false, + }; + + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + mockSongService.uploadSong.mockRejectedValueOnce(new Error('Error')); + + await expect(songController.createSong(file, body, user)).rejects.toThrow( + 'Error', + ); + }); + }); +}); diff --git a/server/src/song/song.module.ts b/server/src/song/song.module.ts index 7a8cd266..5eb5b87c 100644 --- a/server/src/song/song.module.ts +++ b/server/src/song/song.module.ts @@ -17,7 +17,7 @@ import { SongWebhookService } from './song-webhook/song-webhook.service'; MongooseModule.forFeature([{ name: Song.name, schema: SongSchema }]), AuthModule, UserModule, - FileModule, + FileModule.forRootAsync(), ], providers: [SongService, SongUploadService, SongWebhookService], controllers: [SongController, MySongsController], diff --git a/server/src/song/song.service.spec.ts b/server/src/song/song.service.spec.ts new file mode 100644 index 00000000..0533f333 --- /dev/null +++ b/server/src/song/song.service.spec.ts @@ -0,0 +1,1068 @@ +import { HttpException } from '@nestjs/common'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto'; +import { SongStats } from '@shared/validation/song/dto/SongStats'; +import { SongViewDto } from '@shared/validation/song/dto/SongView.dto'; +import { UploadSongDto } from '@shared/validation/song/dto/UploadSongDto.dto'; +import { UploadSongResponseDto } from '@shared/validation/song/dto/UploadSongResponseDto.dto'; +import mongoose, { Model } from 'mongoose'; + +import { FileService } from '@server/file/file.service'; +import { UserDocument } from '@server/user/entity/user.entity'; + +import { + SongDocument, + Song as SongEntity, + SongSchema, + SongWithUser, +} from './entity/song.entity'; +import { SongUploadService } from './song-upload/song-upload.service'; +import { SongService } from './song.service'; +import { SongWebhookService } from './song-webhook/song-webhook.service'; + +const mockFileService = { + deleteSong: jest.fn(), + getSongDownloadUrl: jest.fn(), +}; + +const mockSongUploadService = { + processUploadedSong: jest.fn(), + processSongPatch: jest.fn(), +}; + +const mockSongWebhookService = { + syncAllSongsWebhook: jest.fn(), + postSongWebhook: jest.fn(), + updateSongWebhook: jest.fn(), + deleteSongWebhook: jest.fn(), + syncSongWebhook: jest.fn(), +}; + +describe('SongService', () => { + let service: SongService; + let fileService: FileService; + let songUploadService: SongUploadService; + let songModel: Model; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SongService, + { + provide: SongWebhookService, + useValue: mockSongWebhookService, + }, + { + provide: getModelToken(SongEntity.name), + useValue: mongoose.model(SongEntity.name, SongSchema), + }, + { + provide: FileService, + useValue: mockFileService, + }, + { + provide: SongUploadService, + useValue: mockSongUploadService, + }, + ], + }).compile(); + + service = module.get(SongService); + fileService = module.get(FileService); + songUploadService = module.get(SongUploadService); + songModel = module.get>(getModelToken(SongEntity.name)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('uploadSong', () => { + it('should upload a song', async () => { + const file = { buffer: Buffer.from('test') } as Express.Multer.File; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const body: UploadSongDto = { + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + allowDownload: true, + file: 'somebytes', + }; + + const commonData = { + publicId: 'public-song-id', + createdAt: new Date(), + stats: { + midiFileName: 'test.mid', + noteCount: 100, + tickCount: 1000, + layerCount: 10, + tempo: 120, + tempoRange: [100, 150], + timeSignature: 4, + duration: 60, + loop: true, + loopStartTick: 0, + minutesSpent: 10, + vanillaInstrumentCount: 10, + customInstrumentCount: 0, + firstCustomInstrumentIndex: 0, + outOfRangeNoteCount: 0, + detunedNoteCount: 0, + customInstrumentNoteCount: 0, + incompatibleNoteCount: 0, + compatible: true, + instrumentNoteCounts: [10], + }, + fileSize: 424242, + packedSongUrl: 'http://test.com/packed-file.nbs', + nbsFileUrl: 'http://test.com/file.nbs', + thumbnailUrl: 'http://test.com/thumbnail.nbs', + uploader: user._id, + }; + + const songEntity = new SongEntity(); + songEntity.uploader = user._id; + + const songDocument: SongDocument = { + ...songEntity, + ...commonData, + } as any; + + songDocument.populate = jest.fn().mockResolvedValue({ + ...songEntity, + ...commonData, + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as unknown as SongWithUser); + + songDocument.save = jest.fn().mockResolvedValue(songDocument); + + const populatedSong = { + ...songEntity, + ...commonData, + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as unknown as SongWithUser; + + jest + .spyOn(songUploadService, 'processUploadedSong') + .mockResolvedValue(songEntity); + + jest.spyOn(songModel, 'create').mockResolvedValue(songDocument as any); + + const result = await service.uploadSong({ file, user, body }); + + expect(result).toEqual( + UploadSongResponseDto.fromSongWithUserDocument(populatedSong), + ); + + expect(songUploadService.processUploadedSong).toHaveBeenCalledWith({ + file, + user, + body, + }); + + expect(songModel.create).toHaveBeenCalledWith(songEntity); + expect(songDocument.save).toHaveBeenCalled(); + + expect(songDocument.populate).toHaveBeenCalledWith( + 'uploader', + 'username profileImage -_id', + ); + }); + }); + + describe('deleteSong', () => { + it('should delete a song', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const songEntity = { + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + allowDownload: true, + file: 'somebytes', + publicId: 'public-song-id', + createdAt: new Date(), + stats: {} as SongStats, + fileSize: 424242, + packedSongUrl: 'http://test.com/packed-file.nbs', + nbsFileUrl: 'http://test.com/file.nbs', + thumbnailUrl: 'http://test.com/thumbnail.nbs', + uploader: user._id, + } as unknown as SongEntity; + + const populatedSong = { + ...songEntity, + uploader: { username: 'testuser', profileImage: 'testimage' }, + } as unknown as SongWithUser; + + const mockFindOne = { + exec: jest.fn().mockResolvedValue({ + ...songEntity, + populate: jest.fn().mockResolvedValue(populatedSong), + }), + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + + jest.spyOn(songModel, 'deleteOne').mockReturnValue({ + exec: jest.fn().mockResolvedValue({}), + } as any); + + jest.spyOn(fileService, 'deleteSong').mockResolvedValue(undefined); + + const result = await service.deleteSong(publicId, user); + + expect(result).toEqual( + UploadSongResponseDto.fromSongWithUserDocument(populatedSong), + ); + + expect(songModel.findOne).toHaveBeenCalledWith({ publicId }); + expect(songModel.deleteOne).toHaveBeenCalledWith({ publicId }); + + expect(fileService.deleteSong).toHaveBeenCalledWith( + songEntity.nbsFileUrl, + ); + }); + + it('should throw an error if song is not found', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const mockFindOne = { + findOne: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(null), + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + + await expect(service.deleteSong(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error if user is unauthorized', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const songEntity = new SongEntity(); + songEntity.uploader = new mongoose.Types.ObjectId(); // Different uploader + + const mockFindOne = { + exec: jest.fn().mockResolvedValue(songEntity), + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + + await expect(service.deleteSong(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error if user is unauthorized', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const songEntity = new SongEntity(); + songEntity.uploader = new mongoose.Types.ObjectId(); // Different uploader + + const mockFindOne = { + exec: jest.fn().mockResolvedValue(songEntity), + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + + await expect(service.deleteSong(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + }); + + describe('patchSong', () => { + it('should patch a song', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const body: UploadSongDto = { + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + allowDownload: true, + file: 'somebytes', + }; + + const missingData = { + publicId: 'public-song-id', + createdAt: new Date(), + stats: { + midiFileName: 'test.mid', + noteCount: 100, + tickCount: 1000, + layerCount: 10, + tempo: 120, + tempoRange: [100, 150], + timeSignature: 4, + duration: 60, + loop: true, + loopStartTick: 0, + minutesSpent: 10, + vanillaInstrumentCount: 10, + customInstrumentCount: 0, + firstCustomInstrumentIndex: 0, + outOfRangeNoteCount: 0, + detunedNoteCount: 0, + customInstrumentNoteCount: 0, + incompatibleNoteCount: 0, + compatible: true, + instrumentNoteCounts: [10], + }, + fileSize: 424242, + packedSongUrl: 'http://test.com/packed-file.nbs', + nbsFileUrl: 'http://test.com/file.nbs', + thumbnailUrl: 'http://test.com/thumbnail.nbs', + uploader: user._id, + }; + + const songDocument: SongDocument = { + ...missingData, + } as any; + + songDocument.save = jest.fn().mockResolvedValue(songDocument); + + songDocument.populate = jest.fn().mockResolvedValue({ + ...missingData, + uploader: { username: 'testuser', profileImage: 'testimage' }, + }); + + const populatedSong = { + ...missingData, + uploader: { username: 'testuser', profileImage: 'testimage' }, + }; + + jest.spyOn(songModel, 'findOne').mockResolvedValue(songDocument); + + jest + .spyOn(songUploadService, 'processSongPatch') + .mockResolvedValue(undefined); + + const result = await service.patchSong(publicId, body, user); + + expect(result).toEqual( + UploadSongResponseDto.fromSongWithUserDocument(populatedSong as any), + ); + + expect(songModel.findOne).toHaveBeenCalledWith({ publicId }); + + expect(songUploadService.processSongPatch).toHaveBeenCalledWith( + songDocument, + body, + user, + ); + + expect(songDocument.save).toHaveBeenCalled(); + + expect(songDocument.populate).toHaveBeenCalledWith( + 'uploader', + 'username profileImage -_id', + ); + }, 10000); // Increase the timeout to 10000 ms + + it('should throw an error if song is not found', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const body: UploadSongDto = { + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + file: 'somebytes', + allowDownload: false, + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(null as any); + + await expect(service.patchSong(publicId, body, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error if user is unauthorized', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const body: UploadSongDto = { + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + file: 'somebytes', + allowDownload: false, + }; + + const songEntity = { + uploader: 'different-user-id', + } as any; + + jest.spyOn(songModel, 'findOne').mockReturnValue(songEntity as any); + + await expect(service.patchSong(publicId, body, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error if user is unauthorized', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const body: UploadSongDto = { + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + file: 'somebytes', + allowDownload: false, + }; + + const songEntity = { + uploader: 'different-user-id', + } as any; + + jest.spyOn(songModel, 'findOne').mockReturnValue(songEntity); + + await expect(service.patchSong(publicId, body, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error if user no changes are provided', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const body: UploadSongDto = { + file: undefined, + allowDownload: false, + visibility: 'public', + title: '', + originalAuthor: '', + description: '', + category: 'pop', + thumbnailData: { + backgroundColor: '#000000', + startLayer: 0, + startTick: 0, + zoomLevel: 1, + }, + license: 'standard', + customInstruments: [], + }; + + const songEntity = { + uploader: user._id, + file: undefined, + allowDownload: false, + visibility: 'public', + title: '', + originalAuthor: '', + description: '', + category: 'pop', + thumbnailData: { + backgroundColor: '#000000', + startLayer: 0, + startTick: 0, + zoomLevel: 1, + }, + license: 'standard', + customInstruments: [], + } as any; + + jest.spyOn(songModel, 'findOne').mockReturnValue(songEntity as any); + + await expect(service.patchSong(publicId, body, user)).rejects.toThrow( + HttpException, + ); + }); + }); + + describe('getSongByPage', () => { + it('should return a list of songs by page', async () => { + const query = { + page: 1, + limit: 10, + sort: 'createdAt', + order: true, + }; + + const songList: SongWithUser[] = []; + + const mockFind = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(songList), + }; + + jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); + + const result = await service.getSongByPage(query); + + expect(result).toEqual( + songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), + ); + + expect(songModel.find).toHaveBeenCalledWith({ visibility: 'public' }); + expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: 1 }); + + expect(mockFind.skip).toHaveBeenCalledWith( + query.page * query.limit - query.limit, + ); + + expect(mockFind.limit).toHaveBeenCalledWith(query.limit); + expect(mockFind.exec).toHaveBeenCalled(); + }); + + it('should throw an error if the query is invalid', async () => { + const query = { + page: undefined, + limit: undefined, + sort: undefined, + order: true, + }; + + const songList: SongWithUser[] = []; + + const mockFind = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(songList), + }; + + jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); + + expect(service.getSongByPage(query)).rejects.toThrow(HttpException); + }); + }); + + describe('getSong', () => { + it('should return song info by ID', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const songDocument = { + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + file: 'somebytes', + allowDownload: false, + uploader: {}, + } as any; + + songDocument.save = jest.fn().mockResolvedValue(songDocument); + + const mockFindOne = { + populate: jest.fn().mockResolvedValue(songDocument), + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + + const result = await service.getSong(publicId, user); + + expect(result).toEqual(SongViewDto.fromSongDocument(songDocument)); + expect(songModel.findOne).toHaveBeenCalledWith({ publicId }); + }); + + it('should throw an error if song is not found', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const mockFindOne = { + populate: jest.fn().mockResolvedValue(null), + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + + await expect(service.getSong(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error if song is private and user is unauthorized', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const songEntity = { + visibility: 'private', + uploader: 'different-user-id', + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue({ + populate: jest.fn().mockResolvedValue(songEntity), + } as any); + + await expect(service.getSong(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error if song is private and user is unauthorized', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const songEntity = { + visibility: 'private', + uploader: 'different-user-id', + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue({ + populate: jest.fn().mockResolvedValue(songEntity), + } as any); + + await expect(service.getSong(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error if song is private and user is not logged in', async () => { + const publicId = 'test-id'; + const user: UserDocument = null as any; + + const songEntity = { + visibility: 'private', + uploader: 'different-user-id', + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue({ + populate: jest.fn().mockResolvedValue(songEntity), + } as any); + + await expect(service.getSong(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + }); + + describe('getSongDownloadUrl', () => { + it('should return song download URL', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const songEntity = { + visibility: 'public', + uploader: 'test-user-id', + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + allowDownload: true, + publicId: 'public-song-id', + createdAt: new Date(), + stats: {} as SongStats, + fileSize: 424242, + packedSongUrl: 'http://test.com/packed-file.nbs', + nbsFileUrl: 'http://test.com/file.nbs', + thumbnailUrl: 'http://test.com/thumbnail.nbs', + save: jest.fn(), + }; + + const url = 'http://test.com/song.nbs'; + + jest.spyOn(songModel, 'findOne').mockResolvedValue(songEntity); + jest.spyOn(fileService, 'getSongDownloadUrl').mockResolvedValue(url); + + const result = await service.getSongDownloadUrl(publicId, user); + + expect(result).toEqual(url); + expect(songModel.findOne).toHaveBeenCalledWith({ publicId }); + + expect(fileService.getSongDownloadUrl).toHaveBeenCalledWith( + songEntity.nbsFileUrl, + `${songEntity.title}.nbs`, + ); + }); + + it('should throw an error if song is not found', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + jest.spyOn(songModel, 'findOne').mockResolvedValue(null); + + await expect(service.getSongDownloadUrl(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error if song is private and user is unauthorized', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const songEntity = { + visibility: 'private', + uploader: 'different-user-id', + }; + + jest.spyOn(songModel, 'findOne').mockResolvedValue(songEntity); + + await expect(service.getSongDownloadUrl(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error if no packed song URL is available and allowDownload is false', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const songEntity = { + visibility: 'public', + uploader: 'test-user-id', + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + allowDownload: false, + publicId: 'public-song-id', + createdAt: new Date(), + stats: {} as SongStats, + fileSize: 424242, + packedSongUrl: undefined, + nbsFileUrl: 'http://test.com/file.nbs', + thumbnailUrl: 'http://test.com/thumbnail.nbs', + save: jest.fn(), + }; + + jest.spyOn(songModel, 'findOne').mockResolvedValue(songEntity); + + await expect(service.getSongDownloadUrl(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error in case of an internal error in fileService', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + jest + .spyOn(fileService, 'getSongDownloadUrl') + .mockRejectedValue(new Error()); + + await expect(service.getSongDownloadUrl(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error in case of an internal error on saveing the song', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const songEntity = { + visibility: 'public', + uploader: 'test-user-id', + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + allowDownload: true, + publicId: 'public-song-id', + createdAt: new Date(), + stats: {} as SongStats, + fileSize: 424242, + packedSongUrl: 'http://test.com/packed-file.nbs', + nbsFileUrl: 'http://test.com/file.nbs', + thumbnailUrl: 'http://test.com/thumbnail.nbs', + save: jest.fn().mockRejectedValue(new Error()), // Simulate error on save + }; + + jest.spyOn(songModel, 'findOne').mockResolvedValue(songEntity); + + jest + .spyOn(fileService, 'getSongDownloadUrl') + .mockResolvedValue('http://test.com/song.nbs'); + + await expect(service.getSongDownloadUrl(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + }); + + describe('getMySongsPage', () => { + it('should return a list of songs uploaded by the user', async () => { + const query = { + page: 1, + limit: 10, + sort: 'createdAt', + order: true, + }; + + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const songList: SongWithUser[] = []; + + const mockFind = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue(songList), + }; + + jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); + jest.spyOn(songModel, 'countDocuments').mockResolvedValue(0); + + const result = await service.getMySongsPage({ query, user }); + + expect(result).toEqual({ + content: songList.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song), + ), + page: 1, + limit: 10, + total: 0, + }); + + expect(songModel.find).toHaveBeenCalledWith({ uploader: user._id }); + expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: 1 }); + + expect(mockFind.skip).toHaveBeenCalledWith( + query.page * query.limit - query.limit, + ); + + expect(mockFind.limit).toHaveBeenCalledWith(query.limit); + + expect(songModel.countDocuments).toHaveBeenCalledWith({ + uploader: user._id, + }); + }); + }); + + describe('getSongEdit', () => { + it('should return song info for editing by ID', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const songEntity = new SongEntity(); + songEntity.uploader = user._id; // Ensure uploader is set + + const mockFindOne = { + exec: jest.fn().mockResolvedValue(songEntity), + populate: jest.fn().mockReturnThis(), + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + + const result = await service.getSongEdit(publicId, user); + + expect(result).toEqual(UploadSongDto.fromSongDocument(songEntity as any)); + + expect(songModel.findOne).toHaveBeenCalledWith({ publicId }); + }); + + it('should throw an error if song is not found', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const findOneMock = { + findOne: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(null), + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(findOneMock as any); + + await expect(service.getSongEdit(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an error if user is unauthorized', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + + const songEntity = { + uploader: 'different-user-id', + title: 'Test Song', + originalAuthor: 'Test Author', + description: 'Test Description', + category: 'alternative', + visibility: 'public', + license: 'standard', + customInstruments: [], + thumbnailData: { + startTick: 0, + startLayer: 0, + zoomLevel: 1, + backgroundColor: '#000000', + }, + allowDownload: true, + publicId: 'public-song-id', + createdAt: new Date(), + stats: {} as SongStats, + fileSize: 424242, + packedSongUrl: 'http://test.com/packed-file.nbs', + nbsFileUrl: 'http://test.com/file.nbs', + thumbnailUrl: 'http://test.com/thumbnail.nbs', + } as unknown as SongEntity; + + const findOneMock = { + findOne: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(songEntity), + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(findOneMock as any); + + await expect(service.getSongEdit(publicId, user)).rejects.toThrow( + HttpException, + ); + }); + }); + + describe('getCategories', () => { + it('should return a list of song categories and their counts', async () => { + const categories = [ + { _id: 'category1', count: 10 }, + { _id: 'category2', count: 5 }, + ]; + + jest.spyOn(songModel, 'aggregate').mockResolvedValue(categories); + + const result = await service.getCategories(); + + expect(result).toEqual({ category1: 10, category2: 5 }); + + expect(songModel.aggregate).toHaveBeenCalledWith([ + { $match: { visibility: 'public' } }, + { $group: { _id: '$category', count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + ]); + }); + }); + + describe('getSongsByCategory', () => { + it('should return a list of songs by category', async () => { + const category = 'test-category'; + const page = 1; + const limit = 10; + const songList: SongWithUser[] = []; + + const mockFind = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(songList), + }; + + jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); + + const result = await service.getSongsByCategory(category, page, limit); + + expect(result).toEqual( + songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), + ); + + expect(songModel.find).toHaveBeenCalledWith({ + category, + visibility: 'public', + }); + + expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: -1 }); + expect(mockFind.skip).toHaveBeenCalledWith(page * limit - limit); + expect(mockFind.limit).toHaveBeenCalledWith(limit); + + expect(mockFind.populate).toHaveBeenCalledWith( + 'uploader', + 'username profileImage -_id', + ); + + expect(mockFind.exec).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/song/song.service.ts b/server/src/song/song.service.ts index 6d570b20..435029d4 100644 --- a/server/src/song/song.service.ts +++ b/server/src/song/song.service.ts @@ -18,11 +18,7 @@ import { Model } from 'mongoose'; import { FileService } from '@server/file/file.service'; import { UserDocument } from '@server/user/entity/user.entity'; -import { - SongDocument, - Song as SongEntity, - SongWithUser, -} from './entity/song.entity'; +import { Song as SongEntity, SongWithUser } from './entity/song.entity'; import { SongUploadService } from './song-upload/song-upload.service'; import { SongWebhookService } from './song-webhook/song-webhook.service'; import { removeExtraSpaces } from './song.util'; @@ -96,10 +92,7 @@ export class SongService { throw new HttpException('Song not found', HttpStatus.UNAUTHORIZED); } - await this.songModel - .deleteOne({ publicId: publicId }) - .populate('uploader') - .exec(); + await this.songModel.deleteOne({ publicId: publicId }).exec(); await this.fileService.deleteSong(foundSong.nbsFileUrl); @@ -118,15 +111,9 @@ export class SongService { body: UploadSongDto, user: UserDocument, ): Promise { - const foundSong = (await this.songModel - .findOne({ - publicId: publicId, - }) - .exec()) as unknown as SongDocument; - - if (!user) { - throw new HttpException('User not found', HttpStatus.UNAUTHORIZED); - } + const foundSong = await this.songModel.findOne({ + publicId: publicId, + }); if (!foundSong) { throw new HttpException('Song not found', HttpStatus.NOT_FOUND); @@ -269,8 +256,7 @@ export class SongService { ): Promise { const foundSong = await this.songModel .findOne({ publicId: publicId }) - .populate('uploader', 'username profileImage -_id') - .exec(); + .populate('uploader', 'username profileImage -_id'); if (!foundSong) { throw new HttpException('Song not found', HttpStatus.NOT_FOUND); @@ -300,9 +286,7 @@ export class SongService { src?: string, packed: boolean = false, ): Promise { - const foundSong = await this.songModel - .findOne({ publicId: publicId }) - .exec(); + const foundSong = await this.songModel.findOne({ publicId: publicId }); if (!foundSong) { throw new HttpException('Song not found with ID', HttpStatus.NOT_FOUND); @@ -366,14 +350,11 @@ export class SongService { [sort]: order ? 1 : -1, }) .skip(limit * (page - 1)) - .limit(limit) - .exec()) as unknown as SongWithUser[]; + .limit(limit)) as unknown as SongWithUser[]; - const total = await this.songModel - .countDocuments({ - uploader: user._id, - }) - .exec(); + const total = await this.songModel.countDocuments({ + uploader: user._id, + }); return { content: songData.map((song) => diff --git a/server/src/song/song.util.ts b/server/src/song/song.util.ts index f628eeae..cd320434 100644 --- a/server/src/song/song.util.ts +++ b/server/src/song/song.util.ts @@ -1,9 +1,21 @@ import { UploadConst } from '@shared/validation/song/constants'; -import { formatDuration } from '@web/src/modules/shared/util/format'; + import { customAlphabet } from 'nanoid'; import { SongWithUser } from './entity/song.entity'; +// TODO: Move to shared +export const formatDuration = (totalSeconds: number) => { + const minutes = Math.floor(Math.ceil(totalSeconds) / 60); + const seconds = Math.ceil(totalSeconds) % 60; + + const formattedTime = `${minutes.toFixed().padStart(1, '0')}:${seconds + .toFixed() + .padStart(2, '0')}`; + + return formattedTime; +}; + export function removeExtraSpaces(input: string): string { return input .replace(/ +/g, ' ') // replace multiple spaces with one space diff --git a/server/src/user/user.controller.spec.ts b/server/src/user/user.controller.spec.ts new file mode 100644 index 00000000..a7729b20 --- /dev/null +++ b/server/src/user/user.controller.spec.ts @@ -0,0 +1,91 @@ +import { HttpException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; +import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; + +import { UserDocument } from './entity/user.entity'; +import { UserController } from './user.controller'; +import { UserService } from './user.service'; + +const mockUserService = { + getUserByEmailOrId: jest.fn(), + getUserPaginated: jest.fn(), + getSelfUserData: jest.fn(), +}; + +describe('UserController', () => { + let userController: UserController; + let userService: UserService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UserController], + providers: [ + { + provide: UserService, + useValue: mockUserService, + }, + ], + }).compile(); + + userController = module.get(UserController); + userService = module.get(UserService); + }); + + it('should be defined', () => { + expect(userController).toBeDefined(); + }); + + describe('getUser', () => { + it('should return user data by email or ID', async () => { + const query: GetUser = { + email: 'test@email.com', + username: 'test-username', + id: 'test-id', + }; + + const user = { email: 'test@example.com' }; + + mockUserService.getUserByEmailOrId.mockResolvedValueOnce(user); + + const result = await userController.getUser(query); + + expect(result).toEqual(user); + expect(userService.getUserByEmailOrId).toHaveBeenCalledWith(query); + }); + }); + + describe('getUserPaginated', () => { + it('should return paginated user data', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const paginatedUsers = { items: [], total: 0 }; + + mockUserService.getUserPaginated.mockResolvedValueOnce(paginatedUsers); + + const result = await userController.getUserPaginated(query); + + expect(result).toEqual(paginatedUsers); + expect(userService.getUserPaginated).toHaveBeenCalledWith(query); + }); + }); + + describe('getMe', () => { + it('should return the token owner data', async () => { + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + const userData = { _id: 'test-user-id', email: 'test@example.com' }; + + mockUserService.getSelfUserData.mockResolvedValueOnce(userData); + + const result = await userController.getMe(user); + + expect(result).toEqual(userData); + expect(userService.getSelfUserData).toHaveBeenCalledWith(user); + }); + + it('should handle null user', async () => { + const user = null; + + await expect(userController.getMe(user)).rejects.toThrow(HttpException); + }); + }); +}); diff --git a/server/src/user/user.service.spec.ts b/server/src/user/user.service.spec.ts new file mode 100644 index 00000000..bf5df91c --- /dev/null +++ b/server/src/user/user.service.spec.ts @@ -0,0 +1,290 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; +import { CreateUser } from '@shared/validation/user/dto/CreateUser.dto'; +import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; +import { Model } from 'mongoose'; + +import { User, UserDocument } from './entity/user.entity'; +import { UserService } from './user.service'; + +const mockUserModel = { + create: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + find: jest.fn(), + save: jest.fn(), + exec: jest.fn(), + select: jest.fn(), + countDocuments: jest.fn(), +}; + +describe('UserService', () => { + let service: UserService; + let userModel: Model; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserService, + { + provide: getModelToken(User.name), + useValue: mockUserModel, + }, + ], + }).compile(); + + service = module.get(UserService); + userModel = module.get>(getModelToken(User.name)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new user', async () => { + const createUserDto: CreateUser = { + username: 'testuser', + email: 'test@example.com', + profileImage: 'testimage.png', + }; + + const user = { + ...createUserDto, + save: jest.fn().mockReturnThis(), + } as any; + + jest.spyOn(userModel, 'create').mockReturnValue(user); + + const result = await service.create(createUserDto); + + expect(result).toEqual(user); + expect(userModel.create).toHaveBeenCalledWith(createUserDto); + expect(user.save).toHaveBeenCalled(); + }); + }); + + describe('findByEmail', () => { + it('should find a user by email', async () => { + const email = 'test@example.com'; + const user = { email } as UserDocument; + + jest.spyOn(userModel, 'findOne').mockReturnValue({ + exec: jest.fn().mockResolvedValue(user), + } as any); + + const result = await service.findByEmail(email); + + expect(result).toEqual(user); + expect(userModel.findOne).toHaveBeenCalledWith({ email }); + }); + }); + + describe('findByID', () => { + it('should find a user by ID', async () => { + const id = 'test-id'; + const user = { _id: id } as UserDocument; + + jest.spyOn(userModel, 'findById').mockReturnValue({ + exec: jest.fn().mockResolvedValue(user), + } as any); + + const result = await service.findByID(id); + + expect(result).toEqual(user); + expect(userModel.findById).toHaveBeenCalledWith(id); + }); + }); + + describe('getUserPaginated', () => { + it('should return paginated users', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const users = [{ username: 'testuser' }] as UserDocument[]; + + const usersPage = { + users, + total: 1, + page: 1, + limit: 10, + }; + + const mockFind = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue(users), + }; + + jest.spyOn(userModel, 'find').mockReturnValue(mockFind as any); + jest.spyOn(userModel, 'countDocuments').mockResolvedValue(1); + + const result = await service.getUserPaginated(query); + + expect(result).toEqual(usersPage); + expect(userModel.find).toHaveBeenCalledWith({}); + }); + }); + + describe('getUserByEmailOrId', () => { + it('should find a user by email', async () => { + const query: GetUser = { email: 'test@example.com' }; + const user = { email: 'test@example.com' } as UserDocument; + + jest.spyOn(service, 'findByEmail').mockResolvedValue(user); + + const result = await service.getUserByEmailOrId(query); + + expect(result).toEqual(user); + expect(service.findByEmail).toHaveBeenCalledWith(query.email); + }); + + it('should find a user by ID', async () => { + const query: GetUser = { id: 'test-id' }; + const user = { _id: 'test-id' } as UserDocument; + + jest.spyOn(service, 'findByID').mockResolvedValue(user); + + const result = await service.getUserByEmailOrId(query); + + expect(result).toEqual(user); + expect(service.findByID).toHaveBeenCalledWith(query.id); + }); + + it('should throw an error if username is provided', async () => { + const query: GetUser = { username: 'testuser' }; + + await expect(service.getUserByEmailOrId(query)).rejects.toThrow( + new HttpException( + 'Username is not supported yet', + HttpStatus.BAD_REQUEST, + ), + ); + }); + + it('should throw an error if neither email nor ID is provided', async () => { + const query: GetUser = {}; + + await expect(service.getUserByEmailOrId(query)).rejects.toThrow( + new HttpException( + 'You must provide an email or an id', + HttpStatus.BAD_REQUEST, + ), + ); + }); + }); + + describe('getHydratedUser', () => { + it('should return a hydrated user', async () => { + const user = { _id: 'test-id' } as UserDocument; + const hydratedUser = { ...user, songs: [] } as unknown as UserDocument; + + jest.spyOn(userModel, 'findById').mockReturnValue({ + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(hydratedUser), + } as any); + + const result = await service.getHydratedUser(user); + + expect(result).toEqual(hydratedUser); + expect(userModel.findById).toHaveBeenCalledWith(user._id); + + expect(userModel.findById(user._id).populate).toHaveBeenCalledWith( + 'songs', + ); + }); + }); + + describe('getSelfUserData', () => { + it('should return self user data', async () => { + const user = { _id: 'test-id' } as UserDocument; + const userData = { ...user } as UserDocument; + + jest.spyOn(service, 'findByID').mockResolvedValue(userData); + + const result = await service.getSelfUserData(user); + + expect(result).toEqual(userData); + expect(service.findByID).toHaveBeenCalledWith(user._id.toString()); + }); + + it('should throw an error if user is not found', async () => { + const user = { _id: 'test-id' } as UserDocument; + + jest.spyOn(service, 'findByID').mockResolvedValue(null); + + await expect(service.getSelfUserData(user)).rejects.toThrow( + new HttpException('user not found', HttpStatus.NOT_FOUND), + ); + }); + }); + + describe('usernameExists', () => { + it('should return true if username exists', async () => { + const username = 'testuser'; + const user = { username } as UserDocument; + + jest.spyOn(userModel, 'findOne').mockReturnValue({ + select: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(user), + } as any); + + const result = await service.usernameExists(username); + + expect(result).toBe(true); + expect(userModel.findOne).toHaveBeenCalledWith({ username }); + + expect(userModel.findOne({ username }).select).toHaveBeenCalledWith( + 'username', + ); + }); + + it('should return false if username does not exist', async () => { + const username = 'testuser'; + + jest.spyOn(userModel, 'findOne').mockReturnValue({ + select: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(null), + } as any); + + const result = await service.usernameExists(username); + + expect(result).toBe(false); + expect(userModel.findOne).toHaveBeenCalledWith({ username }); + + expect(userModel.findOne({ username }).select).toHaveBeenCalledWith( + 'username', + ); + }); + }); + + describe('generateUsername', () => { + it('should generate a unique username', async () => { + const inputUsername = 'test user'; + const baseUsername = 'test_user'; + + jest.spyOn(service, 'usernameExists').mockResolvedValueOnce(false); + + const result = await service.generateUsername(inputUsername); + + expect(result).toBe(baseUsername); + expect(service.usernameExists).toHaveBeenCalledWith(baseUsername); + }); + + it('should generate a unique username with a number suffix if base username is taken', async () => { + const inputUsername = 'test user'; + const baseUsername = 'test_user'; + + jest + .spyOn(service, 'usernameExists') + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + const result = await service.generateUsername(inputUsername); + + expect(result).toMatch('test_user_2'); + expect(service.usernameExists).toHaveBeenCalledWith(baseUsername); + }); + }); +}); diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 71ffef25..7b231c16 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -14,7 +14,7 @@ export class UserService { public async create(user_registered: CreateUser) { await validate(user_registered); - const user = new this.userModel(user_registered); + const user = await this.userModel.create(user_registered); user.username = user_registered.username; user.email = user_registered.email; user.profileImage = user_registered.profileImage; @@ -34,16 +34,26 @@ export class UserService { return user; } - public getUserPaginated(query: PageQueryDTO) { - const { page, limit } = query; + public async getUserPaginated(query: PageQueryDTO) { + const { page = 1, limit = 10, sort = 'createdAt', order = 'asc' } = query; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const options = { - page: page || 1, - limit: limit || 10, - }; + const skip = (page - 1) * limit; + const sortOrder = order === 'asc' ? 1 : -1; + + const users = await this.userModel + .find({}) + .sort({ [sort]: sortOrder }) + .skip(skip) + .limit(limit); - return this.userModel.find({}); + const total = await this.userModel.countDocuments(); + + return { + users, + total, + page, + limit, + }; } public async getUserByEmailOrId(query: GetUser) { @@ -80,8 +90,6 @@ export class UserService { } public async getSelfUserData(user: UserDocument) { - if (!user) - throw new HttpException('not logged in', HttpStatus.UNAUTHORIZED); const usedData = await this.findByID(user._id.toString()); if (!usedData) throw new HttpException('user not found', HttpStatus.NOT_FOUND); diff --git a/shared/jest.config.js b/shared/jest.config.js new file mode 100644 index 00000000..fb1e071a --- /dev/null +++ b/shared/jest.config.js @@ -0,0 +1,33 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '.', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.json', + ignoreCodes: ['TS151001'], + }, + ], + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: './coverage', + testEnvironment: 'node', + moduleNameMapper: { + '^@shared/(.*)$': '/../shared/$1', + '^@server/(.*)$': '/src/$1', + }, + testPathIgnorePatterns: [ + '/node_modules/', + '/dist/', + '/coverage/', + ], + coveragePathIgnorePatterns: [ + '/node_modules/', + '/coverage/', + '/dist/', + '.eslintrc.js', + 'jest.config.js', + ], +}; diff --git a/shared/package.json b/shared/package.json index 9663a9d5..9327170a 100644 --- a/shared/package.json +++ b/shared/package.json @@ -27,4 +27,4 @@ "@types/node": "^20.3.1", "typescript": "^5.1.3" } -} +} \ No newline at end of file diff --git a/shared/tests/song/index.spec.ts b/shared/tests/song/index.spec.ts new file mode 100644 index 00000000..50c76d6c --- /dev/null +++ b/shared/tests/song/index.spec.ts @@ -0,0 +1,195 @@ +import assert from 'assert'; + +import { openSongFromPath } from './util'; +import { SongStatsGenerator } from '../../features/song/stats'; + +// TO RUN: +// +// From the root of the 'shared' package, run: +// $ ts-node tests/song/index.ts + +// TODO: refactor to use a proper test runner (e.g. jest) + +const testSongPaths = { + simple: 'files/testSimple.nbs', + extraPopulatedLayer: 'files/testExtraPopulatedLayer.nbs', + loop: 'files/testLoop.nbs', + detune: 'files/testDetune.nbs', + outOfRange: 'files/testOutOfRange.nbs', + outOfRangeCustomPitch: 'files/testOutOfRangeCustomPitch.nbs', + customInstrumentNoUsage: 'files/testCustomInstrumentNoUsage.nbs', + customInstrumentUsage: 'files/testCustomInstrumentUsage.nbs', + tempoChangerWithStart: 'files/testTempoChangerWithStart.nbs', + tempoChangerNoStart: 'files/testTempoChangerNoStart.nbs', + tempoChangerDifferentStart: 'files/testTempoChangerDifferentStart.nbs', + tempoChangerOverlap: 'files/testTempoChangerOverlap.nbs', + tempoChangerMultipleInstruments: + 'files/testTempoChangerMultipleInstruments.nbs', +}; + +const testSongStats = Object.fromEntries( + Object.entries(testSongPaths).map(([name, path]) => { + return [name, SongStatsGenerator.getSongStats(openSongFromPath(path))]; + }), +); + +describe('SongStatsGenerator', () => { + it('Test that the stats are correctly calculated for a simple song with no special properties.', () => { + const stats = testSongStats.simple; + + assert(stats.midiFileName === ''); + assert(stats.noteCount === 10); + assert(stats.tickCount === 19); + assert(stats.layerCount === 3); + assert(stats.tempo === 10.0); + assert(stats.tempoRange === null); + assert(stats.timeSignature === 4); + assert(stats.duration.toFixed(2) === '1.90'); + assert(stats.loop === false); + assert(stats.loopStartTick === 0); + // assert(stats.minutesSpent === 0); + assert(stats.vanillaInstrumentCount === 5); + assert(stats.customInstrumentCount === 0); + assert(stats.firstCustomInstrumentIndex === 16); + assert(stats.customInstrumentNoteCount === 0); + assert(stats.outOfRangeNoteCount === 0); + assert(stats.detunedNoteCount === 0); + assert(stats.incompatibleNoteCount === 0); + assert(stats.compatible === true); + + assert( + stats.instrumentNoteCounts.toString() === + [5, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 2].toString(), + ); + }); + + it('Test that the stats are correctly calculated for a song with an extra populated layer. This means that the last layer has a property changed (like volume or pitch) but no note blocks.', () => { + const stats = testSongStats.extraPopulatedLayer; + + // Should be 5 if we want the last layer with a property changed, regardless + // of the last layer with a note block. We currently don't account for this. + assert(stats.layerCount === 3); + }); + + it('Test that the loop values are correct for a song that loops.', () => { + const stats = testSongStats.loop; + + assert(stats.loop === true); + assert(stats.loopStartTick === 7); + }); + + it('Test that notes with microtonal detune values are properly counted, and make the song incompatible. Also checks that notes crossing the 2-octave range boundary via pitch values are taken into account.', () => { + const stats = testSongStats.detune; + + assert(stats.noteCount === 10); + assert(stats.detunedNoteCount === 4); + assert(stats.incompatibleNoteCount === 6); + assert(stats.outOfRangeNoteCount === 2); + assert(stats.compatible === false); + }); + + it('Test that notes outside the 2-octave range are properly counted in a song where every instrument uses the default pitch (F#4 - 45).', () => { + const stats = testSongStats.outOfRange; + + assert(stats.outOfRangeNoteCount === 6); + assert(stats.incompatibleNoteCount === 6); + assert(stats.compatible === false); + }); + + it("Test that notes outside the 2-octave range are properly counted in a song with instruments that use custom pitch values. The code should calculate the 2-octave supported range based on the instrument's pitch value.", () => { + const stats = testSongStats.outOfRangeCustomPitch; + + assert(stats.outOfRangeNoteCount === stats.noteCount - 3); + }); + + it("Test that the instrument counts are correctly calculated if the song contains custom instruments, but doesn't use them in any note.", () => { + const stats = testSongStats.customInstrumentNoUsage; + + assert(stats.customInstrumentCount === 0); + assert(stats.customInstrumentNoteCount === 0); + + assert(stats.compatible === true); + }); + + it('Test that the instrument counts are correctly calculated if the song contains custom instruments and uses them.', () => { + // Test that the instrument counts are correctly calculated if the song contains custom instruments and uses them. + + const stats = testSongStats.customInstrumentUsage; + const firstCustomIndex = stats.firstCustomInstrumentIndex; + + assert(stats.customInstrumentCount === 2); + assert(stats.customInstrumentNoteCount > 0); + + assert(stats.instrumentNoteCounts[firstCustomIndex + 0] === 3); + assert(stats.instrumentNoteCounts[firstCustomIndex + 1] === 0); + assert(stats.instrumentNoteCounts[firstCustomIndex + 2] === 2); + + assert(stats.compatible === false); + }); + + it("Test with tempo changes. Includes a tempo changer at the start of the song which matches the song's default tempo.", () => { + const stats = testSongStats.tempoChangerWithStart; + + const duration = (1 / 10 + 1 / 12 + 1 / 14 + 1 / 16 + 1 / 18) * 4; + + assert(duration.toFixed(2) === stats.duration.toFixed(2)); + assert(stats.tempo === 10.0); + assert(stats.tempoRange?.toString() === [10.0, 18.0].toString()); + + // Tempo changers shouldn't count as detuned notes, increase custom instrument count + // or incompatible note count. + assert(stats.detunedNoteCount === 0); + assert(stats.customInstrumentCount === 0); + assert(stats.customInstrumentNoteCount === 0); + assert(stats.incompatibleNoteCount === 0); + assert(stats.compatible === true); + }); + + it("Omits the tempo changer at the start. The code should properly consider the song's default tempo at the start of the song.", () => { + const stats = testSongStats.tempoChangerNoStart; + + const duration = (1 / 10 + 1 / 12 + 1 / 14 + 1 / 16 + 1 / 18) * 4; + + assert(duration.toFixed(2) === stats.duration.toFixed(2)); + assert(stats.tempo === 10.0); + assert(stats.tempoRange?.toString() === [10.0, 18.0].toString()); + }); + + it("Includes a tempo changer at the start of the song with a different tempo than the song's default tempo.", () => { + // Includes a tempo changer at the start of the song with a different tempo + // than the song's default tempo. The code should ignore the song's default + // tempo and use the tempo from the tempo changer for calculating the song's + // duration and tempo range. However, the 'tempo' attribute should still be set + // to the song's default tempo. + + const stats = testSongStats.tempoChangerDifferentStart; + + const duration = (1 / 20 + 1 / 12 + 1 / 14 + 1 / 16 + 1 / 18) * 4; + + assert(duration.toFixed(2) === stats.duration.toFixed(2)); + assert(stats.tempo === 10.0); + assert(stats.tempoRange?.toString() === [12.0, 20.0].toString()); + }); + + it('Includes overlapping tempo changers within the same tick. The code should only consider the bottom-most tempo changer in each tick.', () => { + const stats = testSongStats.tempoChangerOverlap; + + const duration = (1 / 10 + 1 / 12 + 1 / 4 + 1 / 16 + 1 / 18) * 4; + + assert(duration.toFixed(2) === stats.duration.toFixed(2)); + assert(stats.tempo === 10.0); + assert(stats.tempoRange?.toString() === [4.0, 18.0].toString()); + }); + + it('Test that multiple tempo changer instruments are properly handled.', () => { + const stats = testSongStats.tempoChangerMultipleInstruments; + + const duration = (1 / 10 + 1 / 12 + 1 / 14 + 1 / 16 + 1 / 18) * 4; + + assert(duration.toFixed(2) === stats.duration.toFixed(2)); + assert(stats.tempo === 10.0); + assert(stats.tempoRange?.toString() === [10.0, 18.0].toString()); + + assert(stats.detunedNoteCount === 0); + }); +}); diff --git a/shared/tests/song/index.ts b/shared/tests/song/index.ts deleted file mode 100644 index 7c5c829a..00000000 --- a/shared/tests/song/index.ts +++ /dev/null @@ -1,260 +0,0 @@ -import assert from 'assert'; - -import { openSongFromPath } from './util'; -import { SongStatsGenerator } from '../../features/song/stats'; - -// TO RUN: -// -// From the root of the 'shared' package, run: -// $ ts-node tests/song/index.ts - -// TODO: refactor to use a proper test runner (e.g. jest) - -const testSongPaths = { - simple: 'files/testSimple.nbs', - extraPopulatedLayer: 'files/testExtraPopulatedLayer.nbs', - loop: 'files/testLoop.nbs', - detune: 'files/testDetune.nbs', - outOfRange: 'files/testOutOfRange.nbs', - outOfRangeCustomPitch: 'files/testOutOfRangeCustomPitch.nbs', - customInstrumentNoUsage: 'files/testCustomInstrumentNoUsage.nbs', - customInstrumentUsage: 'files/testCustomInstrumentUsage.nbs', - tempoChangerWithStart: 'files/testTempoChangerWithStart.nbs', - tempoChangerNoStart: 'files/testTempoChangerNoStart.nbs', - tempoChangerDifferentStart: 'files/testTempoChangerDifferentStart.nbs', - tempoChangerOverlap: 'files/testTempoChangerOverlap.nbs', - tempoChangerMultipleInstruments: - 'files/testTempoChangerMultipleInstruments.nbs', -}; - -const testSongStats = Object.fromEntries( - Object.entries(testSongPaths).map(([name, path]) => { - return [name, SongStatsGenerator.getSongStats(openSongFromPath(path))]; - }), -); - -function testSimple() { - // Test that the stats are correctly calculated for a simple song with no - // special properties. - - const stats = testSongStats.simple; - - assert(stats.midiFileName === ''); - assert(stats.noteCount === 10); - assert(stats.tickCount === 19); - assert(stats.layerCount === 3); - assert(stats.tempo === 10.0); - assert(stats.tempoRange === null); - assert(stats.timeSignature === 4); - assert(stats.duration.toFixed(2) === '1.90'); - assert(stats.loop === false); - assert(stats.loopStartTick === 0); - // assert(stats.minutesSpent === 0); - assert(stats.vanillaInstrumentCount === 5); - assert(stats.customInstrumentCount === 0); - assert(stats.firstCustomInstrumentIndex === 16); - assert(stats.customInstrumentNoteCount === 0); - assert(stats.outOfRangeNoteCount === 0); - assert(stats.detunedNoteCount === 0); - assert(stats.incompatibleNoteCount === 0); - assert(stats.compatible === true); - - assert( - stats.instrumentNoteCounts.toString() === - [5, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 2].toString(), - ); -} - -function testExtraPopulatedLayer() { - // Test that the stats are correctly calculated for a song with an extra - // populated layer. This means that the last layer has a property changed - // (like volume or pitch) but no note blocks. - - const stats = testSongStats.extraPopulatedLayer; - - // Should be 5 if we want the last layer with a property changed, regardless - // of the last layer with a note block. We currently don't account for this. - assert(stats.layerCount === 3); -} - -function testLoop() { - // Test that the loop values are correct for a song that loops. - - const stats = testSongStats.loop; - - assert(stats.loop === true); - assert(stats.loopStartTick === 7); -} - -function testDetune() { - // Test that notes with microtonal detune values are properly counted, and make - // the song incompatible. Also checks that notes crossing the 2-octave range - // boundary via pitch values are taken into account. - - const stats = testSongStats.detune; - - assert(stats.noteCount === 10); - assert(stats.detunedNoteCount === 4); - assert(stats.incompatibleNoteCount === 6); - assert(stats.outOfRangeNoteCount === 2); - assert(stats.compatible === false); -} - -function testOutOfRange() { - // Test that notes outside the 2-octave range are properly counted in a song where - // every instrument uses the default pitch (F#4 - 45). - - const stats = testSongStats.outOfRange; - - assert(stats.outOfRangeNoteCount === 6); - assert(stats.incompatibleNoteCount === 6); - assert(stats.compatible === false); -} - -function testOutOfRangeCustomPitch() { - // Test that notes outside the 2-octave range are properly counted in a song with - // instruments that use custom pitch values. The code should calculate the 2-octave - // supported range based on the instrument's pitch value. - - const stats = testSongStats.outOfRangeCustomPitch; - - assert(stats.outOfRangeNoteCount === stats.noteCount - 3); -} - -function testCustomInstrumentNoUsage() { - // Test that the instrument counts are correctly calculated if the song - // contains custom instruments, but doesn't use them in any note. - - const stats = testSongStats.customInstrumentNoUsage; - - assert(stats.customInstrumentCount === 0); - assert(stats.customInstrumentNoteCount === 0); - - assert(stats.compatible === true); -} - -function testCustomInstrumentUsage() { - // Test that the instrument counts are correctly calculated if the song - // contains custom instruments and uses them. - - const stats = testSongStats.customInstrumentUsage; - const firstCustomIndex = stats.firstCustomInstrumentIndex; - - assert(stats.customInstrumentCount === 2); - assert(stats.customInstrumentNoteCount > 0); - - assert(stats.instrumentNoteCounts[firstCustomIndex + 0] === 3); - assert(stats.instrumentNoteCounts[firstCustomIndex + 1] === 0); - assert(stats.instrumentNoteCounts[firstCustomIndex + 2] === 2); - - assert(stats.compatible === false); -} - -function testTempoChangerWithStart() { - // Test with tempo changes. Includes a tempo changer at the start of the song - // which matches the song's default tempo. - - const stats = testSongStats.tempoChangerWithStart; - - const duration = (1 / 10 + 1 / 12 + 1 / 14 + 1 / 16 + 1 / 18) * 4; - - assert(duration.toFixed(2) === stats.duration.toFixed(2)); - assert(stats.tempo === 10.0); - assert(stats.tempoRange?.toString() === [10.0, 18.0].toString()); - - // Tempo changers shouldn't count as detuned notes, increase custom instrument count - // or incompatible note count. - assert(stats.detunedNoteCount === 0); - assert(stats.customInstrumentCount === 0); - assert(stats.customInstrumentNoteCount === 0); - assert(stats.incompatibleNoteCount === 0); - assert(stats.compatible === true); -} - -function testTempoChangerNoStart() { - // Omits the tempo changer at the start. The code should properly consider - // the song's default tempo at the start of the song. - - const stats = testSongStats.tempoChangerNoStart; - - const duration = (1 / 10 + 1 / 12 + 1 / 14 + 1 / 16 + 1 / 18) * 4; - - assert(duration.toFixed(2) === stats.duration.toFixed(2)); - assert(stats.tempo === 10.0); - assert(stats.tempoRange?.toString() === [10.0, 18.0].toString()); -} - -function testTempoChangerDifferentStart() { - // Includes a tempo changer at the start of the song with a different tempo - // than the song's default tempo. The code should ignore the song's default - // tempo and use the tempo from the tempo changer for calculating the song's - // duration and tempo range. However, the 'tempo' attribute should still be set - // to the song's default tempo. - - const stats = testSongStats.tempoChangerDifferentStart; - - const duration = (1 / 20 + 1 / 12 + 1 / 14 + 1 / 16 + 1 / 18) * 4; - - assert(duration.toFixed(2) === stats.duration.toFixed(2)); - assert(stats.tempo === 10.0); - assert(stats.tempoRange?.toString() === [12.0, 20.0].toString()); -} - -function testTempoChangerOverlap() { - // Includes overlapping tempo changers within the same tick. The code - // should only consider the bottom-most tempo changer in each tick. - - const stats = testSongStats.tempoChangerOverlap; - - const duration = (1 / 10 + 1 / 12 + 1 / 4 + 1 / 16 + 1 / 18) * 4; - - assert(duration.toFixed(2) === stats.duration.toFixed(2)); - assert(stats.tempo === 10.0); - assert(stats.tempoRange?.toString() === [4.0, 18.0].toString()); -} - -function testTempoChangerMultipleInstruments() { - // Test that multiple tempo changer instruments are properly handled. - - const stats = testSongStats.tempoChangerMultipleInstruments; - - const duration = (1 / 10 + 1 / 12 + 1 / 14 + 1 / 16 + 1 / 18) * 4; - - assert(duration.toFixed(2) === stats.duration.toFixed(2)); - assert(stats.tempo === 10.0); - assert(stats.tempoRange?.toString() === [10.0, 18.0].toString()); - - assert(stats.detunedNoteCount === 0); -} - -function runTest(test: () => void) { - console.log('\n------------------------------------'); - - try { - test(); - console.log(`✅ Passed: ${test.name}`); - } catch (e: any) { - console.error(`❌ Failed: ${test.name}\n`); - console.error(e.stack); - } - - console.log('------------------------------------\n'); -} - -function runAllTests() { - runTest(testSimple); - runTest(testExtraPopulatedLayer); - runTest(testLoop); - runTest(testDetune); - runTest(testOutOfRange); - runTest(testOutOfRangeCustomPitch); - runTest(testCustomInstrumentNoUsage); - runTest(testCustomInstrumentUsage); - runTest(testTempoChangerWithStart); - runTest(testTempoChangerNoStart); - runTest(testTempoChangerDifferentStart); - runTest(testTempoChangerOverlap); - runTest(testTempoChangerMultipleInstruments); -} - -runAllTests(); diff --git a/shared/validation/user/dto/GetUser.dto.ts b/shared/validation/user/dto/GetUser.dto.ts index dda1df3f..3feb46a3 100644 --- a/shared/validation/user/dto/GetUser.dto.ts +++ b/shared/validation/user/dto/GetUser.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsMongoId, + IsOptional, IsString, MaxLength, MinLength, @@ -9,23 +10,26 @@ import { export class GetUser { @IsString() + @IsOptional() @MaxLength(64) @IsEmail() @ApiProperty({ description: 'Email of the user', example: 'vycasnicolas@gmailcom', }) - email: string; + email?: string; @IsString() + @IsOptional() @MaxLength(64) @ApiProperty({ description: 'Username of the user', example: 'tomast1137', }) - username: string; + username?: string; @IsString() + @IsOptional() @MaxLength(64) @MinLength(24) @IsMongoId() @@ -33,7 +37,7 @@ export class GetUser { description: 'ID of the user', example: 'replace0me6b5f0a8c1a6d8c', }) - id: string; + id?: string; constructor(partial: Partial) { Object.assign(this, partial); diff --git a/web/src/modules/shared/util/format.ts b/web/src/modules/shared/util/format.ts index 2ca73580..37e996f6 100644 --- a/web/src/modules/shared/util/format.ts +++ b/web/src/modules/shared/util/format.ts @@ -1,3 +1,4 @@ +// TODO: Move to shared/util export const formatDuration = (totalSeconds: number) => { const minutes = Math.floor(Math.ceil(totalSeconds) / 60); const seconds = Math.ceil(totalSeconds) % 60;