diff --git a/.eslintrc.js b/.eslintrc.js index a764698..7a18384 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,6 +17,7 @@ module.exports = { ignorePatterns: [ '.eslintrc.js', 'core/src/database/migrations/**/*.{ts,js}', + 'build-scripts/**', '**/test/**', ], rules: { diff --git a/.github/workflows/ci_release.yml b/.github/workflows/ci_release.yml index 04308db..a2ae951 100644 --- a/.github/workflows/ci_release.yml +++ b/.github/workflows/ci_release.yml @@ -23,6 +23,8 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: yarn install + - name: Run tests + run: yarn test - name: Release env: GH_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/build-scripts/migration-generate.js b/build-scripts/migration-generate.js new file mode 100644 index 0000000..3e19261 --- /dev/null +++ b/build-scripts/migration-generate.js @@ -0,0 +1,26 @@ +const child_process = require('child_process'); +const path = require('path'); + +if (process.argv.length < 3) { + console.error('Usage: yarn migration:generate NameOfMigration'); + console.error( + 'Do not include the file extension or path in the migration name - they will be added automatically.', + ); + process.exit(1); +} + +const cliHelper = path.join( + __dirname, + '../core/dist/src/database/cli-helper.datasource.js', +); +const target = path.join( + __dirname, + '../core/src/database/migrations', + process.argv[2], +); +const args = process.argv.slice(3).join(' '); + +child_process.spawnSync( + `yarn build && npx typeorm migration:generate -p -d ${cliHelper} ${target} ${args}`, + { shell: true, stdio: 'inherit' }, +); diff --git a/core/.env.example b/core/.env.example index 48ac81c..3743529 100644 --- a/core/.env.example +++ b/core/.env.example @@ -25,7 +25,7 @@ ROLE_WITHOUT_PLAYLIST_PERMISSION=YourRoleId # Developer settings - do not touch unless you know what you are doing! # # Turns on TypeORM schema sync for easy development; you might lose data with this on, so be careful! -DATABASE_SYNCHRONIZE=true +#DATABASE_SYNCHRONIZE=true # Turns on extra database logging #DATABASE_LOGGING=true \ No newline at end of file diff --git a/core/package.json b/core/package.json index 87f5347..2c22358 100644 --- a/core/package.json +++ b/core/package.json @@ -17,7 +17,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", - "migration:create": "yarn build && npx typeorm migration:generate -p -d ./dist/src/database/cli-helper.datasource.js" + "migration:generate": "node ../build-scripts/migration-generate.js" }, "jest": { "moduleFileExtensions": [ diff --git a/core/src/app.module.ts b/core/src/app.module.ts index 54a82d1..b52fd20 100644 --- a/core/src/app.module.ts +++ b/core/src/app.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule as NestConfigModule } from '@nestjs/config'; import { BotModule } from './bot/bot.module'; import { DatabaseModule } from './database/database.module'; @@ -9,7 +9,7 @@ import { SentryModule } from './sentry/sentry.module'; @Module({ imports: [ - ConfigModule.forRoot(), + NestConfigModule.forRoot(), SentryModule, DatabaseModule, UsersModule, diff --git a/core/src/config/config-subject-type.enum.ts b/core/src/config/config-subject-type.enum.ts new file mode 100644 index 0000000..996ad64 --- /dev/null +++ b/core/src/config/config-subject-type.enum.ts @@ -0,0 +1,19 @@ +export enum ConfigSubjectType { + /** + * Global configuration, to be used by the entire bot instance. + * This is typically used to store bot-wide settings, like API keys. + */ + Global = 'global', + + /** + * Configuration for a specific guild. + * This may store settings like role selection, moderation settings, etc. + */ + Guild = 'guild', + + /** + * Configuration for a specific user. + * This may store settings like a user's preferred language. + */ + User = 'user', +} diff --git a/core/src/config/config.constants.ts b/core/src/config/config.constants.ts new file mode 100644 index 0000000..db7c3e5 --- /dev/null +++ b/core/src/config/config.constants.ts @@ -0,0 +1 @@ +export const CONFIG_TARGET_MODULE = 'CONFIG_TARGET_MODULE'; diff --git a/core/src/config/config.module.ts b/core/src/config/config.module.ts new file mode 100644 index 0000000..8a043d5 --- /dev/null +++ b/core/src/config/config.module.ts @@ -0,0 +1,42 @@ +import { DynamicModule, Module, Type } from '@nestjs/common'; +import { ConfigService } from './config.service'; +import { MODULE_PACKAGE_NAME } from '../module-system/module.constants'; +import { INQUIRER } from '@nestjs/core'; +import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; +import { Config } from './entities/config.entity'; +import { DataSource } from 'typeorm'; + +@Module({}) +export class ConfigModule { + static forFeature(module?: Type): DynamicModule { + return { + module: ConfigModule, + imports: [TypeOrmModule.forFeature([Config])], + providers: [ + { + provide: ConfigService, + useFactory: (inquirer: Type, dataSource: DataSource) => { + const target = module ?? inquirer; + if (!target) { + throw new Error( + 'Unknown target; try ConfigModule.forFeature(ModuleTypeName)', + ); + } + + const packageName: string = Reflect.getMetadata( + MODULE_PACKAGE_NAME, + target.constructor, + ); + + return new ConfigService( + packageName, + dataSource.getRepository(Config), + ); + }, + inject: [INQUIRER, getDataSourceToken()], + }, + ], + exports: [ConfigService], + }; + } +} diff --git a/core/src/config/config.service.spec.ts b/core/src/config/config.service.spec.ts new file mode 100644 index 0000000..fbfe544 --- /dev/null +++ b/core/src/config/config.service.spec.ts @@ -0,0 +1,187 @@ +import { Test } from '@nestjs/testing'; +import { + CONFIG_SUBJECT, + CONFIG_SUBJECT_TYPE, + ConfigService, +} from './config.service'; +import { MODULE_PACKAGE_NAME } from '../module'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Config } from './entities/config.entity'; +import { Repository } from 'typeorm'; +import { ConfigSubjectType } from './config-subject-type.enum'; + +type TestConfigType = { + testOne: string; + testTwo: number; +}; + +describe('ConfigService', () => { + let service: ConfigService; + let testConfig: TestConfigType; + const mockRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + + beforeEach(async () => { + await Test.createTestingModule({ + providers: [ + { + provide: MODULE_PACKAGE_NAME, + useValue: 'test', + }, + { + provide: getRepositoryToken(Config), + useValue: mockRepository, + }, + ], + }).compile(); + + service = new ConfigService( + 'test', + mockRepository as unknown as Repository, + ); + + testConfig = { + testOne: 'testOne', + testTwo: 2, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should get config without defaults', async () => { + const spy = jest + .spyOn(mockRepository, 'findOne') + .mockResolvedValueOnce(undefined); + const result = await service.getGlobalConfig(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({}); + }); + + it('should get config with defaults', async () => { + const spy = jest + .spyOn(mockRepository, 'findOne') + .mockResolvedValueOnce(undefined); + const result = await service.getGlobalConfig(testConfig); + + expect(spy).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual(testConfig); + }); + + it('should return a global config with correct metadata', async () => { + const spy = jest + .spyOn(mockRepository, 'findOne') + .mockResolvedValueOnce(undefined); + const result = await service.getGlobalConfig(testConfig); + + expect(spy).toHaveBeenCalledTimes(1); + expect(Reflect.getMetadata(CONFIG_SUBJECT_TYPE, result)).toBe( + ConfigSubjectType.Global, + ); + expect(Reflect.hasMetadata(CONFIG_SUBJECT, result)).toBe(false); + }); + + it('should return a guild config with correct metadata', async () => { + const spy = jest + .spyOn(mockRepository, 'findOne') + .mockResolvedValueOnce(undefined); + const result = await service.getGuildConfig( + 1234, + testConfig, + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(Reflect.getMetadata(CONFIG_SUBJECT_TYPE, result)).toBe( + ConfigSubjectType.Guild, + ); + expect(Reflect.getMetadata(CONFIG_SUBJECT, result)).toBe(1234); + }); + + it('should return a user config with correct metadata', async () => { + const spy = jest + .spyOn(mockRepository, 'findOne') + .mockResolvedValueOnce(undefined); + const result = await service.getUserConfig( + 1234, + testConfig, + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(Reflect.getMetadata(CONFIG_SUBJECT_TYPE, result)).toBe( + ConfigSubjectType.User, + ); + expect(Reflect.getMetadata(CONFIG_SUBJECT, result)).toBe(1234); + }); + + it('should save a global config', async () => { + const spy = jest.spyOn(mockRepository, 'save'); + const config = await service.getGlobalConfig(); + + await service.saveConfig(config); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ + module: 'test', + subjectType: ConfigSubjectType.Global, + data: config, + }); + }); + + it('should save a guild config', async () => { + const spy = jest.spyOn(mockRepository, 'save'); + const config = await service.getGuildConfig( + 1234, + testConfig, + ); + + await service.saveConfig(config); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ + module: 'test', + subjectType: ConfigSubjectType.Guild, + subject: 1234, + data: config, + }); + }); + + it('should save a user config', async () => { + const spy = jest.spyOn(mockRepository, 'save'); + const config = await service.getUserConfig( + 1234, + testConfig, + ); + + await service.saveConfig(config); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ + module: 'test', + subjectType: ConfigSubjectType.User, + subject: 1234, + data: config, + }); + }); + + it('should not throw when saving a config object with no metadata', async () => { + expect(async () => { + await service.saveConfig({}); + }).not.toThrow(); + }); + + it('should not throw when the save call fails', async () => { + jest.spyOn(mockRepository, 'save').mockRejectedValueOnce(new Error('test')); + + expect(async () => { + await service.saveConfig(testConfig); + }).not.toThrow(); + }); +}); diff --git a/core/src/config/config.service.ts b/core/src/config/config.service.ts new file mode 100644 index 0000000..b519590 --- /dev/null +++ b/core/src/config/config.service.ts @@ -0,0 +1,129 @@ +import { Injectable, Logger, Scope } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { Config } from './entities/config.entity'; +import { ConfigSubjectType } from './config-subject-type.enum'; +import * as Sentry from '@sentry/core'; + +export const CONFIG_SUBJECT_TYPE = 'config_subject_type'; +export const CONFIG_SUBJECT = 'config_subject'; + +type ConfigMetadata = { + subjectType: ConfigSubjectType; + subject?: number; +}; + +@Injectable({ scope: Scope.TRANSIENT }) +export class ConfigService { + private readonly logger: Logger = new Logger(ConfigService.name); + + constructor( + private readonly packageId: string, + private readonly repository: Repository, + ) {} + + private static getMetadata(config: T): ConfigMetadata { + const subjectType = Reflect.getMetadata(CONFIG_SUBJECT_TYPE, config); + const subject = Reflect.getMetadata(CONFIG_SUBJECT, config); + + if ( + !subjectType || + (subjectType !== ConfigSubjectType.Global && !subject) + ) { + throw new Error( + 'Provided configuration is missing metadata - did you load it through ConfigService?', + ); + } + + return { subjectType, subject }; + } + + private static assignMetadata( + config: T, + subjectType: ConfigSubjectType, + subject?: number, + ): T { + Reflect.defineMetadata(CONFIG_SUBJECT_TYPE, subjectType, config); + if (subject) { + Reflect.defineMetadata(CONFIG_SUBJECT, subject, config); + } + + return config; + } + + private async getConfig( + defaults: T = {} as T, + subjectType: ConfigSubjectType, + subject?: number, + ): Promise { + try { + const result = await this.repository.findOne({ + where: { + module: this.packageId, + subjectType, + subject, + }, + }); + + if (result) { + return ConfigService.assignMetadata(result.data, subjectType, subject); + } + } catch (err) { + const subjectString = subject ? `, ${subjectType} ${subject}` : ''; + + this.logger.warn( + `Failed to load ${subjectType} config for ${this.packageId}${subjectString}; returning defaults`, + err, + ); + Sentry.captureException(err); + } + + return ConfigService.assignMetadata( + { ...defaults } as T, + subjectType, + subject, + ); + } + + public async saveConfig(config: T): Promise { + let metadata; + try { + metadata = ConfigService.getMetadata(config); + + await this.repository.save({ + module: this.packageId, + subjectType: metadata.subjectType, + subject: metadata.subject, + data: config, + }); + } catch (err) { + const subjectType = metadata?.subjectType; + const subjectString = metadata?.subject + ? `, ${subjectType} ${metadata.subject}` + : ''; + + this.logger.warn( + `Failed to save ${subjectType} config for ${this.packageId}${subjectString}; ignoring`, + err, + ); + Sentry.captureException(err); + } + } + + public async getGlobalConfig(defaults: T = {} as T): Promise { + return this.getConfig(defaults, ConfigSubjectType.Global); + } + + public async getGuildConfig( + guildId: number, + defaults: T = {} as T, + ): Promise { + return this.getConfig(defaults, ConfigSubjectType.Guild, guildId); + } + + public async getUserConfig( + userId: number, + defaults: T = {} as T, + ): Promise { + return this.getConfig(defaults, ConfigSubjectType.User, userId); + } +} diff --git a/core/src/config/entities/config.entity.ts b/core/src/config/entities/config.entity.ts new file mode 100644 index 0000000..71f18b3 --- /dev/null +++ b/core/src/config/entities/config.entity.ts @@ -0,0 +1,17 @@ +import { Column, Entity } from 'typeorm'; +import { ConfigSubjectType } from '../config-subject-type.enum'; + +@Entity() +export class Config { + @Column({ type: 'varchar', primary: true }) + module!: string; + + @Column({ type: 'enum', enum: ConfigSubjectType, primary: true }) + subjectType!: ConfigSubjectType; + + @Column({ type: 'bigint', nullable: true, primary: true }) + subject?: number; + + @Column({ type: 'json' }) + data!: any; +} diff --git a/core/src/database/cli-helper.datasource.ts b/core/src/database/cli-helper.datasource.ts index a57d942..6ad7be8 100644 --- a/core/src/database/cli-helper.datasource.ts +++ b/core/src/database/cli-helper.datasource.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; import { DataSource } from 'typeorm'; +import * as path from 'path'; // Used by the TypeORM CLI when generating migrations export default new DataSource({ @@ -10,6 +11,18 @@ export default new DataSource({ password: process.env.DATABASE_PASS ?? '', database: process.env.DATABASE_NAME ?? 'postgres', logging: true, - entities: ['./dist/**/*.entity.{ts,js}'], - migrations: ['./dist/database/migrations/**/*.{ts,js}'], + entities: [ + path.join(__dirname, '../**/*.entity.{ts,js}'), + path.join( + __dirname, + '../../modules/**/venat-module-*/dist/**/*.entity.{ts,js}', + ), + ], + migrations: [ + path.join(__dirname, 'migrations/**/*.{ts,js}'), + path.join( + __dirname, + '../../modules/**/venat-module-*/dist/database/migrations/**/*.{ts,js}', + ), + ], }); diff --git a/core/src/database/database.options.ts b/core/src/database/database.options.ts index 63a04fe..fb3c6d2 100644 --- a/core/src/database/database.options.ts +++ b/core/src/database/database.options.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; +import * as path from 'path'; @Injectable() export class DatabaseOptions implements TypeOrmOptionsFactory { @@ -16,7 +17,13 @@ export class DatabaseOptions implements TypeOrmOptionsFactory { database: this.config.get('DATABASE_NAME', 'postgres'), synchronize: this.config.get('DATABASE_SYNCHRONIZE', false) === 'true', logging: this.config.get('DATABASE_LOGGING', false) === 'true', - migrations: ['./dist/database/migrations/**/*.{ts,js}'], + migrations: [ + path.join(__dirname, 'migrations/**/*.{ts,js}'), + path.join( + __dirname, + '../../node_modules/**/venat-module-*/dist/database/migrations/**/*.{ts,js}', + ), + ], migrationsRun: true, autoLoadEntities: true, }; diff --git a/core/src/database/migrations/1652767871657-AddConfigTable.ts b/core/src/database/migrations/1652767871657-AddConfigTable.ts new file mode 100644 index 0000000..ae86981 --- /dev/null +++ b/core/src/database/migrations/1652767871657-AddConfigTable.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddConfigTable1652767871657 implements MigrationInterface { + name = 'AddConfigTable1652767871657'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "public"."config_subjecttype_enum" AS ENUM('global', 'guild', 'user') + `); + await queryRunner.query(` + CREATE TABLE "config" + ( + "module" character varying NOT NULL, + "subjectType" "public"."config_subjecttype_enum" NOT NULL, + "subject" bigint NULL, + "data" json NOT NULL, + CONSTRAINT "PK_d50d2aaa485df164403875b9448" PRIMARY KEY ("module", "subjectType", "subject") + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP TABLE "config" + `); + await queryRunner.query(` + DROP TYPE "public"."config_subjecttype_enum" + `); + } +} diff --git a/core/src/database/migrations/1652767922579-UserIdToBigint.ts b/core/src/database/migrations/1652767922579-UserIdToBigint.ts new file mode 100644 index 0000000..9c68648 --- /dev/null +++ b/core/src/database/migrations/1652767922579-UserIdToBigint.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UserIdToBigint1652767922579 implements MigrationInterface { + name = 'UserIdToBigint1652767922579'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user" + ALTER COLUMN "id" TYPE BIGINT; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user" + ALTER COLUMN "id" TYPE INTEGER; + `); + } +} diff --git a/core/src/module-system/module.constants.ts b/core/src/module-system/module.constants.ts new file mode 100644 index 0000000..94aa2c4 --- /dev/null +++ b/core/src/module-system/module.constants.ts @@ -0,0 +1,2 @@ +export const MODULE_PACKAGE_NAME = 'module_package_name'; +export const MODULE_PACKAGE_VERSION = 'module_package_version'; diff --git a/core/src/module-system/module.loader.ts b/core/src/module-system/module.loader.ts index d2a979b..85e474b 100644 --- a/core/src/module-system/module.loader.ts +++ b/core/src/module-system/module.loader.ts @@ -1,9 +1,14 @@ -import { DynamicModule, Logger, Module } from '@nestjs/common'; +import { DynamicModule, Logger, Module, Type } from '@nestjs/common'; import * as resolvePackagePath from 'resolve-package-path'; import * as path from 'path'; import * as fs from 'fs/promises'; import { VenatModuleMetadata } from './venat-module.metadata'; import { METADATA_KEY } from './venat-module.decorator'; +import { PackageManifest } from './package-manifest.type'; +import { + MODULE_PACKAGE_NAME, + MODULE_PACKAGE_VERSION, +} from './module.constants'; @Module({}) export class ModuleLoader { @@ -65,9 +70,11 @@ export class ModuleLoader { try { const modulePath = (prefix ?? '') + nodeModule; const module: { [key: string]: object } = await import(modulePath); + const { name: moduleName, version: moduleVer }: PackageManifest = + await import(modulePath + '/package.json'); const nestModule = Object.values(module).find( - (item): item is DynamicModule => + (item): item is Type => Reflect.hasMetadata(METADATA_KEY, item), ); @@ -85,7 +92,29 @@ export class ModuleLoader { ModuleLoader.logger.log( `Found module: ${metadata.name} (${modulePath})`, ); - resolvedModules.push(nestModule); + + // define metadata with the module name and version + // this is used to hop from INQUIRER to the module + Reflect.defineMetadata(MODULE_PACKAGE_NAME, moduleName, nestModule); + Reflect.defineMetadata(MODULE_PACKAGE_VERSION, moduleVer, nestModule); + + // define those as providers too, for ease of access within the module + const dynamicModule: DynamicModule = { + providers: [ + { + provide: MODULE_PACKAGE_NAME, + useValue: moduleName, + }, + { + provide: MODULE_PACKAGE_VERSION, + useValue: moduleVer, + }, + ], + module: nestModule, + }; + + // module is resolved and metadata added, move on + resolvedModules.push(dynamicModule); ModuleLoader.loadedModuleInfo.push(metadata); } catch (error) { if (!(error instanceof Error)) { diff --git a/core/src/module-system/package-manifest.type.ts b/core/src/module-system/package-manifest.type.ts new file mode 100644 index 0000000..09ce995 --- /dev/null +++ b/core/src/module-system/package-manifest.type.ts @@ -0,0 +1,5 @@ +export interface PackageManifest { + name: string; + description?: string; + version?: string; +} diff --git a/core/src/module.ts b/core/src/module.ts index d365849..52257ff 100644 --- a/core/src/module.ts +++ b/core/src/module.ts @@ -1,12 +1,28 @@ +import { ConfigModule } from './config/config.module'; +import { ConfigService } from './config/config.service'; import { VenatModule } from './module-system/venat-module.decorator'; import { VenatModuleMetadata } from './module-system/venat-module.metadata'; import { LookupResult } from './util/io'; import { cleanText, TextParameter } from './util/text'; +import { + MODULE_PACKAGE_NAME, + MODULE_PACKAGE_VERSION, +} from './module-system/module.constants'; +import { UsersModule } from './users/users.module'; +import { UsersService } from './users/users.service'; +import { UserIsBotAdminGuard } from './users/guards/user-is-bot-admin.guard'; export { cleanText, + ConfigModule, + ConfigService, LookupResult, + MODULE_PACKAGE_NAME, + MODULE_PACKAGE_VERSION, TextParameter, + UserIsBotAdminGuard, + UsersModule, + UsersService, VenatModule, VenatModuleMetadata, }; diff --git a/core/src/users/entities/user.entity.ts b/core/src/users/entities/user.entity.ts index 3309f64..fd0eb10 100644 --- a/core/src/users/entities/user.entity.ts +++ b/core/src/users/entities/user.entity.ts @@ -5,7 +5,7 @@ export class User { /** * The Discord user ID/snowflake. */ - @PrimaryColumn() + @PrimaryColumn({ type: 'bigint' }) id!: number; /** diff --git a/core/src/users/users.module.ts b/core/src/users/users.module.ts index 019c99d..9ce1618 100644 --- a/core/src/users/users.module.ts +++ b/core/src/users/users.module.ts @@ -9,6 +9,11 @@ import { ConfigModule } from '@nestjs/config'; @Module({ imports: [TypeOrmModule.forFeature([User]), ConfigModule], providers: [UsersService, UserIsBotAdminGuard, UserCanUseBotGuard], - exports: [UsersService, UserIsBotAdminGuard, UserCanUseBotGuard], + exports: [ + TypeOrmModule, + UsersService, + UserIsBotAdminGuard, + UserCanUseBotGuard, + ], }) export class UsersModule {} diff --git a/core/src/users/users.service.spec.ts b/core/src/users/users.service.spec.ts index 62815ba..b0e337c 100644 --- a/core/src/users/users.service.spec.ts +++ b/core/src/users/users.service.spec.ts @@ -1,12 +1,24 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersService } from './users.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from './entities/user.entity'; describe('UsersService', () => { let service: UsersService; + const mockRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [UsersService], + providers: [ + { + provide: getRepositoryToken(User), + useValue: mockRepository, + }, + UsersService, + ], }).compile(); service = module.get(UsersService); diff --git a/modules/venat-module-example/src/commands/dump-config.command.ts b/modules/venat-module-example/src/commands/dump-config.command.ts new file mode 100644 index 0000000..af8407d --- /dev/null +++ b/modules/venat-module-example/src/commands/dump-config.command.ts @@ -0,0 +1,42 @@ +import { + Command, + DiscordTransformedCommand, + TransformedCommandExecutionContext, +} from '@discord-nestjs/core'; +import { Logger } from '@nestjs/common'; +import { InteractionReplyOptions, MessageEmbed } from 'discord.js'; +import { ConfigService } from '@the-convocation/venat-core'; + +@Command({ + description: 'Dumps configuration data', + name: 'dumpconfig', +}) +export class DumpConfigCommand implements DiscordTransformedCommand { + private readonly logger: Logger = new Logger('DumpConfigCommand'); + + public constructor(private readonly config: ConfigService) {} + + public async handler({ + interaction, + }: TransformedCommandExecutionContext): Promise { + const configs = await Promise.all([ + this.config.getGlobalConfig(), + this.config.getGuildConfig(parseInt(interaction?.guild?.id ?? '0')), + this.config.getUserConfig(parseInt(interaction?.user.id ?? '0')), + ]); + + const embeds: MessageEmbed[] = []; + for (const config of configs) { + embeds.push( + new MessageEmbed({ + title: 'Configuration', + description: JSON.stringify(config, null, 2), + }), + ); + } + + return { + embeds, + }; + } +} diff --git a/modules/venat-module-example/src/module.ts b/modules/venat-module-example/src/module.ts index 5944d69..80b7f63 100644 --- a/modules/venat-module-example/src/module.ts +++ b/modules/venat-module-example/src/module.ts @@ -1,17 +1,36 @@ -import { Logger, Module, OnModuleInit } from '@nestjs/common'; -import { VenatModule } from '@the-convocation/venat-core'; +import { Inject, Logger, Module, OnModuleInit } from '@nestjs/common'; +import { + ConfigModule, + MODULE_PACKAGE_NAME, + MODULE_PACKAGE_VERSION, + UsersModule, + VenatModule, +} from '@the-convocation/venat-core'; +import { DumpConfigCommand } from './commands/dump-config.command'; +import { DiscordModule } from '@discord-nestjs/core'; @VenatModule({ description: 'This is an example module', name: 'Example Module', }) @Module({ - imports: [], + imports: [ + ConfigModule.forFeature(ExampleModule), + DiscordModule.forFeature(), + UsersModule, + ], + providers: [DumpConfigCommand], }) export class ExampleModule implements OnModuleInit { private readonly logger: Logger = new Logger('ExampleModule'); + public constructor( + @Inject(MODULE_PACKAGE_NAME) private readonly packageName: string, + @Inject(MODULE_PACKAGE_VERSION) private readonly packageVersion: string, + ) {} + public onModuleInit(): void { this.logger.log('ExampleModule loaded!'); + this.logger.log(`${this.packageName} v${this.packageVersion}`); } } diff --git a/package.json b/package.json index ee38484..7e3325e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "scripts": { "clean": "yarn workspaces foreach -pt run clean", "build": "yarn workspaces foreach -pt run build", - "dev": "cd core && yarn dev", + "test": "yarn workspaces foreach -At run test", + "dev": "yarn build && cd core && yarn start", "start": "cd core && yarn start", "commit": "cz", "prepare": "husky install",