diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index 6ee366989e..ae244c381d 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -24,6 +24,7 @@ export default { logs: join(homedir, 'logs'), defaultPlugins: join(staticDir, 'plugins'), customPlugins: join(homedir, 'plugins'), + customTutorials: join(homedir, 'custom-tutorials'), pluginsAssets: join(staticDir, 'resources', 'plugins'), commands: join(homedir, 'commands'), defaultCommandsDir: join(defaultsDir, 'commands'), @@ -45,6 +46,7 @@ export default { staticUri: '/static', guidesUri: '/static/guides', tutorialsUri: '/static/tutorials', + customTutorialsUri: '/static/custom-tutorials', contentUri: '/static/content', defaultPluginsUri: '/static/plugins', pluginsAssetsUri: '/static/resources/plugins', diff --git a/redisinsight/api/config/ormconfig.ts b/redisinsight/api/config/ormconfig.ts index a2c97987a9..56b1dcbc4f 100644 --- a/redisinsight/api/config/ormconfig.ts +++ b/redisinsight/api/config/ormconfig.ts @@ -12,6 +12,7 @@ import { ClientCertificateEntity } from 'src/modules/certificate/entities/client import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; import { BrowserHistoryEntity } from 'src/modules/browser/entities/browser-history.entity'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; import migrations from '../migration'; import * as config from '../src/utils/config'; @@ -35,6 +36,7 @@ const ormConfig = { DatabaseAnalysisEntity, BrowserHistoryEntity, SshOptionsEntity, + CustomTutorialEntity, ], migrations, }; diff --git a/redisinsight/api/config/production.ts b/redisinsight/api/config/production.ts index 01cb28b7dd..a9c87effdd 100644 --- a/redisinsight/api/config/production.ts +++ b/redisinsight/api/config/production.ts @@ -12,6 +12,7 @@ export default { prevHomedir, logs: join(homedir, 'logs'), customPlugins: join(homedir, 'plugins'), + customTutorials: join(homedir, 'custom-tutorials'), commands: join(homedir, 'commands'), guides: process.env.GUIDES_DEV_PATH || join(homedir, 'guides'), tutorials: process.env.TUTORIALS_DEV_PATH || join(homedir, 'tutorials'), diff --git a/redisinsight/api/config/staging.ts b/redisinsight/api/config/staging.ts index d23664a670..e28c0435ae 100644 --- a/redisinsight/api/config/staging.ts +++ b/redisinsight/api/config/staging.ts @@ -12,6 +12,7 @@ export default { prevHomedir, logs: join(homedir, 'logs'), customPlugins: join(homedir, 'plugins'), + customTutorials: join(homedir, 'custom-tutorials'), commands: join(homedir, 'commands'), guides: process.env.GUIDES_DEV_PATH || join(homedir, 'guides'), tutorials: process.env.TUTORIALS_DEV_PATH || join(homedir, 'tutorials'), diff --git a/redisinsight/api/migration/1677135091633-custom-tutorials.ts b/redisinsight/api/migration/1677135091633-custom-tutorials.ts new file mode 100644 index 0000000000..f71d279e87 --- /dev/null +++ b/redisinsight/api/migration/1677135091633-custom-tutorials.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class customTutorials1677135091633 implements MigrationInterface { + name = 'customTutorials1677135091633' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "custom_tutorials" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "link" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "custom_tutorials"`); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index 4d6a690a77..d0c49e712a 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -27,6 +27,7 @@ import { workbenchAndAnalysisDbIndex1673934231410 } from './1673934231410-workbe import { browserHistory1674539211397 } from './1674539211397-browser-history'; import { databaseAnalysisRecommendations1674660306971 } from './1674660306971-database-analysis-recommendations'; import { databaseTimeout1675398140189 } from './1675398140189-database-timeout'; +import { customTutorials1677135091633 } from './1677135091633-custom-tutorials'; export default [ initialMigration1614164490968, @@ -58,4 +59,5 @@ export default [ databaseAnalysisRecommendations1674660306971, browserHistory1674539211397, databaseTimeout1675398140189, + customTutorials1677135091633, ]; diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 6672514c51..7a1d7a2347 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -65,6 +65,7 @@ "lodash": "^4.17.20", "nest-router": "^1.0.9", "nest-winston": "^1.4.0", + "nestjs-form-data": "^1.8.7", "node-version-compare": "^1.0.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.5.6", diff --git a/redisinsight/api/src/__mocks__/custom-tutorial.ts b/redisinsight/api/src/__mocks__/custom-tutorial.ts new file mode 100644 index 0000000000..558854a783 --- /dev/null +++ b/redisinsight/api/src/__mocks__/custom-tutorial.ts @@ -0,0 +1,160 @@ +import { CustomTutorial, CustomTutorialActions } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; +import { CustomTutorialManifestType } from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; +import { MemoryStoredFile } from 'nestjs-form-data'; +import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; + +export const mockCustomTutorialId = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-ct-id'; + +export const mockCustomTutorialId2 = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-ct-id-2'; + +export const mockCustomTutorialTmpPath = '/tmp/path'; + +export const mockCustomTutorial = Object.assign(new CustomTutorial(), { + id: mockCustomTutorialId, + name: 'custom tutorial', + createdAt: new Date(), +}); + +export const mockCustomTutorialEntity = Object.assign(new CustomTutorialEntity(), { + ...mockCustomTutorial, +}); + +export const mockCustomTutorial2 = Object.assign(new CustomTutorial(), { + id: mockCustomTutorialId2, + name: 'custom tutorial 2', + createdAt: new Date(), +}); + +export const mockCustomTutorialZipFile = Object.assign(new MemoryStoredFile(), { + size: 100, + buffer: Buffer.from('zip-content', 'utf8'), +}); + +export const mockUploadCustomTutorialDto = Object.assign(new UploadCustomTutorialDto(), { + name: mockCustomTutorial.name, + file: mockCustomTutorialZipFile, +}); + +export const mockCustomTutorialManifestManifestJson = { + 'ct-folder-1': { + type: 'group', + id: 'ct-folder-1', + label: 'ct-folder-1', + // args: { + // withBorder: true, + // initialIsOpen: true, + // }, + children: { + 'ct-sub-folder-1': { + type: CustomTutorialManifestType.Group, + id: 'ct-sub-folder-1', + label: 'ct-sub-folder-1', + // args: { + // initialIsOpen: false, + // }, + children: { + introduction: { + type: CustomTutorialManifestType.InternalLink, + id: 'introduction', + label: 'introduction', + args: { + path: '/ct-folder-1/ct-sub-folder-1/introduction.md', + }, + }, + 'working-with-hashes': { + type: CustomTutorialManifestType.InternalLink, + id: 'working-with-hashes', + label: 'working-with-hashes', + args: { + path: '/ct-folder-1/ct-sub-folder-1/working-with-hashes.md', + }, + }, + }, + }, + 'ct-sub-folder-2': { + type: CustomTutorialManifestType.Group, + id: 'ct-sub-folder-2', + label: 'ct-sub-folder-2', + // args: { + // withBorder: true, + // initialIsOpen: false, + // }, + children: { + introduction: { + type: CustomTutorialManifestType.InternalLink, + id: 'introduction', + label: 'introduction', + args: { + path: '/ct-folder-1/ct-sub-folder-2/introduction.md', + }, + }, + 'working-with-graphs': { + type: CustomTutorialManifestType.InternalLink, + id: 'working-with-graphs', + label: 'working-with-graphs', + args: { + path: '/ct-folder-1/ct-sub-folder-2/working-with-graphs.md', + }, + }, + }, + }, + }, + }, +}; + +export const mockCustomTutorialManifestManifest = { + type: CustomTutorialManifestType.Group, + id: mockCustomTutorialId, + label: mockCustomTutorial.name, + _actions: mockCustomTutorial.actions, + _path: mockCustomTutorial.path, + children: mockCustomTutorialManifestManifestJson, +}; + +export const mockCustomTutorialManifestManifest2 = { + type: CustomTutorialManifestType.Group, + id: mockCustomTutorialId2, + label: mockCustomTutorial2.name, + _actions: mockCustomTutorial2.actions, + _path: mockCustomTutorial2.path, + children: mockCustomTutorialManifestManifestJson, +}; + +export const globalCustomTutorialManifest = { + 'custom-tutorials': { + type: CustomTutorialManifestType.Group, + id: 'custom-tutorials', + label: 'My Tutorials', + _actions: [CustomTutorialActions.CREATE], + args: { + withBorder: true, + initialIsOpen: true, + }, + children: { + [mockCustomTutorialManifestManifest.id]: mockCustomTutorialManifestManifest, + [mockCustomTutorialManifestManifest2.id]: mockCustomTutorialManifestManifest2, + }, + }, +}; + +export const mockCustomTutorialFsProvider = jest.fn(() => ({ + unzipToTmpFolder: jest.fn().mockResolvedValue(mockCustomTutorialTmpPath), + moveFolder: jest.fn(), + removeFolder: jest.fn(), +})); + +export const mockCustomTutorialManifestProvider = jest.fn(() => ({ + getManifestJson: jest.fn().mockResolvedValue(mockCustomTutorialManifestManifestJson), + generateTutorialManifest: jest.fn().mockResolvedValue(mockCustomTutorialManifestManifest), +})); + +export const mockCustomTutorialRepository = jest.fn(() => ({ + get: jest.fn().mockResolvedValue(mockCustomTutorial), + create: jest.fn().mockResolvedValue(mockCustomTutorial), + delete: jest.fn(), + list: jest.fn().mockResolvedValue([ + mockCustomTutorial, + mockCustomTutorial2, + ]), +})); diff --git a/redisinsight/api/src/__mocks__/index.ts b/redisinsight/api/src/__mocks__/index.ts index 8ae591bd75..ab2d9d023d 100644 --- a/redisinsight/api/src/__mocks__/index.ts +++ b/redisinsight/api/src/__mocks__/index.ts @@ -11,6 +11,7 @@ export * from './analytics'; export * from './profiler'; export * from './user'; export * from './databases'; +export * from './custom-tutorial'; export * from './autodiscovery'; export * from './redis'; export * from './server'; diff --git a/redisinsight/api/src/app.module.ts b/redisinsight/api/src/app.module.ts index 402db8875c..4a4fd36a07 100644 --- a/redisinsight/api/src/app.module.ts +++ b/redisinsight/api/src/app.module.ts @@ -21,6 +21,7 @@ import { CoreModule } from 'src/core.module'; import { AutodiscoveryModule } from 'src/modules/autodiscovery/autodiscovery.module'; import { DatabaseImportModule } from 'src/modules/database-import/database-import.module'; import { DummyAuthMiddleware } from 'src/common/middlewares/dummy-auth.middleware'; +import { CustomTutorialModule } from 'src/modules/custom-tutorial/custom-tutorial.module'; import { BrowserModule } from './modules/browser/browser.module'; import { RedisEnterpriseModule } from './modules/redis-enterprise/redis-enterprise.module'; import { RedisSentinelModule } from './modules/redis-sentinel/redis-sentinel.module'; @@ -53,6 +54,7 @@ const PATH_CONFIG = config.get('dir_path'); NotificationModule, BulkActionsModule, ClusterMonitorModule, + CustomTutorialModule.register(), DatabaseAnalysisModule, DatabaseImportModule, ...(SERVER_CONFIG.staticContent diff --git a/redisinsight/api/src/common/utils/errors.util.ts b/redisinsight/api/src/common/utils/errors.util.ts new file mode 100644 index 0000000000..906198d2c8 --- /dev/null +++ b/redisinsight/api/src/common/utils/errors.util.ts @@ -0,0 +1,9 @@ +import { HttpException, InternalServerErrorException } from '@nestjs/common'; + +export const wrapHttpError = (error: Error) => { + if (error instanceof HttpException) { + return error; + } + + return new InternalServerErrorException(error.message); +}; diff --git a/redisinsight/api/src/common/utils/index.ts b/redisinsight/api/src/common/utils/index.ts index ee0efba08b..3aaf1016a1 100644 --- a/redisinsight/api/src/common/utils/index.ts +++ b/redisinsight/api/src/common/utils/index.ts @@ -1 +1,2 @@ export * from './certificate-import.util'; +export * from './errors.util'; diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index d48a79f23a..f1e1cb7296 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -7,6 +7,7 @@ export default { PROFILER_LOG_FILE_NOT_FOUND: 'Profiler log file was not found.', CONSUMER_GROUP_NOT_FOUND: 'Consumer Group with such name was not found.', PLUGIN_STATE_NOT_FOUND: 'Plugin state was not found.', + CUSTOM_TUTORIAL_NOT_FOUND: 'Custom Tutorial was not found.', UNDEFINED_INSTANCE_ID: 'Undefined redis database instance id.', NO_CONNECTION_TO_REDIS_DB: 'No connection to the Redis Database.', WRONG_DATABASE_TYPE: 'Wrong database type.', diff --git a/redisinsight/api/src/core.module.ts b/redisinsight/api/src/core.module.ts index d549235f20..467acda836 100644 --- a/redisinsight/api/src/core.module.ts +++ b/redisinsight/api/src/core.module.ts @@ -7,6 +7,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { RedisModule } from 'src/modules/redis/redis.module'; import { AnalyticsModule } from 'src/modules/analytics/analytics.module'; import { SshModule } from 'src/modules/ssh/ssh.module'; +import { NestjsFormDataModule } from 'nestjs-form-data'; @Global() @Module({ @@ -19,6 +20,7 @@ import { SshModule } from 'src/modules/ssh/ssh.module'; DatabaseModule.register(), RedisModule, SshModule, + NestjsFormDataModule, ], exports: [ EncryptionModule, @@ -27,6 +29,7 @@ import { SshModule } from 'src/modules/ssh/ssh.module'; DatabaseModule, RedisModule, SshModule, + NestjsFormDataModule, ], }) export class CoreModule {} diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts new file mode 100644 index 0000000000..930b4cf65c --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts @@ -0,0 +1,76 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, Delete, Get, HttpCode, Param, Post, + UseInterceptors, UsePipes, ValidationPipe, +} from '@nestjs/common'; +import { + ApiConsumes, ApiExtraModels, ApiTags, +} from '@nestjs/swagger'; +import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; +import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { FormDataRequest } from 'nestjs-form-data'; +import { CreateCaCertificateDto } from 'src/modules/certificate/dto/create.ca-certificate.dto'; +import { UseCaCertificateDto } from 'src/modules/certificate/dto/use.ca-certificate.dto'; +import { CreateClientCertificateDto } from 'src/modules/certificate/dto/create.client-certificate.dto'; +import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto'; +import { CreateBasicSshOptionsDto } from 'src/modules/ssh/dto/create.basic-ssh-options.dto'; +import { CreateCertSshOptionsDto } from 'src/modules/ssh/dto/create.cert-ssh-options.dto'; + +@ApiExtraModels( + CreateCaCertificateDto, UseCaCertificateDto, + CreateClientCertificateDto, UseClientCertificateDto, + CreateBasicSshOptionsDto, CreateCertSshOptionsDto, +) +@UsePipes(new ValidationPipe({ transform: true })) +@UseInterceptors(ClassSerializerInterceptor) +@ApiTags('Tutorials') +@Controller('/custom-tutorials') +export class CustomTutorialController { + constructor(private readonly service: CustomTutorialService) {} + + @Post('') + @HttpCode(201) + @ApiConsumes('multipart/form-data') + @FormDataRequest() + @ApiEndpoint({ + description: 'Create new tutorial', + statusCode: 201, + responses: [ + { + type: Object, + }, + ], + }) + async create( + @Body() dto: UploadCustomTutorialDto, + ): Promise> { + return this.service.create(dto); + } + + @Get('manifest') + @ApiEndpoint({ + description: 'Get global manifest for custom tutorials', + statusCode: 200, + responses: [ + { + type: Object, + }, + ], + }) + async getGlobalManifest(): Promise { + return this.service.getGlobalManifest(); + } + + @Delete('/:id') + @ApiEndpoint({ + statusCode: 200, + description: 'Delete custom tutorial and its files', + }) + async delete( + @Param('id') id: string, + ): Promise { + return this.service.delete(id); + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts new file mode 100644 index 0000000000..8bb76ab242 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts @@ -0,0 +1,32 @@ +import { Module, Type } from '@nestjs/common'; +import { CustomTutorialController } from 'src/modules/custom-tutorial/custom-tutorial.controller'; +import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { + CustomTutorialManifestProvider, +} from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; +import { + LocalCustomTutorialRepository, +} from 'src/modules/custom-tutorial/repositories/local.custom-tutorial.repository'; + +@Module({}) +export class CustomTutorialModule { + static register( + repository: Type = LocalCustomTutorialRepository, + ) { + return { + module: CustomTutorialModule, + controllers: [CustomTutorialController], + providers: [ + CustomTutorialService, + CustomTutorialFsProvider, + CustomTutorialManifestProvider, + { + provide: CustomTutorialRepository, + useClass: repository, + }, + ], + }; + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.spec.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.spec.ts new file mode 100644 index 0000000000..fa4ac63257 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.spec.ts @@ -0,0 +1,154 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + globalCustomTutorialManifest, + mockCustomTutorial, + mockCustomTutorialFsProvider, + mockCustomTutorialId, + mockCustomTutorialManifestManifest, mockCustomTutorialManifestManifest2, + mockCustomTutorialManifestProvider, + mockCustomTutorialRepository, + MockType, mockUploadCustomTutorialDto, +} from 'src/__mocks__'; +import * as fs from 'fs-extra'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; +import { + CustomTutorialManifestProvider, +} from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +jest.mock('fs-extra'); +const mockedFs = fs as jest.Mocked; + +const mockedAdmZip = { + extractAllTo: jest.fn(), +}; +jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + +describe('CustomTutorialService', () => { + let service: CustomTutorialService; + let customTutorialRepository: MockType; + let customTutorialFsProvider: MockType; + let customTutorialManifestProvider: MockType; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mock('fs-extra', () => mockedFs); + jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CustomTutorialService, + { + provide: CustomTutorialRepository, + useFactory: mockCustomTutorialRepository, + }, + { + provide: CustomTutorialFsProvider, + useFactory: mockCustomTutorialFsProvider, + }, + { + provide: CustomTutorialManifestProvider, + useFactory: mockCustomTutorialManifestProvider, + }, + ], + }).compile(); + + service = await module.get(CustomTutorialService); + customTutorialRepository = await module.get(CustomTutorialRepository); + customTutorialFsProvider = await module.get(CustomTutorialFsProvider); + customTutorialManifestProvider = await module.get(CustomTutorialManifestProvider); + }); + + describe('create', () => { + it('Should create custom tutorial', async () => { + const result = await service.create(mockUploadCustomTutorialDto); + + expect(result).toEqual(mockCustomTutorialManifestManifest); + }); + + it('Should throw InternalServerError in case of any non-HttpException error', async () => { + customTutorialRepository.create.mockRejectedValueOnce(new Error('Unable to create')); + + try { + await service.create(mockUploadCustomTutorialDto); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('Unable to create'); + } + }); + }); + + describe('getGlobalManifest', () => { + it('Should return global manifest with 2 tutorials', async () => { + customTutorialManifestProvider.generateTutorialManifest + .mockResolvedValueOnce(mockCustomTutorialManifestManifest) + .mockResolvedValueOnce(mockCustomTutorialManifestManifest2); + + const result = await service.getGlobalManifest(); + + expect(result).toEqual(globalCustomTutorialManifest); + }); + + it('Should return global manifest with 1 tutorials since 1 failed to fetch', async () => { + customTutorialManifestProvider.generateTutorialManifest + .mockResolvedValueOnce(null); + + const result = await service.getGlobalManifest(); + + expect(result).toEqual({ + 'custom-tutorials': { + ...globalCustomTutorialManifest['custom-tutorials'], + children: { + [mockCustomTutorialManifestManifest.id]: mockCustomTutorialManifestManifest, + }, + }, + }); + }); + + it('Should return global manifest without children in case of any error', async () => { + customTutorialRepository.list.mockRejectedValueOnce(new Error('Unable to get list of tutorials')); + + const result = await service.getGlobalManifest(); + + expect(result).toEqual({ + 'custom-tutorials': { + ...globalCustomTutorialManifest['custom-tutorials'], + children: {}, + }, + }); + }); + }); + + describe('delete', () => { + it('Should successfully delete entity and remove related directory', async () => { + await service.delete(mockCustomTutorialId); + + expect(customTutorialFsProvider.removeFolder).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + }); + + it('Should throw NotFound error when try to delete not existing tutorial', async () => { + customTutorialRepository.get.mockResolvedValueOnce(null); + + try { + await service.delete(mockCustomTutorialId); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.CUSTOM_TUTORIAL_NOT_FOUND); + } + }); + + it('Should throw InternalServerError in case of any non-HttpException error', async () => { + customTutorialRepository.delete.mockRejectedValueOnce(new Error('Unable to delete')); + + try { + await service.delete(mockCustomTutorialId); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('Unable to delete'); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts new file mode 100644 index 0000000000..011b1193a8 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -0,0 +1,119 @@ +import { + Injectable, Logger, NotFoundException, +} from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; +import { CustomTutorial, CustomTutorialActions } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; +import { plainToClass } from 'class-transformer'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { + CustomTutorialManifestProvider, +} from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; +import { + CustomTutorialManifestType, + ICustomTutorialManifest, +} from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; +import { wrapHttpError } from 'src/common/utils'; + +@Injectable() +export class CustomTutorialService { + private logger = new Logger('CustomTutorialService'); + + constructor( + private readonly customTutorialRepository: CustomTutorialRepository, + private readonly customTutorialFsProvider: CustomTutorialFsProvider, + private readonly customTutorialManifestProvider: CustomTutorialManifestProvider, + ) {} + + /** + * Create custom tutorial entity + static files based on input + * Currently from zip file only + * @param dto + */ + public async create(dto: UploadCustomTutorialDto): Promise> { + try { + const tmpPath = await this.customTutorialFsProvider.unzipToTmpFolder(dto.file); + + // todo: validate + + // create tutorial model + const model = plainToClass(CustomTutorial, { + ...dto, + id: uuidv4(), + }); + + await this.customTutorialFsProvider.moveFolder(tmpPath, model.absolutePath); + + const tutorial = await this.customTutorialRepository.create(model); + + return await this.customTutorialManifestProvider.generateTutorialManifest(tutorial); + } catch (e) { + this.logger.error('Unable to create custom tutorials', e); + throw wrapHttpError(e); + } + } + + /** + * Get global manifest for all custom tutorials + * In the future will be removed with some kind of partial load + */ + public async getGlobalManifest(): Promise> { + const children = {}; + + try { + const tutorials = await this.customTutorialRepository.list(); + + const manifests = await Promise.all( + tutorials.map( + this.customTutorialManifestProvider.generateTutorialManifest.bind(this.customTutorialManifestProvider), + ), + ) as Record[]; + + manifests.forEach((manifest) => { + if (manifest) { + children[manifest.id] = manifest; + } + }); + } catch (e) { + this.logger.warn('Unable to generate entire custom tutorials manifest', e); + } + + return { + 'custom-tutorials': { + type: CustomTutorialManifestType.Group, + id: 'custom-tutorials', + label: 'My Tutorials', + _actions: [CustomTutorialActions.CREATE], + args: { + withBorder: true, + initialIsOpen: true, + }, + children, + }, + }; + } + + public async get(id: string): Promise { + const model = await this.customTutorialRepository.get(id); + + if (!model) { + this.logger.error(`Custom Tutorial with ${id} was not Found`); + throw new NotFoundException(ERROR_MESSAGES.CUSTOM_TUTORIAL_NOT_FOUND); + } + + return model; + } + + public async delete(id: string): Promise { + try { + const tutorial = await this.get(id); + await this.customTutorialRepository.delete(id); + await this.customTutorialFsProvider.removeFolder(tutorial.absolutePath); + } catch (e) { + this.logger.error('Unable to delete custom tutorial', e); + throw wrapHttpError(e); + } + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts new file mode 100644 index 0000000000..3631333f1d --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { + HasMimeType, IsFile, MaxFileSize, MemoryStoredFile, +} from 'nestjs-form-data'; + +export class UploadCustomTutorialDto { + @ApiProperty({ + type: 'string', + format: 'binary', + description: 'ZIP archive with tutorial static files', + }) + @IsFile() + @HasMimeType(['application/zip']) + @MaxFileSize(10 * 1024 * 1024) + file: MemoryStoredFile; + + @ApiProperty({ + description: 'Name to show for custom tutorials', + }) + @Expose() + @IsString() + @IsNotEmpty() + name: string; +} diff --git a/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts b/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts new file mode 100644 index 0000000000..b41cb1333a --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts @@ -0,0 +1,23 @@ +import { + Entity, PrimaryGeneratedColumn, CreateDateColumn, Column, +} from 'typeorm'; +import { Expose } from 'class-transformer'; + +@Entity('custom_tutorials') +export class CustomTutorialEntity { + @PrimaryGeneratedColumn('uuid') + @Expose() + id: string; + + @Column({ nullable: false }) + @Expose() + name: string; + + @Column({ nullable: true }) + @Expose() + link?: string; + + @CreateDateColumn() + @Expose() + createdAt: Date; +} diff --git a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts new file mode 100644 index 0000000000..17c2ced1f6 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts @@ -0,0 +1,39 @@ +import { CustomTutorialActions } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsEnum, IsNotEmpty } from 'class-validator'; + +export enum CustomTutorialManifestType { + CodeButton = 'code-button', + Group = 'group', + InternalLink = 'internal-link', +} + +export interface ICustomTutorialManifest { + id: string, + type: CustomTutorialManifestType, + label: string, + children?: Record, + args?: Record, + _actions?: CustomTutorialActions[], + _path?: string, +} + +export class CustomTutorialManifest { + @ApiProperty({ type: String }) + @Expose() + @IsNotEmpty() + id: string; + + @ApiProperty({ enum: CustomTutorialManifestType }) + @Expose() + @IsEnum(CustomTutorialManifestType) + type: CustomTutorialManifestType; + + @ApiProperty({ type: String }) + @Expose() + @IsNotEmpty() + label: string; + + children: Record +} diff --git a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts new file mode 100644 index 0000000000..685fcc5f35 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts @@ -0,0 +1,39 @@ +import { Expose } from 'class-transformer'; +import { join } from 'path'; +import config from 'src/utils/config'; + +const PATH_CONFIG = config.get('dir_path'); + +export enum CustomTutorialActions { + CREATE = 'create', + DELETE = 'delete', +} + +export class CustomTutorial { + @Expose() + id: string; + + @Expose() + name: string; + + @Expose() + uri: string; + + @Expose() + link?: string; + + @Expose() + createdAt: Date; + + get actions(): CustomTutorialActions[] { + return [CustomTutorialActions.DELETE]; + } + + get path(): string { + return `/${this.id}`; + } + + get absolutePath(): string { + return join(PATH_CONFIG.customTutorials, this.id); + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts new file mode 100644 index 0000000000..138748a6c7 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts @@ -0,0 +1,155 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockCustomTutorial, mockCustomTutorialTmpPath, mockCustomTutorialZipFile, +} from 'src/__mocks__'; +import * as fs from 'fs-extra'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { InternalServerErrorException } from '@nestjs/common'; + +jest.mock('fs-extra'); +const mockedFs = fs as jest.Mocked; + +const mockedAdmZip = { + extractAllTo: jest.fn(), +}; +jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + +describe('CustomTutorialFsProvider', () => { + let service: CustomTutorialFsProvider; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mock('fs-extra', () => mockedFs); + jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CustomTutorialFsProvider, + ], + }).compile(); + + service = await module.get(CustomTutorialFsProvider); + }); + + describe('unzipToTmpFolder', () => { + let prepareTmpFolderSpy; + + beforeEach(() => { + mockedFs.ensureDir.mockImplementationOnce(() => Promise.resolve()); + mockedFs.remove.mockImplementationOnce(() => Promise.resolve()); + prepareTmpFolderSpy = jest.spyOn(CustomTutorialFsProvider, 'prepareTmpFolder'); + }); + + it('should unzip data', async () => { + await service.unzipToTmpFolder(mockCustomTutorialZipFile); + }); + it('should unzip data to particular tmp folder', async () => { + prepareTmpFolderSpy.mockResolvedValueOnce(mockCustomTutorialTmpPath); + + await service.unzipToTmpFolder(mockCustomTutorialZipFile); + + expect(mockedAdmZip.extractAllTo).toHaveBeenCalledWith(mockCustomTutorialTmpPath, true); + }); + + it('should throw InternalServerError', async () => { + mockedAdmZip.extractAllTo.mockRejectedValueOnce(new Error('Unable to extract file')); + + try { + await service.unzipToTmpFolder(mockCustomTutorialZipFile); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('Unable to extract file'); + } + }); + }); + + describe('moveFolder', () => { + it('should move folder', async () => { + mockedFs.move.mockImplementationOnce(() => Promise.resolve()); + + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + + expect(mockedFs.pathExists).not.toHaveBeenCalled(); + expect(mockedFs.remove).not.toHaveBeenCalled(); + expect(mockedFs.move).toHaveBeenCalledWith( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + }); + + it('should move folder when there is no such folder in the dest path', async () => { + mockedFs.pathExists.mockImplementationOnce(() => Promise.resolve(false)); + mockedFs.move.mockImplementationOnce(() => Promise.resolve()); + + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + true, + ); + + expect(mockedFs.pathExists).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + expect(mockedFs.remove).not.toHaveBeenCalled(); + expect(mockedFs.move).toHaveBeenCalledWith( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + }); + + it('should move folder when and remove existing one before', async () => { + mockedFs.pathExists.mockImplementationOnce(() => Promise.resolve(true)); + mockedFs.remove.mockImplementationOnce(() => Promise.resolve()); + mockedFs.move.mockImplementationOnce(() => Promise.resolve()); + + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + true, + ); + + expect(mockedFs.pathExists).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + expect(mockedFs.remove).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + expect(mockedFs.move).toHaveBeenCalledWith( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + }); + + it('should throw InternalServerError', async () => { + mockedFs.pathExists.mockImplementationOnce(() => Promise.resolve(true)); + mockedFs.remove.mockImplementationOnce(() => Promise.resolve()); + mockedFs.move.mockImplementationOnce(() => Promise.reject(new Error('dest folder exists'))); + + try { + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + true, + ); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('dest folder exists'); + } + }); + }); + + describe('removeFolder', () => { + it('should remove folder', async () => { + mockedFs.remove.mockResolvedValueOnce(); + + await service.removeFolder(mockCustomTutorial.absolutePath); + + expect(mockedFs.remove).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + }); + + it('should not fail in case of any error', async () => { + mockedFs.remove.mockRejectedValueOnce(new Error('No file')); + + await service.removeFolder(mockCustomTutorial.absolutePath); + + expect(mockedFs.remove).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts new file mode 100644 index 0000000000..e082d41e41 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts @@ -0,0 +1,77 @@ +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { MemoryStoredFile } from 'nestjs-form-data'; +import { join } from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import * as fs from 'fs-extra'; +import config from 'src/utils/config'; +import * as AdmZip from 'adm-zip'; + +const PATH_CONFIG = config.get('dir_path'); + +const TMP_FOLDER = `${PATH_CONFIG.tmpDir}/RedisInsight-v2/custom-tutorials`; + +@Injectable() +export class CustomTutorialFsProvider { + private logger = new Logger('CustomTutorialFsProvider'); + + /** + * Unzip custom tutorials to temporary folder + * @param file + */ + public async unzipToTmpFolder(file: MemoryStoredFile): Promise { + try { + const path = await CustomTutorialFsProvider.prepareTmpFolder(); + const zip = new AdmZip(file.buffer); + await fs.remove(path); + await zip.extractAllTo(path, true); + + return path; + } catch (e) { + this.logger.error('Unable to unzip archive', e); + throw new InternalServerErrorException(e.message); + } + } + + /** + * Move custom tutorial from tmp folder to proper path to serve static files + * force - default false, will remove existing folder + * @param tmpPath + * @param dest + * @param force + */ + public async moveFolder(tmpPath: string, dest: string, force = false) { + try { + if (force && await fs.pathExists(dest)) { + await fs.remove(dest); + } + + await fs.move(tmpPath, dest); + } catch (e) { + this.logger.error('Unable to move tutorial to a folder', e); + throw new InternalServerErrorException(e.message); + } + } + + /** + * Delete Tutorial folder + * Will silently log an error if any + * @param path + */ + public async removeFolder(path: string) { + try { + await fs.remove(path); + } catch (e) { + this.logger.warn('Unable to delete tutorial folder', e); + } + } + + /** + * Create tmp folder in user's temporary directory and return path to it + */ + static async prepareTmpFolder(): Promise { + const path = join(TMP_FOLDER, uuidv4()); + await fs.ensureDir(path); + + return path; + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.spec.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.spec.ts new file mode 100644 index 0000000000..70e217aa34 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.spec.ts @@ -0,0 +1,72 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockCustomTutorial, + mockCustomTutorialManifestManifest, mockCustomTutorialManifestManifestJson, +} from 'src/__mocks__'; +import { CustomTutorialManifestProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; +import * as fs from 'fs-extra'; + +jest.mock('fs-extra'); +const mockedFs = fs as jest.Mocked; + +describe('CustomTutorialManifestProvider', () => { + let service: CustomTutorialManifestProvider; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mock('fs-extra', () => mockedFs); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CustomTutorialManifestProvider, + ], + }).compile(); + + service = await module.get(CustomTutorialManifestProvider); + }); + + describe('getManifest', () => { + it('should successfully get manifest', async () => { + mockedFs.readFile.mockResolvedValueOnce(Buffer.from(JSON.stringify(mockCustomTutorialManifestManifestJson))); + + const result = await service.getManifestJson(mockCustomTutorial.absolutePath); + + expect(result).toEqual(mockCustomTutorialManifestManifestJson); + }); + + it('should return null when no manifest found', async () => { + mockedFs.readFile.mockRejectedValueOnce(new Error('No file')); + + const result = await service.getManifestJson(mockCustomTutorial.absolutePath); + + expect(result).toEqual(null); + }); + }); + + describe('generateTutorialManifest', () => { + it('should successfully generate manifest', async () => { + mockedFs.readFile.mockResolvedValueOnce(Buffer.from(JSON.stringify(mockCustomTutorialManifestManifestJson))); + + const result = await service.generateTutorialManifest(mockCustomTutorial); + + expect(result).toEqual(mockCustomTutorialManifestManifest); + }); + + it('should generate manifest without children', async () => { + mockedFs.readFile.mockRejectedValueOnce(new Error('No file')); + + const result = await service.generateTutorialManifest(mockCustomTutorial); + + expect(result).toEqual({ + ...mockCustomTutorialManifestManifest, + children: null, + }); + }); + + it('should return null in case of any error', async () => { + const result = await service.generateTutorialManifest(null); + + expect(result).toEqual(null); + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts new file mode 100644 index 0000000000..cf781c9891 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { join } from 'path'; +import * as fs from 'fs-extra'; +import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { + CustomTutorialManifestType, + ICustomTutorialManifest, +} from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; + +const MANIFEST_FILE = 'manifest.json'; + +@Injectable() +export class CustomTutorialManifestProvider { + private logger = new Logger('CustomTutorialManifestProvider'); + + /** + * Try to get and parse manifest.json + * In case of any error will not throw an error but return null + * In this case tutorial will be displayed but without anything inside + * So user will be able to fix (re-import) tutorial or remove it + * @param path + */ + public async getManifestJson(path: string): Promise { + try { + return JSON.parse( + await fs.readFile(join(path, MANIFEST_FILE), 'utf8'), + ); + } catch (e) { + this.logger.warn('Unable to get manifest for tutorial'); + return null; + } + } + + /** + * Generate custom manifest based on manifest.json inside tutorial folder and + * additional data from local database + * @param tutorial + */ + public async generateTutorialManifest(tutorial: CustomTutorial): Promise> { + try { + return { + type: CustomTutorialManifestType.Group, + id: tutorial.id, + label: tutorial.name, + _actions: tutorial.actions, + _path: tutorial.path, + children: await this.getManifestJson(tutorial.absolutePath), + }; + } catch (e) { + this.logger.warn('Unable to generate manifest for tutorial', e); + return null; + } + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts new file mode 100644 index 0000000000..93307e6fbd --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts @@ -0,0 +1,27 @@ +import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; + +export abstract class CustomTutorialRepository { + /** + * Create custom tutorial entity + * @param model + * @return CustomTutorial + */ + abstract create(model: CustomTutorial): Promise; + + /** + * Create custom tutorial entity + * @param id + * @return CustomTutorial + */ + abstract get(id: string): Promise; + + /** + * Get list of custom tutorials + */ + abstract list(): Promise; + + /** + * Delete custom tutorial by id + */ + abstract delete(id: string): Promise; +} diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.spec.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.spec.ts new file mode 100644 index 0000000000..32f187425b --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.spec.ts @@ -0,0 +1,81 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + mockCustomTutorial, mockCustomTutorialEntity, mockCustomTutorialId, + mockRepository, + MockType, +} from 'src/__mocks__'; +import { + LocalCustomTutorialRepository, +} from 'src/modules/custom-tutorial/repositories/local.custom-tutorial.repository'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; + +describe('LocalCustomTutorialRepository', () => { + let service: LocalCustomTutorialRepository; + let repository: MockType>; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LocalCustomTutorialRepository, + { + provide: getRepositoryToken(CustomTutorialEntity), + useFactory: mockRepository, + }, + ], + }).compile(); + + repository = await module.get(getRepositoryToken(CustomTutorialEntity)); + service = await module.get(LocalCustomTutorialRepository); + + repository.findOneBy.mockResolvedValue(mockCustomTutorialEntity); + repository.createQueryBuilder().getMany.mockResolvedValue([ + mockCustomTutorialEntity, + mockCustomTutorialEntity, + ]); + repository.save.mockResolvedValue(mockCustomTutorialEntity); + }); + + describe('get', () => { + it('should return custom tutorial model', async () => { + const result = await service.get(mockCustomTutorialId); + + expect(result).toEqual(mockCustomTutorial); + }); + + it('should return null when custom tutorial was not found', async () => { + repository.findOneBy.mockResolvedValue(undefined); + + const result = await service.get(mockCustomTutorialId); + + expect(result).toEqual(undefined); + }); + }); + + describe('list', () => { + it('should return list of custom tutorials', async () => { + expect(await service.list()).toEqual([ + mockCustomTutorial, + mockCustomTutorial, + ]); + }); + }); + + describe('create', () => { + it('should create custom tutorial', async () => { + const result = await service.create(mockCustomTutorial); + + expect(result).toEqual(mockCustomTutorial); + expect(repository.save).toHaveBeenCalledWith(mockCustomTutorialEntity); + }); + }); + + describe('delete', () => { + it('should delete custom tutorial by id', async () => { + expect(await service.delete(mockCustomTutorialId)).toEqual(undefined); + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts new file mode 100644 index 0000000000..1ec6b03aa2 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts @@ -0,0 +1,51 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { classToClass } from 'src/utils'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; +import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; + +export class LocalCustomTutorialRepository extends CustomTutorialRepository { + constructor( + @InjectRepository(CustomTutorialEntity) + private readonly repository: Repository, + ) { + super(); + } + + /** + * @inheritDoc + */ + public async create(model: CustomTutorial): Promise { + const entity = classToClass(CustomTutorialEntity, model); + await this.repository.save(entity); + + return classToClass(CustomTutorial, entity); + } + + /** + * @inheritDoc + */ + public async list(): Promise { + const entities = await this.repository + .createQueryBuilder('t') + .orderBy('t.createdAt', 'DESC') + .getMany(); + + return entities.map((entity) => classToClass(CustomTutorial, entity)); + } + + /** + * @inheritDoc + */ + public async get(id: string): Promise { + return classToClass(CustomTutorial, await this.repository.findOneBy({ id })); + } + + /** + * @inheritDoc + */ + public async delete(id: string): Promise { + await this.repository.delete({ id }); + } +} diff --git a/redisinsight/api/src/modules/statics-management/statics-management.module.ts b/redisinsight/api/src/modules/statics-management/statics-management.module.ts index 86326e2b6c..97bc86d076 100644 --- a/redisinsight/api/src/modules/statics-management/statics-management.module.ts +++ b/redisinsight/api/src/modules/statics-management/statics-management.module.ts @@ -27,6 +27,13 @@ const CONTENT_CONFIG = config.get('content'); fallthrough: false, }, }), + ServeStaticModule.forRoot({ + serveRoot: SERVER_CONFIG.customTutorialsUri, + rootPath: join(PATH_CONFIG.customTutorials), + serveStaticOptions: { + fallthrough: false, + }, + }), ServeStaticModule.forRoot({ serveRoot: SERVER_CONFIG.contentUri, rootPath: join(PATH_CONFIG.content), diff --git a/redisinsight/api/yarn.lock b/redisinsight/api/yarn.lock index 1ca7fdaab1..4a65435c3b 100644 --- a/redisinsight/api/yarn.lock +++ b/redisinsight/api/yarn.lock @@ -653,6 +653,11 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@lukeed/csprng@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.0.1.tgz#625e93a0edb2c830e3c52ce2d67b9d53377c6a66" + integrity sha512-uSvJdwQU5nK+Vdf6zxcWAY2A8r7uqe+gePwLWzJ+fsQehq18pc0I2hJKwypZ2aLM90+Er9u1xn4iLJPZ+xlL4g== + "@mapbox/node-pre-gyp@^1.0.0": version "1.0.9" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.9.tgz#09a8781a3a036151cdebbe8719d6f8b25d4058bc" @@ -906,6 +911,11 @@ resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.3.tgz#1185726610acc37317ddab11c3c7f9066966bd20" integrity sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg== +"@tokenizer/token@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" + integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -2026,7 +2036,7 @@ buildcheck@0.0.3: resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.3.tgz#70451897a95d80f7807e68fc412eb2e7e35ff4d5" integrity sha512-pziaA+p/wdVImfcbsZLNF32EiWyujlQLwolMqUQE8xpKNOH7KmZQaY8sXN7DGOEzPAElo9QTaeNRfGnf3iOJbA== -busboy@^1.0.0: +busboy@^1.0.0, busboy@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== @@ -2478,6 +2488,16 @@ concat-stream@^1.5.2: readable-stream "^2.2.2" typedarray "^0.0.6" +concat-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + concurrently@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-5.3.0.tgz#7500de6410d043c912b2da27de3202cb489b1e7b" @@ -3695,6 +3715,15 @@ file-stream-rotator@^0.5.7: dependencies: moment "^2.11.2" +file-type@^16.5.4: + version "16.5.4" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-16.5.4.tgz#474fb4f704bee427681f98dd390058a172a6c2fd" + integrity sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw== + dependencies: + readable-web-to-node-stream "^3.0.0" + strtok3 "^6.2.4" + token-types "^4.1.1" + fill-range@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" @@ -6044,6 +6073,19 @@ nest-winston@^1.4.0: cli-color "^2.0.1" fast-safe-stringify "^2.1.1" +nestjs-form-data@^1.8.7: + version "1.8.7" + resolved "https://registry.yarnpkg.com/nestjs-form-data/-/nestjs-form-data-1.8.7.tgz#ccdbc2060849e34018841bfba557de37ae64abdb" + integrity sha512-mk17APNXELILClea2nwffRrD/NK5Q6zulTJCzNPxwMWfWucHO2HD7Ftjwg2BVnwO27QgqILDLuaO6LEpP6Ng4w== + dependencies: + uid "^2.0.0" + append-field "^1.0.0" + busboy "^1.6.0" + concat-stream "^2.0.0" + file-type "^16.5.4" + mkdirp "^1.0.4" + type-is "^1.6.18" + next-tick@1, next-tick@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" @@ -6624,6 +6666,11 @@ pathval@^1.1.1: resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== +peek-readable@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-4.1.0.tgz#4ece1111bf5c2ad8867c314c81356847e8a62e72" + integrity sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -6887,7 +6934,7 @@ readable-stream@^2.0.1, readable-stream@^2.2.2, readable-stream@^2.3.5: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: +readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -6896,6 +6943,13 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-web-to-node-stream@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz#5d52bb5df7b54861fd48d015e93a2cb87b3ee0bb" + integrity sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw== + dependencies: + readable-stream "^3.6.0" + readdirp@~3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" @@ -7787,6 +7841,14 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +strtok3@^6.2.4: + version "6.3.0" + resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.3.0.tgz#358b80ffe6d5d5620e19a073aa78ce947a90f9a0" + integrity sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw== + dependencies: + "@tokenizer/token" "^0.3.0" + peek-readable "^4.1.0" + superagent@^3.8.3: version "3.8.3" resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" @@ -8062,6 +8124,14 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +token-types@^4.1.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/token-types/-/token-types-4.2.1.tgz#0f897f03665846982806e138977dbe72d44df753" + integrity sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ== + dependencies: + "@tokenizer/token" "^0.3.0" + ieee754 "^1.2.1" + tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" @@ -8265,7 +8335,7 @@ type-fest@^0.8.0, type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-is@^1.6.4, type-is@~1.6.18: +type-is@^1.6.18, type-is@^1.6.4, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -8328,6 +8398,13 @@ typescript@^4.0.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8" integrity sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg== +uid@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/uid/-/uid-2.0.1.tgz#a3f57c962828ea65256cd622fc363028cdf4526b" + integrity sha512-PF+1AnZgycpAIEmNtjxGBVmKbZAQguaa4pBUq6KNaGEcpzZ2klCNZLM34tsjp76maN00TttiiUf6zkIBpJQm2A== + dependencies: + "@lukeed/csprng" "^1.0.0" + unbox-primitive@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"