diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ea8c84c3..da78ec58 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,15 +19,17 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - + with: + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install - - name: Install dependencies # (assuming your project has dependencies) - run: bun install # You can use npm/yarn/pnpm instead if you prefer + - name: Run linter + run: bun run lint - name: Check for changes id: verify-changed-files diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0b6fe977..3ba9bcbe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,16 +13,18 @@ on: jobs: test: runs-on: ubuntu-latest + env: + THUMBNAIL_URL: ${{ vars.THUMBNAIL_URL }} steps: - name: Checkout uses: actions/checkout@v4 - + - name: Install bun uses: oven-sh/setup-bun@v2 - - - name: Install dependencies # (assuming your project has dependencies) - run: bun install # You can use npm/yarn/pnpm instead if you prefer + + - name: Install dependencies + run: bun install - name: Run tests run: bun run test diff --git a/.vscode/settings.json b/.vscode/settings.json index 640488af..c1877421 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,32 +1,10 @@ { - "editor.formatOnSave": true, - "eslint.validate": [ - "typescript" - ], - "eslint.run": "onType", - "eslint.format.enable": true, - "mdx.server.enable": true, - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" - }, - "workbench.colorCustomizations": { - "activityBar.activeBackground": "#fac977", - "activityBar.background": "#fac977", - "activityBar.foreground": "#15202b", - "activityBar.inactiveForeground": "#15202b99", - "activityBarBadge.background": "#069a62", - "activityBarBadge.foreground": "#e7e7e7", - "commandCenter.border": "#15202b99", - "sash.hoverBorder": "#fac977", - "statusBar.background": "#f8b646", - "statusBar.foreground": "#15202b", - "statusBarItem.hoverBackground": "#f6a315", - "statusBarItem.remoteBackground": "#f8b646", - "statusBarItem.remoteForeground": "#15202b", - "titleBar.activeBackground": "#f8b646", - "titleBar.activeForeground": "#15202b", - "titleBar.inactiveBackground": "#f8b64699", - "titleBar.inactiveForeground": "#15202b99" - }, - "peacock.color": "#f8b646" -} \ No newline at end of file + "editor.formatOnSave": true, + "eslint.validate": ["typescript"], + "eslint.run": "onType", + "eslint.format.enable": true, + "mdx.server.enable": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 767ae572..76eba88b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,6 +117,8 @@ To install all dependencies, run in the root of the project: bun install ``` +> **Note**: This will automatically build all workspace packages (database, song, thumbnail, sounds) via the `postinstall` hook to ensure proper workspace dependency linking. + --- ## Running the Project diff --git a/apps/backend/package.json b/apps/backend/package.json index f3861fb6..7ac45435 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -63,12 +63,12 @@ "@nbw/sounds": "workspace:*" }, "devDependencies": { + "@types/bun": "latest", "@faker-js/faker": "^9.3.0", "@nestjs/cli": "^10.4.9", "@nestjs/schematics": "^10.2.3", "@nestjs/testing": "^10.4.15", "@types/bcryptjs": "^2.4.6", - "@types/bun": "^1.2.10", "@types/express": "^4.17.21", "@types/multer": "^1.4.12", "@types/node": "^20.17.10", diff --git a/apps/backend/scripts/build.ts b/apps/backend/scripts/build.ts index 5417739e..21128cd9 100644 --- a/apps/backend/scripts/build.ts +++ b/apps/backend/scripts/build.ts @@ -5,90 +5,90 @@ import { resolve } from 'path'; import { getLatestVersionSoundList } from '@nbw/sounds'; const writeSoundList = async () => { - function writeJSONFile( - dir: string, - fileName: string, - data: Record | string[], - ) { - const path = resolve(dir, fileName); - const jsonString = JSON.stringify(data, null, 0); - writeFileSync(path, jsonString); - } + function writeJSONFile( + dir: string, + fileName: string, + data: Record | string[] + ) { + const path = resolve(dir, fileName); + const jsonString = JSON.stringify(data, null, 0); + writeFileSync(path, jsonString); + } - const dataDir = resolve(__dirname, '..', 'public', 'data'); + const dataDir = resolve(__dirname, '..', 'public', 'data'); - const soundListPath = 'soundList.json'; + const soundListPath = 'soundList.json'; - // Try to create the output directory if it doesn't exist - try { - mkdirSync(dataDir, { recursive: true }); - } catch (err) { - if (err instanceof Error && err.message.includes('EEXIST')) { - console.log('Sound data files already exist; skipping generation.'); - process.exit(0); + // Try to create the output directory if it doesn't exist + try { + mkdirSync(dataDir, { recursive: true }); + } catch (err) { + if (err instanceof Error && err.message.includes('EEXIST')) { + console.log('Sound data files already exist; skipping generation.'); + process.exit(0); + } } - } - // If the files already exist, exit early - const files = [soundListPath].map((fileName) => resolve(dataDir, fileName)); + // If the files already exist, exit early + const files = [soundListPath].map((fileName) => resolve(dataDir, fileName)); - if (files.every((file) => existsSync(file))) { - console.log('Sound data files already exist; skipping generation.'); - process.exit(0); - } + if (files.every((file) => existsSync(file))) { + console.log('Sound data files already exist; skipping generation.'); + process.exit(0); + } - console.log('Generating sound data files...'); + console.log('Generating sound data files...'); - // Write list of sounds in the latest MC version to a JSON file - // Filter the list to only include sounds that match the chosen patterns - // (defined in the shared/ module) - getLatestVersionSoundList().then((soundList) => { - writeJSONFile(dataDir, soundListPath, soundList); - }); + // Write list of sounds in the latest MC version to a JSON file + // Filter the list to only include sounds that match the chosen patterns + // (defined in the shared/ module) + getLatestVersionSoundList().then((soundList) => { + writeJSONFile(dataDir, soundListPath, soundList); + }); }; const build = async () => { - await Bun.$`rm -rf dist`; + await Bun.$`rm -rf dist`; - const optionalRequirePackages = [ - 'class-transformer', - 'class-transformer/storage', - 'class-validator', - '@nestjs/microservices', - '@nestjs/websockets', - '@fastify/static', - ]; + const optionalRequirePackages = [ + 'class-transformer', + 'class-transformer/storage', + 'class-validator', + '@nestjs/microservices', + '@nestjs/websockets', + '@fastify/static' + ]; - const result = await Bun.build({ - entrypoints: ['./src/main.ts'], - outdir: './dist', - target: 'bun', - minify: false, - sourcemap: 'linked', - external: optionalRequirePackages.filter((pkg) => { - try { - require(pkg); - return false; - } catch (_) { - return true; - } - }), - splitting: true, - }); + const result = await Bun.build({ + entrypoints: ['./src/main.ts'], + outdir : './dist', + target : 'bun', + minify : false, + sourcemap : 'linked', + external : optionalRequirePackages.filter((pkg) => { + try { + require(pkg); + return false; + } catch (_) { + return true; + } + }), + splitting: true + }); - if (!result.success) { - console.log(result.logs[0]); - process.exit(1); - } + if (!result.success) { + console.log(result.logs[0]); + process.exit(1); + } - console.log('Built successfully!'); + console.log('Built successfully!'); }; build() - .then(() => { - writeSoundList(); - }) - .catch((err) => { - console.error(err); - process.exit(1); - }); + .then(() => { + writeSoundList(); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index b3f2e3bd..048a9b38 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -18,79 +18,79 @@ import { SongBrowserModule } from './song-browser/song-browser.module'; import { UserModule } from './user/user.module'; @Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: ['.env.development', '.env.production'], - validate, - }), - //DatabaseModule, - MongooseModule.forRootAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: ( - configService: ConfigService, - ): MongooseModuleFactoryOptions => { - const url = configService.getOrThrow('MONGO_URL'); - Logger.debug(`Connecting to ${url}`); + imports: [ + ConfigModule.forRoot({ + isGlobal : true, + envFilePath: ['.env.development', '.env.production'], + validate + }), + //DatabaseModule, + MongooseModule.forRootAsync({ + imports : [ConfigModule], + inject : [ConfigService], + useFactory: ( + configService: ConfigService + ): MongooseModuleFactoryOptions => { + const url = configService.getOrThrow('MONGO_URL'); + Logger.debug(`Connecting to ${url}`); - return { - uri: url, - retryAttempts: 10, - retryDelay: 3000, - }; - }, - }), - // Mailing - MailerModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (configService: ConfigService) => { - const transport = configService.getOrThrow('MAIL_TRANSPORT'); - const from = configService.getOrThrow('MAIL_FROM'); - AppModule.logger.debug(`MAIL_TRANSPORT: ${transport}`); - AppModule.logger.debug(`MAIL_FROM: ${from}`); - return { - transport: transport, - defaults: { - from: from, - }, - template: { - dir: __dirname + '/mailing/templates', - adapter: new HandlebarsAdapter(), - options: { - strict: true, + return { + uri : url, + retryAttempts: 10, + retryDelay : 3000 + }; + } + }), + // Mailing + MailerModule.forRootAsync({ + imports : [ConfigModule], + useFactory: (configService: ConfigService) => { + const transport = configService.getOrThrow('MAIL_TRANSPORT'); + const from = configService.getOrThrow('MAIL_FROM'); + AppModule.logger.debug(`MAIL_TRANSPORT: ${transport}`); + AppModule.logger.debug(`MAIL_FROM: ${from}`); + return { + transport: transport, + defaults : { + from: from + }, + template: { + dir : __dirname + '/mailing/templates', + adapter: new HandlebarsAdapter(), + options: { + strict: true + } + } + }; }, - }, - }; - }, - inject: [ConfigService], - }), - // Throttler - ThrottlerModule.forRoot([ - { - ttl: 60, - limit: 256, // 256 requests per minute - }, - ]), - SongModule, - UserModule, - AuthModule.forRootAsync(), - FileModule.forRootAsync(), - SongBrowserModule, - SeedModule.forRoot(), - EmailLoginModule, - MailingModule, - ], - controllers: [], - providers: [ - ParseTokenPipe, - { - provide: APP_GUARD, - useClass: ThrottlerGuard, - }, - ], - exports: [ParseTokenPipe], + inject: [ConfigService] + }), + // Throttler + ThrottlerModule.forRoot([ + { + ttl : 60, + limit: 256 // 256 requests per minute + } + ]), + SongModule, + UserModule, + AuthModule.forRootAsync(), + FileModule.forRootAsync(), + SongBrowserModule, + SeedModule.forRoot(), + EmailLoginModule, + MailingModule + ], + controllers: [], + providers : [ + ParseTokenPipe, + { + provide : APP_GUARD, + useClass: ThrottlerGuard + } + ], + exports: [ParseTokenPipe] }) export class AppModule { - static readonly logger = new Logger(AppModule.name); + static readonly logger = new Logger(AppModule.name); } diff --git a/apps/backend/src/auth/auth.controller.spec.ts b/apps/backend/src/auth/auth.controller.spec.ts index d6bae4fa..299d1d83 100644 --- a/apps/backend/src/auth/auth.controller.spec.ts +++ b/apps/backend/src/auth/auth.controller.spec.ts @@ -1,3 +1,5 @@ +import { describe, beforeEach, it, expect, jest } from 'bun:test'; + import { Test, TestingModule } from '@nestjs/testing'; import type { Request, Response } from 'express'; @@ -6,172 +8,172 @@ import { AuthService } from './auth.service'; import { MagicLinkEmailStrategy } from './strategies/magicLinkEmail.strategy'; const mockAuthService = { - githubLogin: jest.fn(), - googleLogin: jest.fn(), - discordLogin: jest.fn(), - verifyToken: jest.fn(), - loginWithEmail: jest.fn(), + githubLogin : jest.fn(), + googleLogin : jest.fn(), + discordLogin : jest.fn(), + verifyToken : jest.fn(), + loginWithEmail: jest.fn() }; const mockMagicLinkEmailStrategy = { - send: jest.fn(), + send: 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, - }, - { - provide: MagicLinkEmailStrategy, - useValue: mockMagicLinkEmailStrategy, - }, - ], - }).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); + let controller: AuthController; + let authService: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers : [ + { + provide : AuthService, + useValue: mockAuthService + }, + { + provide : MagicLinkEmailStrategy, + useValue: mockMagicLinkEmailStrategy + } + ] + }).compile(); + + controller = module.get(AuthController); + authService = module.get(AuthService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); }); - 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); + 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); + }); - await expect(controller.githubRedirect(req, res)).rejects.toThrow( - 'Test error', - ); + 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('githubLogin', () => { - it('should call AuthService.githubLogin', async () => { - await controller.githubLogin(); - expect(authService.githubLogin).toHaveBeenCalled(); + describe('githubLogin', () => { + it('should call AuthService.githubLogin', async () => { + await controller.githubLogin(); + expect(authService.githubLogin).toHaveBeenCalled(); + }); }); - }); - describe('googleRedirect', () => { - it('should call AuthService.googleLogin', async () => { - const req = {} as Request; - const res = {} as Response; + describe('googleRedirect', () => { + it('should call AuthService.googleLogin', async () => { + const req = {} as Request; + const res = {} as Response; - await controller.googleRedirect(req, res); + await controller.googleRedirect(req, res); - expect(authService.googleLogin).toHaveBeenCalledWith(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); + 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', - ); + await expect(controller.googleRedirect(req, res)).rejects.toThrow( + 'Test error' + ); + }); }); - }); - describe('googleLogin', () => { - it('should call AuthService.googleLogin', async () => { - await controller.googleLogin(); - expect(authService.googleLogin).toHaveBeenCalled(); + describe('googleLogin', () => { + it('should call AuthService.googleLogin', async () => { + await controller.googleLogin(); + expect(authService.googleLogin).toHaveBeenCalled(); + }); }); - }); - describe('discordRedirect', () => { - it('should call AuthService.discordLogin', async () => { - const req = {} as Request; - const res = {} as Response; + describe('discordRedirect', () => { + it('should call AuthService.discordLogin', async () => { + const req = {} as Request; + const res = {} as Response; - await controller.discordRedirect(req, res); + await controller.discordRedirect(req, res); - expect(authService.discordLogin).toHaveBeenCalledWith(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); + 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', - ); + await expect(controller.discordRedirect(req, res)).rejects.toThrow( + 'Test error' + ); + }); }); - }); - describe('discordLogin', () => { - it('should call AuthService.discordLogin', async () => { - await controller.discordLogin(); - expect(authService.discordLogin).toHaveBeenCalled(); + describe('discordLogin', () => { + it('should call AuthService.discordLogin', async () => { + await controller.discordLogin(); + expect(authService.discordLogin).toHaveBeenCalled(); + }); }); - }); - describe('magicLinkRedirect', () => { - it('should call AuthService.loginWithEmail', async () => { - const req = {} as Request; - const res = {} as Response; + describe('magicLinkRedirect', () => { + it('should call AuthService.loginWithEmail', async () => { + const req = {} as Request; + const res = {} as Response; - await controller.magicLinkRedirect(req, res); + await controller.magicLinkRedirect(req, res); - expect(authService.loginWithEmail).toHaveBeenCalledWith(req, res); - }); + expect(authService.loginWithEmail).toHaveBeenCalledWith(req, res); + }); - it('should handle exceptions', async () => { - const req = {} as Request; - const res = {} as Response; - const error = new Error('Test error'); - (authService.loginWithEmail as jest.Mock).mockRejectedValueOnce(error); + it('should handle exceptions', async () => { + const req = {} as Request; + const res = {} as Response; + const error = new Error('Test error'); + (authService.loginWithEmail as jest.Mock).mockRejectedValueOnce(error); - await expect(controller.magicLinkRedirect(req, res)).rejects.toThrow( - 'Test error', - ); + await expect(controller.magicLinkRedirect(req, res)).rejects.toThrow( + 'Test error' + ); + }); }); - }); - - describe('magicLinkLogin', () => { - it('should call AuthService.discordLogin', async () => { - //const req = {} as Request; - //const res = {} as Response; - // TODO: Implement this test - //await controller.magicLinkLogin(req, res); - // - //expect(mockMagicLinkEmailStrategy.send).toHaveBeenCalledWith(req, res); + + describe('magicLinkLogin', () => { + it('should call AuthService.discordLogin', async () => { + //const req = {} as Request; + //const res = {} as Response; + // TODO: Implement this test + //await controller.magicLinkLogin(req, res); + // + //expect(mockMagicLinkEmailStrategy.send).toHaveBeenCalledWith(req, res); + }); }); - }); - describe('verify', () => { - it('should call AuthService.verifyToken', async () => { - const req = {} as Request; - const res = {} as Response; + describe('verify', () => { + it('should call AuthService.verifyToken', async () => { + const req = {} as Request; + const res = {} as Response; - await controller.verify(req, res); + await controller.verify(req, res); - expect(authService.verifyToken).toHaveBeenCalledWith(req, res); + expect(authService.verifyToken).toHaveBeenCalledWith(req, res); + }); }); - }); }); diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 66e29486..883a9dba 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -1,14 +1,14 @@ import { - Controller, - Get, - HttpException, - HttpStatus, - Inject, - Logger, - Post, - Req, - Res, - UseGuards, + Controller, + Get, + HttpException, + HttpStatus, + Inject, + Logger, + Post, + Req, + Res, + UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; @@ -21,116 +21,116 @@ import { MagicLinkEmailStrategy } from './strategies/magicLinkEmail.strategy'; @Controller('auth') @ApiTags('auth') export class AuthController { - private readonly logger = new Logger(AuthController.name); - constructor( - @Inject(AuthService) - private readonly authService: AuthService, - @Inject(MagicLinkEmailStrategy) - private readonly magicLinkEmailStrategy: MagicLinkEmailStrategy, - ) {} + private readonly logger = new Logger(AuthController.name); + constructor( + @Inject(AuthService) + private readonly authService: AuthService, + @Inject(MagicLinkEmailStrategy) + private readonly magicLinkEmailStrategy: MagicLinkEmailStrategy + ) {} - @Throttle({ - default: { - // one every 1 hour - ttl: 60 * 60 * 1000, - limit: 1, - }, - }) - @Post('login/magic-link') - @ApiOperation({ - summary: + @Throttle({ + default: { + // one every 1 hour + ttl : 60 * 60 * 1000, + limit: 1 + } + }) + @Post('login/magic-link') + @ApiOperation({ + summary: 'Will send the user a email with a single use login link, if the user does not exist it will create a new user', - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - destination: { - type: 'string', - example: 'vycasnicolas@gmail.com', - description: 'Email address to send the magic link to', - }, - }, - required: ['destination'], - }, - }, - }, - }, - }) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async magicLinkLogin(@Req() req: Request, @Res() res: Response) { - throw new HttpException('Not implemented', HttpStatus.NOT_IMPLEMENTED); + requestBody: { + content: { + 'application/json': { + schema: { + type : 'object', + properties: { + destination: { + type : 'string', + example : 'vycasnicolas@gmail.com', + description: 'Email address to send the magic link to' + } + }, + required: ['destination'] + } + } + } + } + }) + + public async magicLinkLogin(@Req() req: Request, @Res() res: Response) { + throw new HttpException('Not implemented', HttpStatus.NOT_IMPLEMENTED); // TODO: uncomment this line to enable magic link login //return this.magicLinkEmailStrategy.send(req, res); - } + } - @Get('magic-link/callback') - @ApiOperation({ - summary: 'Will send the user a email with a single use login link', - }) - @UseGuards(AuthGuard('magic-link')) - public async magicLinkRedirect(@Req() req: Request, @Res() res: Response) { - return this.authService.loginWithEmail(req, res); - } + @Get('magic-link/callback') + @ApiOperation({ + summary: 'Will send the user a email with a single use login link' + }) + @UseGuards(AuthGuard('magic-link')) + public async magicLinkRedirect(@Req() req: Request, @Res() res: Response) { + return this.authService.loginWithEmail(req, res); + } - @Get('login/github') - @UseGuards(AuthGuard('github')) - @ApiOperation({ summary: 'Login with github' }) - public githubLogin() { + @Get('login/github') + @UseGuards(AuthGuard('github')) + @ApiOperation({ summary: 'Login with github' }) + public githubLogin() { // Not need for implementation, its handled by passport - this.logger.debug('GitHub login'); - } + this.logger.debug('GitHub login'); + } - @Get('github/callback') - @UseGuards(AuthGuard('github')) - @ApiOperation({ summary: 'GitHub login callback' }) - public githubRedirect(@Req() req: Request, @Res() res: Response) { - return this.authService.githubLogin(req, res); - } + @Get('github/callback') + @UseGuards(AuthGuard('github')) + @ApiOperation({ summary: 'GitHub login callback' }) + public githubRedirect(@Req() req: Request, @Res() res: Response) { + return this.authService.githubLogin(req, res); + } - @Get('login/google') - @ApiOperation({ summary: 'Login with google' }) - @UseGuards(AuthGuard('google')) - public googleLogin() { + @Get('login/google') + @ApiOperation({ summary: 'Login with google' }) + @UseGuards(AuthGuard('google')) + public googleLogin() { // Not need for implementation, its handled by passport - this.logger.debug('Google login'); - } + this.logger.debug('Google login'); + } - @Get('google/callback') - @ApiOperation({ summary: 'Google login callback' }) - @UseGuards(AuthGuard('google')) - public googleRedirect(@Req() req: Request, @Res() res: Response) { - return this.authService.googleLogin(req, res); - } + @Get('google/callback') + @ApiOperation({ summary: 'Google login callback' }) + @UseGuards(AuthGuard('google')) + public googleRedirect(@Req() req: Request, @Res() res: Response) { + return this.authService.googleLogin(req, res); + } - @Get('login/discord') - @ApiOperation({ summary: 'Login with discord' }) - @UseGuards(AuthGuard('discord')) - public discordLogin() { + @Get('login/discord') + @ApiOperation({ summary: 'Login with discord' }) + @UseGuards(AuthGuard('discord')) + public discordLogin() { // Not need for implementation, its handled by passport - this.logger.debug('Discord login'); - } + this.logger.debug('Discord login'); + } - @Get('discord/callback') - @ApiOperation({ summary: 'Discord login callback' }) - @UseGuards(AuthGuard('discord')) - public discordRedirect(@Req() req: Request, @Res() res: Response) { - return this.authService.discordLogin(req, res); - } + @Get('discord/callback') + @ApiOperation({ summary: 'Discord login callback' }) + @UseGuards(AuthGuard('discord')) + public discordRedirect(@Req() req: Request, @Res() res: Response) { + return this.authService.discordLogin(req, res); + } - @Get('verify') - @ApiOperation({ summary: 'Verify user token' }) - @ApiResponse({ status: 200, description: 'User token verified' }) - @ApiResponse({ status: 401, description: 'User token not verified' }) - @UseGuards(AuthGuard('jwt-refresh')) - public verify( - @Req() req: Request, - @Res({ - passthrough: true, - }) - res: Response, - ) { - this.authService.verifyToken(req, res); - } + @Get('verify') + @ApiOperation({ summary: 'Verify user token' }) + @ApiResponse({ status: 200, description: 'User token verified' }) + @ApiResponse({ status: 401, description: 'User token not verified' }) + @UseGuards(AuthGuard('jwt-refresh')) + public verify( + @Req() req: Request, + @Res({ + passthrough: true + }) + res: Response + ) { + this.authService.verifyToken(req, res); + } } diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index e266b0bf..5649c1c1 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -15,101 +15,101 @@ import { MagicLinkEmailStrategy } from './strategies/magicLinkEmail.strategy'; @Module({}) export class AuthModule { - static forRootAsync(): DynamicModule { - return { - module: AuthModule, - imports: [ - UserModule, - ConfigModule.forRoot(), - MailingModule, - JwtModule.registerAsync({ - inject: [ConfigService], - imports: [ConfigModule], - useFactory: async (config: ConfigService) => { - const JWT_SECRET = config.get('JWT_SECRET'); - const JWT_EXPIRES_IN = config.get('JWT_EXPIRES_IN'); + static forRootAsync(): DynamicModule { + return { + module : AuthModule, + imports: [ + UserModule, + ConfigModule.forRoot(), + MailingModule, + JwtModule.registerAsync({ + inject : [ConfigService], + imports : [ConfigModule], + 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_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'); - } + 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, - MagicLinkEmailStrategy, - JwtStrategy, - { - inject: [ConfigService], - provide: 'COOKIE_EXPIRES_IN', - useFactory: (configService: ConfigService) => - configService.getOrThrow('COOKIE_EXPIRES_IN'), - }, - { - inject: [ConfigService], - provide: 'SERVER_URL', - useFactory: (configService: ConfigService) => - configService.getOrThrow('SERVER_URL'), - }, - { - inject: [ConfigService], - provide: 'FRONTEND_URL', - useFactory: (configService: ConfigService) => - configService.getOrThrow('FRONTEND_URL'), - }, - { - inject: [ConfigService], - provide: 'JWT_SECRET', - useFactory: (configService: ConfigService) => - configService.getOrThrow('JWT_SECRET'), - }, - { - inject: [ConfigService], - provide: 'JWT_EXPIRES_IN', - useFactory: (configService: ConfigService) => - configService.getOrThrow('JWT_EXPIRES_IN'), - }, - { - inject: [ConfigService], - provide: 'JWT_REFRESH_SECRET', - useFactory: (configService: ConfigService) => - configService.getOrThrow('JWT_REFRESH_SECRET'), - }, - { - inject: [ConfigService], - provide: 'JWT_REFRESH_EXPIRES_IN', - useFactory: (configService: ConfigService) => - configService.getOrThrow('JWT_REFRESH_EXPIRES_IN'), - }, - { - inject: [ConfigService], - provide: 'MAGIC_LINK_SECRET', - useFactory: (configService: ConfigService) => - configService.getOrThrow('MAGIC_LINK_SECRET'), - }, - { - inject: [ConfigService], - provide: 'APP_DOMAIN', - useFactory: (configService: ConfigService) => - configService.get('APP_DOMAIN'), - }, - ], - exports: [AuthService], - }; - } + return { + secret : JWT_SECRET, + signOptions: { expiresIn: JWT_EXPIRES_IN || '60s' } + }; + } + }) + ], + controllers: [AuthController], + providers : [ + AuthService, + ConfigService, + GoogleStrategy, + GithubStrategy, + DiscordStrategy, + MagicLinkEmailStrategy, + JwtStrategy, + { + inject : [ConfigService], + provide : 'COOKIE_EXPIRES_IN', + useFactory: (configService: ConfigService) => + configService.getOrThrow('COOKIE_EXPIRES_IN') + }, + { + inject : [ConfigService], + provide : 'SERVER_URL', + useFactory: (configService: ConfigService) => + configService.getOrThrow('SERVER_URL') + }, + { + inject : [ConfigService], + provide : 'FRONTEND_URL', + useFactory: (configService: ConfigService) => + configService.getOrThrow('FRONTEND_URL') + }, + { + inject : [ConfigService], + provide : 'JWT_SECRET', + useFactory: (configService: ConfigService) => + configService.getOrThrow('JWT_SECRET') + }, + { + inject : [ConfigService], + provide : 'JWT_EXPIRES_IN', + useFactory: (configService: ConfigService) => + configService.getOrThrow('JWT_EXPIRES_IN') + }, + { + inject : [ConfigService], + provide : 'JWT_REFRESH_SECRET', + useFactory: (configService: ConfigService) => + configService.getOrThrow('JWT_REFRESH_SECRET') + }, + { + inject : [ConfigService], + provide : 'JWT_REFRESH_EXPIRES_IN', + useFactory: (configService: ConfigService) => + configService.getOrThrow('JWT_REFRESH_EXPIRES_IN') + }, + { + inject : [ConfigService], + provide : 'MAGIC_LINK_SECRET', + useFactory: (configService: ConfigService) => + configService.getOrThrow('MAGIC_LINK_SECRET') + }, + { + inject : [ConfigService], + provide : 'APP_DOMAIN', + useFactory: (configService: ConfigService) => + configService.get('APP_DOMAIN') + } + ], + exports: [AuthService] + }; + } } diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index 311fece9..a688c448 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -1,345 +1,347 @@ +import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; + import type { UserDocument } from '@nbw/database'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; -import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; import type { Request, Response } from 'express'; import { UserService } from '@server/user/user.service'; + import { AuthService } from './auth.service'; import { Profile } from './types/profile'; const mockAxios = { - get: jest.fn(), - post: jest.fn(), - put: jest.fn(), - delete: jest.fn(), - create: jest.fn(), + get : jest.fn(), + post : jest.fn(), + put : jest.fn(), + delete: jest.fn(), + create: jest.fn() }; mock.module('axios', () => mockAxios); const mockUserService = { - generateUsername: jest.fn(), - findByEmail: jest.fn(), - findByID: jest.fn(), - create: jest.fn(), + generateUsername: jest.fn(), + findByEmail : jest.fn(), + findByID : jest.fn(), + create : jest.fn() }; const mockJwtService = { - decode: jest.fn(), - signAsync: jest.fn(), - verify: jest.fn(), + 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: 'COOKIE_EXPIRES_IN', - useValue: '3600', - }, - { - provide: 'FRONTEND_URL', - useValue: 'http://frontend.test.com', - }, - { - provide: 'COOKIE_EXPIRES_IN', - useValue: '3600', - }, - { - 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', - }); + 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 : 'COOKIE_EXPIRES_IN', + useValue: '3600' + }, + { + provide : 'FRONTEND_URL', + useValue: 'http://frontend.test.com' + }, + { + provide : 'COOKIE_EXPIRES_IN', + useValue: '3600' + }, + { + 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 throw an error if no token is provided', async () => { - const req = { headers: { authorization: 'Bearer ' } } as Request; + it('should be defined', () => { + expect(authService).toBeDefined(); + }); - const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - } as any; + describe('verifyToken', () => { + it('should throw an error if no authorization header is provided', async () => { + const req = { headers: {} } as Request; - await authService.verifyToken(req, res); + const res = { + status: jest.fn().mockReturnThis(), + json : jest.fn() + } as any; - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ message: 'No token provided' }); - }); + await authService.verifyToken(req, res); - it('should throw an error if user is not found', async () => { - const req = { - headers: { authorization: 'Bearer test-token' }, - } as Request; + expect(res.status).toHaveBeenCalledWith(401); - const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - } as any; + expect(res.json).toHaveBeenCalledWith({ + message: 'No authorization header' + }); + }); - mockJwtService.verify.mockReturnValueOnce({ id: 'test-id' }); - mockUserService.findByID.mockResolvedValueOnce(null); + it('should throw an error if no token is provided', async () => { + const req = { headers: { authorization: 'Bearer ' } } as Request; - await authService.verifyToken(req, res); + const res = { + status: jest.fn().mockReturnThis(), + json : jest.fn() + } as any; - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ message: 'Unauthorized' }); - }); + await authService.verifyToken(req, res); - it('should return decoded token if user is found', async () => { - const req = { - headers: { authorization: 'Bearer test-token' }, - } as Request; + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: 'No token provided' }); + }); - const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - } as any; + it('should throw an error if user is not found', async () => { + const req = { + headers: { authorization: 'Bearer test-token' } + } as Request; - const decodedToken = { id: 'test-id' }; - mockJwtService.verify.mockReturnValueOnce(decodedToken); - mockUserService.findByID.mockResolvedValueOnce({ id: 'test-id' }); + const res = { + status: jest.fn().mockReturnThis(), + json : jest.fn() + } as any; - const result = await authService.verifyToken(req, res); - expect(result).toEqual(decodedToken); - }); - }); + mockJwtService.verify.mockReturnValueOnce({ id: 'test-id' }); + mockUserService.findByID.mockResolvedValueOnce(null); + + await authService.verifyToken(req, res); - describe('getUserFromToken', () => { - it('should return null if token is invalid', async () => { - mockJwtService.decode.mockReturnValueOnce(null); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: 'Unauthorized' }); + }); - const result = await authService.getUserFromToken('invalid-token'); - expect(result).toBeNull(); + 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); + }); }); - it('should return user if token is valid', async () => { - const decodedToken = { id: 'test-id' }; - mockJwtService.decode.mockReturnValueOnce(decodedToken); - mockUserService.findByID.mockResolvedValueOnce({ id: 'test-id' }); + 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' } as UserDocument); + const result = await authService.getUserFromToken('valid-token'); + expect(result).toEqual({ id: 'test-id' } as UserDocument); + }); }); - }); - - 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'; - - 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('createJwtPayload', () => { + it('should create access and refresh tokens', async () => { + const payload = { id: 'user-id', username: 'testuser' }; + const accessToken = 'access-token'; + const refreshToken = 'refresh-token'; + + 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', - }; - - 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: 3600000, - }); - - expect(res.cookie).toHaveBeenCalledWith( - 'refresh_token', - 'refresh-token', - { - domain: '.test.com', - maxAge: 3600000, - }, - ); - - expect(res.redirect).toHaveBeenCalledWith('http://frontend.test.com/'); + + 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' + }; + + 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: 3600000 + }); + + expect(res.cookie).toHaveBeenCalledWith( + 'refresh_token', + 'refresh-token', + { + domain: '.test.com', + maxAge: 3600000 + } + ); + + expect(res.redirect).toHaveBeenCalledWith('http://frontend.test.com/'); + }); }); - }); - 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', - }; + 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' }); + mockUserService.findByEmail.mockResolvedValue(null); + mockUserService.create.mockResolvedValue({ id: 'new-user-id' }); - const result = await (authService as any).verifyAndGetUser(user); + const result = await (authService as any).verifyAndGetUser(user); - expect(userService.findByEmail).toHaveBeenCalledWith('test@example.com'); + expect(userService.findByEmail).toHaveBeenCalledWith('test@example.com'); - expect(userService.create).toHaveBeenCalledWith( - expect.objectContaining({ - email: 'test@example.com', - profileImage: 'http://example.com/photo.jpg', - }), - ); + expect(userService.create).toHaveBeenCalledWith( + expect.objectContaining({ + email : 'test@example.com', + profileImage: 'http://example.com/photo.jpg' + }) + ); - expect(result).toEqual({ id: 'new-user-id' }); - }); + 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', - }; + 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', - }; + const registeredUser = { + id : 'registered-user-id', + profileImage: 'http://example.com/photo.jpg' + }; - mockUserService.findByEmail.mockResolvedValue(registeredUser); + mockUserService.findByEmail.mockResolvedValue(registeredUser); - const result = await (authService as any).verifyAndGetUser(user); + const result = await (authService as any).verifyAndGetUser(user); - expect(userService.findByEmail).toHaveBeenCalledWith('test@example.com'); - expect(result).toEqual(registeredUser); - }); + 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', - }; + 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(), - }; + const registeredUser = { + id : 'registered-user-id', + profileImage: 'http://example.com/old-photo.jpg', + save : jest.fn() + }; - mockUserService.findByEmail.mockResolvedValue(registeredUser); + mockUserService.findByEmail.mockResolvedValue(registeredUser); - const result = await (authService as any).verifyAndGetUser(user); + const result = await (authService as any).verifyAndGetUser(user); - expect(userService.findByEmail).toHaveBeenCalledWith('test@example.com'); + expect(userService.findByEmail).toHaveBeenCalledWith('test@example.com'); - expect(registeredUser.profileImage).toEqual( - 'http://example.com/new-photo.jpg', - ); + expect(registeredUser.profileImage).toEqual( + 'http://example.com/new-photo.jpg' + ); - expect(registeredUser.save).toHaveBeenCalled(); - expect(result).toEqual(registeredUser); + expect(registeredUser.save).toHaveBeenCalled(); + expect(result).toEqual(registeredUser); + }); }); - }); - describe('createNewUser', () => undefined); + describe('createNewUser', () => undefined); }); diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index b590e003..1ae08006 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -15,214 +15,214 @@ import { TokenPayload, Tokens } from './types/token'; @Injectable() export class AuthService { - private readonly logger = new Logger(AuthService.name); - constructor( - @Inject(UserService) - private readonly userService: UserService, - @Inject(JwtService) - private readonly jwtService: JwtService, - @Inject('COOKIE_EXPIRES_IN') - private readonly COOKIE_EXPIRES_IN: string, - @Inject('FRONTEND_URL') - private readonly FRONTEND_URL: 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('APP_DOMAIN') - private readonly APP_DOMAIN?: string, - ) {} - - public async verifyToken(req: Request, res: Response) { - const headers = req.headers; - const authorizationHeader = headers.authorization; - - if (!authorizationHeader) { - return res.status(401).json({ message: 'No authorization header' }); + private readonly logger = new Logger(AuthService.name); + constructor( + @Inject(UserService) + private readonly userService: UserService, + @Inject(JwtService) + private readonly jwtService: JwtService, + @Inject('COOKIE_EXPIRES_IN') + private readonly COOKIE_EXPIRES_IN: string, + @Inject('FRONTEND_URL') + private readonly FRONTEND_URL: 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('APP_DOMAIN') + private readonly APP_DOMAIN?: string + ) {} + + public async verifyToken(req: Request, res: Response) { + const headers = req.headers; + const authorizationHeader = headers.authorization; + + if (!authorizationHeader) { + return res.status(401).json({ message: 'No authorization header' }); + } + + const token = authorizationHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ message: 'No token provided' }); + } + + try { + const decoded = this.jwtService.verify(token, { + secret: this.JWT_SECRET + }); + + // verify if user exists + const user_registered = await this.userService.findByID(decoded.id); + + if (!user_registered) { + return res.status(401).json({ message: 'Unauthorized' }); + } else { + return decoded; + } + } catch (error) { + return res.status(401).json({ message: 'Unauthorized' }); + } } - const token = authorizationHeader.split(' ')[1]; + public async googleLogin(req: Request, res: Response) { + const user = req.user as GoogleProfile; + const email = user.emails[0].value; - if (!token) { - return res.status(401).json({ message: 'No token provided' }); - } - - try { - const decoded = this.jwtService.verify(token, { - secret: this.JWT_SECRET, - }); - - // verify if user exists - const user_registered = await this.userService.findByID(decoded.id); - - if (!user_registered) { - return res.status(401).json({ message: 'Unauthorized' }); - } else { - return decoded; - } - } catch (error) { - return res.status(401).json({ message: 'Unauthorized' }); - } - } + const profile = { + // Generate username from display name + username : email.split('@')[0], + email : email, + profileImage: user.photos[0].value + }; - public async googleLogin(req: Request, res: Response) { - const user = req.user as GoogleProfile; - const email = user.emails[0].value; + // verify if user exists + const user_registered = await this.verifyAndGetUser(profile); - const profile = { - // Generate username from display name - username: email.split('@')[0], - email: email, - profileImage: user.photos[0].value, - }; + return this.GenTokenRedirect(user_registered, res); + } - // verify if user exists - const user_registered = await this.verifyAndGetUser(profile); + private async createNewUser(user: Profile) { + const { username, email, profileImage } = user; + const baseUsername = username; + const newUsername = await this.userService.generateUsername(baseUsername); - return this.GenTokenRedirect(user_registered, res); - } + const newUser = new CreateUser({ + username : newUsername, + email : email, + profileImage: profileImage + }); - private async createNewUser(user: Profile) { - const { username, email, profileImage } = user; - const baseUsername = username; - const newUsername = await this.userService.generateUsername(baseUsername); + return await this.userService.create(newUser); + } - const newUser = new CreateUser({ - username: newUsername, - email: email, - profileImage: profileImage, - }); + private async verifyAndGetUser(user: Profile) { + const user_registered = await this.userService.findByEmail(user.email); - return await this.userService.create(newUser); - } + if (!user_registered) { + return await this.createNewUser(user); + } - private async verifyAndGetUser(user: Profile) { - const user_registered = await this.userService.findByEmail(user.email); + // Update profile picture if it has changed + if (user_registered.profileImage !== user.profileImage) { + user_registered.profileImage = user.profileImage; + await user_registered.save(); + } - if (!user_registered) { - return await this.createNewUser(user); + return user_registered; } - // Update profile picture if it has changed - if (user_registered.profileImage !== user.profileImage) { - user_registered.profileImage = user.profileImage; - await user_registered.save(); + public async githubLogin(req: Request, res: Response) { + const user = req.user as GithubAccessToken; + const { profile } = user; + + // verify if user exists + const response = await axios.get( + 'https://api.github.com/user/emails', + { + headers: { + Authorization: `token ${user.accessToken}` + } + } + ); + + const email = response.data.filter((email) => email.primary)[0].email; + + const user_registered = await this.verifyAndGetUser({ + username : profile.username, + email : email, + profileImage: profile.photos[0].value + }); + + return this.GenTokenRedirect(user_registered, res); } - return user_registered; - } + public async discordLogin(req: Request, res: Response) { + const user = (req.user as DiscordUser).profile; + const profilePictureUrl = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`; - public async githubLogin(req: Request, res: Response) { - const user = req.user as GithubAccessToken; - const { profile } = user; + const profile = { + // Generate username from display name + username : user.username, + email : user.email, + profileImage: profilePictureUrl + }; - // verify if user exists - const response = await axios.get( - 'https://api.github.com/user/emails', - { - headers: { - Authorization: `token ${user.accessToken}`, - }, - }, - ); + // verify if user exists + const user_registered = await this.verifyAndGetUser(profile); - const email = response.data.filter((email) => email.primary)[0].email; + return this.GenTokenRedirect(user_registered, res); + } - const user_registered = await this.verifyAndGetUser({ - username: profile.username, - email: email, - profileImage: profile.photos[0].value, - }); + public async loginWithEmail(req: Request, res: Response) { + const user = req.user as UserDocument; - return this.GenTokenRedirect(user_registered, res); - } + if (!user) { + return res.redirect(this.FRONTEND_URL + '/login'); + } - public async discordLogin(req: Request, res: Response) { - const user = (req.user as DiscordUser).profile; - const profilePictureUrl = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`; + return this.GenTokenRedirect(user, res); + } - const profile = { - // Generate username from display name - username: user.username, - email: user.email, - profileImage: profilePictureUrl, - }; + public async createJwtPayload(payload: TokenPayload): Promise { + const [accessToken, refreshToken] = await Promise.all([ + this.jwtService.signAsync(payload, { + secret : this.JWT_SECRET, + expiresIn: this.JWT_EXPIRES_IN + }), + this.jwtService.signAsync(payload, { + secret : this.JWT_REFRESH_SECRET, + expiresIn: this.JWT_REFRESH_EXPIRES_IN + }) + ]); + + return { + access_token : accessToken, + refresh_token: refreshToken + }; + } - // verify if user exists - const user_registered = await this.verifyAndGetUser(profile); + private async GenTokenRedirect( + user_registered: UserDocument, + res: Response> + ): Promise { + const token = await this.createJwtPayload({ + id : user_registered._id.toString(), + email : user_registered.email, + username: user_registered.username + }); + + const frontEndURL = this.FRONTEND_URL; + const domain = this.APP_DOMAIN; + const maxAge = parseInt(this.COOKIE_EXPIRES_IN) * 1000; + + res.cookie('token', token.access_token, { + domain: domain, + maxAge: maxAge + }); + + res.cookie('refresh_token', token.refresh_token, { + domain: domain, + maxAge: maxAge + }); + + res.redirect(frontEndURL + '/'); + } - return this.GenTokenRedirect(user_registered, res); - } + public async getUserFromToken(token: string): Promise { + const decoded = this.jwtService.decode(token); - public async loginWithEmail(req: Request, res: Response) { - const user = req.user as UserDocument; + if (!decoded) { + return null; + } - if (!user) { - return res.redirect(this.FRONTEND_URL + '/login'); - } + const user = await this.userService.findByID(decoded.id); - return this.GenTokenRedirect(user, res); - } - - public async createJwtPayload(payload: TokenPayload): Promise { - const [accessToken, refreshToken] = await Promise.all([ - this.jwtService.signAsync(payload, { - secret: this.JWT_SECRET, - expiresIn: this.JWT_EXPIRES_IN, - }), - this.jwtService.signAsync(payload, { - secret: this.JWT_REFRESH_SECRET, - expiresIn: this.JWT_REFRESH_EXPIRES_IN, - }), - ]); - - return { - access_token: accessToken, - refresh_token: refreshToken, - }; - } - - private async GenTokenRedirect( - user_registered: UserDocument, - res: Response>, - ): Promise { - const token = await this.createJwtPayload({ - id: user_registered._id.toString(), - email: user_registered.email, - username: user_registered.username, - }); - - const frontEndURL = this.FRONTEND_URL; - const domain = this.APP_DOMAIN; - const maxAge = parseInt(this.COOKIE_EXPIRES_IN) * 1000; - - res.cookie('token', token.access_token, { - domain: domain, - maxAge: maxAge, - }); - - res.cookie('refresh_token', token.refresh_token, { - domain: domain, - maxAge: maxAge, - }); - - res.redirect(frontEndURL + '/'); - } - - public async getUserFromToken(token: string): Promise { - const decoded = this.jwtService.decode(token) as TokenPayload; - - if (!decoded) { - return null; + return user; } - - const user = await this.userService.findByID(decoded.id); - - return user; - } } diff --git a/apps/backend/src/auth/strategies/JWT.strategy.spec.ts b/apps/backend/src/auth/strategies/JWT.strategy.spec.ts index 052cae9e..9290a530 100644 --- a/apps/backend/src/auth/strategies/JWT.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/JWT.strategy.spec.ts @@ -1,3 +1,5 @@ +import { describe, beforeEach, it, expect, jest } from 'bun:test'; + import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import type { Request } from 'express'; @@ -5,88 +7,88 @@ import type { 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', - ); + 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); }); - }); - - 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 be defined', () => { + expect(jwtStrategy).toBeDefined(); }); - it('should return payload with refreshToken from cookie', () => { - const req = { - headers: {}, - cookies: { - refresh_token: 'test-refresh-token', - }, - } as unknown as Request; + describe('constructor', () => { + it('should throw an error if JWT_SECRET is not set', () => { + jest.spyOn(configService, 'getOrThrow').mockReturnValue(null); - const payload = { userId: 'test-user-id' }; - - const result = jwtStrategy.validate(req, payload); - - expect(result).toEqual({ - ...payload, - refreshToken: 'test-refresh-token', - }); + expect(() => new JwtStrategy(configService)).toThrowError( + 'JwtStrategy requires a secret or key' + ); + }); }); - 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', - ); + 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/apps/backend/src/auth/strategies/JWT.strategy.ts b/apps/backend/src/auth/strategies/JWT.strategy.ts index 6311d4e4..87695398 100644 --- a/apps/backend/src/auth/strategies/JWT.strategy.ts +++ b/apps/backend/src/auth/strategies/JWT.strategy.ts @@ -6,32 +6,32 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { - private static logger = new Logger(JwtStrategy.name); - constructor(@Inject(ConfigService) config: ConfigService) { - const JWT_SECRET = config.getOrThrow('JWT_SECRET'); + private static logger = new Logger(JwtStrategy.name); + constructor(@Inject(ConfigService) config: ConfigService) { + const JWT_SECRET = config.getOrThrow('JWT_SECRET'); - super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: JWT_SECRET, - passReqToCallback: true, - }); - } + super({ + jwtFromRequest : ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey : JWT_SECRET, + passReqToCallback: true + }); + } - public validate(req: Request, payload: any) { - const refreshTokenHeader = req.headers?.authorization; - const refreshTokenCookie = req.cookies?.refresh_token; + public validate(req: Request, payload: any) { + const refreshTokenHeader = req.headers?.authorization; + const refreshTokenCookie = req.cookies?.refresh_token; - const refreshToken = refreshTokenHeader - ? refreshTokenHeader.split(' ')[1] - : refreshTokenCookie; + const refreshToken = refreshTokenHeader + ? refreshTokenHeader.split(' ')[1] + : refreshTokenCookie; - if (!refreshToken) { - throw new Error('No refresh token'); - } + if (!refreshToken) { + throw new Error('No refresh token'); + } - return { - ...payload, - refreshToken, - }; - } + return { + ...payload, + refreshToken + }; + } } diff --git a/apps/backend/src/auth/strategies/discord.strategy/DiscordStrategyConfig.ts b/apps/backend/src/auth/strategies/discord.strategy/DiscordStrategyConfig.ts index af8b44f2..9ad4cc0f 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/DiscordStrategyConfig.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/DiscordStrategyConfig.ts @@ -1,14 +1,14 @@ import { - IsArray, - IsBoolean, - IsEnum, - IsNumber, - IsOptional, - IsString, + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString } from 'class-validator'; import { - StrategyOptions as OAuth2StrategyOptions, - StrategyOptionsWithRequest as OAuth2StrategyOptionsWithRequest, + StrategyOptions as OAuth2StrategyOptions, + StrategyOptionsWithRequest as OAuth2StrategyOptionsWithRequest } from 'passport-oauth2'; import type { ScopeType } from './types'; @@ -18,43 +18,43 @@ type MergedOAuth2StrategyOptions = | OAuth2StrategyOptionsWithRequest; type DiscordStrategyOptions = Pick< - MergedOAuth2StrategyOptions, + MergedOAuth2StrategyOptions, 'clientID' | 'clientSecret' | 'scope' >; export class DiscordStrategyConfig implements DiscordStrategyOptions { - // The client ID assigned by Discord. - @IsString() - clientID: string; - - // The client secret assigned by Discord. - @IsString() - clientSecret: string; - - // The URL to which Discord will redirect the user after granting authorization. - @IsString() - callbackUrl: string; - - // An array of permission scopes to request. - @IsArray() - @IsString({ each: true }) - scope: ScopeType; - - // The delay in milliseconds between requests for the same scope. - @IsOptional() - @IsNumber() - scopeDelay?: number; - - // Whether to fetch data for the specified scope. - @IsOptional() - @IsBoolean() - fetchScope?: boolean; - - @IsEnum(['none', 'consent']) - prompt: 'consent' | 'none'; - - // The separator for the scope values. - @IsOptional() - @IsString() - scopeSeparator?: string; + // The client ID assigned by Discord. + @IsString() + clientID: string; + + // The client secret assigned by Discord. + @IsString() + clientSecret: string; + + // The URL to which Discord will redirect the user after granting authorization. + @IsString() + callbackUrl: string; + + // An array of permission scopes to request. + @IsArray() + @IsString({ each: true }) + scope: ScopeType; + + // The delay in milliseconds between requests for the same scope. + @IsOptional() + @IsNumber() + scopeDelay?: number; + + // Whether to fetch data for the specified scope. + @IsOptional() + @IsBoolean() + fetchScope?: boolean; + + @IsEnum(['none', 'consent']) + prompt: 'consent' | 'none'; + + // The separator for the scope values. + @IsOptional() + @IsString() + scopeSeparator?: string; } diff --git a/apps/backend/src/auth/strategies/discord.strategy/Strategy.spec.ts b/apps/backend/src/auth/strategies/discord.strategy/Strategy.spec.ts index e075f778..d64e2150 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/Strategy.spec.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/Strategy.spec.ts @@ -5,174 +5,174 @@ import DiscordStrategy from './Strategy'; import { DiscordPermissionScope, Profile } from './types'; describe('DiscordStrategy', () => { - let strategy: DiscordStrategy; - const verify: VerifyFunction = jest.fn(); - - beforeEach(() => { - const config: DiscordStrategyConfig = { - clientID: 'test-client-id', - clientSecret: 'test-client-secret', - callbackUrl: 'http://localhost:3000/callback', - scope: [ - DiscordPermissionScope.Email, - DiscordPermissionScope.Identify, - DiscordPermissionScope.Connections, - // DiscordPermissionScope.Bot, // Not allowed scope - ], - prompt: 'consent', - }; - - strategy = new DiscordStrategy(config, verify); - }); - - it('should be defined', () => { - expect(strategy).toBeDefined(); - }); - - it('should have the correct name', () => { - expect(strategy.name).toBe('discord'); - }); - - it('should validate config', async () => { - const config: DiscordStrategyConfig = { - clientID: 'test-client-id', - clientSecret: 'test-client-secret', - callbackUrl: 'http://localhost:3000/callback', - scope: [DiscordPermissionScope.Email, DiscordPermissionScope.Identify], - prompt: 'consent', - }; - - await expect(strategy['validateConfig'](config)).resolves.toBeUndefined(); - }); - - it('should make API request', async () => { - const mockGet = jest.fn((url, accessToken, callback) => { - callback(null, JSON.stringify({ id: '123' })); + let strategy: DiscordStrategy; + const verify: VerifyFunction = jest.fn(); + + beforeEach(() => { + const config: DiscordStrategyConfig = { + clientID : 'test-client-id', + clientSecret: 'test-client-secret', + callbackUrl : 'http://localhost:3000/callback', + scope : [ + DiscordPermissionScope.Email, + DiscordPermissionScope.Identify, + DiscordPermissionScope.Connections + // DiscordPermissionScope.Bot, // Not allowed scope + ], + prompt: 'consent' + }; + + strategy = new DiscordStrategy(config, verify); }); - strategy['_oauth2'].get = mockGet; - - const result = await strategy['makeApiRequest']<{ id: string }>( - 'https://discord.com/api/users/@me', - 'test-access-token', - ); - - expect(result).toEqual({ id: '123' }); - }); - - it('should fetch user data', async () => { - const mockMakeApiRequest = jest.fn().mockResolvedValue({ id: '123' }); - strategy['makeApiRequest'] = mockMakeApiRequest; - - const result = await strategy['fetchUserData']('test-access-token'); - - expect(result).toEqual({ id: '123' }); - }); - - it('should build profile', () => { - const profileData = { - id: '123', - username: 'testuser', - displayName: 'Test User', - avatar: 'avatar.png', - banner: 'banner.png', - email: 'test@example.com', - verified: true, - mfa_enabled: true, - public_flags: 1, - flags: 1, - locale: 'en-US', - global_name: 'testuser#1234', - premium_type: 1, - connections: [], - guilds: [], - } as unknown as Profile; - - const profile = strategy['buildProfile'](profileData, 'test-access-token'); - - expect(profile).toMatchObject({ - provider: 'discord', - id: '123', - username: 'testuser', - displayName: 'Test User', - avatar: 'avatar.png', - banner: 'banner.png', - email: 'test@example.com', - verified: true, - mfa_enabled: true, - public_flags: 1, - flags: 1, - locale: 'en-US', - global_name: 'testuser#1234', - premium_type: 1, - connections: [], - guilds: [], - access_token: 'test-access-token', - fetchedAt: expect.any(Date), - createdAt: expect.any(Date), - _raw: JSON.stringify(profileData), - _json: profileData, + it('should be defined', () => { + expect(strategy).toBeDefined(); }); - }); - it('should fetch scope data', async () => { - const mockMakeApiRequest = jest.fn().mockResolvedValue([{ id: '123' }]); - strategy['makeApiRequest'] = mockMakeApiRequest; + it('should have the correct name', () => { + expect(strategy.name).toBe('discord'); + }); + + it('should validate config', async () => { + const config: DiscordStrategyConfig = { + clientID : 'test-client-id', + clientSecret: 'test-client-secret', + callbackUrl : 'http://localhost:3000/callback', + scope : [DiscordPermissionScope.Email, DiscordPermissionScope.Identify], + prompt : 'consent' + }; + + await expect(strategy['validateConfig'](config)).resolves.toBeUndefined(); + }); + + it('should make API request', async () => { + const mockGet = jest.fn((url, accessToken, callback) => { + callback(null, JSON.stringify({ id: '123' })); + }); + + strategy['_oauth2'].get = mockGet; + + const result = await strategy['makeApiRequest']<{ id: string }>( + 'https://discord.com/api/users/@me', + 'test-access-token' + ); + + expect(result).toEqual({ id: '123' }); + }); + + it('should fetch user data', async () => { + const mockMakeApiRequest = jest.fn().mockResolvedValue({ id: '123' }); + strategy['makeApiRequest'] = mockMakeApiRequest; - const result = await strategy['fetchScopeData']( - DiscordPermissionScope.Connections, - 'test-access-token', - ); + const result = await strategy['fetchUserData']('test-access-token'); - expect(result).toEqual([{ id: '123' }]); - }); + expect(result).toEqual({ id: '123' }); + }); + + it('should build profile', () => { + const profileData = { + id : '123', + username : 'testuser', + displayName : 'Test User', + avatar : 'avatar.png', + banner : 'banner.png', + email : 'test@example.com', + verified : true, + mfa_enabled : true, + public_flags: 1, + flags : 1, + locale : 'en-US', + global_name : 'testuser#1234', + premium_type: 1, + connections : [], + guilds : [] + } as unknown as Profile; + + const profile = strategy['buildProfile'](profileData, 'test-access-token'); + + expect(profile).toMatchObject({ + provider : 'discord', + id : '123', + username : 'testuser', + displayName : 'Test User', + avatar : 'avatar.png', + banner : 'banner.png', + email : 'test@example.com', + verified : true, + mfa_enabled : true, + public_flags: 1, + flags : 1, + locale : 'en-US', + global_name : 'testuser#1234', + premium_type: 1, + connections : [], + guilds : [], + access_token: 'test-access-token', + fetchedAt : expect.any(Date), + createdAt : expect.any(Date), + _raw : JSON.stringify(profileData), + _json : profileData + }); + }); + + it('should fetch scope data', async () => { + const mockMakeApiRequest = jest.fn().mockResolvedValue([{ id: '123' }]); + strategy['makeApiRequest'] = mockMakeApiRequest; + + const result = await strategy['fetchScopeData']( + DiscordPermissionScope.Connections, + 'test-access-token' + ); - it('should no fetch out of scope data', async () => { - const mockMakeApiRequest = jest.fn().mockResolvedValue([{ id: '123' }]); - strategy['makeApiRequest'] = mockMakeApiRequest; + expect(result).toEqual([{ id: '123' }]); + }); - const result = await strategy['fetchScopeData']( - DiscordPermissionScope.Bot, - 'test-access-token', - ); + it('should no fetch out of scope data', async () => { + const mockMakeApiRequest = jest.fn().mockResolvedValue([{ id: '123' }]); + strategy['makeApiRequest'] = mockMakeApiRequest; - expect(result).toEqual(null); - }); + const result = await strategy['fetchScopeData']( + DiscordPermissionScope.Bot, + 'test-access-token' + ); - it('should enrich profile with scopes', async () => { - const profile = { - id: '123', - connections: [], - guilds: [], - } as unknown as Profile; + expect(result).toEqual(null); + }); - const mockFetchScopeData = jest - .fn() - .mockResolvedValueOnce([{ id: 'connection1' }]) - .mockResolvedValueOnce([{ id: 'guild1' }]); + it('should enrich profile with scopes', async () => { + const profile = { + id : '123', + connections: [], + guilds : [] + } as unknown as Profile; - strategy['fetchScopeData'] = mockFetchScopeData; + const mockFetchScopeData = jest + .fn() + .mockResolvedValueOnce([{ id: 'connection1' }]) + .mockResolvedValueOnce([{ id: 'guild1' }]); - await strategy['enrichProfileWithScopes'](profile, 'test-access-token'); + strategy['fetchScopeData'] = mockFetchScopeData; - expect(profile.connections).toEqual([{ id: 'connection1' }]); - expect(profile.guilds).toEqual([{ id: 'guild1' }]); - expect(profile.fetchedAt).toBeInstanceOf(Date); - }); + await strategy['enrichProfileWithScopes'](profile, 'test-access-token'); - it('should calculate creation date', () => { - const id = '123456789012345678'; - const date = strategy['calculateCreationDate'](id); + expect(profile.connections).toEqual([{ id: 'connection1' }]); + expect(profile.guilds).toEqual([{ id: 'guild1' }]); + expect(profile.fetchedAt).toBeInstanceOf(Date); + }); - expect(date).toBeInstanceOf(Date); - }); + it('should calculate creation date', () => { + const id = '123456789012345678'; + const date = strategy['calculateCreationDate'](id); + + expect(date).toBeInstanceOf(Date); + }); - it('should return authorization params', () => { - const options = { prompt: 'consent' }; - const params = strategy.authorizationParams(options); + it('should return authorization params', () => { + const options = { prompt: 'consent' }; + const params = strategy.authorizationParams(options); - expect(params).toMatchObject({ - prompt: 'consent', + expect(params).toMatchObject({ + prompt: 'consent' + }); }); - }); }); diff --git a/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts b/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts index ebc6d905..8a860163 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/Strategy.ts @@ -2,248 +2,248 @@ import { Logger } from '@nestjs/common'; import { plainToClass } from 'class-transformer'; import { validateOrReject } from 'class-validator'; import { - InternalOAuthError, - Strategy as OAuth2Strategy, - StrategyOptions as OAuth2StrategyOptions, - VerifyCallback, - VerifyFunction, + InternalOAuthError, + Strategy as OAuth2Strategy, + StrategyOptions as OAuth2StrategyOptions, + VerifyCallback, + VerifyFunction } from 'passport-oauth2'; import { DiscordStrategyConfig } from './DiscordStrategyConfig'; import { - Profile, - ProfileConnection, - ProfileGuild, - ScopeType, - SingleScopeType, + Profile, + ProfileConnection, + ProfileGuild, + ScopeType, + SingleScopeType } from './types'; interface AuthorizationParams { - prompt?: string; + prompt?: string; } export default class Strategy extends OAuth2Strategy { - // Static properties - public static DISCORD_EPOCH = 1420070400000; - public static DISCORD_SHIFT = 1 << 22; - - public static DISCORD_API_BASE = 'https://discord.com/api'; - - private readonly logger = new Logger('DiscordStrategy'); - private scope: ScopeType; - private scopeDelay: number; - private fetchScopeEnabled: boolean; - public override name = 'discord'; - prompt?: string; - public constructor(options: DiscordStrategyConfig, verify: VerifyFunction) { - super( - { - scopeSeparator: ' ', - ...options, - authorizationURL: 'https://discord.com/api/oauth2/authorize', - tokenURL: 'https://discord.com/api/oauth2/token', - } as OAuth2StrategyOptions, - verify, - ); - - this.validateConfig(options); - this.scope = options.scope; - this.scopeDelay = options.scopeDelay ?? 0; - this.fetchScopeEnabled = options.fetchScope ?? true; - this._oauth2.useAuthorizationHeaderforGET(true); - this.prompt = options.prompt; - } - - private async validateConfig(config: DiscordStrategyConfig): Promise { - try { - const validatedConfig = plainToClass(DiscordStrategyConfig, config); - await validateOrReject(validatedConfig); - } catch (errors) { - this.logger.error(errors); - throw new Error(`Configuration validation failed: ${errors}`); + // Static properties + public static DISCORD_EPOCH = 1420070400000; + public static DISCORD_SHIFT = 1 << 22; + + public static DISCORD_API_BASE = 'https://discord.com/api'; + + private readonly logger = new Logger('DiscordStrategy'); + private scope : ScopeType; + private scopeDelay : number; + private fetchScopeEnabled: boolean; + public override name = 'discord'; + prompt? : string; + public constructor(options: DiscordStrategyConfig, verify: VerifyFunction) { + super( + { + scopeSeparator : ' ', + ...options, + authorizationURL: 'https://discord.com/api/oauth2/authorize', + tokenURL : 'https://discord.com/api/oauth2/token' + } as OAuth2StrategyOptions, + verify + ); + + this.validateConfig(options); + this.scope = options.scope; + this.scopeDelay = options.scopeDelay ?? 0; + this.fetchScopeEnabled = options.fetchScope ?? true; + this._oauth2.useAuthorizationHeaderforGET(true); + this.prompt = options.prompt; } - } - - private async makeApiRequest( - url: string, - accessToken: string, - ): Promise { - return new Promise((resolve, reject) => { - this._oauth2.get(url, accessToken, (err, body) => { - if (err) { - reject(new InternalOAuthError(`Failed to fetch from ${url}`, err)); - return; + + private async validateConfig(config: DiscordStrategyConfig): Promise { + try { + const validatedConfig = plainToClass(DiscordStrategyConfig, config); + await validateOrReject(validatedConfig); + } catch (errors) { + this.logger.error(errors); + throw new Error(`Configuration validation failed: ${errors}`); } + } + private async makeApiRequest( + url: string, + accessToken: string + ): Promise { + return new Promise((resolve, reject) => { + this._oauth2.get(url, accessToken, (err, body) => { + if (err) { + reject(new InternalOAuthError(`Failed to fetch from ${url}`, err)); + return; + } + + try { + resolve(JSON.parse(body as string) as T); + } catch (parseError) { + reject(new Error(`Failed to parse response from ${url}`)); + } + }); + }); + } + + private async fetchUserData(accessToken: string): Promise { + return this.makeApiRequest( + `${Strategy.DISCORD_API_BASE}/users/@me`, + accessToken + ); + } + + public override async userProfile(accessToken: string, done: VerifyCallback) { try { - resolve(JSON.parse(body as string) as T); - } catch (parseError) { - reject(new Error(`Failed to parse response from ${url}`)); + const userData = await this.fetchUserData(accessToken); + const profile = this.buildProfile(userData, accessToken); + + if (this.fetchScopeEnabled) { + await this.enrichProfileWithScopes(profile, accessToken); + } + + done(null, profile); + } catch (error) { + this.logger.error('Failed to fetch user profile', error); + done(error); + } + } + + private async enrichProfileWithScopes( + profile: Profile, + accessToken: string + ): Promise { + await Promise.all([ + this.fetchScopeData('connections', accessToken).then( + (data) => (profile.connections = data as ProfileConnection[]) + ), + this.fetchScopeData('guilds', accessToken).then( + (data) => (profile.guilds = data as ProfileGuild[]) + ) + ]); + + profile.fetchedAt = new Date(); + } + + private async fetchScopeData( + scope: SingleScopeType, + accessToken: string + ): Promise { + if (!this.scope.includes(scope)) { + return null; + } + + if (this.scopeDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, this.scopeDelay)); } - }); - }); - } - - private async fetchUserData(accessToken: string): Promise { - return this.makeApiRequest( - `${Strategy.DISCORD_API_BASE}/users/@me`, - accessToken, - ); - } - - public override async userProfile(accessToken: string, done: VerifyCallback) { - try { - const userData = await this.fetchUserData(accessToken); - const profile = this.buildProfile(userData, accessToken); - - if (this.fetchScopeEnabled) { - await this.enrichProfileWithScopes(profile, accessToken); - } - - done(null, profile); - } catch (error) { - this.logger.error('Failed to fetch user profile', error); - done(error); + + return this.makeApiRequest( + `${Strategy.DISCORD_API_BASE}/users/@me/${scope}`, + accessToken + ); } - } - - private async enrichProfileWithScopes( - profile: Profile, - accessToken: string, - ): Promise { - await Promise.all([ - this.fetchScopeData('connections', accessToken).then( - (data) => (profile.connections = data as ProfileConnection[]), - ), - this.fetchScopeData('guilds', accessToken).then( - (data) => (profile.guilds = data as ProfileGuild[]), - ), - ]); - - profile.fetchedAt = new Date(); - } - - private async fetchScopeData( - scope: SingleScopeType, - accessToken: string, - ): Promise { - if (!this.scope.includes(scope)) { - return null; + + private calculateCreationDate(id: string) { + return new Date(+id / Strategy.DISCORD_SHIFT + Strategy.DISCORD_EPOCH); } - if (this.scopeDelay > 0) { - await new Promise((resolve) => setTimeout(resolve, this.scopeDelay)); + private buildProfile(data: Profile, accessToken: string): Profile { + const { id } = data; + return { + provider : 'discord', + id : id, + username : data.username, + displayName : data.displayName, + avatar : data.avatar, + banner : data.banner, + email : data.email, + verified : data.verified, + mfa_enabled : data.mfa_enabled, + public_flags: data.public_flags, + flags : data.flags, + locale : data.locale, + global_name : data.global_name, + premium_type: data.premium_type, + connections : data.connections, + guilds : data.guilds, + access_token: accessToken, + fetchedAt : new Date(), + createdAt : this.calculateCreationDate(id), + _raw : JSON.stringify(data), + _json : data as unknown as Record + }; } - return this.makeApiRequest( - `${Strategy.DISCORD_API_BASE}/users/@me/${scope}`, - accessToken, - ); - } - - private calculateCreationDate(id: string) { - return new Date(+id / Strategy.DISCORD_SHIFT + Strategy.DISCORD_EPOCH); - } - - private buildProfile(data: Profile, accessToken: string): Profile { - const { id } = data; - return { - provider: 'discord', - id: id, - username: data.username, - displayName: data.displayName, - avatar: data.avatar, - banner: data.banner, - email: data.email, - verified: data.verified, - mfa_enabled: data.mfa_enabled, - public_flags: data.public_flags, - flags: data.flags, - locale: data.locale, - global_name: data.global_name, - premium_type: data.premium_type, - connections: data.connections, - guilds: data.guilds, - access_token: accessToken, - fetchedAt: new Date(), - createdAt: this.calculateCreationDate(id), - _raw: JSON.stringify(data), - _json: data as unknown as Record, - }; - } - - public fetchScope( - scope: SingleScopeType, - accessToken: string, - callback: (err: Error | null, data: Record | null) => void, - ): void { + public fetchScope( + scope: SingleScopeType, + accessToken: string, + callback: (err: Error | null, data: Record | null) => void + ): void { // Early return if scope is not included - if (!this.scope.includes(scope)) { - callback(null, null); - return; - } + if (!this.scope.includes(scope)) { + callback(null, null); + return; + } - // Handle scope delay - const delayPromise = new Promise((resolve) => - setTimeout(resolve, this.scopeDelay ?? 0), - ); - - delayPromise - .then(() => { - this._oauth2.get( - `${Strategy.DISCORD_API_BASE}/users/@me/${scope}`, - accessToken, - (err, body) => { - if (err) { - this.logger.error(`Failed to fetch scope ${scope}:`, err); - - callback( - new InternalOAuthError(`Failed to fetch scope: ${scope}`, err), - null, - ); - - return; - } + // Handle scope delay + const delayPromise = new Promise((resolve) => + setTimeout(resolve, this.scopeDelay ?? 0) + ); - try { - if (typeof body !== 'string') { - const error = new Error( - `Invalid response type for scope: ${scope}`, + delayPromise + .then(() => { + this._oauth2.get( + `${Strategy.DISCORD_API_BASE}/users/@me/${scope}`, + accessToken, + (err, body) => { + if (err) { + this.logger.error(`Failed to fetch scope ${scope}:`, err); + + callback( + new InternalOAuthError(`Failed to fetch scope: ${scope}`, err), + null + ); + + return; + } + + try { + if (typeof body !== 'string') { + const error = new Error( + `Invalid response type for scope: ${scope}` + ); + + this.logger.error(error.message); + callback(error, null); + return; + } + + const json = JSON.parse(body) as Record; + callback(null, json); + } catch (parseError) { + const error = + parseError instanceof Error + ? parseError + : new Error(`Failed to parse scope data: ${scope}`); + + this.logger.error('Parse error:', error); + callback(error, null); + } + } ); - - this.logger.error(error.message); + }) + .catch((error) => { + this.logger.error('Unexpected error:', error); callback(error, null); - return; - } - - const json = JSON.parse(body) as Record; - callback(null, json); - } catch (parseError) { - const error = - parseError instanceof Error - ? parseError - : new Error(`Failed to parse scope data: ${scope}`); - - this.logger.error('Parse error:', error); - callback(error, null); - } - }, - ); - }) - .catch((error) => { - this.logger.error('Unexpected error:', error); - callback(error, null); - }); - } - - public override authorizationParams( - options: AuthorizationParams, - ): AuthorizationParams & Record { - const params: AuthorizationParams & Record = - super.authorizationParams(options) as Record; - - const { prompt } = this; - if (prompt) params.prompt = prompt; - return params; - } + }); + } + + public override authorizationParams( + options: AuthorizationParams + ): AuthorizationParams & Record { + const params: AuthorizationParams & Record = + super.authorizationParams(options) as Record; + + const { prompt } = this; + if (prompt) params.prompt = prompt; + return params; + } } diff --git a/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts b/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts index 0dbc8608..0fc785d8 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts @@ -4,64 +4,64 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DiscordStrategy } from './index'; describe('DiscordStrategy', () => { - let discordStrategy: DiscordStrategy; - let configService: ConfigService; + 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(); + 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); - }); + discordStrategy = module.get(DiscordStrategy); + configService = module.get(ConfigService); + }); - it('should be defined', () => { - expect(discordStrategy).toBeDefined(); - }); + 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); + 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', - ); + 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' }; + 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, - ); + const result = await discordStrategy.validate( + accessToken, + refreshToken, + profile + ); - expect(result).toEqual({ accessToken, refreshToken, profile }); + expect(result).toEqual({ accessToken, refreshToken, profile }); + }); }); - }); }); diff --git a/apps/backend/src/auth/strategies/discord.strategy/index.ts b/apps/backend/src/auth/strategies/discord.strategy/index.ts index 61dc578a..82d9d734 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/index.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/index.ts @@ -7,37 +7,37 @@ import { DiscordPermissionScope } from './types'; @Injectable() export class DiscordStrategy extends PassportStrategy(strategy, 'discord') { - private static logger = new Logger(DiscordStrategy.name); - constructor( - @Inject(ConfigService) - configService: ConfigService, - ) { - const DISCORD_CLIENT_ID = - configService.getOrThrow('DISCORD_CLIENT_ID'); - - const DISCORD_CLIENT_SECRET = configService.getOrThrow( - 'DISCORD_CLIENT_SECRET', - ); - - const SERVER_URL = configService.getOrThrow('SERVER_URL'); - - const config = { - clientID: DISCORD_CLIENT_ID, - clientSecret: DISCORD_CLIENT_SECRET, - callbackUrl: `${SERVER_URL}/api/v1/auth/discord/callback`, - scope: [DiscordPermissionScope.Email, DiscordPermissionScope.Identify], - fetchScope: true, - prompt: 'none', - }; - - super(config); - } - - async validate(accessToken: string, refreshToken: string, profile: any) { - DiscordStrategy.logger.debug( - `Discord Login Data ${JSON.stringify(profile)}`, - ); - - return { accessToken, refreshToken, profile }; - } + private static logger = new Logger(DiscordStrategy.name); + constructor( + @Inject(ConfigService) + configService: ConfigService + ) { + const DISCORD_CLIENT_ID = + configService.getOrThrow('DISCORD_CLIENT_ID'); + + const DISCORD_CLIENT_SECRET = configService.getOrThrow( + 'DISCORD_CLIENT_SECRET' + ); + + const SERVER_URL = configService.getOrThrow('SERVER_URL'); + + const config = { + clientID : DISCORD_CLIENT_ID, + clientSecret: DISCORD_CLIENT_SECRET, + callbackUrl : `${SERVER_URL}/api/v1/auth/discord/callback`, + scope : [DiscordPermissionScope.Email, DiscordPermissionScope.Identify], + fetchScope : true, + prompt : 'none' + }; + + super(config); + } + + async validate(accessToken: string, refreshToken: string, profile: any) { + DiscordStrategy.logger.debug( + `Discord Login Data ${JSON.stringify(profile)}` + ); + + return { accessToken, refreshToken, profile }; + } } diff --git a/apps/backend/src/auth/strategies/discord.strategy/types.ts b/apps/backend/src/auth/strategies/discord.strategy/types.ts index cb276741..286c5c60 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/types.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/types.ts @@ -4,34 +4,34 @@ import passport from 'passport'; * https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes */ export enum DiscordPermissionScope { - ActivitiesRead = 'activities.read', - ActivitiesWrite = 'activities.write', - ApplicationBuildsRead = 'applications.builds.read', - ApplicationBuildsUpload = 'applications.builds.upload', - ApplicationsCommands = 'applications.commands', - ApplicationsCommandsUpdate = 'applications.commands.update', - ApplicationsCommandsPermissionsUpdate = 'applications.commands.permissions.update', - ApplicationsEntitlements = 'applications.entitlements', - ApplicationsStoreUpdate = 'applications.store.update', - Bot = 'bot', - Connections = 'connections', - DmRead = 'dm_channels.read', - Email = 'email', - GdmJoin = 'gdm.join', - Guilds = 'guilds', - GuildsJoin = 'guilds.join', - GuildMembersRead = 'guilds.members.read', - Identify = 'identify', - MessagesRead = 'messages.read', - RelationshipsRead = 'relationships.read', - RoleConnectionsWrite = 'role_connections.write', - Rpc = 'rpc', - RpcActivitiesUpdate = 'rpc.activities.update', - RpcNotificationsRead = 'rpc.notifications.read', - RpcVoiceRead = 'rpc.voice.read', - RpcVoiceWrite = 'rpc.voice.write', - Voice = 'voice', - WebhookIncoming = 'webhook.incoming', + ActivitiesRead = 'activities.read', + ActivitiesWrite = 'activities.write', + ApplicationBuildsRead = 'applications.builds.read', + ApplicationBuildsUpload = 'applications.builds.upload', + ApplicationsCommands = 'applications.commands', + ApplicationsCommandsUpdate = 'applications.commands.update', + ApplicationsCommandsPermissionsUpdate = 'applications.commands.permissions.update', + ApplicationsEntitlements = 'applications.entitlements', + ApplicationsStoreUpdate = 'applications.store.update', + Bot = 'bot', + Connections = 'connections', + DmRead = 'dm_channels.read', + Email = 'email', + GdmJoin = 'gdm.join', + Guilds = 'guilds', + GuildsJoin = 'guilds.join', + GuildMembersRead = 'guilds.members.read', + Identify = 'identify', + MessagesRead = 'messages.read', + RelationshipsRead = 'relationships.read', + RoleConnectionsWrite = 'role_connections.write', + Rpc = 'rpc', + RpcActivitiesUpdate = 'rpc.activities.update', + RpcNotificationsRead = 'rpc.notifications.read', + RpcVoiceRead = 'rpc.voice.read', + RpcVoiceWrite = 'rpc.voice.write', + Voice = 'voice', + WebhookIncoming = 'webhook.incoming' } export type SingleScopeType = `${DiscordPermissionScope}`; @@ -42,188 +42,188 @@ export type ScopeType = SingleScopeType[]; * https://discord.com/developers/docs/resources/user#user-object */ export interface DiscordUser { - id: string; - username: string; - global_name?: string | undefined; - avatar: string; - bot?: string | undefined; - system?: boolean | undefined; - mfa_enabled?: boolean | undefined; - banner?: string | undefined; - accent_color?: number | undefined; - locale?: string | undefined; - verified?: boolean | undefined; - email?: string | undefined; - flags?: number | undefined; - premium_type?: number | undefined; - public_flags?: number | undefined; - avatar_decoration_data?: AvatarDecorationData | undefined; + id : string; + username : string; + global_name? : string | undefined; + avatar : string; + bot? : string | undefined; + system? : boolean | undefined; + mfa_enabled? : boolean | undefined; + banner? : string | undefined; + accent_color? : number | undefined; + locale? : string | undefined; + verified? : boolean | undefined; + email? : string | undefined; + flags? : number | undefined; + premium_type? : number | undefined; + public_flags? : number | undefined; + avatar_decoration_data?: AvatarDecorationData | undefined; } export interface AvatarDecorationData { - asset: string; - sku_id: string; + asset : string; + sku_id: string; } export interface DiscordAccount { - id: string; - name: string; + id : string; + name: string; } export interface DiscordApplication { - id: string; - name: string; - icon?: string | undefined; - description: string; - bot?: DiscordUser; + id : string; + name : string; + icon? : string | undefined; + description: string; + bot? : DiscordUser; } export interface DiscordIntegration { - id: string; - name: string; - type: string; - enabled: boolean; - syncing?: boolean | undefined; - role_id?: string | undefined; - enable_emoticons?: boolean | undefined; - expire_behavior?: number | undefined; - expire_grace_period?: number | undefined; - user?: DiscordUser | undefined; - account: DiscordAccount; - synced_at?: Date | undefined; - subscriber_count?: number | undefined; - revoked?: boolean | undefined; - application?: DiscordApplication | undefined; - scopes?: ScopeType | undefined; + id : string; + name : string; + type : string; + enabled : boolean; + syncing? : boolean | undefined; + role_id? : string | undefined; + enable_emoticons? : boolean | undefined; + expire_behavior? : number | undefined; + expire_grace_period?: number | undefined; + user? : DiscordUser | undefined; + account : DiscordAccount; + synced_at? : Date | undefined; + subscriber_count? : number | undefined; + revoked? : boolean | undefined; + application? : DiscordApplication | undefined; + scopes? : ScopeType | undefined; } export interface ProfileConnection { - id: string; - name: string; - type: string; - revoked?: boolean | undefined; - integrations?: DiscordIntegration[] | undefined; - verified: boolean; - friend_sync: boolean; - show_activity: boolean; - two_way_link: boolean; - visibility: number; + id : string; + name : string; + type : string; + revoked? : boolean | undefined; + integrations?: DiscordIntegration[] | undefined; + verified : boolean; + friend_sync : boolean; + show_activity: boolean; + two_way_link : boolean; + visibility : number; } export interface DiscordRoleTag { - bot_id?: string | undefined; - integration_id?: string | undefined; - premium_subscriber?: null | undefined; - subscription_listing_id?: string | undefined; - available_for_purchase?: null | undefined; - guild_connections?: null | undefined; + bot_id? : string | undefined; + integration_id? : string | undefined; + premium_subscriber? : null | undefined; + subscription_listing_id?: string | undefined; + available_for_purchase? : null | undefined; + guild_connections? : null | undefined; } export interface DiscordRole { - id: string; - name: string; - color: number; - hoist: boolean; - icon?: string | undefined; - unicode_emoji?: string | undefined; - position: number; - permissions: string; - managed: boolean; - tags?: DiscordRoleTag | undefined; - flags: number; + id : string; + name : string; + color : number; + hoist : boolean; + icon? : string | undefined; + unicode_emoji?: string | undefined; + position : number; + permissions : string; + managed : boolean; + tags? : DiscordRoleTag | undefined; + flags : number; } export interface DiscordEmoji { - id?: string | undefined; - name: string | undefined; - roles?: string[]; - user?: DiscordUser; - require_colons?: boolean | undefined; - managed?: boolean | undefined; - animated?: boolean | undefined; - available?: boolean | undefined; + id? : string | undefined; + name : string | undefined; + roles? : string[]; + user? : DiscordUser; + require_colons?: boolean | undefined; + managed? : boolean | undefined; + animated? : boolean | undefined; + available? : boolean | undefined; } export interface DiscordWelcomeScreenChannel { - channel_id: string; - description: string; - emoji_id?: string | undefined; - emoji_name?: string | undefined; + channel_id : string; + description: string; + emoji_id? : string | undefined; + emoji_name?: string | undefined; } export interface DiscordWelcomeScreen { - description?: string | undefined; - welcome_channels: DiscordWelcomeScreenChannel[]; + description? : string | undefined; + welcome_channels: DiscordWelcomeScreenChannel[]; } export interface DiscordSticker { - id: string; - pack_id?: string | undefined; - name: string; - description: string; - tags: string; - type: number; - format_type: number; - available?: boolean | undefined; - guild_id?: string | undefined; - user?: DiscordUser | undefined; - sort_value?: number | undefined; + id : string; + pack_id? : string | undefined; + name : string; + description: string; + tags : string; + type : number; + format_type: number; + available? : boolean | undefined; + guild_id? : string | undefined; + user? : DiscordUser | undefined; + sort_value?: number | undefined; } export interface ProfileGuild { - id: string; - name: string; - icon?: string | undefined; - icon_hash?: string | undefined; - splash?: string | undefined; - discovery_splash?: string | undefined; - owner?: boolean | string; - owner_id: string; - permissions?: string | undefined; - afk_channel_id?: string | undefined; - afk_timeout?: number | undefined; - widget_enabled: boolean | undefined; - widget_channel_id?: string | undefined; - verification_level?: number | undefined; - default_message_notifications?: number | undefined; - explicit_content_filter?: number | undefined; - roles: DiscordRole[]; - emojis: DiscordEmoji[]; - features: string[]; - mfa_level?: number | undefined; - application_id?: string | undefined; - system_channel_id?: string | undefined; - system_channel_flags?: number | undefined; - rules_channel_id?: string | undefined; - max_presences?: number | undefined; - max_members?: number | undefined; - vanity_url_code?: string | undefined; - description?: string | undefined; - banner?: string | undefined; - premium_tier?: number | undefined; - premium_subscription_count?: number | undefined; - preferred_locale?: string | undefined; - public_updates_channel_id?: string | undefined; - max_video_channel_users?: number | undefined; - max_stage_video_channel_users?: number | undefined; - approximate_member_count?: number | undefined; - approximate_presence_count?: number | undefined; - welcome_screen?: DiscordWelcomeScreen | undefined; - nsfw_level?: number | undefined; - stickers?: DiscordSticker[] | undefined; - premium_progress_bar_enabled?: boolean | undefined; - safety_alerts_channel_id?: string | undefined; + id : string; + name : string; + icon? : string | undefined; + icon_hash? : string | undefined; + splash? : string | undefined; + discovery_splash? : string | undefined; + owner? : boolean | string; + owner_id : string; + permissions? : string | undefined; + afk_channel_id? : string | undefined; + afk_timeout? : number | undefined; + widget_enabled : boolean | undefined; + widget_channel_id? : string | undefined; + verification_level? : number | undefined; + default_message_notifications?: number | undefined; + explicit_content_filter? : number | undefined; + roles : DiscordRole[]; + emojis : DiscordEmoji[]; + features : string[]; + mfa_level? : number | undefined; + application_id? : string | undefined; + system_channel_id? : string | undefined; + system_channel_flags? : number | undefined; + rules_channel_id? : string | undefined; + max_presences? : number | undefined; + max_members? : number | undefined; + vanity_url_code? : string | undefined; + description? : string | undefined; + banner? : string | undefined; + premium_tier? : number | undefined; + premium_subscription_count? : number | undefined; + preferred_locale? : string | undefined; + public_updates_channel_id? : string | undefined; + max_video_channel_users? : number | undefined; + max_stage_video_channel_users?: number | undefined; + approximate_member_count? : number | undefined; + approximate_presence_count? : number | undefined; + welcome_screen? : DiscordWelcomeScreen | undefined; + nsfw_level? : number | undefined; + stickers? : DiscordSticker[] | undefined; + premium_progress_bar_enabled? : boolean | undefined; + safety_alerts_channel_id? : string | undefined; } export interface Profile - extends Omit, + extends Omit, DiscordUser { - provider: string; - connections?: ProfileConnection[] | undefined; - guilds?: ProfileGuild[] | undefined; - access_token: string; - fetchedAt: Date; - createdAt: Date; - _raw: unknown; - _json: Record; + provider : string; + connections?: ProfileConnection[] | undefined; + guilds? : ProfileGuild[] | undefined; + access_token: string; + fetchedAt : Date; + createdAt : Date; + _raw : unknown; + _json : Record; } diff --git a/apps/backend/src/auth/strategies/github.strategy.spec.ts b/apps/backend/src/auth/strategies/github.strategy.spec.ts index c8793e00..f553e052 100644 --- a/apps/backend/src/auth/strategies/github.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/github.strategy.spec.ts @@ -1,67 +1,69 @@ +import { describe, beforeEach, it, expect, jest } from 'bun:test'; + import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { GithubStrategy } from './github.strategy'; describe('GithubStrategy', () => { - let githubStrategy: GithubStrategy; - let configService: ConfigService; + 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(); + 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); - }); + githubStrategy = module.get(GithubStrategy); + configService = module.get(ConfigService); + }); - it('should be defined', () => { - expect(githubStrategy).toBeDefined(); - }); + 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); + 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', - ); + 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' }; + 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, - ); + const result = await githubStrategy.validate( + accessToken, + refreshToken, + profile + ); - expect(result).toEqual({ accessToken, refreshToken, profile }); + expect(result).toEqual({ accessToken, refreshToken, profile }); + }); }); - }); }); diff --git a/apps/backend/src/auth/strategies/github.strategy.ts b/apps/backend/src/auth/strategies/github.strategy.ts index 27293151..4d620320 100644 --- a/apps/backend/src/auth/strategies/github.strategy.ts +++ b/apps/backend/src/auth/strategies/github.strategy.ts @@ -5,32 +5,32 @@ import strategy from 'passport-github'; @Injectable() export class GithubStrategy extends PassportStrategy(strategy, 'github') { - private static logger = new Logger(GithubStrategy.name); - constructor( - @Inject(ConfigService) - configService: ConfigService, - ) { - const GITHUB_CLIENT_ID = - configService.getOrThrow('GITHUB_CLIENT_ID'); + private static logger = new Logger(GithubStrategy.name); + constructor( + @Inject(ConfigService) + configService: ConfigService + ) { + const GITHUB_CLIENT_ID = + configService.getOrThrow('GITHUB_CLIENT_ID'); - const GITHUB_CLIENT_SECRET = configService.getOrThrow( - 'GITHUB_CLIENT_SECRET', - ); + const GITHUB_CLIENT_SECRET = configService.getOrThrow( + 'GITHUB_CLIENT_SECRET' + ); - const SERVER_URL = configService.getOrThrow('SERVER_URL'); + const SERVER_URL = configService.getOrThrow('SERVER_URL'); - super({ - clientID: GITHUB_CLIENT_ID, - clientSecret: GITHUB_CLIENT_SECRET, - redirect_uri: `${SERVER_URL}/api/v1/auth/github/callback`, - scope: 'user:read,user:email', - state: false, - }); - } + super({ + clientID : GITHUB_CLIENT_ID, + clientSecret: GITHUB_CLIENT_SECRET, + redirect_uri: `${SERVER_URL}/api/v1/auth/github/callback`, + scope : 'user:read,user:email', + state : false + }); + } - async validate(accessToken: string, refreshToken: string, profile: any) { - GithubStrategy.logger.debug(`GitHub Login Data ${JSON.stringify(profile)}`); + async validate(accessToken: string, refreshToken: string, profile: any) { + GithubStrategy.logger.debug(`GitHub Login Data ${JSON.stringify(profile)}`); - return { accessToken, refreshToken, profile }; - } + return { accessToken, refreshToken, profile }; + } } diff --git a/apps/backend/src/auth/strategies/google.strategy.spec.ts b/apps/backend/src/auth/strategies/google.strategy.spec.ts index c1f1233e..fc19c58b 100644 --- a/apps/backend/src/auth/strategies/google.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/google.strategy.spec.ts @@ -1,3 +1,5 @@ +import { describe, beforeEach, it, expect, jest } from 'bun:test'; + import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { VerifyCallback } from 'passport-google-oauth20'; @@ -5,61 +7,61 @@ import { VerifyCallback } from 'passport-google-oauth20'; import { GoogleStrategy } from './google.strategy'; describe('GoogleStrategy', () => { - let googleStrategy: GoogleStrategy; - let configService: ConfigService; + 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(); + 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); - }); + googleStrategy = module.get(GoogleStrategy); + configService = module.get(ConfigService); + }); - it('should be defined', () => { - expect(googleStrategy).toBeDefined(); - }); + 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); + 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', - ); + 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(); + 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); + googleStrategy.validate(accessToken, refreshToken, profile, done); - expect(done).toHaveBeenCalledWith(null, profile); + expect(done).toHaveBeenCalledWith(null, profile); + }); }); - }); }); diff --git a/apps/backend/src/auth/strategies/google.strategy.ts b/apps/backend/src/auth/strategies/google.strategy.ts index a19e1789..3765493f 100644 --- a/apps/backend/src/auth/strategies/google.strategy.ts +++ b/apps/backend/src/auth/strategies/google.strategy.ts @@ -5,38 +5,38 @@ import { Strategy, VerifyCallback } from 'passport-google-oauth20'; @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { - private static logger = new Logger(GoogleStrategy.name); - constructor( - @Inject(ConfigService) - configService: ConfigService, - ) { - const GOOGLE_CLIENT_ID = - configService.getOrThrow('GOOGLE_CLIENT_ID'); + private static logger = new Logger(GoogleStrategy.name); + constructor( + @Inject(ConfigService) + configService: ConfigService + ) { + const GOOGLE_CLIENT_ID = + configService.getOrThrow('GOOGLE_CLIENT_ID'); - const GOOGLE_CLIENT_SECRET = configService.getOrThrow( - 'GOOGLE_CLIENT_SECRET', - ); + const GOOGLE_CLIENT_SECRET = configService.getOrThrow( + 'GOOGLE_CLIENT_SECRET' + ); - const SERVER_URL = configService.getOrThrow('SERVER_URL'); + const SERVER_URL = configService.getOrThrow('SERVER_URL'); - const callbackURL = `${SERVER_URL}/api/v1/auth/google/callback`; - GoogleStrategy.logger.debug(`Google Login callbackURL ${callbackURL}`); + const callbackURL = `${SERVER_URL}/api/v1/auth/google/callback`; + GoogleStrategy.logger.debug(`Google Login callbackURL ${callbackURL}`); - super({ - clientID: GOOGLE_CLIENT_ID, - clientSecret: GOOGLE_CLIENT_SECRET, - callbackURL: callbackURL, - scope: ['email', 'profile'], - }); - } + super({ + clientID : GOOGLE_CLIENT_ID, + clientSecret: GOOGLE_CLIENT_SECRET, + callbackURL : callbackURL, + scope : ['email', 'profile'] + }); + } - validate( - accessToken: string, - refreshToken: string, - profile: any, - done: VerifyCallback, - ): any { - GoogleStrategy.logger.debug(`Google Login Data ${JSON.stringify(profile)}`); - done(null, profile); - } + validate( + accessToken: string, + refreshToken: string, + profile: any, + done: VerifyCallback + ): any { + GoogleStrategy.logger.debug(`Google Login Data ${JSON.stringify(profile)}`); + done(null, profile); + } } diff --git a/apps/backend/src/auth/strategies/magicLinkEmail.strategy.spec.ts b/apps/backend/src/auth/strategies/magicLinkEmail.strategy.spec.ts index a77f26c6..cbffca59 100644 --- a/apps/backend/src/auth/strategies/magicLinkEmail.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/magicLinkEmail.strategy.spec.ts @@ -1,3 +1,6 @@ +import { describe, beforeEach, it, expect, jest } from 'bun:test'; + +import { UserDocument } from '@nbw/database'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; @@ -7,147 +10,147 @@ import { UserService } from '@server/user/user.service'; import { MagicLinkEmailStrategy } from './magicLinkEmail.strategy'; describe('MagicLinkEmailStrategy', () => { - let strategy: MagicLinkEmailStrategy; - let userService: UserService; - let mailingService: MailingService; - let _configService: ConfigService; - - const mockUserService = { - findByEmail: jest.fn(), - createWithEmail: jest.fn(), - }; - - const mockMailingService = { - sendEmail: jest.fn(), - }; - - const mockConfigService = { - get: jest.fn((key: string) => { - if (key === 'MAGIC_LINK_SECRET') return 'test_secret'; - if (key === 'SERVER_URL') return 'http://localhost:3000'; - return null; - }), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - MagicLinkEmailStrategy, - { provide: UserService, useValue: mockUserService }, - { provide: MailingService, useValue: mockMailingService }, - { provide: ConfigService, useValue: mockConfigService }, - { - provide: 'MAGIC_LINK_SECRET', - useValue: 'test_secret', - }, - { - provide: 'SERVER_URL', - useValue: 'http://localhost:3000', - }, - ], - }).compile(); - - strategy = module.get(MagicLinkEmailStrategy); - userService = module.get(UserService); - mailingService = module.get(MailingService); - _configService = module.get(ConfigService); - }); - - it('should be defined', () => { - expect(strategy).toBeDefined(); - }); - - describe('sendMagicLink', () => { - it('should send a magic link email', async () => { - const email = 'test@example.com'; - - const magicLink = - 'http://localhost/api/v1/auth/magic-link/callback?token=test_token'; - - const user = { username: 'testuser', email }; - - mockUserService.findByEmail.mockResolvedValue(user); - - await MagicLinkEmailStrategy.sendMagicLink( - 'http://localhost:3000', - userService, - mailingService, - )(email, magicLink); - - expect(mockUserService.findByEmail).toHaveBeenCalledWith(email); - - expect(mockMailingService.sendEmail).toHaveBeenCalledWith({ - to: email, - context: { - magicLink: - 'http://localhost/api/v1/auth/magic-link/callback?token=test_token', - username: 'testuser', - }, - subject: 'Noteblock Magic Link', - template: 'magic-link', - }); + let strategy: MagicLinkEmailStrategy; + let userService: UserService; + let mailingService: MailingService; + let _configService: ConfigService; + + const mockUserService = { + findByEmail : jest.fn(), + createWithEmail: jest.fn() + }; + + const mockMailingService = { + sendEmail: jest.fn() + }; + + const mockConfigService = { + get: jest.fn((key: string) => { + if (key === 'MAGIC_LINK_SECRET') return 'test_secret'; + if (key === 'SERVER_URL') return 'http://localhost:3000'; + return null; + }) + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MagicLinkEmailStrategy, + { provide: UserService, useValue: mockUserService }, + { provide: MailingService, useValue: mockMailingService }, + { provide: ConfigService, useValue: mockConfigService }, + { + provide : 'MAGIC_LINK_SECRET', + useValue: 'test_secret' + }, + { + provide : 'SERVER_URL', + useValue: 'http://localhost:3000' + } + ] + }).compile(); + + strategy = module.get(MagicLinkEmailStrategy); + userService = module.get(UserService); + mailingService = module.get(MailingService); + _configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(strategy).toBeDefined(); }); - it('should create a new user if not found and send a magic link email', async () => { - const email = 'testuser@test.com'; + describe('sendMagicLink', () => { + it('should send a magic link email', async () => { + const email = 'test@example.com'; - const magicLink = - 'http://localhost/api/v1/auth/magic-link/callback?token=test_token'; + const magicLink = + 'http://localhost/api/v1/auth/magic-link/callback?token=test_token'; - const user = { username: 'testuser', email }; + const user = { username: 'testuser', email }; - mockUserService.findByEmail.mockResolvedValue(null); - mockUserService.createWithEmail.mockResolvedValue(user); + mockUserService.findByEmail.mockResolvedValue(user); - await MagicLinkEmailStrategy.sendMagicLink( - 'http://localhost:3000', - userService, - mailingService, - )(email, magicLink); + await MagicLinkEmailStrategy.sendMagicLink( + 'http://localhost:3000', + userService, + mailingService + )(email, magicLink); - expect(mockUserService.findByEmail).toHaveBeenCalledWith(email); + expect(mockUserService.findByEmail).toHaveBeenCalledWith(email); - expect(mockMailingService.sendEmail).toHaveBeenCalledWith({ - to: email, - context: { - magicLink: + expect(mockMailingService.sendEmail).toHaveBeenCalledWith({ + to : email, + context: { + magicLink: 'http://localhost/api/v1/auth/magic-link/callback?token=test_token', - username: 'testuser', - }, - subject: 'Welcome to Noteblock.world', - template: 'magic-link-new-account', - }); - }); - }); + username: 'testuser' + }, + subject : 'Noteblock Magic Link', + template: 'magic-link' + }); + }); + + it('should create a new user if not found and send a magic link email', async () => { + const email = 'testuser@test.com'; + + const magicLink = + 'http://localhost/api/v1/auth/magic-link/callback?token=test_token'; - describe('validate', () => { - it('should validate the payload and return the user', async () => { - const payload = { destination: 'test@example.com' }; - const user = { username: 'testuser', email: 'test@example.com' }; + const user = { username: 'testuser', email }; - mockUserService.findByEmail.mockResolvedValue(user); + mockUserService.findByEmail.mockResolvedValue(null); + mockUserService.createWithEmail.mockResolvedValue(user); - const result = await strategy.validate(payload); + await MagicLinkEmailStrategy.sendMagicLink( + 'http://localhost:3000', + userService, + mailingService + )(email, magicLink); - expect(result).toEqual(user); + expect(mockUserService.findByEmail).toHaveBeenCalledWith(email); + + expect(mockMailingService.sendEmail).toHaveBeenCalledWith({ + to : email, + context: { + magicLink: + 'http://localhost/api/v1/auth/magic-link/callback?token=test_token', + username: 'testuser' + }, + subject : 'Welcome to Noteblock.world', + template: 'magic-link-new-account' + }); + }); }); - it('should create a new user if not found and return the user', async () => { - const payload = { destination: 'test@example.com' }; + describe('validate', () => { + it('should validate the payload and return the user', async () => { + const payload = { destination: 'test@example.com' }; + const user = { username: 'testuser', email: 'test@example.com' }; + + mockUserService.findByEmail.mockResolvedValue(user); + + const result = await strategy.validate(payload); + + expect(result).toEqual(user as UserDocument); + }); + + it('should create a new user if not found and return the user', async () => { + const payload = { destination: 'test@example.com' }; - mockUserService.findByEmail.mockResolvedValue(null); + mockUserService.findByEmail.mockResolvedValue(null); - mockUserService.createWithEmail.mockResolvedValue({ - email: 'test@example.com', - username: 'test', - }); + mockUserService.createWithEmail.mockResolvedValue({ + email : 'test@example.com', + username: 'test' + }); - const result = await strategy.validate(payload); + const result = await strategy.validate(payload); - expect(result).toEqual({ - email: 'test@example.com', - username: 'test', - }); + expect(result).toEqual({ + email : 'test@example.com', + username: 'test' + } as UserDocument); + }); }); - }); }); diff --git a/apps/backend/src/auth/strategies/magicLinkEmail.strategy.ts b/apps/backend/src/auth/strategies/magicLinkEmail.strategy.ts index eb528158..32e0eeaf 100644 --- a/apps/backend/src/auth/strategies/magicLinkEmail.strategy.ts +++ b/apps/backend/src/auth/strategies/magicLinkEmail.strategy.ts @@ -6,91 +6,91 @@ import { MailingService } from '@server/mailing/mailing.service'; import { UserService } from '@server/user/user.service'; type authenticationLinkPayload = { - destination: string; + destination: string; }; type magicLinkCallback = (error: any, user: any) => void; @Injectable() export class MagicLinkEmailStrategy extends PassportStrategy( - strategy, - 'magic-link', + strategy, + 'magic-link' ) { - static logger = new Logger(MagicLinkEmailStrategy.name); + static logger = new Logger(MagicLinkEmailStrategy.name); - constructor( - @Inject('MAGIC_LINK_SECRET') - MAGIC_LINK_SECRET: string, - @Inject('SERVER_URL') - SERVER_URL: string, - @Inject(UserService) - private readonly userService: UserService, - @Inject(MailingService) - private readonly mailingService: MailingService, - ) { - super({ - secret: MAGIC_LINK_SECRET, - confirmUrl: `${SERVER_URL}/api/v1/auth/magic-link/confirm`, - callbackUrl: `${SERVER_URL}/api/v1/auth/magic-link/callback`, - sendMagicLink: MagicLinkEmailStrategy.sendMagicLink( - SERVER_URL, - userService, - mailingService, - ), - verify: ( - payload: authenticationLinkPayload, - callback: magicLinkCallback, - ) => { - callback(null, this.validate(payload)); - }, - }); - } + constructor( + @Inject('MAGIC_LINK_SECRET') + MAGIC_LINK_SECRET: string, + @Inject('SERVER_URL') + SERVER_URL: string, + @Inject(UserService) + private readonly userService: UserService, + @Inject(MailingService) + private readonly mailingService: MailingService + ) { + super({ + secret : MAGIC_LINK_SECRET, + confirmUrl : `${SERVER_URL}/api/v1/auth/magic-link/confirm`, + callbackUrl : `${SERVER_URL}/api/v1/auth/magic-link/callback`, + sendMagicLink: MagicLinkEmailStrategy.sendMagicLink( + SERVER_URL, + userService, + mailingService + ), + verify: ( + payload: authenticationLinkPayload, + callback: magicLinkCallback + ) => { + callback(null, this.validate(payload)); + } + }); + } - static sendMagicLink = - ( - SERVER_URL: string, - userService: UserService, - mailingService: MailingService, - ) => - async (email: string, magicLink: string) => { - const user = await userService.findByEmail(email); + static sendMagicLink = + ( + SERVER_URL: string, + userService: UserService, + mailingService: MailingService + ) => + async (email: string, magicLink: string) => { + const user = await userService.findByEmail(email); - if (!user) { - mailingService.sendEmail({ - to: email, - context: { - magicLink: magicLink, - username: email.split('@')[0], - }, - subject: 'Welcome to Noteblock.world', - template: 'magic-link-new-account', - }); - } else { - mailingService.sendEmail({ - to: email, - context: { - magicLink: magicLink, - username: user.username, - }, - subject: 'Noteblock Magic Link', - template: 'magic-link', - }); - } - }; + if (!user) { + mailingService.sendEmail({ + to : email, + context: { + magicLink: magicLink, + username : email.split('@')[0] + }, + subject : 'Welcome to Noteblock.world', + template: 'magic-link-new-account' + }); + } else { + mailingService.sendEmail({ + to : email, + context: { + magicLink: magicLink, + username : user.username + }, + subject : 'Noteblock Magic Link', + template: 'magic-link' + }); + } + }; - async validate(payload: authenticationLinkPayload) { - MagicLinkEmailStrategy.logger.debug( - `Validating payload: ${JSON.stringify(payload)}`, - ); + async validate(payload: authenticationLinkPayload) { + MagicLinkEmailStrategy.logger.debug( + `Validating payload: ${JSON.stringify(payload)}` + ); - const user = await this.userService.findByEmail(payload.destination); + const user = await this.userService.findByEmail(payload.destination); - if (!user) { - return await this.userService.createWithEmail(payload.destination); - } + if (!user) { + return await this.userService.createWithEmail(payload.destination); + } - MagicLinkEmailStrategy.logger.debug(`User found: ${user.username}`); + MagicLinkEmailStrategy.logger.debug(`User found: ${user.username}`); - return user; - } + return user; + } } diff --git a/apps/backend/src/auth/types/discordProfile.ts b/apps/backend/src/auth/types/discordProfile.ts index 18835e6b..78d8ed53 100644 --- a/apps/backend/src/auth/types/discordProfile.ts +++ b/apps/backend/src/auth/types/discordProfile.ts @@ -1,28 +1,28 @@ export type DiscordUser = { - access_token: string; - refresh_token: string; - profile: DiscordProfile; + access_token : string; + refresh_token: string; + profile : DiscordProfile; }; export type DiscordProfile = { - id: string; - username: string; - avatar: string; - discriminator: string; - public_flags: number; - flags: number; - banner: string; - accent_color: string; - global_name: string; - avatar_decoration_data: string | null; - banner_color: string | null; - clan: string | null; - mfa_enabled: boolean; - locale: string; - premium_type: number; - email: string; - verified: boolean; - provider: string; - accessToken: string; - fetchedAt: string; + id : string; + username : string; + avatar : string; + discriminator : string; + public_flags : number; + flags : number; + banner : string; + accent_color : string; + global_name : string; + avatar_decoration_data: string | null; + banner_color : string | null; + clan : string | null; + mfa_enabled : boolean; + locale : string; + premium_type : number; + email : string; + verified : boolean; + provider : string; + accessToken : string; + fetchedAt : string; }; diff --git a/apps/backend/src/auth/types/githubProfile.ts b/apps/backend/src/auth/types/githubProfile.ts index 754737e8..f12c8c94 100644 --- a/apps/backend/src/auth/types/githubProfile.ts +++ b/apps/backend/src/auth/types/githubProfile.ts @@ -1,65 +1,65 @@ type Email = { - value: string; + value: string; }; type Photo = { - value: string; + value: string; }; type ProfileJson = { - login: string; - id: number; - node_id: string; - avatar_url: string; - gravatar_id: string; - url: string; - html_url: string; - followers_url: string; - following_url: string; - gists_url: string; - starred_url: string; - subscriptions_url: string; - organizations_url: string; - repos_url: string; - events_url: string; - received_events_url: string; - type: string; - site_admin: boolean; - name: string; - company: string; - blog: string; - location: string; - email: string; - hireable: boolean; - bio: string; - twitter_username: string; - public_repos: number; - public_gists: number; - followers: number; - following: number; - created_at: string; - updated_at: string; + login : string; + id : number; + node_id : string; + avatar_url : string; + gravatar_id : string; + url : string; + html_url : string; + followers_url : string; + following_url : string; + gists_url : string; + starred_url : string; + subscriptions_url : string; + organizations_url : string; + repos_url : string; + events_url : string; + received_events_url: string; + type : string; + site_admin : boolean; + name : string; + company : string; + blog : string; + location : string; + email : string; + hireable : boolean; + bio : string; + twitter_username : string; + public_repos : number; + public_gists : number; + followers : number; + following : number; + created_at : string; + updated_at : string; }; type Profile = { - id: string; - displayName: string; - username: string; - profileUrl: string; - emails: Email[]; - photos: Photo[]; - provider: string; - _raw: string; - _json: ProfileJson; + id : string; + displayName: string; + username : string; + profileUrl : string; + emails : Email[]; + photos : Photo[]; + provider : string; + _raw : string; + _json : ProfileJson; }; export type GithubAccessToken = { - accessToken: string; - profile: Profile; + accessToken: string; + profile : Profile; }; export type GithubEmailList = Array<{ - email: string; - primary: string; - visibility: string; + email : string; + primary : string; + visibility: string; }>; diff --git a/apps/backend/src/auth/types/googleProfile.ts b/apps/backend/src/auth/types/googleProfile.ts index 8b571c64..62facdf6 100644 --- a/apps/backend/src/auth/types/googleProfile.ts +++ b/apps/backend/src/auth/types/googleProfile.ts @@ -1,34 +1,34 @@ type Email = { - value: string; - verified: boolean; + value : string; + verified: boolean; }; type Photo = { - value: string; + value: string; }; type ProfileJson = { - sub: string; - name: string; - given_name: string; - family_name?: string; - picture: string; - email: string; - email_verified: boolean; - locale: string; + sub : string; + name : string; + given_name : string; + family_name? : string; + picture : string; + email : string; + email_verified: boolean; + locale : string; }; // TODO: this is a uniform profile type standardized by passport for all providers export type GoogleProfile = { - id: string; - displayName: string; - name: { - familyName?: string; - givenName: string; - }; - emails: Email[]; - photos: Photo[]; - provider: string; - _raw: string; - _json: ProfileJson; + id : string; + displayName: string; + name: { + familyName?: string; + givenName : string; + }; + emails : Email[]; + photos : Photo[]; + provider: string; + _raw : string; + _json : ProfileJson; }; diff --git a/apps/backend/src/auth/types/profile.ts b/apps/backend/src/auth/types/profile.ts index edce9d7b..8028d4ad 100644 --- a/apps/backend/src/auth/types/profile.ts +++ b/apps/backend/src/auth/types/profile.ts @@ -1,5 +1,5 @@ export type Profile = { - username: string; - email: string; - profileImage: string; + username : string; + email : string; + profileImage: string; }; diff --git a/apps/backend/src/auth/types/token.ts b/apps/backend/src/auth/types/token.ts index 104354ce..a23085f3 100644 --- a/apps/backend/src/auth/types/token.ts +++ b/apps/backend/src/auth/types/token.ts @@ -1,10 +1,10 @@ export type TokenPayload = { - id: string; - email: string; - username: string; + id : string; + email : string; + username: string; }; export type Tokens = { - access_token: string; - refresh_token: string; + access_token : string; + refresh_token: string; }; diff --git a/apps/backend/src/config/EnvironmentVariables.ts b/apps/backend/src/config/EnvironmentVariables.ts index cbb15109..5eec0c0e 100644 --- a/apps/backend/src/config/EnvironmentVariables.ts +++ b/apps/backend/src/config/EnvironmentVariables.ts @@ -2,111 +2,111 @@ import { plainToInstance } from 'class-transformer'; import { IsEnum, IsOptional, IsString, validateSync } from 'class-validator'; enum Environment { - Development = 'development', - Production = 'production', + Development = 'development', + Production = 'production' } export class EnvironmentVariables { - @IsEnum(Environment) - @IsOptional() - NODE_ENV?: Environment; + @IsEnum(Environment) + @IsOptional() + NODE_ENV?: Environment; - // OAuth providers - @IsString() - GITHUB_CLIENT_ID: string; + // OAuth providers + @IsString() + GITHUB_CLIENT_ID: string; - @IsString() - GITHUB_CLIENT_SECRET: string; + @IsString() + GITHUB_CLIENT_SECRET: string; - @IsString() - GOOGLE_CLIENT_ID: string; + @IsString() + GOOGLE_CLIENT_ID: string; - @IsString() - GOOGLE_CLIENT_SECRET: string; + @IsString() + GOOGLE_CLIENT_SECRET: string; - @IsString() - DISCORD_CLIENT_ID: string; + @IsString() + DISCORD_CLIENT_ID: string; - @IsString() - DISCORD_CLIENT_SECRET: string; + @IsString() + DISCORD_CLIENT_SECRET: string; - // Email magic link auth - @IsString() - MAGIC_LINK_SECRET: string; + // Email magic link auth + @IsString() + MAGIC_LINK_SECRET: string; - // jwt auth - @IsString() - JWT_SECRET: string; + // jwt auth + @IsString() + JWT_SECRET: string; - @IsString() - JWT_EXPIRES_IN: string; + @IsString() + JWT_EXPIRES_IN: string; - @IsString() - JWT_REFRESH_SECRET: string; + @IsString() + JWT_REFRESH_SECRET: string; - @IsString() - JWT_REFRESH_EXPIRES_IN: string; + @IsString() + JWT_REFRESH_EXPIRES_IN: string; - // database - @IsString() - MONGO_URL: string; + // database + @IsString() + MONGO_URL: string; - @IsString() - SERVER_URL: string; + @IsString() + SERVER_URL: string; - @IsString() - FRONTEND_URL: string; + @IsString() + FRONTEND_URL: string; - @IsString() - @IsOptional() - APP_DOMAIN: string = 'localhost'; + @IsString() + @IsOptional() + APP_DOMAIN: string = 'localhost'; - @IsString() - RECAPTCHA_KEY: string; + @IsString() + RECAPTCHA_KEY: string; - // s3 - @IsString() - S3_ENDPOINT: string; + // s3 + @IsString() + S3_ENDPOINT: string; - @IsString() - S3_BUCKET_SONGS: string; + @IsString() + S3_BUCKET_SONGS: string; - @IsString() - S3_BUCKET_THUMBS: string; + @IsString() + S3_BUCKET_THUMBS: string; - @IsString() - S3_KEY: string; + @IsString() + S3_KEY: string; - @IsString() - S3_SECRET: string; + @IsString() + S3_SECRET: string; - @IsString() - S3_REGION: string; + @IsString() + S3_REGION: string; - @IsString() - @IsOptional() - WHITELISTED_USERS?: string; + @IsString() + @IsOptional() + WHITELISTED_USERS?: string; - // discord webhook - @IsString() - DISCORD_WEBHOOK_URL: string; + // discord webhook + @IsString() + DISCORD_WEBHOOK_URL: string; - @IsString() - COOKIE_EXPIRES_IN: string; + @IsString() + COOKIE_EXPIRES_IN: string; } export function validate(config: Record) { - const validatedConfig = plainToInstance(EnvironmentVariables, config, { - enableImplicitConversion: true, - }); + const validatedConfig = plainToInstance(EnvironmentVariables, config, { + enableImplicitConversion: true + }); - const errors = validateSync(validatedConfig, { - skipMissingProperties: false, - }); + const errors = validateSync(validatedConfig, { + skipMissingProperties: false + }); - if (errors.length > 0) { - throw new Error(errors.toString()); - } + if (errors.length > 0) { + throw new Error(errors.toString()); + } - return validatedConfig; + return validatedConfig; } diff --git a/apps/backend/src/email-login/email-login.controller.spec.ts b/apps/backend/src/email-login/email-login.controller.spec.ts index 1e8e48a3..89961423 100644 --- a/apps/backend/src/email-login/email-login.controller.spec.ts +++ b/apps/backend/src/email-login/email-login.controller.spec.ts @@ -1,21 +1,23 @@ +import { jest, describe, beforeEach, expect, it } from 'bun:test'; + import { Test, TestingModule } from '@nestjs/testing'; import { EmailLoginController } from './email-login.controller'; import { EmailLoginService } from './email-login.service'; describe('EmailLoginController', () => { - let controller: EmailLoginController; + let controller: EmailLoginController; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [EmailLoginController], - providers: [EmailLoginService], - }).compile(); + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [EmailLoginController], + providers : [EmailLoginService] + }).compile(); - controller = module.get(EmailLoginController); - }); + controller = module.get(EmailLoginController); + }); - it('should be defined', () => { - expect(controller).toBeDefined(); - }); + it('should be defined', () => { + expect(controller).toBeDefined(); + }); }); diff --git a/apps/backend/src/email-login/email-login.controller.ts b/apps/backend/src/email-login/email-login.controller.ts index 8b6be0a6..a102f95b 100644 --- a/apps/backend/src/email-login/email-login.controller.ts +++ b/apps/backend/src/email-login/email-login.controller.ts @@ -4,5 +4,5 @@ import { EmailLoginService } from './email-login.service'; @Controller('email-login') export class EmailLoginController { - constructor(private readonly emailLoginService: EmailLoginService) {} + constructor(private readonly emailLoginService: EmailLoginService) {} } diff --git a/apps/backend/src/email-login/email-login.module.ts b/apps/backend/src/email-login/email-login.module.ts index 47414fa8..6445ca90 100644 --- a/apps/backend/src/email-login/email-login.module.ts +++ b/apps/backend/src/email-login/email-login.module.ts @@ -4,7 +4,7 @@ import { EmailLoginController } from './email-login.controller'; import { EmailLoginService } from './email-login.service'; @Module({ - controllers: [EmailLoginController], - providers: [EmailLoginService], + controllers: [EmailLoginController], + providers : [EmailLoginService] }) export class EmailLoginModule {} diff --git a/apps/backend/src/email-login/email-login.service.spec.ts b/apps/backend/src/email-login/email-login.service.spec.ts index 1424b132..85dcd6b5 100644 --- a/apps/backend/src/email-login/email-login.service.spec.ts +++ b/apps/backend/src/email-login/email-login.service.spec.ts @@ -1,19 +1,21 @@ +import { jest, describe, beforeEach, expect, it } from 'bun:test'; + import { Test, TestingModule } from '@nestjs/testing'; import { EmailLoginService } from './email-login.service'; describe('EmailLoginService', () => { - let service: EmailLoginService; + let service: EmailLoginService; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [EmailLoginService], - }).compile(); + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [EmailLoginService] + }).compile(); - service = module.get(EmailLoginService); - }); + service = module.get(EmailLoginService); + }); - it('should be defined', () => { - expect(service).toBeDefined(); - }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); }); diff --git a/apps/backend/src/file/file.module.ts b/apps/backend/src/file/file.module.ts index 33303b61..421e6f2b 100644 --- a/apps/backend/src/file/file.module.ts +++ b/apps/backend/src/file/file.module.ts @@ -5,51 +5,51 @@ import { FileService } from './file.service'; @Module({}) export class FileModule { - static forRootAsync(): DynamicModule { - return { - module: FileModule, - imports: [ConfigModule.forRoot()], - providers: [ - { - provide: 'S3_BUCKET_SONGS', - useFactory: (configService: ConfigService) => - configService.getOrThrow('S3_BUCKET_SONGS'), - inject: [ConfigService], - }, - { - provide: 'S3_BUCKET_THUMBS', - useFactory: (configService: ConfigService) => - configService.getOrThrow('S3_BUCKET_THUMBS'), - inject: [ConfigService], - }, - { - provide: 'S3_KEY', - useFactory: (configService: ConfigService) => - configService.getOrThrow('S3_KEY'), - inject: [ConfigService], - }, - { - provide: 'S3_SECRET', - useFactory: (configService: ConfigService) => - configService.getOrThrow('S3_SECRET'), + static forRootAsync(): DynamicModule { + return { + module : FileModule, + imports : [ConfigModule.forRoot()], + providers: [ + { + provide : 'S3_BUCKET_SONGS', + useFactory: (configService: ConfigService) => + configService.getOrThrow('S3_BUCKET_SONGS'), + inject: [ConfigService] + }, + { + provide : 'S3_BUCKET_THUMBS', + useFactory: (configService: ConfigService) => + configService.getOrThrow('S3_BUCKET_THUMBS'), + inject: [ConfigService] + }, + { + provide : 'S3_KEY', + useFactory: (configService: ConfigService) => + configService.getOrThrow('S3_KEY'), + inject: [ConfigService] + }, + { + provide : 'S3_SECRET', + useFactory: (configService: ConfigService) => + configService.getOrThrow('S3_SECRET'), - inject: [ConfigService], - }, - { - provide: 'S3_ENDPOINT', - useFactory: (configService: ConfigService) => - configService.getOrThrow('S3_ENDPOINT'), - inject: [ConfigService], - }, - { - provide: 'S3_REGION', - useFactory: (configService: ConfigService) => - configService.getOrThrow('S3_REGION'), - inject: [ConfigService], - }, - FileService, - ], - exports: [FileService], - }; - } + inject: [ConfigService] + }, + { + provide : 'S3_ENDPOINT', + useFactory: (configService: ConfigService) => + configService.getOrThrow('S3_ENDPOINT'), + inject: [ConfigService] + }, + { + provide : 'S3_REGION', + useFactory: (configService: ConfigService) => + configService.getOrThrow('S3_REGION'), + inject: [ConfigService] + }, + FileService + ], + exports: [FileService] + }; + } } diff --git a/apps/backend/src/file/file.service.spec.ts b/apps/backend/src/file/file.service.spec.ts index 8d5e9e64..c452e598 100644 --- a/apps/backend/src/file/file.service.spec.ts +++ b/apps/backend/src/file/file.service.spec.ts @@ -1,236 +1,237 @@ +import { beforeEach, describe, expect, it, jest, mock } from 'bun:test'; + import { S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Test, TestingModule } from '@nestjs/testing'; -import { beforeEach, describe, expect, it, jest, mock } from 'bun:test'; import { FileService } from './file.service'; mock.module('@aws-sdk/client-s3', () => { - const mS3Client = { - send: jest.fn(), - }; - - return { - S3Client: jest.fn(() => mS3Client), - GetObjectCommand: jest.fn(), - PutObjectCommand: jest.fn(), - HeadBucketCommand: jest.fn(), - ObjectCannedACL: { - private: 'private', - public_read: 'public-read', - }, - }; + const mS3Client = { + send: jest.fn() + }; + + return { + S3Client : jest.fn(() => mS3Client), + GetObjectCommand : jest.fn(), + PutObjectCommand : jest.fn(), + HeadBucketCommand: jest.fn(), + ObjectCannedACL : { + private : 'private', + public_read: 'public-read' + } + }; }); mock.module('@aws-sdk/s3-request-presigner', () => ({ - getSignedUrl: jest.fn(), + 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(); - }); - - describe('verifyBucket', () => { - it('should verify the buckets successfully', async () => { - (s3Client.send as jest.Mock) - .mockResolvedValueOnce({}) // Mock for the first bucket - .mockResolvedValueOnce({}); // Mock for the second bucket - - await fileService['verifyBucket'](); - - // Ensure the mock was called twice - expect(s3Client.send).toHaveBeenCalledTimes(4); + 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(); + }); + + describe('verifyBucket', () => { + it('should verify the buckets successfully', async () => { + (s3Client.send as jest.Mock) + .mockResolvedValueOnce({}) // Mock for the first bucket + .mockResolvedValueOnce({}); // Mock for the second bucket + + await fileService['verifyBucket'](); + + // Ensure the mock was called twice + expect(s3Client.send).toHaveBeenCalledTimes(4); + }); + + it('should log an error if bucket verification fails', async () => { + const error = new Error('Bucket not found'); + (s3Client.send as jest.Mock).mockRejectedValueOnce(error); + + await expect(fileService['verifyBucket']()).rejects.toThrow(error); + }); }); - it('should log an error if bucket verification fails', async () => { - const error = new Error('Bucket not found'); - (s3Client.send as jest.Mock).mockRejectedValueOnce(error); + 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); - await expect(fileService['verifyBucket']()).rejects.toThrow(error); + const result = await fileService.uploadSong(buffer, publicId); + expect(result).toBe('songs/test-id.nbs'); }); - }); - - 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'); - }); - - 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); - }); - - 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', - ); - }); - - 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).toHaveBeenCalled(); - }); - - 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); + it('should throw an error if song upload fails', async () => { + const buffer = Buffer.from('test'); + const publicId = 'test-id'; - const result = await fileService.getSongFile(nbsFileUrl); + (s3Client.send as jest.Mock).mockRejectedValueOnce( + new Error('Upload failed') + ); - // Convert Uint8Array to ArrayBuffer if needed - const arrayBufferResult = result.slice(0, result.byteLength); + await expect(fileService.uploadSong(buffer, publicId)).rejects.toThrow( + 'Upload failed' + ); + }); - expect(arrayBufferResult).toBeInstanceOf(ArrayBuffer); + 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); - expect(new Uint8Array(arrayBufferResult)).toEqual( - new Uint8Array([1, 2, 3]), - ); - }); + const result = await fileService.getSongDownloadUrl(key, filename); + expect(result).toBe(mockUrl); + }); - it('should throw an error if song file retrieval fails', async () => { - const nbsFileUrl = 'test-file.nbs'; + it('should throw an error if signed URL generation fails', async () => { + const key = 'test-key'; + const filename = 'test-file.nbs'; - (s3Client.send as jest.Mock).mockRejectedValueOnce( - new Error('Retrieval failed'), - ); + (getSignedUrl as jest.Mock).mockRejectedValueOnce( + new Error('Signed URL generation failed') + ); - await expect(fileService.getSongFile(nbsFileUrl)).rejects.toThrow( - 'Retrieval failed', - ); - }); + await expect(fileService.getSongDownloadUrl(key, filename)).rejects.toThrow( + 'Signed URL generation failed' + ); + }); - it('should throw an error if song file is empty', async () => { - const nbsFileUrl = 'test-file.nbs'; + 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 mockResponse = { - Body: { - transformToByteArray: jest.fn().mockResolvedValueOnce(null), - }, - }; + const result = await fileService.uploadThumbnail(buffer, publicId); + + expect(result).toBe( + 'https://test-bucket-thumbs.s3.test-region.backblazeb2.com/thumbs/test-id.png' + ); + }); + + it('should delete a song', async () => { + const nbsFileUrl = 'test-file.nbs'; + const mockResponse = {}; + (s3Client.send as jest.Mock).mockResolvedValueOnce(mockResponse); - (s3Client.send as jest.Mock).mockResolvedValueOnce(mockResponse); + await fileService.deleteSong(nbsFileUrl); + expect(s3Client.send).toHaveBeenCalled(); + }); + + 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); + + // Convert Uint8Array to ArrayBuffer if needed + const arrayBufferResult = result.slice(0, result.byteLength); + + expect(arrayBufferResult).toBeInstanceOf(ArrayBuffer); - await expect(fileService.getSongFile(nbsFileUrl)).rejects.toThrow( - 'Error getting file', - ); - }); + expect(new Uint8Array(arrayBufferResult)).toEqual( + new Uint8Array([1, 2, 3]) + ); + }); + + 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'; - 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 mockResponse = { + Body: { + transformToByteArray: jest.fn().mockResolvedValueOnce(null) + } + }; - const result = await fileService.uploadPackedSong(buffer, publicId); + (s3Client.send as jest.Mock).mockResolvedValueOnce(mockResponse); - expect(result).toBe('packed/test-id.zip'); - }); + 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'); + }); }); diff --git a/apps/backend/src/file/file.service.ts b/apps/backend/src/file/file.service.ts index 1eda743e..3aa548f6 100644 --- a/apps/backend/src/file/file.service.ts +++ b/apps/backend/src/file/file.service.ts @@ -1,258 +1,258 @@ import * as path from 'path'; import { - GetObjectCommand, - HeadBucketCommand, - ObjectCannedACL, - PutObjectCommand, - S3Client, + GetObjectCommand, + HeadBucketCommand, + ObjectCannedACL, + PutObjectCommand, + S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Inject, Injectable, Logger } from '@nestjs/common'; @Injectable() export class FileService { - private readonly logger = new Logger(FileService.name); - private s3Client: S3Client; - private region: 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, - ) { - this.s3Client = this.getS3Client(); - // verify that the bucket exists - - this.verifyBucket(); - } - - private async verifyBucket() { - try { - this.logger.debug( - `Verifying buckets ${this.S3_BUCKET_SONGS} and ${this.S3_BUCKET_THUMBS}`, - ); - - await Promise.all([ - this.s3Client.send( - new HeadBucketCommand({ Bucket: this.S3_BUCKET_SONGS }), - ), - this.s3Client.send( - new HeadBucketCommand({ Bucket: this.S3_BUCKET_THUMBS }), - ), - ]); - - this.logger.debug('Buckets verification successful'); - } catch (error) { - this.logger.error( - `Error verifying buckets ${this.S3_BUCKET_SONGS} and ${this.S3_BUCKET_THUMBS}`, - error, - ); - - throw error; + private readonly logger = new Logger(FileService.name); + private s3Client: S3Client; + private region : 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 + ) { + this.s3Client = this.getS3Client(); + // verify that the bucket exists + + this.verifyBucket(); } - } - private getS3Client() { + private async verifyBucket() { + try { + this.logger.debug( + `Verifying buckets ${this.S3_BUCKET_SONGS} and ${this.S3_BUCKET_THUMBS}` + ); + + await Promise.all([ + this.s3Client.send( + new HeadBucketCommand({ Bucket: this.S3_BUCKET_SONGS }) + ), + this.s3Client.send( + new HeadBucketCommand({ Bucket: this.S3_BUCKET_THUMBS }) + ) + ]); + + this.logger.debug('Buckets verification successful'); + } catch (error) { + this.logger.error( + `Error verifying buckets ${this.S3_BUCKET_SONGS} and ${this.S3_BUCKET_THUMBS}`, + error + ); + + throw error; + } + } + + private getS3Client() { // Load environment variables - const key = this.S3_KEY; - const secret = this.S3_SECRET; - const endpoint = this.S3_ENDPOINT; - const region = this.S3_REGION; - - this.region = region; - - // Create S3 client - const s3Client = new S3Client({ - region: region, - endpoint: endpoint, - credentials: { - accessKeyId: key, - secretAccessKey: secret, - }, - forcePathStyle: endpoint.includes('localhost') ? true : false, - }); - - return s3Client; - } - - // Uploads a song to the S3 bucket and returns the key - public async uploadSong(buffer: Buffer, publicId: string) { - const bucket = this.S3_BUCKET_SONGS; - - const fileName = - 'songs/' + path.parse(publicId).name.replace(/\s/g, '') + '.nbs'; - - const mimetype = 'application/octet-stream'; - - await this.s3_upload( - buffer, - bucket, - fileName, - mimetype, - ObjectCannedACL.private, - ); - - return fileName; - } - - public async uploadPackedSong(buffer: Buffer, publicId: string) { - const bucket = this.S3_BUCKET_SONGS; - - const fileName = - 'packed/' + path.parse(publicId).name.replace(/\s/g, '') + '.zip'; - - const mimetype = 'application/zip'; - - await this.s3_upload( - buffer, - bucket, - fileName, - mimetype, - ObjectCannedACL.private, - ); - - return fileName; - } - - public async getSongDownloadUrl(key: string, filename: string) { - const bucket = this.S3_BUCKET_SONGS; - - const command = new GetObjectCommand({ - Bucket: bucket, - Key: key, - ResponseContentDisposition: `attachment; filename="${filename.replace( - /[/"]/g, - '_', - )}"`, - }); - - const signedUrl = await getSignedUrl(this.s3Client, command, { - expiresIn: 2 * 60, // 2 minutes - }); - - return signedUrl; - } - - public async uploadThumbnail(buffer: Buffer, publicId: string) { - const bucket = this.S3_BUCKET_THUMBS; - - const fileName = - 'thumbs/' + path.parse(publicId).name.replace(/\s/g, '') + '.png'; - - const mimetype = 'image/jpeg'; - - await this.s3_upload( - buffer, - bucket, - fileName, - mimetype, - ObjectCannedACL.public_read, - ); - - return this.getThumbnailUrl(fileName); - } - - public getThumbnailUrl(key: string) { - const bucket = this.S3_BUCKET_THUMBS; - const url = this.getPublicFileUrl(key, bucket); - return url; - } - - private getPublicFileUrl(key: string, bucket: string) { - const region = this.region; - if (this.S3_ENDPOINT.includes('localhost')) { - // minio url - return `${this.S3_ENDPOINT}/${bucket}/${key}`; - } // production blackblaze url - else return `https://${bucket}.s3.${region}.backblazeb2.com/${key}`; // TODO: make possible to use custom domain - } - - public async deleteSong(nbsFileUrl: string) { - const bucket = this.S3_BUCKET_SONGS; - - const command = new GetObjectCommand({ - Bucket: bucket, - Key: nbsFileUrl, - }); - - try { - await this.s3Client.send(command); - } catch (error) { - this.logger.error('Error deleting file: ', error); - throw error; + const key = this.S3_KEY; + const secret = this.S3_SECRET; + const endpoint = this.S3_ENDPOINT; + const region = this.S3_REGION; + + this.region = region; + + // Create S3 client + const s3Client = new S3Client({ + region : region, + endpoint : endpoint, + credentials: { + accessKeyId : key, + secretAccessKey: secret + }, + forcePathStyle: endpoint.includes('localhost') ? true : false + }); + + return s3Client; + } + + // Uploads a song to the S3 bucket and returns the key + public async uploadSong(buffer: Buffer, publicId: string) { + const bucket = this.S3_BUCKET_SONGS; + + const fileName = + 'songs/' + path.parse(publicId).name.replace(/\s/g, '') + '.nbs'; + + const mimetype = 'application/octet-stream'; + + await this.s3_upload( + buffer, + bucket, + fileName, + mimetype, + ObjectCannedACL.private + ); + + return fileName; + } + + public async uploadPackedSong(buffer: Buffer, publicId: string) { + const bucket = this.S3_BUCKET_SONGS; + + const fileName = + 'packed/' + path.parse(publicId).name.replace(/\s/g, '') + '.zip'; + + const mimetype = 'application/zip'; + + await this.s3_upload( + buffer, + bucket, + fileName, + mimetype, + ObjectCannedACL.private + ); + + return fileName; + } + + public async getSongDownloadUrl(key: string, filename: string) { + const bucket = this.S3_BUCKET_SONGS; + + const command = new GetObjectCommand({ + Bucket : bucket, + Key : key, + ResponseContentDisposition: `attachment; filename="${filename.replace( + /[/"]/g, + '_' + )}"` + }); + + const signedUrl = await getSignedUrl(this.s3Client, command, { + expiresIn: 2 * 60 // 2 minutes + }); + + return signedUrl; + } + + public async uploadThumbnail(buffer: Buffer, publicId: string) { + const bucket = this.S3_BUCKET_THUMBS; + + const fileName = + 'thumbs/' + path.parse(publicId).name.replace(/\s/g, '') + '.png'; + + const mimetype = 'image/jpeg'; + + await this.s3_upload( + buffer, + bucket, + fileName, + mimetype, + ObjectCannedACL.public_read + ); + + return this.getThumbnailUrl(fileName); + } + + public getThumbnailUrl(key: string) { + const bucket = this.S3_BUCKET_THUMBS; + const url = this.getPublicFileUrl(key, bucket); + return url; + } + + private getPublicFileUrl(key: string, bucket: string) { + const region = this.region; + if (this.S3_ENDPOINT.includes('localhost')) { + // minio url + return `${this.S3_ENDPOINT}/${bucket}/${key}`; + } // production blackblaze url + else return `https://${bucket}.s3.${region}.backblazeb2.com/${key}`; // TODO: make possible to use custom domain + } + + public async deleteSong(nbsFileUrl: string) { + const bucket = this.S3_BUCKET_SONGS; + + const command = new GetObjectCommand({ + Bucket: bucket, + Key : nbsFileUrl + }); + + try { + await this.s3Client.send(command); + } catch (error) { + this.logger.error('Error deleting file: ', error); + throw error; + } + + return; } - return; - } - - async s3_upload( - file: Buffer, - bucket: string, - name: string, - mimetype: string, - accessControl: ObjectCannedACL = ObjectCannedACL.public_read, - ) { - const params = { - Bucket: bucket, - Key: String(name), - Body: file, - ACL: accessControl, - ContentType: mimetype, - ContentDisposition: `attachment; filename=${name.split('/').pop()}`, - CreateBucketConfiguration: { - LocationConstraint: 'ap-south-1', - }, - }; - - const command = new PutObjectCommand(params); - - try { - const s3Response = await this.s3Client.send(command); - return s3Response; - } catch (error) { - this.logger.error('Error uploading file: ', error); - throw error; + async s3_upload( + file: Buffer, + bucket: string, + name: string, + mimetype: string, + accessControl: ObjectCannedACL = ObjectCannedACL.public_read + ) { + const params = { + Bucket : bucket, + Key : String(name), + Body : file, + ACL : accessControl, + ContentType : mimetype, + ContentDisposition : `attachment; filename=${name.split('/').pop()}`, + CreateBucketConfiguration: { + LocationConstraint: 'ap-south-1' + } + }; + + const command = new PutObjectCommand(params); + + try { + const s3Response = await this.s3Client.send(command); + return s3Response; + } catch (error) { + this.logger.error('Error uploading file: ', error); + throw error; + } } - } - public async getSongFile(nbsFileUrl: string): Promise { - const bucket = this.S3_BUCKET_SONGS; + public async getSongFile(nbsFileUrl: string): Promise { + const bucket = this.S3_BUCKET_SONGS; - const command = new GetObjectCommand({ - Bucket: bucket, - Key: nbsFileUrl, - }); + const command = new GetObjectCommand({ + Bucket: bucket, + Key : nbsFileUrl + }); - try { - const response = await this.s3Client.send(command); - const byteArray = await response.Body?.transformToByteArray(); + try { + const response = await this.s3Client.send(command); + const byteArray = await response.Body?.transformToByteArray(); - if (!byteArray) { - throw new Error('Error getting file'); - } + if (!byteArray) { + throw new Error('Error getting file'); + } - const arrayBuffer = new ArrayBuffer(byteArray.length); - const view = new Uint8Array(arrayBuffer); + const arrayBuffer = new ArrayBuffer(byteArray.length); + const view = new Uint8Array(arrayBuffer); - for (let i = 0; i < byteArray.length; i++) { - view[i] = byteArray[i]; - } + for (let i = 0; i < byteArray.length; i++) { + view[i] = byteArray[i]; + } - return arrayBuffer; - } catch (error) { - this.logger.error('Error getting file: ', error); - throw error; + return arrayBuffer; + } catch (error) { + this.logger.error('Error getting file: ', error); + throw error; + } } - } } diff --git a/apps/backend/src/lib/GetRequestUser.spec.ts b/apps/backend/src/lib/GetRequestUser.spec.ts index ebc2e65f..7e25a445 100644 --- a/apps/backend/src/lib/GetRequestUser.spec.ts +++ b/apps/backend/src/lib/GetRequestUser.spec.ts @@ -1,42 +1,44 @@ +import { jest, describe, beforeEach, expect, it } from 'bun:test'; + import type { UserDocument } from '@nbw/database'; import { ExecutionContext, HttpException, HttpStatus } from '@nestjs/common'; import { GetRequestToken, validateUser } from './GetRequestUser'; describe('GetRequestToken', () => { - it('should be a defined decorator', () => { - const mockExecutionContext = { - switchToHttp: jest.fn().mockReturnThis(), - } as unknown as ExecutionContext; + it('should be a defined decorator', () => { + const mockExecutionContext = { + switchToHttp: jest.fn().mockReturnThis() + } as unknown as ExecutionContext; - const result = GetRequestToken(null, mockExecutionContext); + const result = GetRequestToken(null, mockExecutionContext); - expect(typeof result).toBe('function'); - }); + 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, - ), - ); - }); + 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/apps/backend/src/lib/GetRequestUser.ts b/apps/backend/src/lib/GetRequestUser.ts index ab2d581c..ab34fad0 100644 --- a/apps/backend/src/lib/GetRequestUser.ts +++ b/apps/backend/src/lib/GetRequestUser.ts @@ -1,35 +1,35 @@ import type { UserDocument } from '@nbw/database'; import { - ExecutionContext, - HttpException, - HttpStatus, - createParamDecorator, + ExecutionContext, + HttpException, + HttpStatus, + createParamDecorator } from '@nestjs/common'; import type { Request } from 'express'; export const GetRequestToken = createParamDecorator( - (data: unknown, ctx: ExecutionContext) => { - const req = ctx - .switchToHttp() - .getRequest(); + (data: unknown, ctx: ExecutionContext) => { + const req = ctx + .switchToHttp() + .getRequest(); - const user = req.existingUser; + const user = req.existingUser; - return user; - }, + return user; + } ); export const validateUser = (user: UserDocument | null) => { - if (!user) { - throw new HttpException( - { - error: { - user: 'User not found', - }, - }, - HttpStatus.UNAUTHORIZED, - ); - } + if (!user) { + throw new HttpException( + { + error: { + user: 'User not found' + } + }, + HttpStatus.UNAUTHORIZED + ); + } - return user; + return user; }; diff --git a/apps/backend/src/lib/initializeSwagger.spec.ts b/apps/backend/src/lib/initializeSwagger.spec.ts index 8792226b..a944d027 100644 --- a/apps/backend/src/lib/initializeSwagger.spec.ts +++ b/apps/backend/src/lib/initializeSwagger.spec.ts @@ -1,47 +1,48 @@ +import { beforeEach, describe, expect, it, jest, mock } from 'bun:test'; + import { INestApplication } from '@nestjs/common'; import { SwaggerModule } from '@nestjs/swagger'; -import { beforeEach, describe, expect, it, jest, mock } from 'bun:test'; import { initializeSwagger } from './initializeSwagger'; mock.module('@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(), - }, + 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, - }, - }, - ); - }); + 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( + 'docs', + app, + expect.any(Object), + { + swaggerOptions: { + persistAuthorization: true + } + } + ); + }); }); diff --git a/apps/backend/src/lib/initializeSwagger.ts b/apps/backend/src/lib/initializeSwagger.ts index 2e45498c..423eaa87 100644 --- a/apps/backend/src/lib/initializeSwagger.ts +++ b/apps/backend/src/lib/initializeSwagger.ts @@ -1,25 +1,25 @@ import { INestApplication } from '@nestjs/common'; import { - DocumentBuilder, - SwaggerCustomOptions, - SwaggerModule, + DocumentBuilder, + SwaggerCustomOptions, + SwaggerModule } from '@nestjs/swagger'; export function initializeSwagger(app: INestApplication) { - const config = new DocumentBuilder() - .setTitle('NoteBlockWorld API Backend') - .setDescription('Backend application for NoteBlockWorld') - .setVersion('1.0') - .addBearerAuth() - .build(); + const config = new DocumentBuilder() + .setTitle('NoteBlockWorld API Backend') + .setDescription('Backend application for NoteBlockWorld') + .setVersion('1.0') + .addBearerAuth() + .build(); - const document = SwaggerModule.createDocument(app, config); + const document = SwaggerModule.createDocument(app, config); - const swaggerOptions: SwaggerCustomOptions = { - swaggerOptions: { - persistAuthorization: true, - }, - }; + const swaggerOptions: SwaggerCustomOptions = { + swaggerOptions: { + persistAuthorization: true + } + }; - SwaggerModule.setup('docs', app, document, swaggerOptions); + SwaggerModule.setup('docs', app, document, swaggerOptions); } diff --git a/apps/backend/src/lib/parseToken.spec.ts b/apps/backend/src/lib/parseToken.spec.ts index 8db755f3..1b9d2c6f 100644 --- a/apps/backend/src/lib/parseToken.spec.ts +++ b/apps/backend/src/lib/parseToken.spec.ts @@ -1,3 +1,5 @@ +import { jest, describe, beforeEach, expect, it } from 'bun:test'; + import { ExecutionContext } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -6,79 +8,79 @@ import { AuthService } from '@server/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); + 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(); }); - 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; + 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; - jest.spyOn(authService, 'getUserFromToken').mockResolvedValue(null); + const result = await parseTokenPipe.canActivate(mockExecutionContext); - const result = await parseTokenPipe.canActivate(mockExecutionContext); + expect(result).toBe(true); + }); - expect(result).toBe(true); - expect(authService.getUserFromToken).toHaveBeenCalledWith('test-token'); - }); + 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; + 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; + 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); + jest.spyOn(authService, 'getUserFromToken').mockResolvedValue(mockUser); - const result = await parseTokenPipe.canActivate(mockExecutionContext); + const result = await parseTokenPipe.canActivate(mockExecutionContext); - expect(result).toBe(true); - expect(authService.getUserFromToken).toHaveBeenCalledWith('test-token'); + expect(result).toBe(true); + expect(authService.getUserFromToken).toHaveBeenCalledWith('test-token'); - expect( - mockExecutionContext.switchToHttp().getRequest().existingUser, - ).toEqual(mockUser); + expect( + mockExecutionContext.switchToHttp().getRequest().existingUser + ).toEqual(mockUser); + }); }); - }); }); diff --git a/apps/backend/src/lib/parseToken.ts b/apps/backend/src/lib/parseToken.ts index 0b6d090b..3b135686 100644 --- a/apps/backend/src/lib/parseToken.ts +++ b/apps/backend/src/lib/parseToken.ts @@ -1,39 +1,39 @@ import { - CanActivate, - ExecutionContext, - Inject, - Injectable, - Logger, + CanActivate, + ExecutionContext, + Inject, + Injectable, + Logger } from '@nestjs/common'; import { AuthService } from '@server/auth/auth.service'; @Injectable() export class ParseTokenPipe implements CanActivate { - private static logger = new Logger(ParseTokenPipe.name); + private static logger = new Logger(ParseTokenPipe.name); - constructor( - @Inject(AuthService) - private readonly authService: AuthService, - ) {} + constructor( + @Inject(AuthService) + private readonly authService: AuthService + ) {} - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const bearerToken = request.headers.authorization; + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const bearerToken = request.headers.authorization; - if (!bearerToken) { - return true; - } + if (!bearerToken) { + return true; + } - const token = bearerToken.split(' ')[1]; - const user = await this.authService.getUserFromToken(token); + const token = bearerToken.split(' ')[1]; + const user = await this.authService.getUserFromToken(token); - if (!user) { - return true; - } + if (!user) { + return true; + } - request.existingUser = user; + request.existingUser = user; - return true; - } + return true; + } } diff --git a/apps/backend/src/mailing/mailing.controller.spec.ts b/apps/backend/src/mailing/mailing.controller.spec.ts index cebfd371..713384f3 100644 --- a/apps/backend/src/mailing/mailing.controller.spec.ts +++ b/apps/backend/src/mailing/mailing.controller.spec.ts @@ -1,26 +1,28 @@ +import { jest, describe, beforeEach, expect, it } from 'bun:test'; + import { Test, TestingModule } from '@nestjs/testing'; import { MailingController } from './mailing.controller'; import { MailingService } from './mailing.service'; describe('MailingController', () => { - let controller: MailingController; + let controller: MailingController; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [MailingController], - providers: [ - { - provide: MailingService, - useValue: {}, - }, - ], - }).compile(); + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [MailingController], + providers : [ + { + provide : MailingService, + useValue: {} + } + ] + }).compile(); - controller = module.get(MailingController); - }); + controller = module.get(MailingController); + }); - it('should be defined', () => { - expect(controller).toBeDefined(); - }); + it('should be defined', () => { + expect(controller).toBeDefined(); + }); }); diff --git a/apps/backend/src/mailing/mailing.controller.ts b/apps/backend/src/mailing/mailing.controller.ts index 51a4e0ff..2b60e2e5 100644 --- a/apps/backend/src/mailing/mailing.controller.ts +++ b/apps/backend/src/mailing/mailing.controller.ts @@ -4,5 +4,5 @@ import { MailingService } from './mailing.service'; @Controller() export class MailingController { - constructor(private readonly mailingService: MailingService) {} + constructor(private readonly mailingService: MailingService) {} } diff --git a/apps/backend/src/mailing/mailing.module.ts b/apps/backend/src/mailing/mailing.module.ts index fb737e0e..69edf0eb 100644 --- a/apps/backend/src/mailing/mailing.module.ts +++ b/apps/backend/src/mailing/mailing.module.ts @@ -4,8 +4,8 @@ import { MailingController } from './mailing.controller'; import { MailingService } from './mailing.service'; @Module({ - controllers: [MailingController], - providers: [MailingService], - exports: [MailingService], + controllers: [MailingController], + providers : [MailingService], + exports : [MailingService] }) export class MailingModule {} diff --git a/apps/backend/src/mailing/mailing.service.spec.ts b/apps/backend/src/mailing/mailing.service.spec.ts index 4918150e..d5e46f53 100644 --- a/apps/backend/src/mailing/mailing.service.spec.ts +++ b/apps/backend/src/mailing/mailing.service.spec.ts @@ -1,68 +1,70 @@ +import { jest, describe, beforeEach, expect, it } from 'bun:test'; + import { Test, TestingModule } from '@nestjs/testing'; import { MailerService } from '@nestjs-modules/mailer'; import { MailingService } from './mailing.service'; const MockedMailerService = { - sendMail: jest.fn(), + sendMail: jest.fn() }; describe('MailingService', () => { - let service: MailingService; - let mailerService: MailerService; + let service: MailingService; + let mailerService: MailerService; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - MailingService, - { - provide: MailerService, - useValue: MockedMailerService, - }, - ], - }).compile(); + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MailingService, + { + provide : MailerService, + useValue: MockedMailerService + } + ] + }).compile(); - service = module.get(MailingService); - mailerService = module.get(MailerService); - }); + service = module.get(MailingService); + mailerService = module.get(MailerService); + }); - it('should be defined', () => { - expect(service).toBeDefined(); - }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); - it('should send an email using a template', async () => { - const sendMailSpy = jest - .spyOn(mailerService, 'sendMail') - .mockResolvedValueOnce(undefined); + it('should send an email using a template', async () => { + const sendMailSpy = jest + .spyOn(mailerService, 'sendMail') + .mockResolvedValueOnce(undefined); - const to = 'accountHolder@example.com'; - const subject = 'Test Email'; - const template = 'hello'; + const to = 'accountHolder@example.com'; + const subject = 'Test Email'; + const template = 'hello'; - const context = { - name: 'John Doe', - message: 'Hello, this is a test email!', - }; + const context = { + name : 'John Doe', + message: 'Hello, this is a test email!' + }; - await service.sendEmail({ to, subject, template, context }); + await service.sendEmail({ to, subject, template, context }); - expect(sendMailSpy).toHaveBeenCalledWith({ - to, - subject, - template, - context, - attachments: [ - { - filename: 'background-image.png', - cid: 'background-image', - path: `${__dirname}/templates/img/background-image.png`, - }, - { - filename: 'logo.png', - cid: 'logo', - path: `${__dirname}/templates/img/logo.png`, - }, - ], + expect(sendMailSpy).toHaveBeenCalledWith({ + to, + subject, + template, + context, + attachments: [ + { + filename: 'background-image.png', + cid : 'background-image', + path : `${__dirname}/templates/img/background-image.png` + }, + { + filename: 'logo.png', + cid : 'logo', + path : `${__dirname}/templates/img/logo.png` + } + ] + }); }); - }); }); diff --git a/apps/backend/src/mailing/mailing.service.ts b/apps/backend/src/mailing/mailing.service.ts index 8fd5b447..3259acc0 100644 --- a/apps/backend/src/mailing/mailing.service.ts +++ b/apps/backend/src/mailing/mailing.service.ts @@ -2,52 +2,52 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { MailerService } from '@nestjs-modules/mailer'; interface EmailOptions { - to: string; - subject: string; - template: string; - context: { - [name: string]: any; - }; + to : string; + subject : string; + template: string; + context: { + [name: string]: any; + }; } @Injectable() export class MailingService { - private readonly logger = new Logger(MailingService.name); - constructor( - @Inject(MailerService) - private readonly mailerService: MailerService, - ) {} + private readonly logger = new Logger(MailingService.name); + constructor( + @Inject(MailerService) + private readonly mailerService: MailerService + ) {} - async sendEmail({ - to, - subject, - template, - context, - }: EmailOptions): Promise { - try { - await this.mailerService.sendMail({ + async sendEmail({ to, subject, - template: `${template}`, // The template file name (without extension) - context, // The context to be passed to the template - attachments: [ - { - filename: 'background-image.png', - cid: 'background-image', - path: `${__dirname}/templates/img/background-image.png`, - }, - { - filename: 'logo.png', - cid: 'logo', - path: `${__dirname}/templates/img/logo.png`, - }, - ], - }); + template, + context + }: EmailOptions): Promise { + try { + await this.mailerService.sendMail({ + to, + subject, + template : `${template}`, // The template file name (without extension) + context, // The context to be passed to the template + attachments: [ + { + filename: 'background-image.png', + cid : 'background-image', + path : `${__dirname}/templates/img/background-image.png` + }, + { + filename: 'logo.png', + cid : 'logo', + path : `${__dirname}/templates/img/logo.png` + } + ] + }); - this.logger.debug(`Email sent to ${to}`); - } catch (error) { - this.logger.error(`Failed to send email to ${to}`, error); - throw error; + this.logger.debug(`Email sent to ${to}`); + } catch (error) { + this.logger.error(`Failed to send email to ${to}`, error); + throw error; + } } - } } diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 389ee3c0..e95c0cef 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -9,56 +9,56 @@ import { ParseTokenPipe } from './lib/parseToken'; const logger: Logger = new Logger('main.ts'); async function bootstrap() { - const app = await NestFactory.create(AppModule); - app.setGlobalPrefix('v1'); + const app = await NestFactory.create(AppModule); + app.setGlobalPrefix('v1'); - const parseTokenPipe = app.get(ParseTokenPipe); + const parseTokenPipe = app.get(ParseTokenPipe); - app.useGlobalGuards(parseTokenPipe); + app.useGlobalGuards(parseTokenPipe); - app.useGlobalPipes( - new ValidationPipe({ - transform: true, - transformOptions: { - enableImplicitConversion: true, - }, - }), - ); + app.useGlobalPipes( + new ValidationPipe({ + transform : true, + transformOptions: { + enableImplicitConversion: true + } + }) + ); - app.use(express.json({ limit: '50mb' })); - app.use(express.urlencoded({ extended: true, limit: '50mb' })); + app.use(express.json({ limit: '50mb' })); + app.use(express.urlencoded({ extended: true, limit: '50mb' })); - if (process.env.NODE_ENV === 'development') { - initializeSwagger(app); - } + if (process.env.NODE_ENV === 'development') { + initializeSwagger(app); + } - // enable cors - app.enableCors({ - allowedHeaders: ['content-type', 'authorization', 'src'], - exposedHeaders: ['Content-Disposition'], - origin: [process.env.FRONTEND_URL || '', 'https://bentroen.github.io'], - credentials: true, - }); + // enable cors + app.enableCors({ + allowedHeaders: ['content-type', 'authorization', 'src'], + exposedHeaders: ['Content-Disposition'], + origin : [process.env.FRONTEND_URL || '', 'https://bentroen.github.io'], + credentials : true + }); - app.use('/v1', express.static('public')); + app.use('/v1', express.static('public')); - const port = process.env.PORT || '4000'; + const port = process.env.PORT || '4000'; - logger.log('Note Block World API Backend 🎶🌎🌍🌏 '); + logger.log('Note Block World API Backend 🎶🌎🌍🌏 '); - await app.listen(port); + await app.listen(port); - return port; + return port; } bootstrap() - .then((port) => { - logger.warn(`Application is running on: http://localhost:${port}`); - - if (process.env.NODE_ENV === 'development') { - logger.warn(`Swagger is running on: http://localhost:${port}/docs`); - } - }) - .catch((error) => { - logger.error(`Error: ${error}`); - }); + .then((port) => { + logger.warn(`Application is running on: http://localhost:${port}`); + + if (process.env.NODE_ENV === 'development') { + logger.warn(`Swagger is running on: http://localhost:${port}/docs`); + } + }) + .catch((error) => { + logger.error(`Error: ${error}`); + }); diff --git a/apps/backend/src/seed/seed.controller.spec.ts b/apps/backend/src/seed/seed.controller.spec.ts index 63cc91ea..5053a847 100644 --- a/apps/backend/src/seed/seed.controller.spec.ts +++ b/apps/backend/src/seed/seed.controller.spec.ts @@ -1,26 +1,28 @@ +import { jest, describe, beforeEach, expect, it } from 'bun:test'; + import { Test, TestingModule } from '@nestjs/testing'; import { SeedController } from './seed.controller'; import { SeedService } from './seed.service'; describe('SeedController', () => { - let controller: SeedController; + let controller: SeedController; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [SeedController], - providers: [ - { - provide: SeedService, - useValue: {}, - }, - ], - }).compile(); + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SeedController], + providers : [ + { + provide : SeedService, + useValue: {} + } + ] + }).compile(); - controller = module.get(SeedController); - }); + controller = module.get(SeedController); + }); - it('should be defined', () => { - expect(controller).toBeDefined(); - }); + it('should be defined', () => { + expect(controller).toBeDefined(); + }); }); diff --git a/apps/backend/src/seed/seed.controller.ts b/apps/backend/src/seed/seed.controller.ts index ae82f980..cfb870d0 100644 --- a/apps/backend/src/seed/seed.controller.ts +++ b/apps/backend/src/seed/seed.controller.ts @@ -6,16 +6,16 @@ import { SeedService } from './seed.service'; @Controller('seed') @ApiTags('seed') export class SeedController { - constructor(private readonly seedService: SeedService) {} + constructor(private readonly seedService: SeedService) {} - @Get('seed-dev') - @ApiOperation({ - summary: 'Seed the database with development data', - }) - async seed() { - this.seedService.seedDev(); - return { - message: 'Seeding in progress', - }; - } + @Get('seed-dev') + @ApiOperation({ + summary: 'Seed the database with development data' + }) + async seed() { + this.seedService.seedDev(); + return { + message: 'Seeding in progress' + }; + } } diff --git a/apps/backend/src/seed/seed.module.ts b/apps/backend/src/seed/seed.module.ts index bf4ded79..cb71e99d 100644 --- a/apps/backend/src/seed/seed.module.ts +++ b/apps/backend/src/seed/seed.module.ts @@ -11,30 +11,30 @@ import { SeedService } from './seed.service'; @Module({}) export class SeedModule { - private static readonly logger = new Logger(SeedModule.name); - static forRoot(): DynamicModule { - if (env.NODE_ENV !== 'development') { - SeedModule.logger.warn('Seeding is only allowed in development mode'); - return { - module: SeedModule, - }; - } else { - SeedModule.logger.warn('Seeding is allowed in development mode'); - return { - module: SeedModule, - imports: [UserModule, SongModule, ConfigModule.forRoot()], - providers: [ - ConfigService, - SeedService, - { - provide: 'NODE_ENV', - useFactory: (configService: ConfigService) => - configService.get('NODE_ENV'), - inject: [ConfigService], - }, - ], - controllers: [SeedController], - }; + private static readonly logger = new Logger(SeedModule.name); + static forRoot(): DynamicModule { + if (env.NODE_ENV !== 'development') { + SeedModule.logger.warn('Seeding is only allowed in development mode'); + return { + module: SeedModule + }; + } else { + SeedModule.logger.warn('Seeding is allowed in development mode'); + return { + module : SeedModule, + imports : [UserModule, SongModule, ConfigModule.forRoot()], + providers: [ + ConfigService, + SeedService, + { + provide : 'NODE_ENV', + useFactory: (configService: ConfigService) => + configService.get('NODE_ENV'), + inject: [ConfigService] + } + ], + controllers: [SeedController] + }; + } } - } } diff --git a/apps/backend/src/seed/seed.service.spec.ts b/apps/backend/src/seed/seed.service.spec.ts index 7577e9c4..ebe9765b 100644 --- a/apps/backend/src/seed/seed.service.spec.ts +++ b/apps/backend/src/seed/seed.service.spec.ts @@ -1,3 +1,5 @@ +import { jest, describe, beforeEach, expect, it } from 'bun:test'; + import { Test, TestingModule } from '@nestjs/testing'; import { SongService } from '@server/song/song.service'; @@ -6,36 +8,36 @@ import { UserService } from '@server/user/user.service'; import { SeedService } from './seed.service'; describe('SeedService', () => { - let service: SeedService; + let service: SeedService; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - SeedService, - { - provide: 'NODE_ENV', - useValue: 'development', - }, - { - provide: UserService, - useValue: { - createWithPassword: jest.fn(), - }, - }, - { - provide: SongService, - useValue: { - uploadSong: jest.fn(), - getSongById: jest.fn(), - }, - }, - ], - }).compile(); + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SeedService, + { + provide : 'NODE_ENV', + useValue: 'development' + }, + { + provide : UserService, + useValue: { + createWithPassword: jest.fn() + } + }, + { + provide : SongService, + useValue: { + uploadSong : jest.fn(), + getSongById: jest.fn() + } + } + ] + }).compile(); - service = module.get(SeedService); - }); + service = module.get(SeedService); + }); - it('should be defined', () => { - expect(service).toBeDefined(); - }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); }); diff --git a/apps/backend/src/seed/seed.service.ts b/apps/backend/src/seed/seed.service.ts index af3798eb..9ade7b32 100644 --- a/apps/backend/src/seed/seed.service.ts +++ b/apps/backend/src/seed/seed.service.ts @@ -2,19 +2,19 @@ import { Instrument, Note, Song } from '@encode42/nbs.js'; import { faker } from '@faker-js/faker'; import { UPLOAD_CONSTANTS } from '@nbw/config'; import { - CategoryType, - LicenseType, - SongDocument, - UploadSongDto, - UserDocument, - VisibilityType, + CategoryType, + LicenseType, + SongDocument, + UploadSongDto, + UserDocument, + VisibilityType } from '@nbw/database'; import { - HttpException, - HttpStatus, - Inject, - Injectable, - Logger, + HttpException, + HttpStatus, + Inject, + Injectable, + Logger } from '@nestjs/common'; import { SongService } from '@server/song/song.service'; @@ -22,202 +22,202 @@ import { UserService } from '@server/user/user.service'; @Injectable() export class SeedService { - public readonly logger = new Logger(SeedService.name); - constructor( - @Inject('NODE_ENV') - private readonly NODE_ENV: string, - - @Inject(UserService) - private readonly userService: UserService, - - @Inject(SongService) - private readonly songService: SongService, - ) {} - - public async seedDev() { - if (this.NODE_ENV !== 'development') { - this.logger.error('Seeding is only allowed in development mode'); - throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); + public readonly logger = new Logger(SeedService.name); + constructor( + @Inject('NODE_ENV') + private readonly NODE_ENV: string, + + @Inject(UserService) + private readonly userService: UserService, + + @Inject(SongService) + private readonly songService: SongService + ) {} + + public async seedDev() { + if (this.NODE_ENV !== 'development') { + this.logger.error('Seeding is only allowed in development mode'); + throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); + } + + const createdUsers = await this.seedUsers(); + this.logger.log(`Created ${createdUsers.length} users`); + const createdSongs = await this.seedSongs(createdUsers); + this.logger.log(`Created ${createdSongs.length} songs`); } - const createdUsers = await this.seedUsers(); - this.logger.log(`Created ${createdUsers.length} users`); - const createdSongs = await this.seedSongs(createdUsers); - this.logger.log(`Created ${createdSongs.length} songs`); - } - - private async seedUsers() { - const createdUsers: UserDocument[] = []; - - for (let i = 0; i < 100; i++) { - const user = await this.userService.createWithEmail( - faker.internet.email(), - ); - - //change user creation date - (user as any).createdAt = this.generateRandomDate( - new Date(2020, 0, 1), - new Date(), - ); - - user.loginCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); - user.playCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); - user.description = faker.lorem.paragraph(); - - user.socialLinks = { - youtube: faker.internet.url(), - x: faker.internet.url(), - discord: faker.internet.url(), - instagram: faker.internet.url(), - twitch: faker.internet.url(), - bandcamp: faker.internet.url(), - facebook: faker.internet.url(), - github: faker.internet.url(), - reddit: faker.internet.url(), - snapchat: faker.internet.url(), - soundcloud: faker.internet.url(), - spotify: faker.internet.url(), - steam: faker.internet.url(), - telegram: faker.internet.url(), - tiktok: faker.internet.url(), - }; - - // remove some social links randomly to simulate users not having all of them or having none - for (const key in Object.keys(user.socialLinks)) - if (faker.datatype.boolean()) delete (user.socialLinks as any)[key]; - - createdUsers.push(await this.userService.update(user)); + private async seedUsers() { + const createdUsers: UserDocument[] = []; + + for (let i = 0; i < 100; i++) { + const user = await this.userService.createWithEmail( + faker.internet.email() + ); + + //change user creation date + (user as any).createdAt = this.generateRandomDate( + new Date(2020, 0, 1), + new Date() + ); + + user.loginCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); + user.playCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); + user.description = faker.lorem.paragraph(); + + user.socialLinks = { + youtube : faker.internet.url(), + x : faker.internet.url(), + discord : faker.internet.url(), + instagram : faker.internet.url(), + twitch : faker.internet.url(), + bandcamp : faker.internet.url(), + facebook : faker.internet.url(), + github : faker.internet.url(), + reddit : faker.internet.url(), + snapchat : faker.internet.url(), + soundcloud: faker.internet.url(), + spotify : faker.internet.url(), + steam : faker.internet.url(), + telegram : faker.internet.url(), + tiktok : faker.internet.url() + }; + + // remove some social links randomly to simulate users not having all of them or having none + for (const key in Object.keys(user.socialLinks)) + if (faker.datatype.boolean()) delete (user.socialLinks as any)[key]; + + createdUsers.push(await this.userService.update(user)); + } + + return createdUsers; } - return createdUsers; - } - - private async seedSongs(users: UserDocument[]) { - const songs: SongDocument[] = []; - const licenses = Object.keys(UPLOAD_CONSTANTS.licenses); - const categories = Object.keys(UPLOAD_CONSTANTS.categories); - const visibilities = Object.keys(UPLOAD_CONSTANTS.visibility); - - for (const user of users) { - // most users will have 0-5 songs and a few will have 5-10, not a real statist by whatever I just what to test the system in development mode - const songCount = this.generateExponentialRandom(5, 2, 0.5, 10); - - for (let i = 0; i < songCount; i++) { - const nbsSong = this.generateRandomSong(); - const fileData = nbsSong.toArrayBuffer(); - const fileBuffer = Buffer.from(fileData); - - const body: UploadSongDto = { - file: { - buffer: fileData, - size: fileBuffer.length, - mimetype: 'application/octet-stream', - originalname: `${faker.music.songName()}.nbs`, - }, - allowDownload: faker.datatype.boolean(), - visibility: faker.helpers.arrayElement( - visibilities, - ) as VisibilityType, - title: faker.music.songName(), - originalAuthor: faker.music.artist(), - description: faker.lorem.paragraph(), - license: faker.helpers.arrayElement(licenses) as LicenseType, - category: faker.helpers.arrayElement(categories) as CategoryType, - customInstruments: [], - thumbnailData: { - backgroundColor: faker.internet.color(), - startLayer: faker.helpers.rangeToNumber({ min: 0, max: 4 }), - startTick: faker.helpers.rangeToNumber({ min: 0, max: 100 }), - zoomLevel: faker.helpers.rangeToNumber({ min: 1, max: 5 }), - }, - }; - - const uploadSongResponse = await this.songService.uploadSong({ - user, - body, - file: body.file, - }); - - const song = await this.songService.getSongById( - uploadSongResponse.publicId, - ); - - if (!song) continue; - - //change song creation date - (song as any).createdAt = this.generateRandomDate( - new Date(2020, 0, 1), - new Date(), - ); - - song.playCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); - song.downloadCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); - song.likeCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); - await song.save(); - - songs.push(song); - } + private async seedSongs(users: UserDocument[]) { + const songs: SongDocument[] = []; + const licenses = Object.keys(UPLOAD_CONSTANTS.licenses); + const categories = Object.keys(UPLOAD_CONSTANTS.categories); + const visibilities = Object.keys(UPLOAD_CONSTANTS.visibility); + + for (const user of users) { + // most users will have 0-5 songs and a few will have 5-10, not a real statist by whatever I just what to test the system in development mode + const songCount = this.generateExponentialRandom(5, 2, 0.5, 10); + + for (let i = 0; i < songCount; i++) { + const nbsSong = this.generateRandomSong(); + const fileData = nbsSong.toArrayBuffer(); + const fileBuffer = Buffer.from(fileData); + + const body: UploadSongDto = { + file: { + buffer : fileData, + size : fileBuffer.length, + mimetype : 'application/octet-stream', + originalname: `${faker.music.songName()}.nbs` + }, + allowDownload: faker.datatype.boolean(), + visibility : faker.helpers.arrayElement( + visibilities + ) as VisibilityType, + title : faker.music.songName(), + originalAuthor : faker.music.artist(), + description : faker.lorem.paragraph(), + license : faker.helpers.arrayElement(licenses) as LicenseType, + category : faker.helpers.arrayElement(categories) as CategoryType, + customInstruments: [], + thumbnailData : { + backgroundColor: faker.internet.color(), + startLayer : faker.helpers.rangeToNumber({ min: 0, max: 4 }), + startTick : faker.helpers.rangeToNumber({ min: 0, max: 100 }), + zoomLevel : faker.helpers.rangeToNumber({ min: 1, max: 5 }) + } + }; + + const uploadSongResponse = await this.songService.uploadSong({ + user, + body, + file: body.file + }); + + const song = await this.songService.getSongById( + uploadSongResponse.publicId + ); + + if (!song) continue; + + //change song creation date + (song as any).createdAt = this.generateRandomDate( + new Date(2020, 0, 1), + new Date() + ); + + song.playCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); + song.downloadCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); + song.likeCount = faker.helpers.rangeToNumber({ min: 0, max: 1000 }); + await song.save(); + + songs.push(song); + } + } + + return songs; } - return songs; - } + private generateExponentialRandom( + start = 1, + stepScale = 2, + stepProbability = 0.5, + limit = Number.MAX_SAFE_INTEGER + ) { + let max = start; - private generateExponentialRandom( - start = 1, - stepScale = 2, - stepProbability = 0.5, - limit = Number.MAX_SAFE_INTEGER, - ) { - let max = start; + while (faker.datatype.boolean(stepProbability) && max < limit) { + max *= stepScale; + } - while (faker.datatype.boolean(stepProbability) && max < limit) { - max *= stepScale; + return faker.number.int({ min: 0, max: Math.min(max, limit) }); } - return faker.number.int({ min: 0, max: Math.min(max, limit) }); - } - - private generateRandomSong() { - const songTest = new Song(); - songTest.meta.author = faker.music.artist(); - songTest.meta.description = faker.lorem.sentence(); - songTest.meta.name = faker.music.songName(); - songTest.meta.originalAuthor = faker.music.artist(); - - songTest.tempo = faker.helpers.rangeToNumber({ min: 20 * 1, max: 20 * 4 }); - const layerCount = faker.helpers.rangeToNumber({ min: 1, max: 5 }); - - for (let layerIndex = 0; layerIndex < layerCount; layerIndex++) { - const instrument = Instrument.builtIn[layerCount]; - const layer = songTest.createLayer(); - layer.meta.name = instrument.meta.name; - - const notes = Array.from({ - length: faker.helpers.rangeToNumber({ min: 20, max: 120 }), - }).map( - () => - new Note(instrument, { - key: faker.helpers.rangeToNumber({ min: 0, max: 127 }), - velocity: faker.helpers.rangeToNumber({ min: 0, max: 127 }), - panning: faker.helpers.rangeToNumber({ min: -1, max: 1 }), - pitch: faker.helpers.rangeToNumber({ min: -1, max: 1 }), - }), - ); - - 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 + private generateRandomSong() { + const songTest = new Song(); + songTest.meta.author = faker.music.artist(); + songTest.meta.description = faker.lorem.sentence(); + songTest.meta.name = faker.music.songName(); + songTest.meta.originalAuthor = faker.music.artist(); + + songTest.tempo = faker.helpers.rangeToNumber({ min: 20 * 1, max: 20 * 4 }); + const layerCount = faker.helpers.rangeToNumber({ min: 1, max: 5 }); + + for (let layerIndex = 0; layerIndex < layerCount; layerIndex++) { + const instrument = Instrument.builtIn[layerCount]; + const layer = songTest.createLayer(); + layer.meta.name = instrument.meta.name; + + const notes = Array.from({ + length: faker.helpers.rangeToNumber({ min: 20, max: 120 }) + }).map( + () => + new Note(instrument, { + key : faker.helpers.rangeToNumber({ min: 0, max: 127 }), + velocity: faker.helpers.rangeToNumber({ min: 0, max: 127 }), + panning : faker.helpers.rangeToNumber({ min: -1, max: 1 }), + pitch : faker.helpers.rangeToNumber({ min: -1, max: 1 }) + }) + ); + + 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 + } + + return songTest; } - return songTest; - } - - private generateRandomDate(from: Date, to: Date): Date { - return new Date( - faker.date.between({ - from: from.getTime(), - to: to.getTime(), - }), - ); - } + private generateRandomDate(from: Date, to: Date): Date { + return new Date( + faker.date.between({ + from: from.getTime(), + to : to.getTime() + }) + ); + } } diff --git a/apps/backend/src/song-browser/song-browser.controller.spec.ts b/apps/backend/src/song-browser/song-browser.controller.spec.ts index 0e95d2ff..6be13a0e 100644 --- a/apps/backend/src/song-browser/song-browser.controller.spec.ts +++ b/apps/backend/src/song-browser/song-browser.controller.spec.ts @@ -1,3 +1,5 @@ +import { jest, describe, beforeEach, expect, it } from 'bun:test'; + import { FeaturedSongsDto, PageQueryDTO, SongPreviewDto } from '@nbw/database'; import { Test, TestingModule } from '@nestjs/testing'; @@ -5,96 +7,96 @@ 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(), + 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(); + 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[] = []; + 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); + mockSongBrowserService.getRecentSongs.mockResolvedValueOnce(songList); - const result = await controller.getSongList(query); + const result = await controller.getSongList(query); - expect(result).toEqual(songList); - expect(songBrowserService.getRecentSongs).toHaveBeenCalledWith(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, - }; + 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); + mockSongBrowserService.getCategories.mockResolvedValueOnce(categories); - const result = await controller.getCategories(); + const result = await controller.getCategories(); - expect(result).toEqual(categories); - expect(songBrowserService.getCategories).toHaveBeenCalled(); + 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[] = []; + 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); + mockSongBrowserService.getSongsByCategory.mockResolvedValueOnce(songList); - const result = await controller.getSongsByCategory(id, query); + const result = await controller.getSongsByCategory(id, query); - expect(result).toEqual(songList); + expect(result).toEqual(songList); - expect(songBrowserService.getSongsByCategory).toHaveBeenCalledWith( - id, - query, - ); + expect(songBrowserService.getSongsByCategory).toHaveBeenCalledWith( + id, + query + ); + }); }); - }); }); diff --git a/apps/backend/src/song-browser/song-browser.controller.ts b/apps/backend/src/song-browser/song-browser.controller.ts index f9746d1e..57020463 100644 --- a/apps/backend/src/song-browser/song-browser.controller.ts +++ b/apps/backend/src/song-browser/song-browser.controller.ts @@ -1,10 +1,10 @@ import { FeaturedSongsDto, PageQueryDTO, SongPreviewDto } from '@nbw/database'; import { - BadRequestException, - Controller, - Get, - Param, - Query, + BadRequestException, + Controller, + Get, + Param, + Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; @@ -14,51 +14,51 @@ import { SongBrowserService } from './song-browser.service'; @ApiTags('song-browser') @ApiTags('song') export class SongBrowserController { - constructor(public readonly songBrowserService: SongBrowserService) {} + constructor(public readonly songBrowserService: SongBrowserService) {} - @Get('/featured') - @ApiOperation({ summary: 'Get a list of featured songs' }) - public async getFeaturedSongs(): Promise { - return await this.songBrowserService.getFeaturedSongs(); - } + @Get('/featured') + @ApiOperation({ summary: 'Get a list of featured songs' }) + public async getFeaturedSongs(): Promise { + return await this.songBrowserService.getFeaturedSongs(); + } + + @Get('/recent') + @ApiOperation({ + summary: 'Get a filtered/sorted list of recent songs with pagination' + }) + public async getSongList( + @Query() query: PageQueryDTO + ): Promise { + return await this.songBrowserService.getRecentSongs(query); + } - @Get('/recent') - @ApiOperation({ - summary: 'Get a filtered/sorted list of recent songs with pagination', - }) - public async getSongList( - @Query() query: PageQueryDTO, - ): Promise { - return await this.songBrowserService.getRecentSongs(query); - } + @Get('/categories') + @ApiOperation({ summary: 'Get a list of song categories and song counts' }) + public async getCategories(): Promise> { + return await this.songBrowserService.getCategories(); + } - @Get('/categories') - @ApiOperation({ summary: 'Get a list of song categories and song counts' }) - public async getCategories(): Promise> { - return await this.songBrowserService.getCategories(); - } + @Get('/categories/:id') + @ApiOperation({ summary: 'Get a list of song categories and song counts' }) + public async getSongsByCategory( + @Param('id') id: string, + @Query() query: PageQueryDTO + ): Promise { + return await this.songBrowserService.getSongsByCategory(id, query); + } - @Get('/categories/:id') - @ApiOperation({ summary: 'Get a list of song categories and song counts' }) - public async getSongsByCategory( - @Param('id') id: string, - @Query() query: PageQueryDTO, - ): Promise { - return await this.songBrowserService.getSongsByCategory(id, query); - } + @Get('/random') + @ApiOperation({ summary: 'Get a list of songs at random' }) + public async getRandomSongs( + @Query('count') count: string, + @Query('category') category: string + ): Promise { + const countInt = parseInt(count); - @Get('/random') - @ApiOperation({ summary: 'Get a list of songs at random' }) - public async getRandomSongs( - @Query('count') count: string, - @Query('category') category: string, - ): Promise { - const countInt = parseInt(count); + if (isNaN(countInt) || countInt < 1 || countInt > 10) { + throw new BadRequestException('Invalid query parameters'); + } - if (isNaN(countInt) || countInt < 1 || countInt > 10) { - throw new BadRequestException('Invalid query parameters'); + return await this.songBrowserService.getRandomSongs(countInt, category); } - - return await this.songBrowserService.getRandomSongs(countInt, category); - } } diff --git a/apps/backend/src/song-browser/song-browser.module.ts b/apps/backend/src/song-browser/song-browser.module.ts index f1eb25e0..fc5511e1 100644 --- a/apps/backend/src/song-browser/song-browser.module.ts +++ b/apps/backend/src/song-browser/song-browser.module.ts @@ -6,8 +6,8 @@ import { SongBrowserController } from './song-browser.controller'; import { SongBrowserService } from './song-browser.service'; @Module({ - providers: [SongBrowserService], - controllers: [SongBrowserController], - imports: [SongModule], + providers : [SongBrowserService], + controllers: [SongBrowserController], + imports : [SongModule] }) export class SongBrowserModule {} diff --git a/apps/backend/src/song-browser/song-browser.service.spec.ts b/apps/backend/src/song-browser/song-browser.service.spec.ts index f46f98d6..4eb5e0f8 100644 --- a/apps/backend/src/song-browser/song-browser.service.spec.ts +++ b/apps/backend/src/song-browser/song-browser.service.spec.ts @@ -1,3 +1,5 @@ +import { jest, describe, beforeEach, expect, it } from 'bun:test'; + import { PageQueryDTO, SongPreviewDto, SongWithUser } from '@nbw/database'; import { HttpException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -7,130 +9,130 @@ import { SongService } from '@server/song/song.service'; import { SongBrowserService } from './song-browser.service'; const mockSongService = { - getSongsForTimespan: jest.fn(), - getSongsBeforeTimespan: jest.fn(), - getRecentSongs: jest.fn(), - getCategories: jest.fn(), - getSongsByCategory: jest.fn(), + 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(); + 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('getRecentSongs', () => { - it('should return recent songs', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; + 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(); + }); + }); - const songPreviewDto: SongPreviewDto = { - title: 'Test Song', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as any; + describe('getRecentSongs', () => { + it('should return recent songs', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; - jest - .spyOn(songService, 'getRecentSongs') - .mockResolvedValue([songPreviewDto]); + const songPreviewDto: SongPreviewDto = { + title : 'Test Song', + uploader: { username: 'testuser', profileImage: 'testimage' } + } as any; - const result = await service.getRecentSongs(query); + jest + .spyOn(songService, 'getRecentSongs') + .mockResolvedValue([songPreviewDto]); - expect(result).toEqual([songPreviewDto]); + const result = await service.getRecentSongs(query); - expect(songService.getRecentSongs).toHaveBeenCalledWith( - query.page, - query.limit, - ); - }); + 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 }; + 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, - ); + await expect(service.getRecentSongs(query)).rejects.toThrow( + HttpException + ); + }); }); - }); - describe('getCategories', () => { - it('should return categories', async () => { - const categories = { pop: 10, rock: 5 }; + describe('getCategories', () => { + it('should return categories', async () => { + const categories = { pop: 10, rock: 5 }; - jest.spyOn(songService, 'getCategories').mockResolvedValue(categories); + jest.spyOn(songService, 'getCategories').mockResolvedValue(categories); - const result = await service.getCategories(); + const result = await service.getCategories(); - expect(result).toEqual(categories); - expect(songService.getCategories).toHaveBeenCalled(); + 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 }; + 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; + const songPreviewDto: SongPreviewDto = { + title : 'Test Song', + uploader: { username: 'testuser', profileImage: 'testimage' } + } as any; - jest - .spyOn(songService, 'getSongsByCategory') - .mockResolvedValue([songPreviewDto]); + jest + .spyOn(songService, 'getSongsByCategory') + .mockResolvedValue([songPreviewDto]); - const result = await service.getSongsByCategory(category, query); + const result = await service.getSongsByCategory(category, query); - expect(result).toEqual([songPreviewDto]); + expect(result).toEqual([songPreviewDto]); - expect(songService.getSongsByCategory).toHaveBeenCalledWith( - category, - query.page, - query.limit, - ); + expect(songService.getSongsByCategory).toHaveBeenCalledWith( + category, + query.page, + query.limit + ); + }); }); - }); }); diff --git a/apps/backend/src/song-browser/song-browser.service.ts b/apps/backend/src/song-browser/song-browser.service.ts index 0739d5c0..59bb218b 100644 --- a/apps/backend/src/song-browser/song-browser.service.ts +++ b/apps/backend/src/song-browser/song-browser.service.ts @@ -1,10 +1,10 @@ import { BROWSER_SONGS } from '@nbw/config'; import { - FeaturedSongsDto, - PageQueryDTO, - SongPreviewDto, - SongWithUser, - TimespanType, + FeaturedSongsDto, + PageQueryDTO, + SongPreviewDto, + SongWithUser, + TimespanType } from '@nbw/database'; import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; @@ -12,115 +12,115 @@ import { SongService } from '@server/song/song.service'; @Injectable() export class SongBrowserService { - constructor( - @Inject(SongService) - private songService: SongService, - ) {} - - public async getFeaturedSongs(): Promise { - const now = new Date(Date.now()); - - const times: Record = { - hour: new Date(Date.now()).setHours(now.getHours() - 1), - day: new Date(Date.now()).setDate(now.getDate() - 1), - week: new Date(Date.now()).setDate(now.getDate() - 7), - month: new Date(Date.now()).setMonth(now.getMonth() - 1), - year: new Date(Date.now()).setFullYear(now.getFullYear() - 1), - all: new Date(0).getTime(), - }; - - const songs: Record = { - hour: [], - day: [], - week: [], - month: [], - year: [], - all: [], - }; - - for (const [timespan, time] of Object.entries(times)) { - const songPage = await this.songService.getSongsForTimespan(time); - - // If the length is 0, send an empty array (no songs available in that timespan) - // If the length is less than the page size, pad it with songs "borrowed" - // from the nearest timestamp, regardless of view count - if ( - songPage.length > 0 && + constructor( + @Inject(SongService) + private songService: SongService + ) {} + + public async getFeaturedSongs(): Promise { + const now = new Date(Date.now()); + + const times: Record = { + hour : new Date(Date.now()).setHours(now.getHours() - 1), + day : new Date(Date.now()).setDate(now.getDate() - 1), + week : new Date(Date.now()).setDate(now.getDate() - 7), + month: new Date(Date.now()).setMonth(now.getMonth() - 1), + year : new Date(Date.now()).setFullYear(now.getFullYear() - 1), + all : new Date(0).getTime() + }; + + const songs: Record = { + hour : [], + day : [], + week : [], + month: [], + year : [], + all : [] + }; + + for (const [timespan, time] of Object.entries(times)) { + const songPage = await this.songService.getSongsForTimespan(time); + + // If the length is 0, send an empty array (no songs available in that timespan) + // If the length is less than the page size, pad it with songs "borrowed" + // from the nearest timestamp, regardless of view count + if ( + songPage.length > 0 && songPage.length < BROWSER_SONGS.paddedFeaturedPageSize - ) { - const missing = BROWSER_SONGS.paddedFeaturedPageSize - songPage.length; + ) { + const missing = BROWSER_SONGS.paddedFeaturedPageSize - songPage.length; - const additionalSongs = await this.songService.getSongsBeforeTimespan( - time, - ); + const additionalSongs = await this.songService.getSongsBeforeTimespan( + time + ); - songPage.push(...additionalSongs.slice(0, missing)); - } + songPage.push(...additionalSongs.slice(0, missing)); + } - songs[timespan as TimespanType] = songPage; - } + songs[timespan as TimespanType] = songPage; + } + + const featuredSongs = FeaturedSongsDto.create(); + + featuredSongs.hour = songs.hour.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song) + ); + + featuredSongs.day = songs.day.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song) + ); - const featuredSongs = FeaturedSongsDto.create(); + featuredSongs.week = songs.week.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song) + ); - featuredSongs.hour = songs.hour.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); + featuredSongs.month = songs.month.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song) + ); - featuredSongs.day = songs.day.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); + featuredSongs.year = songs.year.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song) + ); - featuredSongs.week = songs.week.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); + featuredSongs.all = songs.all.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song) + ); - featuredSongs.month = songs.month.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); + return featuredSongs; + } - featuredSongs.year = songs.year.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); + public async getRecentSongs(query: PageQueryDTO): Promise { + const { page, limit } = query; - featuredSongs.all = songs.all.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ); + if (!page || !limit) { + throw new HttpException( + 'Invalid query parameters', + HttpStatus.BAD_REQUEST + ); + } - return featuredSongs; - } + return await this.songService.getRecentSongs(page, limit); + } - public async getRecentSongs(query: PageQueryDTO): Promise { - const { page, limit } = query; + public async getCategories(): Promise> { + return await this.songService.getCategories(); + } - if (!page || !limit) { - throw new HttpException( - 'Invalid query parameters', - HttpStatus.BAD_REQUEST, - ); + public async getSongsByCategory( + category: string, + query: PageQueryDTO + ): Promise { + return await this.songService.getSongsByCategory( + category, + query.page ?? 1, + query.limit ?? 10 + ); } - return await this.songService.getRecentSongs(page, limit); - } - - public async getCategories(): Promise> { - return await this.songService.getCategories(); - } - - public async getSongsByCategory( - category: string, - query: PageQueryDTO, - ): Promise { - return await this.songService.getSongsByCategory( - category, - query.page ?? 1, - query.limit ?? 10, - ); - } - - public async getRandomSongs( - count: number, - category: string, - ): Promise { - return await this.songService.getRandomSongs(count, category); - } + public async getRandomSongs( + count: number, + category: string + ): Promise { + return await this.songService.getRandomSongs(count, category); + } } diff --git a/apps/backend/src/song/my-songs/my-songs.controller.spec.ts b/apps/backend/src/song/my-songs/my-songs.controller.spec.ts index 25b3d33f..6e0ed065 100644 --- a/apps/backend/src/song/my-songs/my-songs.controller.spec.ts +++ b/apps/backend/src/song/my-songs/my-songs.controller.spec.ts @@ -1,3 +1,5 @@ +import { beforeEach, describe, expect, it, jest } from 'bun:test'; + import type { UserDocument } from '@nbw/database'; import { PageQueryDTO, SongPageDto } from '@nbw/database'; import { HttpException } from '@nestjs/common'; @@ -5,77 +7,78 @@ import { AuthGuard } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; import { SongService } from '../song.service'; + import { MySongsController } from './my-songs.controller'; const mockSongService = { - getMySongsPage: jest.fn(), + 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 }); + 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 handle thrown an exception if userDocument is null', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; - const user = null; - - await expect(controller.getMySongsPage(query, user)).rejects.toThrow( - HttpException, - ); + it('should be defined', () => { + expect(controller).toBeDefined(); }); - 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'); + 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; + + 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); + mockSongService.getMySongsPage.mockRejectedValueOnce(error); - await expect(controller.getMySongsPage(query, user)).rejects.toThrow( - 'Test error', - ); + await expect(controller.getMySongsPage(query, user)).rejects.toThrow( + 'Test error' + ); + }); }); - }); }); diff --git a/apps/backend/src/song/my-songs/my-songs.controller.ts b/apps/backend/src/song/my-songs/my-songs.controller.ts index 986ac5f3..3507ca0a 100644 --- a/apps/backend/src/song/my-songs/my-songs.controller.ts +++ b/apps/backend/src/song/my-songs/my-songs.controller.ts @@ -12,22 +12,22 @@ import { SongService } from '../song.service'; @Controller('my-songs') @ApiTags('song') export class MySongsController { - constructor(public readonly songService: SongService) {} + constructor(public readonly songService: SongService) {} - @Get('/') - @ApiOperation({ - summary: 'Get a list of songs uploaded by the current authenticated user', - }) - @ApiBearerAuth() - @UseGuards(AuthGuard('jwt-refresh')) - public async getMySongsPage( - @Query() query: PageQueryDTO, - @GetRequestToken() user: UserDocument | null, - ): Promise { - user = validateUser(user); - return await this.songService.getMySongsPage({ - query, - user, - }); - } + @Get('/') + @ApiOperation({ + summary: 'Get a list of songs uploaded by the current authenticated user' + }) + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt-refresh')) + public async getMySongsPage( + @Query() query: PageQueryDTO, + @GetRequestToken() user: UserDocument | null + ): Promise { + user = validateUser(user); + return await this.songService.getMySongsPage({ + query, + user + }); + } } diff --git a/apps/backend/src/song/song-upload/song-upload.service.spec.ts b/apps/backend/src/song/song-upload/song-upload.service.spec.ts index c58c0d22..43b525ca 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.spec.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.spec.ts @@ -1,14 +1,15 @@ +import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; + import { Instrument, Layer, Note, Song } from '@encode42/nbs.js'; import type { UserDocument } from '@nbw/database'; import { - SongDocument, - Song as SongEntity, - ThumbnailData, - UploadSongDto, + SongDocument, + Song as SongEntity, + ThumbnailData, + UploadSongDto } from '@nbw/database'; import { HttpException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; import { Types } from 'mongoose'; import { FileService } from '@server/file/file.service'; @@ -18,408 +19,408 @@ import { SongUploadService } from './song-upload.service'; // mock drawToImage function mock.module('@nbw/thumbnail', () => ({ - drawToImage: jest.fn().mockResolvedValue(Buffer.from('test-image-buffer')), + drawToImage: jest.fn().mockResolvedValue(Buffer.from('test-image-buffer')) })); const mockFileService = { - uploadSong: jest.fn(), - uploadPackedSong: jest.fn(), - uploadThumbnail: jest.fn(), - getSongFile: jest.fn(), + uploadSong : jest.fn(), + uploadPackedSong: jest.fn(), + uploadThumbnail : jest.fn(), + getSongFile : jest.fn() }; const mockUserService = { - findByID: jest.fn(), + 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; - - spyOn(songUploadService as any, 'checkIsFileValid').mockImplementation( - (_file: Express.Multer.File) => undefined, - ); - - spyOn(songUploadService as any, 'prepareSongForUpload').mockReturnValue({ - nbsSong: new Song(), - songBuffer: Buffer.from('test'), - }); - - spyOn( - songUploadService as any, - 'preparePackedSongForUpload', - ).mockResolvedValue(Buffer.from('test')); - - spyOn(songUploadService as any, 'generateSongDocument').mockResolvedValue( - songEntity, - ); - - spyOn(songUploadService, 'generateAndUploadThumbnail').mockResolvedValue( - 'http://test.com/thumbnail.png', - ); - - spyOn(songUploadService as any, 'uploadSongFile').mockResolvedValue( - 'http://test.com/file.nbs', - ); - - 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; - - spyOn(fileService, 'getSongFile').mockResolvedValue(new ArrayBuffer(0)); - - spyOn(songUploadService as any, 'prepareSongForUpload').mockReturnValue({ - nbsSong: new Song(), - songBuffer: Buffer.from('test'), - }); - - spyOn( - songUploadService as any, - 'preparePackedSongForUpload', - ).mockResolvedValue(Buffer.from('test')); - - spyOn(songUploadService, 'generateAndUploadThumbnail').mockResolvedValue( - 'http://test.com/thumbnail.png', - ); - - spyOn(songUploadService as any, 'uploadSongFile').mockResolvedValue( - 'http://test.com/file.nbs', - ); - - 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'; - - 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, - ); + 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 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'; - - spyOn(fileService, 'uploadThumbnail') - // throw an error - .mockRejectedValue(new Error('test error')); - - try { - await songUploadService.generateAndUploadThumbnail( - thumbnailData, - nbsSong, - publicId, - ); - } catch (error) { - expect(error).toBeInstanceOf(HttpException); - } + it('should be defined', () => { + expect(songUploadService).toBeDefined(); }); - }); - describe('uploadSongFile', () => { - it('should upload a song file', async () => { - const file = Buffer.from('test'); - const publicId = 'test-id'; - - 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); + 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; + + spyOn(songUploadService as any, 'checkIsFileValid').mockImplementation( + (_file: Express.Multer.File) => undefined + ); + + spyOn(songUploadService as any, 'prepareSongForUpload').mockReturnValue({ + nbsSong : new Song(), + songBuffer: Buffer.from('test') + }); + + spyOn( + songUploadService as any, + 'preparePackedSongForUpload' + ).mockResolvedValue(Buffer.from('test')); + + spyOn(songUploadService as any, 'generateSongDocument').mockResolvedValue( + songEntity + ); + + spyOn(songUploadService, 'generateAndUploadThumbnail').mockResolvedValue( + 'http://test.com/thumbnail.png' + ); + + spyOn(songUploadService as any, 'uploadSongFile').mockResolvedValue( + 'http://test.com/file.nbs' + ); + + 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)); + }); }); - it('should throw an error if the file is invalid', async () => { - const file = Buffer.from('test'); - const publicId = 'test-id'; - - spyOn(fileService, 'uploadSong').mockRejectedValue( - new Error('test error'), - ); - - try { - await (songUploadService as any).uploadSongFile(file, publicId); - } catch (error) { - expect(error).toBeInstanceOf(HttpException); - } + 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; + + spyOn(fileService, 'getSongFile').mockResolvedValue(new ArrayBuffer(0)); + + spyOn(songUploadService as any, 'prepareSongForUpload').mockReturnValue({ + nbsSong : new Song(), + songBuffer: Buffer.from('test') + }); + + spyOn( + songUploadService as any, + 'preparePackedSongForUpload' + ).mockResolvedValue(Buffer.from('test')); + + spyOn(songUploadService, 'generateAndUploadThumbnail').mockResolvedValue( + 'http://test.com/thumbnail.png' + ); + + spyOn(songUploadService as any, 'uploadSongFile').mockResolvedValue( + 'http://test.com/file.nbs' + ); + + spyOn(songUploadService as any, 'uploadPackedSongFile').mockResolvedValue( + 'http://test.com/packed-file.nbs' + ); + + await songUploadService.processSongPatch(songDocument, body, user); + }); }); - }); - - describe('uploadPackedSongFile', () => { - it('should upload a packed song file', async () => { - const file = Buffer.from('test'); - const publicId = 'test-id'; - - 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); + 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'; + + 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'; + + spyOn(fileService, 'uploadThumbnail') + // throw an error + .mockRejectedValue(new Error('test error')); + + try { + await songUploadService.generateAndUploadThumbnail( + thumbnailData, + nbsSong, + publicId + ); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + } + }); }); - it('should throw an error if the file is invalid', async () => { - const file = Buffer.from('test'); - const publicId = 'test-id'; - - spyOn(fileService, 'uploadPackedSong').mockRejectedValue( - new Error('test error'), - ); + describe('uploadSongFile', () => { + it('should upload a song file', async () => { + const file = Buffer.from('test'); + const publicId = 'test-id'; + + 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'; + + spyOn(fileService, 'uploadSong').mockRejectedValue( + new Error('test error') + ); + + try { + await (songUploadService as any).uploadSongFile(file, publicId); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + } + }); + }); - try { - await (songUploadService as any).uploadPackedSongFile(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'; + + 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'; + + 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(); + 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.meta = { + author : 'Nicolas Vycas', + description : 'super cool song', + importName : 'test', + name : 'Cool Test Song', + originalAuthor: 'Nicolas Vycas' + }; - songTest.tempo = 20; + 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]; + // 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; + // 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 }), - ]; + 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 - } - } + // 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(); + const buffer = songTest.toArrayBuffer(); - const song = songUploadService.getSongObject(buffer); //TODO: For some reason the song is always empty + const song = songUploadService.getSongObject(buffer); //TODO: For some reason the song is always empty - expect(song).toBeInstanceOf(Song); - }); + expect(song).toBeInstanceOf(Song); + }); - it('should throw an error if the array buffer is invalid', () => { - const buffer = new ArrayBuffer(0); + it('should throw an error if the array buffer is invalid', () => { + const buffer = new ArrayBuffer(0); - expect(() => songUploadService.getSongObject(buffer)).toThrow( - HttpException, - ); + 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, - ); - }); + 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; + 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(); + 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); + + describe('getSoundsMapping', () => undefined); + describe('getValidSoundsSubset', () => undefined); + describe('validateUploader', () => undefined); + describe('generateSongDocument', () => undefined); + describe('prepareSongForUpload', () => undefined); + describe('preparePackedSongForUpload', () => undefined); }); diff --git a/apps/backend/src/song/song-upload/song-upload.service.ts b/apps/backend/src/song/song-upload/song-upload.service.ts index bbf37b9d..61869aad 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.ts @@ -1,25 +1,25 @@ import { Song, fromArrayBuffer, toArrayBuffer } from '@encode42/nbs.js'; import { - SongDocument, - Song as SongEntity, - SongStats, - ThumbnailData, - UploadSongDto, - UserDocument, + SongDocument, + Song as SongEntity, + SongStats, + ThumbnailData, + UploadSongDto, + UserDocument } from '@nbw/database'; import { - NoteQuadTree, - SongStatsGenerator, - injectSongFileMetadata, - obfuscateAndPackSong, + NoteQuadTree, + SongStatsGenerator, + injectSongFileMetadata, + obfuscateAndPackSong } from '@nbw/song'; import { drawToImage } from '@nbw/thumbnail'; import { - HttpException, - HttpStatus, - Inject, - Injectable, - Logger, + HttpException, + HttpStatus, + Inject, + Injectable, + Logger } from '@nestjs/common'; import { Types } from 'mongoose'; @@ -30,443 +30,443 @@ import { generateSongId, removeExtraSpaces } from '../song.util'; @Injectable() export class SongUploadService { - private soundsMapping: Record; - private soundsSubset: Set; + private soundsMapping: Record; + private soundsSubset : Set; - // TODO: move all upload auxiliary methods to new UploadSongService - private readonly logger = new Logger(SongUploadService.name); + // TODO: move all upload auxiliary methods to new UploadSongService + private readonly logger = new Logger(SongUploadService.name); - constructor( - @Inject(FileService) - private fileService: FileService, + constructor( + @Inject(FileService) + private fileService: FileService, - @Inject(UserService) - private userService: UserService, - ) {} + @Inject(UserService) + private userService: UserService + ) {} - private async getSoundsMapping(): Promise> { + private async getSoundsMapping(): Promise> { // Object that maps sound paths to their respective hashes - if (!this.soundsMapping) { - const response = await fetch( - process.env.SERVER_URL + '/api/v1/data/soundList.json', - ); + if (!this.soundsMapping) { + const response = await fetch( + process.env.SERVER_URL + '/api/v1/data/soundList.json' + ); - this.soundsMapping = (await response.json()) as Record; - } + this.soundsMapping = (await response.json()) as Record; + } - return this.soundsMapping; - } + return this.soundsMapping; + } - private async getValidSoundsSubset() { + private async getValidSoundsSubset() { // Creates a set of valid sound paths from the full list of sounds in Minecraft - if (!this.soundsSubset) { - try { - const response = await fetch( - process.env.SERVER_URL + '/api/v1/data/soundList.json', - ); - - const soundMapping = (await response.json()) as Record; - const soundList = Object.keys(soundMapping); - this.soundsSubset = new Set(soundList); - } catch (e) { - throw new HttpException( - { - error: { - file: 'An error occurred while retrieving sound list', - }, - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } + if (!this.soundsSubset) { + try { + const response = await fetch( + process.env.SERVER_URL + '/api/v1/data/soundList.json' + ); + + const soundMapping = (await response.json()) as Record; + const soundList = Object.keys(soundMapping); + this.soundsSubset = new Set(soundList); + } catch (e) { + throw new HttpException( + { + error: { + file: 'An error occurred while retrieving sound list' + } + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + return this.soundsSubset; } - return this.soundsSubset; - } + private async validateUploader(user: UserDocument): Promise { + const uploader = await this.userService.findByID(user._id.toString()); - private async validateUploader(user: UserDocument): Promise { - const uploader = await this.userService.findByID(user._id.toString()); + if (!uploader) { + throw new HttpException( + 'user not found, contact an administrator', + HttpStatus.UNAUTHORIZED + ); + } - if (!uploader) { - throw new HttpException( - 'user not found, contact an administrator', - HttpStatus.UNAUTHORIZED, - ); + return uploader._id; } - return uploader._id; - } - - private async generateSongDocument( - user: UserDocument, - publicId: string, - body: UploadSongDto, - thumbUrl: string, - fileKey: string, - packedFileKey: string, - songStats: SongStats, - file: Express.Multer.File, - ): Promise { - const song = new SongEntity(); - song.uploader = await this.validateUploader(user); - song.publicId = publicId; - song.title = removeExtraSpaces(body.title); - song.originalAuthor = removeExtraSpaces(body.originalAuthor); - song.description = removeExtraSpaces(body.description); - song.category = body.category; - song.allowDownload = true || body.allowDownload; //TODO: implement allowDownload; - song.visibility = body.visibility; - song.license = body.license; - - // Pad custom instruments to number of instruments in the song, just for safety - const customInstrumentCount = - songStats.instrumentNoteCounts.length - + private async generateSongDocument( + user: UserDocument, + publicId: string, + body: UploadSongDto, + thumbUrl: string, + fileKey: string, + packedFileKey: string, + songStats: SongStats, + file: Express.Multer.File + ): Promise { + const song = new SongEntity(); + song.uploader = await this.validateUploader(user); + song.publicId = publicId; + song.title = removeExtraSpaces(body.title); + song.originalAuthor = removeExtraSpaces(body.originalAuthor); + song.description = removeExtraSpaces(body.description); + song.category = body.category; + song.allowDownload = true;// || body.allowDownload; //TODO: implement allowDownload; + song.visibility = body.visibility; + song.license = body.license; + + // Pad custom instruments to number of instruments in the song, just for safety + const customInstrumentCount = + songStats.instrumentNoteCounts.length - songStats.firstCustomInstrumentIndex; - const paddedInstruments = body.customInstruments.concat( - Array(customInstrumentCount - body.customInstruments.length).fill(''), - ); - - song.customInstruments = paddedInstruments; - song.thumbnailData = body.thumbnailData; - song.thumbnailUrl = thumbUrl; - song.nbsFileUrl = fileKey; // s3File.Location; - song.packedSongUrl = packedFileKey; - song.stats = songStats; - song.fileSize = file.size; - - return song; - } - - public async processUploadedSong({ - file, - user, - body, - }: { - body: UploadSongDto; - file: Express.Multer.File; - user: UserDocument; - }): Promise { + const paddedInstruments = body.customInstruments.concat( + Array(customInstrumentCount - body.customInstruments.length).fill('') + ); + + song.customInstruments = paddedInstruments; + song.thumbnailData = body.thumbnailData; + song.thumbnailUrl = thumbUrl; + song.nbsFileUrl = fileKey; // s3File.Location; + song.packedSongUrl = packedFileKey; + song.stats = songStats; + song.fileSize = file.size; + + return song; + } + + public async processUploadedSong({ + file, + user, + body + }: { + body: UploadSongDto; + file: Express.Multer.File; + user: UserDocument; + }): Promise { // Is file valid? - this.checkIsFileValid(file); - - // Prepare song for upload - const { nbsSong, songBuffer } = this.prepareSongForUpload( - file.buffer, - body, - user, - ); - - // Prepare packed song for upload - // This can generate a client error if the custom instruments are invalid, so it's done before the song is uploaded - const packedSongBuffer = await this.preparePackedSongForUpload( - nbsSong, - body.customInstruments, - ); - - // Generate song public ID - const publicId = generateSongId(); - - // Upload song file - const fileKey = await this.uploadSongFile(songBuffer, publicId); - - // Upload packed song file - const packedFileKey = await this.uploadPackedSongFile( - packedSongBuffer, - publicId, - ); - - // PROCESS UPLOADED SONG - // TODO: delete file from S3 if remainder of upload method fails - - // Calculate song document's data from NBS file - const songStats = SongStatsGenerator.getSongStats(nbsSong); - - // Generate thumbnail - const thumbUrl: string = await this.generateAndUploadThumbnail( - body.thumbnailData, - nbsSong, - publicId, - ); - - // Create song document - const song = await this.generateSongDocument( - user, - publicId, - body, - thumbUrl, - fileKey, - packedFileKey, // TODO: should be packedFileUrl - songStats, - file, - ); - - return song; - } - - public async processSongPatch( - songDocument: SongDocument, - body: UploadSongDto, - user: UserDocument, - ): Promise { + this.checkIsFileValid(file); + + // Prepare song for upload + const { nbsSong, songBuffer } = this.prepareSongForUpload( + file.buffer, + body, + user + ); + + // Prepare packed song for upload + // This can generate a client error if the custom instruments are invalid, so it's done before the song is uploaded + const packedSongBuffer = await this.preparePackedSongForUpload( + nbsSong, + body.customInstruments + ); + + // Generate song public ID + const publicId = generateSongId(); + + // Upload song file + const fileKey = await this.uploadSongFile(songBuffer, publicId); + + // Upload packed song file + const packedFileKey = await this.uploadPackedSongFile( + packedSongBuffer, + publicId + ); + + // PROCESS UPLOADED SONG + // TODO: delete file from S3 if remainder of upload method fails + + // Calculate song document's data from NBS file + const songStats = SongStatsGenerator.getSongStats(nbsSong); + + // Generate thumbnail + const thumbUrl: string = await this.generateAndUploadThumbnail( + body.thumbnailData, + nbsSong, + publicId + ); + + // Create song document + const song = await this.generateSongDocument( + user, + publicId, + body, + thumbUrl, + fileKey, + packedFileKey, // TODO: should be packedFileUrl + songStats, + file + ); + + return song; + } + + public async processSongPatch( + songDocument: SongDocument, + body: UploadSongDto, + user: UserDocument + ): Promise { // Compare arrays of custom instruments including order - const customInstrumentsChanged = - JSON.stringify(songDocument.customInstruments) !== + const customInstrumentsChanged = + JSON.stringify(songDocument.customInstruments) !== JSON.stringify(body.customInstruments); - const songMetadataChanged = - customInstrumentsChanged || + const songMetadataChanged = + customInstrumentsChanged || songDocument.title !== body.title || songDocument.originalAuthor !== body.originalAuthor || // TODO: verify if song author matches current username // songDocument.uploader.username !== user.username && songDocument.description !== body.description; - // Compare thumbnail data - const thumbnailChanged = - JSON.stringify(songDocument.thumbnailData) !== + // Compare thumbnail data + const thumbnailChanged = + JSON.stringify(songDocument.thumbnailData) !== JSON.stringify(body.thumbnailData); - if (songMetadataChanged || thumbnailChanged) { - // If either the thumbnail or the song metadata changed, we need to - // download the existing song file to replace some fields and reupload it, - // and/or regenerate and reupload the thumbnail - - const songFile = await this.fileService.getSongFile( - songDocument.nbsFileUrl, - ); + if (songMetadataChanged || thumbnailChanged) { + // If either the thumbnail or the song metadata changed, we need to + // download the existing song file to replace some fields and reupload it, + // and/or regenerate and reupload the thumbnail + + const songFile = await this.fileService.getSongFile( + songDocument.nbsFileUrl + ); + + const originalSongBuffer = Buffer.from(songFile); + + // Regenerate song file + packed song file if metadata or custom instruments changed + if (songMetadataChanged) { + this.logger.log('Song metadata changed; reuploading song files'); + + const { nbsSong, songBuffer } = this.prepareSongForUpload( + originalSongBuffer, + body, + user + ); + + // Obfuscate and pack song with updated custom instruments + const packedSongBuffer = await this.preparePackedSongForUpload( + nbsSong, + body.customInstruments + ); + + // Re-upload song file + await this.uploadSongFile(songBuffer, songDocument.publicId); + + // Re-upload packed song file + await this.uploadPackedSongFile( + packedSongBuffer, + songDocument.publicId + ); + } + + if (thumbnailChanged) { + this.logger.log('Thumbnail data changed; re-uploading thumbnail'); + + const nbsSong = this.getSongObject(songFile); + + await this.generateAndUploadThumbnail( + body.thumbnailData, + nbsSong, + songDocument.publicId + ); + } + } + } - const originalSongBuffer = Buffer.from(songFile); + private prepareSongForUpload( + songFileBuffer: Buffer, + body: UploadSongDto, + user: UserDocument + ): { nbsSong: Song; songBuffer: Buffer } { + const songFileArrayBuffer = songFileBuffer.buffer.slice( + songFileBuffer.byteOffset, + songFileBuffer.byteOffset + songFileBuffer.byteLength + ) as ArrayBuffer; + + // Is the uploaded file a valid .nbs file? + const nbsSong = this.getSongObject(songFileArrayBuffer); + + // Update NBS file with form values + injectSongFileMetadata( + nbsSong, + removeExtraSpaces(body.title), + removeExtraSpaces(user.username), + removeExtraSpaces(body.originalAuthor), + removeExtraSpaces(body.description), + body.customInstruments + ); - // Regenerate song file + packed song file if metadata or custom instruments changed - if (songMetadataChanged) { - this.logger.log('Song metadata changed; reuploading song files'); + const updatedSongArrayBuffer = toArrayBuffer(nbsSong); + const songBuffer = Buffer.from(updatedSongArrayBuffer); - const { nbsSong, songBuffer } = this.prepareSongForUpload( - originalSongBuffer, - body, - user, - ); + return { nbsSong, songBuffer }; + } - // Obfuscate and pack song with updated custom instruments - const packedSongBuffer = await this.preparePackedSongForUpload( - nbsSong, - body.customInstruments, - ); + private async preparePackedSongForUpload( + nbsSong: Song, + soundsArray: string[] + ): Promise { + const soundsMapping = await this.getSoundsMapping(); + const validSoundsSubset = await this.getValidSoundsSubset(); - // Re-upload song file - await this.uploadSongFile(songBuffer, songDocument.publicId); + this.validateCustomInstruments(soundsArray, validSoundsSubset); - // Re-upload packed song file - await this.uploadPackedSongFile( - packedSongBuffer, - songDocument.publicId, + const packedSongBuffer = await obfuscateAndPackSong( + nbsSong, + soundsArray, + soundsMapping ); - } - if (thumbnailChanged) { - this.logger.log('Thumbnail data changed; re-uploading thumbnail'); + return packedSongBuffer; + } - const nbsSong = this.getSongObject(songFile); + private validateCustomInstruments( + soundsArray: string[], + validSounds: Set + ): void { + const isInstrumentValid = (sound: string) => + sound === '' || validSounds.has(sound); - await this.generateAndUploadThumbnail( - body.thumbnailData, - nbsSong, - songDocument.publicId, + const areAllInstrumentsValid = soundsArray.every((sound) => + isInstrumentValid(sound) ); - } - } - } - - private prepareSongForUpload( - songFileBuffer: Buffer, - body: UploadSongDto, - user: UserDocument, - ): { nbsSong: Song; songBuffer: Buffer } { - const songFileArrayBuffer = songFileBuffer.buffer.slice( - songFileBuffer.byteOffset, - songFileBuffer.byteOffset + songFileBuffer.byteLength, - ) as ArrayBuffer; - - // Is the uploaded file a valid .nbs file? - const nbsSong = this.getSongObject(songFileArrayBuffer); - - // Update NBS file with form values - injectSongFileMetadata( - nbsSong, - removeExtraSpaces(body.title), - removeExtraSpaces(user.username), - removeExtraSpaces(body.originalAuthor), - removeExtraSpaces(body.description), - body.customInstruments, - ); - - const updatedSongArrayBuffer = toArrayBuffer(nbsSong); - const songBuffer = Buffer.from(updatedSongArrayBuffer); - - return { nbsSong, songBuffer }; - } - - private async preparePackedSongForUpload( - nbsSong: Song, - soundsArray: string[], - ): Promise { - const soundsMapping = await this.getSoundsMapping(); - const validSoundsSubset = await this.getValidSoundsSubset(); - - this.validateCustomInstruments(soundsArray, validSoundsSubset); - - const packedSongBuffer = await obfuscateAndPackSong( - nbsSong, - soundsArray, - soundsMapping, - ); - - return packedSongBuffer; - } - - private validateCustomInstruments( - soundsArray: string[], - validSounds: Set, - ): void { - const isInstrumentValid = (sound: string) => - sound === '' || validSounds.has(sound); - - const areAllInstrumentsValid = soundsArray.every((sound) => - isInstrumentValid(sound), - ); - - if (!areAllInstrumentsValid) { - throw new HttpException( - { - error: { - customInstruments: - 'One or more invalid custom instruments have been set', - }, - }, - HttpStatus.BAD_REQUEST, - ); + + if (!areAllInstrumentsValid) { + throw new HttpException( + { + error: { + customInstruments: + 'One or more invalid custom instruments have been set' + } + }, + HttpStatus.BAD_REQUEST + ); + } } - } - - public async generateAndUploadThumbnail( - thumbnailData: ThumbnailData, - nbsSong: Song, - publicId: string, - ): Promise { - const { startTick, startLayer, zoomLevel, backgroundColor } = thumbnailData; - - const quadTree = new NoteQuadTree(nbsSong); - - const thumbBuffer = await drawToImage({ - notes: quadTree, - startTick: startTick, - startLayer: startLayer, - zoomLevel: zoomLevel, - backgroundColor: backgroundColor, - imgWidth: 1280, - imgHeight: 768, - }); - - // Upload thumbnail - let thumbUrl: string; - - try { - thumbUrl = await this.fileService.uploadThumbnail(thumbBuffer, publicId); - } catch (e) { - throw new HttpException( - { - error: { - file: "An error occurred while creating the song's thumbnail", - }, - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); + + public async generateAndUploadThumbnail( + thumbnailData: ThumbnailData, + nbsSong: Song, + publicId: string + ): Promise { + const { startTick, startLayer, zoomLevel, backgroundColor } = thumbnailData; + + const quadTree = new NoteQuadTree(nbsSong); + + const thumbBuffer = await drawToImage({ + notes : quadTree, + startTick : startTick, + startLayer : startLayer, + zoomLevel : zoomLevel, + backgroundColor: backgroundColor, + imgWidth : 1280, + imgHeight : 768 + }); + + // Upload thumbnail + let thumbUrl: string; + + try { + thumbUrl = await this.fileService.uploadThumbnail(thumbBuffer, publicId); + } catch (e) { + throw new HttpException( + { + error: { + file: "An error occurred while creating the song's thumbnail" + } + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + + this.logger.log(`Uploaded thumbnail to ${thumbUrl}`); + + return thumbUrl; } - this.logger.log(`Uploaded thumbnail to ${thumbUrl}`); - - return thumbUrl; - } - - private async uploadSongFile( - file: Buffer, - publicId: string, - ): Promise { - let fileKey: string; - - try { - fileKey = await this.fileService.uploadSong(file, publicId); - } catch (e) { - throw new HttpException( - { - error: { - file: 'An error occurred while uploading the packed song file', - }, - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); + private async uploadSongFile( + file: Buffer, + publicId: string + ): Promise { + let fileKey: string; + + try { + fileKey = await this.fileService.uploadSong(file, publicId); + } catch (e) { + throw new HttpException( + { + error: { + file: 'An error occurred while uploading the packed song file' + } + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + + this.logger.log(`Uploaded song file to ${fileKey}`); + + return fileKey; } - this.logger.log(`Uploaded song file to ${fileKey}`); - - return fileKey; - } - - private async uploadPackedSongFile( - file: Buffer, - publicId: string, - ): Promise { - let fileKey: string; - - try { - fileKey = await this.fileService.uploadPackedSong(file, publicId); - } catch (e) { - throw new HttpException( - { - error: { - file: 'An error occurred while uploading the song file', - }, - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); + private async uploadPackedSongFile( + file: Buffer, + publicId: string + ): Promise { + let fileKey: string; + + try { + fileKey = await this.fileService.uploadPackedSong(file, publicId); + } catch (e) { + throw new HttpException( + { + error: { + file: 'An error occurred while uploading the song file' + } + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + + this.logger.log(`Uploaded packed song file to ${fileKey}`); + + return fileKey; } - this.logger.log(`Uploaded packed song file to ${fileKey}`); - - return fileKey; - } - - public getSongObject(loadedArrayBuffer: ArrayBuffer): Song { - const nbsSong = fromArrayBuffer(loadedArrayBuffer); - - // If the above operation fails, it will return an empty song - if (nbsSong.length === 0) { - throw new HttpException( - { - error: { - file: 'Invalid NBS file', - errors: nbsSong.errors, - }, - }, - HttpStatus.BAD_REQUEST, - ); + public getSongObject(loadedArrayBuffer: ArrayBuffer): Song { + const nbsSong = fromArrayBuffer(loadedArrayBuffer); + + // If the above operation fails, it will return an empty song + if (nbsSong.length === 0) { + throw new HttpException( + { + error: { + file : 'Invalid NBS file', + errors: nbsSong.errors + } + }, + HttpStatus.BAD_REQUEST + ); + } + + return nbsSong; } - return nbsSong; - } - - private checkIsFileValid(file: Express.Multer.File): void { - if (!file) { - throw new HttpException( - { - error: { - file: 'File not found', - }, - }, - HttpStatus.BAD_REQUEST, - ); + private checkIsFileValid(file: Express.Multer.File): void { + if (!file) { + throw new HttpException( + { + error: { + file: 'File not found' + } + }, + HttpStatus.BAD_REQUEST + ); + } } - } } diff --git a/apps/backend/src/song/song-webhook/song-webhook.service.spec.ts b/apps/backend/src/song/song-webhook/song-webhook.service.spec.ts index 8b09fef1..ec8c2e77 100644 --- a/apps/backend/src/song/song-webhook/song-webhook.service.spec.ts +++ b/apps/backend/src/song/song-webhook/song-webhook.service.spec.ts @@ -1,262 +1,264 @@ +import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; + import { Song as SongEntity, SongWithUser } from '@nbw/database'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { getModelToken } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; -import { beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; import { Model } from 'mongoose'; import { getUploadDiscordEmbed } from '../song.util'; + import { SongWebhookService } from './song-webhook.service'; mock.module('../song.util', () => ({ - getUploadDiscordEmbed: jest.fn(), + getUploadDiscordEmbed: jest.fn() })); const mockSongModel = { - find: jest.fn().mockReturnThis(), - sort: jest.fn().mockReturnThis(), - populate: jest.fn().mockReturnThis(), - save: jest.fn(), + find : jest.fn().mockReturnThis(), + sort : jest.fn().mockReturnThis(), + populate: jest.fn().mockReturnThis(), + save : jest.fn() }; describe('SongWebhookService', () => { - let service: SongWebhookService; - let _songModel: Model; - let _configService: ConfigService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot()], - providers: [ - SongWebhookService, - { - provide: getModelToken(SongEntity.name), - useValue: mockSongModel, - }, - { - provide: 'DISCORD_WEBHOOK_URL', - useValue: 'http://localhost/webhook', - }, - ], - }).compile(); - - service = module.get(SongWebhookService); - _songModel = module.get>(getModelToken(SongEntity.name)); - _configService = module.get(ConfigService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('postSongWebhook', () => { - it('should post a new webhook message for a song', async () => { - const song: SongWithUser = { - publicId: '123', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as SongWithUser; - - (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); - - (global as any).fetch = jest.fn().mockResolvedValue({ - json: jest.fn().mockResolvedValue({ id: 'message-id' }), - }); - - const result = await service.postSongWebhook(song); - - expect(result).toBe('message-id'); - - expect(fetch).toHaveBeenCalledWith('http://localhost/webhook?wait=true', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - }); + let service: SongWebhookService; + let _songModel: Model; + let _configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports : [ConfigModule.forRoot()], + providers: [ + SongWebhookService, + { + provide : getModelToken(SongEntity.name), + useValue: mockSongModel + }, + { + provide : 'DISCORD_WEBHOOK_URL', + useValue: 'http://localhost/webhook' + } + ] + }).compile(); + + service = module.get(SongWebhookService); + _songModel = module.get>(getModelToken(SongEntity.name)); + _configService = module.get(ConfigService); }); - it('should return null if there is an error', async () => { - const song: SongWithUser = { - publicId: '123', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as SongWithUser; + it('should be defined', () => { + expect(service).toBeDefined(); + }); - (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); + describe('postSongWebhook', () => { + it('should post a new webhook message for a song', async () => { + const song: SongWithUser = { + publicId: '123', + uploader: { username: 'testuser', profileImage: 'testimage' } + } as SongWithUser; - (global as any).fetch = jest.fn().mockRejectedValue(new Error('Error')); + (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); - const result = await service.postSongWebhook(song); + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ id: 'message-id' }) + } as unknown as Response) as unknown as typeof global.fetch; - expect(result).toBeNull(); - }); - }); - - describe('updateSongWebhook', () => { - it('should update the webhook message for a song', async () => { - const song: SongWithUser = { - publicId: '123', - webhookMessageId: 'message-id', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as SongWithUser; - - (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); - - (global as any).fetch = jest.fn().mockResolvedValue({}); - - await service.updateSongWebhook(song); - - expect(fetch).toHaveBeenCalledWith( - 'http://localhost/webhook/messages/message-id', - { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - }, - ); - }); + const result = await service.postSongWebhook(song); - it('should log an error if there is an error', async () => { - const song: SongWithUser = { - publicId: '123', - webhookMessageId: 'message-id', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as SongWithUser; + expect(result).toBe('message-id'); - (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); + expect(fetch).toHaveBeenCalledWith('http://localhost/webhook?wait=true', { + method : 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({}) + }); + }); - (global as any).fetch = jest.fn().mockRejectedValue(new Error('Error')); + it('should return null if there is an error', async () => { + const song: SongWithUser = { + publicId: '123', + uploader: { username: 'testuser', profileImage: 'testimage' } + } as SongWithUser; - const loggerSpy = spyOn(service['logger'], 'error'); + (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); - await service.updateSongWebhook(song); + global.fetch = jest.fn().mockRejectedValue(new Error('Error')) as unknown as typeof global.fetch; - expect(loggerSpy).toHaveBeenCalledWith( - 'Error updating Discord webhook', - expect.any(Error), - ); - }); - }); - - describe('deleteSongWebhook', () => { - it('should delete the webhook message for a song', async () => { - const song: SongWithUser = { - publicId: '123', - webhookMessageId: 'message-id', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as SongWithUser; - - (global as any).fetch = jest.fn().mockResolvedValue({}); - - await service.deleteSongWebhook(song); - - expect(fetch).toHaveBeenCalledWith( - 'http://localhost/webhook/messages/message-id', - { - method: 'DELETE', - }, - ); + const result = await service.postSongWebhook(song); + + expect(result).toBeNull(); + }); }); - it('should log an error if there is an error', async () => { - const song: SongWithUser = { - publicId: '123', - webhookMessageId: 'message-id', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as SongWithUser; + describe('updateSongWebhook', () => { + it('should update the webhook message for a song', async () => { + const song: SongWithUser = { + publicId : '123', + webhookMessageId: 'message-id', + uploader : { username: 'testuser', profileImage: 'testimage' } + } as SongWithUser; - (global as any).fetch = jest.fn().mockRejectedValue(new Error('Error')); + (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); - const loggerSpy = spyOn(service['logger'], 'error'); + global.fetch = jest.fn().mockResolvedValue({} as Response) as unknown as typeof global.fetch; - await service.deleteSongWebhook(song); + await service.updateSongWebhook(song); - expect(loggerSpy).toHaveBeenCalledWith( - 'Error deleting Discord webhook', - expect.any(Error), - ); - }); - }); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/webhook/messages/message-id', + { + method : 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({}) + } + ); + }); - describe('syncSongWebhook', () => { - it('should update the webhook message if the song is public', async () => { - const song: SongWithUser = { - publicId: '123', - webhookMessageId: 'message-id', - visibility: 'public', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as SongWithUser; + it('should log an error if there is an error', async () => { + const song: SongWithUser = { + publicId : '123', + webhookMessageId: 'message-id', + uploader : { username: 'testuser', profileImage: 'testimage' } + } as SongWithUser; - const updateSpy = spyOn(service, 'updateSongWebhook'); + (getUploadDiscordEmbed as jest.Mock).mockReturnValue({}); - await service.syncSongWebhook(song); + global.fetch = jest.fn().mockRejectedValue(new Error('Error')) as unknown as typeof global.fetch; - expect(updateSpy).toHaveBeenCalledWith(song); + const loggerSpy = spyOn(service['logger'], 'error'); + + await service.updateSongWebhook(song); + + expect(loggerSpy).toHaveBeenCalledWith( + 'Error updating Discord webhook', + expect.any(Error) + ); + }); }); - it('should delete the webhook message if the song is not public', async () => { - const song: SongWithUser = { - publicId: '123', - webhookMessageId: 'message-id', - visibility: 'private', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as SongWithUser; + describe('deleteSongWebhook', () => { + it('should delete the webhook message for a song', async () => { + const song: SongWithUser = { + publicId : '123', + webhookMessageId: 'message-id', + uploader : { username: 'testuser', profileImage: 'testimage' } + } as SongWithUser; - const deleteSpy = spyOn(service, 'deleteSongWebhook'); + global.fetch = jest.fn().mockResolvedValue({} as Response) as unknown as typeof global.fetch; - await service.syncSongWebhook(song); + await service.deleteSongWebhook(song); - expect(deleteSpy).toHaveBeenCalledWith(song); - }); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost/webhook/messages/message-id', + { + method: 'DELETE' + } + ); + }); - it('should post a new webhook message if the song is public and does not have a message', async () => { - const song: SongWithUser = { - publicId: '123', - visibility: 'public', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as SongWithUser; + it('should log an error if there is an error', async () => { + const song: SongWithUser = { + publicId : '123', + webhookMessageId: 'message-id', + uploader : { username: 'testuser', profileImage: 'testimage' } + } as SongWithUser; - const postSpy = spyOn(service, 'postSongWebhook'); + global.fetch = jest.fn().mockRejectedValue(new Error('Error')) as unknown as typeof global.fetch; - await service.syncSongWebhook(song); + const loggerSpy = spyOn(service['logger'], 'error'); - expect(postSpy).toHaveBeenCalledWith(song); + await service.deleteSongWebhook(song); + + expect(loggerSpy).toHaveBeenCalledWith( + 'Error deleting Discord webhook', + expect.any(Error) + ); + }); }); - it('should return null if the song is not public and does not have a message', async () => { - const song: SongWithUser = { - publicId: '123', - visibility: 'private', - uploader: { username: 'testuser', profileImage: 'testimage' }, - } as SongWithUser; + describe('syncSongWebhook', () => { + it('should update the webhook message if the song is public', async () => { + const song: SongWithUser = { + publicId : '123', + webhookMessageId: 'message-id', + visibility : 'public', + uploader : { username: 'testuser', profileImage: 'testimage' } + } as SongWithUser; + + const updateSpy = spyOn(service, 'updateSongWebhook'); + + await service.syncSongWebhook(song); + + expect(updateSpy).toHaveBeenCalledWith(song); + }); + + it('should delete the webhook message if the song is not public', async () => { + const song: SongWithUser = { + publicId : '123', + webhookMessageId: 'message-id', + visibility : 'private', + uploader : { username: 'testuser', profileImage: 'testimage' } + } as SongWithUser; + + const deleteSpy = spyOn(service, 'deleteSongWebhook'); + + await service.syncSongWebhook(song); + + expect(deleteSpy).toHaveBeenCalledWith(song); + }); + + it('should post a new webhook message if the song is public and does not have a message', async () => { + const song: SongWithUser = { + publicId : '123', + visibility: 'public', + uploader : { username: 'testuser', profileImage: 'testimage' } + } as SongWithUser; + + const postSpy = spyOn(service, 'postSongWebhook'); + + await service.syncSongWebhook(song); + + expect(postSpy).toHaveBeenCalledWith(song); + }); + + it('should return null if the song is not public and does not have a message', async () => { + const song: SongWithUser = { + publicId : '123', + visibility: 'private', + uploader : { username: 'testuser', profileImage: 'testimage' } + } as SongWithUser; - const result = await service.syncSongWebhook(song); + const result = await service.syncSongWebhook(song); - expect(result).toBeNull(); + expect(result).toBeNull(); + }); }); - }); - describe('syncAllSongsWebhook', () => { - it('should synchronize the webhook messages for all songs', async () => { - const songs: SongWithUser[] = [ - { - publicId: '123', - uploader: { username: 'testuser', profileImage: 'testimage' }, - save: jest.fn(), - } as unknown as SongWithUser, - ]; + describe('syncAllSongsWebhook', () => { + it('should synchronize the webhook messages for all songs', async () => { + const songs: SongWithUser[] = [ + { + publicId: '123', + uploader: { username: 'testuser', profileImage: 'testimage' }, + save : jest.fn() + } as unknown as SongWithUser + ]; - mockSongModel.find.mockReturnValue({ - sort: jest.fn().mockReturnThis(), - populate: jest.fn().mockResolvedValue(songs), - }); + mockSongModel.find.mockReturnValue({ + sort : jest.fn().mockReturnThis(), + populate: jest.fn().mockResolvedValue(songs) + }); - const syncSpy = spyOn(service, 'syncSongWebhook'); + const syncSpy = spyOn(service, 'syncSongWebhook'); - await (service as any).syncAllSongsWebhook(); + await service['syncAllSongsWebhook'](); - expect(syncSpy).toHaveBeenCalledWith(songs[0]); + expect(syncSpy).toHaveBeenCalledWith(songs[0]); + }); }); - }); }); diff --git a/apps/backend/src/song/song-webhook/song-webhook.service.ts b/apps/backend/src/song/song-webhook/song-webhook.service.ts index 90e16745..ba551e57 100644 --- a/apps/backend/src/song/song-webhook/song-webhook.service.ts +++ b/apps/backend/src/song/song-webhook/song-webhook.service.ts @@ -7,21 +7,21 @@ import { getUploadDiscordEmbed } from '../song.util'; @Injectable() export class SongWebhookService implements OnModuleInit { - private logger = new Logger(SongWebhookService.name); - - constructor( - @InjectModel(SongEntity.name) - private songModel: Model, - @Inject('DISCORD_WEBHOOK_URL') - private readonly discordWebhookUrl: string | undefined, - ) {} - - async onModuleInit() { - this.logger.log('Updating Discord webhooks for all songs'); - this.syncAllSongsWebhook(); - } + private logger = new Logger(SongWebhookService.name); + + constructor( + @InjectModel(SongEntity.name) + private songModel: Model, + @Inject('DISCORD_WEBHOOK_URL') + private readonly discordWebhookUrl: string | undefined + ) {} + + async onModuleInit() { + this.logger.log('Updating Discord webhooks for all songs'); + this.syncAllSongsWebhook(); + } - public async postSongWebhook(song: SongWithUser): Promise { + public async postSongWebhook(song: SongWithUser): Promise { /** * Posts a new webhook message for a song. * @@ -30,35 +30,35 @@ export class SongWebhookService implements OnModuleInit { * @throws {Error} If the Discord webhook URL is not found. * @throws {Error} If there is an error sending the webhook message. */ - const webhookUrl = this.discordWebhookUrl; - - if (!webhookUrl) { - this.logger.error('Discord webhook URL not found'); - return null; - } - - const webhookData = getUploadDiscordEmbed(song); - - try { - const response = await fetch(`${webhookUrl}?wait=true`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(webhookData), - }); - - const data = (await response.json()) as { id: string }; - - //this.logger.log(`Posted webhook message for song ${song.publicId}`); - return data.id; // Discord message ID - } catch (e) { - this.logger.error('Error sending Discord webhook', e); - return null; + const webhookUrl = this.discordWebhookUrl; + + if (!webhookUrl) { + this.logger.error('Discord webhook URL not found'); + return null; + } + + const webhookData = getUploadDiscordEmbed(song); + + try { + const response = await fetch(`${webhookUrl}?wait=true`, { + method : 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(webhookData) + }); + + const data = (await response.json()) as { id: string }; + + //this.logger.log(`Posted webhook message for song ${song.publicId}`); + return data.id; // Discord message ID + } catch (e) { + this.logger.error('Error sending Discord webhook', e); + return null; + } } - } - public async updateSongWebhook(song: SongWithUser) { + public async updateSongWebhook(song: SongWithUser) { /** * Updates the webhook message for a song. * @@ -69,35 +69,35 @@ export class SongWebhookService implements OnModuleInit { * @throws {Error} If there is an error updating the webhook message. */ - if (!song.webhookMessageId) { - throw new Error('Song does not have a webhook message'); - } + if (!song.webhookMessageId) { + throw new Error('Song does not have a webhook message'); + } - const webhookUrl = this.discordWebhookUrl; + const webhookUrl = this.discordWebhookUrl; - if (!webhookUrl) { - this.logger.error('Discord webhook URL not found'); - return; - } + if (!webhookUrl) { + this.logger.error('Discord webhook URL not found'); + return; + } - const webhookData = getUploadDiscordEmbed(song); + const webhookData = getUploadDiscordEmbed(song); - try { - await fetch(`${webhookUrl}/messages/${song.webhookMessageId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(webhookData), - }); + try { + await fetch(`${webhookUrl}/messages/${song.webhookMessageId}`, { + method : 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(webhookData) + }); - this.logger.log(`Updated webhook message for song ${song.publicId}`); - } catch (e) { - this.logger.error('Error updating Discord webhook', e); + this.logger.log(`Updated webhook message for song ${song.publicId}`); + } catch (e) { + this.logger.error('Error updating Discord webhook', e); + } } - } - public async deleteSongWebhook(song: SongWithUser) { + public async deleteSongWebhook(song: SongWithUser) { /** * Deletes the webhook message for a song. * @@ -108,29 +108,29 @@ export class SongWebhookService implements OnModuleInit { * @throws {Error} If there is an error deleting the webhook message. * */ - if (!song.webhookMessageId) { - throw new Error('Song does not have a webhook message'); - } - - const webhookUrl = this.discordWebhookUrl; - - if (!webhookUrl) { - this.logger.error('Discord webhook URL not found'); - return; - } - - try { - await fetch(`${webhookUrl}/messages/${song.webhookMessageId}`, { - method: 'DELETE', - }); - - this.logger.log(`Deleted webhook message for song ${song.publicId}`); - } catch (e) { - this.logger.error('Error deleting Discord webhook', e); + if (!song.webhookMessageId) { + throw new Error('Song does not have a webhook message'); + } + + const webhookUrl = this.discordWebhookUrl; + + if (!webhookUrl) { + this.logger.error('Discord webhook URL not found'); + return; + } + + try { + await fetch(`${webhookUrl}/messages/${song.webhookMessageId}`, { + method: 'DELETE' + }); + + this.logger.log(`Deleted webhook message for song ${song.publicId}`); + } catch (e) { + this.logger.error('Error deleting Discord webhook', e); + } } - } - public async syncSongWebhook(song: SongWithUser) { + public async syncSongWebhook(song: SongWithUser) { /** * Synchronizes the webhook message for a song. * @@ -142,27 +142,27 @@ export class SongWebhookService implements OnModuleInit { * @returns {Promise} A promise that resolves with the new or updated message ID, or null if the message was deleted. */ - if (song.webhookMessageId) { - // Update existing message - if (song.visibility === 'public') { - await this.updateSongWebhook(song); - return song.webhookMessageId; - } else { - await this.deleteSongWebhook(song); - return null; - } - } else { - // Post new message - if (song.visibility === 'public') { - const newMessageId = await this.postSongWebhook(song); - return newMessageId; - } else { - return null; - } + if (song.webhookMessageId) { + // Update existing message + if (song.visibility === 'public') { + await this.updateSongWebhook(song); + return song.webhookMessageId; + } else { + await this.deleteSongWebhook(song); + return null; + } + } else { + // Post new message + if (song.visibility === 'public') { + const newMessageId = await this.postSongWebhook(song); + return newMessageId; + } else { + return null; + } + } } - } - private async syncAllSongsWebhook() { + private async syncAllSongsWebhook() { /** * Synchronizes the webhook messages for all songs in the database. * @@ -172,22 +172,22 @@ export class SongWebhookService implements OnModuleInit { * * @returns {Promise} A promise that resolves when all songs have been processed. */ - const songQuery = this.songModel - .find({ webhookMessageId: { $exists: false } }) - .sort({ createdAt: 1 }) - .populate('uploader', 'username profileImage -_id'); + const songQuery = this.songModel + .find({ webhookMessageId: { $exists: false } }) + .sort({ createdAt: 1 }) + .populate('uploader', 'username profileImage -_id'); - for (const songDocument of await songQuery) { - const webhookMessageId = await this.syncSongWebhook( - songDocument as unknown as SongWithUser, - ); + for (const songDocument of await songQuery) { + const webhookMessageId = await this.syncSongWebhook( + songDocument as unknown as SongWithUser + ); - songDocument.webhookMessageId = webhookMessageId; + songDocument.webhookMessageId = webhookMessageId; - await songDocument.save(); + await songDocument.save(); - // wait a bit to avoid rate limiting - await new Promise((resolve) => setTimeout(resolve, 2000)); + // wait a bit to avoid rate limiting + await new Promise((resolve) => setTimeout(resolve, 2000)); + } } - } } diff --git a/apps/backend/src/song/song.controller.spec.ts b/apps/backend/src/song/song.controller.spec.ts index f4fc3ac2..d83514a1 100644 --- a/apps/backend/src/song/song.controller.spec.ts +++ b/apps/backend/src/song/song.controller.spec.ts @@ -1,11 +1,7 @@ +import { beforeEach, describe, expect, it, jest } from 'bun:test'; + import type { UserDocument } from '@nbw/database'; -import { - PageQueryDTO, - SongPreviewDto, - SongViewDto, - UploadSongDto, - UploadSongResponseDto, -} from '@nbw/database'; +import { PageQueryDTO, SongPreviewDto, SongViewDto, UploadSongDto, UploadSongResponseDto } from '@nbw/database'; import { HttpStatus, UnauthorizedException } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; @@ -13,334 +9,336 @@ import { Response } from 'express'; import { FileService } from '@server/file/file.service'; + + 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(), + 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); + 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 handle errors', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; - - mockSongService.getSongByPage.mockRejectedValueOnce(new Error('Error')); - - await expect(songController.getSongList(query)).rejects.toThrow('Error'); + it('should be defined', () => { + expect(songController).toBeDefined(); }); - }); - 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; + describe('getSongList', () => { + it('should return a list of songs', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const songList: SongPreviewDto[] = []; - mockSongService.getSong.mockResolvedValueOnce(song); + mockSongService.getSongByPage.mockResolvedValueOnce(songList); - const result = await songController.getSong(id, user); + const result = await songController.getSongList(query); - expect(result).toEqual(song); - expect(songService.getSong).toHaveBeenCalledWith(id, user); - }); + expect(result).toEqual(songList); + expect(songService.getSongByPage).toHaveBeenCalledWith(query); + }); - it('should handle errors', async () => { - const id = 'test-id'; - const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + it('should handle errors', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; - mockSongService.getSong.mockRejectedValueOnce(new Error('Error')); + mockSongService.getSongByPage.mockRejectedValueOnce(new Error('Error')); - await expect(songController.getSong(id, user)).rejects.toThrow('Error'); + await expect(songController.getSongList(query)).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; + 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.getSongEdit.mockResolvedValueOnce(song); + mockSongService.getSong.mockResolvedValueOnce(song); - const result = await songController.getEditSong(id, user); + const result = await songController.getSong(id, user); - expect(result).toEqual(song); - expect(songService.getSongEdit).toHaveBeenCalledWith(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; + it('should handle errors', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; - mockSongService.getSongEdit.mockRejectedValueOnce(new Error('Error')); + mockSongService.getSong.mockRejectedValueOnce(new Error('Error')); - await expect(songController.getEditSong(id, user)).rejects.toThrow( - 'Error', - ); + await expect(songController.getSong(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; + 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.patchSong.mockResolvedValueOnce(response); + mockSongService.getSongEdit.mockResolvedValueOnce(song); - const result = await songController.patchSong(id, req, user); + const result = await songController.getEditSong(id, user); - expect(result).toEqual(response); - expect(songService.patchSong).toHaveBeenCalledWith(id, req.body, user); - }); + expect(result).toEqual(song); + expect(songService.getSongEdit).toHaveBeenCalledWith(id, user); + }); - it('should handle errors', async () => { - const id = 'test-id'; - const req = { body: {} } as any; - const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + it('should handle errors', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; - mockSongService.patchSong.mockRejectedValueOnce(new Error('Error')); + mockSongService.getSongEdit.mockRejectedValueOnce(new Error('Error')); - await expect(songController.patchSong(id, req, user)).rejects.toThrow( - 'Error', - ); + await expect(songController.getEditSong(id, 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; + 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; - const res = { - set: jest.fn(), - redirect: jest.fn(), - } as unknown as Response; + mockSongService.patchSong.mockResolvedValueOnce(response); - const url = 'test-url'; + const result = await songController.patchSong(id, req, user); - mockSongService.getSongDownloadUrl.mockResolvedValueOnce(url); + expect(result).toEqual(response); + expect(songService.patchSong).toHaveBeenCalledWith(id, req.body, user); + }); - await songController.getSongFile(id, src, user, res); + it('should handle errors', async () => { + const id = 'test-id'; + const req = { body: {} } as any; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; - expect(res.set).toHaveBeenCalledWith({ - 'Content-Disposition': 'attachment; filename="song.nbs"', - 'Access-Control-Expose-Headers': 'Content-Disposition', - }); + mockSongService.patchSong.mockRejectedValueOnce(new Error('Error')); - expect(res.redirect).toHaveBeenCalledWith(HttpStatus.FOUND, url); - - expect(songService.getSongDownloadUrl).toHaveBeenCalledWith( - id, - user, - src, - false, - ); + await expect(songController.patchSong(id, req, user)).rejects.toThrow( + 'Error' + ); + }); }); - 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; + 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; - mockSongService.getSongDownloadUrl.mockRejectedValueOnce( - new Error('Error'), - ); + const res = { + set : jest.fn(), + redirect: jest.fn() + } as unknown as Response; - await expect( - songController.getSongFile(id, src, user, res), - ).rejects.toThrow('Error'); - }); - }); + const url = 'test-url'; - 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); - mockSongService.getSongDownloadUrl.mockResolvedValueOnce(url); + await songController.getSongFile(id, src, user, res); - const result = await songController.getSongOpenUrl(id, user, src); + expect(res.set).toHaveBeenCalledWith({ + 'Content-Disposition' : 'attachment; filename="song.nbs"', + 'Access-Control-Expose-Headers': 'Content-Disposition' + }); - expect(result).toEqual(url); + expect(res.redirect).toHaveBeenCalledWith(HttpStatus.FOUND, url); - expect(songService.getSongDownloadUrl).toHaveBeenCalledWith( - id, - user, - 'open', - true, - ); - }); + expect(songService.getSongDownloadUrl).toHaveBeenCalledWith( + id, + user, + src, + false + ); + }); - 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'; + it('should handle errors', async () => { + const id = 'test-id'; + const src = 'test-src'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; - await expect( - songController.getSongOpenUrl(id, user, src), - ).rejects.toThrow(UnauthorizedException); - }); + const res = { + set : jest.fn(), + redirect: jest.fn() + } as unknown as Response; - 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') + ); - mockSongService.getSongDownloadUrl.mockRejectedValueOnce( - new Error('Error'), - ); + await expect( + songController.getSongFile(id, src, user, res) + ).rejects.toThrow('Error'); + }); + }); - await expect( - songController.getSongOpenUrl(id, user, src), - ).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; + 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); + mockSongService.deleteSong.mockResolvedValueOnce(undefined); - await songController.deleteSong(id, user); + await songController.deleteSong(id, user); - expect(songService.deleteSong).toHaveBeenCalledWith(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; + it('should handle errors', async () => { + const id = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; - mockSongService.deleteSong.mockRejectedValueOnce(new Error('Error')); + 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 }); + await expect(songController.deleteSong(id, user)).rejects.toThrow( + 'Error' + ); + }); }); - 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', - ); + 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/apps/backend/src/song/song.controller.ts b/apps/backend/src/song/song.controller.ts index d6711cee..08328d11 100644 --- a/apps/backend/src/song/song.controller.ts +++ b/apps/backend/src/song/song.controller.ts @@ -1,40 +1,40 @@ import { UPLOAD_CONSTANTS } from '@nbw/config'; import type { UserDocument } from '@nbw/database'; import { - PageQueryDTO, - SongPreviewDto, - SongViewDto, - UploadSongDto, - UploadSongResponseDto, + PageQueryDTO, + SongPreviewDto, + SongViewDto, + UploadSongDto, + UploadSongResponseDto } from '@nbw/database'; import type { RawBodyRequest } from '@nestjs/common'; import { - Body, - Controller, - Delete, - Get, - Headers, - HttpStatus, - Param, - Patch, - Post, - Query, - Req, - Res, - UnauthorizedException, - UploadedFile, - UseGuards, - UseInterceptors, + Body, + Controller, + Delete, + Get, + Headers, + HttpStatus, + Param, + Patch, + Post, + Query, + Req, + Res, + UnauthorizedException, + UploadedFile, + UseGuards, + UseInterceptors } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { FileInterceptor } from '@nestjs/platform-express'; import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; import { - ApiBearerAuth, - ApiBody, - ApiConsumes, - ApiOperation, - ApiTags, + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; @@ -48,146 +48,146 @@ import { SongService } from './song.service'; @Controller('song') @ApiTags('song') export class SongController { - static multerConfig: MulterOptions = { - limits: { - fileSize: UPLOAD_CONSTANTS.file.maxSize, - }, - fileFilter: (req, file, cb) => { - if (!file.originalname.match(/\.(nbs)$/)) { - return cb(new Error('Only .nbs files are allowed!'), false); - } - - cb(null, true); - }, - }; - - constructor( - public readonly songService: SongService, - public readonly fileService: FileService, - ) {} - - @Get('/') - @ApiOperation({ - summary: 'Get a filtered/sorted list of songs with pagination', - }) - public async getSongList( - @Query() query: PageQueryDTO, - ): Promise { - return await this.songService.getSongByPage(query); - } - - @Get('/:id') - @ApiOperation({ summary: 'Get song info by ID' }) - public async getSong( - @Param('id') id: string, - @GetRequestToken() user: UserDocument | null, - ): Promise { - return await this.songService.getSong(id, user); - } - - @Get('/:id/edit') - @ApiOperation({ summary: 'Get song info for editing by ID' }) - @UseGuards(AuthGuard('jwt-refresh')) - @ApiBearerAuth() - public async getEditSong( - @Param('id') id: string, - @GetRequestToken() user: UserDocument | null, - ): Promise { - user = validateUser(user); - return await this.songService.getSongEdit(id, user); - } - - @Patch('/:id/edit') - @UseGuards(AuthGuard('jwt-refresh')) - @ApiBearerAuth() - @ApiOperation({ summary: 'Edit song info by ID' }) - @ApiBody({ - description: 'Upload Song', - type: UploadSongResponseDto, - }) - public async patchSong( - @Param('id') id: string, - @Req() req: RawBodyRequest, - @GetRequestToken() user: UserDocument | null, - ): Promise { - user = validateUser(user); - //TODO: Fix this weird type casting and raw body access - const body = req.body as unknown as UploadSongDto; - return await this.songService.patchSong(id, body, user); - } - - @Get('/:id/download') - @ApiOperation({ summary: 'Get song .nbs file' }) - public async getSongFile( - @Param('id') id: string, - @Query('src') src: string, - @GetRequestToken() user: UserDocument | null, - @Res() res: Response, - ): Promise { - user = validateUser(user); - - // TODO: no longer used - res.set({ - 'Content-Disposition': 'attachment; filename="song.nbs"', - // Expose the Content-Disposition header to the client - 'Access-Control-Expose-Headers': 'Content-Disposition', - }); - - const url = await this.songService.getSongDownloadUrl(id, user, src, false); - res.redirect(HttpStatus.FOUND, url); - } - - @Get('/:id/open') - @ApiOperation({ summary: 'Get song .nbs file' }) - public async getSongOpenUrl( - @Param('id') id: string, - @GetRequestToken() user: UserDocument | null, - @Headers('src') src: string, - ): Promise { - if (src != 'downloadButton') { - throw new UnauthorizedException('Invalid source'); + static multerConfig: MulterOptions = { + limits: { + fileSize: UPLOAD_CONSTANTS.file.maxSize + }, + fileFilter: (req, file, cb) => { + if (!file.originalname.match(/\.(nbs)$/)) { + return cb(new Error('Only .nbs files are allowed!'), false); + } + + cb(null, true); + } + }; + + constructor( + public readonly songService: SongService, + public readonly fileService: FileService + ) {} + + @Get('/') + @ApiOperation({ + summary: 'Get a filtered/sorted list of songs with pagination' + }) + public async getSongList( + @Query() query: PageQueryDTO + ): Promise { + return await this.songService.getSongByPage(query); } - const url = await this.songService.getSongDownloadUrl( - id, - user, - 'open', - true, - ); - - return url; - } - - @Delete('/:id') - @UseGuards(AuthGuard('jwt-refresh')) - @ApiBearerAuth() - @ApiOperation({ summary: 'Delete a song' }) - public async deleteSong( - @Param('id') id: string, - @GetRequestToken() user: UserDocument | null, - ): Promise { - user = validateUser(user); - await this.songService.deleteSong(id, user); - } - - @Post('/') - @UseGuards(AuthGuard('jwt-refresh')) - @ApiBearerAuth() - @ApiConsumes('multipart/form-data') - @ApiBody({ - description: 'Upload Song', - type: UploadSongResponseDto, - }) - @UseInterceptors(FileInterceptor('file', SongController.multerConfig)) - @ApiOperation({ - summary: 'Upload a .nbs file and send the song data, creating a new song', - }) - public async createSong( - @UploadedFile() file: Express.Multer.File, - @Body() body: UploadSongDto, - @GetRequestToken() user: UserDocument | null, - ): Promise { - user = validateUser(user); - return await this.songService.uploadSong({ body, file, user }); - } + @Get('/:id') + @ApiOperation({ summary: 'Get song info by ID' }) + public async getSong( + @Param('id') id: string, + @GetRequestToken() user: UserDocument | null + ): Promise { + return await this.songService.getSong(id, user); + } + + @Get('/:id/edit') + @ApiOperation({ summary: 'Get song info for editing by ID' }) + @UseGuards(AuthGuard('jwt-refresh')) + @ApiBearerAuth() + public async getEditSong( + @Param('id') id: string, + @GetRequestToken() user: UserDocument | null + ): Promise { + user = validateUser(user); + return await this.songService.getSongEdit(id, user); + } + + @Patch('/:id/edit') + @UseGuards(AuthGuard('jwt-refresh')) + @ApiBearerAuth() + @ApiOperation({ summary: 'Edit song info by ID' }) + @ApiBody({ + description: 'Upload Song', + type : UploadSongResponseDto + }) + public async patchSong( + @Param('id') id: string, + @Req() req: RawBodyRequest, + @GetRequestToken() user: UserDocument | null + ): Promise { + user = validateUser(user); + //TODO: Fix this weird type casting and raw body access + const body = req.body as unknown as UploadSongDto; + return await this.songService.patchSong(id, body, user); + } + + @Get('/:id/download') + @ApiOperation({ summary: 'Get song .nbs file' }) + public async getSongFile( + @Param('id') id: string, + @Query('src') src: string, + @GetRequestToken() user: UserDocument | null, + @Res() res: Response + ): Promise { + user = validateUser(user); + + // TODO: no longer used + res.set({ + 'Content-Disposition' : 'attachment; filename="song.nbs"', + // Expose the Content-Disposition header to the client + 'Access-Control-Expose-Headers': 'Content-Disposition' + }); + + const url = await this.songService.getSongDownloadUrl(id, user, src, false); + res.redirect(HttpStatus.FOUND, url); + } + + @Get('/:id/open') + @ApiOperation({ summary: 'Get song .nbs file' }) + public async getSongOpenUrl( + @Param('id') id: string, + @GetRequestToken() user: UserDocument | null, + @Headers('src') src: string + ): Promise { + if (src != 'downloadButton') { + throw new UnauthorizedException('Invalid source'); + } + + const url = await this.songService.getSongDownloadUrl( + id, + user, + 'open', + true + ); + + return url; + } + + @Delete('/:id') + @UseGuards(AuthGuard('jwt-refresh')) + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete a song' }) + public async deleteSong( + @Param('id') id: string, + @GetRequestToken() user: UserDocument | null + ): Promise { + user = validateUser(user); + await this.songService.deleteSong(id, user); + } + + @Post('/') + @UseGuards(AuthGuard('jwt-refresh')) + @ApiBearerAuth() + @ApiConsumes('multipart/form-data') + @ApiBody({ + description: 'Upload Song', + type : UploadSongResponseDto + }) + @UseInterceptors(FileInterceptor('file', SongController.multerConfig)) + @ApiOperation({ + summary: 'Upload a .nbs file and send the song data, creating a new song' + }) + public async createSong( + @UploadedFile() file: Express.Multer.File, + @Body() body: UploadSongDto, + @GetRequestToken() user: UserDocument | null + ): Promise { + user = validateUser(user); + return await this.songService.uploadSong({ body, file, user }); + } } diff --git a/apps/backend/src/song/song.module.ts b/apps/backend/src/song/song.module.ts index 52218af7..a5aea3e7 100644 --- a/apps/backend/src/song/song.module.ts +++ b/apps/backend/src/song/song.module.ts @@ -14,24 +14,24 @@ import { SongController } from './song.controller'; import { SongService } from './song.service'; @Module({ - imports: [ - MongooseModule.forFeature([{ name: Song.name, schema: SongSchema }]), - AuthModule, - UserModule, - FileModule.forRootAsync(), - ], - providers: [ - SongService, - SongUploadService, - SongWebhookService, - { - inject: [ConfigService], - provide: 'DISCORD_WEBHOOK_URL', - useFactory: (configService: ConfigService) => - configService.getOrThrow('DISCORD_WEBHOOK_URL'), - }, - ], - controllers: [SongController, MySongsController], - exports: [SongService], + imports: [ + MongooseModule.forFeature([{ name: Song.name, schema: SongSchema }]), + AuthModule, + UserModule, + FileModule.forRootAsync() + ], + providers: [ + SongService, + SongUploadService, + SongWebhookService, + { + inject : [ConfigService], + provide : 'DISCORD_WEBHOOK_URL', + useFactory: (configService: ConfigService) => + configService.getOrThrow('DISCORD_WEBHOOK_URL') + } + ], + controllers: [SongController, MySongsController], + exports : [SongService] }) export class SongModule {} diff --git a/apps/backend/src/song/song.service.spec.ts b/apps/backend/src/song/song.service.spec.ts index b5445461..9fcea05e 100644 --- a/apps/backend/src/song/song.service.spec.ts +++ b/apps/backend/src/song/song.service.spec.ts @@ -1,14 +1,16 @@ +import { jest, describe, beforeEach, expect, it } from 'bun:test'; + import type { UserDocument } from '@nbw/database'; import { - SongDocument, - Song as SongEntity, - SongPreviewDto, - SongSchema, - SongStats, - SongViewDto, - SongWithUser, - UploadSongDto, - UploadSongResponseDto, + SongDocument, + Song as SongEntity, + SongPreviewDto, + SongSchema, + SongStats, + SongViewDto, + SongWithUser, + UploadSongDto, + UploadSongResponseDto } from '@nbw/database'; import { HttpException } from '@nestjs/common'; import { getModelToken } from '@nestjs/mongoose'; @@ -22,1029 +24,1029 @@ import { SongWebhookService } from './song-webhook/song-webhook.service'; import { SongService } from './song.service'; const mockFileService = { - deleteSong: jest.fn(), - getSongDownloadUrl: jest.fn(), + deleteSong : jest.fn(), + getSongDownloadUrl: jest.fn() }; const mockSongUploadService = { - processUploadedSong: jest.fn(), - processSongPatch: jest.fn(), + processUploadedSong: jest.fn(), + processSongPatch : jest.fn() }; const mockSongWebhookService = { - syncAllSongsWebhook: jest.fn(), - postSongWebhook: jest.fn(), - updateSongWebhook: jest.fn(), - deleteSongWebhook: jest.fn(), - syncSongWebhook: jest.fn(), + 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', - ); + 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)); }); - }); - - 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 be defined', () => { + expect(service).toBeDefined(); }); - it('should throw an error if song is not found', async () => { - const publicId = 'test-id'; - const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + 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' + ); + }); + }); - const mockFindOne = { - findOne: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(null), - }; + 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 + ); + }); - jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + it('should throw an error if song is not found', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; - await expect(service.deleteSong(publicId, user)).rejects.toThrow( - HttpException, - ); - }); + const mockFindOne = { + findOne: jest.fn().mockReturnThis(), + exec : jest.fn().mockResolvedValue(null) + }; - 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 + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); - const mockFindOne = { - exec: jest.fn().mockResolvedValue(songEntity), - }; + await expect(service.deleteSong(publicId, user)).rejects.toThrow( + HttpException + ); + }); - jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + 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 - await expect(service.deleteSong(publicId, user)).rejects.toThrow( - HttpException, - ); - }); + const mockFindOne = { + exec: jest.fn().mockResolvedValue(songEntity) + }; - 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 + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); - const mockFindOne = { - exec: jest.fn().mockResolvedValue(songEntity), - }; + await expect(service.deleteSong(publicId, user)).rejects.toThrow( + HttpException + ); + }); - jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + 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 - 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, - ); - }); + const mockFindOne = { + exec: jest.fn().mockResolvedValue(songEntity) + }; - 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, - ); - }); + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); - 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, - ); + await expect(service.deleteSong(publicId, 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('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); + + 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); + + 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, - }; + 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 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), - }; + 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); + jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any); - const result = await service.getSongByPage(query); + const result = await service.getSongByPage(query); - expect(result).toEqual( - songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), - ); + expect(result).toEqual( + songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)) + ); - expect(songModel.find).toHaveBeenCalledWith({ visibility: 'public' }); - expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: 1 }); + expect(songModel.find).toHaveBeenCalledWith({ visibility: 'public' }); + expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: 1 }); - expect(mockFind.skip).toHaveBeenCalledWith( - query.page * query.limit - query.limit, - ); + expect(mockFind.skip).toHaveBeenCalledWith( + query.page * query.limit - query.limit + ); - expect(mockFind.limit).toHaveBeenCalledWith(query.limit); - expect(mockFind.exec).toHaveBeenCalled(); - }); + 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, - }; + 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 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), - }; + 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); + 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: {}, - save: jest.fn(), - } as any; - - songDocument.save = jest.fn().mockResolvedValue(songDocument); - - const mockFindOne = { - populate: jest.fn().mockResolvedValue(songDocument), - ...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 }); + expect(service.getSongByPage(query)).rejects.toThrow(HttpException); + }); }); - it('should throw an error if song is not found', async () => { - const publicId = 'test-id'; + 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 : {}, + save : jest.fn() + } as any; + + songDocument.save = jest.fn().mockResolvedValue(songDocument); + + const mockFindOne = { + populate: jest.fn().mockResolvedValue(songDocument), + ...songDocument + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne); + + const result = await service.getSong(publicId, user); + + expect(result).toEqual(SongViewDto.fromSongDocument(songDocument)); + expect(songModel.findOne).toHaveBeenCalledWith({ publicId }); + }); - const user: UserDocument = { - _id: 'test-user-id', - } as unknown as UserDocument; + it('should throw an error if song is not found', async () => { + const publicId = 'test-id'; - const mockFindOne = null; + const user: UserDocument = { + _id: 'test-user-id' + } as unknown as UserDocument; - jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + const mockFindOne = null; - await expect(service.getSong(publicId, user)).rejects.toThrow( - HttpException, - ); - }); + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne); - 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; + await expect(service.getSong(publicId, user)).rejects.toThrow( + HttpException + ); + }); - const songEntity = { - publicId: 'test-public-id', - visibility: 'private', - uploader: 'different-user-id', - }; + 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; - jest.spyOn(songModel, 'findOne').mockReturnValue(songEntity as any); + const songEntity = { + publicId : 'test-public-id', + visibility: 'private', + uploader : 'different-user-id' + }; - expect(service.getSong(publicId, user)).rejects.toThrow(HttpException); - }); + jest.spyOn(songModel, 'findOne').mockReturnValue(songEntity as any); - 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; + expect(service.getSong(publicId, user)).rejects.toThrow(HttpException); + }); - const songEntity = { - publicId: 'test-public-id', - visibility: 'private', - uploader: 'different-user-id', - }; + 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; - jest.spyOn(songModel, 'findOne').mockReturnValue(songEntity as any); - 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`, - ); + const songEntity = { + publicId : 'test-public-id', + visibility: 'private', + uploader : 'different-user-id' + }; + + jest.spyOn(songModel, 'findOne').mockReturnValue(songEntity as any); + expect(service.getSong(publicId, user)).rejects.toThrow(HttpException); + }); }); - it('should throw an error if song is not found', async () => { - const publicId = 'test-id'; - const user: UserDocument = { _id: 'test-user-id' } as UserDocument; + 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` + ); + }); - jest.spyOn(songModel, 'findOne').mockResolvedValue(null); + it('should throw an error if song is not found', async () => { + const publicId = 'test-id'; + const user: UserDocument = { _id: 'test-user-id' } as UserDocument; - await expect(service.getSongDownloadUrl(publicId, user)).rejects.toThrow( - HttpException, - ); - }); + jest.spyOn(songModel, 'findOne').mockResolvedValue(null); - 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; + await expect(service.getSongDownloadUrl(publicId, user)).rejects.toThrow( + HttpException + ); + }); - const songEntity = { - visibility: 'private', - uploader: 'different-user-id', - }; + 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; - jest.spyOn(songModel, 'findOne').mockResolvedValue(songEntity); + const songEntity = { + visibility: 'private', + uploader : 'different-user-id' + }; - await expect(service.getSongDownloadUrl(publicId, user)).rejects.toThrow( - HttpException, - ); - }); + jest.spyOn(songModel, 'findOne').mockResolvedValue(songEntity); - 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, - ); - }); + 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; + 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') - .mockImplementationOnce(() => { - throw new Error('Internal error'); + jest + .spyOn(fileService, 'getSongDownloadUrl') + .mockImplementationOnce(() => { + throw new Error('Internal error'); + }); + + await expect(service.getSongDownloadUrl(publicId, user)).rejects.toThrow( + HttpException + ); }); - await expect(service.getSongDownloadUrl(publicId, user)).rejects.toThrow( - HttpException, - ); + it('should throw an error in case of an internal error on saving 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().mockImplementationOnce(() => { + throw new Error('Error saving song'); + }) + }; + + 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 + ); + }); }); - it('should throw an error in case of an internal error on saving 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().mockImplementationOnce(() => { - throw new Error('Error saving song'); - }), - }; - - 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('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 + 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(), - }; + const mockFindOne = { + exec : jest.fn().mockResolvedValue(songEntity), + populate: jest.fn().mockReturnThis() + }; - jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); + jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); - const result = await service.getSongEdit(publicId, user); + const result = await service.getSongEdit(publicId, user); - expect(result).toEqual(UploadSongDto.fromSongDocument(songEntity as any)); + expect(result).toEqual(UploadSongDto.fromSongDocument(songEntity as any)); - expect(songModel.findOne).toHaveBeenCalledWith({ publicId }); - }); + 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; + 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), - }; + const findOneMock = { + findOne: jest.fn().mockReturnThis(), + exec : jest.fn().mockResolvedValue(null) + }; - jest.spyOn(songModel, 'findOne').mockReturnValue(findOneMock as any); + jest.spyOn(songModel, 'findOne').mockReturnValue(findOneMock as any); - await expect(service.getSongEdit(publicId, user)).rejects.toThrow( - HttpException, - ); - }); + 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, - ); + 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 }, - ]; + 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); + jest.spyOn(songModel, 'aggregate').mockResolvedValue(categories); - const result = await service.getCategories(); + const result = await service.getCategories(); - expect(result).toEqual({ category1: 10, category2: 5 }); + expect(result).toEqual({ category1: 10, category2: 5 }); - expect(songModel.aggregate).toHaveBeenCalledWith([ - { $match: { visibility: 'public' } }, - { $group: { _id: '$category', count: { $sum: 1 } } }, - { $sort: { count: -1 } }, - ]); + 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(); + + 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/apps/backend/src/song/song.service.ts b/apps/backend/src/song/song.service.ts index 1da06c08..74cf6735 100644 --- a/apps/backend/src/song/song.service.ts +++ b/apps/backend/src/song/song.service.ts @@ -1,21 +1,21 @@ import { BROWSER_SONGS } from '@nbw/config'; import type { UserDocument } from '@nbw/database'; import { - PageQueryDTO, - Song as SongEntity, - SongPageDto, - SongPreviewDto, - SongViewDto, - SongWithUser, - UploadSongDto, - UploadSongResponseDto, + PageQueryDTO, + Song as SongEntity, + SongPageDto, + SongPreviewDto, + SongViewDto, + SongWithUser, + UploadSongDto, + UploadSongResponseDto } from '@nbw/database'; import { - HttpException, - HttpStatus, - Inject, - Injectable, - Logger, + HttpException, + HttpStatus, + Inject, + Injectable, + Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; @@ -28,112 +28,112 @@ import { removeExtraSpaces } from './song.util'; @Injectable() export class SongService { - private logger = new Logger(SongService.name); - constructor( - @InjectModel(SongEntity.name) - private songModel: Model, - - @Inject(FileService) - private fileService: FileService, - - @Inject(SongUploadService) - private songUploadService: SongUploadService, - - @Inject(SongWebhookService) - private songWebhookService: SongWebhookService, - ) {} - - public async getSongById(publicId: string) { - return this.songModel.findOne({ - publicId, - }); - } - - public async uploadSong({ - file, - user, - body, - }: { - body: UploadSongDto; - file: Express.Multer.File; - user: UserDocument; - }): Promise { - const song = await this.songUploadService.processUploadedSong({ - file, - user, - body, - }); - - // Create song document - const songDocument = await this.songModel.create(song); - - // Post Discord webhook - const populatedSong = (await songDocument.populate( - 'uploader', - 'username profileImage -_id', - )) as unknown as SongWithUser; - - const webhookMessageId = await this.songWebhookService.syncSongWebhook( - populatedSong, - ); - - songDocument.webhookMessageId = webhookMessageId; - - // Save song document - await songDocument.save(); - - return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); - } - - public async deleteSong( - publicId: string, - user: UserDocument, - ): Promise { - const foundSong = await this.songModel - .findOne({ publicId: publicId }) - .exec(); - - if (!foundSong) { - throw new HttpException('Song not found', HttpStatus.NOT_FOUND); + private logger = new Logger(SongService.name); + constructor( + @InjectModel(SongEntity.name) + private songModel: Model, + + @Inject(FileService) + private fileService: FileService, + + @Inject(SongUploadService) + private songUploadService: SongUploadService, + + @Inject(SongWebhookService) + private songWebhookService: SongWebhookService + ) {} + + public async getSongById(publicId: string) { + return this.songModel.findOne({ + publicId + }); } - if (foundSong.uploader.toString() !== user?._id.toString()) { - throw new HttpException('Song not found', HttpStatus.UNAUTHORIZED); + public async uploadSong({ + file, + user, + body + }: { + body: UploadSongDto; + file: Express.Multer.File; + user: UserDocument; + }): Promise { + const song = await this.songUploadService.processUploadedSong({ + file, + user, + body + }); + + // Create song document + const songDocument = await this.songModel.create(song); + + // Post Discord webhook + const populatedSong = (await songDocument.populate( + 'uploader', + 'username profileImage -_id' + )) as unknown as SongWithUser; + + const webhookMessageId = await this.songWebhookService.syncSongWebhook( + populatedSong + ); + + songDocument.webhookMessageId = webhookMessageId; + + // Save song document + await songDocument.save(); + + return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); } - await this.songModel.deleteOne({ publicId: publicId }).exec(); + public async deleteSong( + publicId: string, + user: UserDocument + ): Promise { + const foundSong = await this.songModel + .findOne({ publicId: publicId }) + .exec(); - await this.fileService.deleteSong(foundSong.nbsFileUrl); + if (!foundSong) { + throw new HttpException('Song not found', HttpStatus.NOT_FOUND); + } - const populatedSong = (await foundSong.populate( - 'uploader', - 'username profileImage -_id', - )) as unknown as SongWithUser; + if (foundSong.uploader.toString() !== user?._id.toString()) { + throw new HttpException('Song not found', HttpStatus.UNAUTHORIZED); + } - await this.songWebhookService.deleteSongWebhook(populatedSong); + await this.songModel.deleteOne({ publicId: publicId }).exec(); - return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); - } + await this.fileService.deleteSong(foundSong.nbsFileUrl); - public async patchSong( - publicId: string, - body: UploadSongDto, - user: UserDocument, - ): Promise { - const foundSong = await this.songModel.findOne({ - publicId: publicId, - }); + const populatedSong = (await foundSong.populate( + 'uploader', + 'username profileImage -_id' + )) as unknown as SongWithUser; - if (!foundSong) { - throw new HttpException('Song not found', HttpStatus.NOT_FOUND); - } + await this.songWebhookService.deleteSongWebhook(populatedSong); - if (foundSong.uploader.toString() !== user?._id.toString()) { - throw new HttpException('Song not found', HttpStatus.UNAUTHORIZED); + return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); } - if ( - foundSong.title === body.title && + public async patchSong( + publicId: string, + body: UploadSongDto, + user: UserDocument + ): Promise { + const foundSong = await this.songModel.findOne({ + publicId: publicId + }); + + if (!foundSong) { + throw new HttpException('Song not found', HttpStatus.NOT_FOUND); + } + + if (foundSong.uploader.toString() !== user?._id.toString()) { + throw new HttpException('Song not found', HttpStatus.UNAUTHORIZED); + } + + if ( + foundSong.title === body.title && foundSong.originalAuthor === body.originalAuthor && foundSong.description === body.description && foundSong.category === body.category && @@ -144,338 +144,338 @@ export class SongService { JSON.stringify(body.thumbnailData) && JSON.stringify(foundSong.customInstruments) === JSON.stringify(body.customInstruments) - ) { - throw new HttpException('No changes detected', HttpStatus.BAD_REQUEST); - } + ) { + throw new HttpException('No changes detected', HttpStatus.BAD_REQUEST); + } + + // Check if updates to the song files and/or thumbnail are necessary; + // if so, update and reupload them + await this.songUploadService.processSongPatch(foundSong, body, user); + + // Update song document + foundSong.title = removeExtraSpaces(body.title); + foundSong.originalAuthor = removeExtraSpaces(body.originalAuthor); + foundSong.description = removeExtraSpaces(body.description); + foundSong.category = body.category; + foundSong.allowDownload = body.allowDownload; + foundSong.visibility = body.visibility; + foundSong.license = body.license; + foundSong.thumbnailData = body.thumbnailData; + foundSong.customInstruments = body.customInstruments; + + // Update document's last update time + foundSong.updatedAt = new Date(); + + const populatedSong = (await foundSong.populate( + 'uploader', + 'username profileImage -_id' + )) as unknown as SongWithUser; + + const webhookMessageId = await this.songWebhookService.syncSongWebhook( + populatedSong + ); + + foundSong.webhookMessageId = webhookMessageId; - // Check if updates to the song files and/or thumbnail are necessary; - // if so, update and reupload them - await this.songUploadService.processSongPatch(foundSong, body, user); - - // Update song document - foundSong.title = removeExtraSpaces(body.title); - foundSong.originalAuthor = removeExtraSpaces(body.originalAuthor); - foundSong.description = removeExtraSpaces(body.description); - foundSong.category = body.category; - foundSong.allowDownload = body.allowDownload; - foundSong.visibility = body.visibility; - foundSong.license = body.license; - foundSong.thumbnailData = body.thumbnailData; - foundSong.customInstruments = body.customInstruments; - - // Update document's last update time - foundSong.updatedAt = new Date(); - - const populatedSong = (await foundSong.populate( - 'uploader', - 'username profileImage -_id', - )) as unknown as SongWithUser; - - const webhookMessageId = await this.songWebhookService.syncSongWebhook( - populatedSong, - ); - - foundSong.webhookMessageId = webhookMessageId; - - // Save song document - await foundSong.save(); - - return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); - } - - public async getSongByPage(query: PageQueryDTO): Promise { - const { page, limit, sort, order } = query; - - if (!page || !limit || !sort) { - throw new HttpException( - 'Invalid query parameters', - HttpStatus.BAD_REQUEST, - ); + // Save song document + await foundSong.save(); + + return UploadSongResponseDto.fromSongWithUserDocument(populatedSong); } - const songs = (await this.songModel - .find({ - visibility: 'public', - }) - .sort({ - [sort]: order ? 1 : -1, - }) - .skip(page * limit - limit) - .limit(limit) - .populate('uploader', 'username profileImage -_id') - .exec()) as unknown as SongWithUser[]; - - return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); - } - - public async getRecentSongs( - page: number, - limit: number, - ): Promise { - const queryObject: any = { - visibility: 'public', - }; - - const data = (await this.songModel - .find(queryObject) - .sort({ - createdAt: -1, - }) - .skip(page * limit - limit) - .limit(limit) - .populate('uploader', 'username profileImage -_id') - .exec()) as unknown as SongWithUser[]; - - return data.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); - } - - public async getSongsForTimespan(timespan: number): Promise { - return this.songModel - .find({ - visibility: 'public', - createdAt: { - $gte: timespan, - }, - }) - .sort({ playCount: -1 }) - .limit(BROWSER_SONGS.featuredPageSize) - .populate('uploader', 'username profileImage -_id') - .exec(); - } - - public async getSongsBeforeTimespan( - timespan: number, - ): Promise { - return this.songModel - .find({ - visibility: 'public', - createdAt: { - $lt: timespan, - }, - }) - .sort({ createdAt: -1 }) - .limit(BROWSER_SONGS.featuredPageSize) - .populate('uploader', 'username profileImage -_id') - .exec(); - } - - public async getSong( - publicId: string, - user: UserDocument | null, - ): Promise { - const foundSong = await this.songModel.findOne({ publicId: publicId }); - - if (!foundSong) { - throw new HttpException('Song not found', HttpStatus.NOT_FOUND); + public async getSongByPage(query: PageQueryDTO): Promise { + const { page, limit, sort, order } = query; + + if (!page || !limit || !sort) { + throw new HttpException( + 'Invalid query parameters', + HttpStatus.BAD_REQUEST + ); + } + + const songs = (await this.songModel + .find({ + visibility: 'public' + }) + .sort({ + [sort]: order ? 1 : -1 + }) + .skip(page * limit - limit) + .limit(limit) + .populate('uploader', 'username profileImage -_id') + .exec()) as unknown as SongWithUser[]; + + return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); } - if (foundSong.visibility === 'private') { - if (!user) { - throw new HttpException('Song not found', HttpStatus.NOT_FOUND); - } + public async getRecentSongs( + page: number, + limit: number + ): Promise { + const queryObject: any = { + visibility: 'public' + }; + + const data = (await this.songModel + .find(queryObject) + .sort({ + createdAt: -1 + }) + .skip(page * limit - limit) + .limit(limit) + .populate('uploader', 'username profileImage -_id') + .exec()) as unknown as SongWithUser[]; + + return data.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); + } - if (foundSong.uploader.toString() !== user._id.toString()) { - throw new HttpException('Song not found', HttpStatus.NOT_FOUND); - } + public async getSongsForTimespan(timespan: number): Promise { + return this.songModel + .find({ + visibility: 'public', + createdAt : { + $gte: timespan + } + }) + .sort({ playCount: -1 }) + .limit(BROWSER_SONGS.featuredPageSize) + .populate('uploader', 'username profileImage -_id') + .exec(); } - // increment view count - foundSong.playCount++; - await foundSong.save(); - - const populatedSong = await foundSong.populate( - 'uploader', - 'username profileImage -_id', - ); - - return SongViewDto.fromSongDocument(populatedSong); - } - - // TODO: service should not handle HTTP -> https://www.reddit.com/r/node/comments/uoicw1/should_i_return_status_code_from_service_layer/ - public async getSongDownloadUrl( - publicId: string, - user: UserDocument | null, - src?: string, - packed: boolean = false, - ): Promise { - const foundSong = await this.songModel.findOne({ publicId: publicId }); - - if (!foundSong) { - throw new HttpException('Song not found with ID', HttpStatus.NOT_FOUND); + public async getSongsBeforeTimespan( + timespan: number + ): Promise { + return this.songModel + .find({ + visibility: 'public', + createdAt : { + $lt: timespan + } + }) + .sort({ createdAt: -1 }) + .limit(BROWSER_SONGS.featuredPageSize) + .populate('uploader', 'username profileImage -_id') + .exec(); } - if (foundSong.visibility !== 'public') { - if (!user || foundSong.uploader.toString() !== user._id.toString()) { - throw new HttpException( - 'This song is private', - HttpStatus.UNAUTHORIZED, + public async getSong( + publicId: string, + user: UserDocument | null + ): Promise { + const foundSong = await this.songModel.findOne({ publicId: publicId }); + + if (!foundSong) { + throw new HttpException('Song not found', HttpStatus.NOT_FOUND); + } + + if (foundSong.visibility === 'private') { + if (!user) { + throw new HttpException('Song not found', HttpStatus.NOT_FOUND); + } + + if (foundSong.uploader.toString() !== user._id.toString()) { + throw new HttpException('Song not found', HttpStatus.NOT_FOUND); + } + } + + // increment view count + foundSong.playCount++; + await foundSong.save(); + + const populatedSong = await foundSong.populate( + 'uploader', + 'username profileImage -_id' ); - } + + return SongViewDto.fromSongDocument(populatedSong); } - if (!packed && !foundSong.allowDownload) { - throw new HttpException( - 'The uploader has disabled downloads of this song', - HttpStatus.UNAUTHORIZED, - ); + // TODO: service should not handle HTTP -> https://www.reddit.com/r/node/comments/uoicw1/should_i_return_status_code_from_service_layer/ + public async getSongDownloadUrl( + publicId: string, + user: UserDocument | null, + src?: string, + packed: boolean = false + ): Promise { + const foundSong = await this.songModel.findOne({ publicId: publicId }); + + if (!foundSong) { + throw new HttpException('Song not found with ID', HttpStatus.NOT_FOUND); + } + + if (foundSong.visibility !== 'public') { + if (!user || foundSong.uploader.toString() !== user._id.toString()) { + throw new HttpException( + 'This song is private', + HttpStatus.UNAUTHORIZED + ); + } + } + + if (!packed && !foundSong.allowDownload) { + throw new HttpException( + 'The uploader has disabled downloads of this song', + HttpStatus.UNAUTHORIZED + ); + } + + const fileKey = packed ? foundSong.packedSongUrl : foundSong.nbsFileUrl; + const fileExt = packed ? '.zip' : '.nbs'; + + const fileName = `${foundSong.title}${fileExt}`; + + try { + const url = await this.fileService.getSongDownloadUrl(fileKey, fileName); + + // increment download count + if (!packed && src === 'downloadButton') foundSong.downloadCount++; + await foundSong.save(); + + return url; + } catch (e) { + this.logger.error('Error getting song file', e); + throw new HttpException( + 'An error occurred while retrieving the song file', + HttpStatus.INTERNAL_SERVER_ERROR + ); + } } - const fileKey = packed ? foundSong.packedSongUrl : foundSong.nbsFileUrl; - const fileExt = packed ? '.zip' : '.nbs'; + public async getMySongsPage({ + query, + user + }: { + query: PageQueryDTO; + user : UserDocument; + }): Promise { + const page = parseInt(query.page?.toString() ?? '1'); + const limit = parseInt(query.limit?.toString() ?? '10'); + const order = query.order ? query.order : false; + const sort = query.sort ? query.sort : 'recent'; + + const songData = (await this.songModel + .find({ + uploader: user._id + }) + .sort({ + [sort]: order ? 1 : -1 + }) + .skip(limit * (page - 1)) + .limit(limit)) as unknown as SongWithUser[]; + + const total = await this.songModel.countDocuments({ + uploader: user._id + }); + + return { + content: songData.map((song) => + SongPreviewDto.fromSongDocumentWithUser(song) + ), + page : page, + limit: limit, + total: total + }; + } - const fileName = `${foundSong.title}${fileExt}`; + public async getSongEdit( + publicId: string, + user: UserDocument + ): Promise { + const foundSong = await this.songModel + .findOne({ publicId: publicId }) + .exec(); - try { - const url = await this.fileService.getSongDownloadUrl(fileKey, fileName); + if (!foundSong) { + throw new HttpException('Song not found', HttpStatus.NOT_FOUND); + } - // increment download count - if (!packed && src === 'downloadButton') foundSong.downloadCount++; - await foundSong.save(); + if (foundSong.uploader.toString() !== user?._id.toString()) { + throw new HttpException('Song not found', HttpStatus.UNAUTHORIZED); + } - return url; - } catch (e) { - this.logger.error('Error getting song file', e); - throw new HttpException( - 'An error occurred while retrieving the song file', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - - public async getMySongsPage({ - query, - user, - }: { - query: PageQueryDTO; - user: UserDocument; - }): Promise { - const page = parseInt(query.page?.toString() ?? '1'); - const limit = parseInt(query.limit?.toString() ?? '10'); - const order = query.order ? query.order : false; - const sort = query.sort ? query.sort : 'recent'; - - const songData = (await this.songModel - .find({ - uploader: user._id, - }) - .sort({ - [sort]: order ? 1 : -1, - }) - .skip(limit * (page - 1)) - .limit(limit)) as unknown as SongWithUser[]; - - const total = await this.songModel.countDocuments({ - uploader: user._id, - }); - - return { - content: songData.map((song) => - SongPreviewDto.fromSongDocumentWithUser(song), - ), - page: page, - limit: limit, - total: total, - }; - } - - public async getSongEdit( - publicId: string, - user: UserDocument, - ): Promise { - const foundSong = await this.songModel - .findOne({ publicId: publicId }) - .exec(); - - if (!foundSong) { - throw new HttpException('Song not found', HttpStatus.NOT_FOUND); + return UploadSongDto.fromSongDocument(foundSong); } - if (foundSong.uploader.toString() !== user?._id.toString()) { - throw new HttpException('Song not found', HttpStatus.UNAUTHORIZED); + public async getCategories(): Promise> { + // Return an object with categories and their counts, minus empty categories, minus private songs, and sort by count + + const categories = (await this.songModel.aggregate([ + { + $match: { + visibility: 'public' + } + }, + { + $group: { + _id : '$category', + count: { $sum: 1 } + } + }, + { + $sort: { + count: -1 + } + } + ])) as unknown as { _id: string; count: number }[]; + + // Return object with category names as keys and counts as values + return categories.reduce((acc, category) => { + if (category._id) { + acc[category._id] = category.count; + } + + return acc; + }, {} as Record); } - return UploadSongDto.fromSongDocument(foundSong); - } + public async getSongsByCategory( + category: string, + page: number, + limit: number + ): Promise { + const songs = (await this.songModel + .find({ + category : category, + visibility: 'public' + }) + .sort({ createdAt: -1 }) + .skip(page * limit - limit) + .limit(limit) + .populate('uploader', 'username profileImage -_id') + .exec()) as unknown as SongWithUser[]; + + return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); + } - public async getCategories(): Promise> { - // Return an object with categories and their counts, minus empty categories, minus private songs, and sort by count + public async getRandomSongs( + count: number, + category: string + ): Promise { + const songs = (await this.songModel + .aggregate([ + { + $match: { + visibility: 'public' + } + }, + { + $sample: { + size: count + } + } + ]) + .exec()) as unknown as SongWithUser[]; + + await this.songModel.populate(songs, { + path : 'uploader', + select: 'username profileImage -_id' + }); + + return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); + } - const categories = (await this.songModel.aggregate([ - { - $match: { - visibility: 'public', - }, - }, - { - $group: { - _id: '$category', - count: { $sum: 1 }, - }, - }, - { - $sort: { - count: -1, - }, - }, - ])) as unknown as { _id: string; count: number }[]; - - // Return object with category names as keys and counts as values - return categories.reduce((acc, category) => { - if (category._id) { - acc[category._id] = category.count; - } - - return acc; - }, {} as Record); - } - - public async getSongsByCategory( - category: string, - page: number, - limit: number, - ): Promise { - const songs = (await this.songModel - .find({ - category: category, - visibility: 'public', - }) - .sort({ createdAt: -1 }) - .skip(page * limit - limit) - .limit(limit) - .populate('uploader', 'username profileImage -_id') - .exec()) as unknown as SongWithUser[]; - - return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); - } - - public async getRandomSongs( - count: number, - category: string, - ): Promise { - const songs = (await this.songModel - .aggregate([ - { - $match: { - visibility: 'public', - }, - }, - { - $sample: { - size: count, - }, - }, - ]) - .exec()) as unknown as SongWithUser[]; - - await this.songModel.populate(songs, { - path: 'uploader', - select: 'username profileImage -_id', - }); - - return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); - } - - public async getAllSongs() { - return this.songModel.find({}); - } + public async getAllSongs() { + return this.songModel.find({}); + } } diff --git a/apps/backend/src/song/song.util.ts b/apps/backend/src/song/song.util.ts index 50168171..cb5dfaf1 100644 --- a/apps/backend/src/song/song.util.ts +++ b/apps/backend/src/song/song.util.ts @@ -3,99 +3,99 @@ import { SongWithUser } from '@nbw/database'; import { customAlphabet } from 'nanoid'; export const formatDuration = (totalSeconds: number) => { - const minutes = Math.floor(Math.ceil(totalSeconds) / 60); - const seconds = Math.ceil(totalSeconds) % 60; + 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')}`; + const formattedTime = `${minutes.toFixed().padStart(1, '0')}:${seconds + .toFixed() + .padStart(2, '0')}`; - return formattedTime; + return formattedTime; }; export function removeExtraSpaces(input: string): string { - return input - .replace(/ +/g, ' ') // replace multiple spaces with one space - .replace(/\n\n+/g, '\n\n') // replace 3+ newlines with two newlines - .trim(); // remove leading and trailing spaces + return input + .replace(/ +/g, ' ') // replace multiple spaces with one space + .replace(/\n\n+/g, '\n\n') // replace 3+ newlines with two newlines + .trim(); // remove leading and trailing spaces } const alphabet = - '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; const nanoid = customAlphabet(alphabet, 10); export const generateSongId = () => { - return nanoid(); + return nanoid(); }; export function getUploadDiscordEmbed({ - title, - description, - uploader, - createdAt, - publicId, - thumbnailUrl, - thumbnailData, - originalAuthor, - category, - license, - stats, + title, + description, + uploader, + createdAt, + publicId, + thumbnailUrl, + thumbnailData, + originalAuthor, + category, + license, + stats }: SongWithUser) { - let fieldsArray = []; + let fieldsArray = []; - if (originalAuthor) { - fieldsArray.push({ - name: 'Original Author', - value: originalAuthor, - inline: false, - }); - } + if (originalAuthor) { + fieldsArray.push({ + name : 'Original Author', + value : originalAuthor, + inline: false + }); + } - fieldsArray = fieldsArray.concat([ - { - name: 'Category', - value: UPLOAD_CONSTANTS.categories[category], - inline: true, - }, - { - name: 'Notes', - value: stats.noteCount.toLocaleString('en-US'), - inline: true, - }, - { - name: 'Length', - value: formatDuration(stats.duration), - inline: true, - }, - ]); - - return { - embeds: [ - { - title: title, - description: description, - color: Number('0x' + thumbnailData.backgroundColor.replace('#', '')), - timestamp: createdAt.toISOString(), - footer: { - text: UPLOAD_CONSTANTS.licenses[license] - ? UPLOAD_CONSTANTS.licenses[license].shortName - : 'Unknown License', - }, - author: { - name: uploader.username, - icon_url: uploader.profileImage, - //url: 'https://noteblock.world/user/${uploaderName}', + fieldsArray = fieldsArray.concat([ + { + name : 'Category', + value : UPLOAD_CONSTANTS.categories[category], + inline: true }, - fields: fieldsArray, - url: `https://noteblock.world/song/${publicId}`, - image: { - url: thumbnailUrl, + { + name : 'Notes', + value : stats.noteCount.toLocaleString('en-US'), + inline: true }, - thumbnail: { - url: 'https://noteblock.world/nbw-color.png', - }, - }, - ], - }; + { + name : 'Length', + value : formatDuration(stats.duration), + inline: true + } + ]); + + return { + embeds: [ + { + title : title, + description: description, + color : Number('0x' + thumbnailData.backgroundColor.replace('#', '')), + timestamp : createdAt.toISOString(), + footer : { + text: UPLOAD_CONSTANTS.licenses[license] + ? UPLOAD_CONSTANTS.licenses[license].shortName + : 'Unknown License' + }, + author: { + name : uploader.username, + icon_url: uploader.profileImage + //url: 'https://noteblock.world/user/${uploaderName}', + }, + fields: fieldsArray, + url : `https://noteblock.world/song/${publicId}`, + image : { + url: thumbnailUrl + }, + thumbnail: { + url: 'https://noteblock.world/nbw-color.png' + } + } + ] + }; } diff --git a/apps/backend/src/user/user.controller.spec.ts b/apps/backend/src/user/user.controller.spec.ts index b8b94b96..05cb1d0b 100644 --- a/apps/backend/src/user/user.controller.spec.ts +++ b/apps/backend/src/user/user.controller.spec.ts @@ -7,84 +7,84 @@ import { UserController } from './user.controller'; import { UserService } from './user.service'; const mockUserService = { - getUserByEmailOrId: jest.fn(), - getUserPaginated: jest.fn(), - getSelfUserData: jest.fn(), + 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); + 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); }); - }); - describe('getUserPaginated', () => { - it('should return paginated user data', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; - const paginatedUsers = { items: [], total: 0 }; + 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' + }; - mockUserService.getUserPaginated.mockResolvedValueOnce(paginatedUsers); + const user = { email: 'test@example.com' }; - const result = await userController.getUserPaginated(query); + mockUserService.getUserByEmailOrId.mockResolvedValueOnce(user); - expect(result).toEqual(paginatedUsers); - expect(userService.getUserPaginated).toHaveBeenCalledWith(query); + const result = await userController.getUser(query); + + expect(result).toEqual(user); + expect(userService.getUserByEmailOrId).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' }; + describe('getUserPaginated', () => { + it('should return paginated user data', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const paginatedUsers = { items: [], total: 0 }; - mockUserService.getSelfUserData.mockResolvedValueOnce(userData); + mockUserService.getUserPaginated.mockResolvedValueOnce(paginatedUsers); - const result = await userController.getMe(user); + const result = await userController.getUserPaginated(query); - expect(result).toEqual(userData); - expect(userService.getSelfUserData).toHaveBeenCalledWith(user); + expect(result).toEqual(paginatedUsers); + expect(userService.getUserPaginated).toHaveBeenCalledWith(query); + }); }); - it('should handle null user', async () => { - const user = null; + 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); + await expect(userController.getMe(user)).rejects.toThrow(HttpException); + }); }); - }); }); diff --git a/apps/backend/src/user/user.controller.ts b/apps/backend/src/user/user.controller.ts index 193551fa..c31cc461 100644 --- a/apps/backend/src/user/user.controller.ts +++ b/apps/backend/src/user/user.controller.ts @@ -9,43 +9,43 @@ import { UserService } from './user.service'; @Controller('user') export class UserController { - constructor( - @Inject(UserService) - private readonly userService: UserService, - ) {} + constructor( + @Inject(UserService) + private readonly userService: UserService + ) {} - @Get() - @ApiTags('user') - @ApiBearerAuth() - async getUser(@Query() query: GetUser) { - return await this.userService.getUserByEmailOrId(query); - } + @Get() + @ApiTags('user') + @ApiBearerAuth() + async getUser(@Query() query: GetUser) { + return await this.userService.getUserByEmailOrId(query); + } - @Get() - @ApiTags('user') - @ApiBearerAuth() - async getUserPaginated(@Query() query: PageQueryDTO) { - return await this.userService.getUserPaginated(query); - } + @Get() + @ApiTags('user') + @ApiBearerAuth() + async getUserPaginated(@Query() query: PageQueryDTO) { + return await this.userService.getUserPaginated(query); + } - @Get('me') - @ApiTags('user') - @ApiBearerAuth() - @ApiOperation({ summary: 'Get the token owner data' }) - async getMe(@GetRequestToken() user: UserDocument | null) { - user = validateUser(user); - return await this.userService.getSelfUserData(user); - } + @Get('me') + @ApiTags('user') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get the token owner data' }) + async getMe(@GetRequestToken() user: UserDocument | null) { + user = validateUser(user); + return await this.userService.getSelfUserData(user); + } - @Patch('username') - @ApiTags('user') - @ApiBearerAuth() - @ApiOperation({ summary: 'Update the username' }) - async updateUsername( - @GetRequestToken() user: UserDocument | null, - @Body() body: UpdateUsernameDto, - ) { - user = validateUser(user); - return await this.userService.updateUsername(user, body); - } + @Patch('username') + @ApiTags('user') + @ApiBearerAuth() + @ApiOperation({ summary: 'Update the username' }) + async updateUsername( + @GetRequestToken() user: UserDocument | null, + @Body() body: UpdateUsernameDto + ) { + user = validateUser(user); + return await this.userService.updateUsername(user, body); + } } diff --git a/apps/backend/src/user/user.module.ts b/apps/backend/src/user/user.module.ts index 53a58c90..cbbbcca7 100644 --- a/apps/backend/src/user/user.module.ts +++ b/apps/backend/src/user/user.module.ts @@ -6,11 +6,11 @@ import { UserController } from './user.controller'; import { UserService } from './user.service'; @Module({ - imports: [ - MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), - ], - providers: [UserService], - controllers: [UserController], - exports: [UserService], + imports: [ + MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]) + ], + providers : [UserService], + controllers: [UserController], + exports : [UserService] }) export class UserModule {} diff --git a/apps/backend/src/user/user.service.spec.ts b/apps/backend/src/user/user.service.spec.ts index 56e07464..d2ed163d 100644 --- a/apps/backend/src/user/user.service.spec.ts +++ b/apps/backend/src/user/user.service.spec.ts @@ -1,9 +1,9 @@ import { - CreateUser, - GetUser, - PageQueryDTO, - User, - UserDocument, + CreateUser, + GetUser, + PageQueryDTO, + User, + UserDocument } from '@nbw/database'; import { HttpException, HttpStatus } from '@nestjs/common'; import { getModelToken } from '@nestjs/mongoose'; @@ -13,513 +13,513 @@ import { Model } from 'mongoose'; 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(), + 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(); + 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)); }); - }); - describe('findByEmail', () => { - it('should find a user by email', async () => { - const email = 'test@example.com'; - const user = { email } as UserDocument; + 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, 'findOne').mockReturnValue({ - exec: jest.fn().mockResolvedValue(user), - } as any); + jest.spyOn(userModel, 'create').mockReturnValue(user); - const result = await service.findByEmail(email); + const result = await service.create(createUserDto); - expect(result).toEqual(user); - expect(userModel.findOne).toHaveBeenCalledWith({ email }); + expect(result).toEqual(user); + expect(userModel.create).toHaveBeenCalledWith(createUserDto); + expect(user.save).toHaveBeenCalled(); + }); }); - }); - describe('findByID', () => { - it('should find a user by ID', async () => { - const id = 'test-id'; - const user = { _id: id } as UserDocument; + describe('findByEmail', () => { + it('should find a user by email', async () => { + const email = 'test@example.com'; + const user = { email } as UserDocument; - jest.spyOn(userModel, 'findById').mockReturnValue({ - exec: jest.fn().mockResolvedValue(user), - } as any); + jest.spyOn(userModel, 'findOne').mockReturnValue({ + exec: jest.fn().mockResolvedValue(user) + } as any); - const result = await service.findByID(id); + const result = await service.findByEmail(email); - expect(result).toEqual(user); - expect(userModel.findById).toHaveBeenCalledWith(id); + expect(result).toEqual(user); + expect(userModel.findOne).toHaveBeenCalledWith({ email }); + }); }); - }); - describe('getUserPaginated', () => { - it('should return paginated users', async () => { - const query: PageQueryDTO = { page: 1, limit: 10 }; - const users = [{ username: 'testuser' }] as UserDocument[]; + describe('findByID', () => { + it('should find a user by ID', async () => { + const id = 'test-id'; + const user = { _id: id } as UserDocument; - const usersPage = { - users, - total: 1, - page: 1, - limit: 10, - }; + jest.spyOn(userModel, 'findById').mockReturnValue({ + exec: jest.fn().mockResolvedValue(user) + } as any); - const mockFind = { - sort: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue(users), - }; + const result = await service.findByID(id); - jest.spyOn(userModel, 'find').mockReturnValue(mockFind as any); - jest.spyOn(userModel, 'countDocuments').mockResolvedValue(1); + expect(result).toEqual(user); + expect(userModel.findById).toHaveBeenCalledWith(id); + }); + }); - const result = await service.getUserPaginated(query); + describe('getUserPaginated', () => { + it('should return paginated users', async () => { + const query: PageQueryDTO = { page: 1, limit: 10 }; + const users = [{ username: 'testuser' }] as UserDocument[]; - expect(result).toEqual(usersPage); - expect(userModel.find).toHaveBeenCalledWith({}); - }); - }); + const usersPage = { + users, + total: 1, + page : 1, + limit: 10 + }; - 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; + const mockFind = { + sort : jest.fn().mockReturnThis(), + skip : jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue(users) + }; - jest.spyOn(service, 'findByEmail').mockResolvedValue(user); + jest.spyOn(userModel, 'find').mockReturnValue(mockFind as any); + jest.spyOn(userModel, 'countDocuments').mockResolvedValue(1); - const result = await service.getUserByEmailOrId(query); + const result = await service.getUserPaginated(query); - expect(result).toEqual(user); - expect(service.findByEmail).toHaveBeenCalledWith(query.email); + expect(result).toEqual(usersPage); + expect(userModel.find).toHaveBeenCalledWith({}); + }); }); - 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); + 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; - const result = await service.getUserByEmailOrId(query); + jest.spyOn(service, 'findByEmail').mockResolvedValue(user); - expect(result).toEqual(user); - expect(service.findByID).toHaveBeenCalledWith(query.id); - }); + const result = await service.getUserByEmailOrId(query); - it('should throw an error if username is provided', async () => { - const query: GetUser = { username: 'testuser' }; + expect(result).toEqual(user); + expect(service.findByEmail).toHaveBeenCalledWith(query.email); + }); - await expect(service.getUserByEmailOrId(query)).rejects.toThrow( - new HttpException( - 'Username is not supported yet', - HttpStatus.BAD_REQUEST, - ), - ); - }); + it('should find a user by ID', async () => { + const query: GetUser = { id: 'test-id' }; + const user = { _id: 'test-id' } as UserDocument; - it('should throw an error if neither email nor ID is provided', async () => { - const query: GetUser = {}; + jest.spyOn(service, 'findByID').mockResolvedValue(user); - await expect(service.getUserByEmailOrId(query)).rejects.toThrow( - new HttpException( - 'You must provide an email or an id', - HttpStatus.BAD_REQUEST, - ), - ); - }); - }); + const result = await service.getUserByEmailOrId(query); - describe('getHydratedUser', () => { - it('should return a hydrated user', async () => { - const user = { _id: 'test-id' } as UserDocument; - const hydratedUser = { ...user, songs: [] } as unknown as UserDocument; + expect(result).toEqual(user); + expect(service.findByID).toHaveBeenCalledWith(query.id); + }); - jest.spyOn(userModel, 'findById').mockReturnValue({ - populate: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(hydratedUser), - } as any); + it('should throw an error if username is provided', async () => { + const query: GetUser = { username: 'testuser' }; - const result = await service.getHydratedUser(user); + await expect(service.getUserByEmailOrId(query)).rejects.toThrow( + new HttpException( + 'Username is not supported yet', + HttpStatus.BAD_REQUEST + ) + ); + }); - expect(result).toEqual(hydratedUser); - expect(userModel.findById).toHaveBeenCalledWith(user._id); + it('should throw an error if neither email nor ID is provided', async () => { + const query: GetUser = {}; - expect(userModel.findById(user._id).populate).toHaveBeenCalledWith( - 'songs', - ); + await expect(service.getUserByEmailOrId(query)).rejects.toThrow( + new HttpException( + 'You must provide an email or an id', + HttpStatus.BAD_REQUEST + ) + ); + }); }); - }); - describe('getSelfUserData', () => { - it('should return self user data', async () => { - const user = { _id: 'test-id' } as UserDocument; - const userData = { ...user, lastSeen: new Date() } as UserDocument; + 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(service, 'findByID').mockResolvedValue(userData); + jest.spyOn(userModel, 'findById').mockReturnValue({ + populate: jest.fn().mockReturnThis(), + exec : jest.fn().mockResolvedValue(hydratedUser) + } as any); - const result = await service.getSelfUserData(user); + const result = await service.getHydratedUser(user); - expect(result).toEqual(userData); - expect(service.findByID).toHaveBeenCalledWith(user._id.toString()); + expect(result).toEqual(hydratedUser); + expect(userModel.findById).toHaveBeenCalledWith(user._id); + + expect(userModel.findById(user._id).populate).toHaveBeenCalledWith( + 'songs' + ); + }); }); - it('should throw an error if user is not found', async () => { - const user = { _id: 'test-id' } as UserDocument; + describe('getSelfUserData', () => { + it('should return self user data', async () => { + const user = { _id: 'test-id' } as UserDocument; + const userData = { ...user, lastSeen: new Date() } as UserDocument; - jest.spyOn(service, 'findByID').mockResolvedValue(null); + jest.spyOn(service, 'findByID').mockResolvedValue(userData); - await expect(service.getSelfUserData(user)).rejects.toThrow( - new HttpException('user not found', HttpStatus.NOT_FOUND), - ); - }); + const result = await service.getSelfUserData(user); - it('should update lastSeen and increment loginStreak if lastSeen is before today', async () => { - const user = { _id: 'test-id' } as UserDocument; - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - yesterday.setHours(0, 0, 0, 0); + expect(result).toEqual(userData); + expect(service.findByID).toHaveBeenCalledWith(user._id.toString()); + }); - const userData = { - ...user, - lastSeen: yesterday, - loginStreak: 1, - save: jest.fn().mockResolvedValue(true), - } as unknown as UserDocument; + it('should throw an error if user is not found', async () => { + const user = { _id: 'test-id' } as UserDocument; - jest.spyOn(service, 'findByID').mockResolvedValue(userData); + jest.spyOn(service, 'findByID').mockResolvedValue(null); - const result = await service.getSelfUserData(user); + await expect(service.getSelfUserData(user)).rejects.toThrow( + new HttpException('user not found', HttpStatus.NOT_FOUND) + ); + }); - expect(result.lastSeen).toBeInstanceOf(Date); - expect(result.loginStreak).toBe(2); - expect(userData.save).toHaveBeenCalled(); - }); + it('should update lastSeen and increment loginStreak if lastSeen is before today', async () => { + const user = { _id: 'test-id' } as UserDocument; + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); - it('should not update lastSeen or increment loginStreak if lastSeen is today', async () => { - const user = { _id: 'test-id' } as UserDocument; - const today = new Date(); - today.setHours(0, 0, 0, 0); + const userData = { + ...user, + lastSeen : yesterday, + loginStreak: 1, + save : jest.fn().mockResolvedValue(true) + } as unknown as UserDocument; - const userData = { - ...user, - lastSeen: today, - loginStreak: 1, - save: jest.fn().mockResolvedValue(true), - } as unknown as UserDocument; + jest.spyOn(service, 'findByID').mockResolvedValue(userData); - jest.spyOn(service, 'findByID').mockResolvedValue(userData); + const result = await service.getSelfUserData(user); - const result = await service.getSelfUserData(user); + expect(result.lastSeen).toBeInstanceOf(Date); + expect(result.loginStreak).toBe(2); + expect(userData.save).toHaveBeenCalled(); + }); - expect(result.lastSeen).toEqual(today); - expect(result.loginStreak).toBe(1); - expect(userData.save).not.toHaveBeenCalled(); - }); + it('should not update lastSeen or increment loginStreak if lastSeen is today', async () => { + const user = { _id: 'test-id' } as UserDocument; + const today = new Date(); + today.setHours(0, 0, 0, 0); - it('should reset loginStreak if lastSeen is not yesterday', async () => { - const user = { _id: 'test-id' } as UserDocument; - const twoDaysAgo = new Date(); - twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); - twoDaysAgo.setHours(0, 0, 0, 0); + const userData = { + ...user, + lastSeen : today, + loginStreak: 1, + save : jest.fn().mockResolvedValue(true) + } as unknown as UserDocument; - const userData = { - ...user, - lastSeen: twoDaysAgo, - loginStreak: 5, - save: jest.fn().mockResolvedValue(true), - } as unknown as UserDocument; + jest.spyOn(service, 'findByID').mockResolvedValue(userData); - jest.spyOn(service, 'findByID').mockResolvedValue(userData); + const result = await service.getSelfUserData(user); - const result = await service.getSelfUserData(user); + expect(result.lastSeen).toEqual(today); + expect(result.loginStreak).toBe(1); + expect(userData.save).not.toHaveBeenCalled(); + }); - expect(result.lastSeen).toBeInstanceOf(Date); - expect(result.loginStreak).toBe(1); - expect(userData.save).toHaveBeenCalled(); - }); + it('should reset loginStreak if lastSeen is not yesterday', async () => { + const user = { _id: 'test-id' } as UserDocument; + const twoDaysAgo = new Date(); + twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + twoDaysAgo.setHours(0, 0, 0, 0); - it('should increment loginCount if lastSeen is not today', async () => { - const user = { _id: 'test-id' } as UserDocument; - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - yesterday.setHours(0, 0, 0, 0); + const userData = { + ...user, + lastSeen : twoDaysAgo, + loginStreak: 5, + save : jest.fn().mockResolvedValue(true) + } as unknown as UserDocument; - const userData = { - ...user, - lastSeen: yesterday, - loginCount: 5, - save: jest.fn().mockResolvedValue(true), - } as unknown as UserDocument; + jest.spyOn(service, 'findByID').mockResolvedValue(userData); - jest.spyOn(service, 'findByID').mockResolvedValue(userData); + const result = await service.getSelfUserData(user); - const result = await service.getSelfUserData(user); + expect(result.lastSeen).toBeInstanceOf(Date); + expect(result.loginStreak).toBe(1); + expect(userData.save).toHaveBeenCalled(); + }); - expect(result.lastSeen).toBeInstanceOf(Date); - expect(result.loginCount).toBe(6); - expect(userData.save).toHaveBeenCalled(); - }); + it('should increment loginCount if lastSeen is not today', async () => { + const user = { _id: 'test-id' } as UserDocument; + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); - it('should not increment loginCount if lastSeen is today', async () => { - const user = { _id: 'test-id' } as UserDocument; + const userData = { + ...user, + lastSeen : yesterday, + loginCount: 5, + save : jest.fn().mockResolvedValue(true) + } as unknown as UserDocument; - const today = new Date(); - today.setHours(0, 0, 0, 0); + jest.spyOn(service, 'findByID').mockResolvedValue(userData); - const userData = { - ...user, - lastSeen: today, - loginCount: 5, - save: jest.fn().mockResolvedValue(true), - } as unknown as UserDocument; + const result = await service.getSelfUserData(user); - jest.spyOn(service, 'findByID').mockResolvedValue(userData); + expect(result.lastSeen).toBeInstanceOf(Date); + expect(result.loginCount).toBe(6); + expect(userData.save).toHaveBeenCalled(); + }); + + it('should not increment loginCount if lastSeen is today', async () => { + const user = { _id: 'test-id' } as UserDocument; - const result = await service.getSelfUserData(user); + const today = new Date(); + today.setHours(0, 0, 0, 0); - expect(result.lastSeen).toEqual(today); - expect(result.loginCount).toBe(5); - expect(userData.save).not.toHaveBeenCalled(); - }); + const userData = { + ...user, + lastSeen : today, + loginCount: 5, + save : jest.fn().mockResolvedValue(true) + } as unknown as UserDocument; - it('should increment maxLoginStreak if login streak exceeds max', async () => { - const user = { _id: 'test-id' } as UserDocument; + jest.spyOn(service, 'findByID').mockResolvedValue(userData); + + const result = await service.getSelfUserData(user); - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - yesterday.setHours(0, 0, 0, 0); + expect(result.lastSeen).toEqual(today); + expect(result.loginCount).toBe(5); + expect(userData.save).not.toHaveBeenCalled(); + }); - const userData = { - ...user, - lastSeen: yesterday, - loginStreak: 8, - maxLoginStreak: 8, - save: jest.fn().mockResolvedValue(true), - } as unknown as UserDocument; + it('should increment maxLoginStreak if login streak exceeds max', async () => { + const user = { _id: 'test-id' } as UserDocument; - jest.spyOn(service, 'findByID').mockResolvedValue(userData); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); - const result = await service.getSelfUserData(user); + const userData = { + ...user, + lastSeen : yesterday, + loginStreak : 8, + maxLoginStreak: 8, + save : jest.fn().mockResolvedValue(true) + } as unknown as UserDocument; - expect(result.maxLoginStreak).toBe(9); - expect(userData.save).toHaveBeenCalled(); - }); + jest.spyOn(service, 'findByID').mockResolvedValue(userData); + + const result = await service.getSelfUserData(user); - it('should not increment maxLoginStreak if login streak is less than the max', async () => { - const user = { _id: 'test-id' } as UserDocument; + expect(result.maxLoginStreak).toBe(9); + expect(userData.save).toHaveBeenCalled(); + }); - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - yesterday.setHours(0, 0, 0, 0); + it('should not increment maxLoginStreak if login streak is less than the max', async () => { + const user = { _id: 'test-id' } as UserDocument; - const userData = { - ...user, - lastSeen: yesterday, - loginStreak: 4, - maxLoginStreak: 8, - save: jest.fn().mockResolvedValue(true), - } as unknown as UserDocument; + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + + const userData = { + ...user, + lastSeen : yesterday, + loginStreak : 4, + maxLoginStreak: 8, + save : jest.fn().mockResolvedValue(true) + } as unknown as UserDocument; - jest.spyOn(service, 'findByID').mockResolvedValue(userData); + jest.spyOn(service, 'findByID').mockResolvedValue(userData); - const result = await service.getSelfUserData(user); + const result = await service.getSelfUserData(user); - expect(result.maxLoginStreak).toBe(8); - expect(userData.save).toHaveBeenCalled(); + expect(result.maxLoginStreak).toBe(8); + expect(userData.save).toHaveBeenCalled(); + }); }); - }); - describe('usernameExists', () => { - it('should return true if username exists', async () => { - const username = 'testuser'; - const user = { username } as UserDocument; + 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); + jest.spyOn(userModel, 'findOne').mockReturnValue({ + select: jest.fn().mockReturnThis(), + exec : jest.fn().mockResolvedValue(user) + } as any); - const result = await service.usernameExists(username); + const result = await service.usernameExists(username); - expect(result).toBe(true); - expect(userModel.findOne).toHaveBeenCalledWith({ username }); + expect(result).toBe(true); + expect(userModel.findOne).toHaveBeenCalledWith({ username }); - expect(userModel.findOne({ username }).select).toHaveBeenCalledWith( - 'username', - ); - }); + expect(userModel.findOne({ username }).select).toHaveBeenCalledWith( + 'username' + ); + }); - it('should return false if username does not exist', async () => { - const username = 'testuser'; + 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); + jest.spyOn(userModel, 'findOne').mockReturnValue({ + select: jest.fn().mockReturnThis(), + exec : jest.fn().mockResolvedValue(null) + } as any); - const result = await service.usernameExists(username); + const result = await service.usernameExists(username); - expect(result).toBe(false); - expect(userModel.findOne).toHaveBeenCalledWith({ username }); + expect(result).toBe(false); + expect(userModel.findOne).toHaveBeenCalledWith({ username }); - expect(userModel.findOne({ username }).select).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'; + describe('generateUsername', () => { + it('should generate a unique username', async () => { + const inputUsername = 'test user'; + const baseUsername = 'test_user'; - jest.spyOn(service, 'usernameExists').mockResolvedValueOnce(false); + jest.spyOn(service, 'usernameExists').mockResolvedValueOnce(false); - const result = await service.generateUsername(inputUsername); + const result = await service.generateUsername(inputUsername); - expect(result).toBe(baseUsername); - expect(service.usernameExists).toHaveBeenCalledWith(baseUsername); - }); + 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'; + 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); + jest + .spyOn(service, 'usernameExists') + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); - const result = await service.generateUsername(inputUsername); + const result = await service.generateUsername(inputUsername); - expect(result).toMatch('test_user_2'); - expect(service.usernameExists).toHaveBeenCalledWith(baseUsername); + expect(result).toMatch('test_user_2'); + expect(service.usernameExists).toHaveBeenCalledWith(baseUsername); + }); }); - }); - describe('normalizeUsername', () => { - it('should normalize a username', () => { - const inputUsername = 'tést user'; - const normalizedUsername = 'test_user'; + describe('normalizeUsername', () => { + it('should normalize a username', () => { + const inputUsername = 'tést user'; + const normalizedUsername = 'test_user'; - const result = (service as any).normalizeUsername(inputUsername); + const result = (service as any).normalizeUsername(inputUsername); - expect(result).toBe(normalizedUsername); - }); + expect(result).toBe(normalizedUsername); + }); - it('should remove special characters from a username', () => { - const inputUsername = '静_かな'; - const normalizedUsername = '_'; + it('should remove special characters from a username', () => { + const inputUsername = '静_かな'; + const normalizedUsername = '_'; - const result = (service as any).normalizeUsername(inputUsername); + const result = (service as any).normalizeUsername(inputUsername); - expect(result).toBe(normalizedUsername); - }); + expect(result).toBe(normalizedUsername); + }); - it('should replace spaces with underscores in a username', () => { - const inputUsername = 'Имя пользователя'; - const normalizedUsername = '_'; + it('should replace spaces with underscores in a username', () => { + const inputUsername = 'Имя пользователя'; + const normalizedUsername = '_'; - const result = (service as any).normalizeUsername(inputUsername); + const result = (service as any).normalizeUsername(inputUsername); - expect(result).toBe(normalizedUsername); - }); + expect(result).toBe(normalizedUsername); + }); - it('should replace spaces with underscores in a username', () => { - const inputUsername = 'Eglė Čepulytė'; - const normalizedUsername = 'Egle_Cepulyte'; + it('should replace spaces with underscores in a username', () => { + const inputUsername = 'Eglė Čepulytė'; + const normalizedUsername = 'Egle_Cepulyte'; - const result = (service as any).normalizeUsername(inputUsername); + const result = (service as any).normalizeUsername(inputUsername); - expect(result).toBe(normalizedUsername); + expect(result).toBe(normalizedUsername); + }); }); - }); - describe('updateUsername', () => { - it('should update a user username', async () => { - const user = { - username: 'testuser', - save: jest.fn().mockReturnThis(), - } as unknown as UserDocument; + describe('updateUsername', () => { + it('should update a user username', async () => { + const user = { + username: 'testuser', + save : jest.fn().mockReturnThis() + } as unknown as UserDocument; - const body = { username: 'newuser' }; + const body = { username: 'newuser' }; - jest.spyOn(service, 'usernameExists').mockResolvedValue(false); + jest.spyOn(service, 'usernameExists').mockResolvedValue(false); - const result = await service.updateUsername(user, body); + const result = await service.updateUsername(user, body); - expect(result).toEqual({ - username: 'newuser', - publicName: undefined, - email: undefined, - }); + expect(result).toEqual({ + username : 'newuser', + publicName: undefined, + email : undefined + }); - expect(user.username).toBe(body.username); - expect(service.usernameExists).toHaveBeenCalledWith(body.username); - }); + expect(user.username).toBe(body.username); + expect(service.usernameExists).toHaveBeenCalledWith(body.username); + }); - it('should throw an error if username already exists', async () => { - const user = { - username: 'testuser', - save: jest.fn().mockReturnThis(), - } as unknown as UserDocument; + it('should throw an error if username already exists', async () => { + const user = { + username: 'testuser', + save : jest.fn().mockReturnThis() + } as unknown as UserDocument; - const body = { username: 'newuser' }; + const body = { username: 'newuser' }; - jest.spyOn(service, 'usernameExists').mockResolvedValue(true); + jest.spyOn(service, 'usernameExists').mockResolvedValue(true); - await expect(service.updateUsername(user, body)).rejects.toThrow( - new HttpException('Username already exists', HttpStatus.BAD_REQUEST), - ); + await expect(service.updateUsername(user, body)).rejects.toThrow( + new HttpException('Username already exists', HttpStatus.BAD_REQUEST) + ); + }); }); - }); }); diff --git a/apps/backend/src/user/user.service.ts b/apps/backend/src/user/user.service.ts index 051da77e..3106ae9b 100644 --- a/apps/backend/src/user/user.service.ts +++ b/apps/backend/src/user/user.service.ts @@ -1,11 +1,11 @@ import { - CreateUser, - GetUser, - PageQueryDTO, - UpdateUsernameDto, - User, - UserDocument, - UserDto, + CreateUser, + GetUser, + PageQueryDTO, + UpdateUsernameDto, + User, + UserDocument, + UserDto } from '@nbw/database'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; @@ -14,222 +14,222 @@ import { Model } from 'mongoose'; @Injectable() export class UserService { - constructor(@InjectModel(User.name) private userModel: Model) {} - - public async create(user_registered: CreateUser) { - await validate(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; - - return await user.save(); - } - - public async update(user: UserDocument): Promise { - try { - return (await this.userModel.findByIdAndUpdate(user._id, user, { - new: true, // return the updated document - })) as UserDocument; - } catch (error) { - if (error instanceof Error) { - throw error; - } - - throw new Error(String(error)); - } - } + constructor(@InjectModel(User.name) private userModel: Model) {} - public async createWithEmail(email: string): Promise { - // verify if user exists same email, username or publicName - const userByEmail = await this.findByEmail(email); + public async create(user_registered: CreateUser) { + await validate(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; - if (userByEmail) { - throw new HttpException( - 'Email already registered', - HttpStatus.BAD_REQUEST, - ); + return await user.save(); } - const emailPrefixUsername = await this.generateUsername( - email.split('@')[0], - ); + public async update(user: UserDocument): Promise { + try { + return (await this.userModel.findByIdAndUpdate(user._id, user, { + new: true // return the updated document + })); + } catch (error) { + if (error instanceof Error) { + throw error; + } + + throw new Error(String(error)); + } + } - const user = await this.userModel.create({ - email: email, - username: emailPrefixUsername, - publicName: emailPrefixUsername, - }); + public async createWithEmail(email: string): Promise { + // verify if user exists same email, username or publicName + const userByEmail = await this.findByEmail(email); + + if (userByEmail) { + throw new HttpException( + 'Email already registered', + HttpStatus.BAD_REQUEST + ); + } + + const emailPrefixUsername = await this.generateUsername( + email.split('@')[0] + ); + + const user = await this.userModel.create({ + email : email, + username : emailPrefixUsername, + publicName: emailPrefixUsername + }); + + return user; + } - return user; - } + public async findByEmail(email: string): Promise { + const user = await this.userModel.findOne({ email }).exec(); - public async findByEmail(email: string): Promise { - const user = await this.userModel.findOne({ email }).exec(); + return user; + } - return user; - } + public async findByID(objectID: string): Promise { + const user = await this.userModel.findById(objectID).exec(); - public async findByID(objectID: string): Promise { - const user = await this.userModel.findById(objectID).exec(); + return user; + } - return user; - } + public async findByPublicName( + publicName: string + ): Promise { + const user = await this.userModel.findOne({ publicName }); - public async findByPublicName( - publicName: string, - ): Promise { - const user = await this.userModel.findOne({ publicName }); + return user; + } - return user; - } + public async findByUsername(username: string): Promise { + const user = await this.userModel.findOne({ username }); - public async findByUsername(username: string): Promise { - const user = await this.userModel.findOne({ username }); + return user; + } - return user; - } + public async getUserPaginated(query: PageQueryDTO) { + const { page = 1, limit = 10, sort = 'createdAt', order = 'asc' } = query; - public async getUserPaginated(query: PageQueryDTO) { - const { page = 1, limit = 10, sort = 'createdAt', order = 'asc' } = query; + const skip = (page - 1) * limit; + const sortOrder = order === 'asc' ? 1 : -1; - const skip = (page - 1) * limit; - const sortOrder = order === 'asc' ? 1 : -1; + const users = await this.userModel + .find({}) + .sort({ [sort]: sortOrder }) + .skip(skip) + .limit(limit); - const users = await this.userModel - .find({}) - .sort({ [sort]: sortOrder }) - .skip(skip) - .limit(limit); + const total = await this.userModel.countDocuments(); - const total = await this.userModel.countDocuments(); + return { + users, + total, + page, + limit + }; + } - return { - users, - total, - page, - limit, - }; - } + public async getUserByEmailOrId(query: GetUser) { + const { email, id, username } = query; - public async getUserByEmailOrId(query: GetUser) { - const { email, id, username } = query; + if (email) { + return await this.findByEmail(email); + } - if (email) { - return await this.findByEmail(email); - } + if (id) { + return await this.findByID(id); + } - if (id) { - return await this.findByID(id); - } + if (username) { + throw new HttpException( + 'Username is not supported yet', + HttpStatus.BAD_REQUEST + ); + } - if (username) { - throw new HttpException( - 'Username is not supported yet', - HttpStatus.BAD_REQUEST, - ); + throw new HttpException( + 'You must provide an email or an id', + HttpStatus.BAD_REQUEST + ); } - throw new HttpException( - 'You must provide an email or an id', - HttpStatus.BAD_REQUEST, - ); - } - - public async getHydratedUser(user: UserDocument) { - const hydratedUser = await this.userModel - .findById(user._id) - .populate('songs') - .exec(); + public async getHydratedUser(user: UserDocument) { + const hydratedUser = await this.userModel + .findById(user._id) + .populate('songs') + .exec(); - return hydratedUser; - } + return hydratedUser; + } - public async getSelfUserData(user: UserDocument) { - const userData = await this.findByID(user._id.toString()); - if (!userData) - throw new HttpException('user not found', HttpStatus.NOT_FOUND); + public async getSelfUserData(user: UserDocument) { + const userData = await this.findByID(user._id.toString()); + if (!userData) + throw new HttpException('user not found', HttpStatus.NOT_FOUND); - const today = new Date(); - today.setHours(0, 0, 0, 0); // Set the time to the start of the day + const today = new Date(); + today.setHours(0, 0, 0, 0); // Set the time to the start of the day - const lastSeenDate = new Date(userData.lastSeen); - lastSeenDate.setHours(0, 0, 0, 0); // Set the time to the start of the day + const lastSeenDate = new Date(userData.lastSeen); + lastSeenDate.setHours(0, 0, 0, 0); // Set the time to the start of the day - if (lastSeenDate < today) { - userData.lastSeen = new Date(); + if (lastSeenDate < today) { + userData.lastSeen = new Date(); - // if the last seen date is not yesterday, reset the login streak - const yesterday = new Date(today); - yesterday.setDate(today.getDate() - 1); + // if the last seen date is not yesterday, reset the login streak + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); - if (lastSeenDate < yesterday) userData.loginStreak = 1; - else { - userData.loginStreak += 1; - if (userData.loginStreak > userData.maxLoginStreak) - userData.maxLoginStreak = userData.loginStreak; - } + if (lastSeenDate < yesterday) userData.loginStreak = 1; + else { + userData.loginStreak += 1; + if (userData.loginStreak > userData.maxLoginStreak) + userData.maxLoginStreak = userData.loginStreak; + } - userData.loginCount++; + userData.loginCount++; - userData.save(); // no need to await this, we already have the data to send back - } // if equal or greater, do nothing about the login streak + userData.save(); // no need to await this, we already have the data to send back + } // if equal or greater, do nothing about the login streak - return userData; - } + return userData; + } - public async usernameExists(username: string) { - const user = await this.userModel - .findOne({ username }) - .select('username') - .exec(); + public async usernameExists(username: string) { + const user = await this.userModel + .findOne({ username }) + .select('username') + .exec(); - return !!user; - } + return !!user; + } - private normalizeUsername = (inputUsername: string) => - inputUsername - .replace(' ', '_') - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-zA-Z0-9_]/g, ''); + private normalizeUsername = (inputUsername: string) => + inputUsername + .replace(' ', '_') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9_]/g, ''); - public async generateUsername(inputUsername: string) { + public async generateUsername(inputUsername: string) { // Normalize username (remove accents, replace spaces with underscores) - const baseUsername = this.normalizeUsername(inputUsername); + const baseUsername = this.normalizeUsername(inputUsername); - let newUsername = baseUsername; - let counter = 1; + let newUsername = baseUsername; + let counter = 1; - // Check if the base username already exists - while (await this.usernameExists(newUsername)) { - newUsername = `${baseUsername}_${counter}`; - counter++; + // Check if the base username already exists + while (await this.usernameExists(newUsername)) { + newUsername = `${baseUsername}_${counter}`; + counter++; + } + + return newUsername; } - return newUsername; - } + public async updateUsername(user: UserDocument, body: UpdateUsernameDto) { + let { username } = body; + username = this.normalizeUsername(username); - public async updateUsername(user: UserDocument, body: UpdateUsernameDto) { - let { username } = body; - username = this.normalizeUsername(username); + if (await this.usernameExists(username)) { + throw new HttpException( + 'Username already exists', + HttpStatus.BAD_REQUEST + ); + } - if (await this.usernameExists(username)) { - throw new HttpException( - 'Username already exists', - HttpStatus.BAD_REQUEST, - ); - } + if (user.username === username) { + throw new HttpException('Username is the same', HttpStatus.BAD_REQUEST); + } - if (user.username === username) { - throw new HttpException('Username is the same', HttpStatus.BAD_REQUEST); - } + user.username = username; + user.lastEdited = new Date(); - user.username = username; - user.lastEdited = new Date(); + await user.save(); - await user.save(); - - return UserDto.fromEntity(user); - } + return UserDto.fromEntity(user); + } } diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index bc4e305d..9e98024b 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -24,13 +24,13 @@ } }, "include": [ - "src/**/*.ts" + "src/**/*.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts" ], "exclude": [ "node_modules", "dist", - "**/*.spec.ts", - "**/*.test.ts", "e2e/**/*", "test/**/*" ] diff --git a/apps/frontend/mdx-components.tsx b/apps/frontend/mdx-components.tsx index 92af1be8..0f99736c 100644 --- a/apps/frontend/mdx-components.tsx +++ b/apps/frontend/mdx-components.tsx @@ -15,7 +15,7 @@ import { ol, p, pre, - ul, + ul } from '@web/modules/shared/components/CustomMarkdown'; export function useMDXComponents(components: MDXComponents): MDXComponents { @@ -35,6 +35,6 @@ export function useMDXComponents(components: MDXComponents): MDXComponents { pre, code, a, - ...components, + ...components }; } diff --git a/apps/frontend/src/app/(content)/(info)/about/page.tsx b/apps/frontend/src/app/(content)/(info)/about/page.tsx index 37916161..bddc8ab8 100644 --- a/apps/frontend/src/app/(content)/(info)/about/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/about/page.tsx @@ -6,7 +6,7 @@ import { NoteBlockWorldLogo } from '@web/modules/shared/components/NoteBlockWorl import About from './about.mdx'; export const metadata: Metadata = { - title: 'About', + title: 'About' }; const AboutPage = () => { diff --git a/apps/frontend/src/app/(content)/(info)/blog/[id]/page.tsx b/apps/frontend/src/app/(content)/(info)/blog/[id]/page.tsx index c0d63d05..736b1e23 100644 --- a/apps/frontend/src/app/(content)/(info)/blog/[id]/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/blog/[id]/page.tsx @@ -17,18 +17,18 @@ export function generateMetadata({ params }: BlogPageProps): Metadata { const publicUrl = process.env.NEXT_PUBLIC_URL; return { - title: post.title, - authors: [{ name: post.author }], + title : post.title, + authors : [{ name: post.author }], openGraph: { - url: publicUrl + '/blog/' + id, - title: post.title, + url : publicUrl + '/blog/' + id, + title : post.title, siteName: 'Note Block World', - images: [ + images : [ { - url: publicUrl + post.image, - }, - ], - }, + url: publicUrl + post.image + } + ] + } }; } @@ -80,11 +80,11 @@ const BlogPost = ({ params }: BlogPageProps) => {

{/* Add 12 hours to the date to display at noon UTC */} {new Date( - new Date(post.date).getTime() + 12 * 60 * 60 * 1000, + new Date(post.date).getTime() + 12 * 60 * 60 * 1000 ).toLocaleDateString('en-UK', { - day: 'numeric', + day : 'numeric', month: 'short', - year: 'numeric', + year : 'numeric' })}

diff --git a/apps/frontend/src/app/(content)/(info)/blog/page.tsx b/apps/frontend/src/app/(content)/(info)/blog/page.tsx index bcadb92c..bbe24879 100644 --- a/apps/frontend/src/app/(content)/(info)/blog/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/blog/page.tsx @@ -8,7 +8,7 @@ import { getSortedPostsData } from '@web/lib/posts'; import type { PostType } from '@web/lib/posts'; export const metadata: Metadata = { - title: 'Blog', + title: 'Blog' }; async function BlogPage() { diff --git a/apps/frontend/src/app/(content)/(info)/contact/page.tsx b/apps/frontend/src/app/(content)/(info)/contact/page.tsx index ebd20a1a..7cac16df 100644 --- a/apps/frontend/src/app/(content)/(info)/contact/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/contact/page.tsx @@ -5,7 +5,7 @@ import BackButton from '@web/modules/shared/components/client/BackButton'; import Contact from './contact.mdx'; export const metadata: Metadata = { - title: 'Contact', + title: 'Contact' }; const AboutPage = () => { diff --git a/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx b/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx index 2507590c..f975a79a 100644 --- a/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx @@ -17,18 +17,18 @@ export function generateMetadata({ params }: HelpPageProps): Metadata { const publicUrl = process.env.NEXT_PUBLIC_URL; return { - title: post.title, - authors: [{ name: post.author }], + title : post.title, + authors : [{ name: post.author }], openGraph: { - url: publicUrl + '/help/' + id, - title: post.title, + url : publicUrl + '/help/' + id, + title : post.title, siteName: 'Note Block World', - images: [ + images : [ { - url: publicUrl + post.image, - }, - ], - }, + url: publicUrl + post.image + } + ] + } }; } diff --git a/apps/frontend/src/app/(content)/(info)/help/page.tsx b/apps/frontend/src/app/(content)/(info)/help/page.tsx index f0984c24..72624227 100644 --- a/apps/frontend/src/app/(content)/(info)/help/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/help/page.tsx @@ -8,7 +8,7 @@ import { getSortedPostsData } from '@web/lib/posts'; import type { PostType } from '@web/lib/posts'; export const metadata: Metadata = { - title: 'Help Center', + title: 'Help Center' }; async function HelpPage() { diff --git a/apps/frontend/src/app/(content)/layout.tsx b/apps/frontend/src/app/(content)/layout.tsx index 223b1086..5015d184 100644 --- a/apps/frontend/src/app/(content)/layout.tsx +++ b/apps/frontend/src/app/(content)/layout.tsx @@ -2,7 +2,7 @@ import '@web/app/globals.css'; import NavbarLayout from '@web/modules/shared/components/layout/NavbarLayout'; export default async function ContentLayout({ - children, + children }: { children: React.ReactNode; }) { diff --git a/apps/frontend/src/app/(content)/my-songs/page.tsx b/apps/frontend/src/app/(content)/my-songs/page.tsx index 110df923..68f94fa9 100644 --- a/apps/frontend/src/app/(content)/my-songs/page.tsx +++ b/apps/frontend/src/app/(content)/my-songs/page.tsx @@ -5,7 +5,7 @@ import { checkLogin } from '@web/modules/auth/features/auth.utils'; import Page from '@web/modules/my-songs/components/MySongsPage'; export const metadata: Metadata = { - title: 'My songs', + title: 'My songs' }; const MySongsPage = async () => { diff --git a/apps/frontend/src/app/(content)/page.tsx b/apps/frontend/src/app/(content)/page.tsx index ac9ee47c..53850a40 100644 --- a/apps/frontend/src/app/(content)/page.tsx +++ b/apps/frontend/src/app/(content)/page.tsx @@ -11,12 +11,12 @@ async function fetchRecentSongs() { '/song-browser/recent', { params: { - page: 1, // TODO: fiz constants + page : 1, // TODO: fiz constants limit: 16, // TODO: change 'limit' parameter to 'skip' and load 12 songs initially, then load 8 more songs on each pagination - sort: 'recent', - order: false, - }, - }, + sort : 'recent', + order: false + } + } ); return response.data; @@ -28,24 +28,24 @@ async function fetchRecentSongs() { async function fetchFeaturedSongs(): Promise { try { const response = await axiosInstance.get( - '/song-browser/featured', + '/song-browser/featured' ); return response.data; } catch (error) { return { - hour: [], - day: [], - week: [], + hour : [], + day : [], + week : [], month: [], - year: [], - all: [], + year : [], + all : [] }; } } export const metadata: Metadata = { - title: 'Songs', + title: 'Songs' }; async function Home() { diff --git a/apps/frontend/src/app/(content)/song/[id]/page.tsx b/apps/frontend/src/app/(content)/song/[id]/page.tsx index 198a9c96..85c1cb1b 100644 --- a/apps/frontend/src/app/(content)/song/[id]/page.tsx +++ b/apps/frontend/src/app/(content)/song/[id]/page.tsx @@ -12,7 +12,7 @@ interface SongPage { } export async function generateMetadata({ - params, + params }: SongPage): Promise { let song; const publicUrl = process.env.NEXT_PUBLIC_URL; @@ -28,30 +28,30 @@ export async function generateMetadata({ try { const response = await axios.get(`/song/${params.id}`, { - headers, + headers }); song = await response.data; } catch { return { - title: 'Song not found', + title: 'Song not found' }; } return { - title: song.title, + title : song.title, description: song.description, - authors: [{ name: song.uploader.username }], - openGraph: { - url: publicUrl + '/song/' + song.publicId, - title: song.title, + authors : [{ name: song.uploader.username }], + openGraph : { + url : publicUrl + '/song/' + song.publicId, + title : song.title, description: song.description, - siteName: 'Note Block World', - images: [song.thumbnailUrl], + siteName : 'Note Block World', + images : [song.thumbnailUrl] }, twitter: { - card: 'summary_large_image', - }, + card: 'summary_large_image' + } }; } diff --git a/apps/frontend/src/app/(content)/upload/layout.tsx b/apps/frontend/src/app/(content)/upload/layout.tsx index 39086489..6d1261c9 100644 --- a/apps/frontend/src/app/(content)/upload/layout.tsx +++ b/apps/frontend/src/app/(content)/upload/layout.tsx @@ -1,7 +1,7 @@ import '@web/app/enableRecaptchaBadge.css'; export default async function UploadLayout({ - children, + children }: { children: React.ReactNode; }) { diff --git a/apps/frontend/src/app/(content)/upload/page.tsx b/apps/frontend/src/app/(content)/upload/page.tsx index bccf0c9d..997918e2 100644 --- a/apps/frontend/src/app/(content)/upload/page.tsx +++ b/apps/frontend/src/app/(content)/upload/page.tsx @@ -5,7 +5,7 @@ import { checkLogin, getUserData } from '@web/modules/auth/features/auth.utils'; import { UploadSongPage } from '@web/modules/song-upload/components/client/UploadSongPage'; export const metadata: Metadata = { - title: 'Upload song', + title: 'Upload song' }; async function UploadPage() { diff --git a/apps/frontend/src/app/(external)/(auth)/login/email/page.tsx b/apps/frontend/src/app/(external)/(auth)/login/email/page.tsx index be8b80b9..e1d174b1 100644 --- a/apps/frontend/src/app/(external)/(auth)/login/email/page.tsx +++ b/apps/frontend/src/app/(external)/(auth)/login/email/page.tsx @@ -5,7 +5,7 @@ import { LoginWithEmailPage } from '@web/modules/auth/components/loginWithEmailP import { checkLogin } from '@web/modules/auth/features/auth.utils'; export const metadata: Metadata = { - title: 'Sign in', + title: 'Sign in' }; const Login = async () => { diff --git a/apps/frontend/src/app/(external)/(auth)/login/page.tsx b/apps/frontend/src/app/(external)/(auth)/login/page.tsx index b8c1de65..0859da86 100644 --- a/apps/frontend/src/app/(external)/(auth)/login/page.tsx +++ b/apps/frontend/src/app/(external)/(auth)/login/page.tsx @@ -5,7 +5,7 @@ import { LoginPage } from '@web/modules/auth/components/loginPage'; import { checkLogin } from '@web/modules/auth/features/auth.utils'; export const metadata: Metadata = { - title: 'Sign in', + title: 'Sign in' }; const Login = async () => { diff --git a/apps/frontend/src/app/(external)/(legal)/guidelines/page.tsx b/apps/frontend/src/app/(external)/(legal)/guidelines/page.tsx index 5ba331fc..931afc43 100644 --- a/apps/frontend/src/app/(external)/(legal)/guidelines/page.tsx +++ b/apps/frontend/src/app/(external)/(legal)/guidelines/page.tsx @@ -6,7 +6,7 @@ import { Metadata } from 'next'; import { CustomMarkdown } from '@web/modules/shared/components/CustomMarkdown'; export const metadata: Metadata = { - title: 'Community Guidelines', + title: 'Community Guidelines' }; async function TermsOfServicePage() { diff --git a/apps/frontend/src/app/(external)/(legal)/privacy/page.tsx b/apps/frontend/src/app/(external)/(legal)/privacy/page.tsx index 4ef11c59..26321593 100644 --- a/apps/frontend/src/app/(external)/(legal)/privacy/page.tsx +++ b/apps/frontend/src/app/(external)/(legal)/privacy/page.tsx @@ -6,7 +6,7 @@ import { Metadata } from 'next'; import { CustomMarkdown } from '@web/modules/shared/components/CustomMarkdown'; export const metadata: Metadata = { - title: 'Privacy Policy', + title: 'Privacy Policy' }; async function PrivacyPolicyPage() { diff --git a/apps/frontend/src/app/(external)/(legal)/terms/page.tsx b/apps/frontend/src/app/(external)/(legal)/terms/page.tsx index 40290108..4bd4812b 100644 --- a/apps/frontend/src/app/(external)/(legal)/terms/page.tsx +++ b/apps/frontend/src/app/(external)/(legal)/terms/page.tsx @@ -6,7 +6,7 @@ import { Metadata } from 'next'; import { CustomMarkdown } from '@web/modules/shared/components/CustomMarkdown'; export const metadata: Metadata = { - title: 'Terms of Service', + title: 'Terms of Service' }; async function TermsOfServicePage() { diff --git a/apps/frontend/src/app/(external)/[...not-found]/page.tsx b/apps/frontend/src/app/(external)/[...not-found]/page.tsx index ac4b13ea..8d68f3a2 100644 --- a/apps/frontend/src/app/(external)/[...not-found]/page.tsx +++ b/apps/frontend/src/app/(external)/[...not-found]/page.tsx @@ -5,7 +5,7 @@ import { notFound } from 'next/navigation'; // See: https://github.com/vercel/next.js/discussions/50034 export const metadata: Metadata = { - title: 'Page not found', + title: 'Page not found' }; export default function NotFound() { diff --git a/apps/frontend/src/app/(external)/layout.tsx b/apps/frontend/src/app/(external)/layout.tsx index aa9d1eb1..2cc6f442 100644 --- a/apps/frontend/src/app/(external)/layout.tsx +++ b/apps/frontend/src/app/(external)/layout.tsx @@ -3,7 +3,7 @@ import '@web/app/enableRecaptchaBadge.css'; import '@web/app/hideScrollbar.css'; export default async function LoginLayout({ - children, + children }: { children: React.ReactNode; }) { @@ -12,7 +12,7 @@ export default async function LoginLayout({
{children} diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 76ddeaff..742532c6 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -15,7 +15,7 @@ import { TooltipProvider } from '../modules/shared/components/tooltip'; const lato = Lato({ subsets: ['latin'], - weight: ['100', '300', '400', '700', '900'], + weight : ['100', '300', '400', '700', '900'] }); export const metadata: Metadata = { @@ -23,27 +23,27 @@ export const metadata: Metadata = { description: 'Discover, share and listen to note block music from all around the world', applicationName: 'Note Block World', - keywords: ['note block', 'music', 'minecraft', 'nbs', 'note block studio'], - openGraph: { - type: 'website', - images: `${process.env.NEXT_PUBLIC_URL}/nbw-header.png`, - locale: 'en_US', - url: process.env.NEXT_PUBLIC_URL, - siteName: 'Note Block World', - }, + keywords : ['note block', 'music', 'minecraft', 'nbs', 'note block studio'], + openGraph : { + type : 'website', + images : `${process.env.NEXT_PUBLIC_URL}/nbw-header.png`, + locale : 'en_US', + url : process.env.NEXT_PUBLIC_URL, + siteName: 'Note Block World' + } }; const jsonLd: WithContext = { '@context': 'https://schema.org', - '@type': 'WebSite', - name: 'Note Block World', - url: process.env.NEXT_PUBLIC_URL, + '@type' : 'WebSite', + name : 'Note Block World', + url : process.env.NEXT_PUBLIC_URL, description: - 'Discover, share and listen to note block music from all around the world', + 'Discover, share and listen to note block music from all around the world' }; export default function RootLayout({ - children, + children }: { children: React.ReactNode; }) { @@ -100,7 +100,7 @@ export default function RootLayout({ position='bottom-center' toastOptions={{ className: '!bg-zinc-700 !text-white !max-w-fit', - duration: 4000, + duration : 4000 }} /> {process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_GA_ID && ( - - )} + + )} ); diff --git a/apps/frontend/src/app/not-found.tsx b/apps/frontend/src/app/not-found.tsx index 618e98f6..6c79dd81 100644 --- a/apps/frontend/src/app/not-found.tsx +++ b/apps/frontend/src/app/not-found.tsx @@ -33,7 +33,7 @@ const lines = [ "Note blocks don't make good smelting fuel.", 'You played a birthday song but it was last month.', 'That anvil made you A♭ blob.', - 'You got hit with A♯ sword.', + 'You got hit with A♯ sword.' ]; export default function NotFound() { @@ -42,7 +42,7 @@ export default function NotFound() {
diff --git a/apps/frontend/src/global.d.ts b/apps/frontend/src/global.d.ts index a5f1e30d..d72c8f2f 100644 --- a/apps/frontend/src/global.d.ts +++ b/apps/frontend/src/global.d.ts @@ -4,9 +4,9 @@ import type { SoundListType } from '@nbw/database'; interface Window { - latestVersionSoundList: SoundListType; + latestVersionSoundList: SoundListType; } declare global { - var latestVersionSoundList: SoundListType; + var latestVersionSoundList: SoundListType; } diff --git a/apps/frontend/src/lib/axios/ClientAxios.ts b/apps/frontend/src/lib/axios/ClientAxios.ts index bdb14b2c..a349f439 100644 --- a/apps/frontend/src/lib/axios/ClientAxios.ts +++ b/apps/frontend/src/lib/axios/ClientAxios.ts @@ -5,26 +5,26 @@ import { getTokenLocal } from './token.utils'; export const baseApiURL = process.env.NEXT_PUBLIC_API_URL; const ClientAxios = axios.create({ - baseURL: baseApiURL, - withCredentials: true, + baseURL : baseApiURL, + withCredentials: true }); // Add a request interceptor to add the token to the request ClientAxios.interceptors.request.use( - (config) => { - try { - const token = getTokenLocal(); + (config) => { + try { + const token = getTokenLocal(); - config.headers.authorization = `Bearer ${token}`; + config.headers.authorization = `Bearer ${token}`; - return config; - } catch { - return config; + return config; + } catch { + return config; + } + }, + (error) => { + return Promise.reject(error); } - }, - (error) => { - return Promise.reject(error); - }, ); export default ClientAxios; diff --git a/apps/frontend/src/lib/axios/index.ts b/apps/frontend/src/lib/axios/index.ts index 6b5ec67a..4881bc11 100644 --- a/apps/frontend/src/lib/axios/index.ts +++ b/apps/frontend/src/lib/axios/index.ts @@ -3,8 +3,8 @@ import axios from 'axios'; export const baseApiURL = process.env.NEXT_PUBLIC_API_URL; const axiosInstance = axios.create({ - baseURL: baseApiURL, - withCredentials: true, + baseURL : baseApiURL, + withCredentials: true }); export default axiosInstance; diff --git a/apps/frontend/src/lib/axios/token.utils.ts b/apps/frontend/src/lib/axios/token.utils.ts index e3ed7f97..4bd0763c 100644 --- a/apps/frontend/src/lib/axios/token.utils.ts +++ b/apps/frontend/src/lib/axios/token.utils.ts @@ -1,23 +1,23 @@ export const getTokenLocal = (): string | never => { - // get the token cookie - const cookie = document.cookie; + // get the token cookie + const cookie = document.cookie; - const token = cookie - .split('; ') - .find((row) => row.startsWith('token')) - ?.split('=')[1]; + const token = cookie + .split('; ') + .find((row) => row.startsWith('token')) + ?.split('=')[1]; - // TODO: should be changed to a redirect to the login page? - if (!token) throw new InvalidTokenError('Token not found'); + // TODO: should be changed to a redirect to the login page? + if (!token) throw new InvalidTokenError('Token not found'); - return token; + return token; }; export class InvalidTokenError extends Error { - constructor(msg: string) { - super(msg); + constructor(msg: string) { + super(msg); - // Set the prototype explicitly. - Object.setPrototypeOf(this, InvalidTokenError.prototype); - } + // Set the prototype explicitly. + Object.setPrototypeOf(this, InvalidTokenError.prototype); + } } diff --git a/apps/frontend/src/lib/posts.ts b/apps/frontend/src/lib/posts.ts index 109edefb..326a3fe5 100644 --- a/apps/frontend/src/lib/posts.ts +++ b/apps/frontend/src/lib/posts.ts @@ -4,97 +4,97 @@ import path from 'path'; import matter from 'gray-matter'; export type PostType = { - id: string; - title: string; - shortTitle?: string; - date: Date; - image: string; - content: string; - author?: string; - authorImage?: string; + id : string; + title : string; + shortTitle? : string; + date : Date; + image : string; + content : string; + author? : string; + authorImage?: string; }; const blogPostIds = fs - .readdirSync(path.join(process.cwd(), 'posts', 'blog')) - .reduce((acc, fileName) => { + .readdirSync(path.join(process.cwd(), 'posts', 'blog')) + .reduce((acc, fileName) => { // Remove ".md" and date prefix to get post ID - const postId = fileName.replace(/\.md$/, '').split('_')[1]; - acc[postId] = fileName; - return acc; - }, {} as Record); + const postId = fileName.replace(/\.md$/, '').split('_')[1]; + acc[postId] = fileName; + return acc; + }, {} as Record); const helpPostIds = fs - .readdirSync(path.join(process.cwd(), 'posts', 'help')) - .reduce((acc, fileName) => { + .readdirSync(path.join(process.cwd(), 'posts', 'help')) + .reduce((acc, fileName) => { // Remove ".md" and number prefix to get help article ID - const helpId = fileName.replace(/\.md$/, '').split('_')[1]; - acc[helpId] = fileName; - return acc; - }, {} as Record); + const helpId = fileName.replace(/\.md$/, '').split('_')[1]; + acc[helpId] = fileName; + return acc; + }, {} as Record); export function getSortedPostsData( - postsPath: 'help' | 'blog', - sortBy: 'id' | 'date', + postsPath: 'help' | 'blog', + sortBy: 'id' | 'date' ) { - const postsDirectory = path.join(process.cwd(), 'posts', postsPath); + const postsDirectory = path.join(process.cwd(), 'posts', postsPath); - // Get file names under /posts - const fileNames = fs.readdirSync(postsDirectory); + // Get file names under /posts + const fileNames = fs.readdirSync(postsDirectory); - const allPostsData = fileNames.map((fileName) => { + const allPostsData = fileNames.map((fileName) => { // Remove ".md" and prefix from file name to get post ID - const id = fileName.replace(/\.md$/, '').split('_')[1]; - return getPostData(postsPath, id); - }); - - // Sort posts - let sortFunction; - - if (sortBy === 'date') { - sortFunction = (a: PostType, b: PostType) => { - if (a.date < b.date) { - return 1; - } else { - return -1; - } - }; - } else { - sortFunction = (a: PostType, b: PostType) => { - if (a.id > b.id) { - return 1; - } else { - return -1; - } - }; - } - - return allPostsData.sort((a, b) => sortFunction(a, b)); + const id = fileName.replace(/\.md$/, '').split('_')[1]; + return getPostData(postsPath, id); + }); + + // Sort posts + let sortFunction; + + if (sortBy === 'date') { + sortFunction = (a: PostType, b: PostType) => { + if (a.date < b.date) { + return 1; + } else { + return -1; + } + }; + } else { + sortFunction = (a: PostType, b: PostType) => { + if (a.id > b.id) { + return 1; + } else { + return -1; + } + }; + } + + return allPostsData.sort((a, b) => sortFunction(a, b)); } export function getPostData( - postsPath: 'help' | 'blog', - postId: string, + postsPath: 'help' | 'blog', + postId: string ): PostType { - // Look for the file in the posts directory that contains postId as suffix - const fileName = - postsPath === 'blog' ? blogPostIds[postId] : helpPostIds[postId]; + // Look for the file in the posts directory that contains postId as suffix + const fileName = + postsPath === 'blog' ? blogPostIds[postId] : helpPostIds[postId]; - if (!fileName) { - throw new Error(`No file found for post ID: ${postId}`); - } + if (!fileName) { + throw new Error(`No file found for post ID: ${postId}`); + } - const fullPath = path.join(process.cwd(), 'posts', postsPath, fileName); + const fullPath = path.join(process.cwd(), 'posts', postsPath, fileName); - // Read markdown file as string - const fileContents = fs.readFileSync(fullPath, 'utf8'); + // Read markdown file as string + const fileContents = fs.readFileSync(fullPath, 'utf8'); - // Use gray-matter to parse the post metadata section - const matterResult = matter(fileContents); + // Use gray-matter to parse the post metadata section + const matterResult = matter(fileContents); - // Combine the data with the id - return { - id: postId, - ...(matterResult.data as Omit), - content: matterResult.content, - }; + // Combine the data with the id + return { + id : postId, + ...(matterResult.data as Omit), + content: matterResult.content + }; } diff --git a/apps/frontend/src/lib/tailwind.utils.ts b/apps/frontend/src/lib/tailwind.utils.ts index 9ad0df42..dd53ea89 100644 --- a/apps/frontend/src/lib/tailwind.utils.ts +++ b/apps/frontend/src/lib/tailwind.utils.ts @@ -2,5 +2,5 @@ import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } diff --git a/apps/frontend/src/modules/auth/components/client/LoginFrom.tsx b/apps/frontend/src/modules/auth/components/client/LoginFrom.tsx index ab4a701c..4c989fcf 100644 --- a/apps/frontend/src/modules/auth/components/client/LoginFrom.tsx +++ b/apps/frontend/src/modules/auth/components/client/LoginFrom.tsx @@ -10,7 +10,7 @@ import ClientAxios from '@web/lib/axios/ClientAxios'; import { Input, - SubmitButton, + SubmitButton } from '../../../shared/components/client/FormElements'; type LoginFormData = { @@ -27,7 +27,7 @@ export const LoginForm: FC = () => { const { register, handleSubmit, - formState: { errors }, + formState: { errors } } = useForm(); const onSubmit = async ({ email }: LoginFormData) => { @@ -36,19 +36,19 @@ export const LoginForm: FC = () => { const url = `${backendURL}/auth/login/magic-link`; const response = await ClientAxios.post(url, { - destination: email, + destination: email }); console.log(response.data); toast.success(`A magic link has been sent to ${email}!`, { position: 'top-center', - duration: 20_000, // 20 seconds + duration: 20_000 // 20 seconds }); toast.success('It will stay valid for one hour!', { position: 'top-center', - duration: 20_000, // 20 seconds + duration: 20_000 // 20 seconds }); } catch (error) { if ((error as any).isAxiosError) { @@ -60,19 +60,19 @@ export const LoginForm: FC = () => { if (status === 429) { toast.error('Too many requests. Please try again later.', { position: 'top-center', - duration: 20_000, // 20 seconds + duration: 20_000 // 20 seconds }); } else { toast.error('An unexpected error occurred', { position: 'top-center', - duration: 20_000, // 20 seconds + duration: 20_000 // 20 seconds }); } } } else { toast.error('An unexpected error occurred', { position: 'top-center', - duration: 20_000, // 20 seconds + duration: 20_000 // 20 seconds }); } } finally { diff --git a/apps/frontend/src/modules/auth/components/client/login.util.ts b/apps/frontend/src/modules/auth/components/client/login.util.ts index 98750629..acdfc0fd 100644 --- a/apps/frontend/src/modules/auth/components/client/login.util.ts +++ b/apps/frontend/src/modules/auth/components/client/login.util.ts @@ -3,18 +3,18 @@ import { useEffect } from 'react'; export function deleteAuthCookies() { - // delete cookie - const cookiesToBeDeleted = ['refresh_token', 'token']; + // delete cookie + const cookiesToBeDeleted = ['refresh_token', 'token']; - cookiesToBeDeleted.forEach((cookie) => { - if (!document) return; + cookiesToBeDeleted.forEach((cookie) => { + if (!document) return; - if (process.env.NODE_ENV === 'development') { - document.cookie = `${cookie}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/`; - } else { - document.cookie = `${cookie}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; Domain=${process.env.NEXT_PUBLIC_APP_DOMAIN}`; - } - }); + if (process.env.NODE_ENV === 'development') { + document.cookie = `${cookie}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/`; + } else { + document.cookie = `${cookie}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; Domain=${process.env.NEXT_PUBLIC_APP_DOMAIN}`; + } + }); } /** @@ -38,18 +38,18 @@ export function deleteAuthCookies() { * @returns {null} This hook does not return anything. */ export function useSignOut() { - //const router = useRouter(); - function signOut() { - deleteAuthCookies(); - /* We have to use window.location.href here, + //const router = useRouter(); + function signOut() { + deleteAuthCookies(); + /* We have to use window.location.href here, because next should clear the cached page in the client side */ - window.location.href = '/'; - } + window.location.href = '/'; + } - useEffect(() => { - signOut(); - }, []); + useEffect(() => { + signOut(); + }, []); - return null; // we don't need to return anything + return null; // we don't need to return anything } diff --git a/apps/frontend/src/modules/auth/components/loginPage.tsx b/apps/frontend/src/modules/auth/components/loginPage.tsx index 5551d6d3..933ed66b 100644 --- a/apps/frontend/src/modules/auth/components/loginPage.tsx +++ b/apps/frontend/src/modules/auth/components/loginPage.tsx @@ -1,7 +1,7 @@ import { faDiscord, faGithub, - faGoogle, + faGoogle } from '@fortawesome/free-brands-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Link from 'next/link'; diff --git a/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx b/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx index 9ba806f1..06367fd3 100644 --- a/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx +++ b/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx @@ -1,7 +1,8 @@ -import { LoginForm } from './client/LoginFrom'; import { CopyrightFooter } from '../../shared/components/layout/CopyrightFooter'; import { NoteBlockWorldLogo } from '../../shared/components/NoteBlockWorldLogo'; +import { LoginForm } from './client/LoginFrom'; + export const LoginWithEmailPage = () => { return (
{ - // get token from cookies - const token = getTokenServer(); - // if token is not null, redirect to home page - if (!token) return false; - if (!token.value) return false; + // get token from cookies + const token = getTokenServer(); + // if token is not null, redirect to home page + if (!token) return false; + if (!token.value) return false; - try { + try { // verify the token with the server - const res = await axiosInstance.get('/auth/verify', { - headers: { - authorization: `Bearer ${token.value}`, - }, - }); - - // if the token is valid, redirect to home page - if (res.status === 200) return true; - else return false; - } catch { - return false; - } + const res = await axiosInstance.get('/auth/verify', { + headers: { + authorization: `Bearer ${token.value}` + } + }); + + // if the token is valid, redirect to home page + if (res.status === 200) return true; + else return false; + } catch { + return false; + } }; export const getUserData = async (): Promise => { - // get token from cookies - const token = getTokenServer(); - // if token is not null, redirect to home page - if (!token) throw new Error('No token found'); - if (!token.value) throw new Error('No token found'); + // get token from cookies + const token = getTokenServer(); + // if token is not null, redirect to home page + if (!token) throw new Error('No token found'); + if (!token.value) throw new Error('No token found'); - try { + try { // verify the token with the server - const res = await axiosInstance.get('/user/me', { - headers: { - authorization: `Bearer ${token.value}`, - }, - }); - - // if the token is valid, redirect to home page - if (res.status === 200) return res.data as LoggedUserData; - else throw new Error('Invalid token'); - } catch { - throw new Error('Invalid token'); - } + const res = await axiosInstance.get('/user/me', { + headers: { + authorization: `Bearer ${token.value}` + } + }); + + // if the token is valid, redirect to home page + if (res.status === 200) return res.data as LoggedUserData; + else throw new Error('Invalid token'); + } catch { + throw new Error('Invalid token'); + } }; diff --git a/apps/frontend/src/modules/auth/types/User.ts b/apps/frontend/src/modules/auth/types/User.ts index 9ed24892..7c243869 100644 --- a/apps/frontend/src/modules/auth/types/User.ts +++ b/apps/frontend/src/modules/auth/types/User.ts @@ -1,57 +1,57 @@ export type UserTokenData = { - id: string; - email: string; - username: string; + id : string; + email : string; + username: string; }; export type LoggedUserData = { - loginStreak: number; - loginCount: number; - playCount: number; - username: string; - publicName: string; - email: string; - description: string; - profileImage: string; - socialLinks: SocialLinks; - prefersDarkTheme: boolean; - creationDate: string; - lastEdited: string; - lastLogin: string; - createdAt: string; - updatedAt: string; - id: string; + loginStreak : number; + loginCount : number; + playCount : number; + username : string; + publicName : string; + email : string; + description : string; + profileImage : string; + socialLinks : SocialLinks; + prefersDarkTheme: boolean; + creationDate : string; + lastEdited : string; + lastLogin : string; + createdAt : string; + updatedAt : string; + id : string; }; export enum SocialLinksTypes { - BANDCAMP = 'bandcamp', - DISCORD = 'discord', - FACEBOOK = 'facebook', - GITHUB = 'github', - INSTAGRAM = 'instagram', - REDDIT = 'reddit', - SNAPCHAT = 'snapchat', - SOUNDCLOUD = 'soundcloud', - SPOTIFY = 'spotify', - STEAM = 'steam', - TELEGRAM = 'telegram', - TIKTOK = 'tiktok', - THREADS = 'threads', - TWITCH = 'twitch', - X = 'x', - YOUTUBE = 'youtube', + BANDCAMP = 'bandcamp', + DISCORD = 'discord', + FACEBOOK = 'facebook', + GITHUB = 'github', + INSTAGRAM = 'instagram', + REDDIT = 'reddit', + SNAPCHAT = 'snapchat', + SOUNDCLOUD = 'soundcloud', + SPOTIFY = 'spotify', + STEAM = 'steam', + TELEGRAM = 'telegram', + TIKTOK = 'tiktok', + THREADS = 'threads', + TWITCH = 'twitch', + X = 'x', + YOUTUBE = 'youtube' } export type SocialLinks = { - [K in SocialLinksTypes]?: string; + [K in SocialLinksTypes]?: string; }; export type UserProfileData = { - lastLogin: Date; - loginStreak: number; - playCount: number; - publicName: string; - description: string; - profileImage: string; - socialLinks: SocialLinks; + lastLogin : Date; + loginStreak : number; + playCount : number; + publicName : string; + description : string; + profileImage: string; + socialLinks : SocialLinks; }; diff --git a/apps/frontend/src/modules/browse/WelcomeBanner.tsx b/apps/frontend/src/modules/browse/WelcomeBanner.tsx index 89c64d8a..00fbb28a 100644 --- a/apps/frontend/src/modules/browse/WelcomeBanner.tsx +++ b/apps/frontend/src/modules/browse/WelcomeBanner.tsx @@ -7,9 +7,9 @@ export const WelcomeBanner = () => {
diff --git a/apps/frontend/src/modules/browse/components/HomePageComponent.tsx b/apps/frontend/src/modules/browse/components/HomePageComponent.tsx index 4ea9ecb0..127582af 100644 --- a/apps/frontend/src/modules/browse/components/HomePageComponent.tsx +++ b/apps/frontend/src/modules/browse/components/HomePageComponent.tsx @@ -3,16 +3,9 @@ import { faEllipsis } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { CategoryButtonGroup } from './client/CategoryButton'; -import { useFeaturedSongsProvider } from './client/context/FeaturedSongs.context'; -import { useRecentSongsProvider } from './client/context/RecentSongs.context'; -import LoadMoreButton from './client/LoadMoreButton'; -import { TimespanButtonGroup } from './client/TimespanButton'; -import SongCard from './SongCard'; -import SongCardGroup from './SongCardGroup'; import { InterSectionAdSlot, - SongCardAdSlot, + SongCardAdSlot } from '../../shared/components/client/ads/AdSlots'; import { Carousel, @@ -20,10 +13,18 @@ import { CarouselDots, CarouselItem, CarouselNext, - CarouselPrevious, + CarouselPrevious } from '../../shared/components/client/Carousel'; import { WelcomeBanner } from '../WelcomeBanner'; +import { CategoryButtonGroup } from './client/CategoryButton'; +import { useFeaturedSongsProvider } from './client/context/FeaturedSongs.context'; +import { useRecentSongsProvider } from './client/context/RecentSongs.context'; +import LoadMoreButton from './client/LoadMoreButton'; +import { TimespanButtonGroup } from './client/TimespanButton'; +import SongCard from './SongCard'; +import SongCardGroup from './SongCardGroup'; + export const HomePageComponent = () => { const { featuredSongsPage } = useFeaturedSongsProvider(); @@ -46,9 +47,9 @@ export const HomePageComponent = () => { @@ -84,7 +85,7 @@ export const HomePageComponent = () => { ) : ( - ), + ) )}
diff --git a/apps/frontend/src/modules/browse/components/SongCard.tsx b/apps/frontend/src/modules/browse/components/SongCard.tsx index d5025023..315bf7a6 100644 --- a/apps/frontend/src/modules/browse/components/SongCard.tsx +++ b/apps/frontend/src/modules/browse/components/SongCard.tsx @@ -46,7 +46,7 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { ) : ( `${song.uploader.username} • ${formatTimeAgo( - new Date(song.createdAt), + new Date(song.createdAt) )}` )}

diff --git a/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx b/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx index 0de6bb2c..1b83897e 100644 --- a/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx +++ b/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx @@ -7,17 +7,17 @@ import { CarouselContent, CarouselItem, CarouselNextSmall, - CarouselPreviousSmall, + CarouselPreviousSmall } from '@web/modules/shared/components/client/Carousel'; import { useRecentSongsProvider } from './context/RecentSongs.context'; type CategoryButtonProps = { - children: React.ReactNode; - isActive: boolean; + children : React.ReactNode; + isActive : boolean; isDisabled?: boolean; - onClick: (e: React.MouseEvent) => void; - id: string; + onClick : (e: React.MouseEvent) => void; + id : string; }; export const CategoryButtonGroup = () => { @@ -28,11 +28,11 @@ export const CategoryButtonGroup = () => { @@ -71,7 +71,7 @@ export const CategoryButton = ({ isDisabled, onClick, children, - id, + id }: CategoryButtonProps) => { return ( diff --git a/apps/frontend/src/modules/browse/components/client/TimespanButton.tsx b/apps/frontend/src/modules/browse/components/client/TimespanButton.tsx index bc50d568..e92202aa 100644 --- a/apps/frontend/src/modules/browse/components/client/TimespanButton.tsx +++ b/apps/frontend/src/modules/browse/components/client/TimespanButton.tsx @@ -3,17 +3,17 @@ import { Tooltip, TooltipContent, - TooltipTrigger, + TooltipTrigger } from '@web/modules/shared/components/tooltip'; import { useFeaturedSongsProvider } from './context/FeaturedSongs.context'; interface TimespanButtonProps { - children: React.ReactNode; - isActive: boolean; + children : React.ReactNode; + isActive : boolean; isDisabled: boolean; - onClick: (e: React.MouseEvent) => void; - id: string; + onClick : (e: React.MouseEvent) => void; + id : string; } export const TimespanButtonGroup = () => { @@ -75,7 +75,7 @@ export const TimespanButton = ({ isDisabled, onClick, children, - id, + id }: TimespanButtonProps) => { return ( diff --git a/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx b/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx index 8b32d8a1..79988d52 100644 --- a/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx +++ b/apps/frontend/src/modules/browse/components/client/context/FeaturedSongs.context.tsx @@ -1,30 +1,20 @@ 'use client'; -import { - FeaturedSongsDtoType, - SongPreviewDtoType, - TimespanType, -} from '@nbw/database'; +import { FeaturedSongsDtoType, SongPreviewDtoType, TimespanType } from '@nbw/database'; import { createContext, useContext, useEffect, useState } from 'react'; type FeaturedSongsContextType = { featuredSongsPage: SongPreviewDtoType[]; - timespan: TimespanType; - setTimespan: (timespan: TimespanType) => void; - timespanEmpty: Record; + timespan : TimespanType; + setTimespan : (timespan: TimespanType) => void; + timespanEmpty : Record; }; const FeaturedSongsContext = createContext( - {} as FeaturedSongsContextType, + {} as FeaturedSongsContextType ); -export function FeaturedSongsProvider({ - children, - initialFeaturedSongs, -}: { - children: React.ReactNode; - initialFeaturedSongs: FeaturedSongsDtoType; -}) { +export function FeaturedSongsProvider({ children, initialFeaturedSongs }: { children: React.ReactNode; initialFeaturedSongs: FeaturedSongsDtoType; }) { // Featured songs const [featuredSongs] = useState(initialFeaturedSongs); @@ -39,11 +29,10 @@ export function FeaturedSongsProvider({ result[timespan] = featuredSongs[timespan as TimespanType].length === 0; return result; }, - {} as Record, + {} as Record ); useEffect(() => { - // eslint-disable-next-line react-hooks/exhaustive-deps setFeaturedSongsPage(featuredSongs[timespan]); }, [featuredSongs, timespan]); @@ -53,7 +42,7 @@ export function FeaturedSongsProvider({ featuredSongsPage, timespan, setTimespan, - timespanEmpty, + timespanEmpty }} > {children} @@ -66,7 +55,7 @@ export function useFeaturedSongsProvider() { if (context === undefined || context === null) { throw new Error( - 'useFeaturedSongsProvider must be used within a FeaturedSongsProvider', + 'useFeaturedSongsProvider must be used within a FeaturedSongsProvider' ); } diff --git a/apps/frontend/src/modules/browse/components/client/context/HomePage.context.tsx b/apps/frontend/src/modules/browse/components/client/context/HomePage.context.tsx index c65e7ab1..b5ed6953 100644 --- a/apps/frontend/src/modules/browse/components/client/context/HomePage.context.tsx +++ b/apps/frontend/src/modules/browse/components/client/context/HomePage.context.tsx @@ -9,16 +9,16 @@ import { RecentSongsProvider } from './RecentSongs.context'; type HomePageContextType = null; const HomePageContext = createContext( - null as HomePageContextType, + null ); export function HomePageProvider({ children, initialRecentSongs, - initialFeaturedSongs, + initialFeaturedSongs }: { - children: React.ReactNode; - initialRecentSongs: SongPreviewDtoType[]; + children : React.ReactNode; + initialRecentSongs : SongPreviewDtoType[]; initialFeaturedSongs: FeaturedSongsDtoType; }) { return ( @@ -37,7 +37,7 @@ export function useHomePageProvider() { if (context === undefined || context === null) { throw new Error( - 'useHomePageProvider must be used within a HomepageProvider', + 'useHomePageProvider must be used within a HomepageProvider' ); } diff --git a/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx b/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx index 8d7fe9b7..a4643ce2 100644 --- a/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx +++ b/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx @@ -6,31 +6,31 @@ import { useCallback, useContext, useEffect, - useState, + useState } from 'react'; import axiosInstance from '@web/lib/axios'; type RecentSongsContextType = { - recentSongs: (SongPreviewDtoType | null)[]; - recentError: string; - increasePageRecent: () => Promise; - isLoading: boolean; - hasMore: boolean; - selectedCategory: string; - categories: Record; + recentSongs : (SongPreviewDtoType | null)[]; + recentError : string; + increasePageRecent : () => Promise; + isLoading : boolean; + hasMore : boolean; + selectedCategory : string; + categories : Record; setSelectedCategory: (category: string) => void; }; const RecentSongsContext = createContext( - {} as RecentSongsContextType, + {} as RecentSongsContextType ); export function RecentSongsProvider({ children, - initialRecentSongs, + initialRecentSongs }: { - children: React.ReactNode; + children : React.ReactNode; initialRecentSongs: SongPreviewDtoType[]; }) { // Recent songs @@ -61,9 +61,9 @@ export function RecentSongsProvider({ params: { page, limit: fetchCount, // TODO: fix constants - order: false, - }, - }, + order: false + } + } ); const newSongs: Array = response.data; @@ -75,7 +75,7 @@ export function RecentSongsProvider({ setRecentSongs((prevSongs) => [ ...prevSongs.filter((song) => song !== null), - ...response.data, + ...response.data ]); if (response.data.length < fetchCount) { @@ -83,7 +83,7 @@ export function RecentSongsProvider({ } } catch (error) { setRecentSongs((prevSongs) => - prevSongs.filter((song) => song !== null), + prevSongs.filter((song) => song !== null) ); setRecentError('Error loading recent songs'); @@ -91,13 +91,13 @@ export function RecentSongsProvider({ setLoading(false); } }, - [page, endpoint], + [page, endpoint] ); const fetchCategories = useCallback(async function () { try { const response = await axiosInstance.get>( - '/song-browser/categories', + '/song-browser/categories' ); return response.data; @@ -149,7 +149,7 @@ export function RecentSongsProvider({ categories, selectedCategory, setSelectedCategory, - increasePageRecent, + increasePageRecent }} > {children} @@ -162,7 +162,7 @@ export function useRecentSongsProvider() { if (context === undefined || context === null) { throw new Error( - 'useRecentSongsProvider must be used within a RecentSongsProvider', + 'useRecentSongsProvider must be used within a RecentSongsProvider' ); } diff --git a/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx b/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx index 93ee1b15..524592e2 100644 --- a/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx +++ b/apps/frontend/src/modules/my-songs/components/MySongsPage.tsx @@ -3,26 +3,27 @@ import type { SongPageDtoType, SongsFolder } from '@nbw/database'; import axiosInstance from '@web/lib/axios'; +import { getTokenServer } from '../../auth/features/auth.utils'; + import { MySongProvider } from './client/context/MySongs.context'; import { MySongsPageComponent } from './client/MySongsTable'; -import { getTokenServer } from '../../auth/features/auth.utils'; async function fetchSongsPage( page: number, pageSize: number, - token: string, + token: string ): Promise { const response = await axiosInstance .get('/my-songs', { headers: { - authorization: `Bearer ${token}`, + authorization: `Bearer ${token}` }, params: { - page: page + 1, + page : page + 1, limit: pageSize, - sort: 'createdAt', - order: false, - }, + sort : 'createdAt', + order: false + } }) .then((res) => { return res.data; @@ -52,7 +53,7 @@ async function fetchSongsFolder(): Promise { const firstPage = await fetchSongsPage(currentPage, pageSize, token.value); const data: SongsFolder = { - [currentPage]: firstPage, + [currentPage]: firstPage }; return data; @@ -62,10 +63,10 @@ async function fetchSongsFolder(): Promise { return { 0: { content: [], - total: 0, - page: 0, - limit: 0, - }, + total : 0, + page : 0, + limit : 0 + } }; } } diff --git a/apps/frontend/src/modules/my-songs/components/client/DeleteConfirmDialog.tsx b/apps/frontend/src/modules/my-songs/components/client/DeleteConfirmDialog.tsx index e740bf35..cf42afcc 100644 --- a/apps/frontend/src/modules/my-songs/components/client/DeleteConfirmDialog.tsx +++ b/apps/frontend/src/modules/my-songs/components/client/DeleteConfirmDialog.tsx @@ -4,11 +4,11 @@ export default function DeleteConfirmDialog({ isOpen, setIsOpen, songTitle, - onConfirm, + onConfirm }: { - isOpen: boolean; + isOpen : boolean; setIsOpen: (value: boolean) => void; - songId: string; + songId : string; songTitle: string; onConfirm: () => void; }) { diff --git a/apps/frontend/src/modules/my-songs/components/client/MySongsButtons.tsx b/apps/frontend/src/modules/my-songs/components/client/MySongsButtons.tsx index 5a609f89..c2476ab5 100644 --- a/apps/frontend/src/modules/my-songs/components/client/MySongsButtons.tsx +++ b/apps/frontend/src/modules/my-songs/components/client/MySongsButtons.tsx @@ -3,7 +3,7 @@ import { faDownload, faPencil, - faTrash, + faTrash } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Link from 'next/link'; @@ -11,16 +11,16 @@ import Link from 'next/link'; import { Tooltip, TooltipContent, - TooltipTrigger, + TooltipTrigger } from '@web/modules/shared/components/tooltip'; import { downloadSongFile } from '@web/modules/song/util/downloadSong'; export const DownloadSongButton = ({ - song, + song }: { song: { publicId: string; - title: string; + title : string; }; }) => { return ( @@ -33,7 +33,7 @@ export const DownloadSongButton = ({ }; const DownloadButton = ({ - handleClick, + handleClick }: { handleClick: React.MouseEventHandler; }) => { diff --git a/apps/frontend/src/modules/my-songs/components/client/MySongsTable.tsx b/apps/frontend/src/modules/my-songs/components/client/MySongsTable.tsx index 2140824d..ef6b417f 100644 --- a/apps/frontend/src/modules/my-songs/components/client/MySongsTable.tsx +++ b/apps/frontend/src/modules/my-songs/components/client/MySongsTable.tsx @@ -1,7 +1,7 @@ 'use client'; import { faChevronLeft, - faChevronRight, + faChevronRight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { MY_SONGS } from '@nbw/config'; @@ -40,16 +40,16 @@ const NoSongs = () => ( const SongRows = ({ page, - pageSize, + pageSize }: { - page: SongPageDtoType | null; + page : SongPageDtoType | null; pageSize: number; }) => { const maxPage = MY_SONGS.PAGE_SIZE; const content = !page ? Array(pageSize).fill(null) - : (page.content as SongPreviewDtoType[]); + : (page.content); return ( <> @@ -139,7 +139,7 @@ export const MySongsPageComponent = () => { isDeleteDialogOpen, setIsDeleteDialogOpen, songToDelete, - deleteSong, + deleteSong } = useMySongsProvider(); return ( diff --git a/apps/frontend/src/modules/my-songs/components/client/SongRow.tsx b/apps/frontend/src/modules/my-songs/components/client/SongRow.tsx index 7bc60297..69de8873 100644 --- a/apps/frontend/src/modules/my-songs/components/client/SongRow.tsx +++ b/apps/frontend/src/modules/my-songs/components/client/SongRow.tsx @@ -2,7 +2,7 @@ import { faCirclePlay, faEye, faEyeSlash, - faPlay, + faPlay } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { SongPreviewDtoType } from '@nbw/database'; @@ -12,13 +12,14 @@ import Skeleton from 'react-loading-skeleton'; import SongThumbnail from '@web/modules/shared/components/layout/SongThumbnail'; import { formatDuration } from '@web/modules/shared/util/format'; -import { useMySongsProvider } from './context/MySongs.context'; import { DeleteButton, DownloadSongButton, - EditButton, + EditButton } from '../client/MySongsButtons'; +import { useMySongsProvider } from './context/MySongs.context'; + export const SongRow = ({ song }: { song?: SongPreviewDtoType | null }) => { const { setIsDeleteDialogOpen, setSongToDelete } = useMySongsProvider(); @@ -123,9 +124,9 @@ export const SongRow = ({ song }: { song?: SongPreviewDtoType | null }) => { ) : ( <> {new Date(song.createdAt).toLocaleDateString('en-US', { - day: 'numeric', + day : 'numeric', month: 'short', - year: 'numeric', + year : 'numeric' })} )} diff --git a/apps/frontend/src/modules/my-songs/components/client/context/MySongs.context.tsx b/apps/frontend/src/modules/my-songs/components/client/context/MySongs.context.tsx index 72737afd..e2cef96d 100644 --- a/apps/frontend/src/modules/my-songs/components/client/context/MySongs.context.tsx +++ b/apps/frontend/src/modules/my-songs/components/client/context/MySongs.context.tsx @@ -4,14 +4,14 @@ import { MY_SONGS } from '@nbw/config'; import type { SongPageDtoType, SongPreviewDtoType, - SongsFolder, + SongsFolder } from '@nbw/database'; import { createContext, useCallback, useContext, useEffect, - useState, + useState } from 'react'; import { toast } from 'react-hot-toast'; @@ -19,33 +19,33 @@ import axiosInstance from '@web/lib/axios'; import { getTokenLocal } from '@web/lib/axios/token.utils'; type MySongsContextType = { - page: SongPageDtoType | null; - nextpage: () => void; - prevpage: () => void; - gotoPage: (page: number) => void; - totalSongs: number; - totalPages: number; - currentPage: number; - pageSize: number; - isLoading: boolean; - error: string | null; - isDeleteDialogOpen: boolean; + page : SongPageDtoType | null; + nextpage : () => void; + prevpage : () => void; + gotoPage : (page: number) => void; + totalSongs : number; + totalPages : number; + currentPage : number; + pageSize : number; + isLoading : boolean; + error : string | null; + isDeleteDialogOpen : boolean; setIsDeleteDialogOpen: (isOpen: boolean) => void; - songToDelete: SongPreviewDtoType | null; - setSongToDelete: (song: SongPreviewDtoType) => void; - deleteSong: () => void; + songToDelete : SongPreviewDtoType | null; + setSongToDelete : (song: SongPreviewDtoType) => void; + deleteSong : () => void; }; const MySongsContext = createContext( - {} as MySongsContextType, + {} as MySongsContextType ); type MySongProviderProps = { InitialsongsFolder?: SongsFolder; - children?: React.ReactNode; - totalPagesInit?: number; - currentPageInit?: number; - pageSizeInit?: number; + children? : React.ReactNode; + totalPagesInit? : number; + currentPageInit? : number; + pageSizeInit? : number; }; export const MySongProvider = ({ @@ -53,7 +53,7 @@ export const MySongProvider = ({ children, totalPagesInit = 0, currentPageInit = 0, - pageSizeInit = MY_SONGS.PAGE_SIZE, + pageSizeInit = MY_SONGS.PAGE_SIZE }: MySongProviderProps) => { const [loadedSongs, setLoadedSongs] = useState(InitialsongsFolder); @@ -69,14 +69,14 @@ export const MySongProvider = ({ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [songToDelete, setSongToDelete] = useState( - null, + null ); const putPage = useCallback( async ({ key, page }: { key: number; page: SongPageDtoType }) => { setLoadedSongs({ ...loadedSongs, [key]: page }); }, - [loadedSongs], + [loadedSongs] ); const fetchSongsPage = useCallback(async (): Promise => { @@ -86,22 +86,22 @@ export const MySongProvider = ({ try { const response = await axiosInstance.get('/my-songs', { params: { - page: currentPage, + page : currentPage, limit: pageSize, - sort: 'createdAt', - order: 'false', + sort : 'createdAt', + order: 'false' }, headers: { - authorization: `Bearer ${token}`, - }, + authorization: `Bearer ${token}` + } }); const data = response.data as SongPageDtoType; // TODO: total, page and pageSize are stored in every page, when it should be stored in the folder (what matters is 'content') putPage({ - key: currentPage, - page: data, + key : currentPage, + page: data }); setTotalSongs(data.total); @@ -144,7 +144,7 @@ export const MySongProvider = ({ setCurrentPage(page); } }, - [totalPages], + [totalPages] ); const nextpage = useCallback(() => { @@ -169,8 +169,8 @@ export const MySongProvider = ({ try { await axiosInstance.delete(`/song/${songToDelete.publicId}`, { headers: { - authorization: `Bearer ${token}`, - }, + authorization: `Bearer ${token}` + } }); setIsDeleteDialogOpen(false); @@ -208,7 +208,7 @@ export const MySongProvider = ({ setIsDeleteDialogOpen, songToDelete, setSongToDelete, - deleteSong, + deleteSong }} > {children} diff --git a/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx b/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx index 8fb45917..4f17aa89 100644 --- a/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx +++ b/apps/frontend/src/modules/shared/components/CustomMarkdown.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import Markdown, { ExtraProps } from 'react-markdown'; export const CustomMarkdown = ({ - MarkdownContent, + MarkdownContent }: { MarkdownContent: string; }) => { @@ -23,7 +23,7 @@ export const CustomMarkdown = ({ blockquote, pre, code, - a, + a }} > {MarkdownContent} diff --git a/apps/frontend/src/modules/shared/components/NoteBlockWorldLogo.tsx b/apps/frontend/src/modules/shared/components/NoteBlockWorldLogo.tsx index 83c97fd2..554c195d 100644 --- a/apps/frontend/src/modules/shared/components/NoteBlockWorldLogo.tsx +++ b/apps/frontend/src/modules/shared/components/NoteBlockWorldLogo.tsx @@ -6,12 +6,12 @@ export const NoteBlockWorldLogo = ({ size, orientation = 'adaptive', glow, - className, + className }: { - size: number; + size : number; orientation: 'horizontal' | 'vertical' | 'adaptive'; - glow?: boolean; - className?: string; + glow? : boolean; + className? : string; }) => { let flexConfig, marginConfig; @@ -32,7 +32,7 @@ export const NoteBlockWorldLogo = ({ flexConfig, 'flex items-center justify-center gap-2 min-w-fit max-w-full', glow ? 'animate-[nbw-glow_3s_ease-in-out_infinite]' : '', - className, + className )} > { return ( diff --git a/apps/frontend/src/modules/shared/components/client/BackButton.tsx b/apps/frontend/src/modules/shared/components/client/BackButton.tsx index cace754b..95fdfac1 100644 --- a/apps/frontend/src/modules/shared/components/client/BackButton.tsx +++ b/apps/frontend/src/modules/shared/components/client/BackButton.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation'; function BackButton({ className, - children, + children }: React.PropsWithChildren<{ className?: string; }>) { diff --git a/apps/frontend/src/modules/shared/components/client/Carousel.tsx b/apps/frontend/src/modules/shared/components/client/Carousel.tsx index 521b57ef..9d209a84 100644 --- a/apps/frontend/src/modules/shared/components/client/Carousel.tsx +++ b/apps/frontend/src/modules/shared/components/client/Carousel.tsx @@ -2,11 +2,11 @@ import { faChevronLeft, - faChevronRight, + faChevronRight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import useEmblaCarousel, { - type UseEmblaCarouselType, + type UseEmblaCarouselType } from 'embla-carousel-react'; import { ButtonHTMLAttributes, @@ -17,7 +17,7 @@ import { useCallback, useContext, useEffect, - useState, + useState } from 'react'; import { cn } from '../../../../lib/tailwind.utils'; @@ -28,17 +28,17 @@ type CarouselOptions = UseCarouselParameters[0]; type CarouselPlugin = UseCarouselParameters[1]; type CarouselProps = { - opts?: CarouselOptions; - plugins?: CarouselPlugin; + opts? : CarouselOptions; + plugins? : CarouselPlugin; orientation?: 'horizontal' | 'vertical'; - setApi?: (api: CarouselApi) => void; + setApi? : (api: CarouselApi) => void; }; type CarouselContextProps = { - carouselRef: ReturnType[0]; - api: ReturnType[1]; - scrollPrev: () => void; - scrollNext: () => void; + carouselRef : ReturnType[0]; + api : ReturnType[1]; + scrollPrev : () => void; + scrollNext : () => void; canScrollPrev: boolean; canScrollNext: boolean; } & CarouselProps; @@ -69,14 +69,14 @@ export const Carousel = forwardRef< children, ...props }, - ref, + ref ) => { const [carouselRef, api] = useEmblaCarousel( { ...opts, - axis: orientation === 'horizontal' ? 'x' : 'y', + axis: orientation === 'horizontal' ? 'x' : 'y' }, - plugins, + plugins ); const [canScrollPrev, setCanScrollPrev] = useState(false); @@ -117,7 +117,7 @@ export const Carousel = forwardRef< scrollNext(); } }, - [scrollPrev, scrollNext], + [scrollPrev, scrollNext] ); useEffect(() => { @@ -155,7 +155,7 @@ export const Carousel = forwardRef< scrollPrev, scrollNext, canScrollPrev, - canScrollNext, + canScrollNext }} >
); - }, + } ); Carousel.displayName = 'Carousel'; @@ -213,7 +213,7 @@ const CarouselButton = forwardRef< ref={ref} className={cn( 'absolute h-12 w-12 rounded-full bg-zinc-800 hover:bg-zinc-700 hover:scale-110 transition-all duration-200 ease-in-out cursor-pointer', - className, + className )} {...props} /> @@ -235,7 +235,7 @@ export const CarouselPrevious = forwardRef< orientation === 'horizontal' ? '-left-6 top-1/2 -translate-y-1/2' : '-top-12 left-1/2 -translate-x-1/2 rotate-90', - className, + className )} onClick={scrollPrev} {...props} @@ -260,7 +260,7 @@ export const CarouselNext = forwardRef< orientation === 'horizontal' ? '-right-6 top-1/2 -translate-y-1/2' : 'bottom-12 left-1/2 -translate-x-1/2 rotate-90', - className, + className )} onClick={scrollNext} {...props} @@ -281,7 +281,7 @@ const CarouselButtonSmall = forwardRef< ref={ref} className={cn( 'absolute h-10 w-10 rounded-full bg-zinc-900 hover:bg-zinc-800 transition-all duration-200 ease-in-out cursor-pointer', - className, + className )} {...props} /> @@ -308,7 +308,7 @@ export const CarouselPreviousSmall = forwardRef< ? '-left-5 top-1/2 -translate-y-1/2' : '-top-12 left-1/2 -translate-x-1/2 rotate-90', 'shadow-[15px_0_20px_20px_rgb(24,24,27)]', - className, + className )} onClick={scrollPrev} {...props} @@ -338,7 +338,7 @@ export const CarouselNextSmall = forwardRef< ? '-right-5 top-1/2 -translate-y-1/2' : 'bottom-12 left-1/2 -translate-x-1/2 rotate-90', 'shadow-[-15px_0_20px_20px_rgb(24,24,27)]', - className, + className )} onClick={scrollNext} {...props} @@ -351,8 +351,8 @@ export const CarouselNextSmall = forwardRef< CarouselNextSmall.displayName = 'CarouselNextSmall'; type UseDotButtonType = { - selectedIndex: number; - scrollSnaps: number[]; + selectedIndex : number; + scrollSnaps : number[]; onDotButtonClick: (index: number) => void; }; @@ -366,7 +366,7 @@ export const useDotButton = (): UseDotButtonType => { if (!api) return; api.scrollTo(index); }, - [api], + [api] ); const onInit = useCallback(() => { @@ -389,7 +389,7 @@ export const useDotButton = (): UseDotButtonType => { return { selectedIndex, scrollSnaps, - onDotButtonClick, + onDotButtonClick }; }; @@ -404,7 +404,7 @@ export const CarouselDots = () => { onClick={() => onDotButtonClick(index)} className={cn( 'h-2.5 w-2.5 rounded-full bg-zinc-800 hover:bg-zinc-700 transition-all duration-200 ease-in-out', - index === selectedIndex && 'bg-zinc-700', + index === selectedIndex && 'bg-zinc-700' )} /> ))} diff --git a/apps/frontend/src/modules/shared/components/client/Command.tsx b/apps/frontend/src/modules/shared/components/client/Command.tsx index 68fc65d2..8154ace1 100644 --- a/apps/frontend/src/modules/shared/components/client/Command.tsx +++ b/apps/frontend/src/modules/shared/components/client/Command.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Command as CommandPrimitive } from 'cmdk'; import * as React from 'react'; -import { cn } from '../../../../lib/tailwind.utils'; +import { cn } from '@web/lib/tailwind.utils'; const Command = React.forwardRef< React.ElementRef, @@ -15,7 +15,7 @@ const Command = React.forwardRef< ref={ref} className={cn( 'flex h-full w-full min-w-72 flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', - className, + className )} {...props} /> @@ -40,7 +40,7 @@ const CommandInput = React.forwardRef< ref={ref} className={cn( 'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', - className, + className )} {...props} /> @@ -83,7 +83,7 @@ const CommandGroup = React.forwardRef< ref={ref} className={cn( 'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', - className, + className )} {...props} /> @@ -112,7 +112,7 @@ const CommandItem = React.forwardRef< ref={ref} className={cn( 'relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none hover:bg-blue-600 transition-colors duration-100 data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50', - className, + className )} {...props} /> @@ -128,7 +128,7 @@ const CommandShortcut = ({ @@ -145,5 +145,5 @@ export { CommandGroup, CommandItem, CommandShortcut, - CommandSeparator, + CommandSeparator }; diff --git a/apps/frontend/src/modules/shared/components/client/FormElements.tsx b/apps/frontend/src/modules/shared/components/client/FormElements.tsx index 5d453377..48ca3f7e 100644 --- a/apps/frontend/src/modules/shared/components/client/FormElements.tsx +++ b/apps/frontend/src/modules/shared/components/client/FormElements.tsx @@ -13,7 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; export const Label = forwardRef< HTMLLabelElement, React.InputHTMLAttributes & { - id?: string; + id? : string; label: string; } >((props, ref) => { @@ -32,13 +32,13 @@ export const Area = ({ tooltip, isLoading, className, - children, + children }: { - label?: string; - tooltip?: React.ReactNode; + label? : string; + tooltip? : React.ReactNode; isLoading?: boolean; className?: string; - children: React.ReactNode; + children : React.ReactNode; }) => { return ( <> @@ -50,7 +50,7 @@ export const Area = ({
{children} @@ -81,11 +81,11 @@ const InfoTooltip = ({ children }: { children: React.ReactNode }) => { export const Input = forwardRef< HTMLInputElement, React.InputHTMLAttributes & { - id: string; - label: string; - tooltip?: React.ReactNode; - description?: string; - isLoading?: boolean; + id : string; + label : string; + tooltip? : React.ReactNode; + description? : string; + isLoading? : boolean; errorMessage?: string; } >((props, ref) => { @@ -121,10 +121,10 @@ Input.displayName = 'Input'; export const TextArea = forwardRef< HTMLTextAreaElement, React.InputHTMLAttributes & { - id: string; - label: string; - tooltip?: React.ReactNode; - isLoading?: boolean; + id : string; + label : string; + tooltip? : React.ReactNode; + isLoading? : boolean; errorMessage?: string; } >((props, ref) => { @@ -155,12 +155,12 @@ TextArea.displayName = 'TextArea'; export const Select = forwardRef< HTMLSelectElement, React.SelectHTMLAttributes & { - id: string; - label?: string; - tooltip?: React.ReactNode; - isLoading?: boolean; + id : string; + label? : string; + tooltip? : React.ReactNode; + isLoading? : boolean; errorMessage?: string; - description?: string; + description? : string; } >((props, ref) => { const { id, label, isLoading, errorMessage, description, ...rest } = props; @@ -181,7 +181,7 @@ export const Select = forwardRef< ? 'border-red-500 focus:outline-red-500' : 'border-zinc-500' } disabled:border-zinc-700 disabled:cursor-not-allowed disabled:text-zinc-500 p-2`, - props.className, + props.className )} /> )} @@ -200,7 +200,7 @@ export const Checkbox = forwardRef< HTMLInputElement, React.InputHTMLAttributes & { errorMessage?: string; - tooltip?: React.ReactNode; + tooltip? : React.ReactNode; } >((props, ref) => { const { errorMessage, tooltip, ...rest } = props; @@ -238,7 +238,7 @@ export const Slider = forwardRef< ref={ref} className={cn( 'relative flex w-full touch-none select-none items-center', - className, + className )} {...props} > @@ -257,7 +257,7 @@ export const UploadButton = ({ isDisabled }: { isDisabled: boolean }) => { 'transform motion-reduce:transform-none transition duration-150 ease-in-back', isDisabled ? '' - : 'hover:scale-[115%] hover:rotate-6 active:scale-[85%] active:rotate-6', + : 'hover:scale-[115%] hover:rotate-6 active:scale-[85%] active:rotate-6' )} > @@ -98,7 +98,7 @@ export function SongSearchCombo({ size='sm' className={cn( 'mr-2 h-4 w-4', - value === '' ? 'opacity-100' : 'opacity-0', + value === '' ? 'opacity-100' : 'opacity-0' )} /> No sound @@ -117,7 +117,7 @@ export function SongSearchCombo({ size='sm' className={cn( 'mr-2 h-4 w-4', - value === currentSound ? 'opacity-100' : 'opacity-0', + value === currentSound ? 'opacity-100' : 'opacity-0' )} /> {currentSound diff --git a/apps/frontend/src/modules/song/components/client/SongSelector.tsx b/apps/frontend/src/modules/song/components/client/SongSelector.tsx index ed815d1f..e28aea59 100644 --- a/apps/frontend/src/modules/song/components/client/SongSelector.tsx +++ b/apps/frontend/src/modules/song/components/client/SongSelector.tsx @@ -16,30 +16,30 @@ export const SongSelector = () => { const file = acceptedFiles[0]; setFile(file); }, - [setFile], + [setFile] ); const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ - onDrop: handleFileDrop, + onDrop : handleFileDrop, onDropRejected: (fileRejections) => { const error = fileRejections[0].errors[0].code; if (error === 'file-invalid-type') { toast.error("Oops! This doesn't look like a valid NBS file.", { - position: 'bottom-center', + position: 'bottom-center' }); } else if (error === 'file-too-large') { toast.error('This file is too large! (Max size: 3 MB)', { - position: 'bottom-center', + position: 'bottom-center' }); } }, accept: { - 'application/nbs': ['.nbs'], + 'application/nbs': ['.nbs'] }, - maxSize: UPLOAD_CONSTANTS.file.maxSize, + maxSize : UPLOAD_CONSTANTS.file.maxSize, multiple: false, - noClick: true, + noClick : true }); return ( diff --git a/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx b/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx index c5a762a4..f92ed22e 100644 --- a/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx +++ b/apps/frontend/src/modules/song/components/client/SongThumbnailInput.tsx @@ -7,7 +7,7 @@ import { cn } from '@web/lib/tailwind.utils'; import { Tooltip, TooltipContent, - TooltipTrigger, + TooltipTrigger } from '@web/modules/shared/components/tooltip'; import { useSongProvider } from './context/Song.context'; @@ -23,19 +23,19 @@ function ThumbnailSliders({ formMethods, isLocked, maxTick, - maxLayer, + maxLayer }: { formMethods: UseFormReturn & UseFormReturn; - isLocked: boolean; - maxTick: number; - maxLayer: number; + isLocked : boolean; + maxTick : number; + maxLayer : number; }) { const { register } = formMethods; const [zoomLevel, startTick, startLayer] = formMethods.watch([ 'thumbnailData.zoomLevel', 'thumbnailData.startTick', - 'thumbnailData.startLayer', + 'thumbnailData.startLayer' ]); return ( @@ -49,7 +49,7 @@ function ThumbnailSliders({ id='zoom-level' className='w-full disabled:cursor-not-allowed' {...register('thumbnailData.zoomLevel', { - valueAsNumber: true, + valueAsNumber: true })} disabled={isLocked} min={THUMBNAIL_CONSTANTS.zoomLevel.min} @@ -67,7 +67,7 @@ function ThumbnailSliders({ className='w-full disabled:cursor-not-allowed' {...register('thumbnailData.startTick', { valueAsNumber: true, - max: maxTick, + max : maxTick })} disabled={isLocked} min={THUMBNAIL_CONSTANTS.startTick.default} @@ -85,7 +85,7 @@ function ThumbnailSliders({ className='w-full disabled:cursor-not-allowed' {...register('thumbnailData.startLayer', { valueAsNumber: true, - max: maxLayer, + max : maxLayer })} disabled={isLocked} min={THUMBNAIL_CONSTANTS.startLayer.default} @@ -102,13 +102,13 @@ const ColorButton = ({ tooltip, active, onClick, - disabled, + disabled }: { - color: string; + color : string; tooltip: string; - active: boolean; + active : boolean; - onClick: (e: React.MouseEvent) => void; + onClick : (e: React.MouseEvent) => void; disabled: boolean; }) => ( @@ -117,7 +117,7 @@ const ColorButton = ({ type='button' className={cn( 'w-6 h-6 rounded-full flex-none border-2 border-zinc-200 border-opacity-30 disabled:opacity-30', - active && 'outline outline-2 outline-zinc-200', + active && 'outline outline-2 outline-zinc-200' )} style={{ backgroundColor: color }} disabled={disabled} @@ -130,9 +130,9 @@ const ColorButton = ({ export const SongThumbnailInput = ({ type, - isLocked, + isLocked }: { - type: 'upload' | 'edit'; + type : 'upload' | 'edit'; isLocked: boolean; }) => { const { song, formMethods } = useSongProvider(type); diff --git a/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx b/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx index 77fc2ee9..3f95c5e2 100644 --- a/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx +++ b/apps/frontend/src/modules/song/components/client/ThumbnailRenderer.tsx @@ -6,13 +6,13 @@ import { UseFormReturn } from 'react-hook-form'; import { UploadSongForm } from './SongForm.zod'; type ThumbnailRendererCanvasProps = { - notes: NoteQuadTree; + notes : NoteQuadTree; formMethods: UseFormReturn; }; export const ThumbnailRendererCanvas = ({ notes, - formMethods, + formMethods }: ThumbnailRendererCanvasProps) => { const canvasRef = useRef(null); const drawRequest = useRef(null); @@ -23,8 +23,8 @@ export const ThumbnailRendererCanvas = ({ 'thumbnailData.zoomLevel', 'thumbnailData.startTick', 'thumbnailData.startLayer', - 'thumbnailData.backgroundColor', - ], + 'thumbnailData.backgroundColor' + ] ); useEffect(() => { @@ -60,10 +60,10 @@ export const ThumbnailRendererCanvas = ({ startLayer, zoomLevel, backgroundColor, - canvasWidth: canvas.width, + canvasWidth : canvas.width, canvasHeight: canvas.height, - imgWidth: 1280, - imgHeight: 768, + imgWidth : 1280, + imgHeight : 768 }); swap(output, canvas); diff --git a/apps/frontend/src/modules/song/components/client/context/Song.context.tsx b/apps/frontend/src/modules/song/components/client/context/Song.context.tsx index 258ab120..4f43eb34 100644 --- a/apps/frontend/src/modules/song/components/client/context/Song.context.tsx +++ b/apps/frontend/src/modules/song/components/client/context/Song.context.tsx @@ -5,12 +5,12 @@ import { useContext } from 'react'; import { EditSongContext, EditSongProvider, - useEditSongProviderType, + useEditSongProviderType } from '@web/modules/song-edit/components/client/context/EditSong.context'; import { UploadSongContext, UploadSongProvider, - useUploadSongProviderType, + useUploadSongProviderType } from '@web/modules/song-upload/components/client/context/UploadSong.context'; export const SongProvider = ({ children }: { children: React.ReactNode }) => { @@ -24,7 +24,7 @@ export const SongProvider = ({ children }: { children: React.ReactNode }) => { type ContextType = 'upload' | 'edit'; export const useSongProvider = ( - type: ContextType, + type: ContextType ): useUploadSongProviderType & useEditSongProviderType => { const uploadContext = useContext(UploadSongContext); const editContext = useContext(EditSongContext); diff --git a/apps/frontend/src/modules/song/util/downloadSong.ts b/apps/frontend/src/modules/song/util/downloadSong.ts index da241576..d4a7fc8f 100644 --- a/apps/frontend/src/modules/song/util/downloadSong.ts +++ b/apps/frontend/src/modules/song/util/downloadSong.ts @@ -6,57 +6,57 @@ import axios from '@web/lib/axios'; import { getTokenLocal } from '@web/lib/axios/token.utils'; export const downloadSongFile = async (song: { - publicId: string; - title: string; + publicId: string; + title : string; }) => { - const token = getTokenLocal(); - - axios - .get(`/song/${song.publicId}/download`, { - params: { - src: 'downloadButton', - }, - headers: { - authorization: `Bearer ${token}`, - }, - responseType: 'blob', - withCredentials: true, - }) - .then((res) => { - const url = window.URL.createObjectURL(res.data); - const link = document.createElement('a'); - link.href = url; - - link.setAttribute('download', `${song.title}.nbs`); - document.body.appendChild(link); - link.click(); - - // Clean up - link.remove(); - window.URL.revokeObjectURL(url); - }); + const token = getTokenLocal(); + + axios + .get(`/song/${song.publicId}/download`, { + params: { + src: 'downloadButton' + }, + headers: { + authorization: `Bearer ${token}` + }, + responseType : 'blob', + withCredentials: true + }) + .then((res) => { + const url = window.URL.createObjectURL(res.data); + const link = document.createElement('a'); + link.href = url; + + link.setAttribute('download', `${song.title}.nbs`); + document.body.appendChild(link); + link.click(); + + // Clean up + link.remove(); + window.URL.revokeObjectURL(url); + }); }; export const openSongInNBS = async (song: { publicId: string }) => { - axios - .get(`/song/${song.publicId}/open`, { - headers: { - src: 'downloadButton', - }, - }) - .then((response) => { - const responseUrl = response.data; - const nbsUrl = 'nbs://' + responseUrl; - - // Create a link element and click it to open the song in NBS - const link = document.createElement('a'); - link.href = nbsUrl; - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(nbsUrl); - }) - .catch(() => { - toast.error('Failed to open song in NBS'); - }); + axios + .get(`/song/${song.publicId}/open`, { + headers: { + src: 'downloadButton' + } + }) + .then((response) => { + const responseUrl = response.data; + const nbsUrl = 'nbs://' + responseUrl; + + // Create a link element and click it to open the song in NBS + const link = document.createElement('a'); + link.href = nbsUrl; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(nbsUrl); + }) + .catch(() => { + toast.error('Failed to open song in NBS'); + }); }; diff --git a/apps/frontend/src/modules/user/components/UserProfile.tsx b/apps/frontend/src/modules/user/components/UserProfile.tsx index 4ac515af..3f0f5708 100644 --- a/apps/frontend/src/modules/user/components/UserProfile.tsx +++ b/apps/frontend/src/modules/user/components/UserProfile.tsx @@ -14,7 +14,7 @@ const UserProfile = ({ userData }: UserProfileProps) => { publicName, description, profileImage, - socialLinks, + socialLinks } = userData; return ( diff --git a/apps/frontend/src/modules/user/features/song.util.ts b/apps/frontend/src/modules/user/features/song.util.ts index de418f61..b4bd52c8 100644 --- a/apps/frontend/src/modules/user/features/song.util.ts +++ b/apps/frontend/src/modules/user/features/song.util.ts @@ -1,11 +1,11 @@ import axiosInstance from '@web/lib/axios'; export const getUserSongs = async (userId: string) => { - const res = await axiosInstance.get('/song/user', { - params: { - id: userId, - }, - }); + const res = await axiosInstance.get('/song/user', { + params: { + id: userId + } + }); - return res.data; + return res.data; }; diff --git a/apps/frontend/src/modules/user/features/user.util.ts b/apps/frontend/src/modules/user/features/user.util.ts index 70e8dd00..0aac2759 100644 --- a/apps/frontend/src/modules/user/features/user.util.ts +++ b/apps/frontend/src/modules/user/features/user.util.ts @@ -2,13 +2,13 @@ import axiosInstance from '../../../lib/axios'; import { UserProfileData } from '../../auth/types/User'; export const getUserProfileData = async ( - id: string, + id: string ): Promise => { - try { - const res = await axiosInstance.get(`/user/?id=${id}`); - if (res.status === 200) return res.data as UserProfileData; - else throw new Error('Failed to get user data'); - } catch { - throw new Error('Failed to get user data'); - } + try { + const res = await axiosInstance.get(`/user/?id=${id}`); + if (res.status === 200) return res.data as UserProfileData; + else throw new Error('Failed to get user data'); + } catch { + throw new Error('Failed to get user data'); + } }; diff --git a/bun.lock b/bun.lock index 5cfbf9a5..a5ae02da 100644 --- a/bun.lock +++ b/bun.lock @@ -19,9 +19,10 @@ "eslint-plugin-import": "^2.32.0", "eslint-plugin-mdx": "^3.6.2", "eslint-plugin-prettier": "^4.2.5", - "eslint-plugin-unused-imports": "^2.0.0", + "eslint-plugin-react": "^7.37.5", "globals": "^16.4.0", "prettier": "^2.8.8", + "typescript-eslint": "^8.44.1", }, }, "apps/backend": { @@ -74,7 +75,7 @@ "@nestjs/schematics": "^10.2.3", "@nestjs/testing": "^10.4.15", "@types/bcryptjs": "^2.4.6", - "@types/bun": "^1.2.10", + "@types/bun": "latest", "@types/express": "^4.17.21", "@types/multer": "^1.4.12", "@types/node": "^20.17.10", @@ -187,10 +188,12 @@ "@encode42/nbs.js": "^5.0.2", "@nbw/database": "workspace:*", "@timohausmann/quadtree-ts": "^2.2.2", + "jszip": "^3.10.1", "unidecode": "^1.1.0", }, "devDependencies": { "@types/bun": "latest", + "@types/unidecode": "^1.1.0", "typescript": "^5", }, "peerDependencies": { @@ -1004,6 +1007,8 @@ "@types/tailwindcss": ["@types/tailwindcss@3.1.0", "", { "dependencies": { "tailwindcss": "*" } }, "sha512-JxPzrm609hzvF4nmOI3StLjbBEP3WWQxDDJESqR1nh94h7gyyy3XSl0hn5RBMJ9mPudlLjtaXs5YEBtLw7CnPA=="], + "@types/unidecode": ["@types/unidecode@1.1.0", "", {}, "sha512-NTIsFsTe9WRek39/8DDj7KiQ0nU33DHMrKwNHcD1rKlUvn4N0Rc4Di8q/Xavs8bsDZmBa4MMtQA8+HNgwfxC/A=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/uuid": ["@types/uuid@9.0.8", "", {}, "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="], @@ -1528,14 +1533,10 @@ "eslint-plugin-prettier": ["eslint-plugin-prettier@4.2.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.0" }, "peerDependencies": { "eslint": ">=7.28.0", "prettier": ">=2.0.0" } }, "sha512-9Ni+xgemM2IWLq6aXEpP2+V/V30GeA/46Ar629vcMqVPodFFWC9skHu/D1phvuqtS8bJCFnNf01/qcmqYEwNfg=="], - "eslint-plugin-react": ["eslint-plugin-react@7.37.4", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.8", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ=="], + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.0.0-canary-7118f5dd7-20230705", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw=="], - "eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@2.0.0", "", { "dependencies": { "eslint-rule-composer": "^0.3.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "^5.0.0", "eslint": "^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-3APeS/tQlTrFa167ThtP0Zm0vctjr4M44HMpeg1P4bK6wItarumq0Ma82xorMKdFsWpphQBlRPzw/pxiVELX1A=="], - - "eslint-rule-composer": ["eslint-rule-composer@0.3.0", "", {}, "sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg=="], - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], @@ -1758,6 +1759,8 @@ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="], @@ -1982,6 +1985,8 @@ "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "juice": ["juice@10.0.1", "", { "dependencies": { "cheerio": "1.0.0-rc.12", "commander": "^6.1.0", "mensch": "^0.3.4", "slick": "^1.12.2", "web-resource-inliner": "^6.0.1" }, "bin": { "juice": "bin/juice" } }, "sha512-ZhJT1soxJCkOiO55/mz8yeBKTAJhRzX9WBO+16ZTqNTONnnVlUPyVBIzQ7lDRjaBdTbid+bAnyIon/GM3yp4cA=="], "jwa": ["jwa@1.4.1", "", { "dependencies": { "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA=="], @@ -2014,6 +2019,8 @@ "libqp": ["libqp@2.1.1", "", {}, "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -2336,7 +2343,7 @@ "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], - "object.entries": ["object.entries@1.1.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ=="], + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], @@ -2376,6 +2383,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "param-case": ["param-case@2.1.1", "", { "dependencies": { "no-case": "^2.2.0" } }, "sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -2596,7 +2605,7 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + "resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], "resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="], @@ -2656,6 +2665,8 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], @@ -2870,6 +2881,8 @@ "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + "typescript-eslint": ["typescript-eslint@8.44.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.44.1", "@typescript-eslint/parser": "8.44.1", "@typescript-eslint/typescript-estree": "8.44.1", "@typescript-eslint/utils": "8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0ws8uWGrUVTjEeN2OM4K1pLKHK/4NiNP/vz6ns+LjT/6sqpaYzIVFajZb1fj/IDwpsrrHb3Jy0Qm5u9CPcKaeg=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], @@ -3120,19 +3133,19 @@ "@mdx-js/mdx/source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], - "@nbw/backend/@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], + "@nbw/backend/@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], - "@nbw/config/@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], + "@nbw/config/@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], - "@nbw/database/@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], + "@nbw/database/@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], "@nbw/frontend/eslint-plugin-mdx": ["eslint-plugin-mdx@3.1.5", "", { "dependencies": { "eslint-mdx": "^3.1.5", "eslint-plugin-markdown": "^3.0.1", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "tslib": "^2.6.2", "unified": "^11.0.4", "vfile": "^6.0.1" }, "peerDependencies": { "eslint": ">=8.0.0" } }, "sha512-lUE7tP7IrIRHU3gTtASDe5u4YM2SvQveYVJfuo82yn3MLh/B/v05FNySURCK4aIxIYF1QYo3IRemQG/lyQzpAg=="], - "@nbw/song/@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], + "@nbw/song/@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], - "@nbw/sounds/@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], + "@nbw/sounds/@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], - "@nbw/thumbnail/@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], + "@nbw/thumbnail/@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], "@nbw/thumbnail/tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], @@ -3252,8 +3265,12 @@ "eslint-config-next/eslint-plugin-import": ["eslint-plugin-import@2.31.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", "array.prototype.findlastindex": "^1.2.5", "array.prototype.flat": "^1.3.2", "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.0", "hasown": "^2.0.2", "is-core-module": "^2.15.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.0", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.8", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A=="], + "eslint-config-next/eslint-plugin-react": ["eslint-plugin-react@7.37.4", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.8", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "eslint-import-resolver-node/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + "eslint-mdx/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -3266,10 +3283,6 @@ "eslint-plugin-markdown/mdast-util-from-markdown": ["mdast-util-from-markdown@0.8.5", "", { "dependencies": { "@types/mdast": "^3.0.0", "mdast-util-to-string": "^2.0.0", "micromark": "~2.11.0", "parse-entities": "^2.0.0", "unist-util-stringify-position": "^2.0.0" } }, "sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ=="], - "eslint-plugin-react/array-includes": ["array-includes@3.1.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.4", "is-string": "^1.0.7" } }, "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ=="], - - "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], - "espree/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "estree-util-to-js/source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], @@ -3362,6 +3375,8 @@ "jest-mock/@types/node": ["@types/node@22.13.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg=="], + "jest-resolve/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + "jest-runner/@types/node": ["@types/node@22.13.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg=="], "jest-runner/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], @@ -3798,6 +3813,8 @@ "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "postcss-import/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -3806,6 +3823,8 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "pug-filters/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "read-package-json-fast/json-parse-even-better-errors": ["json-parse-even-better-errors@3.0.2", "", {}, "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ=="], @@ -3868,6 +3887,8 @@ "tailwindcss/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + "tailwindcss/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser-webpack-plugin/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], @@ -3888,6 +3909,14 @@ "typed-array-buffer/call-bound": ["call-bound@1.0.3", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "get-intrinsic": "^1.2.6" } }, "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA=="], + "typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.44.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/type-utils": "8.44.1", "@typescript-eslint/utils": "8.44.1", "@typescript-eslint/visitor-keys": "8.44.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.44.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw=="], + + "typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.44.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", "@typescript-eslint/typescript-estree": "8.44.1", "@typescript-eslint/visitor-keys": "8.44.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw=="], + + "typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.44.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.44.1", "@typescript-eslint/tsconfig-utils": "8.44.1", "@typescript-eslint/types": "8.44.1", "@typescript-eslint/visitor-keys": "8.44.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A=="], + + "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.44.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", "@typescript-eslint/typescript-estree": "8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg=="], + "unbox-primitive/call-bound": ["call-bound@1.0.3", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "get-intrinsic": "^1.2.6" } }, "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA=="], "unified-engine/@types/node": ["@types/node@22.13.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg=="], @@ -3974,19 +4003,19 @@ "@jest/types/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "@nbw/backend/@types/bun/bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], + "@nbw/backend/@types/bun/bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], - "@nbw/config/@types/bun/bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], + "@nbw/config/@types/bun/bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], - "@nbw/database/@types/bun/bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], + "@nbw/database/@types/bun/bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], "@nbw/frontend/eslint-plugin-mdx/eslint-mdx": ["eslint-mdx@3.1.5", "", { "dependencies": { "acorn": "^8.11.3", "acorn-jsx": "^5.3.2", "espree": "^9.6.1", "estree-util-visit": "^2.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "synckit": "^0.9.0", "tslib": "^2.6.2", "unified": "^11.0.4", "unified-engine": "^11.2.0", "unist-util-visit": "^5.0.0", "uvu": "^0.5.6", "vfile": "^6.0.1" }, "peerDependencies": { "eslint": ">=8.0.0" } }, "sha512-ynztX0k7CQ3iDL7fDEIeg3g0O/d6QPv7IBI9fdYLhXp5fAp0fi8X22xF/D3+Pk0f90R27uwqa1clHpay6t0l8Q=="], - "@nbw/song/@types/bun/bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], + "@nbw/song/@types/bun/bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], - "@nbw/sounds/@types/bun/bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], + "@nbw/sounds/@types/bun/bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], - "@nbw/thumbnail/@types/bun/bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], + "@nbw/thumbnail/@types/bun/bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], "@nestjs-modules/mailer/glob/jackspeak": ["jackspeak@2.3.6", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ=="], @@ -4056,6 +4085,10 @@ "eslint-config-next/eslint-plugin-import/tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + "eslint-config-next/eslint-plugin-react/array-includes": ["array-includes@3.1.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.4", "is-string": "^1.0.7" } }, "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ=="], + + "eslint-config-next/eslint-plugin-react/object.entries": ["object.entries@1.1.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ=="], + "eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], "eslint-plugin-jsx-a11y/array-includes/es-abstract": ["es-abstract@1.23.9", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.0", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-regex": "^1.2.1", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.0", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.3", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.3", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.18" } }, "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA=="], @@ -4070,8 +4103,6 @@ "eslint-plugin-markdown/mdast-util-from-markdown/unist-util-stringify-position": ["unist-util-stringify-position@2.0.3", "", { "dependencies": { "@types/unist": "^2.0.2" } }, "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g=="], - "eslint-plugin-react/array-includes/es-abstract": ["es-abstract@1.23.9", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.0", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-regex": "^1.2.1", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.0", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.3", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.3", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.18" } }, "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA=="], - "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], @@ -4192,6 +4223,34 @@ "terser-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.44.1", "", { "dependencies": { "@typescript-eslint/types": "8.44.1", "@typescript-eslint/visitor-keys": "8.44.1" } }, "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.44.1", "", { "dependencies": { "@typescript-eslint/types": "8.44.1", "@typescript-eslint/typescript-estree": "8.44.1", "@typescript-eslint/utils": "8.44.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.44.1", "", { "dependencies": { "@typescript-eslint/types": "8.44.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.44.1", "", { "dependencies": { "@typescript-eslint/types": "8.44.1", "@typescript-eslint/visitor-keys": "8.44.1" } }, "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.44.1", "", {}, "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.44.1", "", { "dependencies": { "@typescript-eslint/types": "8.44.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.44.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.44.1", "@typescript-eslint/types": "^8.44.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.44.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.44.1", "", {}, "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.44.1", "", { "dependencies": { "@typescript-eslint/types": "8.44.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.44.1", "", { "dependencies": { "@typescript-eslint/types": "8.44.1", "@typescript-eslint/visitor-keys": "8.44.1" } }, "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg=="], + + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.44.1", "", {}, "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ=="], + "unified-engine/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], "unified-engine/concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -4260,6 +4319,8 @@ "eslint-config-next/eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + "eslint-config-next/eslint-plugin-react/array-includes/es-abstract": ["es-abstract@1.23.9", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.0", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-regex": "^1.2.1", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.0", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.3", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.3", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.18" } }, "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA=="], + "eslint-plugin-jsx-a11y/array-includes/es-abstract/call-bound": ["call-bound@1.0.3", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "get-intrinsic": "^1.2.6" } }, "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA=="], "eslint-plugin-jsx-a11y/array-includes/es-abstract/which-typed-array": ["which-typed-array@1.1.18", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA=="], @@ -4280,10 +4341,6 @@ "eslint-plugin-markdown/mdast-util-from-markdown/unist-util-stringify-position/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "eslint-plugin-react/array-includes/es-abstract/call-bound": ["call-bound@1.0.3", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "get-intrinsic": "^1.2.6" } }, "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA=="], - - "eslint-plugin-react/array-includes/es-abstract/which-typed-array": ["which-typed-array@1.1.18", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA=="], - "jsx-ast-utils/array-includes/es-abstract/call-bound": ["call-bound@1.0.3", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "get-intrinsic": "^1.2.6" } }, "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA=="], "jsx-ast-utils/array-includes/es-abstract/which-typed-array": ["which-typed-array@1.1.18", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA=="], @@ -4328,6 +4385,16 @@ "terser-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.44.1", "", {}, "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.44.1", "", {}, "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.44.1", "", {}, "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.44.1", "", { "dependencies": { "@typescript-eslint/types": "8.44.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw=="], + "vfile-reporter/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "web-resource-inliner/htmlparser2/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="], @@ -4364,6 +4431,10 @@ "eslint-config-next/eslint-plugin-import/array.prototype.findlastindex/es-abstract/which-typed-array": ["which-typed-array@1.1.18", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA=="], + "eslint-config-next/eslint-plugin-react/array-includes/es-abstract/call-bound": ["call-bound@1.0.3", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "get-intrinsic": "^1.2.6" } }, "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA=="], + + "eslint-config-next/eslint-plugin-react/array-includes/es-abstract/which-typed-array": ["which-typed-array@1.1.18", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA=="], + "eslint-plugin-markdown/mdast-util-from-markdown/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="], "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], diff --git a/eslint.config.js b/eslint.config.js index 5c33c8c8..41ca948a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,15 +1,11 @@ import js from '@eslint/js'; -import tseslint from '@typescript-eslint/eslint-plugin'; -import tsparser from '@typescript-eslint/parser'; -import prettierConfig from 'eslint-config-prettier'; -import prettierPlugin from 'eslint-plugin-prettier'; +import tseslint from 'typescript-eslint'; import globals from 'globals'; +import importPlugin from 'eslint-plugin-import'; +import react from 'eslint-plugin-react'; -export default [ - // Base JavaScript configuration - js.configs.recommended, - - // Global ignore patterns +export default tseslint.config( + // Global ignores (no changes here) { ignores: [ '**/node_modules/**', @@ -21,91 +17,71 @@ export default [ '**/*.config.ts', '**/generated/**', '.eslintrc.js', - '**/*.spec.ts', - '**/*.test.ts', ], }, + + // Base recommended configurations (no changes here) + js.configs.recommended, + ...tseslint.configs.recommended, - // Universal TypeScript configuration for the entire monorepo + // Main configuration object { - files: ['**/*.ts', '**/*.tsx'], languageOptions: { - parser: tsparser, - parserOptions: { - ecmaVersion: 2021, - sourceType: 'module', - // Don't use project-based parsing to avoid config issues - ecmaFeatures: { - jsx: true, - }, - }, - globals: { - // Universal globals that work everywhere - ...globals.node, - ...globals.browser, - ...globals.es2021, - console: 'readonly', - process: 'readonly', - Buffer: 'readonly', - __dirname: 'readonly', - __filename: 'readonly', - global: 'readonly', - React: 'readonly', - JSX: 'readonly', - }, + globals: { ...globals.node, ...globals.es2021, ...globals.bun }, }, plugins: { - '@typescript-eslint': tseslint, - prettier: prettierPlugin, + 'import': importPlugin, + }, + settings: { + 'import/resolver': { + typescript: { + project: ['apps/*/tsconfig.json', 'packages/*/tsconfig.json', './tsconfig.json'], + }, + node: true, + }, + 'import/core-modules': ['bun:test', 'bun:sqlite', 'bun'], }, rules: { - // Turn off rules that conflict with TypeScript - 'no-undef': 'off', // TypeScript handles this - 'no-unused-vars': 'off', // Use TypeScript version instead - 'no-redeclare': 'off', // TypeScript handles this better - - // Turn off rules that don't exist or cause issues - 'react-hooks/exhaustive-deps': 'off', - '@next/next/no-sync-scripts': 'off', - 'no-shadow-restricted-names': 'off', - - // TypeScript specific rules - simplified and lenient + ...importPlugin.configs.recommended.rules, + ...importPlugin.configs.typescript.rules, + + // Core and TypeScript rules (keep these) + 'no-console': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-require-imports': 'warn', + '@typescript-eslint/ban-ts-comment': 'warn', '@typescript-eslint/no-unused-vars': [ 'warn', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - ignoreRestSiblings: true, - }, + { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' }, ], - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-inferrable-types': 'off', + 'lines-between-class-members': ['warn', 'always', { exceptAfterSingleLine: true }], // 👈 ADD THIS RULE - // Prettier integration - 'prettier/prettier': [ - 'warn', - { - endOfLine: 'auto', - trailingComma: 'all', - }, - ], + // Import rules (keep these) + 'import/order': ['error', { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'unknown'], + 'pathGroups': [{ pattern: '@/**', group: 'internal' }], + pathGroupsExcludedImportTypes: ['builtin'], + 'newlines-between': 'always', + alphabetize: { order: 'asc', caseInsensitive: true }, + }], + 'import/newline-after-import': 'error', + 'import/no-duplicates': 'error', + }, + }, - // Relaxed rules for monorepo compatibility - 'no-console': 'off', - 'prefer-const': 'warn', - 'no-constant-condition': 'warn', - 'no-constant-binary-expression': 'warn', + // React specific configuration (no changes here) + { + files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'], + plugins: { + react, + }, + rules: { + ...react.configs.recommended.rules, + 'react/react-in-jsx-scope': 'off', + 'react/no-unknown-property': ['error', { ignore: ['custom-prop', 'cmdk-input-wrapper', 'cmdk-group-heading'] }] }, settings: { - react: { - version: 'detect', - }, + react: { version: 'detect' }, }, }, - - // Prettier config (must be last to override conflicting rules) - prettierConfig, -]; +); \ No newline at end of file diff --git a/package.json b/package.json index 66570f4a..fb01ffa9 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,10 @@ "import": "./apps/frontend/dist/index.js", "types": "./apps/frontend/dist/index.d.ts" }, + "./config": { + "import": "./packages/configs/dist/index.js", + "types": "./packages/configs/dist/index.d.ts" + }, "./database": { "import": "./packages/database/dist/index.js", "types": "./packages/database/dist/index.d.ts" @@ -36,27 +40,22 @@ } }, "scripts": { + "postinstall": "bun run build:packages", + "build:packages": "bun run --filter '@nbw/config' build && bun run --filter '@nbw/sounds' build && bun run --filter '@nbw/database' build && bun run --filter '@nbw/song' build && bun run --filter '@nbw/thumbnail' build", + "build:packages:fast": "bun run --filter '@nbw/config' --filter '@nbw/sounds' --parallel build && bun run --filter '@nbw/database' build && bun run --filter '@nbw/song' build && bun run --filter '@nbw/thumbnail' build", "dev:docker": "docker-compose -f docker-compose-dev.yml up -d && bun run dev && docker-compose down", - "build:server": "bun run build:data && cd apps/backend && bun run build", - "build:web": "cd ./apps/frontend && bun run build", - "start:server": "cd ./apps/backend && bun run start", - "start:server:prod": "cd ./apps/backend && bun run start", - "start:web:prod": "cd ./apps/frontend && bun run start", - "dev": "concurrently --success first -n \"server,web\" --prefix-colors \"cyan,magenta\" --prefix \"{name} {time}\" \"cd ./apps/backend && bun run start:dev\" \"cd ./apps/frontend && bun run start\"", - "dev:server": "cd ./apps/backend && bun run start:dev", - "dev:web": "cd ./apps/frontend && bun run start", - "lint": "eslint \"**/*.{ts,tsx}\" --fix --max-warnings 20", - "lint:server": "cd ./apps/backend && eslint \"src/**/*.ts\" --ignore-pattern \"**/*.spec.ts\" --ignore-pattern \"**/*.test.ts\" --fix --max-warnings 10", - "lint:web": "cd ./apps/frontend && eslint \"src/**/*.{ts,tsx}\" --fix --max-warnings 10", - "lint:packages": "cd ./packages/configs && eslint \"src/**/*.ts\" --fix && cd ../database && eslint \"src/**/*.ts\" --fix && cd ../song && eslint \"src/**/*.ts\" --fix && cd ../sounds && eslint \"src/**/*.ts\" --fix && cd ../thumbnail && eslint \"src/**/*.ts\" --fix", - "lint:database": "cd ./packages/database && bun run lint", - "lint:song": "cd ./packages/song && bun run lint", - "lint:sounds": "cd ./packages/sounds && bun run lint", - "lint:thumbnail": "cd ./packages/thumbnail && bun run lint", - "lint:components": "cd ./packages/components && bun run lint", - "test": "cd ./apps/backend && bun test", + "build:server": "bun run build:data && bun run --filter '@nbw/backend' build", + "build:web": "bun run --filter '@nbw/frontend' build", + "start:server": "bun run --filter '@nbw/backend' start", + "start:server:prod": "bun run --filter '@nbw/backend' start", + "start:web:prod": "bun run --filter '@nbw/frontend' start", + "dev": "concurrently --success first -n \"server,web\" --prefix-colors \"cyan,magenta\" --prefix \"{name} {time}\" \"bun run --filter '@nbw/backend' start:dev\" \"bun run --filter '@nbw/frontend' start\"", + "dev:server": "bun run --filter '@nbw/backend' start:dev", + "dev:web": "bun run --filter '@nbw/frontend' start", + "lint": "eslint \"**/*.{ts,tsx}\" --fix", + "test": "bun run build:packages && bun run --filter '@nbw/backend' test", "cy:open": "bun run test:cy", - "test:cy": "cd ./tests && bun run cy:open", + "test:cy": "bun run --filter 'tests' cy:open", "prettier": "prettier --write ." }, "keywords": [], @@ -78,9 +77,10 @@ "eslint-plugin-import": "^2.32.0", "eslint-plugin-mdx": "^3.6.2", "eslint-plugin-prettier": "^4.2.5", - "eslint-plugin-unused-imports": "^2.0.0", + "eslint-plugin-react": "^7.37.5", "globals": "^16.4.0", - "prettier": "^2.8.8" + "prettier": "^2.8.8", + "typescript-eslint": "^8.44.1" }, "dependencies": { "@nbw/sounds": "workspace:*", diff --git a/packages/configs/index.ts b/packages/configs/index.ts index 3f2f3f8c..059cc061 100644 --- a/packages/configs/index.ts +++ b/packages/configs/index.ts @@ -1 +1 @@ -console.log('Hello via Bun!'); +// Entry point for the configs package diff --git a/packages/configs/src/colors.ts b/packages/configs/src/colors.ts index 188d9a85..8c3d4995 100644 --- a/packages/configs/src/colors.ts +++ b/packages/configs/src/colors.ts @@ -25,114 +25,114 @@ https://tailwindcss.com/docs/customizing-colors import colors from 'tailwindcss/colors'; export const BG_COLORS = { - red: { - key: 'red', - name: 'Red', - light: colors.red[400], - dark: colors.red[900], - }, - orange: { - key: 'orange', - name: 'Orange', - light: colors.orange[400], - dark: colors.orange[900], - }, - amber: { - key: 'amber', - name: 'Amber', - light: colors.amber[400], - dark: colors.amber[900], - }, - yellow: { - key: 'yellow', - name: 'Yellow', - light: colors.yellow[400], - dark: colors.yellow[900], - }, - lime: { - key: 'lime', - name: 'Lime', - light: colors.lime[400], - dark: colors.lime[900], - }, - green: { - key: 'green', - name: 'Green', - light: colors.green[400], - dark: colors.green[900], - }, - emerald: { - key: 'emerald', - name: 'Emerald', - light: colors.emerald[400], - dark: colors.emerald[900], - }, - teal: { - key: 'teal', - name: 'Teal', - light: colors.teal[400], - dark: colors.teal[900], - }, - cyan: { - key: 'cyan', - name: 'Cyan', - light: colors.cyan[400], - dark: colors.cyan[900], - }, - sky: { - key: 'sky', - name: 'Sky', - light: colors.sky[400], - dark: colors.sky[900], - }, - blue: { - key: 'blue', - name: 'Blue', - light: colors.blue[400], - dark: colors.blue[900], - }, - indigo: { - key: 'indigo', - name: 'Indigo', - light: colors.indigo[400], - dark: colors.indigo[900], - }, - violet: { - key: 'violet', - name: 'Violet', - light: colors.violet[400], - dark: colors.violet[900], - }, - purple: { - key: 'purple', - name: 'Purple', - light: colors.purple[400], - dark: colors.purple[900], - }, - fuchsia: { - key: 'fuchsia', - name: 'Fuchsia', - light: colors.fuchsia[400], - dark: colors.fuchsia[900], - }, - pink: { - key: 'pink', - name: 'Pink', - light: colors.pink[400], - dark: colors.pink[900], - }, - rose: { - key: 'rose', - name: 'Rose', - light: colors.rose[400], - dark: colors.rose[900], - }, - gray: { - key: 'gray', - name: 'Gray', - light: colors.zinc[200], - dark: colors.zinc[800], - }, + red: { + key : 'red', + name : 'Red', + light: colors.red[400], + dark : colors.red[900] + }, + orange: { + key : 'orange', + name : 'Orange', + light: colors.orange[400], + dark : colors.orange[900] + }, + amber: { + key : 'amber', + name : 'Amber', + light: colors.amber[400], + dark : colors.amber[900] + }, + yellow: { + key : 'yellow', + name : 'Yellow', + light: colors.yellow[400], + dark : colors.yellow[900] + }, + lime: { + key : 'lime', + name : 'Lime', + light: colors.lime[400], + dark : colors.lime[900] + }, + green: { + key : 'green', + name : 'Green', + light: colors.green[400], + dark : colors.green[900] + }, + emerald: { + key : 'emerald', + name : 'Emerald', + light: colors.emerald[400], + dark : colors.emerald[900] + }, + teal: { + key : 'teal', + name : 'Teal', + light: colors.teal[400], + dark : colors.teal[900] + }, + cyan: { + key : 'cyan', + name : 'Cyan', + light: colors.cyan[400], + dark : colors.cyan[900] + }, + sky: { + key : 'sky', + name : 'Sky', + light: colors.sky[400], + dark : colors.sky[900] + }, + blue: { + key : 'blue', + name : 'Blue', + light: colors.blue[400], + dark : colors.blue[900] + }, + indigo: { + key : 'indigo', + name : 'Indigo', + light: colors.indigo[400], + dark : colors.indigo[900] + }, + violet: { + key : 'violet', + name : 'Violet', + light: colors.violet[400], + dark : colors.violet[900] + }, + purple: { + key : 'purple', + name : 'Purple', + light: colors.purple[400], + dark : colors.purple[900] + }, + fuchsia: { + key : 'fuchsia', + name : 'Fuchsia', + light: colors.fuchsia[400], + dark : colors.fuchsia[900] + }, + pink: { + key : 'pink', + name : 'Pink', + light: colors.pink[400], + dark : colors.pink[900] + }, + rose: { + key : 'rose', + name : 'Rose', + light: colors.rose[400], + dark : colors.rose[900] + }, + gray: { + key : 'gray', + name : 'Gray', + light: colors.zinc[200], + dark : colors.zinc[800] + } } as const; export const bgColorsArray = Object.values(BG_COLORS); diff --git a/packages/configs/src/song.ts b/packages/configs/src/song.ts index 1fbe40d6..1d9dc9a3 100644 --- a/packages/configs/src/song.ts +++ b/packages/configs/src/song.ts @@ -1,131 +1,112 @@ -import { BG_COLORS } from './colors'; - -export const THUMBNAIL_CONSTANTS = { - zoomLevel: { - min: 1, - max: 5, - default: 3, - }, - startTick: { - default: 0, - }, - startLayer: { - default: 0, - }, - backgroundColor: { - default: BG_COLORS.gray.dark, - }, -} as const; - export const MIMETYPE_NBS = 'application/octet-stream' as const; export const UPLOAD_CONSTANTS = { - file: { - maxSize: 1024 * 1024 * 3, // 3 MB - }, + file: { + maxSize: 1024 * 1024 * 3 // 3 MB + }, - title: { - minLength: 3, - maxLength: 100, - }, + title: { + minLength: 3, + maxLength: 100 + }, - description: { - maxLength: 1000, - }, + description: { + maxLength: 1000 + }, - originalAuthor: { - maxLength: 50, - }, + originalAuthor: { + maxLength: 50 + }, - category: { - default: 'none', - }, + category: { + default: 'none' + }, - license: { - default: 'none', - }, + license: { + default: 'none' + }, - customInstruments: { - maxCount: 240, - }, + customInstruments: { + maxCount: 240 + }, - categories: { - none: 'No category', - rock: 'Rock', - pop: 'Pop', - jazz: 'Jazz', - blues: 'Blues', - country: 'Country', - metal: 'Metal', - hiphop: 'Hip-Hop', - rap: 'Rap', - reggae: 'Reggae', - classical: 'Classical', - electronic: 'Electronic', - dance: 'Dance', - rnb: 'R&B', - soul: 'Soul', - funk: 'Funk', - punk: 'Punk', - alternative: 'Alternative', - indie: 'Indie', - folk: 'Folk', - latin: 'Latin', - world: 'World', - other: 'Other', - vocaloid: 'Vocaloid', - soundtrack: 'Soundtrack', - instrumental: 'Instrumental', - ambient: 'Ambient', - gaming: 'Gaming', - anime: 'Anime', - movies_tv: 'Movies & TV', - chiptune: 'Chiptune', - lofi: 'Lofi', - kpop: 'K-pop', - jpop: 'J-pop', - }, + categories: { + none : 'No category', + rock : 'Rock', + pop : 'Pop', + jazz : 'Jazz', + blues : 'Blues', + country : 'Country', + metal : 'Metal', + hiphop : 'Hip-Hop', + rap : 'Rap', + reggae : 'Reggae', + classical : 'Classical', + electronic : 'Electronic', + dance : 'Dance', + rnb : 'R&B', + soul : 'Soul', + funk : 'Funk', + punk : 'Punk', + alternative : 'Alternative', + indie : 'Indie', + folk : 'Folk', + latin : 'Latin', + world : 'World', + other : 'Other', + vocaloid : 'Vocaloid', + soundtrack : 'Soundtrack', + instrumental: 'Instrumental', + ambient : 'Ambient', + gaming : 'Gaming', + anime : 'Anime', + movies_tv : 'Movies & TV', + chiptune : 'Chiptune', + lofi : 'Lofi', + kpop : 'K-pop', + jpop : 'J-pop' + }, - licenses: { - standard: { - name: 'Standard License', - shortName: 'Standard License', - description: + licenses: { + standard: { + name : 'Standard License', + shortName: 'Standard License', + description: "The author reserves all rights. You may not use this song without the author's permission.", - uploadDescription: - 'You allow us to distribute your song on Note Block World. Other users can listen to it, but they cannot use the song without your permission.', - }, - cc_by_sa: { - name: 'Creative Commons - Attribution-ShareAlike 4.0', - shortName: 'CC BY-SA 4.0', - description: + uploadDescription: + 'You allow us to distribute your song on Note Block World. Other users can listen to it, but they cannot use the song without your permission.' + }, + cc_by_sa: { + name : 'Creative Commons - Attribution-ShareAlike 4.0', + shortName: 'CC BY-SA 4.0', + description: 'You can copy, modify, and distribute this song, even for commercial purposes, as long as you credit the author and provide a link to the song. If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.\n\nFor more information, please visit the [Creative Commons website](https://creativecommons.org/licenses/by-sa/4.0/).', - uploadDescription: - 'Anyone can copy, modify, remix, adapt and distribute this song, even for commercial purposes, as long as attribution is provided and the modifications are distributed under the same license.\nFor more information, visit the [Creative Commons](https://creativecommons.org/licenses/by-sa/4.0/) website.', + uploadDescription: + 'Anyone can copy, modify, remix, adapt and distribute this song, even for commercial purposes, as long as attribution is provided and the modifications are distributed under the same license.\nFor more information, visit the [Creative Commons](https://creativecommons.org/licenses/by-sa/4.0/) website.' + } }, - }, - visibility: { - public: 'Public', - private: 'Private', - }, + visibility: { + public : 'Public', + private: 'Private' + } } as const; export const TIMESPANS = [ - 'hour', - 'day', - 'week', - 'month', - 'year', - 'all', + 'hour', + 'day', + 'week', + 'month', + 'year', + 'all' ] as const; export const MY_SONGS = { - PAGE_SIZE: 10, - SORT: 'createdAt', + PAGE_SIZE: 10, + SORT : 'createdAt' } as const; export const BROWSER_SONGS = { - featuredPageSize: 10, - paddedFeaturedPageSize: 5, + featuredPageSize : 10, + paddedFeaturedPageSize: 5 } as const; diff --git a/packages/configs/src/thumbnail.ts b/packages/configs/src/thumbnail.ts index e69de29b..2d0edb1d 100644 --- a/packages/configs/src/thumbnail.ts +++ b/packages/configs/src/thumbnail.ts @@ -0,0 +1,18 @@ +import { BG_COLORS } from './colors'; + +export const THUMBNAIL_CONSTANTS = { + zoomLevel: { + min : 1, + max : 5, + default: 3 + }, + startTick: { + default: 0 + }, + startLayer: { + default: 0 + }, + backgroundColor: { + default: BG_COLORS.gray.dark + } +} as const; diff --git a/packages/configs/src/user.ts b/packages/configs/src/user.ts index 4a0e7f37..213473d4 100644 --- a/packages/configs/src/user.ts +++ b/packages/configs/src/user.ts @@ -1,5 +1,5 @@ export const USER_CONSTANTS = { - USERNAME_MIN_LENGTH: 3, - USERNAME_MAX_LENGTH: 32, - ALLOWED_REGEXP: /^[a-zA-Z0-9-_.]*$/, + USERNAME_MIN_LENGTH: 3, + USERNAME_MAX_LENGTH: 32, + ALLOWED_REGEXP : /^[a-zA-Z0-9-_.]*$/ } as const; diff --git a/packages/database/bun.lock b/packages/database/bun.lock deleted file mode 100644 index bbbc828b..00000000 --- a/packages/database/bun.lock +++ /dev/null @@ -1,29 +0,0 @@ -{ - "lockfileVersion": 1, - "workspaces": { - "": { - "name": "database", - "devDependencies": { - "@types/bun": "latest", - }, - "peerDependencies": { - "typescript": "^5", - }, - }, - }, - "packages": { - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], - - "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], - - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], - - "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], - - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - - "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], - } -} diff --git a/packages/database/src/common/dto/PageQuery.dto.ts b/packages/database/src/common/dto/PageQuery.dto.ts index 76dbd225..23ea7591 100644 --- a/packages/database/src/common/dto/PageQuery.dto.ts +++ b/packages/database/src/common/dto/PageQuery.dto.ts @@ -2,69 +2,69 @@ import { TIMESPANS } from '@nbw/config'; import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { - IsBoolean, - IsEnum, - IsNotEmpty, - IsNumber, - IsOptional, - IsString, - Max, - Min, + IsBoolean, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + Min } from 'class-validator'; -import type { TimespanType } from '@database/song/dto/types'; +import type { TimespanType } from '../../song/dto/types'; export class PageQueryDTO { - @Min(1) - @ApiProperty({ - example: 1, - description: 'page', - }) - page?: number = 1; + @Min(1) + @ApiProperty({ + example : 1, + description: 'page' + }) + page?: number = 1; - @IsNotEmpty() - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - @Min(1) - @Max(100) - @ApiProperty({ - example: 20, - description: 'limit', - }) - limit?: number; + @IsNotEmpty() + @IsNumber({ + allowNaN : false, + allowInfinity : false, + maxDecimalPlaces: 0 + }) + @Min(1) + @Max(100) + @ApiProperty({ + example : 20, + description: 'limit' + }) + limit?: number; - @IsString() - @IsOptional() - @ApiProperty({ - example: 'field', - description: 'Sorts the results by the specified field.', - required: false, - }) - sort?: string = 'createdAt'; + @IsString() + @IsOptional() + @ApiProperty({ + example : 'field', + description: 'Sorts the results by the specified field.', + required : false + }) + sort?: string = 'createdAt'; - @IsBoolean() - @Transform(({ value }) => value === 'true') - @ApiProperty({ - example: false, - description: + @IsBoolean() + @Transform(({ value }) => value === 'true') + @ApiProperty({ + example: false, + description: 'Sorts the results in ascending order if true; in descending order if false.', - required: false, - }) - order?: boolean = false; + required: false + }) + order?: boolean = false; - @IsEnum(TIMESPANS) - @IsOptional() - @ApiProperty({ - example: 'hour', - description: 'Filters the results by the specified timespan.', - required: false, - }) - timespan?: TimespanType; + @IsEnum(TIMESPANS) + @IsOptional() + @ApiProperty({ + example : 'hour', + description: 'Filters the results by the specified timespan.', + required : false + }) + timespan?: TimespanType; - constructor(partial: Partial) { - Object.assign(this, partial); - } + constructor(partial: Partial) { + Object.assign(this, partial); + } } diff --git a/packages/database/src/song/dto/CustomInstrumentData.dto.ts b/packages/database/src/song/dto/CustomInstrumentData.dto.ts index 8cb3e835..3adf03ca 100644 --- a/packages/database/src/song/dto/CustomInstrumentData.dto.ts +++ b/packages/database/src/song/dto/CustomInstrumentData.dto.ts @@ -1,6 +1,6 @@ import { IsNotEmpty } from 'class-validator'; export class CustomInstrumentData { - @IsNotEmpty() - sound: string[]; + @IsNotEmpty() + sound: string[]; } diff --git a/packages/database/src/song/dto/FeaturedSongsDto.dto.ts b/packages/database/src/song/dto/FeaturedSongsDto.dto.ts index 65d6eff7..21e821b6 100644 --- a/packages/database/src/song/dto/FeaturedSongsDto.dto.ts +++ b/packages/database/src/song/dto/FeaturedSongsDto.dto.ts @@ -1,21 +1,21 @@ import { SongPreviewDto } from './SongPreview.dto'; export class FeaturedSongsDto { - hour: SongPreviewDto[]; - day: SongPreviewDto[]; - week: SongPreviewDto[]; - month: SongPreviewDto[]; - year: SongPreviewDto[]; - all: SongPreviewDto[]; + hour : SongPreviewDto[]; + day : SongPreviewDto[]; + week : SongPreviewDto[]; + month: SongPreviewDto[]; + year : SongPreviewDto[]; + all : SongPreviewDto[]; - public static create(): FeaturedSongsDto { - return { - hour: [], - day: [], - week: [], - month: [], - year: [], - all: [], - }; - } + public static create(): FeaturedSongsDto { + return { + hour : [], + day : [], + week : [], + month: [], + year : [], + all : [] + }; + } } diff --git a/packages/database/src/song/dto/SongPage.dto.ts b/packages/database/src/song/dto/SongPage.dto.ts index 4e1e0a70..a6c51fba 100644 --- a/packages/database/src/song/dto/SongPage.dto.ts +++ b/packages/database/src/song/dto/SongPage.dto.ts @@ -3,32 +3,32 @@ import { IsArray, IsNotEmpty, IsNumber, ValidateNested } from 'class-validator'; import { SongPreviewDto } from './SongPreview.dto'; export class SongPageDto { - @IsNotEmpty() - @IsArray() - @ValidateNested() - content: Array; + @IsNotEmpty() + @IsArray() + @ValidateNested() + content: Array; - @IsNotEmpty() - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - page: number; + @IsNotEmpty() + @IsNumber({ + allowNaN : false, + allowInfinity : false, + maxDecimalPlaces: 0 + }) + page: number; - @IsNotEmpty() - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - limit: number; + @IsNotEmpty() + @IsNumber({ + allowNaN : false, + allowInfinity : false, + maxDecimalPlaces: 0 + }) + limit: number; - @IsNotEmpty() - @IsNumber({ - allowNaN: false, - allowInfinity: false, - maxDecimalPlaces: 0, - }) - total: number; + @IsNotEmpty() + @IsNumber({ + allowNaN : false, + allowInfinity : false, + maxDecimalPlaces: 0 + }) + total: number; } diff --git a/packages/database/src/song/dto/SongPreview.dto.ts b/packages/database/src/song/dto/SongPreview.dto.ts index 97acff3c..0cc1876a 100644 --- a/packages/database/src/song/dto/SongPreview.dto.ts +++ b/packages/database/src/song/dto/SongPreview.dto.ts @@ -1,75 +1,77 @@ import { IsNotEmpty, IsString, IsUrl, MaxLength } from 'class-validator'; -import type { SongWithUser } from '@database/song/entity/song.entity'; +import type { SongWithUser } from '../entity/song.entity'; + +import type { VisibilityType } from './types'; type SongPreviewUploader = { - username: string; - profileImage: string; + username : string; + profileImage: string; }; export class SongPreviewDto { - @IsString() - @IsNotEmpty() - publicId: string; - - @IsNotEmpty() - uploader: SongPreviewUploader; - - @IsNotEmpty() - @IsString() - @MaxLength(128) - title: string; - - @IsNotEmpty() - @IsString() - description: string; - - @IsNotEmpty() - @IsString() - @MaxLength(64) - originalAuthor: string; - - @IsNotEmpty() - duration: number; - - @IsNotEmpty() - noteCount: number; - - @IsNotEmpty() - @IsUrl() - thumbnailUrl: string; - - @IsNotEmpty() - createdAt: Date; - - @IsNotEmpty() - updatedAt: Date; - - @IsNotEmpty() - playCount: number; - - @IsNotEmpty() - @IsString() - visibility: string; - - constructor(partial: Partial) { - Object.assign(this, partial); - } - - public static fromSongDocumentWithUser(song: SongWithUser): SongPreviewDto { - return new SongPreviewDto({ - publicId: song.publicId, - uploader: song.uploader, - title: song.title, - description: song.description, - originalAuthor: song.originalAuthor, - duration: song.stats.duration, - noteCount: song.stats.noteCount, - thumbnailUrl: song.thumbnailUrl, - createdAt: song.createdAt, - updatedAt: song.updatedAt, - playCount: song.playCount, - visibility: song.visibility, - }); - } + @IsString() + @IsNotEmpty() + publicId: string; + + @IsNotEmpty() + uploader: SongPreviewUploader; + + @IsNotEmpty() + @IsString() + @MaxLength(128) + title: string; + + @IsNotEmpty() + @IsString() + description: string; + + @IsNotEmpty() + @IsString() + @MaxLength(64) + originalAuthor: string; + + @IsNotEmpty() + duration: number; + + @IsNotEmpty() + noteCount: number; + + @IsNotEmpty() + @IsUrl() + thumbnailUrl: string; + + @IsNotEmpty() + createdAt: Date; + + @IsNotEmpty() + updatedAt: Date; + + @IsNotEmpty() + playCount: number; + + @IsNotEmpty() + @IsString() + visibility: VisibilityType; + + constructor(partial: Partial) { + Object.assign(this, partial); + } + + public static fromSongDocumentWithUser(song: SongWithUser): SongPreviewDto { + return new SongPreviewDto({ + publicId : song.publicId, + uploader : song.uploader, + title : song.title, + description : song.description, + originalAuthor: song.originalAuthor, + duration : song.stats.duration, + noteCount : song.stats.noteCount, + thumbnailUrl : song.thumbnailUrl, + createdAt : song.createdAt, + updatedAt : song.updatedAt, + playCount : song.playCount, + visibility : song.visibility + }); + } } diff --git a/packages/database/src/song/dto/SongStats.ts b/packages/database/src/song/dto/SongStats.ts index 49cb712b..4553d2f8 100644 --- a/packages/database/src/song/dto/SongStats.ts +++ b/packages/database/src/song/dto/SongStats.ts @@ -1,69 +1,69 @@ import { - IsBoolean, - IsInt, - IsNumber, - IsString, - ValidateIf, + IsBoolean, + IsInt, + IsNumber, + IsString, + ValidateIf } from 'class-validator'; export class SongStats { - @IsString() - midiFileName: string; + @IsString() + midiFileName: string; - @IsInt() - noteCount: number; + @IsInt() + noteCount: number; - @IsInt() - tickCount: number; + @IsInt() + tickCount: number; - @IsInt() - layerCount: number; + @IsInt() + layerCount: number; - @IsNumber() - tempo: number; + @IsNumber() + tempo: number; - @IsNumber() - @ValidateIf((_, value) => value !== null) - tempoRange: number[] | null; + @IsNumber() + @ValidateIf((_, value) => value !== null) + tempoRange: number[] | null; - @IsNumber() - timeSignature: number; + @IsNumber() + timeSignature: number; - @IsNumber() - duration: number; + @IsNumber() + duration: number; - @IsBoolean() - loop: boolean; + @IsBoolean() + loop: boolean; - @IsInt() - loopStartTick: number; + @IsInt() + loopStartTick: number; - @IsNumber() - minutesSpent: number; + @IsNumber() + minutesSpent: number; - @IsInt() - vanillaInstrumentCount: number; + @IsInt() + vanillaInstrumentCount: number; - @IsInt() - customInstrumentCount: number; + @IsInt() + customInstrumentCount: number; - @IsInt() - firstCustomInstrumentIndex: number; + @IsInt() + firstCustomInstrumentIndex: number; - @IsInt() - outOfRangeNoteCount: number; + @IsInt() + outOfRangeNoteCount: number; - @IsInt() - detunedNoteCount: number; + @IsInt() + detunedNoteCount: number; - @IsInt() - customInstrumentNoteCount: number; + @IsInt() + customInstrumentNoteCount: number; - @IsInt() - incompatibleNoteCount: number; + @IsInt() + incompatibleNoteCount: number; - @IsBoolean() - compatible: boolean; + @IsBoolean() + compatible: boolean; - instrumentNoteCounts: number[]; + instrumentNoteCounts: number[]; } diff --git a/packages/database/src/song/dto/SongView.dto.ts b/packages/database/src/song/dto/SongView.dto.ts index b7b5ed6f..ac2641f9 100644 --- a/packages/database/src/song/dto/SongView.dto.ts +++ b/packages/database/src/song/dto/SongView.dto.ts @@ -1,108 +1,108 @@ import { - IsBoolean, - IsDate, - IsNotEmpty, - IsNumber, - IsString, - IsUrl, + IsBoolean, + IsDate, + IsNotEmpty, + IsNumber, + IsString, + IsUrl } from 'class-validator'; -import { SongStats } from '@database/song/dto/SongStats'; -import type { SongDocument } from '@database/song/entity/song.entity'; +import type { SongDocument } from '../entity/song.entity'; +import { SongStats } from './SongStats'; import type { CategoryType, LicenseType, VisibilityType } from './types'; export type SongViewUploader = { - username: string; - profileImage: string; + username : string; + profileImage: string; }; export class SongViewDto { - @IsString() - @IsNotEmpty() - publicId: string; - - @IsDate() - @IsNotEmpty() - createdAt: Date; - - @IsNotEmpty() - uploader: SongViewUploader; - - @IsUrl() - @IsNotEmpty() - thumbnailUrl: string; - - @IsNumber() - @IsNotEmpty() - playCount: number; - - @IsNumber() - @IsNotEmpty() - downloadCount: number; - - @IsNumber() - @IsNotEmpty() - likeCount: number; - - @IsBoolean() - @IsNotEmpty() - allowDownload: boolean; - - @IsString() - @IsNotEmpty() - title: string; - - @IsString() - originalAuthor: string; - - @IsString() - description: string; - - @IsString() - @IsNotEmpty() - visibility: VisibilityType; - - @IsString() - @IsNotEmpty() - category: CategoryType; - - @IsString() - @IsNotEmpty() - license: LicenseType; - - customInstruments: string[]; - - @IsNumber() - @IsNotEmpty() - fileSize: number; - - @IsNotEmpty() - stats: SongStats; - - public static fromSongDocument(song: SongDocument): SongViewDto { - return new SongViewDto({ - publicId: song.publicId, - createdAt: song.createdAt, - uploader: song.uploader as unknown as SongViewUploader, - thumbnailUrl: song.thumbnailUrl, - playCount: song.playCount, - downloadCount: song.downloadCount, - likeCount: song.likeCount, - allowDownload: song.allowDownload, - title: song.title, - originalAuthor: song.originalAuthor, - description: song.description, - category: song.category, - visibility: song.visibility, - license: song.license, - customInstruments: song.customInstruments, - fileSize: song.fileSize, - stats: song.stats, - }); - } - - constructor(song: SongViewDto) { - Object.assign(this, song); - } + @IsString() + @IsNotEmpty() + publicId: string; + + @IsDate() + @IsNotEmpty() + createdAt: Date; + + @IsNotEmpty() + uploader: SongViewUploader; + + @IsUrl() + @IsNotEmpty() + thumbnailUrl: string; + + @IsNumber() + @IsNotEmpty() + playCount: number; + + @IsNumber() + @IsNotEmpty() + downloadCount: number; + + @IsNumber() + @IsNotEmpty() + likeCount: number; + + @IsBoolean() + @IsNotEmpty() + allowDownload: boolean; + + @IsString() + @IsNotEmpty() + title: string; + + @IsString() + originalAuthor: string; + + @IsString() + description: string; + + @IsString() + @IsNotEmpty() + visibility: VisibilityType; + + @IsString() + @IsNotEmpty() + category: CategoryType; + + @IsString() + @IsNotEmpty() + license: LicenseType; + + customInstruments: string[]; + + @IsNumber() + @IsNotEmpty() + fileSize: number; + + @IsNotEmpty() + stats: SongStats; + + public static fromSongDocument(song: SongDocument): SongViewDto { + return new SongViewDto({ + publicId : song.publicId, + createdAt : song.createdAt, + uploader : song.uploader as unknown as SongViewUploader, + thumbnailUrl : song.thumbnailUrl, + playCount : song.playCount, + downloadCount : song.downloadCount, + likeCount : song.likeCount, + allowDownload : song.allowDownload, + title : song.title, + originalAuthor : song.originalAuthor, + description : song.description, + category : song.category, + visibility : song.visibility, + license : song.license, + customInstruments: song.customInstruments, + fileSize : song.fileSize, + stats : song.stats + }); + } + + constructor(song: SongViewDto) { + Object.assign(this, song); + } } diff --git a/packages/database/src/song/dto/ThumbnailData.dto.ts b/packages/database/src/song/dto/ThumbnailData.dto.ts index 09456c14..bd87655d 100644 --- a/packages/database/src/song/dto/ThumbnailData.dto.ts +++ b/packages/database/src/song/dto/ThumbnailData.dto.ts @@ -3,47 +3,47 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsHexColor, IsInt, IsNotEmpty, Max, Min } from 'class-validator'; export class ThumbnailData { - @IsNotEmpty() - @Max(THUMBNAIL_CONSTANTS.zoomLevel.max) - @Min(THUMBNAIL_CONSTANTS.zoomLevel.min) - @IsInt() - @ApiProperty({ - description: 'Zoom level of the cover image', - example: THUMBNAIL_CONSTANTS.zoomLevel.default, - }) - zoomLevel: number; + @IsNotEmpty() + @Max(THUMBNAIL_CONSTANTS.zoomLevel.max) + @Min(THUMBNAIL_CONSTANTS.zoomLevel.min) + @IsInt() + @ApiProperty({ + description: 'Zoom level of the cover image', + example : THUMBNAIL_CONSTANTS.zoomLevel.default + }) + zoomLevel: number; - @IsNotEmpty() - @Min(0) - @IsInt() - @ApiProperty({ - description: 'X position of the cover image', - example: THUMBNAIL_CONSTANTS.startTick.default, - }) - startTick: number; + @IsNotEmpty() + @Min(0) + @IsInt() + @ApiProperty({ + description: 'X position of the cover image', + example : THUMBNAIL_CONSTANTS.startTick.default + }) + startTick: number; - @IsNotEmpty() - @Min(0) - @ApiProperty({ - description: 'Y position of the cover image', - example: THUMBNAIL_CONSTANTS.startLayer.default, - }) - startLayer: number; + @IsNotEmpty() + @Min(0) + @ApiProperty({ + description: 'Y position of the cover image', + example : THUMBNAIL_CONSTANTS.startLayer.default + }) + startLayer: number; - @IsNotEmpty() - @IsHexColor() - @ApiProperty({ - description: 'Background color of the cover image', - example: THUMBNAIL_CONSTANTS.backgroundColor.default, - }) - backgroundColor: string; + @IsNotEmpty() + @IsHexColor() + @ApiProperty({ + description: 'Background color of the cover image', + example : THUMBNAIL_CONSTANTS.backgroundColor.default + }) + backgroundColor: string; - static getApiExample(): ThumbnailData { - return { - zoomLevel: 3, - startTick: 0, - startLayer: 0, - backgroundColor: '#F0F0F0', - }; - } + static getApiExample(): ThumbnailData { + return { + zoomLevel : 3, + startTick : 0, + startLayer : 0, + backgroundColor: '#F0F0F0' + }; + } } diff --git a/packages/database/src/song/dto/UploadSongDto.dto.ts b/packages/database/src/song/dto/UploadSongDto.dto.ts index dfe47049..14183af1 100644 --- a/packages/database/src/song/dto/UploadSongDto.dto.ts +++ b/packages/database/src/song/dto/UploadSongDto.dto.ts @@ -2,140 +2,140 @@ import { UPLOAD_CONSTANTS } from '@nbw/config'; import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { - IsArray, - IsBoolean, - IsIn, - IsNotEmpty, - IsString, - MaxLength, - ValidateNested, + IsArray, + IsBoolean, + IsIn, + IsNotEmpty, + IsString, + MaxLength, + ValidateNested } from 'class-validator'; -import type { SongDocument } from '@database/song/entity/song.entity'; +import type { SongDocument } from '../entity/song.entity'; import { ThumbnailData } from './ThumbnailData.dto'; import type { CategoryType, LicenseType, VisibilityType } from './types'; const visibility = Object.keys(UPLOAD_CONSTANTS.visibility) as Readonly< - string[] + string[] >; const categories = Object.keys(UPLOAD_CONSTANTS.categories) as Readonly< - string[] + string[] >; const licenses = Object.keys(UPLOAD_CONSTANTS.licenses) as Readonly; export class UploadSongDto { - @ApiProperty({ - description: 'The file to upload', - - // @ts-ignore //TODO: fix this - type: 'file', - }) - file: any; //TODO: Express.Multer.File; - - @IsNotEmpty() - @IsBoolean() - @Type(() => Boolean) - @ApiProperty({ - default: true, - description: 'Whether the song can be downloaded by other users', - example: true, - }) - allowDownload: boolean; - - @IsNotEmpty() - @IsString() - @IsIn(visibility) - @ApiProperty({ - enum: visibility, - default: visibility[0], - description: 'The visibility of the song', - example: visibility[0], - }) - visibility: VisibilityType; - - @IsNotEmpty() - @IsString() - @MaxLength(UPLOAD_CONSTANTS.title.maxLength) - @ApiProperty({ - description: 'Title of the song', - example: 'My Song', - }) - title: string; - - @IsString() - @MaxLength(UPLOAD_CONSTANTS.originalAuthor.maxLength) - @ApiProperty({ - description: 'Original author of the song', - example: 'Myself', - }) - originalAuthor: string; - - @IsString() - @MaxLength(UPLOAD_CONSTANTS.description.maxLength) - @ApiProperty({ - description: 'Description of the song', - example: 'This is my song', - }) - description: string; - - @IsNotEmpty() - @IsString() - @IsIn(categories) - @ApiProperty({ - enum: categories, - description: 'Category of the song', - example: categories[0], - }) - category: CategoryType; - - @IsNotEmpty() - @ValidateNested() - @Type(() => ThumbnailData) - @Transform(({ value }) => JSON.parse(value)) - @ApiProperty({ - description: 'Thumbnail data of the song', - example: ThumbnailData.getApiExample(), - }) - thumbnailData: ThumbnailData; - - @IsNotEmpty() - @IsString() - @IsIn(licenses) - @ApiProperty({ - enum: licenses, - default: licenses[0], - description: 'The visibility of the song', - example: licenses[0], - }) - license: LicenseType; - - @IsArray() - @MaxLength(UPLOAD_CONSTANTS.customInstruments.maxCount, { each: true }) - @ApiProperty({ - description: - 'List of custom instrument paths, one for each custom instrument in the song, relative to the assets/minecraft/sounds folder', - }) - @Transform(({ value }) => JSON.parse(value)) - customInstruments: string[]; - - constructor(partial: Partial) { - Object.assign(this, partial); - } - - public static fromSongDocument(song: SongDocument): UploadSongDto { - return new UploadSongDto({ - allowDownload: song.allowDownload, - visibility: song.visibility, - title: song.title, - originalAuthor: song.originalAuthor, - description: song.description, - category: song.category, - thumbnailData: song.thumbnailData, - license: song.license, - customInstruments: song.customInstruments ?? [], - }); - } + @ApiProperty({ + description: 'The file to upload', + + // @ts-ignore //TODO: fix this + type: 'file' + }) + file: any; //TODO: Express.Multer.File; + + @IsNotEmpty() + @IsBoolean() + @Type(() => Boolean) + @ApiProperty({ + default : true, + description: 'Whether the song can be downloaded by other users', + example : true + }) + allowDownload: boolean; + + @IsNotEmpty() + @IsString() + @IsIn(visibility) + @ApiProperty({ + enum : visibility, + default : visibility[0], + description: 'The visibility of the song', + example : visibility[0] + }) + visibility: VisibilityType; + + @IsNotEmpty() + @IsString() + @MaxLength(UPLOAD_CONSTANTS.title.maxLength) + @ApiProperty({ + description: 'Title of the song', + example : 'My Song' + }) + title: string; + + @IsString() + @MaxLength(UPLOAD_CONSTANTS.originalAuthor.maxLength) + @ApiProperty({ + description: 'Original author of the song', + example : 'Myself' + }) + originalAuthor: string; + + @IsString() + @MaxLength(UPLOAD_CONSTANTS.description.maxLength) + @ApiProperty({ + description: 'Description of the song', + example : 'This is my song' + }) + description: string; + + @IsNotEmpty() + @IsString() + @IsIn(categories) + @ApiProperty({ + enum : categories, + description: 'Category of the song', + example : categories[0] + }) + category: CategoryType; + + @IsNotEmpty() + @ValidateNested() + @Type(() => ThumbnailData) + @Transform(({ value }) => JSON.parse(value)) + @ApiProperty({ + description: 'Thumbnail data of the song', + example : ThumbnailData.getApiExample() + }) + thumbnailData: ThumbnailData; + + @IsNotEmpty() + @IsString() + @IsIn(licenses) + @ApiProperty({ + enum : licenses, + default : licenses[0], + description: 'The visibility of the song', + example : licenses[0] + }) + license: LicenseType; + + @IsArray() + @MaxLength(UPLOAD_CONSTANTS.customInstruments.maxCount, { each: true }) + @ApiProperty({ + description: + 'List of custom instrument paths, one for each custom instrument in the song, relative to the assets/minecraft/sounds folder' + }) + @Transform(({ value }) => JSON.parse(value)) + customInstruments: string[]; + + constructor(partial: Partial) { + Object.assign(this, partial); + } + + public static fromSongDocument(song: SongDocument): UploadSongDto { + return new UploadSongDto({ + allowDownload : song.allowDownload, + visibility : song.visibility, + title : song.title, + originalAuthor : song.originalAuthor, + description : song.description, + category : song.category, + thumbnailData : song.thumbnailData, + license : song.license, + customInstruments: song.customInstruments ?? [] + }); + } } diff --git a/packages/database/src/song/dto/UploadSongResponseDto.dto.ts b/packages/database/src/song/dto/UploadSongResponseDto.dto.ts index 2fedb3e1..b7485bd2 100644 --- a/packages/database/src/song/dto/UploadSongResponseDto.dto.ts +++ b/packages/database/src/song/dto/UploadSongResponseDto.dto.ts @@ -1,73 +1,73 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { - IsNotEmpty, - IsString, - MaxLength, - ValidateNested, + IsNotEmpty, + IsString, + MaxLength, + ValidateNested } from 'class-validator'; -import type { SongWithUser } from '@database/song/entity/song.entity'; +import type { SongWithUser } from '../entity/song.entity'; import * as SongViewDto from './SongView.dto'; import { ThumbnailData } from './ThumbnailData.dto'; export class UploadSongResponseDto { - @IsString() - @IsNotEmpty() - @ApiProperty({ - description: 'ID of the song', - example: '1234567890abcdef12345678', - }) - publicId: string; + @IsString() + @IsNotEmpty() + @ApiProperty({ + description: 'ID of the song', + example : '1234567890abcdef12345678' + }) + publicId: string; - @IsNotEmpty() - @IsString() - @MaxLength(128) - @ApiProperty({ - description: 'Title of the song', - example: 'My Song', - }) - title: string; + @IsNotEmpty() + @IsString() + @MaxLength(128) + @ApiProperty({ + description: 'Title of the song', + example : 'My Song' + }) + title: string; - @IsString() - @MaxLength(64) - @ApiProperty({ - description: 'Original author of the song', - example: 'Myself', - }) - uploader: SongViewDto.SongViewUploader; + @IsString() + @MaxLength(64) + @ApiProperty({ + description: 'Original author of the song', + example : 'Myself' + }) + uploader: SongViewDto.SongViewUploader; - @IsNotEmpty() - @ValidateNested() - @Type(() => ThumbnailData) - @Transform(({ value }) => JSON.parse(value)) - @ApiProperty({ - description: 'Thumbnail data of the song', - example: ThumbnailData.getApiExample(), - }) - thumbnailUrl: string; + @IsNotEmpty() + @ValidateNested() + @Type(() => ThumbnailData) + @Transform(({ value }) => JSON.parse(value)) + @ApiProperty({ + description: 'Thumbnail data of the song', + example : ThumbnailData.getApiExample() + }) + thumbnailUrl: string; - @IsNotEmpty() - duration: number; + @IsNotEmpty() + duration: number; - @IsNotEmpty() - noteCount: number; + @IsNotEmpty() + noteCount: number; - constructor(partial: Partial) { - Object.assign(this, partial); - } + constructor(partial: Partial) { + Object.assign(this, partial); + } - public static fromSongWithUserDocument( - song: SongWithUser, - ): UploadSongResponseDto { - return new UploadSongResponseDto({ - publicId: song.publicId, - title: song.title, - uploader: song.uploader, - duration: song.stats.duration, - thumbnailUrl: song.thumbnailUrl, - noteCount: song.stats.noteCount, - }); - } + public static fromSongWithUserDocument( + song: SongWithUser + ): UploadSongResponseDto { + return new UploadSongResponseDto({ + publicId : song.publicId, + title : song.title, + uploader : song.uploader, + duration : song.stats.duration, + thumbnailUrl: song.thumbnailUrl, + noteCount : song.stats.noteCount + }); + } } diff --git a/packages/database/src/song/entity/song.entity.ts b/packages/database/src/song/entity/song.entity.ts index 8f099e2d..0510419f 100644 --- a/packages/database/src/song/entity/song.entity.ts +++ b/packages/database/src/song/entity/song.entity.ts @@ -2,97 +2,96 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import type { HydratedDocument } from 'mongoose'; import { Schema as MongooseSchema, Types } from 'mongoose'; -import { User } from '@database/user/entity/user.entity'; - +import { User } from '../../user/entity/user.entity'; import { SongStats } from '../dto/SongStats'; import type { SongViewUploader } from '../dto/SongView.dto'; import { ThumbnailData } from '../dto/ThumbnailData.dto'; import type { CategoryType, LicenseType, VisibilityType } from '../dto/types'; @Schema({ - timestamps: true, - versionKey: false, - toJSON: { - virtuals: true, - transform: (doc, ret) => { - delete ret._id; - }, - }, + timestamps: true, + versionKey: false, + toJSON : { + virtuals : true, + transform: (doc, ret) => { + delete ret._id; + } + } }) export class Song { - @Prop({ type: String, required: true, unique: true }) - publicId: string; + @Prop({ type: String, required: true, unique: true }) + publicId: string; - @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) - createdAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html + @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) + createdAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html - @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) - updatedAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html + @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) + updatedAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html - @Prop({ type: MongooseSchema.Types.ObjectId, required: true, ref: 'User' }) - uploader: Types.ObjectId | User; // Populated with the uploader's user document + @Prop({ type: MongooseSchema.Types.ObjectId, required: true, ref: 'User' }) + uploader: Types.ObjectId | User; // Populated with the uploader's user document - @Prop({ type: String, required: true }) - thumbnailUrl: string; + @Prop({ type: String, required: true }) + thumbnailUrl: string; - @Prop({ type: String, required: true }) - nbsFileUrl: string; + @Prop({ type: String, required: true }) + nbsFileUrl: string; - @Prop({ type: String, required: true }) - packedSongUrl: string; + @Prop({ type: String, required: true }) + packedSongUrl: string; - // SONG DOCUMENT ATTRIBUTES + // SONG DOCUMENT ATTRIBUTES - @Prop({ type: Number, required: true, default: 0 }) - playCount: number; + @Prop({ type: Number, required: true, default: 0 }) + playCount: number; - @Prop({ type: Number, required: true, default: 0 }) - downloadCount: number; + @Prop({ type: Number, required: true, default: 0 }) + downloadCount: number; - @Prop({ type: Number, required: true, default: 0 }) - likeCount: number; + @Prop({ type: Number, required: true, default: 0 }) + likeCount: number; - // SONG FILE ATTRIBUTES (Populated from upload form - updatable) + // SONG FILE ATTRIBUTES (Populated from upload form - updatable) - @Prop({ type: ThumbnailData, required: true }) - thumbnailData: ThumbnailData; + @Prop({ type: ThumbnailData, required: true }) + thumbnailData: ThumbnailData; - @Prop({ type: String, required: true }) - category: CategoryType; + @Prop({ type: String, required: true }) + category: CategoryType; - @Prop({ type: String, required: true }) - visibility: VisibilityType; + @Prop({ type: String, required: true }) + visibility: VisibilityType; - @Prop({ type: String, required: true }) - license: LicenseType; + @Prop({ type: String, required: true }) + license: LicenseType; - @Prop({ type: Array, required: true }) - customInstruments: string[]; + @Prop({ type: Array, required: true }) + customInstruments: string[]; - @Prop({ type: Boolean, required: true, default: true }) - allowDownload: boolean; + @Prop({ type: Boolean, required: true, default: true }) + allowDownload: boolean; - @Prop({ type: String, required: true }) - title: string; + @Prop({ type: String, required: true }) + title: string; - @Prop({ type: String, required: false }) - originalAuthor: string; + @Prop({ type: String, required: false }) + originalAuthor: string; - @Prop({ type: String, required: false }) - description: string; + @Prop({ type: String, required: false }) + description: string; - // SONG FILE ATTRIBUTES (Populated from NBS file - immutable) + // SONG FILE ATTRIBUTES (Populated from NBS file - immutable) - @Prop({ type: Number, required: true }) - fileSize: number; + @Prop({ type: Number, required: true }) + fileSize: number; - @Prop({ type: SongStats, required: true }) - stats: SongStats; + @Prop({ type: SongStats, required: true }) + stats: SongStats; - // EXTERNAL ATTRIBUTES + // EXTERNAL ATTRIBUTES - @Prop({ type: String, required: false }) - webhookMessageId?: string | null; + @Prop({ type: String, required: false }) + webhookMessageId?: string | null; } export const SongSchema = SchemaFactory.createForClass(Song); @@ -100,5 +99,5 @@ export const SongSchema = SchemaFactory.createForClass(Song); export type SongDocument = Song & HydratedDocument; export type SongWithUser = Omit & { - uploader: SongViewUploader; + uploader: SongViewUploader; }; diff --git a/packages/database/src/user/dto/CreateUser.dto.ts b/packages/database/src/user/dto/CreateUser.dto.ts index ec6ca8f8..c10491bf 100644 --- a/packages/database/src/user/dto/CreateUser.dto.ts +++ b/packages/database/src/user/dto/CreateUser.dto.ts @@ -1,41 +1,41 @@ import { ApiProperty } from '@nestjs/swagger'; import { - IsEmail, - IsNotEmpty, - IsString, - IsUrl, - MaxLength, + IsEmail, + IsNotEmpty, + IsString, + IsUrl, + MaxLength } from 'class-validator'; export class CreateUser { - @IsNotEmpty() - @IsString() - @MaxLength(64) - @IsEmail() - @ApiProperty({ - description: 'Email of the user', - example: 'vycasnicolas@gmailcom', - }) - email: string; + @IsNotEmpty() + @IsString() + @MaxLength(64) + @IsEmail() + @ApiProperty({ + description: 'Email of the user', + example : 'vycasnicolas@gmailcom' + }) + email: string; - @IsNotEmpty() - @IsString() - @MaxLength(64) - @ApiProperty({ - description: 'Username of the user', - example: 'tomast1137', - }) - username: string; + @IsNotEmpty() + @IsString() + @MaxLength(64) + @ApiProperty({ + description: 'Username of the user', + example : 'tomast1137' + }) + username: string; - @IsNotEmpty() - @IsUrl() - @ApiProperty({ - description: 'Profile image of the user', - example: 'https://example.com/image.png', - }) - profileImage: string; + @IsNotEmpty() + @IsUrl() + @ApiProperty({ + description: 'Profile image of the user', + example : 'https://example.com/image.png' + }) + profileImage: string; - constructor(partial: Partial) { - Object.assign(this, partial); - } + constructor(partial: Partial) { + Object.assign(this, partial); + } } diff --git a/packages/database/src/user/dto/GetUser.dto.ts b/packages/database/src/user/dto/GetUser.dto.ts index 3feb46a3..198ee092 100644 --- a/packages/database/src/user/dto/GetUser.dto.ts +++ b/packages/database/src/user/dto/GetUser.dto.ts @@ -1,45 +1,45 @@ import { ApiProperty } from '@nestjs/swagger'; import { - IsEmail, - IsMongoId, - IsOptional, - IsString, - MaxLength, - MinLength, + IsEmail, + IsMongoId, + IsOptional, + IsString, + MaxLength, + MinLength } from 'class-validator'; export class GetUser { - @IsString() - @IsOptional() - @MaxLength(64) - @IsEmail() - @ApiProperty({ - description: 'Email of the user', - example: 'vycasnicolas@gmailcom', - }) - email?: string; + @IsString() + @IsOptional() + @MaxLength(64) + @IsEmail() + @ApiProperty({ + description: 'Email of the user', + example : 'vycasnicolas@gmailcom' + }) + email?: string; - @IsString() - @IsOptional() - @MaxLength(64) - @ApiProperty({ - description: 'Username of the user', - example: 'tomast1137', - }) - username?: string; + @IsString() + @IsOptional() + @MaxLength(64) + @ApiProperty({ + description: 'Username of the user', + example : 'tomast1137' + }) + username?: string; - @IsString() - @IsOptional() - @MaxLength(64) - @MinLength(24) - @IsMongoId() - @ApiProperty({ - description: 'ID of the user', - example: 'replace0me6b5f0a8c1a6d8c', - }) - id?: string; + @IsString() + @IsOptional() + @MaxLength(64) + @MinLength(24) + @IsMongoId() + @ApiProperty({ + description: 'ID of the user', + example : 'replace0me6b5f0a8c1a6d8c' + }) + id?: string; - constructor(partial: Partial) { - Object.assign(this, partial); - } + constructor(partial: Partial) { + Object.assign(this, partial); + } } diff --git a/packages/database/src/user/dto/Login.dto copy.ts b/packages/database/src/user/dto/Login.dto copy.ts index b433a0d2..207a0693 100644 --- a/packages/database/src/user/dto/Login.dto copy.ts +++ b/packages/database/src/user/dto/Login.dto copy.ts @@ -2,8 +2,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; export class LoginDto { - @ApiProperty() - @IsString() - @IsNotEmpty() - public email: string; + @ApiProperty() + @IsString() + @IsNotEmpty() + public email: string; } diff --git a/packages/database/src/user/dto/LoginWithEmail.dto.ts b/packages/database/src/user/dto/LoginWithEmail.dto.ts index 27c2d9cc..097a812b 100644 --- a/packages/database/src/user/dto/LoginWithEmail.dto.ts +++ b/packages/database/src/user/dto/LoginWithEmail.dto.ts @@ -2,8 +2,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; export class LoginWithEmailDto { - @ApiProperty() - @IsString() - @IsNotEmpty() - public email: string; + @ApiProperty() + @IsString() + @IsNotEmpty() + public email: string; } diff --git a/packages/database/src/user/dto/NewEmailUser.dto.ts b/packages/database/src/user/dto/NewEmailUser.dto.ts index 33be8301..fe60f246 100644 --- a/packages/database/src/user/dto/NewEmailUser.dto.ts +++ b/packages/database/src/user/dto/NewEmailUser.dto.ts @@ -1,30 +1,30 @@ import { ApiProperty } from '@nestjs/swagger'; import { - IsEmail, - IsNotEmpty, - IsString, - MaxLength, - MinLength, + IsEmail, + IsNotEmpty, + IsString, + MaxLength, + MinLength } from 'class-validator'; export class NewEmailUserDto { - @ApiProperty({ - description: 'User name', - example: 'Tomast1337', - }) - @IsString() - @IsNotEmpty() - @MaxLength(64) - @MinLength(4) - username: string; + @ApiProperty({ + description: 'User name', + example : 'Tomast1337' + }) + @IsString() + @IsNotEmpty() + @MaxLength(64) + @MinLength(4) + username: string; - @ApiProperty({ - description: 'User email', - example: 'vycasnicolas@gmail.com', - }) - @IsString() - @IsNotEmpty() - @MaxLength(64) - @IsEmail() - email: string; + @ApiProperty({ + description: 'User email', + example : 'vycasnicolas@gmail.com' + }) + @IsString() + @IsNotEmpty() + @MaxLength(64) + @IsEmail() + email: string; } diff --git a/packages/database/src/user/dto/SingleUsePass.dto.ts b/packages/database/src/user/dto/SingleUsePass.dto.ts index e1e04c25..75635f68 100644 --- a/packages/database/src/user/dto/SingleUsePass.dto.ts +++ b/packages/database/src/user/dto/SingleUsePass.dto.ts @@ -2,13 +2,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; export class SingleUsePassDto { - @ApiProperty() - @IsString() - @IsNotEmpty() - id: string; + @ApiProperty() + @IsString() + @IsNotEmpty() + id: string; - @ApiProperty() - @IsString() - @IsNotEmpty() - pass: string; + @ApiProperty() + @IsString() + @IsNotEmpty() + pass: string; } diff --git a/packages/database/src/user/dto/UpdateUsername.dto.ts b/packages/database/src/user/dto/UpdateUsername.dto.ts index c13cfb28..b9b85059 100644 --- a/packages/database/src/user/dto/UpdateUsername.dto.ts +++ b/packages/database/src/user/dto/UpdateUsername.dto.ts @@ -3,13 +3,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, Matches, MaxLength, MinLength } from 'class-validator'; export class UpdateUsernameDto { - @IsString() - @MaxLength(USER_CONSTANTS.USERNAME_MAX_LENGTH) - @MinLength(USER_CONSTANTS.USERNAME_MIN_LENGTH) - @Matches(USER_CONSTANTS.ALLOWED_REGEXP) - @ApiProperty({ - description: 'Username of the user', - example: 'tomast1137', - }) - username: string; + @IsString() + @MaxLength(USER_CONSTANTS.USERNAME_MAX_LENGTH) + @MinLength(USER_CONSTANTS.USERNAME_MIN_LENGTH) + @Matches(USER_CONSTANTS.ALLOWED_REGEXP) + @ApiProperty({ + description: 'Username of the user', + example : 'tomast1137' + }) + username: string; } diff --git a/packages/database/src/user/dto/user.dto.ts b/packages/database/src/user/dto/user.dto.ts index a611c20f..24e7fe4b 100644 --- a/packages/database/src/user/dto/user.dto.ts +++ b/packages/database/src/user/dto/user.dto.ts @@ -1,16 +1,16 @@ import { User } from '../entity/user.entity'; export class UserDto { - username: string; - publicName: string; - email: string; - static fromEntity(user: User): UserDto { - const userDto: UserDto = { - username: user.username, - publicName: user.publicName, - email: user.email, - }; + username : string; + publicName: string; + email : string; + static fromEntity(user: User): UserDto { + const userDto: UserDto = { + username : user.username, + publicName: user.publicName, + email : user.email + }; - return userDto; - } + return userDto; + } } diff --git a/packages/database/src/user/entity/user.entity.ts b/packages/database/src/user/entity/user.entity.ts index e480e133..26b0e89c 100644 --- a/packages/database/src/user/entity/user.entity.ts +++ b/packages/database/src/user/entity/user.entity.ts @@ -4,86 +4,86 @@ import { Schema as MongooseSchema } from 'mongoose'; @Schema({}) class SocialLinks { - bandcamp?: string; - discord?: string; - facebook?: string; - github?: string; - instagram?: string; - reddit?: string; - snapchat?: string; - soundcloud?: string; - spotify?: string; - steam?: string; - telegram?: string; - tiktok?: string; - threads?: string; - twitch?: string; - x?: string; - youtube?: string; + bandcamp? : string; + discord? : string; + facebook? : string; + github? : string; + instagram? : string; + reddit? : string; + snapchat? : string; + soundcloud?: string; + spotify? : string; + steam? : string; + telegram? : string; + tiktok? : string; + threads? : string; + twitch? : string; + x? : string; + youtube? : string; } @Schema({ - timestamps: true, - toJSON: { - virtuals: true, - transform: (doc, ret) => { - delete ret._id; - delete ret.__v; - }, - }, + timestamps: true, + toJSON : { + virtuals : true, + transform: (doc, ret) => { + delete ret._id; + delete ret.__v; + } + } }) export class User { - @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) - creationDate: Date; + @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) + creationDate: Date; - @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) - lastEdited: Date; + @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) + lastEdited: Date; - @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) - lastSeen: Date; + @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) + lastSeen: Date; - @Prop({ type: Number, required: true, default: 0 }) - loginCount: number; + @Prop({ type: Number, required: true, default: 0 }) + loginCount: number; - @Prop({ type: Number, required: true, default: 0 }) - loginStreak: number; + @Prop({ type: Number, required: true, default: 0 }) + loginStreak: number; - @Prop({ type: Number, required: true, default: 0 }) - maxLoginStreak: number; + @Prop({ type: Number, required: true, default: 0 }) + maxLoginStreak: number; - @Prop({ type: Number, required: true, default: 0 }) - playCount: number; + @Prop({ type: Number, required: true, default: 0 }) + playCount: number; - @Prop({ type: String, required: true }) - username: string; + @Prop({ type: String, required: true }) + username: string; - @Prop({ type: String, required: true, default: '#' }) - publicName: string; + @Prop({ type: String, required: true, default: '#' }) + publicName: string; - @Prop({ type: String, required: true, unique: true }) - email: string; + @Prop({ type: String, required: true, unique: true }) + email: string; - @Prop({ type: String, required: false, default: null }) - singleUsePass?: string; + @Prop({ type: String, required: false, default: null }) + singleUsePass?: string; - @Prop({ type: String, required: false, default: null }) - singleUsePassID?: string; + @Prop({ type: String, required: false, default: null }) + singleUsePassID?: string; - @Prop({ type: String, required: true, default: 'No description provided' }) - description: string; + @Prop({ type: String, required: true, default: 'No description provided' }) + description: string; - @Prop({ - type: String, - required: true, - default: '/img/note-block-pfp.jpg', - }) - profileImage: string; + @Prop({ + type : String, + required: true, + default : '/img/note-block-pfp.jpg' + }) + profileImage: string; - @Prop({ type: SocialLinks, required: false, default: {} }) - socialLinks: SocialLinks; + @Prop({ type: SocialLinks, required: false, default: {} }) + socialLinks: SocialLinks; - @Prop({ type: Boolean, required: true, default: true }) - prefersDarkTheme: boolean; + @Prop({ type: Boolean, required: true, default: true }) + prefersDarkTheme: boolean; } export const UserSchema = SchemaFactory.createForClass(User); diff --git a/packages/song/bun.lock b/packages/song/bun.lock deleted file mode 100644 index d8a1b172..00000000 --- a/packages/song/bun.lock +++ /dev/null @@ -1,29 +0,0 @@ -{ - "lockfileVersion": 1, - "workspaces": { - "": { - "name": "song", - "devDependencies": { - "@types/bun": "latest", - }, - "peerDependencies": { - "typescript": "^5", - }, - }, - }, - "packages": { - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], - - "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], - - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], - - "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], - - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - - "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], - } -} diff --git a/packages/song/package.json b/packages/song/package.json index 549da60b..18c45321 100644 --- a/packages/song/package.json +++ b/packages/song/package.json @@ -26,13 +26,15 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/unidecode": "^1.1.0", "typescript": "^5" }, "dependencies": { "@encode42/nbs.js": "^5.0.2", - "unidecode": "^1.1.0", + "@nbw/database": "workspace:*", "@timohausmann/quadtree-ts": "^2.2.2", - "@nbw/database": "workspace:*" + "jszip": "^3.10.1", + "unidecode": "^1.1.0" }, "peerDependencies": { "typescript": "^5" diff --git a/packages/song/src/injectMetadata.ts b/packages/song/src/injectMetadata.ts index 0b755a8d..237a5dc1 100644 --- a/packages/song/src/injectMetadata.ts +++ b/packages/song/src/injectMetadata.ts @@ -2,28 +2,28 @@ import { Song } from '@encode42/nbs.js'; import unidecode from 'unidecode'; export function injectSongFileMetadata( - nbsSong: Song, - title: string, - author: string, - originalAuthor: string, - description: string, - soundPaths: string[], + nbsSong: Song, + title: string, + author: string, + originalAuthor: string, + description: string, + soundPaths: string[] ) { - if (description != '') description += '\n\n'; - description += 'Uploaded to Note Block World'; + if (description != '') description += '\n\n'; + description += 'Uploaded to Note Block World'; - nbsSong.meta.name = unidecode(title); - nbsSong.meta.author = unidecode(author); - nbsSong.meta.originalAuthor = unidecode(originalAuthor); - nbsSong.meta.description = unidecode(description); + nbsSong.meta.name = unidecode(title); + nbsSong.meta.author = unidecode(author); + nbsSong.meta.originalAuthor = unidecode(originalAuthor); + nbsSong.meta.description = unidecode(description); - // Assign sound files to standard Minecraft asset names (machine- and human-friendly) - // (Also ensures the downloaded song matches what the user picked when uploading) - for (const [id, soundPath] of soundPaths.entries()) { - const customId = nbsSong.instruments.firstCustomIndex + id; - const newSoundPath = soundPath.replace('/sounds/', '/'); + // Assign sound files to standard Minecraft asset names (machine- and human-friendly) + // (Also ensures the downloaded song matches what the user picked when uploading) + for (const [id, soundPath] of soundPaths.entries()) { + const customId = nbsSong.instruments.firstCustomIndex + id; + const newSoundPath = soundPath.replace('/sounds/', '/'); - // @ts-ignore //TODO: fix this - nbsSong.instruments.loaded[customId].meta.soundFile = newSoundPath; - } + // @ts-ignore //TODO: fix this + nbsSong.instruments.loaded[customId].meta.soundFile = newSoundPath; + } } diff --git a/packages/song/src/notes.ts b/packages/song/src/notes.ts index 757e0da7..aa35e9df 100644 --- a/packages/song/src/notes.ts +++ b/packages/song/src/notes.ts @@ -8,68 +8,68 @@ type TreeNode = Rectangle; export { Rectangle } from '@timohausmann/quadtree-ts'; export class NoteQuadTree { - private quadtree: Quadtree; + private quadtree: Quadtree; - public width = 0; - public height = 0; + public width = 0; + public height = 0; - constructor(song: Song) { - const width = song.length; - const height = song.layers.length; + constructor(song: Song) { + const width = song.length; + const height = song.layers.length; - this.quadtree = new Quadtree({ width, height }); + this.quadtree = new Quadtree({ width, height }); - for (const [layerId, layer] of song.layers.entries()) { - for (const tickStr in layer.notes) { - const note = layer.notes[tickStr]; - const tick = parseInt(tickStr); + for (const [layerId, layer] of song.layers.entries()) { + for (const tickStr in layer.notes) { + const note = layer.notes[tickStr]; + const tick = parseInt(tickStr); - const treeItem = new Rectangle({ - x: tick, - y: layerId, - width: 1, - height: 1, - data: { ...note, tick: tick, layer: layerId }, - }); + const treeItem = new Rectangle({ + x : tick, + y : layerId, + width : 1, + height: 1, + data : { ...note, tick: tick, layer: layerId } + }); - // @ts-ignore //TODO: fix this - this.quadtree.insert(treeItem); + // @ts-ignore //TODO: fix this + this.quadtree.insert(treeItem); - if (tick > this.width) this.width = tick; - if (layerId > this.height) this.height = layerId; - } + if (tick > this.width) this.width = tick; + if (layerId > this.height) this.height = layerId; + } + } } - } - public getNotesInRect({ - x1, - y1, - x2, - y2, - }: { - x1: number; - y1: number; - x2: number; - y2: number; - }): Note[] { - const rect = new Rectangle({ - x: Math.min(x1, x2), - y: Math.min(y1, y2), - width: Math.abs(x2 - x1), - height: Math.abs(y2 - y1), - }); + public getNotesInRect({ + x1, + y1, + x2, + y2 + }: { + x1: number; + y1: number; + x2: number; + y2: number; + }): Note[] { + const rect = new Rectangle({ + x : Math.min(x1, x2), + y : Math.min(y1, y2), + width : Math.abs(x2 - x1), + height: Math.abs(y2 - y1) + }); - return this.quadtree - .retrieve(rect) - .flatMap((node) => { - return node.data ? [node.data] : []; - }) - .filter( - (note) => - note.tick >= x1 && + return this.quadtree + .retrieve(rect) + .flatMap((node) => { + return node.data ? [node.data] : []; + }) + .filter( + (note) => + note.tick >= x1 && note.tick <= x2 && note.layer >= y1 && - note.layer <= y2, - ); - } + note.layer <= y2 + ); + } } diff --git a/packages/song/src/obfuscate.ts b/packages/song/src/obfuscate.ts index 490bcb80..ef4f4cc2 100644 --- a/packages/song/src/obfuscate.ts +++ b/packages/song/src/obfuscate.ts @@ -3,112 +3,112 @@ import { Instrument, Layer, Note, Song } from '@encode42/nbs.js'; import { getInstrumentNoteCounts, getTempoChangerInstrumentIds } from './util'; export class SongObfuscator { - private song: Song; - private soundPaths: string[]; - private output: Song; - - public static obfuscateSong(song: Song, soundPaths: string[]) { - return new SongObfuscator(song, soundPaths).output; - } - - private constructor(song: Song, soundPaths: string[]) { - this.song = song; - this.soundPaths = soundPaths; - this.output = this.generateObfuscatedSong(); - } - - private generateObfuscatedSong(): Song { - const song = this.song; - const output = new Song(); - - // ✅ Clear work stats - // ✅ Copy: title, author, description, loop info, time signature - this.copyMetaAndStats(song, output); - const instrumentMapping = this.resolveInstruments(song, output); - this.resolveNotes(song, output, instrumentMapping); - - return output; - } - - private copyMetaAndStats(song: Song, output: Song) { - output.meta.name = song.meta.name; - output.meta.author = song.meta.author; - output.meta.originalAuthor = song.meta.originalAuthor; - output.meta.description = song.meta.description; - output.meta.importName = song.meta.importName; - - output.loop.enabled = song.loop.enabled; - output.loop.startTick = song.loop.startTick; - output.loop.totalLoops = song.loop.totalLoops; - - output.tempo = song.tempo; - output.timeSignature = song.timeSignature; - } - - private resolveInstruments(song: Song, output: Song): Record { + private song : Song; + private soundPaths: string[]; + private output : Song; + + public static obfuscateSong(song: Song, soundPaths: string[]) { + return new SongObfuscator(song, soundPaths).output; + } + + private constructor(song: Song, soundPaths: string[]) { + this.song = song; + this.soundPaths = soundPaths; + this.output = this.generateObfuscatedSong(); + } + + private generateObfuscatedSong(): Song { + const song = this.song; + const output = new Song(); + + // ✅ Clear work stats + // ✅ Copy: title, author, description, loop info, time signature + this.copyMetaAndStats(song, output); + const instrumentMapping = this.resolveInstruments(song, output); + this.resolveNotes(song, output, instrumentMapping); + + return output; + } + + private copyMetaAndStats(song: Song, output: Song) { + output.meta.name = song.meta.name; + output.meta.author = song.meta.author; + output.meta.originalAuthor = song.meta.originalAuthor; + output.meta.description = song.meta.description; + output.meta.importName = song.meta.importName; + + output.loop.enabled = song.loop.enabled; + output.loop.startTick = song.loop.startTick; + output.loop.totalLoops = song.loop.totalLoops; + + output.tempo = song.tempo; + output.timeSignature = song.timeSignature; + } + + private resolveInstruments(song: Song, output: Song): Record { // ✅ Remove unused instruments // ✅ Remove instrument info (name, press) - keep sound hash and pitch - const noteCountPerInstrument = getInstrumentNoteCounts(song); + const noteCountPerInstrument = getInstrumentNoteCounts(song); - const instrumentMapping: Record = {}; + const instrumentMapping: Record = {}; - for (const [ - instrumentId, - instrument, - ] of song.instruments.loaded.entries()) { - if (instrument.builtIn) { - instrumentMapping[instrumentId] = instrumentId; - continue; - } + for (const [ + instrumentId, + instrument + ] of song.instruments.loaded.entries()) { + if (instrument.builtIn) { + instrumentMapping[instrumentId] = instrumentId; + continue; + } - // Remove unused instruments (no notes or no sound defined) - const instrumentName = instrument.meta.name; - const customId = instrumentId - output.instruments.firstCustomIndex; - const soundFilePath = this.soundPaths[customId]; - const isTempoChanger = instrument.meta.name === 'Tempo Changer'; + // Remove unused instruments (no notes or no sound defined) + const instrumentName = instrument.meta.name; + const customId = instrumentId - output.instruments.firstCustomIndex; + const soundFilePath = this.soundPaths[customId]; + const isTempoChanger = instrument.meta.name === 'Tempo Changer'; - if ( - !isTempoChanger && + if ( + !isTempoChanger && (noteCountPerInstrument[instrumentId] === 0 || soundFilePath === '') - ) { - console.log( - `Skipping instrument '${instrumentName}' with ${noteCountPerInstrument[instrumentId]}`, - `notes and sound file '${soundFilePath}' (custom ID: ${customId})`, - ); - - continue; - } - - // Remove instrument info - const newInstrumentId = output.instruments.loaded.length; - const newCustomId = newInstrumentId - output.instruments.firstCustomIndex; - - console.log( - `Keeping instrument '${instrumentName}' with`, - `${noteCountPerInstrument[instrumentId]} notes and sound file`, - `'${this.soundPaths[customId]}' (custom ID: ${customId} -> ${newCustomId})`, - ); - - const newInstrument = new Instrument(newInstrumentId, { - name: instrumentName === 'Tempo Changer' ? 'Tempo Changer' : '', - soundFile: soundFilePath, - key: instrument.key, - pressKey: false, - }); - - output.instruments.loaded.push(newInstrument); - instrumentMapping[instrumentId] = newInstrumentId; + ) { + console.log( + `Skipping instrument '${instrumentName}' with ${noteCountPerInstrument[instrumentId]}`, + `notes and sound file '${soundFilePath}' (custom ID: ${customId})` + ); + + continue; + } + + // Remove instrument info + const newInstrumentId = output.instruments.loaded.length; + const newCustomId = newInstrumentId - output.instruments.firstCustomIndex; + + console.log( + `Keeping instrument '${instrumentName}' with`, + `${noteCountPerInstrument[instrumentId]} notes and sound file`, + `'${this.soundPaths[customId]}' (custom ID: ${customId} -> ${newCustomId})` + ); + + const newInstrument = new Instrument(newInstrumentId, { + name : instrumentName === 'Tempo Changer' ? 'Tempo Changer' : '', + soundFile: soundFilePath, + key : instrument.key, + pressKey : false + }); + + output.instruments.loaded.push(newInstrument); + instrumentMapping[instrumentId] = newInstrumentId; + } + + return instrumentMapping; } - return instrumentMapping; - } - - private resolveNotes( - song: Song, - output: Song, - instrumentMapping: Record, - ) { + private resolveNotes( + song: Song, + output: Song, + instrumentMapping: Record + ) { // ✅ Pile notes at the top // ✅ Bake layer volume into note velocity // ✅ Bake layer pan into note pan @@ -117,119 +117,119 @@ export class SongObfuscator { // ✅ Remove everything in locked layers // ✅ Remove all layer info (name, volume, panning) - const resolveKeyAndPitch = (note: Note) => { - const factoredPitch = note.key * 100 + note.pitch; + const resolveKeyAndPitch = (note: Note) => { + const factoredPitch = note.key * 100 + note.pitch; - let key, pitch; + let key, pitch; - // TODO: add regression test for this behavior - if (factoredPitch < 0) { - // Below A0 - key = 0; - pitch = factoredPitch; - } else if (factoredPitch >= 87 * 100) { - // Above C8 - key = 87; - pitch = factoredPitch - 87 * 100; - } else { - key = Math.floor((factoredPitch + 50) / 100); - pitch = ((factoredPitch + 50) % 100) - 50; - } + // TODO: add regression test for this behavior + if (factoredPitch < 0) { + // Below A0 + key = 0; + pitch = factoredPitch; + } else if (factoredPitch >= 87 * 100) { + // Above C8 + key = 87; + pitch = factoredPitch - 87 * 100; + } else { + key = Math.floor((factoredPitch + 50) / 100); + pitch = ((factoredPitch + 50) % 100) - 50; + } - return { key, pitch }; - }; + return { key, pitch }; + }; - const resolveInstrument = (note: Note) => - instrumentMapping[note.instrument]; + const resolveInstrument = (note: Note) => + instrumentMapping[note.instrument]; - const resolveVelocity = (note: Note, layer: Layer) => { - let velocity = ((note.velocity / 100) * layer.volume) / 100; - velocity = Math.round(velocity * 100); + const resolveVelocity = (note: Note, layer: Layer) => { + let velocity = ((note.velocity / 100) * layer.volume) / 100; + velocity = Math.round(velocity * 100); - return velocity; - }; + return velocity; + }; - const resolvePanning = (note: Note, layer: Layer) => { - let panning; + const resolvePanning = (note: Note, layer: Layer) => { + let panning; - if (layer.stereo === 0) { - panning = note.panning; - } else { - panning = (note.panning + layer.stereo) / 2; - } + if (layer.stereo === 0) { + panning = note.panning; + } else { + panning = (note.panning + layer.stereo) / 2; + } - return panning; - }; + return panning; + }; - const getObfuscatedNote = (note: Note, layer: Layer) => { - const { key, pitch } = resolveKeyAndPitch(note); - const instrument = resolveInstrument(note); - const velocity = resolveVelocity(note, layer); - const panning = resolvePanning(note, layer); + const getObfuscatedNote = (note: Note, layer: Layer) => { + const { key, pitch } = resolveKeyAndPitch(note); + const instrument = resolveInstrument(note); + const velocity = resolveVelocity(note, layer); + const panning = resolvePanning(note, layer); - // Create new note - const newNote = new Note(instrument, { - key, - velocity, - panning, - pitch, - }); + // Create new note + const newNote = new Note(instrument, { + key, + velocity, + panning, + pitch + }); - return newNote; - }; + return newNote; + }; - const addNoteToOutput = (tick: number, note: Note) => { - // Adds a note at tick at the first row that does not have a note yet + const addNoteToOutput = (tick: number, note: Note) => { + // Adds a note at tick at the first row that does not have a note yet - let layerIdToAddNoteTo = lastLayerInTick.get(tick); + let layerIdToAddNoteTo = lastLayerInTick.get(tick); - if (layerIdToAddNoteTo === undefined) { - layerIdToAddNoteTo = 0; - } + if (layerIdToAddNoteTo === undefined) { + layerIdToAddNoteTo = 0; + } - lastLayerInTick.set(tick, layerIdToAddNoteTo + 1); + lastLayerInTick.set(tick, layerIdToAddNoteTo + 1); - const layerToAddNoteTo = - output.layers[layerIdToAddNoteTo] || output.createLayer(); + const layerToAddNoteTo = + output.layers[layerIdToAddNoteTo] || output.createLayer(); - output.setNote(tick, layerToAddNoteTo, note); - }; + output.setNote(tick, layerToAddNoteTo, note); + }; - const tempoChangerIds = getTempoChangerInstrumentIds(song); - const lastLayerInTick = new Map(); + const tempoChangerIds = getTempoChangerInstrumentIds(song); + const lastLayerInTick = new Map(); - for (const layer of song.layers) { - // Skip locked and silent layers - if (layer.isLocked || layer.volume === 0) { - continue; - } + for (const layer of song.layers) { + // Skip locked and silent layers + if (layer.isLocked || layer.volume === 0) { + continue; + } - for (const tickStr in layer.notes) { - const note = layer.notes[tickStr]; - const tick = parseInt(tickStr); + for (const tickStr in layer.notes) { + const note = layer.notes[tickStr]; + const tick = parseInt(tickStr); - // Skip silent notes except if they are tempo changers + // Skip silent notes except if they are tempo changers - // @ts-ignore //TODO: fix this - const isTempoChanger = tempoChangerIds.includes(note.instrument); + // @ts-ignore //TODO: fix this + const isTempoChanger = tempoChangerIds.includes(note.instrument); - // @ts-ignore //TODO: fix this - if (note.velocity === 0 && !isTempoChanger) continue; + // @ts-ignore //TODO: fix this + if (note.velocity === 0 && !isTempoChanger) continue; - // Skip notes with deleted instruments + // Skip notes with deleted instruments - // @ts-ignore //TODO: fix this - if (instrumentMapping[note.instrument] === undefined) continue; + // @ts-ignore //TODO: fix this + if (instrumentMapping[note.instrument] === undefined) continue; - // Add obfuscated note to output + // Add obfuscated note to output - // @ts-ignore //TODO: fix this - const newNote = getObfuscatedNote(note, layer); + // @ts-ignore //TODO: fix this + const newNote = getObfuscatedNote(note, layer); - // @ts-ignore //TODO: fix this - if (isTempoChanger) newNote.pitch = note.pitch; - addNoteToOutput(tick, newNote); - } + // @ts-ignore //TODO: fix this + if (isTempoChanger) newNote.pitch = note.pitch; + addNoteToOutput(tick, newNote); + } + } } - } } diff --git a/packages/song/src/pack.ts b/packages/song/src/pack.ts index b46df6cc..99062607 100644 --- a/packages/song/src/pack.ts +++ b/packages/song/src/pack.ts @@ -4,74 +4,74 @@ import JSZip from 'jszip'; import { SongObfuscator } from './obfuscate'; export async function obfuscateAndPackSong( - nbsSong: Song, - soundsArray: string[], - soundsMapping: Record, + nbsSong: Song, + soundsArray: string[], + soundsMapping: Record ) { - // Create a ZIP file with the obfuscated song in the root as 'song.nbs' - // and the sounds in a 'sounds' folder. Return the ZIP file as a buffer. + // Create a ZIP file with the obfuscated song in the root as 'song.nbs' + // and the sounds in a 'sounds' folder. Return the ZIP file as a buffer. - // Import JSZip as a CommonJS module - // (see: https://github.com/Stuk/jszip/issues/890) + // Import JSZip as a CommonJS module + // (see: https://github.com/Stuk/jszip/issues/890) - // Create a new empty ZIP file - const zip = new JSZip(); + // Create a new empty ZIP file + const zip = new JSZip(); - // Create a 'sounds' folder in the ZIP file - const soundsFolder = zip.folder('sounds'); + // Create a 'sounds' folder in the ZIP file + const soundsFolder = zip.folder('sounds'); - if (!soundsFolder) { - throw new Error('Failed to create sounds folder'); - } + if (!soundsFolder) { + throw new Error('Failed to create sounds folder'); + } - for (const sound of soundsArray) { - if (!sound) continue; + for (const sound of soundsArray) { + if (!sound) continue; - const hash = soundsMapping[sound] || ''; + const hash = soundsMapping[sound] || ''; - if (!hash) { - console.error(`Sound file ${sound} not found in sounds mapping`); - continue; - } + if (!hash) { + console.error(`Sound file ${sound} not found in sounds mapping`); + continue; + } - // Download the sound from Mojang servers - const soundFileUrl = `https://resources.download.minecraft.net/${hash.slice( - 0, - 2, - )}/${hash}`; - - let soundFileBuffer: ArrayBuffer; - - try { - const response = await fetch(soundFileUrl); - soundFileBuffer = await response.arrayBuffer(); - console.log(`Retrieved sound file with hash ${hash}`); - } catch (e) { - console.error(`Error retrieving sound file with hash ${hash}: ${e}`); - continue; - } + // Download the sound from Mojang servers + const soundFileUrl = `https://resources.download.minecraft.net/${hash.slice( + 0, + 2 + )}/${hash}`; - // Add sounds to the 'sounds' folder - const soundFileName = hash; - soundsFolder.file(soundFileName, soundFileBuffer); - } + let soundFileBuffer: ArrayBuffer; + + try { + const response = await fetch(soundFileUrl); + soundFileBuffer = await response.arrayBuffer(); + console.log(`Retrieved sound file with hash ${hash}`); + } catch (e) { + console.error(`Error retrieving sound file with hash ${hash}: ${e}`); + continue; + } + + // Add sounds to the 'sounds' folder + const soundFileName = hash; + soundsFolder.file(soundFileName, soundFileBuffer); + } - const soundHashes = soundsArray.map((sound) => soundsMapping[sound] || ''); + const soundHashes = soundsArray.map((sound) => soundsMapping[sound] || ''); - // Obfuscate the song and add it to the ZIP file - const obfuscatedSong = SongObfuscator.obfuscateSong(nbsSong, soundHashes); - const songBuffer = obfuscatedSong.toArrayBuffer(); - zip.file('song.nbs', songBuffer); + // Obfuscate the song and add it to the ZIP file + const obfuscatedSong = SongObfuscator.obfuscateSong(nbsSong, soundHashes); + const songBuffer = obfuscatedSong.toArrayBuffer(); + zip.file('song.nbs', songBuffer); - // Generate the ZIP file as a buffer - const zipBuffer = await zip.generateAsync({ - type: 'nodebuffer', - mimeType: 'application/zip', // default - comment: 'Uploaded to Note Block World', + // Generate the ZIP file as a buffer + const zipBuffer = await zip.generateAsync({ + type : 'nodebuffer', + mimeType: 'application/zip', // default + comment : 'Uploaded to Note Block World' // TODO: explore adding a password to the ZIP file // https://github.com/Stuk/jszip/issues/115 // https://github.com/Stuk/jszip/pull/696 - }); + }); - return zipBuffer; + return zipBuffer; } diff --git a/packages/song/src/parse.ts b/packages/song/src/parse.ts index 202daf8d..d0ff4da6 100644 --- a/packages/song/src/parse.ts +++ b/packages/song/src/parse.ts @@ -5,73 +5,73 @@ import type { InstrumentArray, SongFileType } from './types'; import { getInstrumentNoteCounts } from './util'; async function getVanillaSoundList() { - // Object that maps sound paths to their respective hashes + // Object that maps sound paths to their respective hashes - const response = await fetch( - process.env.NEXT_PUBLIC_API_URL + '/data/soundList.json', - ); + const response = await fetch( + process.env.NEXT_PUBLIC_API_URL + '/data/soundList.json' + ); - const soundsMapping = (await response.json()) as Record; - const vanillaSoundList = Object.keys(soundsMapping); + const soundsMapping = (await response.json()) as Record; + const vanillaSoundList = Object.keys(soundsMapping); - return vanillaSoundList; + return vanillaSoundList; } export async function parseSongFromBuffer( - buffer: ArrayBuffer, + buffer: ArrayBuffer ): Promise { - const song = fromArrayBuffer(buffer); - - if (song.length === 0) { - throw new Error('Invalid song'); - } - - const quadTree = new NoteQuadTree(song); - - const vanillaSoundList = await getVanillaSoundList(); - - return { - title: song.meta.name, - author: song.meta.author, - originalAuthor: song.meta.originalAuthor, - description: song.meta.description, - length: quadTree.width, - height: quadTree.height, - arrayBuffer: buffer, - notes: quadTree, - instruments: getInstruments(song, vanillaSoundList), - }; + const song = fromArrayBuffer(buffer); + + if (song.length === 0) { + throw new Error('Invalid song'); + } + + const quadTree = new NoteQuadTree(song); + + const vanillaSoundList = await getVanillaSoundList(); + + return { + title : song.meta.name, + author : song.meta.author, + originalAuthor: song.meta.originalAuthor, + description : song.meta.description, + length : quadTree.width, + height : quadTree.height, + arrayBuffer : buffer, + notes : quadTree, + instruments : getInstruments(song, vanillaSoundList) + }; } const getInstruments = ( - song: Song, - vanillaSoundList: string[], + song: Song, + vanillaSoundList: string[] ): InstrumentArray => { - const blockCounts = getInstrumentNoteCounts(song); - - const firstCustomIndex = song.instruments.firstCustomIndex; + const blockCounts = getInstrumentNoteCounts(song); - const customInstruments = song.instruments.loaded.filter( - (instrument) => instrument.builtIn === false, - ); + const firstCustomIndex = song.instruments.firstCustomIndex; - return customInstruments.map((instrument, id) => { - let soundFile = ''; - - const fullSoundPath = instrument.meta.soundFile.replace( - 'minecraft/', - 'minecraft/sounds/', + const customInstruments = song.instruments.loaded.filter( + (instrument) => instrument.builtIn === false ); - if (vanillaSoundList.includes(fullSoundPath)) { - soundFile = fullSoundPath; - } - - return { - id: id, - name: instrument.meta.name || '', - file: soundFile, - count: blockCounts[id + firstCustomIndex] || 0, - }; - }); + return customInstruments.map((instrument, id) => { + let soundFile = ''; + + const fullSoundPath = instrument.meta.soundFile.replace( + 'minecraft/', + 'minecraft/sounds/' + ); + + if (vanillaSoundList.includes(fullSoundPath)) { + soundFile = fullSoundPath; + } + + return { + id : id, + name : instrument.meta.name || '', + file : soundFile, + count: blockCounts[id + firstCustomIndex] || 0 + }; + }); }; diff --git a/packages/song/src/stats.ts b/packages/song/src/stats.ts index 4d53a0d4..35661f7b 100644 --- a/packages/song/src/stats.ts +++ b/packages/song/src/stats.ts @@ -7,325 +7,325 @@ import { getTempoChangerInstrumentIds } from './util'; // SongStatsGenerator.getSongStats(song) export class SongStatsGenerator { - public static getSongStats(song: Song) { - return new SongStatsGenerator(song).toObject(); - } - - private song: Song; - private stats: SongStatsType; - - private constructor(song: Song) { - this.song = song; - - const midiFileName = this.getMidiFileName(); - - const tempoChangerInstrumentIds = getTempoChangerInstrumentIds(this.song); - - const { - noteCount, - tickCount, - layerCount, - outOfRangeNoteCount, - detunedNoteCount, - customInstrumentNoteCount, - incompatibleNoteCount, - instrumentNoteCounts, - } = this.getCounts(tempoChangerInstrumentIds); - - const tempoSegments = this.getTempoSegments(tempoChangerInstrumentIds); - - const tempo = this.getTempo(); - const tempoRange = this.getTempoRange(tempoSegments); - const timeSignature = this.getTimeSignature(); - const duration = this.getDuration(tempoSegments); - const loop = this.getLoop(); - const loopStartTick = this.getLoopStartTick(); - const minutesSpent = this.getMinutesSpent(); - - const { vanillaInstrumentCount, customInstrumentCount } = - this.getVanillaAndCustomUsedInstrumentCounts( - instrumentNoteCounts, - tempoChangerInstrumentIds, - ); - - const firstCustomInstrumentIndex = this.getFirstCustomInstrumentIndex(); - - const compatible = incompatibleNoteCount === 0; - - this.stats = { - midiFileName, - noteCount, - tickCount, - layerCount, - tempo, - tempoRange, - timeSignature, - duration, - loop, - loopStartTick, - minutesSpent, - vanillaInstrumentCount, - customInstrumentCount, - firstCustomInstrumentIndex, - instrumentNoteCounts, - customInstrumentNoteCount, - outOfRangeNoteCount, - detunedNoteCount, - incompatibleNoteCount, - compatible, - }; - } - - private toObject() { - return this.stats; - } - - private getMidiFileName(): string { - return this.song.meta.importName || ''; - } - - private getCounts(tempoChangerInstruments: number[]): { - noteCount: number; - tickCount: number; - layerCount: number; - outOfRangeNoteCount: number; - detunedNoteCount: number; - customInstrumentNoteCount: number; - incompatibleNoteCount: number; - instrumentNoteCounts: number[]; - } { - let noteCount = 0; - let tickCount = 0; - let layerCount = 0; - let outOfRangeNoteCount = 0; - let detunedNoteCount = 0; - let customInstrumentNoteCount = 0; - let incompatibleNoteCount = 0; - - const instrumentNoteCounts = Array( - this.song.instruments.loaded.length, - ).fill(0); - - for (const [layerId, layer] of this.song.layers.entries()) { - for (const tickStr in layer.notes) { - const note = layer.notes[tickStr]; - const tick = parseInt(tickStr); - - if (tick > tickCount) { - tickCount = tick; - } + public static getSongStats(song: Song) { + return new SongStatsGenerator(song).toObject(); + } + + private song : Song; + private stats: SongStatsType; + + private constructor(song: Song) { + this.song = song; + + const midiFileName = this.getMidiFileName(); + + const tempoChangerInstrumentIds = getTempoChangerInstrumentIds(this.song); + + const { + noteCount, + tickCount, + layerCount, + outOfRangeNoteCount, + detunedNoteCount, + customInstrumentNoteCount, + incompatibleNoteCount, + instrumentNoteCounts + } = this.getCounts(tempoChangerInstrumentIds); + + const tempoSegments = this.getTempoSegments(tempoChangerInstrumentIds); + + const tempo = this.getTempo(); + const tempoRange = this.getTempoRange(tempoSegments); + const timeSignature = this.getTimeSignature(); + const duration = this.getDuration(tempoSegments); + const loop = this.getLoop(); + const loopStartTick = this.getLoopStartTick(); + const minutesSpent = this.getMinutesSpent(); + + const { vanillaInstrumentCount, customInstrumentCount } = + this.getVanillaAndCustomUsedInstrumentCounts( + instrumentNoteCounts, + tempoChangerInstrumentIds + ); + + const firstCustomInstrumentIndex = this.getFirstCustomInstrumentIndex(); + + const compatible = incompatibleNoteCount === 0; + + this.stats = { + midiFileName, + noteCount, + tickCount, + layerCount, + tempo, + tempoRange, + timeSignature, + duration, + loop, + loopStartTick, + minutesSpent, + vanillaInstrumentCount, + customInstrumentCount, + firstCustomInstrumentIndex, + instrumentNoteCounts, + customInstrumentNoteCount, + outOfRangeNoteCount, + detunedNoteCount, + incompatibleNoteCount, + compatible + }; + } - // The song may store empty layers at the bottom. We actually want the last layer with a note in it - if (layerId > layerCount) { - layerCount = layerId; + private toObject() { + return this.stats; + } + + private getMidiFileName(): string { + return this.song.meta.importName || ''; + } + + private getCounts(tempoChangerInstruments: number[]): { + noteCount : number; + tickCount : number; + layerCount : number; + outOfRangeNoteCount : number; + detunedNoteCount : number; + customInstrumentNoteCount: number; + incompatibleNoteCount : number; + instrumentNoteCounts : number[]; + } { + let noteCount = 0; + let tickCount = 0; + let layerCount = 0; + let outOfRangeNoteCount = 0; + let detunedNoteCount = 0; + let customInstrumentNoteCount = 0; + let incompatibleNoteCount = 0; + + const instrumentNoteCounts = Array( + this.song.instruments.loaded.length + ).fill(0); + + for (const [layerId, layer] of this.song.layers.entries()) { + for (const tickStr in layer.notes) { + const note = layer.notes[tickStr]; + const tick = parseInt(tickStr); + + if (tick > tickCount) { + tickCount = tick; + } + + // The song may store empty layers at the bottom. We actually want the last layer with a note in it + if (layerId > layerCount) { + layerCount = layerId; + } + + // @ts-ignore //TODO: fix this + const effectivePitch = note.key + note.pitch / 100; + + // Differences between Note Block Studio and this implementation: + + // DETUNED NOTES + // The behavior here differs from Open Note Block Studio v3.10, since it doesn't consider + // non-integer/microtonal notes when deciding if a song is compatible. This is likely to + // change in the future. Since this is relevant to knowing accurately if vanilla note blocks + // can support the song, NBW uses a more modern approach of counting microtonal notes as + // outside the 2-octave range - treating it as only the piano keys between 33-57 and not + // anything in the interval between them. + + // INSTRUMENT PITCH + // We also use the instrument's original pitch when determining if it's out-of-range. + // Note Block Studio also doesn't take this into account - since importing custom sounds + // into the game was out of question back in the legacy versions, we used to only need + // to worry about vanilla note block compatibility (for schematics). + // Now that data packs are a thing, out-of-range notes become relevant not only due to + // note block's key range, but also because the same limit applies to Minecraft's audio + // engine as a whole (e.g. /playsound etc). + // But if the instrument's key is not set to F#4 (45), the range supported by Minecraft + // (without needing to re-pitch the sound externally) also changes (it is always one octave + // above and below the instrument's key). Note Block Studio doesn't account for this - the + // supported range is always F#3 to F#5 (33-57) - but we do because it's useful to know if + // the default Minecraft sounds are enough to play the song (i.e. you can play it using only + // a custom sounds.json in a resource pack). + + // @ts-ignore //TODO: fix this + const instrumentKey = this.song.instruments.loaded[note.instrument].key; // F#4 = 45 + const minRange = 45 - (instrumentKey - 45) - 12; // F#3 = 33 + const maxRange = 45 - (instrumentKey - 45) + 12; // F#5 = 57 + + const isOutOfRange = + effectivePitch < minRange || effectivePitch > maxRange; + + // Don't consider tempo changers as detuned notes or custom instruments + const isTempoChanger = tempoChangerInstruments.includes( + // @ts-ignore //TODO: fix this + note.instrument + ); + + // @ts-ignore //TODO: fix this + const hasDetune = note.pitch % 100 !== 0; + + const usesCustomInstrument = // @ts-ignore //TODO: fix this + note.instrument >= this.song.instruments.firstCustomIndex; + + if (!isTempoChanger) { + if (isOutOfRange) outOfRangeNoteCount++; + if (hasDetune) detunedNoteCount++; + if (usesCustomInstrument) customInstrumentNoteCount++; + if (isOutOfRange || hasDetune || usesCustomInstrument) + incompatibleNoteCount++; + } + + // @ts-ignore //TODO: fix this + instrumentNoteCounts[note.instrument]++; + noteCount++; + } } - // @ts-ignore //TODO: fix this - const effectivePitch = note.key + note.pitch / 100; - - // Differences between Note Block Studio and this implementation: - - // DETUNED NOTES - // The behavior here differs from Open Note Block Studio v3.10, since it doesn't consider - // non-integer/microtonal notes when deciding if a song is compatible. This is likely to - // change in the future. Since this is relevant to knowing accurately if vanilla note blocks - // can support the song, NBW uses a more modern approach of counting microtonal notes as - // outside the 2-octave range - treating it as only the piano keys between 33-57 and not - // anything in the interval between them. - - // INSTRUMENT PITCH - // We also use the instrument's original pitch when determining if it's out-of-range. - // Note Block Studio also doesn't take this into account - since importing custom sounds - // into the game was out of question back in the legacy versions, we used to only need - // to worry about vanilla note block compatibility (for schematics). - // Now that data packs are a thing, out-of-range notes become relevant not only due to - // note block's key range, but also because the same limit applies to Minecraft's audio - // engine as a whole (e.g. /playsound etc). - // But if the instrument's key is not set to F#4 (45), the range supported by Minecraft - // (without needing to re-pitch the sound externally) also changes (it is always one octave - // above and below the instrument's key). Note Block Studio doesn't account for this - the - // supported range is always F#3 to F#5 (33-57) - but we do because it's useful to know if - // the default Minecraft sounds are enough to play the song (i.e. you can play it using only - // a custom sounds.json in a resource pack). - - // @ts-ignore //TODO: fix this - const instrumentKey = this.song.instruments.loaded[note.instrument].key; // F#4 = 45 - const minRange = 45 - (instrumentKey - 45) - 12; // F#3 = 33 - const maxRange = 45 - (instrumentKey - 45) + 12; // F#5 = 57 - - const isOutOfRange = - effectivePitch < minRange || effectivePitch > maxRange; - - // Don't consider tempo changers as detuned notes or custom instruments - const isTempoChanger = tempoChangerInstruments.includes( - // @ts-ignore //TODO: fix this - note.instrument, - ); + // Get end of last tick/layer instead of start + tickCount++; + layerCount++; + + return { + noteCount, + tickCount, + layerCount, + outOfRangeNoteCount, + detunedNoteCount, + customInstrumentNoteCount, + incompatibleNoteCount, + instrumentNoteCounts + }; + } - // @ts-ignore //TODO: fix this - const hasDetune = note.pitch % 100 !== 0; + private getTempo(): number { + return this.song.tempo; + } - const usesCustomInstrument = // @ts-ignore //TODO: fix this - note.instrument >= this.song.instruments.firstCustomIndex; + private getTempoRange( + tempoSegments: Record + ): number[] | null { + const tempoValues = Object.values(tempoSegments); + // If song has only the tempo set at the beginning, we have no tempo changes (indicated as null) + if (tempoValues.length === 1) return null; - if (!isTempoChanger) { - if (isOutOfRange) outOfRangeNoteCount++; - if (hasDetune) detunedNoteCount++; - if (usesCustomInstrument) customInstrumentNoteCount++; - if (isOutOfRange || hasDetune || usesCustomInstrument) - incompatibleNoteCount++; - } + const minTempo = Math.min(...tempoValues); + const maxTempo = Math.max(...tempoValues); - // @ts-ignore //TODO: fix this - instrumentNoteCounts[note.instrument]++; - noteCount++; - } + return [minTempo, maxTempo]; } - // Get end of last tick/layer instead of start - tickCount++; - layerCount++; - - return { - noteCount, - tickCount, - layerCount, - outOfRangeNoteCount, - detunedNoteCount, - customInstrumentNoteCount, - incompatibleNoteCount, - instrumentNoteCounts, - }; - } - - private getTempo(): number { - return this.song.tempo; - } - - private getTempoRange( - tempoSegments: Record, - ): number[] | null { - const tempoValues = Object.values(tempoSegments); - // If song has only the tempo set at the beginning, we have no tempo changes (indicated as null) - if (tempoValues.length === 1) return null; - - const minTempo = Math.min(...tempoValues); - const maxTempo = Math.max(...tempoValues); - - return [minTempo, maxTempo]; - } - - private getTempoSegments( - tempoChangerInstruments: number[], - ): Record { - const tempoSegments: Record = {}; - - if (tempoChangerInstruments.length > 0) { - // TODO: toReversed - for (const layer of Array.from(this.song.layers).reverse()) { - for (const tickStr in layer.notes) { - const note = layer.notes[tickStr]; - const tick = parseInt(tickStr); - - // @ts-ignore //TODO: fix this // Not a tempo changer - if (!tempoChangerInstruments.includes(note.instrument)) continue; - - // The tempo change isn't effective if there's another tempo changer in the same tick, - // so we iterate layers bottom to top and skip the block if a tempo changer has already - // been found in this tick - if (tick in tempoSegments) continue; - - // @ts-ignore //TODO: fix this - const tempo = Math.abs(note.pitch) / 15; // note pitch = BPM = (t/s) * 15 - tempoSegments[tick] = tempo; + private getTempoSegments( + tempoChangerInstruments: number[] + ): Record { + const tempoSegments: Record = {}; + + if (tempoChangerInstruments.length > 0) { + // TODO: toReversed + for (const layer of Array.from(this.song.layers).reverse()) { + for (const tickStr in layer.notes) { + const note = layer.notes[tickStr]; + const tick = parseInt(tickStr); + + // @ts-ignore //TODO: fix this // Not a tempo changer + if (!tempoChangerInstruments.includes(note.instrument)) continue; + + // The tempo change isn't effective if there's another tempo changer in the same tick, + // so we iterate layers bottom to top and skip the block if a tempo changer has already + // been found in this tick + if (tick in tempoSegments) continue; + + // @ts-ignore //TODO: fix this + const tempo = Math.abs(note.pitch) / 15; // note pitch = BPM = (t/s) * 15 + tempoSegments[tick] = tempo; + } + } } - } + + // If there isn't a tempo changer at tick 0, we add one there to set the starting tempo + tempoSegments[0] = 0 in tempoSegments ? tempoSegments[0] : this.song.tempo; + + return tempoSegments; + } + + private getTimeSignature(): number { + return this.song.timeSignature; } - // If there isn't a tempo changer at tick 0, we add one there to set the starting tempo - tempoSegments[0] = 0 in tempoSegments ? tempoSegments[0] : this.song.tempo; + private getDuration(tempoSegments: Record): number { + const tempoChangeTicks = Object.keys(tempoSegments).map((tick) => + parseInt(tick) + ); + + tempoChangeTicks.sort((a, b) => a - b); + + let duration = 0; - return tempoSegments; - } + // Add end of last tick to close last tempo segment + const lastTick = this.song.length + 1; - private getTimeSignature(): number { - return this.song.timeSignature; - } + if (!(lastTick in tempoChangeTicks)) { + tempoChangeTicks.push(lastTick); + } - private getDuration(tempoSegments: Record): number { - const tempoChangeTicks = Object.keys(tempoSegments).map((tick) => - parseInt(tick), - ); + // Iterate pairs of tempo change ticks and calculate their length + for (let i = 0; i < tempoChangeTicks.length - 1; i++) { + const currTick = tempoChangeTicks[i]; + const nextTick = tempoChangeTicks[i + 1]; - tempoChangeTicks.sort((a, b) => a - b); + // @ts-ignore //TODO: fix this + const currTempo = tempoSegments[currTick]; - let duration = 0; + // @ts-ignore //TODO: fix this + const segmentDurationTicks = nextTick - currTick; + const timePerTick = 1 / currTempo; - // Add end of last tick to close last tempo segment - const lastTick = this.song.length + 1; + duration += segmentDurationTicks * timePerTick; + } - if (!(lastTick in tempoChangeTicks)) { - tempoChangeTicks.push(lastTick); + return duration; } - // Iterate pairs of tempo change ticks and calculate their length - for (let i = 0; i < tempoChangeTicks.length - 1; i++) { - const currTick = tempoChangeTicks[i]; - const nextTick = tempoChangeTicks[i + 1]; + private getLoop(): boolean { + return this.song.loop.enabled; + } - // @ts-ignore //TODO: fix this - const currTempo = tempoSegments[currTick]; + private getLoopStartTick(): number { + return this.song.loop.startTick; + } - // @ts-ignore //TODO: fix this - const segmentDurationTicks = nextTick - currTick; - const timePerTick = 1 / currTempo; + private getMinutesSpent(): number { + return this.song.stats.minutesSpent; + } - duration += segmentDurationTicks * timePerTick; + private getVanillaAndCustomUsedInstrumentCounts( + noteCountsPerInstrument: number[], + tempoChangerInstruments: number[] + ): { + vanillaInstrumentCount: number; + customInstrumentCount : number; + } { + const firstCustomIndex = this.song.instruments.firstCustomIndex; + + // We want the count of instruments that have at least one note in the song + // (which tells us how many instruments are effectively used in the song) + + const vanillaInstrumentCount = noteCountsPerInstrument + .slice(0, firstCustomIndex) + .filter((count) => count > 0).length; + + const customInstrumentCount = noteCountsPerInstrument + .filter((_, index) => !tempoChangerInstruments.includes(index)) + .slice(firstCustomIndex) + .filter((count) => count > 0).length; + + return { + vanillaInstrumentCount, + customInstrumentCount + }; } - return duration; - } - - private getLoop(): boolean { - return this.song.loop.enabled; - } - - private getLoopStartTick(): number { - return this.song.loop.startTick; - } - - private getMinutesSpent(): number { - return this.song.stats.minutesSpent; - } - - private getVanillaAndCustomUsedInstrumentCounts( - noteCountsPerInstrument: number[], - tempoChangerInstruments: number[], - ): { - vanillaInstrumentCount: number; - customInstrumentCount: number; - } { - const firstCustomIndex = this.song.instruments.firstCustomIndex; - - // We want the count of instruments that have at least one note in the song - // (which tells us how many instruments are effectively used in the song) - - const vanillaInstrumentCount = noteCountsPerInstrument - .slice(0, firstCustomIndex) - .filter((count) => count > 0).length; - - const customInstrumentCount = noteCountsPerInstrument - .filter((_, index) => !tempoChangerInstruments.includes(index)) - .slice(firstCustomIndex) - .filter((count) => count > 0).length; - - return { - vanillaInstrumentCount, - customInstrumentCount, - }; - } - - private getFirstCustomInstrumentIndex(): number { - return this.song.instruments.firstCustomIndex; - } + private getFirstCustomInstrumentIndex(): number { + return this.song.instruments.firstCustomIndex; + } } diff --git a/packages/song/src/types.ts b/packages/song/src/types.ts index 1de71613..65ded8b3 100644 --- a/packages/song/src/types.ts +++ b/packages/song/src/types.ts @@ -3,31 +3,31 @@ import { SongStats } from '@nbw/database'; import { NoteQuadTree } from './notes'; export type SongFileType = { - title: string; - description: string; - author: string; - originalAuthor: string; - length: number; - height: number; - arrayBuffer: ArrayBuffer; - notes: NoteQuadTree; - instruments: InstrumentArray; + title : string; + description : string; + author : string; + originalAuthor: string; + length : number; + height : number; + arrayBuffer : ArrayBuffer; + notes : NoteQuadTree; + instruments : InstrumentArray; }; export type InstrumentArray = Instrument[]; export type Note = { - tick: number; - layer: number; - key: number; - instrument: number; + tick : number; + layer : number; + key : number; + instrument: number; }; export type Instrument = { - id: number; - name: string; - file: string; - count: number; + id : number; + name : string; + file : string; + count: number; }; export type SongStatsType = InstanceType; diff --git a/packages/song/src/util.ts b/packages/song/src/util.ts index 00e96dbd..5f32b5ba 100644 --- a/packages/song/src/util.ts +++ b/packages/song/src/util.ts @@ -1,28 +1,28 @@ import { Song } from '@encode42/nbs.js'; export function getTempoChangerInstrumentIds(song: Song): number[] { - return song.instruments.loaded.flatMap((instrument, id) => - instrument.meta.name === 'Tempo Changer' ? [id] : [], - ); + return song.instruments.loaded.flatMap((instrument, id) => + instrument.meta.name === 'Tempo Changer' ? [id] : [] + ); } export function getInstrumentNoteCounts(song: Song): Record { - const blockCounts = Object.fromEntries( - Object.keys(song.instruments.loaded).map((instrumentId) => [ - instrumentId, - 0, - ]), - ); + const blockCounts = Object.fromEntries( + Object.keys(song.instruments.loaded).map((instrumentId) => [ + instrumentId, + 0 + ]) + ); - for (const layer of song.layers) { - for (const tick in layer.notes) { - const note = layer.notes[tick]; + for (const layer of song.layers) { + for (const tick in layer.notes) { + const note = layer.notes[tick]; - // @ts-ignore //TODO: fix this - const instrumentId = note.instrument; - blockCounts[instrumentId] = (blockCounts[instrumentId] || 0) + 1; + // @ts-ignore //TODO: fix this + const instrumentId = note.instrument; + blockCounts[instrumentId] = (blockCounts[instrumentId] || 0) + 1; + } } - } - return blockCounts; + return blockCounts; } diff --git a/packages/song/tests/song/index.spec.ts b/packages/song/tests/song/index.spec.ts index 08b8d48c..d99a00c6 100644 --- a/packages/song/tests/song/index.spec.ts +++ b/packages/song/tests/song/index.spec.ts @@ -1,9 +1,10 @@ // @ts-nocheck // TODO: fix this import assert from 'assert'; -import { openSongFromPath } from './util'; import { SongStatsGenerator } from '../../src/stats'; +import { openSongFromPath } from './util'; + // TO RUN: // // From the root of the 'shared' package, run: @@ -12,185 +13,185 @@ import { SongStatsGenerator } from '../../src/stats'; // 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', + 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))]; - }), + 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.', () => { + 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; + const stats = testSongStats.customInstrumentUsage; + const firstCustomIndex = stats.firstCustomInstrumentIndex; - assert(stats.customInstrumentCount === 2); - assert(stats.customInstrumentNoteCount > 0); + 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.instrumentNoteCounts[firstCustomIndex + 0] === 3); + assert(stats.instrumentNoteCounts[firstCustomIndex + 1] === 0); + assert(stats.instrumentNoteCounts[firstCustomIndex + 2] === 2); - assert(stats.compatible === false); - }); + 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; + 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; + 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(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); - }); + // 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; + 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; + 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(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.", () => { + 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 stats = testSongStats.tempoChangerDifferentStart; - const duration = (1 / 20 + 1 / 12 + 1 / 14 + 1 / 16 + 1 / 18) * 4; + 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()); - }); + 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; + 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; + 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()); - }); + 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; + 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; + 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(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); - }); + assert(stats.detunedNoteCount === 0); + }); }); diff --git a/packages/song/tests/song/util.ts b/packages/song/tests/song/util.ts index 40698920..fa729617 100644 --- a/packages/song/tests/song/util.ts +++ b/packages/song/tests/song/util.ts @@ -4,25 +4,25 @@ import { join, resolve } from 'path'; import { Song, fromArrayBuffer } from '@encode42/nbs.js'; export function openSongFromPath(path: string): Song { - // Specify the relative path to the file - const filePath = join(resolve(__dirname), path); + // Specify the relative path to the file + const filePath = join(resolve(__dirname), path); - // Read the file and get its array buffer - const buffer = asArrayBuffer(readFileSync(filePath)); + // Read the file and get its array buffer + const buffer = asArrayBuffer(readFileSync(filePath)); - const song = fromArrayBuffer(buffer); + const song = fromArrayBuffer(buffer); - return song; + return song; } function asArrayBuffer(buffer: Buffer): ArrayBuffer { - const arrayBuffer = new ArrayBuffer(buffer.length); - const view = new Uint8Array(arrayBuffer); + const arrayBuffer = new ArrayBuffer(buffer.length); + const view = new Uint8Array(arrayBuffer); - for (let i = 0; i < buffer.length; ++i) { + for (let i = 0; i < buffer.length; ++i) { // @ts-ignore //TODO: fix this - view[i] = buffer[i]; - } + view[i] = buffer[i]; + } - return arrayBuffer; + return arrayBuffer; } diff --git a/packages/sounds/bun.lock b/packages/sounds/bun.lock deleted file mode 100644 index 591943e0..00000000 --- a/packages/sounds/bun.lock +++ /dev/null @@ -1,29 +0,0 @@ -{ - "lockfileVersion": 1, - "workspaces": { - "": { - "name": "sounds", - "devDependencies": { - "@types/bun": "latest", - }, - "peerDependencies": { - "typescript": "^5", - }, - }, - }, - "packages": { - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], - - "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], - - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], - - "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], - - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - - "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], - } -} diff --git a/packages/sounds/src/fetchSoundList.ts b/packages/sounds/src/fetchSoundList.ts index e1d07d7c..69d2e289 100644 --- a/packages/sounds/src/fetchSoundList.ts +++ b/packages/sounds/src/fetchSoundList.ts @@ -1,86 +1,86 @@ import type { AssetIndexData, VersionData, VersionManifest } from './types'; const MANIFEST_URL = - 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json'; + 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json'; async function fetchWithCache(url: string) { - const response = await fetch(url); - console.log('fetchWithCache', url, response.status); - return response.json(); + const response = await fetch(url); + console.log('fetchWithCache', url, response.status); + return response.json(); } async function fetchVersionManifest() { - const url = MANIFEST_URL; - const data = await fetchWithCache(url); - return data as VersionManifest; + const url = MANIFEST_URL; + const data = await fetchWithCache(url); + return data as VersionManifest; } async function getLatestVersion(type: 'release' | 'snapshot' = 'release') { - const manifestData = await fetchVersionManifest(); - const version = manifestData.latest[type]; - return version; + const manifestData = await fetchVersionManifest(); + const version = manifestData.latest[type]; + return version; } async function getVersionSummary(version: string) { - const manifestData = await fetchVersionManifest(); - const versionData = manifestData.versions.find((v) => v.id === version); + const manifestData = await fetchVersionManifest(); + const versionData = manifestData.versions.find((v) => v.id === version); - if (!versionData) { - throw new Error(`Version ${version} not found`); - } + if (!versionData) { + throw new Error(`Version ${version} not found`); + } - return versionData; + return versionData; } async function getVersionData(version: string) { - const versionData = await getVersionSummary(version); - const url = versionData.url; - const data = await fetchWithCache(url); - return data as VersionData; + const versionData = await getVersionSummary(version); + const url = versionData.url; + const data = await fetchWithCache(url); + return data as VersionData; } async function getAssetIndexSummary(version: string) { - const versionData = await getVersionData(version); - const assetIndex = versionData.assetIndex; - return assetIndex; + const versionData = await getVersionData(version); + const assetIndex = versionData.assetIndex; + return assetIndex; } async function getAssetIndexObjects(version: string) { - const assetIndex = await getAssetIndexSummary(version); - const url = assetIndex.url; - const data = (await fetchWithCache(url)) as AssetIndexData; - return data.objects; + const assetIndex = await getAssetIndexSummary(version); + const url = assetIndex.url; + const data = (await fetchWithCache(url)) as AssetIndexData; + return data.objects; } async function getAssetIndexSounds(version: string) { - const objects = await getAssetIndexObjects(version); + const objects = await getAssetIndexObjects(version); - // Get a new record with only keys that end with '.ogg' - const sounds = Object.entries(objects).filter(([key]) => - key.endsWith('.ogg'), - ); + // Get a new record with only keys that end with '.ogg' + const sounds = Object.entries(objects).filter(([key]) => + key.endsWith('.ogg') + ); - return sounds; + return sounds; } async function getSoundList(version: string) { - const sounds = await getAssetIndexSounds(version); + const sounds = await getAssetIndexSounds(version); - // Return an object with sound names as keys and hashes as values - const soundList = Object.fromEntries( - sounds.map(([key, { hash }]) => [key, hash]), - ); + // Return an object with sound names as keys and hashes as values + const soundList = Object.fromEntries( + sounds.map(([key, { hash }]) => [key, hash]) + ); - return soundList; + return soundList; } export async function getLatestVersionSoundList() { - const version = await getLatestVersion(); - const soundList = await getSoundList(version); - return soundList; + const version = await getLatestVersion(); + const soundList = await getSoundList(version); + return soundList; } // Export the return type of the function as SoundList export type SoundListType = Awaited< - ReturnType + ReturnType >; diff --git a/packages/sounds/src/types.ts b/packages/sounds/src/types.ts index 73776a5b..69d36f6c 100644 --- a/packages/sounds/src/types.ts +++ b/packages/sounds/src/types.ts @@ -1,98 +1,98 @@ export type VersionSummary = { - id: string; - type: 'release' | 'snapshot' | 'old_beta' | 'old_alpha'; - url: string; - time: string; - releaseTime: string; - sha1: string; - complianceLevel: number; + id : string; + type : 'release' | 'snapshot' | 'old_beta' | 'old_alpha'; + url : string; + time : string; + releaseTime : string; + sha1 : string; + complianceLevel: number; }; export type VersionManifest = { - latest: { - release: string; - snapshot: string; - }; - versions: VersionSummary[]; + latest: { + release : string; + snapshot: string; + }; + versions: VersionSummary[]; }; type Action = 'allow' | 'disallow'; type OS = { - name: string; + name: string; }; type Rule = { - action: Action; - os: OS; + action: Action; + os : OS; }; type Artifact = { - path: string; - sha1: string; - size: number; - url: string; + path: string; + sha1: string; + size: number; + url : string; }; type Downloads = { - artifact: Artifact; + artifact: Artifact; }; type Library = { - downloads: Downloads; - name: string; - rules?: Rule[]; + downloads: Downloads; + name : string; + rules? : Rule[]; }; type File = { - id: string; - sha1: string; - size: number; - url: string; + id : string; + sha1: string; + size: number; + url : string; }; type Client = { - argument: string; - file: File; - type: string; + argument: string; + file : File; + type : string; }; type Logging = { - client: Client; + client: Client; }; export type VersionData = { - arguments: { - game: (string | { rules: Rule[]; value: string })[]; - jvm: string[]; - }; - assetIndex: { - id: string; - sha1: string; - size: number; - totalSize: number; - url: string; - }; - assets: string; - complianceLevel: number; - downloads: { - client: Artifact; - client_mappings: Artifact; - server: Artifact; - server_mappings: Artifact; - }; - id: string; - libraries: Library[]; - logging: Logging; - mainClass: string; - minimumLauncherVersion: number; - releaseTime: string; - time: string; - type: string; + arguments: { + game: (string | { rules: Rule[]; value: string })[]; + jvm : string[]; + }; + assetIndex: { + id : string; + sha1 : string; + size : number; + totalSize: number; + url : string; + }; + assets : string; + complianceLevel: number; + downloads: { + client : Artifact; + client_mappings: Artifact; + server : Artifact; + server_mappings: Artifact; + }; + id : string; + libraries : Library[]; + logging : Logging; + mainClass : string; + minimumLauncherVersion: number; + releaseTime : string; + time : string; + type : string; }; export type AssetIndexRecord = Record; export type AssetIndexData = { - objects: AssetIndexRecord; + objects: AssetIndexRecord; }; diff --git a/packages/sounds/src/util.ts b/packages/sounds/src/util.ts index 75cac05d..2754cf31 100644 --- a/packages/sounds/src/util.ts +++ b/packages/sounds/src/util.ts @@ -3,23 +3,23 @@ export type RecordKey = string | number | symbol; export class TwoWayMap { - private map: Map; - private reverseMap: Map; + private map : Map; + private reverseMap: Map; - constructor(map: Map) { - this.map = map; - this.reverseMap = new Map(); + constructor(map: Map) { + this.map = map; + this.reverseMap = new Map(); - for (const [key, value] of map.entries()) { - this.reverseMap.set(value, key); + for (const [key, value] of map.entries()) { + this.reverseMap.set(value, key); + } } - } - get(key: T): U | undefined { - return this.map.get(key); - } + get(key: T): U | undefined { + return this.map.get(key); + } - revGet(key: U): T | undefined { - return this.reverseMap.get(key); - } + revGet(key: U): T | undefined { + return this.reverseMap.get(key); + } } diff --git a/packages/thumbnail/bun.lock b/packages/thumbnail/bun.lock deleted file mode 100644 index 567e0c32..00000000 --- a/packages/thumbnail/bun.lock +++ /dev/null @@ -1,29 +0,0 @@ -{ - "lockfileVersion": 1, - "workspaces": { - "": { - "name": "thumbnail", - "devDependencies": { - "@types/bun": "latest", - }, - "peerDependencies": { - "typescript": "^5", - }, - }, - }, - "packages": { - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], - - "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], - - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], - - "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], - - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - - "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], - } -} diff --git a/packages/thumbnail/src/canvasFactory.ts b/packages/thumbnail/src/canvasFactory.ts index d1c32fb6..fbf2d5e4 100644 --- a/packages/thumbnail/src/canvasFactory.ts +++ b/packages/thumbnail/src/canvasFactory.ts @@ -7,172 +7,172 @@ import type { CanvasUtils } from './types'; let canvasUtils: CanvasUtils; if (typeof document === 'undefined') { - // Node.js/Bun environment - try { - const canvasModule = require('@napi-rs/canvas') as typeof NapiRs; - const path = require('path') as typeof Path; - - const { - createCanvas: nodeCreateCanvas, - loadImage: nodeLoadImage, - GlobalFonts, - } = canvasModule; - - const getPath = (filename: string): string => { - const workingDir = process.cwd(); - - // Try different possible locations for the asset files - const possiblePaths = [ - // When running from package directory - path.join(workingDir, filename.split('/').join(path.sep)), - // When running from root directory - path.join( - workingDir, - 'packages', - 'thumbnail', - filename.split('/').join(path.sep), - ), - ]; - - // Check which path exists - const fs = require('fs'); - - for (const fullPath of possiblePaths) { - if (fs.existsSync(fullPath)) { - return fullPath; - } - } - - // Default to first path if none exist - return possiblePaths[0]!; - }; - - const saveToImage = (canvas: NapiRs.Canvas) => canvas.encode('png'); - - const useFont = () => { - const path = getPath('assets/fonts/Lato-Regular.ttf').toString(); - console.log('Font path: ', path); - - GlobalFonts.registerFromPath(path, 'Lato'); - }; - - let noteBlockImage: Promise; - + // Node.js/Bun environment try { - const path = getPath('assets/img/note-block-grayscale.png'); + const canvasModule = require('@napi-rs/canvas') as typeof NapiRs; + const path = require('path') as typeof Path; + + const { + createCanvas: nodeCreateCanvas, + loadImage: nodeLoadImage, + GlobalFonts + } = canvasModule; + + const getPath = (filename: string): string => { + const workingDir = process.cwd(); + + // Try different possible locations for the asset files + const possiblePaths = [ + // When running from package directory + path.join(workingDir, filename.split('/').join(path.sep)), + // When running from root directory + path.join( + workingDir, + 'packages', + 'thumbnail', + filename.split('/').join(path.sep) + ) + ]; + + // Check which path exists + const fs = require('fs'); + + for (const fullPath of possiblePaths) { + if (fs.existsSync(fullPath)) { + return fullPath; + } + } + + // Default to first path if none exist + return possiblePaths[0]!; + }; + + const saveToImage = (canvas: NapiRs.Canvas) => canvas.encode('png'); + + const useFont = () => { + const path = getPath('assets/fonts/Lato-Regular.ttf').toString(); + console.log('Font path: ', path); + + GlobalFonts.registerFromPath(path, 'Lato'); + }; + + let noteBlockImage: Promise; + + try { + const path = getPath('assets/img/note-block-grayscale.png'); + + noteBlockImage = nodeLoadImage(path); + } catch (error) { + console.error('Error loading image: ', error); + noteBlockImage = Promise.reject(error); + } - noteBlockImage = nodeLoadImage(path); + canvasUtils = { + createCanvas : nodeCreateCanvas, + loadImage : nodeLoadImage, + getPath, + useFont, + saveToImage, + noteBlockImage, + DrawingCanvas: canvasModule.Canvas, + RenderedImage: canvasModule.Image + }; } catch (error) { - console.error('Error loading image: ', error); - noteBlockImage = Promise.reject(error); - } - - canvasUtils = { - createCanvas: nodeCreateCanvas, - loadImage: nodeLoadImage, - getPath, - useFont, - saveToImage, - noteBlockImage, - DrawingCanvas: canvasModule.Canvas, - RenderedImage: canvasModule.Image, - }; - } catch (error) { // Fallback for when @napi-rs/canvas is not available (e.g., in browser build) - console.warn('@napi-rs/canvas not available, using browser fallbacks'); - + console.warn('@napi-rs/canvas not available, using browser fallbacks'); + + const createCanvas = (width: number, height: number) => { + if (typeof OffscreenCanvas !== 'undefined') { + return new OffscreenCanvas(width, height); + } + + throw new Error('OffscreenCanvas not available'); + }; + + const loadImage = (src: string): Promise => { + return Promise.reject( + new Error('loadImage not available in this environment') + ); + }; + + const getPath = (filename: string) => filename; + + const saveToImage = (_canvas: any) => { + throw new Error('saveToImage not implemented in browser'); + }; + + const useFont = () => { + // No-op in fallback + }; + + const noteBlockImage = Promise.reject( + new Error('noteBlockImage not available in this environment') + ); + + canvasUtils = { + createCanvas, + loadImage, + getPath, + useFont, + saveToImage, + noteBlockImage, + DrawingCanvas: HTMLCanvasElement as any, + RenderedImage: HTMLImageElement as any + }; + } +} else { + // Browser environment const createCanvas = (width: number, height: number) => { - if (typeof OffscreenCanvas !== 'undefined') { return new OffscreenCanvas(width, height); - } - - throw new Error('OffscreenCanvas not available'); }; - const loadImage = (src: string): Promise => { - return Promise.reject( - new Error('loadImage not available in this environment'), - ); + const loadImage = (src: string): Promise => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); }; const getPath = (filename: string) => filename; const saveToImage = (_canvas: any) => { - throw new Error('saveToImage not implemented in browser'); + console.warn('saveToImage not implemented in browser'); + throw new Error('saveToImage not implemented in browser'); }; const useFont = () => { - // No-op in fallback - }; + const font = new FontFace('Lato', 'url(/fonts/Lato-Regular.ttf)'); - const noteBlockImage = Promise.reject( - new Error('noteBlockImage not available in this environment'), - ); - - canvasUtils = { - createCanvas, - loadImage, - getPath, - useFont, - saveToImage, - noteBlockImage, - DrawingCanvas: HTMLCanvasElement as any, - RenderedImage: HTMLImageElement as any, + font.load().then((loadedFont) => { + document.fonts.add(loadedFont); + }); }; - } -} else { - // Browser environment - const createCanvas = (width: number, height: number) => { - return new OffscreenCanvas(width, height); - }; - - const loadImage = (src: string): Promise => { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = src; - }); - }; - - const getPath = (filename: string) => filename; - - const saveToImage = (_canvas: any) => { - console.warn('saveToImage not implemented in browser'); - throw new Error('saveToImage not implemented in browser'); - }; - const useFont = () => { - const font = new FontFace('Lato', 'url(/fonts/Lato-Regular.ttf)'); + const noteBlockImagePath = getPath('/img/note-block-grayscale.png'); - font.load().then((loadedFont) => { - document.fonts.add(loadedFont); - }); - }; + const noteBlockImage = loadImage(noteBlockImagePath); - const noteBlockImagePath = getPath('/img/note-block-grayscale.png'); - - const noteBlockImage = loadImage(noteBlockImagePath); + canvasUtils = { + createCanvas, + loadImage, + getPath, + useFont, + saveToImage, + noteBlockImage, + DrawingCanvas: HTMLCanvasElement, + RenderedImage: HTMLImageElement + }; +} - canvasUtils = { +export const { createCanvas, loadImage, getPath, useFont, saveToImage, noteBlockImage, - DrawingCanvas: HTMLCanvasElement, - RenderedImage: HTMLImageElement, - }; -} - -export const { - createCanvas, - loadImage, - getPath, - useFont, - saveToImage, - noteBlockImage, - DrawingCanvas, - RenderedImage, + DrawingCanvas, + RenderedImage } = canvasUtils; diff --git a/packages/thumbnail/src/index.ts b/packages/thumbnail/src/index.ts index 588e7c9e..14f626fb 100644 --- a/packages/thumbnail/src/index.ts +++ b/packages/thumbnail/src/index.ts @@ -4,10 +4,10 @@ export * from './utils'; import type { Note } from '@nbw/song'; import { - createCanvas, - noteBlockImage, - saveToImage, - useFont, + createCanvas, + noteBlockImage, + saveToImage, + useFont } from './canvasFactory'; import type { Canvas, DrawParams } from './types'; import { getKeyText, instrumentColors, isDarkColor, tintImage } from './utils'; @@ -15,7 +15,7 @@ import { getKeyText, instrumentColors, isDarkColor, tintImage } from './utils'; useFont(); export const swap = async (src: Canvas, dst: Canvas) => { - /** + /** * Run a `drawFunction` that returns a canvas and draw it to the passed `canvas`. * * @param drawFunction - Function that draws to a canvas and returns it @@ -23,127 +23,127 @@ export const swap = async (src: Canvas, dst: Canvas) => { * * @returns Nothing */ - // Get canvas context - const ctx = dst.getContext('2d'); + // Get canvas context + const ctx = dst.getContext('2d'); - if (!ctx) { - throw new Error('Could not get canvas context'); - } + if (!ctx) { + throw new Error('Could not get canvas context'); + } - // Swap the canvas - ctx.drawImage(src, 0, 0); + // Swap the canvas + ctx.drawImage(src, 0, 0); }; export const drawNotesOffscreen = async ({ - notes, - startTick, - startLayer, - zoomLevel, - backgroundColor, - canvasWidth, - //canvasHeight, - imgWidth = 1280, - imgHeight = 768, + notes, + startTick, + startLayer, + zoomLevel, + backgroundColor, + canvasWidth, + //canvasHeight, + imgWidth = 1280, + imgHeight = 768 }: DrawParams) => { - // Create new offscreen canvas - const canvas = createCanvas(imgWidth, imgHeight); - const ctx = canvas.getContext('2d'); + // Create new offscreen canvas + const canvas = createCanvas(imgWidth, imgHeight); + const ctx = canvas.getContext('2d'); - if (!ctx) { - throw new Error('Could not get offscreen canvas context'); - } + if (!ctx) { + throw new Error('Could not get offscreen canvas context'); + } - // Disable anti-aliasing - ctx.imageSmoothingEnabled = false; + // Disable anti-aliasing + ctx.imageSmoothingEnabled = false; - // Calculate effective zoom level - const zoomFactor = 2 ** (zoomLevel - 1); + // Calculate effective zoom level + const zoomFactor = 2 ** (zoomLevel - 1); - // Set scale to draw image at correct thumbnail size - if (canvasWidth !== undefined) { - const scale = canvasWidth / imgWidth; - ctx.scale(scale, scale); - } + // Set scale to draw image at correct thumbnail size + if (canvasWidth !== undefined) { + const scale = canvasWidth / imgWidth; + ctx.scale(scale, scale); + } - const width = canvas.width; - const height = canvas.height; + const width = canvas.width; + const height = canvas.height; - ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, width, height); - // Draw background - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, width, height); + // Draw background + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, width, height); - // Check if the background color is dark or light - const isDark = isDarkColor(backgroundColor, 90); + // Check if the background color is dark or light + const isDark = isDarkColor(backgroundColor, 90); - if (isDark) { - ctx.fillStyle = 'rgba(255, 255, 255, 0.15)'; - } else { - ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; - } + if (isDark) { + ctx.fillStyle = 'rgba(255, 255, 255, 0.15)'; + } else { + ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + } - // Draw vertical lines - for (let i = 0; i < width; i += 8 * zoomFactor) { - ctx.fillRect(i, 0, 1, height); - } + // Draw vertical lines + for (let i = 0; i < width; i += 8 * zoomFactor) { + ctx.fillRect(i, 0, 1, height); + } - const loadedNoteBlockImage = await noteBlockImage; + const loadedNoteBlockImage = await noteBlockImage; - // Iterate through note blocks and draw them - const endTick = startTick + width / (zoomFactor * 8) - 1; - const endLayer = startLayer + height / (zoomFactor * 8) - 1; + // Iterate through note blocks and draw them + const endTick = startTick + width / (zoomFactor * 8) - 1; + const endLayer = startLayer + height / (zoomFactor * 8) - 1; - const visibleNotes = notes.getNotesInRect({ - x1: startTick, - y1: startLayer, - x2: endTick, - y2: endLayer, - }); + const visibleNotes = notes.getNotesInRect({ + x1: startTick, + y1: startLayer, + x2: endTick, + y2: endLayer + }); - visibleNotes.forEach(async (note: Note) => { + visibleNotes.forEach(async (note: Note) => { // Calculate position - const x = (note.tick - startTick) * 8 * zoomFactor; - const y = (note.layer - startLayer) * 8 * zoomFactor; - const overlayColor = instrumentColors[note.instrument % 16] ?? '#FF00FF'; - - if (!loadedNoteBlockImage) { - throw new Error('Note block image not loaded'); - } - - // Draw the note block - ctx.drawImage( - tintImage(loadedNoteBlockImage, overlayColor), - x, - y, - 8 * zoomFactor, - 8 * zoomFactor, - ); - - // Draw the key text - const keyText = getKeyText(note.key); - ctx.fillStyle = '#ffffff'; - ctx.font = `${3 * zoomFactor}px Lato`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(keyText, x + 4 * zoomFactor, y + 4 * zoomFactor); - }); - - return canvas; + const x = (note.tick - startTick) * 8 * zoomFactor; + const y = (note.layer - startLayer) * 8 * zoomFactor; + const overlayColor = instrumentColors[note.instrument % 16] ?? '#FF00FF'; + + if (!loadedNoteBlockImage) { + throw new Error('Note block image not loaded'); + } + + // Draw the note block + ctx.drawImage( + tintImage(loadedNoteBlockImage, overlayColor), + x, + y, + 8 * zoomFactor, + 8 * zoomFactor + ); + + // Draw the key text + const keyText = getKeyText(note.key); + ctx.fillStyle = '#ffffff'; + ctx.font = `${3 * zoomFactor}px Lato`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(keyText, x + 4 * zoomFactor, y + 4 * zoomFactor); + }); + + return canvas; }; export const drawToImage = async (params: DrawParams): Promise => { - let canvas; - const { imgWidth, imgHeight } = params; + let canvas; + const { imgWidth, imgHeight } = params; - if (!canvas) { - canvas = createCanvas(imgWidth, imgHeight); - } + if (!canvas) { + canvas = createCanvas(imgWidth, imgHeight); + } - const output = await drawNotesOffscreen(params); - const byteArray = await saveToImage(output); + const output = await drawNotesOffscreen(params); + const byteArray = await saveToImage(output); - // Convert to Buffer - const buffer = Buffer.from(byteArray); - return buffer; + // Convert to Buffer + const buffer = Buffer.from(byteArray); + return buffer; }; diff --git a/packages/thumbnail/src/types.ts b/packages/thumbnail/src/types.ts index 69e0c061..8346bb0e 100644 --- a/packages/thumbnail/src/types.ts +++ b/packages/thumbnail/src/types.ts @@ -4,15 +4,15 @@ import type { NoteQuadTree } from '@nbw/song'; import { DrawingCanvas, RenderedImage } from './canvasFactory'; export interface DrawParams { - notes: NoteQuadTree; - startTick: number; - startLayer: number; - zoomLevel: number; - backgroundColor: string; - canvasWidth?: number; - canvasHeight?: number; - imgWidth: number; - imgHeight: number; + notes : NoteQuadTree; + startTick : number; + startLayer : number; + zoomLevel : number; + backgroundColor: string; + canvasWidth? : number; + canvasHeight? : number; + imgWidth : number; + imgHeight : number; } export type Canvas = typeof DrawingCanvas; @@ -26,12 +26,12 @@ import type { */ export interface CanvasUtils { - createCanvas(width: number, height: number): any; - loadImage(src: string): Promise; - getPath(filename: string): string | URL; - useFont(): void; - saveToImage(canvas: HTMLCanvasElement | NapiRs.Canvas): Promise; - noteBlockImage: Promise | any; - DrawingCanvas: any; - RenderedImage: any; + createCanvas(width: number, height: number): any; + loadImage(src: string): Promise; + getPath(filename: string): string | URL; + useFont(): void; + saveToImage(canvas: HTMLCanvasElement | NapiRs.Canvas): Promise; + noteBlockImage: Promise | any; + DrawingCanvas : any; + RenderedImage : any; } diff --git a/packages/thumbnail/src/utils.spec.ts b/packages/thumbnail/src/utils.spec.ts index 802d38ba..58765045 100644 --- a/packages/thumbnail/src/utils.spec.ts +++ b/packages/thumbnail/src/utils.spec.ts @@ -1,155 +1,157 @@ +import { describe, expect, it } from 'bun:test'; + import { getKeyText, instrumentColors, isDarkColor } from './utils'; describe('instrumentColors', () => { - it('should contain 16 color codes', () => { - expect(instrumentColors).toHaveLength(16); + it('should contain 16 color codes', () => { + expect(instrumentColors).toHaveLength(16); - instrumentColors.forEach((color) => { - expect(color).toMatch(/^#[0-9a-f]{6}$/); + instrumentColors.forEach((color) => { + expect(color).toMatch(/^#[0-9a-f]{6}$/); + }); }); - }); }); describe('getKeyText', () => { - it('should return correct note and octave for given key number', () => { + it('should return correct note and octave for given key number', () => { // Full range of keys from 0 to 87 - const testCases = [ - { key: 0, expected: 'A0' }, - { key: 1, expected: 'A#0' }, - { key: 2, expected: 'B0' }, - { key: 3, expected: 'C1' }, - { key: 4, expected: 'C#1' }, - { key: 5, expected: 'D1' }, - { key: 6, expected: 'D#1' }, - { key: 7, expected: 'E1' }, - { key: 8, expected: 'F1' }, - { key: 9, expected: 'F#1' }, - { key: 10, expected: 'G1' }, - { key: 11, expected: 'G#1' }, - { key: 12, expected: 'A1' }, - { key: 13, expected: 'A#1' }, - { key: 14, expected: 'B1' }, - { key: 15, expected: 'C2' }, - { key: 16, expected: 'C#2' }, - { key: 17, expected: 'D2' }, - { key: 18, expected: 'D#2' }, - { key: 19, expected: 'E2' }, - { key: 20, expected: 'F2' }, - { key: 21, expected: 'F#2' }, - { key: 22, expected: 'G2' }, - { key: 23, expected: 'G#2' }, - { key: 24, expected: 'A2' }, - { key: 25, expected: 'A#2' }, - { key: 26, expected: 'B2' }, - { key: 27, expected: 'C3' }, - { key: 28, expected: 'C#3' }, - { key: 29, expected: 'D3' }, - { key: 30, expected: 'D#3' }, - { key: 31, expected: 'E3' }, - { key: 32, expected: 'F3' }, - { key: 33, expected: 'F#3' }, - { key: 34, expected: 'G3' }, - { key: 35, expected: 'G#3' }, - { key: 36, expected: 'A3' }, - { key: 37, expected: 'A#3' }, - { key: 38, expected: 'B3' }, - { key: 39, expected: 'C4' }, - { key: 40, expected: 'C#4' }, - { key: 41, expected: 'D4' }, - { key: 42, expected: 'D#4' }, - { key: 43, expected: 'E4' }, - { key: 44, expected: 'F4' }, - { key: 45, expected: 'F#4' }, - { key: 46, expected: 'G4' }, - { key: 47, expected: 'G#4' }, - { key: 48, expected: 'A4' }, - { key: 49, expected: 'A#4' }, - { key: 50, expected: 'B4' }, - { key: 51, expected: 'C5' }, - { key: 52, expected: 'C#5' }, - { key: 53, expected: 'D5' }, - { key: 54, expected: 'D#5' }, - { key: 55, expected: 'E5' }, - { key: 56, expected: 'F5' }, - { key: 57, expected: 'F#5' }, - { key: 58, expected: 'G5' }, - { key: 59, expected: 'G#5' }, - { key: 60, expected: 'A5' }, - { key: 61, expected: 'A#5' }, - { key: 62, expected: 'B5' }, - { key: 63, expected: 'C6' }, - { key: 64, expected: 'C#6' }, - { key: 65, expected: 'D6' }, - { key: 66, expected: 'D#6' }, - { key: 67, expected: 'E6' }, - { key: 68, expected: 'F6' }, - { key: 69, expected: 'F#6' }, - { key: 70, expected: 'G6' }, - { key: 71, expected: 'G#6' }, - { key: 72, expected: 'A6' }, - { key: 73, expected: 'A#6' }, - { key: 74, expected: 'B6' }, - { key: 75, expected: 'C7' }, - { key: 76, expected: 'C#7' }, - { key: 77, expected: 'D7' }, - { key: 78, expected: 'D#7' }, - { key: 79, expected: 'E7' }, - { key: 80, expected: 'F7' }, - { key: 81, expected: 'F#7' }, - { key: 82, expected: 'G7' }, - { key: 83, expected: 'G#7' }, - { key: 84, expected: 'A7' }, - { key: 85, expected: 'A#7' }, - { key: 86, expected: 'B7' }, - { key: 87, expected: 'C8' }, - ]; + const testCases = [ + { key: 0, expected: 'A0' }, + { key: 1, expected: 'A#0' }, + { key: 2, expected: 'B0' }, + { key: 3, expected: 'C1' }, + { key: 4, expected: 'C#1' }, + { key: 5, expected: 'D1' }, + { key: 6, expected: 'D#1' }, + { key: 7, expected: 'E1' }, + { key: 8, expected: 'F1' }, + { key: 9, expected: 'F#1' }, + { key: 10, expected: 'G1' }, + { key: 11, expected: 'G#1' }, + { key: 12, expected: 'A1' }, + { key: 13, expected: 'A#1' }, + { key: 14, expected: 'B1' }, + { key: 15, expected: 'C2' }, + { key: 16, expected: 'C#2' }, + { key: 17, expected: 'D2' }, + { key: 18, expected: 'D#2' }, + { key: 19, expected: 'E2' }, + { key: 20, expected: 'F2' }, + { key: 21, expected: 'F#2' }, + { key: 22, expected: 'G2' }, + { key: 23, expected: 'G#2' }, + { key: 24, expected: 'A2' }, + { key: 25, expected: 'A#2' }, + { key: 26, expected: 'B2' }, + { key: 27, expected: 'C3' }, + { key: 28, expected: 'C#3' }, + { key: 29, expected: 'D3' }, + { key: 30, expected: 'D#3' }, + { key: 31, expected: 'E3' }, + { key: 32, expected: 'F3' }, + { key: 33, expected: 'F#3' }, + { key: 34, expected: 'G3' }, + { key: 35, expected: 'G#3' }, + { key: 36, expected: 'A3' }, + { key: 37, expected: 'A#3' }, + { key: 38, expected: 'B3' }, + { key: 39, expected: 'C4' }, + { key: 40, expected: 'C#4' }, + { key: 41, expected: 'D4' }, + { key: 42, expected: 'D#4' }, + { key: 43, expected: 'E4' }, + { key: 44, expected: 'F4' }, + { key: 45, expected: 'F#4' }, + { key: 46, expected: 'G4' }, + { key: 47, expected: 'G#4' }, + { key: 48, expected: 'A4' }, + { key: 49, expected: 'A#4' }, + { key: 50, expected: 'B4' }, + { key: 51, expected: 'C5' }, + { key: 52, expected: 'C#5' }, + { key: 53, expected: 'D5' }, + { key: 54, expected: 'D#5' }, + { key: 55, expected: 'E5' }, + { key: 56, expected: 'F5' }, + { key: 57, expected: 'F#5' }, + { key: 58, expected: 'G5' }, + { key: 59, expected: 'G#5' }, + { key: 60, expected: 'A5' }, + { key: 61, expected: 'A#5' }, + { key: 62, expected: 'B5' }, + { key: 63, expected: 'C6' }, + { key: 64, expected: 'C#6' }, + { key: 65, expected: 'D6' }, + { key: 66, expected: 'D#6' }, + { key: 67, expected: 'E6' }, + { key: 68, expected: 'F6' }, + { key: 69, expected: 'F#6' }, + { key: 70, expected: 'G6' }, + { key: 71, expected: 'G#6' }, + { key: 72, expected: 'A6' }, + { key: 73, expected: 'A#6' }, + { key: 74, expected: 'B6' }, + { key: 75, expected: 'C7' }, + { key: 76, expected: 'C#7' }, + { key: 77, expected: 'D7' }, + { key: 78, expected: 'D#7' }, + { key: 79, expected: 'E7' }, + { key: 80, expected: 'F7' }, + { key: 81, expected: 'F#7' }, + { key: 82, expected: 'G7' }, + { key: 83, expected: 'G#7' }, + { key: 84, expected: 'A7' }, + { key: 85, expected: 'A#7' }, + { key: 86, expected: 'B7' }, + { key: 87, expected: 'C8' } + ]; - testCases.forEach(({ key, expected }) => { - expect(getKeyText(key)).toBe(expected); + testCases.forEach(({ key, expected }) => { + expect(getKeyText(key)).toBe(expected); + }); }); - }); }); describe('isDarkColor', () => { - it('should correctly identify dark colors', () => { + it('should correctly identify dark colors', () => { // Test with default threshold (40) - expect(isDarkColor('#000000')).toBe(true); // black - expect(isDarkColor('#0000FF')).toBe(true); // blue - expect(isDarkColor('#FF0000')).toBe(false); // red - expect(isDarkColor('#00FF00')).toBe(false); // green - expect(isDarkColor('#333333')).toBe(false); // mid gray - expect(isDarkColor('#444444')).toBe(false); // dark gray - expect(isDarkColor('#888888')).toBe(false); // light gray - expect(isDarkColor('#be6b6b')).toBe(false); // light red - expect(isDarkColor('#1964ac')).toBe(false); // first instrument color - expect(isDarkColor('#FFFFFF')).toBe(false); // white - expect(isDarkColor('#F0F0F0')).toBe(false); // light gray - expect(isDarkColor('#be6b6b')).toBe(false); // light red - }); + expect(isDarkColor('#000000')).toBe(true); // black + expect(isDarkColor('#0000FF')).toBe(true); // blue + expect(isDarkColor('#FF0000')).toBe(false); // red + expect(isDarkColor('#00FF00')).toBe(false); // green + expect(isDarkColor('#333333')).toBe(false); // mid gray + expect(isDarkColor('#444444')).toBe(false); // dark gray + expect(isDarkColor('#888888')).toBe(false); // light gray + expect(isDarkColor('#be6b6b')).toBe(false); // light red + expect(isDarkColor('#1964ac')).toBe(false); // first instrument color + expect(isDarkColor('#FFFFFF')).toBe(false); // white + expect(isDarkColor('#F0F0F0')).toBe(false); // light gray + expect(isDarkColor('#be6b6b')).toBe(false); // light red + }); - it('should respect custom threshold', () => { + it('should respect custom threshold', () => { // TODO: Add more test cases for different thresholds - expect(isDarkColor('#888888', 100)).toBe(false); - expect(isDarkColor('#888888', 50)).toBe(false); - expect(isDarkColor('#444444', 50)).toBe(false); - expect(isDarkColor('#444444', 130)).toBe(true); - }); + expect(isDarkColor('#888888', 100)).toBe(false); + expect(isDarkColor('#888888', 50)).toBe(false); + expect(isDarkColor('#444444', 50)).toBe(false); + expect(isDarkColor('#444444', 130)).toBe(true); + }); - it('should handle invalid color strings', () => { + it('should handle invalid color strings', () => { // This is testing the implementation detail that falls back to empty string - expect(isDarkColor('notacolor')).toBe(true); - expect(isDarkColor('')).toBe(true); - }); + expect(isDarkColor('notacolor')).toBe(true); + expect(isDarkColor('')).toBe(true); + }); }); describe('getLuma', () => { - // Note: getLuma isn't exported, but we can test it through isDarkColor - it('should calculate luma correctly', () => { + // Note: getLuma isn't exported, but we can test it through isDarkColor + it('should calculate luma correctly', () => { // These values are calculated from the formula in the code - expect(isDarkColor('#000000')).toBe(true); // 0 - expect(isDarkColor('#FFFFFF')).toBe(false); // 255 - expect(isDarkColor('#FF0000')).toBe(false); // ~54.213 - expect(isDarkColor('#00FF00')).toBe(false); // ~182.376 - expect(isDarkColor('#0000FF')).toBe(true); // ~18.414 - }); + expect(isDarkColor('#000000')).toBe(true); // 0 + expect(isDarkColor('#FFFFFF')).toBe(false); // 255 + expect(isDarkColor('#FF0000')).toBe(false); // ~54.213 + expect(isDarkColor('#00FF00')).toBe(false); // ~182.376 + expect(isDarkColor('#0000FF')).toBe(true); // ~18.414 + }); }); diff --git a/packages/thumbnail/src/utils.ts b/packages/thumbnail/src/utils.ts index 52ad53af..38975834 100644 --- a/packages/thumbnail/src/utils.ts +++ b/packages/thumbnail/src/utils.ts @@ -2,94 +2,94 @@ import { createCanvas } from './canvasFactory'; import type { Canvas, Image } from './types'; export const instrumentColors = [ - '#1964ac', - '#3c8e48', - '#be6b6b', - '#bebe19', - '#9d5a98', - '#572b21', - '#bec65c', - '#be19be', - '#52908d', - '#bebebe', - '#1991be', - '#be2328', - '#be5728', - '#19be19', - '#be1957', - '#575757', + '#1964ac', + '#3c8e48', + '#be6b6b', + '#bebe19', + '#9d5a98', + '#572b21', + '#bec65c', + '#be19be', + '#52908d', + '#bebebe', + '#1991be', + '#be2328', + '#be5728', + '#19be19', + '#be1957', + '#575757' ]; const tintedImages: Record = {}; // Function to apply tint to an image export const tintImage = (image: Image, color: string): Canvas => { - if (tintedImages[color]) { - return tintedImages[color]; - } + if (tintedImages[color]) { + return tintedImages[color]; + } - const canvas = createCanvas(image.width, image.height); - const ctx = canvas.getContext('2d'); + const canvas = createCanvas(image.width, image.height); + const ctx = canvas.getContext('2d'); - if (!ctx) { - throw new Error('Could not get canvas context'); - } + if (!ctx) { + throw new Error('Could not get canvas context'); + } - // Fill background with the color - ctx.fillStyle = color; - ctx.fillRect(0, 0, canvas.width, canvas.height); + // Fill background with the color + ctx.fillStyle = color; + ctx.fillRect(0, 0, canvas.width, canvas.height); - // Apply the note block texture to the color - ctx.globalCompositeOperation = 'hard-light'; - ctx.globalAlpha = 0.67; - ctx.drawImage(image, 0, 0); + // Apply the note block texture to the color + ctx.globalCompositeOperation = 'hard-light'; + ctx.globalAlpha = 0.67; + ctx.drawImage(image, 0, 0); - // Reset canvas settings - ctx.globalCompositeOperation = 'source-over'; - ctx.globalAlpha = 1; + // Reset canvas settings + ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = 1; - tintedImages[color] = canvas; + tintedImages[color] = canvas; - return canvas; + return canvas; }; // Function to convert key number to key text export const getKeyText = (key: number): string => { - const octaves = Math.floor((key + 9) / 12); - - const notes = [ - 'C', - 'C#', - 'D', - 'D#', - 'E', - 'F', - 'F#', - 'G', - 'G#', - 'A', - 'A#', - 'B', - ]; - - const note = notes[(key + 9) % 12]; - - return `${note}${octaves}`; + const octaves = Math.floor((key + 9) / 12); + + const notes = [ + 'C', + 'C#', + 'D', + 'D#', + 'E', + 'F', + 'F#', + 'G', + 'G#', + 'A', + 'A#', + 'B' + ]; + + const note = notes[(key + 9) % 12]; + + return `${note}${octaves}`; }; const getLuma = (color: string): number => { - // source: https://stackoverflow.com/a/12043228/9045426 - const c = color?.substring(1) || ''; // strip # - const rgb = parseInt(c, 16); // convert rrggbb to decimal - const r = (rgb >> 16) & 255; // extract red - const g = (rgb >> 8) & 255; // extract green - const b = (rgb >> 0) & 255; // extract blue + // source: https://stackoverflow.com/a/12043228/9045426 + const c = color?.substring(1) || ''; // strip # + const rgb = parseInt(c, 16); // convert rrggbb to decimal + const r = (rgb >> 16) & 255; // extract red + const g = (rgb >> 8) & 255; // extract green + const b = (rgb >> 0) & 255; // extract blue - const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; // per ITU-R BT.709 + const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; // per ITU-R BT.709 - return luma; + return luma; }; export const isDarkColor = (color: string, threshold = 40): boolean => { - return getLuma(color) < threshold; + return getLuma(color) < threshold; };